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: diff --git a/AGENTS.md b/AGENTS.md index 3cce1f9..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. +- 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. @@ -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//bin.mjs …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-.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=`. 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/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index ebc2e76..ea34956 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -2,13 +2,13 @@ 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. ## 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. @@ -24,9 +24,9 @@ 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). +* **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,17 +38,17 @@ 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. ### 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. -* **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. +* **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 @@ -59,11 +59,11 @@ 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 — `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. -**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 @@ -81,11 +81,11 @@ 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. -**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 @@ -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 @@ -128,16 +128,51 @@ 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` — 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. + +**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 + +* **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. + +### 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. -* **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 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). -* **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 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 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 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. diff --git a/specs/PHILOSOPHY.md b/specs/PHILOSOPHY.md index 4630f00..4249021 100644 --- a/specs/PHILOSOPHY.md +++ b/specs/PHILOSOPHY.md @@ -14,4 +14,7 @@ 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. +- 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). diff --git a/specs/SPEC.md b/specs/SPEC.md index 6cd9a55..7f707b4 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 @@ -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,11 +241,11 @@ 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 -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 @@ -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 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. + +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. + ## 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; 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 ```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). +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 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. +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 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 — the preview document form of 12.7 — and reports: + +* 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 — 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 Renames or moves performed by editing files directly, without the commands, produce no journal entries and are treated as deletions plus additions. @@ -345,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` @@ -357,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` @@ -383,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: @@ -435,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 — 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 @@ -505,22 +529,26 @@ 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. -`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 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`, `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). -`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,21 +561,97 @@ 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 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, 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 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. + +**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` + +```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 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 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. + +### 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 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; 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 — 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. + +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 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` + +```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. 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; 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. + +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 ### 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 — 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). 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. +* 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`). `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). -* 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. +* 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` @@ -555,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 (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` @@ -569,15 +673,43 @@ 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, 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`. 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, 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"`: + +* `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 — `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. + ## 13. Workspace Files ### 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 @@ -585,29 +717,35 @@ 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, 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 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). -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 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 -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 — 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. -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. +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 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. @@ -615,19 +753,22 @@ All state is workspace-local; instances operating on different workspaces MUST N 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, 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: 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 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. 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. 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. ## 15. Example diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 6d30518..49c2ecf 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,14 +18,15 @@ 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, 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 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. * **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 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 @@ -48,7 +49,8 @@ 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). +* **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 @@ -73,7 +75,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 @@ -127,7 +130,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). @@ -159,7 +162,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 @@ -169,7 +172,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. @@ -219,13 +222,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,31 +255,45 @@ 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); 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). ### 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-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-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, 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 `` 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-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-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; 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 + +(T6.6-1 is retired.) -### 6.6 Manual restructuring +* **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 ``, 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). -* **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. +### 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. ## 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. @@ -281,7 +307,7 @@ All category tests run `impact --base ` against a committed baseline and as * **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 @@ -291,7 +317,7 @@ All category tests run `impact --base ` against a committed baseline and as * **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 @@ -310,10 +336,11 @@ All category tests run `impact --base ` against a committed baseline and as ### 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. +* **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 @@ -330,7 +357,7 @@ All category tests run `impact --base ` against a committed baseline and as ### 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. @@ -339,7 +366,7 @@ All category tests run `impact --base ` against a committed baseline and as * **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). @@ -358,41 +385,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 Surfaces -## 11. Query +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). -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. +### 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. 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 + +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. 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, 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). + +### 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 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 `; 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 #`, `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`, `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,8 +474,8 @@ 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-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` @@ -418,7 +488,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, 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). ## 13. Workspace Files @@ -433,26 +516,27 @@ 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 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 -* **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 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). * **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 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. ### 13.5 Concurrency and isolation 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). @@ -462,13 +546,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, 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`; 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`, 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). +* **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 @@ -478,16 +565,19 @@ 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-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. -* **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 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: 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-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); 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. +* **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 @@ -496,12 +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) 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), 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 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 @@ -510,7 +601,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). diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md new file mode 100644 index 0000000..ba4510a --- /dev/null +++ b/specs/patches/0001-external-ui-apis.md @@ -0,0 +1,136 @@ +# 0001 — Foundational machine surfaces for an external spec UI + +- **Type:** Improvement Proposal (IP) +- **Stage:** Tested +- **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, 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. + +## 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, 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 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. + +### 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 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. + +### 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 (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 (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. + +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 + +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 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 — 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 — 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 + +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, 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 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. + +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 + +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. 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. +- 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 + +`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. 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. 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 + +- 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. + +## 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 (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. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md new file mode 100644 index 0000000..4ac171f --- /dev/null +++ b/specs/tmp/FIX_PLAN.md @@ -0,0 +1,421 @@ +# 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/` + (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): `, 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 ``, `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 `` 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 +`` `` 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 `` 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-`#` ``/`` +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=`); +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.) diff --git a/src/cli/args.ts b/src/cli/args.ts index 507cc41..dce7d77 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). +// `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. @@ -25,13 +26,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 { @@ -50,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 ``/`` 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. */ @@ -60,6 +71,25 @@ 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 [ …]`); the upper arity bound is not checked. + */ + readonly variadicPositionals?: boolean; + /** + * Flags that may not be combined with positional operands — SPEC 11.4: + * combining `` operands with `--file` is a usage error, a defect + * the invocation's syntax alone determines (SPEC 12.0). + */ + readonly positionalConflicts?: readonly string[]; + /** + * SPEC 12.0: the command's positional operands are ``/`` + * identities (the `identityValue` rule, positional side) — a multi-`#` + * spelling is a malformed value, parse-level. Never set for `` + * operands: a bare `` 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[]; /** @@ -68,6 +98,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 ` (7). */ @@ -101,10 +138,19 @@ const TEST_HOLD_FLAG: FlagSpec = { valueName: "", }; +/** + * 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, - * `impact` 9, `review` 10.7, `query` 11, `rename` 6.4, `move` 6.5. + * `impact` 9, `review` 10.7, `query` 11.1, `occurrences` 11.3, `view` 11.4, + * `at` 11.5, `inventory` 11.6, `rename` 6.4, `move` 6.5, `version` 12.6. */ const COMMANDS: readonly CommandSpec[] = [ // SPEC 12.1. @@ -122,7 +168,12 @@ const COMMANDS: readonly CommandSpec[] = [ ], }, // SPEC 12.4: `show `. - { path: "show", positionals: [""], flags: [] }, + { + path: "show", + positionals: [""], + identityPositionals: true, + flags: [], + }, // SPEC 8.2: `coverage` runs all profiles, `coverage ` one; `--check`. { path: "coverage", @@ -188,12 +239,21 @@ const COMMANDS: readonly CommandSpec[] = [ TEST_HOLD_FLAG, ], }, - { path: "review export", positionals: [""], flags: [] }, - // SPEC 11: the six query subcommands. - { path: "query node", positionals: [""], 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: [""], flags: [], jsonOnly: true }, + // SPEC 11: the six query subcommands — JSON-only surfaces (12.0). + { + path: "query node", + positionals: [""], + identityPositionals: true, + flags: [], + jsonOnly: true, + }, { path: "query nodes", positionals: [], + jsonOnly: true, flags: [ { name: "--group", takesValue: true, valueName: "" }, { name: "--file", takesValue: true, valueName: "" }, @@ -210,9 +270,20 @@ const COMMANDS: readonly CommandSpec[] = [ { path: "query edges", positionals: [], + jsonOnly: true, flags: [ - { name: "--from", takesValue: true, valueName: "" }, - { name: "--to", takesValue: true, valueName: "" }, + { + name: "--from", + takesValue: true, + valueName: "", + identityValue: true, + }, + { + name: "--to", + takesValue: true, + valueName: "", + identityValue: true, + }, // SPEC 11: `edges --kinds` filters over all four kinds. { name: "--kinds", @@ -222,23 +293,38 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, - { path: "query subtree", positionals: [""], flags: [] }, - { path: "query ancestors", positionals: [""], flags: [] }, + { + path: "query subtree", + positionals: [""], + identityPositionals: true, + flags: [], + jsonOnly: true, + }, + { + path: "query ancestors", + positionals: [""], + identityPositionals: true, + flags: [], + jsonOnly: true, + }, { path: "query reachable", positionals: [], + jsonOnly: true, flags: [ { name: "--from", takesValue: true, valueName: "", required: true, + identityValue: true, }, { name: "--to", takesValue: true, valueName: "", required: true, + identityValue: true, }, { name: "--kinds", @@ -248,15 +334,67 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, - // SPEC 6.4: `rename `. + // SPEC 11.3: `occurrences [--file ] [--to ]` — 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: "" }, + { name: "--to", takesValue: true, valueName: "" }, + ], + }, + // SPEC 11.4: `view [ …] [--file ] [--text]` — JSON-only + // (SPEC 11). Operands assert membership while `--file` restricts the + // domain; combining them is a usage error. + { + path: "view", + positionals: [""], + optionalPositionals: 1, + variadicPositionals: true, + positionalConflicts: ["--file"], + jsonOnly: true, + flags: [ + { name: "--file", takesValue: true, valueName: "" }, + { name: "--text", takesValue: false }, + ], + }, + // SPEC 11.5: `at ` — JSON-only (SPEC 11). `` asserts + // domain membership exactly as a `view` operand does and `` 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: ["", ""], + 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 [--preview]` (6.6). { path: "rename", positionals: ["", "", ""], - flags: [TEST_HOLD_FLAG], + flags: [TEST_HOLD_FLAG, PREVIEW_FLAG], }, // SPEC 6.5: `move ` or - // `move # #` — two positionals either way. - { path: "move", positionals: ["", ""], flags: [TEST_HOLD_FLAG] }, + // `move # #` — two positionals either way, + // `[--preview]` on both forms (6.6). + { + path: "move", + positionals: ["", ""], + 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` + // dispatches it before configuration location. + { path: "version", positionals: [], flags: [], jsonOnly: true }, ]; /** Every dispatch key (`CommandSpec.path`), in specification order. */ @@ -264,6 +402,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 = 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,26 +430,41 @@ 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 }; } /** * 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) { @@ -323,6 +481,66 @@ export function isValidUtf8ArgumentValue(value: string): boolean { return true; } +/** + * SPEC 6.5: a `move` operand is classified by spelling alone — an operand + * containing `#` is a `#` 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 # 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 # pair and one ` + + `without is a file, so the invocation matches neither ` + + `\`move \` nor ` + + `\`move # #\` (SPEC 6.5, 12.0)` + ); + } + return null; +} + +/** + * SPEC 12.0: at most one `#` is well-formed in a ``/`` + * 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[] = []; @@ -365,25 +583,35 @@ 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: 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: `--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 — 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 ` + `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 +622,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 +630,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 +658,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${commandToken}: unknown subcommand '${subToken}' (expected one ` + `of: ${subcommandNameList(entry)})`, + inEffect(), ); } spec = subcommand; @@ -430,6 +667,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { spec = entry; tokens = argv.slice(1); } + jsonOnlySurface = spec.jsonOnly === true; const flagSpecs = new Map(); for (const flag of GLOBAL_FLAGS) flagSpecs.set(flag.name, flag); @@ -445,15 +683,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); @@ -465,7 +699,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 +707,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 +721,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 +737,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,8 +749,17 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: invalid value '${value}' for '${token}' (expected ` + `one of: ${flag.allowed.join(", ")})`, + inEffect(), ); } + if (flag.identityValue === true) { + // SPEC 12.0: a `` 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); } @@ -524,6 +770,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: missing required flag '${flag.name}'` + (flag.valueName === undefined ? "" : ` ${flag.valueName}`), + inEffect(), ); } } @@ -534,24 +781,67 @@ 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(), ); } } // 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( `${spec.path}: missing required argument ` + `${spec.positionals[positionals.length]!}`, + 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(), + ); + } + } + // SPEC 12.0: a `` 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] ?? ""}`, + ); + 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`). + if (spec.path === "move") { + const problem = moveOperandsProblem(positionals); + if (problem !== null) { + return usageError(`move: ${problem}`, inEffect()); + } + } return { ok: true, @@ -586,6 +876,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/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 `` value (SPEC 11.5). */ +export function offsetSpellingOk(spelling: string): boolean { + return OFFSET_SPELLING.test(spelling); +} + +/** The malformed-`` diagnostic (SPEC 11.5, 12.0). */ +export function invalidOffsetMessage(spelling: string): string { + return ( + `invalid 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-`` diagnostic (SPEC 11.5, 11.4, 7, 12.0). */ +export function unknownFileMessage(file: string): string { + return ( + `unknown file '${file}' — the operand names a discovered spec ` + + `source, and no configured group discovers this path ` + + `(SPEC 11.5, 11.4, 7, 12.0)` + ); +} + +/** The wrong-kind-`` 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-`` 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 { + const io = { stdout, stderr }; + const file = invocation.positionals[0]!; + const offsetSpelling = invocation.positionals[1]!; + + // SPEC 11.5/12.0: a malformed 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 ` (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: +// +// - `` 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. +// - `` 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 { + 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(); + 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/commands/build.ts b/src/cli/commands/build.ts index 4017478..0b62b3c 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -22,8 +22,9 @@ 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"; 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; } @@ -48,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, @@ -62,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 96961c0..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,8 +36,9 @@ 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"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; @@ -50,9 +52,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; } @@ -77,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/common.ts b/src/cli/commands/common.ts index 37ca7a1..89446fc 100644 --- a/src/cli/commands/common.ts +++ b/src/cli/commands/common.ts @@ -10,29 +10,33 @@ 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 } 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; } @@ -59,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/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/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 `` a spec source, a code source being the +// wrong-kind operand of 12.0; for `` 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 ``/`` 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; + /** Discovered code-source paths (valid paths only). */ + readonly codePaths: ReadonlySet; + /** 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 `` 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 `` 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/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..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(context.stderr, invocation.command, resolution.message); + 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/inventory.ts b/src/cli/commands/inventory.ts new file mode 100644 index 0000000..2471b99 --- /dev/null +++ b/src/cli/commands/inventory.ts @@ -0,0 +1,247 @@ +// `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 } from "../../core/findings.js"; +import { codeExitClass, orderFindings } from "../../core/findings.js"; +import { + 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"; +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 `inventory` command handler (SPEC 11.6). */ +export async function inventoryCommand( + invocation: Invocation, + context: CommandContext, +): Promise { + 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/commands/move.ts b/src/cli/commands/move.ts index 1a5f31f..10fc14f 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 `#` 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) @@ -36,46 +45,56 @@ // 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 `` (1.4); a `` 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 (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"; 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"; @@ -91,46 +110,91 @@ import { analyzeWorkspaceContent, workspaceInputsOf, } from "../../workspace/pipeline.js"; -import { classifyOccupant, describeOccupant } from "../../workspace/writes.js"; import { + nonDirectoryComponents, + probeOccupant, removeSourceFile, - symlinkWritePathFindings, + obstructedWritePathFindings, writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; -import { isValidUtf8ArgumentValue } from "../args.js"; +import { + flagPresent, + flagValue, + 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"; +import { + emitAppliedMappingReport, + emitConfigurationErrors, + emitFindingsReport, +} from "../report.js"; +import { testHoldSpecOf, usageError } from "./common.js"; +import { emitRefusedPreview, emitSuccessfulPreview } from "./preview.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). 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 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( + preview: boolean, json: boolean, stdout: CliWriter, findings: readonly Finding[], ): ExitCode { + if (preview) { + return emitRefusedPreview(json, stdout, findings); + } emitFindingsReport(json, stdout, findings); 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; @@ -139,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("#"); @@ -150,239 +216,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; @@ -398,37 +231,38 @@ 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 { 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( - stderr, - invocation.command, - `'${destinationArg}' names no target section — the forms are ` + - `\`move \` and \`move # ` + - `#\` (SPEC 6.5)`, - ); - } const analysis = await analyzeWorkspace(workspace); // 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 +273,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)`, @@ -455,7 +289,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 @@ -466,8 +305,8 @@ async function runMove( ); if (section === undefined) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown ID '${origin.id}' in '${origin.file}' — must name an ` + `existing requirement ID of that file (SPEC 6.5, 12.0)`, ); @@ -478,11 +317,18 @@ 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) { 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( @@ -493,15 +339,23 @@ async function runMove( origin.id, destination.file, destination.id, + preview, ); } + 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, ); } @@ -512,21 +366,36 @@ async function runMoveFile( analysis: WorkspaceAnalysis, originPath: string, destination: string, + preview: boolean, ): Promise { 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. `--preview` + // shares exactly this evaluation (SPEC 6.6). + 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(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, @@ -534,6 +403,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 @@ -547,19 +434,31 @@ async function runMoveFile( plan, originPath, destination, - destinationResult.specGroups, + assessment.specGroups, ); if (rewritten.configurationErrors.length > 0) { // 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) { - // SPEC 6.5: the rewrite would not leave a valid workspace — refuse with - // the would-be findings, nothing modified. - return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); + // 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( + false, + invocation.json, + stdout, + rewritten.findings, + ); } // SPEC 6.5/6.4/12.1: the finishing regeneration's outputs, derived @@ -585,13 +484,13 @@ 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, ]); 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 @@ -606,12 +505,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; } @@ -657,6 +555,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); @@ -667,6 +567,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)), }); } @@ -680,156 +583,55 @@ async function runMoveSection( oldId: string, targetPath: string, newId: string, + preview: boolean, ): Promise { 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 `` 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). - 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: `` 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 - // `` minus its final segment, needed whenever `` 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)`, - ); - } + // 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); + + // 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(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, @@ -839,6 +641,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 @@ -855,14 +675,26 @@ 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) { - // 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. - return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); + // 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( + false, + invocation.json, + stdout, + rewritten.findings, + ); } // SPEC 6.5/6.4/12.1: the finishing regeneration's outputs, derived @@ -885,13 +717,13 @@ 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, ]); 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 @@ -904,11 +736,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; } @@ -953,6 +785,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, }; } @@ -964,11 +798,14 @@ 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)), }); } -/** The `move` command handler (SPEC 6.5). */ +/** The `move` command handler (SPEC 6.5, 6.6). */ export async function moveCommand( invocation: Invocation, context: CommandContext, @@ -978,6 +815,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), @@ -985,10 +838,10 @@ 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(context.stderr, invocation.command, outcome.usageMessage); + return usageError(invocation, context, outcome.usageMessage); } return outcome.value; } 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 ] [--to ]` (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 { + // --- 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/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 { + 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/query-core.ts b/src/cli/commands/query-core.ts index 3c11f00..75fb9df 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). */ @@ -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 `` 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 — 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-`` 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-`` 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 `` 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 — 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. */ @@ -352,7 +369,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 +378,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 +397,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 +411,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 @@ -414,9 +432,9 @@ export function answerQuery( if (raw === undefined) { continue; } - const message = unknownGraphNodeMessage(view, flag, raw); + const message = graphNodeProblem(view, flag, raw); if (message !== null) { - return usageError(stderr, invocation.command, message); + return usageError(invocation, io, message); } } const edges = view.edges.filter( @@ -430,7 +448,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 +458,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, @@ -462,9 +480,9 @@ 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(stderr, invocation.command, message); + return usageError(invocation, io, message); } } const adjacency = new Map>(); 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..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 ``/`` 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 `` positional of `node`/`subtree`/`ancestors`, + * the `` 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). - const prevalidated = prevalidateQuery(invocation, groups, stderr); + // 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/rename.ts b/src/cli/commands/rename.ts index b996157..b75aebe 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -25,25 +25,31 @@ // 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 (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"; 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"; @@ -62,73 +68,43 @@ import { workspaceInputsOf, } from "../../workspace/pipeline.js"; import { - symlinkWritePathFindings, + obstructedWritePathFindings, writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; +import { flagPresent, flagValue, jsonOutputInEffect } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; -import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; -import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; +import { + emitAppliedMappingReport, + emitConfigurationErrors, + emitFindingsReport, +} from "../report.js"; +import { testHoldSpecOf, usageError } from "./common.js"; +import { emitRefusedPreview, emitSuccessfulPreview } from "./preview.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). 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 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( + preview: boolean, json: boolean, stdout: CliWriter, findings: readonly Finding[], ): ExitCode { + if (preview) { + return emitRefusedPreview(json, stdout, findings); + } emitFindingsReport(json, stdout, findings); 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; @@ -144,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 { const { workspace, stdout, stderr } = context; const analysis = await analyzeWorkspace(workspace); @@ -158,7 +139,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 +153,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}' — must name a discovered source file ` + `of a configured spec group, workspace-relative (SPEC 6.4, 12.0)`, ); @@ -179,16 +165,20 @@ 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 - // 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( - stderr, - invocation.command, + invocation, + context, `unknown ID '${oldId}' in '${file}' — must name an existing ` + `requirement ID of that file (SPEC 6.4, 12.0)`, ); @@ -196,47 +186,51 @@ 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 - // modified. - if (newId === oldId) { - return emitRefusal( + return emitFindingsRefusal( + preview, invocation.json, stdout, - `the new ID must differ from the old ID ${JSON.stringify(oldId)} ` + - `(SPEC 6.4)`, + analysis.findings, ); } - 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)`); + + // 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. `--preview` shares exactly this evaluation (SPEC 6.6). + const refusals = evaluateRenameRefusals({ origin, oldId, newId }); + if (refusals.length > 0) { + return emitFindingsRefusal(preview, invocation.json, stdout, refusals); } - if (origin.document.sections.some((s) => s.id === newId)) { - return emitRefusal( + + // 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, - `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)`, + workspace, + plan.entry.mapping, + plan.previewFiles, + analysis.classification.specSources.map((source) => source.path), ); } - // The pure plan: the identity mapping, the journal entry, and the minimal - // in-place rewrites of every affected source (SPEC 6.4, 6.1). - const plan = planRename(analysis.specs, analysis.code, file, oldId, newId); - // 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 @@ -247,13 +241,25 @@ 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) { - // SPEC 6.4: the rewrite would not leave a valid workspace — refuse with - // the would-be findings, nothing modified. - return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); + // 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( + false, + invocation.json, + stdout, + rewritten.findings, + ); } // SPEC 6.4/12.1: the finishing regeneration's outputs, derived exactly as @@ -274,13 +280,13 @@ 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, ]); 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, @@ -292,12 +298,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; } @@ -331,11 +336,14 @@ 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)), }); } -/** The `rename` command handler (SPEC 6.4). */ +/** The `rename` command handler (SPEC 6.4, 6.6). */ export async function renameCommand( invocation: Invocation, context: CommandContext, @@ -345,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), @@ -352,10 +376,10 @@ 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(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..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"; @@ -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)`, ); @@ -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[2], ): Promise { - const findings = await symlinkWritePathFindings(context.workspace.root, [ + const findings = await obstructedWritePathFindings(context.workspace.root, [ sessionFilePath(name), ]); if (findings.length > 0) { @@ -153,13 +154,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 @@ -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; } @@ -246,15 +246,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 @@ -328,7 +329,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..e8e3306 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 @@ -44,9 +51,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, @@ -82,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"; @@ -250,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 @@ -306,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. */ @@ -316,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( @@ -345,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; @@ -367,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 { @@ -381,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( @@ -393,8 +440,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}`, ), @@ -403,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, }; } @@ -427,7 +471,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 +481,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)`, @@ -506,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, @@ -526,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) @@ -561,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, @@ -580,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), + }; } /** @@ -679,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; @@ -774,21 +841,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 62d5979..b27302f 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,20 +57,27 @@ 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, 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"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, prepareGraphForRead } from "../prepare.js"; import { emitFindingsReport } from "../report.js"; import { emitDocument, testHoldSpecOf, usageError } from "./common.js"; import { @@ -94,7 +106,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` @@ -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 @@ -115,8 +127,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)`, ); @@ -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(stderr, invocation.command, resolution.message); + 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; + } + + // 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; } - const { analysis } = prepared; + + // 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 @@ -165,12 +200,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 +215,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], ); } @@ -223,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) { @@ -272,7 +313,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 +498,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 +528,15 @@ export async function reviewExportCommand( // Unreachable: the parser enforces the positional (SPEC 10.7). throw new Error("xspec internal error: review export without "); } - 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..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 { const { stdout, stderr } = context; - // SPEC 13.3: refresh-on-read, then answer. - const prepared = await prepareGraphForRead(invocation, context); + // SPEC 12.0/13.3: the `` 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,7 +111,10 @@ export async function showCommand( const resolved = resolveRow(view, invocation.positionals[0]); if (!resolved.ok) { - return usageError(stderr, invocation.command, resolved.message); + // 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) { // SPEC 12.4/11: the machine form is `query node`'s document exactly. 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/commands/view.ts b/src/cli/commands/view.ts new file mode 100644 index 0000000..7b3a4ed --- /dev/null +++ b/src/cli/commands/view.ts @@ -0,0 +1,423 @@ +// `xspec view [ …] [--file ] [--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). `` +// 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 { + // --- 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(); + 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(); + 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 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 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(); + 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. 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 | 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(); + 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 | null, + children: readonly JsonObject[], + ): 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, + ownText, + subtreeText, + }; + } +} 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..7c60bf8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -21,10 +21,15 @@ 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 { tryFastAt } from "./commands/at-fast.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 = ( @@ -35,129 +40,165 @@ 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 Promise> = new Map( - COMMAND_PATHS.map((path): [string, () => Promise] => { - 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] => { + 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 "occurrences": + // SPEC 11.3. + return [ + path, + async () => + (await import("./commands/occurrences.js")).occurrencesCommand, + ]; + case "view": + // SPEC 11.4. + return [ + path, + async () => (await import("./commands/view.js")).viewCommand, + ]; + case "at": + // SPEC 11.5. + return [ + 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 [ + 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). */ @@ -180,11 +221,29 @@ 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; } + + // 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 @@ -197,11 +256,16 @@ export async function main( // upward search from the working directory, or the `--config ` // 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; } @@ -221,12 +285,34 @@ 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"); 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..0388cd5 100644 --- a/src/cli/prepare.ts +++ b/src/cli/prepare.ts @@ -10,15 +10,24 @@ // 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). import type { ExitCode } from "../core/findings.js"; import type { GraphData } from "../core/graph-data.js"; +import { + analyzeWorkspaceForAvailability, + 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"; import { emitConfigurationErrors, emitFindingsReport } from "./report.js"; @@ -37,30 +46,143 @@ 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 { + 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 { + 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 { - const prepared = await prepareWorkspaceForRead(context.workspace); - switch (prepared.kind) { - case "configuration": - emitConfigurationErrors(context.stderr, 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. */ +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 { + 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 }; +} + +/** + * 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 { + 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/cli/report.ts b/src/cli/report.ts index 3ee92f6..6f5310f 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,106 @@ // 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 type { ResolvedOccurrence } from "../core/availability.js"; 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 { CliWriter } from "./io.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 { PreviewDelta, PreviewFileEdits } from "../core/preview.js"; +import type { CliWriter, CommandIo } 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` — 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 `${renderPathText(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 = `${renderPathText(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 `[]`. 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 { - 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: pathTextJson(location.file), + range: { start: location.range.start, end: location.range.end }, + })), + path: finding.path === null ? null : pathTextJson(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); } @@ -119,32 +127,248 @@ 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("")); +} + +/** The SPEC 12.7 unavailability marker — the one explicit-absence form. */ +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"}` + * — 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 + * 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 - * 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`; + finding.path === null ? "" : `${renderPathText(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. + * 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/availability.ts b/src/core/availability.ts new file mode 100644 index 0000000..334b1c6 --- /dev/null +++ b/src/core/availability.ts @@ -0,0 +1,367 @@ +// 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 { SpecDocument, SpecSection } from "./mdx.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; + + constructor(files: Iterable) { + const keys = new Set(); + 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; +} + +// --------------------------------------------------------------------------- +// 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(); + const visited = new Set(); + + 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(); + + 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/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/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 1ba0d00..5d7949e 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -30,15 +30,20 @@ // 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"; 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"; +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"; @@ -49,12 +54,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; + readonly designate: DesignateSpecifier; /** * The configured Markdown emit destinations (SPEC 7.3, * `markdownEmitDestinations`) — empty while emission is disabled — for @@ -73,6 +82,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). */ @@ -100,10 +120,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). */ @@ -133,12 +163,34 @@ 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. */ 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 @@ -176,8 +228,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 }; } @@ -198,7 +251,7 @@ export function analyzeCodeSource( return { kind: "unparseable", finding: parseFailureFinding( - path, + file, sourceFile, offsets, tsx, @@ -208,6 +261,7 @@ export function analyzeCodeSource( } const analyzer = new CodeAnalyzer( path, + file, sourceFile, offsets, program.getTypeChecker(), @@ -224,22 +278,20 @@ 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 { - return { - condition: 20, - file: path, - range: { start: 0, end: 0 }, - message: - `unparseable source: not well-formed ${grammar} — the file's ` + +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, range: { start: 0, end: 0 } }], + ); } /** @@ -248,10 +300,10 @@ function stackOverflowFinding(path: string, 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 @@ -263,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", @@ -280,11 +332,11 @@ function createSingleFileProgram( /** The 14.20 finding for a parse failure, locating it (SPEC 14.20). */ function parseFailureFinding( - path: string, - sourceFile: ts.SourceFile, + file: PathText, + 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"; @@ -293,31 +345,72 @@ 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, + range: { + start: offsets.byteOffset(start), + end: offsets.byteOffset(end), + }, + }, + ], + ); } // --------------------------------------------------------------------------- // 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"; @@ -335,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 { @@ -347,15 +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(); + private readonly declarations = new Map(); /** Named-unit construct → its unit (attribution, SPEC 4.6). */ - private readonly unitByNode = new Map(); + private readonly unitByNode = new Map(); constructor( private readonly path: string, - private readonly sourceFile: ts.SourceFile, + private readonly file: PathText, + private readonly sourceFile: tst.SourceFile, private readonly offsets: Utf8Offsets, - private readonly checker: ts.TypeChecker, + private readonly checker: tst.TypeChecker, private readonly context: CodeAnalysisContext, ) {} @@ -365,6 +459,7 @@ class CodeAnalyzer { this.walk(this.sourceFile); return { path: this.path, + file: this.file, text: this.sourceFile.text, units: this.units, imports: this.imports, @@ -378,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()), @@ -386,21 +481,24 @@ class CodeAnalyzer { } private addFinding( - condition: 8 | 11 | 15 | 18, - node: ts.Node, + condition: 7 | 8 | 11 | 15 | 18, + node: tst.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.file, range: this.rangeOf(node) }], + identities, + ), + ); } /** 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); @@ -411,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 = @@ -425,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 ) { @@ -437,12 +535,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; @@ -451,6 +555,7 @@ class CodeAnalyzer { start: this.offsets.byteOffset(span.start), end: this.offsets.byteOffset(span.end), }); + const range = spanRange(classified.span); return { kind, location, @@ -468,7 +573,8 @@ class CodeAnalyzer { accessRange: spanRange(segment.accessSpan), })), }, - range: spanRange(classified.span), + range, + occurrenceRange: occurrenceRange ?? range, }; } @@ -497,8 +603,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(); for (const entry of bound) { const entries = byName.get(entry.name); @@ -506,17 +615,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: tst.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.file, + range: this.rangeOf(declaration), + })), + ), + ); for (const entry of entries) { this.declarations.set(entry.declaration, { kind: "poisoned" }); } @@ -532,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; @@ -588,24 +710,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) { @@ -620,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"; }[] = []; @@ -682,11 +810,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 }, ); } @@ -701,6 +829,7 @@ class CodeAnalyzer { specifierQuote: quote === "'" ? "'" : '"', specifierRange: this.rangeOf(literal), targetPath: valid ? targetPath : null, + targetFile: valid ? targetFile : null, defaultBinding, textBindings, valid, @@ -714,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)) { @@ -742,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; @@ -784,10 +913,17 @@ class CodeAnalyzer { */ private checkDerivedSpecifier( specifier: string, - at: ts.Node, + at: tst.Node, 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( @@ -819,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 => { @@ -858,15 +994,83 @@ 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 ` 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: 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: tst.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 { + 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; @@ -917,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)) { @@ -938,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; @@ -971,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), @@ -994,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 @@ -1018,8 +1222,8 @@ class CodeAnalyzer { * position). */ private visitNodeBindingUse( - identifier: ts.Identifier, - binding: { readonly kind: "node"; readonly modulePath: string }, + identifier: tst.Identifier, + binding: { readonly kind: "node"; readonly target: SpecModuleTarget }, ): void { const use = climbUseExpression(identifier); const parent = use.parent; @@ -1029,14 +1233,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). @@ -1071,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; } @@ -1100,8 +1321,8 @@ class CodeAnalyzer { * position too, recording its `embeds` edge (4.3) — never a marker. */ private visitTextBindingUse( - identifier: ts.Identifier, - binding: { readonly kind: "text"; readonly modulePath: string }, + identifier: tst.Identifier, + binding: { readonly kind: "text"; readonly target: SpecModuleTarget }, ): void { const parent = identifier.parent; if (ts.isCallExpression(parent) && parent.expression === identifier) { @@ -1123,8 +1344,8 @@ class CodeAnalyzer { * form is MDX-only (4.3 → 14.8); a cross-module node is 14.11 (4.4). */ private analyzeTextCall( - call: ts.CallExpression, - calleeBinding: { readonly kind: "text"; readonly modulePath: string }, + call: tst.CallExpression, + calleeBinding: { readonly kind: "text"; readonly target: SpecModuleTarget }, ): void { if (call.questionDotToken !== undefined) { this.addFinding( @@ -1231,27 +1452,54 @@ class CodeAnalyzer { ); return; } - if (rootBinding.modulePath !== calleeBinding.modulePath) { - // SPEC 4.4 → 14.11: a node passed to another module's text export. + 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)`, + `${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; } // 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.modulePath, + rootBinding.target.path, this.attributionOf(call), + this.rangeOf(call), ), ); } @@ -1262,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) @@ -1270,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) || @@ -1278,12 +1526,39 @@ 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; } +/** + * 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: tst.Node, + boundary: number, + sourceFile: tst.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 @@ -1296,7 +1571,7 @@ function stripParentheses(expression: ts.Expression): ts.Expression { * `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; @@ -1350,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; @@ -1416,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)) && @@ -1443,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 ( @@ -1468,12 +1743,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..0d03ee3 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,8 +18,10 @@ // `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"; import { compileGlob, unboundToCaptures } from "./glob.js"; @@ -141,7 +143,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 { @@ -154,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 ); @@ -207,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)) { @@ -249,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; @@ -339,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 ` + @@ -428,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"; } @@ -458,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); @@ -1345,8 +1350,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, @@ -1389,15 +1396,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..829cfc5 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -18,13 +18,14 @@ 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; // "/" 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."); @@ -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[]; } @@ -131,15 +168,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). @@ -211,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"; @@ -322,72 +413,97 @@ 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; - 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; + 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; - findings.push({ - condition: 14, - file: fileLabel, - message: + bothGroups = true; + 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) { + // 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 (!valid || decoded === null) 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/findings.ts b/src/core/findings.ts index 0f60cee..844a121 100644 --- a/src/core/findings.ts +++ b/src/core/findings.ts @@ -1,13 +1,19 @@ // 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"; +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 @@ -16,7 +22,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 +45,261 @@ 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> = { - 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). 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 FindingEdge { - readonly kind: "contains" | "depends" | "embeds" | "references"; - readonly source: string; - readonly target: string; +export interface FindingLocation { + readonly file: PathText; + 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). 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 + * 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: PathText | null; + readonly identities: readonly string[]; +} + +/** + * 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: PathText | null, + identities: readonly string[] = [], +): Finding { + return { + code: conditionCode(condition), + message, + locations: [], + path, + identities, + }; } -/** 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 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). 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 ( + comparePathTexts(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( + 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; 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); + 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 = comparePathTexts(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-data.ts b/src/core/graph-data.ts index c175ff9..6e44949 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; @@ -32,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 @@ -48,19 +54,60 @@ 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 { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; +import type { + DependencyEdgeKind, + GraphEdge, + GraphEdgeKind, + WorkspaceGraph, +} from "./graph.js"; 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"; +/** + * 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 - * sources and configuration and is refreshed or rebuilt (SPEC 13.3). + * 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 = 2; +const GRAPH_DATA_VERSION = 4; /** One recorded derivation input: a discovered source and its fingerprint. */ export interface StoredSourceInput { @@ -131,6 +178,34 @@ 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; +} + +/** + * 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; } /** @@ -145,6 +220,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 +247,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 @@ -202,13 +283,32 @@ 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, 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 }; } /** @@ -216,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, @@ -257,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, @@ -328,12 +435,20 @@ 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, 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); } @@ -373,10 +488,14 @@ const EDGE_KINDS: ReadonlySet = 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; @@ -393,17 +512,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, }; @@ -559,10 +680,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 { @@ -582,3 +708,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 = 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 efbc343..5532533 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 @@ -31,11 +34,15 @@ // 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 } 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 { definedIdentitySections } from "./mdx.js"; +import type { PathText } from "./path-text.js"; +import { comparePathTexts, pathTextKey, renderPathText } from "./path-text.js"; import type { ReferenceTarget, SpecImportModel, @@ -67,6 +74,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; @@ -93,6 +108,48 @@ export interface GraphEdge { readonly target: string; } +/** SPEC 5.2/5.7: the dependency edge kinds — the kinds occurrences record. */ +export type DependencyEdgeKind = Exclude; + +/** + * 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; @@ -112,6 +169,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[]; } // --------------------------------------------------------------------------- @@ -130,6 +199,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; readonly codeIndex: ReadonlyMap; @@ -141,16 +211,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; @@ -167,6 +240,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; @@ -278,6 +352,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[] = []; @@ -305,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, @@ -342,6 +421,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); @@ -351,6 +434,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); @@ -364,7 +449,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 }); }; @@ -384,6 +469,39 @@ 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 + // (SPEC 14 location cardinality, 14.9). + const edgeSpellings = new Map(); + 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) { @@ -407,8 +525,26 @@ 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. + addSpelling( + source.identity, + resolved.node.identity, + spec.document.path, + dependency.reference.range, + ); } } } @@ -431,11 +567,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 ` + @@ -446,8 +585,27 @@ 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 + // container, opening brace through closing brace. + addSpelling( + source.identity, + resolved.node.identity, + spec.document.path, + embedded.embedding.range, + ); } } } @@ -478,17 +636,160 @@ 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, + ); } } - // 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, - ); + // --- 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). 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 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 ` + + `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 resolved; + return { + ok: false, + 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 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 ${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 }], + ), + ); + } + for (const embedded of spec.references.embeddings) { + 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. 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, + "embeds", + null, + outcome.node.identity, + ); + 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( + locatedFinding( + 6, + `unknown text target: the text(...) reference to ` + + `${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 }], + ), + ); + } + } + for (const analysis of invalidPathCode) { + for (const reference of analysis.references) { + const resolved = resolution.resolveExternal( + reference.modulePath, + reference.segments, + ); + 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( + 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); // The collapsed edge set, ordered (source, kind, target) — kinds in // SPEC 5.2 listing order (SPEC 12.0 determinism). @@ -499,16 +800,33 @@ 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(requirementNodes, requirementIndex, edges), + ...dependencyCycleFindings( + requirementNodes, + requirementIndex, + edges, + edgeSpellings, + ), ); - findings.push(...importCycleFindings(specs, parsedByPath)); + findings.push(...importCycleFindings([...specs, ...invalidPathSpecs])); return new WorkspaceGraph({ requirementNodes, codeLocations, edges, + occurrences, findings, requirementIndex, codeIndex, @@ -524,7 +842,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). */ @@ -638,12 +956,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, edges: readonly GraphEdge[], + edgeSpellings: ReadonlyMap, ): Finding[] { const adjacency = new Map>(); for (const edge of edges) { @@ -661,18 +983,29 @@ 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/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)`, - }; - return finding; + 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 }], + ); }); } @@ -680,46 +1013,80 @@ 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 the import that closes the - * reported cycle. + * 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, -): Finding[] { +function importCycleFindings(specs: readonly SpecFileAnalysis[]): Finding[] { + /** Byte key of one parsed file (both `PathText` forms, SPEC 12.0). */ + const keyed = new Map(); + for (const spec of specs) { + keyed.set(pathTextKey(spec.document.file), spec); + } const adjacency = new Map>(); 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) => { - // 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], - ); - const finding: Finding = { - condition: 9, - file: cycle[0], - ...(closing !== undefined ? { range: closing.statement.range } : {}), - cycle, - message: - `spec import cycle: ${cycle.join(" → ")} — import cycles among ` + + 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 = keyed.get(cycle[step]); + if (spec === undefined) continue; + for (const declared of spec.imports.imports) { + if ( + declared.targetFile !== null && + pathTextKey(declared.targetFile) === cycle[step + 1] + ) { + locations.push({ + file: spec.document.file, + range: declared.statement.range, + }); + } + } + } + const renderedCycle = cycle + .map((key) => renderPathText(fileOf(key))) + .join(" → "); + return locatedFinding( + 9, + `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)`, - }; - return finding; + 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: fileOf(cycle[0]), range: { start: 0, end: 0 } }], + ); }); } @@ -729,9 +1096,12 @@ function importCycleFindings( * 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[][] { diff --git a/src/core/journal.ts b/src/core/journal.ts index 7735389..c998cc2 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, @@ -90,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[]; } /** @@ -183,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) { @@ -195,7 +210,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; } @@ -205,20 +220,21 @@ 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 { +): 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 { - condition: 13, - file: JOURNAL_PATH, - line, - range, - message: + ...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)`, + `${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, }; } @@ -828,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, @@ -872,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/core/mdx.ts b/src/core/mdx.ts index 2559fd2..c36f4d1 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -47,6 +47,8 @@ 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 type { PathText } from "./path-text.js"; import { decodeSourceBytes } from "./source-text.js"; import { containsControl, @@ -94,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; @@ -148,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). */ @@ -190,8 +231,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). */ @@ -899,8 +954,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 }; } @@ -914,11 +970,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(); @@ -928,7 +984,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) { @@ -939,15 +995,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, range: { start: 0, end: 0 } }], + ), }; } throw error; @@ -958,7 +1012,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, @@ -975,7 +1029,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 +1062,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, range }], + ); } /** A byte range from a parse failure's UTF-16 point (and optional end). */ @@ -1071,6 +1123,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 { + // 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(); + 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(); + 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(); + 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 @@ -1117,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; } @@ -1144,6 +1274,7 @@ class DocumentBuilder { constructor( private readonly path: string, + private readonly file: PathText, private readonly text: string, private readonly offsets: Utf8Offsets, ) { @@ -1162,6 +1293,9 @@ class DocumentBuilder { tags: [], idAttribute: null, dependency: null, + attributes: [], + tagsDefined: true, + coverageDefined: true, idPresent: false, }; } @@ -1179,7 +1313,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.file, range }]), + ); } /** The node's UTF-16 span; every parsed mdast node carries one. */ @@ -1478,6 +1614,9 @@ class DocumentBuilder { tags: [], idAttribute: null, dependency: null, + attributes: [], + tagsDefined: true, + coverageDefined: true, idPresent: false, }; this.processAttributes(node, section); @@ -1493,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( @@ -1519,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 / are id, d, // coverage, and tags. @@ -1583,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", @@ -1591,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; @@ -1612,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]; @@ -1642,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") { @@ -1653,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, @@ -1666,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, @@ -1675,7 +1856,7 @@ class DocumentBuilder { ); } } - return; + return tagsValid; } // name === "id" (SPEC 1.3): record the declared ID and validate its @@ -1710,6 +1891,9 @@ class DocumentBuilder { ); } } + // The spelled identity stays spelled whatever its segments (SPEC 11.2); + // its definedness is judged by `definedIdentitySections`. + return true; } /** @@ -1755,9 +1939,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(); + /** Declared ID → the location of every bearer, in document order. */ + const bearers = new Map(); for (const section of this.sections) { if (!section.idPresent) { // SPEC 1.3 → 14.1: a non-root section without `id`. @@ -1801,33 +1991,34 @@ 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.file, range })), + ), + ); } } /** 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, + file: this.file, text: this.text, offsets: this.offsets, root: this.root, 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, 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/path-text.ts b/src/core/path-text.ts new file mode 100644 index 0000000..ee8e485 --- /dev/null +++ b/src/core/path-text.ts @@ -0,0 +1,146 @@ +// 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; +} + +/** + * 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); + 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, + * ``, 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 + : ``; +} 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/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(); + + 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/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/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 `` + * or the section form's to-be-created ``. `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(); + 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>(); + 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(); + 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(); + for (const spec of specs) { + specByPath.set(spec.document.path, spec); + } + const adjacency = new Map>(); + 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(); + 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(); + 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, +): 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 — + // `#` equal to `#` (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 `` 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 + // (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 ``, 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/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(), }; } 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..b62cce6 100644 --- a/src/core/source-text.ts +++ b/src/core/source-text.ts @@ -9,6 +9,8 @@ // as a byte offset into the file. 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 }); @@ -82,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. @@ -94,30 +96,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..4143ad7 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -19,12 +19,19 @@ // 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 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"; +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, @@ -58,10 +65,31 @@ 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; + /** + * 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; } @@ -73,6 +101,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 @@ -120,6 +158,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(); + private readonly byKey = new Map(); + + constructor( + definedPaths: Iterable, + 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 = new Set([ "S", @@ -149,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 []; } @@ -172,23 +394,27 @@ 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; 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). + * 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, + designate: DesignateSpecifier, ): SpecImportModel { const imports: SpecImport[] = []; const bindings = new Map(); const findings: Finding[] = []; - /** name → whether any import already bound it (duplicate rule). */ - const seenNames = new Set(); + /** name → the distinct import declarations binding it (duplicate rule). */ + const declarationsByName = new Map(); for (const block of document.esmBlocks) { for (const statement of block.imports) { @@ -252,24 +478,32 @@ 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 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; + designatedFile = 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; + designatedFile = designation.file; } } @@ -288,42 +522,42 @@ 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.file, range: statement.range }], + ), + ); targetPath = null; + targetFile = null; + undefinedTarget = 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({ - condition: 15, - file: document.path, - range: statement.range, - message: - `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)`, - }); - 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 } + 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)) { + declared.push(statement); } } @@ -339,11 +573,35 @@ export function analyzeSpecImports( end: specifierLiteral.getEnd(), }), targetPath, + targetFile, + // SPEC 11.4: the datum turns on specifier form and discovery + // alone — kept through the invalid-import reset above. + designatedFile, valid, }); } } + // 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.file, range: decl.range })), + ), + ); + bindings.set(name, { kind: "poisoned" }); + } + return { imports, bindings, @@ -353,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). */ @@ -481,8 +739,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 @@ -531,12 +820,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.file, range }]), + ); } /** @@ -607,6 +893,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; @@ -656,11 +960,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)`, ); @@ -672,31 +976,59 @@ 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; } 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; } - /** 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 { 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.file, + range: containerRange ?? translate.range(classified.span), + }, + ], + ), }; } if (classified.kind === "string") { @@ -711,15 +1043,30 @@ 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.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") { @@ -777,12 +1124,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/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; 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/availability.ts b/src/workspace/availability.ts new file mode 100644 index 0000000..db6bd56 --- /dev/null +++ b/src/workspace/availability.ts @@ -0,0 +1,194 @@ +// 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 * 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 { + 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 { obstructedWritePathFindings } 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 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 analyzeWorkspaceForAvailability( + workspace: LoadedWorkspace, +): Promise { + 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 { + 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; + } + + // 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 obstructedWritePathFindings( + workspace.root, + build.writePaths, + ); + if (writeFindings.length > 0) { + return; + } + + // 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. 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, + refreshedGraphData(stored.data, build.graphData), + ); + } +} + +/** + * 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 { + 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 + * `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 { + const prepared = await analyzeWorkspaceForAvailability(workspace); + if (prepared.kind === "answer") { + await finishAvailabilityRefresh(workspace, prepared.analysis); + } + return prepared; +} diff --git a/src/workspace/baseline.ts b/src/workspace/baseline.ts index 0b110ee..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`, @@ -33,10 +53,12 @@ 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"; 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"; @@ -70,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; + /** 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 }; } @@ -186,10 +259,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 ? "" : `${renderPathText(concerned)}: `; + return `\n ${file}${finding.message}`; }); return ( `the workspace content at baseline ref '${ref}' cannot be parsed and ` + @@ -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 { +): Promise { 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 { + 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, @@ -385,8 +541,25 @@ export async function resolveBaseline( } oidForSource.set(sourcePath, oid); } - const sourceBlobs = await readBlobs(root, [...oidForSource.values()]); - if (sourceBlobs === null) return unreadable; + // 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(); + 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 failure(unreadableMessage(ref)); const analysis = await analyzeWorkspaceContent(configParse.configuration, { classification, @@ -400,6 +573,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 @@ -412,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 { + const read = await readBaseline(workspace, ref); + if (!read.ok) { + return read; + } + return validateBaselineContent(read.read); +} 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/check.ts b/src/workspace/check.ts index 0a29a74..4e582be 100644 --- a/src/workspace/check.ts +++ b/src/workspace/check.ts @@ -22,8 +22,9 @@ 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, + GRAPH_DATA_AREA, graphDataMatchesCurrent, } from "../core/graph-data.js"; import type { LoadedGraphData } from "./graph-data.js"; @@ -47,26 +48,62 @@ 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, + ); +} + +/** + * 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 + * 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, + ); } /** @@ -75,10 +112,16 @@ function orphanFinding(rel: string): 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, judged by the shared compare-with-current predicate + * - 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`). + * 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. @@ -127,15 +170,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)) { - findings.push(staleFinding(GRAPH_DATA_PATH, "does not match")); + // 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(mismatchedGraphDataStaleFinding()); } // 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/config.ts b/src/workspace/config.ts index 044dfd1..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 @@ -22,6 +25,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"; @@ -35,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). @@ -57,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 }; @@ -69,6 +82,7 @@ export function parseLocatedWorkspace( workspace: { root: located.root, configFileName: located.configFileName, + configAnchor: located.configAnchor, configHash: sha256Hex(located.configBytes), configuration: parsed.configuration, }, @@ -95,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, @@ -108,13 +124,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/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 8fd5bae..b828414 100644 --- a/src/workspace/graph-data.ts +++ b/src/workspace/graph-data.ts @@ -13,15 +13,21 @@ // // 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"; +import { compareBytes } from "../core/bytes.js"; import type { GraphData } from "../core/graph-data.js"; import { GRAPH_DATA_PATH, @@ -32,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; } @@ -56,31 +83,96 @@ 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 { 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 { state: "readable", bytes, data }; +} + +/** + * 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 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 { + const loaded = await loadGraphData(root); + if (loaded.state !== "readable" || loaded.data === null) { + return { state: loaded.state === "absent" ? "absent" : "unreadable" }; } - return { bytes, data: parseGraphData(text) }; + // 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(loaded.data.derivedFiles)].sort(compareBytes), + }; } /** @@ -89,7 +181,7 @@ export async function loadGraphData(root: string): Promise { * 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 d538cf4..86cbad5 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, @@ -61,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 { + 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 @@ -95,17 +108,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([]), @@ -165,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/locate.ts b/src/workspace/locate.ts index 053b3aa..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 @@ -18,6 +26,8 @@ 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"; @@ -29,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: [{ condition: 14, message, file }] }; +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. */ @@ -84,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 ` + ` (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 { @@ -116,6 +155,7 @@ export async function locateWorkspace( located: { root: path.dirname(configPath), configFileName, + configAnchor, configBytes: bytes, }, }; diff --git a/src/workspace/pipeline.ts b/src/workspace/pipeline.ts index f680b9b..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,14 +36,19 @@ 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 { 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"; @@ -49,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"; @@ -68,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. */ @@ -77,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; /** @@ -100,21 +126,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 @@ -129,6 +140,15 @@ export interface WorkspaceContent { * be read (reported as an unparseable source, SPEC 14.20). */ readonly readSource: (rel: string) => Promise; + /** + * 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; /** * Load the journal (SPEC 6.1). Called only when analysis proceeds past * configuration errors — those precede all source analysis (SPEC 14). @@ -151,6 +171,7 @@ export async function analyzeWorkspace( return analyzeWorkspaceContent(configuration, { classification, readSource: (rel) => readSourceBytes(root, rel), + readInvalidSource: (bytes) => readInvalidSourceBytes(root, bytes), loadJournal: () => loadJournal(root), }); } @@ -172,7 +193,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: [] }); @@ -182,6 +203,8 @@ export async function analyzeWorkspaceContent( markdownDestinations: new Set(), specs: [], code: [], + invalidPathSpecs: [], + invalidPathCode: [], graph, textModel, hashes: new Map(), @@ -205,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( @@ -232,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); @@ -245,15 +279,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 } }], + ), + ); } } @@ -268,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") { @@ -279,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 @@ -296,6 +405,8 @@ export async function analyzeWorkspaceContent( markdownDestinations, specs, code, + invalidPathSpecs, + invalidPathCode, graph, textModel, hashes, @@ -332,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. @@ -347,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 { + 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 @@ -354,13 +492,14 @@ async function readSourceBytes( * last-write-wins territory; it was discovered, and its content cannot be * analyzed. */ -function unreadableSourceFinding(rel: string): Finding { - return { - condition: 20, - file: rel, - message: - `unparseable source: the discovered file could not be read — it ` + +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( + 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/refresh.ts b/src/workspace/refresh.ts index 16ee1cc..6dedff8 100644 --- a/src/workspace/refresh.ts +++ b/src/workspace/refresh.ts @@ -6,11 +6,20 @@ // 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 -// 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. +// 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 +// 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 +38,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 +45,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 = @@ -45,17 +53,23 @@ 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; } | { /** - * 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. */ @@ -72,65 +86,150 @@ 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 { +): Promise { 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 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 — 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 + * 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; + }; + +export async function assessWorkspaceRead( + workspace: LoadedWorkspace, + analysis: WorkspaceAnalysis, +): Promise { 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). 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; + ); - if (graphDataMatchesCurrent(stored.bytes, stored.data, build)) { - // Matching data is served as is — no write, nothing modified. + // 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); + 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", - analysis, - graphData: refreshedGraphData(stored.data, build), + graphData: build.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, - ]); - if (writeFindings.length > 0) { - return { kind: "findings", findings: writeFindings }; + 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 () => {} }; } - 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 { + 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 21193fe..9306bfa 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,61 @@ export async function listSessionNames(root: string): Promise { return sortSessionNames(names); } +/** + * SPEC 11.6: every directory entry directly under the review-session + * directory whose name is a well-formed session file 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 { + 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}`); +} + +/** + * 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 { + 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 diff --git a/src/workspace/writes.ts b/src/workspace/writes.ts index 28f6e58..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). @@ -35,6 +39,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 @@ -43,7 +48,13 @@ import type { Finding } 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 { @@ -51,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"; @@ -60,6 +74,50 @@ export async function classifyOccupant( return "other"; } +/** + * 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 { + return classifyOccupant(absoluteOf(root, rel)); +} + +/** + * 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 { + const components = new Set(); + 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) { @@ -96,64 +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 { +): Promise { 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 { - return { - condition: 22, - file: rel, - message: - `symbolic link in a 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: - `replace ${component} with a real directory, or redirect the write ` + - `so no path xspec writes passes through it (SPEC 14.22)`, - }; +/** 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: 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, ): Promise { const unique = [...new Set(rels)].sort(compareBytes); - const findings: Finding[] = []; + const obstructions = new Map(); 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); } /** @@ -244,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, @@ -279,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 { - if ((await symlinkComponentOf(root, rel)) !== null) return; + if ((await obstructedComponentOf(root, rel)) !== null) return; await fsp.rm(absoluteOf(root, rel), { force: true }); } @@ -299,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 { - 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; 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), +// ``/`` 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 +// (`
`, ``, …: 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-.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} */ + 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} */ + 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 `); + 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 `` 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-.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/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..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"); @@ -1332,15 +1386,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, + }), + ), }; } @@ -1518,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/fixtures/conf-md/product.mjs b/test/fixtures/conf-md/product.mjs index 83eed4c..333ef8d 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,111 @@ 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 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 }[]} */ + 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 +824,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 +845,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" && @@ -1445,14 +1594,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, + }), + ), }; } @@ -1542,18 +1772,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 ` (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. `` 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]); @@ -1623,6 +1989,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/fixtures/conf-valid/product.mjs b/test/fixtures/conf-valid/product.mjs index 39d886f..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: ``/`` 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} */ + 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); @@ -995,14 +1106,94 @@ 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.17: "invalid-prop", + "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..d0baa6c --- /dev/null +++ b/test/helpers/adapters/forms.ts @@ -0,0 +1,2187 @@ +// 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 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` +// member, the `root`/`config` anchoring, and the resolved +// 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) +// - 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 +// 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 { + AppliedMappingPair, + AtReport, + AtResolution, + AtSection, + DependencyEdgeKind, + ErrorDocument, + FileView, + Finding, + FindingLocation, + FindingsReport, + InventoryAnchoring, + InventoryConfigurationView, + InventoryCoverageProfileView, + InventoryDerivedEntry, + InventoryGroupDef, + InventoryJournalStatus, + InventoryPolicyRuleView, + InventoryPolicySelector, + InventoryResolvedMap, + InventorySourceEntry, + MarkedBytePath, + OccurrenceRecord, + OccurrenceSource, + OccurrenceSourceNode, + OccurrencesReport, + PathValue, + PreviewDelta, + PreviewDeltaDatum, + PreviewEdit, + PreviewFileEntry, + PreviewReport, + SourceRange, + VersionDocument, + ViewAttributeEntry, + ViewFilesReport, + ViewImportEntry, + ViewNode, + ViewReport, +} from "./model.js"; +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, +} from "./model.js"; +import type { DecodeSite } from "./decode.js"; +import { + at, + describeJsonValue, + expectArray, + expectBoolean, + expectNonEmptyString, + expectNonEmptyStringArray, + expectNonNegativeInteger, + expectObject, + expectString, + expectToken, + 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 { + assertUnavailabilityMarkerForms(doc, context); + 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 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 { + assertUnavailabilityMarkerForms(doc, context); + 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 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 { + assertUnavailabilityMarkerForms(doc, context); + 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) -------------------------------- + +/** + * 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) }; +} + +// --- 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, 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, + context?: string, +): DecodedDatum { + assertUnavailabilityMarkerForms(doc, context); + 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) => { + 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; + }); +} + +/** + * 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[] { + assertUnavailabilityMarkerForms(doc, context); + const site = rootSite("11.6 inventory (findings)", context); + const obj = expectObject(doc, site); + return decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); +} + +/** + * 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 { + assertUnavailabilityMarkerForms(doc, context); + 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"), + ), + }; +} + +// --- 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, + 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 { + assertUnavailabilityMarkerForms(doc, context); + 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 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 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 { + assertUnavailabilityMarkerForms(doc, context); + 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 = [ + "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 { + assertUnavailabilityMarkerForms(doc, context); + 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 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 { + assertUnavailabilityMarkerForms(doc, context); + 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 { + assertUnavailabilityMarkerForms(doc, context); + 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 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 { + assertUnavailabilityMarkerForms(doc, context); + 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. */ +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 { + assertUnavailabilityMarkerForms(doc, context); + 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) ----------------------- + +/** + * 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. + * + * 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, + 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..398e180 100644 --- a/test/helpers/adapters/index.ts +++ b/test/helpers/adapters/index.ts @@ -5,23 +5,38 @@ // // 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 +// 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, // 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) // -// 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 "./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/model.ts b/test/helpers/adapters/model.ts index 136e2c7..6779735 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 = [ @@ -173,28 +174,465 @@ 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[]; } +/** + * 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; +} + +/** + * 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). + */ +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[]; +} + +/** + * 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[]; +} + +/** + * 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[]; +} + +/** + * 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; +} + +/** 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[]; +} + +/** + * 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[]; @@ -257,6 +695,93 @@ 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; +} + +/** + * 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[]; @@ -305,11 +830,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/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/helpers/adapters/query.ts b/test/helpers/adapters/query.ts index a8ee6ad..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 @@ -276,6 +277,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 { @@ -351,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; + 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/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 { + 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 { + 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/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/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/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": , ..., +// { ..., "creationParameters": , "decompositions": , +// ..., // "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 { 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 { + 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 { + await garbleRecordedMember( + absPath, + SESSION_SHAPE.decompositionsKey, + "recorded decompositions", + ); +} 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/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 => { 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/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; + /** 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 | 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, + 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, + members: readonly string[], + label: string, +): Set { + const set = new Set(); + 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, + edges: readonly CoverageOracleEdge[], +): void { + const successors = new Map>(); + 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(); + const visiting = new Set(); + 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(edgeKinds); + const forward = new Map>(); + const backward = new Map>(); + 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([[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/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; + +/** 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>; + /** + * Nodes that may additionally carry `upstream-changed` — the documented + * one-sided-cause tolerance (module header), accepted present or absent. + */ + readonly optionalUpstream: ReadonlySet; + /** + * 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; + /** Nodes present on the current side only (each required `changed`). */ + readonly added: ReadonlySet; + /** Nodes present on the baseline side only (each required `changed`). */ + readonly deleted: ReadonlySet; +} + +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> { + const memo = new Map>(); + const visiting = new Set(); + const resolve = (identity: string): Set => { + 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(); + 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>, + identity: string, + ): Set => memo.get(identity) ?? new Set(); + + // 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(); + const effVisiting = new Set(); + 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>(); + const optionalUpstream = new Set(); + for (const id of kept) { + const categories = new Set(); + 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(["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(["changed"])); + } + return { + required, + optionalUpstream, + originators, + added: new Set(added), + deleted: new Set(deleted), + }; +} 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 (``, `
`, +// `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, ``/`` tags together 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; + /** Every current-graph node's prediction (one entry per node, possibly empty). */ + readonly nodes: ReadonlyMap; + /** The `changed` set — the originating nodes (added created-root included). */ + readonly changed: ReadonlySet; + /** Current identities added by the move: the created target root, if any. */ + readonly added: ReadonlySet; + /** Per-node own-content token sequences, baseline side, baseline identities. */ + readonly beforeOwnTokens: ReadonlyMap; + /** Per-node own-content token sequences, current side, current identities. */ + readonly afterOwnTokens: ReadonlyMap; +} + +// --------------------------------------------------------------------------- +// 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 ")) { + 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: `` }; +} + +/** + * 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; + /** Identity → declared `d` targets, this document's sections. */ + readonly depends: Map; +} + +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(); + const depends = new Map(); + const runs = new Map(); + 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, +): Map> { + const memo = new Map>(); + const visiting = new Set(); + const resolve = (identity: string): Set => { + 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(); + 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(); + 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(); + const mappedBefore = new Map(); + const mappedBeforeGraph = new Map(); + 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(); + const afterGraph = new Map(); + 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(); + 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(identityMap.values()); + candidates.add(originParent); + if (targetParent !== null) candidates.add(targetParent); + const changed = new Set(); + 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>, + identity: string, + ): Set => memo.get(identity) ?? new Set(); + 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>(); + const effVisiting = new Set(); + const effCauses = (identity: string): ReadonlySet => { + 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(); + 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 => { + const causes = new Set(); + 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(); + 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(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(); + 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/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/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 3781859..9e2317c 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", @@ -256,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). 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/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/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index aaeb361..f72eaa9 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -20,35 +20,62 @@ 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, ViewReport } from "../helpers/adapters/index.js"; import { + GRAPH_DATA_AREA_PATH, ITEM_STATUSES, + RECORD_GARBAGE_BYTES, + assertBareEdgeEndpoints, assertJsonKeysByteSorted, + assertNodeEdgeListsBare, assertReportMentions, + assertUnavailabilityMarkerForms, classifyIgnoredReasons, + compareFindings, conditionMention, + corruptGraphDataShapeBlind, + decodeAppliedMappingReport, + decodeAtReport, decodeCoverageReport, + decodeDatum, decodeEdgesReport, + decodeErrorDocument, decodeExportReport, decodeFindingsReport, decodeIdsReport, decodeIdsTreeReport, decodeImpactReport, + decodeInventoryAnchoring, + decodeInventoryDocument, + decodeInventoryFindings, + decodeInventoryRecordedDatum, + decodeInventoryResolvedMap, decodeItemReport, decodeNextReport, decodeNodeMetadataSummary, decodeNodeReport, + decodeNodeIdentityRowsReport, + decodeOccurrencesReport, decodeNodeRowsReport, decodeNodeSummary, decodeNodeSummaryRowsReport, decodeNodeTextSummary, + decodePreviewReport, decodeReachableReport, decodeSessionListReport, decodeSessionStatusReport, + decodeVersionDocument, + decodeViewFilesReport, + decodeViewReport, + expectNonNegativeInteger, + isGraphDataKey, + rootSite, stageBlockedByAbsentItem, stageBlockedByCycle, stageDeleteItemField, stageDuplicateItemEntry, stageGarbleCreationParameters, + stageGarbleDecompositions, stageUnknownItemStatus, } from "../helpers/adapters/index.js"; import { TestWorkspace } from "../helpers/workspace.js"; @@ -207,24 +234,275 @@ 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 <S id="validCredentials"> nested inside login', - file: "specs/A.mdx", - location: { start: 40, end: 78 }, + code: "invalid-structural-id", // 14.2 + message: 'expected <S id="login.validCredentials"> 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: [], + }, + ], +}; + +// 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_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: [], + }, + ], +}; + +// 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: [], }, ], }; @@ -275,6 +553,73 @@ 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" }, + ], +}; + +// 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: [ { @@ -345,6 +690,83 @@ 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", +}; + +// 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 { @@ -601,6 +1023,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, @@ -712,119 +1170,2124 @@ const DECODERS: readonly DecoderSpec[] = [ { label: "entry missing file", doc: omit(GOOD_IDS, "files", 0, "file") }, { label: "entry missing ids", doc: omit(GOOD_IDS, "files", 1, "ids") }, { - label: "ids not an array", - doc: put(GOOD_IDS, "login", "files", 0, "ids"), + label: "ids not an array", + doc: put(GOOD_IDS, "login", "files", 0, "ids"), + }, + { + label: "empty id", + doc: put(GOOD_IDS, ["login", ""], "files", 0, "ids"), + }, + ], + }, + { + name: "ids --tree", + decode: decodeIdsTreeReport, + good: GOOD_IDS_TREE, + verify: (decoded: ReturnType<typeof decodeIdsTreeReport>) => { + expect(decoded.files[0].nodes[0].id).toBe("login"); + expect(decoded.files[0].nodes[0].children[0].id).toBe( + "login.validCredentials", + ); + expect(decoded.files[0].nodes[0].children[0].children).toEqual([]); + }, + bad: [ + { label: "missing files list", doc: {} }, + { + label: "node missing id", + doc: omit(GOOD_IDS_TREE, "files", 0, "nodes", 0, "id"), + }, + { + label: "node missing children", + doc: omit(GOOD_IDS_TREE, "files", 0, "nodes", 0, "children"), + }, + { + label: "nested node missing children", + doc: omit( + GOOD_IDS_TREE, + "files", + 0, + "nodes", + 0, + "children", + 0, + "children", + ), + }, + { + label: "children not an array", + doc: put(GOOD_IDS_TREE, {}, "files", 0, "nodes", 0, "children"), + }, + ], + }, + { + name: "12.7 findings report", + decode: decodeFindingsReport, + good: GOOD_FINDINGS, + verify: (decoded: ReturnType<typeof decodeFindingsReport>) => { + 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[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<typeof decodeFindingsReport>): 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<typeof decodeFindingsReport>): void => { + expect(decoded.findings[0]!.path).toEqual({ bytes: "ff2f61" }); + }, + }, + ], + bad: [ + { label: "missing findings list", doc: {} }, + { + label: "null findings (null never encodes emptiness, SPEC 12.7)", + doc: { findings: null }, + }, + { + label: "an extra member on the report (12.7: exactly {findings})", + doc: { findings: [], summary: "3 errors" }, + }, + { + label: "finding missing its code member (null is never omitted)", + doc: omit(GOOD_FINDINGS, "findings", 0, "code"), + }, + { + 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", + doc: omit(GOOD_FINDINGS, "findings", 1, "message"), + }, + { + label: "empty message", + doc: put(GOOD_FINDINGS, "", "findings", 1, "message"), + }, + { + 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, + "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: "path string carrying a lone surrogate (no UTF-8 bytes)", + doc: put(GOOD_FINDINGS, "\ud800", "findings", 3, "path"), + }, + { + label: "finding missing identities", + doc: omit(GOOD_FINDINGS, "findings", 2, "identities"), + }, + { + 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]), + ], + }, + }, + ], + }, + { + 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 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 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, + 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, + good: { + error: { + code: "configuration-error", // 14.14 + message: "unknown key `bogus` in xspec.config.ts", + locations: [], + path: "xspec.config.ts", + identities: [], + }, + }, + verify: (decoded: ReturnType<typeof decodeErrorDocument>) => { + // {"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<typeof decodeErrorDocument>): 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<typeof decodeErrorDocument>): 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", + }, + }, + }, + ], + }, + { + // 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`, + // 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" }] }, + }, + { + 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"] }, + }, + ], + }, + { + // 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 }, + }, + }, + ], + }, + { + // 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" }, + }, + ], + }, + { + // 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: "empty id", - doc: put(GOOD_IDS, ["login", ""], "files", 0, "ids"), + label: "a selector mixing the group and files forms", + doc: put( + GOOD_RESOLVED_INVENTORY, + { group: "aux", kind: "spec", files: "src/**" }, + "configuration", + "policy", + 0, + "from", + ), }, - ], - }, - { - name: "ids --tree", - decode: decodeIdsTreeReport, - good: GOOD_IDS_TREE, - verify: (decoded: ReturnType<typeof decodeIdsTreeReport>) => { - expect(decoded.files[0].nodes[0].id).toBe("login"); - expect(decoded.files[0].nodes[0].children[0].id).toBe( - "login.validCredentials", - ); - expect(decoded.files[0].nodes[0].children[0].children).toEqual([]); - }, - bad: [ - { label: "missing files list", doc: {} }, { - label: "node missing id", - doc: omit(GOOD_IDS_TREE, "files", 0, "nodes", 0, "id"), + 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: "node missing children", - doc: omit(GOOD_IDS_TREE, "files", 0, "nodes", 0, "children"), + 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: "nested node missing children", - doc: omit( - GOOD_IDS_TREE, - "files", - 0, - "nodes", - 0, - "children", + 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, - "children", + "path", ), }, { - label: "children not an array", - doc: put(GOOD_IDS_TREE, {}, "files", 0, "nodes", 0, "children"), + 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: "build/check findings", - decode: decodeFindingsReport, - good: GOOD_FINDINGS, - verify: (decoded: ReturnType<typeof decodeFindingsReport>) => { - expect(decoded.findings).toHaveLength(3); - 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", + // 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: "missing findings list", doc: {} }, { - label: "finding missing condition", - doc: omit(GOOD_FINDINGS, "findings", 0, "condition"), + 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: "condition not a 14.<n> identity", - doc: put(GOOD_FINDINGS, "oops", "findings", 0, "condition"), + label: "absent graphData member (null is never omission, SPEC 12.7)", + doc: omit(GOOD_INVENTORY_DOCUMENT, "graphData"), }, { - label: "condition outside section 14", - doc: put(GOOD_FINDINGS, "15.1", "findings", 0, "condition"), + label: "absent journal member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "journal"), }, { - label: "condition 14.0 (no such condition)", - doc: put(GOOD_FINDINGS, "14.0", "findings", 0, "condition"), + label: + 'journal without occupied (the member form is {"path", ' + + '"occupied"} exactly, SPEC 12.7)', + doc: omit(GOOD_INVENTORY_DOCUMENT, "journal", "occupied"), }, { - label: "finding missing message", - doc: omit(GOOD_FINDINGS, "findings", 1, "message"), + label: "journal with an extra member", + doc: put(GOOD_INVENTORY_DOCUMENT, 3, "journal", "lines"), }, { - label: "empty message", - doc: put(GOOD_FINDINGS, "", "findings", 1, "message"), + label: "a stringly-typed occupied", + doc: put(GOOD_INVENTORY_DOCUMENT, "false", "journal", "occupied"), }, { - label: "malformed location", - doc: put( - GOOD_FINDINGS, - { start: 78, end: 40 }, - "findings", - 0, - "location", - ), + label: "absent sessions member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "sessions"), }, { - label: "wrong-typed file (must reject, not default)", - doc: put(GOOD_FINDINGS, 9, "findings", 0, "file"), + label: "null sessions (an empty list is [], never null, SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, null, "sessions"), }, { - label: "edge with unknown kind", - doc: put(GOOD_FINDINGS, "dependz", "findings", 1, "edge", "kind"), + 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: "cycle with empty identity", - doc: put(GOOD_FINDINGS, [""], "findings", 2, "cycle"), + label: "absent recorded member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "recorded"), }, ], }, @@ -981,6 +3444,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, @@ -1131,6 +3645,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") }, @@ -1164,6 +3705,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") }, { @@ -1262,6 +3808,295 @@ 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>): 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", + ), + ); +}); + +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", () => { + 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 { @@ -1365,6 +4200,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: [], @@ -1492,6 +4328,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 @@ -1503,6 +4361,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); @@ -1590,6 +4449,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 () => { @@ -1616,6 +4480,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/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/); +}); 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/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/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/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/); +}); 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/suite/registry/index.ts b/test/suite/registry/index.ts index cab4071..c39e1a8 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -33,12 +33,14 @@ 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"; 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"; @@ -54,10 +56,17 @@ 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 { 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"; 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"; @@ -72,6 +81,9 @@ 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"; +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. @@ -95,12 +107,14 @@ export const productTestSuite = new ProductTestSuite([ ...section54Tests, ...section55Tests, ...section56Tests, + ...section57Tests, ...section61Tests, ...section62Tests, ...section63Tests, ...section64Tests, ...section65Tests, ...section66Tests, + ...section67Tests, ...section7BasicsTests, ...section7DiscoveryTests, ...section71to73Tests, @@ -116,10 +130,17 @@ export const productTestSuite = new ProductTestSuite([ ...section107iTests, ...section107iiTests, ...section11Tests, + ...section112Tests, + ...section113Tests, + ...section114Tests, + ...section115Tests, + ...section116Tests, ...section120iTests, ...section120iiTests, ...section121to122Tests, ...section123to125Tests, + ...section126Tests, + ...section127Tests, ...section131to132Tests, ...section133Tests, ...section134Tests, @@ -134,4 +155,7 @@ export const productTestSuite = new ProductTestSuite([ ...section16P8Tests, ...section16P9Tests, ...section16P10Tests, + ...section16P11Tests, + ...section16P12Tests, + ...section16P13Tests, ]); diff --git a/test/suite/registry/section-1.3.ts b/test/suite/registry/section-1.3.ts index 83b8717..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 @@ -303,18 +304,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)})`, + ); + } } } @@ -375,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"; @@ -422,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); + } }, }); 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..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), @@ -14,11 +15,23 @@ // 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, + OccurrenceRecord, + OccurrenceSourceNode, + SourceRange, +} from "../../helpers/adapters/index.js"; import { + assertBareEdgeEndpoints, + assertNodeEdgeListsBare, + decodeEdgesReport, decodeImpactReport, decodeNextReport, decodeNodeReport, + decodeOccurrencesReport, + decodeReachableReport, decodeSessionStatusReport, } from "../../helpers/adapters/index.js"; import { @@ -39,6 +52,7 @@ import { import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertSameJson, buildFindings, buildOk, @@ -959,16 +973,21 @@ const BOM = "\u{FEFF}"; const VALID_SECTION_SOURCE = '<S id="ok">\nValid content.\n</S>\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 +995,8 @@ function assertUnparseableFinding( JSON.stringify( findings.map((finding) => ({ condition: finding.condition, - file: finding.file ?? null, + locations: finding.locations, + path: finding.path, })), ), ); @@ -1086,10 +1106,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: { @@ -1155,6 +1220,605 @@ 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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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(); + } }, }); @@ -1166,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/section-10.1.ts b/test/suite/registry/section-10.1.ts index c583c39..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 @@ -47,8 +56,15 @@ // 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 { + Finding, + SessionStatusRow, +} from "../../helpers/adapters/index.js"; import { assertReportMentions, decodeFindingsReport, @@ -59,12 +75,12 @@ import { stageDeleteItemField, stageDuplicateItemEntry, stageGarbleCreationParameters, + stageGarbleDecompositions, stageUnknownItemStatus, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -78,8 +94,11 @@ import { import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { + assertConditionCounts, + assertFindingLocated, assertSameJson, buildOk, + expectErrorDocument, expectExit, runCli, runJson, @@ -412,9 +431,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 +489,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 +589,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 +709,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); @@ -755,10 +778,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, @@ -774,24 +824,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. */ @@ -928,7 +966,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 --- @@ -967,6 +1005,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) => { @@ -1070,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/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.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`, + ); + }, + ); }, }); 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)), ); } 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..27926e3 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, @@ -84,16 +90,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" @@ -586,6 +594,7 @@ function payloadProjection(item: ReviewItem): unknown { node: entry.node, before: entry.before, after: entry.after, + sourceRange: entry.sourceRange, })), }; } @@ -595,8 +604,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. */ @@ -632,22 +644,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. */ @@ -682,8 +685,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), ); } @@ -779,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` @@ -885,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 @@ -926,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 ----------------- @@ -1057,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"; @@ -1072,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, @@ -1126,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, { @@ -1162,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 }, @@ -1171,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, @@ -1201,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`, + ); }, ); }, @@ -1400,10 +1631,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 +2327,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 +2377,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 +2398,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)`, ); } @@ -2533,8 +2765,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"; @@ -2547,7 +2783,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."; @@ -2644,13 +2882,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 @@ -2715,7 +2970,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 ------- @@ -2744,7 +2999,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"); @@ -2757,11 +3013,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). @@ -2827,13 +3085,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 = @@ -3140,10 +3399,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, @@ -3151,12 +3415,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), @@ -3207,6 +3473,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/section-11.2.ts b/test/suite/registry/section-11.2.ts new file mode 100644 index 0000000..8a5c55f --- /dev/null +++ b/test/suite/registry/section-11.2.ts @@ -0,0 +1,4100 @@ +// TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1 +// 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), +// 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 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, 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; +// 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 +// 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 * as fsp from "node:fs/promises"; +import type { + Finding, + OccurrenceRecord, + PathValue, + SourceRange, + ViewAttributeEntry, + ViewImportEntry, + ViewNode, +} from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeFindingsReport, + 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 { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { runProduct } from "../../helpers/subprocess.js"; +import type { ArgvValue, 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. +// 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: { + 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 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 { + 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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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). +export 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 ------------- +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; +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"); +export 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). +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'); +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"); +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` +// 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). +export 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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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). + +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; +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"); +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`; +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"). +export 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. +export 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(); + } + } + }, +}); + +// --------------------------------------------------------------------------- +// 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). + * 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). 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 ArgvValue[], + context: string, +): Promise<void> { + 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 ` + + `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(); + } + } + }, +}); + +// --------------------------------------------------------------------------- +// 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, + T11_2_2, + T11_2_3, + T11_2_4, + T11_2_5, + T11_2_6, +]; diff --git a/test/suite/registry/section-11.3.ts b/test/suite/registry/section-11.3.ts new file mode 100644 index 0000000..bc20821 --- /dev/null +++ b/test/suite/registry/section-11.3.ts @@ -0,0 +1,2013 @@ +// 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), +// 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. +// +// 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). +// +// 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. +// +// 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 { + Finding, + 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 { assertLeavesUnchanged } from "../../helpers/snapshot.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, + expectAvailabilityUsageError, + 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, + assertFindingLocated, + assertSameJson, + buildFindings, + 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(); + } + } + }, +}); + +// --------------------------------------------------------------------------- +// 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(); + } + } + }, +}); + +// --------------------------------------------------------------------------- +// 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(); + } + } + }, +}); + +// --------------------------------------------------------------------------- +// 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/section-11.4.ts b/test/suite/registry/section-11.4.ts new file mode 100644 index 0000000..5ec2e07 --- /dev/null +++ b/test/suite/registry/section-11.4.ts @@ -0,0 +1,3779 @@ +// 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), +// 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). +// +// 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. +// +// 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). +// +// 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. +// +// 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. +// +// 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 +// 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 +// 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). 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-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, 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 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, + ViewAttributeEntry, + ViewImportEntry, + ViewNode, +} from "../../helpers/adapters/index.js"; +import { decodeViewReport } from "../../helpers/adapters/index.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"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + expectAvailabilityUsageError, + SPEC_AND_CODE_CONFIG, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; +import { + assertConditionCounts, + assertFindingLocated, + assertSameJson, + buildFindings, + buildOk, + expectExit, + runJson, +} 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, 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[] = []; + 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 | 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 + * 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.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 ` + + `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(); + } + }, +}); + +// --- 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(); + } + }, +}); + +// --- 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(); + } + }, +}); + +// --- 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(); + } + }, +}); + +// --- 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(); + } + } + }, +}); + +// ============================================================================= +// 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/section-11.5.ts b/test/suite/registry/section-11.5.ts new file mode 100644 index 0000000..b144222 --- /dev/null +++ b/test/suite/registry/section-11.5.ts @@ -0,0 +1,1582 @@ +// 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), +// 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, + Finding, + OccurrenceRecord, + SourceRange, + ViewNode, +} from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + 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 { assertLeavesUnchanged } from "../../helpers/snapshot.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 { + expectAvailabilityUsageError, + SPEC_AND_CODE_CONFIG, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; +import { + assertConditionCounts, + assertFindingLocated, + assertSameJson, + buildFindings, + expectExit, + runCli, + 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. 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"; +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); +F.add("\n"); // blank line: each import must start its own MDX block +const IMPORT_ONE = F.add(IMPORT_ONE_TEXT); +F.add("\n\n"); +const IMPORT_TWO = F.add(IMPORT_TWO_TEXT); +F.add("\n\n"); // blank line: the comment is a flow expression block +const COMMENT_TOP = F.add(COMMENT_TOP_TEXT); +F.add("\n\n"); +const A_OPEN = F.add(A_OPEN_TEXT); +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\n"); +const ABC_OPEN = F.add(ABC_OPEN_TEXT); +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\n"); +F.add(CLOSE_TEXT); +const ABC_RANGE: SourceRange = { start: ABC_OPEN.start, end: F.pos }; +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\n"); +const A_CLOSE = F.add(CLOSE_TEXT); +const A_RANGE: SourceRange = { start: A_OPEN.start, end: F.pos }; +F.add("\n\n"); +const PROSE_BETWEEN = F.add(PROSE_BETWEEN_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"); +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\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) ----------------------- + +/** + * 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(); + } + }, +}); + +// --- 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(); + } + }, +}); + +// --- 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/section-11.6.ts b/test/suite/registry/section-11.6.ts new file mode 100644 index 0000000..caa184c --- /dev/null +++ b/test/suite/registry/section-11.6.ts @@ -0,0 +1,2316 @@ +// 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), +// 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 (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` (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 +// 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). +// +// 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. +// +// 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. +// +// 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, 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"; +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, + corruptGraphDataShapeBlind, + decodeInventoryAnchoring, + decodeInventoryDocument, + decodeInventoryFindings, + decodeInventoryResolvedMap, + 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 { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertFindingConcernsPath, + assertSameJson, + buildFindings, + buildOk, + expectConfigurationError, + expectErrorDocument, + expectExit, +} from "./support.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(); + } + }, +}); + +// --- 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(); + } + }, +}); + +// --- 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 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, + 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(); + } + }, +}); + +// --- 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/section-11.ts b/test/suite/registry/section-11.ts index cf3d234..3dc625b 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<T>(options: BothFormsOptions<T>): Promise<T> { } /** - * 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)`, ); } @@ -1430,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>'; @@ -1461,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, @@ -1577,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`", + ); }, ); }, diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index 3c909d3..c144393 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 <path>` (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 @@ -22,15 +23,29 @@ // // 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 +// 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 @@ -42,7 +57,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 // `<node>`/`<graph-node>`/`<file>` arguments — resolved against the cwd // each would name a nonexistent file and exit 2 — and content for `--file`, @@ -65,6 +85,7 @@ import { decodeReachableReport, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertExitCode, assertStdoutEmpty, fail, @@ -84,6 +105,7 @@ import { assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, runCli, runJson, @@ -187,6 +209,14 @@ 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 (same information; byte-identity not asserted — see the module + * header). + */ + readonly jsonOnly?: true; } /** A harvested id the story guarantees is set by the time it is consumed. */ @@ -239,11 +269,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: () => [ @@ -254,7 +296,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: () => [ @@ -273,6 +324,7 @@ const SWEEP_STEPS: readonly SweepStep[] = [ what: "review export", argv: () => ["review", "export", SWEEP_SESSION], harvest: harvestItemIds, + jsonOnly: true, }, { what: "review show", @@ -333,10 +385,40 @@ 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, 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 @@ -364,6 +446,42 @@ 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)`, + ); + 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)`, + ); + 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: 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); } } @@ -375,12 +493,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 — 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(); 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(); } @@ -416,10 +540,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: { @@ -461,19 +587,36 @@ 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)}`, ); } + 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: { @@ -495,6 +638,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( @@ -504,12 +652,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, @@ -519,16 +678,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: { @@ -553,6 +714,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); }, ); @@ -717,10 +883,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)`, ); }, }); @@ -755,7 +921,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`"; @@ -786,7 +956,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. @@ -939,10 +1113,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 @@ -960,10 +1134,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)`, ); } @@ -1097,10 +1271,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)`, ); }, ); @@ -1239,10 +1413,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)`, ); } }, @@ -1315,10 +1489,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.0-ii.ts b/test/suite/registry/section-12.0-ii.ts index aabfd78..d69732b 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-13. // -// 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), @@ -47,9 +46,29 @@ // - 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-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 @@ -58,17 +77,45 @@ // 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 } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertExitCode, fail, parseJsonStdout, @@ -81,11 +128,12 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertDirectoriesEqual, + assertLeavesUnchanged, diffSnapshots, 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, @@ -97,9 +145,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, @@ -825,6 +877,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 +894,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 +903,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 +1137,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 +1191,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 +1281,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 +1353,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 +1368,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 +1390,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, + }, ]); }, ); @@ -1349,6 +1552,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 // --------------------------------------------------------------------------- @@ -1621,6 +2276,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"], @@ -1717,17 +2382,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( @@ -1764,10 +2442,540 @@ 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, T12_0_9, + T12_0_10, T12_0_11, T12_0_12, + T12_0_13, ]; diff --git a/test/suite/registry/section-12.1-12.2.ts b/test/suite/registry/section-12.1-12.2.ts index ba3b0f3..2faf844 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, @@ -201,11 +245,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 +269,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,15 +287,73 @@ 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)), ); } } +/** + * 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( { @@ -965,15 +1332,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)}`, ); } }, @@ -982,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( { @@ -996,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)", ); }, ); diff --git a/test/suite/registry/section-12.3-12.5.ts b/test/suite/registry/section-12.3-12.5.ts index 3ae9f99..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 @@ -61,7 +61,13 @@ // (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). 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. @@ -74,17 +80,21 @@ 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"; -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 +102,7 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -142,9 +153,10 @@ async function withWorkspace<T>( } /** - * 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 +172,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)`, ); } @@ -907,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 = [ @@ -924,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: { @@ -1018,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, diff --git a/test/suite/registry/section-12.6.ts b/test/suite/registry/section-12.6.ts new file mode 100644 index 0000000..605e248 --- /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 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/section-12.7.ts b/test/suite/registry/section-12.7.ts new file mode 100644 index 0000000..83804ca --- /dev/null +++ b/test/suite/registry/section-12.7.ts @@ -0,0 +1,2330 @@ +// 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), +// 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 is +// 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 +// 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. +// +// 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. +// +// 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 { + Finding, + OccurrenceRecord, + PathValue, + SourceRange, + ViewNode, + ViewReport, +} from "../../helpers/adapters/index.js"; +import { + assertUnavailabilityMarkerForms, + decodeAtReport, + decodeFindingsReport, + decodeInventoryResolvedMap, + decodeOccurrencesReport, + decodeVersionDocument, + 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, 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 { + assertConditionCounts, + assertFindingMentionsLocation, + assertSameJson, + buildFindings, + buildOk, + expectErrorDocument, + 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-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-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 +// --------------------------------------------------------------------------- + +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); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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); + }, +}); + +// --------------------------------------------------------------------------- +// 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, + T12_7_3, +]; diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index 1e68120..2c06baf 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -50,23 +50,87 @@ // 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-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 +// 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 { assertBytesEqual, @@ -93,6 +157,7 @@ import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertFindingConcernsPath, assertFindingLocated, assertSameJson, buildOk, @@ -139,19 +204,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). - */ -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( @@ -183,9 +243,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 +262,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> { @@ -365,6 +427,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 // --------------------------------------------------------------------------- @@ -383,7 +511,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 }, @@ -590,6 +718,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, " + @@ -610,22 +805,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( { @@ -690,15 +904,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 () => { @@ -832,6 +1041,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 @@ -874,7 +1139,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 () => { @@ -945,9 +1210,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)`, ); }, }, @@ -1037,6 +1310,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; @@ -1249,6 +1593,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)}`, + ); + } + }, + ); }, }); @@ -1279,20 +1958,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)), ); } } @@ -1318,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( { @@ -1488,7 +2431,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 +2442,7 @@ const T13_3_3 = defineProductTest({ JSON.stringify( findings.map((finding) => ({ condition: finding.condition, - file: finding.file, + locations: finding.locations, })), ), ); @@ -1544,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, + ); + }, + ); }, }); diff --git a/test/suite/registry/section-13.4.ts b/test/suite/registry/section-13.4.ts index 69e79e1..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 @@ -51,6 +52,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 @@ -68,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"; @@ -105,7 +144,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, @@ -266,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, @@ -1136,6 +1188,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 +1215,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 +1324,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)", ); }, ); @@ -1488,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, @@ -1496,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/section-13.5.ts b/test/suite/registry/section-13.5.ts index dcf174d..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 @@ -146,7 +160,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 +192,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 +204,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 +250,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 +264,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[], @@ -311,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 diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index bc4979a..98e91dc 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -1,13 +1,22 @@ // TEST-SPEC §14 (validation errors: the reporting contract) — SUITE-49: -// T14-1 … T14-5. +// 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 // 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) — 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) — 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) — 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), @@ -51,14 +60,126 @@ // - 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. +// - 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. +// - 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. +// - 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"; import { + CONDITION_CODE_TOKENS, assertReportMentions, + corruptGraphDataShapeBlind, + decodeAtReport, decodeEdgesReport, decodeFindingsReport, + decodeInventoryFindings, + decodeOccurrencesReport, + decodePreviewReport, + decodeViewFilesReport, } from "../../helpers/adapters/index.js"; import { assertExitCode, @@ -74,15 +195,34 @@ 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, byteWindow, expectConfigurationError, + expectErrorDocument, expectExit, runCli, runJson, @@ -172,6 +312,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). @@ -462,7 +620,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 +778,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 +804,10 @@ function assertMaskingReport( const filesOf = (condition: string): string[] => findings .filter((finding) => finding.condition === condition) - .map((finding) => finding.file ?? "<no file>") + .map((finding) => { + const file = finding.locations[0]?.file; + return typeof file === "string" ? file : "<no location>"; + }) .sort(); assertSameJson( filesOf("14.5"), @@ -748,6 +915,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. */ @@ -758,6 +940,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" }, }; } @@ -773,6 +956,7 @@ function codeArm(condition: string, label: string, source: string): SweepEntry { "src/app.ts": source, }, }, + answers: { kind: "code-source" }, }; } @@ -861,6 +1045,7 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ ].join("\n"), }, }, + answers: { kind: "code-source" }, }, { condition: "14.13", @@ -875,10 +1060,12 @@ 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); }, + answers: { kind: "no-domain-file", file: "specs/a.mdx" }, }, specArm( "14.15", @@ -921,6 +1108,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'), { @@ -942,9 +1134,130 @@ export default defineConfig({ dirs: ["real-out"], symlinks: { out: "real-out" }, }, + answers: { kind: "no-domain-file", file: "specs/a.mdx" }, }, ]; +// --------------------------------------------------------------------------- +// 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 + * 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[], @@ -973,213 +1286,316 @@ 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 - // 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" + // 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)", + ); + }); -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( + // --- 14.21: reported by `check`, by `review` subcommands naming the + // 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)`, + ); + 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.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( + "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.12) `check --json`", + "T14-4 (14.21, failing workspace) `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)", - ); - await workspace.file( - ".xspec/reviews/bad.json", - "{ this is not a parseable session", - ); - await expectExit( + ), + { "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, - ["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)", + 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( - await checkFindings( + 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). 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, - "T14-4 (14.21) `check --json`", + ["inventory"], + 1, + `${inventoryContext} — the condition-23 finding accompanies ` + + `the answer and the invocation exits 1 (SPEC 14.23, 11.6)`, ), - { "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, + 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, - `${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)`, - ); - } - }, - ); + `${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)", + ); + }); // --- 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)", - ); - }, - ); + ["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); @@ -1199,8 +1615,92 @@ 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)`, + ); + } + }); + } }, }); @@ -1350,11 +1850,1175 @@ 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); + } + }); + }, +}); + +// --------------------------------------------------------------------------- +// 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)", + ); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// 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, T14_3, T14_4, T14_5, + T14_6, + T14_7, + T14_8, ]; 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-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-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/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/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, 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..d036a61 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,35 +46,97 @@ // 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 -// 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). +// 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. +// +// 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 // 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 +// (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 -// 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 +174,23 @@ 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, + 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 +201,7 @@ import type { BodyItem, Edit, EditClass, + ProseItem, RefModel, SectionItem, WorkspaceModel, @@ -124,6 +210,9 @@ import { applyEdit, genEditOfClass, genWorkspaceModel, + refIdentity, + renderOpenTag, + renderRef, renderWorkspace, semanticsOf, } from "./section-16-p4.js"; @@ -151,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; @@ -175,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 [ @@ -193,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)], ), @@ -212,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 { @@ -800,71 +680,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 +910,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 +1078,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 +1114,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 +1164,7 @@ function moveCandidates( } consider(site.file, site.dotted, ancestorKeys); } - return candidates; + return { candidates, createdOk: maxOut < minIn }; } const genSectionMoveTrial: Gen<SectionMoveTrial> = (choices) => { @@ -1223,7 +1191,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 +1200,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 +1762,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 +1781,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(); @@ -1501,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"}`; @@ -1529,9 +2029,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 +2055,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 +2082,7 @@ const P_5 = defineProductTest({ async (trial) => { await runSectionMoveTrial(product, trial); }, - { runs: 5, maxShrinkExecutions: 80, render: renderSectionMoveTrial }, + { runs: 8, maxShrinkExecutions: 80, render: renderSectionMoveTrial }, ); }, }); diff --git a/test/suite/registry/section-16-p7.ts b/test/suite/registry/section-16-p7.ts index dc04d7e..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) }], ]), ); } @@ -974,13 +1055,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 ?? "<no rule>"} :: ` + - (finding.edge === undefined - ? "<no edge>" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`) - ); + if (finding.identities.length !== 4) { + return `<malformed 14.12 identities> ${JSON.stringify(finding.identities)}`; + } + const [rule, from, kind, to] = finding.identities; + return `${rule} :: ${kind}: ${from} -> ${to}`; } function renderCaptureTrial(trial: CaptureTrial): string { @@ -1065,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. diff --git a/test/suite/registry/section-16-p8.ts b/test/suite/registry/section-16-p8.ts index 129a741..13662d1 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 @@ -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/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-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(); } 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); 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)", + ); + }, + ); }, }); 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-5.7.ts b/test/suite/registry/section-5.7.ts new file mode 100644 index 0000000..8897c11 --- /dev/null +++ b/test/suite/registry/section-5.7.ts @@ -0,0 +1,1639 @@ +// 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), +// 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; 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' +// 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 { + DependencyEdgeKind, + Finding, + GraphEdge, + OccurrenceRecord, + OccurrenceSourceNode, + SourceRange, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeOccurrencesReport, + renderPathValue, +} 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 { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertEdgeSetEqual, + assertFindingLocated, + assertSameJson, + buildFindings, + buildOk, + byteWindow, + expectExit, + 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. +export 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. +export 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. +export 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). +export 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"); + +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"; +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. */ +export 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. +// +// 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, + 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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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. +export 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"; +export 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"; +export 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. + */ +export 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. +// +// 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 " + + "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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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). + +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"; +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)}"; +export 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; +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 +// 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"; +export 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. */ +export 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). 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 " + + "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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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. + +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 +// 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"; +export 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"; +export 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. +// +// 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", + 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). +export 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/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.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..b73b8cc 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 @@ -34,6 +40,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 @@ -43,14 +59,37 @@ // - 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`. -// - 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). +// 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 +// 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). 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 @@ -63,6 +102,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, @@ -70,7 +110,6 @@ import { } from "../../helpers/adapters/index.js"; import { assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -80,13 +119,19 @@ 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 { + assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertFindingMentionsLocation, + assertFindingNamesIdentity, assertSameJson, buildFindings, buildOk, + byteWindow, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -293,41 +338,97 @@ 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. 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). + */ +export 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)`, ); } /** - * A rename usage error (SPEC 6.4, 12.0: nonexistent `<file>` 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). + * 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, @@ -341,14 +442,14 @@ 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)`, ); - 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( @@ -478,7 +579,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, @@ -520,12 +621,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 @@ -959,14 +1084,111 @@ 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, +); + +/** + * 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) (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, - { [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). @@ -975,41 +1197,17 @@ const T6_4_3 = defineProductTest({ workspace, "T6.4-3 `build` over the staged workspace", ); - - const cases: readonly (readonly [string, string])[] = [ - [ - "a.then", - "new ID invalid per 1.4 — its segment is the forbidden name `then`", - ], - [ - "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"], - [ - "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) { + // 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"], - "T6.4-3 (new ID violating the structural parent rules — a " + - "top-level section's ID has exactly one segment, 1.3)", - ); }, ); }, @@ -1049,37 +1247,134 @@ 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"); + +/** + * 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: - "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\``); - 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`, - ); + 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( - SPECS_ONLY_CONFIG, - { [U4_FILE]: U4_SOURCE, [U4_BAD_FILE]: U4_BAD_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 @@ -1098,20 +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)`, - ); + 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)`, + ); + } }, ); @@ -1150,6 +1441,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( + 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 + // 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_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 ` + + `(SPEC 6.4, 11.2, 12.0)`, + ); + }, + ); }, }); @@ -1273,7 +1654,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, @@ -1287,10 +1668,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 d895257..44990c1 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), @@ -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, @@ -60,19 +73,88 @@ // 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 -// 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-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 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 +// (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 — 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 +// 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 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-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 @@ -83,6 +165,7 @@ import { Buffer } from "node:buffer"; import type { GraphEdge } from "../../helpers/adapters/index.js"; import { + decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, decodeNodeRowsReport, @@ -91,7 +174,6 @@ import { assertBytesEqual, assertExitCode, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -109,13 +191,20 @@ import type { RunResult, } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; import { + assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, + assertFindingConcernsPath, assertFindingLocated, + assertFindingMentionsLocation, + assertFindingNamesIdentity, assertSameJson, buildFindings, buildOk, + byteWindow, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -132,6 +221,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" @@ -351,64 +455,159 @@ function renderArgv(argv: readonly ArgvValue[]): string { .join(" "); } +/** + * 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). + * 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). + */ +export 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). - * Accepts raw-byte argv elements for the Linux-leg non-UTF-8 destination arm. + * 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 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 ArgvValue[], + argv: readonly string[], + expected: RefusalExpectation | readonly RefusalExpectation[], context: string, ): Promise<void> { - const command = renderArgv(argv); + const expectations: readonly RefusalExpectation[] = Array.isArray(expected) + ? expected + : [expected]; + const command = argv.join(" "); await assertLeavesUnchanged( workspace.root, 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; + const counts: Record<string, number> = {}; + for (const expectation of expectations) { + counts[expectation.finding] = (counts[expectation.finding] ?? 0) + 1; + } + assertConditionCounts( + findings, + 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)`, + ); + 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)`, ); } /** - * 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). + * 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)`, ); - 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( @@ -570,7 +769,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, @@ -608,12 +807,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. @@ -1055,10 +1282,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. @@ -1089,7 +1320,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 { @@ -1116,10 +1347,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, @@ -1339,12 +1600,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">', @@ -1381,6 +1658,32 @@ 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 +// 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). @@ -1393,23 +1696,321 @@ 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"), -]); +// 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 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", 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 (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 }, + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, async (workspace) => { + // Destination occupants (the staging note above): staged before the + // 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( @@ -1418,122 +2019,76 @@ const T6_5_4 = defineProductTest({ "T6.5-4 `build` over the staged workspace", ); - const cases: readonly (readonly [readonly string[], string])[] = [ - [ - ["move", "specs/A.mdx#mv", "specs/B.mdx#bmv"], - "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"], - "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"], - "file form whose destination file already exists (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#keep", "specs/B.mdx#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"], - "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"], - "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"], - "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"], - "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"], - "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"], - "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 " + - "`specs/plain/**` spec glob, isolating 14.19's extension rule " + - "(SPEC 6.5, 7.1, 14.19)", - ], - ]; - for (const [argv, 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, argv, + expected, `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)", - ); - } + // 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( + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + 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_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); + await workspace.file( + MOVE_PRECONDITION_BREAK_FILE, + MOVE_PRECONDITION_BREAK_SOURCE, + ); await expectRefusalModifiesNothing( product, workspace, - ["move", "specs/A.mdx#keep", "specs/B.mdx#kp"], - "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})`, ); }, ); @@ -1577,7 +2132,82 @@ const U5_BROKEN_SOURCE = [ "", ].join("\n"); -const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ +// 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 +// 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"), +]); + +// 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", @@ -1592,19 +2222,119 @@ 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). +export const MOVE_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", + ], +]; + +// 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: - "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), 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\``); - for (const [argv, label] of U5_USAGE_CASES) { + for (const [argv, label] of MOVE_USAGE_CASES) { await expectMoveUsageError( product, workspace, @@ -1612,17 +2342,82 @@ const T6_5_5 = defineProductTest({ `${context}, ${label}`, ); } + + // 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 MOVE_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 (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 + // 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, + async () => { + await expectMoveUsageError( + product, + workspace, + 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)`, + ); + }, + `${context}: \`move ${U5_A} <non-UTF-8 bytes>\` — the usage ` + + `error modifies nothing (SPEC 6.5, 12.0)`, + ); + } }, ); // --- Ordering arm: the workspace also fails build validation --- await withWorkspace( - SPECS_ONLY_CONFIG, - { - [U5_A]: U5_A_SOURCE, - [U5_B]: U5_B_SOURCE, - [U5_BAD]: U5_BAD_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 @@ -1641,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, @@ -1650,6 +2445,16 @@ const T6_5_5 = defineProductTest({ `— 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)`, + ); + } }, ); @@ -1692,6 +2497,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( + 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 + // 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_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 ` + + `(SPEC 6.5, 6.4, 11.2, 12.0)`, + ); + }, + ); }, }); @@ -1729,7 +2627,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, @@ -1778,6 +2676,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)", ); @@ -1798,6 +2703,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)", ); @@ -1806,6 +2719,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, @@ -1814,4 +2943,5 @@ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_4, T6_5_5, T6_5_6, + T6_5_7, ]; diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index db1be16..40b5f39 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -1,79 +1,310 @@ -// TEST-SPEC §6.6 (manual restructuring) — SUITE-24: T6.6-1. +// 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 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). +// 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: 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 -// mapping the old identity onto the new one) is maximally tempted — and -// diagnosed by the journal and impact assertions. +// 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): -// - "No journal entry" is realized through SPEC 6.1's strongest observable: -// the journal file comes into existence with the first journaled operation, -// and a manual edit is none — so `.xspec/journal` is asserted absent after -// the direct edit and after every subsequent command (successful and -// failing `build`s, `impact`). -// - "A deletion plus an addition (not continuity)" is asserted as the -// complete per-node impact table of the fixture, in the SUITE-20 -// conventions: entries merged per node identity (SPEC 9.3 fixes the -// grouping, not the adapter-level granularity); an uncategorized, undeleted -// node has no requirement entry (the T1.5-1 convention); the old identity -// reports as deleted and `changed` only, the new one as added — `changed` -// only, not deleted (SPEC 5.6's added/deleted convention); the propagated -// `descendant-changed` attributions are pinned exactly per T5.6-2's -// precedent (the parent to the added and the removed child; the file root -// to the parent and both children); the originating category `changed` is -// attribution-bounded by the originating-node set, the empty list accepted. -// A product treating the edit as continuity reports no categories at all — -// or maps the vacated identity forward — and fails the table. -// - The 14.5 findings are located within the reference-bearing opening tag's -// byte window (the T2.4-4 operationalization for unresolved-`d` findings). +// - 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. +// - 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. +// - 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`). +// - 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). +// - 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 type { - ChangeCategory, - ImpactReport, -} from "../../helpers/adapters/index.js"; -import { decodeImpactReport } from "../../helpers/adapters/index.js"; -import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { Buffer } from "node:buffer"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { ProductBinding } from "../../helpers/subprocess.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, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + fail, + 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 { + 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 { + assertGraphDataPresent, + deleteGraphData, + isGraphDataKey, +} from "./section-13.3.js"; +import { + CORE_DECL, + awaitHoldFile, + describeExit, + holdPathFor, + runBounded, +} from "./section-13.5.js"; +import { + assertAppliedMapping, assertConditionCounts, - assertFindingLocated, + assertFindingConcernsPath, assertSameJson, buildFindings, buildOk, - byteWindow, + expectErrorDocument, expectExit, + runJson, } from "./support.js"; -// Exactly one spec group (SPEC 7). No code groups exist in these fixtures, so -// no code location can be impacted. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// 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 spec-only workspace, run `body`, dispose (H-1). */ +/** 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": SPECS_ONLY_CONFIG, ...files }, + files: { "xspec.config.ts": config, ...files }, }); try { return await body(workspace); @@ -83,437 +314,2877 @@ async function withWorkspace<T>( } /** - * Assert the journal file does not exist (SPEC 6.6, 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`. + * 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 assertNoJournal( +async function assertPreviewPremises( + product: ProductBinding, workspace: TestWorkspace, - moment: string, context: string, ): Promise<void> { - const kind = await workspace.kind(JOURNAL_PATH); - if (kind !== "absent") { + await buildOk(product, workspace, `${context} premise \`build\``); + const journalKind = await workspace.kind(JOURNAL_PATH); + if (journalKind !== "absent") { fail( - `${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)`, + `${context}: staging premise — no journal file exists before the ` + + `first journaled operation (SPEC 6.1); found ${journalKind} at ` + + `${JOURNAL_PATH}`, ); } } /** - * `impact --base <ref> --json`: exit 0 (impact is informational, SPEC 9.3; - * H-5) with exactly one JSON document, decoded as the impact report (H-3). + * 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 impactAgainst( +async function assertRealRunPerformsPlan( product: ProductBinding, workspace: TestWorkspace, - ref: string, + operationArgv: readonly string[], + previewMapping: readonly AppliedMappingPair[], context: string, -): Promise<ImpactReport> { - const result = await expectExit( - product, - workspace, - ["impact", "--base", ref, "--json"], - 0, +): Promise<void> { + const argv = [...operationArgv, "--json"]; + const applied = decodeAppliedMappingReport( + await runJson( + product, + workspace, + argv, + `${context}: \`${argv.join(" ")}\``, + ), context, ); - return decodeImpactReport(parseJsonStdout(result, context), 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)`, + ); } -/** Expected attribution for one category of one node (module header, H-4). */ -interface ExpectedCategory { - readonly category: ChangeCategory; - /** Attribution pinned exactly. Exactly one of `exact`/`within`. */ - readonly exact?: readonly string[]; - /** Attribution bounded: the merged `attributedTo` must be a subset. */ - readonly within?: readonly string[]; +// --------------------------------------------------------------------------- +// 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", + ); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// 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 complete expectation for one node identity of the fixture. */ -interface ExpectedNodeImpact { - /** Current identity; the baseline identity for the deleted node. */ - readonly identity: string; - /** Whether entries naming the node must flag it deleted (default false). */ - readonly deleted?: boolean; - /** The node's exact category set; empty = must receive no category. */ - readonly categories: readonly ExpectedCategory[]; +/** + * 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, + }; } /** - * Assert an impact report's requirement-level content against the complete - * per-node expectation table of the fixture (SPEC 5.6, 6.6, 9.1, 9.3) — the - * SUITE-20 conventions restated in the module header. + * 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). */ -function assertImpactTable( - report: ImpactReport, - expectations: readonly ExpectedNodeImpact[], +async function expectRefusedPreviewEquivalence( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + expected: RefusalExpectation | readonly RefusalExpectation[], context: string, -): void { - const expectedBy = new Map<string, ExpectedNodeImpact>(); +): Promise<void> { + const expectations = expectationsOf(expected); + const counts: Record<string, number> = {}; for (const expectation of expectations) { - if (expectedBy.has(expectation.identity)) { - throw new Error( - `fixture bug: duplicate expectation for ${expectation.identity}`, - ); - } - for (const category of expectation.categories) { - if ((category.exact === undefined) === (category.within === undefined)) { - throw new Error( - `fixture bug: category ${category.category} of ` + - `${expectation.identity} must declare exactly one of exact/within`, - ); - } - } - expectedBy.set(expectation.identity, expectation); - } - - // Merge the report per node identity (SPEC 9.3 fixes the grouping, not the - // adapter-level entry granularity — the SUITE-20 convention). - interface MergedNode { - readonly deletedFlags: Set<boolean>; - readonly attributions: Map<ChangeCategory, string[]>; - } - const actualBy = new Map<string, MergedNode>(); - for (const entry of report.requirements) { - for (const identity of entry.nodes) { - const expected = expectedBy.get(identity); - if (expected === undefined) { - fail( - `${context}: the report names ${JSON.stringify(identity)}, which is ` + - `no current node of the fixture and no staged deleted identity ` + - `(in the workspace-relative identity form of SPEC 1.5); ` + - `entry: ${JSON.stringify(entry)}`, - ); - } - let merged = actualBy.get(identity); - if (merged === undefined) { - merged = { deletedFlags: new Set(), attributions: new Map() }; - actualBy.set(identity, merged); - } - merged.deletedFlags.add(entry.deleted); - for (const category of entry.categories) { - const attributed = merged.attributions.get(category.category) ?? []; - attributed.push(...category.attributedTo); - merged.attributions.set(category.category, attributed); - } - } + counts[expectation.finding] = (counts[expectation.finding] ?? 0) + 1; } - - for (const expected of expectations) { - const merged = actualBy.get(expected.identity); - const expectedNames = expected.categories - .map((category) => category.category) - .sort(); - - if (expectedNames.length === 0) { - if (merged !== undefined) { - fail( - `${context}: ${expected.identity} must receive no category ` + - `(SPEC 5.6) and so appear in no requirement entry (SPEC 9.3 ` + - `groups output by category; the T1.5-1 convention), but the ` + - `report names it with categories ` + - `${JSON.stringify([...merged.attributions.keys()].sort())}`, - ); - } - continue; - } - if (merged === undefined) { - 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 ` + - `requirement entry names it`, + 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)`, ); - } - const expectedDeleted = expected.deleted ?? false; - for (const flag of merged.deletedFlags) { - if (flag !== expectedDeleted) { + // 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}: ${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)}`, + `${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)`, + ); +} - assertSameJson( - [...merged.attributions.keys()].sort(), - expectedNames, - `${context}: the exact category set of ${expected.identity} (SPEC 5.6 — ` + - `categories are independent flags; none missing, none extra)`, +/** + * 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)`, ); - - for (const category of expected.categories) { - const attributed = [ - ...new Set(merged.attributions.get(category.category) ?? []), - ].sort(); - if (category.exact !== undefined) { - assertSameJson( - attributed, - [...category.exact].sort(), - `${context}: the ${category.category} category of ` + - `${expected.identity} must be attributed to exactly its ` + - `originating node(s) (SPEC 5.6, 9.1)`, - ); - } else { - for (const identity of attributed) { - if (!category.within?.includes(identity)) { - fail( - `${context}: the ${category.category} category of ` + - `${expected.identity} is attributed to ` + - `${JSON.stringify(identity)}, which is no originating node ` + - `of this change (SPEC 5.6: every category is attributed to ` + - `its originating nodes); originating nodes: ` + - JSON.stringify([...(category.within ?? [])].sort()), - ); - } - } - } + 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`, + ); } } - - assertSameJson( - report.code, - { direct: [], transitive: [] }, - `${context}: no code groups are configured, so no code location is ` + - `impacted (SPEC 9.2)`, - ); } -// --------------------------------------------------------------------------- -// T6.6-1 — manual restructuring -// --------------------------------------------------------------------------- - -// Impact arm: `a.mid` is manually renamed to `a.neo` by overwriting the file; -// everything but the one `id` attribute — the renamed node's text included — -// is byte-identical across the edit, and nothing references the node, so the -// edited workspace stays valid and the deletion-plus-addition semantics are -// observable in isolation. `a.keep` is the untouched sibling that must stay -// uncategorized. -const I1_FILE = "specs/A.mdx"; -const I1_TOP = "specs/A.mdx#a"; -const I1_MID = "specs/A.mdx#a.mid"; -const I1_NEO = "specs/A.mdx#a.neo"; -const I1_KEEP = "specs/A.mdx#a.keep"; - -const impactArmSource = (midId: string): string => - [ - '<S id="a">', - "Holder text.", - "", - `<S id="${midId}">`, - "Mid text staying byte-identical across the manual rename.", - "</S>", - "", - '<S id="a.keep">', - "Keeper text.", - "</S>", - "</S>", - "", - ].join("\n"); - -// The originating nodes of the manual edit (SPEC 5.6: those carrying -// `changed` — the deleted old node, the added new node, and the parent whose -// own content lost one child reference and gained another). -const I1_ORIGINATORS = [I1_MID, I1_NEO, I1_TOP]; - -// Validation arm: the manually renamed node has two dependents referencing -// the old identity — a same-file local string and a cross-file imported -// chain — each staged as an exact prefix + opening-tag construct so the 14.5 -// findings' locations are pinned to byte windows (SPEC 14; the T2.4-4 -// operationalization). -const V2_ORIGIN = "specs/B.mdx"; -const V2_WATCH = "specs/Watch.mdx"; - -function originSource( - midId: string, - depRef: string, -): { text: string; prefix: string; construct: string } { - const prefix = [ - '<S id="b">', - "Holder text.", - "", - `<S id="${midId}">`, - "Mid text.", - "</S>", - "", - "", - ].join("\n"); - const construct = `<S id="b.dep" d={"${depRef}"}>`; - const text = `${prefix}${construct}\nSame-file dependent text.\n</S>\n</S>\n`; - return { text, prefix, construct }; -} - -function watchSource(ref: string): { - text: string; - prefix: string; - construct: string; -} { - const prefix = 'import B from "./B.xspec"\n\n'; - const construct = `<S id="watch" d={B.${ref}}>`; - const text = `${prefix}${construct}\nCross-file dependent text.\n</S>\n`; - return { text, prefix, construct }; +/** + * 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_1 = defineProductTest({ - id: "T6.6-1", +const T6_6_3 = defineProductTest({ + id: "T6.6-3", 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)", + "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) => { - // --- Impact arm: deletion plus addition, never continuity --- + // --- Refusal equivalence: T6.4-3's cases, staged identically --- await withWorkspace( - { [I1_FILE]: impactArmSource("a.mid") }, + RENAME_REFUSAL_CONFIG, + RENAME_REFUSAL_FILES, async (workspace) => { - const context = "T6.6-1 impact arm"; - await workspace.gitInit(); - const base = await workspace.gitCommitAll("pre-edit baseline"); - await buildOk(product, workspace, `${context}: \`build\``); - await assertNoJournal( + await buildOk( + product, workspace, - "before any journaled operation (staging premise)", - context, + "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})`, + ); + } + }, + ); - // The manual rename: only the one `id` attribute changes; the node's - // text is byte-identical, tempting continuity inference (SPEC 6.6). - await workspace.file(I1_FILE, impactArmSource("a.neo")); + // --- 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, - `${context}: \`build\` after the direct edit — nothing references ` + - `the vacated identity, so the workspace stays valid`, + "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 assertNoJournal( + await expectRefusedPreviewEquivalence( + product, workspace, - "after the direct edit and the `build` over it", - context, + MOVE_DERIVED_PATH_CASE.argv, + MOVE_DERIVED_PATH_CASE.expected, + `T6.6-3 move refusal (${MOVE_DERIVED_PATH_CASE.reason})`, ); + }, + ); - const label = `${context}: \`impact --base <pre-edit ref> --json\``; - assertImpactTable( - 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). - { - identity: I1_MID, - deleted: true, - categories: [{ category: "changed", within: I1_ORIGINATORS }], - }, - // The new identity: added, `changed` only — and not deleted. - { - identity: I1_NEO, - categories: [{ category: "changed", within: I1_ORIGINATORS }], - }, - // The parent: its own content lost the child reference to the - // old identity and gained one to the new (5.5: child constructs - // hash by canonical identity, and no journal maps them) — - // `changed` — plus `descendant-changed` attributed to the - // removed and the added child (T5.6-2's precedent). - { - identity: I1_TOP, - categories: [ - { category: "changed", within: I1_ORIGINATORS }, - { category: "descendant-changed", exact: [I1_MID, I1_NEO] }, - ], - }, - // The file root: `descendant-changed` attributed to P and C. - { - identity: I1_FILE, - categories: [ - { - category: "descendant-changed", - exact: [I1_TOP, I1_MID, I1_NEO], - }, - ], - }, - // The untouched sibling: no category. - { identity: I1_KEEP, categories: [] }, - ], - label, + // 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})`, ); - await assertNoJournal(workspace, "after `impact --base`", context); }, ); - // --- Validation arm: dependents fail 14.5 until rewritten --- - const staleOrigin = originSource("b.neo", "b.mid"); - const staleWatch = watchSource("b.mid"); + // --- Usage-error equivalence: T6.4-4's usage errors on its + // ordering-shaped staging --- await withWorkspace( - { - [V2_ORIGIN]: originSource("b.mid", "b.mid").text, - [V2_WATCH]: staleWatch.text, - }, + RENAME_USAGE_CONFIG, + RENAME_USAGE_ORDERING_FILES, async (workspace) => { - const context = "T6.6-1 validation arm"; - await buildOk(product, workspace, `${context}: \`build\``); - await assertNoJournal( + const context = "T6.6-3 rename usage"; + const findings = await buildFindings( + product, workspace, - "before any journaled operation (staging premise)", - context, + `${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)`, ); - - // The manual rename, leaving both dependents naming the old identity. - await workspace.file(V2_ORIGIN, staleOrigin.text); - - const staleLabel = `${context}: \`build --json\` with the dependents still naming the vacated identity`; - const findings = await buildFindings(product, workspace, staleLabel); - assertConditionCounts( - findings, - { "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)`, - ); - for (const [file, source, surface] of [ - [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); - if (located.length !== 1) { - fail( - `${staleLabel}: expected exactly one 14.5 finding naming ` + - `${file} (the ${surface}); got ${String(located.length)} — ` + - `findings: ${JSON.stringify(findings)}`, - ); - } - assertFindingLocated( - located[0]!, - { file, window: byteWindow(source.prefix, source.construct) }, - `${staleLabel}: the 14.5 finding for the ${surface}`, + if (findings.length === 0) { + fail( + `${context}: staging premise — the failing \`build\` must ` + + `report at least one validation finding (SPEC 14)`, ); } - await assertNoJournal( + 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, - "after the direct edit and the failing `build`", - context, + RENAME_SOLO_ARGV, + "T6.6-3 rename usage, spells-no-identity arm", ); + }, + ); - // "Until rewritten": manually retarget both dependents to the new - // identity — validation passes again, and still no journal entry. - await workspace.file(V2_ORIGIN, originSource("b.neo", "b.neo").text); - await workspace.file(V2_WATCH, watchSource("b.neo").text); - await buildOk( + // --- 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\` after rewriting both dependents to the new ` + - `identity — the workspace validates again (SPEC 6.6, 14.5)`, + `${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 assertNoJournal( + }, + ); + await withWorkspace( + MOVE_SOLO_CONFIG, + MOVE_SOLO_FILES, + async (workspace) => { + await runSoloUsageArm( + product, workspace, - "after the dependents were rewritten and the `build` over them", + 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(); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// 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)`, + ); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// 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)`, + ); }, ); }, }); -/** TEST-SPEC §6.6, in canonical ID order (SUITE-24). */ -export const section66Tests: readonly ProductTestEntry[] = [T6_6_1]; +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/section-6.7.ts b/test/suite/registry/section-6.7.ts new file mode 100644 index 0000000..0da7b7c --- /dev/null +++ b/test/suite/registry/section-6.7.ts @@ -0,0 +1,521 @@ +// 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.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 +// mapping the old identity onto the new one) is maximally tempted — and +// diagnosed by the journal and impact assertions. +// +// Conservative operationalizations (noted per H-4): +// - "No journal entry" is realized through SPEC 6.1's strongest observable: +// the journal file comes into existence with the first journaled operation, +// and a manual edit is none — so `.xspec/journal` is asserted absent after +// the direct edit and after every subsequent command (successful and +// failing `build`s, `impact`). +// - "A deletion plus an addition (not continuity)" is asserted as the +// complete per-node impact table of the fixture, in the SUITE-20 +// conventions: entries merged per node identity (SPEC 9.3 fixes the +// grouping, not the adapter-level granularity); an uncategorized, undeleted +// node has no requirement entry (the T1.5-1 convention); the old identity +// reports as deleted and `changed` only, the new one as added — `changed` +// only, not deleted (SPEC 5.6's added/deleted convention); the propagated +// `descendant-changed` attributions are pinned exactly per T5.6-2's +// precedent (the parent to the added and the removed child; the file root +// to the parent and both children); the originating category `changed` is +// attribution-bounded by the originating-node set, the empty list accepted. +// A product treating the edit as continuity reports no categories at all — +// or maps the vacated identity forward — and fails the table. +// - The 14.5 findings are located within the reference-bearing opening tag's +// byte window (the T2.4-4 operationalization for unresolved-`d` findings). + +import type { + ChangeCategory, + ImpactReport, +} from "../../helpers/adapters/index.js"; +import { decodeImpactReport } 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 type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertFindingLocated, + assertSameJson, + buildFindings, + buildOk, + byteWindow, + expectExit, +} from "./support.js"; + +// Exactly one spec group (SPEC 7). No code groups exist in these fixtures, so +// no code location can be impacted. +const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +const JOURNAL_PATH = ".xspec/journal"; + +/** Stage a fresh spec-only workspace, run `body`, dispose (H-1). */ +async function withWorkspace<T>( + files: Readonly<Record<string, string>>, + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": SPECS_ONLY_CONFIG, ...files }, + }); + try { + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +/** + * 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`. + */ +async function assertNoJournal( + workspace: TestWorkspace, + moment: string, + context: string, +): Promise<void> { + const kind = await workspace.kind(JOURNAL_PATH); + if (kind !== "absent") { + fail( + `${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.7, 6.1)`, + ); + } +} + +/** + * `impact --base <ref> --json`: exit 0 (impact is informational, SPEC 9.3; + * H-5) with exactly one JSON document, decoded as the impact report (H-3). + */ +async function impactAgainst( + product: ProductBinding, + workspace: TestWorkspace, + ref: string, + context: string, +): Promise<ImpactReport> { + const result = await expectExit( + product, + workspace, + ["impact", "--base", ref, "--json"], + 0, + context, + ); + return decodeImpactReport(parseJsonStdout(result, context), context); +} + +/** Expected attribution for one category of one node (module header, H-4). */ +interface ExpectedCategory { + readonly category: ChangeCategory; + /** Attribution pinned exactly. Exactly one of `exact`/`within`. */ + readonly exact?: readonly string[]; + /** Attribution bounded: the merged `attributedTo` must be a subset. */ + readonly within?: readonly string[]; +} + +/** The complete expectation for one node identity of the fixture. */ +interface ExpectedNodeImpact { + /** Current identity; the baseline identity for the deleted node. */ + readonly identity: string; + /** Whether entries naming the node must flag it deleted (default false). */ + readonly deleted?: boolean; + /** The node's exact category set; empty = must receive no category. */ + readonly categories: readonly ExpectedCategory[]; +} + +/** + * Assert an impact report's requirement-level content against the complete + * per-node expectation table of the fixture (SPEC 5.6, 6.6, 9.1, 9.3) — the + * SUITE-20 conventions restated in the module header. + */ +function assertImpactTable( + report: ImpactReport, + expectations: readonly ExpectedNodeImpact[], + context: string, +): void { + const expectedBy = new Map<string, ExpectedNodeImpact>(); + for (const expectation of expectations) { + if (expectedBy.has(expectation.identity)) { + throw new Error( + `fixture bug: duplicate expectation for ${expectation.identity}`, + ); + } + for (const category of expectation.categories) { + if ((category.exact === undefined) === (category.within === undefined)) { + throw new Error( + `fixture bug: category ${category.category} of ` + + `${expectation.identity} must declare exactly one of exact/within`, + ); + } + } + expectedBy.set(expectation.identity, expectation); + } + + // Merge the report per node identity (SPEC 9.3 fixes the grouping, not the + // adapter-level entry granularity — the SUITE-20 convention). + interface MergedNode { + readonly deletedFlags: Set<boolean>; + readonly attributions: Map<ChangeCategory, string[]>; + } + const actualBy = new Map<string, MergedNode>(); + for (const entry of report.requirements) { + for (const identity of entry.nodes) { + const expected = expectedBy.get(identity); + if (expected === undefined) { + fail( + `${context}: the report names ${JSON.stringify(identity)}, which is ` + + `no current node of the fixture and no staged deleted identity ` + + `(in the workspace-relative identity form of SPEC 1.5); ` + + `entry: ${JSON.stringify(entry)}`, + ); + } + let merged = actualBy.get(identity); + if (merged === undefined) { + merged = { deletedFlags: new Set(), attributions: new Map() }; + actualBy.set(identity, merged); + } + merged.deletedFlags.add(entry.deleted); + for (const category of entry.categories) { + const attributed = merged.attributions.get(category.category) ?? []; + attributed.push(...category.attributedTo); + merged.attributions.set(category.category, attributed); + } + } + } + + for (const expected of expectations) { + const merged = actualBy.get(expected.identity); + const expectedNames = expected.categories + .map((category) => category.category) + .sort(); + + if (expectedNames.length === 0) { + if (merged !== undefined) { + fail( + `${context}: ${expected.identity} must receive no category ` + + `(SPEC 5.6) and so appear in no requirement entry (SPEC 9.3 ` + + `groups output by category; the T1.5-1 convention), but the ` + + `report names it with categories ` + + `${JSON.stringify([...merged.attributions.keys()].sort())}`, + ); + } + continue; + } + if (merged === undefined) { + 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.7, 5.6) — but no ` + + `requirement entry names it`, + ); + } + + const expectedDeleted = expected.deleted ?? false; + for (const flag of merged.deletedFlags) { + if (flag !== expectedDeleted) { + fail( + `${context}: ${expected.identity} must be reported ` + + `${expectedDeleted ? "as deleted, under its baseline identity" : "as present, not deleted"} ` + + `(SPEC 6.7, 5.6, 9.3); an entry naming it has deleted: ${String(flag)}`, + ); + } + } + + assertSameJson( + [...merged.attributions.keys()].sort(), + expectedNames, + `${context}: the exact category set of ${expected.identity} (SPEC 5.6 — ` + + `categories are independent flags; none missing, none extra)`, + ); + + for (const category of expected.categories) { + const attributed = [ + ...new Set(merged.attributions.get(category.category) ?? []), + ].sort(); + if (category.exact !== undefined) { + assertSameJson( + attributed, + [...category.exact].sort(), + `${context}: the ${category.category} category of ` + + `${expected.identity} must be attributed to exactly its ` + + `originating node(s) (SPEC 5.6, 9.1)`, + ); + } else { + for (const identity of attributed) { + if (!category.within?.includes(identity)) { + fail( + `${context}: the ${category.category} category of ` + + `${expected.identity} is attributed to ` + + `${JSON.stringify(identity)}, which is no originating node ` + + `of this change (SPEC 5.6: every category is attributed to ` + + `its originating nodes); originating nodes: ` + + JSON.stringify([...(category.within ?? [])].sort()), + ); + } + } + } + } + } + + assertSameJson( + report.code, + { direct: [], transitive: [] }, + `${context}: no code groups are configured, so no code location is ` + + `impacted (SPEC 9.2)`, + ); +} + +// --------------------------------------------------------------------------- +// T6.7-1 — manual restructuring +// --------------------------------------------------------------------------- + +// Impact arm: `a.mid` is manually renamed to `a.neo` by overwriting the file; +// everything but the one `id` attribute — the renamed node's text included — +// is byte-identical across the edit, and nothing references the node, so the +// edited workspace stays valid and the deletion-plus-addition semantics are +// observable in isolation. `a.keep` is the untouched sibling that must stay +// uncategorized. +const I1_FILE = "specs/A.mdx"; +const I1_TOP = "specs/A.mdx#a"; +const I1_MID = "specs/A.mdx#a.mid"; +const I1_NEO = "specs/A.mdx#a.neo"; +const I1_KEEP = "specs/A.mdx#a.keep"; + +const impactArmSource = (midId: string): string => + [ + '<S id="a">', + "Holder text.", + "", + `<S id="${midId}">`, + "Mid text staying byte-identical across the manual rename.", + "</S>", + "", + '<S id="a.keep">', + "Keeper text.", + "</S>", + "</S>", + "", + ].join("\n"); + +// The originating nodes of the manual edit (SPEC 5.6: those carrying +// `changed` — the deleted old node, the added new node, and the parent whose +// own content lost one child reference and gained another). +const I1_ORIGINATORS = [I1_MID, I1_NEO, I1_TOP]; + +// Validation arm: the manually renamed node has two dependents referencing +// the old identity — a same-file local string and a cross-file imported +// chain — each staged as an exact prefix + opening-tag construct so the 14.5 +// findings' locations are pinned to byte windows (SPEC 14; the T2.4-4 +// operationalization). +const V2_ORIGIN = "specs/B.mdx"; +const V2_WATCH = "specs/Watch.mdx"; + +function originSource( + midId: string, + depRef: string, +): { text: string; prefix: string; construct: string } { + const prefix = [ + '<S id="b">', + "Holder text.", + "", + `<S id="${midId}">`, + "Mid text.", + "</S>", + "", + "", + ].join("\n"); + const construct = `<S id="b.dep" d={"${depRef}"}>`; + const text = `${prefix}${construct}\nSame-file dependent text.\n</S>\n</S>\n`; + return { text, prefix, construct }; +} + +function watchSource(ref: string): { + text: string; + prefix: string; + construct: string; +} { + const prefix = 'import B from "./B.xspec"\n\n'; + const construct = `<S id="watch" d={B.${ref}}>`; + const text = `${prefix}${construct}\nCross-file dependent text.\n</S>\n`; + return { text, prefix, construct }; +} + +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.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.7-1 impact arm"; + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-edit baseline"); + await buildOk(product, workspace, `${context}: \`build\``); + await assertNoJournal( + workspace, + "before any journaled operation (staging premise)", + context, + ); + + // The manual rename: only the one `id` attribute changes; the node's + // text is byte-identical, tempting continuity inference (SPEC 6.7). + await workspace.file(I1_FILE, impactArmSource("a.neo")); + + await buildOk( + product, + workspace, + `${context}: \`build\` after the direct edit — nothing references ` + + `the vacated identity, so the workspace stays valid`, + ); + await assertNoJournal( + workspace, + "after the direct edit and the `build` over it", + context, + ); + + const label = `${context}: \`impact --base <pre-edit ref> --json\``; + assertImpactTable( + 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.7, 5.6). + { + identity: I1_MID, + deleted: true, + categories: [{ category: "changed", within: I1_ORIGINATORS }], + }, + // The new identity: added, `changed` only — and not deleted. + { + identity: I1_NEO, + categories: [{ category: "changed", within: I1_ORIGINATORS }], + }, + // The parent: its own content lost the child reference to the + // old identity and gained one to the new (5.5: child constructs + // hash by canonical identity, and no journal maps them) — + // `changed` — plus `descendant-changed` attributed to the + // removed and the added child (T5.6-2's precedent). + { + identity: I1_TOP, + categories: [ + { category: "changed", within: I1_ORIGINATORS }, + { category: "descendant-changed", exact: [I1_MID, I1_NEO] }, + ], + }, + // The file root: `descendant-changed` attributed to P and C. + { + identity: I1_FILE, + categories: [ + { + category: "descendant-changed", + exact: [I1_TOP, I1_MID, I1_NEO], + }, + ], + }, + // The untouched sibling: no category. + { identity: I1_KEEP, categories: [] }, + ], + label, + ); + await assertNoJournal(workspace, "after `impact --base`", context); + }, + ); + + // --- Validation arm: dependents fail 14.5 until rewritten --- + const staleOrigin = originSource("b.neo", "b.mid"); + const staleWatch = watchSource("b.mid"); + await withWorkspace( + { + [V2_ORIGIN]: originSource("b.mid", "b.mid").text, + [V2_WATCH]: staleWatch.text, + }, + async (workspace) => { + const context = "T6.7-1 validation arm"; + await buildOk(product, workspace, `${context}: \`build\``); + await assertNoJournal( + workspace, + "before any journaled operation (staging premise)", + context, + ); + + // The manual rename, leaving both dependents naming the old identity. + await workspace.file(V2_ORIGIN, staleOrigin.text); + + const staleLabel = `${context}: \`build --json\` with the dependents still naming the vacated identity`; + const findings = await buildFindings(product, workspace, staleLabel); + assertConditionCounts( + findings, + { "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.7, 14.5)`, + ); + for (const [file, source, surface] of [ + [V2_ORIGIN, staleOrigin, "same-file local string reference"], + [V2_WATCH, staleWatch, "cross-file imported chain reference"], + ] as const) { + 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 ` + + `${file} (the ${surface}); got ${String(located.length)} — ` + + `findings: ${JSON.stringify(findings)}`, + ); + } + assertFindingLocated( + located[0]!, + { file, window: byteWindow(source.prefix, source.construct) }, + `${staleLabel}: the 14.5 finding for the ${surface}`, + ); + } + await assertNoJournal( + workspace, + "after the direct edit and the failing `build`", + context, + ); + + // "Until rewritten": manually retarget both dependents to the new + // identity — validation passes again, and still no journal entry. + await workspace.file(V2_ORIGIN, originSource("b.neo", "b.neo").text); + await workspace.file(V2_WATCH, watchSource("b.neo").text); + await buildOk( + product, + workspace, + `${context}: \`build\` after rewriting both dependents to the new ` + + `identity — the workspace validates again (SPEC 6.7, 14.5)`, + ); + await assertNoJournal( + workspace, + "after the dependents were rewritten and the `build` over them", + context, + ); + }, + ); + }, +}); + +/** TEST-SPEC §6.7, in canonical ID order (SUITE-24). */ +export const section67Tests: readonly ProductTestEntry[] = [T6_7_1]; diff --git a/test/suite/registry/section-7-basics.ts b/test/suite/registry/section-7-basics.ts index db2ff6b..e0322cb 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 @@ -35,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 @@ -44,22 +55,23 @@ // 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"; import { decodeCoverageReport, decodeEdgesReport, + decodeFindingsReport, decodeIdsReport, } from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -70,10 +82,12 @@ 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, expectConfigurationError, + expectErrorDocument, expectExit, runJson, } from "./support.js"; @@ -417,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})`); @@ -457,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`, + ); + }); }, }); @@ -730,11 +946,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 1ce5699..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"; @@ -316,17 +318,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 `<malformed 14.12 identities> ${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 ?? "<no rule>"} :: ` + - (finding.edge === undefined - ? "<no edge>" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`), - ) - .sort(); + return findings.map(renderPolicyIdentities).sort(); } const T7_1_1 = defineProductTest({ @@ -436,11 +445,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)})`, ); } @@ -849,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 deb0510..1c1117a 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 <name>` (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) @@ -57,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 @@ -72,11 +89,11 @@ import type { } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, + decodeEdgesReport, decodeFindingsReport, } from "../../helpers/adapters/index.js"; import { assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -89,9 +106,11 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, readGeneratedModule, runJson, @@ -259,19 +278,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 ?? "<no rule>"} :: ` + - (finding.edge === undefined - ? "<no edge>" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`), - ) + .map((finding) => { + if (finding.identities.length !== 4) { + return `<malformed 14.12 identities> ${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)`, ); } @@ -573,8 +605,9 @@ const T7_4_1 = defineProductTest({ ); // (d) Unknown profile name at `coverage <name>` → 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) => { @@ -587,11 +620,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( @@ -1606,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", @@ -1623,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 @@ -1713,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)", + ); }, }); 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 `<malformed 14.12 identities> ${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 ?? "<no rule>"} :: ` + - (finding.edge === undefined - ? "<no edge>" - : `${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..485170d 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -8,11 +8,19 @@ // product only via diagnosed assertion failures (H-8). import { Buffer } from "node:buffer"; -import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; -import { decodeFindingsReport } from "../../helpers/adapters/index.js"; +import type { + AppliedMappingPair, + Finding, + FindingLocation, + GraphEdge, +} from "../../helpers/adapters/index.js"; +import { + decodeErrorDocument, + decodeFindingsReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -65,14 +73,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 +127,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 ` + @@ -162,7 +206,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 +218,8 @@ export function assertConditionCounts( ): void { const counts: Record<string, number> = {}; 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<Record<string, number>>): string[] => Object.entries(record) @@ -205,8 +253,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,41 +262,140 @@ 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; + 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)})`, + ); + } + } +} + +/** + * 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 ( - window !== undefined && - (finding.location.start < window.start || finding.location.end > window.end) + finding.identities.some((entry) => entry === full || entry === expected.id) ) { - 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)})`, - ); + 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 { @@ -297,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); +} diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 7002a98..2281fd8 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,19 +23,34 @@ // 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 -// 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). // - 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 (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 @@ -43,10 +58,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 @@ -93,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"], @@ -167,6 +186,10 @@ 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"], + "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"], @@ -180,7 +203,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"], @@ -188,10 +211,16 @@ 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.6-1": ["6.6"], + "T6.5-6": ["6.5", "14"], + "T6.5-7": ["6.5"], + "T6.6-2": ["6.6"], + "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"], "T7-3": ["7", "14"], @@ -229,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"], @@ -260,14 +290,46 @@ 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"], - "T11-1": ["11"], - "T11-2": ["11"], - "T11-3": ["11"], - "T11-4": ["11"], - "T11-5": ["11"], - "T11-6": ["11"], - "T11-7": ["11"], + "T10.7-12": ["1.7", "10.7"], + "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"], + "T11.2-1": ["11.2"], + "T11.2-2": ["11.2"], + "T11.2-3": ["11.2"], + "T11.2-4": ["11.2"], + "T11.2-5": ["11.2"], + "T11.2-6": ["11.2"], + "T11.3-1": ["11.3"], + "T11.3-2": ["11.3"], + "T11.3-3": ["11.3"], + "T11.3-4": ["11.3"], + "T11.4-1": ["11.4"], + "T11.4-2": ["11.4"], + "T11.4-3": ["11.4"], + "T11.4-4": ["11.4"], + "T11.4-5": ["11.4"], + "T11.4-6": ["11.4"], + "T11.5-1": ["11.5"], + "T11.5-2": ["11.5"], + "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"], + // 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"], @@ -277,8 +339,14 @@ 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.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"], @@ -289,11 +357,36 @@ 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"], + // 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"], + // 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"], + // 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"], "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"], @@ -302,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"], @@ -314,6 +411,22 @@ 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"], + // 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"], + // 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"], @@ -325,4 +438,7 @@ 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"], + "P-12": ["5.7", "11.5"], + "P-13": ["7.4", "8", "8.1", "8.2"], }; 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); diff --git a/test/suite/section-6.7.test.ts b/test/suite/section-6.7.test.ts new file mode 100644 index 0000000..c4ca7d8 --- /dev/null +++ b/test/suite/section-6.7.test.ts @@ -0,0 +1,9 @@ +// 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 { section67Tests } from "./registry/section-6.7.js"; + +declareProductTests(section67Tests); diff --git a/test/windows/e6-byte-identity.test.ts b/test/windows/e6-byte-identity.test.ts index fb3aeae..add34dd 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) — +// 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: `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)", 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): //