diff --git a/.github/scripts/run-without-network.sh b/.github/scripts/run-without-network.sh index 8712f69a..09eeb267 100755 --- a/.github/scripts/run-without-network.sh +++ b/.github/scripts/run-without-network.sh @@ -1,21 +1,76 @@ #!/usr/bin/env bash -# Run a command with network access disabled (TEST-SPEC E-1: the suite must -# pass with network access disabled after setup, and product invocations run -# with network access denied). +# Run a command with network access disabled and as an unprivileged user +# (TEST-SPEC E-1): the suite must pass with network access disabled after +# setup, product invocations run with network access denied, and the Linux leg +# runs product invocations as an unprivileged user so that the permission-based +# stagings of environment refusals (T13.5-7, T14-9, T14-10) take effect. The +# harness verifies each staging on itself and reports a privileged runner as a +# harness error (H-11); this script is what makes the runner unprivileged. # -# The command runs in a fresh network namespace containing only a loopback -# interface, so the command and every subprocess it spawns (the harness's -# product invocations included) have no route off the machine, while the -# Actions runner agent keeps its own connectivity. Failures here are loud by -# design: falling back to a networked run would silently drop the E-1 -# guarantee. +# The command runs inside two nested user namespaces, entered by this script +# re-invoking itself one stage at a time: +# +# outer `unshare --map-root-user --net`. A fresh network namespace holding +# only a loopback interface, so the command and every subprocess it +# spawns (the harness's product invocations included) have no route +# off the machine while the Actions runner agent keeps its own +# connectivity. Root inside this namespace is needed only to bring +# loopback up. It must not run the suite: a root-mapped namespace +# also carries CAP_DAC_OVERRIDE over every file the runner user owns, +# so permission removal is ineffective there — a process writes into +# a read-only directory and reads a mode-200 file — exactly the +# privileged runner E-1 excludes. +# +# inner `unshare --map-user --map-group` (util-linux >= 2.38; ubuntu-24.04 +# ships 2.39). Maps the identity back to the runner's real uid and +# gid; executing a program as a non-zero uid inside a user namespace +# clears every capability. Mode bits then refuse exactly as they do +# for a plain unprivileged process, the network namespace is +# inherited, and the uid the harness, git, and the product observe is +# the runner's own. The command runs here, after the stage verifies +# both properties. +# +# Failures here are loud by design: falling back to a networked or privileged +# run would silently drop the E-1 guarantee. set -euo pipefail +me="run-without-network.sh" + +case "${1-}" in + --stage-outer) + # Inside the root-mapped user + network namespaces. + uid=$2; gid=$3; shift 3 + ip link set lo up 2>/dev/null || true + exec unshare --map-user="$uid" --map-group="$gid" -- bash "$0" --stage-inner "$uid" "$@" + ;; + --stage-inner) + # Inside the inner user namespace: the runner's own identity, no + # capabilities. Verify both before running anything. + uid=$2; shift 2 + if [ "$(id -u)" != "$uid" ]; then + echo "$me: uid $(id -u) inside the inner namespace, expected $uid" >&2 + exit 1 + fi + if ! grep -Eq '^CapEff:[[:space:]]*0+$' /proc/self/status; then + echo "$me: capabilities survived the inner namespace (privileged runner, E-1)" >&2 + exit 1 + fi + exec "$@" + ;; +esac + +uid=$(id -u) +gid=$(id -g) +if [ "$uid" -eq 0 ]; then + echo "$me: refusing to run as root — E-1 requires an unprivileged runner" >&2 + exit 1 +fi + # Ubuntu 24.04 can restrict unprivileged user namespaces; lift the restriction # for this VM so `unshare` needs no root. A no-op where already permitted. sudo sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true # --map-root-user grants CAP_NET_ADMIN inside the new namespaces (to bring up -# loopback); the real uid outside remains the runner user. -exec unshare --map-root-user --net -- \ - sh -c 'ip link set lo up 2>/dev/null || true; exec "$@"' -- "$@" +# loopback); the real uid outside remains the runner user, and the inner stage +# maps it back. +exec unshare --map-root-user --net -- bash "$0" --stage-outer "$uid" "$gid" "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c5e5d6..f1c67371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,14 @@ # # suite-linux Linux. The full suite — sections 1–17, certification # included (E-1) — run with network access disabled after -# dependency installation, so product invocations run with -# network denied. Expected red until the product conforms to -# SPEC.md; green is required from then on. +# dependency installation and as an unprivileged identity +# (.github/scripts/run-without-network.sh), so product +# invocations run with network denied and the permission- +# based stagings of environment refusals (T13.5-7, T14-9, +# T14-10) take effect; the harness verifies each staging on +# itself and reports a privileged runner as a harness error. +# Expected red until the product conforms to SPEC.md; green +# is required from then on. # # suite-windows Windows. The E-6 platform-sensitive subset. The E-6 # byte-identity comparison reads the Linux leg's @@ -27,8 +32,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: @@ -51,7 +64,7 @@ jobs: run: npm run typecheck - name: Build the product run: npm run build - - name: Run harness self-tests and certification (network disabled) + - name: Run harness self-tests and certification (network disabled, unprivileged) run: bash .github/scripts/run-without-network.sh npm run test:self suite-linux: @@ -67,17 +80,28 @@ jobs: - run: npm ci - name: Build the product run: npm run build - - name: Run the full suite (network disabled after setup, E-1) + - name: Run the full suite (network disabled after setup, unprivileged, E-1) run: bash .github/scripts/run-without-network.sh npm test env: XSPEC_E6_EXCHANGE_DIR: ${{ github.workspace }}/.e6-exchange - name: Upload E-6 exchange outputs for the Windows leg - # No files exist until the harness writes the representative fixture's - # outputs into XSPEC_E6_EXCHANGE_DIR on this leg. The exchange lives - # in a dot-directory, which upload-artifact v4 excludes by default - # (include-hidden-files: false since v4.4) — without the override the - # written exchange uploads as nothing and the Windows comparison - # fails loudly on a missing manifest (E-6, H-9). + # Runs regardless of the suite step's verdict (a step without `if:` is + # skipped once an earlier step fails), so an exchange written by the + # passing E-6 writer test (test/suite/e6-exchange-writer.test.ts) + # always reaches the Windows leg even while unrelated Linux product + # tests are red — the Phase 9 red-green period included. `!cancelled()` + # rather than `always()`: a cancelled job uploads nothing. The Windows + # byte-identity test still fails loudly (H-9) exactly when no exchange + # was written — never because some other Linux test failed. No files + # exist until the harness writes the representative fixture's outputs + # into XSPEC_E6_EXCHANGE_DIR on this leg; `if-no-files-found: ignore` + # keeps this step harmless when the suite step never ran or the writer + # test wrote nothing. The exchange lives in a dot-directory, which + # upload-artifact v4 excludes by default (include-hidden-files: false + # since v4.4) — without the override the written exchange uploads as + # nothing and the Windows comparison fails loudly on a missing + # manifest (E-6, H-9). + if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: name: e6-linux-outputs @@ -103,9 +127,12 @@ jobs: - name: Build the product run: npm run build - name: Download E-6 exchange outputs from the Linux leg - # The artifact does not exist until the Linux leg produces outputs; the - # harness's E-6 comparison must itself fail loudly when the exchange - # directory is missing while the product exists. + # The artifact is absent only when the Linux job produced no exchange + # (it died before its suite step, or the E-6 writer test itself did not + # complete) — its upload step runs whenever that job is not cancelled, + # red product tests notwithstanding. The harness's E-6 comparison must + # itself fail loudly when the exchange directory is missing while the + # product exists. continue-on-error: true uses: actions/download-artifact@v4 with: diff --git a/AGENTS.md b/AGENTS.md index 3cce1f93..aefee345 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,17 +4,206 @@ 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. -- 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). +- Build the product: `npm run build` — compiles `src/` (TypeScript ESM, `src/tsconfig.json`) to `dist/`; the `xspec` bin is `dist/cli/bin.js`. Run it: `node dist/cli/bin.js`. A hand-staged scratch workspace (an `xspec.config.ts` plus sources, anywhere on disk) needs no `node_modules`: the product resolves the configuration's `import … from "xspec"` itself, so `cd && node /abs/path/to/dist/cli/bin.js build --json` works as is — the quickest way to eyeball a finding's exact location before pinning it in a test. A ready-made configuration for such a workspace is the `E6_CONFIG` template literal in `test/helpers/e6.ts` (main specs under `specs/**/*.mdx`, code under `src/**/*.ts`): copy it verbatim into `xspec.config.ts`. For a baseline-taking command (`impact --base `, `review create --base --name `) the scratch workspace must also be a git repository holding the ref: `git init`, an identity (`git config user.email` and `user.name` — the harness's isolated git configuration does not apply to hand staging), `git add -A && git commit`; a `.xspec/journal` committed at the ref is the baseline's journal, and the working copy's uncommitted edits are the current workspace. +- 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 project's `type-error.ts`, `import-conflict.ts`, and `import-duplicate.ts`). - 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. +- Two copies of TypeScript 5.9.3 (the release SPEC 14.20 fixes) are installed: the product's `typescript` dependency, pinned exactly, and the harness's own devDependency `typescript-5.9.3`, an npm alias of `typescript@5.9.3`. Harness code imports `typescript-5.9.3`, never `typescript` — the tooling driver and S-4 do, and so must S-9's TypeScript well-formedness check (its parser must be a harness dependency independent of the product) and any other harness use; product code keeps reaching `typescript` through `src/core/ts-module.ts`. npm cannot list one package name in both dependency sets (the devDependencies entry would replace the product's), hence the alias. After a clean `npm ci`, `node_modules/.bin/tsc` is the product's copy; an incremental `npm install` may relink it to the alias — the bytes are identical either way. - Full test suite (TEST-SPEC sections 1–17, certification included; the Linux CI leg): `npm test`. Build the product first — tests invoke the built executable. -- Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). -- Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. -- Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. -- 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). +- Timings on a 4-core machine: the whole suite project (`npx vitest run --config test/vitest.config.ts --project suite`; 79 files, 345 tests, the E-6 writer included, at the Phase 9 re-descent's end, FIX_PLAN Task 66; 77 files, 337 tests at 3285994) takes ~16 min at Phase 10's end, ~11 min before it (637 s at 3285994, 704 s at 3d0f424, 745 s in Phase 10 at FIX_PLAN Task 26, 722 s at Task 27, 724 s at Task 28, 745 s at Task 30, 734 s at Task 31, 771 s at Task 32, 757 s at Task 33, 774 s at Task 34, 819 s at Task 35 — T6.5-16, T6.6-3, and T14-7 now running to completion; 811 s at Task 36, 827 s at Task 37, 849 s at Task 38, 842 s at Task 39, 867 s at Task 40, 860 s at Task 41, 862 s at Task 42, 885 s at Task 43, 903 s at Task 44, 906 s at Task 45, 889 s at Task 46, 936 s at Task 47, 935 s at Task 48 — T13.5-7 and T14-9 now running to completion; 920 s at Task 49; 906 s at Task 50; 925 s at Task 51; 881 s at Task 66, 336 of 337 passing, T14-11 alone failing at its arm (n); 853 s at Task 67, likewise; 842 s at Task 68, all 337 passing (T14-11's arm (n) green); 848 s at Task 69, all 337 passing; 842 s at Task 70, all 337 passing; 853 s at Task 71, all 337 passing; 853 s at Task 72, all 337 passing; 862 s at Task 73, all 337 passing; 927 s at Task 74, all 337 passing; 852 s at Task 75, all 337 passing; 951 s at Task 76, all 337 passing; 887 s at Task 77, all 337 passing; 869 s at Task 78, all 337 passing; 896 s at Task 79, all 337 passing; 914 s at Task 80, all 337 passing; 914 s at Task 81, all 337 passing; 937 s at Task 87, all 337 passing; 938 s at Task 88, all 337 passing; 923 s at Task 89, all 337 passing; 960 s at Task 54, all 337 passing; ~15 min in earlier plans; in the Phase 9 re-descent, 1233 s at its FIX_PLAN Task 15 under the namespace — 338 tests with the E-6 writer, 333 passing, the five diagnosed product failures T1.4-1, T1.4-4, T7-6, T13.4-11, and P-1 — the same five CI's suite job reported at 1d16b45; 1183 s at FIX_PLAN Task 16's TypeScript staging survey, an instrumented run, the same five; 1090 s at FIX_PLAN Task 17 with `--reporter=verbose`, alone, the same five; 1095 s at FIX_PLAN Task 19, likewise, with T6.5-22(a)'s driver hook judging every performed move, the same five; 1240 s at FIX_PLAN Task 66, alone under the namespace with `--reporter=verbose --reporter=json --outputFile.json=` (the JSON report's per-test `failureMessages` open with the error class, so `HarnessAssertionError` — a diagnosed product failure, H-8 — is told from a harness error without reading the log): 345 tests, 317 passing, 28 failing, every failure a `HarnessAssertionError` — P-1, P-5, T1.4-1, T1.4-4, T4-2, T6.4-3, T6.5-4, T6.5-11, T6.5-20, T6.5-21, T6.5-22, T6.5-23, T6.6-3, T7-2, T7-6, T7.1-1, T7.3-1, T12.0-5, T12.0-10, T12.7-2, T13.4-9, T13.4-10, T13.4-11, T14-4, T14-6, T14-7, T14-11, T14-12 — the same 28 CI's suite job reported at a84033e and f8e031e; 1307 s at the re-descent's second plan's FIX_PLAN Task 14, its confirmation run, alone with the same reporters in CI's whole inner stage (network off as well; the unprivileged-identity bullet has the recipe): 345 tests, 317 passing, the same 28 failing, every one a `HarnessAssertionError`, and the same 28 CI's run 871 reported at 79d2013) and `npm run test:self` ~1.5 min (89 s at 3285994, 113 s at Phase 10's Task 54; ~2.5 min in earlier plans, ~4 min with the suite running beside it; the box is saturated by either alone, so overlapping them risks timing-sensitive flakes). With stdout redirected to a file (a background run), Vitest's default reporter prints nothing between the `RUN` banner and the final summary — passed files never get a line and failed files appear only at the end — so a silent log is not a hang; pass `--reporter=verbose` for per-test lines as they complete. +- Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). Append `--reporter=verbose` to see each test's duration — the quick check that a many-invocation sweep (e.g. T6.1-1's ~34 CLI runs, ~9 s at steady state) actually drove every arm rather than short-circuiting. Vitest's `-t ` name filter narrows further but matches substrings of the whole title, so filtering on a test ID (`-t "T6.5-5"`) also runs every test whose title cites that ID (T6.5-4's title mentions T6.5-5, adding its ~9 s) — read the verbose list to see what actually ran. To select one registered test inside a file, add `-t ' '` with a trailing space (Vitest's name filter is a regex over the declared name ` `, so `-t 'T6\.5-1 '` runs T6.5-1 alone, not T6.5-10 through T6.5-19). +- 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. The builder's `gitInit`/`gitCommitAll` serve the workspace root's repository only; a nested repository (T6.3-5's stagings in `test/suite/registry/section-6.3.ts`) is scripted through the public `git([...])` with `-C <subdir>` and an inline `-c user.name=… -c user.email=…` identity (the isolated environment has none), and `git -c protocol.file.allow=always submodule --quiet add ./<subdir> <subdir>` registers an already-initialized in-place repository as a submodule without cloning (the gitlink is staged; `.git` stays a directory) — the `protocol.file.allow` override is what git 2.38+ demands for a local-path submodule. +- Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. Its certification runner prints one `PASS`/`FAIL` line per (test, fixture) pair as it drives each fixture executable; a violator's `FAIL` lines are its expected outcomes, not failures — Vitest's own failures are the `×` lines and the final `Tests` summary, so when piping the output through `grep`, filter on those rather than on `FAIL` (the runner's lines are indented — ` FAIL T…` — so a line-anchored `grep -v '^FAIL '` does not drop them; match `×` and the `Tests` summary instead, and never cap the output with `head -N`: the runner's 30-odd per-pair lines precede the summary, so redirect the run to a log file and grep that afterwards). To certify one fixture family alone, filter `test/self/certification.test.ts` on the family name every one of its test titles carries: `npx vitest run --config test/vitest.config.ts --project self test/self/certification.test.ts -t CORE` runs the CONF-CORE conformer and each VIOL-CORE-* violator (~35 s in all) and skips every other family; `-t ORPHAN` runs the CONF-ORPHAN conformer (`test/fixtures/conf-orphan/`) and its VIOL-ORPHAN-* violators over T13.4-11 alone (~4 s per fixture, ~12 s with Vitest's start-up). Current totals (Phase 9 re-descent, from FIX_PLAN Task 9 on, all six conformers and twenty-one violators wired): the self project under the namespace runs 26 files, 4206 tests from the re-descent's second plan's FIX_PLAN Task 13 on (it added that file's sixth test, pinning every command's run under JSON output and the mutating commands' performed, preview, and section forms; 172 s alone at that plan's Task 14, its confirmation run, all passing, 0 skipped; CI's harness-self job ran it in 133 s at 79d2013) (4205 at that plan's Task 12, which added the fifth, pinning `armSteps`; 4204 at its Task 11, which added the fourth, the JSON-output reading guard; ~181 s) (4203 at that plan's Task 10, which added the 26th file, `test/self/p8-fixed-seed-draws.test.ts`, with three tests; ~177 s) (25 files and 4200 tests at that plan's Task 9, which added S-5's three correction-judge tests), 4197 tests from that plan's Task 7 on (iteration 82; it added one staged-source ledger record, T4.5-3's `SPEC?.a;` arm, one more test of `test/self/s9-staged-sources.test.ts`, 1570 there; ~184 s; unchanged at that plan's Task 8, which restaged two records in place, ~181 s) (4196 at that plan's Tasks 4 through 6, Task 4 adding two staged-source ledger records, each one more test of that file, 1569 there; 4194 at that plan's Task 3, which added five S-9 builder vectors for spec-group files not named `.mdx` — four in `test/self/s9-fixture-well-formedness.test.ts`, one in `test/self/s9-undeclared-staging.test.ts`; 4189 at that plan's Task 2b, which added S-3's two capture-limit vectors and S-8's helper-conversion vector; 4186 at its Task 2, which added S-8's capture-limit vector; 4185 from the first plan's Task 64 through Task 66, its last, 4158 at Task 63, 4094 at Task 62, 3983 at Task 61, 3955 at Task 60, 3950 at Tasks 58 and 59, 3947 at Task 57, 3940 at Tasks 52 through 56, 3935 at Task 51, 3933 at Task 50, 3929 at Task 49, 3925 at Task 48, 3921 at Task 47, 3911 at Task 46, 3905 at Task 45, 3895 at Task 44, 3881 at Task 43, 3862 at Task 42, 3857 at Task 41, 3844 at Task 40, 3840 at Task 39, 3833 at Task 38, 3826 at Task 37, 3823 at Task 36, 3821 at Task 35, 3796 at Task 34, 3784 at Tasks 32 and 33, 3770 at Task 31, 3767 at Tasks 29 and 30, 3762 at Task 28, 3761 at Task 27, 3759 at Task 26, 3757 at Task 25, 3744 at Task 24, 3743 at Task 23, 3740 at Task 21, 3731 at Task 19, 24 files and 3693 tests at Task 18, 3621 at Task 17, 3550 at Task 16o, 3542 at Task 16n, 3534 at Task 16m, 3494 at Task 16l, 3452 at Task 16k, 3424 at Task 16j, 3401 at Task 16i, 3338 at Task 16h, 3289 at Task 16g, 3242 at Task 16f, 3221 at Task 16e, 3199 at Task 16d, 3128 at Task 16c, 3078 at Task 16b, 3061 at Task 16, 3023 at Task 15, 3017 at Task 14, 22 files and 2961 tests at Task 13, 2960 at Task 12, 2959 before it: S-9's staged-sources self-test runs one test per staged-source record, so each new record adds one; Task 13 added one S-5 test, Task 14 the new file `test/self/s9-typescript-well-formedness.test.ts`, 56 tests, Task 15 six S-2 tests, and Task 16 the TypeScript records' tests — 17 fixed ones plus one per TypeScript judgement, 21 at Task 16, 38 at Task 16b, 88 at Task 16c, 159 at Task 16d, 181 at Task 16e, 202 at Task 16f, 249 at Task 16g, 298 at Task 16h, 361 at Task 16i, 384 at Task 16j, 412 at Task 16k, 454 at Task 16l, 494 at Task 16m, 502 at Task 16n, and 503 from Task 16o; Task 16o also added five `test/self/s9-undeclared-staging.test.ts` tests, one fixed TypeScript-records test, and one MDX record's judgement — the Windows leg's drive-mismatch fixture's two records; Task 17 added the 71 tests of S-9's Unicode 15.1 block in `test/self/s9-fixture-well-formedness.test.ts`, Task 18 the new file `test/self/s6-name-analysis.test.ts`, 72 tests, Task 19 the new file `test/self/added-import-identifiers.test.ts`, 38 tests, Task 21 nine S-9 staged-source tests, one per T1.4-5 record, Task 23 three, one per record of T2.4-5's escaped-root workspace, Task 25 thirteen, one per record of T4-2's eleven new arm rows, its no-other-construct `src/c.ts`, and its `specs/NAME.mdx`, Task 26 two, one per record of T4.3-2's template-literal arm's `src/app.ts` and T4.5-3's `login-v2` spec source, Task 28 one, the record of T5.7-2's U+3000/U+202F arm's `specs/MAIN.mdx`, Task 34 twelve, one per record of T6.5-7's CRLF and lone-CR re-runs — three MDX and three TypeScript records per kind, Task 35 twenty-five — five `test/self/import-insertion.test.ts` tests and one per record of T6.5-8's restaged arms and their CRLF and lone-CR re-runs, thirteen MDX net and seven TypeScript, Task 36 two — two `test/self/import-insertion.test.ts` tests of the `pinnedOffsets` set, T6.5-9's code-arm origin record replaced one for one, Task 38 seven, one per record of T6.5-18's configuration, origin, target, and four `src/c.ts` stagings, Task 39 seven, one per record of T6.5-20's three configurations and four spec sources, Task 40 four, one per record of T6.5-20 (c)'s two configurations, its code source, and `specs/B.md/C.mdx`, Task 41 thirteen, one per record of T6.5-20 (d)'s two configurations and nine `src/c.ts` stagings and of (e)'s configuration and `specs/A.mdx`, Task 42 five, one per record of T6.5-21's three configurations, its `specs/A.mdx`, and (b)'s `specs/A.md`, Task 43 nineteen, one per record of T6.5-22's configuration, `specs/A.mdx`, and fourteen receivers, and three `test/self/added-import-identifiers.test.ts` tests of `judgeAddedImportsOfFile`, Task 44 fourteen, one per record of T6.5-23's configuration, `specs/origin.mdx`, `specs/target.mdx`, `specs/c.mdx`, and ten `src/c.ts` stagings, Task 45 ten, one per record of T6.5-23 (f)'s `specs/A.mdx`, `specs/B.mdx`, and seven `src/c.ts` stagings and (g)'s `specs/target.mdx`, Task 46 six, one per record of T6.5-23 (h)–(k)'s six `src/c.ts` stagings, Task 47 ten, one per record of T6.5-23 (l)–(p)'s eight `src/c.ts` stagings, (l)/(m)'s `specs/A.mdx`, and (o)'s `specs/third.mdx`, Task 48 four, one per record of T7-2's four import-modifier arms, Task 49 four, one per record of T7.1-1's path-character configuration and spec source and its code-source control's configuration and code source, Task 50 four, one per record of T7.3-1's graph-data-area and look-alike outDir configurations, Task 51 two, one per record of T11.6-2's invalid-path `.mdx` sources, Task 52 five, one per record of T12.0-5's positive side of the backslash — two MDX (`specs/a`, backslash, `b.mdx` and the code side's `specs/A.mdx`) and three TypeScript (the code side's configuration, `src/a`, backslash, `b.ts`, and `src/ab.ts`), Task 60 five, one per record of T14-11 (x)'s five code sources, Task 61 twenty-eight — eleven, one per record of T14-12's new arms (four MDX: (ae)–(ag)'s and (aa)'s `specs/S.mdx`; seven TypeScript: the five new code arms', (ab)'s configuration, and (ac)'s code source), four S-9 MDX form vectors, and thirteen S-9 TypeScript tests — the ten `T14_12_CODE_FORM_VECTORS`, their count, and (ac)'s judgement and offset pin), Task 62 one hundred eleven — S-9 P-2/P-3 form vectors over P-2's full brace-side whitespace set (fifteen new own-line `{X}` vectors, nineteen own-line block-comment sequences with X in every gap, and seventy-six inline ones, both forms per code point with and without a tail) and one test pinning that set and its vectors' coverage, Task 63 sixty-four — one S-9 P-5 import-header form vector per drawn spec basename, Task 64 twenty-seven — three property-runner self-tests of the TypeScript per-draw check (`test/self/property-infrastructure.test.ts`, 18 tests) and twenty-four S-9 TypeScript tests of the §16 generators' forms in `test/self/s9-typescript-well-formedness.test.ts` (twenty-two vectors and two coverage tests), all passing (~137–172 s, 146 s at Task 17, 144 s at Task 18, 144 s at Task 19, 147 s at Task 21, 138 s at Task 23, 131 s at Task 25, 136 s at Task 26, 143 s at Task 28, 134 s at Task 34, 132 s at Task 35, 130 s at Task 36, 152 s at Task 38, 155 s at Task 39, 159 s at Task 40, 156 s at Task 41, 157 s at Task 42, 155 s at Task 43, 155 s at Task 44, 153 s at Task 45, 153 s at Task 46, 153 s at Task 47, 154 s at Task 48, 154 s at Task 49, 149 s at Task 50, 148 s at Task 51, 152 s at Task 52, 152 s at Task 55 (3940 tests, unchanged), 154 s at Task 56 (3940 tests, unchanged), 157 s at Task 58 (3950 tests), 161 s at Task 59 (3950 tests, unchanged), 159 s at Task 60 (3955 tests), 159 s at Task 61 (3983 tests), 155 s at Task 62 (4094 tests), 156 s at Task 64 (4185 tests), 164 s at Task 66 (4185 tests, unchanged), 171 s at the second plan's Task 2 (4186 tests), 171 s at its Task 2b (4189 tests), 170 s at its Task 3 (4194 tests), 176 s at its Task 4 (4196 tests); CI's harness-self job ~108 s), and certification sums to 154 PASS / 38 FAIL / 0 error / 0 hang over 27 `certification run against` lines, every FAIL a violator's expected outcome: the 6 conformers' 39 (test, fixture) pairs all PASS, and the 21 violators' 153 pairs are 115 PASS and 38 FAIL, unchanged at Task 66 and at the re-descent's second plan's Task 14 (`grep 'certification run against' <log> | grep -oE '[0-9]+ pass, [0-9]+ fail, [0-9]+ error, [0-9]+ hang' | awk '{p+=$1; f+=$3; e+=$5; h+=$7} END {print p, f, e, h}'`; for one kind's sum, insert `| grep ' conformer:'` or `| grep ' violator:'` after the first `grep`). +- The self project — and, once the Linux-leg tests (T13.5-7, T14-9, T14-10) are registered, the suite project — must run as an unprivileged identity. The permission-based stagings of environment refusals (`test/helpers/permissions.ts`; TEST-SPEC E-1) verify themselves on the harness's own process before any product is invoked and throw `HarnessStagingError` — a harness error, never a diagnosed product failure — when the runner is privileged: root, or any identity holding CAP_DAC_OVERRIDE, writes into a read-only directory and reads a mode-0o200 file, so `test/self/permission-staging.test.ts` fails as root by design (never skip or work around it). CI runs both projects through `.github/scripts/run-without-network.sh`, which refuses root; in a root sandbox reproduce its inner stage with `unshare --map-user=1000 --map-group=1000 -- npm run test:self` (util-linux 2.38+: the command runs as uid 1000 with every capability cleared, while the repository's root-owned files stay owner-accessible, so git, npm, and Vitest's cache work unchanged — the whole self project takes the same ~2.5 min there). A plain non-root user needs no wrapper. To reproduce CI's whole inner stage, network denied as well (as `.github/scripts/run-without-network.sh` runs it), nest that wrapper in a fresh network namespace whose loopback is up: `unshare --map-root-user --net -- bash -c 'python3 <lo-up.py> && cd /home/user/xspec && unshare --map-user=1000 --map-group=1000 -- <command>'`. This sandbox has neither `ip` nor `ifconfig`, so `<lo-up.py>` is a five-line Python helper that ORs IFF_UP (1) into `lo`'s flags through `fcntl.ioctl` on an `AF_INET` datagram socket: `SIOCGIFFLAGS` (0x8913) reads them and `SIOCSIFFLAGS` (0x8914) writes them, each request `struct.pack('16sH14s', b'lo', flags, bytes(14))`. Inside, the command runs as uid 1000 with `CapEff` 0, loopback binds, names do not resolve, and the agent proxy is unreachable. Hand-staging a permission refusal in a scratch workspace as root works only when the staging and the product run share one namespace invocation — `unshare --map-user=1000 --map-group=1000 -- bash -c 'cd <workspace> && chmod -R a-w .xspec && node /abs/path/to/dist/cli/bin.js build --json'` (or `chmod 0100 specs/sub` for an unlistable directory): inside it the mapped identity owns the root-created files and the cleared capabilities make the mode bits bite, whereas outside it root ignores them. +- Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies but one: the CONF-DISC conformer (`test/fixtures/conf-disc/product.mjs`, its violators with it) judges discovered code sources' well-formedness (SPEC 14.20) with the harness's own `typescript-5.9.3`, loaded lazily through `createRequire` by the first invocation that discovers a code source (~0.25 s), never `typescript` — so `npm ci` must have run, as for the tooling driver. The certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. Every fixture exits 70 — outside SPEC 12.0's exit partition — for a fixture-internal crash (the stack on stderr), and the CONF-DISC, CONF-CORE, and CONF-ORPHAN fixtures also for an invocation outside their certified scope (`xspec: fixture scope error: …` on stderr, e.g. CONF-DISC's `query nodes`, `query edges --kinds`, or a `check` on a workspace passing `build`'s validations (the scope serves `check` over T7-6's validation-failing invalid-source workspaces alone), CONF-CORE's section-form `move` or a `rename --preview` of a performable operation, CONF-ORPHAN's every command but `build` and `check`, `--test-hold`, a `coverage` or `policy` rule, a spec source other than one `<S id="…">` section of plain Markdown lines, or a code source other than `export const NAME = DIGITS` lines; CONF-ORPHAN keeps its graph data, record included, in `.xspec/graph.json`): such an exit is a fixture-side condition, never a product verdict, so a test hitting it needs a fixture (or staging) fix, not a product diagnosis. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. A fixture judging a byte-order mark must inspect the raw bytes (EF BB BF): a UTF-8 `TextDecoder` strips a leading BOM from its output unless constructed with `ignoreBOM: true`, so a check on the decoded text never sees one (the CONF-AVAIL conformer, `test/fixtures/conf-avail/product.mjs`, decodes with `ignoreBOM: true` after that byte check). +- To run a selection of product tests against one fixture executable that the certification manifest does not (yet) list — e.g. a violator before its manifest entry lands — put a temporary `*.test.ts` under `test/self/` (the self project's include pattern is `test/self/**/*.test.ts`; files elsewhere never run) that calls `runProductTests` from `test/self/certification-runner.ts` with a `ProductBinding` of the fixture (`command: process.execPath`, `prefixArgs: [<abs bin path>]`) and `productTestSuite.select([...ids])` from `test/suite/registry/index.ts`, run it as `npx vitest run --config test/vitest.config.ts --project self test/self/<file>.test.ts`, and delete the file before committing. +- Confirming that a CONF-ORPHAN violator fails T13.4-11 on its one expected arm alone: the registered body (`T13_4_11` in `test/suite/registry/section-13.4.ts`) walks its arms in sequence and stops at the first failing one, so in a normal `-t ORPHAN` run the arms after it never meet the violator. Drop the expected arm's `walkOrphanArm(...)` line from the body with a one-line `sed`, run `-t ORPHAN`, and restore the file with `git checkout --` in the same command so the restore cannot be skipped: the violator's runner line then reads `PASS` (its certification test showing `×`, expected under the edit), proving every other arm passes against it (~9 s per run). VIOL-ORPHAN-THROUGHLINK (`test/fixtures/conf-orphan/bin-throughlink.mjs`, switch `componentLinksInsideRoot`, consumed in `recordedOccupant` alone, so the removal and 14.10's recorded-file form move together) fails at (e)'s inside staging's first `check` and passes with that staging's line dropped. VIOL-ORPHAN-LINKTARGET (`bin-linktarget.mjs`, switch `removeLinkTarget`, consumed in `removeRecorded` alone, so 14.10's recorded-file form is unchanged) fails at (c) after `build`, the link at `specs/A.md` still standing and its outside target deleted, and passes with (c)'s `walkOrphanArm(product, ORPHAN_ARM_LINK)` line dropped. The scratch probe pattern for a fixture's arm by hand: stage `xspec.config.ts` and `specs/A.mdx` in a directory, spawn `node test/fixtures/conf-orphan/<bin> build --json` there, restage, and re-run `check --json` / `build --json`. +- Red-checking a strengthened product test against the built product (Phase 9): the same temporary self-test can bind a stand-in instead of a fixture — a small `.mjs` wrapper kept outside the repository (the scratchpad) that spawns the real `dist/cli/bin.js` with the invocation's argv and cwd, rewrites the one answer under test (e.g. drops or duplicates a `refused-id-collision` finding's locations in a `rename … --json` report before re-serializing it), and exits with the product's exit code — bound as `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` and driven through `runProductTests(binding, productTestSuite.select([id]))` (`runProductTests` is exported by `test/self/certification-runner.ts`, `productTestSuite` by `test/suite/registry/index.ts`; the binding needs a `label`; a temporary `test/self/zz-<task>-standin.test.ts` doing so runs alone as `--project self <file> --disable-console-intercept` and is deleted before committing); each result's `diagnosis` (printed through `console.info` under `--disable-console-intercept`) shows which assertion caught the rewrite, while the unmodified answer keeps the test green. One T14-7 run takes ~15 s per mode. A stand-in that adds a finding to a report must insert it at its pinned 12.7 position — numbered conditions in numeric order before refusal reasons and code-less findings — or the form-exact findings decode rejects the whole document as a wrong order, never reaching the assertion under check (T10.1-6's `check` arm: a 14.10 unit-form finding goes before the 14.22 one). A stand-in that copies or injects a product finding locates it by its stable `code` (e.g. `obstructed-write-path`), never by a `condition` member: product findings carry the code alone, the harness's decoders deriving the condition from it (T11.2-6's source-outDir red check). The same pattern red-checks the write-refusal states of T13.5-7 (`test/suite/registry/write-refusal-staging.ts`) against the built product, which today dies with exit 70 at every refused write instead of exiting 2: a scratch wrapper that spawns `dist/cli/bin.js` synchronously and, on exit 70 with `EACCES: permission denied, <op> '<path>'` on stderr, prints the 12.7 error document (`code` `"write-failure"`, `path` the concerned workspace-relative path derived from the crash site — the product writes every file through a `.xspec.tmp-<pid>-<n>` sibling renamed into place, so a temp path under `.xspec` maps to `.xspec/journal` for `rename`/`move` and to `.xspec` for a read, one under `.xspec/reviews` to the session file named in argv, one beside a source to that source or, for `build`, a derived path there; a refused `mkdir` maps to the Markdown file it would hold, a refused `unlink` to the path itself) and exits 2 — leaves the product's real partial state in place, so the twin-read pinned-state assertions, the computed `check` staleness sets, and the recovery arms run for real; the module exports each arm (`sourceEditsArm` … `refreshingReadsArm`, `runKillArm`), so a temporary self-test can call the arms after a diagnosed one directly with the wrapper's `ProductBinding` instead of going through `runProductTests`. All of it must run under the unprivileged namespace (below). The same wrapper drives T14-9 (`test/suite/registry/section-14-ii.ts`) with two mappings beyond the above — a temp path in a `move`'s destination directory that holds no `.mdx` yet maps to the destination itself, and an `EACCES` on a path outside the workspace (a `--test-hold` file in a read-only directory) maps to 13.5's code-null usage error — and T14-9 passes through it in ~40 s, while a perturbation of the wrapper's answer (a wrong `path`, an empty stderr) is caught at its first arm; against the built product T14-9 fails diagnosed at arm (a) within seconds. T14-10 (same module) needs no stand-in: its arms are plain module-private functions (`sourceContentArm` … `sessionDirectoryArm`), so a temporary self-test can drive the arms after a diagnosed one by `export`ing them for the run (a one-line `sed` on their `async function` lines, reverted before committing) and calling each in turn with `builtProductBinding()`, printing pass / `HarnessAssertionError` / other per arm through `console.info` under `--disable-console-intercept` — all eight arms take ~15 s; print several thousand characters of a diagnosed message, since the product's answer document sits at its end. Against the built product T14-10 fails diagnosed at arm (b) within seconds. T14-11 (`test/suite/registry/section-14.ts`) is driven the same way — `export` its `T14_11_CASES`, `runRangeRuleArm`, `runRepeatedDependencyArm`, and `runRefusedReadArm` for the run (a `sed` on their `const`/`async function` lines, reverted before committing) and print, beside each arm's outcome, every pinned location's byte slice of its fixture (`Buffer.from(kase.files[file]).subarray(start, end)`), the direct check that a pinned range still covers exactly its construct; all fifteen arms take ~10 s under the unprivileged namespace. T12.7-3 (`test/suite/registry/section-12.7.ts`) red-checks through the same wrapper with two rewrites — an exit-2 `configuration-error` whose `--config` value resolves against the invocation cwd to nothing gets `path` set to the value as given, and an exit-70 `EACCES` crash maps as above (`scandir` → `read-failure` with the directory's workspace-relative path; a temp path under `.xspec` → `write-failure` with `.xspec`) — the whole test passing in ~6 s, while a wrong `path` or an empty stderr on the refusal arms is caught at the write-failure arm; against the built product T12.7-3 fails diagnosed at its first unoccupied-`--config` arm (the product canonicalizes the value to `../cfg/xspec.config.ts`). T14-7's own arms (FIX_PLAN Task 13: the destination spellings, the spec-import-cycle participants) are reached through the same wrapper only with base fixes for the product's earlier deviations — a `refused-invalid-id` finding's `identities` trimmed to the new identity, the file-form self-move's extra `refused-destination-exists` dropped, and the spec-import-cycle finding given the local reference spelling's location `specs/A.mdx [72, 75)` before the import declaration — after which the whole test passes in ~22 s. Run the self project and a suite run one at a time: concurrently, S-4's tooling self-tests fail spuriously (`×` lines in the vitest output) and pass again alone. T14-2's escape-spelled arms (`test/suite/registry/section-14.ts`, FIX_PLAN Task 17) green-check through the same wrapper with one rewrite: on `build --json` exiting 1, clone the existing `unknown-dependency` finding and the last `unknown-ts-reference` finding, locating the clones at the escape-spelled `d` literal (quotes included) in `specs/ref.mdx` and at the escape-spelled marker chain (terminator excluded) in `src/escaped.ts`, inserted right after their originals so the pinned findings order (12.7) holds — the whole test passes in ~1 s, and a wrong range on either clone is caught at its window; against the built product T14-2 fails diagnosed at its count assertion within seconds (`14.5 x1`, `14.7 x2`: the product resolves the interpreted spellings where SPEC 2.4 reads them verbatim). Two stand-in pitfalls: a wrapper that walks a workspace holding a non-UTF-8 file name must read directories with `{ encoding: "buffer" }` (a lossy string decode makes `statSync` throw ENOENT on the decoded name and silently aborts the rewrite), and a finding it inserts must land in 12.7's pinned findings order — by condition number, then locations, then concerned-path bytes — since the form-exact decode rejects an out-of-order array before any test assertion runs. T2.4-5 (`test/suite/registry/section-2.4.ts`; since the re-descent's FIX_PLAN Task 23, eight verbatim spellings and two escape-free controls in its failing workspace, then a second, valid escaped-root workspace) red-checks through a stand-in that rewrites the workspace's staged sources before spawning the product, since the faulty behavior under check is a reading of the input, not a shape of the answer: replacing the MDX segment escape (`BASE.lo`, a backslash, `u0067in`) with `BASE.login` in every `.mdx` under the cwd fails the test diagnosed at its condition counts, and replacing the escaped root (`B`, a backslash, `u0041SE`) with an unbound `BXSE` in every `.mdx` and `.ts` fails it at the escaped-root workspace's `build` (exit 1, the product's `invalid-argument`); the unmodified product passes (~3 s per mode), and the same input rewrite of `BASE.delete` to an unknown segment fails T2.4-1 at its `build`. Build the spellings in the wrapper from `String.fromCharCode(92)`: the tool-parameter layer decodes a backslash-u sequence in a payload. The same wrapper pattern red-checks T4.4-1 (`test/suite/registry/section-4.3-4.4.ts`): a `conform` mode that, on `build`/`check`/`occurrences` answers, re-codes the product's `cross-module-text` finding as `unknown-ts-reference` or `invalid-argument` where the located call spells `.missing` or `!`, empties an `identities` entry holding `#`, and inserts the owed `embeds` occurrence record (the finding's file and range, the whole-file source, target `specs/A.mdx#a`) into an empty `occurrences` answer takes the whole test green in ~4 s, while the unmodified product fails diagnosed at the first `occurrences` assertion. T4.6-3 (`test/suite/registry/section-4.6.ts`, FIX_PLAN Task 27) red/green-checks through the same wrapper with two modes acting on `query edges` answers — `esc` re-sources `src/esc.ts#foo` at the file `src/esc.ts`, `conform` additionally re-sources each declaration file's `src/x.d.*#f` at its file (the rewritten edge list re-sorted by `from`, `to`, `kind`; the edges adapter enforces no order) — driven through `runProductTests(binding, productTestSuite.select(["T4.6-3"]))` from a temporary self-test: `raw` fails diagnosed at the first workspace's `references` set (the product interprets the escape-spelled function name), `esc` at the declaration-file workspace's set (the product attributes a `.d.ts` file's inner marker to `path#f`), and `conform` passes the whole test in ~2 s; the three modes together take ~8 s as root (no permission staging is involved). T6.5-6 (`test/suite/registry/section-6.5.ts`, FIX_PLAN Task 30) red/green-checks through the same wrapper with one rewrite: a `conform` mode that, on `move … --preview --json` exiting 0, drops every `id-rewrite` and `reference-rewrite` edit from the preview's `files` before re-serializing (the no-op edits SPEC 6.5 says are neither made nor reported) takes the whole test green in ~2 s — the byte compares, `check`, identities, journal, and refusal arms all holding against the product's real behavior — while `raw` fails diagnosed at the preview's no-op assertion in under a second (the product reports three `id-rewrite`s and one `reference-rewrite` inside the origin deletion of a kept-ID cross-file move; its bytes are exact). T6.5-11 (`test/suite/registry/section-6.5-ii.ts`) is red-checked the same way, except that the product refuses its move outright (a 14.11 finding on its own would-be text), so the scratch wrapper performs the section move itself on the fixture — deletes the origin construct with its line, appends the re-identified text to the target, rewrites the call and edits the imports as 6.5 pins them (its fresh identifiers `Tgt`/`txt`), then runs the real `build` as the finishing regeneration — and patches the real preview's `src/c.ts` entry to the pinned three edits; through it the whole test passes in ~15 s, and a `;`-joined or single-quoted declaration, a kept origin import, a mid-line insertion, a preview offset off by one line, a preview lacking the removal, a fresh identifier equal to a retained binding, and a second default binding in (b) are each caught at their own assertion. T7.3-1's `outDir` spelling arms (`test/suite/registry/section-7.1-7.3.ts`, FIX_PLAN Task 35) green-check through the same wrapper with one intervention before the spawn: when the working directory's `xspec.config.ts` spells an `outDir` that is not in plain workspace-relative form (SPEC 7.3), print the 12.7 `configuration-error` document (`path` `xspec.config.ts`, `locations` `[]`) on stdout under `--json`, a message on stderr, and exit 2 — the whole test passes in ~2 s, a `./xspec.config.ts` path or a stray file written before the refusal is caught at the local helper's own assertions, and against the built product T7.3-1 fails diagnosed at the `""` arm within seconds (the product emits next to each source for `""` and normalizes `./out`, `out/../x`, `out//x`, and `out/`, refusing `/out` alone). The configured-set arms of T7.4-1, T7.5-1, and T11.6-2 (`test/suite/registry/section-7.4-7.5.ts`, `test/suite/registry/section-11.6.ts`; FIX_PLAN Task 36) green-check through the same wrapper with one rewrite: on `inventory` exiting 0, sort each profile's `targetTags` and each `tags` selector by UTF-8 bytes with duplicates dropped and re-order `edgeKinds`/`kinds` to depends, embeds, references before re-serializing — all three pass in ~10 s (as root: no permission staging is involved), a perturbed coverage count in the spelled workspace (recognizable by the repeated element in its `xspec.config.ts`) is caught at T7.4-1's decoded twin compare and a trailing byte on a `coverage`/`check` answer at both byte compares; against the built product the three fail diagnosed at the inventory decode within seconds (the product echoes `targetTags`/`tags` as configured and keeps kind lists in configured order, duplicates collapsed). T10.7-1's create-refusal arms (`test/suite/registry/section-10.7-i.ts`, FIX_PLAN Task 39) red-check through the same wrapper with three modes acting on a `review create … --json` answer at exit 1 — `codeless` rewrites a `corrupt-session` finding's code to null, `beside` appends a code-less finding after a `corrupt-session` one (12.7's code-less position), `write` leaves a stray `.xspec/reviews/stray.json` beside the refusal — each caught at its own assertion (the two answer rewrites at the first corrupt arm's count, the stray write at the existing-valid-session arm's compare-around), while `raw` passes in ~5 s as root (no permission staging is involved). Confine such rewrites to the exit-1 answers holding the finding under test: a mode that fires on every `create` — the exit-2 usage errors and the valid existing-name refusal included — is caught earlier and elsewhere (a stray file makes `list` report a corrupt session `stray` at the unknown-profile arm; a second code-less finding whose `identities` differ from the product's informational `["s"]` breaks 12.7's findings order at the decode), which proves nothing about the assertion under check. Task 40's `--file` spelling arms (T11-2, T11.3-2, T11.4-2, T12.3-1; the shared `OUTSIDE_ROOT_FILE_PATTERNS`, `insideNoMatchFilePatterns`, and `expectFilePatternUsageError` in `test/suite/registry/support.ts`) green-check through the same wrapper with two rewrites — an inside-root `--file` value holding a `.` or empty segment answered with the surface's empty form (`{"findings": [], "nodes" | "occurrences" | "views" | "files": []}`, exit 0) before any spawn, and every exit-0 `query nodes` answer's tag arrays byte-sorted with duplicates dropped whether or not `--json` is among the arguments (the surface is JSON-only, so T11-2's `queryBothForms` decodes the flag-less invocation as JSON too — a rewrite keyed on `--json` leaves that arm red) — all four passing in ~30 s as root (no permission staging is involved), while a `badpath` mode (an outside spelling's error document with `path` set to the spelling) and a `silent` mode (its stderr suppressed) are each caught at the shared helper's own assertion; against the built product every outside spelling answers as pinned on all four surfaces and every inside spelling is normalized and matched, so T11.3-2, T11.4-2, and T12.3-1 fail diagnosed at their first sharp inside spelling within seconds. T12.0-14 (`test/suite/registry/section-12.0-iii.ts`) green-checks through the same wrapper pattern with four rewrites in argv space and no answer rewriting: leading flag tokens hoisted after the command word (a value-taking flag's value carried along), the `--` token dropped with any later `-`-prefixed token judged a surplus operand by the wrapper itself (exit 2, the message on stderr, the error document on stdout only when a `--json` flag precedes `--`), and `build --file <x>` judged an unknown flag after consuming its value (stdout empty when the consumed token was the `--json`) — the whole test passes in ~7 s through a plain Vitest file binding `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` and calling each entry's `run` directly, a perturbation printing the error document for `ids -- --json` is caught at that arm, and against the built product T12.0-14 fails diagnosed at its first arm within a second (`--json ids`: the product answers "expected a command before any flags", exit 2). T2.4-2's TypeScript-only arms (`test/suite/registry/section-2.4.ts`, the post-3265b20 plan's Task 17) red/green-check through the same wrapper with one rewrite keyed on the staged `specs/A.mdx` spelling `BASE.auth!` or `BASE.auth as X`: on `build --json` exiting 1, replace the answer's findings with one `unparseable-source` finding located at the rule's zero-length offset, computed in the wrapper from the file's bytes (the byte after `!`; the offset of `as`) — the whole test passes in ~4 s as root (no permission staging is involved), while the parser's position one byte earlier (`offby1`), a one-byte range there (`nonempty`), and the product's 14.8 kept beside the 14.20 (`beside`) are each caught at their own assertion; against the built product T2.4-2 fails diagnosed at the non-null arm's count within seconds (its widened grammar parses `BASE.auth!` and reports 14.8 at the expression, and it reports the `as` forms as 14.20 with a one-byte range at acorn's position — `[94, 95)` in `d`, one byte before the rule's offset 95, and `[99, 100)` in `text(...)`, where the rule fixes `[99, 99)`), the seven ECMAScript-derived forms before it — the two-comparisons `BASE.auth<X>y` arm with its `d` range pinned exactly at the expression included — passing. T6.5-8 (`test/suite/registry/section-6.5.ts`, FIX_PLAN Task 28) red/green-checks through the same wrapper with one rewrite: a `conform` mode that snapshots `src/app.ts`, `specs/Origin.mdx`, and `specs/Target.mdx` before a performed `move` (no `--preview`), and on exit 0 drops the trailing `;` from every `import X from "…";` line absent from the snapshot (the added declaration alone), then re-runs the real `build` so the graph data matches the edited file — the whole test passes in ~5 s, while `raw` fails diagnosed at the TS arm within seconds (the product spells the added declaration `import Target from "../specs/Target.xspec";`, a statement terminator SPEC 6.5 spells none of; its MDX arms pass raw). A rewrite matched by pattern alone (every `Origin.xspec` declaration) also strips the retained origin import's `;`, and the helper's single-run isolation catches that as an edit outside the added import — hence the snapshot. +- Red-checking a certified test's arm that the conformer and every violator pass (CONF-DISC's T7-4 literal-backslash arm, for one): copy the fixture's directory (e.g. `test/fixtures/conf-disc/`) into the scratchpad, apply a one-point mutation of the behavior under check to the copy's `product.mjs` with a small Node script (assert each anchor matches exactly once; build any backslash from `String.fromCharCode(92)`), and bind the copy's `bin.mjs` in the temporary self-test above: `runProductTests` over the one test shows the earlier arms passing and the arm under check catching the mutant, its diagnosis naming the assertion. A copy outside the repository cannot resolve the CONF-DISC fixture's lazily loaded `typescript-5.9.3` (`Cannot find module`, exit 70) on any invocation that discovers a code source (`build`, `check`, `ids`, or `query edges` over a code group; `inventory` parses nothing), so give its binding `env: { NODE_PATH: "<repo>/node_modules" }` (`childEnvironment` in `test/helpers/subprocess.ts` merges a binding's `env` over the ambient environment). To drive one arm alone, `export` its module-private helper for the run with a one-line `sed` and undo it with the inverse `sed` (not `git checkout --`, which would also drop the task's uncommitted edits). +- P-3's route (CERTIFICATIONS.md §CONF-MD's staging constraint; `runP3Trial` in `test/suite/registry/section-16-p2-p3.ts`): every node's texts, source range, and children come from its own `query node` answer, decoded by `decodeNodeTextAlgebraSummary` (`test/helpers/adapters/query.ts`: own and subtree text, the 12.7 range, and the outgoing `contains` edges' targets, dependency edges passed over unread); the children are ordered by the ranges their own answers report, and the generator's `DocNode.childRefs` is never read. The CONF-MD fixtures' `query node` reports `edges: {incoming, outgoing}` of `contains` edges alone, built from the parse's section tree, which neither violator switch touches. Red-checking P-3: copy `test/fixtures/conf-md/` to the scratchpad, rewrite the copy's one `outgoing: node.children.map(...)` line, and bind the copy's `bin.mjs` through the temporary-self-test recipe above (CONF-MD loads no TypeScript, so no `NODE_PATH`): `node.children.slice(0, -1)` (each answer drops its last child's edge) fails P-3 diagnosed at seed 271828183's trial 6 of 6 — the earlier trials pass, since dropping a child whose subtree text is empty leaves the algebra intact — and `.slice().reverse()` passes (the range ordering at work; with `children.sort(bySourceRange)` disabled it fails). Timings under the namespace: `-t MD` certification ~66 s; P-3 alone against the built product ~34 s (`-t 'P-3 '` on `test/suite/section-16-p2-p3.test.ts`, passing), the file ~64 s (P-2 and P-3 passing); one mutant run ~31 s. +- Red-checking a new self-test vector (a decoder, comparator, or oracle vector in `test/self/`) against the helper change it guards: stash that helper file alone, run the one self file, and pop the stash in a single command so the restore cannot be skipped — `git stash push -q test/helpers/adapters/model.ts && (unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project self test/self/s5-output-adapters.test.ts 2>&1 | grep -E '×|Tests '); git stash pop -q` — the vectors must fail there and pass once the change is back (`git diff --stat` on the helper afterwards confirms the pop). +- Red-checking a matrix-driven product test (one registered body looping over staged shapes and destinations, e.g. T6.2-3's fourteen stagings in `test/suite/registry/section-6.2.ts`) against the built product: commit the extension first, mutate one cell's pin in place (a `sed`/Python edit of the one expectation, e.g. a shape's post-move own text or one node's category row), run the single test with the `-t '<ID> '` filter and grep the `HarnessAssertionError` line for the arm's context label, then `git checkout -- <file>` and `git diff --quiet` to prove the revert; the arms run sequentially and the body stops at the first diagnosed failure, so mutating the last arm proves every earlier arm ran green, and mutating an early cell shows which cell caught it. When the file already carries the iteration's uncommitted edits, `git checkout -- <file>` would discard those too: mutate the last arm with a unique sentinel comment instead (`"SPEC.a /*T23*/;"` for a valid marker, `"text(SPEC.a /*T23*/);"` for a valid call) by a substitution that asserts its site count first (a Python heredoc; key on the quoted statement alone — an arm whose `lines` array Prettier keeps on one line has no comma after its last entry, so a comma-suffixed key matches only the `offending` site and a guarded chain silently skips the mutation, the unmutated test then passing), revert by the reverse substitution, and prove the revert by a zero sentinel count plus `git diff --stat` returning to its pre-mutation numbers. T6.2-3 alone takes ~22 s at steady state (~90 CLI invocations; its `timeoutMs` is 240 s). T6.2-4's three stagings (the two pinned final-position shapes and the `changed` twin, ~40 invocations) take ~9 s; its `timeoutMs` is 180 s. When the file under variation already holds uncommitted work, `git checkout -- <file>` would discard it: copy the file to the scratchpad first (`cp <file> <scratch>/<task>-<name>.bak`), apply the variant, run, and `cp` the copy back in the same command, proving the restore with a `grep -c` for the variant's marker (0) and an unchanged `git diff --stat`. For a body looping over an arm table (T11-6's `codeArms`), the variant is a `.filter((a) => …)` on the loop's iterable that drops the diagnosed arm, so every later arm runs. +- T13.3-2's deletion arm (`test/suite/registry/section-13.3.ts`) red/green-checks through the same wrapper pattern with one rewrite of workspace state rather than of an answer: when a refreshing read (`ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`) exits 0 having recreated `.xspec/graph.json` from absence, the wrapper parses the file, sets `derivedFiles` to `[]`, and rewrites it through the product's own `canonicalJson` (`dist/core/canonical-json.js`, the serializer `serializeGraphData` ends in) — byte-faithful, so the product's `check` and later reads see a matching store (`graphDataMatchesCurrent` carries the stored record over) and `inventory` answers `recorded` `[]`, the conforming state SPEC 13.3 pins for an absent record. Driven through `runProductTests(binding, productTestSuite.select(["T13.3-2"]))` from a temporary self-test (deleted before committing), the whole test passes in ~20 s; against the built product it fails diagnosed at the first post-read `inventory` (the refresh writes `build`'s fresh record) within ~3 s. Since Phase 10 FIX_PLAN Task 47 the product stores the record apart and passes the arm unaided: `.xspec/graph.json` holds the snapshot with its derivation inputs, the one file a refreshing read writes, and `.xspec/record.json` the recorded derived-file paths, written only by `build` and the `rename`/`move` finishing regeneration — the rewrite above fits only a product keeping `derivedFiles` inside `graph.json`. Hand-probing the record states in a scratch workspace: delete both files for the absent record (a refreshing read then recreates `graph.json` alone; `inventory` reports `recorded` `[]` and `check` is clean); overwrite both with garbage for T6.6-6's shape-blind corrupt state (a refreshing read rewrites `graph.json` and leaves `record.json` as it is, so `inventory` still reports `recorded` unavailable and `check` the unreadable-record unit form alone). +- T11-2's malformed `--tag` arms and T11.3-3's malformed `--to` arms (`test/suite/registry/section-11.ts`, `section-11.3.ts`; the shared `expectSyntaxClassUsageError` and `stageConfigurationStateTwins` in `test/suite/registry/support.ts`) green-check through the same wrapper pattern with a rule rather than a rewrite: the wrapper judges the `--tag`/`--to` value by SPEC 1.4/11.3 itself and, for a malformed one, prints the plain usage error document (`code` and `path` null, one stderr line) and exits 2 without spawning the product — the only way the configuration-state twins (the same files under an invalid and under no `xspec.config.ts`) answer the same bytes as the configured workspace — and keeps Task 40's rewrites for the rest of T11-2; driven through `runProductTests(binding, productTestSuite.select(["T11-2", "T11.3-3"]))` from a temporary self-test (deleted before committing), both tests pass in ~6 s. A wrapper that spawns the product first is caught at the invalid twin's plain-error pin (the product answers 14.14 there), an empty stderr at the stderr check, a `path` set to the spelling at the code/path pin, a message naming the cwd at the byte compare, and an accepted spelling at its exit assertion — each a diagnosed failure naming the arm. T11-4's `--kinds` arms (`section-11.ts`) green-check through the same rule pattern: the wrapper judges the comma-separated list by SPEC 11.1 itself (an empty or foreign element malformed, a repeat collapsed), and one that strips the empty element, drops the foreign one, rejects or varies the repeated spelling's answer, judges configuration first, omits stderr, or sets `path` is caught at its own assertion; the built product passes T11-4 whole (~4 s). T12.0-10's U+2028 `--tag` and U+2029 `--to` syntax-class rows (`T12_0_10_SYNTAX_ROWS` in `test/suite/registry/section-12.0-ii.ts`) green-check through the same rule pattern: a wrapper judging a `--tag` value or a `--to` id part that carries U+2028 or U+2029 by SPEC 1.4 itself (the plain usage error, exit 2, the product not spawned) and passing everything else through lets T12.0-10 pass whole (~25 s through `productTestSuite.select(["T12.0-10"])`); one judging the tag alone fails at the U+2029 `--to` row, one judging the `--to` alone at the U+2028 `--tag` row. The built product (c62f451) fails T12.0-10 at the U+2028 `--tag` row, a diagnosed product failure: on a configured workspace it accepts both spellings (exit 0, an empty node set and an empty selection), so on the invalid-configuration twin it answers 14.14 instead. +- T11.4-4's masked-target arm (`test/suite/registry/section-11.4.ts`) green-checks through the same wrapper pattern with one rewrite of the answer: the built product reported (until Phase 10 FIX_PLAN Task 14) a byte-order-mark file's 14.20 at `{0,3}`, the mark's three bytes, where SPEC 14 pins one zero-length range at offset 0, so the wrapper rewrites that one range in `view` answers and the test passes whole against the product under it (every import-datum arm, the embedding's 14.6, and the masking arms), while rewrites trimming the semicolon-terminated declaration's range or reporting the non-canonical import's spelling as its target each fail as diagnosed; driven through `runProductTests(binding, productTestSuite.select(["T11.4-4"]))` from a temporary self-test (deleted before committing). +- T11.6-1's physical-anchoring arms (`test/suite/registry/section-11.6.ts`) red-check through the same wrapper pattern with two rewrites of an exit-0 `inventory` answer, applied only when `PWD` names the same directory as `process.cwd()` — the harness inherits the worker's environment, so `PWD` is stale except where a test sets it: `runProduct`'s `env` option merges last, and a linked working directory is staged as `cwd` plus `env: { PWD: <link path> }` (the T13.4-6 staging), since the kernel resolves the child's working directory physically while a shell's `PWD` keeps the link — `lexical` re-anchors on `PWD` (an upward search for `xspec.config.ts` by `path.dirname` from `PWD`, the relation spelled from `PWD`) and is caught at the inside-link arm, `mixed` spells the relation from `process.cwd()` to that lexically found root and is caught at the above-link arm, while `raw` and the built product pass whole in ~3 s (as root: no permission staging is involved). +- 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. The representative fixture (`runE6RepresentativeFixture` in `test/helpers/e6.ts`) runs 25 invocations (~10 s against the built product): after the file-form `move` and its `check`, step `move-section` moves `specs/sub/Moved.mdx#core.mid.tip` under `specs/Refs.mdx`'s `refs` as `refs.tip` (the built product adds `import Other from "./Other.xspec"` to Refs.mdx and `import Refs from "../specs/Refs.xspec"` to `src/app.ts`), `assertSectionMoveLanded` reads Refs.mdx's shape, and `check-post-section-move` follows; the section move stages nothing, so the fixture's S-9 records stay four. The cross-leg comparison reproduces on Linux: run the writer test with `XSPEC_E6_EXCHANGE_DIR=<dir>` (a directory the namespace's user can write), then `npx vitest run --config test/vitest.config.ts --project windows test/windows/e6-byte-identity.test.ts` under the namespace with the same variable (passes, ~10 s). Red-checking the section-move probe: a scratch stand-in wrapper that spawns `dist/cli/bin.js` and, after a successful section-form move, rewrites one inserted U+000A to U+000D U+000A and reruns `build` (so derived files match, as a product writing those bytes would leave them), bound as `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` from a temporary `test/self/zz-*.test.ts` that calls `runE6RepresentativeFixture` and `assertE6RunMatchesExchange` against that exchange (deleted before committing): a rewrite in Refs.mdx fails the premise at step `move-section`, one in `src/app.ts` fails the workspace comparison (`.xspec/graph.json` first), and a pass-through binding, or the added line moved to offset 0 or to the file's end under another identifier, passes. - Local-only suite (TEST-SPEC E-2; separately invocable, never run in CI, currently empty): `npm run test:local`. - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). +- Measuring what the fixed CI seeds actually stage through a section-16 module's private generators (e.g. P-1's `segmentCandidate`/`tagsValueCandidate` in `test/suite/registry/section-16-p1.ts`): copy the module minus its registered test (`sed '/^const P_1 = defineProductTest/,$d'`) to a `*.tmp.ts` twin beside it, append an `export { … }` of the generators and oracle functions, and drive `drawFixedSeedTrials(generator, 25)` (`test/helpers/property.ts`; 25 = the default runs per seed) from a temporary self-test (pattern above) that classifies the draws and prints counts through `console.info` under `--disable-console-intercept` — the way to confirm a generator change still reaches every certified flip class and every staging shape before running the certification; delete both files before committing (nothing imports the twin, but `tsc -p test` and Prettier would see it). P-1 itself runs ~35 s against the built product on the default seed set (150 `build` invocations) and ~15 s under `XSPEC_PROPERTY_SEED=random` (one seed per property). To count a violator's flip class in that temporary self-test, re-judge each draw with the twin's own `segmentsVerdict`/`tagsVerdict` after replacing the violator's deviating code points by `a` (no forbidden name contains `a`, and `a` neither splits a segment nor a tag): a draw is in VIOL-VALID-SEP's class when the oracle rejects it and accepts it with U+2028 and U+2029 replaced, in VIOL-VALID-CTRL's likewise for U+0000-U+0008, U+000E-U+001F, and U+007F, and in VIOL-VALID-WIDE's when the oracle accepts it and it holds U+00A0 or U+0085. The fixed seeds' P-1 counts since the re-descent's FIX_PLAN Task 3 (U+2028 and U+2029 moved into the invalid quote-and-escape group at weight 10 each, U+00A0 and U+0085 staying the valid boundaries at weight 5 each), 75 draws per property: segment draws: 11 hold U+2028 and 10 hold U+2029; SEP's class 5 (3 holding U+2028, 2 holding U+2029), WIDE's 6 (4 holding U+00A0, 3 holding U+0085), CTRL's 4; 19 accepted. Tag draws: 11 hold U+2028 and 9 hold U+2029; SEP's class 7 (4 and 4), WIDE's 3 (2 and 1), CTRL's 8; 33 accepted. Equal weights 3 to 8 left U+2029 at most one SEP-class segment draw (none at 3), and 3 to 5 none among the tag draws. The segment property falsifies first at seed 271828183: CTRL at trial 22 (shrunk to a lone U+0000), WIDE at trial 2 (shrunk to `aaa`, U+00A0, `.a`), and SEP's class first at trial 11 (`a`, U+2028, `0b`), where the built product, which predates 1.4's bar on the two code points, fails P-1 diagnosed (shrunk to a lone U+2028; ~14 s). - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. -- CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. +- CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job runs under `if: ${{ !cancelled() }}` (with `if-no-files-found: ignore`), so the exchange the E-6 writer test wrote is uploaded even when other Linux tests fail — only a cancelled job, or one that died before its suite step, ends with no artifact: the Windows byte-identity verdict is meaningful whenever the Linux writer test completed, not only on a fully green Linux run, and that test fails loudly on the missing manifest exactly when no exchange was written. +- Reading a CI run from this sandbox (no `gh` CLI; the shell cannot reach api.github.com): the GitHub MCP tools can. `actions_list` (`list_workflow_runs` filtered by branch, then `list_workflow_jobs` on the run's ID) gives each job's and step's status and conclusion, and `actions_get`'s `get_workflow_run_logs_url` gives a signed URL on `results-receiver.actions.githubusercontent.com` that `curl` fetches through the proxy: a zip holding one `<n>_<job name>.txt` log per job (0.3 MB for a green run). A push to the branch cancels the branch's run in progress (`concurrency` with `cancel-in-progress`), so a run completes only when no later push lands within ~13–20 min (run 642 at 8f5660d: `harness-self` 2 min; `suite-linux` 11 min, its `npm test` running the suite and self projects together — 99 files, 3287 tests — in 673 s; `suite-windows` 1 min after it, since it waits on the Linux leg's E-6 exchange; run 643 at c62f451, the same product and harness trees: `suite-linux` 19 min, `npm test` in 1126 s; run 853 at f8e031e, the Phase 9 re-descent's end: `harness-self` 2 min, its self project in 77 s; `suite-linux` 19 min, `npm test` (104 files, 4530 tests) in 1147 s, the suite's 28 diagnosed product failures its only failures; `suite-windows` 1.5 min after it, 3 files, 9 tests; run 871 at 79d2013, the re-descent's second plan's last harness change: `harness-self` 3 min, its self project (26 files, 4206 tests) in 133 s; `suite-linux` 19 min, `npm test` (105 files, 4551 tests) in 1106 s, the same 28 diagnosed product failures its only failures; `suite-windows` 1 min after it, 3 files, 9 tests). A CI log's lines carry a timestamp prefix and ANSI colour codes (Vitest colours its output under `CI`), and its `×` lines carry the bare test title with no `file > ` path, so the failed-ID recipe of the fourth-plan Task 24 bullet (which greps the `×` lines of a local `--reporter=verbose` log for `> ID`) finds nothing on it. On a CI log, strip the colour codes and take the IDs from Vitest's ` FAIL ` summary lines instead: `sed -E 's/\x1b\[[0-9;]*m//g' <log> | grep -E ' FAIL +suite ' | grep -oE '> (T[0-9.]+-[0-9]+|P-[0-9]+) ' | sed -E 's/^> //; s/ $//' | sort -u`. The `suite` project badge keeps out the self project's failures (` FAIL self `, read the same way) and the certification runner's per-pair ` FAIL T…` lines (a violator's expected outcomes, which carry no `> `). Checked on run 33275585205's `suite-linux` log (98 failed): 98 ` FAIL suite ` lines and 97 IDs, the one line without an ID being the E-6 writer test's (`test/suite/e6-exchange-writer.test.ts > E-6 Linux leg: …`); run 642's green log holds no ` FAIL ` summary line at all. +- Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`, and the registry modules' own generic JSON walkers, `canonicalJson`, `collectStringLeaves` and `canonicalizeJson` in `test/suite/registry/section-10.*.ts` and `section-12.0-i.ts`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. A walker its module does not export can be exercised the same way as a scratch `.ts` twin of it run under `node --experimental-strip-types` (Node 22); `deriveMdx(source)` answers `{ derives: true }` or `{ derives: false, reason, position? }`. +- Staged-scale gates (TEST-SPEC S-2, S-8): `test/self/staged-scale.ts` derives the suite's staged input maxima from what the suite stages, per kind. The generator maximum `LARGEST_GENERATED_INPUT_BYTES` (196,830 bytes: `specs/A.mdx` plus the whole mutation budget of appended depth-4096 towers, built by `largestGeneratedDocument()`) comes from the generators themselves — `NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`, `TERMINATOR_SEQUENCES`, `FUZZ_BASE_FILES`, and the byte-exact tower builder `sectionTowerSource(depth, balanced)` exported by `test/suite/registry/section-16-p8.ts` — through `DEEPEST_STAGED_TOWER` (4096) and `TOWER_BYTES` (65,542); a generator bound moves it. The deterministic maximum `LARGEST_DETERMINISTIC_INPUT_BYTES` (4,225,030 bytes: T1.3-7's chained-id tower, `depthTower(DEPTH_FLOOR)` exported by `test/suite/registry/section-1.3.ts`, built by `largestDeterministicDocument()`) is quadratic in `DEPTH_FLOOR` (2048), which alone moves it. `LARGEST_STAGED_INPUT_BYTES` and `largestStagedDocument()` are the larger of the two — today the deterministic one. `test/self/s2-workspace-builder.test.ts` stages the tower, the generator maximum, and the deterministic maximum (the largest document the suite stages, staged as T1.3-7 stages it — `specs/A.mdx` through the declarative `files` path) through the workspace builder and reads each back byte-complete with its structural counts (the input side; its exact-size pins move only when a bound does — update them deliberately), and `test/self/s8-answer-scale-capacity.test.ts` (the answer side, with P-2/P-3's `specSubtreeTexts` oracle) pins each quantity to its closed form, binds every pin to the kind it sizes (the fixed-seed replay bounds and the 500× blowup pin to the generator maximum; the 8× blowup pin to the staged maximum — T1.3-7's largest answer, ~13 MB of `view`, is ~3× its input), replays the fixed CI seed set through `drawFixedSeedTrials(generator, runs)` (`test/helpers/property.ts`; the same per-seed PRNG stream `checkProperty` uses, no property body) to confirm the staged draws stay inside the generator maxima, and drives every H-3/12.7 decoder and answer-document walk on synthetic documents at that scale: a `view` nested 8192 deep (two depth-4096 towers nested by a shuffle mutation) and the `view --text` blowup — two depth-4096 towers whose LF → U+2028 rewrite leaves every level's separators as content, ~204 MB in the `\u2028` spelling. The blowup document is written to a workspace file and streamed to stdout by a stand-in script through `runProduct`, so the test costs ~10 s and holds roughly 0.6–0.8 GB in the Vitest worker (the captured bytes, their UTF-8 string, and the parsed document coexist); run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/s8-answer-scale-capacity.test.ts`. `DEFAULT_MAX_OUTPUT_BYTES` in `test/helpers/subprocess.ts` (512 MiB) is gated by that test at no less than twice the blowup document — lower it, grow a generator's scale, or grow T1.3-7's `DEPTH_FLOOR` past the 8× pin, and S-8 fails, by design; memory for the cap is committed only as output arrives. S-2's deterministic-maximum vector ("the largest document the suite stages — T1.3-7's 2048-deep chained-id tower") is the slowest builder vector and runs under Vitest's 5000 ms default test timeout (neither the test nor `test/vitest.config.ts` overrides it): 2.3–2.5 s with its file run alone (`npx vitest run --config test/vitest.config.ts --project self test/self/s2-workspace-builder.test.ts`; 2.3–2.9 s at 44c5dad) and 3.3 s inside the full self project (the re-descent's second plan's Task 14). It has timed out only under concurrent load (load average ~11 on 4 cores, another run beside it), so rerun it alone before reading such a timeout as a failure. +- Answer-scale timings (TEST-SPEC H-11; `test/suite/registry/section-16-p11.ts`): P-11's per-invocation hang guard (`FUZZ_COMMAND_TIMEOUT_MS`, 120 s) and body budget (`timeoutMs`, 20 min) are derived in their comments from the largest answer a P-11 draw admits, measured through `runProduct` against the built product at 20ee9fd on a 4-core machine: `view --text specs/A.mdx` over `FUZZ_BASE_FILES` with `sectionTowerSource(4096, true)` appended twice to `specs/A.mdx` and every LF rewritten to U+2028 emits 58.6 MB and exits in 17.0 s (bare `view --text`: 16.5 s; the same input with LF → U+0020: 9.3 s; `view`, `occurrences`, and `at` at that scale: 1.0–1.5 s; any arm over an unmutated-scale draw: ~0.3 s), and the pinned seed set's 36 trials draw 5 tower trials (sections at most 2048 deep, no terminator rewrite) and 18 `view --text` answer arms, 2 of them over towers — a conforming P-11 sweep runs in ~35 s here. Re-measure with a temporary self-test (pattern above) that stages that input through `TestWorkspace.create`, times `runProduct(builtProductBinding(), { cwd, argv, timeoutMs: 900_000 })`, and prints through `console.info` under `--disable-console-intercept`; count what the pinned seeds stage with `drawFixedSeedTrials(genAvailabilityTrial, 12)` (`test/helpers/property.ts`). Revisit both constants whenever a generator bound (`NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`) or the product's answer time at that scale moves. P-8's per-invocation hang guard (`FUZZ_COMMAND_TIMEOUT_MS` in `test/suite/registry/section-16-p8.ts`; 60 s since the re-descent's second plan's FIX_PLAN Task 11, which added `view`, `view --text`, `occurrences`, `at`, `inventory`, `version`, and `query reachable` to P-8's menu — 10 s before) is derived the same way over P-8's own menu forms, and its kill is likewise reported unshrunk (`shrinkable: false`). Measured at that task against the built product (Phase 10's, c62f451) alone under the namespace, each case staging `FUZZ_BASE_FILES` through `TestWorkspace.create`, a staging `build`, then the case's file written `unchecked`, every menu form timed through `runProduct(builtProductBinding(), { cwd, argv, timeoutMs: 900_000 })` (~2.5 min for four cases): P-8's largest answer is `view specs/B.mdx --text` over `specs/B.mdx` with `sectionTowerSource(4096, true)` appended twice, 25.0 MB in 3.8–5.0 s — B's maximum, since the LF → U+2028 rewrite leaves B unparseable (its import line swallows the file; `view specs/B.mdx --text` then answers 478 bytes); every other menu form over either file at that scale, with or without the rewrite, at most 2.4 s (`view specs/A.mdx` 23.7 MB), `inventory` and `version` under 0.4 s; over the unmutated base, ~0.5 s. The same run re-measured P-11's maximum against today's product: `view --text specs/A.mdx` over the two towers with every LF rewritten to U+2028 now emits 125.8 MB in 25.4 s (58.6 MB in 17.0 s at 20ee9fd), so P-11's 120 s guard is now 4.7× its maximum, not the 7× its comment derives. The re-descent's second plan's FIX_PLAN Task 13 measured P-8's added forms the same way (a fresh staged workspace per form): the `rename` and file-form `move` previews, the section-form moves performed and previewed (both targets), and `show specs/A.mdx#a --json`, over `specs/A.mdx` or `specs/B.mdx` carrying the two appended balanced 4096-deep towers (A also under the U+2028 rewrite), each exit 1 with the 13.3 gate's report — 4.7 MB as JSON, 1.8 MB human — in at most 2.0 s, and over one or three appended 4096-deep parenthesis towers in `src/app.ts` exit 1 (the workspace fails) in under 0.6 s; `view specs/B.mdx --text` stays P-8's largest answer and the 60 s guard stands. +- Checking whether a staged MDX shape is well-formed (SPEC 14.20) before pinning it in a fixture: the harness declares the stock MDX 3 parser as its own devDependencies (`micromark` with `micromark-extension-mdxjs`, `mdast-util-from-markdown` with `mdast-util-mdx` — TEST-SPEC S-9 needs a means independent of the product's `remark-mdx`), so from the repository root `node --input-type=module -e 'import { fromMarkdown } from "mdast-util-from-markdown"; import { mdxjs } from "micromark-extension-mdxjs"; import { mdxFromMarkdown } from "mdast-util-mdx"; fromMarkdown(TEXT, { extensions: [mdxjs()], mdastExtensions: [mdxFromMarkdown()] })'` throws where the stock grammar rejects the shape (bare specifiers resolve from the working directory for `-e`; a script under the scratchpad cannot resolve them) — the product's 14.20 verdict up to its documented widenings of that grammar (`src/core/mdx.ts`). The product's own verdict needs no workspace: a scratch `.mjs` importing `parseSpecSource` (or `specSourceParseFailure`, the verdict alone) from `/home/user/xspec/dist/core/mdx.js` by absolute path prints a text's 14.20 finding or its document model — every section's `range`, `openingTagRange`, and `closingTagRange`, and the findings — in milliseconds (a 4096-deep tower parses in ~0.3 s). JSX tag matching lives in the mdast layer: the tokenizer alone (`postprocess(parse({ extensions: [mdxjs()] }).document().write(preprocess()(TEXT, "utf8", true)))` from `micromark`) accepts an unclosed or mismatched tag that the full parse, and the product, reject. Beware CommonMark's block structure, which the CONF-MD conformer's hand-rolled lexer does not model: a paragraph-continuation line whose first non-whitespace character is `>` interrupts as a block quote whatever its indentation (MDX disables indented code), so a text-context multi-line JSX tag cannot close on a bare `>` line, while a tag opening at line start can (the concrete JSX flow attempt). An ESM block interrupts no paragraph and runs to a blank line: an import line following text without a blank line between is paragraph text, one directly followed by a non-blank line fails acorn, and two imports on one physical line separated by U+2028 or U+2029 derive as one block with a blank line on each side (acorn reads the ECMAScript LineTerminator; automatic semicolon insertion applies). The harness's own entry for that check is `deriveMdx` in `test/helpers/mdx-derivability.ts` (bytes or a string; `{ allowances: [...] }` names the S-9 early-error allowances of `MDX_ALLOWANCES` a source relies on, applied inside the parse so a later rejection still surfaces; the verdict is `{ derives: true }` or `{ derives: false, reason, position? }`); its self-test runs alone, no namespace needed, with `npx vitest run --config test/vitest.config.ts --project self test/self/s9-fixture-well-formedness.test.ts` (~0.5 s); it judges T3-1's and T6.2-3's staged sources verbatim, imported from their registry modules (`REMOVALS_SOURCE` in `test/suite/registry/section-3.ts`; `I3_ROOM_SOURCE` and the two post-move files in `section-6.2.ts`), so a restaging of either fixture is checked by that single file before any product runs (the self project can import registry modules: `defineProductTest` only builds a frozen entry). The workspace builder (`test/helpers/workspace.ts`) applies that same check to every staged file whose path ends in `.mdx`, at staging time (`TestWorkspace.create` and `file()` alike, before the bytes are written): well-formed is the default, and a staging declares the exceptions by workspace-relative path in `mdx: { unparseable: [...], unchecked: [...], allowances: { "<path>": [...] } }` on the `create` declaration (it also governs later `file()` calls on those paths) or per call as `file(path, contents, { mdx: "well-formed" | "unparseable" | "unchecked" | { allowances: [...] } })`; a source contradicting its declaration throws `HarnessStagingError` (mode `mdx-derivability`, the message naming the path and the parser's reason and position) — a harness error the S-7 sweep, the certification runner, and the suite report as such, never a diagnosed product failure and never a skip — so every 14.20 fixture (invalid UTF-8, a byte-order mark, a syntax rejection) needs its `unparseable` entry, every early-error form its allowance, and `unchecked` is only for fuzz mutations and noise files whose derivability TEST-SPEC does not declare (never a way to hide an ill-formed fixture; S-9). The parse is in-process and cheap at the suite's scale (the 4096-deep tower ~0.3 s, the 4.2 MB deterministic maximum ~1.4 s), so no staging is exempted for size; a staging refused inside `create` leaves no temporary directory behind. Its identifier characters — expressions', ESM blocks', JSX element and attribute names', private names', and RegExp group names' — and its whitespace are Unicode 15.1's, judged code point by code point (FIX_PLAN Task 17; S-9, SPEC 14.20): TypeScript 5.9.3's ESNext tables (`ts.isIdentifierStart`/`isIdentifierPart` of `typescript-5.9.3`, equal to Unicode 15.1's ID_Start/ID_Continue), never acorn 8.17's (Unicode 17) nor the runtime's (Unicode 17 on Node 22.22.2) — so an identifier or JSX name holding U+1C89 (a Unicode 16 letter) does not derive, its staging needing an `unparseable` entry, while U+2EBF0 (a 15.1 letter, astral) derives in a JSX element or attribute name as in an expression. acorn's name, private-name, and JSX-name tokens are held to 15.1 as they finish (`Unicode151Parser`), and the MDX tag tokenizer sees each non-ASCII code point inside an open tag through a stand-in of its 15.1 class (`withUnicode151Jsx`, an astral code point read whole from the text at micromark's offset), its rejection messages naming the actual character `(U+XXXX, judged by Unicode 15.1)`; `deriveMdx` throws a plain `Error` — a harness error, never a verdict — on a runtime whose `\s` class is not Unicode 15.1's, since the stock empty-expression judgement reads it. That block of the self-test runs alone with `-t 'Unicode 15.1'` on the file (71 tests, ~1 s, half of it the scan confirming acorn admits every 15.1 identifier character); stashing the helper alone (the red-check recipe above) fails 27 of them. +- Checking whether a staged TypeScript shape — a code source or a configuration file — is well-formed (SPEC 14.20) before pinning it in a fixture (FIX_PLAN Task 14; TEST-SPEC S-9's TypeScript clause): the harness's judge is `judgeTypeScript(source, name)` in `test/helpers/ts-derivability.ts` (bytes or a decoded string, and the file name), which uses `typescript-5.9.3`'s parser at ESNext. A name ending `.tsx` parses as TSX and every other name as plain TypeScript; the parser is given a neutral name of that kind, so a `.d.ts` name is plain TypeScript too, never TypeScript's ambient declaration-file parse. Each text is read twice, as module code and as script code, by forcing the module indicator on and off through `createSourceFile`'s `setExternalModuleIndicator`. Only the parse's own diagnostics count (scanner and parser): no program, binder, or checker is created. Bytes that are not valid UTF-8, or that begin with a byte-order mark, are unparseable (SPEC 1.6). The verdict is `{ verdict: "well-formed" }`, `{ verdict: "unparseable", reason, errors }`, or `{ verdict: "one-way", accepts, reason, errors }`; `errors` lists each reading's diagnostics, each located by UTF-16 index, byte offset, and 1-based line and column. `tsDeclarationProblem(verdict, "well-formed" | "unparseable")` returns the harness error's text, or undefined when the verdict agrees with the declaration. A one-way text is a problem under either declaration; the top-level `await` forms are the cases (`await /re/;` is accepted as module code only, `let a = await / 2 / 1;` as script code only). Quick probe with no build: a scratch `.mts` that imports `judgeTypeScript` from `/home/user/xspec/test/helpers/ts-derivability.ts` by absolute path runs under `node --experimental-strip-types --no-warnings <file>.mts` and prints a verdict in milliseconds. The self-test runs alone, no namespace needed: `npx vitest run --config test/vitest.config.ts --project self test/self/s9-typescript-well-formedness.test.ts` (56 tests, ~2 s with start-up). TypeScript 5.9.3 facts the vectors rely on: it parses `await x` as an await expression outside an await context too, so that text is accepted both ways and left to the checker. It scans U+200B, U+0085, and a non-leading U+FEFF as whitespace. At ESNext it admits U+2EBF0 in identifiers (ES5 does not), but never U+1C89 (TS1127 at its first byte). Its scanner rejects `010` (TS1121) and `09` (TS1489) at the literal's first digit. An invalid `/// <reference …/>` directive (TS1084) is one of the parse's own diagnostics. It accepts `using` and `await using` declarations both ways wherever they stand — at a file's top level too (`using f = () => {};`, `await using h = () => {};`), and inside an async function — so a staging of either form needs no `ts` declaration (FIX_PLAN Task 22's T1.7-2 `src/using.ts` and T4.6-1 arms). The workspace builder (`test/helpers/workspace.ts`) applies this check at staging time (FIX_PLAN Task 15), through the same four stagings as the `.mdx` check (`TestWorkspace.create`'s initial files, `file()`, `edit()`, `copyFrom()`), before the bytes are written: every file whose name ends in one of `TS_DEFAULT_SUFFIXES` (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs` — so `.d.ts` names, `xspec.config.ts`, and every `--config` target the suite stages, all named `.ts`) is declared well-formed by default, and a staging declares the exceptions by workspace-relative path (keyed and normalized as the `mdx` declaration's keys) in `ts: { unparseable: [...], unchecked: [...], wellFormed: [...], perDraw: [...] }` on the `create` declaration (it also governs later `file()`, `edit()`, and `copyFrom()` stagings of those paths) or per call as `file(path, contents, { ts: "well-formed" | "unparseable" | "unchecked" | "per-draw" })` (`perDraw` and `"per-draw"` since FIX_PLAN Task 16o: a property draw's composed configuration or code source, judged well-formed at staging and exempt from the undeclared-staging guard's TypeScript arm, section-16 modules only); a path in two lists is refused at creation. `unparseable` is for a file TEST-SPEC declares unparseable or malformed (T1.6-5's encodings, T7-2's syntax-error and encoding configurations, T11.6-4's, T12.6-2's, and T12.7-3's malformed configurations, T14-3's and T14-5's TSX-only constructs, T14-11's (m) and (v) code sources, every code-source arm of T14-12 wherever it is staged — `unparseableDecl` and T14-11's `reassertedCase` derive it from the arm's `kind`), `unchecked` for one whose well-formedness the document does not declare (P-8's and P-11's mutated code sources and configuration, T13.4-2's truncated and garbage-overwritten derived files, T13.4-4's noise at the generated module's path, and since FIX_PLAN Task 16i T7.5-6's tampered generated module — product-written bytes plus a harness comment, staged by `file()` with `{ ts: "unchecked" }`, and since FIX_PLAN Task 16l T12.2-2's and T12.2-4 (a)'s likewise), and `wellFormed` for a code source whose name the default does not reach (a code group globs any name: §11.4's `docs/impl.mdx` — T2.1-2's `docs/EXTRA.mdx` carries its declaration in its MDX record's `ts` since FIX_PLAN Task 16b, T7.3-1's `specs/A.md` (well-formed) and T7-6's `specs/a'b.md` holding `)` (`unparseable`) are `StagedTs` records carrying theirs since FIX_PLAN Task 16h, and T7.5-5's `src/end$` and `src/end` share one well-formed `StagedTs` record (`CODE_MARKER_TO_P`) since FIX_PLAN Task 16i, and T13.4-11(b)'s `specs/A.md` is a well-formed `StagedTs` record since FIX_PLAN Task 16l, a record making its path judged). A contradiction — and a text accepted read one way only, under any declaration but `unchecked` — throws `HarnessStagingError` (mode `ts-derivability`, naming the path, the verdict's first TypeScript error, and the remedy), a harness error the S-7 sweep, the certification runner, and the suite report as such; `tsDeclarationOf(path)` reads back the declaration in effect (undefined for a path nothing judges), and `judgeTsDeclaration(key, bytes, declaration)` is the one code path. The S-2 self-test (`test/self/s2-workspace-builder.test.ts`, 21 tests) carries the builder's TypeScript vectors. Finding every staging a builder check judges in one pass, instead of one harness error per run: temporarily make the private `checkTs` (or `checkMdx`) append one JSON line per judged staging — path, declaration, the judge's verdict, `this.invocationMark.invoked`, and the running body's ID (`product-invocations.ts` keeps it in an `AsyncLocalStorage`; export a getter for the run) — to a scratch file and return instead of throwing, run the self project (S-7's sweep reaches every body's pre-invocation stagings) and the suite once each, and revert the edit. The staged-source ledger covers TypeScript through its TypeScript records (the bullet on them below; FIX_PLAN Task 16), the undeclared-staging guard through its TypeScript arm (Task 16o), and the property runner's per-draw check judges each draw's generated code sources and configurations before the body runs (Task 64; the per-draw bullet below) — P-7's capture sources among them, whose names the default does not reach: the capture arm's `drawSources` marks each `"code-source"`, and its body lists them in `ts.perDraw`, so the builder judges them at staging too. +- Task 46's malformed-value arms (T12.0-5's U+FFFD table and normalization negatives in `test/suite/registry/section-12.0-i.ts`, T6.5-5's destination arms in `test/suite/registry/section-6.5.ts`) red/green-check through the same scratch wrapper with three interventions on the argv before the spawn — `accept` strips U+FFFD from every argument (caught at the first malformed arm's exit code: the stripped invocation succeeds, or on `move` refuses with exit 1), `loadconf` prints a `configuration-error` document and exits 2 whenever the working directory's `xspec.config.ts` is the unknown-key twin and an argument holds U+FFFD (caught at the invalid twin's `code`-null pin inside `expectSyntaxClassUsageError`), and `normalize` rewrites a `./specs/A.mdx` or `specs//A.mdx` `show`/`view` operand to `specs/A.mdx` (caught at the `./` arm) — ~30 s per test as root (no permission staging is involved), `raw` passing both tests whole. A wrapper receives non-UTF-8 argv bytes already decoded to U+FFFD by Node's `process.argv`, so it can delegate the Linux byte arms but never forward them byte-exact: hand-drive those directly against `dist/cli/bin.js` with `$'a.m\377d'` quoting (and `$'\357\277\275'` for U+FFFD), which the built product answers with `argument N is not valid UTF-8`, `code` null, identically with the configuration invalid. +- Task 47's syntax-class rows (T12.0-10's `T12_0_10_SYNTAX_ROWS` in `test/suite/registry/section-12.0-ii.ts`) green-check through the same scratch wrapper with a rule rather than a rewrite: a `conform` mode that answers six argv shapes with the plain usage error document (`code` and `path` null, one stderr line) without spawning the product — `--preview` beside `--test-hold`, a `review … --name` value beginning with `.`, an `occurrences --to` value holding whitespace, a `--tag` value holding the escape character, a `--file` value beginning with `../`, and an `at` third operand that is not decimal digits — and spawns the product for everything else; driven through `runProductTests(binding, productTestSuite.select(["T12.0-10"]), { concurrency: 1 })` from a temporary self-test (deleted before committing) it passes the whole test in ~13 s as root (no permission staging is involved), while `raw` fails diagnosed at the `--test-hold` row within ~10 s. Against the built product those six shapes are the diagnosed failures: the first five load configuration before the syntax check (14.14 on both configuration-state twins), and `at`'s offset spelling is judged after the configuration search but before the parse — the plain `+7` error under an invalid configuration, 14.14 with `path` `"."` under none — while its argument count precedes the search; every other row of the table, `view <file> --file` and the `query nodes --group <code-group>` precedence arm included, passes as pinned. Since Phase 10 FIX_PLAN Task 44 all six pass against the built product: the parser judges them before the configuration is located. +- Task 49's T12.2-4 (`test/suite/registry/section-12.1-12.2.ts`) green/red-checks through the same scratch-wrapper pattern (a temporary `test/self/*.test.ts` binding `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` through `runProductTests`, deleted before committing): the wrapper spawns the product and rewrites only `check --json` on a workspace failing build's validations (a finding whose code is not `stale-output`, `policy-violation`, or `corrupt-session` present) — `conform` drops the `policy-violation` the product reports there, adds the unit-form `stale-output` (path `.xspec`, locations []) when `.xspec/graph.json` is present but unparseable, and adds one `stale-output` per `extra/E.xspec.*` file when `xspec.config.ts` lacks the `extra:` group (arm (b)'s orphans), re-sorting the array by condition ordinal (12.7); the whole test then passes in ~5 s, while `keep-policy` (the product's real answer), `no-orphans`, `no-unit`, `mismatch-beside` (a per-file 14.10 for `hi/H.xspec.ts`), and `unit-located` (the unit form naming `.xspec/graph.json`) are each caught at their own arm's assertion. +- S-6's name analysis behind T6.5-22(a) (FIX_PLAN Task 18): `analyzeNames(kind, text)` in `test/helpers/oracles/name-analysis.ts` — `kind` `"spec-source"`, `"typescript"` (a code source whose name does not end `.tsx`), or `"tsx"` (SPEC 14.20), `text` the file's decoded pre-operation content — returns the declared and referenced name sets and a TSX source's pragma factories; `nameVerdict(analysis, name)` and `addedIdentifierBreaches(analysis, added)` (empty when every added identifier passes T6.5-22(a), else one `{ identifier, clause }` per breach) read it. It throws a plain error (a harness error) on a file that is not well-formed under its grammar — a spec source is read through `readMdxTree` in `test/helpers/mdx-derivability.ts` (S-9's parse, every allowance admitted), a code source is first held to `judgeTypeScript` — so a caller judges a product-written file's well-formedness itself before analyzing it. Its vectors (`test/self/s6-name-analysis.test.ts`, 72 tests, ~1 s alone) run in the self project; to eyeball the sets for a new receiver, log them from a temporary `test/self/*.test.ts` run with `--reporter=verbose` (the helper has relative imports, so the native type-stripping probe below cannot load it). +- T6.5-22(a)'s universal assertion (FIX_PLAN Task 19) lives in the subprocess driver: `startProduct` (`test/helpers/subprocess.ts`) hands every invocation whose argv holds the token `move` to `prepareAddedImportCheck` (`test/helpers/added-import-identifiers.ts`, loaded on demand), which reads the argv by SPEC 12.0's grammar (flags anywhere, value-taking flags by name, `--` ending flag reading) and, for a performed move (never a `--preview`) under a configuration it can read (the `--config` path, else the nearest `xspec.config.ts` at or above the physical working directory; the declarative form of SPEC 7, literals as spelled; globs through `test/helpers/oracles/glob.ts`; no symbolic link followed; 13.4's derived files and Markdown emit destinations excluded), reads the discovered spec and code sources before the spawn and again once the run exits 0. Every import declaration a changed or created source gained (compared by characters; a file-form move's specifier literals blanked and its relocated file compared with its origin) is judged with S-6's name analysis of the pre-operation file, and a breach — or a rewritten source not well-formed before or after the move — makes `runProduct` / `waitForExit` reject with a `HarnessAssertionError` beginning `T6.5-22(a)` and listing `<file>: the added identifier `<id>` (`<declaration>`) is <clause>`, one line per breach. Nothing is wired per test: every move through the driver is judged, registered bodies, property draws, certification, and self-tests alike; a run exiting non-zero, a preview, and an unreadable configuration go unjudged. `readMdxTree` (`test/helpers/mdx-derivability.ts`) admits, beyond S-9's five named allowances, the early errors of a strict-mode-barred or `await` binding or reference (`READING_RULES`), so a product-written `import let from "./let.xspec"` reads (T6.5-22: well-formed under 14.20) and is diagnosed as barred, while `deriveMdx`'s S-9 verdicts, and `MDX_ALLOWANCES`, are unchanged. Its self-test `test/self/added-import-identifiers.test.ts` drives a stand-in product (a Node script applying the JSON plan in `XSPEC_STANDIN_PLAN`: files to remove and write, an exit code), the pattern for hand-probing the hook with any write a product might make; red-check: make `prepareAddedImportCheck` in `subprocess.ts` return undefined unconditionally and the self-test's nine hook vectors fail. +- T1.4-5 (re-descent FIX_PLAN Task 21; `test/suite/registry/section-1.4.ts`, its records and `runT145ConversionArm`): passes against the built product (`-t 'T1\.4-5 '` on `test/suite/section-1.4.test.ts`, ~5.6 s under the namespace; the product's fresh identifier is `a`, joined to the target's ESM block); the suite registers 338 tests from it on, 339 with the E-6 writer (341 from T6.5-22 on, re-descent FIX_PLAN Task 43: 342 with the E-6 writer, in 78 files — `npx vitest list --config test/vitest.config.ts --project suite --json` counts them without running any). Red-checking a move arm's own byte assertion against a rewrite that leaves the target not well-formed (`<O>.` then U+1C89 then `x`, `.n.2fa`): the T6.5-22(a) driver hook rejects the move invocation first (its "not well-formed under its grammar after it" line), so to reach the body's assertion disable the hook for the run with a one-line `sed` turning `if (!argv.includes("move")) return undefined;` in `test/helpers/subprocess.ts` into an unconditional `return undefined;`, and restore with `git checkout -- test/helpers/subprocess.ts` in the same command; a still-well-formed rewrite (`<O>["delete"]`, the ideograph quoted) reaches the body's assertion with the hook in place. The stand-in for (a)'s consumer compile rewrites the generated `specs/B.xspec.impl.d.ts` (the product's type companion, where `"`U+1C89`x"` stands quoted) during the `query edges` invocation, after `check` has run, so the compile alone sees it: TS1127 Invalid character in that file. +- T6.4-2 (re-descent FIX_PLAN Task 29; `test/suite/registry/section-6.4.ts`, arms 5 to 9 over fixture M's templates staged with `top.login`, records `T6.4-2 arms 5 to 9 …`): passes against the built product (`-t 'T6\.4-2 '` on `test/suite/section-6.4.test.ts`, ~8 s for its nine arms, ~3.5 s before). It red-checks through the stand-in wrapper pattern (above) with a rewrite of the product's output after a successful `rename`, applied to every file under the workspace's `specs/` and `src/`: double-quoted computed access holding U+1C89 then `x` rewritten to dot access (a product classing characters by its runtime's Unicode tables; Node 22 here carries Unicode 17) fails arm 9; `Core.top.` or `CORE.top.` followed by U+2EBF0, U+00E9, or `delete` rewritten to double-quoted computed access fails arms 7, 6, and 5; `['delete']` rewritten to `["delete"]` fails arm 5; `["2fa"]` rewritten to `.2fa` fails arm 8 — each at `specs/Refs.mdx`, the first file compared — and the U+1C89 and U+2EBF0 rewrites applied to `.ts` files alone fail at `src/app.ts`; the unperturbed product passes (~7 s per mode). Build the characters in the wrapper from their code points. +- T6.4-3's barred-character arms (re-descent FIX_PLAN Task 30; `BARRED_CHARACTER_RENAME_CASES` in `test/suite/registry/section-6.4.ts`, spread into `RENAME_REFUSAL_CASES` after the whitespace arm, so T6.6-3 and T14-7 iterate them too): against the built product T6.4-3, T6.6-3, and T14-7 each fail diagnosed at the U+2028 arm within seconds (`-t 'T6\.4-3 |T6\.6-3 |T14-7 '` over `test/suite/section-6.4.test.ts`, `section-6.6.test.ts`, and `section-14.test.ts`, ~11 s): the product performs `rename specs/A.mdx a a<U+2028>b` (exit 0, rewriting `a` and its three descendants), and likewise for U+2029, while it refuses the `"`, `'`, backslash, and `&` arms as pinned. So T14-7's later arms (its move reasons) and T6.6-3's later twins are reached against the built product only through the stand-in wrapper pattern (above) with one argv rewrite: a `rename` whose third positional operand holds U+2028 or U+2029 is spawned with each replaced by `&`, and the answer's `identities` entries get the original operand back (the product's `&` refusal modifies nothing) — through it all three tests pass, the three run with a raw and a perturbed mode in ~220 s under the namespace; leaving the `&` in `identities` is caught by T6.4-3 and T14-7 at the identities assertion, while T6.6-3 passes (it compares the preview to the real refusal; the concerned data is the home test's). Build the characters in the wrapper from their code points. +- T6.5-4's barred-character arms (re-descent FIX_PLAN Task 33; `BARRED_CHARACTER_NEW_ID_CASES` and `BARRED_DESTINATION_PATH_CASES` in `test/suite/registry/section-6.5.ts`, spread into `MOVE_REFUSAL_CASES` after the empty-`<new-id>` arm and after the lacking-`.mdx` arm, so T6.6-3 and T14-7 iterate them too; the `<new-id>` characters are section-6.4.ts's exported `BARRED_NEW_ID_CHARACTERS`): against the built product T6.5-4 fails diagnosed at the U+2028 `<new-id>` arm within seconds (`move specs/A.mdx#keep specs/B.mdx#a<U+2028>b` performed, exit 0; the four ASCII `<new-id>` arms are refused as pinned), and a hand probe (a scratch workspace staged like `MOVE_REFUSAL_FILES`, `build`, then each move) shows the product performs all sixteen barred destination-path moves as well (`specs/a<c>b.mdx` in both forms for the double quote, the single quote, the backslash, U+000A, U+000D, U+2028, and U+2029, and `specs/it's/b.mdx` in both forms), exit 0 with the files written. T6.6-3 and T14-7 still fail first at T6.4-3's U+2028 rename arm (above). All three pass through the stand-in wrapper pattern (above) with three argv rewrites: a `rename` third operand or a `move` destination's id part holding U+2028 or U+2029 is spawned with each replaced by `&`, and a `move` destination whose path part holds a 7.1-barred character is spawned with that path replaced by an out-of-group `docs/zz-standin.mdx` (the product's own `refused-invalid-destination`), every string in the JSON answer restored to the original spelling — T6.5-4 in ~22 s, T6.6-3 ~125 s, T14-7 ~65 s under the namespace. Perturbations caught: the `&` identity left in place at the move U+2028 arm's identities (T6.5-4, T14-7); the substitute path left in place at the double-quote file arm's concerned path (T6.5-4, T14-7); directory-component-only paths passed through (a file-name-only validator) at the `specs/it's/b.mdx` file arm's exit code; a stray file written at the barred path at the double-quote file arm's modifies-nothing compare; a barred-path `--preview` passed through at T6.6-3's preview exit code. Build the characters in the wrapper from code points. +- Quick probe of a harness helper outside Vitest: Node 22's native type stripping runs a scratch `.mts` under the scratchpad that imports the helper by absolute path (`node /abs/probe.mts`), so long as that helper imports only packages — Node rewrites no `./x.js` specifier to `./x.ts`, so a helper with relative imports (`test/helpers/oracles/section-move.ts` imports `./markdown.js`) fails to load this way while `test/helpers/mdx-derivability.ts` (packages only) works, the quickest way to check whether a hand-spelled MDX composition derives before pinning it in a vector. Two grammar facts such probes settled for the S-6 vectors: prose before a section's opening tag (`Lead-in prose.<S id="m">`) makes it a text-position tag that only a text-position closing tag on the same paragraph's lines can close — a `</S>` alone on a later line is a flow-position tag, interrupts the paragraph, and the parse fails with "Expected a closing tag for <S> before the end of paragraph" — whereas a flow expression at a line's start admits a tag directly after it on that line (`{text(X)}<S id="m">`), both then flow-position, so a `</S>` alone on a later line closes it and a `</S> tail` in the following paragraph does not. A `deriveMdx` rejection's `position` is the stock parser's, not necessarily the offset SPEC 14's syntax-failure rule fixes: for `{text("a") text("b")}` acorn's "Unexpected content after expression" points at the end of the parsed expression (the space after the first call, one byte before the second `text`), while the rule — the longest prefix with which some well-formed file begins — gives the second `text`'s offset (the prefix through the space begins a well-formed file, the one through `t` does not); pin the rule's offset, computed from the staged bytes, and treat the parser's position as a cross-check within a byte or so (FIX_PLAN Task 16, T2.3-3). +- S-9's per-draw check in the property runner: `checkProperty`'s `drawSources` option (`test/helpers/property.ts`; `mdxSources` before re-descent FIX_PLAN Task 64) takes a function from a draw to the files it stages as `[path, contents, label?, role?]` tuples (`DrawSource`; no entry is declared unparseable — the document declares no draw unparseable, so every generated draw must be well-formed, TEST-SPEC 16 and S-9; the fourth plan's Task 23b `"unparseable"` mark went in the fifth-plan Task 3); before the body runs — the initial trial and each shrunk candidate alike — every `.mdx` entry is judged by `deriveMdx`, and every code source and configuration file — a `TS_DEFAULT_SUFFIXES` name, or an entry whose `role` is `"code-source"` (a code group globs any name: P-7's capture sources, whose names the default never reaches) — by the TypeScript check, through the builder's own `judgeTsDeclaration(where, bytes, "per-draw", path)` (both readings, the path selecting TSX or plain); any other entry is ignored. A failing entry is a harness error `while checking trial N of M (S-9: every MDX source a draw stages derives, and every code source and configuration file it stages is well-formed TypeScript)` (or `while shrinking (S-9 …)`) carrying the seed and `XSPEC_PROPERTY_SEED=<seed>`, its `cause` a `HarnessStagingError` of mode `mdx-derivability` or `ts-derivability` naming `<path> (<label>)`; a `HarnessStagingError` thrown inside the body (the workspace builder's staging-time check) is reported `while running trial N of M (the workspace builder refused the <mode> staging of <path>)`. Each property derives its sources from the same pure function its body stages from: P-2/P-3 `stagedSources`, P-4 `stagedP4Sources` (per-edit rewrites included), P-5 `stagedPuritySources` and `stagedSectionMoveSources` (`buildSectionMove(trial).files`), P-6 `stagedReplaySources` (the trial state evolved through `applyEditToState` and `applyPureOp` exactly as the body evolves it), P-7 (both arms: the configuration — `discoveryConfig`, `captureConfig` — every `mdxSection`, and the capture arm's code sources marked `"code-source"`; the capture body declares those sources `ts.perDraw` too, so the builder judges them at staging), P-9 (edit operations replayed on a cloned model), P-12 (every composed file, each judged must-derive — its workspaces valid by construction since the fifth-plan Task 1), P-13 (its whole rendered map: configuration, `.mdx` sources, `c0/U.ts`, `c1/V.ts`), P-1 `stagedSegmentSources`/`stagedTagsSources` (from `segmentSource` and `tagsSource`); P-8 and P-11 stage imperfect input by design. The fixed form-vector sets `P1_FORM_VECTORS`, `P2_P3_FORM_VECTORS`, `P4_FORM_VECTORS`, `P5_FORM_VECTORS`, `P7_FORM_VECTORS`, `P9_FORM_VECTORS`, `P12_FORM_VECTORS` (`P12_UNPARSEABLE_VECTORS` went with the twists, fifth-plan Task 1), and `P13_FORM_VECTORS` are exported by their generator modules — built from the generators' own constants and templates (P-1's alphabet and families through `segmentSource`/`tagsSource` in every admissible quote kind; P-9 and P-13 from a fixed model through `renderP9Workspace`/`applyP9Edit` and `renderP13Files`; P-12 through the line templates `genFileLines` itself composes with; P-5 through `buildFilePieces` itself) — and judged by `test/self/s9-fixture-well-formedness.test.ts` (1986 tests since re-descent FIX_PLAN Task 63 — one P-5 import-header vector per drawn spec basename — 1922 since Task 62, 1811 before it, ~4 s alone, no namespace needed): a form added to a generator is added to its vector set, and a vector that does not derive is a generator defect to fix in the generator, never a vector to drop. The TypeScript form-vector sets (re-descent FIX_PLAN Task 64), `[name, staged path, source]` each: `P7_TS_FORM_VECTORS` (`discoveryConfig` over one and two patterns, `captureConfig` over one and two rules, `codeSource` at depths 0, 1, and 3 — every alphabet character, é spelled on every platform), `P13_TS_FORM_VECTORS` (`renderP13Files` over three fixed trials: `c0/U.ts` and `c1/V.ts`, configurations with and without the `code` block, each optional profile member omitted in turn), `P8_P11_TS_FORM_VECTORS` (the fuzz base configuration and `src/app.ts`), and one configuration vector each for P-1, P-2/P-3, P-4, P-5/P-6, P-9, P-10, and P-12 (their fixed records' `.source`) — are judged by `test/self/s9-typescript-well-formedness.test.ts` (93 tests since Task 64, ~3 s alone: 22 vectors, a coverage test requiring a configuration vector for each of P-1 to P-13, and one for the code-source paths), each well-formed both ways and through `judgeTsDeclaration(…, "per-draw", path)`; the same rule holds — a composed TypeScript form is added to its set, and one that is not well-formed is a generator defect. Red check of the TypeScript per-draw check (Task 64): drop the closing `)` from P-13's `renderConfig` template on a scratch-backed copy — the three P-13 configuration vectors fail, and P-13 alone against the built product fails in ~30 ms with `harness error while checking trial 1 of 8 (S-9: …) with seed 271828183`, no product run; corrupting P-7's `codeSource` fails its three codeSource vectors and the capture arm's trial 1 alike (`<source path> (code source)`), and with that arm's `drawSources` emptied the builder refuses the source's `ts-derivability` staging at trial 1 instead; restore with `cp` and `cmp`. A template-only refactor of a generator (P-12's line templates) is proven draw-preserving by a temporary self-test writing `JSON.stringify(drawFixedSeedTrials(genP12Trial, 25))` to a scratch file with the change and, after `git stash push` of the module, without it, the two files compared by `cmp` (the stash popped and the test deleted in the same command); a hook's wiring inside a registered property is red-checked by mutating its staging function (a `</S>`-dropping `segmentSource`) and reading the S-9 harness error at trial 1 before the product runs, the module restored from a scratch copy. Grammar fact those vectors settled: CommonMark inline delimiters pair across a JSX tag — an emphasis (`*`, `_`) or link (`[`, `]`, `(`, `)`) with one end inside an inline section and the other outside, on one line or across a paragraph's lines — and mdast-util-mdx-jsx then rejects the element ("Expected the closing tag … after the end of emphasis"), so generated prose that shares a paragraph with inline tags must exclude those six characters (P-2's alphabet spells `;`, `,`, `%`, `@`, `?`, `$` in their places; block-level punctuation such as `#`, `-`, `|`, `:`, `!` cannot pair across a tag). A second grammar fact: the stock grammar derives a blank line inside a flow tag — inside a quoted attribute value and between attributes alike — so P-1's `withoutBlankLineHazards` repair is conservative rather than an S-9 necessity, and no such shape belongs among the declared-unparseable vectors. +- Section-16 property timings against the built product at the fixed seeds, each run alone under the unprivileged namespace (`-t '<ID> '` on the suite project): P-2 ~42 s and P-3 ~36 s when they passed (since FIX_PLAN Task 11 both fail diagnosed at seed 271828183's trial 6 — P-2 in ~90 s and P-3 in ~80 s, shrinking included; see the Task 11 bullet below; both pass again since the post-re-descent Phase 10 plan's Task 13, P-2 ~20 s and P-3 ~16 s, the file ~40 s; at re-descent FIX_PLAN Task 62 this machine measured P-2 ~30 s and P-3 ~25 s, the file ~60 s, alike with and without that task's change), P-4 ~2.5 min (ending at its known tag-set decode failure), P-5 ~2 min (arm 1 fails at that same decode; arm 2 alone ~35 s) — both pass since Phase 10's node-tag-set fix (post-re-descent plan Task 3): P-4 ~82 s and P-5 ~83 s, both arms, in one 10-file suite run on 4 workers (since re-descent FIX_PLAN Task 63 P-5 fails diagnosed against the built product, ~165 s alone with shrinking: its purity arm passes and its section-move arm is falsified at seed 271828183's trial 1, shrunk to a created-target move where T6.5-22(a)'s driver hook catches the product's stem-derived `import require from "./require.xspec"` in the created file — a hand-staged probe of the shrunk workspace shows the same bytes, `check` exiting 0; through a scratch stand-in that renames each barred added binding to a fresh one, respells its uses, and re-runs the real `build`, P-5 passes both arms in ~115 s; at the fixed seeds the section-move arm draws 9 created-target trials of 24, adding 12 imports, 10 of them steering a basename-derived binding onto a barred basename — measured through the twin recipe above, classifying each draw's added imports from `forEachRef` over the moved subtree), P-6 ~41 s, P-7 ~37 s, P-8 ~123 s of test time (~131 s wall with Vitest's start-up; passing, at the re-descent's second plan's FIX_PLAN Task 13 — 54 menu forms, 2–6 drawn per trial, 162 picks running 210 drawn-form invocations, the review composites' steps included — against its 600 s `timeoutMs`: a hang's diagnosis, one unshrunk 60 s per-invocation guard on top of the sweep, is ~185 s, and a falsification's 100 shrink executions ~340 s at the mean trial of ~3.4 s; ~134 s at that plan's Task 12 — 41 forms, 223 invocations, where the 600 s was raised from 420 s — a stand-in falsification at seed 271828183's trial 8 took 107 s with 28 executions; earlier ~94 s at that plan's Task 10 and 91 s at its Task 11, 31 forms and 129 picks against 420 s, and ~57 s when the FIX_PLAN Task 12 bullet below measured it), P-9 ~86 s, P-12 ~2.6 min since the fifth-plan Task 1 (158 s for its 1339 `at` invocations; every trial now builds, presumably letting `at` take the store-backed fast path above — ~5–6 min before, when most trials did not build and a wrapper `timeout` under 400 s cut it off), P-13 ~37 s; under `XSPEC_PROPERTY_SEED=random` P-2, P-3, and P-5's arm 2 take about the same. All eleven section-16 files together (`test/suite/section-16-*.test.ts`, 13 tests) under the namespace on 4 workers take ~495 s at re-descent FIX_PLAN Task 64: P-1 (seed 271828183, trial 11 of 25, shrunk to a lone U+2028 that `build` accepts) and P-5's section-move arm (seed 271828183, trial 1 of 8, the barred `import require from "./require.xspec"`) fail diagnosed, the other eleven pass. The filter `-t 'P-2 '` also matches T11.4-6 (its title cites the P-2 oracle) and `-t 'P-12 '` a second test — read the failed tests' names in the summary, not the counts. To run P-5's arm 2 alone (arm 1 fails first against the product), temporarily guard arm 1's `checkProperty` call in `test/suite/registry/section-16-p5-p6.ts` with `if (process.env["XSPEC_TMP_SKIP_ARM1"] === undefined)`, run with that variable set, and restore the file with `git checkout -- test/suite/registry/section-16-p5-p6.ts` before committing anything; to see which boundary layouts the seeds drew, add a temporary `console.info(layoutName(trial.layout) + " | " + built.description)` after the `buildSectionMove` call in `runSectionMoveTrial` and run with `--disable-console-intercept`, and to drive one inline family alone — the joined-close (`body</S>`) layouts, say — replace the family pick in `genMovedLayout` with `choices.pick(INLINE_TEXT_BOUND_LAYOUTS.filter((l) => l.closeJoined))` and raise that option's weight; both families (every remainder and lead kind, U+000B/U+000C included) passed against the built product this way at the fixed seeds (FIX_PLAN Task 10). The self project (`unshare --map-user=1000 --map-group=1000 -- npm run test:self`) reports 20 files, 1133 passed, 1 skipped in ~100 s at this point. +- FIX_PLAN Task 11 (the P-2/P-3 generator's refined forms; `test/suite/registry/section-16-p2-p3.ts`): P-2 and P-3 now fail diagnosed against the built product on the fixed seeds (`-t 'P-[23] property'` on the suite project, both in one run of ~170 s; replay with `XSPEC_PROPERTY_SEED=271828183`) — the product leaves an import that follows a comment inside its ESM block unrecognized (an own-line `// note` before it, or a `/* c */` on its line: the binding is unbound, its references answer 14.8 `invalid-argument`, and the declaration stays in the emitted Markdown, which is P-2's byte compare on `specs/C.mdx` at trial 6 of 12), while a `;`, a trailing `// note` or `/* c */`, and an own-line comment after the last import are handled; and it refuses `{}` and `{` U+00A0 / U+FEFF / U+2028 / U+2029 `}` as 14.16 `invalid-construct` (P-3's `build` at trial 6 of 6), while the line-comment containers, the run-on `{// c}` form, block-comment sequences, and embeddings with whitespace and comments beside the call build exit 0. Hand-staging each ESM-block form in its own scratch workspace against `dist/cli/bin.js build --json` (a `.mts` under the scratchpad writing the files and spawning the product) isolates such a deviation in a few seconds. Two grammar facts the per-draw S-9 check surfaced at the fixed seeds, both guarded in the generator (`opensBlockConstruct`, applied by `keptProse` to every line lead): a line opening a CommonMark list item — `-`, or one to nine digits then `.` or `)`, followed by whitespace, after any indentation, since MDX disables indented code — makes the continuation line of a later multi-line expression a lazy line the expression grammar rejects (`unexpected-lazy`, whether the container is a comment or an embedding), and an ATX heading line cannot host a multi-line container at all (`unexpected-eof`); `-a`, `9.a`, `#{`, `---`, `- - -`, a `-` alone, and a setext underline are harmless. The run-on rule's mechanism in the stock parser: `micromark-util-events-to-acorn`'s emptiness test deletes a line comment only when a line ending ends it, so at `{// c}` the first `}` is inside a non-empty comment and swallowed, and the container runs to the next `}`, where `// c` plus the terminator is empty — the judgement the CONF-MD conformer's `skipEcmascriptTrivia`/`scanExpressionContainer` (`test/fixtures/conf-md/product.mjs`) reproduce by ending line comments at U+000A or U+000D alone; T2.7-4's U+2028/U+2029 negative arms lie outside that fixture's scope. Measuring which forms the fixed seeds reach needs no twin module for this generator: `generatedDoc` and `sourceOf` are exported, so a temporary `test/self/*.test.ts` can import them directly and count regex matches over `drawFixedSeedTrials(generatedDoc, 12)` (P-2's runs) under `--disable-console-intercept`; at the current seeds every refined form is drawn at least once across the 36 documents. Since re-descent FIX_PLAN Task 62 the whitespace drawn between a comment's braces is P-2's full set of 19 code points (`ECMASCRIPT_ONLY_WHITESPACE`, exported and pinned by an S-9 self-test; the block-comment sequence gaps `SEQUENCE_GAPS`), each pick one draw whatever the set's size, so the change kept every fixed-seed document's structure (dumping `drawFixedSeedTrials(generatedDoc, 12)` with and without it: 15 characters substituted in 6 of the 36 documents, nothing else) and the CONF-MD outcomes with it; the fixed seeds reach 11 of the 19 (U+2001–U+2003, U+2005, U+2006, U+2008, U+200A, U+2028, U+2029, U+202F, U+205F, in 7 whitespace-only containers and 4 sequence gaps), the S-9 vectors and T2.7-4 every one. +- FIX_PLAN Task 12 (P-8's refined mutation classes — fragment, braces, esmBlock — in `test/suite/registry/section-16-p8.ts`): P-8 alone against the built product takes ~57 s at the fixed seeds and about the same under `XSPEC_PROPERTY_SEED=random` (`-t 'P-8 '` on the suite project under the namespace). Measuring which mutation kinds and modes the pinned seed set reaches needs no twin module: a temporary `test/self/*.test.ts` importing `genFuzzTrial` and `drawFixedSeedTrials` counts the descriptions of `trial.mutations` over `drawFixedSeedTrials(genFuzzTrial, 12)` (36 trials, 64 mutations since the re-descent's second plan's Task 12 moved the draws with 2–6 menu forms per trial) under `--disable-console-intercept`, classifying each by its leading phrase (`splice at`, `insert ill-formed UTF-8`, `insert a balanced fragment`, `rewrite the container`, `the declaration line`, ...); at the current seeds every kind and every target file is drawn — a boundary expression replacing a container's content and statements directly after a declaration among them — but not every mode of the refined classes (no deleted closer, ECMAScript-whitespace singleton, split or joined block, or statement at a drawn line start; the earlier draws reached a deleted closer and a split block but no boundary expression, and both reach only the U+200B singleton, a non-whitespace one) — those are reached under random seeds. The largest file the fixed seeds stage is 22,528 bytes (the depth-2048 unclosed section tower), before and after that task. The refined classes anchor on the evolving bytes (a `{…}` container, a `<S ` tag, an `import`/`export` line start) and seed the anchor when a file holds none, so a change to `FUZZ_BASE_FILES` changes which anchors exist but never makes a mode inapplicable; the largest refined draw adds 76 bytes, far under a tower, so the S-8 generator maximum (`LARGEST_GENERATED_INPUT_BYTES`) is unmoved. +- P-8's fixed-seed draw guard (the re-descent's second plan's FIX_PLAN Task 10; TEST-SPEC §16 P-8, E-5): `test/self/p8-fixed-seed-draws.test.ts` replays `drawFixedSeedTrials(genFuzzTrial, P8_RUNS_PER_SEED)` — P-8's own draws, at the run count `section-16-p8.ts` exports and `P_8` itself passes (12 per seed; no `seeds`, so the default set) — and asserts, before any product runs, (1) the giant-nesting floor: an `.mdx` target's nesting draw (`append`/`replace with a depth-<n> balanced|unclosed section tower`) whose `sectionTowerSource(depth, balanced)` bytes survive intact in the trial's staged file reaches `GIANT_NESTING_FLOOR` (2048) — a TypeScript bracket or parenthesis tower never counts, nor a tower a later mutation of the same file undid (a third test pins both exclusions on synthetic trials); and (2) every exported `COMMAND_MENU` form is drawn at least once; a fourth test (the re-descent's second plan's FIX_PLAN Task 11) pins (3) `jsonOutputInEffect`, P-8's reading of SPEC 12.0's JSON-output rule — `--json` read as a flag (never another flag's value, never after `--`; arity from `VALUE_FLAGS`, exported by `test/helpers/added-import-identifiers.ts`) or a JSON-only surface (`query`, `occurrences`, `view`, `at`, `inventory`, `version`, `review export`) — over fixed vectors, and that every JSON-only surface the menu holds (`review export` among them since that plan's Task 12) has a form without `--json`; a fifth (Task 12) pins (4) `armSteps`, the review composites: every menu form holding the `<session>` slot (`SESSION_SLOT`) — `review status`, `show`, `split`, `resolve`, and `export`, each with and without `--json` — runs `review create --strategy audit --name r1 --json` first, and an item form (`<item-id>`, `ITEM_ID_SLOT`: `show`, `split`, `resolve`) then a JSON read — `review status r1 --json` for `split` (its first item), `review next r1 --json` otherwise — whose item, else `p8-absent-item`, fills the drawn step's slot; no slot ever reaches the product; a sixth (that plan's Task 13) pins (5) the rest of the sweep's shape: every command — `review` and `query` per subcommand — runs with JSON output in effect at least once (`build` through `FIXED_BUILD_ARM`, the exported fixed `build --json` arm, the menu holding its bare form; `show specs/A.mdx#a --json` was added for `show`, the one command lacking such a run), and the mutating commands' forms, classified by operand (a `#`-bearing target makes a move the section form): `rename` and file-form `move` performed with `--json` and previewed with and without it, section-form `move` performed and previewed with and without `--json` into a target file `FUZZ_BASE_FILES` holds (`specs/A.mdx#c` to `specs/B.mdx#c`) and one it lacks (to `specs/C.mdx#e`, created), its origin a section the base spells, and no form carrying `--test-hold`. Run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/p8-fixed-seed-draws.test.ts` (~2.5 s, no product). At the current generator (2–6 menu forms per trial since Task 12, `MAX_COMMANDS_PER_TRIAL`; 2–4 before) the floor rests on one draw, seed 314159265's trial 3 (`specs/B.mdx: replace with a depth-2048 unclosed section tower`, the only section-tower draw at 12 runs per seed), so the floor test fails at 2 runs per seed or fewer; all 54 menu forms (41 before Task 13's rename and move previews, section-form moves, and `show --json`; 31 before Task 12's ten review composites, 20 before Task 11's read surfaces) are drawn at 11, 12, 16, and 20 runs per seed (162 picks at 12, six forms drawn once — the bare held-target section move, `ids --json`, `check --json`, the bare `rename` preview, `show specs/A.mdx#a`, and `version --json`; 53 of 54 at 10 runs, `version --json` the last; 12 of 54 at 1). `pick` consumes one PRNG value whatever the menu's length (two or more entries) and `listOf` sizes the list by its own `boolean` draws, so adding menu forms changes only which forms the existing picks land on, never the mutation draws or later trials — and since the picks are fixed values, a menu of L forms is fully drawn exactly when they hit every residue modulo L, whatever the forms' order: at 12 runs per seed (2–6 per trial) the 162 picks hit every residue modulo 41, 43, 48, 54, and 59 but miss one modulo 53 and two modulo 55, so the twelve forms Task 13 planned (53 in all) left one form undrawn wherever they stood, and the thirteenth (`show --json`) made the menu whole without moving a mutation draw; measure a candidate length with a temporary self-test drawing a twin of `genFuzzTrial` (its mutations through the exported `drawFuzzMutation`, then `listOf` picking from L dummy entries) through `drawFixedSeedTrials` and counting the distinct entries drawn. Raising the count instead (2–8) drew all 53 at 12 runs but moved later trials' mutations, dropping kinds the module header's dry run lists (the spread attribute, the EOF-unbalanced braces, the UTF-16BE BOM); raising `listOf`'s `max` or weighting the pick shifts every later trial of a seed, the floor's trial included. Red-checking it: temporarily set the file's `REPLAY_RUNS` to a literal (3 to 7 fail the coverage test alone, 2 or fewer both), or make `genFuzzTrial` pick from `COMMAND_MENU.slice(1)` (the coverage test alone fails, `build` undrawn), restoring the file from a scratch copy afterwards; the fourth and sixth tests fail when `jsonOutputInEffect` is reverted to the flag-only `argv.includes("--json")` (the fourth on ten vectors, `version` first; the sixth on `occurrences`, `view`, and `at`, which the menu runs only without `--json`); dropping the bare `["version"]` form fails the fourth (its bare-form check names `version`) and the coverage test (53 forms leave one undrawn, above); the fifth fails alone when `armSteps` returns every form as its one step or `split` reads `next`, and with the fourth and the coverage test when the bare `review export` form is dropped; the sixth fails alone when a section form gains `--test-hold`, when the created target is respelled as a held one (`specs/B.mdx#e`), or when an origin names no base section (`specs/A.mdx#zz`), and with the coverage test when `show --json` or a section form is dropped. +- P-8's review composites (the re-descent's second plan's FIX_PLAN Task 12; `armSteps`, `yieldedItemId`, and `runFuzzForm` in `test/suite/registry/section-16-p8.ts`): at the fixed seeds the item path — a composite's read yielding an item and the drawn command running on it — is reached on two trials since that plan's Task 13 (54 menu forms; the mutation draws unmoved): seed 271828183's trial 8 (`specs/B.mdx: replace the whole file with 0 drawn byte(s)`, which leaves the workspace valid) — the `show --json` composite's create exits 0, `review next r1 --json` yields `item-3`, and `review show r1 item-3 --json` exits 0; the later `split --json` composite's create is refused (exit 1, `r1` exists), `review status r1 --json` yields `item-1`, and `review split r1 item-1 --json` performs (exit 0) — and seed 161803399's trial 4 (`specs/A.mdx: insert empty braces "{// c\n}" at 165`, also valid) — the `split --json` composite (create exit 0, `item-1`, split exit 0), then the bare `show` composite (create refused, `item-3`, show exit 0); that trial then performs the created-target section move `move specs/A.mdx#c specs/C.mdx#e` (exit 0, T6.5-22(a)'s driver hook judging its added imports). At Task 12's 41 forms the same trial 8 reached it through the bare `split` and `show` composites. The fixed seeds' trials whose mutated workspace builds (exit 0) are seed 271828183's trials 5, 8, and 12 and seed 161803399's trial 4 (a temporary self-test staging each `drawFixedSeedTrials(genFuzzTrial, 12)` trial over a built base and running `build`); only those can reach an item, so a draw shift that leaves none of them an item composite loses the path. Of the 30 `review create` runs (28 composites' first steps, 2 static forms), 2 exit 0, 20 exit 1 (the 13.3 gate over a failing workspace, or `r1` already created), and 8 exit 2 (a mutated configuration); no `resolve` composite reaches an item (40 composites at Task 12: 2 creates exit 0, 29 exit 1, 9 exit 2). Over the generator maximum (two depth-4096 balanced towers appended to `specs/A.mdx` or `specs/B.mdx`) every composite step answers within 2.1 s: `review create --json` the gate's report at `check --json`'s 4.7 MB, every step naming the session a ~450-byte exit-2 error document (a scratch self-test running each `armSteps` step through `runProduct` with `builtProductBinding()`). A draw shift — an added menu form changes which forms the picks land on, a count or weight change moves every trial — can lose the item path silently, since the self project runs no product. Re-check it after one either with a temporary log in `runFuzzForm` after the item read (each composite step's argv, exit code, and item, guarded by an environment variable; run `-t 'P-8 '` with `--disable-console-intercept`; delete before committing) or with a scratch stand-in wrapper (the red-check pattern above) that spawns `dist/cli/bin.js` and exits 3 whenever `review split` names an item other than `p8-absent-item`: P-8 must then be falsified at an item-path trial — today seed 271828183's trial 8, shrunk to `specs/B.mdx: truncate to the first 0 byte(s)` with `build` and the split composite, its diagnosis naming `review split r1 item-1 --json` as step 3 of 3 (~107 s). +- FIX_PLAN Task 13 (T1.6-5's pinned 14.20 offsets, `test/suite/registry/section-1.6-1.7.ts`): T1.6-5 now fails diagnosed against the built product on its first arm and, probed by hand through `dist/cli/bin.js build --json` over the same four staged files, on every arm: the product's start offsets are the pinned ones (21 and 0 for the spec sources, 29 and 0 for the code sources — `BAD_UTF8_*_OFFSET` is `Buffer.byteLength` of each staged prefix, `BOM_OFFSET` 0) but its ranges were not empty (`{21,22}`, `{0,3}`, `{29,30}`, `{0,3}`) where SPEC 14 pins one zero-length range — zero-length since Phase 10 FIX_PLAN Task 14, and T1.6-5 passes. Green check: a stand-in (the recipe above) that sets `range.end = range.start` on every `unparseable-source` finding's location passes all four arms in mode `conform` while mode `raw` fails as diagnosed; `-t 'T1.6-5 '` on `test/suite/section-1.6-1.7.test.ts` runs the test alone in ~5 s. +- FIX_PLAN Task 14 (T1.7-2's further source-range forms, `test/suite/registry/section-1.6-1.7.ts`): the test stages 17 code files (19 occurrence records) and compares every record before its verdict, so one run lists each deviating form; against the built product it fails diagnosed on exactly four (`-t 'T1.7-2 '` on `test/suite/section-1.6-1.7.test.ts`, ~6 s): the constructor's marker is sourced at the bare class `src/ctor.ts#Ctor` with the class's range (SPEC 4.6/1.7: `Ctor.constructor`, the member's range), `export @dec class` and `export function` ranges start at `export` (SPEC 1.7: at the `@` / at `function` — the product's `unitRange` excludes only the `export default ` prefix of a merged named default export), and a `.d.ts` file's body-bearing function is a unit `#f` (SPEC 4.6: no unit, the whole-file range). The decorated class/member from their first `@`, `@dec export class`, `@dec export default class`, `export default () => {};` through its `;`, and legacy `module A.B` all match. Green check: a stand-in (the recipe above) that, on `occurrences` exiting 0, re-sources `src/ctor.ts#Ctor` at `Ctor.constructor` with the member's range (`indexOf("constructor")` through the member's `}`), starts the `expdec`/`spaced` ranges at `@dec class` / `function spaced`, re-sources `src/types.d.ts#f` at the file with `{0, byteLength}`, and re-serializes with `JSON.stringify(doc, null, 2) + "\n"` (the product's form: sorted keys, two-space indentation, trailing newline) passes the whole test in mode `conform` while `raw` fails as diagnosed, ~3 s for both modes as root (no permission staging is involved). Hand-probing such forms: the product parses `export @dec class`, `@dec export default class {}`, and the legacy `module` keyword under its TypeScript 5.9 without a finding, so `build` on the fixture exits 0 — the test's premise. +- FIX_PLAN Task 18 (T2.7-1's fragment and attribute-expression arms, T2.7-3's spread grammar pair; `test/suite/registry/section-2.7.ts`): T2.7-1 passes against the built product with the new arms (the product reports a fragment as one 14.16 from `<>` through `</>`, creates no node for it, and keeps it byte-for-byte in the enclosing text; `<S id="x" d={1}>` is 14.8 alone and `<div a={1}></div>` one 14.16). T2.7-3 now fails diagnosed on its `{...a, b}` arm alone (`-t 'T2.7-3 '` on `test/suite/section-2.7.test.ts`, ~5 s): the product reports the 14.20 at the parser's position with a non-empty range (`{60, 61}`, the `b` after the comma) where SPEC 14 pins the zero-length range at the comma's offset (`{58, 58}`; the same product behavior T1.6-5, T2.3-3, and T2.4-2 fail on); its other arms — the exact braced-construct ranges of `{...extra}` and `{...(a, b)}` included — pass. Green check: a stand-in (the recipe above; `task18-standin.mjs` in the scratchpad) that relocates the `unparseable-source` finding to a zero-length range at the comma passes the whole test in mode `conform`, while `raw`, `offby1`, and `nonempty` fail at that one assertion. Both staged MDX halves of the pair were confirmed with `deriveMdx` before pinning (`{...(a, b)}` derives; `{...a, b}` is rejected at the stock parser's position 60, two bytes past the rule's offset). The fragment probe: `<>Fragment text.</>` on its own line parses as a paragraph holding one `mdxJsxTextElement` spanning exactly `<>` through `</>`, so the pinned range is the same as a flow fragment's would be. +- FIX_PLAN Task 19 (T2.7-4, the comment forms and brace content classes; `test/suite/registry/section-2.7.ts`): T2.7-4 fails diagnosed against the built product (`-t 'T2.7-4 '` on `test/suite/section-2.7.test.ts`, ~5 s) at its first arm, the shared emitting workspace's `build`: the product reports `{}`, `{ }`, and `{` U+00A0 / U+FEFF / U+2028 / U+2029 `}` as 14.16 `invalid-construct` ("an empty expression container"), while `{ /* a */ /* b */ }`, `{// c` U+000A `}`, `{// c` U+000D `}`, and the run-on `{// c}` U+000A `}` build exit 0 with the pinned Markdown bytes, own text, and `comments` ranges; the expression-beside-comment arm (`{/* a */ 1}`, T2.7-1's enclosed-construct machinery, now taking the test ID) passes; every 14.20 arm fails on its range alone — a non-empty range at the parser's position starting at the pinned offset (U+0085 `{65, 67}`, U+200B `{65, 68}`, the `{// c` U+2028/U+2029 `}` U+000A `}` forms `{72, 73}`) and, for `{// c}` with no later `}`, `{48, 49}` at the first `}` where SPEC 14 pins the file's byte length, 50 — its condition counts and the bare `view --text` (exit 1, no view for the unparseable file) pass. The whole body was exercised in one run by a temporary, uncommitted edit filtering `T2_7_4_COMMENT_FORMS` to the four accepted forms and catching the 14.20 range assertion (logging its message under `--disable-console-intercept`), then `git checkout -- <file>`; hand-staging each arm in its own scratch workspace against `dist/cli/bin.js build --json` and `view --text` (a `.mts` under the scratchpad) gave the per-arm table in seconds. Every staged shape was confirmed with `deriveMdx` first: the ten comment forms derive inline and own-line alike, and the five 14.20 forms are rejected — the stock parser's position being the code point for U+0085/U+200B and the first `}` for the U+2028/U+2029 forms (the rule's offsets), but also the first `}` for `{// c}` with no later `}`, where the rule gives the file's byte length (at the end of the file the parser reports the crash it recorded at the swallowed brace). Current verdict (re-descent FIX_PLAN Task 24, 50264f4): T2.7-4 passes against the built product — the failure above was an earlier product's — with fifteen more comment forms, `{` X `}` for each Zs code point Unicode 15.1 places outside Latin-1 (files `specs/zs-<hex>.mdx` in the shared emitting workspace), and the 14.20 arm `{` U+180E `}` at offset 65, which T14-11 re-asserts as (w.9) (the stagings after it, T14-12's included, moved up by one); T14-11 passes too. `-t 'T2\.7-4 '` on `test/suite/section-2.7.test.ts` runs ~18 s (the body ~10 s), the whole file ~32 s, `test/suite/section-14.test.ts` whole ~153 s. Red check for arms the product passes: a scratch stand-in that, before spawning `dist/cli/bin.js`, swaps the form under check in every `.mdx` under the cwd (`{` X `}` for `{` U+180E `}`, all of them three UTF-8 bytes so offsets keep; or `{` U+180E `}` for `{` U+3000 `}`, a product reading U+180E as whitespace) and writes the original bytes back after the product exits, driven through the temporary `runProductTests` self-test: each of the fifteen swaps fails T2.7-4 at the emitting `build` (its diagnosis naming `specs/zs-<hex>.mdx` at offset 20), the reverse swap fails T2.7-4 at the U+180E arm's `build --json` and T14-11 at (w.9), and the plain pass-through passes both (~38 s for seventeen T2.7-4 runs). +- FIX_PLAN Task 20 (T3-7, ESM-block comments and the `;`-terminated import; `test/suite/registry/section-3.ts`): T3-7 fails diagnosed against the built product (`-t 'T3-7 '` on `test/suite/section-3.test.ts`, ~5 s) at its first assertion, `build`: the product reports the `d` references rooted at `E` and `B` as 14.8 `invalid-argument` ("no spec-module import in this file binds") — it recognizes no import declaration that follows a comment inside its ESM block (the line after an own-line `// note`; `/* c */ ` before the declaration on its line), while a declaration followed by ` // note` on its line and a `;`-terminated declaration are handled. The rest of the body was proved green by the diagnostic-variant recipe: a temporary, uncommitted edit filtering `T3_7_ARMS` to its single-line arms (`composeT37Fixture(T3_7_ARMS.filter((arm) => arm.lines.length === 1))` — the trailing-comment and semicolon arms), the test then passing in full (byte-exact Markdown; the root's own and subtree text through `query node`; `view --text`'s `imports` with the `;`-inclusive range, `comments`, and root own text), then `git checkout -- <file>`. The fixture is a per-arm table `composeT37Fixture` lays out (each arm's compiled lines hand-derived), so a hand-staged twin needs only the arms' lines: a scratch `.mts` importing `deriveMdx` by absolute path to judge each file, writing the workspace, then `dist/cli/bin.js build --json`, `query node specs/main.mdx`, and `view --text` over it gave the per-arm verdicts in seconds. +- T4-2 (`test/suite/registry/section-4.ts`; FIX_PLAN Tasks 21 and 25): against the built product T4-2 fails diagnosed at its first import-type arm (`type T = import("./NAME.xspec").default`, the fifteenth of `XSPEC_RULE_ARMS`: `build --json` exits 0 with no finding), so the arms after it never run in a normal run (`-t 'T4-2 '` on the suite project ~25 s; the whole body, 42 negative and five positive arms, ~19 s). The product fails exactly the eleven arms Task 25 added — the two import types, the three string-named module declarations, and the derived-path table's import-type and string-named-declaration rows — each `expected exit code 1 … got exit code 0`, and passes every other arm, the escape-spelled one included. Per-arm verdicts in one run: a temporary edit wrapping the arm loop's `runInvalidTsImportArm` call in try/catch, collecting one `PASS`/`FAIL <name> :: <message>` line per arm and writing them after the loop with `(await import("node:fs")).writeFileSync(<scratch file>, …)` (the suite intercepts console output, so `console.log` shows nothing there); restore the file from a scratch copy and `cmp`. A stand-in (the wrapper recipe above) whose base fix answers `build --json` over a `src/app.ts` beginning `type T = import(`, `let v: typeof import(`, `declare module "`, or `module "` with one `invalid-import` finding at `[0, <first line's byte length>)`, exit 1, passes the whole test (~19 s per mode); on top of it, perturbations of the no-other-construct arm's `src/c.ts` are each caught at their own assertion — `build` exiting 1, an extra `references` edge from `src/c.ts` in `query edges`, an extra `import-specifier-rewrite` on the first `require` literal in the `move … --preview --json` entry, and the real `move` also rewriting that literal (the post-move byte compare). Task 21's side-effect facts that follow still hold. The diagnostic-variant recipe for this body: a temporary, uncommitted edit replacing the arm loop's array with `[...SIDE_EFFECT_IMPORT_ARMS]` and prefixing the lexical-positives `await withWorkspace(COLOCATED_CONFIG, …)` with `if (process.env["T4_2_LEXICAL"] === "1")`, then the single-test run and `git checkout -- test/suite/registry/section-4.ts` plus `git diff --quiet`. Under it the product passes every side-effect arm: `import "./missing.xspec"` and `import "../specs/BASE.xspec.ts"` each report one 14.15 `invalid-import` finding within the statement's byte window, and the valid `import "../specs/BASE.xspec"` alone in `src/side.ts` builds and checks at exit 0, records no edge, and the `occurrences` document holds exactly the two ordinary consumers' records (`src/one.ts [references] -> specs/BASE.mdx#core`, `src/two.ts [embeds] -> specs/BASE.mdx#core`); the non-static dynamic `import()` and non-spec-collision positives pass too. Red checks under the same variant: making the first side-effect arm's statement the valid specifier fails at that arm (`expected exit code 1 … got exit code 0`), and dropping one entry from the expected occurrence multiset (`SIDE_EFFECT_CONSUMER_EDGES.slice(1)`) fails at the `occurrences` assertion, its message printing the product's actual records. +- FIX_PLAN Task 22 (T4-5, the type-only import collisions, `test/suite/registry/section-4.ts`): T4-5 fails diagnosed against the built product at its first arm (`-t 'T4-5 '` on the suite project, ~5 s per run of which the test itself is ~0.3 s), so the arms after it never run in a normal run. Under a print-only diagnostic variant every one of its eight arms (four pairings, both declaration orders) behaves alike on `build --json`, `check --json`, and `occurrences --file src/app.ts`: exactly one 14.15 locating both colliding import declarations by their own characters, statement terminator included (`[41, 77)` and `[78, 119)` in the first arm — the `text` import occupies bytes 0-40 and is not located), no 14.7 for either chain, and an empty occurrence record set — the type-only exemption's silence TEST-SPEC T4-5 forbids (SPEC 2.4, 4.5: a colliding identifier roots no chain whether or not either import is type-only); the normal run stops at the first arm's conditions assertion (`actual: ["14.15"]`, `expected: ["14.7","14.7","14.15"]`). The diagnostic-variant recipe for this body: a temporary, uncommitted edit that, under an environment variable naming a scratch file, makes `assertTypeOnlyCollisionFindings` and the final occurrence assertion of `assertTypeOnlyCollisionArm` append their inputs as JSON lines to that file (`appendFileSync` from `node:fs`) and return, then `T4_5_DIAG=<file> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite test/suite/section-4.test.ts -t 'T4-5 '`, then `git checkout -- test/suite/registry/section-4.ts && git diff --quiet`; a `console.log` from a registered body does not reach the run's output under the harness's Vitest configuration (the test passing under such a variant), so a variant that must show data writes to a file, which the root-owned scratchpad accepts inside the namespace. +- FIX_PLAN Task 24 (T4.5-8's further located forms and two-block spec-source arm, T4.5-9's registration; `test/suite/registry/section-4.5.ts`): both fail diagnosed against the built product at their first arm (that plan's product, 2026-09-23; the Phase 10 product in `dist/`, built from 8a0da01, passes both — ~19 s and ~17 s at the re-descent's FIX_PLAN Task 26) (`-t 'T4.5-8 '` / `-t 'T4.5-9 '` on `test/suite/section-4.5.test.ts`, ~5 s each), so the later arms never run in a normal run. To visit every arm in one ~12 s run, commit the real change first, then apply a throwaway `try`/`catch` around each arm call in the test's `run` that prints the caught message through `console.info` (run with `--disable-console-intercept`), and revert it with `git checkout -- <file>` in the same command (`git diff --quiet` proves the revert). Under it: every T4.5-8 colliding arm, the five new forms included, fails alike (`build --json` exits 0 — no 14.15 for a value-level declaration), the three type-level controls pass, and both spec-source layouts stage under the `duplicate-import-binding` allowance — the two-block layout needs it too, `deriveMdx` rejecting both with acorn's "Identifier 'BASE' has already been declared" — and fail alike: the product reports one 14.16 (`invalid-construct`) at the export statement and nothing else (no 14.15, 14.5, or 14.6). T4.5-9's three import-collision arms report one 14.15 locating both imports by their own characters, terminators included, and no 14.18 for the node the argument spells (`["14.15"]` where `["14.15","14.18"]` is expected), their `text("x")` cells passing; the `function text` and `const text` arms report no 14.15 and treat the call as the spec module's `text` call — `text(SPEC.a)` builds clean (exit 0), `text("x")` reports 14.8 (`invalid-argument`) at the argument alone, and `text(B.a)` reports 14.11 (`cross-module-text`, identities `["specs/A.mdx"]`) at the call; the type-alias control passes. A single cell is probed by hand by writing the module's `SPEC_AND_CODE_CONFIG` text as `xspec.config.ts` beside `specs/A.mdx`, `specs/B.mdx`, `src/t.ts`, and `src/app.ts` in a scratch directory and running `node dist/cli/bin.js build --json` there. +- T4.3-2's and T4.5-3's template-literal arms (re-descent FIX_PLAN Task 26; `T4_3_2_ARMS` in `test/suite/registry/section-4.3-4.4.ts`, `T4_5_3_ARMS` in `section-4.5.ts`, whose `login-v2` arm stages its own spec module through the arm table's optional `specFiles`): both tests pass against the built product (~3.6 s and ~3.2 s; the two files' 13 tests ~57 s together). They red-check through the stand-in pattern above with a scratch wrapper that answers `build --json` over any workspace whose `src/app.ts` holds a backtick with `{"findings": []}` and exit 0 — a product reading the template literal as a static string literal: each test then fails at exactly its template-literal arm's exit-1 expectation, while the wrapper's passthrough mode keeps both green (~13 s for the pair). +- T4.5-4's `using`/`await using` locals and T4.5-8's module-scope `using`/`await using` arms (re-descent FIX_PLAN Task 27; `T4_5_4_APP_SOURCE` with its expected occurrence records `T4_5_4_EXPECTED_RECORDS`, and `T4_5_8_COLLIDING_ARMS`, in `test/suite/registry/section-4.5.ts`): both pass against the built product (`-t 'T4\.5-4 '` ~8 s and `-t 'T4\.5-8 '` ~29 s with start-up; the file's 9 tests ~64 s). Since Task 27 every T4.5-8 colliding arm's non-import 14.15 location is byte-exact (`constructRange`), the import's still the end-widened window. Both red-check by harness mutation — commit the real change first, apply a throwaway rename of the local to `SPEX` (in T4.5-8 both the arm's `line` and its `construct`, or `stageSameScopeArm` throws at module load), run, and revert with `git checkout -- <file>`: T4.5-4's block `using` then fails only at the `occurrences --file` record check (its would-be edge equals the past-block chain's `src/app.ts#usingBlockScope` edge), its `await using` at the `references` edge set (an extra `src/app.ts#awaitUsingScope` edge), and each T4.5-8 arm at `build --json` exiting 0; widening the `using` arm's `construct` to `SPEC = f();` fails the byte-exact check alone (the product reports `SPEC = f()`). +- T5.7-2's token-bound staging (FIX_PLAN Task 26, and re-descent FIX_PLAN Task 28's U+3000/U+202F arm; `TOKEN_BOUND_ARMS` in `test/suite/registry/section-5.7.ts`): six per-arm workspaces, U+00A0, U+FEFF, U+3000, and U+202F composed with `String.fromCodePoint`. All six `d`-value forms — U+00A0 on both sides, U+FEFF before, U+3000 before with U+202F after (staged value bytes `7b e38080 BASE.a e280af 7d`, the span `{88,94}`), `/* c */`, `// c` + LF, and the run-on `// c}` + LF — derive under `deriveMdx`, while the control that ends the run-on value at its first `}` (`d={// c}` LF `BASE.a>`) is rejected by acorn, so a staged run-on `d` value must carry the second `}`. Against the built product all six arms pass (`-t 'T5.7-2 '` on `test/suite/section-5.7.test.ts`, ~10 s). The U+3000/U+202F arm red-checks the same way: filter the loop to it (`TOKEN_BOUND_STAGINGS.filter((s) => s.arm.before === IDEOGRAPHIC_SPACE)`) and widen the expected `range` to `{ start: range.start - utf8Length(arm.before), end: range.end + utf8Length(arm.after) }` — the span a product whose whitespace is ASCII's plus 14.20's named code points reports — and the run fails diagnosed at that arm's range assertion (expected `{85,97}` against the product's `{88,94}`). Red check of the run-on arm: with the loop filtered to one arm (`TOKEN_BOUND_ARMS.filter((a) => a.before.startsWith("// c}"))`) and the final `assertSameJson`'s expected `range` replaced by `{ start: range.start - utf8Length(arm.before), end: range.end }`, the run fails diagnosed at that arm's range assertion (expected `{85,97}` against the product's `{91,97}`); apply both edits with one `perl -0pi`, run, and restore from a scratch copy in the same command when the file holds uncommitted work, then `cmp` to prove the revert. +- T6.5-1's five-declaration arm (re-descent FIX_PLAN Task 31; `fiveDeclarationArm` in `test/suite/registry/section-6.5.ts`, the fifth entry of `FILE_MOVE_ARMS`, its records named `T6.5-1 (e) …`; its `findingFreeClose` makes the post-move `check` and a closing `build` run with `--json`, each required to report `{"findings": []}`, and pins the pre-move edge sets empty as a staging premise): against the built product T6.5-1 passes in full (`-t 'T6\.5-1 '` on `test/suite/section-6.5.test.ts`, ~24 s under the namespace; the whole file, ten tests, ~78 s, all passing). Red check through the stand-in wrapper pattern (above), keyed on the arm's workspace (`src/c.ts` present and opening with `import type T `): dropping the `src/c.ts` entry from the copy's `move … --preview --json` answer is caught by the preview's `files` pin; reverting the real move's rewrite of `src/c.ts`'s side-effect import, or of `specs/B.mdx`'s unreferenced import, is caught by the byte contract at that range; answering the post-move `check`, or the closing `build`, with exit 1 is caught by the finding-free close; the pass-through mode stays green (~20 s per mode under the namespace). +- T6.5-2's byte-exact arms (FIX_PLAN Task 29; `X2_ARMS` in `test/suite/registry/section-6.5.ts`): eight arms, each its own workspace; the indented-closing-tag arm alone stages `SPECS_MD_CONFIG` and runs `build` after the move to assert the compiled `specs/G.md`. Against the built product all eight pass (`-t 'T6.5-2 '` on `test/suite/section-6.5.test.ts`, ~7 s): the product inserts before an indented ` </S>` with the added terminator (the two spaces left a line of their own and kept in the compiled Markdown) and performs the in-line fourth geometry (`foo <S id="p">bar</S> baz` receiving `<S id="a.m">x</S>`) byte-exact. The arm's former staging — the flow-form `a.mv` moved into `<S id="c">Gamma holder.</S>` — composed a target `deriveMdx` rejects (`Expected a closing tag for <S> before the end of paragraph`: a line holding a tag alone interrupts the paragraph that holds the text-position parent's opening tag), T6.5-16(c)'s refused shape, which the product performs and reports as a success (exit 0, the ill-formed file written) — the fact T6.5-16's staging will meet. Every expected `.mdx` text of the arms is judged by the S-9 self-test through the exported `X2_COMPOSED_FORMS` (a new arm's expectation is covered automatically; the former composed target is pinned there as unparseable), so a composed expectation the stock parser rejects fails the self project, not the product. Red check: the matrix recipe above — mutate one pin (drop the `" "` line from the `specs/G.md` expectation, or prefix `</S> baz` with a space), run the single test, restore from a scratch copy. +- T6.5-7's terminator-kind re-runs (re-descent FIX_PLAN Task 34; `B7_KINDS` in `test/suite/registry/section-6.5.ts`, the CRLF and lone-CR entries built by `b7TerminatorKind` at module load, their records named `T6.5-7 CRLF re-run …` and `T6.5-7 lone CR re-run …`): one registered body runs the fixture — the MDX files and both code variants in one workspace — three times, every terminator of every staged file (the configuration included) LF, then CRLF, then a lone CR; the later two workspaces follow a product invocation, so every file there is a record. Against the built product T6.5-7 passes in full (`-t 'T6\.5-7 '` on `test/suite/section-6.5.test.ts`, ~10 s under the namespace). Red check through the stand-in wrapper pattern (above), perturbing one file after a successful `move`: rewriting the CRLF target's final U+000A to the file's own terminator style is caught at that run's target bytes; prepending a CR to the lone-CR origin (an emptied line left behind), inserting one after the lone-CR `src/own-line.ts`'s first line, or prepending a CR to the CRLF origin (half a CRLF left behind) is caught at that run's file bytes; the pass-through mode stays green (~12 s per mode under the namespace). +- T6.5-8's restaged arms and terminator re-runs (re-descent FIX_PLAN Task 35; `A8_KINDS` in `test/suite/registry/section-6.5.ts`, built by `a8Kind` at module load, its records named `T6.5-8 …`, `T6.5-8 CRLF re-run …`, and `T6.5-8 lone CR re-run …`): nine workspaces — the TS, MDX-origin, and MDX-target arms over `specs/origin.mdx`, `specs/target.mdx`, `specs/keep.mdx`, and `src/c.ts`, run with every staged terminator LF, then CRLF, then a lone CR; the TS arm's added run is pinned at the start of line 2 through `assertAddedImportInsertion`'s `pinnedOffset` (`test/helpers/import-insertion.ts`, whose readers judge line starts by SPEC 3's terminators through the exported `atLineStart`, so a conforming insertion after a lone CR is accepted). T6.5-9's code arm (re-descent FIX_PLAN Task 36; the `A9_*` section) re-stages the TS arm's LF files with the lures in `src/c.ts` (`a9Code`: the non-spec lures' import on line 2, the lure declarations before `f`) and confines the added run to the starts of lines 2 and 3 through `assertAddedImportInsertion`'s `pinnedOffsets` (a set; `pinnedOffset` is the one-offset form, and a caller sets one or neither); against the built product it passes (~12 s under the namespace; the product binds `target2` at the start of line 2). Red check through the stand-in wrapper pattern, relocating the added import line after a successful `move`: to the file's end, below the first non-import statement, or to line 1, each caught at the confined placement, while below the non-spec import (line 3) stays green; rebinding the import and the marker to the `const` lure `target` or to the alias `targetSPEC` is caught first by T6.5-22(a)'s driver hook, and with the hook disabled (T6.5-22(a)'s bullet above: `prepareAddedImportCheck` returning undefined) the `const` draws T6.5-9's compile assertion (TS2440) while the alias passes T6.5-9, which leaves it to T6.5-22(a). Against the built product T6.5-8 passes in full (`-t 'T6\.5-8 '` on `test/suite/section-6.5.test.ts`, ~11 s under the namespace; the product binds `target` or `origin` and inserts at the start of line 2 in every receiving file). Red check through the stand-in wrapper pattern (above), perturbing the added import line after a successful `move`: moved to the file's end, it is caught by the TS arm's pin in the LF run; its U+000A respelled in the file's style, in the CRLF run (the TS arm, or the MDX-origin arm when only `.mdx` files are perturbed); a U+000A prepended to it after a lone CR — the mid-line form a reader judging line starts by U+000A alone writes — in the lone-CR run, with its own diagnosis; a `;` appended, in the LF run; the pass-through mode stays green (~12 s per mode under the namespace). +- T6.5-11's restaged arms (re-descent FIX_PLAN Task 37; `CALL_MOVE_ARMS` in `test/suite/registry/section-6.5-ii.ts`): six arms (a)–(f), each its own workspace over `specs/origin.mdx`, `specs/target.mdx`, `src/c.ts`, and, for (a) and (f), `specs/k.mdx` (`third`); (a), (b), (e), and (f) pin the added run at the start of line 2, where the origin declaration's line stood (`placement`, checked against `assertExactDeclarationInsertion`'s readings), while (c) and (d) accept any line start; (e) requires the callee re-rooted at the held `tt` (`existingCallee`), (f) bars it (`untimelyCallee`); preview parity runs for (a), (e), and (f) (`preview`), the `import-addition` required at the origin removal's start or end. Against the built product T6.5-11 fails diagnosed at (f) alone — the product re-roots the callee at the untimely `tt`, writing `tt(target.y)` beside `import target from …` appended at the file's end — while (a)–(e) pass. Red/green checks through the stand-in wrapper pattern (above), the wrapper rewriting `src/c.ts` after a performed `move` and then running the product's `build` so the later `check --json` stays clean (arms told apart by `src/c.ts`'s first line and whether it holds `tt`): the added line relocated to the file's end in (a) or (b) is caught by the pin; (e)'s addition widened to `import target, { text as targetText }` with the call re-rooted is caught by the held-callee check; (f)'s conforming answer written (one declaration at line 2, `targetText(target.y)`, the preview's addition moved to the removal's end, edits re-sorted in the 12.7 order) turns T6.5-11 green; that answer with the line at the file's end is caught by (f)'s pin; the preview's addition moved to offset 0 is caught by the start-or-end check (~20 s per mode under the namespace). +- T6.5-9's spec-source arm and T6.5-10's arms (a) and (c) (`test/suite/registry/section-6.5.ts`, FIX_PLAN Task 30) judge the rewritten target under the stock MDX 3 grammar through the module-local `assertRewrittenSpecDerives` (`deriveMdx`): against the built product T6.5-10 fails diagnosed at arm (a) and T6.5-9's spec-source arm at that assertion, because the product inserts an added declaration into an import-free target at offset 0, directly above the first tag line (`import x from "./x.xspec"` then `<S id="b">`), which the grammar rejects — the block absorbs the tag line — while the file's end after its final terminator was admissible; into a target holding an ESM block ((c)) it joins the block after the last declaration, an admissible offset, and passes. To drive the arms after a diagnosed one, wrap the arm's `await withWorkspace(` call through its closing `);` in a `try { … } catch (error) { void error; }` (for T6.5-9 the TS arm's `withWorkspace(SPEC_AND_CODE_CONFIG, A9_FILES, …)` call) from a scratch copy and restore afterwards: T6.5-10's (b), (c), and (c)'s sibling then pass in ~8 s, and with (a)'s derivability call alone replaced by `void 0;` the whole test passes (its preview pins hold: the product's fresh identifier is `x`, so two `reference-rewrite`s at the exact 5.7 spans and one zero-length `import-addition`). Red checks: `ordered.join("\n\n")` in T6.5-9's spec arm is caught at its contiguity pin, dropping the blank line from `C10_C_TARGET_BASE` at (c)'s insertion assertion, and a `+ 1` on a span in `c10MovedOccurrenceSpans` at (c)'s preview assertion. A data-showing variant (an `fsp.writeFile` of the read target text after `readSourceText`) records the product's identifiers: `S2`/`text2` for `specs/S.mdx`/`specs/text.mdx` in T6.5-9, `x` in T6.5-10. +- T6.5-12 and T6.5-14 (FIX_PLAN Task 31; `test/suite/registry/section-6.5-iii.ts`, wrapper `test/suite/section-6.5-iii.test.ts`): both pass against the built product — the whole file runs in ~12 s under `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite test/suite/section-6.5-iii.test.ts`, single tests by `-t 'T6.5-12 '` / `-t 'T6.5-14 '`. The product converts the target's own `A.x` references to `d={"y"}`/`{text("y")}` and removes the last-use import with its line; it creates `specs/new.mdx` as the two declarations, the empty line, and the moved text, binding the module basenames lower-cased (`a`, `x`) as the fresh identifiers. T6.5-14's category arms commit a baseline through `workspace.gitInit()` and `gitCommitAll()` before the move and read `impact --base <hash> --json`. Red-check recipe (an expectation mutation on a scratch copy of the module, restored after each single run): flip `'{text("y")}'` in `R12_OWN_LINES_AFTER` to single quotes (T6.5-12 fails at the target's bytes); in `assertF14Categories` set the created root's `required: ["changed"]` to `[]`, or the moved subtree's `required: []` to `["changed"]` (T6.5-14 fails at the impact pins); drop one `\n` from the candidate composition in `runF14DeclarationsArm` (T6.5-14 fails at the created file's bytes). +- T6.5-13 (`test/suite/registry/section-6.5-iii.ts`, arms (a) through (l) plus the (a)/(b)/(d) variants and (l)'s indented twin, sixteen entries in `A13_ARMS`): run alone with `-t 'T6.5-13 '` on `test/suite/section-6.5-iii.test.ts` (about 1–3 s per arm against the built product; the whole body ~30 s and T6.5-19's ~3 s, both passing since Phase 10's FIX_PLAN Task 26 — the `impact` arms (h), (j), (k) commit a git baseline and (g) repeats its move in a second workspace; the body stops at its first failing arm). To observe every arm, apply the table-driven filter variant per arm — replace `for (const arm of A13_ARMS) {` with `for (const arm of A13_ARMS.filter((arm) => arm.key === "(e)")) {` on the working file and restore it from a scratch `.bak` copy after each run (`cmp` afterwards) — and swallow the byte contract (`if (!expected.includes(actual) && false) {`), the preview comparison (comment out `a13AssertPreviewEdits(entry, arm, context);`), and the own-content checks (`&& false` on the `after.ownText` and `changed !== arm.root.ownHashChanges` conditions) to reach a failing arm's later assertions, `impact` included. Observed: the product places the added declaration at offset 0 of a spec target holding no ESM block (its preview reports the `import-addition` there too) and binds the module basename lower-cased (`x`, `y`, `b`), so the cross-file arms (a) through (d), (g), (h), (i), (j), (l), and (l, indented) fail diagnosed at the byte contract — for (l) it turns the paragraph's `import B …` line into a live declaration that its own `view`, `check`, and `build` then do not see (the pre-move `build --json` is clean and `view` lists no import, as the arm expects) — while the same-file arms (e) and (f) and the removal-side arm (k), whose third file already holds an ESM block, pass; with the byte and preview contracts swallowed, (h)'s and (j)'s roots keep their own text (no terminator added) and (h)'s `impact` gives the receiving root only `descendant-changed`. Expectation mutations on (k) (an extra trailing LF in the target's bytes, a rewrite range end off by one, a flipped ownHash expectation, `q` required `changed`) each fail at their own assertion, as (e)/(f)'s did. +- T6.5-13 (h)/(j)'s `impact` pins (`a13DependentImpact` in `test/suite/registry/section-6.5-iii.ts`; the fifth-determination plan's Task 2): the target root is required `changed` and `descendant-changed` (attributed within `[p, moved]`, `p` mandatory) and, in (j), `upstream-changed` through its `{text("p")}` embedding (an `embeds` edge, SPEC 5.2, 5.6); the dependent file's root is required `upstream-changed` with the target root among its attribution. `assertImpactPins` (same module) treats a pin with `required: []` as "named by no entry" and fails on any category, so an `optional` list on such a pin tolerates nothing (the previous `A13_DEPENDENT` pin had that dead shape and would have failed a conforming product at `specs/dep.mdx must receive no category`; no other registry pin has it). To reach (h)'s and (j)'s `impact` assertions against the built product, apply the filter-and-swallow variant of the bullet above and add a data-showing line in `a13AssertImpact` before its `assertImpactPins(` call — `(await import("node:fs")).writeFileSync(<scratch path>, JSON.stringify(report, null, 1));` (the module imports no `node:fs`; the function is async) — restoring from a scratch copy (`cmp` afterwards); the narrower filter `-t 'T6.5-13 admissible'` leaves T6.5-19 out. Observed: the product reports the receiving root without `changed` in both arms (`descendant-changed` attributed to `p`; in (j) `upstream-changed` attributed to `p` too) and `specs/dep.mdx` and `specs/dep.mdx#k` `upstream-changed` attributed to `p` alone, so both arms still fail diagnosed at the root's `changed` requirement. The pins were red/green-checked with synthetic reports — the dumped reports made strict (the root `changed`, the dependents attributed to it too) pass; each single mutation (a dropped category, an attribution omitting `p` or the root, the dependent file's root absent) fails at its own pin — through a temporary `test/self/zz-<task>-standin.test.ts` importing `assertImpactPins` and `a13DependentImpact` under temporary `export`s added by sed, run under the unprivileged namespace with the old and the new module swapped in place, and deleted before committing. +- T6.5-15 (FIX_PLAN Task 34; `test/suite/registry/section-6.5-iii.ts`, arms (a) through (e) in `J15_ARMS`, its stagings and composed expectations exported as `J15_FORM_VECTORS` to the S-9 self-test): run alone with `-t 'T6.5-15 '` on `test/suite/section-6.5-iii.test.ts` (the body stops at its first failing arm). To observe every arm, apply the arm-filter variant — `for (const arm of J15_ARMS.filter((arm) => arm.key.startsWith("(d)"))) {` in place of `for (const arm of J15_ARMS) {` on the working file, restored from a scratch `.bak` copy after each run (`cmp` afterwards) — and swallow the preview contract (`if (false) assertSameJson(` before its `removals` comparison) to reach the byte assertions. Passes against the built product since Phase 10's FIX_PLAN Task 34 (~13 s alone, ~9 s of it the five arms): a spec source's import removals are judged per ESM block (`SpecImportPlan.removedImports(bytes)` and `removalsLeaveBlockHeaded` in `src/core/move.ts`), the block's first declaration kept, unreported, where the removals would leave the block's first line starting with anything but a kept declaration spelled `import` then U+0020 — the only opening of the stock ESM construct at a line's start, so a kept `import`-TAB, `import/**/`, or indented declaration there is paragraph text (the stock parser of the well-formedness bullet above — `fromMarkdown` with `micromark-extension-mdxjs` and `mdast-util-mdx` — shows the node types). To hand-probe other block shapes, stage `J15_EMIT_CONFIG`'s configuration, `specs/A.mdx`–`specs/C.mdx` each holding one section, an origin `specs/o.mdx` (the block, an empty line, a kept section `k`, the moved section `m` carrying the uses said to depart) and a target `specs/t.mdx` importing the moved uses' modules under the origin's identifiers, then compare `move specs/o.mdx#m specs/t.mdx#m --preview --json`'s `import-removal` ranges with the origin's bytes after the real move and a clean `check`. +- T6.5-16 (FIX_PLAN Task 35; `test/suite/registry/section-6.5-iii.ts`, the 24 refused arms (a) through (e) in `R16_REFUSED_ARMS` and the five performed controls in `R16_CONTROL_ARMS`; the staged files, each refusal's deriving side, and each control's composed expectation exported as `R16_FORM_VECTORS` (95 forms) and every would-be text the entry refuses as `R16_REFUSED_VECTORS` (24) to the S-9 self-test): run alone with `-t 'T6.5-16 '` on `test/suite/section-6.5-iii.test.ts` (about 5 s; the body stops at its first failing arm, so observing every arm takes the try/catch variant — each arm's outcome appended to a scratch log named by an environment variable, the refused loop `.slice(0, 0)` to reach the controls — on the working file, restored from a scratch `.bak` copy and `cmp`-checked). Observed against the built product: all 24 refused arms fail diagnosed at the `move … --json` exit code — the product performs every shape at exit 0 with the applied mapping, its widened grammar accepting the composed texts — and all five controls pass. Since the post-re-descent plan's Task 11 (section tags pair exactly as stock MDX 3 pairs them) the product no longer performs arm (a): its in-memory re-validation of the rewritten workspace rejects the would-be target, and the move exits 1 reporting that file's 14.20 alone, so the body fails at arm (a)'s reasons assertion (`got ["14.20"]`, where `refused-invalid-rewrite` is required — FIX_PLAN Task 35); the other arms were not re-observed. Red-check recipes: a doubled space in the self-closing parent's `compose`, a trailing space in `R16_D_CONTROL`'s origin expectation, and a third U+000A in `R16_E_CONTROL`'s target expectation each fail that control alone at its byte assertion; `r16AssertFinding`, which the product never reaches, is checked with the stand-in recipe above — a scratch wrapper answering the section form's `move … --json` with the refusal finding (the construct located by searching the origin for `<S id="<id>"`, `identities` the target path, or the origin's for the `p.m` deletion arms) and delegating every other invocation to `dist/cli/bin.js`: mode `conform` passes all 29 arms, while moving the range end, adding the origin path to `identities`, setting `path`, or changing the code fails at the `locations`, `identities`, `path`, and codes assertions respectively, and a duplicated finding at the H-3 decoder's pinned 12.7 order. Vitest resolves the `development` export condition, so under it the S-9 parser stack runs its development build with `devlop` assertions: `deriveMdx` reports such an assertion (`name` "Assertion", `code` "ERR_ASSERTION") as a non-derivation, which T6.5-16(d)'s `===` remainder needs — "expected heading on stack" there, where the production build (plain `node`) rejects the same text with its element-matching message; `node --conditions=development` on a scratch `.mts` probe reproduces the development verdict, and reverting the guard fails the two setext vectors of `test/self/s9-fixture-well-formedness.test.ts` with the thrown assertion. Since Phase 10 FIX_PLAN Task 35 the whole test passes against the built product (~35 s for the body in a full-suite run; T6.6-3, replaying its arms under `--preview`, ~83 s, and T14-7 ~45 s): the refusal evaluation judges the would-be files itself, so no arm reaches the post-plan re-validation. +- T6.5-16's remaining arms (FIX_PLAN Task 36; `test/suite/registry/section-6.5-iii.ts`): (f), the (g) family with `noOffset` — the concerned file as the other edits leave it plus the entry's named offsets probed by `deriveMdx` with the declaration inserted per 6.5's terminator rule (`r16Declared`), the "deriving yet inadmissible" offsets (paragraph text after a paragraph line; a block joining lines that were no block's) reasoned in the arm comments as T6.5-13 pins the admissible side — (h), (i), the applicability arms with `beside`/`besidePath`, the created-target arms, the two `R16_ALONE_ARMS` (refused for another reason alone), and the performed control `R16_G_CONTROL` with `added` (the fresh identifier read back off the declaration line, the composition pinned with it) and `impact` (reusing `a13AssertImpact` after a `gitInit`/`gitCommitAll` baseline); `R16_FORM_VECTORS` 162, `R16_REFUSED_VECTORS` 38. The same `-t 'T6.5-16 '` run and try/catch variant as above (`R16_REFUSED_ARMS.slice(25, 33)` reaches the (g) family, (h), and (i); `R16_CONTROL_ARMS.slice(5)` the new control); a variant logging a message's first line loses a byte assertion's expected/actual, which are read instead by hand-staging the control's four files in a scratch workspace (`xspec.config.ts` with the module's `CONFIG`) and running `dist/cli/bin.js move … --json` there. Observed against the built product: the two alone arms pass; every new refused arm fails diagnosed at the exit code (the move performed at exit 0); the three `beside` arms fail at the codes assertion — `refused-id-collision`, `refused-missing-target-parent`, and `refused-invalid-destination` each reported alone; the control fails at the target's byte assertion — the declaration placed at offset 0 as `import x from "./x.xspec"` (the basename lower-cased), the embedding rewritten to `x.a`, the origin's bytes as composed. Red/green check through the stand-in recipe above (a scratch `task36-standin.mjs`, mode first on its argv, bound by a temporary `test/self/zz-task36-standin.test.ts` calling `runProductTests` over the one entry with `--disable-console-intercept`, each result's outcome and first diagnosis line appended to a scratch log, deleted before committing): the wrapper answers every JSON section move by rule — `refused-invalid-id` alone for a `then` segment; `refused-invalid-destination` (with its path) beside the rewrite finding for a non-`.mdx` target; the created `.mdx` alone; `refused-missing-target-parent` when the target holds no `id="<parent>"`, the rewrite finding beside it only when the origin holds `</S>- item`; `refused-id-collision` beside it when the target holds `id="<new-id>"`; the construct located by `<S id="<id>"` and its first `</S>` or self-closing `>`, the second location the `{text(X.a)}` inside the moved text or the origin's `{text("p.m")}`, `identities` both paths for the `specs/z.mdx` origin, the origin for `p.m` moves, else the target — and, for the control's real (non-JSON) move, rewrites the product's `specs/b.mdx` to the 6.5 placement with the product's identifier and runs `build`. Mode `conform` passes all 45 arms in ~25 s; `range2`, `range-second`, `locations-drop`, `identities-drop`, `identities-order`, `besidepath`, `beside-drop`, `alone-extra`, `placement`, and `ident` fail at the `locations` (first arm; the (g) family's second entry), `identities` ((i)), beside-path (`specs/new.txt`), codes (the collision arm; the `p.then` alone arm), and control byte assertions respectively. +- T6.5-17 (FIX_PLAN Task 37; `test/suite/registry/section-6.5-iii.ts`, the `M17_*` section): four refused arms over T2.1-6's in-section ESM block plus the performed control (e), run through `runR16ControlArm(product, M17_CONTROL, "T6.5-17")` — that runner and `r16AssertDerives` take an optional trailing `testId` (default `T6.5-16`) for the diagnoses; `M17_FORM_VECTORS` 18 under the S-9 self-test. `-t 'T6.5-17 '` on `test/suite/section-6.5-iii.test.ts` takes ~5 s under the unprivileged namespace. Observed against the built product: arm (a) fails diagnosed at the exit code — the product dies with exit 70 (`xspec internal error: overlapping move deletions`, from `deletionEditsWithLineDrops` in `dist/core/move.js`) on any moved section holding an ESM block, modifying nothing — so the later arms are unreached; hand-staging the control's three files with the module's `CONFIG` and running `dist/cli/bin.js move specs/a.mdx#m specs/b.mdx#m --json` performs it (exit 0) with the declaration at offset 0 of the target as `import x from "./x.xspec"` and no empty line after it (`import x …`, `<S id="p">`, …), the moved text after the target's final terminator with its embedding rewritten to `x.a`, and the origin's bytes as composed. Red/green check through the stand-in recipe above (a scratch `task37-standin.mjs`, mode first on its argv, bound by a temporary `test/self/zz-task37-standin.test.ts` calling `runProductTests` over the one entry per mode with `--disable-console-intercept`, each result's outcome and first diagnosis line printed through `console.info`, deleted before committing): the wrapper answers every non-preview JSON section move whose moved text (the origin construct from `<S id="<id>"` through `</S>`) holds `^import … from "…"$` lines with exit 1 and the refusal document — `refused-invalid-id` first (14's listing order, which the form-exact decode enforces) when the new ID has a `then` segment, then `refused-moved-import` locating each declaration line by its characters — and, for a moved text holding none, runs the real move and, when the target was `<S id="p">`, `x`, `</S>` and the product exited 0, rewrites the target to the pinned composition with the product's own fresh identifier (read off its declaration line) and re-runs the real `build`; `conform` passes the whole test in ~4 s, while `exit0` (exit 0 with the refusal document) is caught at (a)'s exit code, `range` (each location one byte longer) at (a)'s locations, `single` (the first location only) at (b)'s locations, `ident` (`identities` `["specs/a.mdx"]`) and `path` (`path` `specs/a.mdx`) at their members on (a), `alone` (no `refused-invalid-id` beside) at (d)'s codes, `modify` (a byte appended to the target before refusing) at (a)'s modifies-nothing compare, `placement` (the product's offset-0 declaration left as is) at the control's target bytes, and `keep` (the origin's declaration dropped) at the control's origin bytes; all ten modes take ~40 s as root (no permission staging is involved). Since Phase 10 FIX_PLAN Task 25 the whole test passes against the built product (`refused-moved-import`, evaluated in `src/core/refusal.ts` before any planning; `-t 'T6.5-17 '` ~5 s). +- T6.5-18 (re-descent FIX_PLAN Task 38; `test/suite/registry/section-6.5-iii.ts`, the `A18_*` section): four arms in `A18_ARMS` — `base` (the shadowed default), `(a)` (the type-only default), `(b)` (the type-only `text`), and `callee` (the shadowed callee) — each its own workspace of ledger records (`SPEC_AND_CODE_CONFIG`, the origin and target `stagedMdx` records, and one `src/c.ts` record per arm, built by `a18Arm` at module load), the added run read by `assertExactDeclarationInsertion` and pinned at the start of line 2, where the origin declaration's line stood; preview parity admits the `import-addition` at the origin removal's start or end. The module's `withWorkspace` takes an optional third `config` and its `queryEdgesOfKind` accepts `"references"`; the consumer compiler options of `test/helpers/tooling.ts` set no `noUnusedLocals`, so a staged local never read (`const T = 1`) compiles clean; `A18_FORM_VECTORS` 4 under the S-9 self-test. `-t 'T6\.5-18 '` on `test/suite/section-6.5-iii.test.ts` takes ~19 s of test time (~27 s with start-up) under the namespace. Against the built product all four arms pass (hand-staged, the product adds `import target from …` or `import { text as targetText } from …` at line 2 and re-roots the call's argument at `T`). Red/green check through the stand-in recipe above (a scratch `task38-standin.mjs`, bound by a temporary `test/self/zz-task38-standin.test.ts` that reads its modes from an environment variable, deleted before committing; the wrapper tells the arms apart by `src/c.ts`'s first line and, after a performed `move`, rewrites `src/c.ts` and re-runs the real `build`): `T.y` (base, (a)) and `tt(T.y)` ((b), callee) are caught by the read's barred-binding check, the added line relocated to the file's end ((a), (b)) by the pin, `import target, { text as targetText }` ((b)) and a trailing `;` (callee) by the exact-declaration reader, `targetText(target.y)` (callee) by the held-root check, the preview's addition at offset 0 by the start-or-end check, and the `#y` edge dropped from (a)'s `query edges` answer by the edge set, while the preview's addition moved to the removal's start and (b)'s `{ text }` spelling (`import { text } from …`, `text(T.y)`) stay green (~12 s per mode). +- T6.5-19 (FIX_PLAN Task 39; `test/suite/registry/section-6.5-iii.ts`, the `A19_*` section): both arms ride `runA13Arm` (which, with `a13AssertPremiseDerives`, takes an optional trailing `testId`, default `T6.5-13`) and probe the entry's named offsets under `deriveMdx` through `r16Probe`/`r16Declared` before the move; `A19_FORM_VECTORS` (18) and `A19_UNDERIVABLE_VECTORS` (5) under the S-9 self-test. Run alone with `-t 'T6.5-19 '` on `test/suite/section-6.5-iii.test.ts` (~6 s); against the built product it fails diagnosed at (a)'s bytes and, under the arm-filter variant (`A19_ARMS.slice(1)`), at (b)'s: the product places the added declaration at offset 0 of the receiving file with no empty line after it (`import x from "./x.xspec"`, U+000A, `<S id="p">` …; `import b from "./b.xspec"`, U+000A, `<S id="a" d={b.m}>` …), reports `import-addition [0,0)` and, in (b), a no-op `id-rewrite [3,9)` for the kept ID, while its `query node` gives the pinned own texts (empty) and ownHash verdicts and its `check` stays clean. The stand-in red/green check (`task39-standin.mjs` in the scratchpad, driven through `runProductTests(binding, productTestSuite.select(["T6.5-19"]))` from a temporary `test/self/zz-task39-standin.test.ts` deleted before committing; all seven modes in ~20 s under the namespace): `conform` snapshots `specs/*.mdx` before a performed `move` and, on exit 0, relocates the declaration line heading a spec file that did not head it before to the file's end per 6.5's terminator rule (U+000A before it when the file's last line lacks one), re-runs the real `build`, and on `move … --preview --json` rewrites the `import-addition` offset to the file's byte length and drops every `id-rewrite` when the mapping keeps every ID — the whole test passes; `naive` (the empty line's start inside the section, the entry's headline failure), `keep0` (the product's bytes), `midline` (the tag line's end), and `blank` (an empty line before the declaration) are each caught at (a)'s byte contract, and `previewonly` (conforming bytes, the preview's offset at the empty line's start, 11) at (a)'s preview assertion. +- T6.5-20 (re-descent FIX_PLAN Task 39; the new `test/suite/registry/section-6.5-iv.ts`, wrapper `test/suite/section-6.5-iv.test.ts`): its refused stagings are the `D20RefusedStaging` entries `d20RefusedStagings` returns — one fresh workspace each (configuration and files staged-source records; `builtOccupant` `null` for a staging staged before any build, else the derived path its premise `build` must leave a plain file), whose moves (the file form, then the section form `#x` creating the target at the same destination) `runD20RefusedStaging` runs in turn; T6.6-3's twins (`section-6.6.ts`, after the T6.5-17 twins) go through the same two functions, so a refused staging added to the table is twinned with no T6.6-3 edit. The companion legs read the product's companion paths through `readRecordedCompanionPaths` (`test/suite/registry/support.ts`; T13.4-9(e)'s reading: a scratch twin's `build`, then `inventory`'s `recorded` set): the built product records three per source, `NAME.xspec.impl.d.ts`, `NAME.xspec.impl.d.ts.map`, and `NAME.xspec.impl.js`. Run alone with `-t 'T6\.5-20 '` on `test/suite/section-6.5-iv.test.ts` (~8 s with Vitest's start-up): against the built product it fails diagnosed at its first arm — the product performs `move specs/Z.mdx specs/A.xspec.ts/B.mdx` and dies with exit 70 in the regeneration (`cannot write specs/A.xspec.ts/B.xspec.ts`, the moved file already overwritten by the module); hand probes show it performs every before-any-build staging of (a) and (b) (exit 70 under the module and companion paths and for `specs/B.mdx` under outDir `specs/B.mdx/md`, exit 0 for `specs/x.md/y.mdx` and `specs/a.mdx` under outDir `out`) while refusing the after-build module-path pair with exactly the one pinned finding. T6.6-3 still fails diagnosed earlier, at T6.4-3's U+2028 arm, so its T6.5-20 twins are reached only through a stand-in: a scratch wrapper answering a JSON `move` in a workspace whose `specs/Z.mdx` holds T6.5-20's bytes, with no `.xspec/` yet, to a destination under the relation with a synthesized `refused-invalid-destination` (`path` the destination path, `locations` `[]`; the findings-only form, or for `--preview` the four-member form with the three nulls) and passing everything else through, driven by a temporary `test/self/*.test.ts` that runs `T6.5-20` through `runProductTests` and the twin loop directly (`expectRefusedPreviewEquivalence` exported for the run with a one-line `sed`, reverted): `conform` passes both (~10 s as root), while a location on the finding, a wrong `path`, a stray write, an `obstructed-write-path` finding beside the after-build refusal, the outDir staging performed, (b)'s last staging performed, and a preview `path` differing from the real one are each caught at their own assertion — the location and path perturbations by the home test alone, the twin comparing the preview to the real refusal. Arm (c) (FIX_PLAN Task 40) follows (b) in the table: beside the code source `specs/B.md` and beside `specs/B.md/C.mdx` under a `specs/*.md` code group, beside `specs/B.md/x.ts`, `specs/A.xspec.ts/c.ts`, and one `specs/A.xspec.<suffix>/c.ts` per companion (a second `readRecordedCompanionPaths` read, its twin holding `specs/Z.mdx`'s bytes at `specs/A.mdx`) under a `specs/**/*.ts` one, all emitting next to sources, then the section-form exemption staging after a `build` (`builtOccupant` `specs/B.md/C.md`); the body ends with the performed exemption (`runD20Exemption`, after every refused staging). Hand probes: the built product performs every (c) refused staging of either form, exit 0, its regeneration deleting the source hidden or replaced, and the section-form exemption likewise, while it performs the file-form exemption as pinned. Stand-in check for (c) (scratch `t40/t40-standin.mjs`, Task 39's pattern): the wrapper also recognizes the exemption stagings by `specs/B.md/C.mdx`'s bytes once `.xspec/` exists, refusing the section form and passing the file form through, and the twin loop runs as a temporary `defineProductTest` entry (ID `T6.6-3`, its body the loop) through `runProductTests`, so the loop runs inside a registered-body context; `conform` passes T6.5-20 (~7.4 s as root) and the twin loop (~8 s), while each (c) staging performed, the section-form exemption performed, the file-form exemption refused, its emitted Markdown or moved bytes perturbed, the vacated directory left in place, a stray write, and a (c) preview `path` differing are each caught at their own assertion. Arms (d) and (e) (FIX_PLAN Task 41) extend the table: (d)'s six module-linking stagings (`D20_D_FORMS`; `specs/Z.mdx` beside a one-line `src/c.ts` under a `src/**/*.ts` code group, emission next to sources, before any build) follow (c)'s companion stagings, ahead of the section-form exemption staging; then come (e)'s after-build file-form controls, one per retired path (`specs/A.xspec.ts`, `specs/A.md`, and each companion of (e)'s own `specs/A.mdx`, read by a third `readRecordedCompanionPaths` twin, `readD20RetiredCompanions`), and (e)'s section form before any build. After the exemption the body runs the performed arms through `runD20Performed`: (d)'s nine controls (the six forms under `markdown: { emit: false }`; `require(…)`, the triple-slash reference, and the template-literal `import()` with emission on), then (e)'s five retired-path moves. Each (e) destination's derived paths are pinned as plain files: its module, its Markdown, and each companion that a twin holding the moved bytes at the destination records. Hand probes against the built product: it answers four of (d)'s six stagings (`import`, `export * from`, `import X = require`, and `import()`) with an `invalid-import` finding (14.15), exit 1, and performs the import type and `declare module` stagings, exit 0. On (e)'s section form it dies with exit 70 (`cannot write specs/A.xspec.ts/B.xspec.ts`) after rewriting the origin. It passes every (d) control, every (e) performed move, and every (e) after-build control as pinned. Stand-in check (scratch `t41/t41-standin.mjs`, Task 40's pattern): the wrapper also refuses (d)'s stagings when `src/c.ts` holds one of the six forms and the configuration emits, and (e)'s section form when `specs/A.mdx` holds (e)'s bytes. The temporary self-test ran T6.5-20 through `runProductTests` and the twin loop directly, outside any registered body, which also works (`expectRefusedPreviewEquivalence` exported for the run, reverted). `conform` passes both (~49 s together under the namespace). Each of these is caught at its own assertion: the real product's (d) answers, the import type or `declare module` staging performed, a stray write during a refused (d) move, a refused (d) control of either kind, a dirty `check` after a control, (e)'s section form performed, (e)'s performed moves refused, a missing companion or Markdown beneath the fresh directory, perturbed moved bytes, a missing premise occupant, and a (d) or (e) preview `path` differing. +- T6.5-21 (re-descent FIX_PLAN Task 42; `test/suite/registry/section-6.5-iv.ts`, the `D21_*` section after T6.5-20): its refused stagings are `D21_REFUSED_STAGINGS` — (a) after a premise `build` (`d21BuildPremise` re-pins `specs/A.md` a plain file), its file-form move and then the two-reason move `D21_TWO_REASON_MOVE` (`refused-invalid-destination`, then `refused-exposed-derived-file`), and (b) before any build — each run through `runD21RefusedStaging`, each move carrying its expected findings (code, `path`, and `identities` where pinned); `D21_A_STAGING`, `D21_TWO_REASON_MOVE`, `D21_REFUSED_STAGINGS`, and `runD21RefusedStaging` are exported for T6.6-3 (its twins follow T6.5-20's), T12.7-2, and T14-7. The performed controls (c), (d), and (e) follow in the body, with no T6.6-3 twin; (d) stages its link through section-13.4.ts's exported `stageLinkToOutsideFile` and `assertOutsideLinkTargetUnchanged`, and (c)'s and (e)'s Markdown are compared with a freshly built twin's (`d21AssertLikeTwin`; (e)'s twin carries the moved workspace's own sources by `copyFrom`, each first judged with `deriveMdx`). `refused-exposed-derived-file` is in `IDENTITY_PINNED_REFUSAL_CODES` (`test/suite/registry/support.ts`), so a case naming it must state `identities` `[]`. Run alone with `-t 'T6\.5-21 '` on `test/suite/section-6.5-iv.test.ts` (~1 s to its diagnosed failure, ~8 s with Vitest's start-up): against the built product it fails diagnosed at (a)'s first move (exit 0, the move performed); hand probes show it performs (a), (b), and the two-reason move (exit 0: it predates the reason and T6.5-4's barred characters) and passes (c), (d), and (e) as pinned. T6.6-3 fails diagnosed earlier, so its T6.5-21 twins are reached only through a stand-in. Stand-in check (scratch `t42/t42-standin.mjs`, Task 41's pattern): the wrapper refuses a file-form `move` of `specs/A.mdx` holding T6.5-21's bytes whenever `specs/A.md` is a plain file (never a link) and the configuration holds the glob `"specs/*.md"`, synthesizing `refused-invalid-destination` first for a destination holding `'` (the findings-only form, or for `--preview` the four-member form with the three nulls), and passes everything else through; a temporary `test/self/*.test.ts` ran T6.5-21 through `runProductTests` beside a temporary `defineProductTest` entry (ID `T6.6-3`) running the twin loop (`expectRefusedPreviewEquivalence` exported for the run with a one-line `sed`, reverted and `cmp`-checked): `conform` passes both (~6 s as root), while 23 red modes (~60 s together) are each caught at their own assertion — the real product's (a) and (b) answers, a location, a wrong `path`, and non-empty `identities` (the home test alone), a stray write, a finding beside, the two reasons reversed (the form-exact decode), one reason dropped, a preview `path` differing (the twin alone), (c) or (d) refused, (c)'s stale Markdown kept or its emitted Markdown perturbed, (d)'s target written through or its link kept, (e) refused under `--preview` or for real, (e)'s preview writing, (e)'s stale Markdown restored (caught by `check`, and with `check` faked clean by the twin compare), `specs/sub/A.md` missing, and (a)'s premise Markdown missing. +- T6.5-22 (re-descent FIX_PLAN Task 43; `test/suite/registry/section-6.5-iv.ts`, the `B22_*` section after T6.5-21): its (b) lures are `B22_LURES`, 38 entries `{ lured, receiver, standing, why }`, each run by `runB22Lure` in a fresh workspace of staged-source records — `B22_CONFIG` (one spec group, one code group over `src/**/*.ts` and `src/**/*.tsx`), `B22_ORIGIN_SOURCE` (`specs/A.mdx`: sections `a`, `m`, `w`), and the receiver `b22Receiver` builds (`specs/host.mdx` roots `{text(A.a)}` at `A`, a code receiver the marker `A.m`, each beside a kept use of `A.w`) — its move `move specs/A.mdx#<a|m> specs/<lured>.mdx#<a|m> --json` creating the target. A lure's premise (S-6's analysis gives the lured name the entry's `standing` in the receiver) fails as a plain harness error. After the move the body re-judges the receiving file with `judgeAddedImportsOfFile` (`test/helpers/added-import-identifiers.ts`: the driver hook's own judgement over one file's two texts, returning the added declarations with their specifier values and the breach lines), asserts exactly one declaration added with the canonical specifier, and asserts `check --json` clean. The body collects each lure's `HarnessAssertionError` and fails once with `T6.5-22 (b): <n> of 38 lures failed, each diagnosed`; any other error propagates. Run alone with `-t 'T6\.5-22 '` on `test/suite/section-6.5-iv.test.ts` (~23 s of test time, ~30 s with Vitest's start-up, under the namespace). Against the built product it fails diagnosed, 26 of 38 lures at the driver's hook: its stem-derived choice binds `Object`, `require`, `exports`, `__x`, `escape`, `unescape`, `Iterator`, `AsyncIterator`, and `SuppressedError` in both kinds of file, `React` in both `.tsx` receivers, `h` in all four `h` receivers, `preact`, and `Frag`. It passes `let`, `await`, `yield`, and `eval` (it skips those words: `let2`, `await2`, …) and `helper`, `Record`, and `test` (`helper2`, `Record2`, `test2`). Stand-in check (scratch `t43/t43-standin.mjs`, driven by a temporary `test/self/*.test.ts` calling `runProductTests(binding, productTestSuite.select(["T6.5-22"]))` with the binding `{ command: process.execPath, prefixArgs: [standin, mode, "dist/cli/bin.js"] }`): after a performed move the wrapper renames the receiver's added binding (`import X from "<…><lured>.xspec"` and `X.a`/`X.m`) and re-runs the real `build`. `conform` (a fresh name) passes all 38 (~55 s); `lured` (the lured name) fails 38 of 38 at the hook and, with the hook disabled by T1.4-5's `sed` recipe, 38 of 38 at the body's own judgement; a second added declaration, a non-canonical specifier, a refused move, and a dirty `check` each fail 38 of 38 at their own assertion. +- T6.5-23 (re-descent FIX_PLAN Tasks 44, 45, 46, and 47; the new `test/suite/registry/section-6.5-v.ts`, wrapper `test/suite/section-6.5-v.test.ts`, the `S23_*` section): its stagings are `S23_ARMS`, one `S23Arm` per staging — `key`, the `receiver` and its `kind` (`typescript`; `spec-source` for (g)), the staged text and its record, the other `files` (the configuration included) and the `argv`, the rewrites (`S23Rewrite`: a pre-operation span and its replacement given the added identifier, zero-length for (g)'s moved-text insertion; (a)–(e) rewrite the marker `O.x` to `<X>.y` alone, (f) `A.m` to `<X>.m` or `B.m`, (h)–(j) `O.x` to `<X>.y` beside the removal of `O`'s declaration with its line, spelled `""`, and (k) the call `ta(A.m)` whole, to `<Y>(B.m)` or `tb(B.m)`), the `addition` (`S23Addition`: specifier, the admissible `offsets` TEST-SPEC names, why, and — (k) alone — `spelled`, an `S23Spelling`: the declaration given the identifier, its form in words, and `also`, the identifiers it spells apart (`text`), whose composed forms the premises judge beside the placeholder's; `s23Added` and `s23Form` read it, the default-binding form where it is absent) or `undefined` where the receiver gains none (then exactly the rewrite-only bytes, no declaration added), the `placement` and the `excluded` offsets with TEST-SPEC's reasons (diagnoses only), `deriving` (inadmissible offsets whose forms TEST-SPEC says derive, judged as premises), `preview` (`S23PreviewExpectation`: the `reference-rewrite` count or spans and the `import-removal` spans; the preview is taken where it is set or a declaration is added, the `import-addition` parity always asserted then) and `edges` (`S23Edge`, each `query edges --from <from> --to <to> --kinds <kind>` answering exactly that edge) — built at module load by `s23Arm` ((a)–(e)), `s23FArm` ((f): `S23_CONFIG`, `specs/A.mdx` holding `m` and `k`, `specs/B.mdx` holding `b`, `move specs/A.mdx#m specs/B.mdx#m`), literally ((g): `R16_CONFIG` and T6.5-13's `A13_ORIGIN_STAGED`, `A13_THIRD_STAGED` — staged at `specs/x.mdx` and `specs/k.mdx` — and `a13MovedLines`, exported from `section-6.5-iii.ts`; `move specs/a.mdx#m specs/target.mdx#p.n`), `s23RemovalArm` ((h)–(j): `f` = `export function f() { O.x }`, `O`'s declaration removed with its line over TEST-SPEC's range — [0, 38), [8, 46), [20, 58), [0, 38) — cross-checked against the staged text at module load, the addition at the removal's end, whose bytes the removal's start composes as well, so the preview's `import-addition` parity alone tells the two apart; (h) also states its preview's `reference-rewrite` and `import-removal` spans and the marker's `references` edge from `src/c.ts#f`), or `s23KArm` ((k) and its control: (f)'s configuration, spec sources, and move; the call rewritten whole; the preview's one `reference-rewrite` spanning the call and no `import-removal`; the call's `embeds` edge from `src/c.ts` to `specs/B.mdx#m`), `s23LmArm` ((l) and (m): (f)'s configuration and move over `specs/A.mdx` holding `m`, its child `m.c`, and `k` (`S23_LM_A_SOURCE`) and (f)'s `specs/B.mdx`; two rewrites, `A1.m` to `<X>.m` and `A2.m.c` to `<X>.m.c` ((l)) or `B.m.c` ((m)); the addition at 34 alone, cross-checked by `s23Stated` over `s23Starts`; the preview's two `reference-rewrite` edits and no `import-removal`; both markers' `references` edges from `src/c.ts`, to `specs/B.mdx#m` and `specs/B.mdx#m.c`), `s23Nested` ((n): `f` spread over lines, or `namespace N {`; 37 or 38; the body's three line starts judged among the premises as `deriving`), literally ((o): `S23_O`, the spec source `specs/third.mdx` under R16_CONFIG with (a)'s origin and target, nothing added, `O`'s declaration removed over [0, 31) as a rewrite spelled `""` and `O.x` re-rooted to `T.y`; the preview's `reference-rewrite` [45, 48) and `import-removal` [0, 31); `p`'s `depends` edge to `specs/target.mdx#y`), or `s23TrailingWhitespace` ((p): U+0020, U+0009, U+000B, U+000C from code points after `O`'s declaration; 39 alone; the preview's `reference-rewrite` [61, 64) and no `import-removal`) — each run by `runS23Arm` in a fresh workspace of records ((a)–(e): `S23_CONFIG`: one spec group, one code group `src/**/*.ts`; `specs/origin.mdx` holding `x` and `w`; `specs/target.mdx` holding `z`; (e)'s shape adds `specs/c.mdx`). `s23Compose` composes the receiver in pre-operation coordinates (a span replaced whole; the declaration before an edit beginning at its offset and after one ending there, its leading U+000A judged by `atLineStart` over the composed text with the insertion absent), the generic form later arms with removals reuse (a removal is a rewrite spelled `""`). `s23AssertPremises` judges the staged text, every admissible offset's composed form (placeholder `X`) — or the rewrite-only form where nothing is added — and every `deriving` form with `s23Malformed` (`judgeTypeScript`; `deriveMdx` for a spec source) and requires the admissible forms pairwise distinct — a harness error otherwise, before any product runs. The body collects each staging's `HarnessAssertionError` and fails once (`T6.5-23: <n> of <N> stagings failed, each diagnosed`; a failure diagnosed outside the staging's own assertions — the driver's T6.5-22(a) hook — is prefixed with the staging's key); a byte failure names where the product's bytes read as the declaration inserted (`s23Misplacement`: every offset tried under 6.5's line discipline, then with and without the leading U+000A). Where no single added declaration is read (T6.5-22(a)'s judgement reads only top-level ones), `s23SpelledIdentifiers` recovers the identifier of a declaration of the addition's spelling wherever it stands, so the diagnosis still names its offset ((n): a body's line start). Run alone with `-t 'T6\.5-23 '` on `test/suite/section-6.5-v.test.ts` (~63 s of test time, ~70 s with Vitest's start-up, under the namespace, from Task 47 on; ~45 s and ~51 s at Task 46; ~34 s and ~41 s at Task 45). Against the built product it fails diagnosed, 13 of 33 stagings from Task 47 on (9 of 24 at Task 46, 8 of 18 at Task 45): (a) between two directives (inserted at 73, the start of line 3, after `// note`), (d) (46, after `// note`), (d) with U+00A0 (40, the start of line 2), (d) with U+2028 (73, the file's end, untimely), and (e) 6.5's shape (130, after `// c4`); (f)'s 6.5 example and both boundary stagings (`type T = number`, `import Z = require("./z")`), where the product roots the marker at the untimely `B` — `B.m`, nothing added, confirmed by a hand-staged probe of the example; (a), both (b) stagings, (c), and (e)'s `;` staging pass — the product inserts after the last import's line — as do (f)'s control, both exempt-side stagings, and the precedence branch, and (g). Task 46's (k) fails as well — the product writes `tb(B.m)` and adds nothing, `tb` declared after the call and so untimely (6.5), confirmed by a hand-staged probe (its preview: the `reference-rewrite` [82, 89) alone) — while (h), both (i) stagings, (j), and (k)'s control pass. Task 47's (l) fails too — the product adds `import B …` at 73, after `import A2 …`, rooting both markers at it, `B.m` on line 2 untimely — as do (m) (`B.m` written, nothing added) and both (n) stagings (46, after `// note`), each confirmed by a hand-staged probe (scratch `t47/probe/stage.mjs`; the previews: (l) the `import-addition` at 73 and `reference-rewrite` [34, 38) and [73, 79); (m) the two `reference-rewrite` edits alone, [34, 38) and [106, 112); (n) the `import-addition` at 46); (o) and the four (p) stagings pass. Stand-in check (scratch `t44/t44-standin.mjs`, driven by a temporary `test/self/*.test.ts` calling `runProductTests(binding, productTestSuite.select(["T6.5-23"]))` with `{ command: process.execPath, prefixArgs: [standin, mode, "dist/cli/bin.js"] }` under `--disable-console-intercept`, deleted before committing): on `move specs/origin.mdx#x specs/target.mdx#y` the wrapper recognizes the staging by its `src/c.ts` text (a table of the ten texts and their admissible offsets), rewrites the preview's `src/c.ts` `import-addition` to the mode's offset (re-sorted in 12.7's order), and after the real move writes the staging composed with `import tgt from "../specs/target.xspec"` at that offset and re-runs the real `build`. `conform` (first admissible offset) and `last` (last one) pass all ten (~30 s each); `raw` fails 5 of 10 as the product does; `offset0` and `semicolon` fail 10 of 10 at the byte contract, `previewoff` (the previewed offset plus one) and `previewtwo` (two previewed additions) 10 of 10 at the preview parity, and `dirty` (no re-run `build`) 10 of 10 at `check --json` clean; the seven modes take ~150 s together. Task 45's stand-in (scratch `t45/t45-standin.mjs`, the same driver; it intercepts (f)'s and (g)'s moves alone — and, in `noedge`, `query edges` — so (a)–(e)'s five product failures stay in every mode, ~40 s per mode): `conform` (each (f) addition staging rewritten to TEST-SPEC's bytes — `import bm from "../specs/B.xspec"` at 33, `bm.m`, the preview's `import-addition` at 33, the real `build` re-run) fails only those five; `addall` (that addition in every (f) staging) fails the control, both exempt stagings, and the precedence branch as well (9 of 18); `previewextra` (an `import-addition` at 33 in the no-addition stagings' previews) the exempt and precedence stagings (8); `previewspan` (the exempt previews' `reference-rewrite` doubled, the precedence one narrowed to [70, 72)) the same three (8); `noedge` (`query edges` answering `{"edges": []}`) the example and the precedence branch (7); `g0`, `g28`, and `g59` ((g) rewritten to each admissible offset, preview with it) only the five; `g26` (the split) fails (g) as well (6) — through the driver's T6.5-22(a) hook, which reads the split `import K …` as an added `K` declaration, before T6.5-23's byte contract runs. Task 46's stand-in (scratch `t46/t46-standin.mjs`, the same driver with `T46_MODE` and `T46_STANDIN` in the environment; it intercepts the two moves only where `src/c.ts` is one of the six new stagings — and, in `noedge`, every `query edges` — ~55 s per mode, the nine product failures standing in every mode unless named): `prev0` (the preview's `import-addition` at the removal's start) and `refuse` (exit 2) fail (h), both (i), and (j) (13 of 24); `above` (the added line at 0, above the comment) and `dropcomment` (the comment removed with the declaration) both (i) (11); `hspans` ((h)'s `import-removal` narrowed to [0, 37)) (h) and `jlate` ((j)'s line after `"use client"`, 51) (j) (10 each); `noedge` (h), (k)'s control, and (f)'s precedence branch (12); `kconform` ((k) rewritten to `import { text as tY } …` at 49 and `tY(B.m)`, the preview with it) and `ktext` (`import { text } …` at 82 and `text(B.m)`) pass (k) (8); `kdefault` (`import Bm, { text as tY } …`), `ktextas` (`import { text as text } …`), `kmid` (the mid-line 48), and `kspan` (the call's `reference-rewrite` narrowed to [82, 84)) fail (k) (9 each); `kcontrol` ((k) conformed, the control given `import { text as tY } …` at 49 and `tY(B.m)`) fails the control alone (9). Task 47's stand-in (scratch `t47/t47-standin.mjs`, the same driver with `T47_MODE` and `T47_LOG`; it recognizes (l)–(p) by the receiver's staged text — `specs/third.mdx` for (o) — ~70 s per mode, the nine earlier product failures standing in every mode): `conform` ((l), (m), and both (n) rewritten to TEST-SPEC's bytes at 34 or 37, the preview with it, the real `build` re-run) and `last` ((n) at 38) fail only those nine; `red1` (bytes: (l) at 39, (m) `<X>.m.c`, (n) at 74 and 66, (o) an added `import tgt from "./target.xspec"` rooting `tgt.y`, (p) at 38 for U+0020 and U+0009 and at 37 for U+000B and U+000C) and `red2` (previews: (l) one `reference-rewrite` dropped, (m) one doubled, both (n) the `import-addition` at 38 beside bytes at 37, (o) the `import-removal` narrowed to [0, 30), (p) the `reference-rewrite` narrowed, an `import-removal` added, the `import-addition` moved to 38, or doubled) fail all nine new stagings (18 of 33), each at the mutated assertion; `red3` (`query edges` answering `{"edges": []}` for (l)'s `m.c`, (m)'s `m`, and (o)'s `p`) fails those three (12). +- T6.6-3's preview twins (FIX_PLAN Task 40; `test/suite/registry/section-6.6.ts`): T6.5-6's refusals run on the post-move workspace staged directly from section-6.5.ts's exported `MOVE_IDENTITY_FILES_AFTER` (the cases `MOVE_IDENTITY_REFUSAL_CASES` — the section-form self-move, the same-file collision, and the file-form self-move, the last T6.6-3's own), and T6.5-16's and T6.5-17's arms from section-6.5-iii.ts's exported `R16_REFUSED_ARMS`, `R16_ALONE_ARMS`, and `M17_REFUSED_ARMS` under `R16_CONFIG` through `expectRefusedArmPreviewTwin`; the test's hang guard is 240 s. Run alone with `-t 'T6.6-3 '` on `test/suite/section-6.6.test.ts` (~30 s to its diagnosed failure against the built product, ~65 s when every twin passes). Observed against the built product: it fails diagnosed at the file-form self-move's premise — `move specs/B.mdx specs/B.mdx --json` reports `refused-destination-exists` (`path` `specs/B.mdx`) beside `refused-identity-unchanged` (`identities` `["specs/B.mdx"]`), the preview the same with the three nulls, where SPEC 6.5/14 pin identity-unchanged alone; under the arm-filter variants (`MOVE_IDENTITY_REFUSAL_CASES.slice(0, 2)`, then also `R16_REFUSED_ARMS.slice(0, 0)`) at T6.5-16 (a)'s premise (the move performed, exit 0) and at T6.5-17 (a)'s (exit 70), the two alone arms passing; the section-form self-move and the same-file collision twins pass (the product's real and preview reports agree there). The stand-in red/green check (a scratch `task40-standin.mjs`, mode first on its argv, driven by a temporary `test/self/zz-task40-standin.test.ts` calling `runProductTests` over the one entry per mode with `--disable-console-intercept`, deleted before committing): the temporary test first dumps the three exported arm tables to a scratch `task40-arms.json` (key, files, argv, locations, identities, beside, besidePath, or codes), and the wrapper answers a JSON `move` whose operands and staged spec-file bytes match a dumped arm with a synthesized refusal — `refused-invalid-rewrite` or `refused-moved-import` carrying the arm's locations and identities, the beside reasons as plain findings, all in 14's listing order — as the findings-only form for the real run and the four-member form with the three nulls for `--preview`, drops the product's `refused-destination-exists` from a file-form self-move's report, and passes everything else through to the real product; `conform` passes the whole test in ~65 s (as root; no permission staging is involved), while `preview-exit0` (the preview exit 0 with an empty plan) is caught at T6.5-16 (a)'s preview exit code, `preview-form` (the findings-only form) at the form-exact preview decode, `preview-nulls` (`mapping` `[]` beside the two nulls) at the decode's all-or-none null check, `preview-codes` (the first finding dropped from the preview) and `preview-location` (a location one byte longer in the preview) at the same-findings compare, `real-modify` (a stray file written before the real refusal) at the modifies-nothing compare, `file-self-beside` (the product's beside reason kept) at the file-form premise, and `fffd-preview` (exit 1 with a refusal for a U+FFFD destination under `--preview`) at the usage arm's exit-2 assertion; the eight red modes take ~250 s together. +- T6.6-4's tie-break stagings (FIX_PLAN Task 41; `test/suite/registry/section-6.6.ts`, arm (e) over section-6.5-iii.ts's exported `A13_TIE_BREAK_ARMS` — T6.5-13's (b), (d), (d, terminated), and (g) under `R16_CONFIG`, the exported `a13ReadAddedIdentifiers` reading the fresh identifiers): run alone with `-t 'T6.6-4 '` on `test/suite/section-6.6.test.ts` (~4 s to its diagnosed failure against the built product, arms (a) through (d) passing first). Observed against the built product: every tie-break arm fails at `assertTieBreakEntry` — the preview reports the `import-addition` at [0, 0) (the offset-0 placement) where the pins are the tag's end, [12, 12), for (b) and (g) and the file's end, [4, 4) and [5, 5), for (d) and (d, terminated), and for (g) one `import-addition` entry for the two added declarations; with that call commented out, (b) fails at the byte contract (the product's `specs/b.mdx` reads `import x from "./x.xspec"`, U+000A, the paired form, `</S>` with no final terminator). Each arm is reached through the table-driven filter variant (`A13_TIE_BREAK_ARMS.filter((arm) => arm.key === "(g)")` on the `for` line, the file restored from a scratch `.bak` copy and `cmp`-checked after each run). The stand-in red/green check (a scratch `task41-standin.mjs`, mode first on its argv, driven by a temporary `test/self/zz-task41-standin.test.ts` calling `runProductTests` over the one entry per mode under `--disable-console-intercept`, deleted before committing): the temporary test dumps the table to a scratch `task41-arms.json` (each arm's files, argv, receiving, added, the composed forms for the fixed identifiers `x1`/`y1`, others, receivingEdits, origin, movedConstruct, movedIdAttribute, embeddings), and the wrapper, on a `move` whose operands and every staged file match a dumped arm, answers `--preview --json` with the product's document whose `files` are replaced by the plan it composes itself from the dumped bytes (ASCII fixtures, so char index = byte offset) and, on the real `move`, lets the product perform it, then writes the receiving file's composed form and the other files' bytes and re-runs the real `build`; `conform` passes the whole T6.6-4 in ~10 s (as root; no permission staging is involved), while `swap` (`target-insertion` before `import-addition` at one offset) is caught at the form-exact preview decode's order check, `one` ((g)'s two additions reported as one) at (g)'s tie-break entry, `offset0` (the addition at [0, 0)) at (b)'s, `noref` (the origin's `reference-rewrite` dropped) at the whole-plan compare on `specs/a.mdx`, and `bytes` (the product's real bytes kept) at (b)'s byte contract; `raw` fails diagnosed as the product does. The five red modes take ~30 s together. +- T7-2's import-modifier arms (re-descent FIX_PLAN Task 48; the last four rows of `FORM_VIOLATIONS` in `test/suite/registry/section-7-basics.ts`, their records named `T7-2 xspec.config.ts (a type-only import clause: …)`, `(a type-only import specifier: …)`, `(a deferred import: …)`, and `(import attributes: …)`, each declared well-formed): against the built product T7-2 fails diagnosed at the `import defer { defineConfig } from "xspec"` arm alone — the product loads that configuration and builds (exit 0, `{"findings": []}`) — while it refuses the type-clause, type-specifier, and attributes arms with `configuration-error`, exit 2 (`-t 'T7-2 '` on `test/suite/section-7-basics.test.ts`, ~12 s under the namespace; the whole file, three tests, ~42 s, T7-1 and T7-3 passing). To see that the `defer` arm is the only failure, drop its row from a scratch-backed copy of the module and rerun `-t 'T7-2 '`: the rest of T7-2 then passes whole; restore the module from the copy and `cmp` it. +- T7.1-1's path-character arms and code-source control (re-descent FIX_PLAN Task 49; `PATH_CHARACTER_ARMS`, `runPathCharacterArm`, and the `CODE_PATH_CONTROL_*` constants in `test/suite/registry/section-7.1-7.3.ts`, their records named `T7.1-1 xspec.config.ts (the path-character arms: …)`, `T7.1-1 the path-character arms' spec source (…)`, `T7.1-1 xspec.config.ts (the code-source path control: …)`, and `T7.1-1 the code-source path control's code source (…)`): against the built product T7.1-1 fails diagnosed at its first path-character arm — `specs/a"b.mdx` builds with exit 0 and `{"findings": []}`; a hand probe shows the product applies no 7.1 bar to any of the eight paths, while the control passes (`-t 'T7\.1-1 '` on `test/suite/section-7.1-7.3.test.ts`, ~9 s under the namespace). The arms green-check through the stand-in wrapper pattern (above): a scratch `.mjs` that runs `dist/cli/bin.js` and, when `specs/` under the cwd holds an `.mdx` file whose relative path contains one of the seven barred characters (built from their code points), appends one `invalid-source-path` finding per such file (`identities` and `locations` `[]`, `path` the file) to a `build --json` or `check --json` answer and exits 1, and in a `view` answer replaces every node identity of such a file's view with `{"unavailable": true}`, appends the same finding, and exits 1. Through it T7.1-1 passes (~10 s, 24 product invocations: 5 for the two-group and non-`.mdx` workspaces, two per path-character arm, three for the control), while leaving the identities defined, dropping the view's finding, missing the directory-component arm, flagging the control's code source, or a wrong concerned path each fail at the matching assertion. On Linux the `"`, backslash, LF, and CR arms and the control (its name holds a backslash) run; elsewhere the body skips them. +- T7.3-1's graph-data-area `outDir` arms (re-descent FIX_PLAN Task 50; `GRAPH_DATA_AREA_OUTDIRS` and `LOOKALIKE_OUTDIRS` in `test/suite/registry/section-7.1-7.3.ts`, the body's arms (d'') and (d'''), their records named `T7.3-1 xspec.config.ts (outDir ".xspec" …)`, `… (outDir ".xspec/md" …)`, and `… (outDir ".xspec2", a look-alike of the graph-data area)`, `… (outDir ".xspecs/md", …)`): against the built product T7.3-1 fails diagnosed at its `".xspec"` arm — `build --json` exits 0 with `{"findings": []}`; a hand probe shows the product accepts `.xspec` and `.xspec/md` and emits `specs/A.md` into the graph-data area (`.xspec/specs/A.md`, `.xspec/md/specs/A.md`), while the look-alikes emit under `.xspec2/` and `.xspecs/md/` as asserted (`-t 'T7\.3-1 '` on `test/suite/section-7.1-7.3.test.ts`, ~6 s under the namespace). The arms green-check through the stand-in wrapper pattern (above): a scratch `.mjs` that reads the `outDir: "…"` literal from the cwd's `xspec.config.ts` and, when it is `.xspec` or begins `.xspec/`, prints a `configuration error` line on stderr and, under `--json`, the 12.7 error document (`code` `"configuration-error"`, `path` `"xspec.config.ts"`, `identities` and `locations` `[]`) and exits 2 without running the product, else runs `dist/cli/bin.js` unchanged. Through it T7.3-1 passes (~9 s, 26 product invocations, every arm after the new ones included), while refusing `.xspec` alone, refusing every spelling whose bytes begin `.xspec`, creating `.xspec/` before refusing, or replacing a look-alike's created `outDir` by a symlink each fail at the matching assertion. +- T11.6-2's invalid-path `.mdx` derived-map arms (re-descent FIX_PLAN Task 51; `T11_6_2_INVALID_PATH_ENTRIES` in `test/suite/registry/section-11.6.ts`, staged in the emit workspace: `specs/a'b.mdx`, and on the Linux leg `specs/b<0xFF>.mdx` through the byte-path `file`, their records named `T11.6-2 emit workspace specs/a'b.mdx …` and `… specs/b<0xFF>.mdx …`): T11.6-2 passes against the built product (`-t 'T11\.6-2 '` on `test/suite/section-11.6.test.ts`, ~8 s with Vitest's start-up under the namespace). Red check through the stand-in wrapper pattern (above): a scratch `.mjs` that, on an `inventory` exiting 0, nulls `module` and `markdown` for the derived entries whose `source` is the byte form or a string holding `'` (built from `String.fromCharCode(39)`), or only for the byte-form one, or rewrites the byte-form entry's `module` to its lossy UTF-8 decode or its `markdown` to the source bytes plus `.md`, or nulls `specs/a'b.mdx`'s `markdown` alone, fails T11.6-2 at that entry's own assertion each time, the unmodified product passing (six modes ~15 s in one temporary self-test). +- T12.0-5's positive side of the backslash (re-descent FIX_PLAN Task 52; `expectBackslashCodeArms` and `expectBackslashSpecArms` in `test/suite/registry/section-12.0-i.ts`, called at the end of T12.0-5's body under `process.platform === "linux"` — the Windows subset reruns the entry whole and skips no arm; the backslash built from `String.fromCharCode(0x5c)`, each side its own workspace of staged-source records): against the built product T12.0-5 now fails diagnosed at the spec side's `view` — exit 0, the identity `specs/a<0x5C>b.mdx#pb` defined, no finding: the build predates SPEC 7.1's backslash bar (14.19) — after the code side's three `occurrences --file` runs pass (`-t 'T12\.0-5 '` on `test/suite/section-12.0-i.test.ts`, ~12.5 s test time, ~19 s with Vitest's start-up under the namespace; the whole file ~63 s, T12.0-1 to T12.0-4 and T12.0-6 passing). Hand probe in a scratch workspace (`E6_CONFIG`-style one spec group): `node <repo>/dist/cli/bin.js view "specs/a$(printf '\x5c')b.mdx"` (and `at … 0`) — bash builds the byte, never a typed escape. Red/green through the stand-in wrapper pattern (above), one temporary self-test looping modes over `productTestSuite.select(["T12.0-5"])` (~12.5 s per mode): `conform` rewrites a `view`/`at` answer that exits 0 over a backslash-named `.mdx` operand into every node identity (or the `at` section's) `{"unavailable":true}`, the finding `{code:"invalid-source-path", identities:[], locations:[], message, path:<operand>}` first, exit 1 — the whole test passes, ranges included; on top of it, a backslash read as `/` in `occurrences` argv, the backslash dropped (an escape), a `<0x5C>*` pattern replaced by one matching nothing, the `view` or the `at` operand rewritten to `specs/a/b.mdx` (exit 2), the section's or the `at` section's identity left defined, and the finding omitted each fail at their own assertion; a mode must leave the existing `specs<0x5C>A.mdx` negative's operand alone, or that earlier arm catches it first. Run facts: self project 25 files, 3940 passed under the namespace (152 s); certification 154 PASS / 38 FAIL / 0 error / 0 hang over 27 runs, unchanged. +- T12.7-2's two-reason file move (re-descent FIX_PLAN Task 55; `runTwoReasonFileMoveArm` in `test/suite/registry/section-12.7.ts`, the body's last arm, after arms A–D): it stages T6.5-21(a) through section-6.5-iv.ts's own `runD21RefusedStaging` over `{ ...D21_A_STAGING, moves: [D21_TWO_REASON_MOVE] }` (the premise `build` re-pinning `specs/A.md` a plain file; the records `D21_SPEC_MD_CONFIG` and `D21_A_SOURCE`, their names now led by `T6.5-21/T6.6-3/T12.7-2`, so no new S-9 record and no self-project count change) and pins `TWO_REASON_FINDINGS` (code and `path`) literally. Run alone with `-t 'T12\.7-2 '` on `test/suite/section-12.7.test.ts` (~6.5 s of tests, ~13 s with Vitest's start-up): against the built product arms A–D pass and the test fails diagnosed at the new arm (exit 0 — hand-probed, the product performs `move specs/A.mdx "specs/a'b.mdx"`, predating the reason and 7.1's barred characters). Stand-in check: Task 42's scratch wrapper (`t42/t42-standin.mjs`, the T6.5-21 bullet) through a temporary `test/self/zz-*.test.ts` calling `runProductTests` on `productTestSuite.select(["T12.7-2"])`, one Vitest test per mode under the namespace (~7 s each): `conform` passes the whole body; `order` fails at the form-exact decode's order check (`$.findings[1]`); `two-one`, `beside`, and `path` at the pinned code/path sequence; `real-a` at the exit code. +- T13.4-4's directory arms (re-descent FIX_PLAN Task 56; `T13_4_4_DIRECTORY_STAGINGS` and `verifyDirectoryOccupants` in `test/suite/registry/section-13.4.ts`, the body's arm 3): two workspaces created with the body's others before its first product invocation (plain `MARKDOWN_CONFIG` beside the shared `CORE_A_STAGED` record, so no new S-9 record and no self-project count change) — directories at `specs/A.xspec.ts` and `specs/A.md`, empty, then each holding `notes.txt`. Run alone with `-t 'T13\.4-4 '` on `test/suite/section-13.4.test.ts` (~1.5 s of tests, ~10 s with Vitest's start-up): passes against the built product (hand-probed: `build` exits 0 over both stagings, plain files there, `check --json` finding-free). Red-check through the scratch-wrapper pattern (a temporary `test/self/zz-*.test.ts` calling `runProductTests` on `productTestSuite.select(["T13.4-4"])`, one Vitest test per mode under the namespace, ~10 s each) with a stand-in acting only on `build` over a workspace whose `specs/A.xspec.ts` or `specs/A.md` is a directory: exiting 1 is caught at the arm's build exit code; moving the directories aside and restoring them over the written files, at the plain-file kind check; moving `notes.txt` out beside them, at the whole-workspace compare; the unmodified answer passes the whole body. +- T13.4-9 (re-descent FIX_PLAN Task 57; `test/suite/registry/section-13.4.ts`, the `RELATION_*` section between T13.4-8 and T13.4-11: `RELATION_STAGINGS_A_TO_D`, `relationCompanionStagings`, and `RELATION_STAGING_F`, driven by `runRelationStaging` and `expectRelationReport`): seven new staged-source records (four configurations, the code source, two `.mdx` sources), so the self project gains seven tests. Run alone with `-t 'T13\.4-9 '` on `test/suite/section-13.4.test.ts`: fails diagnosed against the built product at (a)'s `build` (exit 0; hand-probed: the product writes `specs/a.md` over the directory, deleting `specs/a.md/b.mdx`, and `ids` answers), a product failure under SPEC 14.22's relation between derived paths. The built product records three companions per spec source (`NAME.xspec.impl.d.ts`, `NAME.xspec.impl.d.ts.map`, `NAME.xspec.impl.js`), so (e) stages six workspaces, three per leg. Red/green-check through the scratch-wrapper pattern with a stand-in that computes the relation itself: spec sources from the configuration's glob (`**/*.mdx` or under `specs/`), derived paths as the module, those three companions, and the emit path (under `out/` or next to the source), and offending paths as derived paths with a discovered source or another derived path beneath. It answers `build`, `check`, and `ids` `--json` with one `obstructed-write-path` finding (sorted keys, `locations` `[]`) and exit 1, and delegates everything else (the companion twins, (f)'s premise build) to `dist/cli/bin.js`. The unmodified answer passes the whole body (~4.5 s of tests). Vetting derived paths against one another only is caught at (d)'s build; leaving companions out, at (e)'s first staging (the real product then dies with exit 70 writing through the companion directory); a second finding when the offending path is a plain file, at (f)'s build count; delegating `ids`, at (a)'s gated read; a non-empty `locations`, at (a)'s build. A stand-in duplicating a finding byte-identically is rejected by the 12.7 decoder's collapse rule before the count assertion, so give a duplicate a distinct message to exercise the count. +- T13.4-10 (re-descent FIX_PLAN Task 58; `test/suite/registry/section-13.4.ts`, the `OBSTRUCTION_*` section between T13.4-9 and T13.4-11: `OBSTRUCTION_ARMS` walked by `walkObstructionArm`, the correction judged by `assertManualDeletionCorrection` through the pure `judgeManualDeletionCorrection` of `test/helpers/adapters/human.ts`, whose fixed vectors are S-5's three "correction judge" tests in `test/self/s5-output-adapters.test.ts`, selected with `-t "correction judge"`): three new staged-source records (two configurations and `specs/A.mdx`), so the self project gains three tests; the twin's graph-data deletion imports `deleteGraphData` from `section-13.3.ts`. Run alone with `-t 'T13\.4-10 '` on `test/suite/section-13.4.test.ts` (~1.3 s of tests): fails diagnosed against the built product at the recorded arm's `check`, on the correction (the product's recorded-file message says to run `xspec build` to remove the file, with no manual deletion). Hand-probed beyond that point, the product's `build` after the manual deletion exits 0 but removes the directory now at the recorded path `out/specs/A.md`, so `out/specs/A.md/specs/A.md` is missing and `check` reports it stale — a second product failure, under 13.4's removal rule; the unrecorded twin passes against the product. Green/red-check through the scratch-wrapper pattern with a stand-in that delegates to `dist/cli/bin.js`, reruns an exit-0 `build` once (a no-op at a conforming fixed point; it restores the emit path the built product's removal deletes), and, once `xspec.config.ts` holds the reconfigured `outDir`, rewrites the message of the `stale-output` finding concerning `out/specs/A.md` to a manual-deletion correction whenever an `obstructed-write-path` finding concerns the same path. The unmodified answer passes both arms. A message that also says to run `xspec build` to remove the file, one with neither a manual marker nor an instruction to the reader to delete or remove the file ("delete it, then rebuild" passes), one presenting xspec as the remover ("xspec will remove it") or a build as the removal's means ("remove it: run `xspec build`"), or one removing it manually by rebuilding is caught at the correction; a dropped `stale-output` finding, or an added mismatch-form one (inserted after the recorded one, in 12.7's path order), at `check`'s count; a recorded-file finding added in the twin, at the twin's count; the orphan deleted by the wrapper after the refused `build` or after `check`, or a file written under `.xspec/` after the refused `build`, at the compare-around; a wrong `path` on the `obstructed-write-path` finding, at its concerned path; an extra finding beside it at `build`, at `build`'s count; and no rerun of the exit-0 `build`, at the emit-path assertion. +- T14-11's refined `d`-value ranges and `d={}` (FIX_PLAN Task 42; `test/suite/registry/section-14.ts`, arm (c) and the new arms (p)–(t)): against the built product the five new arms pass — the product locates `(BASE.a)` with its parentheses, a comma sequence whole, `BASE.missing` alone past a block comment and past U+00A0/U+FEFF, a spread entry from its `...`, and the elisions of an array literal as one 14.8 at the whole literal (two literals, two findings) — while arm (c) fails diagnosed: `d={}` and `d={ /* c */ }` are reported 14.20 (the condition agreeing with SPEC 2.7/14.20) but with a non-empty range at the byte after the opening brace (`{46,47}` for both) where SPEC 14 pins the zero-length range at the closing brace (46 and 55). The stock parser rejects both forms as `unexpected-empty-expression` at the byte after `{` (`deriveMdx`; the rule's offset for `d={}` alone), so both stagings are `mdx.unparseable`; the seven well-formed new shapes derive. Per-arm table: the try/catch variant on the `for` line of `T14_11`'s `run`, each verdict appended to a scratch file (`console.log` inside the body does not reach the output), the file restored from a scratch copy and `cmp`-checked — ~8 s for all arms; (h), (j), (m), (n) fail as recorded at 67a9275. Stand-in red/green (a scratch `task42-standin.mjs`, mode first on its argv, driven by a temporary `test/self/zz-task42-standin.test.ts` calling `runProductTests` over `productTestSuite.select(["T14-11"])` per mode under `--disable-console-intercept`, ~45 s for four modes, deleted before committing): `conform` (every `unparseable-source` location moved past ECMAScript trivia — ASCII whitespace, U+00A0, U+FEFF, block comments — to a following `}` and made zero-length) carries the test past (c) to (h); `raw`, `nonempty` (end = start + 1), and `unmoved` (zero-length at the product's start) each stop at (c). Mutation red check (the matrix recipe on a scratch copy, the loop filtered to `"pqrst".includes(k.arm)`): (p)'s pin without its `(`, (r)'s U+FEFF pin including the U+FEFF, and (t)'s first literal without its `]` each fail against the product's exact range. +- T14-11's colliding-declaration forms, fragment, and UTF-8 offsets (FIX_PLAN Task 43; `test/suite/registry/section-14.ts`, arm (k) and the new arms (u) and (v); the four forms exported from `section-4.5.ts` as `T4_5_8_FURTHER_LOCATED_FORMS`): against the built product the fragment pin in (k) passes (a top-level `<>Fragment.</>` is located from `<>` through `</>`), (u) fails diagnosed at its exit-code assertion — `build --json` exits 0 with `{"findings": []}` beside all four colliding forms, no 14.15 and no 14.7, the value-level collision silence T4.5-8 records — and (v) fails diagnosed at the ranges: every encoding failure is reported at the right offset (`41 E2 82 41` and `41 E2 82` at EOF → 1, `C0 80` and `ED A0 80` → 0, a spec and a code source alike) but as a one-byte range `{start, start+1}` where SPEC 14 pins zero-length. `deriveMdx` judges all four byte sequences non-derivable (`not valid UTF-8: an invalid sequence at byte offset n`, the offset agreeing with the pin), so the four spec stagings are `mdx.unparseable`. Per-arm verdicts: the try/catch variant on the `for` line of `T14_11`'s `run` with an arm filter (`T14_11_CASES.filter((k) => "kuv".includes(k.arm))`, each verdict appended to a scratch file, an early `return` under the env variable), the file restored from a scratch copy and `cmp`-checked — ~5 s per run. Mutation red check: (k)'s fragment pin without its `</>` fails against the product's exact range. Stand-in red/green (a scratch `task43-standin.mjs`, mode first on its argv, driven by a temporary `test/self/zz-task43-standin.test.ts` over `productTestSuite.select(["T14-11"])` under the same filter variant and `--disable-console-intercept`, ~10 s per mode, deleted before committing): `conform` (every `unparseable-source` range made zero-length at the product's start) passes (v) and `notice` (the start shifted two bytes, zero-length — a decoder reporting where it resynchronizes) fails it; `conform-u` (14.7 and 14.15 findings synthesized from the staged `src/collide-*.ts` files at the pinned constructs, emitted in 12.7's order — numbered conditions by ordinal, then locations by file bytes and start — with sorted keys and exit 1) passes (u), and `conform-u-wide` (`let SPEC;` located through its `;`, the exported class from its `export`) fails it at the pinned ranges. +- T14-12's positive arms (FIX_PLAN Task 44; the new `test/suite/registry/section-14-iii.ts`, wrapper `test/suite/section-14-iii.test.ts`; the staged MDX sources exported as `T14_12_FORM_VECTORS` — `[name, source, allowances]` triples, `test.each` spreading them — and judged by the S-9 self-test as deriving under exactly the named allowance and rejected without it): run alone with `-t 'T14-12 '` on `test/suite/section-14-iii.test.ts` (at Task 44, ~5 s to its diagnosed failure against the product then built at arm (b) — the current product passes (a)–(o) and fails first at Task 61's (af), see that bullet: the product reported 14.20 for `export { nope }` — "Could not parse import/exports with acorn" at the `nope` — and, under the per-arm diagnostic variant, 14.20 "Could not parse expression with acorn" for `{1 = 2}`, `{let}`, and `{010}` (arms (c)–(e)), while (a) and (f)–(o) pass: it delegates ECMAScript's early errors to its parser). The diagnostic variant that shows every arm's outcome: on a scratch copy, wrap each `await run…Arm(…)` of the body in try/catch appending `"(arm): PASS"` or the failure message's first line to a scratch log with `appendFileSync` (a `console.log` inside a registered body does not reach the run's output), run, restore the module from the copy, `cmp`. Mutation red-checks run under a variant skipping the arms the product fails (`/* skipped */` for `runExportNopeArm`, `SPEC_FORM_ARMS.filter((a) => a.arm > 'e')`): (g)'s `conditions` 14.8→14.5, (m)'s `unit` `C.run`→`C`, and (k)'s 14.16→14.17 each fail at their own arm. Arm (b)'s surfaces green/red through the scratch stand-in `task44-standin.mjs` (argv `[mode, binJs, …]`; the temporary `test/self/zz-task44-standin.test.ts` binding `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` through `runProductTests(binding, productTestSuite.select(["T14-12"]))`, run alone on the self project with `--disable-console-intercept`, deleted before committing): `conform` runs the real product on a temporary copy of the workspace in which the `export { nope }` line is replaced by same-length spaces — a whitespace-only line ends the ESM block with every later offset unchanged, so the product's own `view` and `occurrences` documents are the conforming answers — and injects the condition-16 finding at the statement whole with exit 1 into the `build --json`, `view`, and `occurrences` answers, on which (b) passes whole and the body proceeds to (c); `collision` (a 14.15 beside it), `extra-import` (the statement listed as an import entry), and `drop-record` (an empty `occurrences` answer) fail at (b)'s count, `imports`, and `occurrences` assertions respectively. An injected finding must keep 12.7's order (a 14.15 before the 14.16): the findings decoder rejects an out-of-order report before any assertion runs. +- T14-12's negative arms and T14-4's stagings over T14-12 (FIX_PLAN Task 45; `test/suite/registry/section-14-iii.ts`, arms (p)–(w) in the exported `T14_12_UNPARSEABLE_ARMS`; the spec sources judged by the S-9 self-test as `T14_12_UNPARSEABLE_VECTORS`, the pinned offset confirmed against `deriveMdx`'s rejection position — the parser's UTF-16 index converted to the byte length of the prefix — for every arm but the spread's, whose extra content the stock parser reports at the `b` past the comma; the 14.16 and 14.20 stagings exported as `T14_12_REPORTER_STAGINGS` and spread into `section-14.ts`'s `SWEEP_ENTRIES`): run alone with `-t 'T14-12 '` on `test/suite/section-14-iii.test.ts` (at Task 45, ~6 s to its diagnosed failure at arm (b), unchanged) and `-t 'T14-4 '` on `test/suite/section-14.test.ts` (~36 s; at Task 45 T14-4, which passed before, failed diagnosed at its first T14-12 entry — (b) `export { nope }` reported 14.20 where the entry pins one 14.16). Observed against the built product under the per-arm try/catch variant (the negative loop alone, the positive calls removed on a scratch copy, each verdict appended to a scratch log, the module restored and `cmp`-checked): (w) passes — the unbalanced `{text("a")` is reported zero-length at the file's byte length — while (p)/(q) report the whole literal `010`/`09` from its `0` (`[55,58)`/`[55,57)` where the pins are `[56,56)`), (r) reports the spread's extra content as a one-byte range at the `b` (`[91,92)`, the pin `[89,89)` at the comma), (t), (u), (v) report one-byte ranges at the right offsets (`[32,33)`, `[87,88)`, `[80,81)`), and (s) reports no 14.20 at all — the product reads `import BAD from "./missing.xspec"` followed on the next line by `const x = 1` as well-formed and reports the in-file 14.15 and 14.4 that masking forbids. T14-4's per-entry variant (the sweep loop filtered to labels starting with `T14-12`, a try/catch around its `withWorkspace` call, verdicts appended to a scratch log, ~27 s): (b)–(e) fail at `build --json`'s count (14.20 for the early-error forms), (s) at its count, and (f), (h), (i), (k), (p)–(r), (t)–(w) pass on every surface — counts alone, the offsets being T14-12's. Stand-in red/green (a scratch `task45-standin.mjs`, argv `[mode, binJs, …]`, driven by a temporary `test/self/zz-task45-standin.test.ts` calling the temporarily `export`ed `runUnparseableArm` per arm and mode with the binding `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` under `--disable-console-intercept`, ~17 s for four modes as root — no permission staging is involved — the export reverted and the file deleted before committing): `conform` (every `unparseable-source` range made zero-length at SPEC's offset — the second digit of a `0`-led literal in a `.ts` file, the comma before a spread's extra content, the product's start otherwise — and, for (s), the product's findings replaced by one 14.20 at the `const` line's start) passes all eight arms; `raw` fails as the product does; `nonempty` (end = start + 1) fails every arm but (s) at the range; `leak` ((s)'s synthesized 14.20 appended beside the product's 14.4 and 14.15) fails (s) at the count; a mutation of (w)'s `offset` by ±1 fails against the product's exact range. The quickest read of the product's ranges before pinning such arms is a scratch `stage.mjs` that writes each workspace with `Buffer.from(text, "utf8")` and runs `build --json`, `check --json`, `occurrences`, `view`, and `at specs/A.mdx 0` from it, printing the findings (a `.ts` staging answers `view`/`at` finding-free at exit 0: a code source is in neither domain). A scratch variant written through an unquoted bash heredoc loses its backslashes (`"\\n"` arrives as a literal line break inside the TS string and the file fails to transform, "no tests" collected): quote the delimiter (`<<'EOF'`) and pass paths through the environment. +- T14-11's (w) family — the syntax-failure offsets of T2.3-3, T2.4-2, T2.7-3, T2.7-4, and T14-12 re-asserted (FIX_PLAN Task 46; `test/suite/registry/section-14.ts`, arms (w.1)–(w.19) built by `reassertedCase` from `T14_11_REASSERTED_STAGINGS`, the `UnparseableStaging` records (`support.ts`) the home modules export — `T2_3_3_UNPARSEABLE_STAGING`, `T2_4_2_UNPARSEABLE_STAGINGS`, `T2_7_3_SPREAD_UNPARSEABLE_STAGING`, `T2_7_4_UNPARSEABLE_STAGINGS`, and T14-12's `T14_12_UNPARSEABLE_ARMS` mapped by `t1412Staging`): run alone with `-t 'T14-11 '` on `test/suite/section-14.test.ts` (~1 s to its diagnosed failure at arm (c), unchanged — the (w) arms come last in `T14_11_CASES`, so neither that run nor the S-7 sweep against the stub, which fails at (a), ever stages them; the per-arm variant is the only check that every (w) staging passes the S-9 builder). The variant: on a scratch copy, the T14-11 body's loop filtered to `k.arm.startsWith("w.")` with a try/catch appending `PASS`/`FAIL <arm> [<error constructor>] <first message lines>` to a scratch log and a `return` before the later arms (~2 s; the module restored from the copy and `cmp`-checked). Observed against the built product: (w.19) passes — the unbalanced `{text("a")` zero-length at the file's byte length — while (w.1) reports `[48,49)` at the space before the second `text` (pinned `[49,49)`); (w.2)/(w.3) report one 14.8 where 14.20 is pinned (the product parses the non-null assertion `BASE.auth!` and treats it as dynamic); (w.4) `[94,95)` at the space before `as` in `d` (pinned `[95,95)`); (w.5), (w.9), (w.10), (w.16), (w.17), (w.18) one-byte ranges at the pinned offsets; (w.6) and (w.14) the spread's extra content at the `b` past the comma (`[60,61)` for `[58,58)`, `[91,92)` for `[89,89)`); (w.7)/(w.8) the code point's own bytes (`[65,67)` for U+0085, `[65,68)` for U+200B, pinned `[65,65)`); (w.11) `[48,49)` at the first `}` of the run-on `{// c}` where the file's byte length 50 is pinned; (w.12)/(w.13) the whole literal from its `0`; (w.15) 14.15 and 14.4 with no 14.20. Stand-in red/green (a scratch `task46-standin.mjs`, argv `[mode, binJs, …]`, matching the workspace's staged bytes against a map `[{file, offset, bytes: base64}]` the driver writes from the exported cases; driven by a temporary `test/self/zz-task46-standin.test.ts` calling the temporarily `export`ed `runRangeRuleArm` per case and mode with the binding `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` under `--disable-console-intercept`, ~35 s for five modes as root; the exports reverted and the file deleted before committing): `conform` (the product's findings replaced by one `unparseable-source` zero-length at the pinned offset, synthesized where the product reports none) passes all nineteen; `raw` fails the eighteen the product deviates on; `nonempty` (end = start + 1) and `shift` (offset + 1) fail every arm; `leak` (the conforming finding beside the product's other findings) fails (w.2), (w.3), and (w.15) alone. The product's raw 14.20 document, for shaping such a stand-in: `{"findings": [{"code": "unparseable-source", "identities": [], "locations": [{"file", "range": {"start", "end"}}], "message", "path": null}]}` at exit 1. +- T14-7's refined refusal arms (FIX_PLAN Task 47; `test/suite/registry/section-14.ts`): refused-invalid-rewrite and refused-moved-import re-asserted over `section-6.5-iii.ts`'s exported `R16_REFUSED_ARMS` (the one arm beside `refused-id-collision` filtered out — `T14_7_INVALID_REWRITE_ARMS`, 36 arms) and `M17_REFUSED_ARMS` (4), each staged under `R16_CONFIG` and asserted through `assertRefusalReport` with `locatedAtEach` over the entry's exact ranges (`path` null comes with it), `identities` the entry's, and every `beside` reason's expectation derived from the operands (`besideExpectation`: `refused-invalid-id` → the `<target-file>#<new-id>` operand, `refused-missing-target-parent` → `<new-id>` minus its final segment over the target file, `refused-invalid-destination` → the entry's `besidePath`); the invalid-path identities (`runT147InvalidPathArms`: `move specs/A.mdx#x 'specs/new.txt#x y'` and `… specs/new.txt#p.y`, then `query node` on `specs/new.txt#x y` and `specs/new.txt#p` → exit 2 with the error document); and the sibling spec-import-cycle arm (`runT147SiblingCycleArm`: A imports C, `x` carries `d={C.foo}`, C imports B via `foo`'s `d={B.bar}`). `-t 'T14-7 '` on `test/suite/section-14.test.ts` still fails diagnosed at the first rename arm's identities in ~1 s (the new arms come after it), so the new arms are observed by driving the four module-private runners directly: `sed` their `async function runT147…(` lines to `export async function` (restored from a scratch copy and `cmp`-checked before committing) and run a temporary `test/self/zz-task47-standin.test.ts` under `--project self … --disable-console-intercept` in the unprivileged namespace, calling each runner with `builtProductBinding()` and with stand-in bindings `{ command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` (about 2 min for all bindings and modes): the scratch `task36-standin.mjs` (R16 by rule; `conform` passes all 36 arms in ~13 s, while `range2`, `identities-drop`, `beside-drop`, and `besidepath` are caught at the first arm's located-bearer window, arm (i)'s identities, arm (d)'s multiset, and the created `specs/new.txt` arm's beside path), `task37-standin.mjs` (M17; `conform` passes in ~2 s, while `range`, `single`, `ident`, `path`, and `exit0` are caught at the window, the location count, the identities, the null path, and the exit code), and a `task47-standin.mjs` that runs the real product and rewrites its JSON answer (`conform` adds the chain spelling's location to the product's refused-cycle finding, sorted into 12.7's order, and passes both of T14-7's own new arms; `c-missing` and `c-wrong-file` are caught at the located-bearer set, `b-drop-destination` at the multiset, `b-identities-extra` at the identities, `b-path-null` at the concerned path, `b-query-exit0` at the exit code). Observed against the built product: the invalid-path arms pass (both findings with the pinned identities and path, `refused-invalid-id`/`refused-missing-target-parent` before `refused-invalid-destination` in 14's order, `query node` exit 2 with the code-null error document); the sibling cycle arm fails diagnosed at the located-bearer set — the product locates only C's import of B, `specs/C.mdx [0, 25)`, its message naming the cycle `specs/B.mdx → specs/C.mdx → specs/B.mdx`, never the chain spelling in A; the invalid-rewrite arms fail at arm (a)'s exit code (the move performed at exit 0) and the moved-import arms at arm (a)'s (exit 70), as at their homes. +- T14-7's re-descent arms (re-descent FIX_PLAN Task 59; `test/suite/registry/section-14.ts`): the body also stages T6.5-4's outside-root link staging and the derived-path arm's link sibling (`runT147LinkArms`, over `section-6.5.ts`'s exported `MOVE_LINK_OUTSIDE_*` and `MOVE_DERIVED_LINK_*`), runs every link refusal — those two and the shared table's inside-root entries `MOVE_LINK_INSIDE_CASES` (exported, spread into `MOVE_REFUSAL_CASES`) — inside `assertLinkAndTargetUnchanged` (a root snapshot narrowed by `snapshotDirectory`'s `exclude` to the link entry, its ancestors, and an inside target's tree; an outside target compared on its own; the link paths exported as `MOVE_LINK_COMPONENT` and `MOVE_DERIVED_LINK_COMPONENT`), and iterates T6.5-20's and T6.5-21's refused stagings through their exported tables and runners (`runT147DerivedPathRelationArms`, `runT147ExposedDerivedFileArms`); `assertRefusalReport` asserts `locations` `[]` beside every stated `path`. Against the built product `-t 'T14-7 '` still fails diagnosed first at T6.4-3's U+2028 rename arm (~4 s). The whole test is reached through a chained stand-in (scratch `t59/standin.mjs`, `t59/to41.mjs`, `t59/to42.mjs`): Task 33's `t33_standin.mjs` in `conform` mode spawns `to41.mjs`, which runs Task 41's `t41/t41-standin.mjs` with `to42.mjs` as its binary, which runs Task 42's `t42/t42-standin.mjs` over `dist/cli/bin.js`, each level's mode passed down by environment (`T59_M41`, `T59_M42`); a `move` in a workspace whose `specs/A.mdx` holds T6.5-21's bytes skips the t33 level, whose barred-path rewrite would hide the two-reason move's `'` from t42. Driven by a temporary `test/self/zz-t59-standin.test.ts` calling `runProductTests(binding, productTestSuite.select(["T14-7"]), { concurrency: 1 })`, `conform` passes the whole test in ~151 s as root (no permission staging is involved), and sixteen perturbations (~40–85 s each) are each caught at their own assertion: t42's `loc`, `ids`, `path`, `beside`, `real-a`, and `two-one` at the T6.5-21 arm's `locations`, identities, path, multiset, exit code, and multiset; t41's `real-d` at T6.5-20 (d)'s multiset (the product's 14.15); a location and a 14.22 finding beside on a T6.5-20 refusal at its `locations` and multiset; a location on the first `refused-destination-exists` at its `locations`; and a file written through, or a directory put over, each of the three links (inside, outside, derived) at its own link-and-target compare. +- T14-11's re-descent arms (re-descent FIX_PLAN Task 60; `test/suite/registry/section-14.ts`): arm (r)'s `T14_11_D_TRIVIA` also stages sections `m4` and `m5`, U+1680 and U+3000 (`OGHAM_SPACE`, `IDEOGRAPHIC_SPACE`) on each side of `BASE.missing`, five 14.5 findings pinned; and arm (x), the last entry of `T14_11_CASES` (after the (w) family), stages `T14_11_LINKING_FORMS` — `src/export-require.ts`, `src/typeof-import.ts`, `src/qualified-import.ts`, `src/module.ts`, and `src/export-module.ts`, each a `StagedTs` record led by `const before = "café"` — one 14.15 finding per file. Against the built product `-t 'T14-11 '` on `test/suite/section-14.test.ts` (~25 s under the namespace) runs every arm through (w) green, the widened (r) included, then fails diagnosed at (x) (`actual: ["14.15 x1"]`, `expected: ["14.15 x5"]`): the product reports no 14.15 for either import type or either module declaration and locates `export import X = require(…)` from `export` (`[24, 69)` for the pinned `[31, 69)`), so arms (n) and (o), which run after the table, meet this product only through a stand-in. The stand-in check (scratch `t60/standin.mjs`, argv `[mode, binJs, …]`: in a workspace holding `src/export-require.ts`, `build --json`'s findings replaced by five 14.15 findings at hand-computed ranges, sorted in 12.7's order — by file, bytewise — or the order adapter rejects the answer; driven by a temporary `test/self/zz-*.test.ts` calling `runProductTests` with `section14ValidationTests.filter((t) => t.id === "T14-11")` under the namespace, ~45 s for two modes): `conform` passes T14-11 whole, (n) and (o) included; `shift` (one start + 1) fails it at (x). Arm (r)'s red check: moving `IDEOGRAPHIC_SPACE` into `m5`'s pin (the text unchanged, the pinned start 3 bytes earlier) fails T14-11 at (r) against the product. +- T14-12's re-descent arms (re-descent FIX_PLAN Task 61; `test/suite/registry/section-14-iii.ts`): ten arms lettered past (w) in TEST-SPEC order, every exotic character built from its code point (`EXT_I`, `TJE`, `ZWSP`, `NEL`). The release pin (x)–(z), the language level's code source (aa), and the whitespace arm (ad) are rows of `CODE_FORM_ROWS` (a row's optional `target` names a spec source other than `specs/A.mdx`: (aa)'s is `specs/S.mdx`, one section U+2EBF0); the language level's configuration (ab) is `runAliasedConfigArm` (`build` exit 0, `check` clean, `ids --json` listing the spec group's file); the U+1C89 negative (ac) stands in `T14_12_UNPARSEABLE_ARMS` after (q), so T14-4 and T14-6 sweep it and T14-11's (w) family re-asserts it; the Unicode-pin arms (ae)–(ag) are rows of `SPEC_FORM_ROWS`, so they join `T14_12_FORM_VECTORS`, (ae) and (af) (14.16 alone) also T14-4's and T14-6's sweeps. The positive code sources and (ab)'s configuration are exported as `T14_12_CODE_FORM_VECTORS`, judged accepted both ways by `test/self/s9-typescript-well-formedness.test.ts`. Against the built product `-t 'T14-12 '` on `test/suite/section-14-iii.test.ts` (~6 s) fails diagnosed at (af): the product reports 14.20 ("Unexpected character ... (U+D87A) in name") for `<a` U+2EBF0 ` />`, and likewise for (ag)'s attribute name — a JSX name judged one UTF-16 code unit at a time — while under the per-arm try/catch variant (the Task 44 bullet's recipe; ~10 s) every other arm passes, (a)–(e) included. `-t 'T14-4 '` (~68 s) and `-t 'T14-6 '` (~18 s, under the namespace: T14-6's later arms stage permission refusals, a harness error as root) on `test/suite/section-14.test.ts` fail diagnosed at the (af) entry; a per-entry variant (each loop over `SWEEP_ENTRIES` filtered to labels starting `T14-12`, a try/catch around its `withWorkspace` call logging to a scratch file, the module restored and `cmp`-checked; ~60 s for both) shows every other T14-12 entry passing, (ae) and (ac) included. `-t 'T14-11 '` still runs green through its (w) family, (ac) included, and fails at its (x) alone. Green probe for (af) and (ag): a scratch `stage.mjs` staging same-length ASCII twins (`<abbbb />`, `abbbb="v"`) gets the product's 14.16 at [33,42) and 14.17 at [43,52), exactly the pinned ranges. Mutation red-checks under the per-arm variant — (ae) 14.16 to 14.17, the unit of (x), (y), (z), and (ad), (aa)'s target id, (ab)'s expected ids, (ac)'s pin moved to offset 5 with the text unchanged — each fail at their own arm. +- Hand-driving a certification fixture through the hold seam (FIX_PLAN Task 48; `test/fixtures/conf-core/bin.mjs` and its `bin-<deviation>.mjs` siblings): a scratch Node driver stages a copy of the CORE workspace shape (T13.5-1's `SPECS_ONLY_CONFIG` and `A_MDX` in `test/suite/registry/section-13.5.ts`; a fixture needs no `node_modules`), spawns the bin with `--test-hold <path>` and the workspace as `cwd`, polls for the hold file on a `setTimeout` loop (50 ms steps for 2 s — foreground `sleep` is blocked in this sandbox's shell, so poll in-process), runs any excluded command while it is held, deletes the file, and awaits the exit — one invocation's seam ordering (hold created or not, exit while held or after, exit code) in about a second, no runner involved; a session's item ids come off `.xspec/reviews/<name>.json` (`items[].scopeRoot` → `id`). The CORE family alone (`… test/self/certification.test.ts -t CORE`, ~35 s under the unprivileged namespace, the run logged and grepped afterwards) then confirms the pairs: since Task 48 VIOL-CORE-LATELOCK fails exactly T13.5-8 at the `rename specs/A.mdx nope x --test-hold <path>` seam-ordering arm (the wait for a hold file that is never created), its failing-workspace arms passing, and the CONF-CORE conformer's `build --json` success document is `{"findings":[]}` (SPEC 12.7). +- The permission-staging platform arm (FIX_PLAN Task 49; `test/self/permission-staging.test.ts`): the E-1 guard `assertLinux` in `test/helpers/permissions.ts` reads `process.platform` when called, and in Node 22 `process.platform` is an own property of `process` that is configurable though not writable, so the arm presents the guard a foreign platform (`Object.defineProperty(process, "platform", { ...descriptor, value: "win32" })`, the original descriptor reinstated after each value and by `onTestFinished`) over a target inside a fresh `TestWorkspace` and runs on every platform — the self project reports 0 skipped. Vitest's `list` omits a `test.runIf(false)`-marked test, so `npx vitest list --config test/vitest.config.ts --project self <file>` shows whether a skip marker remains; a `-t` filter reports the file's filtered-out tests as `skipped` in the summary, which is the filter's accounting, not a marker. Red checks on a scratch-restored copy (each ~10 s under the unprivileged namespace with `-t 'platform guard'`): flip `toContain("Linux leg")` to `"Windows leg"` (fails on the guard's message, which names the presented platform); replace `value: platform,` with `value: original.value,` (fails at the arm's `process.platform` pre-assertion, before any staging); neuter the guard in the helper (`if (false && process.platform !== "linux")`, restored by `cp` and `cmp`) — the first staging then stages the workspace root read-only and returns, `stagingError` fails on the missing throw, and the workspace's `dispose` removes the read-only root through its writable-retry. Never point such an arm's target at `os.tmpdir()`: a bypassed guard would stage that holding directory read-only. +- Task 51's exact check-side pins (FIX_PLAN Task 51; the former `nonStale` filters: `checkFamilyFindings` in `test/suite/registry/section-12.1-12.2.ts`, T11.2-6's garbage-journal fixture in `section-11.2.ts`, T14-1, T14-3, and T14-4's failing-workspace 14.21 row and sweep in `section-14.ts`, T4.4-1's `check` in `section-4.3-4.4.ts`): the six tests run together in ~40 s under the unprivileged namespace (`--project suite` over the four wrapper files with `-t 'T12\.2-2 |T14-1 |T14-3 |T14-4 |T11\.2-6 |T4\.4-1 '`). Against the built product T12.2-2, T14-1, and T14-3 pass; T11.2-6 keeps Task 27's fixture-2 diagnosis (its garbage-journal fixture passes exact); T4.4-1 keeps its `occurrences` diagnosis (its `check` pin passes); T14-4 now fails diagnosed at the sweep's 14.22 entry (`symbolic link in a write path`), earlier than its former first-T14-12-entry failure: on that never-built refused-write workspace the product reports six mismatch-form 14.10 findings beside the 14.22 where SPEC 14.10 leaves them unreported. Red-checked through the scratch-wrapper pattern (above) with a `phantom-stale` mode inserting one `stale-output` unit-form finding (`path` `.xspec`, `identities` and `locations` `[]`) before the first finding of a condition above 10 into every `check --json` report holding none: all six tests then fail at an exact pin (T14-4 at its 14.12 arm, the earliest), ~30 s in all. +- T14-11's fifth encoding form (fifth-determination FIX_PLAN Task 1; `test/suite/registry/section-14.ts`, the `prefix` entry of `T14_11_ENCODING_FORMS` — `Café` then `FF`, offset 5 — staged by the (v) family as `specs/prefix.mdx` and `src/prefix.ts`; the S-9 vector table in `test/self/s9-fixture-well-formedness.test.ts` gained the `43 61 66 C3 A9 FF` row at offset 5): T14-11 alone against the built product still fails diagnosed at arm (c), so a later arm is reached only through an arm filter on a scratch-backed copy — `sed` the body's loop line `for (const kase of T14_11_CASES) {` to iterate `T14_11_CASES.filter((k) => k.arm === "v")` (one site), run `-t 'T14-11 '` on the suite project (~5 s), restore and `cmp`. The arm's diagnosis prints its location lists on the `actual:` / `expected:` lines after the `HarnessAssertionError:` line (the summary line ends at "values differ", so grep the full log): the product answers every encoding form at the pinned offset as a one-byte range (`{5, 6}` for both `prefix` files). A scratch stand-in whose `build --json` mode rewrites each `unparseable-source` finding's `range.end` to its `start` (bound as `{ label, command: process.execPath, prefixArgs: [wrapper, mode, binJs] }` and driven by `runProductTests(binding, section14ValidationTests.filter((t) => t.id === "T14-11"))` from a temporary `test/self/zz-*.test.ts` under `--disable-console-intercept`) greens the filtered arm (v) — the body then fails diagnosed at arm (n), the repeated-`d` arm after the loop — and the `prefix` pin mutated to 4 turns it red. The self project is 20 files, 2127 passed, 0 skipped after this task. +- T11.2-4's enclosure arm (fifth-determination FIX_PLAN Task 3; `test/suite/registry/section-11.2.ts`, staging 5 — `ENCL_A`/`ENCL_B` composed by `stageEnclosure`, `specs/ENCL.mdx` beside `SPECS_ONLY_CONFIG`, one workspace per spelling): both spellings derive under `deriveMdx` (the entry's one-line spelling as a paragraph holding text-position tags, the flow-tag spelling as a flow element holding a paragraph), and the built product passes the arm — its `view --text` and `occurrences` answers on hand-staged copies (`node dist/cli/bin.js view --text` in a scratch workspace holding the config and the file) match the SPEC-derived pins byte-exact (the 14.16 finding `<div>` through `</div>`, `x` a root child, the occurrence's `source` the root with its whole-file range, every text value as pinned) — so T11.2-4 alone (`-t 'T11.2-4 '` on the suite project, ~8 s) is green. The CONF-AVAIL fixture needed no change: its `parseMdx` parents a section through element frames to the innermost section (the root) and owns an enclosed embedding by the same rule, and its atom-attributed compile yields the pinned texts; certification totals stay 144 PASS / 33 FAIL (T11.2-4: CONF-AVAIL and VIOL-AVAIL-NOFILE pass, NULLMARKER and OMIT fail at the resolution-matrix arm, as before). The arm's pins were red-checked by four expectation mutations on a scratch-backed copy (`sed` the (a) root own text, the occurrence `source` to `UNAVAILABLE`, the exact-location pin to `widened(staging.divRange)`, the (b) root subtree text; run `-t 'T11.2-4 '`, `cp` the backup back, `cmp`): each fails diagnosed at the arm's own assertion. The self project is 20 files, 2127 passed, 0 skipped after this task. +- The staged-source ledger (fifth-determination FIX_PLAN Task 5; TEST-SPEC S-9's before-any-product clause for the `.mdx` stagings a test body makes after invoking the product — S-7's sweep never reaches them): `test/helpers/staged-mdx.ts` — `stagedMdx(name, source, mdx = "well-formed")` registers a `StagedMdx` record (bytes plus S-9 declaration; the name `"<TEST-ID> <what it stages>"`, unique, its leading token — `/`-joined IDs for a record two tests share, e.g. `"T14-4/T14-6 …"` — must name registered tests; `unchecked` is refused) at module load; `test/suite/registry/index.ts` seals the ledger after the modules load, so a registration at run time (inside a test body or a helper it calls) throws — create records at module top level only. `TestWorkspace.file(rel, record)` stages the record's bytes under the record's declaration (an `mdx` option beside a record throws `HarnessStagingError` mode `mdx-derivability`; a record at a path not named `.mdx` declares that path an MDX source for its own write); a spec-group file not named `.mdx` (an invalid path, 14.19, whose content 14.20 still judges) is judged only when its staging declares it an MDX source — `mdx: { wellFormed: [path] }` (the MDX analogue of `ts.wellFormed`; an `.mdx` path listed there is refused as redundant), another `mdx` list naming it (`unparseable`, `unchecked`, `allowances`, `perDraw`), a `file()` `mdx` option, or an MDX record at the path (the last two for that write alone) — and is then judged and guarded exactly as an `.mdx` path (a TypeScript record there throws mode `ts-derivability`; vectors in `test/self/s9-fixture-well-formedness.test.ts`, `s9-undeclared-staging.test.ts`, `s9-staged-sources.test.ts`); a constant serving both an initial `files` entry and a later `file()` call becomes a record whose `.source` fills the `files` entry. `test/self/s9-staged-sources.test.ts` loads the whole registry and judges every record with `judgeMdxDeclaration` (exported from `test/helpers/workspace.ts` — the builder's own judge, one code path): run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts` (~5 s, no namespace needed); `npx vitest list --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts | grep '> T'` lists the records. Conversion rule (the plan's preamble carries the full text): every `file()` call staging an `.mdx` path — literal, constant, or byte path — made after a product invocation in that workspace passes a record created at module level from the SAME expression (moved, never re-spelled; staged bytes identical) carrying the declaration in effect for that write (the former `mdx` option, else the workspace declaration's entry for the path, else well-formed); arm/variant tables type their source field `StagedMdx` (one record per row, named with the row's key); a template function called with body-local state is enumerated, in order, into a module-level record table indexed from the body. An edit of bytes the PRODUCT wrote (a prior rename's or move's rewritten source, read back and substring-replaced — the former `editSource`/`stalenessEdit` helpers of `section-14-ii.ts`/`write-refusal-staging.ts`) goes through `workspace.edit(rel, from, to)` (first occurrence replaced, judged under the path's declaration at staging time; a missing `from` throws a plain `Error`): never a record (no harness constant equals those bytes), and never `edit()` on a file whose current bytes are the harness's own constant (that edit is a deterministic fixture: hoist `CONSTANT.replace(from, to)` into a record). Red check of the judge: on a scratch-backed copy of a module, break one record's bytes (drop a closing tag), run the self-test alone, read the `HarnessStagingError` naming the record, restore with `cp` and verify with `cmp`. Verdict check after a conversion: each converted test alone against the built product (`unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite <file-substring> -t '<ID> '`, e.g. `section-14 -t 'T14-(2|7) '`, ~6 s for those two) must fail or pass exactly as before; a record site past a diagnosed product failure is not reached (T14-7's `T14_7_BAD_INVALID` lies past the refused-invalid-id deviation) — accepted bounded behavior. The self project reports 21 files, 2151 passed after Task 5 (certification 144 PASS / 33 FAIL unchanged). +- Fifth-determination FIX_PLAN Task 6 (the ledger conversion of `section-1.5.ts`, `section-1.6-1.7.ts`, `section-2.2-2.3.ts`, `section-2.4.ts`, `section-2.5-2.6.ts`, `section-2.7.ts`, `section-4.5.ts`, `section-5.4.ts`): sixteen records (T1.5-2, T1.6-4, T2.2-4, T2.4-5, T2.5-2, T2.5-3, two for T2.6-2, five for T2.7-2, T2.7-3, two for T4.5-2), the S-9 self-test at 40 tests and the self project at 21 files, 2167 passed (~2 min in the namespace). Reach is judged per body: a `file()` staging after the body's FIRST product invocation, in any workspace, converts (T1.5-2's byte-path staging into a fresh workspace), one after `gitInit`/`gitCommitAll` alone stays (T1.5-1). `section-5.4.ts`'s manual re-spellings of product-rewritten source are `anchorOnce` (the former `replaceOnce`'s two diagnoses, no rewrite) then `workspace.edit()`; T5.4-1's appended authorship is an `edit()` extending the file's unique tail `REINTRO_TAIL` after an `endsWith` diagnosis — the idiom for appending to product-written bytes. Verdict check of the twelve touched tests: `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite --reporter=verbose section-1.5.test section-1.6-1.7.test section-2.2-2.3 section-2.4.test section-2.5-2.6 section-2.7.test section-4.5.test section-5.4.test -t 'T(1\.5-2|1\.6-4|2\.2-4|2\.4-5|2\.5-2|2\.5-3|2\.6-2|2\.7-2|2\.7-3|4\.5-2|5\.4-1|5\.4-2) '` (about a minute; the verbose reporter's per-test lines carry the IDs, and `grep -E 'Object\.run|section-[0-9.-]+\.ts:[0-9]+'` shows each failure's frame) — unchanged: T1.5-2, T2.4-5, T2.5-3, T2.7-3 fail diagnosed (T2.7-3 at its spread-unparseable arm, before the quoted arm's record site; the other three past their record sites), the other eight pass. A full self run's `FAIL` lines are the certification runner's documented violator verdicts (33), not vitest failures: judge a run by its `Test Files`/`Tests` summary lines. +- Fifth-determination FIX_PLAN Task 7 (the ledger conversion of `section-5.5.ts`): thirty-four records. The module's four template functions (`ownHashSource`, `subtreeSource`, `effectiveSource`, `metadataSource`) feed module-level record tables through a per-template `<name>Arm(label, shape): StagedArm` helper (`StagedArm` = `{ label; source: StagedMdx }`, the module's shared row type) naming each row `"<ID> <label>"`, plus fourteen named singles (`T5_5_2_*`, `KIND_MANUAL`, `T5_5_3_*`, `T5_5_4_*`); a body-local arm table holding template SHAPES (T5.5-3/4/5's former `changedArms`/`edgeArms`/`unchangedArms`/`reorderArms`, whose loop called the template) moves to module level as a whole, because a record's template call must run at load, and the loop then stages `arm.source`. The S-9 self-test is at 74 tests and the self project at 21 files, 2201 passed (~2 min in the namespace); certification 144/33 unchanged. Verdict check: `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-5.5.test --reporter=verbose` (~26 s for all six): T5.5-1/2/3/4/6 pass, T5.5-5 fails diagnosed at its base-state `query node specs/A.mdx#m` (the adapter's `$.tags` byte-order pin against the product's spelled-order tag echo), before every one of its seven record sites. Red check for a record built by a template call (no literal bytes to break): `sed` an unclosed tag into that one call's shape argument (`c1Text: "Child one, edited. <S id=\"x\">"`), run the S-9 self-test alone (~5 s), read the `HarnessStagingError` naming the record, restore from the scratch copy and `cmp`. +- T5.5-2's kind arm (`test/suite/registry/section-5.5.ts`: `kindParent`, `KIND_BASELINE`, `KIND_MANUAL`, `KIND_P_SUBTREE`; the re-descent's second plan's Task 8) stages TEST-SPEC's in-line geometry: `p`'s one line is `foo <S id="p.k">Kid text.</S> baz` at the baseline and `foo {text(B.k)} baz` after the journaled `move specs/A.mdx#p.k specs/B.mdx#k`, which leaves `foo baz` there and creates `specs/B.mdx` as `<S id="k">Kid text.</S>` plus U+000A; against the built product `p`'s ownHash differs across the replacement while its subtree text is `foo Kid text. baz` plus U+000A in both states, and the whole §5.5 file passes (~25 s under the namespace). It red-checks through the stand-in pattern above (a temporary `test/self/zz-*.test.ts` calling `runProductTests(binding, productTestSuite.select(["T5.5-2"]))`, ~10 s per mode as root, no permission staging): a `blind` wrapper that stores the kind workspace's baseline `query node specs/A.mdx#p` ownHash under a key derived from the cwd (that state recognized by `<S id="p.k">Kid text.</S>` in `specs/A.mdx`) and answers the after state's query (recognized by `{text(B.k)}`) with it fails the test at the kind arm's ownHash assertion, and an `impact` wrapper dropping `p`'s `changed` category, and the entry it empties, from the after state's `impact` answer fails it at the impact assertion; the pass-through passes. +- Fifth-determination FIX_PLAN Task 8 (the ledger conversion of `section-5.6.ts` and `section-6.7.ts`): five records — `T5_6_4_TAGS_EDITED` (T5.6-4's arm-2 `metaSource` call) and `T6_7_1_RENAMED`, `T6_7_1_STALE_ORIGIN`, `T6_7_1_REWRITTEN_ORIGIN`, `T6_7_1_REWRITTEN_WATCH` (T6.7-1's `impactArmSource`/`originSource`/`watchSource` builders, the records wrapping the builders' `.text`; `staleOrigin`/`staleWatch` moved to module level unchanged so their `prefix`/`construct` still pin the 14.5 byte windows). The plan's site lists are mechanical (every `.mdx` `file()` call in the module): a site that precedes the body's first product invocation — every T5.6-n body's edits between `gitCommitAll("baseline")` and its first `buildOk`, ten of the eleven listed `section-5.6.ts` sites — stays a plain `file()` (S-7's sweep reaches it against the stub); read the body before converting a listed site. The S-9 self-test is at 79 tests and the self project at 21 files, 2206 passed (~2 min in the namespace); certification 144/33 unchanged. Verdict check: `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-5.6.test section-6.7.test --reporter=verbose` (~15 s): all seven (T5.6-1 through T5.6-6, T6.7-1) pass, before and after. Red-check variants for a record with no literal bytes to break: an unclosed tag spliced into a template's attribute argument (`metaSource("none", 'alpha gamma"><S id="x')`) or appended to a builder's `.text` (`watchSource("b.neo").text + '<S id="x">'`), applied by an exact-match Python replacement on the working file after a `cp` to the scratchpad, the self-test alone run (~5 s, the `HarnessStagingError` names the record), then `cp` back and `cmp`. +- Fifth-determination FIX_PLAN Task 9 (the ledger conversion of `section-6.3.ts`, `section-6.4.ts`, `section-6.5.ts`/`section-6.6.ts`, `section-8.ts`, `section-13.3.ts`, `section-13.5.ts`, `section-15.ts`): fourteen records — `T6_3_4_FIXED` (the former `{ mdx: "well-formed" }` option carried as the record's declaration, which overrides the workspace's `unparseable` entry for `specs/Broken.mdx`), `T6_3_5_INNER_V1` and `T6_3_5_INNER_EDITED` (the nested-repository and submodule arms' `r5Source` calls; arm (a)'s edit precedes the body's first invocation and stays plain), `T6_4_6_OTHER_INVALID`, `MOVE_PRECONDITION_BREAK` (exported from `section-6.5.ts` and shared by T6.5-4 and T6.6-3 — a constant one module exports for another's identically staged arm becomes ONE record, created and exported by the defining module and named `"T6.5-4/T6.6-3 …"`; the source alias is module-private now), `T8_5_B_EDITED`, `T13_3_2_A_EDITED`, `T13_3_3_B_ID_LESS`, `T13_5_1_A_EDITED` (staged by the `staleWorkspaceArm` helper on three workspaces — a helper's staging counts under the body that calls it), `T13_5_5_POLL_STATE_TWO`/`T13_5_5_POLL_STATE_ONE` (the poll loop's alternation enumerated to one record per state, the body picking by the same `stateTwo` condition), `T13_5_8_BOM` (declared `"unparseable"`, the former call option), `T15_1_HELLO_EDITED` and `T15_1_HELLO_RESTORED`. Sites that stay: `section-13.3.ts`'s `restoreGraphData` (product-written graph data under `.xspec/`), `section-13.4.ts`'s mutation rounds (generated files and graph data — no `.mdx` path), the journal appends, and the `.mdx` copies into fresh workspaces (`section-6.4.ts` ~2407, `section-6.5.ts` ~1654/2613). The S-9 self-test is at 93 tests (73 records) and the self project at 21 files, 2220 passed (~2 min in the namespace); certification 144/33 unchanged. Verdict check (~42 s): `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-6.3.test section-6.4.test section-6.5.test section-6.6.test section-8.test section-13.3.test section-13.5.test section-15.test --reporter=verbose -t 'T(6\.3-4|6\.3-5|6\.4-6|6\.5-4|6\.6-3|8-5|13\.3-2|13\.3-3|13\.5-1|13\.5-5|13\.5-8|15-1) '` — unchanged: T13.3-2 (arm A's empty-record deviation, before its arm-B record site), T13.5-1 (its `build --test-hold --json` arm, before the stale arm's record site), and T6.6-3 (its identity-terms arm, after the precondition arm's record site, which is reached and passes) fail diagnosed; the other nine pass; each failure frame moves by exactly the inserted line count. Red check for an `"unparseable"`-declared record: drop what makes it unparseable (the `String.fromCodePoint(0xfeff) +` prefix of `BOM_MDX`) — the self-test fails naming the record as declared unparseable but deriving; for a well-formed one wrapping a constant, drop a closing-tag row (`F4_FIXED_SOURCE`'s `"</S>",`). +- Fifth-determination FIX_PLAN Task 10 (the ledger conversion of `section-9.ts` and `section-9.3.ts`): three records — `T9_1_ALPHA_V2` (T9-1's post-build `p1Source("Alpha text v2.")`), `T9_1_1_DEPS_EDITED` (T9.1-1's arm-B `workedExample.depsSource("[Tree.top.mid, Tree.top.other]")` — the body's `wx` alias resolved to the imported object, the same function and identical bytes), `T9_3_2_MDEP_EDITED` (T9.3-2's metadata-terminus `mdepSource("[MTgts.t1, MTgts.t2]")`). Edits of product-rewritten bytes go through `workspace.edit()` after their diagnosed premise: T9.2-5's post-rename text edit (its `includes` premise kept; `edit(C5_SPEC, "Renamed node text v1.", "Renamed node text v2.")` replaces the `specText.replace` + `file()`) and `section-9.3.ts`'s `editSourceExpecting` (its exactly-once `fail()` diagnosis kept, its `file()` now `edit(rel, expected, replacement)`; T9.3-3's two arms). The plan's other listed sites precede their bodies' first product invocation and stay plain `file()` calls: T9.1-1's arm-A leaf edit, T9.2-1's three, T9.2-2's, T9.2-3's, T9.2-4's two, T9.3-1's two, and T9.3-2's run-1 edits (each body stages them between `gitCommitAll` and its first `buildOk`). The S-9 self-test is at 96 tests (76 records) and the self project at 21 files, 2223 passed (~2 min in the namespace); certification 144/33 unchanged. Verdict check (~10 s): `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-9.test section-9.3.test --reporter=verbose -t 'T(9-1|9\.1-1|9\.2-5|9\.3-2|9\.3-3) '` — all five pass, before and after. Red check for the template-built record: `mdepSource('[MTgts.t1, MTgts.t2]}><S id="x"')` spliced into the record's argument on the working file after a `cp` to the scratchpad (an exact-match Python replacement); the self-test alone fails naming the record (the parser rejects the `}` before an attribute name); restore with `cp` and verify with `cmp`. +- Fifth-determination FIX_PLAN Task 11 (the ledger conversion of `section-10.1.ts` and `section-10.2-10.3.ts`): eleven records — `A_MDX_EDITED` (ONE record shared by T10.1-1's stale arm and T10.1-6's three stale twins, the latter staged by the `assertCreateFollowsRefresh` helper — a constant staged both by a body and by a shared helper other tests call is one record named with every calling test's ID, `"T10.1-1/T10.1-6 …"`), `T10_1_5_B_INVALID` (declared well-formed: the id-less section derives, only 14.1 fails it), `T10_2_2_KID_V1`/`_V2`/`_E1` and `T10_2_2_UNCOVERED_E1`, `T10_2_4_V2`/`_V3` and `T10_2_4_WITHOUT_K_V3`, `T10_3_1_X_V2`, `T10_3_2_KID_V2` (the template calls moved to module level). Sites that stay: T10.2-1's edit (between `gitCommitAll` and its first `build`), every arm's initial `files` entry, and the `.json` session-file stagings. No §10.1–10.3 test is certification-scoped (`test/self/certification-fixtures.ts`'s `inScope` lists name, among §10–§12, only T10.4-5 and T11.2-2, T11.2-4, T11.3-4, T11.4-1, T11.4-3, T11.4-4), so the full self run alone confirms the certification totals. The S-9 self-test is at 107 tests (87 records) and the self project at 21 files, 2234 passed (~2 min in the namespace); certification 144/33 unchanged. Verdict check (~25 s): `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-10.1.test section-10.2-10.3 --reporter=verbose -t 'T(10\.1-1|10\.1-5|10\.1-6|10\.2-2|10\.2-4|10\.3-1|10\.3-2) '` — unchanged: T10.1-1, T10.1-5, T10.2-2, T10.2-4, T10.3-1, T10.3-2 pass; T10.1-6 fails diagnosed at its `.xspec/reviews`-symlink arm's `review status s --json` (exit 0 where SPEC 10.1/12.0 pins exit 2), before its three record sites, its frames moved by exactly the inserted line count. Red check for a `.replace`-built record: splice an unclosed tag into the replacement (`A_MDX.replace("Kid text.", 'Kid text, edited. <S id="x">')`) on the working file after a `cp` to the scratchpad (an exact-once Python replacement), run the S-9 self-test alone (~5 s; the `HarnessStagingError` names the record), `cp` back and `cmp`; a template-built record the same way (`t5Spec('Ex text v2. <S id="x">')`). +- Fifth-determination FIX_PLAN Task 12 (the ledger conversion of `section-10.4.ts`): thirty-five records — T10.4-1's six scenarios' `write()` closures over mutable version variables enumerated into module-level `as const` tuples in staging order (`T10_4_1_SC_STATES` (4), `_PC_` (5), `_DC_` (5), `_MC_` (3), `_CI_` (4), `_UR_` (3): the identical template calls with the closure's cumulative arguments spelled out, every state carrying the earlier edits forward, the arms indexing through them; the subtree-coherence pre-`create` edit precedes the body's first `build` and stays plain as `T10_4_1_SC_PRE_CREATE`), T10.4-2's eight presence flips, T10.4-3's a.s edit (its a.k edit precedes the first `build` and stays), `T4R_WITHOUT_CHILD` (the constant became the record), and T10.4-5's staleness edit (the module's one CONF-CORE-scoped test; certification 144 PASS / 33 FAIL unchanged, summed over the 23 `certification run against` lines of the self run's output). T10.4-4's reintroduction append over the rename-rewritten `specs/E.mdx` (the former `Buffer.concat`) is an anchored `edit()`: a module-local `anchorOnce` (section-5.4.ts's, diagnosed) plus an `endsWith` check over `T4I_TAIL` (s's closing run, which SPEC 6.4's minimal in-place edits leave as the file's end), then `edit(T4I_FILE, T4I_TAIL, T4I_TAIL + T4I_NEW_SECTION)`. Byte-identity check for a conversion, end to end: a temporary, uncommitted hook at the end of `TestWorkspace.write()` (`test/helpers/workspace.ts`, after `fsp.writeFile`) that, when an environment variable names a log file, appends one tab-separated line per write — the relative path, the byte length, and the sha256 hex digest of the bytes (`createHash` from `node:crypto`); run the module's suite file with the variable set before and after the conversion (`P3_TASK12_CAPTURE=<scratch log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-10.4.test --reporter=verbose`, ~76 s; `process.env` reaches the workers through `unshare`; a `-t 'T10\.4-[2345] '` filter runs a subset, the log then compared with the baseline's matching tail) and `diff` the two logs (the §10.4 file's 70 writes, initial `files` entries included, identical); restore the helper from a scratch copy afterward and confirm with `git diff --stat`. Verdicts unchanged: T10.4-1 through T10.4-5 all pass against the built product (the whole file ~76 s). Red check of a table record: splice an unclosed tag into one tuple element's template call (`scSpec("Parent own v1.", ' tags="pt"', 'Child text v1. <S id="x">', "", "Outside v0.")`) on a scratch-copied working file, run the S-9 self-test alone (the `HarnessStagingError` names the record), restore and `cmp`. The S-9 self-test is at 142 tests (122 records); the self project at 21 files, 2269 passed (~2 min in the namespace). +- Fifth-determination FIX_PLAN Task 13 (the ledger conversion of `section-10.5.ts` and `section-10.6.ts`): eleven records — `T10_5_1_X1_EDITED` and `T10_5_1_Y_EDITED` (T10.5-1's extended- and chain-fixture edits, staged into later-arm workspaces after the worked change's `build`, the body's first invocation), `T10_5_4_A_WITHOUT_VEF` (the deletion of v.e and v.f), `T10_5_5_W_PB_EDITED`, `T10_5_5_W_RC_REVERTED`, `T10_5_5_V_GA_EDITED`, `T10_5_5_V_WITH_Z` (sub-fixture A's p.b edit and r.c revert, both of sub-fixture B's edits), `T10_5_6_C_PAR_S_EDITED` (the post-decoy par.s edit), `T10_6_2_B_WITHOUT_FE` (the deletion of f and e), `T10_6_3_R_PB_V0` and `_V1` (the authoring and edit of p.b) — each the same template call moved to module level. The plan's other listed sites precede their bodies' first `build` and stay plain `file()` calls: T10.5-1's worked-change edit, T10.5-2's, T10.5-3's `N_CURRENT`, T10.5-4's two, T10.5-5's first, T10.5-6's par.k edit (each between `gitCommitAll` — or `git branch` — and the first `build`); the plan's `— read` sites were template calls too (no §10.5/§10.6 body reads product-written bytes back, so no `edit()`); every initial `files` entry stays plain (the later-arm ones — T10.5-1's extended and chain fixtures, T10.5-5's sub-fixture B, T10.6-2's sub-fixture 2 — are the reach observation's). Neither module declares `mdx`, so every record is well-formed by default; no §10.5/§10.6 test is certification-scoped. Byte identity checked end to end with Task 12's capture hook, one log per suite file since the suite project runs files in parallel (`P3_TASK13_CAPTURE=<scratch log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-10.6.test --reporter=verbose`, then the same for `section-10.5.test`; 13 and 41 writes, identical before and after; ~20 s and ~40 s). Verdicts unchanged: T10.5-1 through T10.5-6 and T10.6-1 through T10.6-3 all pass against the built product. Red check of a template-built record: `ySpec('Cee text v1. <S id="x">')` / `rSpec('Pab text v1. <S id="x">')` spliced into the record's argument (an exact-once Python replacement on the working file after a `cp` to the scratchpad), the S-9 self-test alone failing by record name, `cp` back and `cmp`. Editing a registry module's header comment: it ends with a blank line before the first `import type {` line, which a `sed -n '1,N p'` display hides — anchor a header insertion on that line structure (the last header line, the blank, the import), never on a display-derived line number. The S-9 self-test is at 153 tests (133 records); the self project at 21 files, 2280 passed (~2 min in the namespace); certification 144 PASS / 33 FAIL unchanged. +- Fifth-determination FIX_PLAN Task 14 (the ledger conversion of `section-10.7-i.ts`): seven records — `T10_7_2_B_LEAF` and `T10_7_2_N_LEAF` (the coverage arm's post-create B.mdx and extra/N.mdx additions; N.mdx is the same `leafSpec` call in the audit arm, so one record is staged at both sites — a test staging identical bytes twice needs one record, a second registration of the name throwing at load), `T10_7_3_C_PAR_EDITED` (the healthy session's par edit against the real baseline), `T10_7_4_K_WITHOUT_U2` and `T10_7_4_K_WITHOUT_U2_U1` (the two section deletions), `T10_7_5_W_EDITED` (the edit after a2's resolution), `T10_7_6_T_PA_EDITED` (stage D's edit) — each the same template call moved to module level; every listed `.mdx` site follows its body's first `build`. The plan's `— read` site (638) is `CORRUPT_CREATE_ARMS`'s garbage-bytes write over the session path — a `— read` annotation can mark a non-`.mdx` helper site, judged and left — as is T10.7-5's corrupt-session staging; the config sites are `.ts`; every initial `files` entry stays plain (T10.7-1's per-state corrupt-session workspaces and T10.7-2's audit arm are the reach observation's later-arm workspaces). The module declares no `mdx`, so every record is well-formed by default; no §10.7 test is certification-scoped. Byte identity checked with Task 12's capture hook (`P3_TASK14_CAPTURE=<scratch log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-10.7-i.test --reporter=verbose`; 37 writes, identical before and after, ~30 s each). Verdicts unchanged: T10.7-1 through T10.7-6 all pass against the built product. Red check of a template-built record: `c5Spec('Dub text v1. <S id="x">')` spliced into `T10_7_5_W_EDITED`'s argument (an exact-once Python replacement on the working file after a `cp` to the scratchpad — the call no longer appears in the body, so it occurs once), the S-9 self-test alone failing by record name, `cp` back and `cmp`. The S-9 self-test is at 160 tests (140 records); the self project at 21 files, 2287 passed (~2 min in the namespace); certification 144 PASS / 33 FAIL unchanged. +- Fifth-determination FIX_PLAN Task 15 (the ledger conversion of `section-10.7-ii.ts`): eleven records — `T10_7_7_A2_KID_V1` (the payload arm's reviewed a.k edit), `T10_7_8_X_KAY_V1` (the x.k edit invalidating its stored no-change), `T10_7_9_G_WITH_Z` (g.a.z authored in the path-blocks arm), `T10_7_9_H_WITH_B` and `T10_7_9_H_WITH_B_C` (the audit arm's authorings), `T10_7_10_R_PA_V1` (the p.a edit invalidating its resolution), `T10_7_11_D_TO_K2` (src's covering d edge moved to k2), `T10_7_12_A_V1` and `T10_7_12_A_V2` (the matrix arm's states), `T10_7_12_B_X_T1` and `T10_7_12_B_WITHOUT_X` (the provenance arm's states) — each the same template call moved to module level next to its template. The plan's site 1807 (T10.7-9's path-blocks v1 edit) sits between `gitCommitAll("baseline")` and the body's first `build`, so it precedes the body's first product invocation and stays plain (a note at the site says why); 1169/3037/3038 are `.ts`; every initial `files` entry stays plain (T10.7-7's fully-resolved and payload arms, T10.7-9's audit arm, and T10.7-12's provenance and coverage arms are the reach observation's later-arm workspaces). The module declares no `mdx`, so every record is well-formed by default; no §10.7 test is certification-scoped. Byte identity checked with Task 12's capture hook (`P3_TASK15_CAPTURE=<scratch log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-10.7-ii.test --reporter=verbose`; 37 writes, identical before and after, ~58 s each; the hook's `createHash` can be a dynamic `await import("node:crypto")` inside `write()`, leaving the helper's import block untouched). Verdicts unchanged: T10.7-7 through T10.7-12 all pass against the built product. Red check of a template-built record: `r10Spec('Paa line v1. <S id="x">')` spliced into `T10_7_10_R_PA_V1`'s argument (an exact-once Python replacement on the working file after a `cp` to the scratchpad), the S-9 self-test alone failing by record name (1 failed, 170 passed), `cp` back and `cmp`. Certification totals are summed from the self run's 23 `certification run against <fixture>: N test(s) — p pass, f fail, e error, h hang` lines (`grep 'certification run against' <log> | grep -oE '[0-9]+ pass, [0-9]+ fail, [0-9]+ error, [0-9]+ hang' | awk '{p+=$1; f+=$3} END {print p, f}'`). The S-9 self-test is at 171 tests (151 records); the self project at 21 files, 2298 passed (~2 min in the namespace); certification 144 PASS / 33 FAIL unchanged. +- Fifth-determination FIX_PLAN Task 16 (the ledger conversion of the §11, §12.0, and §12.7 modules — `section-11.3.ts`, `section-12.0-ii.ts`, `section-12.7.ts`): six records — `EMPTY_HOLDER_SOURCE` (the constant became the record: T11.3-4's specs/holder.mdx, staged between the arms after arm 1's `occurrences`; the one CONF-AVAIL-scoped test with a touched site, certification 144 PASS / 33 FAIL unchanged), `T12_0_8_M_V2` (the impact arm's doubly-edited specs/M.mdx, the same `tieImpactSpecSource` call moved next to its template, staged into a later-arm workspace after the reachable arm's `build`), `T12_7_1_IN` and `T12_7_1_TGT` (the byte-form paths arm's two byte-path stagings, records over the `IN_SOURCE`/`TGT_SOURCE` constants the arm's slice checks keep using — a byte path takes a record through the same `file()` overload, `isMdxPath` judging the path's trailing bytes), `T12_7_1_UR_EDITED` (the unpinned-surface ranges arm's current source over the committed baseline, a record over `UR_SOURCE`), and `T12_7_3_A_EDITED` (the 14.24 arm's inline literal moved into the record; T12.7-3 fails as diagnosed in its first arm — the config-paths arm's `build --json --config ./../cfg//xspec.config.ts` finding — so the site is unreached against the built product, accepted per the plan's checks). The plan's other listed sites precede their bodies' first invocation and stay plain, a note at each: `section-11.2.ts` 1569 (T11.2-3's non-UTF-8 byte path, before the gate `build`), `section-11.5.ts` 1464 (T11.5-3's, likewise), `section-12.0-ii.ts` 296 (`makeStoryWorkspace`'s omega edit — a helper's staging is judged under every calling body, and T12.0-7 (twice) and T12.0-9 call it before their first invocation) and 2313 (T12.0-11's omega edit between `gitCommitAll` and `build`), `section-12.7.ts` 1654 (T12.7-2's condition-ordering byte path, its first arm's staging before that arm's `build`); `section-11.6.ts` has no `.mdx` site (1651 is `.bin`, 1719 `.ts`, 1768 the journal, 1856–1865 session files), `section-12.0-ii.ts` 1339/2068 are session files and `section-12.7.ts` 2698 a config; `section-11.ts`, `section-11.1.ts`, `section-11.4.ts`, and `section-12.0-i.ts` hold no `file()` call at all. None of the three modules declares `mdx` for a converted path, so every record is well-formed by default. Later-arm initial `files` joining the reach observation: T12.0-8's coverage and impact arms (the impact arm's `tieImpactSpecSource("Changed a v1.", "Changed b v1.")`), T12.0-9's corrupt, invalid, wrong-kind, and exclusion arms, T12.0-10's workspaces after its twin pair, and every §12.7 arm after each body's first. Byte identity checked with Task 12's capture hook, a byte-path key hex-encoded in the log (`P3_TASK16_CAPTURE=<scratch log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-11.3.test -t 'T11.3-4 ' --reporter=verbose`, ~5 s, 4 writes; `section-12.0-ii.test -t 'T12.0-8 '`, ~6 s, 9 writes; the whole `section-12.7.test`, ~15 s, 42 writes — identical before and after). Verdicts unchanged: T11.3-4, T12.0-8, T12.7-1, and T12.7-2 pass against the built product; T12.7-3 fails as diagnosed. Red check of a literal-built record: `'<S id="a">\nAlpha, edited. <S id="x">\n</S>\n'` spliced into `T12_7_3_A_EDITED`'s literal (an exact-once Python replacement on the working file after a `cp` to the scratchpad), the S-9 self-test alone failing by record name (1 failed, 176 passed), `cp` back and `cmp`. The S-9 self-test is at 177 tests (157 records); the self project at 21 files, 2304 passed (~2 min in the namespace); certification 144 PASS / 33 FAIL unchanged. +- Fifth-determination FIX_PLAN Task 17 (the ledger conversion of `section-12.1-12.2.ts`): nine records — `T12_1_3_ALPHA_SOURCE` (T12.1-3's arm-2 manual rename: the body read specs/A.mdx's bytes back after the arm-1 `build` and staged them at specs/C.mdx; the record is the fixture's own bytes, also `REGEN_FILES`'s initial specs/A.mdx through `.source`, and the arm first pins as its staging premise that `build` left the source untouched — `assertBytesEqual` of the current bytes against `.source`, SPEC 12.1 — so a product rewriting a source on `build` is a diagnosed failure, never a silently different copy), `FAILED_BUILD_VALID_SOURCE` (`"T12.1-4/T12.2-2/T12.2-3 …"`, the constant became the record: staged back after an edit under all three tests, its `.source` filling the eight initial `files` entries — `withWorkspace`, `REGEN_FILES`, and `T12_2_4_FILES` widened to `Readonly<Record<string, FileContents>>` for it), `FAILED_BUILD_INVALID_SOURCE` (`"T12.1-4/T12.2-2 …"`), `T12_2_2_B_INVALID_SEGMENT`, `T12_2_2_A_EDITED`, and `T12_2_2_A_MISMATCH_EDIT` (T12.2-2's inline literals at the sites the plan annotated `— read` — 879, 1024, 1124 in the plan's numbering — each after the family workspace's `build`; the fourth `— read` site, 1345, is the session file), `T12_2_3_A_EDITED` (the body-local `editedSource`, staged at two sites), `T12_2_4_L_VALID` and `T12_2_4_L_INVALID` (the lo/L.mdx states after each arm's `t1224Prepare` build — the `t1224FailingStaging` helper's site, called under T12.2-4 alone, and arm (d)'s repair; the valid one's `.source` fills `T12_2_4_FILES`). Non-`.mdx` sites stay plain (the generated module, the link target, the configuration, the journal line, the session file). No §12.1/§12.2 test is in certification scope: 144 PASS / 33 FAIL unchanged. Later-arm initial `files` joining the reach observation: T12.2-2's family workspaces after the first (families 2–9) and T12.2-4's arms (b)–(d). Byte identity checked with Task 12's capture hook (`P3_TASK17_CAPTURE=<scratch log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-12.1-12.2.test --reporter=verbose`, ~22 s, 58 writes — identical before and after). Verdicts unchanged: T12.1-1, T12.1-3, T12.1-4, T12.2-1, T12.2-2, T12.2-3 pass against the built product; T12.2-4 fails as diagnosed at arm (a)'s failing-staging `check` — after the helper's record site (reached), before arm (d)'s (unreached). Red check: `<S id="x">` spliced into `T12_2_3_A_EDITED`'s text line (an exact-once Python replacement on the working file after a `cp` to the scratchpad), the S-9 self-test alone failing by record name (1 failed, 185 passed), `cp` back and `cmp`. The S-9 self-test is at 186 tests (166 records); the self project at 21 files, 2313 passed (~2 min in the namespace); certification 144 PASS / 33 FAIL unchanged. +- The undeclared-staging guard (fifth-determination FIX_PLAN Task 18; TEST-SPEC S-9's timing clause, S-7, H-8): `test/helpers/product-invocations.ts` tracks product invocations two ways, both marked by `startProduct` (`test/helpers/subprocess.ts`) right before spawning, whatever the binding (a certification fixture, the empty stub, a compiled consumer program alike): per workspace (`TestWorkspace.create` registers the root and its realpath, `dispose` unregisters; an invocation whose cwd is a root or lies under one — realpath matched too, so T13.4-6's link cwd counts — marks it; read it as `workspace.productInvoked`) and per body (`runProductTestBody(id, body)`, an `AsyncLocalStorage` context that the suite wrapper `test/suite/declare.ts` and the certification runner's `runOne` establish around every registered body run; `productInvokedInBody()` answers the body's ID once it has invoked anything, in whatever workspace — S-7's actual reach, since the sweep stops at the body's first invocation wherever it happens). `TestWorkspace.file()` on an `.mdx` path with plain contents (not a `StagedMdx` record) after either mark throws `HarnessStagingError` mode `undeclared-staging` (`undeclared-staging staging of <path>: an MDX source staged with plain contents (declared …) after a product invocation in this workspace` or `… in the running body of <ID> (in another workspace …)`, the message naming every remedy) unless the effective declaration is `unchecked` (P-8's mutations) or the new `per-draw` member of `MdxFileDeclaration` (the unparseable twin fourth-plan Task 23b gave it went in the fifth-plan Task 3 — no draw is declared unparseable) — a property draw the runner's `mdxSources` (`drawSources` since re-descent FIX_PLAN Task 64) judged before the body saw it, judged as well-formed at staging, refused on a ledger record, passed at exactly four sites (`section-16-p4.ts` ×2, `section-16-p5-p6.ts`, `section-16-p9.ts`) and never outside `section-16-*.ts`. Declared stagings by construction: `edit()` (product-written bytes, unchanged) and the new `copyFrom(source, rel, destRel = rel)` — another live workspace's current bytes staged under the destination's declaration, the H-6 two-directory seeding of T6.4-7 (`section-6.4.ts`), T6.5-1 and T6.5-3 (`section-6.5.ts`), formerly `fresh.file(rel, await other.readBytes(rel))` — which the guard treats as plain contents only when no product has been invoked in `source`. A workspace declaration's initial `files` were outside the guard until fourth-plan Task 24 extended it to `create()` (the last bullet). The E-6 exchange fixture (`test/helpers/e6.ts`, no registry entry, never swept by S-7) stages its leaf edit as the record `"E-6 specs/Other.mdx version two — …"`; `test/self/s9-staged-sources.test.ts` imports `../helpers/e6.js` BEFORE the registry manifest (which seals the ledger) and accepts `E-6` as a record's lead ID (exactly one such record) — keep that import order, or the fixture's load throws `sealed`. Self-test: `test/self/s9-undeclared-staging.test.ts` (12 tests, no namespace needed; a stand-in binding of `process.execPath` with `-e process.exit(0)` marks like any product, since the driver's path is what marks). Red check of the guard: on a scratch-backed copy of a converted module, stage one record's `.source` instead of the record (`sed -i 's/file("specs\/A.mdx", A_MDX_EDITED)/file("specs\/A.mdx", A_MDX_EDITED.source)/' test/suite/registry/section-10.1.ts`), run `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite test/suite/section-10.1.test.ts -t 'T10.1-1 '`, read the `undeclared-staging` diagnosis, restore with `cp` and verify with `cmp`. The decisive check is the full suite against the built product (`unshare … -- npx vitest run --config test/vitest.config.ts --project suite --reporter=verbose > <log> 2>&1`, ~15 min, never beside the self project): `grep -c 'undeclared-staging staging of' <log>` must be 0, and `grep -E '^\s+×' <log> | grep -oE '> (T[0-9.]+-[0-9]+|P-[0-9]+) ' | sort -u` must equal the known diagnosed-failure set (82 IDs; `comm` it against a list). The first run flagged exactly the three seeding sites above — per-body flags in fresh later-arm workspaces the per-workspace mark alone would have missed; with `copyFrom` the suite reports 337 tests, 82 failed (the known set), no harness error. The self project reports 22 files, 2326 passed; certification 144 PASS / 33 FAIL / 0 error unchanged. A record site behind a diagnosed product failure is not reached — the guard surfaces it when the product gets there (accepted bounded behavior, H-8). Since FIX_PLAN Task 16o the guard has a TypeScript arm (`guardUndeclaredTsStaging` in `test/helpers/workspace.ts`, the marks read through the shared `invocationBefore()`): after either mark it refuses, mode `undeclared-staging`, plain contents at a path the TypeScript check judges (a `TS_DEFAULT_SUFFIXES` name, or one a `ts` option or the workspace's `ts` declaration names) whose effective TypeScript declaration is neither `unchecked` nor `per-draw` — staged by `file()`, as an initial `files` entry at creation (per-body mark only), or by `copyFrom()` out of a workspace no product touched — and an MDX record carrying no `ts` at such a path (a code-group `.mdx` path, whose TypeScript reading the S-9 self-test never judged). The diagnosis — ``undeclared-staging staging of <path>: a code source or configuration file staged with plain contents (declared "…") after a product invocation …`` for `file()` and `copyFrom()`, ``… an initial `files` entry of a workspace created after a product invocation …, a code source or configuration file staged with plain contents …`` at creation, ``… the MDX staged-source record "<name>" carries no TypeScript declaration …`` for the record — names the TypeScript remedies: a `StagedTs` record, an MDX record's `ts`, `unchecked`, the per-draw declaration. `edit()` and `copyFrom()` out of an invoked workspace stay exempt. The TypeScript `per-draw` declaration (`{ ts: "per-draw" }` per `file()` call, `ts.perDraw` on `create`; `TsFileDeclaration` gained the member) is judged well-formed at staging (`judgeTsDeclaration` judges it as `well-formed`, its refusal naming the generator) and exempt from the guard, section-16 modules only: P-7's configurations and P-13's configuration, `c0/U.ts`, and `c1/V.ts` (`ts: { perDraw: tsPathsOf(files) }`); a record refuses it, and a `ts.perDraw` path in a second list or beside a record entry is refused at creation. Self-test: `test/self/s9-undeclared-staging.test.ts` 19 tests from Task 16o (five new — a `.ts` code source and `xspec.config.ts` refused after a workspace invocation and after a body invocation, by `file()` and as an initial entry; exempt as a record, `unchecked`, per-draw, `edit()`, and `copyFrom()` out of an invoked workspace; an MDX record without `ts` at a code-group `.mdx` path refused; the per-draw judge, the ledger refusals, and `tsPathsOf`; the two older sites that staged `src/app.ts` and `xspec.config.ts` as unjudged names now stage `specs/code.md`); with the arm disabled (an early `return` in `guardUndeclaredTsStaging`, on a scratch-backed copy) its four guard tests fail. Red check of the TypeScript arm on a converted module: on a scratch-backed copy of `test/suite/registry/section-1.1-1.2.ts`, stage `SKELETON_CONSUMER.source` instead of the record (`await workspace.file("consumer.ts", SKELETON_CONSUMER)`), run `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts -t 'T1\.1-2 '` (~7 s), read ``HarnessStagingError: undeclared-staging staging of consumer.ts: a code source or configuration file staged with plain contents (declared "well-formed") after a product invocation in this workspace …``, restore with `cp` and verify with `cmp`. The Windows leg's drive-mismatch arm (`test/windows/e6-drive-mismatch.test.ts`, outside every registered body and S-7's sweep, so the guard never refuses its creation) stages its `xspec.config.ts` and `specs/a.mdx` as records of `test/helpers/e6-drive-mismatch.ts` (`ANCHOR_CONFIG`, `ANCHOR_SOURCE`, named `T11.6-1 drive-mismatch arm (E-6 Windows leg) …`, their expressions moved verbatim), which `test/self/s9-staged-sources.test.ts` imports after `../helpers/e6.js` and before the registry manifest — keep that order, or the module's load throws `sealed`. Run facts at Task 16o: the full suite against the built product under the namespace (`unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite --reporter=verbose > <log> 2>&1`, 1107 s, never beside the self project): 77 files (4 failed, 73 passed), 338 tests, 5 failed, 333 passed; the failed-ID set (the fourth-plan Task 24 recipe) is P-1, T1.4-1, T1.4-4, T7-6, and T13.4-11, the known set, unchanged since FIX_PLAN Task 16; `grep -oE '^[A-Za-z]*Error' <log> | sort | uniq -c` gives 5 `HarnessAssertionError` and nothing else; `undeclared-staging`, `mdx-derivability`, `ts-derivability`, `HarnessStagingError`, `harness error`, and `timed out` each 0 times; P-2 through P-13 and the E-6 Linux-leg writer pass (P-7 and P-13 under `ts.perDraw`). No suite site needed converting. Self project 23 files, 3550 tests, all passing under the namespace; certification 154 PASS / 38 FAIL / 0 error / 0 hang over 27 runs, unchanged. +- Record-accepting initial files and the `perDraw` list (sixth-determination FIX_PLAN Task 1; TEST-SPEC S-9's timing clause for the initial `.mdx` files of a workspace a body creates after its first product invocation — a later arm's `withWorkspace`/`TestWorkspace.create`, a helper's twin — which S-7's sweep never reaches and which `create()` stages outside the undeclared-staging guard): `WorkspaceDecl.files` is `Readonly<Record<string, InitialFileContents>>` (`test/helpers/workspace.ts`; `InitialFileContents = FileContents | StagedMdx`), and `TestWorkspace.create()` stages a `StagedMdx` entry under the record's own declaration through the same private `recordStaging` as `file()`'s record branch: a record at a non-`.mdx` key throws `HarnessStagingError` mode `mdx-derivability` ("not an `.mdx` path"), and a record whose path the workspace `mdx` declaration names in ANY list (`unparseable`, `unchecked`, `allowances`, `perDraw`) is a contradiction ("drop the declaration entry (or change the record)") — so a converted initial entry's path leaves the workspace declaration, the record carrying the declaration instead; `create()`'s catch disposes the workspace on either refusal. A plain entry stages exactly as before, and `create()`'s initial entries stayed outside the guard (a plain `.mdx` entry of a later-arm workspace a convention only) until the guard's extension in fourth-plan Task 24 (the last bullet). `WorkspaceMdxDecl.perDraw` (a path list like `unchecked`) resolves to the `per-draw` declaration: judged well-formed at creation, exempt from the guard for later plain `file()` stagings of the path, refused beside a record and in a second list; section-16 modules only (draw-derived initial files the runner's `drawSources` judged). `stageConfigurationStateTwins` (`test/suite/registry/support.ts`) takes the widened type. Self-tests: `test/self/s9-staged-sources.test.ts` (192 tests; the describe "the builder stages an initial `files` record under the record's declaration" uses the registry's one `unparseable` record — T13.5-8's BOM source — and, for the contradicting-record `create()` refusal, a fresh unsealed ledger plus a fresh builder through `vi.resetModules()`, whose errors are matched by `name`/`mode` rather than `instanceof`) and `test/self/s9-undeclared-staging.test.ts` (13 tests). Red check of the record-declaration path: replace `this.checkMdx(rel, data, declaration)` in `stageInitial` by `this.checkMdx(rel, data, undefined)` (a Python substitution keyed on the two preceding lines `declaration = undefined;` and `}`, site count asserted 1), run `npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts -t 'initial `files` record'` — the unparseable-record and fresh-ledger tests fail with `mdx-derivability staging of specs/unparseable.mdx: declared well-formed (S-9's default) …` — then `git checkout -- test/helpers/workspace.ts && git diff --quiet`. Run facts at 79308be: self project 22 files, 2332 passed, 0 skipped under the namespace; certification 144/33/0/0; T12.1-3 and T10.1-1 keep their passing verdicts against the built product. +- The E-6 fixture's initial sources as records (sixth-determination FIX_PLAN Task 2; TEST-SPEC S-9's timing clause for the §18 representative fixture, `test/helpers/e6.ts`, which is no registry entry — S-7's sweep never runs it, no per-body mark is in effect while it runs, and the undeclared-staging guard reaches only its leaf edit, never `create()`'s initial files): the fixture's four `.mdx` stagings are ledger records — `E-6 specs/Other.mdx version one — the initial source` (`otherSource("version one")`), ``E-6 specs/Other.mdx version two — the leaf edit before `impact` `` (unchanged), `E-6 specs/Core.mdx` (`E6_CORE_SOURCE`, which stays a string beside its record because the `at` step derives its byte offset from it), and `E-6 specs/Refs.mdx` (`E6_REFS_SOURCE`) — the three initial ones passed in the fixture's `files` (`xspec.config.ts` and `src/app.ts` are not `.mdx` and stay plain); `test/self/s9-staged-sources.test.ts` pins exactly four `E-6`-led records (`expect(e6Records).toBe(4)`) and still imports `../helpers/e6.js` BEFORE the registry manifest (load-bearing: a record created after the seal throws). Run facts: the S-9 self-test alone 195 tests over 170 records (`npx vitest list … | grep -c '> T'` gives 166, plus the four `> E-6` lines); self project 22 files, 2335 passed, 0 skipped under the namespace (~133 s); certification 144/33/0/0 over the 23 `certification run against` lines; `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite e6-exchange-writer --reporter=verbose` passes against the built product before and after (~8 s) with identical writes — the sha256 capture hook (the `P3_TASK12_CAPTURE` recipe above; a byte path's key is `Buffer.from(rel).toString("hex")`) logs the same 6 lines, the five initial entries plus the leaf edit. Red check of an initial record: splice `<S id="x">` into `E6_CORE_SOURCE`'s `"Core holder text."` line on a scratch-backed copy and run `npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts -t 'E-6 specs/'` — the Core record fails as `HarnessStagingError` mode `mdx-derivability` (`… staging of E-6 specs/Core.mdx: declared well-formed (S-9's default) but the stock MDX 3 parser rejects it at line 4 …`) while the other three E-6 records pass; restore from the scratch copy and `cmp`. +- The §16 property modules' initial `.mdx` files under S-9's timing clause (sixth-determination FIX_PLAN Task 3): every trial after the first creates its workspace after the body's first product invocation, so a draw-derived initial `.mdx` entry is declared `perDraw` (`WorkspaceMdxDecl.perDraw`, the initial-file form of `per-draw`: judged well-formed at creation, exempt from the undeclared-staging guard, and already judged by the runner's `drawSources` before the body saw the draw) and a harness-constant one is a staged-source record. The `perDraw` list is derived from the rendered map by `mdxPathsOf(files)` (`test/helpers/workspace.ts`, beside `isMdxPath`: the plain `.mdx` keys in map order, record entries left out — a list naming a record's path is the contradiction `create()` refuses) in `section-16-p2-p3.ts` (P-2's two workspaces, P-3's one), `-p4.ts` (both), `-p7.ts` (both arms), `-p9.ts`, `-p12.ts` (every file since the fifth-plan Task 1, which removed the break-parse twist; the fifth-plan Task 3 then removed the per-draw unparseable declaration fourth-plan Task 23b had made for it), `-p13.ts`, and `-p5-p6.ts` through its module-local `drawWorkspace(rendered)` (three sites); `-p1.ts`'s `inStagedWorkspace` lists `specs/A.mdx` literally. Since FIX_PLAN Task 16o the draw-composed TypeScript files are declared per draw likewise — `ts: { perDraw: tsPathsOf(files) }` in `-p7.ts` (both arms' `xspec.config.ts`) and `-p13.ts` (`xspec.config.ts`, `c0/U.ts`, `c1/V.ts`); `tsPathsOf` (beside `mdxPathsOf`) lists the plain keys a `TS_DEFAULT_SUFFIXES` name reaches, in map order, records left out. P-7's capture code sources reach no such name (its path alphabet spells no `t` or `j`) and stay unjudged until the runner's own per-draw TypeScript check (FIX_PLAN Task 64). The records: `FUZZ_BASE_RECORDS` in `section-16-p8.ts` (`"P-8/P-11 specs/A.mdx"`, `"P-8/P-11 specs/B.mdx"`, from `BASE_SPEC_A`/`BASE_SPEC_B`, which stay strings in the exported `FUZZ_BASE_FILES` because the fuzz generators mutate their bytes; P-8 stages `fuzzBaseWorkspaceFiles()`, P-11 substitutes the record for each unmutated entry of `trial.files` — since FIX_PLAN Task 16n the map also holds the TypeScript records of the base configuration and `src/app.ts` (`BASE_CONFIG`, `BASE_CODE`), so P-11 picks by `mutatedPaths(trial)`, not by the `.mdx` mutations alone — and keeps its mutated ones plain and `unchecked`) and `"P-10 specs/A.mdx"` (`A_MDX`, `section-16-p10.ts`). P-8, P-10, and P-11 pass no `drawSources` and create no draw-derived well-formed `.mdx` workspace. Sites hook (temporary, uncommitted; the reviewers' instrumentation with a declaration column): as the first statement of `TestWorkspace.stageInitial`, `if (process.env.P4_SITES !== undefined && isMdxPath(rel) && !(contents instanceof StagedMdx) && productInvokedInBody() !== undefined) appendFileSync(process.env.P4_SITES, String(productInvokedInBody()) + "\t" + rel + "\t" + JSON.stringify(this.mdxDeclarations.get(mdxKey(rel)) ?? "well-formed") + "\n");` (`appendFileSync` from `node:fs`), run with the variable set (`P4_SITES=<log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite <file-substring> --reporter=verbose`), `cut -f1,3 <log> | sort | uniq -c` for the per-test declaration counts and `cut -f1,2 <log> | sort -u` for the distinct (test, path) sites; it logs plain contents only, so a converted record vanishes from the log and a `perDraw` entry shows as `"per-draw"` — a `"well-formed"` line after a conversion is a site still to convert. It combines with the `P3_TASK12_CAPTURE` hook in one run (both variables set; the capture line gains a leading `productInvokedInBody() ?? "-"` column). The eleven `test/suite/section-16-*.test.ts` files run together as `--project suite test/suite/section-16-` in ~11 min on 4 workers (P-11 and P-12 the long tail); restore the helper from a scratch copy afterwards (`cp`, `cmp`, `grep -c TEMP-HOOK` = 0). Run facts: before and after the conversion the eleven files give identical verdicts and diagnoses (P-1…P-5 fail diagnosed at the same seeds, trials, and shrinks; P-6…P-13 pass), identical capture logs (3655 writes), and the "after" sites log holds 0 `"well-formed"` lines against the "before" log's 2313 (2205 `"per-draw"`, P-11's 36 `"unchecked"`, P-12's 1 `"unparseable"`); the S-9 self-test alone 199 tests over 173 records (`grep -c '> T\|> P-\|> E-6'` on the `vitest list` output); self project 22 files, 2339 passed, 0 skipped under the namespace (~141 s; 2335 + the three record tests + the `mdxPathsOf` test); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. Red check of a §16 record: splice `<S id="x">` into `A_MDX`'s `"Kid text."` line (`section-16-p10.ts`) on a scratch-backed copy and run `npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts -t 'P-10 specs/'` — the record fails as `HarnessStagingError` mode `mdx-derivability` (`… staging of P-10 specs/A.mdx: declared well-formed (S-9's default) but the stock MDX 3 parser rejects it at line 4 …`); restore with `cp` and `cmp`. +- Every generated draw must derive (fifth-plan FIX_PLAN Task 3; TEST-SPEC 16's preamble and S-9): the document declares no generated draw unparseable — P-1's invalid draws derive, and P-8 and P-11 stage their imperfect input as `unchecked` mutations — so every `.mdx` source a draw stages is judged must-derive, by the property runner (`checkDrawSources` over `drawSources` since re-descent FIX_PLAN Task 64, `DrawSource` being `[path, contents, label?, role?]`) and by the builder at staging, and the per-draw declarations are `per-draw` alone: `file()`'s option (`section-16-p4.ts`, `-p5-p6.ts`, `-p9.ts`) and the workspace declaration's `perDraw` list for a draw's initial files (section-16 modules only). A draw a generator composes ill-formed is a generator defect reported as a harness error with its seed — never a declaration to add. The per-draw unparseable mechanism fourth-plan Task 23b (5a76901) built for P-12's break-parse twist, which the fifth-plan Task 1 removed, went in this task: its `MdxFileDeclaration` member and `WorkspaceMdxDecl` list with the judge's message, the guard's exemption and remedy text, the list's resolution and refusals, and `file()`'s acceptance (`test/helpers/workspace.ts`; every other part of Task 24's guard stays — records, `unchecked`, `perDraw`, `edit()`, `copyFrom()`, non-`.mdx` paths); `RecordDeclaration`'s third exclusion (`test/helpers/staged-mdx.ts` excludes `unchecked` and `per-draw`); and `DrawSource`'s optional fourth element with `checkDrawMdx`'s must-not-derive branch (`test/helpers/property.ts` is byte-identical to its pre-5a76901 text; `git show 5a76901` keeps the mechanism should a later document declare such a draw). `npm run typecheck` proves no caller remains (a four-element `DrawSource` no longer compiles). Self-tests: `test/self/s9-staged-sources.test.ts` 822 over 796 records and `test/self/property-infrastructure.test.ts` 15 — both their pre-5a76901 text, the former's header rewrapped — and `test/self/s9-undeclared-staging.test.ts` 14 (the two mechanism tests, the per-body arm's and the initial-files test's mechanism entries, and the remedy expectation removed). Run facts at the task: self project 22 files, 2950 passed, 0 skipped under the namespace (~92 s); certification 144 PASS / 33 FAIL / 0 error / 0 hang over 23 lines; P-12 and P-7 against the built product (their two suite files in one run under the namespace, ~158 s) pass at the fixed seeds. +- The §1 modules' post-invocation initial `.mdx` files as records (sixth-determination FIX_PLAN Task 4; TEST-SPEC S-9's timing clause for the initial files of a workspace a body creates after its first product invocation): fifty-two records — `section-1.3.ts` 11 (the structural arm rows composed in place by `structuralArm(recordName, parts)` into `StructuralArm.source`, T1.3-3's arm hoisted to `SKIPPED_LEVEL_ARM`, T1.3-4's two, T1.3-5's `CROSS_FILE_A`/`_B`, T1.3-6's form arms through `invalidIdFormArm()`), `section-1.4.ts` 38 (the template calls evaluated once at module load into the computed tables `INVALID_SEGMENT_FIXTURES`, `VALID_TAG_FIXTURES`, `INVALID_TAG_FIXTURES` — `staged(name, assembled)` pairing an `Assembled` fixture with its record as `StagedAssembled` — plus `EMPTY_NESTED` and `LONE_EMPTY_SEGMENT`; the record names carry the arm's diagnostic `name`), `section-1.6-1.7.ts` 3 (`EXTENDED_SOURCE`, T1.6-5's code-arm `VALID_SECTION_SOURCE`, `ENDPOINT_SPEC_SOURCE`, wrapped in place); `section-1.5.ts`'s T1.5-2 record, renamed `T1.5-2 the valid section source at every arm's spec path`, replaces its three `.source` fills; `section-1.1-1.2.ts` unchanged (every creation precedes its body's first invocation). The five `test/suite/section-1.*.test.ts` files run together in ~40 s on 4 workers (`--project suite section-1.1-1.2.test section-1.3.test section-1.4.test section-1.5.test section-1.6-1.7.test`): 27 tests, 22 pass, 5 fail diagnosed (T1.4-1 and T1.4-4 at the double-quote arm's exit code, T1.5-2 at the U+FFFD arm, T1.6-5 at the spec arm's 14.20 range, T1.7-2 at 4 of 19 occurrence records) — identical before and after. A multi-file run's `P3_TASK12_CAPTURE` log is compared SORTED (`diff <(sort before) <(sort after)`; the workers interleave lines, so the raw logs differ in order while the multiset of `<body>\t<path>\t<bytes>\t<sha256>` lines is the identity check; 173 writes here); the failure diagnoses are compared by extracting them from the verbose logs (`grep -A3 HarnessAssertionError <log> | grep -oE 'HarnessAssertionError: T[0-9.-]+[^:]*: [^`]{0,90}' | sort -u`, then `diff`). The sites hook (the `P4_SITES` recipe above) logged the task's 39 lines (11 distinct (test, path) pairs, all `"well-formed"`) before and 0 after; an arm never reached against the built product (T1.6-5's code arm; T1.4-1's arms past the double-quote arm) is judged by reading the body and converted all the same. Run facts: the S-9 self-test alone 251 tests over 225 records (`grep -c '> T\|> P-\|> E-6'` on the `vitest list` output); self project 22 files, 2391 passed, 0 skipped under the namespace (~137 s; 2339 + the 52 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. Red check of a computed-table record: splice `<S id="x">` into `segmentStaging`'s trailing part (`">\nSection with the segment under test.\n</S>\n"` → `">\nSection <S id=\"x\"> with the segment under test.\n</S>\n"`) on a scratch-backed copy of `section-1.4.ts` and run the S-9 self-test alone with `-t 'T1.4-'` — the 22 `T1.4-1 arm …` records and the lone empty segment (every fixture through `segmentStaging`) fail as `HarnessStagingError` mode `mdx-derivability` (declared well-formed, the stock parser rejects the unclosed tag; 23 failed, 15 passed of the 38) while `EMPTY_NESTED` (assembled directly) and the 14 T1.4-4 tag records pass; restore with `cp` and `cmp` (`git diff --stat` on the file empty). Since U+2028 left T1.4-2's valid boundaries (`BOUNDARY_CODE_POINTS` is U+00A0 and U+0085 alone; re-descent FIX_PLAN Task 2), `section-1.4.ts` holds 37 records, 13 of them T1.4-4 tag records, so that red check fails 23 and passes 14 of the 37; the S-9 self-test alone then runs 821 tests over 795 records. +- The §2 modules' post-invocation initial `.mdx` files as records (sixth-determination FIX_PLAN Task 5; TEST-SPEC S-9's timing clause for the initial files of a workspace a body creates after its first product invocation): eighty-eight records — `section-2.1.ts` 28 (`invalidImportStagings(testId, arms)` pairing each invalid-import arm of either table with its `specs/A.mdx` record per test, `LEXICAL_IMPORTER_FILES` computed once at load, `duplicateBindingArm(name, separator)` rows carrying the `duplicate-import-binding` allowance, `VALID_BASE_FILES`'s one record shared by T2.1-2 and T2.1-3, the two `docs/EXTRA.mdx` records, `SELF_IMPORT_SOURCE`), `section-2.2-2.3.ts` 10 (`t233ArmStagings(kind, sectionId, forms)`), `section-2.4.ts` 20 (`DYNAMIC_FORM_STAGINGS` — a discriminated union whose well-formed member carries the `d`/`text(...)` records and whose TypeScript-only member the offset, the exported `T2_4_2_UNPARSEABLE_STAGINGS` and the body's unparseable arms staying plain until Task 22; `DYNAMIC_ARM_BASE_RECORDS` made from the exported string map `DYNAMIC_ARM_BASE_FILES`; `arityArm`; T2.4-4's `SEGMENT_EXACT_BASE`, text-arm and positive-arm sources), `section-2.5-2.6.ts` 6 (`INVALID_COVERAGE_STAGINGS`, `T2_6_3_SELECT_SOURCE`), `section-2.7.ts` 24 (`FOREIGN_CONSTRUCT_STAGINGS`, the three enclosed-construct arms through `enclosedConstructArm(recordName, parts)`, `INVALID_PROP_STAGINGS`, `REPEATED_UNKNOWN_SOURCE`, `T2_7_3_DOUBLE_QUOTED`). The five `test/suite/section-2.*.test.ts` files run together in ~34 s on 4 workers (`--project suite section-2.1.test section-2.2-2.3.test section-2.4.test section-2.5-2.6.test section-2.7.test`): 29 tests, 21 pass, 8 fail diagnosed (T2.1-2 at the escape-spelled specifier arm's exit code, T2.3-3 at its first invalid-container arm's count, T2.4-2 at the first TypeScript-only form in `d`, T2.4-5 at the six verbatim spellings' counts, T2.5-3 at the `none` arm, T2.6-1 (at the reversed spelling's `query node`, the adapter refusing the spelled-order tag array `["b","a"]`), T2.7-3 at the `{...a, b}` arm, T2.7-4 at the comment forms' build) — identical before and after the conversion; the `P4_SITES` hook logged 107 lines (22 (test, path) pairs, 25 distinct with the declaration column) before and only the two reached unparseable remainders (T2.4-2's and T2.7-3's `specs/A.mdx`) after; the `P3_TASK12_CAPTURE` log holds 273 writes, identical when compared sorted. A module's new records are judged in ~5 s right after its conversion, before any suite run, by the S-9 self-test filtered to the section (`npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts -t 'T2.1-'`, no namespace needed): a duplicate record name or an ill-formed record fails there first. Waiting on a background suite run from a foreground call: a Python loop polling the run's log for its `EXIT` line with `time.sleep(2)` (in-process, so not the blocked `sleep` command). Run facts: the S-9 self-test alone 339 tests over 313 records (306 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2479 passed, 0 skipped under the namespace (~137 s; 2391 + the 88 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 6 (the §3–§4 modules' post-invocation initial files as records; `test/suite/registry/section-3.ts`, `section-4.ts`, `section-4.3-4.4.ts`, `section-4.5.ts`, `section-4.6.ts`; cbfbe4e): the six §3–§4 suite files run together in ~31 s (4 workers; 36 tests, 8 diagnosed failures — T3-7, T4-2, T4-5, T4.4-1, T4.5-8, T4.5-9, T4.6-1, T4.6-3 — 28 passes, 235 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes about a minute. Both hooks are installed by one re-runnable Python patch (`p4-task6-hooks.py` in the scratchpad: the two imports after `import * as fsp from "node:fs/promises";`, the sites line after `stageInitial`'s signature, the capture line after `write()`'s `fsp.writeFile`, each anchor asserted unique, a patched file refused; `git checkout -- test/helpers/workspace.ts` restores). The `P4_SITES` hook appends lazily: when nothing is logged the file is never created, so "0 lines after" shows as a missing file (`wc -l` errors), not an empty one. Duration-free verdict comparison: `grep -oE '^ (✓|×) \|suite\| test/suite/[^ ]+ > T[0-9.-]+' <log> | sort -k4,4V` per run, then `diff` (the verbose reporter appends `<n>ms` to each line, so whole-line diffs never match). Red check of a record built inside a per-arm staging function called from a computed table (`stageSpecSourceCollision` → `T4_5_8_SPEC_SOURCE_STAGINGS`): splice an unclosed tag into a part constant (`const T4_5_8_MDX_BODY_PREFIX = "Gamma <S id=\"x\"> behavior ";`) on a scratch-backed copy and run `npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts -t 'T4.5-8 specs/COL'` — both layouts' records fail as `HarnessStagingError` mode `mdx-derivability` (2 failed, 357 skipped); restore with `cp` and `cmp`. Run facts: the S-9 self-test alone 359 tests over 333 records (326 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2499 passed, 0 skipped under the namespace (~136 s; 2479 + the 20 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 7 (the §5 modules' post-invocation initial files as records; `test/suite/registry/section-5.1-5.3.ts`, `section-5.4.ts`, `section-5.5.ts`, `section-5.7.ts`; `section-5.6.ts` needs none): the five §5 suite files run together in ~36 s (4 workers; 21 tests, 2 diagnosed failures — T5.5-5, T5.7-4 — 19 passes, 154 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes about 1.5 min; the hooks go in and out with `p4-task6-hooks.py` and `git checkout -- test/helpers/workspace.ts`. Derivability of a composed source before converting it: a scratch `.mts` importing `deriveMdx` by absolute path (`import { deriveMdx } from "/home/user/xspec/test/helpers/mdx-derivability.ts"`), the source composed from the module's own parts (code points via `String.fromCodePoint`, never escape spellings), run with `node --experimental-strip-types` — T5.7-2's five token-bound `d` values (U+00A0, U+FEFF, block comment, line comment, run-on line comment) all answer `{"derives":true}`, so their records carry no allowance. A failure whose message begins with an adapter's label rather than the test ID (T5.5-5's `HarnessAssertionError: query node/show (T5.5-5 base state: …)`) is missed by the `HarnessAssertionError: T[0-9.-]+` extraction — use `grep -oE 'HarnessAssertionError: [^`]{0,160}' <log> | sort -u` for the diagnosis comparison. A record made from a template call over an arm table whose string the body still needs (T5.7-2's byte-range self-check slices the composed bytes): the computed table pairs `{ arm, source, main }` — the string and the record made from it — and the per-arm helper takes the pair. Red check of the computed-table records: splice an unclosed tag into `TOKEN_TAG_POST` (`"}>\nS <S id=\"x\"> text.\n</S>\n"`) on a scratch-backed copy of `section-5.7.ts` and run `npx vitest run --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts -t 'T5.7-2 token bounds'` — the five `specs/MAIN.mdx` records fail as `HarnessStagingError` mode `mdx-derivability` and the BASE record passes (5 failed, 1 passed, 371 skipped); restore with `cp` and `cmp`. Run facts: the S-9 self-test alone 377 tests over 351 records (344 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2517 passed, 0 skipped under the namespace (~134 s; 2499 + the 18 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 8 (the §6.1–§6.3 modules' post-invocation initial files as records; `test/suite/registry/section-6.1.ts`, `section-6.2.ts`, `section-6.3.ts`): the three §6.1–§6.3 suite files run together in ~48 s (4 workers; 12 tests, 2 diagnosed failures — T6.2-1, T6.2-2 — 10 passes, 133 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes under 2 min; the hooks go in with `p4-task6-hooks.py` and out with `git checkout -- test/helpers/workspace.ts`. A composition a per-cell helper performs from a module-level row (T6.2-3's `runImpureStaging` composing `specs/ca.mdx` from `shape`) converts as a computed table keyed by the row — `M3_ORIGIN_STAGINGS`, `{ shape, origin }`, one record per shape staged at every cell sharing the row — with the helper taking the pair; the S-9 self-test filter `-t 'T6.2-3 impure staging'` reaches its five records plus the two target and one Deps records (red check: `<S>` for `</S>` in the template on a scratch-backed copy → 5 failed, 3 passed; restore with `cp` and `cmp`). A workspace `mdx.unparseable` declaration for an initial `files` entry becomes the record's `"unparseable"` argument and must leave the declaration (T6.3-4's `specs/Broken.mdx`), else `create()` refuses the contradiction. The ledger self-test's filter for a section's records takes a regex — `-t 'T6\.[123]-'` (33 records: 30 new + 3 existing, ~10 s, no namespace); the verbose reporter's verdict lines are `✓ |suite| <file> > <ID> …` / `× |suite| …`, so `grep -oE '(✓|×) \|suite\| [^>]*> T[0-9.]+-[0-9]+'` on the two logs, sorted and diffed, is the verdict comparison. Run facts: the S-9 self-test alone 407 tests over 381 records (374 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2547 passed, 0 skipped under the namespace (~138 s; 2517 + the 30 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 9 (the §6.4 and §6.7 modules' post-invocation initial files as records; `test/suite/registry/section-6.4.ts`, `section-6.7.ts`, and the `withWorkspace` type widening in `section-6.6.ts`): the two suite files run together in ~25 s (8 tests, 1 diagnosed failure — T6.4-3 — 7 passes, 81 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes under a minute; the hooks go in with `p4-task6-hooks.py` and out with `git checkout -- test/helpers/workspace.ts`. A body-local staging map a runner both stages and compares against (T6.4-2's `stagedL`/`stagedM`; `runMinimalEditArm`'s `touched`) hoists to a module-level record-bearing map (`T6_4_2_L_FILES`/`T6_4_2_M_FILES`), the runner reading a record's bytes through `.source` (`staged instanceof StagedMdx ? staged.source : staged`) so its message selection is unchanged — `StagedMdx` imported as a value beside `stagedMdx`. An exported string set another registry module's string-typed `withWorkspace` consumes (`section-6.4.ts`'s three rename sets, consumed by `section-6.6.ts`) converts in place with the consumer's parameter widened to `InitialFileContents` (one import, one type); a consumer spreading it into a `WorkspaceDecl` (`section-14.ts`'s T14-7) needs nothing. The ledger self-test's filter `-t 'T6\.(4|7)-'` reaches 24 records (19 new + 5 existing, ~10 s, no namespace); red check: `"Hub text."` → `"Hub <S id=\"x\"> text."` in `T5_TARGET_SOURCE` on a scratch-backed copy, `-t 'T6.4-5 '` → the Target record fails as `mdx-derivability` (end-tag-mismatch), the Core record passes; restore with `cp` and `cmp`. Run facts: the S-9 self-test alone 426 tests over 400 records (393 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2566 passed, 0 skipped under the namespace (~137 s; 2547 + the 19 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 10 (the §6.5 module's post-invocation initial files as records; `test/suite/registry/section-6.5.ts`, with `section-6.4.ts`'s seven U4 records exported and renamed): the suite file runs in ~55 s (10 tests, 5 diagnosed failures — T6.5-6 through T6.5-10 — 5 passes, 172 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes about two minutes; the hooks go in with `p4-task6-hooks.py` and out with `git checkout -- test/helpers/workspace.ts`. A helper composing a row's sources from a geometry, where several rows compose identical bytes (`fileMoveArm` over `FILE_MOVE_ARMS`: arms (a) and (c) the moved file, (a) and (d) the importer), registers them through a memoizing module-level record function (`t651Record`: a `Map<string, StagedMdx>`, the name a function of exactly the composition inputs the bytes are a function of, the record reused on a repeat, a same-name/different-bytes composition thrown at load) — one record per byte sequence, staged at every site, created at load like any computed table. A constant byte-identical to another registry module's record is reused by exporting that record, renaming it with the calling ID, and aliasing it in the consumer (`const U5_A_SOURCE = U4_SOURCE;`), the consumer's spelling deleted and the sha256 capture proving identity; the record of a set only another module stages after its first invocation (`MOVE_IDENTITY_FILES_AFTER`, T6.6-3's) is created in the exporting module and named with the staging test alone. The ledger self-test's filter `-t 'T6\.[4-6]-'` reaches 72 records (49 new + 23 existing, ~5 s, no namespace); red check: `"A holder text."` → `"A <S id=\"x\"> holder text."` in `movedFileSource` on a scratch-backed copy, `-t 'T6.5-1 '` → the three moved-file records fail as `mdx-derivability`, the other file's and the three importers' pass (3 failed, 4 passed); restore with `cp` and `cmp`. Run facts: the S-9 self-test alone 475 tests over 449 records (442 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2615 passed, 0 skipped under the namespace (~132 s; 2566 + the 49 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 11 (the §6.5-ii and §6.5-iii modules' post-invocation initial files as records; `test/suite/registry/section-6.5-ii.ts`, `section-6.5-iii.ts`, with a value-level touch of `section-6.6.ts`): the two suite files run together in ~21 s (9 tests, 7 diagnosed failures — T6.5-11, T6.5-13, T6.5-15, T6.5-16, T6.5-17, T6.5-18, T6.5-19, each at its first arm — 2 passes, 40 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes under a minute; the hooks go in with `p4-task6-hooks.py` and out with `git checkout -- test/helpers/workspace.ts`. Byte-identical sources across tests and paths are ONE record named with every staging test in ID order and every path it lands at (`A13_THIRD_STAGED`: `"T6.5-13/T6.5-15/T6.5-16/T6.5-17/T6.5-19/T6.6-3/T6.6-4/T14-7 the module holding a alone (specs/x.mdx; T6.5-15's specs/A.mdx)"`), the consumer constants aliased to the first spelling (`const R16_K = A13_EXISTING_TARGET;`, `const M17_X_STAGED = A13_THIRD_STAGED;`) or deleted; a generator whose output equals another test's record is held to it at load (`j15SharedModule(binding, record)` throws on a byte mismatch), and a row factory taking an optional pre-made record checks it the same way (`r16MovedShape`). A module whose exported arm tables other tests restage names their records with every restaging test (T6.6-3's preview twins over `R16_REFUSED_ARMS`, `R16_ALONE_ARMS`, `M17_REFUSED_ARMS`; T6.6-4's `A13_TIE_BREAK_ARMS`; T14-7's `R16_REFUSED_ARMS` minus the `refused-id-collision` arm and `M17_REFUSED_ARMS`); a consumer reading such an entry as text takes the record's `.source` (`section-6.6.ts`'s `tieBreakPlan`, one `StagedMdx` value import), and the S-9 vectors built from `Object.entries(arm.files)` read it through a module-local `stagedText()` so the self-test's imports stay string tuples. The ledger self-test's filter `-t 'T6\.5-1[1-9]'` reaches 87 tests, the module's 83 records among them (~6 s, no namespace). Run facts: the S-9 self-test alone 558 tests over 532 records (525 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2698 passed, 0 skipped under the namespace (~141 s; 2615 + the 83 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 12 (the §6.6 module's post-invocation initial files as records; `test/suite/registry/section-6.6.ts`, with `section-6.5.ts`'s `A8_PLAIN_TARGET` exported and renamed): the suite file runs in ~47 s under the namespace (5 tests — T6.6-2, T6.6-5, T6.6-6 pass; T6.6-3 fails diagnosed at its identity-terms arm, T6.6-4 at arm (e)'s first tie-break arm; 60 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes under two minutes; the hooks go in with `p4-task6-hooks.py` and out with `git checkout -- test/helpers/workspace.ts`. A record another registry module already holds for the same bytes is reused by exporting it and aliasing it in the consumer (`const P2_TARGET_SOURCE = A8_PLAIN_TARGET;`, `const R6_TARGET_SOURCE = A8_PLAIN_TARGET;` — the consumer's spellings deleted), the record renamed with every staging test's ID, one staging it in its first workspace only included (T6.6-6). A constant a plan function still reads as a string (`armBPlan`'s `B4_ORIGIN_SOURCE`, `armCPlan`'s `C4_MV_SOURCE`/`C4_USER_SOURCE`, `armDPlan`'s `D4_SOLO_SOURCE`, the real-run assertion's `preSource: B4_THIRD_SOURCE`) keeps the string and gets a `*_STAGED` record made from it; one nothing else reads has its expression moved into the record (`C4_PAL_STAGED`) or is wrapped in place (`P2_ORIGIN_SOURCE`). A workspace a body creates from another module's exported `WorkspaceDecl` after its first invocation (T6.6-3's `TestWorkspace.create(CORE_DECL)` from `section-13.5.ts`) converts in the exporting module, its record named with the consuming test too (Task 21). Red check: an unclosed `mv` tag spliced into `C4_MV_SOURCE` on a scratch-backed copy fails `T6.6-4/T6.6-5 specs/Mv.mdx` alone under the ledger self-test's `-t 'T6\.6-'` filter (70 tests, ~6 s, no namespace); restore from the scratch copy rather than `git checkout --` while the file holds uncommitted work. Run facts: the S-9 self-test alone 566 tests over 540 records (533 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2706 passed, 0 skipped under the namespace (~137 s; 2698 + the 8 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 13 (the §7 basics, discovery, and §7.1–7.3 modules' post-invocation initial files as records; `test/suite/registry/section-7-basics.ts`, `section-7-discovery.ts`, `section-7.1-7.3.ts`): the three suite files run together in ~20 s under the namespace (9 tests — T7-5, T7-6, T7.1-1, T7.2-1 pass; T7-1, T7-2, T7-3, T7-4, T7.3-1 fail diagnosed; 171 writes in the `P3_TASK12_CAPTURE` log, compared sorted), so the before/after pair takes under a minute; the hooks go in with `p4-task6-hooks.py` and out with `git checkout -- test/helpers/workspace.ts`. A source several registry modules stage byte-identically after their bodies' first invocations is ONE record, exported by the first module in registry order and imported by the others (`import { SECTION_A_SOURCE, SECTION_B_SOURCE } from "./section-7-basics.js";`), named with every staging test in ID order across the modules (a later task converting a module that spells the same bytes — `section-7.4-7.5.ts`'s `mdxSection`, `section-12.6.ts`'s `VALID_SOURCE` — imports and renames it). A run-time map builder over module-level probe rows (`probeFiles`) takes an optional `source: StagedMdx` row field — required by the later workspaces' row type (`StagedProbe`), absent from the first workspace's rows — and stages `probe.source ?? mdxSection(probe.id)`; a string a raw beside-the-root write still needs (`stageBesideRoot`'s `x/M.mdx`) is kept and the record made from it. Red check: an unclosed tag spliced into `section-7-basics.ts`'s `mdxSection` template on a scratch-backed copy fails the A, B, and O records alone under the ledger self-test's `-t 'T7[-.]'` filter (17 tests, ~5 s, no namespace) — the discovery module's own template is untouched, so its records pass; restore from the scratch copy. Run facts: the S-9 self-test alone 583 tests over 557 records (550 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2723 passed, 0 skipped under the namespace (~140 s; 2706 + the 17 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 14 (the §7.4–7.5 module's post-invocation initial files as records; `test/suite/registry/section-7.4-7.5.ts`, with `section-7-basics.ts`'s `SECTION_A_SOURCE` renamed and `section-7-discovery.ts`'s `SECTION_C_SOURCE` exported and renamed): the suite file runs in ~35 s under the namespace (8 tests — T7.4-2, T7.5-2…T7.5-6 pass; T7.4-1 and T7.5-1 fail diagnosed at their set-reading `inventory` assertions, each in its last workspace; 255 writes in the `P3_TASK12_CAPTURE` log), so the before/after pair takes about a minute; the hooks go in with `p4-task6-hooks.py` and out by restoring a scratch copy of `test/helpers/workspace.ts` taken before patching (`cp`, then `git diff --quiet` on the file). A module that spells a template another module already registers a record from (the §7 `mdxSection`) imports that module's record for the id (exporting it where it was module-local, renaming it with the new staging tests and paths) and registers its own ids once each; a fixture-builder function called at module level whose `.mdx` values are parameter-independent literals hoists them to module-level records declared BEFORE the function — the module-level call follows the definition, and a `const` record is unusable until initialized — one record staged in every spelling. Prettier reflows a widened map header longer than 80 columns into `: Readonly<\n Record<string, InitialFileContents>\n> = {` or `… =\n {` with the literal indented two more columns (the forms `section-6.4.ts`, `section-6.5.ts`, and `section-7.1-7.3.ts` already show); accept it rather than introducing an alias. Ledger-wide duplicate-bytes probe (one-off, uncommitted): a temporary `test/self/p4-task14-dupes.test.ts` importing `../helpers/e6.js` and `../suite/registry/index.js`, grouping `stagedMdxLedger()` by `JSON.stringify(record.mdx) + ":" + Buffer.from(record.source).toString("hex")`, and writing the groups of two or more names as JSON to the file `process.env.P4_DUPES_OUT` names (a file survives the reporter), run as `P4_DUPES_OUT=<scratch>/dupes.json npx vitest run --config test/vitest.config.ts --project self test/self/p4-task14-dupes.test.ts` (~7 s, no namespace) and deleted before committing — it found 28 byte-identical groups across modules from earlier tasks (recorded in the plan's Task 14 paragraph) and none among this task's records. The ledger self-test's `-t 'T7[-.]'` filter now reaches 43 tests (~5 s, no namespace). Red check: splice `<S id="x">` into this module's own `mdxSection` template (`Text for ${id}.` → `Text <S id="x"> for ${id}.`) on a scratch-backed copy — exactly its eight template-built records (x, d, t, g, w, p, q, r) fail as `mdx-derivability`, the imported A and C (the other modules' templates) and the 18 literal-bodied records pass (8 failed, 35 passed); restore from the scratch copy and `cmp`. Run facts: the S-9 self-test alone 609 tests over 583 records (576 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2749 passed, 0 skipped under the namespace (~140 s; 2723 + the 26 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 15 (the §8, §9.3, and §10.1–10.3 modules' post-invocation initial files as records; `test/suite/registry/section-8.ts`, `section-9.3.ts`, `section-10.1.ts`, `section-10.2-10.3.ts`; `section-9.ts` needed nothing — one workspace per body): the five suite files run together in ~75 s under the namespace (28 tests — 27 pass; T10.1-6 fails diagnosed at its `.xspec/reviews`-symlink arm's `review status s --json`; 179 writes in the `P3_TASK12_CAPTURE` log; 4 workers, so the capture logs compare sorted), so the before/after pair takes under three minutes. The hooks go in with `p4-task6-hooks.py` and out by restoring a scratch copy of `test/helpers/workspace.ts` taken before patching; `npm run format` after a conversion reflows the patched hook lines too (harmless — the hooks keep working, the helper's `git diff --stat` just grows until the copy is restored). A shared map's `.mdx` entry holding a string constant another record derives from (`A_MDX` → `A_MDX_EDITED` through `.replace`) becomes a record made FROM the string (`stagedMdx(name, A_MDX)`), the string kept; a body's first workspace staging the same constant inline (T10.1-5) takes the record too. Whether a new record duplicates another module's bytes is learned from Task 14's ledger-wide duplicate-bytes probe (this time as a temporary `test/self/p4-task15-dupes.test.ts`, run with `P4_DUPES_OUT=<scratch>/dupes.json`, deleted before committing; ~8 s, no namespace): 592 records in 28 groups after this task — the new §10.1 `specs/A.mdx` record joined the §6.1/§6.3 group (recorded in the plan's Task 15 paragraph, not acted on). The ledger self-test's `-t 'T(8[.-]|9\.3-|10\.[12]-)'` filter reaches 20 tests (~6 s, no namespace); red check on two records at once: splice `<S id="x">` into the required-set `tgt/T.mdx` body and `t2Spec('Kid text e0. <S id="x">')` on scratch-backed copies — exactly those two fail as `mdx-derivability` (2 failed, 18 passed); restore with `cp` and `cmp`. Run facts: the S-9 self-test alone 618 tests over 592 records (585 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2758 passed, 0 skipped under the namespace (~125 s; 2749 + the 9 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 16 (the §10.4–10.7 modules' post-invocation initial files as records; `test/suite/registry/section-10.4.ts`, `section-10.5.ts`, `section-10.6.ts`, `section-10.7-i.ts`, `section-10.7-ii.ts`; e660a9c): the five suite files run together in ~94 s under the namespace (26 tests, all passing; 198 writes in the `P3_TASK12_CAPTURE` log; 4 workers, so the capture logs compare sorted), so the before/after pair takes about three minutes. The hooks go in with `p4-task6-hooks.py` and out by restoring a scratch copy of `test/helpers/workspace.ts`. The ledger-wide duplicate-bytes probe (Task 14's recipe; this time `test/self/p4-task16-dupes.test.ts`, run with `P4_DUPES_OUT=<scratch>/p4-task16-dupes.json`, deleted before committing — the scratchpad keeps its source) belongs BEFORE a conversion's closing commit, not after: it found the split sub-fixture's `F_SOURCE` registered as a second record of the bytes `T10_6_2_B_WITHOUT_FE` already held (`b2Spec(false)`), which the S-9 self-test never flags; the fix is the alias `const F_SOURCE = T10_6_2_B_WITHOUT_FE;` placed AFTER the record (a `const` alias before its record is a TDZ error at load), the record renamed for both sites — 613 records in the same 28 groups as after Task 15. Re-capturing one suite file after such a fix compares against the multi-file before-log by filtering that log's lines whose first column is one of the file's test IDs, plus a `comm -13` sub-multiset check of its `-`-column lines (pre-invocation writes carry no test ID, so they cannot be attributed to a file). The ledger self-test's `-t 'T10\.[4-7]-'` filter reaches 85 tests (~7 s, no namespace); red check on two records at once: `urSpec("", "You leaf v0. <S id=\"x\">", "Elsewhere v0.")` in the U.mdx initial record and `"Aye own text. <S id=\"x\">"` in the F.mdx source on scratch-backed copies — exactly those two fail as `mdx-derivability` (2 failed, 84 passed); restore with `cp` and `cmp`. Run facts: the S-9 self-test alone 639 tests over 613 records (606 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2779 passed, 0 skipped under the namespace (~120 s; 2758 + the 21 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 17 (the §11, §11.2, and §11.3 modules' post-invocation initial files as records; `test/suite/registry/section-11.ts`, `section-11.2.ts`, `section-11.3.ts`, with `section-5.7.ts`'s restaged fixtures exported as records; a588b11): the four suite files (`section-11.test`, `section-11.2.test`, `section-11.3.test`, `section-5.7.test`) run together in ~24 s under the namespace (21 tests, 7 diagnosed failures — T11-2, T11-6, T11-7, T11.2-6, T11.3-2, T11.3-3, T5.7-4; 152 writes in the `P3_TASK12_CAPTURE` log, compared sorted). The hooks go in with `p4-task6-hooks.py` and out by restoring a scratch copy of `test/helpers/workspace.ts` (`cmp` it against `git show HEAD:test/helpers/workspace.ts`); `npm run format` reformats the patched helper's long hook lines while the hooks are in — harmless, but `git diff --stat` then shows the helper as ~31 lines changed until the copy is restored. A conversion script that edits several modules in sequence is not idempotent: when an assertion fails midway (here `" R_SOURCE,\n"` matched three lines of `section-11.3.ts` — the import plus two sliceCheck arguments — so `R_SOURCE` stays imported beside `R_STAGED`), the modules already saved stay converted; write the remainder as a second script rather than re-running the first. The ledger-wide duplicate-bytes probe (`p4-task16-dupes.test.ts`, renamed per task, run BEFORE the closing commit) found `NO_OCC_BASE_SOURCE` registered as a second record of `TOKEN_BASE_SOURCE`'s bytes in the same module — fixed by the alias `export const NO_OCC_BASE_STAGED = TOKEN_BASE_SOURCE;` placed after the record, the record renamed for all three tests, the literal deleted; a new cross-module group (T5.7-4's `specs/SPARE.mdx` = T6.5-3's `specs/Spare.mdx`) is recorded, not merged: 640 records in 29 groups (compare against `p4-task16-dupes2.json`, Task 16's post-fix baseline, not its first probe). The ledger self-test's `-t 'T11[-.]|T5\.7-'` filter reaches 37 tests (~5 s, no namespace); red check on two records at once: `Leaf text. <S>` in `T11_4_LEAF` (section-11.ts) and `"X text. <S>"` in `CONJ_T_SOURCE` (section-11.3.ts) on scratch-backed copies — exactly those two fail as `mdx-derivability` (2 failed, 35 passed); restore with `cp` and `cmp` (`p4-task17-red.sh`). Run facts: the S-9 self-test alone 666 tests over 640 records (633 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2806 passed, 0 skipped under the namespace (~120 s; 2779 + the 27 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 18 (the §11.4–11.6 modules' post-invocation initial files as records; `test/suite/registry/section-11.4.ts`, `section-11.5.ts`, `section-11.6.ts`, with `section-2.7.ts`'s valueless-`tags` arm record exported as `VALUELESS_TAGS_STAGED` for T11.4-3; 65da001): the four suite files (`section-11.4.test`, `section-11.5.test`, `section-11.6.test`, `section-2.7.test` — the last for the reused record's byte identity) run together in ~57 s under the namespace (17 tests, 8 diagnosed failures — T11.4-2, T11.4-3, T11.4-4, T11.4-6, T11.5-3, T11.6-2, T2.7-3, T2.7-4; 152 writes in the `P3_TASK12_CAPTURE` log, compared sorted), so the before/after pair takes about two minutes. The hooks go in with `p4-task6-hooks.py` and out by restoring a scratch copy of `test/helpers/workspace.ts`; the sites log's expected remainder after the conversion is T2.7-3's `specs/A.mdx` `"unparseable"` alone (Task 22's). An inline `files` literal moves to a module-level record without re-spelling it by extracting the `"<path>": <expr>,` line from the module text by regex within the test's region (`p4-task18-convert-11.5-11.6.py`'s `hoist`), and a one-line `const X = <literal>;` is wrapped by taking the expression between the prefix and `;\n` (`wrap_line`) — the way to move a literal holding non-ASCII characters (`É`, `ä`, `—`) through the tool layer untouched. A computed record table one of whose rows another test stages by import takes an optional row field naming the shared record (`InvalidPropArm.shared`, the table's `arm.shared ?? stagedMdx(…)`), the record created before the table from the exported constant. The ledger-wide duplicate-bytes probe (`p4-task16-dupes.test.ts` renamed per task, `P4_DUPES_OUT=<scratch>/p4-task18-dupes.json`, deleted before committing): 657 records in the same 29 groups as `p4-task17-dupes2.json` (compare the sorted group sets). The ledger self-test's `-t 'T11\.[456]-|T2\.7-3'` filter reaches 34 tests (~5 s, no namespace); red check on two records at once: `Annexe. <S>` in the `aux/x.mdx` record (section-11.6.ts) and `Tgt line. <S></S>` in the `specs/tgt.mdx` record (section-11.4.ts) on scratch-backed copies — exactly those two fail as `mdx-derivability` (2 failed, 32 passed); restore with `cp` and `cmp` (`p4-task18-red.sh`; a splice anchor must start where the line's text starts — a literal beginning with a non-ASCII word is anchored on its tail). Run facts: the S-9 self-test alone 683 tests over 657 records (650 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2823 passed, 0 skipped under the namespace (~117 s; 2806 + the 17 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 19 (the §12.0 modules' post-invocation initial files as records; `test/suite/registry/section-12.0-i.ts`, `section-12.0-ii.ts`, `section-12.0-iii.ts`; 8a4583f): the three suite files (`section-12.0-i.test`, `section-12.0-ii.test`, `section-12.0-iii.test`) run together in ~62 s under the namespace (14 tests, 2 diagnosed failures — T12.0-10, T12.0-14; 85 writes in the `P3_TASK12_CAPTURE` log, compared sorted). Recovering a conversion an earlier iteration left uncommitted (its logs' provenance unknown): save the working copies to the scratchpad and `cmp` them, write HEAD's copies back with `git show HEAD:<path> > <path>`, apply the hooks (`p4-task6-hooks.py`), run the "before" side, copy the saved conversions back and `cmp`, run the "after" side, then `git checkout test/helpers/workspace.ts` (it held no other edit) — one tree, no worktree or second `node_modules`. The diagnosis extraction `HarnessAssertionError: [^`]{0,160}` stops at the first backtick, so a diagnosis that begins with a backticked command (both §12.0 failures) yields the test ID alone; compare the full blocks instead — `awk '/HarnessAssertionError: /{p=1} /^ ❯ /{p=0} p' <log> | sed -E 's#/tmp/xspec-harness-[A-Za-z0-9]+#/tmp/xspec-harness-X#g'` on each log, then `diff` (the temporary workspace paths differ run to run). A red-check splice into a literal the module spells more than once is anchored on the record's name: find the unique name line, then the first occurrence of the literal after it (`p4-task19-r-red.py`). The ledger self-test's `-t 'T12\.0-'` filter reaches 14 tests (~4 s, no namespace); red check: `Alpha intro. <S id="x">` in the addressing record (section-12.0-i.ts) and `Middle a text. <S id="x">` in the coverage arm's `specs/tgt/T.mdx` record (section-12.0-ii.ts) fail exactly those two as `mdx-derivability` (2 failed, 12 passed); restore with `cp` and `cmp`. The duplicate-bytes probe (`p4-task16-dupes.test.ts` copied to `test/self/p4-task19-dupes.test.ts`, `P4_DUPES_OUT=<scratch>/p4-task19-r-dupes.json`, deleted before committing): 670 records in 30 groups, compared with `p4-task18-dupes.json` as sets of name sets. Run facts: the S-9 self-test alone 696 tests over 670 records (663 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2836 passed, 0 skipped under the namespace (~88 s; 2823 + the 13 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 20 (the §12.1–§12.7 modules' post-invocation initial files as records; `test/suite/registry/section-12.1-12.2.ts`, `section-12.3-12.5.ts`, `section-12.6.ts`, `section-12.7.ts`, with `section-7-basics.ts`'s `SECTION_A_SOURCE` renamed; b38740e): the four suite files (`section-12.1-12.2.test`, `section-12.3-12.5.test`, `section-12.6.test`, `section-12.7.test`) run together in ~22 s under the namespace (16 tests, 3 diagnosed failures — T12.2-4 at arm (a), T12.3-1 in its ordering workspace, T12.7-3 in its config-paths arm; 120 writes in the `P3_TASK12_CAPTURE` log, compared sorted); the hooks patch (`p4-task6-hooks.py`) applied to a pristine `test/helpers/workspace.ts`, restored afterwards from a scratch copy (`cp`, `cmp`, `git diff --quiet`). The diagnoses compare as Task 19's full blocks with temporary paths normalized. A record name must not spell ` > `, the separator `vitest list` prints between the file, the describe, and the test name: `npx vitest list --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts | awk -F' > ' 'NF>3'` prints the names that do (none after this task), and `grep '> T'` still counts one line per record. The ledger self-test's `-t 'T12\.[1-7]-'` filter reaches 30 tests (~4 s, no namespace); red check (`p4-task20-red.py apply|restore`: splice anchored on the record's unique name, then the first occurrence of the literal after it): `"Alpha behavior. <S id=\"x\">"` in the valid-a1 record (section-12.1-12.2.ts), `"Leaf line. <S id=\"x\">"` in the restricted-tree record (section-12.3-12.5.ts), and `Sigma. <S id="x">` in the specs/sub/S.mdx record (section-12.7.ts) fail exactly those three as `mdx-derivability` (3 failed, 27 passed); restored and compared. The duplicate-bytes probe (`p4-task16-dupes.test.ts` copied to `test/self/p4-task20-dupes.test.ts`, `P4_DUPES_OUT=<scratch>/p4-task20-dupes.json`, deleted before committing): 686 records in 31 groups, compared with `p4-task19-r-dupes.json` as sets of name sets. Run facts: the S-9 self-test alone 712 tests over 686 records (679 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2852 passed, 0 skipped under the namespace (~90 s; 2836 + the 16 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 21 (the §13 modules' and the H-6 refusal fixtures' post-invocation initial files as records; `test/suite/registry/section-13.1-13.2.ts`, `section-13.3.ts`, `section-13.4.ts`, `section-13.5.ts`, `write-refusal-staging.ts`, with `section-12.0-i.ts`'s `STREAMS_VALID_SOURCE` renamed; 31d884c): the refusal fixtures are staged by T14-9 and T14-10 too, and `CORE_DECL` by T6.6-3, so the checks ran the four §13 suite files together with `section-14-ii.test` and `section-6.6.test` — `P4_SITES=<log> P3_TASK12_CAPTURE=<log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-13.1-13.2.test section-13.3.test section-13.4.test section-13.5.test section-14-ii.test section-6.6.test --reporter=verbose` (~43 s; 29 tests, 8 diagnosed failures — T13.3-2, T13.4-6, T13.5-1, T13.5-7, T14-9, T14-10, T6.6-3, T6.6-4; 207 writes in the capture log, compared sorted; the script is `p4-task21-run.sh before|after` in the scratchpad). The full-block diagnosis comparison needs one more normalization here: the built product names its temporary write files `.xspec.tmp-<pid>-<n>`, and T13.5-7's and T14-9's exit-70 diagnoses quote that name, so pipe the extracted blocks through `sed -E 's#\.xspec\.tmp-[0-9]+-#.xspec.tmp-PID-#g'` as well as the `/tmp/xspec-harness-X` rewrite before the `diff`. Red check (`p4-task21-red.py apply|restore`): an unclosed tag spliced into one new record per module fails exactly those five as `mdx-derivability` in the S-9 self-test (5 failed, 722 passed; ~5 s, no namespace). The duplicate-bytes probe (`p4-task21-dupes.json`, compared with `p4-task20-dupes.json` as sets of name sets): 701 records in 32 groups. Run facts: the S-9 self-test alone 727 tests over 701 records (694 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2867 passed, 0 skipped under the namespace (~90 s; 2852 + the 15 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 22 (the T14-11 exchange — the §2 `UnparseableStaging` exports, T14-12's arm stagings, and their consumer — plus `section-14-iii.ts`'s other post-invocation initial files, as staged-source records; `test/suite/registry/support.ts`, `section-14.ts`, `section-2.2-2.3.ts`, `section-2.4.ts`, `section-2.7.ts`, `section-14-iii.ts`; 53331cd): the records are staged by T14-4's and T14-6's sweeps (`T14_12_REPORTER_STAGINGS`) and T14-11's (w) arms too, so the checks ran six suite files together — `P4_SITES=<log> P3_TASK12_CAPTURE=<log> unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite section-2.2-2.3.test section-2.4.test section-2.7.test section-14.test section-14-iii.test section-15.test --reporter=verbose` (~42 s; 28 tests, 11 diagnosed failures — T2.3-3, T2.4-2, T2.4-5, T2.7-3, T2.7-4, T14-2, T14-4, T14-6, T14-7, T14-11, T14-12; 335 writes in the capture log, compared sorted; the script is `p4-task22-run.sh <tag>` in the scratchpad, `p4-task22-diag.py <log>` extracting each failure's message block up to its first stack frame with temporary paths and `.xspec.tmp-<pid>-` names normalized). Most re-staged sites lie behind diagnosed failures, so a per-arm diagnostic variant reached them, before and after the conversion alike: `p4-task22-variant.py apply|restore` (scratch copies, restored and `cmp`-checked) renames each arm runner `runX` to `runX__inner` and appends a hoisted `async function runX(...args)` wrapper that try/catches and appends `<label> PASS` or `<label> FAIL [<error constructor>] <first three message lines>` to `$P4_T22_VARIANT_LOG` — anchored on the runners' names alone, so one patch applies to both sides of a conversion that rewrites their bodies (`runUnparseableFormArm`, `runCommentForms`, `runEnclosedConstructArm`, `runUnparseableCommentArm`, `runRangeRuleArm`, and T14-12's five runners) — while T2.3-3's two form loops iterate `.slice(0, 0)` (its unparseable arm then runs as the body's first) and T14-4's and T14-6's bodies get a prepended loop over `SWEEP_ENTRIES` filtered to the `T14-12` labels, each entry's `build --json` findings logged as `[condition, code, locations]` JSON, then `return` (~29 s; 107 log lines and 478 writes, both compared sorted). A red check on an `unparseable` record cannot splice an unclosed tag (the source stays unparseable and the judge passes it): make the source derive or flip the declaration to `"well-formed"` (`p4-task22-red.py apply|restore`: 19 records fail as `mdx-derivability` in the S-9 self-test). The builder self-test's `someUnparseableRecord()` (`test/self/s9-staged-sources.test.ts`) borrows the ledger's first `unparseable` record — T2.3-3's since this task — so a red check making that record derive fails that self-test as well. Duplicate-bytes probe (`p4-task22-dupes.json`): 730 records in 33 groups (the new one: the code arms' spec source with `"T4.5-9 specs/A.mdx"`). Run facts: the S-9 self-test alone 756 tests over 730 records (723 `T…`, 4 `E-6`, 3 `P-…`; ~5 s, no namespace); self project 22 files, 2896 passed, 0 skipped under the namespace (~90 s; 2867 + the 29 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- FIX_PLAN (fourth plan) Task 23 (the §14 modules' post-invocation initial files as staged-source records; `test/suite/registry/section-14.ts`, `section-14-ii.ts`, with records reused from `section-12.1-12.2.ts`, `section-5.1-5.3.ts`, `section-6.5.ts`, and `section-6.5-iii.ts`; c782148): the plain before/after pair ran seven suite files together — `p4-task23-run.sh <tag> [file-substrings…]` in the scratchpad (default: the four converted-or-reused modules' suite files), with `section-6.5.test section-6.5-iii.test section-6.6.test` added for the exported and renamed records (~74 s; 44 tests, 21 diagnosed failures; 514 writes in the capture logs, compared sorted). A suite file added after the before run gets its own before run from HEAD's copies of its modules (`git show HEAD:<path> > <path>`, the converted copies back from the scratchpad afterwards and `cmp`-checked), the two before logs concatenated and sorted against the one after log. One diagnostic variant reaches every arm behind `section-14.ts`'s diagnosed failures: the module-local `withWorkspace` swallows its body's failure and logs `<n> <files keys> PASS|FAIL …`, so every later arm and sweep entry runs (T14-4, T14-6, T14-7, T14-11); `section-14-ii.ts`'s T14-9/T14-10 arm functions take Task 22's rename-and-wrap form, and arm (g)'s first block an inner try/catch so its invalid-configuration workspace is reached (`p4-task23-variant.py apply|restore`; ~105 s for the two files; 205 log lines and 609 writes, both compared sorted). The conversion scripts (`p4-task23-convert-14.py`, `-14b.py`, `-14c.py`, `p4-task23-convert-others.py`) move each expression with a string-, template-, and comment-aware scanner (`p4_task23_lib.py`: `find_close`, `expr_end`, `call_args`, `wrap_after`) instead of re-typing it; Python's `json.dumps` writes a non-ASCII character of a generated record name as a backslash-u escape, so keep generated names ASCII (or pass `ensure_ascii=False`) and grep the diff's added lines for backslash-u escapes before committing. The duplicate-bytes probe (`p4-task23-dupes2.json`): 796 records in 36 groups; a new entry whose bytes equal a record the same test already stages elsewhere (T14-7's two) is that record, exported and renamed. The ledger self-test alone runs in ~5 s (no namespace); red check (`p4-task23-red.py apply|restore`): an unclosed tag spliced into four new records and the sweep's 14.20 source made to derive fail exactly those five as `mdx-derivability` (5 failed, 817 passed). Run facts: the S-9 self-test alone 822 tests over 796 records (789 `T…`, 4 `E-6`, 3 `P-…`); self project 22 files, 2962 passed, 0 skipped under the namespace (~91 s; 2896 + the 66 record tests); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- The undeclared-staging guard covers initial files (fourth-plan FIX_PLAN Task 24, the plan's last task; TEST-SPEC S-9's timing clause for the initial `.mdx` files of a workspace created after a body's first product invocation, S-7, H-8): `TestWorkspace.stageInitial` (`test/helpers/workspace.ts`) calls `guardUndeclaredStaging(rel, <the workspace declaration's entry> ?? "well-formed", "initial entry")` for a plain `.mdx` entry before the S-9 judge — the same guard `file()` and `copyFrom()` call (site `"write"`), one code path. At creation only the per-body mark can be set (the root was registered an instant before and nothing has run in it), so a creation is refused only inside a registered body — the suite wrapper, the certification runner and S-7's sweep, a property runner's later trials — after the body's first invocation; outside a body context (a self-test, the E-6 fixture) creation never refuses. Exempt: a record entry and a path the workspace declaration lists `unchecked` or `perDraw` (the latter still judged at creation; the per-draw unparseable list went in the fifth-plan Task 3); refused whatever the plain entry's declaration (well-formed, `unparseable`, allowances — those ride a record). `create()`'s catch disposes the half-built workspace, entries staged before the refused one included. The diagnosis: ``undeclared-staging staging of <path>: an initial `files` entry of a workspace created after a product invocation in the running body of <ID> (in another workspace: S-7's sweep stops at the body's first invocation wherever it happens), staged with plain contents (declared "…") — …``, then the remedies (a staged-source record as the entry's value, the path dropped from the workspace's `mdx` declaration; `mdx.perDraw` for a property draw's initial file; `mdx.unchecked` for a P-8 mutation or noise file); `file()`'s message is unchanged. A future unconverted post-invocation entry surfaces as that harness error at the first run reaching its site (behind a diagnosed product failure: when the product gets there). Self-test: `test/self/s9-undeclared-staging.test.ts` 16 tests (14 since the fifth-plan Task 3 removed the per-draw unparseable declaration's two tests; the new one inside `runProductTestBody("T0-9", …)`: before the body's first invocation a plain creation passes; after it the refusals, each leaving nothing behind, and the exemptions; outside any body after an invocation elsewhere a creation passes). Observing "nothing left behind" without racing the other self-test files' concurrent `xspec-harness-*` directories: its `createInPrivateTemp` points `TMPDIR`, `TMP`, and `TEMP` at a fresh private directory for the one `create()` call (`os.tmpdir()` reads them at each call; Vitest 4's default `forks` pool gives each test file its own process environment), restores them, and lists that directory — a success leaves exactly one `xspec-harness-*` entry, a refusal none. Red checks: `git stash push -q test/helpers/workspace.ts && (unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project self test/self/s9-undeclared-staging.test.ts 2>&1 | grep -E '×|Tests '); git stash pop -q` fails exactly the new test (1 failed, 15 passed); the guard on a converted module — on a scratch-backed copy of `test/suite/registry/section-2.1.ts`, make the shared record `"T2.1-2/T2.1-3 specs/BASE.mdx"` plain (`).source,` closing its `stagedMdx(…)` in `VALID_BASE_FILES`) and run `unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite test/suite/section-2.1.test.ts -t 'T2.1-3 '` (~5 s): T2.1-3 fails as ``HarnessStagingError: undeclared-staging staging of specs/BASE.mdx: an initial `files` entry of a workspace created after a product invocation in the running body of T2.1-3 …`` (a later arm's workspace; its first workspace's plain entry passes) — restore with `cp` and verify with `cmp`. Run facts at 3d0f424: self project 22 files, 2967 passed, 0 skipped under the namespace (~90 s); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines; the full suite against the built product (`unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite --reporter=verbose > <log> 2>&1`, 704 s on 4 workers — the first full run since the conversions of Tasks 3–23b): 77 files (47 failed, 30 passed), 337 tests, 82 failed, 255 passed; the failed-ID set (`grep -E '^\s+×' <log> | grep -oE '> (T[0-9.]+-[0-9]+|P-[0-9]+) ' | sed -E 's/^> //; s/ $//' | sort -u`) `comm -3`-empty against the known 82-ID list; `grep -oE '^[A-Za-z]*Error' <log> | sort | uniq -c` gives 82 `HarnessAssertionError` and nothing else; `grep -c 'undeclared-staging' <log>` 0; the E-6 exchange test passes; P-6 through P-13 pass (P-12 under the guard). No site needed converting. +- The staged-source ledger's TypeScript records (FIX_PLAN Task 16 and its split tasks; TEST-SPEC S-9's TypeScript and timing clauses, H-8): `test/helpers/staged-ts.ts` — `stagedTs(name, source, ts = "well-formed", grammar = "ts")` registers a `StagedTs` at module load (bytes; declaration `"well-formed"` or `"unparseable"`, never `unchecked`; grammar `"ts"`, or `"tsx"` for a `.tsx` path — SPEC 14.20 selects TSX by the `.tsx` suffix alone), named as MDX records are (`"<TEST-ID>[/<TEST-ID>…] <what it stages>"`, the lead IDs registered tests or `E-6`), sealed by the manifest beside the MDX ledger (`sealStagedTsLedger()` in `test/suite/registry/index.ts`). The builder (`test/helpers/workspace.ts`) takes a record in `file(rel, record)` and as an initial `files` entry (`InitialFileContents = FileContents | StagedMdx | StagedTs`) and stages its bytes under the record's declaration whatever the name (a record makes its path judged, as `ts.wellFormed` does); it throws `HarnessStagingError` mode `ts-derivability`, nothing written, for a `ts` option beside a record, a workspace `ts` entry naming an initial record's path, an `.mdx` path, and a path selecting the other grammar. An `.mdx` path a code group discovers is an MDX source and a code source at once: its MDX record carries the TypeScript declaration too (`stagedMdx(name, source, mdx, ts)`, e.g. T2.1-2's `docs/EXTRA.mdx`), staged under both, a `ts` option or workspace `ts` entry beside it a contradiction. `test/self/s9-staged-sources.test.ts` judges every TypeScript record with `judgeTsDeclaration(record.name, bytes, record.ts, tsGrammarFileName(record.grammar))` (the optional fourth argument names the grammar's neutral file) and every MDX record carrying `ts` as plain TypeScript, one test each: `npx vitest list --config test/vitest.config.ts --project self test/self/s9-staged-sources.test.ts | grep 'every staged TypeScript source'` lists them (503 from Task 16o: E-6's two — the self-test pins that count — the Windows leg's drive-mismatch configuration (`ANCHOR_CONFIG` of `test/helpers/e6-drive-mismatch.ts`, Task 16o), nineteen of the §1 modules, seventeen of §2 and §3, T2.1-2's `docs/EXTRA.mdx` MDX record carrying `ts` among them, fifty of `section-4.ts`, T4-2's and T4-5's arm tables laid out at module load one record per row, and seventy-one of §4.3–§4.6 — fifteen of `section-4.3-4.4.ts`, fifty of `section-4.5.ts`, six of `section-4.6.ts` — their arm tables likewise one record per row, and twenty-two of Task 16e — seven of `section-5.7.ts`, eight of `section-6.4.ts`, one each of the other §5 and §6.1–§6.3 modules but `section-5.6.ts`, and `support.ts`'s `UNKNOWN_KEY_CONFIG`, and twenty-one of Task 16f — ten of `section-6.5.ts`, five of `section-6.5-ii.ts`, two each of `section-6.5-iii.ts` and `section-6.6.ts`, one of `section-6.7.ts`, and `section-13.5.ts`'s configuration — and forty-seven of Task 16g, all of `section-7-basics.ts`, thirty-three of them its refused-configuration tables' rows, and forty-nine of Task 16h — twenty-six of `section-7-discovery.ts` (T7-4's outside-root and inside-root arms one record per row) and twenty-three of `section-7.1-7.3.ts` (T7.3-1's emission-matrix, emit-required, and invalid-outDir tables one record per row), and sixty-three of Task 16i, all of `section-7.4-7.5.ts` (T7.4-1's and T7.5-1's matrix tables one record per row; its byte-identical code sources one record each, `OK_CODE_SOURCE` for `src/impl.ts` and `dualcode/d.ts`, `CODE_MARKER_TO_P` for T7.5-5's five paths), and twenty-three of Task 16j — two of `section-8.ts`, one of `section-9.3.ts`, two each of `section-10.1.ts` and `section-10.2-10.3.ts`, four of `section-10.4.ts`, one each of `section-10.5.ts` and `section-10.6.ts`, four of `section-10.7-i.ts`, and six of `section-10.7-ii.ts` (the §10 modules' configuration constants the records themselves, staged in every workspace that names them; `section-8.ts`'s two wrapped in place in their `files` maps; `section-9.3.ts`'s a record wrapping `section-5.6.ts`'s constant), and twenty-eight of Task 16k — two of `section-11.ts`, three of `section-11.2.ts` (its exported `SPECS_ONLY_CONFIG` the record itself, staged by import in the §11.3, §11.4, and P-12 bodies), five of `section-11.3.ts` (wrappers of other modules' plain constants), eight of `section-11.6.ts` (T11.6-4's broken configuration an `unparseable` record), three of `section-12.0-i.ts`, six of `section-12.0-ii.ts` (its two byte-identical inline unknown-key configurations one record), and one of `section-12.0-iii.ts`, and forty-two of Task 16l — eight of `section-12.1-12.2.ts` (`markdownConfig(true)` and `markdownConfig(false)` one module-level record each, staged at every site), two each of `section-12.3-12.5.ts` and `section-12.6.ts` (T12.6-2's malformed `--config` target an `unparseable` record), nine of `section-12.7.ts` (T12.7-3's malformed configuration one `unparseable` record staged at `cfg/xspec.config.ts` and `a/xspec.config.ts`), one of `section-13.1-13.2.ts`, two of `section-13.3.ts`, thirteen of `section-13.4.ts` (T13.4-11's `OrphanArm` typing its configuration fields `StagedTs`), three of `write-refusal-staging.ts`, and two of `section-14-ii.ts` (T14-9's precedence fixture, staged through `write-refusal-staging.ts`'s `prepareRefusalWorkspace`), and forty of Task 16m — thirty-one of `section-14.ts` (its `SPECS_ONLY_CONFIG` and `SPEC_AND_CODE_CONFIG` the records themselves, staged at every site; `BOGUS_KEY_CONFIG` one record for the byte-identical inline configurations of T14-3's configuration-error arm and `BOGUS_KEY_DECL`; `markdownConfig(true)`'s record; the sweep's 14.22 configuration and code-source entries wrapped in place, `codeArm` taking a record; T14-5's `.mts` arm's configuration and `src/view.mts`, an `unparseable` record wrapping `T14_5_UNIT_SOURCE` — the `.tsx` arm, the body's first workspace, stages the constant plain, and one record carries one grammar and one declaration; T14-11's code sources, (m)'s and (v)'s `unparseable`, (u)'s one record per table row, `RangeRuleCase.config` typed `StagedTs` and its `ts` field gone, and `reassertedCase` throwing at load unless each (w) staging's failing file is a record declared unparseable), one of `section-14-ii.ts` (T14-10 (g)'s invalid configuration), and eight of `section-14-iii.ts` (its two configurations the records themselves, T14-12's code-form arms (l)–(o) one record per row, and the negative code arms (p) and (q) `unparseable` records, `unparseableDecl` declaring nothing beside them), and eight of Task 16n — one configuration each of `section-16-p1.ts`, `section-16-p2-p3.ts`, `section-16-p4.ts`, `section-16-p9.ts`, and `section-16-p10.ts` (each module's constant the record itself; P-2/P-3's `stagedSources` hands the runner the draw's composed files only), `section-16-p5-p6.ts`'s `P5_P6_SPECS_ONLY_CONFIG` (a wrapper of `section-5.6.ts`'s plain `SPECS_ONLY_CONFIG`, staged by `drawWorkspace`), and `section-16-p8.ts`'s two, the fuzz base configuration and `src/app.ts` in `FUZZ_BASE_RECORDS` beside its two MDX records (P-12 needs none: its trials stage `section-11.2.ts`'s record by import); 502 at Task 16n, 494 at Task 16m, 454 at Task 16l, 412 at Task 16k, 384 at Task 16j, 361 at Task 16i, 298 at Task 16h, 249 at Task 16g, 202 at Task 16f, 181 at Task 16e, 159 at Task 16d, 88 at Task 16c, 38 at Task 16b, 21 at Task 16; the self-test alone runs 1350 tests from Task 16o, 1347 at Task 16n, 1339 at Task 16m, 1299 at Task 16l, 1257 at Task 16k, 1229 at Task 16j, 1206 at Task 16i, 1143 at Task 16h, ~8 s). Red check: break one record's bytes on a scratch-backed copy (T1.5-2's `CODE_ARM_SOURCE` in `section-1.5.ts`, `export const ok` to `export const`), run the self-test alone with `-t '<record name>'`, read `HarnessStagingError: ts-derivability staging of <record name>: declared well-formed, but it is not well-formed TypeScript …`, restore with `cp` and verify with `cmp`. Conversion rule: every code source or configuration file (any path the TypeScript check judges — a `TS_DEFAULT_SUFFIXES` name, or one declared in `ts.wellFormed` or `ts.unparseable`) a body stages after its first product invocation — a `file()` call after an invocation in that workspace or anywhere in the running body, or an initial `files` entry of a workspace the body creates after its first invocation — is a `StagedTs` created at module level from the SAME expression (moved, never re-spelled; staged bytes identical; verify escape-spelled literals byte-wise), carrying the declaration in effect for that write (the former `ts` option, else the workspace declaration's entry — then dropped from the workspace declaration, the record carrying it — else well-formed) and the grammar its path selects. A module constant serving pre- and post-invocation stagings becomes the record itself (named after the tests staging it after an invocation; other uses pass the record too, a string use takes `.source`); a template function called with body-local state is enumerated, in order, into a module-level record table, and one called with fixed arguments gives one module-level record per distinct call (`section-7-discovery.ts`'s `invalidSourceConfig`, since Task 16l `section-12.1-12.2.ts`'s `markdownConfig` and `section-13.1-13.2.ts`'s `emissionConfig`); an arm or variant table types its field `StagedTs`. An imported constant whose owner's bodies stage it only before an invocation stays plain in its owner, and the consuming module wraps it in a record of its own, passed at its post-invocation sites only (`section-9.3.ts`'s `T9_3_3_ARM_2_CONFIG`; since Task 16k `section-11.3.ts`'s `T11_3_1_*`, `section-12.0-ii.ts`'s, and `section-12.0-iii.ts`'s wrappers); an inline literal is hoisted into a module-level record, byte-identical inline literals of one module sharing one. Not records: `unchecked` stagings (P-8's and P-11's mutations, T13.4-2's damaged derived files, T13.4-4's noise, and a tampered generated module — product-written bytes plus a harness suffix, staged by `file()` with `{ ts: "unchecked" }`, since judging them would make a product's malformed module a harness error: T7.5-6's `hi/H.xspec.ts` since Task 16i, and `section-12.1-12.2.ts`'s `${original}// tampered` stagings — T12.2-2's `specs/A.xspec.ts` and T12.2-4 (a)'s `hi/H.xspec.ts` — since Task 16l), `edit()` of product-written bytes, `copyFrom()` of product output, and draw-composed files (P-7's configurations; P-13's configuration, `c0/U.ts`, and `c1/V.ts`), declared per draw since FIX_PLAN Task 16o (`ts.perDraw`; the runner's own per-draw TypeScript check is FIX_PLAN Task 64's). Finding a module's remaining plain post-invocation TypeScript stagings (the detector, temporary; since FIX_PLAN Task 16o the guard's TypeScript arm refuses each such staging at the first run reaching it, so the detector serves only to list every site in one pass): save `test/helpers/workspace.ts`, add a module-level function that appends one JSON line (path key, declaration, site, the workspace mark, `productInvokedInBody()`, the first stack frames under `test/suite/` or `test/helpers/`) to the file `$XSPEC_TS_PLAIN` names when the declaration is defined and not `unchecked` and either mark is set, and call it from the plain-contents branch of `file()` (declaration `options.ts ?? this.tsDeclarationOf(rel)`, mark `this.invocationMark.invoked`), of `stageInitial()` (`this.tsDeclarationOf(rel)`, mark false — only the per-body mark applies at creation), of `copyFrom()` when `!source.productInvoked`, and from the `StagedMdx` branches when the record carries no `ts`; run the module's suite files under the namespace with the variable set (and `test/self/certification.test.ts`, whose conformer runs reach arms past the built product's diagnosed failures), then restore with `cp` and verify with `cmp`. No line means no plain post-invocation staging was reached; a site behind a diagnosed product failure that no conformer reaches shows only by reading the module. The first survey (an instrument in `checkTs` logging every judged staging with both marks, at 3f43daa, over the whole suite and `certification.test.ts`) found 1902 post-invocation TypeScript stagings, about 600 distinct (path, bytes) pairs, across about 70 modules; certification added arms of T7-6 and T13.4-11 the suite's diagnosed failures hide. After the §1 conversion and E-6 (Task 16 itself): the §1 suite files and the E-6 writer keep their verdicts against the built product (26 of 28 pass; T1.4-1 and T1.4-4 fail diagnosed at their U+2028 arms), and the detector finds no plain post-invocation staging in them. After §2 and §3 (Task 16b): the six suite files keep their verdicts against the built product (36 of 36 pass, ~40 s under the namespace), and the detector finds no plain post-invocation staging in them; run over `certification.test.ts` (~2.2 min) it still logs the bodies of later split tasks and of the property runner — P-1, P-2, P-3, T7-4, T7-6, T13.4-11, T13.5-1, T13.5-4, T13.5-8, T11.2-4, and T11.4-3. After `section-4.ts` (Task 16c): the two §4-preamble suite files (`section-4.test.ts`, `section-4.1-4.2.test.ts`) keep their verdicts against the built product (12 of 12 pass, ~35 s under the namespace), and the detector finds no plain post-invocation staging in them (92 before); `section-4.1-4.2.ts` stages nothing after an invocation (one workspace per body, no `file()`), and no §4 test is certified, so `certification.test.ts` reaches none of them. After §4.3–§4.6 (Task 16d): `section-4.3-4.4.test.ts`, `section-4.5.test.ts`, and `section-4.6.test.ts` keep their verdicts against the built product (17 of 17 pass; ~17 s, ~60 s, and ~11 s under the namespace), and the detector finds no plain post-invocation staging in them (135 before); `stageOffendingStatement`, `stageSameScopeArm`, and `stageCollidingTextCall` in `section-4.5.ts` run at module load, so a broken arm table there fails the registry's load with its harness error. After §5 and §6.1–§6.4 (Task 16e): the nine suite files `section-5.1-5.3.test.ts` through `section-6.4.test.ts` keep their verdicts against the built product (40 of 40 pass; ~190–200 s run one file at a time under the namespace), and the detector finds no plain post-invocation staging in them (65 before); `section-5.6.ts` stages only `.mdx` records after an invocation, and T6.4-7's `copyFrom()` seeding of product output stays plain. Shared constants that later modules' bodies also stage after an invocation are records since Task 16e, so the detector no longer logs those sites: `support.ts`'s `UNKNOWN_KEY_CONFIG` (the configuration-state twins' invalid configuration; T6.4-3, T6.5-5, T11-2, T11-4, and T12.0-5 stage the twins after an invocation, T11.3-3 before any), `section-5.7.ts`'s exported `SPEC_AND_CODE_CONFIG` (T11.3-1), and `section-6.4.ts`'s exported `RENAME_REFUSAL_CONFIG`, `RENAME_USAGE_CONFIG`, and `RENAME_USAGE_ORDERING_FILES`'s `src/app.ts` (T6.6-3); the twins' `missing` workspace still stages the callers' own plain `src/app.ts` maps (T11-2, T11-4; T6.5-5's passes a record since Task 16f). The consumer suite files `section-6.5`, `6.6`, `11`, `11.3`, `12.0-i`, and `14` keep their verdicts with those records (41 of 41 pass, ~190 s together under the namespace). After §6.5–§6.7 (Task 16f): the five suite files `section-6.5.test.ts`, `section-6.5-ii.test.ts`, `section-6.5-iii.test.ts`, `section-6.6.test.ts`, and `section-6.7.test.ts` keep their verdicts against the built product (25 of 25 pass; ~325 s together under the namespace with `--no-file-parallelism`), and the detector finds no plain post-invocation staging in them (183 before); T6.5-1's consumer `src/app.ts` records are made in `fileMoveArm` at module load, one per specifier, and the code sources and configurations only a body's first workspace stages, before any invocation (T6.5-7's, T6.5-8's TS arm's, T6.5-9's, T6.5-18's, and T6.6-4(a)'s), stay plain. Shared constants made records by Task 16f for other modules' bodies: `section-6.5.ts`'s exported `MOVE_REFUSAL_CONFIG` and `MOVE_DERIVED_PATH_CONFIG` and `section-6.5-iii.ts`'s exported `R16_CONFIG` (T14-7's later workspaces), and `section-13.5.ts`'s one configuration (`CORE_DECL`'s and `ISO_TWO_DECL`'s; T6.6-3, T13.5-1, T13.5-4, T13.5-6, and T13.5-8 stage it after an invocation), so the detector logs no site of `section-13.5.ts`'s own (T13.5-7's are `write-refusal-staging.ts`'s); over `certification.test.ts` (27 of 27 pass, ~133 s) it now logs only P-1, P-2, P-3, T7-4, T7-6, T11.2-4, T11.4-3, and T13.4-11. The consumer suite files `section-13.5` and `section-14` keep their verdicts with those records (17 of 17 pass, ~260 s together under the namespace). After `section-7-basics.ts` (Task 16g): `section-7-basics.test.ts` keeps its verdicts against the built product (3 of 3 pass, ~51 s under the namespace), and the detector finds no plain post-invocation staging in it (48 before); `refusedConfigArms` makes the five refused-configuration tables' records at module load, one per row, `expectConfigRefused` takes a record (its declaration the record's), and `SPECS_ONLY_CONFIG` is one record serving T7-1 and T7-3; no T7-1–T7-3 body is certified per fixture, so over `certification.test.ts` (~146 s with the detector) it still logs only P-1, P-2, P-3, T7-4, T7-6, T11.2-4, T11.4-3, and T13.4-11. After `section-7-discovery.ts` and `section-7.1-7.3.ts` (Task 16h): the two suite files keep their verdicts against the built product (5 of 6 pass, T7-6 failing diagnosed at its invalid-source arm; ~31 s together under the namespace with `--no-file-parallelism`), and the detector finds no plain post-invocation staging in them (45 before); `runT74SingleCasingGlobProbe` stages its configuration record on the Windows leg too (E-6), T7-6's `specs/a'b.md` record is made from the string constant its condition-20 window still spells, and `expectConfigRefused` of `section-7.1-7.3.ts` takes a record; over `certification.test.ts` (~141 s with the detector; the CONF-DISC conformer reaches T7-6's arms past the built product's diagnosed failure) it now logs only P-1, P-2, P-3, T11.2-4, T11.4-3, and T13.4-11. After `section-7.4-7.5.ts` (Task 16i): `section-7.4-7.5.test.ts` keeps its verdicts against the built product (8 of 8 pass, ~45 s under the namespace), and the detector finds no plain post-invocation staging in it (106 before); no T7.4 or T7.5 test is certified, so over `certification.test.ts` (~132 s with the detector) it still logs only P-1, P-2, P-3, T11.2-4, T11.4-3, and T13.4-11. After §8–§10 (Task 16j): the ten suite files `section-8.test.ts`, `section-9.test.ts`, `section-9.3.test.ts`, and the seven §10 files keep their verdicts against the built product (54 of 54 pass, ~435 s together under the namespace with `--no-file-parallelism`), and the detector finds no plain post-invocation staging in them (53 before); `section-9.ts` stages no TypeScript after an invocation (each body's one workspace, its code-source edits before the first `build`); `section-5.6.ts`'s exported `SPECS_ONLY_CONFIG` stays a plain string, and a consumer staging it after an invocation wraps it in a record of its own (T9.3-3's arm 2: `T9_3_3_ARM_2_CONFIG` in `section-9.3.ts`); no §8–§10 test is certified, so over `certification.test.ts` (~123 s with the detector) it still logs only P-1, P-2, P-3, T11.2-4, T11.4-3, and T13.4-11. After §11 and §12.0 (Task 16k): the nine suite files `section-11.test.ts`, `section-11.2.test.ts` through `section-11.6.test.ts`, and the three `section-12.0` files keep their verdicts against the built product (44 of 44 pass; ~351 s before and ~332 s after, together under the namespace with `--no-file-parallelism`), and the detector finds no plain post-invocation staging in them (51 before); `section-11.4.ts` needed no edit (its later workspaces stage `section-11.2.ts`'s record) and `section-11.5.ts` stages no TypeScript after an invocation; P-12's trials stage that record by import, so `section-16-p12.test.ts` under the detector (passing, ~274 s) logs nothing; over `certification.test.ts` (~133 s with the detector) it now logs only P-1, P-2, P-3, and T13.4-11. After §12.1–§13 (Task 16l): the nine suite files `section-12.1-12.2.test.ts`, `section-12.3-12.5.test.ts`, `section-12.6.test.ts`, `section-12.7.test.ts`, `section-13.1-13.2.test.ts`, `section-13.3.test.ts`, `section-13.4.test.ts`, `section-13.5.test.ts`, and `section-14-ii.test.ts` keep their verdicts against the built product (40 of 41 pass, T13.4-11 failing diagnosed at arm (a); ~334 s before and ~314 s after, together under the namespace with `--no-file-parallelism`), and the detector finds no plain post-invocation staging in them but T14-10's invalid configuration in `section-14-ii.ts` (Task 16m's; 102 before); T14-9's precedence fixture in `section-14-ii.ts` is staged through `write-refusal-staging.ts`'s `prepareRefusalWorkspace`, so its two records came with that module's; over `certification.test.ts` (~122 s with the detector; the CONF-ORPHAN conformer reaches T13.4-11's later arms) it now logs only P-1, P-2, and P-3. After §14 (Task 16m): the three suite files `section-14.test.ts`, `section-14-ii.test.ts`, and `section-14-iii.test.ts` keep their verdicts against the built product (12 of 12 pass; ~259 s before and ~252 s after, together under the namespace with `--no-file-parallelism`), and the detector finds no plain post-invocation staging in them (194 before); over `certification.test.ts` (~123 s with the detector; 27 of 27 pass, 154 PASS / 38 FAIL lines) it still logs only P-1, P-2, and P-3, the property runner's fixed files (Task 16n). After the §16 properties' fixed files (Task 16n): the eleven `section-16-*.test.ts` suite files keep their verdicts against the built product (12 of 13 pass, P-1 failing diagnosed at seed 271828183's trial 11, shrunk to a lone U+2028, as before; 351 s before and 363 s after, together under the namespace on 4 workers with the detector and the write log on), and the detector over them logs only the draw-composed files Task 16o declares per draw — P-7's configurations (38) and P-13's configuration, `c0/U.ts`, and `c1/V.ts` (49) — of 422 before (P-1 39, P-2 70, P-3 17, P-4 34, P-5 32, P-6 11, P-8 70, P-9 8, P-10 8, P-11 46, and those 87; P-12 none); over `certification.test.ts` (27 of 27 pass, 154 PASS / 38 FAIL lines, ~134 s with the detector) it logs nothing. A byte-identity check for a conversion: temporarily make the builder's private `write()` append `{rel, length, sha256}` per write to a scratch file, run the module's suite file once with the HEAD module and once with the converted one, and compare the two ordered sequences (Task 16c: 162 writes, equal); over several suite files in one run, log `process.pid` too and compare per process — each test file runs in its own forked worker, and Vitest may order the files differently from one run to the next (Task 16e: 371 writes over the nine §5/§6.1–§6.4 files, run with `--no-file-parallelism`, equal; Task 16f: 739 over the five §6.5–§6.7 files and 655 over `section-13.5` and `section-14`, equal; Task 16g: 106 over `section-7-basics.test.ts`, equal; Task 16h: 162 over `section-7-discovery.test.ts` and `section-7.1-7.3.test.ts`, 99 and 63 per process, equal; Task 16i: 261 over `section-7.4-7.5.test.ts`, equal; Task 16j: 393 over the ten §8–§10 suite files, run with `--no-file-parallelism`, equal per process; Task 16k: 312 over the nine §11 and §12.0 suite files, run with `--no-file-parallelism`, equal per process; Task 16l: 513 over the nine §12.1–§13 suite files, `section-13.5` and `section-14-ii` among them, run with `--no-file-parallelism`, equal per process; Task 16m: 669 over the three §14 suite files, run with `--no-file-parallelism`, equal per process; Task 16n: 1540 over the eleven property suite files, run on 4 workers, 11 processes, equal per process). +- P-12 valid by construction (fifth-plan FIX_PLAN Task 1; TEST-SPEC §16 preamble, S-9; `test/suite/registry/section-16-p12.ts`): `embedArgumentMenu(hasImport, s1Closed)` and `dPropMenu(hasImport, s1Closed)` offer the anchor `"t"`/`'t'` always, `M0.t` with the import, and the `"s1"` forms (the embedding and `d={["t", "s1"]}`) only once `genFileLines` has passed `s1`'s closing tag (`s1` is always the first, top-level section); the twists, the `"zz"` spellings, and `P12_UNPARSEABLE_VECTORS` are gone, `stagedP12Sources` returns every file unmarked, and `runP12Trial` declares `mdx: { perDraw: mdxPathsOf(files) }`. `P12_FORM_VECTORS` holds 44 vectors, pinned `toBe(44)` in `test/self/s9-fixture-well-formedness.test.ts` (1728 tests): the two every-form files (`s1` first, spelling the omitted prop and the opening menus alone inside it; the `"s1"` embeddings right after its closing tag; the later sections from the full menus) and 19 + 23 minimal contexts, where a `"s1"` form follows a closed `<S id="s1">`/`mot.`/`</S>` and the `d` array's bearer is `s2`. Draw facts: the CI set (`drawFixedSeedTrials(genP12Trial, 3)`, 9 trials) has 20 files, 8 imports, one `M0.t`, 4 embeddings and 6 single `d` props (10 occurrences), one depth-2 section, multi-byte prose in 14 files, and 1339 `at` invocations; neither it nor the 75-trial set (`…, 25`) draws a `"s1"` reference or a `d` array, which a 1000-trial sample on 40 other seeds (`1000 + 7919·i`, 25 runs each, passed as `drawFixedSeedTrials`'s third argument) draws 39 times (21 embeddings, 18 arrays) in 38 trials. Validity cross-check recipe (implementation-time, uncommitted): dump the draws and `P12_FORM_VECTORS` as JSON to the scratchpad from a temporary self-test (`writeFileSync(…, JSON.stringify(drawFixedSeedTrials(genP12Trial, 25)))`, deleted afterwards), then a Python script stages each trial in a fresh scratch directory with `SPECS_ONLY_CONFIG` (`test/suite/registry/section-11.2.ts`) as `xspec.config.ts` and runs `node /home/user/xspec/dist/cli/bin.js build --json` there — every trial of both sets, the sample's 38 `"s1"`-spelling trials, and all 44 vectors (a with-import vector staged as `specs/B.mdx` beside an anchor-only `specs/A.mdx`, `<S id="t">\nmot.\n</S>\n`) exit 0 with `{"findings": []}` (~1 min in all); before the change the reviewer's replay found only 3 of the 9 CI trials building. Red check: on a scratch-backed copy, `stagedP12Sources` returning `contents.replace("</S>", "")` fails P-12 in ~4.6 s at trial 1 of 3 of seed 271828183 with the runner's S-9 harness error (`HarnessStagingError: mdx-derivability staging of specs/A.mdx: composed by the generator as well-formed … end-tag-mismatch`) before any product invocation; restore with `cp` and `cmp`. Run facts at the fifth-plan Task 1: P-12 alone against the built product (`unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite test/suite/section-16-p12.test.ts --reporter=verbose`) passes at the fixed seeds in 158 s; self project 22 files, 2954 passed, 0 skipped under the namespace (~91 s); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- P-12 requires every staged file's view entry (fifth-plan FIX_PLAN Task 2; TEST-SPEC §16 P-12, H-8; SPEC 11.4, 11.2; `test/suite/registry/section-16-p12.ts`): right after the bare `view` answer's entries are collected into `viewByPath` (after the unknown-path and duplicate-view checks) and before the two `occurrences` invocations, `runP12Trial` maps `trial.files`, in order, to `[path, content, entry]` triples — a staged file with no entry fails (`fail`, context "P-12 `xspec view`": ``the answer carries no view for "<path>" — a staged spec source, parseable (valid by construction and judged derivable by S-9 before the product ran) and discovered under the configuration's `specs/**/*.mdx` …``) — and the at sweep iterates those triples; the masked-case arm (no entry answered by `{ unavailable: true }` at every offset) and the `UNAVAILABLE` constant are gone, the masked case being T11.5-3's deterministic arm, outside P-12's valid draws. Red check (the stand-in wrapper recipe above): a scratch wrapper whose `drop-view` mode pops the last element of every `view` answer's `views` array and re-serializes the document (exit code kept), driven from a temporary `test/self/zz-p5-task2-standin.test.ts` (`runProductTests(binding, productTestSuite.select(["P-12"]), { concurrency: 1 })`, run alone under the namespace with `--disable-console-intercept`, deleted before committing), fails P-12 as a diagnosed `PropertyFalsifiedError` (a `HarnessAssertionError`: outcome `fail`) in ~7 s at trial 1 of 3 of seed 271828183 — the drawn trial stages `specs/A.mdx`, `specs/B.mdx`, and `specs/C.mdx` (C's view the one dropped), and after 4 accepted shrink steps (4 property executions) the counterexample is `specs/A.mdx` alone holding the anchor (`<S id="t">`, `mot.`, `</S>`, each on its own line), its assertion the new message naming `specs/A.mdx`: the runner reports the shrunk counterexample's assertion, so the named file is the shrunk trial's last, not the drawn trial's. Run facts at the fifth-plan Task 2: P-12 alone against the built product (Task 1's command) passes at the fixed seeds (155 s test time, ~160 s wall); self project 22 files, 2954 passed, 0 skipped under the namespace (~90 s); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines. +- The recorded configuration parse in `.xspec/graph.json` (`inputs.config`; SPEC 13.3; `src/core/config-data.ts`) is pretty-printed with two-space indentation, so a hand edit must match its multi-line form (a list spans one line per element). Since the e8223cd plan's Task 4, a recorded tag or kind list not in its 12.7 set form (tags strictly in byte order, kinds strictly in 5.2's order) does not reconstruct: the store-backed fast path (`query`, `at`) falls back and the full path's compare-and-refresh rewrites the store. Hand check on a scratch workspace: `build`, copy the store, rewrite one recorded `targetTags` into a spelled order with a repeat, run `query nodes --json`, then `cmp` the store against the copy (byte-equal again once refreshed). A coverage session's recorded `targetTags`/`edgeKinds` are likewise read as sets: hand-edit them in `.xspec/reviews/<name>.json` and `review export <name>` reports the set forms, the session not corrupt. +- Hand-probing SPEC 2.4's verbatim literals (Phase 10 FIX_PLAN Task 6; escape-spelled `d`/`text` literals, computed keys, chain segments, and import specifiers): stage the scratch workspace from a small Node script that builds the backslash as `const BS = String.fromCharCode(0x5c)` and composes each spelling as a template literal over it (`` `lo${BS}u0067in` ``), then confirm the six-character spelling reached the file (`grep -c u0067 specs/A.mdx`) — Edit/Write and Bash payloads may decode backslash-u escapes on the way in, comment text included, so product comments are best phrased without a literal escape spelling. T2.4-5, T2.1-2, T4-2, and T14-2 together run in ~1.5 min under the namespace (`section-2.4`, `section-2.1`, `section-4`, `section-14`; section-14 alone ~45 s). +- Hand-probing SPEC 2.3's embedding classification (Phase 10 FIX_PLAN Task 7): stage T2.3-3's shape in a scratch workspace — `specs/A.mdx` holding `<S id="a">Alpha text.</S>`, a blank line, then `<S id="p">` LF, the form, LF `</S>` LF, beside a specs-only configuration, so the container starts at byte 38 — and read `build --json`'s codes and ranges; build an escaped callee's backslash by character code as for Task 6. `section-2.2-2.3`, `section-2.7`, `section-3`, and `section-5.7` together run in ~16 s under the namespace; `section-14`, `section-11.2`, `section-2.4`, `section-11.4`, and `section-16-p12` together in ~3 min (P-12 passes); `section-16-p2-p3` alone in ~2.5 min, P-2 and P-3 failing where the P-2/P-3 generator bullet above records (seed 271828183: trial 6 of 12 on the ESM-block import after a comment, trial 6 of 6 on `{}`). A background run (its command ending `; echo "EXIT $?" >> <log>`) is awaited in-process with `timeout 600 bash -c 'until grep -q "^EXIT" <log>; do sleep 3; done'` — a foreground `sleep` alone is blocked in this sandbox. +- Hand-probing SPEC 2.4's verbatim configuration literals (Phase 10 FIX_PLAN Task 8; globs, group and profile names, keys, and the `"xspec"` specifier in `xspec.config.ts`): write each configuration from a small Node script that composes the backslash by character code, as for Task 6, then read `build --json` and `inventory --json` — the inventory's `configuration` echoes every glob and name exactly as the product read it. `section-7-basics`, `section-7-discovery`, `section-7.1-7.3`, `section-7.4-7.5`, and `section-11.6` together run in ~33 s under the namespace; `section-14`, `section-14-ii`, and `section-12.0-i`/`-ii`/`-iii` together in ~72 s. +- Hand-probing SPEC 14.19's path rules (Phase 10 FIX_PLAN Task 9): stage a file name holding U+FFFD (or other non-ASCII bytes, such as a leading U+FEFF) from its UTF-8 bytes in octal — `printf 'specs/A\357\277\275.mdx'` yields the name — never from a backslash-u spelling the tool layer may decode. Then read `build --json` (the U+FFFD-pathed source bears exactly one `invalid-source-path` with its plain-string path, and nothing is written) and `view --file 'specs/A*.mdx' --json` (exit 1, every identity `{"unavailable":true}`); no argument value can name such a file (12.0), so reach it by glob. `section-1.5` and `section-11.5` together run in ~50 s under the namespace; `section-7-discovery`, `section-11.2`, and `section-11.4` together in ~15 s. A leading U+FEFF in a name (Task 55) is invisible in `--json` output and terminals: check an emitted path's bytes with `od -c` (`357 273 277` opens the string). `section-1.5`, `section-7-discovery`, `section-11.5`, and `section-11.6` together run in ~50 s; `section-11.2`, `section-11.4`, and `section-12.7` together in ~15 s. +- Hand-probing SPEC 14.20's derivability-alone rule for braces and ESM blocks (Phase 10 FIX_PLAN Task 12; `src/core/mdx-acorn.ts`, the acorn remark-mdx is handed, early errors excluded): the parser is importable on its own — a scratch `.mjs` importing `mdxAcorn` and `MDX_ACORN_OPTIONS` from `/home/user/xspec/dist/core/mdx-acorn.js` and calling `mdxAcorn.parseExpressionAt(content, 0, { ...MDX_ACORN_OPTIONS })` (an ESM block's text: `mdxAcorn.parse`) throws exactly where a container's content fails to derive, and returns for a form that fails only an early error (`1 = 2`, `let`, `010`, `export { nope }`); for a whole file's verdict and model use `parseSpecSource` as in the well-formedness bullet above. A differential check against the stock parser also runs from a scratch script when the harness's devDependencies are imported by absolute path (`/home/user/xspec/node_modules/mdast-util-from-markdown/index.js`, `.../micromark-extension-mdxjs/index.js`, `.../mdast-util-mdx/index.js` — only bare specifiers fail to resolve outside the repository): every text the stock parser accepts must stay accepted, and each text only the product accepts must be a stock rejection for an ECMAScript early error — review samples by acorn's message, since acorn spells some early errors and some derivation failures alike ("Unexpected token" for an escape-spelled keyword or a `const` without initializer, "Assigning to rvalue" for `1 = 2` and for `a + b = 1`, "Invalid number" for `010` and for `1e`). A 60,000-draw token-soup run of that check takes ~2 min. Suites for the task under the namespace: `section-2.4` with `section-14-iii` ~14 s; `section-14`, `section-2.1`, `section-2.7`, `section-4`, and `section-11.4` together ~51 s; `section-16-p2-p3` with `section-16-p8` ~145 s (P-8 passes). +- Hand-probing SPEC 2.7's MDX comments (Phase 10 FIX_PLAN Task 13; `classifyExpression` in `src/core/mdx.ts`, `isEmptyExpression` in `src/core/mdx-acorn.ts`): a scratch `.mjs` importing `parseSpecSource` from `/home/user/xspec/dist/core/mdx.js` prints a text's `document.comments` (each container's range, brace through brace) and `document.findings` in milliseconds — stage the container inline (`<S id="s">` LF `Alpha {…} beta.` LF `</S>` LF) and own-line (between blank lines) to cover text and flow position, with the stock verdict beside it from `fromMarkdown` with `mdxjs()` and `mdxFromMarkdown()` imported by absolute path (the Task 12 bullet above). remark-mdx derives content its comment deletions empty (block comments first, then line comments through U+000A or U+000D; JavaScript's `\s` for whitespace, which is exactly ECMAScript's WhiteSpace and LineTerminator) as a whole Program — the empty-expression path of `micromark-util-events-to-acorn` — so an MDX comment is a container whose estree Program has an empty body, and with acorn configured the estree is always attached. Those deletions can empty content that holds a token (`{// /*` LF `x; y /* */` LF `}`), which the stock parser then accepts as statements; since FIX_PLAN Task 56 the product derives such content as one expression or fails (14.20) — see the Task 56 bullet below. T2.7-4's ten comment forms staged in one emitting workspace (`EMIT_TRUE_CONFIG` in `test/suite/registry/section-2.7.ts`, one file per form, each inline and own-line) check `build`, the emitted Markdown bytes, own text, and `view --text --json`'s `comments` ranges in ~2 s. +- Hand-probing SPEC 14's 14.20 offsets (Phase 10 FIX_PLAN Task 14; `src/core/mdx-syntax-failure.ts` over `js-syntax-failure.ts`, `ts-syntax-failure.ts`, and `viable-prefix.ts`): a scratch `.mjs` importing `parseSpecSource` from `/home/user/xspec/dist/core/mdx.js` and `analyzeCodeSource` from `dist/core/code-analysis.js` (context stub `{ designate: () => ({ kind: "none" }), markdownDestinations: new Set() }`) prints each unparseable file's one zero-length range in milliseconds. The stagings T14-11's (w) arms re-assert (exported by `section-2.2-2.3.ts`, `section-2.4.ts`, `section-2.7.ts`, and `section-14-iii.ts`, the offsets beside the files) dump to JSON without touching `test/`: a scratch Vitest config outside the repository (`root: "/home/user/xspec"`, `test.dir` and `test.include` naming a scratch `*.test.ts` that imports those registry modules by absolute path and writes each staging's files as base64 with its offset) runs from the repository root with `npx vitest run --config <scratch config>` in ~2 s. T14-11 visits its arms in order and stops at the first failing one (arm (h) until Task 15 lands), so its 14.20 arms ((c), (m), (v), (w)) are checked through those probes; the home tests (T1.6-5, T2.3-3, T2.4-2, T2.7-3, T2.7-4, T11.4-4, T14-12) run in seconds. To probe an edited `src/` while a suite run is using `dist/` (rebuilding `dist/` mid-run corrupts the run), compile to a scratch out-dir (`npx tsc -p src --outDir <scratch>/dist`) beside a symlink `<scratch>/node_modules` to the repository's, so the compiled modules' bare imports resolve. A semantic spot check of the offsets needs no oracle: mutate a representative spec source (sections, a list item and a block quote holding expressions, ESM, JSX) by one to three random character edits, and for each 14.20 at UTF-16 offset o try a fixed set of completions (closing quotes, braces, brackets, `*/`, `</S>` runs, …) on the first o+1 characters through `parseSpecSource` — any completion yielding a `document` proves o early (finding none on the first o characters is only inconclusive); 300 draws take ~2–4 min under `node --max-old-space-size=2048`, and the fuzzed failures' offsets cost ≤ ~40 ms per file. +- Hand-probing SPEC 14.20's "whitespace and comments alone" (Phase 10 FIX_PLAN Task 56; `judgeCommentsAsSpelled` in `src/core/mdx-acorn.ts`): the product departs from the stock parser by design wherever the comment deletions and the grammar's lexing disagree — a `/*` inside a line comment reaching a later `*/`, or a line comment the grammar ends at U+2028/U+2029 before an LF or CR — so the Task 12 stock differential admits those texts as changed verdicts — attribute contents included since FIX_PLAN Task 57: the stock parser refuses an attribute value's or spread attribute's content its deletions empty before calling acorn (`unexpected-empty-expression`, aborting the parse), and the product undoes that refusal where the content holds a token or fails to lex, by blanking the content's leading comments to U+00A0 and parsing again (`parseAsJudged` in `src/core/mdx-syntax-failure.ts`, which `parseMdx` and every 14.20 analysis parse go through; `restoreRespelledContent` in `src/core/mdx.ts` restores the collected `value`). Task 57's attribute differential (`d` values staged plain, in a block quote, and in a list item, a spread attribute, a text-position `<b x={…} />`; 3,000 draws, 15,000 files) takes ~90 s — the probes behind each undone refusal are full re-parses — and a sample printed with `JSON.stringify` shows U+2028/U+2029 raw, looking like a space, so replace them with visible markers before judging a changed verdict. An old-versus-new differential isolates a change's effect exactly: `git archive <old commit> src tsconfig.base.json package.json | tar -x -C <scratch>/old`, a symlink `<scratch>/old/node_modules` to the repository's, and `npx tsc -p <scratch>/old/src --outDir <scratch>/old/dist` (seconds, like `npm run build`; the archived `package.json` makes the compiled `.js` ESM); a scratch `.mjs` then imports `parseSpecSource` from both `dist/core/mdx.js` trees and compares verdicts (the 14.20 offset, or findings, comments, and embeddings) over token-soup contents staged inline, own-line, and as a `d` value — 3,000 draws (9,000 files) in ~20 s. Spell U+2028 and U+2029 in such scripts from character codes (`String.fromCharCode(0x2028)`): an escape spelled in a tool payload may arrive decoded, a raw line terminator that breaks a regex literal. In gnostic mode (acorn configured) the stock expression factory counts no `{` and tries every `}`, and a construct attempt that reaches the end of the file throws its last failure: a paragraph line such as `{a: 1, b: 2} /* */` below an open text expression is tried as a flow expression and fails at its `:` — the stock verdict, with or without Task 56. +- Hand-probing SPEC 14.20's lazy-line failures (Phase 10 FIX_PLAN Task 58; `lazyLineOffset` in `src/core/mdx-syntax-failure.ts`): the stock grammar throws `unexpected-lazy` (source `micromark-extension-mdx-expression`) at a line that, inside a flow expression's or a flow tag's attribute or spread braces, fails to continue its block containers — without trying any brace beyond it; in a block quote a file ending with a line ending ends with such a line, empty. The product measures the content through the line before it as `openContainerOffset` measures an open container (`measuredThroughLine`), so a block-quoted file's offset no longer depends on its final line ending; the finding's message stays the grammar's lazy-line text. Probe every shape with and without the final LF. The Task 58 differential (the Task 56 bullet's old-versus-new recipe) stages token-soup contents as a block-quoted `d` value, flow expression, and spread, a block-quoted `d` value whose continuation lines lack the `> ` (lazy lines), and list-item, plain, and `> - `-nested `d` values, each with and without the final LF — 300 draws, 4,200 files, ~16 s; every changed verdict had an old lazy failure. To hand-compute a staging's SPEC offset, complete the prefix you believe viable and parse it with the stock parser (`unified().use(remarkParse).use(remarkMdx)` imported from the repository's `node_modules` by absolute path), or with the product's own judgement (the Task 59 bullet below). +- Hand-probing SPEC 14.20 offsets inside nested containers (Phase 10 FIX_PLAN Task 59; `contentLinePrefix`, `collectedPastLine`, and `continuationsOf` in `src/core/mdx-syntax-failure.ts`): a container's content inside a list item in a block quote, or a block quote in a list item, continues only past the whole nested prefix (`> `, ` > `), which the product now takes from the content's own lines, list markers blanked. The product's well-formedness judgement, without the offset analysis, is a scratch module importing `parseAsJudged` from `/home/user/xspec/dist/core/mdx-syntax-failure.js` and `mdxAcorn`/`MDX_ACORN_OPTIONS` from `dist/core/mdx-acorn.js` and calling `parseAsJudged(text, (t) => unified().use(remarkParse).use(remarkMdx, { acorn: mdxAcorn, acornOptions: MDX_ACORN_OPTIONS }).parse(t))` (throws when the text is not well-formed; ~1 ms per small file) — an exact offset check for a reported 14.20 at o is that some completion of the first o characters is well-formed and none of the first o+1 is found. Grammar facts that decide such completions: with indented code disabled, a block quote marker may follow any number of spaces (` > x` continues `> - ` content), so a line of spaces alone stays viable; a line may spell a nested prefix otherwise than the content does (`> > ` continues `>> - `); a blank line without `>` ends a block quote, even inside a list item. The Task 59 differential (the Task 56 bullet's old-versus-new recipe) extends the Task 58 stagings with `- > `, `> > - `, `1. > `, and nested flow-expression forms and a vocabulary token that breaks a content line into a random truncation of its prefix (partial and lazy lines) — 1,000 draws, 24,000 files, ~80 s; a mutation fuzz of a spec source holding such nestings (1–3 random edits) runs ~13 ms per file. Pairing offsets after such a partial line are located since FIX_PLAN Task 60 (next bullet); a pathological 200-space line after a 2,000-line nested construct costs ~17 s, bounded by `PROBE_REACH`. +- Hand-probing SPEC 14.20 tag-pairing offsets (Phase 10 FIX_PLAN Task 60; `constructEndOffset`, `prefixRests`, `completion`, `leftOpen`, and `hiddenFailure` in `src/core/mdx-syntax-failure.ts`): an element left open when its container ends is located by probing each prefix past the container's end with the element's closer — directly, after `x`, and, on a line that so far spells container syntax alone, after the rests of its container prefix. A probe failing by a construct's class (`completion` returning `"class"`: the tokenizer met the closer inside a tag or expression the prefix leaves open) proves nothing about pairing — the grammar pairs tags only after tokenizing — so such a probe counts only where the line's content begins inside the container — a prefix ending inside a JSX tag is judged since FIX_PLAN Task 61 with the tag finished (next bullet). The practical oracle for a reported offset o is a completion search with the product's well-formedness judgement (the Task 59 bullet): some completion of the first o characters — a rest of the container prefix, a finish (`}`, `"`, `x/>`, `x/> y`, `S>`), then `</S>` after zero or more prefixed line breaks — derives, and none of the first o+1 is found; the search misses finishes such as a closing tag's name, so an inconclusive verdict needs a hand check (~0.5 s per verdict). Grammar facts that decide such completions: a flow tag line interrupts a paragraph (`- <S id="s">` LF ` - x` LF ` </S>` derives); a closing tag in phrasing content never closes a flow element (`- <S id="s">` LF ` x` LF `x</S>` does not); a text element's closer may end a lazy line (`- a <S id="s">b` LF `c</S>` derives); a trailing `-`, `*`, `+`, or `1.` becomes a list marker once a space follows, so more indentation keeps a line in its containers only after spaces, tabs, or `>`. The Task 60 differential (the Task 56 bullet's old-versus-new recipe) opens the element after `- `, `> `, `> - `, `- > `, `> > - `, `1. > `, `>> `, `- - `, `>`, `- `, `> 1. `, ` - `, or `- a ` (a text element), adds up to two content lines, then a random truncation of the container prefix and token soup (tags, braces, markers, further truncated lines), with and without a final LF: 100 draws, 2,600 files, ~22 s. Costs: rests skip suffixes beginning inside a whitespace run, the scan skips whitespace after a line whose bare closer derives, and `hiddenFailure` now scans past its cut, bounded by `PROBE_REACH` — a 203-space line after 2,000 flow expressions in a list item costs ~1.5 s. +- Hand-probing SPEC 14.20 offsets at a tag typed after an element's construct ended (Phase 10 FIX_PLAN Task 61; `tagEndingAt`, `tagFinishes`, `closerRest`, `absorberFinishes`, and `finishedTagCompletes` in `src/core/mdx-syntax-failure.ts`): past a line's container syntax, `constructEndOffset` judges a prefix ending inside a JSX tag (its own parse failing in `micromark-extension-mdx-jsx` at its end) with the tag finished by the tokenizer's state — a quote, `""`, or a name character, then `/>` or `>`; otherwise `>`, `/>`, `x>`, and, right after `<` or inside a closing tag's name, the rest of the element's closer or of the one the grammar's mismatch names (`expected corresponding closing tag for <N>`) — then `x` and the closer, or the closer, counting a derivation or a pairing failure at or after the prefix's end but never a closing tag's attribute or self-closing slash (`unexpected-attribute`, `unexpected-self-closing-slash`, which mdast-util-mdx-jsx places at or after it). Grammar facts: whitespace may follow `</` but not precede its `/` (`</ S>` derives, `< /S>` does not); an earlier code span, link resource (`[a](</b>)`, `[a](x</b)`, `[a](u "</b")`), or definition (`[a]: </b>` after a block start) can still make a `<` no tag, so `absorberFinishes` (backtick runs as long as the block's, `>)`, `)`, `")`, `')`, `))`, a definition's `>` or nothing, then the closer, also on the next line) are tried before a prefix is judged non-viable — without them the differential below met offsets placed early. The Task 59 bullet's oracle misses closing-tag finishes (`a>`, `S> y`): add a bare finish (no appended `</S>`) and hand-check what stays inconclusive. The Task 61 differential (the Task 60 recipe's forms with a tag-heavy vocabulary: `<`, `</`, `</S`, `</b`, `<b`, `<b x="`, `<b x=`, `</ `, `<a></`, `<>`, `</>`, `<a.`, `/>`; a second run adds `*`, `` ` ``, `_`, `**`, `[a`, `](u)`, `*<b>`, `*</`) moved 164 of 2,600 and 328 of 5,200 offsets, every one earlier and checked; a 600-draw mutation fuzz moved 3, all earlier and right. Costs: a probe inside a tag takes about three parses (the bare closer, the prefix's own, a finish), so a 240-character tag typed on a lazy or continuation line after a 2,000-line construct costs ~10–13 s (~2.5–6.6 s before), bounded by `PROBE_REACH`. +- Hand-probing SPEC 14.20 offsets at a line that leaves a flow-position container's block container (Phase 10 FIX_PLAN Task 62; `lineLeavingOffset` and `collectedPastLine` in `src/core/mdx-syntax-failure.ts`): micromark ends a flow expression's, flow tag attribute's, or spread's content at the start of a line that begins a new list item or block quote, or a sibling of the list item holding it (`closeFlow` in `micromark/lib/initialize/document.js`), and throws the end of the file placed there — before that line's container syntax, which may still continue the content (a lone `>` is a blank line of a list item in a block quote; spaces may precede a block quote marker) — so the product collects the content through the line before and measures it as an open container's (`measuredThroughLine`), probing the leaving line character by character. An attribute value's whitespace-only content is refused before acorn sees it, so `collectedPastLine` retries its `}` probe after an `x` on the further line. The Task 62 differential (the Task 56 bullet's old-versus-new recipe): the Task 61 differential's 13 container forms, a flow expression, `d={`, `{...`, or `{` opened in each, up to two lines of JS soup, then a line spelling a random truncation of the container prefix and a leaving token (`- `, `-`, `* `, `+ `, `1. `, `1.`, `1) `, `> `, `>`, `>>`, `> >`, `#`, `- - `, tab forms) and soup, a random closing line, a quarter of the files CRLF — 300 draws, 7,800 files, ~2 min; it moved 2,267 offsets, every one later. Its oracle checks (the Task 59 bullet's completion search) need spread finishes (`...x}>`) and closer-then-text finishes (each `}`-ending finish followed by ` b`, as in `x} b</S>`): the grammar tries a brace line below a paragraph line as a flow expression first, and such an attempt that meets the end of the file or a lazy line throws, giving way to the paragraph reading only when text follows its closing brace; what stays inconclusive yields to a brute-force search of up to two closing tokens (`x`, `)`, `]`, `}`, `*/`, a backtick, quotes, `...x`, a prefixed line break) before the container's finish (~1–3 s per verdict). The earlier differentials draw with `seed % n` of an LCG modulo 2^31, badly skewed for small n (a `rnd(4)` choice gave one value 994 times in 1,000 draws), so they barely stage some of their forms: draw from high bits (mulberry32) in new ones. Costs: the leaving line is probed as the lazy-line path probes — a 200-space line then `- x` after a 2,000-line flow expression in `- > ` costs ~25 s (its lazy-line twin ~23 s), bounded by `PROBE_REACH`; P-8 alone ~58 s. +- Hand-probing SPEC 14.20 offsets past a brace that closes a container's content on a line that then fails (Phase 10 FIX_PLAN Task 63; `closesAt`, `pastClosingBrace`, `goesOn`, `goesOnPrefixed`, and `contentStart`'s `LINE_SYNTAX` check in `src/core/mdx-syntax-failure.ts`): the stock grammar closes a flow expression at the first `}` whose content derives, and anything after it on its line but whitespace, flow tags, and a flow expression after a tag (never one right after an expression: `}<b/>{x}{x}` fails at its second `{`) makes the flow construct give way (`nok`); the paragraph the grammar reads instead holds no content spanning a blank line, so its text expression fails at the paragraph's end (`unexpected-eof`, or `acorn` from a brace it tried), placed before the brace — where `openContainerOffset` starts. `measuredThroughLine` now recognizes such a brace where `continues` fails at a `}` (the content before it collected at it, none past it: two parses) and scans past it, judging each prefix by the grammar: a failure placed at or before the brace is the reading that gave way, a pairing failure is left to `hiddenFailure`, and a class failure past the brace counts by `classOffset` (nested scans bounded by `PAST_BRACE_DEPTH`) — so a tag after the brace (`} <b/>c`), an expression after a tag (`}<b/>{x}c`), and a flow tag's attribute value or spread closed so (`d={` LF LF `x}>c`) are passed to the text after them. A tag or expression past the brace may span lines: the grammar places the end of a file ending in a line's container syntax at that line's start (`- ` item: `}<b` LF ` ` fails there; `> - `: `}<b` LF `>` and `}<b` LF `> ` alike), so such a prefix is judged after the rest of the brace line's container prefix, then `x` or nothing (`goesOnPrefixed`). Before, `contentStart` matched collected content ending with a line terminator to any head whose last line begins with that `}` (an empty last content line is a suffix of every line), so `continues` accepted every character past the brace and the product located the failure at the line's end; where the content's last line held more (`a,` LF `]}c`), `continues` stopped at the brace, one early. The Task 59 bullet's oracle (with the Task 62 bullet's finishes) checks these offsets directly — the completion at o is `</S>` or nothing — but each verdict costs ~1–4 s (the search at o+1 is exhaustive), so check large dumps in parallel parts (three `node` processes over `i % 3`). The Task 63 battery (`{` alone or below `<S id="s">` in `""`, `- `, `> `, `> - `, `- > `, `1. `, and `>> `, contents spanning an empty or whitespace-only line, ten brace lines — `}c}`, `} c`, `x}c`, `} <b/>c`, `}{x}`, `}<b/>{x}c`, `}</S>c`, `}c`, `b}` TAB `c`, `}<b` LF `/>c` — with and without a final LF, LF and CRLF: 2,128 files, ~45 s) moved 988 offsets (702 earlier, 286 later), and all 2,128 check ok; the Task 59 differential (1,000 draws, 24,000 files) moved 14, all earlier and ok; the Task 62 differential (300 draws, 7,800 files) moved 30 (28 earlier): 24 ok and 6 in its form `- a ` — Task 64's shapes, late before and after (FIX_PLAN Task 64); a mutation fuzz (1–3 edits of a source holding such expressions at the top level, in `> - `, and in `- > `; 600 draws, ~17 s) moved 12, 11 checked ok and one a Task 64 shape (a brace line below a paragraph holding an open `<S id>`), late before and after. Content running more than 256 lines past the grammar's place was located at most that far on until FIX_PLAN Task 65 (below). Costs: a 200-space run after the brace of a 250-line content ~0.7 s at the top level, ~2.2 s in `- > `; 50 tags after it ~0.3 s and ~1.8 s. +- Hand-probing SPEC 14.20 offsets at a brace line below a paragraph line holding an open text element (Phase 10 FIX_PLAN Task 64; `textReadingBound` in `src/core/mdx-syntax-failure.ts`, applied to every result of `measuredThroughLine` and to `openContainerOffset`'s measure of content running to the file's end): the stock grammar reads a line whose content begins with `{` as a flow expression first — it interrupts the paragraph above, and its content may span blank lines — but below an open text element that reading never derives (the paragraph ends with the element open); the text reading (text after the closing brace makes the flow attempt give way) holds no line of container syntax and whitespace alone (`LINE_SYNTAX`: blank, or a block quote's start, which interrupts a paragraph). So where the brace's content is still open at the first such line past the brace line (`collectedAtEnd` of the prefix through the line before it has that opening), the offset is at most that line's terminator; the paragraph test is one parse of the prefix through the line above — the stock ``Expected a closing tag for <N> (…) before the end of `paragraph` ``, its place ending at that line's end (trailing whitespace included). Tag-pairing probes (`constructEndOffset`'s `completion`) see the bound through `classOffset`, so the pairing failure the flow reading leaves (`a <S id="s">` LF `{` LF LF `x}`, 43 before, 40 now) is located at the blank line too. A paragraph holding a backtick or a bracket before the brace line is left alone: a code span, link title, or definition label or title closing past the brace can hide the brace and the element in the text reading (`` `a <S id="s"> `` LF `` {` `` LF LF `` `} `` and `[a <S id="s">` LF `` {` `` LF `]: u` LF LF `` `} `` are viable to their ends: ` b` appended derives). Bounded since: a construct other than a brace line begun on the line below — a tag holding the brace (`<b x={`), a brace after a tag — since FIX_PLAN Task 66, and a paragraph line holding an open text expression (`a {` LF `{`) since FIX_PLAN Task 67 (both below). The Task 64 battery (`a <S id="s">`, with and without two trailing spaces, at the top level, in `- `, `> `, `> - `, `- > `, `1. `, `>> `, and a lazy brace line under `- ` and `> `; nine content shapes between the brace line and its closing — an empty line, a lone prefix, a whitespace line, a line before and after a blank one, a failing `)`, a content line and no blank one, none, a template literal spanning a blank line; nine closings — `x} b</S>`, `}c`, `x}`, `x}` LF `</S>`, none, a leaving ` -`, `- x}`, a tag after the brace, a tag spanning lines; with and without a final LF, LF and CRLF: 5,832 files, ~5 min) moved 2,344 offsets, all earlier, and all 2,344 check ok by the Task 59 bullet's oracle (with the Task 62 bullet's finishes; ~22 min in four parallel parts over `i % 4`); the Task 63 battery and the Task 59 differential moved none; the Task 62 differential moved 86, all in its `- a ` form, all earlier and ok; a mutation fuzz of a source holding such paragraphs (`Intro <b>x` LF `{[`…, `- a <S id="q">x` LF ` {A.c`…, in `> ` and `> - ` too; 600 draws, ~22 s) moved 18 and the Task 63 fuzz 2, all earlier: their files hold several open elements, which the oracle's single `</S>` cannot close, so check them by closing every element the grammar names (append `</N>` inline for ``before the end of `paragraph` ``, on its own line otherwise) — each new offset is a blank line's terminator with a derivable completion. Costs: none measurable where the bound does not apply; where it does, cheaper than before (a 2,000-line content ~0.6 s, ~1.1 s before); P-8 ~63 s in a full run. +- Hand-probing SPEC 14.20 offsets inside a container's content running far past the grammar's place (Phase 10 FIX_PLAN Task 65; `lastCollectedLine`, `lineEndsFrom`, and `contentLinePrefix` in `src/core/mdx-syntax-failure.ts`): `openContainerOffset` finds the content's last line — the last past which a `}` on a further line still falls in the container (`collectedPastLine`) — probing the first `LINEAR_LINES` (8) lines past the place one by one, then galloping by doubling steps and bisecting, where a 256-line walk had stopped early and measured the next content line as the content's end; `contentLinePrefix` looks back past any run of blank content lines (a trailing run of over 256 `>` lines in `> - ` had collected nothing). A probe past the content's end tries every prefix, twice each (~10 parses), one within it one or two, hence the lead-in: a pure search ran the Task 63 battery ~9% slower, the lead-in at parity. The Task 65 staging (`<S id="s">` LF `{[` LF, n lines alternating `a,` and blank, `]}c` LF `</S>`, at the top level and in `- > ` and `> - `, with and without a final LF) is located at the `c` for n from 250 to 5,000, checked ok by the Task 59 bullet's oracle at n = 1,000, 2,001, and 5,000 (the 5,000 verdict ~18 min, its search past the `c` exhaustive); it costs ~0.1 s at n = 300, ~0.9 s at 2,000, and ~5 s at 5,000 at the top level, ~0.4 s, ~4 s, and ~17 s nested (every probe parses the whole prefix). A long-content battery — the Task 63 battery's seven container forms; `{[` below `<S id="s">`, or `d={[` or `{...[` in it; n content lines alternating `a,` and blank, one inner blank run, dense, or a trailing blank run; closings `]}c` (`]}>c` in a tag), `]}` closed, a failing `)`, a leaving `- x`, none, a lazy `x`; with and without a final LF; n = 300: 1,008 files, ~2.2 min in four parts — moved 129 offsets, all later: every text-after closing over non-dense content, and three content-to-end files in `> - ` ending in the trailing run, now the file's length. All check ok: 127 by the oracle (~10 min in four parts), the attribute and spread ones by hand, since the oracle's finishes cannot close a `[` inside a tag (the file, LF `> ]}>` LF `> </S>` appended, derives). Its CRLF slice (192 files) moved 52 and an n = 700 slice (72) 18, all later and ok. The Task 59, 62, 63, and 64 differentials and batteries and the Task 63 and 64 mutation fuzzes moved nothing, at unchanged cost; P-8 ~65 s. +- Hand-probing SPEC 14.20 offsets at a construct begun below a paragraph line holding an open text element (Phase 10 FIX_PLAN Task 66; `openParagraphAbove`, `walkToOpenParagraph`, `endsInsideConstruct`, `leavesParagraphOpen`, and `textReadingBound` in `src/core/mdx-syntax-failure.ts`): Task 64's bound now applies wherever the construct holding the container's brace begins on the line right below such a paragraph line — the brace anywhere on that line (past container syntax, after a tag — `<b x={`, `<b {...`, `<b/>{` — or after text), or on a later line inside a tag or expression begun there (`<b` LF `x={`, `<b y={1 +` LF `2} x={`). `openParagraphAbove` walks up from the brace's line over each line whose prefix the grammar ends inside a JSX tag or an expression (`unexpected-eof` placed at the prefix's end), at most `CONSTRUCT_LINES` (64), stops without a bound at a blank line or the file's first line, and applies Task 64's paragraph test and guard (`leavesParagraphOpen`) to the line above them; every step parses a prefix and the probes ask of the same lines again and again, so its findings are memoized per analysis by the text before the brace's line (`paragraphsAbove`, set and cleared by `mdxSyntaxFailureOffset`). It is sound because a paragraph past its line cannot end inside a construct unfinished there, and micromark-extension-mdx-jsx's tag factory gives way only on `<` followed by a space or a line ending (`startAfter` in `lib/factory-tag.js`), crashing on every other error, flow and text alike, so both readings tokenize the same tag; the flow tag construct (`lib/jsx-flow.js`) gives way only on text after its tags and expressions on their last line. The content-open check retries its `}` after an `x`: `<b x={}` is refused as an empty attribute before acorn sees it, so `collectedAtEnd` of the brace's line alone finds nothing. The Task 59 bullet's oracle needs tag finishes followed by text for these: add `x} />c`, `x}/>c`, `x}>c</b>`, `...x} />c`, `...x}>c</b>`, and `x} />c</b>` to its `JS0`. The hand probes (the plan's four stagings and four neighbours — `<b/>{`, `<b y="1" x={`, `x}>c</S>`, a file ending in the blank line — in `""`, `- `, `> `, `> - `, and `- > `, the blank line empty or the prefix's own syntax, with and without a final LF, LF and CRLF: 256 files) moved 150, all earlier, and all 256 check ok. The Task 66 battery (below `a <S id="s">` in the Task 64 battery's nine container forms, and below `a <S id="s">` with two trailing spaces in `""` and `> `; eight constructs — `<b x={`, `<b {...`, `<b` LF `x={`, `<b/>{`, `<b y="1" x={`, `<b` LF `y="1"` LF `{...`, `<b y={1 +` LF `2} x={`, and a text-first control `c <b x={`; six content shapes — an empty line, a lone prefix, a whitespace line, a line before a blank one, a failing `)` before a blank one, none; five closings — `x} />c</S>`, `x}/>`, `x} />` LF `</S>`, none, a leaving `- x} />`; with and without a final LF, LF and CRLF: 8,640 plus 1,920 files, ~4 min per 4,320 in parallel parts) moved 3,904 offsets, all earlier — never in the control, a content failing before the blank line, or one without it: 3,456 check ok by the oracle (~30 min in four parts) and 448 are inconclusive, every one the spread-line construct, whose SPEC offset is the `{` itself, earlier still (the grammar tries a paragraph line beginning with `{` as a flow expression, and spread content never derives as one: residual gap 84, below); a quarter of its unchanged files in the brace constructs (467 of 1,868, block quote forms whose blank line is empty and files ending in the content) check ok but for 89 in the spread-line construct, inconclusive alike. The Task 63 battery (2,128 files), the Task 64 battery (5,832), the Task 59 differential (24,000), the Task 62 differential (7,800), the Task 65 long battery (1,008), and the Task 63 and 64 mutation fuzzes (600 draws each) moved nothing, at unchanged cost. Costs: the Task 66 battery runs at unchanged cost once memoized (706 s before, 689 s after, in two parts beside other runs; ~5% slower unmemoized); a tag of n attribute lines below the paragraph ~0.2 s at n = 10, ~3.2 s at n = 2,000 (~1.7 s before), where the bounded walk gives up and the probes' offset stands (residual gap 82, below, records the bounds that make it early); P-8 ~68 s in a full run (~65 s at Task 65). A text tag cut by a paragraph's end outside any brace (`a <b` LF LF `/>c`, `a <b` LF) is located at the end of the paragraph's content, a line ending or more early: residual gap 83, below. +- Hand-probing SPEC 14.20 offsets at a construct begun below a paragraph line holding an open text-level expression (Phase 10 FIX_PLAN Task 67; `holdsOpenTextExpression`, `walkToOpenParagraph`, `textReadingBound`, and `textExpressionBound` in `src/core/mdx-syntax-failure.ts`): below a paragraph line whose prefix ends inside a text expression (`a {`), or an attribute value expression or spread attribute of a text tag (`a <b x={`, `a <S id="s" {...`), a line beginning with `{` or `<` is tried as a flow construct, which interrupts the paragraph and leaves the expression open — it never derives — while in the text reading the line is the expression's content, which cannot span a line of container syntax and whitespace alone; the analysis measures the brace line's flow expression, whose content runs on past such a line and may be viable where the expression's is not (`a {` LF `{` LF `a +`: `a +` continues the flow content, while the expression's `{a +` fails at the `+`). The walk up from the brace's line records the first line whose prefix ends inside an expression a paragraph holds — the prefix through the line's terminator fails with the same `unexpected-eof` placed at the line's end, where a flow construct's is placed past the terminator or reported lazy — and walks on over it as before, so Task 66's element paragraph above is still found. `textExpressionBound` collects the expression's content as the text reading has it: the line below respelled right past its container syntax with U+00A0 (`RESPELLED`: it begins no flow construct, and acorn reads it as whitespace or a character of the string, comment, template, or JSX text it falls in) and a `}` appended at the end of the last line before the first line of container syntax and whitespace alone and before the flow reading's offset (`collectedAtEnd`; `textReadingBound` now looks for that line up to the offset inclusive, and measures the expression without one), the content accepted only where it opens on the paragraph line or above (a code span, link, or definition hiding the expression ends by the paragraph's end, so the probe sees it); the bound is where that content fails within (mapped back past the inserted character), else the line's terminator where the content cannot take it, else the blank line's terminator, and the offset the least of it and the flow reading's; `openContainerOffset` applies the bound to an attribute whose content, whitespace alone, runs to the file's end as well (`a {` LF `<b x={` LF LF: 37 before, 36 now, the blank line's terminator). The Task 59 bullet's oracle needs further finishes for these: add `x}}>c`, `}}>c`, `}]}`, `x}]}`, `}} />`, `x}} />c`, `x} />}`, `x}/>}`, `x}} />`, `...x}}>c`, `x} />]}`, `x}/>]}`, `x} />]} c`, `x} />} />`, `x}/>}/>`, `x} />} />c`, and `x}/>}/>c` to its `JS0` (a longer list slows each exhaustive verdict). The hand probes (the plan's four stagings in `""`, `- `, `> `, `> - `, and `- > `, the expression opened after text (`a {`) or at a paragraph continuation line's start (`a` LF `{`), closed with text after (`x}} b`), closed alone (`x}}`), or left open, the blank line empty or the prefix's own, with and without a final LF, LF and CRLF: 240 files, ~6 min with the oracle on every one) moved 70, all earlier, and all 212 unparseable ones check ok; the other 28 are well-formed (below `a`, the continuation line `{`'s flow expression spans the blank line). The Task 67 battery (below `a {`, `a {[`, `a <b x={`, `{} {`, and `a {` with two trailing spaces, and the controls `c` LF `{` and `a {x}`, in `""`, `- `, `> `, `> - `, `- > `, and a lazy construct line under `- ` and `> `; a brace line with the Task 64 battery's nine content shapes and nine closings made the expression's — `x}} b`, `}}c`, `x}}`, `x}` LF `}`, none, a leaving ` -`, `- x}}`, a tag after, a tag spanning lines — 15,876 files; or Task 66's constructs `<b x={`, `<b/>{`, `<b` LF `x={`, and a text-first control `c {`, with five content shapes and five closings — 19,600 files; with and without a final LF, LF and CRLF; ~2 min per 8,000 files a part) moved 5,990 and 5,200 offsets, all earlier, never in a control or the text-first construct, at lower or like cost (119 s against 136 s, and 131 s against 132 s, in two parts each); 3,655 of them — every `a {` file and one in eight of the others, at each stage of the change — check ok by the oracle (~1 h in four parts), and of a one-in-fifty sample of the files the first stage left unchanged (521 unparseable), the 465 still unchanged check ok, and so do the 56 the later stages moved, at their new offsets. The Task 63 battery (2,128 files), the Task 64 battery (5,832), the Task 66 battery (10,560), the Task 59 differential (24,000), the Task 65 long battery (1,008), and the Task 63 and 64 mutation fuzzes (600 draws each) moved nothing, at like cost (the Task 64 and 66 batteries ~3% slower: the walk now runs where no blank line precedes the flow reading's offset); the Task 62 differential (7,800) moved 3, all earlier and ok — its `- a ` form's spread `<S id="s" {...` above a brace line. P-8 ~64 s. Not covered: the brace line's content judged by both readings at once — `a {` LF `{1} b}` is 31 where SPEC fixes 30, and `a {` LF `{x}` LF is 29 where SPEC fixes 32 (residual gap 85, below); a text expression open at an ATX heading's end is located past the heading's line ending — `# a {` LF is 31 where SPEC fixes 30 (residual gap 86, below). +- Known residual 14.20 location gaps (accepted for this run by ruling): gaps in where a 14.20 syntax failure is located (SPEC 14's location rule for 14.20), found only by constructed probes, batteries, and fuzzing — formerly FIX_PLAN Tasks 82–86, now closed for this run and not to be worked on. Each staging is a spec source's content after `import A from "./A.mdx"` LF LF (25 characters); each SPEC offset was checked by the Task 59 bullet's oracle. Record any further such gap here as a one-line entry (staging, offset given, offset SPEC fixes), never as a plan task. + - 82 (a run past a probe's 256-character reach — `PROBE_REACH`, `CONSTRUCT_LINES`, and `LINE_BACKUPS` in `src/core/mdx-syntax-failure.ts`): `<S id="s">` LF `{[` LF `a,` LF LF, 260 spaces, `]}c` LF `</S>` LF gives 299 (the line's start plus 256) where SPEC fixes 305, the `c`; `a <S id="s">` LF `<b` LF, 60 lines `y0="1"` … `y59="1"`, `x={` LF LF `x} />c</S>` LF gives 293 where SPEC fixes 515. + - 83 (an end of file inside a text tag at a paragraph's end): `a <b` LF LF `/>c` gives 29, the end of the paragraph's content, where SPEC fixes 30, the blank line's terminator. + - 84 (a spread attribute beginning a line inside a text tag): `a <b` LF `{...x} />c` gives 29 where SPEC fixes 30, the `{`. + - 85 (a brace line below a paragraph line holding an open text expression, judged by one reading at a time): `a {` LF `{1} b}` gives 31 where SPEC fixes 30, the `1`; `a {` LF `{x}` LF gives 29 where SPEC fixes 32, the line's terminator. + - 86 (a text expression open at an ATX heading's end): `# a {` LF gives 31, its length, where SPEC fixes 30, the heading's line ending. +- Known SPEC 6.5 gap, deferred to a future SPEC revision (accepted for this run by ruling): SPEC 6.5 deletes a TypeScript import declaration in place and drops the line it leaves empty, but names no refusal reason for a removal that joins two top-level statements through automatic semicolon insertion. That happens when the import stands between a statement lacking a terminating `;` and a line opening with `/`, `(`, `[`, `` ` ``, `+` or `-`, in a file needing no import addition, such as one already importing the target module. The joined text then either fails to parse, or parses with a changed meaning that nothing detects (a joined line opening with `(` or `[` continues the previous expression). When it fails to parse, the product refuses the move through its post-move re-validation's 14.20 and modifies nothing, although SPEC 6.5 says a successful move's finishing regeneration cannot fail. The Task 75 engineer's staging: the file already holds `import Target …`, and the removed `import ORG …` sat between `let a = 1` and `/re/.test("x")`. This is a note only: it is not a plan task and is not to be worked on in this run. +- Hand-probing SPEC 14's repeated-prop locations (Phase 10 FIX_PLAN Task 15; `processAttributes` in `src/core/mdx.ts` collects every spelling's attribute range per prop name and emits one 14.17 per repeated name after its attribute loop): a scratch workspace reproduces T14-11's arms (h) and (n) byte-exactly. Use the `SPECS_ONLY_CONFIG` of `test/suite/registry/section-14.ts` (`specs/**/*.mdx`). Its `specs/A.mdx` holds the 33-byte `T14_11_PREAMBLE` — `<S id="ok">` LF `Target: café.` LF `</S>` LF LF, the `é` written through `printf` from its UTF-8 bytes in octal (`\303\251`) — and then the arm's sections. Pipe `build --json` (or `occurrences --json`) through a one-line `node -e` that prints each finding's `code` and its locations as `[start,end]` pairs, to see every range at once. T14-11's body runs its table arms in order — (a)–(m), then (p)–(w) — then arm (n) (`runRepeatedDependencyArm`), then, on Linux, arm (o), and stops at the first diagnosed failure, so a `section-14.test.ts` run names only the first failing arm. `section-14.test.ts` with `section-1.3`, `section-2.5-2.6`, `section-2.7`, `section-11.2`, and `section-11.4` runs in ~56 s under the namespace. Since FIX_PLAN Task 68, every braced `d` spelling of a section is recorded (`SpecSection.dependencies`, in tag order) and analyzed as a single `d` is (`analyzeSpecReferences` in `src/core/spec-references.ts`): a later spelling's entries record occurrences and edges (collapsing across spellings), a cycle through them locates their spellings, and each reports its own 14.5 or 14.8 beside the one 14.17 — a later quoted or valueless `d` holds no entries. Since FIX_PLAN Task 69 every spelling of a repeated prop is judged as if it stood alone — `judgeStringProp` (value form, `coverage` value, 1.4) for `id`, `coverage`, and `tags`, `processDependencyProp` for every `d`, and the unknown-prop 14.17 per spelling — recording nothing past the first: stage the offending tag after the sibling `<S id="ok">` LF `A valid sibling section.` LF `</S>` LF LF (T2.7-3's template, pure ASCII) and read `build --json`; `<S id="a" id="b c">` and its reverse, `tags="ok" tags="bad#tag"`, `coverage="none" coverage="maybe"`, a later braced or valueless spelling, a later quoted `d`, and `foo="1" foo="2"` each give that spelling's own 14.4 or 14.17 beside the one repetition 14.17, in either order. T14-11 alone (`-t 'T14-11 '`) runs in ~17 s under the namespace; `section-14` with `section-2.2-2.3`, `section-5.7`, `section-11.2` through `section-11.5`, `section-1.3`, `section-2.7`, and `section-6.4` in ~170 s. +- Hand-probing SPEC 2.4's same-scope collisions (Phase 10 FIX_PLAN Task 16; `moduleValueDeclarations`, `namespaceBindsValue`, and the `colliding` binding kind in `src/core/code-analysis.ts`; `exportedDeclarationBindings` in `src/core/mdx.ts`; the `colliding` binding and the `unresolved` outcome in `src/core/spec-references.ts`): write `SPEC_AND_CODE_CONFIG` (`test/suite/registry/section-5.7.ts`) as `xspec.config.ts` in a scratch directory beside `specs/A.mdx` and a `src/app.ts` spelling T4.5-8's layout (the import, a blank line, the declaration, a blank line, `SPEC.a;`, `text(SPEC.b);`), or a `specs/COL.mdx` holding `import BASE from "./BASE.xspec"` and `export const BASE = 1` beside `specs/BASE.mdx`, and run `build --json` and `occurrences --file <file>` there; the file is pure ASCII, so string indices are the byte offsets the findings carry. T4.5-9 stops at its first failing cell, so to see every cell (arms × arguments) wrap the `assertCollidingTextCallCell` call in its `run`'s nested loop (and the closing `assertCollidingTextControl` call) in a throwaway `try`/`catch` printing each verdict through `console.info`, run `-t 'T4.5-9 '` with `--disable-console-intercept`, and restore the file with `git checkout -- test/suite/registry/section-4.5.ts`; prove the restore with `git diff --quiet -- test/` — a bare `git diff --quiet` also fails on uncommitted `src/` edits. T4.5-8 alone takes ~14 s of a `section-4.5.test.ts` run. Since FIX_PLAN Task 17 an import-import collision takes the same `colliding` kind in both files (in `code-analysis.ts` its `node`/`text` flags come from every colliding spec binding's role, type-only ones included, and `binders` phrases the messages; `poisoned` now marks an invalid import's binding alone): probe it with T4-5's layout — `import { text } from "../specs/A.xspec";`, the colliding pair, a blank line, `SPEC.a;`, `text(SPEC.b);` — beside a `src/t.ts` exporting `SPEC`, or with a `specs/A.mdx` importing `BASE` from two modules above `<S id="x" d={BASE.b1}>`; a `-t 'T4-5 '` run takes ~10 s and a `-t 'T4.5-9 '` run ~17 s. +- Hand-probing SPEC 14.11's cross-module `text` calls (Phase 10 FIX_PLAN Task 18; `analyzeTextCall` in `src/core/code-analysis.ts` records such a call like any other, its `calledModule` beside it, and `crossModuleTextFinding` in `src/core/graph.ts` reports the 14.11 only where resolution succeeds, beside the edge and occurrence): write `SPEC_AND_CODE_CONFIG` (`test/suite/registry/section-5.7.ts`) as `xspec.config.ts` beside `specs/A.mdx` (`<S id="a">`) and `specs/B.mdx`, and a `src/cross.ts` holding T4.4-1's 85-byte prefix (`import A from "../specs/A.xspec";`, `import { text as textB } from "../specs/B.xspec";`, a blank line) then `textB(A.missing);`, `textB(A.a!);`, and `textB(A.a);` on their own lines. `build --json` gives 14.7 alone, 14.8 alone, and one 14.11 at the call with `identities` `["specs/B.mdx"]`; a bare `occurrences` (exit 1) lists the one `embeds` record of `textB(A.a)`. With the called module staged at `specs/B#.mdx` (imported as `../specs/B#.xspec`), the 14.11's `identities` are `[]` beside the 14.19, and the record is still listed. A code file at an invalid path records the occurrence with its source unavailable, and an argument into an unparseable module is 14.7 alone. `section-4.3-4.4.test.ts` with `section-5.7.test.ts` runs in ~13 s under the namespace. +- Hand-probing SPEC 14's `text(...)` call locations (Phase 10 FIX_PLAN Task 70; `analyzeTextCall` in `src/core/code-analysis.ts` locates its 14.8s and its undefined-member 14.7 at the call, and the two unresolved-reference loops in `src/core/graph.ts` locate a code reference's 14.7 at its `occurrenceRange` — the call for an `embeds` reference, the bare chain for a marker): beside the layout above, a `src/app.ts` holding `import SPEC, { text } from "../specs/A.xspec";`, a blank line, then `text(SPEC.missing);`, `text("x");`, and `` text(`x`); `` gives each finding at its call, [48,66), [68,77), [79,88); under T4.4-1's 85-byte prefix `textB(A.missing)` gives [85,101) and `textB(A.a!)` [103,114); a call into a 14.19 spec source (`import C from "../specs/C#.xspec";`, `text(C.c);`) and a call in a code file at an invalid path (`src/bad#.ts`) locate the call too, a marker there keeping its bare chain. To see every finding's located text at a glance, drive `build --json` from a small Node script (`execFileSync`, reading an exit-1 answer from the thrown error's `stdout`) that prints each location's bytes, `readFileSync(file).subarray(start, end)`; these probes stage no permission refusal, so they run as root with no namespace. Task 70's six named suite files (`section-4.3-4.4`, `5.7`, `14`, `2.4`, `11.3`, `12.1-12.2`) ran in 133 s under the namespace, and the other modules asserting 14.7, 14.8, or 14.11 (`section-11.4`, `11.5`, `11.6`, `12.7`, `14-iii`, `2.7`, `4.5`, `4`, `13.5`, `14-ii`) in 118 s. A `text` binding as a `text` call's argument (FIX_PLAN Task 88) probes on the same layout: `text(text);`, `text(text.x);`, and `text(SPEC.a, text);` below the import and blank line give one `unsupported-node-usage` each at the binding's identifier alone, [53,57), [65,69), [87,91), beside the arity 14.8 at [74,92) — the walk reports it (`visitIdentifier` → `visitTextBindingUse`), while `analyzeTextCall`'s branch for an argument rooted at a `text` binding only returns, recording nothing; wrapped or accessed arguments (`(text)`, `text!`, `text as any`, `text[0]`, `text.x.y`), a colliding identifier binding only `text` exports (`import { text as t }` from two modules, beside its 14.15), and another module's `text` binding (`text(textB)`) locate the identifier the same way. Task 88's five named files (`section-4.5`, `4`, `4.3-4.4`, `5.7`, `14`; 31 tests) ran in 146 s under the namespace. +- Hand-probing SPEC 4.6's code units (Phase 10 FIX_PLAN Tasks 19, 71; `unitName` in `src/core/code-analysis.ts` binds a class constructor's implementation as a unit named `constructor`, reading the keyword token's spelling in `constructorNameIsPlain`): write `E6_CONFIG` (`test/helpers/e6.ts`) as `xspec.config.ts` beside a `specs/S.mdx` with sections `a` and `b`, and a `src/c.ts` that imports `SPEC` from `../specs/S.xspec` and holds classes with markers. Pipe `occurrences --json` through node to print each record's `source.identity` beside the text its `source.range` spans. A string-literal `"constructor"() {}` attributes to the bare class, and an overload signature takes no slot. `static constructor()` beside `constructor()` is `C.constructor@2`. The range excludes a JSDoc comment and includes a `public` modifier. TypeScript's parser rejects an escape-spelled `constructor` (TS1260) and a field named `constructor` (TS1005), so either file is 14.20: SPEC 14.20 takes TypeScript's parser acceptance as its grammar. To diff a failing test's `actual:` and `expected:` arrays from a saved vitest log, extract both lines with `awk '/HarnessAssertionError: <ID>/{f=1} f&&/^[ \t]+(expected|actual):/{print; n++} n>=2{exit}'`, then parse each from its first `[`; `actual:` prints first. The sandbox's `awk` is mawk 1.3.4, which has no `\s` class, so a `\s` pattern silently matches nothing. Under the namespace, the six suite files Task 19 names run together in ~52 s, and P-8 with P-11 in ~62 s. An abstract class member (Task 71) binds no unit whatever it spells: in a `src/c.ts` holding `abstract class A {` with `abstract m(): void { SPEC.a; }`, `abstract p = () => { SPEC.b; };`, `abstract get g(): number { SPEC.a; return 1; }`, `abstract constructor() { SPEC.b; }`, and a concrete `m(): void { SPEC.b; }`, `build --json` reports no findings (TS1245, TS1267, and TS1242 are post-parse checks, 14.20), the four abstract members' markers attribute to `src/c.ts#A`, and the concrete method's to `src/c.ts#A.m`; `query edges --from 'src/c.ts#A.m@2' --json` exits 2 as an unknown node. A unit declared inside an abstract member's body chains from the class (`A.inner`). Under the namespace, the four suite files Task 71 names run together in ~29 s, the seven neighbours (§11.3–11.6, 4.5, 5.7, 1.4) in ~62 s, and P-1, P-8, P-10, and P-11 in ~88 s. +- Hand-probing SPEC 4.6's unit binding and 1.7's unit ranges (Phase 10 FIX_PLAN Tasks 20, 72; `collectUnits`, `unitRange`, `declarationRange`, `plainName`, and `isDeclarationFileName` in `src/core/code-analysis.ts`): stage the scratch workspace from a small Node script, building escape spellings with `String.fromCharCode(92)`. Let the configuration's code group glob `src/**/*.ts`, `src/**/*.mts`, and `src/**/*.cts`, so `x.d.mts` and `x.d.cts` are discovered. Then print each `occurrences --json` record's `source.identity` beside the bytes its `source.range` spans. A class's own range is reachable only through a `text(...)` call in a non-unit property initializer (`x = text(SPEC.a);`); a bare chain there (`x = SPEC.a;`) is 14.18 and fails `build`. TypeScript 5.9 exports `ts.isDeclarationFileName` at run time but not in its typings, and its rule treats `\` as a path separator; the product reads SPEC 4.6's rule over the path's last `/`-joined segment. Under the namespace, `section-4.6.test.ts` with `section-1.6-1.7.test.ts` runs in ~15 s. A default export's exported expression is read as spelled (Task 72): stage one code file per form, a file holding one default export — `export default (() => { SPEC.a; });`, `export default (function named() { SPEC.a; });`, `export default ((() => { SPEC.a; }));`, `export default (class Named { m() { SPEC.a; } });`, and the unwrapped `export default () => { SPEC.a; };` — each importing `SPEC` by default (`import SPEC from "../specs/S.xspec";`); `build --json` reports no findings, the wrapped forms' markers attribute to the bare file and the wrapped class's method to `path#m` (never `Named.m`, nor `default.m` for an anonymous `(class { … })`), `query edges --from '<path>#default' --json` exits 2 as an unknown node for a wrapped form, and the unwrapped arrow keeps `path#default`, its range the whole declaration through its `;`. Under the namespace, `section-4.6.test.ts`, `section-1.6-1.7.test.ts`, and `section-11.test.ts` run together in ~28 s, the seven neighbours of Task 71 (§11.3–11.6, 4.5, 5.7, 1.4) in ~64 s, and P-1, P-8, P-10, and P-11 in ~87 s. +- Hand-probing `rename`/`move` refusals (Phase 10 FIX_PLAN Tasks 21–25, 35, 73–75; the pure evaluation in `src/core/refusal.ts`, which `src/cli/commands/move.ts` and `src/cli/commands/rename.ts` call before planning): in a scratch workspace as above, first confirm `build --json` reports no findings — every refusal reason is defined only over a workspace passing `build`'s validations (SPEC 6.4), an invalid one reporting its numbered findings instead — then `move <file>#<id> <target-file>#<new-id> --preview --json` prints the 12.7 preview document without modifying anything: on a refusal, exit 1 with `mapping`, `files`, and `delta` null and each reason's `code`, `locations`, and `identities` in `findings`. Drop `--preview` to confirm the real operation reports the same and leaves every file byte-identical (hash the files before and after). Since Task 27 the preview also runs the real operation's post-plan guards — the in-memory re-validation of the rewritten workspace and the 14.22 write-set check (`validateRewrittenWorkspace` in `src/cli/commands/rewrite-validation.ts`) — so a planning defect the real move refuses with numbered findings (T6.5-9's staging until Task 32: 14.7, 14.15, and 14.18 in `src/app.ts`) shows identically under `--preview`, a non-mutating probe of a plan's would-be workspace. `src/core/refusal.ts` holds two literal NUL bytes (the key separators in `wouldBeDependencyCycles`' template strings), so plain `grep` calls it a binary file: search it with `grep -a`, and if an edit tool mangles it, edit it byte-wise with a script that asserts each replaced passage occurs exactly once and that the NUL count is unchanged. The suite files these tasks name — `section-6.6`, `section-14`, and `section-6.5` — run together in ~76 s under the namespace (T6.6-3 alone, `-t 'T6.6-3 '`, in ~26 s), and the neighbours `section-6.5-iii` and `section-12.7` together in ~38 s; a background run must be one foreground command ending `; echo "EXIT $?" >> <log>` — backgrounding it again with `&` inside the background command leaves the tool reporting completion at once while the run goes on. A composite test's later arms that a still-failing earlier arm hides (T6.6-3's and T14-7's replays of T6.5-17's `refused-moved-import` arms, behind Task 35's) are observed by driving the module-private helper directly: copy the registry module to the scratchpad, `sed` the helper's `async function` line to `export async function` (`expectRefusedArmPreviewTwin` in `test/suite/registry/section-6.6.ts`, called once per entry of the exported `M17_REFUSED_ARMS` with that entry's files, argv, and codes; `runT147MovedImportArms` in `section-14.ts`, which loops over the table itself), and call it with `builtProductBinding()` (`test/helpers/subprocess.ts`) from a temporary `test/self/zz-*.test.ts` run under `--project self --reporter=verbose --disable-console-intercept` in the namespace (both helpers in ~6 s), then restore each module from its copy, `cmp`-check it, and delete the temporary test before committing (`git status --short` shows no `test/` path). Since Task 35 `evaluateMoveSectionRefusals` composes every section move's would-be files through `judgeMoveSectionRewrite` (`src/core/move.ts`: the one `composeMoveSection` pass `planMoveSection` also runs — given the code analyses too since Task 75 — tolerating the preconditions other reasons report — the exact self-move, a moved import declaration, a moved reference to the target's own root, a target path that is no spec path), so a performed move composes its spec sources twice and a planning change reaches the refusal too; a `refused-invalid-rewrite` finding's `identities` name the files the judgement blames — the origin always, the target (a created one included) only with an insertion point, and any spec or code source (a code source since Task 75) holding no admissible offset for its additions, whose rooted spellings it locates — the quickest read of a would-be text's verdict (T6.5-16's arms are ready stagings; the move-heavy properties P-5, P-8, and P-10 kept their timings). Since Task 73 a move's would-be cycles — `wouldBeDependencyCycles` and `wouldBeImportCycles`, each returning its cycles with their paths and located constructs — reach the report as one `refused-cycle` built by `refusedCycleFinding`, locating their union once per construct; a ready staging that closes both kinds (specs-only configuration): `specs/A.mdx` holding `import B from "./B.xspec"` and the sections `<S id="k">`, `<S id="u" d={B.q}>`, and `<S id="s" d={[B.p, "k"]}>`, `specs/B.mdx` the sections `p` and `q`, each holding one line of text, blank lines between blocks; `move specs/A.mdx#s specs/B.mdx#p.s --preview --json` exits 1 with the one finding locating A's import `[0,25)`, `B.p`, and `"k"`. At Task 73 the four files its verification names — `section-6.5`, `section-6.6`, `section-12.7`, and `section-14` — ran together in ~139 s under the namespace (27 tests), and with `section-6.5-ii`, `section-6.5-iii`, `section-6.4`, and `section-6.3` beside them in ~161 s (48 tests). Since Task 74 the spec-import half judges the relation the rewrite leaves, read from the composition itself: `judgeMoveSectionRewrite` (`src/core/move.ts`) returns `{ verdict, imports }`, `imports` the `WouldBeSpecImport` list built from `SpecImportPlan` — each declaration the rewrite keeps by its own characters, an already-unused one and a block's first declaration the joint-removal rule keeps included, and each it adds by the spellings rooted at its binding, recorded per module — which `wouldBeImportCycles` judges (a file move's relation: `fileMoveSpecImports`, every declaration with the paths mapped), so a declaration the rewrite removes is never located; under an intrinsically invalid new ID `evaluateMoveSectionRefusals` composes under the old ID for the relation alone (an unspellable ID such as `a"b` included). Two ready stagings (specs-only configuration, each section holding one line of text, blank lines between blocks): `specs/A.mdx` holding `import X from "./B.xspec"` and `import Y from "./B.xspec"` on successive lines, then `<S id="keep">` and `<S id="m" d={[X.b, "keep"]}>`, `specs/B.mdx` holding `import C from "./C.xspec"` and `<S id="b" d={C.c}>`, `specs/C.mdx` the section `c` — `move specs/A.mdx#m specs/C.mdx#m --preview --json` exits 1 with one `refused-cycle` locating Y `[26,51)`, `"keep"`, and B's import, never the removed X; and that `specs/A.mdx` with `// note` in Y's place beside a `specs/B.mdx` holding the section `b` alone — `move specs/A.mdx#m specs/B.mdx#m`, with or without `--preview`, exits 1 with one `refused-cycle` locating X `[0,25)` (kept to head its block) and `"keep"`, where the post-move re-validation's 14.9 `cycle` had stood. At Task 74 the same eight files ran together in ~163 s (48 tests), and the move-heavy property files `section-16-p5-p6`, `section-16-p8`, `section-16-p9`, and `section-16-p10` together in ~120 s (5 tests). +- Hand-probing a section move's import additions to code files (Phase 10 FIX_PLAN Tasks 28, 30–33, 75, 76; the code-file assembly loop in `src/core/move.ts`, `defaultImportLine` spelling the declaration): stage T6.5-8's TS arm in a scratch workspace under `E6_CONFIG` — `specs/Origin.mdx` holding `org` with `org.mv` and `org.stay`, `specs/Target.mdx` holding one section, `src/app.ts` importing `../specs/Origin.xspec` as `ORG` with the markers `ORG.org.mv;` and `ORG.org.stay;` — and run `move specs/Origin.mdx#org.mv specs/Target.mdx#mv` (`--preview --json` first for the `import-addition` offset, then the real move, `cat src/app.ts`, `check --json`, `query edges --json`). A code marker must be a bare expression statement: a chain inside any other expression (an array element, an argument other than `text`'s) is 14.18 `unsupported-node-usage`, so such a staging fails `build` before any move. To judge a would-be code file as 14.20 does, run `node -e` from the repository root with `require("typescript")`: `ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true)` — an empty `parseDiagnostics` is well-formedness (the top-level-import rule is a post-parse check, so an import inside a function body parses clean), and `.statements` lists the top-level declarations. Since Task 30 a code file's spec import is removed once the move re-roots its last occurrence (`CodeReference.rootImport` and `calleeImport` in `src/core/code-analysis.ts` index the declaration each chain root and `text` callee resolve to): stage `import ORG from "../specs/Origin.xspec"` whose only marker is `ORG.org.mv;` — the preview lists an `import-removal` spanning the declaration plus its dropped line's terminator and, where a binding is added, an `import-addition` at that removal's end — and add `export type N = typeof ORG.org` beside it to see a type-level spelling keep no import (`check` stays clean). A scratchpad Node driver that stages one workspace per case from a JSON list (configuration, `specs/Origin.mdx`, `specs/Target.mdx`, the code file) and prints each `--preview --json`, the code file's bytes through `JSON.stringify`, and `check --json` runs a dozen such stagings in seconds. Since Task 31 a moved `text(...)` call is re-rooted whole (`CodeReference.callee` records the callee identifier; `importDeclarationLine` spells an added declaration binding exactly the lacked bindings): stage T6.5-11's layout (`test/suite/registry/section-6.5-ii.ts`: `specs/origin.mdx` holding `x` and `w`, `specs/target.mdx` holding `z`, `src/c.ts` importing `O, { text as t }` from `../specs/origin.xspec` and returning `t(O.x)` from `f`) and run `move specs/origin.mdx#x specs/target.mdx#y`: the call reads `targetText(target.y)` under an added `import target, { text as targetText } from "../specs/target.xspec"`, the preview reporting one `reference-rewrite` over the call's span, callee through closing parenthesis; a file already holding `import T from "../specs/target.xspec"` gains `import { text as targetText } from …` alone, and a same-file move (`specs/origin.mdx#w.x`) touches no callee. `section-6.5-ii.test.ts` (T6.5-11) runs in ~14 s under the namespace (~20 s from the re-descent's FIX_PLAN Task 37, six arms). Since Task 32 an added declaration's fresh identifiers avoid every identifier the receiving code file spells (`CodeAnalysis.spelledNames`, collected by `spelledIdentifierNames` in `src/core/code-analysis.ts` with its own stack, and seeding `taken` in the `addition` closure of `src/core/move.ts`), `arguments` and `eval` skipped as reserved: T6.5-9's staging (`A9_APP_BEFORE` in `test/suite/registry/section-6.5.ts` beside `src/util.ts`) binds `Target2`, and a function-local `const Target` beside the moved marker yields `Target2.mv` inside it; `section-6.5.test.ts -t 'T6.5-9 '` runs in ~9 s under the namespace. Since Task 33 each re-rooted chain root and `text` callee takes the first existing target-module binding, in document order, that no local declaration shadows at the occurrence, and otherwise the added declaration's: `CodeReference.shadowedImportNames` holds the import-bound identifiers the checker's value-level `resolveName`, asked at the chain's root (`shadowedImportNamesAt` in `src/core/code-analysis.ts`, over every binding of every spec module import), resolves to anything but that binding, and `existingBinding` in `src/core/move.ts` skips them. T6.5-18's staging (`A18_*` in `test/suite/registry/section-6.5-iii.ts`, `SPEC_AND_CODE_CONFIG`) yields `target.y` in `f` under an added `import target from "../specs/target.xspec"`; a parameter, a hoisted `var`, a catch variable, a named function or class expression, a `for…of` variable, or a namespace export shadow likewise, an inner block's `const` beside the occurrence and a local `interface` do not, and a callee's `text` binding is judged the same way (a parameter `tt` makes `t(O.x)` read `targetText(T.y)` under an added `import { text as targetText } …`). `-t 'T6.5-18 '` on `section-6.5-iii.test.ts` runs in ~7 s under the namespace. The judgement costs ~0.4 µs per (reference, import binding) pair: a 6,000-marker file with 60 spec imports builds ~150 ms slower. Since Task 75 a code file's additions stand at an admissible offset, chosen as a spec source's are: `additionCandidates` (shared with `placeSpecImportAdditions`) orders the old anchor (after the line of the file's last spec-module import), the first removed import's line start, the file's start, then every line start and every line's end, line starts first, and `placeCodeImportAdditions` takes the first at which `topLevelImportRanges` (`src/core/code-analysis.ts`: the composed text parsed as `analyzeCodeSource` judges 14.20, then the byte ranges of `sourceFile.statements`' import declarations) holds each added declaration exactly — so a candidate inside a block comment, a template literal, JSX text (`.tsx`), or a function body (which TypeScript's parser accepts) is passed over, as is one before a line headed by `;` (which the declaration would absorb) or before a shebang. Stagings over T6.5-8's TS arm (`src/app.ts` holding `ORG.org.mv;` and `ORG.org.stay;`): `import ORG from "../specs/Origin.xspec"; /* a` then `b */` (and likewise `; export function f() {` over an indented marker and `}`, a template literal opened on that line and closed on the next, or a `<div>` element spanning lines in `src/app.tsx`) puts the declaration at offset 0, the preview's `import-addition` at 0, the marker's edge kept; a one-line `import ORG …; ORG.org.mv; ORG.org.stay;` without a final terminator takes offset 0 over the file's end; `#!/usr/bin/env node` first takes the line after it. The rewrite judgement now takes the code analyses (`evaluateMoveSectionRefusals`' `code` input), so a code file left no admissible offset is refused as `refused-invalid-rewrite` (identities the code file's path, locations the moved construct and every occurrence rooted at the added bindings, a `text(...)` call by the whole call): `let a = 1`, `import ORG from "../specs/Origin.xspec"`, `/re/.test("x")`, `let b = 2`, `import ORG2 from "../specs/Origin.xspec"`, `/re/.test("y")`, `ORG.org.mv;`, `ORG2.org.mv;` — each removal ASI-joins a `/re/` line to the line above (`1 / re / .test`), and the one addition can part only one — where the post-move re-validation's 14.20 had stood (with one such removal the addition at the removed line's start parts it, and the move proceeds; where the file already holds a target binding, so nothing is added, one such removal still leaves the file ill-formed, and the post-move re-validation refuses the move with that 14.20 — SPEC 6.5 names no refusal reason for a code file its removals alone leave ill-formed). Since FIX_PLAN Task 76 a chain's existing root may be any binding of the target module's default export — `CodeImport.defaultBindings` in `src/core/code-analysis.ts` lists the default clause's and each `{ default as X }` element's in written order, and `defaultBindingsOf` in `src/core/move.ts` yields it: with `src/app.ts` `import { default as TGT } from "../specs/Target.xspec";`, `import ORG from "../specs/Origin.xspec";`, `TGT.tgt;`, `ORG.org.mv;` (one per line) the move leaves `TGT.mv;` with no addition and the ORG line dropped, `query edges` listing `src/app.ts` `references` `specs/Target.mdx#mv`; a type-only `{ type default as TGT }`, or a `TGT` a parameter shadows at the occurrence, still takes the addition there, `import TGT2, { default as TGT }` roots at `TGT2` (document order), and `import { default as ORG }` of the origin is removed like a default clause. The scratchpad driver pattern above (one workspace per JSON case, `--preview --json`, the move, the code file's bytes, `check --json`, `query edges --json` filtered to `src/`) runs such a list in seconds. At Task 75 `section-6.5`, `section-6.5-ii`, and `section-6.6` ran together in ~98 s under the namespace (16 tests), and `section-6.5-iii` alone in ~92 s (8 tests). At Task 76 the four ran together in ~103 s (24 tests). +- Hand-probing SPEC 7's configuration occupancy (Phase 10 FIX_PLAN Task 36; `occupantOf` and `searchUpward` in `src/workspace/locate.ts`): in a scratch workspace holding a valid root `xspec.config.ts`, stage `mkdir -p dirocc/xspec.config.ts` and `mkdir linkocc && ln -s ../xspec.config.ts linkocc/xspec.config.ts`; `node /abs/path/to/dist/cli/bin.js ids --json` run from `dirocc/` or `linkocc/`, or from the root with `--config dirocc/xspec.config.ts` or `--config linkocc/xspec.config.ts`, exits 2 with the one `configuration-error` whose `path` is the entry itself (`xspec.config.ts` from its own directory, the `--config` value from the root), never loading the valid file above. A refused kind read during the upward search stages as root only inside one namespace invocation: `unshare --map-user=1000 --map-group=1000 -- bash -c 'cd <ws>/p/c && chmod 0 <ws>/p && node /abs/path/to/dist/cli/bin.js ids --json; chmod 755 <ws>/p'` (restore the mode inside the same invocation) — until Task 49 renders it as `read-failure` it exits 70 with `EACCES … lstat`, never a search continuing past it. +- Hand-probing SPEC 7's outside-root depth rule (Phase 10 FIX_PLAN Task 39; `globLiesOutsideRoot` in `src/core/glob.ts`, the pure count `compileGlob` applies before and apart from matching, so configured globs, policy `files` selectors, and every `--file` share it): in a scratch workspace, a small shell script that rewrites `xspec.config.ts` with the probed glob as a second spec group's pattern, or as a policy rule's `from: { files: … }`, and runs `build --json` shows each decision — `**/../x/*.mdx`, `specs/**/../../x.mdx`, and `**/a/../../x` are 14.14 (exit 2, the message naming the glob), while `a/**/../x/*.mdx`, `specs/**/../*.mdx`, and `**/$1/../x` load. With a valid configuration, `ids --json`, `query nodes`, `occurrences`, and `view`, each given `--file '**/../x'`, exit 2 with the plain usage error on stderr. Under the namespace `section-7-discovery`, `section-7.4-7.5`, and `section-12.3-12.5` together run in ~35 s; `section-11`, `section-11.3`, and `section-11.4` in ~24 s; `section-16-p7`, `section-7-basics`, and `section-12.0-ii` in ~75 s. +- Hand-probing SPEC 7's inside-root segments (Phase 10 FIX_PLAN Task 40; `parseSegment` in `src/core/glob.ts` reads a `.`, `..`, or empty pattern segment as the `never` segment, matching no path segment, so a pattern holding one matches nothing and `mayMatchWithin` enters no directory for it; nothing resolves a pattern's segments any more, and captures are read over the whole spelling): stage `ctl/C.mdx`, `specs/A.mdx`, `b/M.mdx`, and `a/N.mdx`, each one `<S id="…">` section, with a spec group holding the probed glob beside a `ctl/*.mdx` control group, and read `ids --json` — `a/../b/*.mdx`, `./specs/*.mdx`, `specs//*.mdx`, `specs/*.mdx/`, `specs/./*.mdx`, `specs/../specs/*.mdx`, and `specs/**/../*.mdx` list the control alone, while `specs/*.mdx` and `b/*.mdx` add their file. Given as `--file` over a valid workspace, the same spellings answer `ids`, `query nodes`, `occurrences`, and `view` with an empty, finding-free document, exit 0. As a policy rule's `from: { files: … }`, run `build` before `check` (else `check` reports `stale-output` first): `b/./*.mdx` or `$1/./*.mdx` yields no `policy-violation` where `b/*.mdx` and `$1/*.mdx` do, and `$1/../$1/*.mdx`, a capture spelled twice, is 14.14. +- Hand-probing SPEC 7.3's `outDir` spelling (Phase 10 FIX_PLAN Task 41; `outDirSpellingProblem` in `src/core/discovery.ts`, which `validateMarkdown` in `src/core/config.ts` and `configurationFromStored` in `src/core/config-data.ts` both apply): stage one scratch workspace per spelling, writing its configuration from a Node script that serializes the value with `JSON.stringify` (so the verbatim literal is exactly the spelling), then run `build --json` — a refused spelling exits 2 with the one `configuration-error` document naming `xspec.config.ts`, nothing written. The stored-form guard needs no staged store: import `parseConfiguration(text, fileName)` (it takes the file's text as a string, not bytes) from `dist/core/config.js` and `configurationToStored`/`configurationFromStored` from `dist/core/config-data.js`, set the stored `markdown.outDir`, and read `null` (the read fast path falls back to the full parse) for every refused spelling. `section-7.1-7.3`, `section-11.6`, and `section-13.4` together run in ~25 s under the namespace. +- Hand-probing SPEC 14's unoccupied `--config` echo (Phase 10 FIX_PLAN Task 42; the absent branch of `locateWorkspace` in `src/workspace/locate.ts`, whose failure result's `concernedPath` `src/cli/main.ts` hands to `emitConfigurationErrors` in `src/cli/report.ts`): in a scratch root holding an invalid `xspec.config.ts`, a `specs/A.mdx`, and empty `work/` and `cfg/` directories, run `node /abs/path/to/dist/cli/bin.js build --json --config <value>` from `work/` — `./../cfg//xspec.config.ts` and an absolute `<root>/cfg/absent.config.ts` each exit 2 with the one `configuration-error` whose `path` (and stderr prefix) is the value byte-for-byte; after `cfg/xspec.config.ts` is created (any content, or as a directory) the same spellings report `../cfg/xspec.config.ts`. T12.7-3 alone (`-t 'T12.7-3 '` on `test/suite/section-12.7.test.ts`, ~9 s under the unprivileged namespace) passes every configuration arm since Task 42 and stops at its Linux-leg permission-staged arms, which run only under that namespace: 14.24 (`.xspec` unwritable; Task 48), then 14.25 (`specs/sub` unlistable; Task 49). The five files `section-12.7`, `section-7-basics`, `section-11.6`, `section-12.0-i`, and `section-12.6` run together in ~52 s. +- Hand-probing SPEC 11.6's physical anchoring of a `--config` path (Phase 10 FIX_PLAN Task 77; `physicalDirectory` and `pathSegments` in `src/workspace/anchor.ts`, `physicalWorkingDirectory` and `namedConfigurationEntry` in `src/workspace/locate.ts`): the working directory, then a `--config` value's directory components, are resolved link by link as the filesystem resolves a path — `..` the physical parent of the directory reached so far — never the entry itself, and the entry is read and anchored at its physical path. In a scratch root holding valid `xspec.config.ts`, `a/xspec.config.ts`, and `a/b/xspec.config.ts` (each with a `specs/` source), the links `L` → `a/b` and `L2` → `L`, and a directory `other/`, `inventory --json --config L/xspec.config.ts` (or `L2/…`, or the absolute path through `L`) reports `root` `a/b` and `config` `a/b/xspec.config.ts`; `--config L/../xspec.config.ts` reads and reports `a/xspec.config.ts` (the filesystem's `..` after a link, never the lexical `xspec.config.ts`); from `other/` `--config ../L2/../b/xspec.config.ts` reports `../a/b`; from `a/b` the absolute path through `L` reports `.`. Under a linked component a malformed file (`LB` → `bad`), a directory occupant (`LD` → `dirocc`), and a symbolic-link occupant (`L/../linkocc.config.ts`, `a/linkocc.config.ts` → `../xspec.config.ts`) exit 2 with the one `configuration-error` whose `path` is the physical spelling (`bad/xspec.config.ts`, `dirocc/xspec.config.ts`, `a/linkocc.config.ts`), `--config L/..` concerns `a`, and an unoccupied path — a missing component, a dangling link, a link to a file, `xspec.config.ts/..` — is still echoed as given (Task 42). Refusals by the `setpriv` recipe: `chmod 0600 a` makes `--config L/xspec.config.ts` (or through an absolute link to `a/b`) exit 2 with the one `read-failure` whose `path` is `a`, the directory whose lookup was refused; a self-looping link component (`ln -s loop loop`, `--config loop/xspec.config.ts`) exits 2 with `path` `.` (ELOOP); `chmod 0600 cfg` with `--config cfg/xspec.config.ts` still concerns `cfg/xspec.config.ts` (the kind read). The six files `section-11.6`, `section-12.7`, `section-7-basics`, `section-12.0-i`, `section-12.6`, and `section-7-discovery` run together in ~52 s under the namespace. +- Hand-probing SPEC 12.0's invocation grammar (Phase 10 FIX_PLAN Task 43; `walkTokens`, `FLAG_ARITY`, and `matchCommand` in `src/cli/args.ts`): in a scratch root holding the one-group `xspec.config.ts` and `specs/A.mdx` (`<S id="a">`, a line of text, `</S>`), run `node /abs/path/to/dist/cli/bin.js <argv>` and read exit code, stdout, and stderr — `--json ids` must equal `ids --json` byte-for-byte and `ids --` equal `ids`; `ids -- --json`, `ids -j`, `ids extra`, `build --file --json`, `build --test-hold --json` (no `./--json` created), and `ids --file --json extra` exit 2 with stdout empty; `build --bogus --json`, `build --json --file`, `ids --json --config`, and `ids --json --json` exit 2 with the plain error document; `ids --file --json` exits 0 with the empty human listing; `ids --config --json` is the missing-configuration error, stdout empty. Suite files under the unprivileged namespace: `section-12.0-iii.test.ts` (T12.0-14 alone) ~11 s, `section-13.5.test.ts` ~40 s, and `section-12.0-i`, `-ii`, `section-12.6`, `section-12.7` together ~80 s. +- Hand-probing SPEC 12.0's syntax class (Phase 10 FIX_PLAN Task 44; `parseArgv` in `src/cli/args.ts` — each flag's `spelling` and each command's `operandSpellings` name a `SpellingRule` judged by `spellingProblem`, and `PREVIEW_FLAG.excludes` holds the `--test-hold`-beside-`--preview` rule; the session-name form lives in the import-free `src/core/session-name.ts`, because `src/core/review.ts` reaches the TypeScript compiler through `src/core/config.ts` and the parser loads on every invocation): stage two scratch roots holding `specs/A.mdx` under the scratchpad (outside the repository, so the configuration-less root's upward search finds nothing), one with an `xspec.config.ts` carrying an unknown top-level key (`bogus: true`) and one with none, and run each syntax-class argv in both — `rename specs/A.mdx a b --preview --test-hold hold --json`, `review create --strategy audit --name .x --json`, `review status .x --json`, `at specs/A.mdx +7`, `occurrences --to specs/A.mdx#then`, `ids --file ../x --json`, `view --file a/../../x` — each must exit 2 with byte-identical stdout in both roots, the plain error document (`code` and `path` null), and no `hold` file created; a handler receiving a `--file` value compiles it through `compileFileFlag` in `src/cli/commands/common.ts`, which throws an internal error should an outside-root pattern ever get past the parser. Suite files under the unprivileged namespace: `section-12.0-ii.test.ts` with `section-11.3.test.ts` ~80 s. +- Hand-probing SPEC 14.24's write failures (Phase 10 FIX_PLAN Task 48; `performWrite` in `src/workspace/writes.ts` wraps every write primitive's filesystem mutations — the parent-directory creation, the temp write and rename, a removal, an append — and turns a failure carrying an errno `code` and a `syscall` into the `EnvironmentRefusal` of `src/workspace/environment-refusal.ts`, which `main` catches around `dispatchInWorkspace` and renders through `emitEnvironmentRefusal` in `src/cli/report.ts`; the occupant classifications around a write are reads and stay outside it; `rename`/`move` source writes run in the preview's `files` order through `orderSourceWrites` in `src/core/edits.ts`): the unprivileged namespace cannot enter the scratchpad — its ancestor `/tmp/claude-0` is mode 0700 and owned by uid 1000 (`ubuntu`), whom the namespace's mapped identity (root outside) is not — so as root run a scratch probe as that real user instead: `chown -R 1000:1000 <scratchpad>/<dir>` once, then `setpriv --reuid=1000 --regid=1000 --clear-groups -- bash <scratchpad>/<dir>/probe.sh` (invoke scripts through `bash`), where the script stages a workspace under `<dir>`, drives `node /home/user/xspec/dist/cli/bin.js`, and stages refusals with `chmod` (`chmod -R a-w specs/b` for `rename specs/b/B.mdx b b2 --json` refused at B — A rewritten, C untouched, `path` `specs/b/B.mdx`; `chmod a-w .xspec .xspec/journal` for the journal append; `chmod -R a-w .xspec` on a stale workspace for `build`, `query nodes`, or `ids`, each exiting 2 with `path` `.xspec`), restoring the modes inside the same script. Under the namespace T12.7-3 alone runs in ~10 s and T14-6 alone (`-t 'T14-6 '` on `section-14.test.ts`) in ~21 s — both now stopping at their 14.25 arms (Task 49) — T14-9 alone (`-t 'T14-9 '` on `section-14-ii.test.ts`) in ~35 s, and `section-13.5.test.ts` (T13.5-7 with its kill arm) in ~81 s. +- Hand-probing SPEC 13.5's atomic journal append under exhausted storage (Phase 10 FIX_PLAN Task 78; `appendDurableFile` in `src/workspace/writes.ts`, handed the validated journal's bytes by `appendJournalEntry` in `src/workspace/journal.ts`): as root, a mount namespace can mount a size-limited tmpfs — run the probe as `unshare -m -- bash <scratchpad>/<script>` (the mount lives and dies with that namespace; the suite's unprivileged namespace cannot mount, and tmpfs enforces its size against root too) — where the script stages the one-group workspace (`specs: { main: ["specs/**/*.mdx"] }`, `specs/b/B.mdx` holding `<S id="b0">`, a line of text, `</S>`), runs `build`, `rename specs/b/B.mdx b0 b`, then `rename specs/b/B.mdx b <x repeated 1900 times>` (a rename entry is 106 bytes plus twice each of the two IDs, so the journal ends at 4020 bytes, just short of a 4096-byte page), copies `.xspec` aside, mounts `tmpfs -o size=64k` over `.xspec`, copies the content back, and remounts with `size=` the used bytes (`df --output=used -B1 .xspec`), leaving no free page, then `umount .xspec` at the end. The next rename's entry straddles the page boundary: exit 2 with the `write-failure` concerning `.xspec/journal`, the journal byte-identical, no temp file left in `.xspec`, and `check` reporting `stale-output` alone (an O_APPEND append had grown the journal to 4096 bytes, a partial line `check` reported as `journal-error`). Under the unprivileged namespace `section-13.5`, `section-14-ii`, `section-6.1`, and `section-6.4` together (20 tests) run in ~78 s. +- Hand-probing SPEC 6.1's append to a journal whose last line lacks its terminator (Phase 10 FIX_PLAN Task 89; `appendedJournalBytes` in `src/core/journal.ts` composes the post-append journal — the validated bytes, then a line feed where they are nonempty and do not end with one, then the entry's canonical line and its terminator — for `reanalyzeRewritten` in `src/cli/commands/rename.ts`, both rewritten-workspace analyses in `src/cli/commands/move.ts`, and `appendJournalEntry` in `src/workspace/journal.ts`, which hands `appendDurableFile` the composition past `prior`, so the file written is the journal the rewritten workspace was validated against): no permission staging is involved, so the probe runs as root directly. Stage the one-group workspace (`specs: { main: ["specs/**/*.mdx"] }`; `specs/b/B.mdx` holding `<S id="b0">`, a line of text, `</S>`, a blank line, and `<S id="b1">` likewise; `specs/a/A.mdx` holding `<S id="a">` likewise), run `build`, `rename specs/b/B.mdx b0 b`, `truncate -s -1 .xspec/journal`, and `build` again (`check` exits 0); then, each on a fresh staging, `rename specs/b/B.mdx b c`, `move specs/b/B.mdx specs/c/C.mdx`, and `move 'specs/b/B.mdx#b1' 'specs/a/A.mdx#a.x'`, with and without `--preview`, exit 0 with no findings — each had exited 1 with a `journal-error` naming line 1, the model having joined the new entry to the unterminated line — the journal holding the two entries on two terminated lines, its prior bytes a byte prefix, and `check` exiting 0 (no `stale-output`: graph data's recorded journal fingerprint matches the file); a further rename appends a third line; and with the unterminated journal committed to git, `impact --base HEAD` after the rename exits 0 with no changes (`computeJournalReplay` compares lines without their terminators). Under the unprivileged namespace `section-6.1`, `section-6.4`, `section-6.5`, `section-6.6`, `section-13.5`, and `section-14-ii` together (35 tests) run in ~139 s. +- Hand-probing SPEC 14.25's read failures (Phase 10 FIX_PLAN Task 49; `readFailure`/`performRead` in `src/workspace/environment-refusal.ts`, raised by `probeOccupant` and `readableDirectoryEntries` in `src/workspace/writes.ts`, the discovery walk in `src/workspace/discovery.ts`, and `occupantOf` in `src/workspace/locate.ts`; `classifyOccupant` stays the raw errno-throwing judgement) uses the same `setpriv` recipe as the 14.24 probes above, staging each refusal by mode inside the script and restoring it there: `chmod 0100 specs/sub` refuses a discovered directory's listing with search kept (every configuration-loading command exits 2, `path` `specs/sub`), `chmod 0100 .xspec/reviews` the session directory's (`review list`, `inventory`, `check` exit 2, `path` `.xspec/reviews`; `build`, `ids` exit 0), `chmod 0600 .xspec/reviews` a session file's kind read (search refused, listing kept: `review list`/`review status s` exit 2, `path` `.xspec/reviews/s.json`), `chmod 0600 .xspec` the journal's kind read (`build`, `ids` exit 2, `path` `.xspec/journal`), `chmod 0600 ..` run from a nested working directory (the `chmod` made after the `cd`, inside the same subshell) the anchoring resolution's lookup of the working directory in its parent (`path` `..`, the examined directory in its anchoring form; since Task 77 the working directory is resolved before the upward search, whose first lookup had concerned `.`), `chmod 0600 cfg` a `--config cfg/xspec.config.ts` kind read (`path` `cfg/xspec.config.ts`), and `chmod 0100 .` the root's own listing (`path` `.` from the root, `..` from `specs/`). A registered test that stops at an earlier arm another task owns (T14-10 stopped at arm (b) until Task 50 landed) can have its later arms run alone through a temporary twin, deleted before committing: copy the registry module beside itself (`cp test/suite/registry/section-14-ii.ts test/suite/registry/section-14-ii.p6tmp.ts`) and append an `export { … }` of the module-private arm functions (`sessionContentArm`, `configurationContentArm`, `derivedContentArm`, `graphDataArm`, `discoveryListingArm`, `sessionDirectoryArm`), then add a temporary `test/suite/zz-<task>-twin.test.ts` declaring one Vitest `test` per arm that runs `runProductTestBody("T14-10", () => arm(builtProductBinding()))` (`test/helpers/product-invocations.ts`, `test/helpers/subprocess.js`) — never `declareProductTests`, which refuses an entry that is not the manifest's own — and run that file alone under the namespace (~17 s for the six arms); `rm` both files afterwards and confirm `git status --short test/` is empty. The three target files of Task 49 (`section-12.7.test.ts`, `section-14.test.ts`, `section-14-ii.test.ts`) run together in ~2 min under the namespace. The refused content reads that are their objects' own conditions (Phase 10 FIX_PLAN Task 50; `readJournalContent`/`refusedJournal` in `src/workspace/journal.ts`, `readStoredFile` in `src/workspace/graph-data.ts`, `graphDataAreaOccupant` in `src/workspace/writes.ts`) probe by the same recipe on a built workspace that has journaled one rename: `chmod 0200 .xspec/journal` makes `build`, `check`, `ids`, `query nodes`, and a `rename` exit 1 with the one `journal-error` concerning `.xspec/journal` (`inventory` exit 0, `occupied` true; `at` and `occurrences` answer, exit 0; `impact --base` and `review create --name r --base` exit 2 with the replay's baseline usage error, `code` null), and `chmod 0200 .xspec/graph.json .xspec/record.json` makes `inventory --json` and `move … --preview --json` exit 1 with the one `unreadable-record` concerning `.xspec` and `recorded`/`delta` `{"unavailable": true}`, `check` exit 1 with the unreadable-record unit form, `ids` exit 0 (the snapshot rewritten, the record left at mode 0200 until `build` replaces it). A refused kind read of `.xspec` itself needs an I/O error and cannot be staged by mode. T14-10 whole (`-t 'T14-10 '` on `section-14-ii.test.ts`) runs in ~29 s under the namespace (~35 s wall); Task 50's neighbours (`section-6.1`, `section-6.6`, `section-11.6`, `section-12.1-12.2`, `section-13.3`) together in ~102 s. +- Hand-probing SPEC 13.5's discovery before acquisition (Phase 10 FIX_PLAN Task 79; `runMutatingCommand` in `src/cli/commands/mutation.ts`, the one caller of `withMutationExclusivity`, runs `discoverWorkspace` and reports `discoveryConfigurationErrors` (`src/workspace/pipeline.ts`) before acquiring, then hands the classification to `analyzeWorkspace`, directly or through `analyzeGraphForRead` and `loadSessionForCommand`) uses the 14.25 `setpriv` recipe above: stage the one-group workspace with `specs/b/B.mdx` (`b` holding `b.k`) and `specs/sub/C.mdx` (`c`), run `build` and `review create --strategy audit --name s` (`review next s --json` names `item-3`, `b.k`'s `subtree-coherence` item), then, with `chmod 0100 specs/sub` or with the configuration adding `code: { app: ["specs/b/*.mdx"] }` (the spec/code overlap of 7.2), run each of `rename specs/b/B.mdx b b2`, `move specs/b/B.mdx specs/b/B2.mdx`, `move specs/b/B.mdx#b.k specs/sub/C.mdx#c.k`, `review create --strategy audit --name n`, `review resolve s item-3 --status skipped`, and `review split s item-3` with `--test-hold <hold> --json` under `timeout 4`: each exits 2 at once with `read-failure` (`path` `specs/sub`) or `configuration-error`, no hold file created (until Task 79 each created it and waited, `timeout` exiting 124). For the holder form, the script starts `review create --strategy audit --name h --test-hold <h1>` in the background and polls for `<h1>` with a `node -e 'setTimeout(()=>{},25)'` loop; a second mutating command without `--test-hold` then reports the exclusion usage error (`code` null) on the valid workspace but `read-failure` or `configuration-error` under those stagings; restore both, delete `<h1>`, and the holder exits 0. The probe runs in ~10 s. Under the namespace `section-13.5`, `section-6.4`, `section-6.5`, `section-6.6`, `section-10.7-i`, `section-10.7-ii`, `section-12.0-ii`, and `section-14-ii` together (51 tests) run in ~172 s. +- Hand-probing SPEC 14.10's staleness forms beside a refused write (Phase 10 FIX_PLAN Task 51; `mismatchStalenessFindings` and `recordStalenessFindings` in `src/workspace/check.ts`, gated in `checkCommand`, `src/cli/commands/check.ts`) needs no permission staging, so a scratch workspace probes as root: with `markdown: { emit: true, outDir: "out" }` and `out` a plain file, or a symbolic link to a real directory, `check --json` reports the one 14.22 concerning `out` and no mismatch form (per file or graph data), while the unreadable-record unit form and the recorded-file form still report beside a 14.22 (T10.1-6's `.xspec` plain-file arm). Under the namespace T11.2-6 and T13.4-6 together (`-t 'T11\.2-6 |T13\.4-6 '` over `section-11.2.test.ts` and `section-13.4.test.ts`) run in ~14 s, T14-4 alone (`-t 'T14-4 '` on `section-14.test.ts`) in ~63 s, and Task 51's six verification files (`section-14`, `section-13.4`, `section-11.2`, `section-12.1-12.2`, `section-13.3`, `section-10.1`) together in ~145 s. +- Hand-probing SPEC 14.10's validity-independent forms and 14.12's gate (Phase 10 FIX_PLAN Task 52; `discoveredGeneratedPaths` and `orphanedRecordedPaths` in `src/core/build.ts`, `passesBuildValidations` in `checkCommand`, `src/cli/commands/check.ts`) needs no permission staging, so a scratch workspace probes as root: three spec groups (`hi: ["specs/H*.mdx"]`, `lo: ["specs/L*.mdx"]`, `extra: ["extra/**/*.mdx"]`), a forbidden `hi`→`lo` rule with one violating `d={L.l1}`, and `markdown: { emit: true, outDir: "out" }`, built once and copied back before each probe. Dropping the `extra` group beside a garbage `.xspec/journal`, `check --json` reports the 14.13 with one `stale-output` per recorded `extra` path (the module, three companions, and `out/extra/E.md`) and no 14.12; renaming `specs/L.mdx` to `specs/L#.mdx` reports L's five recorded paths beside the 14.15 and 14.19; an unparseable `specs/L.mdx` orphans nothing (its derived paths are generated by name shape alone); `out` a plain file reports the 14.22 alone. Under the namespace T12.2-4 alone (`-t 'T12.2-4 '` on `section-12.1-12.2.test.ts`) runs in ~11 s; the full suite project at Task 52's product commit ran in 910 s (337 tests, 336 passed, T14-11 failing at arm (n), waiting on Task 68). +- Hand-probing SPEC 13.4's read side in 14.10's recorded-file form (Phase 10 FIX_PLAN Task 80; `readsReach` in `src/workspace/writes.ts`, asked by the orphan loop of `recordStalenessFindings` in `src/workspace/check.ts` before it classifies a recorded path's own occupant) needs no permission staging for its link cases, so a scratch workspace probes them as root: the one-group configuration plus `markdown: { emit: true, outDir: "old" }` and `specs/A.mdx`, `build`, then `outDir` changed to `"out"`; with `mv old real-old; ln -s real-old old` — or `old/specs` replaced by a link to the moved directory, or `old` a plain file or a dangling link — `check --json` reports `stale-output` concerning `.xspec` and `out/specs/A.md` alone (exit 1; `build` then exits 0, `real-old/specs/A.md` untouched, and `check` is clean), while the control without a link, and a link at `old/specs/A.md` itself, add the `old/specs/A.md` finding. The refused kind reads take the 14.25 `setpriv` recipe above: `chmod 0600 old` (search refused, so `old/specs`'s kind read is) makes `check` and `build` exit 2 with `read-failure` concerning `old/specs`; `chmod 0600 old/specs` keeps `check`'s `stale-output` for `old/specs/A.md` (exit 1) while `build`'s removal exits 2 with `read-failure` concerning `old/specs/A.md`; `old` a link to a directory staged `0600` reports nothing for it. The probe runs in ~10 s. Under the namespace `section-12.1-12.2`, `section-13.4`, `section-13.3`, `section-10.1`, `section-14`, and `section-11.2` together (39 tests) run in ~147 s. +- Hand-probing SPEC 14.22 beside source and journal findings (Phase 10 FIX_PLAN Task 81; `buildValidationFindings` in `src/workspace/build-validation.ts` over `discoveredWritePaths` in `src/core/build.ts`, used by `build`, `check`, `assessWorkspaceRead` in `src/workspace/refresh.ts`, and `rename`/`move`'s invalid-workspace refusal; the byte-form walk `obstructedByteComponentOf` in `src/workspace/writes.ts`) needs no permission staging, so a scratch workspace probes as root: the one-group configuration plus `markdown: { emit: true, outDir: "out" }`, a valid `specs/A.mdx`, and `out` a plain file; adding `specs/B.mdx` holding `<S id="b" d={missing}>`, or a garbage `.xspec/journal`, `build`, `check`, `ids`, `rename specs/A.mdx a a`, and `move specs/A.mdx specs/C.mdx --preview` each report the 14.22 concerning `out` beside the 14.8 or the 14.13 (exit 1, nothing modified), and on the otherwise valid workspace `rename` and `move` report it alone, never `refused-identity-unchanged` or `refused-invalid-destination`. A non-UTF-8 source path stages from its bytes (`printf '\377'` in the file name): `specs/<FF>.mdx` as the only source reports its 14.19 beside `out`'s 14.22, and `specs/<FF>/A.mdx` with `out/specs/<FF>` a plain file reports the 14.22 concerning that component in the byte form (`"path": {"bytes": "6f75742f73706563732fff"}`), while `out` a link to a directory holding that plain file reports `out` alone. The probe runs in ~5 s. Under the namespace the six neighbour files (`section-13.4`, `section-11.2`, `section-13.3`, `section-14`, `section-10.1`, `section-12.1-12.2`; 39 tests) run in ~145 s. +- Hand-probing `review list`'s 14.21 findings (Phase 10 FIX_PLAN Task 53; `reviewListCommand` in `src/cli/commands/review.ts`, whose `findings` member carries the finding `loadSession` in `src/workspace/reviews.ts` builds) needs no permission staging, so a scratch workspace probes as root: the one-group `xspec.config.ts` (`specs: { main: ["specs/**/*.mdx"] }`) and `specs/A.mdx` (`<S id="a">`, a line of text, `</S>`), `build`, `review create --strategy audit --name good`, then a garbage `.xspec/reviews/s.json` and a directory at `.xspec/reviews/a-b.json`; `review list --json` then reports two `corrupt-session` findings (12.7 order puts `a-b.json` first) beside the unchanged `sessions` listing, exit 1, and the human form appends the findings lines and count after the listing. `section-10.1.test.ts`, `section-10.7-i.test.ts`, and `section-14.test.ts` in one namespace run take ~140 s (T14-11 the one failure, at arm (n), waiting on Task 68). +- Hand-probing a `d` value's or an embedding's reference locations (Phase 10 FIX_PLAN Task 87; `deriveContentExpression` in `src/core/mdx-acorn.ts`, `classifyReferenceText` and `parseExpressionText` in `src/core/references.ts`, used by `analyzeDependencyValue` and `analyzeEmbedding` in `src/core/spec-references.ts`) needs no permission staging, so a scratch workspace probes as root: the `SPECS_ONLY_CONFIG` of `test/suite/registry/section-14.ts`, and `specs/A.mdx` written by `printf` per value as T14-11's 33-byte preamble then `<S id="r" d=…>` LF `R` LF `</S>` LF (the attribute at 43, its content at 46). `occurrences --json` carries the findings and the occurrences in one answer, so one `node -e` over it prints each finding's and occurrence's `[start,end)` beside the bytes it locates; `rename specs/A.mdx ok okay-v2 --json` on a valid staging checks the rewritten spellings. The structure — the value, its array literal's entries, a call's arguments — is remark-mdx's own derivation (acorn from the content's start, parentheses preserved), and each reference's own text is then classified alone by TypeScript's JavaScript-with-JSX reading (`ScriptKind.JSX`, parenthesized): where that reading is not one expression spanning the text, the reference is dynamic. Comparing the two readings over a battery of contents takes a scratch `.mjs` importing `mdxAcorn` and `MDX_ACORN_OPTIONS` from `dist/core/mdx-acorn.js` and `typescript` through `createRequire`; the disagreements found: a JSX element as the object of a member access, call, tagged template, `new`, or postfix update (`<b/>.x`: TypeScript parses JSX at the update-expression level), and, in a `.ts` or `.tsx` reading only, comparisons read as type arguments (`a<b, c>(d)`, `a<b>`, LF, `c`). The eight neighbour files the task named (`section-14`, `section-2.7`, `section-2.4`, `section-2.2-2.3`, `section-5.7`, `section-6.4`, `section-11.3`, `section-11.4`; 47 tests) run in ~146 s under the namespace. +- The known state after the Phase 10 plan (its Task 54, the plan's last task, which deleted the plan — its final text, whose index records how each of VERIFY's 82 failures at 3bfedb5 turned green, is `git show 8f5660d:specs/tmp/FIX_PLAN.md`; its Tasks 82–86 were closed by ruling into the residual-14.20 bullet above, and the deferred SPEC 6.5 gap is the note after that bullet): run facts at 8f5660d, the product as Task 89 left it (8a0da01) and the harness as Phase 9 left it (`git diff 3bfedb5..8f5660d -- test/` empty). `npm ci` was not needed (the installed `node_modules/.package-lock.json` matched `package-lock.json` entry for entry); `npm run build`, `npm run typecheck`, and `npm run format:check` ran clean. The full suite project against the built product (`unshare --map-user=1000 --map-group=1000 -- npx vitest run --config test/vitest.config.ts --project suite --reporter=verbose > <log> 2>&1` in the background, awaited by the `EXIT`-line poll above; 960 s on 4 workers): 77 files, 337 tests, all passed, none failed or skipped — the failed-ID set (the fourth-plan Task 24 recipe above) empty, `grep -oE '^[A-Za-z]*Error' <log>` empty, and `undeclared-staging`, `mdx-derivability`, `harness error`, `HarnessStagingError`, `ProductRunTimeoutError`, `ProductRunOutputOverflowError`, and `timed out` each 0 times; P-1 through P-13 and the E-6 Linux-leg exchange writer pass. Self project 22 files, 2950 passed, 0 skipped under the namespace (113 s); certification 144 PASS / 33 FAIL / 0 error / 0 hang over the 23 `certification run against` lines, the 33 FAILs being violators' expected outcomes. CI on 8f5660d (push-event run 36538179013, number 642, read as the CI-log bullet above describes): `harness-self` green (22 files, 2950 tests, 101 s); `suite-linux` green (`npm test`: 99 files, 3287 tests, all passed, 673 s, no ` FAIL ` summary line, certification 144/33/0/0 over 23 lines); `suite-windows` green (the E-6 subset: 3 files, 9 tests — the byte-identity comparison against the Linux leg's exchange, T11.6-1's drive-mismatch arm, T1.5-1, and T12.0-5 among them — on a tree including Task 77's Windows working-directory resolution, d5f8d0b, until then checked there by reading only). CI on c62f451, the Task 54 commit (this file and the plan's deletion alone, the product and harness trees 8f5660d's): run 36540673258, number 643, green on all three legs — `harness-self` 22 files, 2950 tests; `suite-linux` 99 files, 3287 tests, all passed in 1126 s, no ` FAIL ` summary line, certification 144/33/0/0 over 23 lines; `suite-windows` 3 files, 9 tests. No test is known to fail: the known-failing list the earlier plans kept in this bullet is empty, and every test must pass. diff --git a/package-lock.json b/package-lock.json index 070d7817..17f4a565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "acorn-jsx": "^5.3.2", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", - "typescript": "^5.9.3", + "typescript": "5.9.3", "unified": "^11.0.5" }, "bin": { @@ -21,7 +21,12 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-mdx": "^3.0.0", + "micromark": "^4.0.2", + "micromark-extension-mdxjs": "^3.0.0", "prettier": "^3.9.5", + "typescript-5.9.3": "npm:typescript@5.9.3", "vitest": "^4.1.10" }, "engines": { @@ -2237,6 +2242,21 @@ "node": ">=14.17" } }, + "node_modules/typescript-5.9.3": { + "name": "typescript", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 9242ff90..37a42785 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,17 @@ "acorn-jsx": "^5.3.2", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", - "typescript": "^5.9.3", + "typescript": "5.9.3", "unified": "^11.0.5" }, "devDependencies": { "@types/node": "^22.20.1", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-mdx": "^3.0.0", + "micromark": "^4.0.2", + "micromark-extension-mdxjs": "^3.0.0", "prettier": "^3.9.5", + "typescript-5.9.3": "npm:typescript@5.9.3", "vitest": "^4.1.10" } } diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index ebc2e76f..6fe26747 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -2,119 +2,140 @@ 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. Harness-side defects that make a conforming input or answer fail — builder, capture, decode, oracle, and driver defects — are spurious fails, not vacuous passes; TEST-SPEC.md gates them by self-test rather than certification (S-2, S-3, S-6, S-8, and S-9 — the last verifying, independently of any product, that every MDX source TEST-SPEC.md declares well-formed, the MDX sources the scopes below stage among them, derives under the grammar 14.20 fixes, that every TypeScript source and configuration file it declares well-formed is accepted by the release 14.20 fixes both as module code and as script code, and that every source it declares unparseable (14.20) is not — MDX text that does not derive, TypeScript text rejected under both readings: a class S-9 itself places outside certification, since a conformer judging 14.20 on its accepting side alone passes an ill-formed fixture exactly as its violators do), and no fixture here targets them. No Bug Report exists, so no fixture is justified empirically. Every test not named in an in-scope set below is deliberately uncertified (see Exclusions). There are no spec modules, so there are no module certification files. -Each conformer entry states its **scope** — the SPEC.md behaviors and command surface it implements and the workspace shapes it accepts — and its **in-scope tests**: the named subset (C-1) the certification runner executes against the conformer and each of its violators. Every in-scope test passes against the conformer. Each violator entry states its scope (its conformer's), its single deviation, the tests it certifies, and its expected failures: exactly the certified tests fail against it, and every other in-scope test passes. Where TEST-SPEC.md leaves an in-scope test's fixture content open and an expected-failure set — or the conformer's ability to pass within scope — depends on the choice, the entry states that choice as a **staging constraint** — a condition certification imposes on the harness's fixture for the named test, binding alongside C-1. +Each conformer entry states its **scope** — the SPEC.md behaviors and command surface it implements and the workspace shapes it accepts — and its **in-scope tests**: the named subset (C-1) the certification runner executes against the conformer and each of its violators. Every in-scope test passes against the conformer. Each violator entry states its scope (its conformer's), its single deviation, the tests it certifies, and its expected failures: exactly the certified tests fail against it, and every other in-scope test passes. Where TEST-SPEC.md leaves an in-scope test's fixture content open and an expected-failure set — or the conformer's ability to pass within scope — depends on the choice, the entry states that choice as a **staging constraint** — a condition certification imposes on the harness's fixture for the named test, binding alongside C-1. Every conformer, whatever its scope, locates configuration as 7 states — by upward search for `xspec.config.ts` from the working directory, or at the path `--config <path>` names, resolved against the working directory (12.0) — and resolves configured paths and globs relative to the configuration file's directory, the workspace root; `--test-hold <path>` likewise resolves against the working directory (12.0). An in-scope test may therefore run from any working directory H-2 lets it choose — T7-4's path-resolution staging from a subdirectory of the workspace included — and no fixture fails a test on that choice. Every conformer likewise reads its arguments under the invocation grammar of 12.0 — flag tokens standing anywhere, a value-taking flag taking the whole next token whatever it looks like, arity fixed by name across commands (`--test-hold` value-taking on every command, so `build --test-hold --json` consumes `--json` as its value and leaves JSON out of effect, T13.5-1), the remaining tokens matching the synopsis exactly, a surplus operand a usage error — and, with JSON in effect, reports every usage error in the exit-2 error document of 12.7: the grammar is universal (12.0), so no in-scope usage-error assertion is left to a fixture's own parser. ## 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`, `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), their argument checks of 12.0 (a nonexistent old ID a usage error, exit 2) and valid-workspace precondition (6.4, 6.5), reporting the applied mapping in the performed-operation form of 12.7; `rename --preview` only as T13.5-8's non-mutating boundary drives it — the refused form, a nonexistent old ID's usage error, acquiring nothing and creating no hold file (6.6), and `--test-hold` beside `--preview` the syntax-class usage error of 12.0 — a preview of a performable operation lying outside this surface; `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4, and — on a workspace whose graph data is stale but whose sources are valid (a section's text edited after `build`) — the 13.3 refresh a mutating `review` subcommand performs after the hold and before its own writes, writing graph data byte-identical to what `build` writes (13.3, T10.1-1), as T13.5-1's stale-workspace arm observes it; and `review create --base <ref>` only in T13.5-8's refused baseline arm — this git-less scope's unreadable-baseline case (6.3, T6.3-5), exit 2 after acquisition and the hold as `impact --base` is exit 2 — a staging constraint: that arm runs on a workspace lying in no repository, where every ref is unresolvable whatever its spelling. Validation within scope: `build`, `check`, the gate of 13.3 over every gated read and mutating `review` subcommand, and the precondition of `rename`/`move` detect and report — in the form of 14/12.7, exit 1, nothing written — the one condition an in-scope failing workspace stages, a staging constraint on T13.5-8: a second spec source beginning with a byte-order mark (14.20, 1.6), its finding the zero-length range at offset 0 (14), the file masked and never an operand of any arm's command, added after the workspace was built valid and the session its `review resolve` names was created under `--strategy audit`; nothing else in scope presents a finding. 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 `--coverage`; `--base` only in T13.5-8's refused arm above); every read command T13.4-5's byte-compares and T13.5-4's concurrent reads drive is drawn from the read surface enumerated above — a choice within the tests' latitude, neither enumerating its reads; the journal's sweep of every command surface is T6.1-1's (see Exclusions); T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held, and T13.5-8's modifies-nothing compares likewise bracket each excluded or refused invocation alone, no `build` or read command running inside a bracket or while any command is held — the bracketing both VIOL-CORE-EARLYWRITE's and VIOL-CORE-CHATTYREADS's passing sides lean on; and every other mutating command the in-scope tests start — T13.5-2's held and excluded commands, T13.5-3's killed and subsequent commands, T13.5-4's held mutator, T13.5-8's refused valid-workspace commands (its nonexistent-old-ID and baseline arms) — starts on a freshly built workspace with no refresh pending, as T13.5-1's own basic arm stages itself (T10.1-1), T13.5-1's stale-workspace arm alone starting one on stale graph data, and T13.5-8's failing-workspace arms — the held `review resolve`, the excluded commands, and the gate arm — starting where the gate writes nothing (13.3) — the freshness constraint VIOL-CORE-EARLYREFRESH's passing side leans on. Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included — its ordering before every modification, the 13.3 refresh included; its acquisition point before the argument checks of 12.0, baseline resolution (6.3), and the gate and refresh of 13.3 (T13.5-8); and its refusal by non-mutating commands as an unknown flag of the syntax class (12.0, T13.5-1). Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. -**In-scope tests:** T6.1-1, T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5. +**In-scope tests:** T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5, T13.5-8. -**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T6.1-1, T13.4-5, and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. +**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4, T13.5-8) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T13.4-5 and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. T13.5-1's stale-workspace arm is the seam's sharpest ordering case: the one file its pending refresh would touch is graph data, so a while-held snapshot scoped to sources and durable files passes the arm against every product, and no other in-scope failure isolates that omission. T13.5-8 is the seam's acquisition-point case: its seam-ordering arms wait for a hold file on invocations a later check refuses — a product acquiring late never creates one there, exiting at once — so a wait that tolerates the hold's non-appearance, proceeding on the command's exit or on a timeout, passes those arms against any product, and no other in-scope fixture ever leaves a held invocation's hold uncreated; VIOL-CORE-LATELOCK isolates exactly that on the two arms the argument checks of 12.0 and baseline resolution (6.3) refuse — the only arms it fails, so a tolerant wait passes T13.5-8 against it whole — and VIOL-CORE-NOLOCK certifies beside it the exclusion-first arm's exit-code discrimination (2, never the gate's 1). VIOL-CORE-LATELOCK's acquisition point stops short of the gate deliberately: a deviation acquiring past the gate as well never holds the exclusion-first arm's `review resolve` on the failing workspace, and that arm then fails on the exit codes VIOL-CORE-NOLOCK already discriminates — 1 where 2 is asserted — so T13.5-8 fails against such a fixture whatever its wait tolerates, and the fixture could fail certification only jointly with VIOL-CORE-NOLOCK; the gate arm's own wait is on that ground isolated by no violator. T6.1-2 is in scope as the journal-write control the lock and journal violators' passing sides cite (its determinism compare unchanged under VIOL-CORE-EARLYWRITE, VIOL-CORE-EARLYREFRESH, and VIOL-CORE-CHATTYREADS) and is targeted by no violator: it asserts a positive byte-equality between two product-written entries — neither negative, temporal, nor seam-routed — so a deviation fails it loud, not vacuously. T6.1-1 sweeps every command surface and lies outside this scope (see Exclusions). ### VIOL-CORE-NOLOCK * **Scope:** CONF-CORE. * **Deviation:** Mutating commands do not exclude one another. The hold file is still created before any modification and honored, but a second mutating command started while another runs or is held is not refused: it proceeds normally instead of failing with the usage error of 13.5/12.0. -* **Certifies:** T13.5-2. -* **Expected failures:** exactly T13.5-2 (the second command succeeds and modifies the workspace while the first is held) — a staging constraint: T13.5-2's excluded commands carry no `--test-hold`; given one at the holding command's hold path (harmless against the conformer, which refuses them before hold creation), they would under this deviation fail exit 2 on the occupied path without modifying anything, and the expected failure would never materialize. All other in-scope tests pass: T13.5-1 drives one mutating command, T13.5-3's two run sequentially (the second starts only after the first is killed), and T13.5-5's repeated `build`s are not mutating commands (13.5); T13.5-4's commands concurrent with the held mutator are non-mutating; the journal, durable-file, and review-read tests involve no concurrent mutators. +* **Certifies:** T13.5-2, T13.5-8. +* **Expected failures:** exactly T13.5-2 (the second command succeeds and modifies the workspace while the first is held) and T13.5-8 (its exclusion-first arm: each mutating command started while `review resolve` is held on the failing workspace is not refused — it proceeds to the gate, or to `rename`'s precondition, and exits 1 with the condition-20 finding where the arm asserts the exclusion's exit 2, modifying nothing either way; its seam-ordering and non-mutating-boundary arms, each run with no other holder, are unmoved) — a staging constraint: T13.5-2's and T13.5-8's excluded commands carry no `--test-hold`; given one at the holding command's hold path (harmless against the conformer, which refuses them before hold creation), they would under this deviation fail exit 2 on the occupied path without modifying anything, and the expected failures would never materialize. All other in-scope tests pass: T13.5-1 drives one mutating command, T13.5-3's two run sequentially (the second starts only after the first is killed), and T13.5-5's repeated `build`s are not mutating commands (13.5); T13.5-4's commands concurrent with the held mutator are non-mutating; the journal, durable-file, and review-read tests involve no concurrent mutators. ### 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 — creation failing on an occupied path as 13.5 states, the exit-2 refusal then following the completed writes — waits for its deletion, and exits normally. An invocation without `--test-hold` creates no hold file (13.5) and degenerates to conforming behavior, where the deviation is unobservable; and the argument checks of 12.0, baseline resolution (6.3), the gate of 13.3, and the valid-workspace precondition of `rename`/`move` (6.4, 6.5) are judged after acquisition and before the writes they gate, as the conformer judges them, while the exit they refuse with is deferred as the writes' completion is: an invocation they refuse creates the hold file having written nothing, waits for its deletion, and only then exits with its error — the deviation unobservable there too. * **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 — in the stale-workspace arm the refresh and the session write alike — and its occupied-hold-path arm fails too: the operation's writes land before hold creation fails, so the exit-2 refusal leaves a modified workspace where the arm asserts nothing modified) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — under the scope's bracketing constraint (the compare's snapshot taken while command 1 is already held), this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5); and T13.5-8's invocations are refused or excluded before any write, the hold placed among no writes as the conformer places it, its exclusion-first arm refused on acquisition as before. + +### VIOL-CORE-EARLYREFRESH + +* **Scope:** CONF-CORE. +* **Deviation:** The 13.3 refresh a mutating `review` subcommand performs on a stale workspace (T10.1-1) runs before workspace exclusivity is acquired, so stale graph data is rewritten before the hold file is created. A single deviation: one ordering rule of 13.5 (the hold precedes every modification, the refresh included) broken for the refresh alone; the hold file is still created after exclusivity and before every other write — the session write, `rename`/`move`'s edits, journal appends, and the finishing regeneration of 6.4/6.5 — and a workspace whose graph data is current is refreshed by nothing, where the deviation is unobservable — as is a workspace failing `build`'s validations, on which the refresh writes nothing early or late (13.3) and the gate's findings, like every usage error, are reported only after acquisition and the hold (T13.5-8's failing-workspace arms). +* **Certifies:** T13.5-1. +* **Expected failures:** exactly T13.5-1 (its stale-workspace arm: while `review create --strategy audit --test-hold` is held, graph data already holds the refreshed content, not its pre-invocation bytes; the arm's post-release observations, the seam-neutrality compare — both twins refresh, to identical final states — and every other arm, staged on freshly built workspaces, are unmoved, so the test fails on the stale arm's while-held compare alone). All other in-scope tests pass under the scope's freshness constraint: no other in-scope mutating command starts with a refresh pending — T13.5-8's refused valid-workspace arms included, its failing-workspace arms refreshing nothing (above) — so none refreshes at all — and one that did would write graph data alone, outside T13.5-2's journal, sessions, and sources compare and byte-identical to what T13.5-4's concurrent reads would themselves write (13.3, 12.0) — while T6.1-2, T10.4-5, T13.4-5, and T13.5-5 observe journal, session, and derived-file behavior where the ordering is unobservable: `build` and the reads refresh as the conformer does, and a mutating `review` subcommand driven on stale graph data — T10.4-5's `resolve`, when it follows the staleness-inducing edit before any read has refreshed — refreshes early but, with no seam and no concurrent command in play, writes exactly the bytes the conformer writes after its hold (13.3), to the same final state. ### VIOL-CORE-STALELOCK * **Scope:** CONF-CORE. * **Deviation:** Workspace exclusivity is not released by abnormal termination: after a mutating command's process is killed, every later mutating command in that workspace is refused with the usage error of 13.5/12.0. Normal completion still releases. * **Certifies:** T13.5-3. -* **Expected failures:** exactly T13.5-3 (the subsequent mutating command after a kill is refused instead of succeeding). All other in-scope tests pass: no other in-scope test kills a mutating command, and normal completion behaves as the conformer. +* **Expected failures:** exactly T13.5-3 (the subsequent mutating command after a kill is refused instead of succeeding). All other in-scope tests pass: no other in-scope test kills a mutating command — T13.5-8's held, excluded, and refused commands all terminate normally — and normal completion behaves as the conformer. ### 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. +* **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 — T13.5-8's invocations, refused or excluded, write none; 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. -* **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. +* **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. +* **Certifies:** T13.4-5. +* **Expected failures:** exactly T13.4-5 (its journal byte-compares under `build` and the read commands fail — each compared invocation the scope's read surface answers exit 0 appends; in this git-less scope `impact --base` is refused exit 2 and appends nothing, which leaves `build` and the remaining reads to fail the compare; its session-file compares and its never-regenerated arm are untouched). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends under two staging constraints — one on T13.5-1: its held run and its no-hold twin drive the same command sequence, and neither drives `build` or a read command between the mutating command's start and the final-state compare — the seam flag rides the mutating command, which this deviation leaves unchanged, so the appends are byte-identical on both sides, and no read while held breaks the while-held byte-identity or desynchronizes the twins' journals; and one on T13.5-4: its final-`build` byte-equality to a clean build compares derived files (13.4) alone — the inconsistency it resolves is derived-file inconsistency (13.5), and the compare's extent is otherwise within the test's latitude — which the inertness condition keeps independent of the two workspaces' differing appended-line counts, where a whole-workspace compare would see the storm's many inert lines against the clean twin's one and fail the test against this violator; T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing; and T13.5-8's compares bracket its excluded and refused invocations alone under the same constraint — mutating commands, unchanged, and a `rename --preview` refused exit 2, which appends nothing — no `build` or read command running inside a bracket or while a command is held, and the `build` or `check` that establishes its failing workspace, exiting 1 and appending one inert line, running outside every bracket. +* **Note:** the representative value of this violator for the suite's other never-modifies assertions (T6.1-1's journal sweep above all — see Exclusions — and T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.5-20, T6.5-21, T6.6-2, T13.4-9, T13.4-10, 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 * **Scope:** CONF-CORE. * **Deviation:** Review reads persist read-time invalidation: when `status`, `next`, `show`, or `export` computes that a resolved item's recorded state differs from the current graph (10.4), it rewrites that item's stored status to `invalidated` in the session file. Reads over sessions with no stale resolution write nothing. * **Certifies:** T10.4-5. -* **Expected failures:** exactly T10.4-5 (the session file is not byte-identical across a read that computes invalidation). All other in-scope tests pass under a staging constraint: T13.4-5's fixture sessions contain no stale resolution when its `build`-and-read byte-compares run — staleness under reads belongs to T10.4-5's fixture — and no other in-scope test reads a session with a stale resolution. +* **Expected failures:** exactly T10.4-5 (the session file is not byte-identical across a read that computes invalidation). All other in-scope tests pass under a staging constraint: T13.4-5's fixture sessions contain no stale resolution when its `build`-and-read byte-compares run — staleness under reads belongs to T10.4-5's fixture — and no other in-scope test reads a session with a stale resolution — T13.5-8 drives no review read at all, its held `review resolve` turned back by the gate and its excluded commands refused before any session is read. + +### VIOL-CORE-LATELOCK + +* **Scope:** CONF-CORE. +* **Deviation:** Workspace exclusivity is acquired late: a mutating command acquires it — and creates its hold file — only once the argument checks of 12.0 and baseline resolution (6.3) have passed, instead of before them (13.5) — the two checks 12.0 places ahead of source validation, and the only ones this deviation moves. A single deviation: 13.5's acquisition point moved past those two checks and no further; the gate and refresh of 13.3, the valid-workspace precondition of `rename`/`move` (6.4, 6.5), the operation's own validation (6.4, 6.5, 10.7), and every modification still follow acquisition and the hold as 13.5 orders them, a second mutating command is still refused on acquisition — now after those two checks — and the hold file still precedes every write. Observable exactly where an argument check or baseline resolution refuses: such an invocation exits 2 with that usage error having acquired nothing and created no hold file, `--test-hold` or not, and, started while another mutating command is held, is refused for that reason in the exclusion's place — exit 2 either way; an invocation passing both checks — every performable one, and every one the gate or the precondition turns back — acquires, holds, and proceeds or is refused exactly as the conformer's does. +* **Certifies:** T13.5-8. +* **Expected failures:** exactly T13.5-8 (its two seam-ordering arms refused ahead of the gate: `rename specs/A.mdx nope x` — the nonexistent old ID's usage error, 12.0 — and `review create --base <ref> --name n` — the unreadable baseline's, 6.3 — each under `--test-hold`, exit 2 at once with no hold file ever created, where the arm waits for the hold and asserts the exit only after its deletion; every other arm is conforming: on the failing workspace, `review create --strategy audit --name n --test-hold`, naming no baseline, passes its argument checks and acquires, the hold created before the gate — the workspace byte-identical while held, exit 1 with the condition-20 finding only after deletion — and the exclusion-first arm's holding `review resolve` holds likewise, the session it names existing and its `--status` value in vocabulary, the item ID being judged only past the gate (12.0), so `review create`, `review resolve`, and `rename specs/A.mdx a b`, each passing its own argument checks, are refused on acquisition — exit 2, promptly, nothing modified — as against the conformer; the non-mutating boundary is unmoved, a preview acquiring nothing on either side) — a staging constraint: `a` is an ID `specs/A.mdx` declares, as the arm presumes in ruling out the precondition's exit 1, so no argument check refuses that `rename` in the exclusion's place. All other in-scope tests pass: every other in-scope mutating invocation is performable on a valid workspace and names no baseline — its argument checks pass, so acquisition and the hold precede the gate, the refresh, and every modification as the conformer's do — hence T13.5-1's while-held byte-identity (its stale-workspace arm included, the refresh's writes following the hold), its occupied-hold-path arm (creation failing after the argument checks, exit 2, nothing modified), its seam-neutrality compare, and its non-mutating refusals hold; T13.5-2's excluded command, started on a valid workspace, passes its argument checks and is refused on acquisition — exit 2, nothing modified, still promptly, no wait being involved; T13.5-3's kill still releases and its subsequent command still acquires; T13.5-4's held mutator holds as before; and the journal, durable-file, session-read, and atomic-write tests (T6.1-2, T13.4-5, T10.4-5, T13.5-5) observe no check ordering. ## 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 — values in either quote kind (2.7), as P-1's staging discipline spells them, read verbatim (2.4: no escape sequence or character reference interpreted, so an escape- or reference-spelled value is a segment or tag containing `\` or `&`, condition 4, never its interpreted spelling — T1.4-1, T1.4-4), the bearers nested as deeply as P-1's `.`-bearing draws stage them (T1.3-2..4) — (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git, and no brace content beyond T1.3-6's braced value, so the ECMAScript classes 1.4 excepts for 14.20's judgements and 5.7's token bounds reach no in-scope fixture — within scope the classes of 1.4 alone are exercised. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 — 14.4 one finding per offending `id` or `tags` attribute, located at the attribute (14, T14-11), a descendant spelling a malformed ancestor segment as its own prefix reporting in its own attribute too — and 14.17 as T1.3-6's invalid-form arms stage it: a repeated `id`, a braced value, and the valueless bare name (`<S id>`), each condition 17 and never condition 1, each masking condition 2 for the bearer's immediate children as an absent `id` does (14.1, 14.2) — (file, location, condition identity with its stable code where the report form carries one — 14, 12.7 — 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags in the set form of 12.7 (byte order, duplicates collapsed, `[]` when tagless), 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 — the whitespace and control classes, `.`, `#`, the forbidden names, 1.4's quote-and-escape bullet (the quote, escape, and character-reference characters `"` `'` `\` `&`, and U+2028 and U+2029, in neither the whitespace nor the control class), and U+FFFD), 2.4's verbatim reading of attribute values, 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 a corruption loud in two cases: staging that yields 14.20 or a clean build fails the arm against the conformer, and staging that takes every arm a violator moves in a test out of 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. U+2028 and U+2029 sharpen both hazards: barred by 1.4's quote-and-escape bullet though in neither the whitespace nor the control class, they are line separators that line-splitting and text-normalizing tooling rewrites into the whitespace class's terminators — a corrupted arm then still sees 14.4 — and P-1 weights its draws toward them as invalid values. Certification does not reach a corruption confined to some of the arms a violator moves in a test. Each violator moves several jointly — VIOL-VALID-CTRL T1.4-1's three control-character arms and T1.4-4's two, VIOL-VALID-WIDE the U+00A0 and U+0085 arms of T1.4-2 and T1.4-4, VIOL-VALID-SEP the U+2028 and U+2029 arms of T1.4-1 and T1.4-4, each also P-1's reach of its code points — and C-1 judges whole tests: while any arm the violator moves in a test stays in the class, the test fails against it all the same, though the arms catch different products — a product barring U+2028 but not U+2029 is caught only by the U+2029 arms, one whose whitespace class holds U+00A0 but not U+0085 only by the U+00A0 arms. The document accepts that residual rather than staging a violator per code point; line-separator normalization, the hazard VIOL-VALID-SEP answers, rewrites both of its code points at once. T1.3-1–T1.3-6 and T2.6-1/T2.6-2 are in scope as controls — the structural, duplicate, masking, and tag-splitting behavior the violators' passing sides cite as unchanged — and are targeted by no violator: T2.6's assertions are positive, and the 1.3 negatives lack 1.4's hazard — their staging is plain nesting and repeated or duplicated `id`s, which no tooling silently normalizes, their conditions are distinct per rule or carry the expected form (14.1–14.3, 14.17), and T1.3-6's masking absences are braced in-test by the findings asserted beside them — so a staging miss fails loud rather than passing vacuously. ### VIOL-VALID-CTRL * **Scope:** CONF-VALID. * **Deviation:** The control-character rule of 1.4 is not enforced for code points outside the whitespace class: segments and tags containing U+0000–U+0008, U+000E–U+001F, or U+007F are accepted as valid. Whitespace characters (U+0009–U+000D, U+0020) remain rejected in segments, and tag splitting is unchanged. * **Certifies:** T1.4-1, T1.4-4, P-1. -* **Expected failures:** exactly T1.4-1 (its control-character representative arms U+0000, U+001F, U+007F build instead of failing 14.4), T1.4-4 (its control-character tag arms U+0000, U+007F build), and P-1 (generated segments/tags in the non-whitespace control class are accepted where 1.4 rejects them). All other in-scope tests pass: no other in-scope test stages non-whitespace control characters, and structural, duplicate, whitespace, forbidden-name, boundary-class, and tag-splitting behavior are unchanged. +* **Expected failures:** exactly T1.4-1 (its control-character representative arms U+0000, U+001F, U+007F build instead of failing 14.4), T1.4-4 (its control-character tag arms U+0000, U+007F build), and P-1 (generated segments/tags in the non-whitespace control class are accepted where 1.4 rejects them). All other in-scope tests pass: no other in-scope test stages non-whitespace control characters, and structural, duplicate, whitespace, forbidden-name, boundary-class, quote-and-escape-bullet (U+2028 and U+2029 among it), U+FFFD, verbatim-reading, and tag-splitting behavior are unchanged — T1.4-1's and T1.4-4's arms under those rules, and P-1's draws under them, still fail 14.4 or build as asserted, so each certified test fails on its control-character arms alone. ### VIOL-VALID-WIDE * **Scope:** CONF-VALID. -* **Deviation:** U+00A0, U+0085, and U+2028 are treated as whitespace for 1.4 validity: a segment or tag containing any of them is rejected with 14.4. Tag splitting and all other classifications are unchanged. +* **Deviation:** U+00A0 and U+0085 are treated as whitespace for 1.4 validity: a segment or tag containing either is rejected with 14.4. Tag splitting and all other classifications are unchanged. * **Certifies:** T1.4-2, T1.4-4, P-1. -* **Expected failures:** exactly T1.4-2 (segments containing the three code points fail instead of building), T1.4-4 (its boundary arm — the same character classes applied to tags — fails), and P-1 (generated values containing the three code points are rejected where 1.4 accepts them). All other in-scope tests pass: T1.4-1 stages none of the three code points, and T2.6-1/T2.6-2 split only on the true whitespace characters of 1.4. +* **Expected failures:** exactly T1.4-2 (segments containing the two code points fail instead of building), T1.4-4 (its valid-boundary arm — tags containing U+00A0 or U+0085 — fails), and P-1 (generated values containing the two code points are rejected where 1.4 accepts them). All other in-scope tests pass: T1.4-1 stages neither code point (its quote, escape, reference, U+2028, U+2029, U+FFFD, and verbatim-reading arms lie under rules the deviation leaves enforced), and T2.6-1/T2.6-2 split only on the true whitespace characters of 1.4. + +### VIOL-VALID-SEP + +* **Scope:** CONF-VALID. +* **Deviation:** 1.4's bar on U+2028 and U+2029 is not enforced: a segment or tag containing either is accepted as valid. A single deviation: one clause of 1.4's quote-and-escape bullet dropped; `"`, `'`, `\`, and `&` remain barred, and neither code point joins the whitespace class, so tag splitting (2.6) is unchanged — a tag containing either is kept whole — as is every other rule and class. +* **Certifies:** T1.4-1, T1.4-4, P-1. +* **Expected failures:** exactly T1.4-1 (its U+2028 and U+2029 arms build instead of failing 14.4), T1.4-4 (its U+2028 and U+2029 tag arms build), and P-1 (generated segments and tags containing either code point are accepted where 1.4 rejects them). All other in-scope tests pass: T1.4-2 stages neither code point (its valid boundaries are U+00A0 and U+0085), no other in-scope test stages them, and every other arm of T1.4-1 and T1.4-4 — and P-1's draws holding neither — fails 14.4 or builds as asserted, so each certified test fails on its arms holding the two code points alone. ## 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, every file well-formed MDX (14.20) and valid, with imports (2.1, valid forms as staged: semicolon-terminated or not — a spelled `;` among the declaration's own characters, 14.20 — several to one ESM block, two on one physical line separated by U+2028, an ECMAScript line terminator under which the block derives (T3-3's ESM-block arm), and JavaScript comments beside them in their block — `// note` after an import, an own-line `// note` between two imports, a block comment before one — content under 3, no MDX comment (T3-7's forms, as P-2 composes them)), same-file and cross-file `text(...)` embeddings (2.3) in every positive form of T2.3-3 as P-2 composes them — the bare `{text(...)}`, whitespace beside the call, block comments before and after it, a line comment before it ended by its terminator, and the run-on `{// c}` form holding the call on the next line, the container running to the second `}` (14.20) — each replaced whole, opening brace through closing brace, comments, whitespace, and interior terminators included (3, 2.3), MDX comments in every form of 2.7 as P-2 composes them — `{/* … */}` single- and multi-line, `{}`, block-comment sequences, line-comment containers ended by U+000A or U+000D before the closing brace, and the run-on `{// c}` form closed by a later `}` (14.20's deletion judgement: a brace on a commented-out line closes nothing) — with ECMAScript's whitespace and line terminators between braces — U+FEFF, U+2028, U+2029, and every space separator of Unicode 15.1 but U+0020 (U+00A0, U+1680, U+2000 through U+200A, U+202F, U+205F, and U+3000; 14.20), T2.7-4's positive forms as P-2 draws them — each removed whole (3), 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, an opening tag spanning several lines among them (T3-3's multi-line-construct arms — its two multi-line comment arms and its three multi-line opening-tag arms: a terminator among a removed construct's own characters is deleted with it, the lines the construct spans merging into one, dropped when left empty purely by the removal and kept with its residue otherwise, 3 — each a well-formed file under the flow and in-line tag positions 14.20 fixes, S-9 gating the shapes); `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 identity, source range (1.7), own and subtree text (1.6, defined through the rules of 3), and its `contains` edges (5.2) — the outgoing ones naming its children — and `query nodes` in its unfiltered form, one row per requirement node (11.1), as the enumeration of a document's nodes; 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). Staging constraints: every command the in-scope tests drive is drawn from this surface — P-3, whose route TEST-SPEC.md leaves open, reads each node's own and subtree text from `query node`, takes its children, in document order, from the same answer's outgoing `contains` edges ordered by the children's source ranges, and enumerates a document's nodes from `query nodes` or from the generated document itself — never through `view` (with or without `--text`) or `query subtree`, neither served here; and the hashes, tags, coverage attribute, and dependency edges `query node` and the `query nodes` rows also carry (11.1) are consulted by no in-scope test, their content out of scope. Validation within scope: nothing in scope presents a finding — every staged form is well-formed and valid, which S-9 verifies independently of any product, a conformer judging on its accepting side alone passing an ill-formed fixture as its violators do — so the conformer's judgement of 14.20 and 14.16 is exercised on its accepting side alone: the classification of each staged container as a comment, an embedding, or, in an ESM block, content, by the whitespace-and-comments judgement of 14.20 with ECMAScript's whitespace and line terminators, its space separators Unicode 15.1's, while 1.4's classes govern line dropping in every line of the file, one within an ESM block or braces included (3, 1.4); the negative arms of T2.7-4, T2.3-3, and T14-12 — invalid containers and unparseable files — lie outside this scope. Contracts under certification: 3 in full — removal (an import's by its declaration's characters alone, a spelled `;` included, a comment beside it in its block staying as content; a comment container's whole, opening brace through closing brace), replacement (an embedding's whole container, whatever stands beside the call), the line-drop rule over merged lines included, with 1.4's classes in every line of the file (a line left holding U+2028 alone between two removed imports of one block is kept, T3-3), line terminators, the parse-not-pattern grammar boundary — the accepting side of 14.20 as far as the staged forms reach it — 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). P-3 is likewise in scope and targeted by no violator: its equalities compare the product's answers to each other — `query node`'s subtree text against the product's own compiled output and against the texts of the children its `contains` edges name (1.6, 5.2) — so no harness oracle stands between them to mis-trust and a divergence is loud, not vacuous; the two violators are deliberately consistent across Markdown output and text so that P-3 passes under both, its passing side anchoring that the harness compares the product to itself rather than to an expectation of its own. T3-1, T3-2, and T3-5 are in scope and targeted by no violator as positive controls: byte-asserted emissions — removals, chained replacement, in-line tags — whose next-to-source destinations anchor T3-6's negative half (above) and whose fixtures, holding none of the two violators' bytes under the staging constraints their entries state, anchor the violators' passing sides. P-2's generator also composes comment and embedding forms — ESM blocks carrying JavaScript comments beside their imports (T3-7), comments in every form of 2.7 with ECMAScript-only whitespace between braces (T2.7-4), and embeddings with comments beside the call (T2.3-3) — and the scope admits each: a conformer misclassifying one — a block's `// note` removed as an MDX comment, a line-comment container ended at its first `}`, a container holding U+00A0 alone refused as unparseable — fails P-2 on the draw that reaches the form, a spurious fail, while a product misclassifying one fails the byte-asserted emission of the form's deterministic twin loud; so those classes justify no violator, and the twins stay outside this scope — T3-7 and T2.7-4 assert `view`'s `comments`, and T3-7 its `imports`, beside their emissions, which this read surface does not serve, and T2.3-3 and T2.7-4 carry negative arms (see Exclusions). T2.1-6's ESM block inside a section lies outside this scope likewise: its dropped declaration line is a positive byte-asserted emission, loud against a product compiling the block as content, and its `view` listing and the `query edges` its references arm rides are not served here. ### VIOL-MD-CLASS * **Scope:** CONF-MD. -* **Deviation:** The line-drop rule classifies U+00A0, U+0085, and U+2028 as whitespace when deciding whether a line is left empty or whitespace-only — consistently in Markdown output and, through 1.6, in own and subtree text. A line left holding only those code points after removals is dropped with its terminator. +* **Deviation:** The line-drop rule classifies U+00A0, U+0085, and U+2028 as whitespace when deciding whether a line is left empty or whitespace-only — consistently in Markdown output and, through 1.6, in own and subtree text. A line left holding only those code points after removals is dropped with its terminator. A single deviation: one classification of the line-drop rule; the judgement of what brace and ESM-block content is whitespace and comments (14.20) and the ESM block's line terminators — U+00A0 and U+2028 ECMAScript whitespace or line terminators there on either side, so `{` U+00A0 `}` is a comment and two imports separated by U+2028 derive for the violator as for the conformer — are unchanged. * **Certifies:** T3-3, P-2. -* **Expected failures:** exactly T3-3 (its class-boundary arms: lines left holding only U+00A0, U+0085, or U+2028 are dropped instead of kept) and P-2 (the oracle keeps such lines; generated content weighted toward the boundary code points reaches the divergence). All other in-scope tests pass: a staging constraint keeps the three code points off their fixtures' removal-affected lines, and P-3 compares the product's text values to its own compiled output, which the consistent deviation keeps equal. +* **Expected failures:** exactly T3-3 (its class-boundary arms: lines left holding only U+00A0, U+0085, or U+2028 are dropped instead of kept — the ESM-block arm's line, left holding U+2028 alone between its two removed imports, among them; its U+2029 arm and its multi-line-construct arms — the merged comment lines and the three multi-line opening-tag arms — holding none of the three, are unmoved) and P-2 (the oracle keeps such lines; generated content weighted toward the boundary code points reaches the divergence). All other in-scope tests pass: a staging constraint keeps the three code points off their fixtures' removal-affected lines, and P-3 compares the product's text values to its own compiled output, which the consistent deviation keeps equal. ### VIOL-MD-CR * **Scope:** CONF-MD. -* **Deviation:** A lone U+000D is not recognized as a line terminator by the line model of 3 — consistently in Markdown output and, through 1.6, in own and subtree text. CRLF and lone U+000A remain terminators; a lone U+000D is an ordinary in-line character. +* **Deviation:** A lone U+000D is not recognized as a line terminator by the line model of 3 — consistently in Markdown output and, through 1.6, in own and subtree text. CRLF and lone U+000A remain terminators; a lone U+000D is an ordinary in-line character. A single deviation: one terminator of the line model of 3; the deletion judgement of 14.20, which ends a line comment at U+000A or U+000D alike, and the ESM block's line terminators are unchanged — a line-comment container ended by a carriage return (T2.7-4's twin, a form P-2 composes over mixed terminators) is a comment on either side, removed whole with the U+000D among its own characters, the residues about it joining into one line as they do for the conformer. * **Certifies:** T3-4, P-2. * **Expected failures:** exactly T3-4 (its lone-CR arms: line boundaries, and therefore the drop rule's line extents, diverge byte-wise) and P-2 (generated documents over mixed terminators reach lone-CR lines where the oracle diverges). All other in-scope tests pass: a staging constraint keeps lone U+000D out of their fixtures — they use terminators the deviation leaves recognized — and P-3's internal consistency is preserved as in VIOL-MD-CLASS. ## 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 and, as T7-6's code-group exclusion arm and T7-4's literal-`\` arm stage them, code groups (7.2) of well-formed `.ts` sources spelling no marker, `text` call, or module-linking form whose specifier names a spec module or designates a derived-file path (4, 14.15) — each discovered code source an edgeless whole-file code location (4.6), nothing in scope giving a code file an edge — both kinds under the glob grammar of 7 (a no-match group, and the empty `specs` and `code` maps, are valid with zero sources); as T7-6's invalid-source arm stages them (below), a spec source at a path 7.1 bars and a code-group file that is no well-formed TypeScript; imports of 2.1's single-default-binding form, resolving lexically against the importing file's directory to a discovered source (2.1), an undiscovered target failing with 14.15; `markdown` with `emit: true` as T7-6's destination arm stages it and as its invalid-source arm pins it, next to sources, and emission disabled — `markdown` absent or `emit: false`, either spelling 7.3 admits — as that arm's control stages it, destinations classified by configuration alone (7.3); symbolic links present in the tree; no `coverage`, `policy`, or git; content of derived and emitted files beyond path is out of scope. Staging constraints: T7-6's exclusion arms but its invalid-source arm are staged on both group sides — the spec side observed through `ids`, the code side through `query edges --from <path>` (11.1): on a workspace passing `build`'s validations, each excluded path a code glob matches — the module `build` generated next to its source (13.1) above all, a file under `.xspec/`, and an enabled emit destination — is refused as a path in no configured group (exit 2, 12.0), beside a discovered code source's whole-file location answering exit 0, T7-3's idiom for code discovery; and the staged code globs match, beyond those derived-classified paths, only the well-formed `.ts` sources above, no spec-group file among them; and no derived-classified path lies within the reach of both a spec glob and a code glob — the `.xspec/` file and the destination the spec side's arms match distinct from those the code glob matches, neither side's globs reaching the other side's derived paths, or the two sides staged in separate workspaces — so 14.14's both-groups rule stays dormant under the conformer's 13.4 exclusion and with that exclusion lifted alike (VIOL-DISC-DERIVED). T7-6's invalid-source arm and its control are staged as TEST-SPEC.md pins them, exempt from those constraints — emission next to sources, a spec glob `specs/*.mdx`, a code group globbing `specs/*.md`, and `specs/a'b.mdx`, a spec source at a path 7.1 bars (14.19), beside a plain file `specs/a'b.md` holding `)`, no well-formed TypeScript (14.20) — and observed by `check` alone — a staging constraint: each such workspace is staged from scratch and no `build` succeeds on it, so no record exists (13.3) and `check` reports exactly the findings of `build`'s validations (12.2), 14.10's mismatch forms being undetectable on a failing workspace and its recorded-file form meeting no record (14.10, T12.2-4): with emission enabled, `specs/a'b.md` remains the invalid source's configured emit destination, though nothing is emitted on a failing workspace (12.1), its derived paths following the `NAME.mdx` name shape alone (13.1, 7.3), so 13.4 excludes that path and the condition-19 finding stands alone; with emission disabled, the path is no emit destination, the code group discovers it, and its condition-20 finding stands beside the condition-19 one. Command surface: `build` and `ids` (12.3) as the observation of the discovered spec set, and `inventory` (11.6) in its full 12.7 document form over the scope's workspaces — T7-4's observation that a glob matching nothing is configured as spelled (`configuration`, `globs` as configured); `query edges --from <path>` (11.1) as the observation of the discovered code set — for a discovered code source's whole-file location, exit 0 with its empty edge enumeration, the JSON document 11 makes its only output form; for a path in no configured group, an excluded derived path included, the usage error of 12.0 (exit 2, the error document of 12.7), a check preceding the gate of 13.3 (12.0), which the staging keeps dormant; `check` (12.2) as T7-6's invalid-source arm and its control drive it, over their validation-failing workspaces alone (above); the configuration-error behavior of 14.14/12.0 for patterns resolving outside the workspace root, and the source-error reporting of 14.15, 14.19, and 14.20 — 14.19's missing-extension form, which binds spec-group files alone, dormant in conforming behavior, since every staged spec-group match lacking `.mdx` is a derived-classified path the 13.4 exclusion keeps out of the discovered set, while its path-character form (7.1) reports T7-6's invalid source, and 14.20 reports the invalid-source control's code source alone. A staging constraint: every command the in-scope tests drive is drawn from this surface — T7-4's and T7-5's observations of the discovered set, which TEST-SPEC.md leaves open (T7-5 names none), ride `build`'s exit and findings, `ids`, and `inventory`'s listing of every discovered source (11.6) over the groups their fixtures alone declare — spec groups, and the one code group of T7-4's literal-`\` arm, whose discovered set `inventory` lists (VIOL-DISC-DERIVED's constraint) — and T7-6's ride the observations pinned above; `view`, `query nodes`, and every other read are not served. Contracts under certification: glob semantics of 7 — `*`, `?`, `**` (any segments only as a whole pattern segment, each `*` of an in-segment `**` the single-segment wildcard), byte-wise case-sensitive matching, dot-segment rule, every other character a literal (`\` included, the configuration literal read verbatim, 2.4), and the outside-root decision by spelling alone (a leading `/` or a depth falling below zero a configuration error, 14.14; a `.`, `..`, or empty segment inside the root matching nothing; a drive-qualified spelling ordinary segments) — discovery's refusal to follow symbolic links, and the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and enabled emit destinations in no spec or code group — an invalid source's destination included, derived paths following the `NAME.mdx` name shape alone, 13.1, 7.3), with 7.1's path-character bar (14.19) as T7-6's invalid-source arm stages it. **In-scope tests:** T7-4, T7-5, T7-6. -**Justification.** All three assert non-discovery — negative observations that pass vacuously when the staged names (bracket-bearing, multi-byte, dot-prefixed, link-mediated, derived-classified) never reach the matcher at all. They are also the canonical stock-dependency hazard: a product delegating to a common glob or filesystem-walking dialect satisfies every positive arm while violating the negative ones. T7-6's exclusion arms are the sharpest case: they carry no positive control — nothing observable separates matched-but-excluded from never-matched, so a glob that misses the staged derived paths (a wildcard stopped by the dot-segment rule short of `.xspec/`) passes forever — and they are the sole carrier of 13.4's source exclusion (T13.4-7 delegates wholly to T7-6). +**Justification.** All three assert non-discovery — negative observations that pass vacuously when the staged names (bracket-bearing, multi-byte, dot-prefixed, link-mediated, derived-classified) never reach the matcher at all. They are also the canonical stock-dependency hazard: a product delegating to a common glob or filesystem-walking dialect satisfies every positive arm while violating the negative ones. T7-6's exclusion arms are the sharpest case: on either group side nothing observable separates matched-but-excluded from never-matched — the code side's discovered-source control shows the group live, not that the derived path reached the matcher — so a glob that misses the staged derived paths (a wildcard stopped by the dot-segment rule short of `.xspec/`) passes forever; and they are the sole carrier of 13.4's source exclusion (T13.4-7 delegates wholly to T7-6), the product's own generated modules under an everyday code glob among the paths it must exclude. ### VIOL-DISC-DIALECT * **Scope:** CONF-DISC. * **Deviation:** Glob patterns are interpreted in a common dialect in which `[` `]` bracket expressions and `{` `}` brace alternations are active metacharacters, instead of the literals 7 requires — a single deviation: one rule of 7 (every character outside `*`, `?`, and `**` is a literal) broken for one dialect's metacharacter subset. `*`, `?`, `**`, case sensitivity, and the dot-segment rule are unchanged. * **Certifies:** T7-4. -* **Expected failures:** exactly T7-4 (its literal-metacharacter arms: `a[1].mdx` matches `a1.mdx` and fails to match the file named `a[1].mdx`; `b{a,c}.mdx` matches `ba.mdx`/`bc.mdx` and not the literal name). T7-5 and T7-6 pass: their patterns carry no bracket or brace characters, so their matching — and T7-6's exclusion — is unchanged. +* **Expected failures:** exactly T7-4 (its `[1]` and `{a,c}` 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). Its `!` and `+(x)` literal-metacharacter arms pass — neither carries a bracket or brace, so the negation and extglob spellings stay the literals 7 requires under the deviation as well — and T7-4's other arms — semantics, casing, byte, dot-segment, outside-root-by-spelling, inside-matching-nothing, drive-qualified, in-segment `**`, and literal-`\` — carry none either and are unmoved, so the test fails on its bracket and brace arms alone; T7-5 and T7-6 pass under a staging constraint: their fixtures' patterns, T7-6's code globs included, carry no bracket or brace characters, so their matching — and T7-6's exclusion on both group sides — is unchanged. ### VIOL-DISC-SYMLINK @@ -126,18 +147,81 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ### VIOL-DISC-DERIVED * **Scope:** CONF-DISC. -* **Deviation:** Discovery does not apply the source exclusion of 13.4: a path whose file name contains `.xspec.`, a file under `.xspec/`, or a file at an enabled Markdown emit destination, when matched by a spec-group glob, is treated as an ordinary match — a single deviation: one rule of 13.4 (derived files are never sources) dropped. Glob semantics, the dot-segment rule, link behavior, 14.19 for non-`.mdx` matches, and the import and empty-map rules are unchanged. +* **Deviation:** Discovery does not apply the source exclusion of 13.4: a path whose file name contains `.xspec.`, a file under `.xspec/`, or a file at an enabled Markdown emit destination, when matched by a spec-group or code-group glob, is treated as an ordinary match of its group's kind — on the spec side an `.mdx` name parsed as MDX and any other name reported as 14.19; on the code side a discovered code source whose content is parsed as plain TypeScript (14.20: the grammar its name selects), an edgeless whole-file location where it parses (4.6) and a condition-20 finding where it does not — the enabled destination's Markdown, the harness-staged file under `.xspec/` (its content left open by the test), and the generated module (its content out of this scope) each parsing or not as their bytes happen to — 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, the parse of a discovered source (14.20), the gate of 13.3, 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; on the code-group side, each excluded path the code glob matches — the module `build` generated next to its source, the staged file under `.xspec/`, the enabled destination — enters the discovered code set as a member of its code group alone (the scope's staging keeps every derived-classified path out of the reach of both a spec glob and a code glob, so the lifted exclusion never makes one a file matched by both, 7.2's configuration error: 14.14, exit 2 with the configuration-error document of 12.7, preceding all source analysis, 12.0), so `query edges --from` no longer refuses it as a path in no configured group: it answers exit 0 with an empty enumeration where every discovered file parses, and exit 1 at the gate of 13.3 — the workspace now failing `build`'s validations — where one does not (14.20) or where the spec side's 14.19 finding shares the workspace, the discovered-source control answering alike, never the asserted exit 2 (12.0), so the arm fails on the exit code whatever the parse outcome; in the invalid-source arm, the code glob's match at the invalid source's emit destination `specs/a'b.md` enters the discovered code set, `check` reporting its condition-20 finding beside the condition-19 one where the arm asserts that one alone, the emission-disabled control unmoved; the import arm is untouched, the zero-source arm too where its globs reach no derived path, and the test fails on the exclusion arms in any case). T7-4 and T7-5 pass under a staging constraint: their fixtures declare no code group but T7-4's literal-`\` arm's, whose glob `src/a\*.ts` matches no derived-classified path, 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` or `inventory` no `build` precedes (refresh writes graph data alone, 13.3; `inventory` writes nothing, 11.6) — 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, U+FFFD-free workspace-relative paths free of the characters 7.1 bars there (`"`, `'`, `\`, U+000A, U+000D, U+2028, U+2029) — imports (2.1, resolved lexically: a non-canonical specifier designating the same discovered source, T11.4-4), `d` props, `{text(...)}` embeddings, and MDX comments (2.7) as the in-scope fixtures stage them — a staging constraint: comments in the usual `{/* … */}` form and embeddings spelling nothing beside the call, the further forms of 2.7 and 2.3 (T2.7-4, T2.3-3) lying outside this scope — several of each in one file where T11.4-1's list-order arm stages them, and a spec source unparseable by encoding where T11.4-4's masked-target arm stages one — a staging constraint: a file beginning with a byte-order mark (1.6), its condition-20 finding the zero-length range at offset 0 (14) — never invalid UTF-8 with a non-empty well-formed prefix (its finding then located past offset 0, 14), and never a syntax failure, the offset rules of 14 for both lying outside this scope — the file masked (11.2: no view, its finding accompanying only when it is itself requested, an embedding into it the embedding file's own 14.6); one code group (7.2) only as T11.4-4's wrong-kind-target arm stages it — a staging constraint: its glob matches one `.mdx` file that no spec-group glob matches, a discovered code source, so a specifier designating it names no spec source (2.1, 14.15) and no view domain holds it (11.2), and T11.4-4, the sole in-scope test declaring a code group, drives `view` alone, so no in-scope invocation reads the code source's content, which is out of scope; no `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), T11.2-4's occurrence-record observations ride `occurrences` and `view`, and T11.4-3 drives `view` alone — its tag-set-form arm asserts the form on the `view` node, and its mention of `query node` and `show --json` is read as the cross-reference to T12.7-1 it cites, those surfaces asserted where their data arise (T2.6-1's `query node` tags; T12.4-1's field-by-field equality with `query node`) and not served here: on T11.4-3's own fixture, failing `build`'s validations (14.17), the gate of 13.3 would turn either read back unanswered (exit 1), so no assertion of the form could ride them there. 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 in the value forms of 12.7 — a tag set in byte order, duplicates collapsed, never case-folded, `[]` when tagless; a coverage attribute the string `"required"` or `"none"` — as T11.4-3's form arms assert them, 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 — a stray element preserved by its own tags, the sections and embeddings it encloses classified by their own forms (11.2), as T11.2-4's enclosure arm stages it: a `<div>` in flow position at the root level holding a section and an embedding, its one condition-16 finding located opening tag through closing tag (14), the section a child of the root in the tree (the positional enclosure of 11.4), the embedding's occurrence recorded with the root as its `source`, and under `--text` the root's own text carrying the element's tags with the section's contribution excised and the embedding expanded (1.6, 3) — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4 — every attribute a tag spells listed by form, a valueless bare name (`<S id>`, `<S id="x" tags>`) included, its interpreted datum unavailable and its finding condition 17, never condition 1 (T11.2-2, T11.4-3); every import declaration with its binding name (the default binding's identifier; the stated `null` of 12.7 for the side-effect-only, named-only, and namespace-only forms, each invalid, 2.1, a 14.15 finding beside it, its binding name never the marker, 11.4) and its resolved target, the discovered spec source its specifier designates under 2.1, parseable or not, whatever the declaration's binding form, explicitly unavailable where specifier form or discovery defines none (11.4): a specifier of invalid form (the bare specifier) or one designating no discovered spec source (a file that does not exist, `./typo.xspec`, or a discovered code source), the invalidity a located 14.15 beside it (T11.4-4); the declaration's range its own characters, a spelled `;` among them (14.20; T11.4-4's semicolon-terminated arm) — the comment ranges of 11.4 (the full braced container), with `imports`, `comments`, and `occurrences` each in document order (11.4, 12.7), findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17, and 14.20 at offset 0 as staged above), 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`; its enclosure arm, every datum defined, is unmoved), T11.4-3 (the per-node identity, tags, and coverage unavailability arms), T11.4-4 (the unresolved import targets — `./typo.xspec`, the bare specifier, the specifier designating the discovered code source; its masked-target and non-canonical-specifier arms carry defined targets and are unmoved). 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. + +## CONF-ORPHAN — removal of recorded derived paths + +**Scope.** Workspaces with one configured spec group of trivial single-section `.mdx` sources — no imports, embeddings, comments, or props beyond `id` — as T13.4-11 stages them (`specs/A.mdx`; `specs/B.md/C.mdx`, then `specs/B.mdx`); `markdown` with `emit: true`, emitting next to sources or under an `outDir` (7.3), then reconfigured as T13.4-11's arms stage it — `outDir` changed, or emission disabled by `markdown` absent or `emit: false`, either spelling 7.3 admits; a code group (7.2) globbing `specs/*.md` only as T13.4-11(b) adds it, its one match the well-formed TypeScript `export const n = 1` (14.20), a discovered code source no in-scope command reads beyond its discovery and well-formedness; and the occupants T13.4-11 stages at or above a recorded derived path — at the path, a directory holding a file no glob matches, that discovered code source, a symbolic link to a file outside the workspace root, or nothing; at a directory component, a plain file, or a symbolic link to a directory holding a foreign plain file, inside the workspace under no group's globs or outside its root; no `coverage`, `policy`, or git. A staging constraint: each spec glob matches `.mdx` names alone (`specs/*.mdx`, or `specs/**/*.mdx` where the order-independence arm needs it), so — the code group arriving only as emission is disabled (b) — no glob reaches a derived path while it is one, and 13.4's source exclusion stays dormant. Command surface: `build` (12.1) — generating each source's module (13.1, no companion written) and, while emission is enabled, its Markdown per 3 (13.2), writing graph data whose record lists the derived paths generated (13.3), each write replacing its path's occupant, a directory holding files included (T13.4-11's order-independence arm), and traversing no symbolic link (13.4), and removing each recorded path the current sources and configuration no longer generate as 13.4 directs — and `check` (12.2), reporting in the form of 14 and 12.7 with exit codes per 12.0: 14.10's per-file and graph-data forms and its recorded-file form, each occupant judged itself (14.10), no other condition arising — every source valid and well-formed, no write path below a non-directory, 14.22 dormant. Content of modules and graph data beyond path, byte-determinism (12.0), and the record is out of scope. A staging constraint: the order-independence arm's observation that the workspace is exactly the regenerated one compares the workspace's files with those of a twin holding the same sources and configuration, freshly built by `build` (H-6), never `inventory` or another read this surface does not serve. Contracts under certification: 13.4's removal of a recorded path no longer generated — its occupant judged at the path itself, a directory or a discovered source left as it is, anything else removed, a symbolic link as the link itself, never its target; nothing read below a workspace-relative directory component occupied by anything other than a directory, a symbolic link included whatever it targets, the path then holding nothing and its removal making no write — and 14.10's recorded-file form, reporting exactly the occupants that removal would remove. + +**In-scope tests:** T13.4-11. + +**Justification.** T13.4-11's left-in-place arms are absence-of-effect assertions — no condition-10 finding, the removal making no write, the occupant byte-identical — and arm (e) is their link-mediated case: what discriminates it is the foreign plain file `A.md` in the directory the symbolic link at `out/specs` targets, which a product resolving the recorded `out/specs/A.md`'s parent through the link reports as a stale recorded file and deletes, inside or outside the workspace, behind a reported success. No assertion braces that staging: with the file missing, or the link reaching a directory that lacks it, the recorded path holds nothing however it is resolved, so the arm passes against that product exactly as against a conforming one, and its after-state compare — a before-and-after compare TEST-SPEC.md does not make a presence check — sees the same absence on both sides. That is criterion (a)'s link-mediated class, which CONF-DISC certifies for T7-5 through VIOL-DISC-SYMLINK. Arm (c) is the same class at the recorded path itself: what discriminates it is the symbolic link at `specs/A.md`, which a product removing a recorded link's target in place of the link (13.4: a symbolic link itself, never its target) leaves standing while it deletes the file outside the workspace. No assertion braces that link either: with it missing — the step omitted, or its creation refused over the emitted file still there — the path still holds the emitted Markdown, a plain file no group discovers, which that product removes in the ordinary way exactly as a conforming one does, so every assertion of the arm holds against both: the condition-10 finding, nothing left at the path, the target unchanged on both sides of its before-and-after compare, the last `check` clean. Each of the two arms has its own violator, whose deviation moves that arm alone: C-1 judges whole tests, so a deviation moving both would leave one arm's staging uncertified, T13.4-11 failing through the other either way. The same holds within (e), whose two stagings catch different products: the inside staging catches both a product following the link wherever it leads and one following it only within the workspace root — a containment check before reading or deleting — while the outside staging catches the former alone. VIOL-ORPHAN-THROUGHLINK therefore follows links to directories inside the root alone, moving the inside staging and bracing its foreign file; the outside staging is left uncertified, the product it targets caught by the inside staging too. The test's other arms brace their own stagings and are the controls the violators' passing sides cite, targeted by no violator: a missed occupant staging in (a), (d), or (f) — a missed link in (e), or a missed code-group addition in (b) — leaves at the recorded path a plain file no group discovers, which the conformer reports (14.10) and removes where the arm asserts neither, failing loud; (b)'s missed overwrite alone leaves the emitted Markdown there for its code group to discover — either no well-formed TypeScript, `build` then exiting 1 (14.20) where 0 is asserted, or a discovered source the arm still tests; the order-independence arm is a positive byte assertion; and the recording step the arms ride is braced by (c)'s condition-10 finding on the same next-to-source staging and by T13.4-10's recorded-file finding on the `outDir` staging (d) shares. Neither violator classifies an occupant by following its link — VIOL-ORPHAN-THROUGHLINK resolves directory components alone, and VIOL-ORPHAN-LINKTARGET judges the link as itself, deviating only in what its removal deletes — and nothing here certifies (c) as catching a product that classifies the occupant by following its link: no arm discriminates one, a link to a file classifying as removable either way. + +### VIOL-ORPHAN-THROUGHLINK + +* **Scope:** CONF-ORPHAN. +* **Deviation:** Removal of a recorded derived path no longer generated resolves the path's workspace-relative directory components through symbolic links to directories inside the workspace root: below a component such a link occupies, the occupant is the entry the link's target holds under the path's remaining components, judged and removed by 13.4's other rules as if it stood at the recorded path, and 14.10's recorded-file form, reporting exactly the occupants that removal would remove, reports it. A single deviation: one clause of 13.4's removal rule (a path lying below a workspace-relative directory component occupied by anything other than a directory is left as it is, nothing read there) dropped where that component is a symbolic link to a directory inside the workspace root, for that removal and 14.10's recorded-file form alone — 13.4's reads of the journal, the session directory, and the record unchanged; the occupant at the recorded path itself is still judged as itself — a symbolic link there removed as the link, never its target — a component occupied by a plain file, or by a symbolic link to a directory outside the workspace root, still leaves the path holding nothing, and derived-file writes (13.4's replacement of a path's occupant) still traverse no symbolic link. The removal's deletion through the link — a write that traverses a link, a removal that removes something being a write (13.4) — is that single deviation itself, not a second. +* **Certifies:** T13.4-11. +* **Expected failures:** exactly T13.4-11 (its arm (e), in the staging whose link targets a directory inside the workspace: the foreign `A.md` that directory holds is reported by the first `check` as a condition-10 recorded-file finding concerning `out/specs/A.md` and deleted by `build`, where the arm asserts no such finding and the target's `A.md` byte-identical). Its staging outside the workspace root passes — the link's target lies outside the root, so the path still holds nothing and nothing is read or removed there — and so do its other arms: (a), (b), (c), (f), and the order-independence arm stage no symbolic link at a directory component of a recorded path — (c)'s link occupies the recorded path itself, judged and removed as itself — and (d)'s component is a plain file, below which the path still holds nothing; so the test fails on (e)'s inside staging alone. + +### VIOL-ORPHAN-LINKTARGET + +* **Scope:** CONF-ORPHAN. +* **Deviation:** Removal of a recorded derived path no longer generated, where the path's occupant is a symbolic link, deletes in place of the link the plain file the link resolves to — nothing where it resolves to no plain file — and leaves the link standing. A single deviation: one clause of 13.4's removal rule (a symbolic link removed as the link itself, never its target) inverted, for that removal alone; the occupant is still judged as itself, so 14.10's recorded-file form is unchanged, reporting the link concerning the recorded path; nothing is read below a workspace-relative directory component occupied by anything other than a directory; and derived-file writes (13.4's replacement of a path's occupant) still replace a symbolic link at a derived file's path as the occupant, traversing none. The removal's deletion through the link — a write that traverses a link, a removal that removes something being a write (13.4) — is that single deviation itself, not a second. +* **Certifies:** T13.4-11. +* **Expected failures:** exactly T13.4-11 (its arm (c): `build` exits 0 having deleted the file outside the workspace that the link at `specs/A.md` targets and left the link standing, where the arm asserts the link itself gone and its target byte-identical; the first `check`'s condition-10 recorded-file finding concerning `specs/A.md` is unmoved, the link judged as itself, and the last `check` is clean, the rebuilt record no longer listing the path, 13.3). Its other arms pass: (a), (b), (d), (f), and the order-independence arm stage no symbolic link at a recorded path, and (e)'s link occupies a directory component, below which the recorded path holds nothing and nothing is read; so the test fails on arm (c) alone. +* **Note:** the representative value of this violator for T13.4-4's nothing-written-through-the-link compare (see Exclusions) holds insofar as that arm shares the staging certified here — a symbolic link to a file replacing the occupant at a derived file's path, its target compared before and after. ## 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-5's reachability — criterion (a)'s property-test case — is left uncertified on the same ground: its generator draws only moves the product performs (6.2's and 6.5's refused shapes excluded by construction — a refused section-move shape an S-9 harness error with its seed, the destination refusals no derivability check sees drawn clear — never a product failure or a draw to skip) and its oracle predicts 6.2's full `changed` enumeration, but every class of that enumeration the generator can reach is anchored pointwise by deterministic fixtures whose failure is loud — the worked straddling-line shape in T6.2-3's three stagings, its clean boundary, and its sibling stagings (d) and (e), T6.2-4's pinned final-position shapes and its `changed` twin, exactly the stagings S-6 feeds the oracle as its vectors — the classes it does not draw are anchored alike — the import addition off a line's start by T6.5-13(h) and (j), one arm per mechanism 6.2 names, and each refused shape by T6.5-16 beside its movable control — and the barred name classes its drawn basenames reach, under T6.5-22(a)'s assertion, by T6.5-22(b)'s fixed lure per class, the name analysis behind that assertion S-6's to gate; a conformer that would make P-5's draws discriminating carries the section-move, hash, and impact engines whole, and the builder's fidelity to the draws' declared bytes is S-2's, their derivability S-9's. * **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. -* **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. +* **P-11, P-12**: P-11's imperfect-input classes are broad basins under P-8's mutators — nearly any byte mutation of a source lands in some finding family, unlike the boundary code points P-1 and P-2 must weight their generators toward — each class anchored pointwise by the deterministic 11.2 fixtures; its datum-form discipline is certified deterministically through the CONF-AVAIL datum-form violators, its termination, exit, and complete-document clauses are loud (S-3 captures exits and hangs; a partial document fails its own parse), and a conformer admitting it would need occurrence analysis over fuzzed TypeScript — P-8's scope argument. P-12 enumerates every offset of every file — reachability is total by construction — and its comparator is computed from the product's own `view` answers, anchored by T11.5-1's precomputed fixture, so there is no independent oracle to mis-trust. +* **The scale-capacity class (T1.3-7, P-8's giant-nesting floor, H-11, S-2's scale vectors, S-8)**: TEST-SPEC.md places it outside certification by construction — a harness that cannot stage, capture, decode, or walk a conforming input or answer at the staged scale fails spuriously against the conformer, never vacuously against a violator (S-8; S-2 on the input side) — and T1.3-7's assertions are positive counts and trees that fail loud against a product that truncates or refuses; a fixture nested to the floor would only re-prove the builder's and decoders' capacity, which S-2 and S-8 gate before any product exists. +* **The rewrite byte contracts of 6.4/6.5 (T1.4-5(b)'s and (c)'s conversion spellings, T6.4-2's keepable-form and whole-file arms, T6.5-1's canonical specifier-rewrite contract — its unused and type-only declarations included — T6.5-2's insertion geometries and terminator-kind arms, T6.5-7, T6.5-8, T6.5-10, T6.5-11's whole-call rewrite with its pinned added declaration — either of 6.5's two spellings, value-blind in the fresh identifiers alone — its binding-choice arms, and its removal, T6.5-12's target-side conversion, T6.5-13's admissible-offset and composition arms, T6.5-14's created-file content, T6.5-15's joint removals, T6.5-18's shadow-aware, value-level re-rooting, T6.5-19's in-section exclusion, T6.5-22's barred and captured names, T6.5-23's statement boundaries, directive prologue, and timeliness, and T6.5-6's no-op discipline)**: positive, byte-asserted against expected files composed from the rules of 6.4/6.5 and 3 — the diff-isolated added runs of T6.5-8, T6.5-10(a)/(c), T6.5-11, T6.5-13, T6.5-18, and T6.5-19 included, each absent altogether, and the assertion with it, against a product that inserts nothing or more than one run, and standing at the wrong offset against one that judges admissibility by derivability alone (T6.5-13(l), T6.5-19: a declaration placed at a line start the contract forbids); T6.5-23's arms refused, placed elsewhere, or rooted at another binding against a product judging statement ends or the directive prologue over the edited text, or timeliness for default bindings alone — a refusal or other bytes where the composed bytes are asserted; T6.5-15's unreported kept declarations braced by their bytes standing where the wrong product removes them; the before-and-after own-text and ownHash compares of T6.5-13 and T6.5-19 braced by the byte contracts on the same stagings; T6.5-18's wrong outcome — a marker re-rooted by name at a shadowed binding, no finding and no staleness — silent to the product alone, the test's added run and its `references` edge both absent against it; T6.5-6's unreported no-op rewrites braced by the moved text landing byte-identical; and T6.5-22's barred and captured names read from the added bytes, each lure failing loud against a product binding the lured name, the name analysis the check applies gated by S-6's vectors per barred class and in both directions of the declared-and-referenced count, so an analysis too narrow fails S-6 rather than passing a breaching product — so their failure modes are loud; a conformer passing them carries the identity-continuity engine whole (the P-5 argument above), and their one staging hazard, the builder's fidelity to declared bytes, is S-2's — the derivability of every composed form S-9's. +* **T13.5-6, T13.5-7, T14-9, T14-10** (workspace isolation; write-refused and interrupted mutation; environment refusals): T13.5-6 is squarely in criterion (a)'s temporal class, but P-10's rationale extends to it — it admits no deviation with an unambiguous expected-failure set, cross-workspace interference surfacing only when schedules overlap, and its isolation is additionally the harness's own H-1 obligation, exercised on every parallel run (E-3). T13.5-7, T14-9, and T14-10 stage the environment's refusal (14.24, 14.25) by permission removal — T13.5-7 and T14-9 for a mutating command while it is held at the seam (T13.5-1's choreography, certified via the CONF-CORE lock violators), T14-10 by read-permission removal on files and directories with no hold involved, its one mutating command, arm (g)'s representative `rename`, unheld: the refusal is the environment's, not the product's, so a staging that misses — ineffective, or applied after the write or read it should refuse — leaves the command succeeding where a refusal is asserted, an exit-2 error document or the finding a refused read maps to (T14-10's table), a loud failure, and E-1 makes the harness verify each staging on itself before invoking the product, an ineffective one a harness error (H-11), never a pass or a skip; their operative assertions are positive — each per-command state byte-compared against an unrefused twin (H-6), the error document's `write-failure` or `read-failure` code with its concerned path, the condition each refused read maps to (T14-10's table), and `check`'s exact report of what a stopped operation leaves — and a conformer passing them would carry the whole write and read surface (Markdown emission under `outDir`, git baselines, code groups, sessions, every refreshing read — P-8's scope argument). T13.5-7's kill arm is a robustness check only: its post-release kills land nondeterministically and its assertion is disjunctive over the states 13.5 admits for exactly that reason, so no deviation fails it deterministically, and the held-point choreography it shares is VIOL-CORE-EARLYWRITE's on T13.5-1. +* **T6.1-1** (journal lifecycle): it byte-compares the journal around every command surface — `occurrences`, `view`, `at`, `inventory`, `version`, and the previews of 6.6 among them — so a conformer passing it whole would serve all of 11 and 6.6 (P-8's scope argument), outside every scope here. Its never-modified compares are the compare-around machinery VIOL-CORE-CHATTYREADS certifies over the same journal on T13.4-5, whose read set the scope constrains; its lifecycle negative — no journal after `build` in a fresh workspace — is braced in-test by the positive at the same path (the file appears at `.xspec/journal` with the first `rename`/`move`), so a mislocated absence check fails loud rather than passing silently. +* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their byte-precise enumerations against precomputed expectations fail loud, the record form they decode certified through the CONF-AVAIL datum-form violators; T1.4-5's access arms are positive — `build` and `check` clean, each edge reported, the consumer compile of the dot and quoted spellings at TypeScript 5.9.3 riding the same driver — so a product rejecting or misquoting a spelling fails loud. T6.5-9's and T6.5-18's compile-clean observations ride the same driver — S-4 checks each diagnostic kind's detection directly, the import-conflicts-with-local-declaration and duplicate-identifier kinds T6.5-9's value-level lures turn on among them — and no lure rests on the compile alone: those value-level lures root the rewritten markers at nothing against a product taking one, so the `references` edges asserted beside the compile are absent and `check` reports 14.15 beside 14.7 where the test asserts it clean; its `type`-alias lure, colliding with nothing to xspec (4.5) and caught by the compile only where the generated default export has a type meaning, is T6.5-22(a)'s name check's for every product (above); and its spec-source arm's freshness clauses — the compiler-provided names and one identifier bound twice — are 14.15's, `check` clean asserted beside the positive `view` listing of two distinct `name`s and the contiguous block's bytes (T6.5-13(g)); T4-4's type-only arms and T4.5-4's callee-side and `using`-shadowing arms pair each no-edge negative with an in-test positive control (the ordinary-binding statements recording their edges; the condition-18 finding, and the `embeds` occurrence or the chain's edge recorded outside the shadowing scope), so a product resolving by name fails the paired positive rather than passing silently; T4.5-8's same-scope collisions pair each no-edge negative with the condition-15 and condition-7 findings asserted beside it and with type-level controls whose edges are recorded; T4-5's type-only collisions and T4.5-9's calls through a colliding `text` identifier pair theirs the same way — the condition-15 finding locating both declarations beside condition 7 or condition 18 at the chain, the `type text = number` control recording its edge — as does T4.4-1's finding contract, its condition-7-alone and condition-8-alone arms braced by the condition asserted, its `identities` a form-exact literal (`["specs/B.mdx"]`; `[]` over the invalid path), and the call's occurrence beside the finding decoded through the CONF-AVAIL-certified record form; T4-2's side-effect-only arm is a positive validity assertion (exit 0) on a form binding nothing, its specifier held to the rule by two asserted 14.15 arms, and its spellings naming no module — `require` calls, a triple-slash directive, a plain string, template-literal `import()` calls — pair their no-finding, no-edge negatives with the marker's edge listed beside them and the file move's one specifier rewrite; and the constructor, decorator, declaration-file, and legacy-`module` arms of T4.6-1, T4.6-3, and T1.7-2 are positive attributions and precomputed ranges, T4.6-3's `x.dts.ts` control keeping `path#f` beside the declaration files losing it; and T2.4-5's verbatim-literal negatives assert their 14.5–14.7 findings present beside an escape-free control recording its edge, their discriminating half — the consumer type-checking clean against the generated module — riding the same S-4 channel. +* **T10.1-4 session-corruption arms, T10.1-6, and the precedence pairs**: the per-arm precision hazard of T10.1-4 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-6's occupancy arms are positive findings — condition 22 concerning `.xspec/reviews` or `.xspec`, condition 23 for the area — beside no-write compares on the compare-around machinery, and its `create` ordering assertion is positive too (graph data refreshed before the session-directory refusal, `check` then reporting the per-file staleness). 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), and T12.0-14's grammar arms each pair the refused spelling with the accepted one on the same fixture (`ids --config --json` against `ids --json --config`, `ids --` against `ids`, `--name -a` creating the session `-a`), a lenient parser failing the pair. +* **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) — T6.6-4(b)'s `import-addition` admitting whichever of the two pre-operation offsets 6.5 admits where a collapsed origin deletion or import removal makes one composed position (both in T6.5-13(i)/(k), T6.5-11(a)/(b), and T6.5-18; the end alone in T6.5-23(h) through (j)), a latitude the byte contract closes, and its coincidence arms counting the zero-length entries the tie-break cannot order; T6.6-3's refusal equivalence over T6.5-16's, T6.5-17's, T6.5-20's, and T6.5-21's shapes is a positive same-findings compare against the operation's own report; 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, T14-11; the performed-operation form at T6.4-1, T6.5-1, T6.6-2)**: 12.7's value forms are universal (H-3) — asserted literally on the pinned document forms and, through the value-blind H-3 decode, on the unpinned surfaces T12.7-1 sweeps (`query` rows, `show --json`, the review payloads) — so the decode rigor the sweeps depend on is certified representatively through the CONF-AVAIL datum-form violators, where the marker and the stated `null` are densest: the two states a defaulting decoder collapses. A range carried as `[start, end]` or `{"from", "to"}` has no such collapse to hide behind — it fails the literal `{"start", "end"}` assertion unless an adapter re-maps it, the harness discipline H-3 forbids and S-5's wrong-shape feed probes — and staging a fixture per surface and per condition would be completeness. The stable-code, refusal-reason, location-cardinality, and per-condition range assertions (T6.4-3's two-bearer collision arm, T14-11's precomputed offsets, T14-7's `identities` over invalid destination paths — plain-string literals over the path as spelled, `["specs/new.txt#x y"]`, form-exact — and its `refused-cycle` and `refused-invalid-rewrite` location sets, computed as the spellings rooted at an added binding whether or not a `reference-rewrite` would report them, among them) are positive identity checks that fail loud when a staged condition does not fire — the finding is then absent altogether, and the assertion with it — or fires at another range; and the performed-operation document is a form-exact literal (`{"findings", "mapping"}`, its `mapping` byte-equal to the preview's), failing loud under any other member set. +* **The well-formedness contract and the brace-content classes (T14-12; the 14.20 and 14.16 arms of T2.3-3, T2.7-1, T2.7-3, T2.7-4, and T2.4-2; T14-3's `d={]}`; T14-11's `d={}`, parenthesized, comma-sequence, spread, elision, and encoding-offset arms; T1.6-5's offset; T2.1-3's and T4.5-8's one-block arms; T5.7-2's token-bound and line-comment arms)**: every arm asserts either a condition's identity by its stable code at a precomputed byte offset — 14.20's zero-length range where the text does not derive, the ordinary finding (14.15, 14.16, 14.17, 14.8) or the recorded occurrence's span where it does — or, for T14-12's release, language-level, and code-source-whitespace pins, a well-formed file's clean `build` and `check` with its marker's edge recorded: a positive check that fails loud against a product handing an ESM block to a parser enforcing early errors (14.20 reported where 14.15 or 14.16 is asserted), scanning a container or an attribute value to its first `}`, or bounding tokens by ASCII whitespace alone (the asserted range absent or elsewhere), or parsing at another TypeScript release, language level, or Unicode version, or a code source by ECMAScript's lexical grammar (a well-formed file masked as 14.20, or U+1C89 admitted where 14.20 is asserted); the absences asserted beside them — no 14.20 beside the 14.15 or 14.16 finding, no second finding for an attribute value expression, no edge and no occurrence beside a 14.16 — are braced by the finding asserted on the same staging. Their exotic bytes (U+0085, U+200B, U+180E, U+00A0, U+FEFF, U+2028, U+2029, Unicode 15.1's other space separators — U+1680, U+2000 through U+200A, U+202F, U+205F, U+3000 — U+2EBF0, U+1C89, the ill-formed UTF-8 sequences) are the hazard class CONF-VALID and CONF-MD certify, but every such negative arm is pinned by byte offsets: a byte tooling normalizes changes the file's byte length and every precomputed offset past it, so a corrupted staging fails loud against a conforming product rather than passing vacuously — T2.7-4's carriage-return twin, the one arm a normalization (U+000D to U+000A) would leave at the same byte length, rides the lone-CR staging VIOL-MD-CR certifies on T3-4 and P-2 through the same builder — and the positive arms holding them, T14-12's well-formed pins among them, rest on the builder's fidelity to declared bytes, S-2's round-trip to gate. The positive comment and embedding forms of T2.7-4 and T2.3-3, and T3-7's ESM-block comments, are among the classes P-2 composes: CONF-MD admits them in scope, so the conformer passes P-2 only by classifying them as 14.20 does, and a product misclassifying one fails the byte-asserted emission of the form's deterministic twin loud — so no violator deviating on comment or embedding classification is staged, and the twins themselves stay outside CONF-MD's read surface (T3-7 and T2.7-4 assert `view`'s `comments`, and T3-7 its `imports`; T2.3-3 and T2.7-4 carry the negative arms above). +* **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. E-6's representative fixture is a product-to-itself byte compare across the two legs (H-4) — its specifier-computation and inserted-terminator probes included — positive and loud on the leg that exposes a native-separator or native-terminator product, no harness oracle standing between. +* **The remaining negative matrices and refusals (2.1 import negatives — T2.1-2's lexical-resolution and above-root arms included, 2.4 — T2.4-5's MDX arms, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures — T6.3-5's exit-2 arms beside its positive repository-resolution arms, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare, T6.5-16's `refused-invalid-rewrite` shapes, each standing beside a movable control, its would-be text not well-formed (verified underivable by S-9 independently of the product) or holding no admissible offset for an addition it needs, T6.5-17's `refused-moved-import` arms beside their positive counterpart, T6.4-3's and T6.5-4's barred-character arms, and T6.5-20's and T6.5-21's destination reasons beside their performed controls and exemptions included, their findings positive form-exact checks — the stable code, the located construct and rooted spellings or import ranges, `identities` in byte order or exactly `[]` — absent altogether against a product performing the move, and their applicability arms (`refused-invalid-rewrite` withheld under an intrinsically invalid `<new-id>`, which `refused-invalid-id` then reports alone, while `refused-moved-import`, carrying no such qualifier, reports beside it; the target's text judged only where an insertion point exists) the masking structure this bullet closes on, 7.x configuration validation — T7-1's occupancy, T7-2's verbatim, encoding, repeated-key, and import-modifier, T7-3's U+FFFD, and T7.1-1's path-character arms — the last beside its valid code-source control — included, and T7.3-1's graph-data-area arms beside their look-alike controls and its classification arms (an enabled emit destination matched by a spec glob undiscovered, whether or not emission has yet run: the non-discovery class CONF-DISC certifies at its sharpest on T7-6's destination arm, braced here in-test by the emission-off twin under which the same would-be destination is discovered, 7.3, so a glob that never reaches the path fails the twin loud rather than passing the negative silently), 10.1 session-name and non-session negatives, 12.0 usage errors — T12.0-5's malformed-value positions, T12.0-10's barred-character arms, and T12.0-14's grammar arms, the argument, spelling, and domain-and-exit matrices of the machine-interface surfaces — T11-2's syntactic `--tag` acceptance, 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 and its forbidden-name unit arms (positive answers with the unit's edges, exit 0), their answer-side decode rigor the CONF-AVAIL-certified machinery and their exit assertions S-3's — T11.4-5's consultation-domain negatives, the 13.4 symlink write refusals and their byte-compares — T13.4-6, and T6.5-4's symbolic-link component arms, whose link-and-target byte-compares are braced by the refusal's stable code and exit 1, which a product writing through the link fails first — the derived-path relations of 13.4 and 14.22 — T13.4-9 and T13.4-10, one condition-22 finding asserted per offending path beside the workspace byte-compare, and the source relations of T13.4-9(d) and T6.5-20(c), whose miss deletes a source behind a reported success, asserting the refusal positively, so that a staging missing the relation fails against a conforming product, which then builds or moves where the refusal is asserted (T13.4-4: a directory holding nothing discovered is replaced) — 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, confinement, and reporter-matrix contracts of 14 — T14-3, T14-4, and T12.2-4's no-condition-10-or-12 negatives, each beside the validation finding and the positive recorded-file form asserted on the same stagings)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, the answer-side no-write compares of T11.2-1/T11.2-6, and T11.2-6's finding-free answer beside `build`'s condition-22 report of the same obstruction): 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. +* **T13.4-4's link arm** (a symbolic link at a derived file's own path replaced as the occupant, nothing written through it): none of the arm's assertions braces its link — with the link missing, `build` writes a plain file at the path for a product writing through a link exactly as for a conforming one, so against both the link is gone, a plain file is present, no error is reported, and the target is unchanged on both sides of its before-and-after compare. That link is T13.4-11(c)'s staging, though — a symbolic link to a file replacing the occupant at a derived file's path, its target compared before and after — which VIOL-ORPHAN-LINKTARGET certifies, a harness whose link staging misses failing that certification; the arm rides it representatively, insofar as it shares that staging machinery (the violator's note), and a violator writing through the link would re-prove the same staging, which would be completeness. diff --git a/specs/PHILOSOPHY.md b/specs/PHILOSOPHY.md index 4630f00b..188f501f 100644 --- a/specs/PHILOSOPHY.md +++ b/specs/PHILOSOPHY.md @@ -14,4 +14,8 @@ 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). +- Name grammars favor addressability over permissiveness (ruled 2026-09-03 by Liaison on Developer's behalf, without consulting Developer, for the external-UI patch's SPEC revisit): when a character admitted in requirement IDs, tags, source paths, or configuration names cannot be spelled or addressed through xspec's own interface — U+FFFD, which a command-line argument cannot distinguish from an invalid byte; `"`, `'`, `\`, and `&`, which MDX attribute values, MDX/TypeScript string literals, and `rename`'s in-place quote-preserving rewrite cannot always spell — exclude the character from the grammar (a validation finding / configuration error) rather than admit valid-but-unaddressable names or add spelling-and-refusal machinery. Basis: GOALS' complete-CLI-interface, every-behavior-observable, and durable-identity (rename/move rewrite all references) goals, and the seed's identifier-friendly naming guidance; such characters in names are pathological (mojibake, injection-like spellings), so the narrowing costs realistic workspaces nothing. General rule: Liaison answers product-shaping edge-case questions on Developer's behalf when GOALS plus the seed's stated intent determine the answer and the cost falls only on pathological inputs; choices that change realistic workspaces' behavior, or touch approval-gated documents, still go to Developer. diff --git a/specs/SPEC.md b/specs/SPEC.md index 6cd9a557..ab928c46 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -57,17 +57,19 @@ Each ID segment: * MUST NOT contain `"."` * MUST NOT contain `"#"` * MUST NOT contain control characters or whitespace +* MUST NOT contain `"`, `'`, `\`, or `&` — the quote, escape, and character-reference characters — or U+2028 (LINE SEPARATOR) or U+2029 (PARAGRAPH SEPARATOR), so that every segment is spelled verbatim in every form (2.4, 2.7, 6.4) +* MUST NOT contain U+FFFD (REPLACEMENT CHARACTER), which no argument value carries (12.0) * MUST NOT be `"$"`, `"__proto__"`, `"prototype"`, `"constructor"`, or `"then"` -Here and throughout this specification, whitespace means exactly the characters U+0009 (tab), U+000A (line feed), U+000B (vertical tab), U+000C (form feed), U+000D (carriage return), and U+0020 (space), and control characters means exactly U+0000–U+001F and U+007F; no other code point (U+00A0, U+0085, and U+2028 included) belongs to either class. Tag splitting (2.6) and line dropping (3) use these same definitions. +Here and throughout this specification, whitespace means exactly the characters U+0009 (tab), U+000A (line feed), U+000B (vertical tab), U+000C (form feed), U+000D (carriage return), and U+0020 (space), and control characters means exactly U+0000–U+001F and U+007F; no other code point (U+00A0, U+0085, and U+2028 included) belongs to either class; the one exception is the judgement of what brace and ESM-block content is whitespace and comments (14.20, as 2.3 and 2.7 read it) and the token bounds spanning and locating reference spellings (5.7, 14), which take whitespace and line terminators from ECMAScript. Tag splitting (2.6) and line dropping (3) use these same definitions in every line of a file, one within an ESM block or braces included; these classes decide this specification's own uses of the terms alone, never what an input language's grammar derives (14.20). -Identifier-friendly camelCase segments are recommended for clean TypeScript property access, but no naming style is enforced beyond the rules above. Segments that are not valid TypeScript identifiers are accessed with bracket notation (2.4) in generated modules. +Identifier-friendly camelCase segments are recommended for clean TypeScript property access, but no naming style is enforced beyond the rules above. A segment is a valid TypeScript identifier exactly when TypeScript, at the release and language level 14.20 fixes, admits its first character to begin an identifier and each other character to continue one — a test of characters alone: a reserved word (`delete`, `default`) is one, since property access admits any identifier name (2.4); ECMAScript 2024 admits each character so admitted in the same place (14.20), so dot access spelling such a segment is well-formed in either kind of source (6.4). Other segments are accessed with bracket notation (2.4). A tag (2.6) follows the same rules as an ID segment, except that tags MAY contain `"."`. ### 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, and a refusal's `identities` (14) are spellings in this form over a would-be operation, carried whatever their paths' validity and defining none — so the `#` in an identity is unambiguous. ### 1.6 Own text, subtree text, and own content @@ -76,7 +78,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 +86,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, by its own characters — a decorator list is part of the class declaration or member it decorates, whose own characters begin at its first decorator — a leading `export` or `export default`, and whatever separates it from the construct's first token, excluded save where the export declaration is itself that construct (below); only what leads is excluded, so `export @dec class C {}` spans `@dec class C {}` while `@dec export class C {}` spans whole from its `@`, the `export` inside. 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 decorator list preceding its `export` and a statement terminator the declaration spells included (`export default () => {};` spans through its `;`). 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 @@ -96,7 +104,7 @@ xspec source files import nothing from xspec; `<S>`, `<Spec>`, and `text` are pr import BASE from "./BASE.xspec" ``` -An import specifier MUST be a relative path beginning with `./` or `../` and ending in `.xspec`, resolved against the importing file's directory; `DIR/NAME.xspec` designates the source file `DIR/NAME.mdx`. The designated file MUST be a discovered source file of a configured spec group (7.1); any other specifier or target is invalid (14.15). The only permitted import form is a single default binding, as above; named, namespace, and side-effect-only imports are invalid (14.15). Multiple imports MAY bind the same module under different names, but no two imports in a file may bind the same identifier, and no import in an xspec source file may bind the identifier `S`, `Spec`, or `text` — the compiler-provided names are never shadowed, so construct recognition is never ambiguous (14.15); an import whose binding is never used is valid and records no edges. Import cycles among spec source files are invalid, even when no requirement-level dependency cycle exists; a file that imports itself is an import cycle of length one. +An import specifier MUST be a relative path beginning with `./` or `../` and ending in `.xspec`, resolved lexically against the importing file's directory: read in order from that directory, a `.` or empty segment (a doubled `/`) designates the same directory, a `..` segment its parent, and any other segment the entry so named; a specifier whose ascent passes above the workspace root — its depth, counted as 7 counts a glob's depth but starting from the importing directory's own depth, falling below zero — designates nothing (14.15), whatever the root's parent holds, and every other specifier resolves to a path in discovered-path form (7), `DIR/NAME.xspec` designating the source file `DIR/NAME.mdx`. No spelling is required to be canonical: each resolving to a discovered spec source's path designates it, the canonical spelling being the one rewrites produce (6.5). The designated file MUST be a discovered source file of a configured spec group (7.1); any other specifier or target is invalid (14.15). The only permitted import form is a single default binding, as above; named, namespace, and side-effect-only imports are invalid (14.15). Multiple imports MAY bind the same module under different names, but no two imports in a file may bind the same identifier — nor may an import share its identifier with a declaration an export statement holds (2.7), the collision of 2.4 (14.15) — and no import in an xspec source file may bind the identifier `S`, `Spec`, or `text` — the compiler-provided names are never shadowed, so construct recognition is never ambiguous (14.15); an import whose binding is never used is valid and records no edges. Import cycles among spec source files are invalid, even when no requirement-level dependency cycle exists; a file that imports itself is an import cycle of length one. ### 2.2 Dependency prop @@ -112,7 +120,7 @@ The `d` prop records a `depends` edge, does not render into Markdown output, and ### 2.3 Embedding requirement text -`{text(...)}` embeds the target's subtree text into the compiled Markdown output and records an `embeds` edge from the containing section to the target. The argument follows the same external/local duality as `d`: node form for imported modules, string form for local IDs. +`{text(...)}` embeds the target's subtree text into the compiled Markdown output and records an `embeds` edge from the containing section to the target. The argument follows the same external/local duality as `d`: node form for imported modules, string form for local IDs. An embedding is an expression container, in flow or text position, whose one expression (14.20) is a call — optional chaining excluded — whose callee is the identifier `text` itself, spelled plainly, neither parenthesized nor escaped (2.4), whatever whitespace and comments stand beside the call (`{ text("a") }`, `{/* n */ text("a")}`); a container holding any other expression is invalid (14.16). ```mdx <S id="summary"> @@ -125,7 +133,7 @@ As specified: ### 2.4 Static argument rule -The argument to `text(...)` and every reference in `d` MUST be a static string literal or a static property chain rooted at an imported spec module. A static string literal is a plain single- or double-quoted string; template literals are not static. A static property chain is the module's import binding followed by zero or more segments, each either a non-computed property access whose name is an identifier (`.login`) or a computed access whose index is a static string literal (`["login-v2"]`) — the form by which segments that are not TypeScript identifiers are referenced (1.4). No other syntax participates in a chain: optional chaining, non-null assertions, parentheses, and any other index or expression form make the reference dynamic. A `text(...)` call MUST have exactly one argument. Dynamic references and `text(...)` calls of any other arity are invalid (14.8). +The argument to `text(...)` and every reference in `d` MUST be a static string literal or a static property chain rooted at an imported spec module. A static string literal is a plain single- or double-quoted string; template literals are not static. The value of such a literal, and of a quoted attribute value (2.7), is the characters between its delimiters exactly as spelled — no escape sequence or character reference is interpreted — for every static string literal and quoted attribute value this specification reads: an identity, a reference, or a tag list, a `coverage` value (2.7), an import specifier (2.1, 4), and every configuration literal (7) alike; so a spelling containing `\` or `&` names no valid identity or tag (1.4), and a `coverage` value spelled with an escape or character reference is neither `required` nor `none` (14.17). A static property chain is the module's import binding followed by zero or more segments, each either a non-computed property access, its name any identifier name the file's grammar admits there, a reserved word included (`.login`, `.delete`), or a computed access whose index is a static string literal (`["login-v2"]`) — the form by which segments that are not valid TypeScript identifiers (1.4) are referenced. A segment's identifier is likewise read as spelled: one carrying a Unicode escape sequence (`.\u006Cogin`) spells a name containing `\`, which no segment contains (1.4), so the reference names no node and does not resolve (14.5–14.7) — which binding roots a chain is the language's scoping question (2.1, 4.5), not a spelling one. An identifier bound by a spec module import and, in the same scope, by another import (2.1), either type-only or not (4.5) — or, the import's binding being value-level (4), by a non-import declaration binding it at value level: a variable (`using` and `await using` included), function, class, or enum declaration, or a namespace declaration binding a value; in a spec source, a declaration an export statement holds (2.7) — roots no resolving chain: the language names no single binding, so a chain rooted at it names no target — no edge, no occurrence (5.7) — and its spelling reports as unresolved (14.5–14.7) beside the collision finding (14.15). A type-level declaration of that scope (an interface, a type alias, a namespace binding no value) collides with nothing — the import roots the chain — and a declaration of an inner scope shadows the binding instead (4.5). No other syntax participates in a chain: optional chaining, parentheses, and any other index or access form the file's grammar derives applied to the chain make the reference dynamic, while a form the grammar does not derive is a parse failure of the file (14.20), never a dynamic reference — so a non-null assertion (`BASE.a!`), a type assertion, or any other TypeScript-only syntax is dynamic in a TypeScript source, and in a spec source, whose expressions are ECMAScript 2024 (14.20), a parse failure unless ECMAScript derives the spelling with another meaning, which then governs: `d={BASE.a<X>y}`, two comparisons, is a well-formed container holding no static chain (14.8). A `text(...)` call MUST have exactly one argument. Dynamic references and `text(...)` calls of any other arity are invalid (14.8). ### 2.5 Coverage attribute @@ -153,7 +161,7 @@ Repeated failed logins lock the account. ### 2.7 Permitted constructs -Beyond standard Markdown content, an xspec source file may contain only spec module imports (2.1), `<S>`/`<Spec>` sections, `{text(...)}` embeddings, and MDX comments (`{/* … */}`). Any other JSX element, any other expression container, and any export statement are invalid (14.16). Comments are pure annotations: they do not enter own text or any hash, and Markdown output removes them (3). The props defined on `<S>`/`<Spec>` are `id`, `d`, `coverage`, and `tags`; no prop name may occur more than once on one element — a repeated prop, defined or unknown, is invalid (14.17). Every prop is a named attribute: a spread attribute (`{...expr}`) on a section element is invalid (14.17). The value of `id`, `coverage`, and `tags` MUST be a static string literal in quoted attribute form, single- or double-quoted alike (2.4) — as in `id="login"`; any other value form — a braced expression such as `id={"login"}` included — is invalid (14.17). The value of `d` MUST be a braced expression (as in `d={BASE.auth.login}`) holding a single static reference or an array literal of static references (2.2, 2.4); a quoted or valueless `d` is invalid (14.17), and a braced `d` value that is not such a reference or array literal is a dynamic argument (14.8). Unknown props, and `coverage` values other than `required` and `none`, are invalid (14.17). +An xspec source file is an MDX document — well-formed MDX as 14.20 fixes it; any other file is unparseable (14.20). Beyond standard Markdown content, it may contain only spec module imports (2.1), `<S>`/`<Spec>` sections, `{text(...)}` embeddings (2.3), and MDX comments. An MDX comment is an empty expression: an expression container, in flow or text position, whose content — the characters between its braces — is nothing but whitespace and JavaScript comments as 14.20 counts them, a brace on a commented-out line closing nothing (14.20): `{/* … */}` is the usual form, and `{}`, `{ /* a */ /* b */ }`, and a container of line comments each ended before the closing brace are comments too. A container holding anything else — an expression, alone or beside comments — is no comment: an invalid expression container (14.16) where the grammar derives its content, an unparseable file (14.20) where it does not. Any other JSX element, a fragment (`<>…</>`) included, any other expression container, and any export statement are invalid (14.16). A section is an MDX element node: `<S>` or `<Spec>` spelled inside an expression container is part of that container's expression — content under 3, never a section (14.16). Comments are pure annotations: they do not enter own text or any hash, and Markdown output removes them (3). The props defined on `<S>`/`<Spec>` are `id`, `d`, `coverage`, and `tags`; no prop name may occur more than once on one element — a repeated prop, defined or unknown, is invalid (14.17). Every prop is a named attribute: a spread attribute (`{...expr}`) on a section element is invalid (14.17). The value of `id`, `coverage`, and `tags` MUST be a static string literal in quoted attribute form, single- or double-quoted alike (2.4) — as in `id="login"`; any other value form — a braced expression such as `id={"login"}` included — is invalid (14.17). The value of `d` MUST be a braced expression (as in `d={BASE.auth.login}`) holding a single static reference or an array literal of static references (2.2, 2.4); a quoted or valueless `d` is invalid (14.17), a braced `d` value that is not such a reference or array literal is a dynamic argument (14.8), and braces enclosing only whitespace and comments (`d={}`, `d={ /* c */ }`) enclose no expression the grammar admits — an attribute value admits no empty expression, so the file is not well-formed MDX (14.20). Unknown props, and `coverage` values other than `required` and `none`, are invalid (14.17). ## 3. Markdown Compilation @@ -165,7 +173,7 @@ When enabled (7.3), each source file compiles to a pure Markdown file. The outpu * replaces each `text(...)` expression with the target's compiled subtree text, fully expanded * preserves all other Markdown content and author whitespace -Removal is exact textual deletion of the construct's own characters, in place. A line terminator is the sequence U+000D U+000A (one terminator), a U+000A not preceded by U+000D, or a U+000D not followed by U+000A; a line is a maximal terminator-free run of characters plus the terminator that ends it, and the final line MAY have no terminator. A line that contained non-whitespace (1.4) in the source but is left empty or whitespace-only purely by removals (or by a `text(...)` replacement whose expansion is empty) is dropped together with its line terminator, if any; every other line keeps its remaining content and terminator. `<S>` and `<Spec>` are transparent annotations: they divide the source into requirement nodes but are not rendered as visible markup. Authors are responsible for normal Markdown spacing around in-line tags; for example, `<S id="a">Example:</S><S id="b">1. A</S>` strips to `Example:1. A`. +Removal is exact textual deletion of the construct's own characters, in place — an import's being its declaration's alone, so a JavaScript comment beside it in its ESM block (`// note`) is no MDX comment (2.7) and stays as content. A line terminator is the sequence U+000D U+000A (one terminator), a U+000A not preceded by U+000D, or a U+000D not followed by U+000A, in every line of the file, one within an ESM block or braces included (1.4); a line is a maximal terminator-free run of characters plus the terminator that ends it, and the final line MAY have no terminator. A line that contained non-whitespace (1.4) in the source but is left empty or whitespace-only purely by removals (or by a `text(...)` replacement whose expansion is empty) is dropped together with its line terminator, if any; every other line keeps its remaining content and terminator. A removed construct's own characters may span several lines: a line terminator among them is deleted with the construct, joining the lines it spanned into one line — judged by the drop rule as a whole, and one that contained non-whitespace, the construct itself — and a deleted terminator is never reintroduced. `<S>` and `<Spec>` are transparent annotations: they divide the source into requirement nodes but are not rendered as visible markup. Authors are responsible for normal Markdown spacing around in-line tags; for example, `<S id="a">Example:</S><S id="b">1. A</S>` strips to `Example:1. A`. ## 4. Generated TypeScript Modules @@ -175,7 +183,7 @@ For each source file `NAME.mdx`, xspec generates a TypeScript module imported as import SPEC, { text } from "./NAME.xspec" ``` -In a TypeScript file, an import declaration is a spec module import exactly when its specifier ends in `.xspec`; the specifier follows the same form and resolution as 2.1 and MUST designate a discovered spec source (14.15). The permitted bindings from a spec module are the default export and the named `text` export, each optionally aliased; any other binding is invalid (14.15). A spec module import MAY be type-only — a `type` modifier on the declaration or on a named binding — and a binding introduced type-only is a type-level name (4.5). An import declaration is the only construct through which a TypeScript file consumes a spec module; every other module-linking form whose specifier ends in `.xspec` is invalid (14.15): a dynamic `import()` with such a static specifier, an export declaration with such a module specifier (`export * from`, `export * as NS from`, `export { … } from`, type-only forms included) — so no re-export carries a spec module's nodes or `text` past the edge recording and restrictions of 4.5 — and an `import X = require(…)` declaration. A dynamic `import()` whose specifier is not static is not analyzed and records nothing. An import or export declaration, `import X = require(…)`, or static-specifier dynamic `import()` in a code-group file whose relative specifier designates a derived-file path (13.4: a file name containing `.xspec.`, a path under `.xspec/`, or a configured Markdown emit destination) without being a spec module import — e.g. `./NAME.xspec.ts` — is invalid (14.15): derived files are consumed only through their `.xspec` specifier. +In a TypeScript file, an import declaration is a spec module import exactly when its specifier ends in `.xspec`; the specifier follows the same form and resolution as 2.1 and MUST designate a discovered spec source (14.15). The permitted bindings from a spec module are the default export and the named `text` export, each optionally aliased; any other binding is invalid (14.15). A side-effect-only import (`import "./NAME.xspec"`), invalid in a spec source (2.1), binds nothing here, records nothing, and is valid, its specifier held to the rule above like any other's. A spec module import MAY be type-only — a `type` modifier on the declaration or on a named binding — and a binding introduced type-only is a type-level name (4.5). A TypeScript file's module-linking forms — the constructs naming a module by a string literal, their specifier — are exactly: an import declaration; an export declaration with a module specifier (`export * from`, `export * as NS from`, `export { … } from`, type-only forms included); an `import X = require(…)` declaration; a dynamic `import()` whose specifier is a static string literal; an import type (`import("…")` in a type, `typeof import("…")` included); and a string-named module declaration, its name the specifier (`declare module "…" { … }`, `declare` or not, a module augmentation included). An import declaration is the only one through which a TypeScript file consumes a spec module; every other module-linking form whose specifier ends in `.xspec` is invalid (14.15) — so every specifier naming a spec module stands in an import declaration, the form a file move rewrites (6.5), and no re-export carries a spec module's nodes or `text` past the edge recording and restrictions of 4.5. No other construct names a module to xspec: a dynamic `import()` whose specifier is not static is not analyzed and records nothing, and a triple-slash directive — a comment to the grammar (14.20) — or a string literal in any other position, a `require(…)` call's argument included, is never read as a specifier, validated as one, or rewritten (6.5). A module-linking form in a code-group file whose relative specifier designates a derived-file path (13.4: a file name containing `.xspec.`, a path under `.xspec/`, or a configured Markdown emit destination) without being a spec module import — e.g. `./NAME.xspec.ts` — is invalid (14.15): derived files are imported only through their `.xspec` specifier. The generated module MUST begin with a header identifying it as generated by xspec from its source file. Manual edits to generated files are invalid; staleness is detected by `xspec check` (14.10). @@ -195,7 +203,7 @@ The string form of `text(...)` is MDX-only; TypeScript usage is always the node ### 4.4 Module branding -Node types are branded per generated module. Passing a node from one module to the `text` export of another is invalid (14.11): it is a TypeScript type error, and at runtime the call MUST throw an error identifying both the node's module and the called module. When consuming multiple spec modules in one file, the `text` exports are aliased on import. +Node types are branded per generated module. Passing a node from one module to the `text` export of another is invalid (14.11): it is a TypeScript type error, and at runtime the call MUST throw an error whose message names both the node's module and the called module by their source files' workspace-relative paths (1.5). When consuming multiple spec modules in one file, the `text` exports are aliased on import. ### 4.5 Dependency markers @@ -210,13 +218,13 @@ function printHello() { A marker records a `references` edge from the enclosing code location to the node. At runtime, a marker is a harmless property read; markers MUST be valid with no additional tooling installed. A bare reference to the root node records a `references` edge to the root node only; because roots never participate in coverage paths (8), a root marker grants no coverage in any profile, but it makes the code location impacted by any change, in the document or upstream of it, that changes the root's subtreeHash or effectiveHash (9.2). -A marker and the argument to `text(...)` MUST each be a static property chain (2.4) rooted directly at a spec module import binding; the static argument rule applies in TypeScript equally: a non-static bare reference in expression-statement position is, like a non-static `text(...)` argument, an invalid argument (14.8), not unsupported usage (14.18). Rooting is scope-aware and value-level: an identifier that TypeScript scoping resolves to a local declaration shadowing an import binding is not a spec module reference, and neither is a binding introduced type-only (4), whose value-level use is a TypeScript error in the consumer, outside xspec's validations (6.4) — a chain rooted at either records no edge and falls under none of these conditions. The sanctioned value-level uses are exact: a node — a default-export binding or a chain of child property accesses from it — appears only as a marker or as the sole argument of a call whose callee is a spec module's `text` export, and a `text` binding appears only as such a callee. That call is an ordinary expression, valid in expression-statement position too, where it records its `embeds` edge (4.3) and is not a marker. Any other value-level use of either binding — aliasing, destructuring, re-export, storage in variables or data structures, passing to any other function — is invalid (14.18); passing a node to the `text` export of a different spec module is the cross-module call of 4.4 (14.11). Type-level references are unrestricted and record no edges. +A marker and the argument to `text(...)` MUST each be a static property chain (2.4) rooted directly at a spec module import binding; the static argument rule applies in TypeScript equally: a non-static bare reference in expression-statement position is, like a non-static `text(...)` argument, an invalid argument (14.8), not unsupported usage (14.18). Rooting is scope-aware and value-level: an identifier that TypeScript scoping resolves to a local declaration shadowing an import binding is not a spec module reference, and neither is a binding introduced type-only (4), whose value-level use is a TypeScript error in the consumer, outside xspec's validations (6.4) — a chain rooted at either records no edge and falls under none of these conditions. Shadowing is an inner scope's: an identifier a spec module import binds that another import, or a value-level declaration of the same module scope, also binds roots no resolving chain (2.4) — its chains unresolved (14.7) beside the collision (14.15), whether or not either import is type-only (4): the type-only exemption reaches a chain the language roots at one binding, and a colliding identifier roots it at none — while a type-level declaration of that scope (an interface, a type alias) leaves the import rooting the chain (2.4). The sanctioned value-level uses are exact: a node — a default-export binding or a chain of child property accesses from it — appears only as a marker or as the sole argument of a call whose callee is a spec module's `text` export, and a `text` binding appears only as such a callee. That call is an ordinary expression, valid in expression-statement position too, where it records its `embeds` edge (4.3) and is not a marker. Any other value-level use of either binding — aliasing, destructuring, re-export, storage in variables or data structures, passing to any other function — is invalid (14.18); passing a node to the `text` export of a different spec module is the cross-module call of 4.4 (14.11), which needs an argument that resolves. A call whose callee identifier a spec module import binds to its `text` export and another import, or a value-level declaration of the same module scope, also binds (2.4) is no spec module's `text` call — the language names no single binding, so no module's `text` is the one called: it records no edge and no occurrence (5.7) and falls under no condition of a `text` call (14.7, 14.8, 14.11), whatever its argument, while a spec module binding or node its argument spells is used there as in any other call — outside the sanctioned uses (14.18) — beside the collision (14.15). Type-level references to a spec module import's bindings are unrestricted and record no edges; an import type is a module-linking form, governed by 4. ### 4.6 Code locations and attribution A code location is either a whole file, identified by its workspace-relative path, or a named code unit within a file, identified as `path#unit`, where `unit` is the dot-joined chain of enclosing named-unit names, outermost first (`src/auth.ts#LoginService.validate`). -A named code unit is a construct that statically binds a plain identifier name to executable code: a function declaration; a class declaration; a class member with a non-computed identifier name (a method, getter, setter, or a property whose initializer is a function expression, an arrow function, or a class expression); a variable declaration with a plain identifier name whose initializer is a function expression, an arrow function, or a class expression; a namespace declaration — a dotted name (`namespace A.B`) declares nested namespaces, one named unit per dot-separated name, as in TypeScript's grammar (14.20); or a default export, whose name is `default` when the exported construct is anonymous. Constructs that do not statically bind a plain identifier — anonymous or immediately invoked functions; computed, string-literal, numeric-literal, or private (`#`-prefixed) member names; destructuring bindings — are not named code units. +A named code unit is a construct that statically binds a plain identifier name — an identifier spelled without escape sequences, read as spelled (2.4) — to executable code: a function declaration; a class declaration, decorated or not; a class member, decorated or not, with a non-computed plain identifier name (a constructor — a unit named `constructor`, `path#C.constructor` in a class `C` — a method, getter, setter, or a property whose initializer is a function expression, an arrow function, or a class expression); a variable declaration (`using` and `await using` included) with a plain identifier name whose initializer is a function expression, an arrow function, or a class expression; a namespace declaration — spelled `namespace X`, or `module X` with the legacy keyword, the same declaration wherever this specification names one — a dotted name (`namespace A.B`) declares nested namespaces, one named unit per dot-separated name, as in TypeScript's grammar (14.20); or a default export whose exported construct is a function declaration, class declaration, function expression, arrow function, or class expression — named `default` when that construct is anonymous; a default export of any other expression (an object literal, an identifier, a call, a literal value) binds no unit. Both readings are by the construct's own form: an initializer or exported expression that merely wraps one of the named forms — parenthesized, `as`-cast, `satisfies`-qualified, or non-null-asserted — is another expression and binds no unit. Constructs that do not statically bind a plain identifier — anonymous or immediately invoked functions; computed, string-literal, numeric-literal, or private (`#`-prefixed) member names; any name spelled with an escape sequence (`\u0066`); destructuring bindings — are not named code units. Neither is a declaration that binds no executable code — an overload signature, a body-less method signature, an abstract member, or a declaration in an ambient context, whether a `declare` modifier introduces it or its file is a declaration file, ambient by kind — by TypeScript's file-name rule, a name ending in `.d.mts` or `.d.cts`, or ending in `.ts` with `.d.` earlier in its last path segment (`.d.ts`, `.d.css.ts`): none is a unit or occupies a document-order slot (below), so a function implementation preceded by its overload signatures is `path#f`, never `path#f@N`. xspec attributes a TypeScript reference to the innermost enclosing named code unit, and to the file when none encloses it. When the same `unit` chain occurs more than once in a file (a getter/setter pair, same-named declarations in sibling scopes), occurrences after the first are disambiguated with a 1-based document-order suffix: `path#unit@2` identifies the second occurrence. @@ -235,11 +243,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 +277,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) — a cross-module call (4.4) included: its argument resolving, as the condition requires (14.11), it records its `embeds` edge (4.5) and its occurrence beside its finding — 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), a call through a colliding `text` identifier (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 @@ -277,32 +295,79 @@ xspec maintains a journal at `.xspec/journal`: a plain-text, append-only file wi ### 6.2 Identity guarantee -`xspec rename` and the file form of `xspec move` are pure: they change only identities and reference spellings, and MUST leave every hash in the workspace byte-identical and produce no change categories relative to any baseline, because child constructs and references hash by canonical identity (5.4, 5.5), which journaled renames and moves preserve. The section form of `xspec move` is not pure in general: its identity mapping changes no hash — no hash changes merely because identities changed (5.4), and every node of the moved subtree keeps its metadataHash — but its text edits (6.5) can, and each node whose own content sequence (1.6) changes is `changed`, with the ordinary cascades of 5.6 following, attributed to it. The operation removes a child construct from the origin parent and inserts one into the target parent, so distinct parents' sequences necessarily change — one loses a node reference, the other gains one. The moved text travels verbatim, and an interior line reads at the destination exactly as at the origin; but at the two lines the construct's boundaries straddle — its opening tag's line and its closing tag's line — the line-drop rule of 3 consults characters outside the moved text, and because insertion (6.5) starts the construct at the start of a line and follows its closing tag with a line terminator, a moved node's own content can differ between origin and destination. For example, a multi-line section whose opening tag is followed on its origin line only by whitespace but preceded there by non-whitespace contributes that line's within-construct remainder and terminator at the origin, where the line is kept, and not at the destination, where the line is dropped (3); such a node is `changed`, with the same cascades. A moved node none of whose own-content bytes lie on the straddling lines keeps its ownHash. When origin and target parent coincide, the text rules of 6.5 may reproduce the parent's own content exactly — a final construct re-inserted at its own former position — and such a move changes no hash and is pure in effect. +`xspec rename` and the file form of `xspec move` are pure: they change only identities and reference spellings, and MUST leave every hash in the workspace byte-identical and produce no change categories relative to any baseline, because child constructs and references hash by canonical identity (5.4, 5.5), which journaled renames and moves preserve. The section form of `xspec move` is not pure in general: its identity mapping changes no hash — no hash changes merely because identities changed (5.4), and every node of the moved subtree keeps its metadataHash — but its text edits (6.5) can, and each node whose own content sequence (1.6) changes is `changed`, with the ordinary cascades of 5.6 following, attributed to it. The operation removes a child construct from the origin parent and inserts one into the target parent, so distinct parents' sequences necessarily change — one loses a node reference, the other gains one. The moved construct's own-content byte runs (1.6) travel verbatim — the rewrites of 6.5 fall inside tags, props, and embedding expressions, all excisions — and an interior line contributes at the destination exactly what it contributed at the origin; but at the two lines the construct's boundaries straddle — its opening tag's line and its closing tag's line — the line-drop rule of 3 consults characters outside the moved text, and because insertion (6.5) starts the construct at the start of a line and follows its closing tag with a line terminator, a moved node's own content can differ between origin and destination. For example, a multi-line section whose opening tag is preceded on its origin line by non-whitespace and followed there by nothing but spaces and tabs, if anything, and whose closing tag is preceded on its line by nothing but spaces and tabs, if anything, and followed there by non-whitespace — the lines `foo <S id="m">`, `body`, `</S> bar` — contributes both boundary lines' within-construct whitespace and the opening line's terminator at the origin, where the lines are kept, and not at the destination, where both are dropped (3); such a node is `changed`, with the same cascades. Not every difference the rule admits is realizable, because a move whose rewritten files would not be well-formed is refused (6.5), and a U+000B or U+000C — whitespace under 1.4, not to the grammar, whose whitespace is spaces and tabs and which alone decides derivability (14.20) — keeps, at a line's start, the tag it adjoins in text position: the same section with its closing tag inside a paragraph line (`body</S>`) is refused, not moved — at a line's start its opening tag is a flow-position tag, which a text-position closing tag cannot close — unless the whitespace following the opening tag contains such a character, which realizes the difference on the opening line alone; and the worked shape spelled with such a character among the whitespace following its opening tag or among that preceding its closing tag, but not both, is refused in turn — the tag it adjoins staying in text position while the other, alone on its line, is a flow-position tag, and neither closes the other. A moved node none of whose own-content bytes lie on the straddling lines keeps its ownHash. An import addition (6.5) at the start of a line — judged, as 6.5 judges every insertion point, over the composed text: the file's end after a final terminator, an offset at which a preceding insertion's terminator ends the line, and one where a removal drops the file's unterminated last line included — changes no node's own content: the added line is dropped whole (3), and the compiled output is as it was; one placed elsewhere, which 6.5 admits only in a file holding no such offset, splits a line of the root's own content — the offset lies in no section construct, and what follows it on the line is empty or whitespace-only, or nothing at the file's end, since a non-blank remainder would join the added line's ESM block, admissible only where it was a block's line before, and a block admitting a mid-line offset admits its first line's start, which 6.5 takes over it — into a line the added terminator ends and, unless the offset is the file's end, a remainder line, empty or whitespace-only in the source and so kept (3), the drop rule judging the ended line on its own: the root keeps its own content exactly when the offset is the file's end — after a final line lacking a terminator — and that line, so ended, is dropped, having held non-whitespace only within removed constructs (1.6, 3); otherwise the receiving file's root is `changed` — the ended line kept with the added terminator, or the remainder line kept where the whole line was dropped — and its dependents `upstream-changed`, though the move touched no requirement of that file. A successful section move leaves `changed` no node but these: the origin and target parents; each node whose own-content bytes lie on a line the deletion joins or drops or the insertion splits — the moved construct's boundary lines at the origin and the insertion point's line at the destination, the moved subtree's nodes, the parents, and any sibling with bytes there alike — and which the drop rule of 3 decides differently there; and the root of each spec source receiving an import addition elsewhere than at a line's start, so judged. When origin and target parent coincide, the text rules of 6.5 may reproduce the parent's own content exactly — a final construct re-inserted at its own former position — and such a move changes no hash and is pure in effect. ### 6.3 Baseline resolution -When a command takes a baseline git ref, the baseline graph is reconstructed from the workspace content at that ref — sources and configuration alike, so group membership reflects the configuration as it stood at that ref. A journal file absent at the baseline ref, or absent in the current workspace, is read as an empty journal; an empty journal is a prefix of every journal, so baselines predating the first journaled operation resolve normally. The journal entries present in the current journal but absent from the journal content at the baseline ref are applied, in file order, to map baseline identities to current identities; chained mappings compose. Git history itself provides the ordering; journal entries contain no timestamps. Baseline hashes are computed with the journal content at the baseline ref; because the journal is append-only, canonical identities agree between baseline and current for every node changed only by journaled renames or moves, and hashes agree wherever those operations were pure (6.2). If replay produces an ambiguous or unresolvable mapping, if the journal at the baseline ref is not a prefix of the current journal (the append-only invariant was violated), or if the baseline content cannot be parsed and validated as a workspace, the command MUST fail with an actionable error naming the offending entries or files; a baseline that cannot be read or reconstructed is a usage error (12.0). +When a command takes a baseline git ref, the baseline graph is reconstructed from the workspace content at that ref — sources and configuration alike, so group membership reflects the configuration as it stood at that ref. The baseline configuration is the file at the current configuration file's repository-relative path in the ref's tree — the file the upward search found or the path `--config` names (7) — and the baseline workspace root is that file's directory. The repository is the one whose working tree contains the current configuration file — the innermost, where repositories nest (a submodule or nested repository) — whatever repository the working directory lies in: the ref resolves there, and the path is relative to that working tree's root. When the configuration file lies inside no repository's working tree, or the ref's tree holds no file at that path, the baseline cannot be reconstructed (below). A journal file absent at the baseline ref, or absent in the current workspace, is read as an empty journal; an empty journal is a prefix of every journal, so baselines predating the first journaled operation resolve normally. The journal entries present in the current journal but absent from the journal content at the baseline ref are applied, in file order, to map baseline identities to current identities; chained mappings compose. Git history itself provides the ordering; journal entries contain no timestamps. Baseline hashes are computed with the journal content at the baseline ref; because the journal is append-only, canonical identities agree between baseline and current for every node changed only by journaled renames or moves, and hashes agree wherever those operations were pure (6.2). If replay produces an ambiguous or unresolvable mapping, if the journal at the baseline ref is not a prefix of the current journal (the append-only invariant was violated), or if the baseline content cannot be parsed and validated as a workspace, the command MUST fail with an actionable error naming the offending entries or files; a baseline that cannot be read or reconstructed is a usage error (12.0). ### 6.4 Rename ```sh -xspec rename <file> <old-id> <new-id> +xspec rename <file> <old-id> <new-id> [--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 `<file>` 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 (1.4), 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 — every rewritten spelling carrying the identity's characters verbatim, which every valid identity admits in every form (1.4, 2.4). 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 (a `<new-id>` that is not a well-formed argument value never reaches this check — a usage error, 12.0); 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. Every rewritten reference resolves by construction — each targets an identity the operation creates or keeps, spelled verbatim as above, in local form or through a binding of that identity's own module (6.5) — so no refusal reason exists for it. A `<file>` or old ID that does not exist is a usage error (12.0); so is a `<file>` 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), workspace exclusivity is held before either (13.5), 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 for any validation reason, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. The operation's writes are ordered — every source edit, then the journal append, then the finishing regeneration (13.5) — and a write the environment refuses (14.24) stops it at that write, leaving what was written, each file complete (13.5): met during the finishing regeneration, it leaves the operation's identity effects complete and the derived files not yet regenerated stale (14.10) until the next successful `build`; met earlier, it leaves no journal entry, the source edits already made standing as manual restructuring (6.7, 13.5). A successful rename's report is the applied mapping: the complete identity mapping the operation journaled — the preview's `mapping` (6.6) — carried in JSON in the applied-mapping document form of 12.7. A rename a write failure stops after its journal append is complete in identity effect but not successful: it reports the write failure alone (12.0, 14.24), its journaled mapping observable thereafter only through the journal's effects (6.1). ### 6.5 Move ```sh -xspec move <old-file> <new-file> -xspec move <file>#<id> <target-file>#<new-id> +xspec move <old-file> <new-file> [--preview] +xspec move <file>#<id> <target-file>#<new-id> [--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 `<id>` with `<new-id>`. 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 (`</S>` or `</Spec>`) 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). +**Forms.** 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 `<id>` with `<new-id>`. 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, its content composed as fixed below; a target path occupied by anything else is refused (below). + +**Moved text and 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 — judged over the composed text, never the pre-operation text alone (below). 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 (`</S>` or `</Spec>`) 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. + +**Reference spellings.** 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, and the full mapping is appended to the journal. The operation roots each reference spelling whose target's identity the mapping changes, and each one the moved text carries, whatever its target, at the form in which it resolves in the file where it will stand after the operation — the target file, for one the moved text carries (2.1, 2.2, 4.5): local form (2.2, 6.4) where that file is the source of the module its target then belongs to, and otherwise a value-level binding (4.5) of that module — a reference in external form — a `d` reference's or embedding's chain (2.2, 2.4), a marker, a `text(...)` argument — at the module's default binding, and a TypeScript `text(...)` call's callee at the module's `text` binding (4.3, 4.4). Read from the target file, a moved-text reference in local form to a node the origin file keeps would name a node of the target file (2.2), and one rooted at a binding of the origin file (2.4) that the target file lacks, or binds to another module, would name nothing or another node: each is rooted at a binding the target file holds of its target's module, or at one an addition gives it (below) — or in local form where that module is the target file's own, as is the target file's own reference, through a binding of the origin module, to a node of the moved subtree. Each such binding is one the file already holds that no local declaration shadows at the occurrence (4.5) and, in a TypeScript source, that is timely for the spelling (below) — the choice among several, like an added import's identifiers, implementation latitude exercised deterministically (below) — or, where the file holds none, the binding of the declaration the operation adds to it (below), and an import is added exactly where a file lacks a binding a spelling is rooted at: one added declaration per module whose bindings the file's spellings are rooted at and it lacks, binding exactly the lacked ones — a chain is rooted at the default export, and a TypeScript call's callee at `text` (2.1, 4) — whether or not the spellings' characters change. In a TypeScript source, a binding is timely for a spelling the operation roots — a chain, or a call's callee — when its declaration is or precedes that of the binding the spelling was rooted at before the operation, or follows it with no top-level statement between them but import declarations (positions in pre-operation coordinates, an added declaration's at its offset). Where each import runs where it stands, as in TypeScript's CommonJS output (an ECMAScript module hoists every import), each top-level statement but an import declaration that ran after the replaced binding's initialization then runs after the new one's, so none finds a rewritten spelling's binding uninitialized where it found the replaced one initialized: in a TypeScript source holding `import A from "../specs/A.xspec"`, `A.m`, and `import B from "../specs/B.xspec"`, in that order, a move of the marker's node into B's module roots the marker at an added declaration's binding, not at `B`, which that output initializes only after the marker runs. A rewrite is made, and reported (6.6), exactly when it changes the construct's characters: a spelling that already resolves to its target's new identity in the form it is rooted at — read with the chosen binding in place, an added declaration's included — an `id` attribute already spelling its node's new ID, and a specifier still designating its source (below) are neither rewritten nor reported, so a file move, which changes no reference spelling and no `id`, reports specifier rewrites and its relocation alone, and a cross-file section move keeping its ID rewrites no `id` attribute and no local-form reference inside the moved text to a node of the moved subtree; a spelling rooted at an added binding is located by the addition it needs whether or not it is rewritten (14). A call whose target the section form carries into another file is rooted, callee and argument, at bindings of the module its target joins and so rewritten whole, over its occurrence's span (5.7), and is never the cross-module call of 14.11, while the compiler's `text` of an MDX embedding (2.3) and the callee of a call whose target keeps its module — every call under a file move — are not touched. + +**Import edits.** An occurrence (5.7) uses a binding when its chain is rooted at it (2.4, 4.5) or, for a TypeScript `text(...)` call, its callee is it (4.5); an existing spec module import is removed exactly when an occurrence used a binding of its before the rewrite and none uses any binding of its after it (an import whose bindings were already unused stays, 2.1) — except that in a spec source, whose ESM block the grammar bounds line-sensitively (14.20), the removals in one block are judged together, over the block as all of them would leave it: where they would leave it headed by anything but a declaration at the start of its first line — a JavaScript comment (the first of `import A from "./A.xspec"`, `// note`, `import B from "./B.xspec"` on successive lines, or `import A from "./A.xspec" // note` above `import B …`; 3), or an indented declaration — the remaining declarations would derive as paragraph text (14.20), so the block's first declaration stays, whether or not any other declaration would remain, its binding unused (2.1) and no removal reported for it (6.6), and the others are removed, the block it still heads deriving as before — a block they would leave with no line at all, every line dropped (3), being headed by nothing, its first declaration removed with the rest. A type-level spelling of a binding (4.5) is no occurrence and keeps no import — a removal can leave one naming a vanished binding, a consumer type error outside xspec's validations, as 6.4 states — while the workspace stays valid. + +Import edits are exact. A specifier rewrite (the file form) replaces the characters between the specifier literal's delimiters, the quote style kept (6.4) — either style spells every valid spec-source path (7.1) — with the canonical relative spelling of the designated source's post-operation path, `.mdx` replaced by `.xspec` (2.1), from the importing file's post-operation directory: the `..` ascents, then the descending segments, joined with `/`, no `.` segments — as 11.6 spells anchoring — prefixed `./` when there is no ascent (2.1). A specifier is rewritten exactly when its current characters, resolved from that directory after the operation, would no longer designate that source; one that still would — the moved file's own imports when it is relocated within its directory — is neither rewritten nor reported (6.6). 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. + +**Added imports.** An added import binds fresh identifiers, each: + +* one module code, strict throughout, admits as a binding: no ECMAScript reserved word (`default`, `enum`, `await`, and `yield` among them), and none of `let`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, `eval`, and `arguments`, whose binding 14.20's derivability admits but an ECMAScript runtime and TypeScript's compiler reject in module code; +* none of `require` and `exports`, which TypeScript's compiler reserves in a module it emits in any format but ECMAScript's; none beginning with `__`, as do the helpers its emit declares at a module's top level (`__awaiter`); and none naming a global its emitted code may read, whatever the module format and target — a property ECMAScript 2024, its Annex B included (`escape`, `unescape`), defines on the global object (a lowered object spread reads `Object`, and below some targets its compiler reserves `Promise`, `Reflect`, `WeakMap`, and `WeakSet`), `Iterator`, `AsyncIterator`, or `SuppressedError`, and, in a TSX source (14.20), a name through which TypeScript's classic JSX transform reaches the file's JSX factories where no compiler option names them: `React` (`React.createElement`, `React.Fragment`), or the leading identifier of a factory a `@jsx` or `@jsxFrag` pragma in one of its comments names, the pragma's name matched regardless of ASCII case, as TypeScript matches it (`h` of `/** @jsx h */` or of `/* @JSX h */`); +* bound by no declaration already in the file, in any scope and at value or type level alike (2.1, 4), so that none shadows it where a spelling is rooted at it (4.5); +* equal to no name the file already references at value or type level, whether or not the file binds it (a global's or an ambient declaration's included, such as a test runner's `test`, whose references the added module-scope binding would capture); +* distinct from the others added there; and +* in a spec source, none of the compiler-provided names (2.1). + +An added import is spelled exactly: `import X from "…"` in a spec source; in a TypeScript source `import X from "…"`, `import { text as Y } from "…"`, or `import X, { text as Y } from "…"`, as the lacked bindings require, the named binding `{ text }` where its identifier is `text` itself — single spaces as shown, no statement terminator, the specifier double-quoted in the canonical spelling a specifier rewrite produces (above), from the importing file's directory. Into a file existing before the operation it 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, judged as the target insertion's is (below) — at an admissible offset, one where: + +* the offset lies inside none of the file's statements before the edit (in a spec source, its ESM blocks' declarations; in a TypeScript source, a line start between `const s = textC` and `(C.c)`, one statement calling `textC` that automatic semicolon insertion would let the added line part, lies inside one), so that the added line stands between whole ones and splits none, and the file, as every edit of the rewrite leaves it, is well-formed under its grammar (14.20) with the added line an import declaration; +* in a TypeScript source, that declaration is a top-level one whose bindings are timely (above) for every spelling rooted at them, and the offset lies at or after the end of the file's directive prologue (ECMAScript's: its leading statements each a string literal alone, such as `"use client"`) and follows the end of a top-level statement with nothing but whitespace (1.4) between, so that the added line parts no comment from the statement it precedes (a `// @ts-expect-error` from the line it governs) — the prologue's end and the statement's each judged, like timeliness, over the file before the edit, a statement the rewrite removes included, so the added line can take a removed declaration's place: a comment that preceded that declaration then precedes the added line (a `// @ts-expect-error` above a removed `import O …` then governs the added import), and a string-literal statement that followed it stays, as before the edit, no directive; +* in a spec source, that declaration belongs to an ESM block — one the line begins or one it joins — that stands inside no section construct of the file so left, the inserted one included (an ESM block derives inside a section element too, 14.20, but a moved section holding one is refused, below, so no performed rewrite adds one inside a section), and whose other lines were an ESM block's before the edit. + +In a spec source these conditions follow the grammar, which bounds an ESM block line-sensitively (14.20): a block cannot interrupt a paragraph, so the line after a paragraph line is paragraph text, and it runs to the next blank line or the file's end, so an added line followed by a non-blank line absorbs it — one holding no declaration leaves the block underivable, an offset inadmissible as not well-formed, and a comment-headed or indented paragraph of declarations, no block until the added line heads it, turns into declarations the rewrite accounts for nowhere (2.1, 14.15, 14.16), an offset inadmissible, deriving or not, as joining lines that were no ESM block's before the edit. + +**Composition and admissibility.** How every edit of the rewrite leaves a file is fixed by composition in pre-operation coordinates (6.6): each range an edit replaces or deletes is replaced whole; an addition's offset lies strictly inside no other edit's range; an insertion at an offset where another edit's range ends stands after that edit's result, and where one begins, before it — excepting the target insertion alone at the end of the self-closing target parent's rewrite range, which precedes the appended closing tag (above), while a declaration added at that offset stands after the tag, outside the parent, as the rule reads; and insertions sharing one offset stand in a fixed order, whatever order the preview lists them (6.6): the target insertion — the moved text and its terminators — first, then, after the appended closing tag where one applies, the added declarations, contiguous, in the order the implementation fixes deterministically, in a spec source one ESM block. So at the end of a file whose last line is a paragraph line — the insertion point of a top-level `new-id` — an addition is admissible where the moved text ends in a flow-position closing tag: the added line then follows that tag's line, no paragraph line, while the reverse order would make it paragraph text. Whether an insertion point — the target insertion's or an added declaration's — is at the start of a line, which decides the U+000A before it (above), is judged over the composed text with that insertion's own result absent, never over the pre-operation text alone: it is at the start of a line exactly when nothing precedes it there or a line terminator (3) immediately precedes it. Hence a declaration sharing its offset with a declaration ordered before it, or with the target insertion where no appended closing tag stands between them, is preceded by no terminator — that declaration's, or the moved text's, ends the line before it — which is what keeps the added declarations contiguous, while the first declaration added after a self-closing target parent's appended closing tag is preceded by the tag's `>`, hence by an added terminator, the tag's line parting the block from the moved text and the block itself contiguous (a target file holding `<S id="p" />` alone, its line lacking a terminator — an addition at its start would absorb the tag's line, so the tag's end is its only admissible offset, whereas with a final terminator the file's end after it, a line-start offset, is taken instead — becomes `<S id="p">`, U+000A, the moved text, U+000A, `</S>`, U+000A, the declaration, U+000A, its root keeping its own content, 6.2); a top-level `new-id` inserted at the end of a file whose last line lacks a terminator is preceded by one, and a declaration added there after it by none (the moved text's closing tag, U+000A, the declaration, U+000A — no empty line between); and an insertion at an offset where a removal's or the origin deletion's range ends reads what that edit leaves — at the end of a file whose unterminated last line the edit drops, a terminator or nothing, so none is added. A file holding no admissible offset for an addition it needs refuses the move (`refused-invalid-rewrite`, below). The identifier choice and the choice among admissible offsets are implementation latitude, exercised deterministically — an admissible offset at the start of a line, judged as above, the file's end after a final terminator included, taken over any other, so that the file's nodes keep their own content wherever it admits one (6.2): rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and the offset is exactly the one the operation's preview reports (6.6). + +**Created target file.** A created target file's initial content is fixed instead of chosen: the declarations it needs, each followed by a U+000A line terminator, in an order the implementation fixes deterministically, then — when there is at least one — an empty line, one further U+000A, and then the moved text and its terminator: the empty line ends the ESM block, so the content derives exactly when the moved text alone at a line's start would; the preview's file-creation class subsumes it all (6.6). A successful move regenerates derived files, and reports its applied mapping, as rename does (6.4). + +**Validation and refusals.** A move operand is classified by spelling alone: an operand containing `#` is a `<file>#<id>` 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, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>`, or `<new-file>` equal to `<old-file>`, compared byte-wise (12.0) — is refused and appends no journal entry — and, in the section form, `<new-id>`, and each ID its prefix replacement produces, collides with no ID remaining in the target file after the removal. Move additionally MUST refuse, each case under the reason 14 assigns it: -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, `<target-file>#<new-id>` equal to `<file>#<id>`, is refused and appends no journal entry — and, in the section form, `<new-id>` 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 `<new-id>` minus its final segment, needed whenever `<new-id>` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; 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. +* `refused-cycle` — a move that would create an import cycle among spec source files or a dependency cycle. +* `refused-destination-exists` — a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included — unless the destination path is the origin path itself: the exact self-move (above) is refused as identity-unchanged alone, as rename's is (6.4), its only occupant being the origin the relocation would remove; and 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. +* `refused-missing-target-parent` — a section-form move whose target parent — the target file's section bearing `<new-id>` minus its final segment, needed whenever `<new-id>` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal. +* `refused-invalid-destination` — 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) — one spelled with a `.`, `..`, or empty segment, which no discovered path carries (7, 12.0), included — belonging to a code group as well (14.14), lacking the `.mdx` extension, or containing `"`, `'`, `\`, U+000A, U+000D, U+2028, or U+2029 (7.1, 14.19) — 14.19's other invalid forms, a destination containing `#` or U+FFFD or not valid UTF-8, are unspellable as arguments and so usage errors, never refusals (12.0, above); 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), while a nonexistent component obstructs nothing, since writes create those (13.4); a file-form destination or a target file to be created, or a derived path it would generate, that is a directory component of another derived path the sources would generate after the move (13.1, 13.2, 7.3) or lies under one, or a derived path it would generate that is the path of, or a directory component of the path of, a discovered source other than a relocated origin — the finishing regeneration's writes then meeting a plain file where they need a directory (14.22), or replacing a source, or a directory holding a source or another derived path the sources would generate after the move (13.4), and an emit destination so added over a code source (7.2) first excluding that source from every group (13.4); or, while Markdown emission is enabled (7.3), the path the destination would emit Markdown to (13.2) designated by the relative specifier of a code source's module-linking form, which the move would make a derived-file path (4, 14.15). +* `refused-exposed-derived-file` — a file-form move, while Markdown emission is enabled, whose origin's emit destination — no longer an emit destination once the relocation removes the origin, so no longer excluded from discovery (13.4) — holds an occupant discovery would then yield as a source (7, 13.4). +* `refused-invalid-rewrite` — a section-form move whose exact edits would leave a rewritten file invalid: the origin file as its deletion leaves it, or the target file as its parent rewrite and insertion leave it or as its creation composes it (the one file as deletion and insertion both leave it, when origin and target coincide), not well-formed MDX (14.20); or a file the rewrite must add an import to holding no admissible offset (above). The grammar reads the edited text line-sensitively — a tag alone on its line is a flow-position tag, which interrupts a paragraph and closes no text-position tag, and an ESM block absorbs the non-blank lines after it (14.20) — so the exact edits cannot always compose: a section whose opening tag is preceded on its line by non-whitespace and followed there by nothing but spaces and tabs, if anything — whitespace to the grammar (14.20) — its closing tag inside a paragraph line, does not derive at a line's start, nor does one with a U+000B or U+000C among that whitespace — keeping its opening tag in text position there — whose closing tag stands alone on its line (6.2); a section opening a flow-position tag — a tag alone on its line, or a self-closing tag — does not derive inside a parent whose tags stand in text position (`foo <S id="p">bar</S> baz`, whether its closing tag stands on the opening tag's line or on a later one, or the self-closing form of such a parent after its rewrite); a deletion can leave what followed the construct on its line at the line's start — a list marker, a setext underline, a flow-position tag or expression — interrupting the paragraph that holds the parent's text-position opening tag; an insertion at the end of a file whose last line belongs to an ESM block is absorbed into it; and the moved text of a section standing in a block quote carries the `>` prefixes of its interior lines, which at a line's start put its closing tag inside a quote its opening tag stands outside. Each is this refusal, reported beside every other applicable reason (14) and judged over the would-be text: the origin file's, and the additions every other file needs, always; the target file's — its well-formedness and its additions alike — exactly when an insertion point exists — when the target path is a discovered spec source or an absent path (`refused-destination-exists` otherwise, 14) and the target parent exists outside the moved subtree (`refused-missing-target-parent` otherwise); and, like `refused-structural-parent`, only under an intrinsically valid `<new-id>` (`refused-invalid-id`, 14), an invalid one spelled verbatim into `id` attributes and reference spellings (6.4) leaving the would-be text undefined. +* `refused-moved-import` — a section-form move whose moved text holds an import declaration, judged over the moved text as it stands, whatever the edits would leave: the exact edits would carry the declaration into the target file, where its specifier resolves from that file's directory (2.1) and its binding may collide with one the file holds (14.15), while every origin-kept reference rooted at its binding would lose it (2.4) — outcomes no rewrite of this section covers — so such a section is movable once the declaration stands outside it. -### 6.6 Manual restructuring +These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace — its files well-formed, every reference spelling it carries or rewrites resolving to its target in local form or through a binding its file holds, in a TypeScript source one timely for it (above), every specifier it rewrites or adds designating its source — its canonical spelling, a well-formed literal in either quote style (7.1) — no added import binding a name strict module code cannot bind or one the rules above bar for TypeScript's compilation, capturing a name its file references, or parting one of its statements, nor, in a TypeScript source, preceding the end of its first statement or directive prologue or parting a comment from the statement it precedes, each judged over the file before the edit (above), no import declaration carried between files (above), no module-linking form made to designate a derived-file path, no file exposed to discovery by a vacated emit destination, and no source hidden or replaced by an added derived path (above), the destination's path and write paths vetted here — against the filesystem, every other derived path, and every source (above) — 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 for any validation reason, its removals included, whatever the order of its writes (13.4); a write the environment refuses is a write failure (14.24), as in 6.4. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). + +### 6.6 Previews + +`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 — one entry per node whose identity changes: the renamed or moved section and each of its descendants, or, for a file move, the root and every section of the moved file; +* 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 located 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 — the form 6.5 fixes — subsuming the insertion and the import additions the rewrite requires there, 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, its delimiters included, 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 — for a self-closing target parent, the end of its tag's range, where the rewrite of 6.5 places the closing tag the insertion precedes — 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. @@ -343,13 +408,13 @@ 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). +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; no `type` or `defer` modifier and no import attributes) 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; comments are permitted anywhere and contribute nothing. The file's bytes MUST be valid UTF-8 and MUST NOT begin with a byte-order mark, as a source file's must (1.6). Configuration therefore cannot carry side effects, environment-dependent values (12.0), or network access; a configuration file that violates the encoding rule, is not well-formed TypeScript (14.20), 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 <path>` 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 <path>` option. The upward search stops at the nearest directory — the working directory itself first — holding an entry named `xspec.config.ts`, whatever occupies it. The configuration file is the occupant of the path so found or named, read only when it is a plain file: any other occupant — a directory, a symbolic link whatever it targets, or anything else — is missing or invalid configuration (14.14), never read through, and a plain file the environment refuses to read is invalid configuration too (14.14, 14.25). `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). So are a key repeated within one object literal — an identifier key and a string-literal key spelling the same name included, whatever TypeScript's own diagnosis of the repetition — and an empty group, profile, or rule name (`""`). A group, profile, or rule name containing U+FFFD is a configuration error (14.14): no argument value carries the character (12.0), and configured names are named in arguments. 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 or contains U+FFFD 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 — with this meaning only as a whole pattern segment; elsewhere each `*` of a `**` is the single-segment wildcard); matching is case-sensitive; a path segment beginning with `.` is matched only by a pattern segment written with a leading `.`. A discovered file's workspace-relative path is the directory-entry names descending from the workspace root, joined with `/` (1.5), so it carries no `.`, `..`, or empty segment. Whether a glob lies outside the workspace root is decided by its spelling alone — here and for the `--file` patterns of 11 and 12.3: reading its `/`-separated segments in order from a depth of zero, a `..` segment lowers the depth by one; a `.` segment, an empty segment (a doubled or trailing `/`), and a `**` segment, which may match no segment at all, leave it unchanged; and every other segment raises it by one (a drive-qualified spelling is ordinary segments). A glob beginning with `/`, or whose depth ever falls below zero, is outside the root, a configuration error (14.14); every other glob is inside, its `.`, `..`, and empty segments matching nothing. 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` -Named groups of xspec source files, each a list of globs. A file MAY belong to multiple groups. Every matched file MUST have the `.mdx` extension; a match with any other name is invalid (14.19). +Named groups of xspec source files, each a list of globs. A file MAY belong to multiple groups. Every matched file MUST have the `.mdx` extension, and its workspace-relative path MUST NOT contain `"`, `'`, `\`, U+000A (line feed), U+000D (carriage return), U+2028 (line separator), or U+2029 (paragraph separator), so that every canonical specifier spelling designating a spec source (2.1) — the spelling every specifier rewrite and import addition writes (6.5) — delimited by either quote character, is a well-formed string literal (14.20) whose value is the same whether read verbatim (2.4) or as TypeScript tooling reads it (13.1). A match with any other name, or whose path contains such a character, is invalid (14.19). ### 7.2 `code` @@ -357,7 +422,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` is a directory path relative to the workspace root, spelled as one or more non-empty `/`-separated segments, none `.` or `..` — the form of every workspace-relative path (1.5, 7) — so each emit destination, `outDir` joined by `/` to the emitted file's default workspace-relative path (13.2), is itself a plain workspace-relative path, compared byte-wise where 4 and 13.4 compare it; any other spelling — empty, beginning with `/`, or carrying a `.`, `..`, or empty segment — is a configuration error (14.14). So is an `outDir` of `.xspec` or beginning with `.xspec/`: no emit destination lies in the graph-data area (13.3), which holds graph data at unenumerated paths beside the journal (6.1) and review sessions (10.1), so graph data is the only derived file under it (13.1, 13.4). 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` @@ -372,6 +437,8 @@ Named coverage profiles. Each profile has: * `mode` (required): `"direct"` or `"transitive"` * `edgeKinds`: optional subset of `["depends", "embeds", "references"]`; defaults to all three; an empty list is a configuration error (14.14) +`targetTags` and `edgeKinds` — like a rule's `kinds` and a selector's `tags` (7.5) — are read as sets: a repeated element collapses, as on a list-valued flag (11.1). + ### 7.5 `policy` Named policy rules constraining which dependency edges may exist. Each rule has: @@ -383,14 +450,14 @@ Named policy rules constraining which dependency edges may exist. Each rule has: A selector matches nodes (or code locations) by exactly one of: `{ group: <name> }`, `{ files: <glob> }`, or `{ tags: [<tag>, ...] }` (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: * `forbidden`: any edge whose source matches `from` and whose target matches `to` is a violation. * `allowedOnly`: every edge whose source matches `from` MUST have a target matching `to`; each edge that does not is a violation. -Violations are findings reported by `xspec check` — and only by `check`: `build` does not evaluate policy (12.1, 14.12) — with the rule name and the offending edge, and cause exit code 1. +Violations are findings reported by `xspec check` — and only by `check`, and only on a workspace passing `build`'s validations: `build` does not evaluate policy, and a failing workspace defines no graph to constrain (12.1, 14.12) — with the rule name and the offending edge, and cause exit code 1. ## 8. Coverage @@ -435,11 +502,11 @@ Review turns graph results into a staged checklist. xspec separates the review m ### 10.1 Sessions -A review session is stored at `.xspec/reviews/<session-name>.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 `<session-name>.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/<session-name>.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. The session directory `.xspec/reviews/` holds sessions only while a directory occupies its path: absent — every workspace's state before its first `review create`, whose write brings it into existence (13.4) — it holds none, and nonexistence is never a refused read (14.25); occupied by anything other than a directory — a plain file, or a symbolic link whatever it targets — or lying below such an occupant of the graph-data area's own path (13.4), it holds none either: no command lists through such an occupant (7, 13.4), which obstructs every session write, `create`'s included (14.22). Only a directory entry directly under it named `<session-name>.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 read (14.25) or 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 -A review item contains: `id` (unique within the session), `kind` (assigned by the strategy), `scope` (the requirement nodes or code locations under review), `context` (nodes whose text frames the review), `reason`, `origin` (the originating nodes (5.6), when applicable), `baseline` and `current` (the item's relevant hashes and node presence, 10.4), `status`, optional `note`, and `blockedBy` (item IDs that must resolve first; empty except where a strategy (10.5, 10.6) or `split` (10.7) assigns blockers). An item is created with status `unresolved`, wherever it enters the session — `create`, re-derivation (10.5), or `split` (10.7). `baseline` is fixed when the item enters the session: in a baseline session, the values in the graph at the recorded baseline (10.7); in a session without one (`audit`, `coverage`), the values in the current graph at that moment. `current` is the recorded state of 10.4 — written at item creation, rewritten at each resolve. Reads report both fields as recorded (nodes presented per 10.4); read-time invalidation compares `current` against the current graph and never rewrites it. Items MUST carry enough baseline identity and baseline text that they remain actionable after the referenced nodes are edited, moved, or deleted; the text values carried and presented are fixed by the payload rule of 10.7. +A review item contains: `id` (unique within the session and never containing U+FFFD, so every item is nameable as an `<item-id>` operand, 12.0), `kind` (assigned by the strategy), `scope` (the requirement nodes or code locations under review), `context` (nodes whose text frames the review), `reason`, `origin` (the originating nodes (5.6), when applicable), `baseline` and `current` (the item's relevant hashes and node presence, 10.4), `status`, optional `note`, and `blockedBy` (item IDs that must resolve first; empty except where a strategy (10.5, 10.6) or `split` (10.7) assigns blockers). An item is created with status `unresolved`, wherever it enters the session — `create`, re-derivation (10.5), or `split` (10.7). `baseline` is fixed when the item enters the session: in a baseline session, the values in the graph at the recorded baseline (10.7); in a session without one (`audit`, `coverage`), the values in the current graph at that moment. `current` is the recorded state of 10.4 — written at item creation, rewritten at each resolve. Reads report both fields as recorded (nodes presented per 10.4); read-time invalidation compares `current` against the current graph and never rewrites it. Items MUST carry enough baseline identity and baseline text that they remain actionable after the referenced nodes are edited, moved, or deleted; the text values carried and presented are fixed by the payload rule of 10.7. ### 10.3 Statuses @@ -505,22 +572,26 @@ xspec review next <name> [--json] xspec review show <name> <item-id> xspec review split <name> <item-id> xspec review resolve <name> <item-id> --status <status> [--note <text>] -xspec review export <name> --json +xspec review export <name> [--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 <name>` reports the session's items in item order — each with id, kind, scope, status, and blocked state — plus totals by status. `show <name> <item-id>` reports the full item: every field of 10.2 plus the same self-contained text payload as `next --json`. `export <name>` 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 <name>` reports the session's items in item order — each with id, kind, scope, status, and blocked state — plus totals by status. `show <name> <item-id>` reports the full item: every field of 10.2 plus the same self-contained text payload as `next --json`. `export <name>` 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. +`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 (10.1); when that session is corrupt, its corruption (14.21) is reported in the refusal's place — one finding, exit 1 either way. -## 11. Query +## 11. Query Surfaces -`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): +Five commands give scripts, agents, and external tools machine access to the workspace: `query` (11.1) answers set-level graph questions; `occurrences` (11.3) enumerates reference occurrences; `view` (11.4) returns whole-document structural views; `at` (11.5) resolves byte positions; `inventory` (11.6) reports the workspace's shape. Each is JSON-only: a single JSON document is its only output form, with or without `--json` (12.0) — `occurrences`, `view`, `at`, and `inventory` in the document forms of 12.7, `query` carrying its defining section's information (12.7). `occurrences`, `view`, and `at` answer per file under the availability contract of 11.2; `query` reads only valid workspaces (13.3); `inventory` parses no sources and answers whatever their validity (11.6). + +### 11.1 `xspec query` + +`xspec query` gives scripts and agents set-level access to the graph: ```sh xspec query node <node> @@ -531,31 +602,107 @@ xspec query ancestors <node> xspec query reachable --from <graph-node> --to <graph-node> [--kinds <kinds>] ``` -`<node>` is a requirement-node identity: `path#id`, or a bare `path` for a file's root node (1.5). `<graph-node>` 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 <node>` returns the queried node and all its descendants, in document order; `ancestors <node>` 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 <glob>` 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. +`<node>` is a requirement-node identity: `path#id`, or a bare `path` for a file's root node (1.5). `<graph-node>` 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 tags and the coverage attribute are both 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. `--tag` accepts any well-formed tag (1.4), whatever the workspace contains — acceptance is syntactic, as on `occurrences --to` (11.3): a spelling no tag can have (empty, or containing whitespace, `"`, `\`, …) is a malformed value, a usage error (12.0), while a well-formed tag no node carries matches nothing, exit 0. `nodes`, `subtree`, and `ancestors` return one row per node: identity, source range, tags, and coverage attribute (tags and coverage attribute both absent for roots). `subtree <node>` returns the queried node and all its descendants, in document order; `ancestors <node>` 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 read as a set drawn from its command's vocabulary: an element that is empty — a leading, trailing, or doubled comma — or outside the vocabulary is an invalid flag value (12.0), and a repeated element collapses; `--file <glob>` 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) — one the environment refuses to read included (14.25) — 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; a chain rooted at an identifier a spec module import and another declaration of its scope both bind (2.4) — records no edge and no occurrence, and never reports an unavailable target: its position reaches consumers through its finding's range (14). Resolution is per spelling, whatever the validity of the attribute holding it: each entry of every `d` attribute of a section that repeats the prop (14.17) resolves or not on its own — an occurrence, or a finding (14.5), beside the repetition's finding — exactly as the entries of a single `d` do. 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 — an element by its own tags: the sections and embeddings it encloses (11.4) are classified by their own forms. 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 the source path a condition-19 finding concerns (14.19) — the one concerned path naming a domain file's own finding: an obstructing component (14.22) that is itself a discovered file attaches nothing; 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 `<file>` 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 — a refresh write the environment refuses (14.24) alone stops them, a usage error before any answer (13.3). + +### 11.3 `xspec occurrences` + +```sh +xspec occurrences [--file <glob>] [--to <node>] +``` + +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) — malformed as an argument value (12.0) or as an identity. A spelling is well-formed exactly when it is a well-formed argument value (12.0), 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> …] [--file <glob>] [--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 `<file>` 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 `<file>` 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 element (an invalid element of 14.16 — inside an expression container, `<S>` is no section, 2.7) 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, its self-closing tag's own characters, equal to its construct range (1.7), 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 — the discovered spec source its specifier designates under 2.1, parseable or not — explicitly unavailable where specifier form or discovery defines none (11.2): a specifier of invalid form, or one designating no discovered spec source, a discovered code source included — the invalidity itself a located finding (14); +* every reference occurrence in the file (5.7), in document order; +* every MDX comment's source range: its full braced container, opening brace through closing brace — the construct compilation removes (3). + +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 <file> <offset> +``` + +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). `<file>` 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), judged after the operand's domain check, which the file's length presupposes; so is an `<offset>` 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 or contains U+FFFD 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). The offset bound above is judged wherever the content was read — an encoding or syntax failure (14.20) leaves the length known — and never on a file whose content the environment refuses (14.25): its length is read from nothing this specification models, so there every well-formed offset yields the unavailable resolution beside the condition-20 finding, exit 1. + +### 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 `.` (from the root itself, the root is `.` and the configuration file `xspec.config.ts`; from its child directory `a`, `..` and `../xspec.config.ts`). The working directory and the workspace root enter this spelling as physical directory paths, every symbolic link among their components resolved, and the configuration file as its own name under the root so spelled: a link between the working directory and the root spells the physical relation; a link above both changes nothing. 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 — beside a `--config` value echoed as given (14), 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 the record is empty wherever none exists — before any generation has run, or after recorded state was removed without a rebuild (13.4) — whatever else the area holds (14.23). +* **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 every other path under the area, graph data being the only derived file there (7.3), 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 — none below an area path holding no directory (13.4): the inventory reads no journal content. And the review-session files: every directory entry directly under the review-session directory (10.1) — none while its path, or the area's own (13.4), holds no 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, a group's globs, and a file's groups in configuration order; tag and kind sets in their value forms (12.7); and session files in byte order of file name. + +Recorded state that exists but cannot be read as a record — the area's own path occupied by a non-directory included (14.23) — 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 finding's condition — a read the environment refuses — the session directory's listing, or the kind of the journal path's or the session directory's occupant — is the usage error of 14.25, as for every command, while a refused read of the area's own occupant is condition 23 (above, 14.25) — 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 the `--json` flag is given — a `--json` token read as a flag, not as another flag's value (grammar below) — governing error delivery even when the arguments are themselves the error, an unknown command or flag included (a repeated `--json`, itself a usage error, still puts it in effect) — 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 <path>` (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 (`<node>`, `<graph-node>`, `<file>`, `--file`) are workspace-relative in the form of 1.5, independent of the working directory. `--config <path>` and `--test-hold <path>` are filesystem paths resolved against the working directory. -* Argument values are interpreted as UTF-8; an argument value that is not valid UTF-8 is a usage error. -* IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the create-time session-name restriction of 10.1 is the sole exception). -* All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. +* Invocation grammar. Arguments are tokens. A token beginning with `--` is a flag token, spelled exactly `--` followed by the flag's name, and no other token is: there are no single-dash short forms, so a token beginning with a single `-` is an operand or a value. A flag that takes a value (`--config <path>`, `--file <glob>`, …) takes the whole next token as its value, whatever that token looks like — a value beginning with `-` or `--` included — and lacks its value, a usage error, when no token follows; a flag that takes none (`--json`, `--tree`, …) takes none. `--name=value` is not a spelling of any flag: such a token, like any `--` token naming no flag the command accepts — the flags its synopsis or defining section names, plus the global `--json` and `--config` and, for a mutating command, `--test-hold` (13.5; excluded under `--preview`, 6.6) — is an unknown flag, a usage error. The token `--` ends flag reading: it is dropped, and every later token is a non-flag token, `--`-prefixed spellings included. Flag tokens may stand anywhere among the arguments — before the command word, between it and its operands, or after them; once the flags, their values, and any `--` are removed, the remaining tokens are, in order, the command, its subcommand where it has one (`query`, `review`), and its operands, and they MUST match the command's synopsis exactly: no command word, an unknown command or subcommand, a missing operand, or more operands than the synopsis admits (`xspec ids extra`, a fourth operand to `rename`) is a usage error of the syntax class (below) — a surplus token is never accepted and ignored. A flag MAY be given at most once per invocation; repeating a flag is a usage error. A flag's arity is fixed by its name, the same for every command — known before the command word is identified, since flags may precede it — and a `--` token naming no flag of any command takes no value. List-valued flags (`--kinds`) take one comma-separated value (11). +* Arguments that name requirement nodes, graph nodes, workspace files, or file globs (`<node>`, `<graph-node>`, `<file>`, `--file`) are workspace-relative, independent of the working directory. They are read as spelled and compared byte-wise against workspace-relative paths (below; 7), which no normalization touches: `./specs/A.mdx` and `specs//A.mdx` name or match no discovered file, whose paths carry no `.` or `..` segment or doubled separator (1.5, 7), and `specs\A.mdx` names or matches only a discovered file of that very path, never `specs/A.mdx`: `\` is an ordinary byte, no separator (1.5). `<node>` and `<graph-node>` values are identities in the form of 1.5, their `#` splitting path from id or unit; the split applies equally to an operand spelled `<file>#<id>` (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 `<file>` 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 <path>` and `--test-hold <path>` are filesystem paths resolved against the working directory. +* An argument value is well-formed only when its bytes are valid UTF-8 and it contains no U+FFFD (REPLACEMENT CHARACTER); any other argument value is a malformed value — a usage error of the syntax class (below), judged before every per-flag and per-operand check — and a well-formed value is read as the text its bytes encode. The limit withholds no valid name: no ID segment or tag (1.4), no code-unit name (4.6 — a plain TypeScript identifier admits no U+FFFD), no session name or review item ID (10.1, 10.2), no valid source path (7, 14.19), and no configured group, profile, or rule name (7, 14.14) contains U+FFFD, so only a free-text or foreign value — a `--note`, a `--base` ref, a `--config` or `--test-hold` path — is ever refused for carrying a genuine U+FFFD. +* IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the sole exceptions are the create-time session-name restriction of 10.1 and 6.5's match of a JSX pragma's name). +* 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 and a `--config` value echoed as given (14) the sole absolute forms — 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 valid UTF-8, above). A path containing U+FFFD has a plain string form and is presented in it, and is likewise an invalid source path (14.19) that no identity carries and no argument value names (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, subcommands, or flags; missing required flags or arguments; surplus operands (grammar above); 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); a write the environment refuses (14.24); a read it refuses (14.25). +* 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 arguments alone determine — the syntax class — is reported without loading configuration: an unknown command, subcommand, or flag, a repeated flag, a missing required flag or argument, a surplus operand, a malformed value, and every invalid flag value or operand spelling that a fixed vocabulary, a spelling rule, or a co-occurrence rule decides — `--status`, `--strategy`, `query nodes --coverage`, and each `--kinds` element outside its vocabulary (10.7, 11.1); `review create`'s exactly-one-of rule (10.7); `--test-hold` beside `--preview` (6.6); `<file>` operands beside `--file` (11.4); a session name outside the form of 10.1; an `<offset>` spelled as anything but decimal digits (11.5); a `--to` or `--tag` spelling malformed as an identity or tag (11.3, 11.1); and a `--file` pattern outside the workspace root (11.1) — decided by its spelling alone (7). A configuration error (14.14) precedes every other error of exit class 2, each consulting configuration, discovery, or the workspace: for a mutating command, first the exclusivity and hold-file errors of 13.5 — acquisition precedes every later check (13.5) — then the unknown and wrong-kind names and files of the usage class, a code group's name under `--group` (11.1), an `<offset>` beyond the file's length (11.5), and a baseline (6.3). A write failure (14.24) is met only at the write it refuses, after every check and validation; a read failure (14.25) at the read it refuses, in the order the command makes its reads — the configuration search and discovery of 7 before every error consulting them. ### 12.1 `xspec build` -Parses configured sources; validates section structure, IDs, tags, and references; resolves dependencies; generates TypeScript modules (13.1); optionally emits Markdown; and writes graph data. `build` does not evaluate policy rules: policy violations are `check` findings (7.5, 14.12), and `build` succeeds and regenerates output whether or not policy is satisfied. Rebuilding regenerates every derived file and removes recorded derived files that the current sources and configuration no longer generate (13.3, 13.4). A `build` that fails — validation errors (exit 1) or a configuration error (12.0) — modifies nothing: every derived file and all graph data remain byte-for-byte as they were. +Parses configured sources; validates section structure, IDs, tags, and references; resolves dependencies; generates TypeScript modules (13.1); optionally emits Markdown; and writes graph data. `build` does not evaluate policy rules: policy violations are `check` findings (7.5, 14.12), and `build` succeeds and regenerates output whether or not policy is satisfied. Rebuilding regenerates every derived file and removes recorded derived files that the current sources and configuration no longer generate (13.3, 13.4). A `build` that fails — validation errors (exit 1) or a configuration error (12.0) — modifies nothing: every derived file and all graph data remain byte-for-byte as they were. A `build` a write failure stops (14.24) has replaced exactly the derived files it wrote before the refused write, each complete — which those are is not pinned (13.5) — `check` reporting whatever it leaves stale (14.10) and the next successful `build` resolving it (13.4). ### 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 — evaluated on a passing workspace alone (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` @@ -567,17 +714,48 @@ Lists requirement IDs grouped by file — files in byte order of workspace-relat xspec show <node> ``` -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. +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 and coverage attribute (both 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 value and document forms 12.7 fixes, their member names included, and, for every other command, the information its JSON output carries under the universal-JSON and same-information conventions of 12.0, the member names 12.7 leaves unfixed lying outside the contract — 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, the applied-mapping report of 6.4 and 6.5, 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) under member names this section does not fix. Consumers locate every datum of the forms below 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 tagless section's `tags` (below), 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, in the anchoring form of 11.6, or a `--config` value echoed as given (14) — 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 coverage attribute (2.5), interpreted (11.2), is the string `"required"` or `"none"`. +* A tag set — a node's interpreted tags (2.6, 11.2), a profile's `targetTags`, a selector's `tags` (7.4, 7.5) — is an array of tag strings in byte order (12.0), duplicates collapsed; a section carrying no tags (2.6) has `[]`. A kind set — a profile's `edgeKinds`, a rule's `kinds` (7.4, 7.5), configured or defaulted — is an array of dependency-kind tokens in the order 5.2 lists them: `"depends"`, `"embeds"`, `"references"`. +* 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, `globs` as configured, in configuration order; `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 and, like `edgeKinds`, in its set form above; `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, `kinds` in its set form, each selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}` (7.5), a `tags` selector's list a tag set. `sources` is one `{"path", "groups"}` per discovered file, `groups` one `{"name", "kind"}` each, in configuration order; `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 files' workspace-relative paths, `.xspec/reviews/<name>.json` (10.1). 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). +* `rename`/`move` performed (6.4, 6.5): on success `{"findings", "mapping"}` — `findings` `[]`, a successful operation carrying none, and `mapping` the applied mapping in the preview's `mapping` form above; a refused operation reports the findings-alone form above. +* `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, a write failure, or a read failure (14.14, 14.24, 14.25), its stable code and concerned path; 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,49 +763,60 @@ 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 for any validation reason; a write of it the environment refuses is a write failure (14.24), failing the read before it answers and leaving what the refresh wrote, each file complete (13.5), a state `check` reports (14.10). The record is left unchanged in every state — an absent record stays absent, the empty record (11.6), whatever graph data the refresh writes beside it — and 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 — and, for a mutating `review` subcommand, exclusivity acquisition (13.5) — 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. -* 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: 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), except two kinds of orphan, left for manual deletion. Orphan removal relies on the recorded derived-file paths (13.3): an orphan the record does not list, such as one 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. And an orphan, recorded or not, occupying a workspace-relative directory component of a path the rebuild writes obstructs that write like any other non-directory occupant (14.22): the rebuild is refused before any write or removal (12.1), so the orphan stays until it is 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 (14.13, 14.21). -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). +Derived-file paths belong to xspec: writing a derived file replaces whatever exists at its path, whether or not xspec wrote it — except that a module, companion, or Markdown path that is a directory component of a discovered source's path or of another such path is refused before any write (14.22). Removing a recorded derived file the current sources and configuration no longer generate (12.1) removes its path's occupant when that is anything but a directory or a discovered source — a symbolic link itself, never its target; a path holding a directory, a discovered source, or nothing — no occupant, or lying below a workspace-relative directory component occupied by anything other than a directory, where nothing is read (below) — is left as it is, the removal making no write: every derived file is a plain file, and a source is never derived. A completed regeneration's outcome is therefore independent of the order of its writes and removals (13.5): a removal below a path a derived-file write replaced finds nothing there. 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; a removal's path so placed holds nothing to remove, above); path components above the workspace root are unrestricted. Reads traverse none either: below a workspace-relative directory component occupied by anything other than a directory — a symbolic link included, whatever it targets — nothing is read, the path holding nothing (14.25's absence, never its refusal): the journal so placed is empty (6.1) and unoccupied to the inventory (11.6), and the session directory so placed holds no sessions (10.1) — the record alone, whose container the graph-data area is, reads instead as unreadable under such an occupant of the area's own path (14.23). 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, by its absence, 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 <path>`: 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 — without waiting for the holder's exclusivity to end — with a usage error (12.0), modifying nothing, so concurrency never loses a journal append or a resolution. A mutating command acquires exclusivity once its configuration is loaded and its sources discovered (7, 14.14) and before every later check and read — the argument checks of 12.0, baseline resolution (6.3), the gate and refresh of 13.3, and its own validation — so the workspace it validates is the one it rewrites; the refusal therefore precedes those checks' usage errors and the gate's findings (12.0), and the hold-file seam below engages at that point, on an invocation a later check refuses or the gate turns back alike. 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 <path>`: 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`, but for the orphans 13.4 leaves for manual deletion. A mutating command interrupted before completion, or stopped by a write the environment refuses (14.24), leaves the writes already made, each complete, and makes no later one. Its writes are ordered, so the state left is determined: `rename` and `move` make every source edit first — one write per file the operation rewrites, relocates, or creates, in the order the preview's `files` lists them (6.6, 12.7), a relocation producing the destination and then removing the origin — then the journal append, then the finishing regeneration (6.4); `review create` examines the session directory only past the gate and refresh of 13.3 — its existing-name refusal (10.7), the corruption reported in that refusal's place (14.21), and the obstruction of its own write path (14.22) follow any refresh write — and every mutating `review` subcommand writes its session file once, last; the order among a `build`'s or a refresh's derived-file writes is not pinned. `xspec check` reports such a state exactly insofar as it manifests as a condition of 14 — spellings left unresolved or imports left invalid by a partly applied rewrite (14.5–14.7, 14.15), stale derived files (14.10) — and no further: the journal append is the commit point of an operation's identity effects, an entry existing only once every source edit is in place, so an operation stopped before it leaves no entry and its completed source edits stand as manual restructuring (6.7) — deletions and additions against every baseline, which nothing detects once the workspace is otherwise valid — while one stopped after it is complete but for the derived files the next successful `build` regenerates. ## 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. -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 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`, 24 `write-failure`, 25 `read-failure`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. + +Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7), the range fixed per condition below. 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; a repeated prop locates every attribute spelling the name; 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. Ranges are exact per condition. A reference spelling — unresolved (14.5–14.7), non-static or of wrong arity (14.8), or cross-module (14.11) — is located by the span its occurrence occupies or would occupy, per kind (5.7): a `d` value's offending expression — an entry of its array literal, or, when it is no array literal, the expression its braces enclose — by its own characters as spelled, first token through last, so that parentheses enclosing it are included (`d={(BASE.a)}` locates `(BASE.a)`) while the braces, and any whitespace and comments between them and it, are excluded, as in its would-be occurrence (5.7); a spread entry of the array literal by its own characters, `...` included, and the elisions of one array literal — holes among its entries, spelling no expression — together as one finding, however many, by the whole array literal, brackets included; an MDX embedding's full braced container, opening brace through closing brace; a TypeScript `text(...)` call, callee through closing parenthesis; a marker's bare reference chain — and, for a non-static reference in expression-statement position, the statement's expression — exclusive of any terminator; so a spelling that records no occurrence (5.7, 11.2) is positioned here, keeping the byte classification of 11.4 exact on imperfect files. An attribute condition (14.2–14.4, 14.17) locates the attribute's own characters, the attribute range of 11.4: a duplicate identity each bearer's `id` attribute, a malformed segment the `id` attribute, a malformed tag the `tags` attribute. A missing `id` (14.1) locates the section's opening tag, the opening-tag range of 11.4. An import condition (14.15) locates the declaration — an import declaration, an export declaration, or `import X = require(…)`, a leading `export` of the last (`export import X = require(…)`) excluded as in 1.7 — by its own characters, the import range of 11.4, a dynamic `import()` by its call expression, an import type likewise, `import` through the closing parenthesis of its argument list — a `typeof` before it and a qualifier or type arguments after it excluded — a string-named module declaration by its own characters, a leading `export` excluded as in 1.7, and a colliding non-import declaration (2.4) by the construct binding the name, as 1.7 reads one: a variable declarator by its own characters — its name or binding pattern through its initializer, where one is spelled, the enclosing statement excluded — and a function, class, enum, or namespace declaration by its own characters, a decorator list included and a leading `export` excluded as in 1.7. An invalid construct (14.16) locates its own characters: an element from the first character of its opening tag through the last character of its closing tag — a fragment's `<>` and `</>` — or its self-closing tag's own characters; an expression container from its opening brace through its closing brace; an export statement whole. Unsupported usage (14.18) locates the binding's spelling at the offending use: the binding's identifier, extended — for a node binding — by the longest static property chain (2.4) it roots there. An unparseable source (14.20) carries one zero-length range at the failure's offset: for a refused read (14.25), 0; for an encoding failure, the byte length of the longest prefix of the file that is well-formed UTF-8 — the offset of the first byte of the first ill-formed sequence, whether it is malformed (`41 E2 82 41` locates 1; `C0 80` and `ED A0 80` locate 0) or truncated by the file's end, never a later byte at which a decoder notices it — 0 for a byte-order mark; for a syntax failure, the byte length of the longest whole-character prefix of the file with which some well-formed file begins — the file's byte length when the whole file is such a prefix, its bytes ending before the grammar is satisfied — an offset the grammar alone fixes, deterministic (12.0) and never past the file's length. Conditions without an in-source location — configuration, path-level, journal, session, record, write, and read 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 (14.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 path is concerned — the path the upward search found, or the path `--config` names — it is that path, whatever occupies it (7), except that a `--config` path nothing occupies — the one concerned path no physical resolution (11.6) can spell — is reported as the argument value exactly as given (12.0); 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. +4. Invalid segment or tag: violation of 1.4 — one finding per `id` or `tags` attribute whose value violates it, so a descendant spelling a malformed ancestor segment as its own prefix (1.3) reports in its own `id` attribute too. 5. Unknown dependency: a `d` reference that does not resolve. 6. Unknown text target: a `text(...)` reference that does not resolve. -7. Unknown TypeScript reference: a marker or `text` call that does not resolve; this is also a type error against the generated module. +7. Unknown TypeScript reference: a marker or `text` call that does not resolve; for a spelling free of escape sequences (2.4), 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). -11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. -12. Policy violation: rule name plus offending edge. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). -13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). -14. Configuration error: missing or invalid configuration — a configuration file that is not well-formed TypeScript or not in the declarative form of 7; missing required fields, unknown keys (7), or invalid profile, rule, or group shapes; group names referenced by profiles, rules, or selectors that are unknown or not of the kind the reference requires (7.4, 7.5); ambiguous kinds (7.4, 7.5); an empty `edgeKinds`, `targetTags`, rule `kinds`, or selector `tags` list; a capture violation (7.5); a glob or `markdown.outDir` resolving outside the workspace root (7, 7.3); a file matched by both a spec and a code group. Reported by every command when it loads the configuration and discovers sources, as a usage error (12.0), not a finding. -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 `<S>`/`<Spec>`, an expression container other than a `text(...)` embedding or an MDX comment (2.7), or an export statement in a source file. +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 — an occupant the removal of 13.4 would remove, so neither a directory nor a discovered source — one finding per such path, concerning it (its workspace-relative derived path is the finding's `path`, 12.7) and instructing rebuilding — or, for a recorded file obstructing a path the rebuild writes, which refuses the rebuild (13.4, 14.22), its manual deletion. 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, and so is a plain file whose content or kind the environment refuses to deliver (14.25). 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). On a workspace failing `build`'s validations (12.1), the content the current sources and configuration generate is undefined: the mismatch forms — per file and graph data — are undetectable there and go unreported (14), while the two forms consulting no generated content are reported whatever the sources' validity — the unreadable-record form, and the recorded-file form, which compares the record against the set of generated paths, the discovered sources, and each recorded path's occupant (13.4), all of which discovery, configuration, and the filesystem define on any workspace (13.1, 7.3, 11.6). 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 — its edge and occurrence stand (5.7); additionally a TypeScript type error and a runtime throw per 4.4. The condition needs a node, an argument that resolves (11.2): a call condition 7 or 8 reports — its argument unresolved, not static, or not exactly one — is that condition alone, whatever spec module the chain's root binding designates, so `textB(A.missing)` is condition 7 only and `textB(A.a!)` condition 8 only; a call through a colliding `text` identifier (4.5), no `text` call, is never this condition. Its `identities` (12.7) hold exactly one element, the foreign module (above): the root identity (1.5) of the spec module whose `text` export is called — or none where that module's path is invalid (14.19), the condition reported still: its root identity is undefined, and no identity over an invalid path is ever emitted (11.2, 1.5). +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). Like coverage, impact, and review (8–10, 13.3), the rules are evaluated only over a workspace passing `build`'s validations, the one state in which the graph they constrain is defined: on a failing workspace no violation is detectable, and none is reported (14). +13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4); a journal the environment refuses to read (14.25). +14. Configuration error: missing or invalid configuration — a found or named configuration path occupied by anything other than a plain file (7), a configuration file the environment refuses to read (14.25), one that is not valid UTF-8 or begins with a byte-order mark (7), is not well-formed TypeScript (14.20), or is not in the declarative form of 7; missing required fields, unknown keys or a key repeated within one object literal (7), an empty group, profile, or rule name (7), a duplicate profile or rule name (7.4, 7.5), a group, profile, or rule name containing U+FFFD (7), 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 outside the workspace root (7) or a `markdown.outDir` not in workspace-relative form or naming the graph-data area or a path under it (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 module-linking form other than an import declaration — an export declaration, an `import X = require(…)` declaration, a dynamic `import()`, an import type, or a string-named module declaration — whose specifier ends in `.xspec` (4), or a module-linking form 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, or a spec module import's value-level binding (4) sharing its identifier with a non-import declaration of the same scope that binds it at value level (2.4) — in a spec source, a declaration an export statement holds, the statement's own invalidity (14.16) reported beside it. +16. Invalid construct: a JSX element other than `<S>`/`<Spec>`, a fragment included, an expression container in flow or text position other than a `text(...)` embedding or an MDX comment (2.7) — an attribute value expression is part of its element, no container of this condition — or an export statement in a source file. 17. Invalid prop: an unknown or repeated prop, or a spread attribute, on `<S>`/`<Spec>` (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). +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 spec module binding or node passed to a call through a colliding `text` identifier — no `text` call — is this one (4.5); 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 U+FFFD or is not valid UTF-8 (7), or a spec-group file without the `.mdx` extension or whose workspace-relative path contains `"`, `'`, `\`, U+000A, U+000D, U+2028, or U+2029 (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, begins with a byte-order mark (1.6), or whose content the environment refuses to deliver (14.25) — content that cannot be read parses as nothing, so the file is masked exactly as one that fails to parse; the error reports the location of the parse failure (the offset rule above). Well-formedness is a contract about the input languages, fixed by grammar and edition, and is decided by derivability alone: a file is well-formed exactly when the language's syntactic grammar — its productions, and the supplemental grammars that refine what a covering production matches — derives the whole text. No rule beyond derivability takes part, whether the language's own text classes its violation as a syntax error or its tools report it only after parsing: ECMAScript's static-semantic early errors — a duplicate lexically declared name (two imports binding one identifier; an import and an export declaration binding one name), an export naming no declaration, an assignment to a target that is not simple (`1 = 2`), a strict-mode restriction (`let` as an identifier reference, a legacy octal literal), so whether content is strict mode code is immaterial — TypeScript's post-parse grammar checks (a misplaced modifier; a rest parameter that is not last, which TypeScript's productions derive and ECMAScript's do not), name binding (a duplicate declaration), and type checking alike; so a file failing only such rules is well-formed and proceeds: in a spec source, the identifier collisions of 2.1 and 2.4 — within one ESM block or across blocks — and an export statement, whatever it names, are findings in a well-formed file (14.15, 14.16), never a parse failure. Well-formed MDX is MDX syntax at major version 3 — the MDX format's third major version — under which the content of an expression container or of an attribute value expression is exactly one ECMAScript 2024 expression — text the `Expression` production derives with its `In` and `Await` parameters set and `Yield` not, the parameters of a module's top-level code, no statement's lookahead restriction applying (`{function(){}}` derives): a comma sequence (`{a, b}`, `d={BASE.a, BASE.b}`) is one expression, an invalid container or a dynamic argument (14.16, 14.8) rather than a parse failure, and `await` is admitted — beside whitespace and comments alone; a spread attribute's braces hold `...` followed by exactly one `AssignmentExpression` under the same parameters — the operand of a spread element, so `{...a, b}` is not well-formed while `{...(a, b)}` is — beside whitespace and comments alone; and an ESM block is one ECMAScript 2024 module — text its `Module` goal symbol derives — holding import and export declarations only. In all three, JSX is permitted: the JSX syntax extension's elements and fragments stand wherever a primary expression may — between braces (2.7) and in an ESM block's declarations alike, so `export const x = <b/>` is an export statement (14.16), never a parse failure. The one admitted exception is the empty expression (2.7), whitespace and comments alone between the braces of an expression container in flow or text position. Between braces and within an ESM block, whitespace and line terminators are those of the ECMAScript 2024 lexical grammar — U+00A0, U+FEFF, U+2028, and U+2029 included, U+0085 and U+200B not — for the judgements of this condition and the token bounds of reference spellings (5.7, above) alone: everywhere else, line dropping (3) included, the definitions of 1.4 govern this specification's own uses of the terms, whatever construct a line lies within — never derivability, which the language's grammar alone decides. Whether the whole content, or what follows its one expression, is whitespace and comments alone is judged by deleting first each block comment — `/*` through the nearest `*/` — then each line comment — `//` through the first U+000A or U+000D, U+2028 and U+2029 notwithstanding, a line comment that does not end before the closing brace staying put — and requiring nothing but whitespace and line terminators to remain: a brace on a commented-out line closes no container, which runs on to a later brace at which its content derives or leaves the file unparseable, the offset rule above locating the failure. The content so judged, as spelled before those deletions, must moreover lex to no token under the grammar, which ends a line comment at U+2028 and U+2029 as well: `{// c}` and a following line holding `}` form one empty expression, while the same container with U+2028 spelled after `c` is not well-formed, the grammar ending the comment there and finding a brace. The braces of an attribute value or of a spread attribute admit no empty expression, so `d={}` is not well-formed MDX, its syntax failure located at the offset of its closing brace; neither is a file with a JavaScript syntax error inside braces (`d={]}`, `{text(}`), an unbalanced brace, or an import spelled with syntax the edition lacks (import attributes, `with { … }`). ECMAScript 2024 takes its identifier characters, JSX names' included, and its space separators from the latest Unicode version: here Unicode 15.1's, and whether a text deriving only under a later version's is well-formed is not fixed by this specification. Well-formed TypeScript is TypeScript's grammar at release 5.9.3, TSX or plain as the file name selects — a grammar published in no form but TypeScript's parser, so derivability there is that parser's acceptance: a file is well-formed exactly when that release's scanning and parsing of it at the language level ESNext — the level deciding which characters an identifier admits (1.4) — report no syntax error, whatever tree its error-tolerant parser builds, the post-parse grammar checks, name binding, and type checking above excluded; text ECMAScript derives but TypeScript rejects while scanning or parsing — a legacy octal literal or a leading-zero decimal (`010`, `09`) — is therefore this condition in a TypeScript source, while between a spec source's braces it is well-formed, its strict-mode early error excluded (above). No other release's acceptance takes part: `{ using x = f(); }` and `import a from "./a.json" with { type: "json" };` derive, though earlier releases reject them. Every text that release so accepts both as a module's code and as a script's is well-formed; whether one is well-formed that it accepts read one way only — the readings differing in how they take top-level `await` (`let a = await / 2 / 1;` derives only as a script's code, `await /re/;` only as a module's) — is not fixed by this specification. The configuration file is judged by the same grammar (7, 14.14). +21. Corrupt review session: a session file that is not a plain file (13.4), cannot be read (14.25) or 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 obstructs nothing by its absence, since writes create those (13.4). It is equally this condition when a generated module's, companion's, or emitted Markdown file's path xspec writes (13.1, 13.2) is a directory component of another such path it writes or of a discovered source's path, occupied or not — the plain file written there would leave the other write no directory, or replace a directory holding a source (13.4). A command refuses the write and reports it before making any write of its own; `check` reports it without writing. The paths judged are the judging command's own write paths: `build`'s are the derived files the current sources and configuration generate (13.1, 13.2) and graph data (13.3), `check` and the gate of 13.3 judge exactly `build`'s (12.2, 13.3), and every other writing command judges the paths its own writes would produce, before modifying anything — so the session directory occupied by a non-directory (10.1) is reported by `review create`, never by `check` or the gate — judged where `create` examines the session directory, after the gate and refresh of 13.3 (13.5): on a passing workspace the refusal follows the refresh, stale graph data already rewritten (13.3), and writes no session file. 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 in itself 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, and the relation above with the destination or such a derived path as either of its paths, are 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, graph data the environment refuses to read (14.25), or the graph-data area's own path (11.6) occupied by anything other than a directory, a symbolic link included, whatever it targets (13.4): no record can be read under such an occupant, and it is never read as an empty record. The condition is a record that exists and cannot be decoded, never a record's absence: where no recorded state exists — the area absent, or a directory holding none, whatever else it holds (the journal, sessions, graph data a refresh wrote, 13.3) — the record is empty (11.6), read as such by every surface consulting it, and no condition is met. 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). +24. Write failure: a write xspec makes — a file's creation, replacement, append, relocation, or removal (13.4, 12.1) — that the environment refuses: permission denied, a read-only filesystem, exhausted storage, or any other failure the filesystem reports for a write the rules of 13.4 permit (a directory component occupied by a non-directory is 14.22, or leaves a removal nothing to remove (13.4), never this condition). Reported by the command making the write — `build` (12.1), `rename` and `move` (6.4, 6.5), the refreshing reads of 13.3, and the mutating `review` subcommands (10.7, 13.5) — as a usage error (12.0), not a finding: the command stops at the refused write, attempting no later one and leaving every write already made, each complete (13.5), and exits 2. The concerned path is the workspace-relative path of the file the write would have produced or removed — a relocation being two writes, the destination's production and then the origin's removal (13.5), each concerning its own path — except that a write of graph data (13.3) concerns the graph-data area (11.6), as 14.10 and 14.23 name it: the record's layout is unenumerated, so no path inside the area is named. Never reported by `check` or by any other command that writes nothing; a hold file that cannot be created is the usage error of 13.5, never this condition. +25. Read failure: a read xspec makes — a file's content, a directory's entries, or a path occupant's kind (7, 13.4) — that the environment refuses: permission denied, an I/O error, or any other failure the filesystem reports for a read. An object's nonexistence is never this condition — an absent object reads as its own section states: an absent journal is empty (6.1), an absent record is the empty record (14.23), an absent session directory holds no sessions (10.1), an absent configuration path is missing configuration (14.14), and an absent path is matched by no glob (7). The object read decides the outcome, the refusal being one more way an object cannot be read: a discovered source file's content is condition 20, the file masked exactly as one that fails to parse (11.2, 12.0), its finding at offset 0, so the surfaces of 11.2 still answer per file; the journal's is condition 13; a session file's is condition 21; the configuration file's is condition 14; a derived file's content or kind that `check` compares is condition 10, the path stale; and a refused read of the graph-data area's own occupant, or of anything under the area other than those durable paths and the session directory (below), is the state of condition 23 — unreadable recorded state to every surface consulting the record (11.6, 6.6, 13.4), staleness to `check` (14.10), and graph data that does not match to a refreshing read, which regenerates it (13.3). Every other refused read — a directory discovery lists (7), the session directory (10.1), the directories the upward search and the anchoring resolution examine (7, 11.6), a path occupant's kind wherever else xspec examines one (6.5, 7, 11.6, 13.4), the journal's, a session file's, and the configuration path's included, whose refused kind read is this condition where their refused content read is the file's own condition above — is this condition, a usage error (12.0) like a write failure (14.24): the command making the read stops at it, attempting nothing further and leaving every write already made, each complete (13.5), and exits 2. The concerned path is the object's workspace-relative path — for a directory above the workspace root, or examined before the root is known (7), its anchoring form (11.6). Never a finding; reported by every command that makes the read, at the read (12.0). + +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 — every location and concerned path in current, pre-operation coordinates, since a refused operation modifies nothing (6.4, 6.5) — 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. A reason concerning an identity carries it as the sole element of its `identities` (12.7), in the form of 1.5 over the operation's destination file — `<file>#id` for a rename, `<target-file>#id` for a section move, and for a file move the bare `<new-file>`, its root identity — whether or not the ID is valid, and whether or not the path is a valid source path (14.19): every entry of a refusal's `identities` is a spelling in the form of 1.5 over the would-be operation, carried whatever its path's validity — an invalid destination reported beside as `refused-invalid-destination` — and whether or not a node defines it, since no invalid path bears a defined identity (1.5, 11.2); a reason concerning a path carries it as the finding's `path`. The reasons and their codes: `refused-invalid-id` — the new ID is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning the new identity alone: an ID the prefix replacement produces is in intrinsic form exactly when the new ID is, the suffix it appends being valid on a workspace passing `build`'s validations, so no produced identity reports separately — 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 — each section the operation would leave in place whose ID a produced ID equals, never the renamed or moved section that would take it — its `identities` the located bearers' identities (1.5), in location order; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `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, read over the pre-operation sources: for a dependency cycle, every reference spelling recording a participating dependency edge — each exists before the operation, since a move rewrites reference spellings and adds none, and the containment the move creates is, like every `contains` edge, located by no spelling; for a spec import cycle, each participating import declaration existing before the operation by its own characters, and each participating import the operation would add (6.5), which exists in no pre-operation coordinates, by every reference spelling the operation roots at its binding (6.5), whether or not the spelling's characters change; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it — unless it is the origin path itself, the exact self-move, which `refused-identity-unchanged` alone reports (6.5) — or the section form's target path is occupied by anything other than a discovered spec source (6.5), concerning that path — occupancy judged at a path in discovered-path form alone (7): a destination or target spelled with a `.`, `..`, or empty segment, `./a.mdx` for the origin `a.mdx` included, is `refused-invalid-destination`'s (6.5) and never occupied under this reason; `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, a workspace-relative directory component of it or of a derived path it would generate is occupied by anything other than a directory, a file-form destination or a target file to be created, or a derived path it would generate, is a directory component of another derived path the sources would generate after the move or lies under one, a derived path it would generate is the path of, or a directory component of the path of, a discovered source other than a relocated origin, or the path it would emit Markdown to is designated by a code source's module-linking form (6.5), concerning the destination path; `refused-exposed-derived-file` — a file move's origin emits Markdown to a path holding an occupant discovery would yield as a source once the relocation leaves that path no emit destination (6.5), concerning that path; `refused-invalid-rewrite` — the section form's exact edits would leave the origin or the target file other than well-formed MDX, or a file the rewrite must add an import to holds no admissible offset for it (6.5), evaluated, like `refused-structural-parent`, only over an intrinsically valid new ID: one finding, locating the moved section's construct in the origin file (1.7) and, for each addition no offset admits, every reference spelling the operation roots at its binding, whether or not the spelling's characters change — as `refused-cycle` locates an import the operation would add — its `identities` the workspace-relative paths of the files concerned — each whose would-be text is not well-formed MDX, a target file to be created included, and each holding no admissible offset for an addition it needs — each a spec source's root identity or a code source's whole-file identity (1.5, 4.6) — a created target file's spelled whatever its path's validity (above) — in byte order; `refused-moved-import` — the section form's moved text holds an import declaration (6.5): one finding locating each such declaration in the origin file by its own characters, the import range of 11.4, its `identities` empty. 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 @@ -679,4 +868,4 @@ specs/DERIVED.mdx#derived.hello depends specs/SPEC.mdx#print.hello src/hello.ts#hello references specs/DERIVED.mdx#derived.hello ``` -The path `hello → derived.hello → print.hello` satisfies a transitive coverage profile targeting `print.hello`. If the text of `print.hello` is edited: `print.hello` is `changed`; `print` and the SPEC root are `descendant-changed` via `print.hello`; `derived.hello`, `derived`, and the DERIVED root are `upstream-changed`; `hello.ts#hello` is transitively impacted. A default `path-blocks` session for this change contains exactly: a `subtree-coherence` item for `print.hello`, a `parent-consistency` item for `print` blocked by it, a `dependency-consistency` item for `derived.hello`, and a `code-impact` item for `hello.ts#hello`. If `print.hello` is instead renamed with `xspec rename`, the journal records the mapping and an impact run against the pre-rename baseline reports no changes. +The path `hello → derived.hello → print.hello` satisfies a transitive coverage profile targeting `print.hello`. If the text of `print.hello` is edited: `print.hello` is `changed`; `print` and the SPEC root are `descendant-changed` via `print.hello`; `derived.hello`, `derived`, and the DERIVED root are `upstream-changed`; `src/hello.ts#hello` is transitively impacted. A default `path-blocks` session for this change contains exactly: a `subtree-coherence` item for `print.hello`, a `parent-consistency` item for `print` blocked by it, a `dependency-consistency` item for `derived.hello`, and a `code-impact` item for `src/hello.ts#hello`. If `print.hello` is instead renamed with `xspec rename`, the journal records the mapping and an impact run against the pre-rename baseline reports no changes. diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 6d305186..c4034b87 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<section>-<n>` (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<section>-<n>` (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/<NAME>.md` is added, a test module `specs/modules/TEST-<NAME>.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, the performed `rename`/`move` report (6.4, 6.5: `{"findings", "mapping"}`), 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, a coverage attribute the string `"required"` or `"none"`, a tag set or kind set the 12.7 set form (tags in byte order, duplicates collapsed; kinds in 5.2's order) — 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`, and the `review` payloads — assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, …). The adapter's latitude is exactly the surrounding unpinned shape — which members hold which data, nesting, grouping — never the value forms: a located value-form datum is asserted literally (T12.7-1's unpinned-surface arms), and a product carrying it in any other shape — a range as `[start, end]` or `{"from", "to"}` in a `query` row or a review payload — fails, the adapter never mapping it back. Adapters are the only place aware of unpinned shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque, the transformation is applied to a file the product itself wrote, never fabricated from an assumed layout: where a shape is discernible — the session file's JSON objects and sorted keys (T10.1-4; 10.1, 13.4) — the staging lives in this same adapter layer, shape-aware, value-blind, and failing loudly when the shape does not match; where SPEC.md enumerates no layout at all — graph data, 13.3 (T6.6-6 and the tests staging its record corruption) — the staging is shape-blind, truncation or garbage over the whole operational path set, and no adapter is involved. * **H-4 Byte assertions.** Where SPEC.md requires byte determinism or exact bytes (12.0 determinism, 3 Markdown output, 6.5 move edits, 13.4 stable ordering), tests assert byte equality. Where SPEC.md declares content opaque (journal entry content 6.1, graph data content 13.3), tests assert only the stated observable contract (location, line-orientation, append-only effect, refresh/staleness behavior) and MUST NOT pin opaque bytes across product versions — except for determinism checks comparing the product to itself. -* **H-5 Exit codes and streams.** Every test asserts the exact exit code and, where relevant, the stdout/stderr separation of 12.0 (reports and findings 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 @@ -39,7 +40,7 @@ These requirements bind the harness implementation regardless of test framework * **T1.2-1** The root node is queryable by bare path (`query node specs/A.mdx`), has no `id`, and `query subtree specs/A.mdx` returns the root first, then every section of the file in document order. * **T1.2-2** The generated module's default export is the root node: a consumer passes the default export to `text()` and receives the entire compiled Markdown output of the file (subtree text of the root, 1.6/3). -* **T1.2-3** Roots are never coverage targets: a profile with `targets: "all"` over a group never lists any root in required/covered/uncovered; a root is reported ignored with reason `root node` when in the target group (8.1, 8.2); `query nodes --coverage required` and `--coverage none` match no root; `query node` on a root reports the coverage attribute absent (11). +* **T1.2-3** Roots are never coverage targets: a profile with `targets: "all"` over a group never lists any root in required/covered/uncovered; a root is reported ignored with the root-node exclusion reason when in the target group (8.1, 8.2; the reason is adapter-located information, H-3 — 8.2 names it in prose and pins no wording); `query nodes --coverage required` and `--coverage none` match no root; `query node` on a root reports the coverage attribute absent (11). ### 1.3 Requirement IDs @@ -48,20 +49,22 @@ 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, a braced-`id` (`id={"x"}`) section, and a valueless-`id` (`<S id>`, the bare name — T2.7-3's arm) section, each with an immediate child whose ID the structural rule would otherwise judge — each bearer reports 14.17 and no 14.1, its immediate children report no 14.2, and their own children's structural checks still report (14.2); a product reading the bare name as an absent `id` masks the same children under condition 1, so the bearer's own code is the valueless arm's discriminating assertion. +* **T1.3-7 Depth.** SPEC.md bounds no nesting depth — 1.3's structural rule holds at every level: a valid workspace whose one file nests sections at least 2048 levels deep (P-8's giant-nesting floor, 16) builds with exit 0; `query subtree` on the root returns the root plus every section, in document order, the count asserted; `view` serves the full positional tree. The deterministic anchor of P-8's floor outside the generator machinery, and a deterministic exercise of the harness's answer-scale capacity (H-11, S-8). ### 1.4 ID segments and tags -* **T1.4-1 Segment validity matrix.** For each rule, a workspace differing only in one segment: empty segment (`a..b` spelled via nesting, and a lone empty `id=""`), `"#"` in a segment, each whitespace character U+0009 U+000A U+000B U+000C U+000D U+0020, each control-character class representative (U+0000, U+001F, U+007F), and each forbidden name `$`, `__proto__`, `prototype`, `constructor`, `then` — all fail with 14.4. -* **T1.4-2 Exact character classes.** Segments containing U+00A0, U+0085, and U+2028 are valid (SPEC.md 1.4 excludes them from both classes); builds succeed and the nodes are queryable by identity. +* **T1.4-1 Segment validity matrix.** For each rule, a workspace differing only in one segment: empty segment (`a..b` spelled via nesting, and a lone empty `id=""`), `"#"` in a segment, each whitespace character U+0009 U+000A U+000B U+000C U+000D U+0020, each control-character class representative (U+0000, U+001F, U+007F), each forbidden name `$`, `__proto__`, `prototype`, `constructor`, `then`, each of the quote, escape, and character-reference characters — `"` (`id='a"b'`, single-quoted so the value is spellable), `'` (`id="a'b"`), `\` (`id="a\b"`), `&` (`id="a&b"`) — U+2028 and U+2029 (one arm each, the code point spelled between two letters: 1.4's quote-and-escape bullet bars both, though neither is whitespace or a control character under 1.4, T1.4-2), and U+FFFD (`id="a�b"`) — all fail with 14.4, one finding per offending `id` attribute located at the attribute (T14-11). Verbatim spellings (2.4: no escape sequence or character reference is interpreted): `id="a\u002Eb"` is a segment containing `\` — condition 4, never the two-segment ID `a.b` — and `id="a.b"` a segment containing `&`, condition 4 likewise, where a product interpreting the escape or the reference accepts a structurally valid ID instead. +* **T1.4-2 Exact character classes.** Segments containing U+00A0 and U+0085 are valid (SPEC.md 1.4 excludes them from both classes, and no rule of 1.4 bars them); builds succeed and the nodes are queryable by identity. U+2028 and U+2029 belong to neither class either — the classification T3-3's line-drop arms rely on — yet 1.4's quote-and-escape bullet bars both from every segment and tag, so they are T1.4-1's and T1.4-4's invalid arms, never valid ones here. * **T1.4-3 Non-identifier segments.** A segment like `login-v2` is valid; the generated module exposes it via bracket notation (2.4/4.1): a consumer using `SPEC["login-v2"]` type-checks and resolves; dot access to it is a type error. -* **T1.4-4 Tags.** A tag containing `.` is valid; a tag containing `#`, a tag that is a forbidden name, and a tag containing a non-whitespace control character (U+0000 and U+007F representatives) each fail with 14.4. Same-character-class boundaries as T1.4-2 apply to tags. The empty and whitespace rules of 1.4 admit no invalid-tag fixture: `tags` splits on runs of whitespace with leading/trailing whitespace ignored (2.6), so no tag token can be empty or contain whitespace — whitespace-only values behave as omitted (T2.6-2), and the whitespace control characters U+0009–U+000D are split away as separators. +* **T1.4-4 Tags.** A tag containing `.` is valid; a tag containing `#`, a tag that is a forbidden name, a tag containing a non-whitespace control character (U+0000 and U+007F representatives), a tag containing `"` (`tags='x"y'`), `'` (`tags="x'y"`), `\` (`tags="x\y"`), or `&` (`tags="x&y"`), a tag containing U+2028 or U+2029 (one arm each, 1.4's quote-and-escape bullet), and a tag containing U+FFFD each fail with 14.4, one finding per offending `tags` attribute located at it (T14-11); `tags="x\u0079"` is a tag containing `\` (2.4: read verbatim), condition 4, never the tag `xy`. T1.4-2's valid boundaries apply to tags: a tag containing U+00A0 or U+0085 is valid — neither code point splits the value (2.6) nor is barred — while U+2028 and U+2029 are the invalid arms above. The empty and whitespace rules of 1.4 admit no invalid-tag fixture: `tags` splits on runs of whitespace with leading/trailing whitespace ignored (2.6), so no tag token can be empty or contain whitespace — whitespace-only values behave as omitted (T2.6-2), and the whitespace control characters U+0009–U+000D are split away as separators. +* **T1.4-5 Identifier by characters.** 1.4 makes "valid TypeScript identifier" a test of characters alone at the release and language level 14.20 fixes — the first character one TypeScript admits to begin an identifier, each other one it admits to continue one — so a reserved word is one (2.4: a property access's name is any identifier name the file's grammar admits there), and ECMAScript 2024 admits each such character in the same place, so dot access spelling such a segment is well-formed in either kind of source. (a) Access: `specs/B.mdx` holds top-level sections `delete`, `default`, `é` (U+00E9), and `Ᲊx` (U+1C89, then `x`; (c)); a spec source importing it as `B` spells `d={B.delete}`, `{text(B.default)}`, and `d={B.é}`, and a code source importing it as `SPEC, { text }` spells the markers `SPEC.delete` and `SPEC.é` and the call `text(SPEC.default)` — every file well-formed (S-9, the code source under 14.20's TypeScript grammar alike): `build` and `check` exit 0 with no finding, `query edges` reports each `depends`, `embeds`, and `references` edge to its node, and a consumer compiling `SPEC.delete`, `SPEC.default`, `SPEC.é`, and `SPEC["Ᲊx"]` against the generated module type-checks under standard tooling run at TypeScript 5.9.3, the release whose identifier test 1.4 adopts (14.20), never a later one whose Unicode tables would mask the quoting discriminator below (4.1, H-2) — a product classing reserved words or non-ASCII letters as non-identifiers, rejecting the dot spellings or generating no dot-accessible property for them, fails, as does one judging by its runtime's Unicode tables whether a generated property name needs quoting, which leaves `Ᲊx` unquoted where TypeScript 5.9.3 cannot parse it (13.1). (b) Conversion (6.4's fallback spellings, 6.5): a section `m` moved out of `specs/a.mdx` into an existing target `specs/t.mdx` lacking `a.mdx`'s module, its moved text holding one `d` array of local references `"delete"`, `"é"`, and `"n.2fa"` to origin nodes outside the moved subtree — `2fa` a segment whose first character can only continue an identifier — converts them to imported form through the target's added declaration: under T6.5-8's discipline, the fresh identifier `<O>` and the choice among line-start admissible offsets its only unknowns, the array reads exactly `d={[<O>.delete, <O>.é, <O>.n["2fa"]]}` in the target, its brackets, commas, and spaces unchanged — dot access for the identifier-valid segments, a reserved word and a non-ASCII letter included, double-quoted computed access for `2fa` — and the rewritten target derives (S-9), `build` and `check` clean; a product writing `<O>["delete"]` or `<O>["é"]`, or dot access for `2fa`, fails. (c) The release and language level: TypeScript 5.9.3 at ESNext (14.20) judges each character, never a runtime's own Unicode tables or an older language level. U+1C89 (CYRILLIC CAPITAL LETTER TJE) passes every bar of 1.4, yet that release admits it neither to begin nor to continue an identifier, while a runtime whose Unicode tables postdate 15.1 admits it to do both; U+2EBF0, a CJK ideograph of Unicode 15.1, it admits at ESNext but not at ES5. (b)'s move, its moved `d` array holding instead the local references `"Ᲊx"` (U+1C89, then `x`) and `"𮯰"` (U+2EBF0) to origin sections so named outside the moved subtree, reads exactly `d={[<O>["Ᲊx"], <O>.𮯰]}` in the target, which derives (S-9), `build` and `check` clean; a product classing characters by its runtime's tables writes `<O>.Ᲊx`, and one judging at ES5 `<O>["𮯰"]`, each failing. Renamed segments keeping or losing dot access by the same test, these two characters included, are T6.4-2's arms. ### 1.5 Node identity * **T1.5-1** Identities in every output (`query`, `show`, `ids`, coverage, impact) are workspace-relative and `/`-separated, regardless of the working directory the command runs from (run from a nested directory and from the root; compare outputs byte-wise). -* **T1.5-2** A discovered source file whose path contains `#` fails with 14.19 — one arm for a spec-group file, one for a code-group file (14.19 covers both); a discovered source whose workspace-relative path is not valid UTF-8 also fails with 14.19 (7) — staged on the Linux leg, where file names are byte strings. -* **T1.5-3** `path#id` addresses a section and bare `path` addresses the root across `query node`, `show`, and `move`/`rename` arguments. +* **T1.5-2** A discovered source file whose path contains `#` fails with 14.19 — one arm for a spec-group file, one for a code-group file (14.19 covers both); a discovered source whose workspace-relative path is not valid UTF-8 also fails with 14.19 (7) — staged on the Linux leg, where file names are byte strings — and so does one whose path contains U+FFFD (`specs/A�.mdx`, stageable on both legs), its finding's concerned path presented as a plain string (12.0: a U+FFFD path has a plain string form, never the byte form), the file reachable by glob through `view` with every identity unavailable (T11.2-3) and nameable by no argument (T11.5-3). +* **T1.5-3** `path#id` addresses a section and bare `path` addresses the root across `query node` and `show` (1.5, 11.1, 12.4); `move`'s section-form operands spell `<file>#<id>` under the same split (12.0, 6.5), while `rename`'s `<file>` and `move`'s file-form operands name files — a bare path there is a file operand, never a root-node address (6.4, 6.5). ### 1.6 Own text, subtree text, and own content @@ -69,21 +72,23 @@ These requirements bind the harness implementation regardless of test framework * **T1.6-2 Run counting.** N child constructs produce N+1 own-text runs including empty ones: adjacent children with no bytes between them, a child at the very start, and a child at the very end of the section — own text for these cases matches exact expected bytes. * **T1.6-3 Expansion everywhere.** With `{text(...)}` embeddings present, own and subtree text reported by `query`, `show`, and review payloads (10.2, 10.7) carry the embedded text fully expanded, and `text(node)` at runtime (4.3) returns expanded subtree text. * **T1.6-4 Own content vs expansion.** Editing the target of an embedding changes the target's hashes but not the embedder's ownHash or subtreeHash (asserted via `query node` hashes before/after and via impact categories: embedder is `upstream-changed`, not `changed`; 5.5). -* **T1.6-5 Encoding.** A spec or code source that is invalid UTF-8, or begins with a BOM, fails with 14.20. Code-point counting is exercised in T4.2-2. +* **T1.6-5 Encoding.** A spec or code source that is invalid UTF-8, or begins with a BOM, fails with 14.20 (its offset T14-11's: the byte length of the longest well-formed UTF-8 prefix, 0 for a byte-order mark). Code-point counting is exercised in T4.2-2. ### 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, and likewise for the `using` and `await using` units of T4.6-1 (4.6: both count as variable declarations) — `using f = () => { SPEC.a }` spans `f = () => { SPEC.a }`, and `await using h = () => { SPEC.b }` spans `h = () => { SPEC.b }`, neither from `using` nor from `await`; 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. Further forms (1.7, 4.6): a constructor's unit spans the constructor member; a decorated class and a decorated member each span from their first decorator (`@dec class C { @dec m() {…} }` → `path#C` from the class's `@`, `path#C.m` from the member's `@`); the export exclusion — `export @dec class C {}` spans `@dec class C {}`, `@dec export class C {}` spans whole from its `@`, and `export function f() {}` (several spaces) spans from `function`, whatever separates `export` from the construct excluded; the `default` unit — `export default () => {};` spans through its `;`, `export default function () {}` ends at its closing brace (no terminator spelled), and `@dec export default class {}` spans from its `@`; a legacy `module A.B { }` shares its one declaration's range across `path#A` and `path#A.B`, as `namespace A.B` does; and a marker in a declaration file (T4.6-3) carries the whole-file range. The same ranges appear on a `code-impact` scope in the 10.7 payload (T10.7-12). ## 2. Source Syntax ### 2.1 Imports * **T2.1-1 Valid import.** `import BASE from "./BASE.xspec"` where `BASE.mdx` is a discovered file of a configured spec group: builds; references through the binding resolve. -* **T2.1-2 Specifier forms.** `../` specifiers resolve against the importing file's directory. Invalid specifiers each fail with 14.15: absolute or bare (non-relative) specifier; relative specifier not ending in `.xspec`; specifier designating a file that exists but is not a discovered source of any configured spec group; relative `.xspec` specifier whose designated file does not exist (`./typo.xspec` with no `typo.mdx` — the ordinary typo'd path). -* **T2.1-3 Binding forms.** Named import, namespace import, and side-effect-only import from a `.xspec` specifier each fail with 14.15. Two imports binding the same module under different names are valid; two imports binding the same identifier fail; an import binding `S`, `Spec`, or `text` fails (14.15). +* **T2.1-2 Specifier forms.** `../` specifiers resolve against the importing file's directory. Resolution is lexical (2.1): a `.` or empty segment designates the same directory and `..` the parent, and no spelling is required to be canonical — `./sub/../BASE.xspec` (no `sub/` existing on disk), `.//BASE.xspec`, `././BASE.xspec`, and `../specs/BASE.xspec` from `specs/` each designate `specs/BASE.mdx`: the import is valid, references through it resolve, and `view` reports the resolved target `specs/BASE.mdx` (T11.4-4) — a product requiring canonical spellings, or resolving through the filesystem, fails the nonexistent-`sub` arm. Invalid specifiers each fail with 14.15: absolute or bare (non-relative) specifier; relative specifier not ending in `.xspec`; specifier designating a file that exists but is not a discovered source of any configured spec group — one arm an `.mdx` file matched by no group, one an `.mdx` file matched only by a code group, a discovered code source (2.1; also an unavailable view target, T11.4-4); relative `.xspec` specifier whose designated file does not exist (`./typo.xspec` with no `typo.mdx` — the ordinary typo'd path); a specifier whose ascent passes above the workspace root (`../../outside/BASE.xspec` from `specs/`, the root's parent holding a real `outside/BASE.mdx`) — designating nothing whatever the root's parent holds, the discriminator against filesystem resolution; and a specifier spelled with an escape sequence (`"./B\u0041SE.xspec"`), read verbatim (2.4) as a segment no discovered path spells. +* **T2.1-3 Binding forms.** Named import, namespace import, and side-effect-only import from a `.xspec` specifier each fail with 14.15 (the side-effect-only form is valid in a TypeScript file, 4 — T4-2). Two imports binding the same module under different names are valid; two imports binding the same identifier fail with 14.15 in a well-formed file, never 14.20, under both stagings 14.20 distinguishes — one arm with the two declarations in one ESM block (consecutive lines, no blank line between), one with them in separate blocks — the single-block arm the one a product delegating ECMAScript's early errors to its parser fails (14.20: a duplicate lexically declared name is an early error, excluded from derivability; T14-12); an import binding `S`, `Spec`, or `text` fails (14.15). * **T2.1-4 Unused import.** An import whose binding is never used builds successfully and records no edges (`query edges --from` the file's nodes shows none from it). * **T2.1-5 Import cycles.** A two-file spec import cycle fails with 14.9 even when no requirement-level dependency cycle exists; a file importing itself fails as a length-one import cycle. +* **T2.1-6 ESM block inside a section.** An ESM block derives inside a section element too (14.20, as 6.5 cites it): a section `<S id="m">`, U+000A, U+000A, `import X from "./X.xspec"`, U+000A, U+000A, `body {text(X.a)}`, U+000A, `</S>` builds (exit 0) with `check` clean; references through the binding resolve from inside the section and from a sibling section outside it (`query edges`); the declaration is removed and its line dropped from the compiled Markdown (3, byte-asserted with emission enabled) and from the section's own text (1.6, `query node`: the empty lines kept, the declaration's line gone); `view` lists the declaration under `imports` with its range and resolved target `specs/X.mdx`; and the file's `contains` structure is as without the block — the root containing `m` and its sibling, `m` containing no node. Its refusal as moved text is T6.5-17's. ### 2.2 Dependency prop @@ -97,19 +102,21 @@ These requirements bind the harness implementation regardless of test framework * **T2.3-1** `{text(BASE.auth.login)}` replaces the expression with the target's compiled subtree text in Markdown output (byte-asserted) and records an `embeds` edge from the containing section. * **T2.3-2** String form `{text("local.requirement")}` resolves within the same file; both forms may target any depth, including embedding a whole file via the module binding (root target). +* **T2.3-3 Embedding form.** 2.3 fixes what an embedding is: an expression container whose one expression is a call, optional chaining excluded, whose callee is the identifier `text` itself, spelled plainly, whatever whitespace and comments stand beside the call. Positive arms: `{ text("a") }`, `{/* n */ text("a")}`, a comment after the call, `{text("a") /* n */}` (what follows the one expression is whitespace and comments alone, 14.20), a line comment before it, `{// n` U+000A `text("a")}` (the comment ended by the terminator under 14.20's deletion judgement), and the run-on form `{// c}` U+000A `text("a")}` — its first `}` on the commented-out line closing nothing, the container running to the second `}`, at which its content derives as the call (14.20, 2.7; the empty twin is T2.7-4's) — are embeddings — each records its `embeds` edge, its occurrence spans the full container, opening brace through closing brace — for the two-line forms `{` through the second line's `}`, the interior terminator included (5.7, T5.7-2) — with no `comments` entry (11.4), no finding, and `build` exit 0, and Markdown compilation replaces the whole container — comment, whitespace, and interior terminator included — with the target's subtree text (byte-asserted, 3); a product ending every container at its first `}`, recognizing the run-on for empty containers alone (T2.7-4's staging), or stripping only leading trivia when classifying the callee fails one of these arms. Negative arms, each an invalid container (14.16) — one finding located brace through brace, no edge, no occurrence, never 14.6 and never 14.8: a parenthesized callee `{(text)("a")}`; an escaped callee `{te\u0078t("a")}` (2.4: spelled escaped, not `text`); an optional call `{text?.("a")}`; a comma sequence `{text("a"), 1}` (one expression, not a call, 14.20); and `{await text("a")}` (`await` derives, 14.20). `{text("a") text("b")}` is no expression the grammar derives — 14.20, its zero-length range at the offset of the second `text` (the prefix through the space after the first call begins a well-formed file; T14-12). The by-form classification of 11.2 holds for each negative arm: under `view --text` the container's bytes are content, preserved byte-for-byte in the enclosing text and located by the finding (T11.2-4). ### 2.4 Static argument rule -* **T2.4-1 Static accepted forms.** Double- and single-quoted string literals; property chains with dot access and computed access via static string literal (`BASE["login-v2"]`), including mixed chains — all build. -* **T2.4-2 Dynamic forms rejected.** Each fails with 14.8, in `d` and in `text(...)`, in MDX: template literal argument; identifier or call as index; optional chaining; non-null assertion; parenthesized chain; conditional expression. +* **T2.4-1 Static accepted forms.** Double- and single-quoted string literals; property chains with dot access and computed access via static string literal (`BASE["login-v2"]`), including mixed chains, and dot access naming a reserved word (`BASE.delete`; 2.4: a non-computed access's name is any identifier name the file's grammar admits there, a reserved word included — the code-source twin `SPEC.delete` among T1.4-5's arms) — all build. +* **T2.4-2 Dynamic forms rejected.** Each fails with 14.8, in `d` and in `text(...)`, in MDX — forms ECMAScript 2024 derives that make the reference dynamic (2.4): template literal argument; identifier or call as index; optional chaining on the chain (`d={BASE?.a}`, `{text(BASE?.a)}` — the argument; an optional call `{text?.(BASE.a)}` is instead no embedding, 14.16, T2.3-3); parenthesized chain; conditional expression; and the "another meaning" case, `d={BASE.a<X>y}` — two comparisons in ECMAScript — a well-formed container holding no static chain, 14.8 located at its whole expression (T14-11), never 14.20. TypeScript-only syntax in a spec source is a parse failure of the file, never a dynamic reference (2.4: a spec source's expressions are ECMAScript 2024, 14.20): a non-null assertion `d={BASE.a!}` and `{text(BASE.a!)}`, and a type assertion `d={BASE.a as X}` and `{text(BASE.a as X)}`, each report 14.20 alone — no 14.8 — with the one zero-length range at the offset the syntax-failure rule of 14 fixes, precomputed: for `d={BASE.a!}` the offset of its closing brace and for `{text(BASE.a!)}` the offset of its closing parenthesis (`!` may begin `!=`, so the prefix through `!` begins a well-formed file), and for the `as` forms the offset of `as` (no well-formed file continues a member expression with an identifier). The TypeScript-source counterparts — where the same spellings are dynamic, 14.8 — are T4.5-3's and T4.3-2's. * **T2.4-3 Arity.** `text()` with zero and with two arguments fails with 14.8. * **T2.4-4 Computed access is segment-exact.** Against an imported module `BASE` whose file contains nodes `a` and `a.b`: `d={BASE["a.b"]}` and `text(BASE["a.b"])` do not resolve — a chain segment is exactly one ID segment, and no segment contains `.` (1.4/2.4) — failing with 14.5 and 14.6 respectively; the TypeScript marker `BASE["a.b"]` fails with 14.7 and is a type error against the generated module. `BASE["a"]["b"]` and `BASE.a.b` resolve to node `a.b`, and the same-file local string `d={"a.b"}` names the path `a.b` and resolves (2.2). +* **T2.4-5 Verbatim literals.** 2.4 reads every static string literal and quoted attribute value exactly as spelled — no escape sequence or character reference interpreted — so a spelling whose interpreted value would name a node names nothing. Against a file holding `login` and, in an imported `BASE`, `a.b`: `d={"lo\u0067in"}` and `{text("lo\u0067in")}` in MDX do not resolve — 14.5 and 14.6 respectively, the literal's value `lo\u0067in` containing `\` (1.4) — and `d={"a.b"}`, whose interpreted value would spell the path `a.b`, reports 14.5 likewise; `d={BASE["a\u002Eb"]}` and `text(BASE["\u0061"]["b"])` do not resolve (14.5, 14.6) though the interpreted computed keys would; in a code source the marker `BASE.lo\u0067in` — a chain segment carrying a Unicode escape spells a name containing `\` (2.4) — reports 14.7, records no edge and no occurrence (`occurrences` lists none for it, T5.7-4), and the discriminating half is asserted through the standard-tooling channel of H-2: the consumer file type-checks clean against the generated module (TypeScript reads the escaped identifier as `login`, which exists), so a product deriving resolution from the interpreted name reports no finding and records an edge, failing this arm; the escape-free control `BASE.login` beside it records its edge. In MDX the same segment escape — `d={BASE.lo\u0067in}` and `{text(BASE.lo\u0067in)}`, `BASE`'s module holding `login` — reports 14.5 and 14.6 respectively and records no edge and no occurrence, though ECMAScript decodes the escape to `login`: a spec source reaches its expressions through ECMAScript 2024's grammar (14.20), never TypeScript's, so the code-source arm cannot stand in for it, and a product resolving through the decoded name records both edges and fails; the escape-free control `d={BASE.login}` beside them records its edge. The root, by contrast, is the language's scoping question, never a spelling one (2.4): `d={B\u0041SE.login}` and `{text(B\u0041SE.login)}` in MDX and the marker `B\u0041SE.login` in a code source, each segment escape-free, are rooted at the `BASE` import binding and each records its edge and occurrence with no finding — failing a product that reads the root as spelled too, finds no binding named `B\u0041SE`, and reports the reference. Each such MDX file derives (S-9), and each such code file is text TypeScript 5.9.3 accepts both as module code and as script code (14.20). The rule's other literals are asserted where they live: `coverage` (T2.5-3), `id` and `tags` (T1.4-1, T1.4-4), import specifiers (T2.1-2, T4-2), configuration literals (T7-2), unit names (T4.6-3). ### 2.5 Coverage attribute * **T2.5-1 Default.** A non-root node without the attribute is coverage-required: appears in a profile's required set (8.1); `query nodes --coverage required` lists it. * **T2.5-2 `coverage="none"`.** The node is excluded from coverage targets (reported ignored with its reason, 8.2), can still be a `d` target, still appears in impact reports when changed, and its children remain coverage-required (each observable in one workspace). -* **T2.5-3 Values.** `coverage="required"` is accepted and behaves as the default; any other value fails with 14.17. +* **T2.5-3 Values.** `coverage="required"` is accepted and behaves as the default; any other value fails with 14.17, a value spelled with an escape sequence or character reference included — `coverage="n\u006Fne"` and `coverage="none"` are neither `required` nor `none` (2.4: read verbatim) — each finding located at the attribute (T14-11). ### 2.6 Tags @@ -119,35 +126,38 @@ These requirements bind the harness implementation regardless of test framework ### 2.7 Permitted constructs -* **T2.7-1 Foreign constructs.** A JSX element other than `<S>`/`<Spec>`, an expression container other than `text(...)` or an MDX comment, and an export statement each fail with 14.16. +* **T2.7-1 Foreign constructs.** A JSX element other than `<S>`/`<Spec>`, an expression container that is neither an embedding (2.3, T2.3-3) nor an MDX comment (2.7, T2.7-4), and an export statement each fail with 14.16. A fragment `<>…</>` is an invalid element (2.7): exactly one condition-16 finding located from `<>` through `</>` (T14-11), no node created for it, its enclosed content preserved as content under `view --text` (11.2) — enclosed sections and embeddings classified by their own forms (T11.2-4's enclosure arm). An attribute value expression is part of its element, no container of this condition (14.16): `<S id="x" d={1}>` reports 14.8 alone, and `<div a={1}></div>` exactly one condition-16 finding, the element's — the absence of any second finding asserted in each arm. A section spelled inside an expression container — `{<S id="x">…</S>}` — is part of that container's expression (2.7): no node `x` in the view's tree (`query` is gated on this failing workspace, 13.3), exactly one condition-16 finding located at the container (brace through brace), and the container's bytes preserved as content in `view --text`'s enclosing text (11.2). * **T2.7-2 Comments.** An MDX comment inside a section: absent from Markdown output; not part of own text (`query node`). Hash and category stability (1.6/3), each arm against a committed baseline: editing only the comment's content (inline and own-line layouts), deleting an inline comment that shares its line with retained non-whitespace content, and deleting an own-line comment together with its entire line (construct plus terminator) each change no hash and produce no change categories. Boundary: deleting only an own-line comment's construct characters — leaving the emptied line in place — changes the containing section's ownHash and makes it `changed`, with the cascades of 5.6: the line, previously dropped as left empty purely by removals, is now already empty in the source and kept, contributing its terminator (3, T3-3). -* **T2.7-3 Props.** Repeated props (defined or unknown) fail with 14.17; unknown props fail with 14.17; a spread attribute (`{...expr}`) on `<S>`/`<Spec>` fails with 14.17 (2.7); braced string values fail with 14.17 for each of the three string props — `id={"login"}`, `coverage={"none"}`, `tags={"a"}`; quoted (`d="x"`) or valueless `d` fails with 14.17; braced `d` holding a non-reference expression (e.g. a number, an object literal) fails with 14.8. Positive quoting arm (2.7: single- or double-quoted alike): `id='login'`, `coverage='none'`, and `tags='a b'` build byte-identically in outputs to their double-quoted variants. +* **T2.7-3 Props.** Repeated props (defined or unknown) fail with 14.17; unknown props fail with 14.17; a spread attribute (`{...expr}`) on `<S>`/`<Spec>` fails with 14.17 (2.7) — its grammar pair (14.20): `<S id="x" {...(a, b)}>` is well-formed, 14.17 at the whole braced construct (T14-11), while `<S id="x" {...a, b}>` is not — 14.20 at the offset of its comma, a spread attribute's braces holding `...` and exactly one assignment expression (T14-12); braced string values fail with 14.17 for each of the three string props — `id={"login"}`, `coverage={"none"}`, `tags={"a"}`; valueless (bare-name) values fail with 14.17 for each of the three — `<S id>`, `<S id="x" coverage>`, `<S id="x" tags>` (2.7: any value form but the quoted static string), the `<S id>` arm reporting 14.17 and no 14.1 (14.1: a value not in quoted static-string form is condition 17, never condition 1 — a product reporting `<S id>` as `missing-id` fails; its masking of the children: T1.3-6; the view side of the same bare-name forms: T11.2-2, T11.4-3); quoted (`d="x"`) or valueless `d` fails with 14.17; braced `d` holding a non-reference expression (e.g. a number, an object literal) fails with 14.8. Positive quoting arm (2.7: single- or double-quoted alike): `id='login'`, `coverage='none'`, and `tags='a b'` build byte-identically in outputs to their double-quoted variants. +* **T2.7-4 Comment forms and brace content classes.** Every form 2.7 makes an MDX comment behaves as T2.7-2's usual `{/* … */}`: removed from Markdown output (byte-asserted, 3), absent from own text (`query node`), no finding, `build` exit 0, and listed in `view`'s `comments` with its full container range, opening brace through closing brace (11.4) — `{}`; `{ /* a */ /* b */ }`; a line-comment container ended before its closing brace, `{// c` U+000A `}`, and its twin ended by a carriage return, `{// c` U+000D `}` (14.20 runs a line comment through the first U+000A or U+000D alike) — a two-line construct, U+000D being a line terminator (1.4, 3), removed under T3-3's merge-and-drop rule, its `comments` range from `{` through `}`, and staged with no later `}` in the file, so that a product ending line comments at U+000A alone reads its `}` as lying on the commented-out line, finds the container unclosed (14.20), and fails; and the run-on form `{// c}` U+000A `}`, whose first brace lies on the commented-out line and closes nothing (14.20), the container running to the second `}` — a multi-line construct removed under T3-3's merge-and-drop rule, its `comments` range from `{` through the second `}`. Whitespace between braces is ECMAScript's (14.20, 1.4), its space separators Unicode 15.1's (14.20): `{` U+00A0 `}`, `{` U+FEFF `}`, `{` U+2028 `}`, and `{` U+2029 `}` are comments likewise, and so is `{` X `}` for each space separator X (general category Zs) that Unicode 15.1 places outside Latin-1 — U+1680, U+2000 through U+200A, U+202F, U+205F, and U+3000, one arm per code point — failing a product whose brace-side whitespace is ASCII's plus the four code points 14.20 names, which takes `{` U+3000 `}` (the ideographic space CJK input methods produce) for no empty expression and masks the file; while `{` U+0085 `}`, `{` U+200B `}`, and `{` U+180E `}` are 14.20 — none is ECMAScript whitespace or a line terminator (14.20 excludes the first two by name, and U+180E, a space separator up to Unicode 6.2, is a format character under Unicode 15.1, neither whitespace nor an identifier character, failing a product whose tables predate Unicode 6.3 and read that container as a comment), so the content is no empty expression, and none begins any token of the grammar, so it derives no expression either — the zero-length range at the offset of the code point (the prefix through `{` begins a well-formed file). An expression beside comments is no comment: `{/* a */ 1}` → 14.16, located brace through brace, no `comments` entry. Further parse failures fix the comment grammar (14.20; T14-12): `{// c` U+2028 `}` U+000A `}` → 14.20 at the offset of the first `}` — the deletion judgement runs the line comment through U+000A, so the first brace closes nothing, while the lexical grammar ends the comment at U+2028 and finds a brace token — and its twin with U+2029 in place of U+2028, failing at the same offset (14.20 ends a line comment at either); and `{// c}` with no later `}` in the file → 14.20 at the file's byte length (the whole file a prefix of a well-formed one). ## 3. Markdown Compilation All tests here run with `markdown: { emit: true }` and byte-assert emitted files, except T3-6 (emission scope). -* **T3-1 Removals.** Imports, `<S>`/`<Spec>` 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, `<S>`/`<Spec>` 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 (`<S id="x">`, `<div>`, `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-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, U+2028, or U+2029 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. The classes hold inside an ESM block too (3, 1.4: in every line of the file, one within an ESM block or braces included): one ESM block spelling two imports on one physical line separated by U+2028 — an ECMAScript line terminator, so the block derives (14.20) — compiles, both declarations removed by their own characters, to a kept line holding U+2028 alone plus its terminator, byte-asserted, where a product applying ECMAScript's line terminators to line dropping drops it. 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. A multi-line opening tag is deleted the same way (3: any removed construct): the lines it spans merge into one, dropped when left empty purely by the removal and kept with its residue otherwise — three byte-asserted arms, each a well-formed file (14.20; S-9 gates the shapes): the own-lines drop form, `<S`, U+000A, ` id="x"`, U+000A, `>` standing alone on its three lines (a flow-position tag, its body and its closing tag on lines of their own), whose merged line is left empty purely by the removal and drops with its terminator; the in-line kept form with residue on both sides, `foo <S`, U+000A, ` id="x"`, U+000A, ` coverage="required"> bar</S>` — a three-line tag whose two interior terminators are among its own characters — compiling to `foo bar` plus its terminator; and the flow-start kept form with residue after the `>` alone, `<S`, U+000A, ` id="x"`, U+000A, `> bar</S>`, compiling to ` bar` plus its terminator. Staging constraint, under the grammar 14.20 fixes: a tag opening after non-whitespace on its line is an in-line (text-position) tag inside a paragraph, and every later line of that tag, and of the section it opens, is a paragraph-continuation line — none blank, none beginning a construct that interrupts a paragraph (a `>` whatever indentation precedes it, a heading, a code fence, a thematic break, a list item, a line holding tags or expression containers alone) — with the closing tag standing within that paragraph, never at the start of a later line, where it is a flow tag that interrupts the paragraph and leaves the in-line element unclosed; so an in-line tag cannot end on a bare `>` line — `foo <S`, U+000A, ` id="x"`, U+000A, `> bar</S>` does not derive — while a tag opening at line start can: the flow attempt takes the bare `>` line, fails on the ` bar` after it, and the fallback paragraph spans all three lines. * **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). * **T3-5 In-line tags.** `<S id="a">Example:</S><S id="b">1. A</S>` compiles to `Example:1. A` (transparent annotations; author responsibility for spacing). * **T3-6 Emission scope.** With `markdown` absent or `emit: false`, no `.md` file is emitted for any source (7.3); with `emit: true` every discovered spec source emits (13.2). +* **T3-7 ESM-block comments.** An import's removal is its declaration's characters alone, and a JavaScript comment beside it in its ESM block is no MDX comment: it stays as content (3, 2.7). Byte-asserted arms in one workspace, each block well-formed (14.20) and every import valid and used: an import followed on its line by ` // note` — the line keeps ` // note` and its terminator, not left whitespace-only; an own-line `// note` between two imports of one ESM block (consecutive lines) — the import lines drop, the comment line survives with its terminator; and a block comment preceding an import on the block's second line (`/* c */ import B from "./B.xspec"`) — the line keeps `/* c */ ` and its terminator. The same bytes appear in the root's own text (`query node`, `view --text`), `view`'s `comments` lists none of them (11.4: MDX comments alone), and the imports are listed under `imports` with their declaration ranges. Terminator arm: a semicolon-terminated import on its own line, `import C from "./C.xspec";` — the `;` ends ECMAScript's ImportDeclaration (14.20), so it is among the declaration's own characters: removed with the declaration, the line dropped as left empty purely by the removal (byte-asserted), and the import's range under `imports` ending after the `;` (T11.4-4); a product deleting through the specifier literal alone leaves a stray `;` as content and fails. ## 4. Generated TypeScript Modules Consumer programs in this section are compiled and run under standard TypeScript tooling only (13.1); type-error assertions assert that compilation fails and the failing location is the consumer reference under test. * **T4-1 Header.** Each generated module begins with a header identifying it as generated by xspec from its source file (asserted as: header present, mentions xspec and the source path — wording free). -* **T4-2 TS import rules.** In code-group files, each fails with 14.15: importing a `.xspec` specifier that designates no discovered spec source — one arm an existing file that is not a discovered spec source, one a nonexistent file; a bare and an absolute specifier ending `.xspec` (the specifier form rule of 2.1 applies in TS, 4); binding anything other than the (optionally aliased) default and `text` exports; a dynamic `import()` with a static `.xspec` specifier; an export declaration whose module specifier ends in `.xspec` — one arm each for `export * from`, `export * as NS from`, `export { … } from`, and a type-only form (`export type { … } from`), all 14.15 (4: no re-export carries a spec module past 4.5); an `import X = require("./NAME.xspec")` declaration; and, for each module-linking form 4 names — an import declaration, an export declaration, an `import X = require(…)`, and a static-specifier dynamic `import()` — a relative specifier designating a derived-file path other than through `.xspec` (`./NAME.xspec.ts` directly; a path under `.xspec/`; a configured Markdown emit destination — the last only while emission is enabled, 7.3). A dynamic `import()` with a non-static specifier is not analyzed: build succeeds, no edges recorded. Duplicate bindings (14.15 applies in either kind of file): an import binding an identifier already bound by another import in the same code-group file fails when either import is a spec module import — spec colliding with spec, and spec colliding with non-spec in both orders; two colliding non-spec imports are not an xspec finding (neither is a spec module import; the unused colliding bindings trigger no xspec error). +* **T4-2 TS import rules.** In code-group files, each fails with 14.15: importing a `.xspec` specifier that designates no discovered spec source — one arm an existing file that is not a discovered spec source, one a nonexistent file; a bare and an absolute specifier ending `.xspec` (the specifier form rule of 2.1 applies in TS, 4); a `.xspec` specifier whose ascent passes above the workspace root, a real `.mdx` file lying there (designating nothing, as T2.1-2), and one spelled with an escape sequence in a name segment (`"./N\u0041ME.xspec"`, read verbatim, 2.4) — while, positively, `"./sub/../NAME.xspec"` and `".//NAME.xspec"` resolve lexically to `NAME.mdx` (4: the resolution of 2.1) and markers through them record edges; binding anything other than the (optionally aliased) default and `text` exports — a named binding other than `text`, a namespace import — while a side-effect-only import `import "./NAME.xspec"` binds nothing and is instead valid (4; invalid in a spec source, T2.1-3): in a code-group file beside ordinary consumers, `build` and `check` exit 0, it records no edge and no occurrence (`query edges`, `occurrences`), and its specifier is held to the rule like any other's — `import "./missing.xspec"` (no such source) and `import "../specs/NAME.xspec.ts"` (a derived-file path) each fail with 14.15; a dynamic `import()` with a static `.xspec` specifier; an export declaration whose module specifier ends in `.xspec` — one arm each for `export * from`, `export * as NS from`, `export { … } from`, and a type-only form (`export type { … } from`), all 14.15 (4: no re-export carries a spec module past 4.5); an `import X = require("./NAME.xspec")` declaration; an import type naming a `.xspec` module — `type T = import("./NAME.xspec").default` and `let v: typeof import("./NAME.xspec")` (4.5: an import type is a module-linking form, never a free type-level reference); a string-named module declaration whose name ends in `.xspec` — `declare module "./NAME.xspec" { }` as a module augmentation (its file holding `export {}`), the undeclared `module "./NAME.xspec" { }`, and the non-relative ambient wildcard `declare module "*.xspec" { }`, whose name ends in `.xspec` though it designates nothing, discriminating a product that checks relative specifiers alone; and, for each module-linking form 4 names — an import declaration, an export declaration, an `import X = require(…)`, a static-specifier dynamic `import()`, an import type, and a string-named module declaration — a relative specifier designating a derived-file path other than through `.xspec` (`./NAME.xspec.ts` directly; a path under `.xspec/`; a configured Markdown emit destination — the last only while emission is enabled, 7.3). Every such file is text accepted by TypeScript 5.9.3 both as module code and as script code (14.20): the relative-name and undeclared-module diagnostics are post-parse checks, so each file is well-formed and its finding 14.15's. No other construct names a module (4): in a code source `src/c.ts` that imports `NAME.mdx`'s module through an import declaration and marks one of its nodes, a `require("../specs/NAME.xspec")` call, a `require("./missing.xspec")` call, a `/// <reference path="../specs/NAME.xspec.ts" />` directive, a string literal `const p = "../specs/NAME.xspec"`, and three dynamic `import()` calls whose specifier is a template literal — `` import(`../specs/NAME.xspec`) ``, `` import(`./missing.xspec`) ``, and `` import(`../specs/NAME.xspec.ts`) ``, no static string literal (2.4: template literals are not static), so not analyzed (4) — each raise no 14.15 and record no edge — `build` and `check` exit 0, `query edges` listing the marker's edge alone — and under a file move of `NAME.mdx` into another directory the import declaration's specifier is rewritten (6.5) while those seven spellings stay byte-unchanged, the move's preview reporting for the file one `import-specifier-rewrite`, spanning that declaration's specifier, and no other edit — a product validating or rewriting such text fails, as does one reading a no-substitution template literal as a static string literal, which takes the three calls for module-linking forms and reports 14.15 for each. A dynamic `import()` with a non-static specifier — a variable (`import(p)`) as well as a template literal — is not analyzed: build succeeds, no edges recorded. Duplicate bindings (14.15 applies in either kind of file): an import binding an identifier already bound by another import in the same code-group file fails when either import is a spec module import — spec colliding with spec, and spec colliding with non-spec in both orders; two colliding non-spec imports are not an xspec finding (neither is a spec module import; the unused colliding bindings trigger no xspec error). * **T4-3 Aliased bindings.** `import SPEC, { text as t } from "./NAME.xspec"` works: `t(SPEC.a)` returns text and records the edge. -* **T4-4 Type-only imports.** A type-only spec module import — a `type` modifier on the declaration (`import type SPEC from "./NAME.xspec"`) and, separately, on a named binding (`import { type text as t } from "./NAME.xspec"`) — is valid (4). Bindings introduced type-only are type-level names: a marker-shaped expression statement `SPEC.a` and a call `t(SPEC.a)` rooted at them record no edge (`query edges` reports none from the file) and trigger no xspec finding — not 14.8 and not 14.18: such value-level uses fall under no condition (4.5, 14.18) — and `build`/`check` exit 0, the workspace staying valid; the consumer-side TypeScript error is outside xspec's validations (as in T6.4-5). Control: the same statements under ordinary bindings record their `references`/`embeds` edges. +* **T4-4 Type-only imports.** A type-only spec module import — a `type` modifier on the declaration (`import type SPEC from "./NAME.xspec"`) and, separately, on a named binding (`import { type text as t } from "./NAME.xspec"`) — is valid (4). Bindings introduced type-only are type-level names: a marker-shaped expression statement `SPEC.a` and a call `t(SPEC.a)` rooted at them — in the declaration-modifier arm `t` is bound by an ordinary `import { text as t } from "./NAME.xspec"` beside the type-only default binding (two declarations of one module binding distinct identifiers, valid under 4), so an ordinary `text` callee receives a chain rooted at a type-only binding; in the named-binding arm `SPEC` is itself bound type-only, by a second declaration `import type SPEC from "./NAME.xspec"` beside the `{ type text as t }` one, so callee and argument are both rooted at type-only bindings — record no edge (`query edges` reports none from the file) and trigger no xspec finding — not 14.8 and not 14.18: such value-level uses fall under no condition (4.5, 14.18) — and `build`/`check` exit 0, the workspace staying valid; the consumer-side TypeScript error is outside xspec's validations (as in T6.4-5). Control: the same statements under ordinary bindings record their `references`/`embeds` edges. The mixed combination — an ordinary `SPEC` node as the argument of a type-only `t` — is staged in neither arm and asserted nowhere: an ambiguity in SPEC.md 4.5, not a harness gap — 4.5 pins the outcome of chains rooted at type-only bindings and states none for an ordinary node passed to such a callee (14.18's "any other function", or the consumer type error 4.5 places outside xspec's validations) — so this document asserts nothing there rather than pin an outcome SPEC.md does not. +* **T4-5 Type-only import collisions.** A colliding identifier roots no chain whether or not either import is type-only (2.4, 4.5, 4): the type-only exemption reaches a chain the language roots at one binding, and a colliding identifier roots it at none. In a code-group file holding the marker `SPEC.a` and the call `text(SPEC.b)`, `text` bound by a separate `import { text } from "./A.xspec"` (two declarations of one module binding distinct identifiers, valid under 4), one arm per pairing, each staged in both declaration orders: `import SPEC from "./A.xspec"` beside `import type SPEC from "./B.xspec"`; beside a non-spec `import type { SPEC } from "./t"`; the spec import itself type-only (`import type SPEC from "./A.xspec"`) beside a value-level non-spec `import { SPEC } from "./t"`; and both type-only (`import type SPEC from "./A.xspec"` beside `import type { SPEC } from "./t"`). Every arm: `build` and `check` report 14.15 locating both declarations and 14.7 for each chain, exit 1; no edge and no occurrence — `occurrences --file` on the failing workspace lists none for the two spellings beside the findings (11.2) — never the type-only exemption's silence (T4-4), which a product rooting the chain at whichever binding TypeScript's own resolution prefers exhibits instead. ### 4.1 Node skeleton * **T4.1-1** The default export is the root; child sections appear as properties named by ID segment; a consumer chain to a leaf type-checks; a chain naming a missing requirement path is a TypeScript type error against the generated module. * **T4.1-2 Readonly.** Assigning to a node property is a type error. -* **T4.1-3 No text as values.** Every node reachable by child property access is an opaque token whose only supported operations are child property access and passing to `text()` (4.1): for each node in a fixture, the value obtained by the supported child-access operation is accepted by `text()`, and the supported operations behave as specified; no requirement text is observable through the module's values — a consumer that never imports `text` and performs only supported operations obtains no node's own or subtree text from the values reachable by those operations (asserted on those values; requirement text is obtainable at runtime only via the `text` export). +* **T4.1-3 No text as values.** Every node reachable by child property access is an opaque token whose only supported operations are child property access and passing to `text()` (4.1): for each node in a fixture, the value obtained by the supported child-access operation is accepted by `text()`, and the supported operations behave as specified; no requirement text is observable through the module's values — a consumer that never imports `text` and performs only supported operations obtains no node's own or subtree text from the values reachable by those operations. The observation is concrete, so the arm cannot pass as a tautology: over every runtime value reachable from the default export by child property access, the harness asserts the value is not a string and that a reflective deep walk of it — property keys and values, own and inherited, its string coercion, and its JSON serialization — encounters no string equal to or containing any node's own or subtree text (fixture texts distinctive: no text equals or contains an ID segment, so property names never match), requirement text being obtainable at runtime only via the `text` export (4.1, 4.3). ### 4.2 Documentation and navigation @@ -159,28 +169,30 @@ 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, a computed index by template literal (`` text(SPEC[`a`]) ``, `SPEC`'s module holding `a` — 2.4: template literals are not static, so a product reading a no-substitution template literal as a static string literal records the edge), an optional-chaining chain, and the TypeScript-only forms 2.4 makes dynamic in a TypeScript source, never a parse failure there: `text(SPEC.a!)`, `text(SPEC.a as X)`, `text(<X>SPEC.a)` (in a `.ts` file, where the angle-bracket assertion parses), and `text(SPEC.a satisfies X)` — each 14.8 located at the call (T14-11), no edge, no occurrence, the file well-formed (the spec-source counterparts are 14.20, T2.4-2); 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 -* **T4.4-1** Passing a node from module A to module B's `text` export: TypeScript type error (14.11); when compiled with the error suppressed at the consumer's responsibility (or executed via the emitted JS), the call throws at runtime with an error identifying both A (the node's module) and B (the called module). +* **T4.4-1** Passing a node from module A to module B's `text` export: TypeScript type error (14.11); when compiled with the error suppressed at the consumer's responsibility (or executed via the emitted JS), the call throws at runtime with an error whose message contains both modules' source files' workspace-relative paths, `/`-separated (4.4, 1.5) — `specs/A.mdx` (the node's module) and `specs/B.mdx` (the called module) as substrings — a product naming the generated module files, using native separators, or naming one module alone failing the assertion. Finding contract (14.11, 5.7): `build` and `check` report the condition-11 finding located at the call, callee through closing parenthesis (T14-11), exit 1, its `identities` exactly `["specs/B.mdx"]` — one element, the root identity of the called module, never the node's module and never a generated-module path (T12.7-1); the call's `embeds` edge and occurrence stand beside the finding — the workspace failing `build`, observed through `occurrences` (11.2): exactly one record for the call, `kind` `"embeds"`, `source` the calling unit's identity and range, `target` the node's identity (`specs/A.mdx#a`), the finding accompanying, exit 1. A resolving argument is required: `textB(A.missing)` reports condition 7 alone and `textB(A.a!)` condition 8 alone, no condition 11 beside either and no occurrence for either. Invalid called path (Linux leg, T11.2-3): with `textB` imported from a discovered `specs/B#.mdx` (the import valid — the file is discovered — its path condition 19), `textB(A.a)` reports condition 11 with `identities` exactly `[]`, the finding still reported and the occurrence still recorded (no identity over an invalid path is ever emitted, 1.5, 11.2). A call through a colliding `text` identifier is never this condition (T4.5-9). * **T4.4-2** Consuming two spec modules in one file with aliased `text` imports: each alias accepts only its own module's nodes. ### 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-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-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 fails with 14.8 (invalid argument, not 14.18), located as the statement's expression exclusive of its terminator (T14-11), no edge and no occurrence: a computed index by variable; a computed index by template literal, `` SPEC[`login-v2`]; ``, `SPEC`'s module holding `login-v2` (2.4: template literals are not static, so a product reading a no-substitution template literal as a static string literal records the edge and reports nothing); optional chaining (`SPEC?.a;`); and the TypeScript-only forms 2.4 makes dynamic in a TypeScript source — a non-null assertion `SPEC.a!;`, a type assertion `SPEC.a as X;`, an angle-bracket assertion `<X>SPEC.a;` (in a `.ts` file), and `SPEC.a satisfies X;` — each a well-formed file, never 14.20 (the spec-source counterparts, T2.4-2). +* **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; among the locals, a `using SPEC = f()` in a block and an `await using SPEC = f()` in an async function (2.4, 4.5: variable declarations, `using` and `await using` included; each file accepted by TypeScript 5.9.3 both as module code and as script code (14.20)), each shadowing the import in its scope: a chain rooted there records no edge and raises no finding, while the same chain outside that scope records its edge. Callee side (4.5: rooting is scope-aware and value-level for the `text` binding as for the node chain): with an inner-scope `function text(x: unknown) {}` shadowing the imported `text`, a call `text(SPEC.a)` in that scope has a non-spec callee and a node argument — the "passing to any other function" of 4.5, 14.18 (T4.5-5): `build` and `check` report the condition-18 finding located at that use, exit 1; the call records no `embeds` edge and no occurrence (5.7) — `occurrences --file` on that file, answering on the failing workspace (11.2), lists none for it and carries the finding — and the shadowed `text` import binding, never used, stays a valid import (2.1, 4). Control, in the same file: the identical call outside the shadowing scope is an ordinary `text` call, its `embeds` occurrence listed. A product resolving the callee by name records an `embeds` edge and no finding, failing the arm; unlike T4-4's unasserted mixed combination — a type-only callee, whose value use is a consumer type error 4.5 places outside xspec's validations — the shadowing local is ordinary value-level TypeScript, so 4.5 pins this outcome. * **T4.5-5 Sanctioned uses only.** Each fails with 14.18: aliasing a node to a variable; destructuring the module; re-exporting the binding; storing a node in an array/object; passing a node to a function other than a spec module's `text` export; using `text` as a value (passing/storing it) other than as a callee. * **T4.5-6 text in statement position.** A `text(...)` call as an expression statement is valid, records an `embeds` edge, and is not a marker (kind asserted via `query edges --kinds`). -* **T4.5-7 Type-level freedom.** `typeof SPEC.a.b` and other type-level references build with no edges recorded and are not rewritten by rename (see T6.4-5). +* **T4.5-7 Type-level freedom.** `typeof SPEC.a.b` and other type-level references to a spec module import's bindings build with no edges recorded and are not rewritten by rename (see T6.4-5); an import type naming a `.xspec` module is no such reference but a module-linking form, 14.15 (4.5, T4-2). +* **T4.5-8 Same-scope collisions.** An identifier a spec module import binds that a non-import declaration of the same scope also binds at value level roots no resolving chain (2.4, 4.5, 14.15). In a code source importing `SPEC` from `./A.xspec` with a marker `SPEC.a` and a call `text(SPEC.b)`, one arm per colliding form at module scope — `const SPEC = 1`, `using SPEC = f()` and `await using SPEC = f()` (2.4: variable declarations, `using` and `await using` included; each file accepted by TypeScript 5.9.3 both as module code and as script code (14.20), top-level placement included), `function SPEC() {}`, `class SPEC {}`, `enum SPEC {}`, and `namespace SPEC { export const v = 1 }` (a namespace binding a value): `build` and `check` report the condition-15 collision beside condition 7 for each chain (unresolved), exit 1; no edge and no occurrence for its spellings (5.7, T5.7-4) — observed through `occurrences --file`, which answers on this failing workspace (11.2), listing none for them beside the findings, while `query edges` is gated there (13.3) and observes nothing; and the condition-15 finding locates every colliding declaration — the import by its own characters and the non-import by the construct binding the name (14, T14-11), byte-asserted against precomputed offsets: the variable declarator's own characters (`SPEC = 1`, the `const` statement excluded; `SPEC = f()` for each `using` arm, the `using` or `await using` excluded) and the function, class, enum, or namespace declaration's own characters, an `export` prefix excluded. Further located forms, one arm each (14's declarator and decorator rules): a declarator without initializer, `let SPEC;` — located at `SPEC` alone; a binding pattern, `const { SPEC } = o` — at `{ SPEC } = o`; a decorated class, `@dec class SPEC {}` — from its `@`; and `export`-prefixed declarations, `export class SPEC {}` and `export function SPEC() {}` — from `class` and `function`, the `export` and what separates it excluded. Type-level controls in the same file (2.4, 4.5: colliding with nothing): `interface SPEC {}`, `type SPEC = number`, and `namespace SPEC { export type T = number }` (a namespace binding no value) each leave the import rooting the chain — the edges recorded, no finding, exit 0 — while an inner-scope `const SPEC = 1` shadows instead (T4.5-4). The spec-source case: a file holding `export const BASE = 1` beside `import BASE from "./BASE.xspec"` reports 14.16 for the export statement, 14.15 for the collision (locating the import and the declarator), and 14.5/14.6 for the `d` and `text(...)` spellings rooted at `BASE`, no edge and no occurrence recorded for them — the two declarations staged in one ESM block (the export on the line after the import), the staging 14.20 makes a finding in a well-formed file and never a parse failure (T14-12), with a second arm across two blocks reporting identically. +* **T4.5-9 Call through a colliding `text` identifier.** A call whose callee identifier a spec module import binds to its `text` export and another import, or a value-level declaration of the same module scope, also binds is no spec module's `text` call (4.5): it records no edge and no occurrence and falls under no condition of a `text` call, while a spec module binding or node its argument spells is used outside the sanctioned uses (14.18), beside the collision (14.15). In a code-group file importing `SPEC, { text }` from `./A.xspec`, one arm per colliding form at module scope: a second, non-spec import binding `text` (`import { text } from "./t"`); a second spec module's `text` (`import B, { text } from "./B.xspec"`); a type-only import (`import type { text } from "./t"`); and the value-level declarations `function text(x: unknown) {}` and `const text = (x: unknown) => ""`. In each, the call `text(SPEC.a)`: `build` and `check` report 14.15 locating both declarations and 14.18 located at `SPEC.a` (the identifier extended by its longest static chain, T14-11), exit 1 — no 14.6, no 14.7, no 14.8, no 14.11; no edge and no occurrence — `occurrences --file` on the failing workspace lists none for it beside the findings (11.2, T5.7-4). Whatever the argument: `text("x")` beside the same collision reports no 14.8 (and no 14.18, the argument spelling no binding or node); a cross-module argument `text(B.a)`, `B` a valid second module's default binding, reports 14.18 at `B.a`, never 14.11 (T4.4-1). Control in the same workspace: `type text = number` beside the import collides with nothing (2.4) — the call records its `embeds` edge and occurrence, no finding — and the inner-scope shadowing declaration is T4.5-4's. ### 4.6 Code locations and attribution -* **T4.6-1 Attribution.** Markers and `text(...)` calls placed: at file top level → attributed to the file (`path`); inside a function declaration, a class method, a getter, a setter, a class member property initialized with an arrow function, with a function expression, and with a class expression, a variable declaration initialized with an arrow function, with a function expression, and with a class expression, a namespace, and a named default export → attributed to `path#unit` with the dot-joined chain, outermost first (nested cases like `Class.method` and `ns.fn` asserted). Markers' `references` edges and `text(...)` calls' `embeds` edges (4.3: from the calling code location) attribute to the same innermost enclosing named unit. A class declaration is itself an innermost attribution target, not only a chain element: a marker in a class `static` block (statements in the class body; the block binds no name, so it is no unit) and a `text(...)` call in a plain non-function property initializer (an ordinary expression, 4.5; such a property is not a named unit, 4.6) each attribute to the bare class unit `path#C`. Dotted namespaces (4.6): `namespace A.B` declares nested namespaces, one named unit per dot-separated name — a marker directly inside `namespace A.B { }` attributes to `path#A.B`, and one inside a function `f` declared there to `path#A.B.f`. +* **T4.6-1 Attribution.** Markers and `text(...)` calls placed: at file top level → attributed to the file (`path`); inside a function declaration, a class method, a constructor (`path#C.constructor`, a unit named `constructor`, 4.6), a getter, a setter, a class member property initialized with an arrow function, with a function expression, and with a class expression, a variable declaration initialized with an arrow function, with a function expression, and with a class expression, a `using` declaration initialized with an arrow function (`using f = () => { SPEC.a }` at top level → `path#f`) and an `await using` one inside an async function `g` (`await using h = () => { SPEC.b }` → `path#g.h`) — 4.6 counting both as variable declarations, each file accepted by TypeScript 5.9.3 both as module code and as script code (14.20) — a namespace, a legacy `module X` namespace and a dotted `module A.B` (the same declaration as `namespace`, 4.6: units named exactly as `namespace X` and `namespace A.B` derive), and a named default export → attributed to `path#unit` with the dot-joined chain, outermost first (nested cases like `Class.method` and `ns.fn` asserted). Markers' `references` edges and `text(...)` calls' `embeds` edges (4.3: from the calling code location) attribute to the same innermost enclosing named unit. A class declaration is itself an innermost attribution target, not only a chain element: a marker in a class `static` block (statements in the class body; the block binds no name, so it is no unit) and a `text(...)` call in a plain non-function property initializer (an ordinary expression, 4.5; such a property is not a named unit, 4.6) each attribute to the bare class unit `path#C`. Dotted namespaces (4.6): `namespace A.B` declares nested namespaces, one named unit per dot-separated name — a marker directly inside `namespace A.B { }` attributes to `path#A.B`, and one inside a function `f` declared there to `path#A.B.f`. Decorated declarations are units like undecorated ones (4.6): with `dec` declared in the file, `@dec class C { @dec m() { SPEC.a } }` attributes the marker to `path#C.m`, a marker in a decorated class's static block to `path#C`, and `export @dec class D { m() { SPEC.a } }` to `path#D.m`; their ranges are T1.7-2's. * **T4.6-2 Anonymous default.** A marker inside an anonymous default-exported function is attributed to `path#default`. -* **T4.6-3 Not named units.** Markers inside an IIFE, a function stored via destructuring, a computed-name class member, a string-literal-name class member, a numeric-literal-name class member (`123() {}`), and a private (`#`-prefixed) member (`#priv() {}`) attribute to the nearest enclosing named unit or the file — the private-member arm asserts attribution to the bare class unit `path#C`, never to a `#`-named unit (4.6). +* **T4.6-3 Not named units.** Markers inside an IIFE, a function stored via destructuring, a computed-name class member, a string-literal-name class member, a numeric-literal-name class member (`123() {}`), and a private (`#`-prefixed) member (`#priv() {}`) attribute to the nearest enclosing named unit or the file — the private-member arm asserts attribution to the bare class unit `path#C`, never to a `#`-named unit (4.6). Value-side boundary (4.6: a variable declaration is a named unit only when its initializer is a function expression, an arrow function, or a class expression): a `text(...)` call that is itself the initializer of a plain-identifier variable declaration — `const s = text(SPEC.a)` — attributes, inside a function `f`, to `path#f` and, at top level, to `path`, never to a unit named `s` (asserted via `query edges`; `s` stores the returned string, no node or `text` binding, so 4.5 is untouched). Wrappers and value forms (4.6): an initializer that merely wraps a named form — `const f = (() => { SPEC.a })`, `const g = ((): void => { SPEC.a }) as () => void`, `const h = (function () { SPEC.a }) satisfies () => void`, `const k = (() => { SPEC.a })!` — binds no unit, its marker attributing to the file (or to the enclosing unit where one encloses it); a default export of an object literal, of an identifier, of a call, or of a literal value binds no unit — a marker inside an arrow function held by the exported object literal's property attributes to the file; declarations binding no executable code — overload signatures, body-less method signatures, abstract members, and `declare` ambient declarations — are no units and occupy no document-order slot: a function implementation preceded by its two overload signatures is `path#f`, never `path#f@3`; a class method implementation preceded by its overloads is `path#C.m`; and with two block-scoped classes named `C` in sibling blocks, the first abstract with `abstract get v(): number` and the second holding `get v() { SPEC.a; return 1 }`, the getter is `path#C.v`, never `path#C.v@2`; a unit name spelled with an escape sequence (`function f\u006Fo() { SPEC.a }`) binds no unit (4.6, 2.4), the marker attributing to the file, never to `path#foo`. Declaration files, ambient by kind (4.6): code-group files named `x.d.ts`, `x.d.mts`, `x.d.cts`, and `x.d.css.ts` (the group's globs `src/**/*.ts`, `src/**/*.mts`, `src/**/*.cts`), each holding a body-bearing `function f() { SPEC.a }` and a top-level marker — every such file is well-formed (14.20: TypeScript's ambient-context checks are post-parse), `build` and `check` exit 0 on the otherwise valid workspace, `f` is no unit, and both markers attribute to the whole file `path` (`query edges`; the occurrence's `source` range the entire file, T1.7-2), never to `path#f`; a control `x.dts.ts` — no `.d.` in its last path segment — keeps `path#f`. * **T4.6-4 Duplicate chains.** A getter/setter pair and two same-named declarations in sibling scopes: second occurrence in document order is `path#unit@2` (1-based), asserted via `query edges`/coverage boundary membership. ## 5. Workspace Graph @@ -202,7 +214,7 @@ Consumer programs in this section are compiled and run under standard TypeScript ### 5.5 Hashes * **T5.5-1 Reporting and determinism.** `query node` reports all four hashes; rebuilding the identical workspace in a fresh directory yields identical hashes (H-6). -* **T5.5-2 ownHash.** Changes when: an own-text run is edited; a child is added; removed; two byte-identical children are reordered; an embedded reference is added; removed; retargeted; repositioned between runs. Unchanged when: a child's text is edited (only child's hashes change); an embedded target's text is edited — the fixture MUST include an own-line embedding whose target's edit toggles its subtree text between empty and non-empty, so the Markdown line-drop outcome flips (3) while the embedder's ownHash is byte-identical across the toggle: for own content the excised expression counts as remaining line content and the target's text is no part of it (1.6). Kind distinction (1.6/5.5: child and embedding references distinguished): across a baseline, a child construct is replaced at its exact position by a `text(...)` embedding of the same canonical identity with identical surrounding bytes — journaled-move the child section to another file, then embed the moved node (imported form) at its former position — and the parent's ownHash differs from baseline, the parent `changed`: the two own-content sequences are equal runs around one reference differing only in kind. +* **T5.5-2 ownHash.** Changes when: an own-text run is edited; a child is added; removed; two byte-identical children are reordered; an embedded reference is added; removed; retargeted; repositioned between runs. Unchanged when: a child's text is edited (the parent's ownHash alone stays — its subtreeHash and effectiveHash change, T5.5-3); an embedded target's text is edited — the fixture MUST include an own-line embedding whose target's edit toggles its subtree text between empty and non-empty, so the Markdown line-drop outcome flips (3) while the embedder's ownHash is byte-identical across the toggle: for own content the excised expression counts as remaining line content and the target's text is no part of it (1.6). Kind distinction (1.6/5.5: child and embedding references distinguished): across a baseline, a child construct is replaced at its exact position by a `text(...)` embedding of the same canonical identity with identical surrounding bytes — journaled-move the child section to another file, then embed the moved node (imported form) at its former position — and the parent's ownHash differs from baseline, the parent `changed`: the two own-content sequences are equal runs around one reference differing only in kind. Fixture geometry: the child construct and its replacement are in-line — within one line, flanked by content on that line (`foo <S id="c">…</S> baz` at baseline, `foo {text(X.c)} baz` after) — because on its own line a construct's straddling lines drop with their terminators (3) while an own-line `{text(...)}` keeps its line in own content (1.6): the runs would then differ too and the arm would pass vacuously; in-line, the runs are byte-identical and only the reference kind differs. * **T5.5-3 subtreeHash.** Changes exactly under the 5.5 conditions: any descendant added/removed/reordered or any in-subtree own-content change; unchanged for sibling-subtree edits and for embedded-target edits outside the subtree. * **T5.5-4 effectiveHash.** Changes when a dependency edge (either kind a requirement node can bear: `depends` or `embeds` — `references` edges originate only at code locations, 4.5) is added, removed, or retargeted anywhere in the subtree; when a dependency target's effectiveHash changes (transitively); and on retarget between two targets that have equal effectiveHash (byte-identical twin targets fixture). Unchanged when an unrelated node changes. Per-edge pairs (5.5: one pair enters per dependency edge, not per distinct target): a node bearing both `d={T}` and `{text(T)}` to one target contributes two identical pairs — removing the `d` reference alone changes effectiveHash while ownHash stays unchanged (the discriminating arm: a product deduplicating pairs per distinct target reports it unchanged), and removing the embedding alone changes it too. * **T5.5-5 metadataHash.** Changes iff `d` target set, `coverage`, or tags change; embedded `text(...)` references do not affect it; root nodes have a metadataHash (computed from empty inputs) reported by `query node`. Order-insensitivity: reordering the references within one multi-element `d` array and reordering a multi-tag `tags` list changes no hash — metadataHash and effectiveHash included (5.5: target sets enter sorted by canonical identity, tags sorted) — and yields no change categories against a baseline. @@ -219,69 +231,108 @@ All category tests run `impact --base <ref>` 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. Token bounds take ECMAScript's whitespace — its space separators Unicode 15.1's (14.20) — and comments (1.4, 14): `d={` U+00A0 `BASE.a` U+00A0 `}`, `d={` U+FEFF `BASE.a` `}`, `d={` U+3000 `BASE.a` U+202F `}`, and `d={ /* c */ BASE.a }` each record an occurrence spanning `BASE.a` alone, the brace-side whitespace and comment excluded as ASCII whitespace is; a product spanning only ASCII whitespace fails the first three, and one whose class is ASCII's plus the code points 14.20 names fails the third (T2.7-4). Line-comment forms inside the value (14.20's deletion judgement and run-on rule apply to an attribute value's braces as to a container's, T2.7-4): `d={// c` U+000A `BASE.a}` — the comment ended by the terminator — and its run-on twin `d={// c}` U+000A `BASE.a}`, whose first `}` lies on the commented-out line and closes nothing, the value running to the second `}` — are each a well-formed `d` (no 14.20, no 14.8, `build` exit 0) recording an occurrence spanning `BASE.a` alone; a product scanning an attribute value to its first `}` reports 14.8 or 14.20 there and fails. +* **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 chain rooted at an identifier an import and a same-scope value-level declaration both bind (T4.5-8, its collision and unresolved findings beside it); a type-only collision's chains (T4-5); a call through a colliding `text` identifier (T4.5-9, 4.5); a cross-module call whose argument does not resolve or is not static (T4.4-1 — the resolving cross-module call records its occurrence beside its condition-11 finding, 5.7); 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-1 Lifecycle.** No journal file exists after `build` in a fresh workspace; the file appears at `.xspec/journal` with the first `rename`/`move`; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); no other command modifies it (6.1): `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review` (the reads and `create`/`resolve`/`split` alike), `query`, `occurrences`, `view`, `at`, `inventory`, `version`, and `rename`/`move --preview` are each byte-compared around their invocation on a journal-bearing workspace — the H-7 mapping of 6.1's never-modified clause, which T13.4-5, T6.6-2, and T11.6-4 cover from their sides. * **T6.1-2 Determinism.** The same operation on the same workspace state (two identical directories) appends byte-identical entries. -* **T6.1-3 Integrity.** `check` reports a malformed journal (garbage line), naming the line, with 14.13; a journal path occupied by a directory or symbolic link is a journal error (14.13). The conflicting and well-formed-yet-unreplayable arms of 12.2/14.13 admit no discriminating fixture at `check`: entry content is opaque (6.1, H-4), so such an entry cannot be authored directly, and tampering with product-written lines (duplicating one, recombining entries) has no product-independent expected outcome — manual restructuring is never journaled (6.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 * **T6.2-1 Rename purity.** After `xspec rename`, every node's four hashes are byte-identical to before (full-workspace sweep) and `impact --base <pre-rename ref>` reports no change categories. The fixture includes a code location bearing a marker and a `text(...)` call whose targets are renamed nodes (both rewritten, 6.4), and the assertion extends to impacted code: the directly and the transitively impacted groups are empty — the location's baseline impact edges (old identities) and current ones (new identities) unify through the journal (9.2); a product failing to unify them evaluates a deleted and an added target instead (each counting as changed in both hashes, 9.2) and reports the location spuriously impacted. * **T6.2-2 File-move purity.** Same assertions — impacted-code emptiness included, over a marker and a `text(...)` call targeting the moved file's nodes (import specifiers rewritten, 6.5) — for `xspec move old.mdx new.mdx`; identities change only in their file part. -* **T6.2-3 Section move impurity.** `xspec move a.mdx#x b.mdx#y` on a clean-boundary fixture — no moved node has own-content bytes on the construct's straddling lines (opening and closing tags each alone on their lines, descendants on interior lines): every node of the moved subtree keeps ownHash, subtreeHash, and metadataHash; origin parent and target parent are each `changed` with ordinary cascades attributed to them; no other node is `changed`. Impure boundary (6.2's worked case): a multi-line moved section whose opening tag is preceded on its origin line by non-whitespace and followed there only by whitespace — the within-construct remainder and terminator contribute to its own content at the origin (line kept) but not at the destination (line dropped, 3) — is itself additionally `changed`, with the 5.6 cascades attributed to it; its metadataHash is still unchanged (6.2: metadataHash is kept unconditionally; ownHash only when no own-content bytes lie on the straddling lines). -* **T6.2-4 Same-parent final-position move.** Moving a parent's last child onto itself (same parent, same final position, new ID): changes no hash and is pure in effect (no categories) apart from the identity mapping. +* **T6.2-3 Section move impurity.** `xspec move a.mdx#x b.mdx#y` on a clean-boundary fixture — no moved node has own-content bytes on the construct's straddling lines (opening and closing tags each alone on their lines, descendants on interior lines), the insertion point at a line start — `b.mdx` ending with a terminator for the top-level `y` spelled here, or, into a flow-position parent (`b.mdx#p.y`), that parent's closing tag alone on its line — so that the insertion splits no line, no node but the parents holding own-content bytes on the construct's boundary lines, and no import addition needed (every reference the moved text carries targets the moved subtree in local form) — so that 6.2's enumeration of what a successful move leaves `changed` reaches the parents alone: every node of the moved subtree keeps ownHash, subtreeHash, and metadataHash; origin parent and target parent are each `changed` with ordinary cascades attributed to them; no other node is `changed`. Impure boundary (6.2's worked case): a multi-line moved section whose opening tag is preceded on its origin line by non-whitespace and followed there by nothing but spaces and tabs, if anything, and whose closing tag is preceded on its line by nothing but spaces and tabs, if anything, and followed there by non-whitespace — both boundary lines' within-construct whitespace and the opening line's terminator contribute to its own content at the origin (lines kept) but not at the destination (both lines dropped, 3) — is itself additionally `changed`, with the 5.6 cascades attributed to it; its metadataHash is still unchanged (6.2: metadataHash is kept unconditionally; ownHash only when no own-content bytes lie on the straddling lines). Three stagings, each moved to top level and into a flow-position parent (its tags alone on their lines) alike, origin and destination each deriving under the grammar 14.20 fixes (S-9): (a) the worked shape with spaces before its closing tag — origin `foo <S id="m">`, U+000A, `body`, U+000A, two spaces, `</S> bar`; its moved text `<S id="m">`, U+000A, `body`, U+000A, two spaces, `</S>` lands at the destination's line start followed by U+000A (6.5), where each tag, alone on its line, is a flow-position tag and both boundary lines drop (3): the opening line's terminator and the closing line's two spaces leave the node's runs; (b) the both-sided U+000C spelling (U+000B alike) — origin `foo <S id="m">`, U+000C, U+000A, `body`, U+000A, U+000C, `</S> bar` — whitespace under 1.4, so both destination lines are left whitespace-only by the removals and drop with their terminators, but no whitespace to the MDX grammar, so both tags stay in text position there, the section closing within its paragraph; (c) the `body</S>` variant with a U+000C remainder (U+000B alike) — origin `foo <S id="m">`, U+000C, U+000A, `body</S>`, an in-line section closed within its paragraph (T3-3's constraint), whose difference is realized on the opening line alone: the destination line `<S id="m">`, U+000C is dropped, the tag staying an in-line tag there too. In each staging the moved node is `changed` — the bytes named gone from its runs at the destination — with metadataHash kept and the 5.6 cascades attributed to it, the two parents `changed` as above, and no other node `changed`. Set aside here, their moved text not deriving at a line's start (6.2), are the `body</S>` variant with a space, tab, or empty remainder — its tag a flow-position tag at the destination, which the closing tag inside the following paragraph cannot close — and the one-sided U+000B/U+000C spellings of the worked shape: each is refused, T6.5-16's arms. Sibling stagings — 6.2's `any sibling with bytes there alike`, which no other fixture realizes (the clean-boundary fixture has no such node, the impure stagings change the moved node itself, T6.5-13(h)/(j) the root, and P-5's boundary lines hold prose outside the construct alone): (d) at the origin — a flow-position parent `<S id="p">`, U+000A, `<S id="p.s"> </S><S id="p.m">text</S>`, U+000A, `</S>`, U+000A, its second line a paragraph of two in-line siblings, `p.m` moved to another file's top level (`move a.mdx#p.m b.mdx#m`, the target `<S id="k">z</S>`, U+000A, the moved text landing on a line of its own, a paragraph line): the deletion leaves `<S id="p.s"> </S>` alone on its line, deriving as a flow element, and the drop rule of 3 decides that line differently — kept before, `text` remaining, dropped after, whitespace-only purely by removals — so `p.s` is `changed`, its run ` ` gone, with the 5.6 cascades attributed to it, while the moved node keeps its ownHash, its bytes `text` on a kept line at both sides, and carries no category; `p` and the target root `changed` as parents, no other node `changed`; (e) at the destination — a text-position target parent `foo <S id="p">`, U+000A, `<S id="p.s">`, U+000C, `</S></S> tail`, U+000A receiving `<S id="m">text</S>` (alone on its origin line) into `p.n`: the insertion, preceded by `p.s`'s closing tag, splits the line with an added terminator, leaving `<S id="p.s">`, U+000C, `</S>` alone on its line — kept in text position by the U+000C, no whitespace to the grammar (6.2; its space-spelled twin, left a flow line there, is refused — T6.5-16(d)'s insertion-side arm), and dropped as whitespace-only under 1.4 — so `p.s` is `changed` beside `p` and the origin parent, `p.n` carrying no category, the target root keeping its own content (`foo ` and ` tail` on kept lines at both sides), no other node `changed`; every composition derives (S-9). The pair discriminates a product marking every node with bytes on a boundary line `changed` (failing on (d)'s moved node) and one re-evaluating only the parents and the moved subtree (failing on `p.s`). The last member of 6.2's enumeration — the root of a spec source receiving an import addition elsewhere than at a line's start — is T6.5-13(h)'s and (j)'s, one arm per mechanism 6.2 names — the remainder line kept where the whole line was dropped, and the ended line kept with the added terminator — no addition being needed here. +* **T6.2-4 Same-parent final-position move.** Moving a parent's last child onto itself (same parent, same final position, new ID) is pure in effect exactly when the re-insertion reproduces the parent's own content sequence byte for byte — 6.2's `may`, which the fixture's shape decides, not the operation — so the fixture is pinned to two shapes whose re-insertion does: a flow-form last child, its opening and closing tags alone on their lines, the parent's closing tag alone on the following line — `<S id="p">`, U+000A, `<S id="p.m">`, U+000A, `y`, U+000A, `</S>`, U+000A, `</S>`, U+000A under `move specs/a.mdx#p.m specs/a.mdx#p.n`: the deletion removes exactly the construct's lines (the joined line it leaves empty dropping with line 4's terminator, 6.5, 3), leaving `<S id="p">`, U+000A, `</S>`, U+000A, and the insertion before that `</S>`, at a line start (no terminator added), restores them, the composed file byte-identical to the original but for the `id` attribute; and the top-level shape T6.5-13(f) asserts byte-exactly, the root the coincident parent. In each, no hash changes — the parent's own content sequence reproduced, the re-inserted child entering by its canonical identity (5.4) — and no node carries any category apart from the identity mapping. The `changed` twin, 6.2's `may` on its other side: T6.5-13(e)'s shape, `foo <S id="p">`, U+000A, `<S id="p.m">x</S></S> baz`, U+000A under the same command, whose composed text `foo <S id="p">`, U+000A, `<S id="p.n">x</S>`, U+000A, `</S> baz`, U+000A derives (S-9; (e)) and gives `p`'s run after its child the moved text's terminator, U+000A, where it was empty — `p` `changed`, its ownHash with it, its metadataHash kept, with the 5.6 cascades attributed to it; the moved node keeps its hashes (`x` on a kept line at both sides) and carries no category; the root keeps its own content, as (e) asserts; no other node `changed`. S-6 feeds the pinned shapes to the section-move oracle as its final-position vectors and the twin as its coincident-parent-`changed` vector (P-5: a coincident parent `changed` iff the re-insertion fails to reproduce its sequence) — a harness deriving them from an arbitrary last child, whose closing tag may share a kept line with the parent's, fails. ### 6.3 Baseline resolution * **T6.3-1 Config at ref.** A baseline where the configuration had different group membership: baseline graph reflects the old configuration (a file added to a group since then is absent from the baseline side). * **T6.3-2 Absent journal.** A baseline predating the journal file resolves normally (an absent journal reads as empty, and an empty journal is a prefix of every journal); a current workspace whose journal file is absent while the baseline's journal is non-empty reads as empty on the current side and fails as a prefix violation (T6.3-4). * **T6.3-3 Replay and composition.** Rename a→b, commit, rename b→c: impact with the older baseline maps a→c through composed entries and reports no changes. -* **T6.3-4 Failures.** Each fails at a baseline-taking command with an actionable error naming the offending entries or files, as a usage error (exit 2, 12.0): a baseline journal that is not a prefix of the current journal (simulate by rewriting history/journal in the fixture); a replay that cannot resolve a mapping — a garbage line appended to the current journal after the baseline commit (shape-independent staging, H-4), run at `impact --base` and at `review create --base`, the error naming the offending entries and `review create` modifying nothing (10.7); a baseline whose sources fail parse/validation; an unresolvable ref. Precedence arm (12.0: baseline resolution precedes source validation): `impact --base <unresolvable-ref>` and `review create --base <unresolvable-ref>` on a workspace whose current sources also fail build validation each exit 2 with the baseline error, report no validation findings, and modify nothing — a product that validates the current sources before resolving the baseline exits 1 there; the resolvable-ref counterpart over invalid sources is T13.3-3's refresh failure (exit 1). +* **T6.3-4 Failures.** Each fails at a baseline-taking command with an actionable error naming the offending entries or files, as a usage error (exit 2, 12.0): a baseline journal that is not a prefix of the current journal (simulate by rewriting history/journal in the fixture); a replay that cannot resolve a mapping — a garbage line appended to the current journal after the baseline commit (shape-independent staging, H-4), run at `impact --base` and at `review create --base`, the error naming the offending entries and `review create` modifying nothing (10.7); a baseline whose sources fail parse/validation; an unresolvable ref. 6.3's remaining replay failure, an *ambiguous* mapping, admits no discriminating staging (recorded here as T6.5-6 records its unstageable clauses): entry content is opaque (6.1, H-4), so no fixture can compose entries known to replay ambiguously rather than unresolvably, and the two share one observable outcome — exit 2, the error naming the offending entries — which the garbage-line arm asserts. Precedence arm (12.0: baseline resolution precedes source validation): `impact --base <unresolvable-ref>` and `review create --base <unresolvable-ref>` on a workspace whose current sources also fail build validation each exit 2 with the baseline error, report no validation findings, and modify nothing — a product that validates the current sources before resolving the baseline exits 1 there; the resolvable-ref counterpart over invalid sources is T13.3-3's refresh failure (exit 1). +* **T6.3-5 Repository and path of the baseline.** 6.3 fixes where a baseline resolves: the repository whose working tree contains the current configuration file — the innermost where repositories nest — whatever repository the working directory lies in; the baseline configuration is the file at the current configuration file's repository-relative path in the ref's tree, the baseline root its directory. (a) A workspace rooted in a repository subdirectory: the repository at `R`, the configuration at `R/sub/xspec.config.ts`, sources under `R/sub/specs/`; a commit `c1`, then an edit; `impact --base c1` run from `R/sub`, and from `R` with `--config sub/xspec.config.ts`, reports the edit against the baseline reconstructed from `sub/` at `c1` — the configuration read from `sub/xspec.config.ts` in that tree, identities workspace-relative to `sub/` (`specs/A.mdx#a`, never `sub/specs/A.mdx#a`). (b) Nested repositories: an outer repository at `R` and an inner one at `R/inner` — a nested repository whose files the outer committed before the inner was initialized, and separately a submodule — the configuration at `R/inner/xspec.config.ts`, both repositories holding a tag `v1` whose trees differ (the outer's `v1` holding the inner workspace's files with an extra section, or, for the submodule, a gitlink and no such files; the inner's `v1` the workspace without the extra section); `impact --base v1 --config inner/xspec.config.ts` run from `R` resolves `v1` in the inner repository — the extra section on neither side, neither deleted nor present — and `review create --base v1` records the inner commit (T10.5-6): a product resolving the ref in the working directory's repository fails both. (c) A configuration outside any working tree — a workspace directory that is no repository and lies inside none — makes `impact --base HEAD` a usage error, exit 2; (d) so does a ref whose tree holds no file at the configuration's repository-relative path — a commit predating `sub/xspec.config.ts`, and one in which the file bore another name — each exit 2 with an actionable error, nothing modified (6.3, 12.0). ### 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-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 `<file>`; 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-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-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 preview's `mapping` (6.4) — and under `--json` it is the form-exact performed-operation document of 12.7 (H-3): exactly `{"findings", "mapping"}`, `findings` `[]`, `mapping` one `{"from", "to"}` per mapped identity ordered by `from` bytes, byte-equal to the `mapping` the same operation's `--preview` reported on the same state (T6.6-2); a product emitting any other member set, or the mapping in another shape, fails. +* **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. The rewritten segment itself keeps every keepable form (6.4: the fallback spellings apply only where a form cannot be kept), byte-asserted in MDX and TS spellings alike: a double-quoted computed segment renamed to an identifier-valid name stays computed and double-quoted (`BASE["login-v2"]` → `BASE["login2"]`, never `BASE.login2`); a single-quoted computed segment keeps its single quotes whether its new name is identifier-valid (`BASE['login-v2']` → `BASE['login2']`) or not (→ `BASE['login-v3']`); a dot-access segment renamed to an identifier-valid name stays dot access — a reserved word (`BASE.login` → `BASE.delete`), a non-ASCII letter (→ `BASE.é`), and U+2EBF0 (→ `BASE.𮯰`), which TypeScript 5.9.3 admits at ESNext but not at ES5, included, 1.4 judging characters alone, at the release and language level 14.20 fixes (T1.4-5(c)); a single-quoted local string reference whose own segment is rewritten — `{text('login-v2')}` and a `d` array's `'login-v2'` entry — keeps its single quotes (T6.5-7 pins the same for a move's prefix re-identification). Where the form cannot be kept: a dot-access segment whose new name is not a TS identifier is written as double-quoted computed access — `2fa`, whose first character can only continue an identifier, included (`BASE.login` → `BASE["2fa"]`), and `Ᲊx` — U+1C89, then `x` — U+1C89 being a character TypeScript 5.9.3 admits nowhere in an identifier, though a runtime whose Unicode tables postdate 15.1 admits it (→ `BASE["Ᲊx"]`): a product classing characters by its runtime's tables writes `BASE.Ᲊx`, in the TS spelling a file that release cannot parse (14.20), behind a reported success; a reference converted between local and imported form (6.5) uses dot access for identifier-valid segments, double-quoted computed access for the others, and double-quoted string literals. A product normalizing every touched segment to dot access or double quotes fails the keepable-form arms. Whole-file byte contract: each rewritten file, `.mdx` and `.ts` alike — T6.4-1's marker and `text(...)`-call rewrites in code included — is asserted byte-equal to an expected file differing from the original in the rewritten segments alone (6.4: minimal in-place edits bind code sources as they bind MDX), failing a product that reprints a code file through a printer on rename. The `id`-attribute rewrites fall under the same contract, quotes included: the fixture spells the renamed section's own `id` and one rewritten descendant's `id` single-quoted (2.7 admits `id='login'`, T2.7-3), and the expected bytes keep those quotes around the new values (6.4: rewrites are minimal in-place edits — the double-quoted fallback applies only where a form cannot be kept, and the fixture's new IDs contain no quote character) — a product re-emitting every rewritten `id` attribute double-quoted, or rewriting the whole attribute as `id="…"`, fails. +* **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4) — `refused-invalid-id`, `identities` exactly `["<file>#<new-id>"]` (14, T14-7) — among its arms, one per character 1.4's quote-and-escape bullet bars, each spelled between two letters in a one-segment `<new-id>` renaming a top-level section `a` of `specs/A.mdx`: `"`, `'`, `\`, and `&` (`rename specs/A.mdx a 'a"b'`, and `a'b`, `a\b`, and `a&b` likewise), and U+2028 and U+2029 (`a`, the code point, `b`, as T1.4-1 spells them) — each `<new-id>` a well-formed argument value (12.0: valid UTF-8, no U+FFFD), so each arm exits 1 with that finding alone, its `identities` carrying the character verbatim, never exit 2 — no spelling rule decides a `<new-id>`, unlike T12.0-10's `--to` and `--tag` arms (11.1, 11.3) — the workspace and journal byte-unchanged: a product whose new-ID check omits a character its source validation bars (T1.4-1) performs the rename, writing the character into `id` attributes and reference spellings, a workspace failing validation behind a reported success; a `<new-id>` containing U+FFFD or not valid UTF-8 never reaches this check — a malformed argument value, exit 2 (6.4, 12.0, T12.0-5); equal to old; colliding with an existing ID — one arm a single colliding bearer, one two: rename `a`→`b` in a file holding `a` with child `a.c` beside `b` with child `b.c`, so the new ID and an ID its prefix replacement produces (`b.c`) each collide (6.4: each ID the prefix replacement produces), the one `refused-id-collision` finding locating both bearers, `b` and `b.c` (14, T14-7: every colliding bearer — a product locating the first alone fails); violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). 6.4 states that every rewritten reference resolves by construction, so no refusal reason exists for it (14): the property is asserted on the success side of every successful rename — T6.4-1's `check` clean and every edge retargeted. +* **T6.4-4 Usage errors (exit 2).** Nonexistent `<file>` — one arm a path with no file on disk, one an `.mdx` file present on disk, holding a section spelling the old ID, but matched by no spec group (12.0: a file named in an argument exists as a member of the discovered set, T11.4-2's operand rule — a product probing the filesystem instead finds the file and proceeds); nonexistent old ID; a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); so is renaming an ID whose sole bearer spells it beneath an ancestor spelling no identity (an undefined ancestor chain — 6.4's second parenthesized mechanism, 11.2): the bearer establishes existence and the ancestor's finding refuses (exit 1, never exit 2); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. +* **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). Move arm (6.4: a rename or move alike can leave type-level references naming vacated identities): the same `typeof` reference to a node then moved by a section-form move into another file is likewise left byte-unchanged — naming the vacated identity — the workspace valid, and the move's applied mapping and journal entry the same as without the reference. * **T6.4-6 Valid-workspace precondition.** With a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything. * **T6.4-7 Finishing regeneration.** After a successful rename, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the rewritten sources; `check` immediately after reports no staleness (14.10). ### 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 `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. -* **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. -* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists; section form whose `<new-id>` 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 `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; 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 — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. - -### 6.6 Manual restructuring - -* **T6.6-1** Renaming an ID by editing the file directly: no journal entry; impact reports a deletion plus an addition (not continuity); dependents referencing the old identity fail validation (14.5) until rewritten. +* **T6.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 — the performed-operation document of 12.7, its `mapping` one entry per node of the moved file: the root (bare-path identities, `from` the old path, `to` the new) and every section (6.6). Byte contract of the specifier rewrite (6.5): the importing `.ts` file (T6.2-2's fixture — a marker and a `text(...)` call through one `.xspec` import) and the moved file are each byte-identical to their pre-move bytes outside the specifier literals' characters — the ranges 6.6 classes `import-specifier-rewrite`, read by running the preview on a copy — and within each such range the characters between the delimiters, the quote style kept, are exactly the canonical relative spelling of the designated source's post-operation path from the importing file's post-operation directory: the `..` ascents, then the descending segments, joined with `/`, no `.` segments, `./`-prefixed when there is no ascent, `.mdx` replaced by `.xspec` — byte-asserted over three geometries: a move into a subdirectory (the importer's `"./A.xspec"` becomes `"./sub/A.xspec"`; the moved file's own `'./C.xspec'` becomes `'../C.xspec'`, single quotes kept), a move out of one (an ascent lost), and a move across sibling directories (`"../x/A.xspec"`, ascents before descents). A product spelling a `.` segment, omitting the `./` prefix, leaving a stale `..`, switching quote style, or rewriting anything beyond the literals fails. Rewritten exactly when its characters would no longer designate the source (6.5): a relocation within the moved file's own directory (`specs/A.mdx` → `specs/A2.mdx`) leaves the moved file's own imports — a canonical `./C.xspec` and a non-canonical `.//C.xspec`, each still designating `C.mdx` from that directory (2.1) — byte-untouched and unreported, while the importer's `./A.xspec` is rewritten to `./A2.xspec`; the preview's `files` (T6.6-4) carries exactly the relocation entry and the importer's `import-specifier-rewrite`, and the real move's bytes agree. Rewritten whatever uses the declaration's bindings (6.5: the relocation rewrites the moved file's own import specifiers and the paths by which other files import its module, every specifier naming a spec module standing in an import declaration, 4): under `move specs/A.mdx specs/sub/A.mdx`, the spec glob `specs/**/*.mdx` reaching the destination, with `specs/C.mdx` discovered, `specs/A.mdx` = `import C from "./C.xspec"`, U+000A, U+000A, `<S id="x">x</S>`, U+000A, `C` referenced nowhere (2.1: valid, recording no edge); `specs/B.mdx` = `import A from "./A.xspec"`, U+000A, U+000A, `<S id="b">b</S>`, U+000A, `A` referenced nowhere likewise; and a code source `src/c.ts` = `import type T from "../specs/A.xspec"`, U+000A, `import { type text as t } from "../specs/A.xspec"`, U+000A, `import "../specs/A.xspec"`, U+000A, `let v: typeof T.x`, U+000A — a type-only import under each modifier form (T4-4), `T` spelled at type level alone and `t` nowhere, and a side-effect import (T4-2), none of the three recording an edge or an occurrence (4, 4.5) — each of the five declarations is rewritten under the byte contract above, its quote style kept: the moved file's to `"../C.xspec"`, `specs/B.mdx`'s to `"./sub/A.xspec"`, and each of `src/c.ts`'s three to `"../specs/sub/A.xspec"`, no other byte of the three files changing; the preview's `files` carries the relocation entry and exactly one `import-specifier-rewrite` per declaration, five in all, each spanning its specifier literal, and no other edit (T6.6-4(c)); and `build` and `check` exit 0 with no finding afterward. A product collecting the specifiers it rewrites from recorded edges, occurrences, or used bindings — as 6.5's reference rewrites follow occurrences — leaves these five designating no discovered source, the next `check` reporting 14.15 for each behind a reported success, and fails. +* **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 the insertion point is not at the start of a line — judged over the composed text with the insertion's own result absent, never over the pre-operation text alone (6.5) — staged over four geometries, one arm each: before a closing tag standing alone on its line (a line start: no terminator added) — with a sibling arm before a closing tag indented on its line, ` </S>` after two spaces, a flow-position tag still (14.20): the insertion point, preceded by the spaces, is no line start, so a terminator is added and the two spaces are left a line of their own before the moved text, kept in the compiled Markdown, no non-whitespace having stood on it (3), the composed form deriving (S-9) — a product reading T3-3's `alone on its line` as `at a line start` failing; at the end of a file ending with a terminator (a line start likewise); at the end of a file whose last line lacks a terminator (one added before the moved text); and before a closing tag sharing its line with preceding content — a text-position parent `foo <S id="p">bar</S> baz` receiving a single-line in-line section holding prose outside its tags and expression containers, `<S id="m">x</S>`, whose line is then a paragraph continuation at the destination (one holding nothing else — tags and expression containers alone — is a flow line there, 14.20, T3-3's constraint: T6.5-16(c)'s refused shape), giving `foo <S id="p">bar`, U+000A, the moved text, U+000A, `</S> baz` (one added) — the geometries where the pre-operation and composed judgements differ being T6.5-13's; target file created when absent (its fixed content T6.5-14's); no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7, T6.5-8), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. Terminator-kind arms (3, 6.5): every terminator a move inserts is U+000A, whatever terminators the file holds, while a line start and the drop rule are judged by 3's terminators — a CRLF one terminator, a lone CR one too — and every terminator the edits leave in place is kept byte-for-byte. Byte-exact, one arm per kind T, CRLF and lone CR, origin and target sharing it: the origin `specs/o.mdx` = `<S id="a">x</S>`, T, `<S id="m">`, T, `y`, T, `</S>`, T and the target `specs/t.mdx` = `<S id="p">`, T, `x`, T, `</S>`, T; `move specs/o.mdx#m specs/t.mdx#p.m` leaves the origin exactly `<S id="a">x</S>`, T — the line the deletion empties dropped with its whole terminator — and the target exactly `<S id="p">`, T, `x`, T, `<S id="p.m">`, T, `y`, T, `</S>`, U+000A, `</S>`, T — the moved text carrying its own terminators, none added before it, the insertion point following a T being a line start, and U+000A after it; and the end-of-file geometry likewise, `move specs/o.mdx#m specs/u.mdx#n` into `specs/u.mdx` = `<S id="p">x</S>`, T, leaving it exactly `<S id="p">x</S>`, T, `<S id="n">`, T, `y`, T, `</S>`, U+000A, the origin as before. These fail a product matching the file's terminator style, which writes T after the moved text, and one whose edit layer splits lines on U+000A alone, which in the lone-CR arms leaves the emptied origin line behind and adds a U+000A before the moved text; every file before and after derives (S-9). The added-declaration and import-removal counterparts are T6.5-8's and T6.5-7's terminator-kind recurrences. +* **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers (the code-file freshness observation: T6.5-9; the moved text's own imported-form references to a third module, whose bindings travel with it: T6.5-10) and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. Third-file arm (6.5: all references across the workspace): a spec source that is neither origin nor target, importing the origin module and referencing a moved node through it (a `d` chain), has that reference rewritten to the target module under an import of it added there (bytes per T6.5-8's discipline), the origin import removed when the moved reference was its binding's last and kept when another reference through it remains (one arm each); `query edges` reports the third file's edge under the moved node's new identity and `check` is clean. +* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a directory, by a symbolic link, and by a broken symbolic link (target absent), one arm each, each refused `refused-destination-exists` (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the directory arm discriminates a product probing for a file alone; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` with `identities` exactly `["<target-file>#<new-id>"]` — `["b.mdx#"]` for the empty arm (14, T14-7) — 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; and, one arm per character 1.4's quote-and-escape bullet bars, a one-segment `<new-id>` carrying it between two letters — `"`, `'`, `\`, and `&` (destination operand `b.mdx#a"b`, and `a'b`, `a\b`, and `a&b` likewise), and U+2028 and U+2029 (spelled as in T1.4-1) — each refused alike, `identities` exactly `["b.mdx#<new-id>"]` with the character verbatim, exit 1 and never exit 2, the operand a well-formed argument value (12.0) that no spelling rule decides (T6.4-3's discriminator, met in the section form); section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; containing a character 7.1 bars from spec-source paths — `"`, `'`, `\`, U+000A, U+000D, U+2028, or U+2029 — one file-form arm and one section-form arm creating the target per character (`move specs/a.mdx "specs/a'b.mdx"`; `move specs/a.mdx#x "specs/a'b.mdx#x"`, nothing at that path), and for `'` one arm of each form placing it in a directory component instead (`move specs/a.mdx "specs/it's/b.mdx"`; `move specs/a.mdx#x "specs/it's/b.mdx#x"`, `specs/it's` absent — 7.1 bars the character anywhere in the path, so a validator checking the file name alone fails; the spec glob, `specs/**/*.mdx`, reaches every destination these arms spell, since under a glob reaching the top level alone `specs/it's/b.mdx` would belong to no spec group, refused under the same code concerning the same path, and that validator would pass), each refused `refused-invalid-destination` concerning the destination path as spelled (6.5, 14.19), never a usage error — every such spelling is a well-formed argument value (12.0) — nothing modified; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The symbolic-link arms of the same clause (6.5: a component occupied by a symbolic link, whatever it targets — discovery never traverses one, 7, and writes never traverse or replace one, 13.4): a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, where `specs/sub` is a symbolic link to a real, empty directory — each form staged with the link targeting a directory inside the workspace and, separately, one outside the workspace root, under the spec glob `specs/**/*.mdx`, which reaches both destinations (under a glob reaching the top level alone, each would belong to no spec group, refused under the same code concerning the same path whatever occupies `specs/sub`) — are refused `refused-invalid-destination`, never 14.22 (14, T14-7), exit 1, nothing modified: the link itself and its target directory are byte-identical afterward, no file written through the link inside or outside the workspace; and a sibling of the derived-path arm stages `<outDir>/new` as such a link instead of a plain file, refused identically. These are the discriminating cases for a product vetting components through link-following `stat`, which sees a directory at the link, proceeds, and writes the moved file — and its regenerated derived files — through the link, possibly outside the workspace; T13.4-6's symlink arm covers only 14.22 on `build` writes, never this refusal. The link lies under no current source's write path and is never a source (7), so the workspace passes `build`'s validations. 6.5 states, and T6.5-5 confirms as exit-2 outcomes, that a destination containing `#` or U+FFFD or not valid UTF-8 is unspellable as an argument — a usage error, never a refusal: a destination path exists only as an operand spelling, and every spelling that would present one is a malformed value before any refusal is evaluated — a non-UTF-8 argument value and a U+FFFD-bearing one each exit 2 (12.0, T12.0-5), so a valid operand never denotes such a path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. +* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file — in each form, both of T6.4-4's spellings: absent on disk, and present but undiscovered — or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) and a destination operand containing U+FFFD (`specs/B�.mdx`; `specs/B.mdx#x�`, either leg) are malformed argument values, exit 2, the error document's `code` `null` — never `refused-invalid-destination` or `refused-invalid-id` (12.0, 6.5, T12.0-5). These exit-2 invocations are the stagings T6.5-4's unspellable-destination 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 and rewrites no `id` attribute and no local-form reference inside the moved text (6.5: a rewrite is made and reported exactly when it changes the construct's characters) — with the moved subtree holding a descendant `x.c` and a local reference `d={"x.c"}`, the preview reports no `id-rewrite` and no `reference-rewrite` inside the origin deletion, and the moved text lands in `b.mdx` byte-identical (the construct's own characters), a product rewriting attributes or references to their unchanged spellings, or reporting such no-op edits, failing; the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `<new-id>` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `<new-id>` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). + +* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line — `import C from "./c.xspec"; import T2 from "./target.xspec"`, the `;` between them the only spelling under which two declarations on one line derive (14.20), among the own characters of the declaration it terminates (T3-7); every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant and spells that descendant's `id` attribute single-quoted (2.7); no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line — the line left exactly `import C from "./c.xspec"; `, the `;` and the space that preceded the deleted declaration remaining, no whitespace trimmed; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference and the single-quoted `id` attribute are each re-identified by prefix replacement with their single-quote spellings preserved (6.4: minimal in-place edits — binding the `id`-attribute rewrite as they bind references, T6.4-2); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. The code-source counterpart, fully composed (no import added, so no latitude): a `.ts` file importing the origin module, the target module, and a retained third module, its import declarations heading it before every other statement so that the target binding is timely for the moved markers (6.5: declared before the origin's, or after it with only import declarations between) — the third module's declaration and the origin's sharing one line in a second variant, the origin's following it, a `;` between them as TypeScript's grammar requires alike (14.20), the line then left the retained declaration, its `;`, and the space — whose only references through the origin binding are markers on nodes of the moved subtree, beside a marker through the target binding and one through the third; after the section move, the origin-module import — its binding left without references — is removed with 6.5's exact extent (the own-line declaration's line dropped with its terminator; the shared-line declaration's own characters alone deleted, the retained declaration kept byte-for-byte on its kept line), the moved markers are rewritten through the existing target binding (6.5: an import is added only where the file lacks the binding), and the file is asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3 — the import-removal rule binds code sources as it binds MDX, and a product that reprints a code file on removal fails here while passing every resolution-only assertion (T6.5-1, T6.2-2). The addition side in code is T6.5-8's TS arm. Each fixture, the code-source variants included, recurs with every line terminator of its staged files CRLF, and again with every one a lone CR (3: each one terminator), the expected bytes composed by the same rules — the own-line declaration's line dropped with its whole terminator, both bytes of a CRLF; every other staged terminator kept byte-for-byte; every terminator the move inserts U+000A — failing a product whose edit layer splits lines on U+000A alone, which leaves the lone-CR files' emptied lines behind, and one matching the file's terminator style where the moved text lands. + +* **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, judged over the composed text with the insertion's own result absent (an addition at the offset where an import removal's range ends reads what the removal leaves), never over the pre-operation text alone — and the declaration's exact spelling (6.5), asserted with the identifier choice alone left free (6.5's latitude, exercised deterministically) and the offset confined by 6.5's preference: value-blind in the fresh identifier, byte-exact in every other character (the ethos T6.5-7 applies to removals, sharpened by 6.5's spelling rule). Three section-move arms over `specs/origin.mdx`, `specs/target.mdx`, and a code file `src/c.ts`, 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 choice among the receiving file's line-start admissible offsets, of which each arm's file holds at least one (the offsets themselves, the preference among them, and the forced mid-line form: T6.5-13): a TS arm — `src/c.ts` = `import O from "../specs/origin.xspec"`, U+000A, then a function `f` holding the markers `O.x`, on a moved node, and `O.w`, on an unmoved one, so the rewrite needs a target-module binding the file lacks while the origin import keeps its remaining reference and stays, the start of line 2 its one line-start admissible offset (6.5: in a TypeScript source an added declaration follows the end of a top-level statement with nothing but whitespace between, so offset 0 is never admissible, and one after `f` would be untimely for the moved marker, a non-import statement standing between it and `O`'s declaration; T6.5-23 stages the other TypeScript conditions) — an MDX origin arm — the origin file, already holding a retained third-module import (grammar-permitted offsets exist, and freshness is live against its binding), keeps a local string reference to a moved descendant, whose conversion to imported form (6.4's pinned spellings) makes the origin file itself gain the target module's import — and an MDX target arm, the third conversion direction, which T6.5-7 (imported→local) and the origin arm (origin-side local→imported) leave byte-unasserted where the moved text lands: the moved subtree holds a local string reference (a `d` entry) to an origin node outside the subtree, one of whose ID segments is not identifier-valid, and the target file — an existing discovered source lacking the origin module's import — gains that import, the reference converting to imported form through the fresh binding in 6.4's pinned spellings (dot access, double-quoted computed access for the non-identifier segment), the moved text otherwise byte-identical and the origin file losing the section and gaining no import; so the addition-side line discipline is asserted in the receiving target file, where T6.6-4(b) asserts only the preview's offset. In each arm the harness isolates the single added byte run by diff against the composed bytes and asserts that it is exactly the declaration followed by U+000A, at an insertion offset lying at the start of a line judged over the composed text (6.5: a line-start admissible offset, which each arm's receiving file holds, is taken over any other, so the mid-line form — U+000A, the declaration, U+000A — is never conforming here; T6.5-13 forces it) — in the TS arm exactly the start of line 2 — and that the declaration is byte-exactly 6.5's spelling with the fresh identifier substituted: `import <X> from "../specs/target.xspec"` in the TS arm (the canonical ascent spelling from `src/`), `import <X> from "./target.xspec"` in the MDX origin arm and `import <X> from "./origin.xspec"` in the MDX target arm (the files sharing `specs/`) — single spaces as shown, no statement terminator, the specifier double-quoted in the canonical relative spelling a specifier rewrite produces (T6.5-1), from the importing file's directory — no other byte inserted, the fresh identifier's value unpinned and read from the declaration to check that every rewritten reference is rooted at it. This fails a product that joins the added declaration to a neighbor with `;` — still parsing, resolving, and byte-deterministic in either grammar, the shared line still dropping whole from Markdown output in the MDX arms (3), hence passing every other test — inserts at a mid-line offset while a line-start one exists, adds a spurious blank line, writes any terminator but U+000A, or spells the declaration otherwise: single quotes, a `;`, other spacing, a non-canonical specifier (`./sub/../target.xspec`, no `./` prefix, a `.mdx` extension), or a `.` segment. Each arm recurs with every line terminator of its staged files CRLF, and again with every one a lone CR: the added byte run is still exactly the declaration followed by U+000A, at a line start judged by 3's terminators — in the TS arm the start of line 2, after the CRLF or lone CR ending line 1 — and every staged terminator is kept byte-for-byte, failing a product that matches the file's terminator style, writing CRLF or CR after the declaration, and one judging line starts by U+000A alone, for which no lone CR ends a line. +* **T6.5-9 Fresh identifiers.** The freshness clauses of 6.5 — an added import's identifiers are bound by no declaration already in the file, in any scope and at value or type level alike, and equal no name the file already references (2.1, 4; the barred-name lists and the reference clause are T6.5-22's) — span, in a code file, local declarations as well as imports, and a breach is observable to xspec only in part: an added import sharing its identifier with a value-level declaration of the module scope — a `const`, a `function`, a `class`, a non-spec import — is the collision of 14.15 (4.5, T4.5-8), `build` and `check` reporting it beside 14.7 for the rewritten markers, which the colliding identifier roots at nothing; one sharing it with a type-level declaration, a `type` alias, collides with nothing to xspec (4.5) — the import still roots the markers, no `build`/`check` finding, no staleness — and even the consumer's compile reports it only where the imported default export has a type meaning, which the generated module decides: TypeScript's import-conflicts-with-local-declaration check compares meanings, so a class default export conflicts with the alias and a value-only one does not. In MDX every binding is an import, so every collision is 14.15's and T6.5-3's post-move `check` suffices there; in a code file the compile reaches the value-level lures, and T6.5-22(a)'s name check — an added identifier bound by no declaration of the pre-operation file, at value or type level — reaches the `type` alias. A product checking freshness against import bindings alone therefore passes T6.5-8's TS arm, whose receiving file leaves every plausible identifier free. This test stages the collision: T6.5-8's TS arm re-staged with a receiving code file that additionally declares, at module scope, bindings pre-empting the identifiers a product would plausibly derive — a local `const`, a `function`, a `class`, and a `type` alias, and a non-spec import binding — spelled from the target file's basename (as written, lower- and upper-cased, `Spec`- and `SPEC`-suffixed) and from the origin binding's name with a digit and with an underscore appended, the file compiling clean before the move under standard tooling, its import declarations — the origin's and the non-spec lures' — heading it before every other statement, so that the added line stands at the start of the line directly after one of them (6.5, T6.5-8's TS arm), the diff-isolated run asserted as T6.5-8 asserts it. After the section move, asserted through the standard-tooling channel of H-2: the rewritten file compiles with no diagnostics — a collision with a value-level lure producing TypeScript's import-conflicts-with-local-declaration error (the `const`, `function`, and `class`) or its duplicate-identifier error (the non-spec import) — so the added declaration's identifier equals none of the value-level pre-empted names, the `type` alias's name left to T6.5-22(a); `query edges` reports the moved markers' `references` edges from the file to the moved nodes' new identities and the unmoved marker's edge through the retained origin binding; and `check` is clean (T6.5-3). The pre-empted set is a lure, not a bound: the identifier stays the product's choice (6.5's latitude), and compile-cleanliness is the assertion whatever the choice. Spec-source arm — the freshness rule's other clauses (6.5: an added import's identifiers are distinct from the others added there and, in a spec source, none of the compiler-provided names `S`, `Spec`, `text`, 2.1): a spec target lacking imports of two third modules, `specs/S.mdx` and `specs/text.mdx`, both referenced by the moved text through the origin's bindings (T6.5-10's shape), so that a product deriving identifiers from basenames would bind `S` and `text` (14.15) and one deriving them from a fixed stem would bind one identifier twice (14.15: two imports binding one identifier): after the move `check` is clean, `view` lists the two added declarations under `imports` with distinct `name`s and targets `specs/S.mdx` and `specs/text.mdx`, each moved reference is rooted at the binding of its own module (`query edges` under the new identities), and the declarations stand contiguous in one ESM block (T6.5-13(g)). +* **T6.5-10 Third-module bindings carried with moved text.** A reference inside the moved text need not target a moved node to need rewriting: an imported-form reference to a node of a spec module `X` that is neither origin nor target — `d={X.foo}`, `{text(X.bar)}` — is bound by the origin file's import of `X`, which the moved text leaves behind, so in the target file it needs a binding of `X`'s module (6.5: an import is added when a rewritten reference needs a module binding its file lacks — and only then, the reading T6.5-7's TS arm pins for markers rewritten through an existing binding). Read from the target file, 6.5 names what such a spelling would otherwise do — rooted at a binding of the origin file that the target file lacks, it would name nothing; at one the target file binds to another module, another node — and where it is rooted instead: a binding the target file holds of its target's module, or one an addition gives it; the arms stage each situation: the target lacking the identifier and every binding of the module, an addition rooting the spellings (a); lacking the identifier but holding a binding of the module, which roots them, nothing added (b); and binding the identifier itself to another module, an addition rooting them under a fresh identifier (c). T6.5-7 keeps its third-module reference outside the moved subtree and T6.5-8's conversions are local↔imported alone, so no fixture of theirs meets this shape. Three section-move arms over three spec sources — origin `a.mdx`, target `b.mdx`, and `x.mdx`, all in one directory, (c) adding a fourth, `z.mdx` — where the moved subtree holds a `d` reference and a `{text(...)}` embedding through the origin's `X` binding, one of them through a double-quoted computed segment (`X["bar-baz"]`), and no reference to a moved node lies outside the subtree: (a) value-blind, T6.5-8's discipline — the target file, an existing discovered source, holds no import of `x.mdx`'s module, and the origin's only references through `X` lie inside the moved subtree: after the move the target's bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier — appearing in the added declaration and at each moved reference's root, `X` itself admissible there, being fresh in that file — and the choice among the target's line-start admissible offsets (T6.5-8); the single added byte run isolated by diff is exactly `import <X> from "./x.xspec"` followed by U+000A at a line-start offset, under 6.5's line and spelling discipline (T6.5-8) — the fresh identifier its only unpinned character run — each moved reference is rooted at the identifier that declaration binds with its access form kept (6.4), the moved text otherwise byte-identical; the origin file loses the section and, its `X` binding left without references, its own-line `X` declaration with the line's terminator (6.5's exact extent, T6.5-7), and is otherwise byte-identical; and the preview's rewrite reporting follows the identifier the real run chose (6.5: a rewrite is made, and reported, exactly when it changes the construct's characters, read with the chosen binding in place, an added declaration's included): its `reference-rewrite` edits inside the origin deletion's range (T6.6-4(b)) are exactly the two moved spellings' occurrence spans when that identifier is not `X` and none when it is `X` — the spellings then already resolving in the form they are rooted at, neither rewritten nor reported — the target's `import-addition` reported either way (the refusal-side reading of the rule T14-7's `refused-cycle` arm's); (b) byte-composable, no latitude — the target file already imports `x.mdx`'s module under another binding `Z`, referenced by a section of its own, and the origin keeps a reference through `X` outside the moved subtree: no import is added (the file lacks no binding of the module), each moved reference is re-rooted to `Z` with quote style and access form kept — `X.foo` → `Z.foo`, `X["bar-baz"]` → `Z["bar-baz"]` (6.4: minimal in-place edits) — the origin's `X` declaration stays byte-for-byte, and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3; (c) the identifier bound to another module — value-blind, (a)'s discipline: the target file holds `import X from "./z.xspec"`, `z.mdx` a fourth source spelling the nodes `q` and `foo`, and uses that binding in a section of its own (`d={X.q}`) while holding no binding of `x.mdx`'s module, the origin staged as (a); `z.mdx`'s `foo` is the lure for a product that keeps a moved spelling wherever the target binds its root identifier, whatever module the binding designates — `X.foo` left untouched then names `z.mdx`'s `foo`, a silent retarget behind a reported success, `check` clean. After the move the target's bytes are composable up to (a)'s two unknowns: the single added byte run isolated by diff is exactly `import <F> from "./x.xspec"` followed by U+000A at a line-start admissible offset, `<F>` read from the declaration and asserted distinct from `X` (6.5, 2.1: fresh, bound by no declaration already in the file; T6.5-22), each moved spelling re-rooted to `<F>` with its access form kept — `<F>.foo`, `<F>["bar-baz"]` — the target's own `X.q` and its `z` declaration byte-untouched, the origin as in (a), its `X` declaration removed with its line; the preview reports, inside the origin deletion's range, one `reference-rewrite` per moved spelling — their characters necessarily change — and, under the target, the one `import-addition`; and a sibling in which the target additionally binds `x.mdx`'s module as `Z`, used by a section of its own ((b)'s shape, the origin staged as (b)), adds nothing and re-roots each moved spelling to `Z`, `X.q` and both declarations byte-untouched — (b)'s whole-file contract. In every arm `query edges` reports the moved nodes' `depends` and `embeds` edges under their new identities to `x.mdx`'s unchanged nodes — in (c) to `specs/x.mdx#foo` and `specs/x.mdx#bar-baz` and to no node of `z.mdx`, the target's own section's edge to `specs/z.mdx#q` standing as before — and `build` and `check` are clean (6.5: a successful move's finishing regeneration runs on a valid workspace). A product converting only between local and imported forms leaves `X.foo` unbound in the target — an invalid workspace behind a reported success — and fails (a) and (b); one adding a second import of a module the target already binds fails (b)'s whole-file contract and (c)'s sibling's; one rooting by identifier name rather than by module passes (a), where `X` is free, and (b), where it is absent, and fails (c)'s byte and edge contracts. +* **T6.5-11 TypeScript `text(...)` calls across the move.** A `text(...)` call whose target the section form carries into another file is rewritten whole — callee through the target module's `text` binding, argument through its default binding — over its occurrence's span, never becoming the cross-module call of 14.11, and imports are added binding exactly the lacked bindings and removed exactly when an occurrence used a binding of theirs before and none after (6.5). Arms over `specs/origin.mdx` holding `x` (moved to `specs/target.mdx#y`) and a code file `src/c.ts`: (a) `src/c.ts` = `import K from "../specs/k.xspec"`, U+000A, `import O, { text as t } from "../specs/origin.xspec"`, U+000A, then a function `f` holding the marker `K.a` and the call `t(O.x)` — `K`, a retained third module's binding, heading the file so that the origin declaration's removal starts after a statement's end — after the move the file compiles clean under standard tooling, `build` and `check` are clean (no 14.11, no 14.7), `query edges` reports one `embeds` edge from `src/c.ts#f` to `specs/target.mdx#y`, and the bytes are asserted under T6.5-8's discipline with the call's span as a second isolated run: the single added byte run is exactly `import <X>, { text as <Y> } from "../specs/target.xspec"` — or `import <X>, { text } from "../specs/target.xspec"` where the fresh identifier chosen for the `text` binding is `text` itself, which the file leaves free (6.5's two spellings, as (b) admits them) — followed by U+000A where the origin declaration's line stood, directly after `K`'s line: the one composed position the removal's start and end make, each following a statement's end and timely, every later line start inside the statement `f`, should `f` span lines — no top-level position (6.5, T6.5-23(n)) — or untimely, `f` standing between it and `O`'s declaration (6.5's spelling, line, and placement discipline, T6.5-8, T6.5-23: the fresh identifiers its only unpinned runs — a product emitting two declarations, `{text as Y}` without the single spaces, single quotes, or a `;` fails), the call's occurrence span (callee through closing parenthesis, 5.7) is replaced by a call whose callee is the added `text` binding — `<Y>`, or `text` — and whose argument is `<default>.y`, and the origin declaration — both its bindings having lost their last use — is removed with its line (6.5, T6.5-7), no other byte changed; (b) the file already holds `import T from "../specs/target.xspec"` (its default binding used by a marker `T.z`) and lacks its `text` — the file = `import T …`, U+000A, `import O, { text as t } from "../specs/origin.xspec"`, U+000A, then the marker and `f`, both imports heading it, `T`'s first, so that `T` is timely for the moved argument (6.5: its declaration precedes `O`'s) and the removal starts after a statement's end: the addition binds only `text` — exactly `import { text as <Y> } from "../specs/target.xspec"`, or `import { text } from "../specs/target.xspec"` where the fresh identifier is `text` itself (6.5), followed by U+000A where the origin declaration's line stood, as in (a), no second default binding — the argument rewritten through the existing `T` (`<fresh>(T.y)`), the origin import removed; (c) the origin import kept where another occurrence still uses a binding of it: with a second call `t(O.w)` on an unmoved node, the origin declaration stays byte-for-byte and the moved call alone is rewritten; (d) a type-level spelling keeps no import (6.5, 4.5): the file's only value-level use of `O` is the moved call while `type N = typeof O.x` remains — after the move the origin import is removed, `build` and `check` are clean, and the consumer type error the vanished binding causes lies outside xspec's validations (the standard-tooling compile fails, the discriminating expectation); (e) the file holds the target module's `text` binding and lacks its default — `src/c.ts` = `import { text as tt } from "../specs/target.xspec"`, U+000A, `import O, { text as t } from "../specs/origin.xspec"`, U+000A, then a function `f` holding the call `t(O.x)`, the only use of `O` and of `t`, `tt` unused before the move (2.1): `tt` is value-level, unshadowed at the call, and timely for its callee, its declaration preceding `t`'s (6.5), so the callee is re-rooted at `tt` and the addition binds only the default — the single added byte run is exactly `import <X> from "../specs/target.xspec"` followed by U+000A where the origin declaration's line stood, directly after `tt`'s line (the one composed position, as in (a)), the call's occurrence span becomes exactly `tt(<X>.y)`, the origin declaration is removed with its line, and `tt`'s declaration stands byte-for-byte, no other byte changed (T6.5-8's discipline); a product judging the `text` binding lacked whenever the default is — adding `import <X>, { text as <Y> } …` and writing `<Y>(<X>.y)`, or adding `text` unused beside a callee rooted at `tt` — resolves, compiles, and records the same edge, so the byte contract alone fails it; (f) the timeliness side, mirroring T6.5-23(k): (a)'s file with `import { text as tt } from "../specs/target.xspec"`, U+000A, appended after `f` — `tt` untimely for the callee, `f` standing between `t`'s declaration and its own (6.5) — so one declaration binds both, exactly as in (a): `import <X>, { text as <Y> } from "../specs/target.xspec"`, or `import <X>, { text } from "../specs/target.xspec"` where the fresh identifier is `text` itself, `<Y>` never `tt`, followed by U+000A where the origin declaration's line stood, every later line start untimely, the call becoming `<Y>(<X>.y)` and `tt`'s declaration standing byte-for-byte; a product checking a held `text` binding's timeliness only when it would otherwise add nothing writes `tt(<X>.y)` beside `import <X> …` and fails. In (e) and (f), after the move `build` and `check` are clean, the file compiles clean (H-2), `query edges` reports one `embeds` edge from `src/c.ts#f` to `specs/target.mdx#y`, and the `--preview` reports, for `src/c.ts`, one `reference-rewrite` spanning the call's occurrence, one `import-addition` at the origin declaration's removal's start or at its end, and one `import-removal` spanning the origin declaration with its adjunct drop. Preview parity (6.6, 12.7): the `--preview` of (a) reports, for `src/c.ts`, one `reference-rewrite` spanning the call's occurrence, one `import-addition` at the origin declaration's removal's start or at its end — one composed position, the choice 6.5's latitude, the real operation's bytes the same either way (T6.6-4(b)) — and one `import-removal` spanning the origin declaration with its adjunct drop (T6.6-4). +* **T6.5-12 The target file's own references to moved nodes.** The fourth conversion direction 6.5 states: the target file's own reference, through a binding of the origin module, to a node of the moved subtree is rooted in local form after the move — T6.5-7 pins moved-text imported→local, T6.5-8 the two local→imported directions, and T6.5-3's third-file arm a file that is neither origin nor target, so no byte-asserted arm of theirs covers it. The target `specs/b.mdx` imports `specs/a.mdx` as `A` and spells, in a section of its own, `d={A.x}` and `{text(A.x)}`; after `move specs/a.mdx#x specs/b.mdx#y` they read `d={"y"}` and `{text("y")}` (6.4's pinned spellings for converted references: double-quoted string literals), the `A` import removed with its line — 6.5's exact extent, T6.5-7 — when those were its binding's last uses (one arm), and kept byte-for-byte when another use remains, a `d={A.w}` to an unmoved node (the other arm); the target file is otherwise byte-identical apart from the moved text's landing (T6.5-2) and its own rewrites (T6.5-7), asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3; `query edges` reports the section's `depends` and `embeds` edges to `specs/b.mdx#y`; `build` and `check` are clean. A product leaving `A.x` naming a vacated identity — an unresolved reference behind a reported success — or rooting the reference at a binding of the target file's own module fails. +* **T6.5-13 Admissible offsets, the line-start preference, and composition in pre-operation coordinates.** 6.5 fixes where an added declaration may stand — an admissible offset, one lying inside none of the file's statements before the edit (in a spec source, its ESM blocks' declarations: T6.5-23(g)), at which the file as every edit leaves it is well-formed with the added line an import declaration of an ESM block standing inside no section construct, one the line begins or one it joins whose other lines were a block's before the edit (a TypeScript source's further conditions — the directive prologue, a preceding statement's end, timeliness — T6.5-23's) — and which is taken: an admissible offset at the start of a line, judged over the composed text, over any other; and it fixes how edits compose in pre-operation coordinates — insertions sharing an offset standing in the order the target insertion, the appended closing tag where one applies, then the added declarations, contiguous. Byte-asserted arms against expected files composed from the rules of 6.4/6.5 and 3, value-blind in the fresh identifiers alone (read from the added declarations, whose other characters are T6.5-8's), the moved text of every arm whose receiving file needs a declaration carrying, beside prose, a `{text(X.a)}` embedding through the origin's binding `X` of a third module `specs/x.mdx` the receiving file lacks, so that exactly `import <X> from "./x.xspec"` is needed there (T6.5-10's shape) — (i) and (k), whose asserted declarations are the origin's and a third file's, stage a moved text local to its subtree; `build` and `check` clean after each move, the receiving root's own text and ownHash compared through `query node` before and after, and the real operation's bytes agreeing with the preview's offsets (T6.6-4(b)): (a) the preference — a target `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A, moved into `p.n`, holds two admissible offsets, the file's end after its final terminator (a line start) and the end of the `</S>` line before that terminator (mid-line, which would leave a kept empty line): the result is exactly the file with the moved text and its terminator inserted before `</S>` and the declaration plus U+000A appended after the final terminator, nothing else, the preview's `import-addition` at the file's byte length, and the root's own text and ownHash unchanged (6.2: an addition at a line's start changes no node's own content, the added line dropped whole) — a product inserting at the mid-line offset, or splitting a line anywhere, fails; a sibling arm whose only line-start admissible offset is the start of an empty line after a non-paragraph line — `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A, U+000A, `trailing` with no final terminator, where an added line at the file's end would follow a paragraph line as paragraph text and one at the start of the `trailing` line would absorb it — places the declaration at that empty line's start, the empty line kept after it, the root unchanged likewise; (b) 6.5's worked self-closing case — a target file holding `<S id="p" />` alone, its line lacking a terminator, moved into `p.n`: an addition at offset 0 would absorb the tag's line into its ESM block, so the tag's end is the only admissible offset, and the result is exactly `<S id="p">`, U+000A, the moved text, U+000A, `</S>`, U+000A, the declaration, U+000A, the root keeping its own content; the preview reports `target-parent-rewrite` spanning the tag and `target-insertion` and `import-addition` both zero-length at the tag's end (the 12.7 tie-break observed, T6.6-4); with a final terminator after the tag the same bytes result, the addition reported at the file's end — a line start, taken over the tag's end; (c) the forced mid-line case — a target `<S id="p">`, U+000A, `x`, U+000A, `</S>` with no final terminator, moved into `p.n`: offset 0 absorbs the tag line and every other line start lies inside the section, so the file's end is the only admissible offset, and the result is exactly `<S id="p">`, U+000A, `x`, U+000A, the moved text, U+000A, `</S>`, U+000A, the declaration, U+000A — the added terminator ending the `</S>` line, which drops as it did before, having held non-whitespace only within removed constructs, so the root keeps its own content (6.2) — the preview's `import-addition` at the file's byte length; (d) a top-level `new-id` at the end of a file whose last line lacks a terminator — `para` alone, unterminated — the moved text a flow-form section, its closing tag alone on its last line: exactly `para`, U+000A, the moved text, U+000A, the declaration, U+000A, no empty line between (6.5: the moved text's terminator ends the line before the declaration, whose offset, the file's end, is the target insertion's; standing after the closing tag's line, no paragraph line, the addition is admissible where the reverse order would make it paragraph text), the preview's `target-insertion` and `import-addition` both zero-length at the file's byte length (T6.6-4's cross-class coincidence at the end of a paragraph-ended file, its unterminated variant); with a final terminator after `para`, the same coincidence and the same bytes — the target insertion at a line start, so no terminator added before the moved text, and the declaration's only line-start admissible offset the file's end again: offset 0 would absorb `para` into the block, and the end of the `para` line before its terminator would leave the declaration paragraph text — `target-insertion` and `import-addition` both zero-length at the file's byte length (the same coincidence's terminated variant); the root, the target parent of a top-level `new-id`, `changed` in both variants (6.2: a parent gaining a child reference), its own text going from `para` to `para`, U+000A in the unterminated one — the added terminator ends the previously unterminated `para` line, kept with its content (3) — and staying `para`, U+000A in the terminated one, its ownHash changing in both (5.5: the gained reference enters at its position), the qualifier's refusing side — the same target receiving an in-line moved text, whose own line is a paragraph line — T6.5-16(g)'s top-level twin; (e) the target insertion judged over the composed text — a same-file move in `foo <S id="p">`, U+000A, `<S id="p.m">x</S></S> baz`, U+000A, `move specs/a.mdx#p.m specs/a.mdx#p.n`, no declaration needed: the origin deletion's range ends exactly at the insertion point, which line 1's terminator precedes in the composed text, so no terminator is added, and the result is exactly `foo <S id="p">`, U+000A, `<S id="p.n">x</S>`, U+000A, `</S> baz`, U+000A — a product judging the pre-operation text (the point preceded by `>`) emits an empty line before the moved text, ending the paragraph with `p` unclosed, and fails; the root keeps its own text and ownHash — `foo ` and ` baz`, U+000A its runs on kept lines at both sides, `p` the one parent between them; (f) an insertion at the end of a file whose unterminated last line the origin deletion drops — `<S id="a">x</S>`, U+000A, `<S id="m">`, U+000A, `y`, U+000A, `</S>` with no final terminator, `move specs/a.mdx#m specs/a.mdx#n`, a same-file top-level move of the file's last section: what the deletion leaves before the file's end is line 1's terminator, so none is added, and the result is exactly `<S id="a">x</S>`, U+000A, `<S id="n">`, U+000A, `y`, U+000A, `</S>`, U+000A; the root, origin and target parent at once, keeps its own text — U+000A at both sides: line 1's terminator after `a`'s excised contribution on that kept line, the construct's own lines dropped (3) — and its ownHash, the re-inserted child entering by its canonical identity (5.4): T6.2-4's pure-in-effect final-position move, no hash changing; (g) two declarations added to one spec file — the moved text through two third-module bindings the target lacks, into (b)'s self-closing target: after the appended closing tag, U+000A, then the two declarations on contiguous lines, each followed by U+000A alone, one ESM block with no empty line between them, in an order the product fixes (read from the result, byte-identical across repeated runs, H-6), the first preceded by the terminator the tag's `>` requires — and the preview's `edits` for the target (12.7) hold, beside the `target-parent-rewrite` spanning the tag, exactly two `import-addition` entries, one per added declaration (6.6: every edit the operation would make; 6.5: one added declaration per lacked module), both zero-length at the tag's end and, by the class-name tie-break, ordered before the `target-insertion` there (T6.6-4's same-class coincidence), the real bytes agreeing — a product reporting one entry for the contiguous block fails; (h) the mid-line addition off the file's end — 6.2's `changed` root: a target `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A, `trailing` with no final terminator, moved into `p.n` (the moved text a clean-boundary flow-form section, T6.2-3's): offset 0 would absorb the tag's line into the block, every line start from `x` through `</S>` lies inside `p`, the start of the `trailing` line would absorb that line, and the file's end follows a paragraph line, so the end of the `</S>` line before its terminator is the only admissible offset — mid-line, the placement 6.5 admits only in a file holding no line-start one — and the result is exactly `<S id="p">`, U+000A, `x`, U+000A, the moved text, U+000A, `</S>`, U+000A, the declaration, U+000A, U+000A, `trailing`: the added terminator ends the `</S>` line, which drops as it did before, the declaration's line drops whole, and the `</S>` line's original terminator is left an empty line, kept (3) — the remainder line kept where the whole line was dropped (6.2); the preview's `import-addition` zero-length at that offset, the real operation agreeing; the root's own text changing from `trailing` to U+000A, `trailing` in its post-child run (`query node`), its ownHash with it; and `impact --base` against a commit made immediately before the move reporting 6.2's enumeration exactly — the target root `changed`, beside `p` and the origin parent `changed` with their ordinary cascades (T6.2-3), a dependent of the target root in another file (`d={B}`, T2.2-2) `upstream-changed` with the root among the originating nodes it is attributed to (5.6), the moved node carrying no category (its runs, its embedding's canonical identity, and its metadataHash unchanged, 5.5), and no other node `changed` — failing a product that treats a receiving root as untouched by any addition, or that judges the addition's line start over the pre-operation text; (i) the third line-start kind 6.2 names — an offset where a removal or the origin deletion drops the file's unterminated last line: origin `<S id="a" d={"m"} />`, U+000A, `<S id="m">`, U+000A, `y`, U+000A, `</S>` with no final terminator, `move specs/a.mdx#m specs/b.mdx#m` into an existing target needing no declaration (the moved text local to its subtree), the declaration this arm asserts being the origin's own — `d={"m"}` converting to imported form, `d={<T>.m}`, through the target module's declaration the origin lacks (T6.5-8's origin direction): the deletion's range runs from the start of line 2 to the file's end, leaving `<S id="a" d={<T>.m} />`, U+000A; offset 0 would absorb the tag's line into the block, so the composed file's end — line 1's terminator preceding it, a line start — is taken with no terminator added (6.5: an insertion where the origin deletion's range ends reads what the deletion leaves), and the result is exactly `<S id="a" d={<T>.m} />`, U+000A, `import <T> from "./b.xspec"`, U+000A; the preview's `import-addition` zero-length at the deletion's start or at the file's byte length — the two pre-operation offsets the collapsed deletion makes one composed position, the choice between them 6.5's latitude — the bytes the same either way; the origin root, the origin parent, `changed` by its lost child reference alone, its own text empty before and after; a product judging line start over the pre-operation text, where `</S>` precedes the file's end, adds a terminator and leaves a kept empty line, failing the byte contract while passing every other arm; (j) the file's-end addition whose ended line is kept — 6.2's other `changed` mechanism, the ended line kept with the added terminator, and the qualifier on its file's-end exception, under which the root keeps its own content exactly when the line so ended is dropped, having held non-whitespace only within removed constructs: a target `<S id="p">`, U+000A, `x`, U+000A, `</S>{text("p")}` with no final terminator — the closing tag's line a flow line holding a tag and an expression container alone (14.20; T3-3's constraint), the root embedding its own child no cycle (5.3) — moved into `p.n` with (h)'s moved text: offset 0 would absorb the tag's line into the block and every other line start lies inside `p`, so the file's end, mid-line, is the only admissible offset, and the result is exactly `<S id="p">`, U+000A, `x`, U+000A, the moved text, U+000A, `</S>{text("p")}`, U+000A, the declaration, U+000A — the added terminator ends the `</S>{text("p")}` line, which is kept, its excised embedding counting as remaining line content (1.6) so that the drop rule of 3 spares it, unlike (c)'s bare `</S>` line — the preview's `import-addition` at the file's byte length, the real operation agreeing; the root's own-content run after the embedding gains that U+000A (the expansion no part of own content, 1.6) — its own text through `query node` gaining a trailing U+000A beside the moved text's arrival in the expansion — so its ownHash changes, and `impact --base` against a commit made immediately before the move reports (h)'s enumeration exactly: the target root `changed` beside `p` and the origin parent, its other-file dependent (`d={B}`, as (h) stages it) `upstream-changed`, the moved node carrying no category, no other node `changed` — failing a product that reads the exception without its qualifier, leaving a receiving root untouched by any addition at the file's end, which passes (a) through (i); (k) the removal-side line start — an insertion at an offset where an import removal's range ends reads what the removal leaves (6.5), the origin deletion's twin of (i) and the one arm where the case T6.5-8 names parenthetically decides a terminator: T6.5-11(a) and (b), T6.5-18 and its type-only variants, and T6.5-23(h) through (j) also place an added line at an import removal's start or end, but each such offset is a line start whether the text is read before the edit or as composed, so only here do the two readings part: a third spec source `specs/c.mdx` = `<S id="q" d={A.m} />`, U+000A, `import A from "./a.xspec"` with no final terminator — deriving, the tag's line a flow line and the declaration the block's only line, the file's unterminated last — under `move specs/a.mdx#m specs/b.mdx#m`, `m` a clean-boundary flow-form section (T6.2-3's) beside a sibling section in `a.mdx`, moved to the top level of an existing `b.mdx` ending in a terminator, neither file needing a declaration (the moved text local to its subtree), the declaration this arm asserts being `c.mdx`'s: `A.m` re-roots to `<T>.m`, `A`'s declaration — its last use gone — is removed with its unterminated last line, the removal's range from the start of line 2 to the file's byte length, and over the composed text, `<S id="q" d={<T>.m} />`, U+000A, offset 0 would absorb the tag's line into the block, so the composed file's end — line 1's terminator preceding it, a line start — is the only admissible offset, taken with no terminator added: the result is exactly `<S id="q" d={<T>.m} />`, U+000A, `import <T> from "./b.xspec"`, U+000A; the preview's `reference-rewrite` spanning `A.m`, its `import-removal` spanning line 2, and its `import-addition` zero-length at the removal's start or at the file's byte length — the two pre-operation offsets one composed position, the choice between them 6.5's latitude, as (i) allows; `c.mdx`'s root keeping its own content, `q` carrying no category (its `d` target's canonical identity, mapped through the journal, and effectiveHash unchanged, 5.5), the two parents — `a.mdx`'s and `b.mdx`'s roots — `changed` with their ordinary cascades; a product judging the line start over the pre-operation text, where `"` precedes the file's end, adds a terminator and leaves a kept empty line, failing the byte contract while passing (i); (l) the admissibility exclusion for lines that were no ESM block's before the edit (6.5) — a target `// note`, U+000A, `import B from "./B.xspec"`, U+000A, U+000A, `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A, its first two lines one paragraph — an ESM block cannot interrupt a paragraph (14.20), so the second line is content, no declaration: `specs/B.mdx` absent and the pre-move `build` clean all the same (T3-1's grammar boundary), `view` listing no import — moved into `p.n` with (h)'s moved text: offset 0 heads a block joining the paragraph's lines, `import <X> …`, `// note`, `import B …` deriving as one block of two declarations, yet the offset is inadmissible, as joining lines that were no ESM block's before the edit; the start of line 2, the start of the empty line, and every mid-line offset of the paragraph leave the added line paragraph text; every line start from `<S id="p">` on absorbs the tag's line or lies inside `p`; so the file's end after the final terminator — a line start following a flow closing-tag line — is the only admissible offset, and the result is exactly the file with the moved text and its terminator inserted before `</S>` and `import <X> from "./x.xspec"`, U+000A appended, the paragraph's bytes untouched and still content in the compiled Markdown, `view` listing under `imports` the added declaration alone (11.4), the preview's `import-addition` at the file's byte length, the root's own text and ownHash unchanged (6.2); an indented twin — ` import B from "./B.xspec"` heading the file in the paragraph's place, a paragraph line likewise — alike: offset 0 and the offset after its two spaces each head a block absorbing that line, deriving yet inadmissible, the file's end again the only admissible offset, the expectations the same (T6.5-15(a)/(c)'s comment-headed and indented forms, met on the addition side); a product judging admissibility by derivability alone takes offset 0 in each and turns paragraph text into live declarations — one an import of a nonexistent module, an invalid workspace behind a reported success — failing the byte contract, which no other arm's offsets distinguish from 6.5's rule; the refusal-side twin T6.5-16(g)'s. Every composed form here derives under the grammar 14.20 fixes (S-9); the in-section exclusion, which no offset here decides — each in-section line start also absorbing a line or following a paragraph line, the in-section offsets that derive mid-line — is T6.5-19's. +* **T6.5-14 Created target file's fixed content.** 6.5 fixes a created target file's initial content instead of leaving it chosen: the declarations it needs, each followed by U+000A, in an order the product fixes, then — when there is at least one — one further U+000A, the empty line that ends the ESM block, then the moved text and its terminator; the preview's `file-creation` class subsumes it all (T6.6-4(d); its delta T6.6-5). Byte-asserted, the fresh identifiers and the declarations' order alone unpinned (each declaration's other characters T6.5-8's): a created target `specs/new.mdx` needing no declaration — the moved text's references local to its subtree — is exactly the moved text followed by U+000A, a product adding a leading empty line, a trailing one, or no terminator failing; one needing declarations is staged with moved text carrying a local `d` reference to an origin node outside the subtree — a sibling leaf of the moved section, none of its bytes on the construct's boundary lines and no dependency on or embedding of the moved subtree or its parents, so that the move leaves its canonical identity and effectiveHash unchanged (5.5); a target the move changes — the origin parent, an ancestor of it, or a node depending on one of them — would make the moved node `upstream-changed` (5.6) and the category expectation below false — converted to imported form through the created file's declaration of the origin module, in 6.4's spellings — and a `{text(X.a)}` embedding through the origin's binding of a third module `specs/x.mdx` (T6.5-10's shape), so that exactly two declarations are needed: the file is exactly `import <O> from "./a.xspec"` and `import <X> from "./x.xspec"`, in either order, each followed by U+000A, then U+000A, then the moved text, its references rooted at `<O>` and `<X>`, followed by U+000A — a product emitting no empty line, two, the moved text first, or the declarations elsewhere fails; the created content derives (S-9), `build` and `check` are clean, and against a baseline committed before the move the created file's root is `changed` by addition alone, carrying no other category (5.6, P-5's convention), a clean-boundary moved subtree (T6.2-3) carrying none — the moved node's metadataHash its target's canonical identity, preserved, and its effectiveHash the target's, unchanged (5.5). +* **T6.5-15 Joint import removals over an ESM block.** In a spec source the removals in one ESM block are judged together, over the block as all of them would leave it (6.5): where they would leave it headed by anything but a declaration at the start of its first line — a JavaScript comment or an indented declaration — the remaining declarations would derive as paragraph text (14.20), so the block's first declaration stays, its binding unused (2.1) and no removal reported for it, while the others are removed; a block they would leave with no line at all loses its first declaration with the rest. T6.5-7's own-line and shared-line extents never leave a comment or an indented declaration heading a block. Byte-asserted arms, each in an origin spec source whose moved subtree carries every use of the bindings said to lose theirs, the origin asserted byte-equal to expected bytes composed from the rules of 6.5 and 3, `check` clean after each move, and the compiled Markdown of each kept form asserted (comment lines staying as content, T3-7): (a) `import A from "./A.xspec"`, `// note`, `import B from "./B.xspec"` on successive lines, both bindings losing their last use — A's declaration stays byte-for-byte, its binding unused and valid, B's is removed with its line, and the preview reports one `import-removal`, B's, none for A; (b) `import A from "./A.xspec" // note` above `import B from "./B.xspec"`, both losing their last use — the same outcome; (c) `import A from "./A.xspec"` above the indented ` import B from "./B.xspec"`, A losing its last use and B keeping one outside the moved subtree — A stays byte-for-byte, no removal reported: the block would otherwise be headed by an indented declaration, deriving as paragraph text; (d) `import A …`, `import B …` on successive lines, every declaration losing its use and the removals leaving no line at all — every declaration removed, the first included, both lines dropped; (e) the control — `import A …`, `import B …`, `// note`, `import C …` on successive lines, A keeping its use, B and C losing theirs: B and C are removed with their lines, the block left headed by A at its first line's start with `// note` its second line. A product removing A in (a), (b), or (c) leaves a file whose comment-headed or indented block derives as a paragraph, its surviving references unresolved (14.5, 14.6) — an invalid workspace behind a reported success; one keeping A in (d) or (e) fails the byte contract. +* **T6.5-16 `refused-invalid-rewrite`.** A section-form move whose exact edits would leave a rewritten file other than well-formed MDX — the origin as its deletion leaves it, the target as its parent rewrite and insertion leave it or as its creation composes it, the one file as both leave it when origin and target coincide — or a file the rewrite must add an import to holding no admissible offset (T6.5-13's rule, T6.5-23's TypeScript conditions included) is refused (6.5, 14): exit 1, nothing modified (workspace byte-compare, the journal absent or byte-unchanged), and, form-exact per 12.7, exactly one `refused-invalid-rewrite` finding per operation however many files or shapes it covers — its `locations` the moved section's construct range in the origin file (1.7) plus, for each addition no offset admits, every reference spelling the operation roots at that binding, whether or not its characters would change, each by its occurrence span (5.7; the reading T14-7's `refused-cycle` clause takes); its `identities` the workspace-relative paths of the files concerned, in byte order — each whose would-be text is not well-formed, a created target's included, and each holding no admissible offset for an addition it needs; its `path` `null` — the preview reporting the same finding, exit 1, `mapping`, `files`, and `delta` `null` (T6.6-3). Each shape below composes a would-be text verified underivable (S-9) from a valid pre-move workspace, and stands beside its movable control: (a) the `body</S>` variant — origin `foo <S id="m">`, then a space, a tab, or nothing, U+000A, `body</S>` (one arm each), moved to top level: at a line's start its opening tag is a flow-position tag, which the text-position closing tag cannot close (6.2); the control T6.2-3(c)'s U+000B/U+000C remainder; (b) the one-sided spellings of 6.2's worked three-line shape — U+000C among the whitespace following the opening tag, the closing tag then alone on its line at the destination, and U+000C preceding the closing tag, the opening tag then alone on its line there (one arm each; U+000B alike; at the origin the worked shape's closing tag is followed by ` bar`) — refused either way, the both-sided and none-sided spellings the movable controls (T6.2-3(a), (b)); (c) a flow-position section — its tags alone on their lines; separately a self-closing section; and separately a single-line section whose line holds nothing outside its tags and expression containers, `<S id="m"><S id="m.q" /></S>`, alone on its line a flow line whose tags are flow-position tags (14.20; T3-3's constraint), as an empty `<S id="m"></S>` or an embedding-only `<S id="m">{text(X.a)}</S>` would be — moved into a parent whose tags stand in text position: `foo <S id="p">bar</S> baz` and `foo <S id="p">bar`, U+000A, `</S> baz` (the closing tag on a later line), one arm each, and the self-closing parent `foo <S id="p" /> baz`, refused after its paired-form rewrite; the control a single-line in-line section holding prose outside its tags, `<S id="m">x</S>`, moved into the same parents (T6.5-2's fourth geometry); (d) a deletion leaving what followed the construct on its line at the line's start, inside a text-position parent `foo <S id="p">bar`, U+000A, the line, U+000A, `</S> baz` — the line `<S id="p.m">x</S>- item`, `<S id="p.m">x</S>===`, `<S id="p.m">x</S><S id="p.q" />`, or `<S id="p.m">x</S>{/* c */}`, one arm each, each pre-move file deriving (its second line a paragraph continuation, the bytes after the construct text) and each deletion leaving a list marker, a setext underline, a flow-position tag, or a flow-position expression interrupting the paragraph that holds the parent's opening tag — and, one arm more, the parent's own closing tag as the remainder, `foo <S id="p">bar`, U+000A, `<S id="p.m">x</S></S>`, U+000A, deriving before the move, its second line a paragraph continuation, whose deletion leaves `</S>` alone on its line, a flow-position tag closing no text-position tag (6.2); the control the same line with plain prose after the construct, `<S id="p.m">x</S> more`, whose deletion leaves ` more` a paragraph-continuation line — moved successfully, the origin exactly `foo <S id="p">bar`, U+000A, ` more`, U+000A, `</S> baz`, U+000A (the line keeps content, so 3 drops nothing); and, the insertion-side counterpart, one arm more — a target `foo <S id="p">`, U+000A, `<S id="p.s"> </S></S> tail`, U+000A, deriving, its second line a paragraph continuation, receiving `<S id="m">text</S>`, alone on its origin line, into `p.n`: the insertion, preceded on its line by `p.s`'s closing tag, splits the line with an added terminator (6.5), leaving `<S id="p.s"> </S>` alone on its line — a flow line, its tags flow-position tags closing no text-position tag and interrupting the paragraph that holds `p`'s opening tag — refused, `identities` the target's path alone; its control T6.2-3(e), the U+000C spelling of that line, kept in text position and performed; (e) a top-level `new-id` insertion at the end of a file whose last line belongs to an ESM block — a target holding only `import A from "./A.xspec"` (`specs/A.mdx` discovered, the binding unused, 2.1), terminated and unterminated (one arm each) — absorbed into the block; the control the same declaration followed by U+000A, U+000A — the empty line ending the block — receiving the same top-level `<new-id>` at its end, moved successfully: exactly that file with the moved text and U+000A appended after the empty line's terminator (a line start, none added), `check` clean — failing a product that refuses whenever the target's last non-blank line is a declaration; (f) a moved section standing in a block quote — `> <S id="m">`, `> x`, `> </S>` — whose moved text carries the `>` prefixes of its interior lines, its closing tag standing at the destination inside a quote its opening tag stands outside; the control the same section standing in no quote (T6.5-2's flow-form arms); (g) a file the rewrite must add an import to holding no admissible offset — a target `foo <S id="p">bar</S> baz`, with and without a final terminator (one arm each), receiving into `p.n` a single-line in-line section `<S id="m">x {text(X.a)}</S>` rooted at the origin's binding of a third module `specs/x.mdx` the target lacks: offset 0 would absorb the paragraph line into the block, the file's end follows a paragraph line, and every other offset splits the paragraph, so no offset admits the declaration — the finding locating, beside the moved construct, the embedding's braced container as the spelling rooted at the lacked binding, `identities` `["specs/b.mdx"]`; its pseudo-block twin — the same target headed by T6.5-13(l)'s paragraph, `// note`, U+000A, `import B from "./B.xspec"`, U+000A, U+000A, above `foo <S id="p">bar</S> baz`, U+000A, receiving the same moved text: offset 0 heads a block joining the paragraph's lines, deriving yet inadmissible (6.5: lines that were no ESM block's before the edit), the start of line 2, the start of the empty line, and the file's end each follow a paragraph line, the start of the `foo` line heads a block that absorbs that line (not well-formed, 14.20), and every other offset splits a paragraph line — refused alike, `identities` `["specs/b.mdx"]`, where a product judging admissibility by derivability alone performs the move at offset 0, the paragraph turned into live declarations; its top-level twin — the same moved text, alone on its origin line, to the top level of a paragraph-ended target `specs/b.mdx` = `para`, U+000A, terminated and unterminated (one arm each; `move specs/a.mdx#m specs/b.mdx#m`): the composed text is `para`, U+000A, `<S id="m">x {text(X.a)}</S>`, U+000A either way (the target insertion at a line start in the terminated variant, preceded by an added terminator in the other), and it derives (S-9), the moved text's line a paragraph continuation of `para` — the prose outside its tags denies the flow attempt (14.20), whatever precedes the line — yet no offset admits the declaration: offset 0 absorbs `para` into the block; the end of the `para` line and every offset inside it leave the added line after a paragraph line; and the file's end, the target insertion's offset, where the declaration stands after the moved text (6.5's fixed order), leaves it after the moved text's own paragraph line — 6.5's end-of-file qualifier, an addition there admissible where the moved text ends in a flow-position closing tag, decided on its refusing side, which no other arm reaches (P-5's generated sources begin with an empty line, offset 0 admissible in each) — refused alike, `identities` `["specs/b.mdx"]`, the finding locating the construct and the embedding's braced container; its controls T6.5-13(d), the same target receiving a flow-form moved text whose closing tag, alone on its last line, makes that offset admissible, and, one arm more, the same in-line moved text to the top level of `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A — performed, its only admissible offset the end of the `</S>` line before its terminator (offset 0 absorbing the tag's line; the end of that line heading a block inside `p`, deriving yet excluded, T6.5-19; the start of the `x` line absorbing it; the end of the `x` line and the start of the `</S>` line leaving the added line paragraph text; the file's end, a line start after a flow line before the operation, following the moved text's paragraph line after it), T6.5-13(h)'s forced mid-line placement: exactly `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A, the declaration, U+000A, U+000A, `<S id="m">x {text(X.a)}</S>`, U+000A, `build` and `check` clean, the root `changed` — a parent gaining a child reference, its run after `p` now U+000A, the kept remainder line, and its run after `m` U+000A — which pins that the in-line moved text's own line is a paragraph line whatever precedes it; a product treating the file's end after a top-level target insertion as admissible outright, or judging admissibility over the pre-operation text, performs the refused move and leaves the declaration paragraph text — `X` unbound in `specs/b.mdx`, an invalid workspace behind a reported success — and places the control's declaration after the moved text alike; and its origin-side twin — an origin `foo <S id="p">bar <S id="p.m">x</S></S> {text("p.m")} baz` with no terminator, moved into a clean flow-position target, the kept reference's conversion to imported form needing an import of the target module the origin holds no admissible offset for — `identities` `["specs/a.mdx"]`, the finding locating the construct and `{text("p.m")}` — the controls T6.5-13's arms, whose receiving files each hold an admissible offset; (h) the same-file variant — `foo <S id="p">bar</S> baz`, U+000A, `<S id="m">`, U+000A, `x`, U+000A, `</S>`, U+000A, `move specs/a.mdx#m specs/a.mdx#p.n` — `identities` `["specs/a.mdx"]`, one path; its control the same-file move of T6.5-13(e), an in-line section into a text-position parent, performed; (i) two files concerned, one finding — an origin `specs/z.mdx` = `import X from "./x.xspec"`, U+000A, U+000A, `foo <S id="p">bar`, U+000A, `<S id="p.m">x {text(X.a)}</S>- item`, U+000A, `</S> baz`, U+000A — (d)'s shape, deriving before the move, its deletion leaving `- item` at the line's start — and the target `specs/b.mdx` = `foo <S id="p">bar</S> baz`, U+000A, lacking `x.mdx`'s module — (g)'s shape, no offset admitting the declaration — under `move specs/z.mdx#p.m specs/b.mdx#p.n`, an insertion point existing so that both texts are judged: exactly one finding, its `identities` exactly `["specs/b.mdx", "specs/z.mdx"]` — byte order, the target first — its `locations` the construct and the embedding's braced container, both in `specs/z.mdx`, in start order (12.7); a product emitting one finding per file, or the origin's path first, fails. Applicability (6.5): reported beside every other applicable reason — (c)'s shape staged beside `refused-id-collision` (a section `p.n` already in the target) reports both; judged only under an intrinsically valid `<new-id>` — (c)'s shape with `<new-id>` `p.then` reports `refused-invalid-id` alone; the origin's would-be text is judged always while the target's is judged only when an insertion point exists — a missing target parent beside an origin deletion of shape (d) reports `refused-missing-target-parent` and `refused-invalid-rewrite` with `identities` the origin path alone, and a missing target parent beside a flow-form moved section and a text-position target file, the origin clean, reports `refused-missing-target-parent` alone, no target text existing to judge; and a created target's path is spelled whatever its validity — (a)'s shape moved to the absent `specs/new.txt#y` reports `refused-invalid-destination` (`path` `specs/new.txt`) beside `refused-invalid-rewrite` with `identities` `["specs/new.txt"]` (T14-7) — and the creation's composition is judged on its own, no other reason present: (a)'s shape moved to the valid absent `specs/new.mdx#y` reports `refused-invalid-rewrite` alone, `identities` exactly `["specs/new.mdx"]`, the would-be content T6.5-14's form — the re-identified moved text and U+000A, no declaration needed — underivable (S-9: its opening tag at a line's start a flow-position tag, its closing tag inside the paragraph `body</S>`), the origin's `foo ` deriving, exit 1, nothing created. +* **T6.5-17 `refused-moved-import`.** A section-form move whose moved text holds an import declaration is refused (6.5, 14), judged over the moved text as it stands, whatever the edits would leave: exit 1, nothing modified (workspace and journal byte-compared), exactly one `refused-moved-import` finding whose `locations` are each such declaration's own characters in the origin file — the import range of 11.4 — its `identities` exactly `[]`, its `path` `null` (12.7), the preview reporting the same with `mapping`, `files`, and `delta` `null` (T6.6-3). Fixture: a valid pre-move workspace whose origin section holds a blank-line-separated ESM block — `<S id="m">`, U+000A, U+000A, `import X from "./x.xspec"`, U+000A, U+000A, `body {text(X.a)}`, U+000A, `</S>` — the form that derives inside a section element (14.20; T2.1-6 its positive observation, the pre-move `build` exit 0 repeating it here): one arm with one declaration in the block, one with two (`import X …`, `import Y …` on successive lines: two locations); an arm whose composition would otherwise be well-formed and whose binding is unused — the block's declaration referenced nowhere (2.1) — refused all the same; reported beside every other applicable reason and carrying no intrinsic-validity qualifier — an arm with `<new-id>` `then` reports `refused-invalid-id` and `refused-moved-import` both, unlike `refused-invalid-rewrite` (T6.5-16); and the positive counterpart: the same workspace with the declaration moved to the file's top-level ESM block, a second reference through `X` standing outside the moved subtree, moves successfully — the moved reference re-rooted through an added binding of `x.mdx`'s module in the target (T6.5-10), the origin's declaration kept byte-for-byte for its remaining use, `check` clean. +* **T6.5-18 Shadow-aware, value-level binding choice.** 6.5 roots a rewritten spelling at a binding the file already holds only where no local declaration shadows it at the occurrence (4.5), and otherwise — the file holding none — at the binding of the declaration the operation adds, an import being added exactly where the file lacks a binding the spelling is rooted at; T6.5-7's TS arm exercises the unshadowed choice (the existing target binding used, no import added), this test the shadowed one, whose wrong outcome is silent. Fixture: `specs/origin.mdx` holding `x`, `specs/target.mdx` holding `z`, and `src/c.ts` = `import T from "../specs/target.xspec"`, `import O from "../specs/origin.xspec"`, a module-scope marker `T.z`, and a function `f` holding `const T = 1` beside the marker `O.x` — a valid workspace: the inner-scope `T` shadows the import within `f` (T4.5-4) and is no same-scope collision of 14.15 (T4.5-8), `O.x` resolving through the unshadowed `O`, `build` and `check` clean, the file compiling clean under standard tooling (H-2). After `move specs/origin.mdx#x specs/target.mdx#y`: the file holds no binding of the target module that no local declaration shadows at the occurrence — `T` is shadowed there — so a declaration is added binding a fresh identifier `<F>` (its value unpinned, 6.5's latitude; equal to `T` under no reading, the module-scope import and the local of `f` both binding it — 6.5's freshness spanning the local, T6.5-9), the marker is rewritten to `<F>.y`, and the origin declaration, its only use gone, is removed. Asserted under T6.5-8's diff-isolated discipline, the marker's span a second isolated run: the single added byte run is exactly `import <F> from "../specs/target.xspec"` followed by U+000A where the origin declaration's line stood, directly after `T`'s — the one composed position the removal's start and end make, each following a statement's end and timely (6.5), every later line start untimely past the statement `T.z` — `<F>` read from it; the marker's occurrence span (5.7) is replaced by `<F>.y`; the origin declaration is removed with its line (T6.5-7); the `T` declaration and `T.z` stand byte-for-byte, no other byte changed. Observed: `query edges` reports one `references` edge from `src/c.ts#f` to `specs/target.mdx#y`, beside `T.z`'s edge to `specs/target.mdx#z` as before the move; `build` and `check` are clean — no 14.7, and no 14.15, two imports binding one module under distinct identifiers colliding with nothing (2.1, 14.15); and the file compiles clean (H-2). A product rooting by name at the shadowed `T` rewrites the marker to `T.y`, adds no import, and records no edge from `f` — a chain rooted at the local is no spec reference (4.5): no finding, no staleness, a dropped edge behind a reported success — failing the edge assertion and the byte contract (its `T.y` a consumer type error besides). Preview parity (6.6, 12.7): the `--preview` reports, for `src/c.ts`, one `reference-rewrite` spanning the marker, one `import-addition` at the removal's start or at its end (as T6.5-11's parity admits), and one `import-removal` spanning the origin declaration with its adjunct drop (T6.6-4). Type-only bindings (6.5: a spelling in external form is rooted at a value-level binding of its target's module, 4.5; a binding introduced type-only is a type-level name, 4, a chain rooted at it recording no edge and raising no finding, T4-4): the same move over two variants of `src/c.ts`, each a valid workspace before it, `build` and `check` clean, the file compiling clean under standard tooling (H-2). (a) The target module's default bound type-only — `import type T from "../specs/target.xspec"`, U+000A, `import O from "../specs/origin.xspec"`, U+000A, `let v: typeof T.z`, U+000A, `O.x`, U+000A, `T` spelled at type level alone: the file holds no value-level binding of the target module, so a declaration is added binding a fresh `<F>` — equal to `T` under no reading, a declaration of the file binding it (6.5's freshness, T6.5-9) — and the file becomes exactly `import type T from "../specs/target.xspec"`, U+000A, `import <F> from "../specs/target.xspec"`, U+000A, `let v: typeof T.z`, U+000A, `<F>.y`, U+000A, `query edges` reporting the marker's `references` edge from `src/c.ts` to `specs/target.mdx#y`. (b) The target module's `text` bound type-only — `import T, { type text as tt } from "../specs/target.xspec"`, U+000A, `import O, { text as textO } from "../specs/origin.xspec"`, U+000A, `T.z`, U+000A, `textO(O.x)`, U+000A: the call, its target carried into the target module, is rewritten whole (6.5), its argument re-rooted at `T`, value-level and timely, its declaration preceding `O`'s, while the file holds no value-level `text` binding of that module — `tt` is type-only — so a declaration binding its `text` alone is added, exactly `import { text as <Y> } from "../specs/target.xspec"`, or `import { text } from "../specs/target.xspec"` where the fresh identifier is `text` itself (6.5), `<Y>` equal to neither `T` nor `tt`, with no default binding; the call's occurrence span becomes exactly `<Y>(T.y)`, `text(T.y)` under the second spelling, and `query edges` reports its `embeds` edge from `src/c.ts` to `specs/target.mdx#y`. In each, the origin declaration, its last use gone, is removed with its line, and the added line stands where that line stood — the removal's start and end one composed position, each following a statement's end and timely, every later line start untimely, a statement other than an import declaration standing between it and the origin declaration — every other byte standing as before (T6.5-8's discipline, the fresh identifier read from the added declaration); `build` and `check` are clean afterward, the file compiles clean (H-2), and the preview reports, for `src/c.ts`, one `reference-rewrite` spanning the marker or the call's occurrence, one `import-addition` at the removal's start or at its end, and one `import-removal` spanning the origin declaration with its adjunct drop. A product rooting at any binding of the right module, type-only or not, adds no import and writes `T.y` in (a) — a chain rooted at a type-only binding, recording no edge and raising no finding (4.5): a dropped edge behind a reported success — and `tt(T.y)` in (b), whose callee no value-level binding provides; each fails the byte contract, (a) the edge assertion as well, and each leaves a consumer type error besides. The callee side of the shadow rule (6.5: a call's callee is rooted at its module's `text` binding, a held one only where no local declaration shadows it at the occurrence): `src/c.ts` = `import T, { text as tt } from "../specs/target.xspec"`, U+000A, `import O, { text as t } from "../specs/origin.xspec"`, U+000A, `T.z`, U+000A, then a function `f` holding `const tt = 1` beside the call `t(O.x)`, the only use of `O` and of `t` — a valid workspace before the move, the inner-scope `tt` shadowing the import within `f` as the base fixture's `T` does, `build` and `check` clean, the file compiling clean (H-2) — under the same move: the argument is re-rooted at `T`, unshadowed and timely, while `tt`, the file's only `text` binding of the target module, is shadowed at the call, so a declaration binding that module's `text` alone is added — exactly `import { text as <Y> } from "../specs/target.xspec"`, or `import { text } from "../specs/target.xspec"` where the fresh identifier is `text` itself (6.5), `<Y>` equal to neither `T` nor `tt`, with no default binding — followed by U+000A where the origin declaration's line stood, every later line start untimely past `T.z`; the call's occurrence span becomes exactly `<Y>(T.y)`, `text(T.y)` under the second spelling, the origin declaration is removed with its line, and every other byte stands as before (T6.5-8's discipline). Afterward `build` and `check` are clean, the file compiles clean (H-2), `query edges` reports the call's `embeds` edge from `src/c.ts#f` to `specs/target.mdx#y`, and the preview reports, for `src/c.ts`, one `reference-rewrite` spanning the call's occurrence, one `import-addition` at the removal's start or at its end, and one `import-removal` spanning the origin declaration with its adjunct drop. A product rooting the callee by name writes `tt(T.y)`, calling the local: a node passed to a function other than `text`, which `check` then reports (14.18), and a consumer type error besides — failing the byte contract and the clean `check`. +* **T6.5-19 The in-section exclusion.** In a spec source 6.5 admits only an offset whose added declaration's ESM block stands inside no section construct of the file as every edit of the rewrite leaves it, the inserted one included. An ESM block derives inside a section element (14.20, T2.1-6), so derivability does not decide the exclusion, and no arm of T6.5-13 or T6.5-16(g) does either: each in-section line start there also absorbs the following line or follows a paragraph line, and the in-section offsets that do derive are mid-line, never preferred — so a product judging admissibility by derivability and T6.5-13(l)'s exclusion alone, then applying the line-start preference, passes them all while placing a declaration inside a section wherever the section holds an interior empty line: the workspace valid, `check` clean, every node's own content as it was (the declaration's line dropping whole, 3), only the bytes and the preview's offset differing. Two byte-asserted arms, composed and observed as T6.5-13's are — value-blind in the fresh identifier alone, `build` and `check` clean after each move, the receiving root's own text and ownHash compared through `query node` before and after, the real operation's bytes agreeing with the preview's offsets (T6.6-4(b)) — each receiving file holding exactly one line start at which the added line derives as a declaration, inside a section, and exactly one admissible offset, mid-line at the file's end, so that the two readings take different offsets: (a) the target side — a target `<S id="p">`, U+000A, U+000A, `x`, U+000A, `</S>` with no final terminator, moved into `p.n` with T6.5-13(h)'s moved text, a clean-boundary flow-form section carrying, beside prose, a `{text(X.a)}` embedding through the origin's binding `X` of a third module `specs/x.mdx` the target lacks, so that exactly `import <X> from "./x.xspec"` is needed there: offset 0 would absorb the tag's line into the block; the start of line 2, the empty line, heads a block that empty line ends — `<S id="p">`, U+000A, the declaration, U+000A, U+000A, `x`, U+000A, the moved text, U+000A, `</S>` derives, the block inside `p` — a line start, yet inadmissible; the end of the tag's line before its terminator heads such a block too, mid-line; the start of the `x` line would absorb that line; the end of the `x` line leaves the added line paragraph text; the start of the `</S>` line is the insertion point, where the declaration would stand after the moved text and absorb the `</S>` line; so the file's end, mid-line after `</S>`, is the only admissible offset, and the result is exactly `<S id="p">`, U+000A, U+000A, `x`, U+000A, the moved text, U+000A, `</S>`, U+000A, the declaration, U+000A — the added terminator ending the `</S>` line, which drops as it did before, having held non-whitespace only within removed constructs, so the root keeps its own content, its own text and ownHash unchanged (6.2, as in T6.5-13(c)) — the preview's `import-addition` at the file's byte length; a product reading the exclusion out of 6.5 inserts at the empty line's start, the line-start preference taking that offset over the file's end, and fails the byte contract and the preview's offset while passing T6.5-13 whole; (b) the origin side — `of the file so left`: an origin `<S id="m">`, U+000A, `z`, U+000A, `</S>`, U+000A, `<S id="a" d={"m"}>`, U+000A, U+000A, `y`, U+000A, `</S>` with no final terminator, `move specs/a.mdx#m specs/b.mdx#m` into an existing target needing no declaration (the moved text local to its subtree), the declaration this arm asserts being the origin's own — `d={"m"}` converting to imported form, `d={<T>.m}`, through the target module's declaration the origin lacks (T6.5-13(i)'s conversion): the deletion's range runs from the file's start through line 3's terminator, the construct's own characters and the terminator of the line their removal leaves empty (3, 6.6), leaving `<S id="a" d={<T>.m}>`, U+000A, U+000A, `y`, U+000A, `</S>`, over which the composed file's start would absorb the tag's line into the block, the start of the empty line heads a block that line ends — deriving, at a line start, inside `a` — the end of the tag's line before its terminator heads such a block mid-line, the start of the `y` line would absorb that line, the end of the `y` line and the start of the `</S>` line each leave the added line paragraph text, and the file's end, mid-line after `</S>`, is the only admissible offset: the result is exactly `<S id="a" d={<T>.m}>`, U+000A, U+000A, `y`, U+000A, `</S>`, U+000A, `import <T> from "./b.xspec"`, U+000A, the preview's `import-addition` at the file's byte length, the origin root, the origin parent, `changed` by its lost child reference alone, its own text empty before and after (T6.5-13(i)); a product applying the exclusion in the target file alone, or judging it by derivability, inserts at the empty line's start and fails alike. Every form here derives under the grammar 14.20 fixes (S-9), the excluded in-section forms included — the exclusion, not derivability, deciding them. +* **T6.5-20 Destination refusals over derived paths.** 6.5 refuses, as `refused-invalid-destination` concerning the destination path, a move whose destination would leave the finishing regeneration a write it cannot make, or a source it would hide or replace: each arm below exits 1 and modifies nothing (workspace byte-compare, the journal absent or byte-unchanged), reporting exactly that finding — `path` the destination as spelled, `locations` `[]` (14, T14-7) — never 14.22, a refused operation reporting refusal reasons alone (14), and its `--preview` reports the same (T6.6-3). Spec globs are `specs/**/*.mdx` throughout, reaching every destination below, so that each destination is otherwise valid and the relation under test is the refusal's only reason: under a glob reaching the top level alone, (a)'s destinations, each below a subdirectory of `specs/`, would belong to no spec group — refused under the same code, concerning the same path, by a product lacking the relation. (a) Under a derived path the sources would generate after the move: beside a discovered `specs/A.mdx`, whose module path is `specs/A.xspec.ts`, a file-form move `move specs/Z.mdx specs/A.xspec.ts/B.mdx` and a section-form move creating the target `specs/A.xspec.ts/B.mdx`, and the same pair under each companion path of `specs/A.mdx`, its destination `specs/A.xspec.<suffix>/B.mdx` (the paths read as T13.4-9(e) reads them; none for a product writing no companions) — a product whose relation leaves out companions performing it, its regeneration then writing the companion over the directory holding the moved file — each staged before any build, so nothing occupies the module or companion path and T6.5-4's occupied-component arm stays apart: the derived-path relation holds whether or not anything occupies the derived path. The module-path pair once more, staged after a `build`, where the two relations meet at one component: `specs/A.xspec.ts` is then the plain file that build wrote, occupying a directory component of the destination (T6.5-4's relation) while being the derived path the destination lies under, one reason either way (6.5), so each move reports exactly one `refused-invalid-destination` finding concerning the destination (14: one finding per reason) — failing a product that vets the two relations in separate passes and reports a finding from each. And a derived path of the destination under one while the destination itself lies under none: under `markdown.outDir: "out"`, beside a discovered `specs/x.mdx` emitting `out/specs/x.md`, `move specs/Z.mdx specs/x.md/y.mdx` and the section-form move creating that target, staged before any build likewise — the destination's emit path `out/specs/x.md/y.md` lying under `out/specs/x.md`, while `specs/x.md`, the destination's own component, is no derived path — a product asking only whether the destination lies under a derived path performing the move, whose regeneration would then write `out/specs/x.md` as a plain file where `out/specs/x.md/y.md` needs a directory. (b) A directory component of another derived path: under `markdown.outDir: "out"`, beside a discovered `specs/a.md/b.mdx` emitting `out/specs/a.md/b.md`, `move specs/Z.mdx specs/a.mdx`, whose emit path `out/specs/a.md` is a directory component of that path; and the destination itself such a component — under `markdown.outDir: "specs/B.mdx/md"`, staged before any build so nothing occupies `specs/B.mdx` (`refused-destination-exists` otherwise, T6.5-4), `move specs/Z.mdx specs/B.mdx`, every emit destination then lying under the destination's path. (c) A source hidden or replaced, with emission next to sources and each refused staging staged before any build, so that nothing occupies `specs/Z.md` — built first, Z's Markdown there, at a path no longer an emit destination once the relocation removes Z, would be discovered by the `specs/*.md` glob, and `refused-exposed-derived-file` would apply beside (6.5, T6.5-21): `move specs/Z.mdx specs/B.mdx` beside a discovered code source `specs/B.md` (a code group globbing `specs/*.md`, the file well-formed TypeScript) — the emit path `specs/B.md` that source's path, which the move would exclude from every group (13.4) — and, separately, beside a discovered `specs/B.md/C.mdx`, the emit path a directory component of that source's path, and beside a discovered code source `specs/B.md/x.ts`, the only file beneath `specs/B.md` (a code group globbing `specs/**/*.ts` instead, the file holding `export const v = 1`); and `move specs/Z.mdx specs/A.mdx` beside a discovered code source `specs/A.xspec.ts/c.ts` (that group, the same content; its file name holding no `.xspec.`, so no exclusion applies, 13.4), the module path `specs/A.xspec.ts` a directory component of its path, and, separately, one staging per companion path of the destination, beside a discovered code source `specs/A.xspec.<suffix>/c.ts` instead (the paths read as T13.4-9(e) reads them, the twin holding `specs/Z.mdx`'s bytes at `specs/A.mdx`; none for a product writing no companions), that companion path a directory component of its path. A code source generates no derived path, so in the stagings beside `x.ts` and `c.ts` the source relation alone refuses — `C.mdx`'s derived paths, under the emit path, meet (b)'s relation as well — and a product vetting derived paths against sources for equality alone performs them, as does, beside `specs/A.xspec.<suffix>/c.ts`, one whose relations leave out companions, its regeneration writing the plain file over the directory and so deleting the source (13.4); and the exemption, performed: `move specs/B.md/C.mdx specs/B.mdx` after a `build`, `C.mdx` holding no import and the only source under `specs/B.md` — the one source below the emit path being the relocated origin (6.5) — leaves `specs/B.mdx` holding the moved bytes and `specs/B.md` a plain file holding its Markdown, the emitted file replacing the vacated directory with nothing under it (13.4, T13.4-11), `check` clean. (d) A module-linking form made to designate a derived-file path (4, 14.15): with emission next to sources, a code source `src/c.ts` holding one module-linking form alone, its relative specifier `../specs/B.md` — one staging per form 4 names, each the line followed by U+000A: `import "../specs/B.md"`, `export * from "../specs/B.md"`, `import X = require("../specs/B.md")`, `import("../specs/B.md")`, `type T = import("../specs/B.md")`, and `declare module "../specs/B.md" { }`, each text TypeScript 5.9.3 accepts both as module code and as script code (14.20; the relative-name and undeclared-module diagnostics are post-parse checks, as T4-2 states) and each valid while no `specs/B.mdx` exists, `specs/B.md` then no derived-file path — makes `move specs/Z.mdx specs/B.mdx` refused, the specifier designating the destination's would-be emit path: a product collecting designations from import declarations alone, the one form a file move rewrites (4, 6.5), performs the move in the other five stagings, leaving 14.15 for the next `check` behind a reported success — T4-2's per-form arms judge the 14.15 validator over an existing derived-file path, never the move's prediction of one; its controls, each performed with `check` clean afterward: emission disabled, under each of the six stagings, no emit destination arising; and emission enabled with the path named only by `require("../specs/B.md")`, by `/// <reference path="../specs/B.md" />`, and by `` import(`../specs/B.md`) ``, whose template literal is no static string literal (2.4), none a module-linking form (4). Each refused staging of (b) through (d) recurs under the section form, `specs/Z.mdx` holding a section `x` and `move specs/Z.mdx#x specs/a.mdx#x`, `move specs/Z.mdx#x specs/A.mdx#x`, or `move specs/Z.mdx#x specs/B.mdx#x` creating the target file at the file-form move's destination, and is refused alike — 6.5 applies each relation and the module-linking designation to a target file to be created as to a file-form destination; and, the section form relocating no origin, (c)'s exemption staging under it — `move specs/B.md/C.mdx#x specs/B.mdx#x`, `C.mdx` holding a section `x` — is refused, the source left below the emit path being no relocated origin. (e) The derived paths a file-form move retires: the relation reads the derived paths the sources would generate after the move, so a relocated origin's own module, Markdown, and companion paths — generated by no source once the relocation removes it — refuse nothing. (c)'s exemption meets this in one direction, its destination's emit path `specs/B.md` a directory component of `C.mdx`'s retired module and Markdown paths; this arm meets it in the other, a destination lying under a retired path. With emission next to sources and `specs/A.mdx` — holding a section `x` and no import — the only source, each staged before any build so that nothing occupies a retired path: `move specs/A.mdx specs/A.xspec.ts/B.mdx`, the destination under the origin's own module path; `move specs/A.mdx specs/A.md/B.mdx`, under its Markdown path; and, one staging per companion path, `move specs/A.mdx specs/A.xspec.<suffix>/B.mdx` (the paths read as T13.4-9(e) reads them; none for a product writing no companions) — each exits 0, the destination holding the moved bytes and its derived paths written beneath the fresh directory, `check` clean — failing a product that judges a destination lying under a derived path over the pre-move sources, the origin's derived paths included. Its controls: each staged after a `build` instead is refused `refused-invalid-destination` by T6.5-4's relation alone, the retired path then the plain file that build wrote, occupying a directory component of the destination; and the section form, which relocates no origin — `move specs/A.mdx#x specs/A.xspec.ts/B.mdx#x`, creating the target, staged before any build — is refused `refused-invalid-destination` concerning the target path, `specs/A.mdx` still generating `specs/A.xspec.ts` after the move: a product exempting the origin's derived paths in the section form too performs it, which (c)'s section-form staging cannot catch, the source relation refusing that staging as well. +* **T6.5-21 `refused-exposed-derived-file`.** A file-form move, while emission is enabled, whose origin's emit destination holds an occupant discovery would yield as a source once the relocation leaves that path no emit destination is refused (6.5, 13.4, 14): exit 1, nothing modified (workspace byte-compare, the journal absent or byte-unchanged), exactly one finding with code `refused-exposed-derived-file`, `path` the origin's emit destination, `locations` `[]` and `identities` `[]`, the `--preview` reporting the same (T6.6-3). Each arm stages `move specs/A.mdx specs/sub/A.mdx` with emission next to sources and spec globs `specs/**/*.mdx`: (a) the product-emitted Markdown — after a `build`, `specs/A.md` holds `A.mdx`'s Markdown while a second spec glob `specs/*.md` would discover it, as a spec-group file without `.mdx`, once it is no emit destination; (b) a user-authored file there before any emission — no build ever run, `specs/A.md` a plain file of the user's, and a code group globbing `specs/*.md` instead; both refused. Controls, each performed: (c) no glob reaching `specs/A.md` — after a `build`, the move succeeds and its finishing regeneration removes the stale `specs/A.md`, recorded and no longer generated, and emits `specs/sub/A.md`; (d) a symbolic link as the occupant — after a `build`, `specs/A.md` replaced by a link to a file outside the workspace, (a)'s `specs/*.md` glob present: discovery never yields a link (7), so the move succeeds, the finishing regeneration removing the recorded link as the link itself, its target byte-identical (13.4, T13.4-11); (e) the section form — in (a)'s staging, `specs/A.mdx` holding a section `x` and no import or reference, `move specs/A.mdx#x specs/sub/A.mdx#x` creating the target file: a section move relocates no origin, so `specs/A.md` stays `A.mdx`'s emit destination, and the reason, 6.5's for a file-form move alone, does not apply — the move succeeds, exit 0 with no finding, its `--preview` succeeding alike (6.6), `specs/sub/A.mdx` created, `specs/A.md` regenerated in place, holding `A.mdx`'s Markdown as the move leaves it, and `specs/sub/A.md` emitted, `check` clean afterward; a product applying the check to every move whose origin's emit destination holds a discoverable occupant refuses it, failing. Multi-reason order (14, 12.7): `move specs/A.mdx "specs/a'b.mdx"` in (a)'s staging reports two findings, `refused-invalid-destination` (T6.5-4's barred character) then `refused-exposed-derived-file` — 14 listing `refused-exposed-derived-file` after `refused-invalid-destination` and before `refused-invalid-rewrite` (T12.7-2). +* **T6.5-22 Barred and captured names.** 6.5 constrains an added import's identifiers beyond freshness against the file's module-scope declarations — each: one module code, strict throughout, admits as a binding, so no ECMAScript reserved word (`default`, `enum`, `await`, `yield` among them) and none of `let`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, `eval`, `arguments`; none of `require` and `exports`, and none beginning with `__`; none naming a global the compiler's emitted code may read — a property ECMAScript 2024, its Annex B included, defines on the global object (clause 19's value, function, constructor, and other properties: `globalThis`, `Infinity`, `NaN`, and `undefined`; `eval`, `isFinite`, `isNaN`, `parseFloat`, `parseInt`, `decodeURI`, `decodeURIComponent`, `encodeURI`, and `encodeURIComponent`; the constructors from `AggregateError` through `WeakSet`; and `Atomics`, `JSON`, `Math`, and `Reflect` — and Annex B's `escape` and `unescape`, B.2.1), `Iterator`, `AsyncIterator`, or `SuppressedError`, and, in a TSX source, `React` or the leading identifier of a factory a `@jsx` or `@jsxFrag` pragma in one of its comments names, the pragma's name matched regardless of ASCII case; bound by no declaration already in the file, in any scope and at value or type level alike; equal to no name the file already references at value or type level, bound or not; distinct from the others added; and, in a spec source, none of `S`, `Spec`, `text`. Most breaches pass `check` — 14.20's derivability admits a strict-mode-barred binding (`import let from "./let.xspec"` derives), and a captured global changes no xspec finding — so the assertion is on the added bytes. (a) The universal assertion: in every operation the suite performs that adds an import, whichever test performs it — among them T1.4-5(b) and (c), T6.5-3's third-file arm, T6.5-8 through T6.5-15, T6.5-16's performed controls, T6.5-17 through T6.5-19, T6.5-23, this test's (b), P-5's drawn moves, and the real runs T6.6-4(b) makes on a copy, a list of examples that never bounds the assertion — each added identifier, read from its added declaration (its value otherwise unpinned, 6.5's latitude), is checked against every constraint above the fixture decides: none of the barred names, bound by no declaration of the pre-operation file, referenced nowhere in it — a reference being, as 6.5's clause reads it, an identifier the file spells where name resolution looks it up through scope, at value or type level, whatever it resolves to (a declaration of the file, a global or ambient one, or nothing), a type reference such as `Record` in an annotation and a JSX tag name that is a value reference (`<Foo />`, never an intrinsic `<div />`) among them, but never a property or member name (after `.`, in an expression or a qualified type name; an object literal's non-shorthand key; a class, interface, or enum member's; a JSX attribute's), a label, or the name an import or export specifier spells for the other module (`text` in `{ text as t }`, so that the `{ text }` spelling T6.5-11 and T6.5-23(k) admit meets it) — distinct from the others added there, and, in a spec source, none of `S`, `Spec`, `text`. (b) Lures — each a section move whose receiving file needs an import of a target module named to steer a basename- or stem-derived choice onto a barred or captured name, under (a)'s assertion and with `check` clean after the move: targets `specs/let.mdx`, `specs/await.mdx`, `specs/yield.mdx`, `specs/eval.mdx`, `specs/Object.mdx`, `specs/require.mdx`, `specs/exports.mdx`, `specs/__x.mdx`, `specs/escape.mdx`, `specs/unescape.mdx`, `specs/Iterator.mdx`, `specs/AsyncIterator.mdx`, and `specs/SuppressedError.mdx`, each received once by a spec source and once by a `.ts` code source — `await` a reserved word that a script's code admits as a binding, `exports` barred beside `require`, and `__x` by its `__` prefix alone, so that every barred class of 6.5 has a fixed lure beside P-5's drawn basenames (§16's anchoring); `yield` the one reserved word 6.5 names whose binding and use derive in both kinds of file — `import yield from "./yield.xspec"` beside `{text(yield.a)}` derives, an expression's `Yield` parameter unset and the strict-mode early error excluded (14.20), and TypeScript 5.9.3 accepts `import yield from "../specs/yield.xspec"` with a use `yield.m` both as module code and as script code — so that (a) alone sees a product binding it, whereas a spec source's `{text(await.a)}` does not derive (14.20) and the post-move `check` sees such a product first; `escape` and `unescape` barred as Annex B's global-object properties, and `Iterator`, `AsyncIterator`, and `SuppressedError` by name, being no ECMAScript 2024 global-object properties, so that a product whose barred globals are clause 19's alone fails; a `.tsx` receiver whose body holds classic-runtime JSX (`<div />`), receiving `specs/React.mdx`, and a second `.tsx` receiver of `specs/React.mdx` whose body holds no JSX — 6.5 bars `React` in every TSX source, so that a product barring it only where the file spells JSX fails; `.tsx` receivers carrying `/** @jsx h */` and `/* @JSX h */` — the ASCII-case-insensitive match, 12.0's second exception (T12.0-6) — each receiving `specs/h.mdx`, one carrying `/** @jsx preact.h */`, receiving `specs/preact.mdx`, and one carrying `/** @jsxFrag Frag */` alone, receiving `specs/Frag.mdx` — the fragment factory's pragma, which a product reading `@jsx` pragmas alone misses; two more `.tsx` receivers of `specs/h.mdx` whose pragma TypeScript's own reading ignores, one carrying the line comment `// @jsx h` and one carrying `/** @jsx h */` after the file's first statement, inside a function body — TypeScript 5.9.3 reads a `@jsx` pragma only from a block comment among the file's leading comments, while 6.5 bars the name a pragma in any of the file's comments names, so that a product taking the factory from TypeScript's pragma reading fails; `.ts` receivers declaring `helper` only inside a function (`function g() { const helper = 1; return helper }`) and only as a type (`type helper = number`), each receiving `specs/helper.mdx`; a `.ts` receiver whose only mention of `Record` is the type annotation of `let r: Record<string, number> = {}`, receiving `specs/Record.mdx` — a lib type's name, no reserved word and no global-object property, so that 6.5's reference clause alone bars it, at type level, which a product collecting value-level references alone misses; and a `.ts` receiver calling an undeclared global `test(…)`, a test runner's, receiving `specs/test.mdx` — where binding `test` would also turn the call into a use of a spec module binding, a condition-18 finding in the post-move `check` (4.5). Each receiving code file is text TypeScript 5.9.3 accepts both as module code and as script code (14.20), and each spec source derives (S-9). +* **T6.5-23 Statement boundaries, the directive prologue, and timeliness.** 6.5 admits an added declaration only at an offset lying inside none of the file's statements before the edit, so that it splits none, and, in a TypeScript source, only at or after the end of the directive prologue and following the end of a top-level statement with nothing but whitespace (1.4) between — the prologue's end and the statement's each judged, like timeliness, over the file before the edit, a statement the rewrite removes included — its bindings timely for every spelling rooted at them, a chain or a call's callee — declared at or before the binding each spelling was rooted at, or after it with only import declarations between — while a spelling is re-rooted at an existing binding only where that binding is timely — in a spec source, which 6.5 holds to no timeliness, wherever the file holds one unshadowed ((o)). Byte-asserted arms, each a section move of `specs/origin.mdx#x` to `specs/target.mdx#y` (or the move the arm names) whose receiver must gain `import <X> from "../specs/target.xspec"` (in (f)'s 6.5 example, in its two boundary stagings (those interposing `type T = number` and `import Z = require("./z")`), and in (l) and (m), `import <X> from "../specs/B.xspec"`; in (g), `import <X> from "./x.xspec"`; in (k), the declaration it names; and none in (f)'s control, its two exempt-side stagings (those interposing `import type { T } from "./t"` and `import "./p"`), its precedence branch at a distance, (k)'s control, or (o)), value-blind in `<X>` alone (in (k), `<Y>`; T6.5-8's discipline, T6.5-22(a)), `build` and `check` clean after each, the preview's `import-addition`, wherever the receiver gains a declaration, at the offset the real operation then uses (T6.6-4(b)), and every code file, before and after, text TypeScript 5.9.3 accepts both as module code and as script code (14.20); `O.w` marks an unmoved node, keeping the origin import, and `f` below is `export function f() { O.x; O.w }` — in (h) through (j) instead `export function f() { O.x }`, the origin import losing its last use, and in (n) spread over lines: (a) the directive prologue — `src/c.ts` = `"use client"`, U+000A, `import O from "../specs/origin.xspec"`, U+000A, `f`, U+000A: the added line stands at the start of line 2 or of line 3 — the line starts after the prologue's statement and after `O`'s, both timely — never at offset 0, which would end the prologue, an offset the statement-end condition excludes as well, no statement's end preceding it; and the staging the prologue condition alone decides, a line start lying between two directives — `src/c.ts` = `"use client"`, U+000A, `"use strict"; import O from "../specs/origin.xspec" // note`, U+000A, `f`, U+000A, its statements `"use client"` [0, 12), `"use strict";` [13, 26), and `O`'s declaration [27, 64), the prologue ending at 26: the start of line 2 follows `"use client"`'s end and is timely but lies before the prologue's end, the start of line 3 follows a comment, and the file's end is untimely, so no line start is admissible, and the result is exactly the file with U+000A, the declaration, and U+000A inserted at offset 26, 27, 64, or 65 — the prologue's end, after the space following it, `O`'s declaration's end, or after the space before `//` (6.5's latitude among them) — both directives staying directives; a product lacking the prologue condition finds the start of line 2 its one admissible line start, takes it under the line-start preference, and leaves `"use strict"` an ordinary statement after the added line, failing the byte contract; (b) file-top directives — the same file headed by `// @ts-nocheck` in place of the prologue, and separately by `/// <reference lib="esnext" />`: no statement precedes the start of line 2, so the added line stands at the start of line 3, directly after `O`'s declaration, never above the directive; (c) a comment governing a statement — `import O …`, U+000A, `// @ts-expect-error`, U+000A, `const n: number = "x"`, U+000A, `f`, U+000A: the added line stands at the start of line 2, before the comment, never between it and the statement it governs (6.5: the added line parts no comment from the statement it precedes), and the file compiles clean under standard tooling before and after the move (H-2) — a product inserting after the comment leaves the directive governing the import and the type error bare; (d) a trailing comment — `import O from "../specs/origin.xspec" // note`, U+000A, `f`, U+000A: no line start qualifies — the start of line 2 follows the comment, and every later one is untimely — so the result is exactly the file with U+000A, the declaration, and U+000A inserted at the declaration's end or after the space before `//`, the two admissible offsets, both mid-line (6.5's latitude between them; the forced mid-line placement of T6.5-13(c) met in a TypeScript source); and the same forced placement where no comment but a character follows the declaration that is no whitespace under 1.4, though ECMAScript's lexical grammar, and so TypeScript's scanner, takes it as whitespace or a line terminator — 6.5's statement-end condition reads whitespace as 1.4 defines it, whose ECMAScript exception covers brace and ESM-block content and the token bounds of reference spellings alone — `src/c.ts` = `import O from "../specs/origin.xspec"`, U+00A0, U+000A, `f`, U+000A, and separately `import O from "../specs/origin.xspec"`, U+2028, `f`, U+000A, `O`'s declaration at [0, 37) and `f` at [40, 72) in both: in the first, U+00A0 [37, 39) stands between the declaration's end and both the offset after it (39) and the start of line 2 (40); in the second, U+2028 [37, 40) stands between that end and the offset after it (40), which is no line start either, U+2028 being no line terminator (3); each is inadmissible, as are offset 0, which follows no statement's end, every offset inside a statement, and those after `f`, untimely, so the declaration's end, 37, is the only admissible offset, and the result is exactly the file with U+000A, the declaration, and U+000A inserted at 37 — U+00A0 or U+2028 then leading the line after the added one — the preview's `import-addition` at 37; a product judging the statement-end condition with ECMAScript's whitespace (TypeScript's own trivia) finds the start of line 2 admissible in the first staging and takes it under the line-start preference, and one taking U+2028 for a line terminator as well takes offset 40 as a line start in the second, each failing the byte contract; (e) statement splitting — `import O from "../specs/origin.xspec"`, U+000A, `;`, U+000A, `f`, U+000A, the `;` terminating `O`'s declaration across a line: the added line stands at the start of line 3, never at the start of line 2, inside the declaration; and 6.5's own shape, staged so that its in-statement line start is the one line start a product judging statement ends over the composed text would find — `import C, { text as textC } from "../specs/c.xspec" // c1`, U+000A, `const s = textC`, U+000A, `(C.c) // c3`, U+000A, `import O from "../specs/origin.xspec" // c4`, U+000A, `f`, U+000A, one statement calling `textC` across lines 2 and 3: every admissible offset is mid-line — at, or after the space following, the end of line 1's declaration, of the call statement on line 3, or of `O`'s declaration, each before its comment — and the result is the file with U+000A, the declaration, and U+000A inserted at one of those six offsets, never at the start of line 3, where automatic semicolon insertion would part the statement, leaving `s` the function `textC` itself and `(C.c)` a non-static bare reference (14.8); (f) timeliness — 6.5's example: `src/c.ts` = `import A from "../specs/A.xspec"`, U+000A, `A.m`, U+000A, `A.k`, U+000A, `import B from "../specs/B.xspec"`, U+000A, `B.b`, U+000A, under `move specs/A.mdx#m specs/B.mdx#m`: `B` is untimely for the moved marker — its declaration follows `A`'s with the statement `A.m` between — so a second declaration of `B`'s module is added, binding `<X>` distinct from `B`, and the result is exactly `import A …`, U+000A, `import <X> from "../specs/B.xspec"`, U+000A, `<X>.m`, U+000A, then `A.k`, `import B …`, and `B.b` unchanged — the start of line 2 the only line-start admissible offset (the end of line 1, admissible too, is mid-line), every later line start untimely — with `query edges` reporting the marker's `references` edge to `specs/B.mdx#m`; a product rooting at `B` writes `B.m`, which TypeScript's CommonJS output reads before initializing `B` — a valid workspace behind a reported success, failing the byte contract; its control, `import B …` moved to line 2, before `A.m`: `B` is timely, the marker is re-rooted to `B.m`, nothing is added, and the file is byte-composable exactly; and the boundary of the import-declaration exemption — the control with a line holding `type T = number` inserted between `import A …` and `import B …`, and separately one holding `import Z = require("./z")`, a module-linking form other than an import declaration (4): neither statement reads `B`, and the type alias emits no code, yet each is a top-level statement other than an import declaration standing between the two declarations, so `B` is untimely (6.5) and a declaration of `B`'s module is added at the start of line 2 (offset 33), between `import A …` and the interposed line — the one line-start admissible offset, the start of line 3 and every later one untimely — the result exactly `import A …`, U+000A, `import <X> from "../specs/B.xspec"`, U+000A, the interposed line, U+000A, `import B …`, U+000A, `<X>.m`, U+000A, `A.k`, U+000A, `B.b`, U+000A; a product exempting statements that emit no code, or every module-linking form, re-roots the marker at `B` and adds nothing, failing the byte contract; and the exempt side — the control with a line holding `import type { T } from "./t"` inserted there instead, and separately one holding `import "./p"`, a type-only and a side-effect import declaration, neither binding a value nor naming a spec module: each is an import declaration, so `B` stays timely (6.5), the marker is re-rooted to `B.m`, nothing is added, and the file is byte-composable exactly, the preview reporting one `reference-rewrite` and no `import-addition` or `import-removal`; a product exempting only spec module imports, or only imports binding a value, adds a declaration of `B`'s module, failing the byte contract; and the precedence branch at a distance — `src/c.ts` = `import B from "../specs/B.xspec"`, U+000A, `B.b`, U+000A, `import A from "../specs/A.xspec"`, U+000A, `A.m`, U+000A, `A.k`, U+000A, under the same move, `specs/A.mdx` holding `m` and `k` and `specs/B.mdx` holding `b` and no `m`, as throughout (f): `B`'s declaration precedes `A`'s, so `B` is timely for the moved marker (6.5: its declaration is or precedes that of the binding the spelling was rooted at), though the statement `B.b` stands between them — the precedence branch bounds no distance, the import-declaration condition governing a later declaration alone — so the marker is re-rooted to `B.m`, nothing is added, `A`'s declaration stays, kept by `A.k`, and the file becomes exactly `import B …`, U+000A, `B.b`, U+000A, `import A …`, U+000A, `B.m`, U+000A, `A.k`, U+000A, the preview reporting one `reference-rewrite` spanning the marker, [70, 73), and no `import-addition` or `import-removal`, and `query edges` the marker's `references` edge from `src/c.ts` to `specs/B.mdx#m`; a product admitting a held binding only where no top-level statement but import declarations stands between its declaration and the replaced binding's, whichever comes first — the succession condition applied in both directions — adds a declaration of `B`'s module and writes `<X>.m`, failing the byte contract; (g) the spec-source side of the split rule — a target `specs/target.mdx` = `import K from "./k.xspec"`, U+000A, `;`, U+000A, U+000A, `<S id="p">`, U+000A, `x {text(K.a)}`, U+000A, `</S>`, U+000A, its first two lines one declaration of one ESM block, receiving into `p.n` T6.5-13(h)'s moved text, which needs `import <X> from "./x.xspec"`: the start of line 2 derives (S-9) yet splits `K`'s declaration and is inadmissible, so the added line stands at offset 0, at the empty line's start, or at the file's end — the admissible line starts, 6.5's latitude among them — never at the start of line 2; (h) a removed declaration's place — the basic move, a preceding statement's end judged over the file before the edit, a statement the rewrite removes included: `src/c.ts` = `import O from "../specs/origin.xspec"`, U+000A, `f`, U+000A, `O`'s declaration, its last use gone, removed with its line, the removal's range [0, 38): the move is performed, the added line standing at the removal's end, offset 38, the only admissible offset — it follows the removed declaration's end with nothing but that line's terminator between, and is timely, while offset 0 follows no statement's end, offset 37 lies strictly inside the removal's range, and every later line start is untimely, the statement `f` standing between it and `O`'s declaration — and the file becomes exactly `import <X> from "../specs/target.xspec"`, U+000A, `export function f() { <X>.y }`, U+000A, the preview reporting one `reference-rewrite` spanning the marker, one `import-removal` spanning [0, 38), and the `import-addition` at 38, never 0 (T6.6-4(b)), and `query edges` the marker's `references` edge from `src/c.ts#f` to `specs/target.mdx#y`; a product judging statement ends over the composed text finds no admissible offset and refuses the move (`refused-invalid-rewrite`), and one mapping the composed position to the removal's start reports offset 0, each failing; (i) a comment above the removed declaration — (h)'s file headed by the line `// note`, U+000A, and separately by `// @ts-expect-error`, U+000A, 6.5's own example: the removal's start, the start of line 2, follows only a comment, so the added line stands at the removal's end — pre-operation offset 46, or 58 under `// @ts-expect-error` — the only admissible offset, and the file becomes exactly the comment line, then the added line, then `f`'s line as (h) rewrites it, the comment now preceding the added line (6.5: a comment that preceded the removed declaration then precedes the added line), the preview's `import-addition` at 46 or 58; a product removing the comment with the declaration, or placing the added line above the comment, fails; (j) a string-literal statement after the removed declaration — `src/c.ts` = `import O from "../specs/origin.xspec"`, U+000A, `"use client"`, U+000A, `f`, U+000A, under (h)'s move: before the edit the file's directive prologue is empty, its first statement an import declaration, so `"use client"` is no directive, and judged over the file before the edit (6.5) the removal that would bring it to the file's head changes neither — the added line stands at the removal's end, offset 38, the only admissible offset, the start of line 3 untimely, the statement `"use client"` standing between it and `O`'s declaration — and the file becomes exactly `import <X> from "../specs/target.xspec"`, U+000A, `"use client"`, U+000A, `export function f() { <X>.y }`, U+000A, the string-literal statement staying, as before the edit, no directive, the preview's `import-addition` at 38; a product judging the prologue over the composed text admits no offset before `"use client"`, finds every later one untimely, and refuses the move; (k) a callee's timeliness — `src/c.ts` = `import A, { text as ta } from "../specs/A.xspec"`, U+000A, `import B from "../specs/B.xspec"`, U+000A, `ta(A.m)`, U+000A, `import { text as tb } from "../specs/B.xspec"`, U+000A, `tb(B.b)`, U+000A, `A.k`, U+000A, under `move specs/A.mdx#m specs/B.mdx#m`: the call, its target carried into `B`'s file, is rewritten whole (6.5), its argument re-rooted at `B`, timely, its declaration directly following `A`'s, and its callee never at `tb`, untimely, the statement `ta(A.m)` standing between `ta`'s declaration and `tb`'s, so a declaration binding only `B`'s module's `text` is added — exactly `import { text as <Y> } from "../specs/B.xspec"`, or `import { text } from "../specs/B.xspec"` where the fresh identifier is `text` itself (6.5), no second default binding — at the start of line 2 or of line 3 (offset 49 or 82), the two line-start admissible offsets, both timely (6.5's latitude between them); the call's occurrence span becomes exactly `<Y>(B.m)`, `text(B.m)` under the second spelling, `A`'s declaration stays, kept by `A.k`, and no other byte changes, the preview reporting one `reference-rewrite` spanning the call's occurrence, the `import-addition` at the offset the real operation uses, and no `import-removal`, and `query edges` the call's `embeds` edge from `src/c.ts` to `specs/B.mdx#m`; a product judging timeliness for default bindings alone writes `tb(B.m)`, which TypeScript's CommonJS output reads before initializing `tb`'s module binding — a valid workspace behind a reported success, failing the byte contract; its control, re-rooting at bindings the file holds — `src/c.ts` = `import A, { text as ta } from "../specs/A.xspec"`, U+000A, `import { text as tb } from "../specs/B.xspec"`, U+000A, `import B from "../specs/B.xspec"`, U+000A, `ta(A.m)`, U+000A, `tb(B.b)`, U+000A, `A.k`, U+000A, under the same move: `tb` is timely for the callee, its declaration directly following `ta`'s, and `B` for the argument, `tb`'s import declaration alone standing between `A`'s declaration and `B`'s (6.5: or follows it with no top-level statement between them but import declarations), so the call is re-rooted at both existing bindings, its occurrence span becoming exactly `tb(B.m)`, nothing is added, `A`'s declaration stays, kept by `A.k`, and no other byte changes — the file byte-composable exactly — the preview reporting one `reference-rewrite` spanning the call's occurrence and no `import-addition` or `import-removal`, and `query edges` the call's `embeds` edge from `src/c.ts` to `specs/B.mdx#m`; a product never re-rooting a callee at a `text` binding the file holds adds `import { text as <Y> } …` here, and one judging timeliness as precedence or direct succession alone adds a declaration binding `B`'s module's default for the argument, each failing the byte contract; (l) one added binding rooting spellings formerly rooted at different bindings — `src/c.ts` = `import A1 from "../specs/A.xspec"`, U+000A, `A1.m`, U+000A, `import A2 from "../specs/A.xspec"`, U+000A, `A2.m.c`, U+000A, `A1.k`, U+000A, `A2.k`, U+000A, two declarations of one module binding distinct identifiers (valid, 4, T4-4), `specs/A.mdx` holding `m`, its child `m.c`, and `k`, and `specs/B.mdx` no `m`, under `move specs/A.mdx#m specs/B.mdx#m`: both moved markers need `B`'s module's default, which the file lacks, so one declaration is added, its binding rooting both, and it must be timely for each (6.5: for every spelling rooted at them) — for `A1.m` no later than the start of line 2 (offset 34), the statement `A1.m` standing between `A1`'s declaration and every later offset, and for `A2.m.c` no later than the start of line 4 — so the start of line 2 is the only line-start admissible offset (the end of line 1, admissible too, is mid-line), the starts of lines 3 and 4 being timely for `A2.m.c` alone, and the result is exactly `import A1 …`, U+000A, `import <X> from "../specs/B.xspec"`, U+000A, `<X>.m`, U+000A, `import A2 …`, U+000A, `<X>.m.c`, U+000A, `A1.k`, U+000A, `A2.k`, U+000A, both former bindings kept by `A1.k` and `A2.k`, the preview reporting two `reference-rewrite` edits, the `import-addition` at 34, and no `import-removal`, and `query edges` the markers' `references` edges from `src/c.ts` to `specs/B.mdx#m` and `specs/B.mdx#m.c`; a product judging the added binding's timeliness against one spelling's former binding alone — `A2`, the last found — admits the starts of lines 3 and 4 as well, and one placing the added line directly after that binding's declaration writes it at the start of line 4, after `<X>.m`, which TypeScript's CommonJS output then runs before initializing `<X>`, failing the byte contract; (m) a held binding beside an added one, for one module in one file — `src/c.ts` = `import A1 from "../specs/A.xspec"`, U+000A, `A1.m`, U+000A, `import B from "../specs/B.xspec"`, U+000A, `import A2 from "../specs/A.xspec"`, U+000A, `A2.m.c`, U+000A, `A1.k`, U+000A, `A2.k`, U+000A, `B.b`, U+000A, `specs/A.mdx` and the two declarations of its module as in (l), and `specs/B.mdx` holding `b` and no `m`, under `move specs/A.mdx#m specs/B.mdx#m`: 6.5 roots each spelling at a binding the file already holds, unshadowed and timely, and at an added declaration's binding only where the file holds none, so one file can need both — `B` is untimely for `A1.m`, the statement `A1.m` standing between `A1`'s declaration and `B`'s, so a declaration of `B`'s module is added, binding `<X>` distinct from `B`, at the start of line 2 (offset 34), the only line-start admissible offset (the end of line 1, admissible too, is mid-line; every later offset untimely for `A1.m`), while `B`'s declaration precedes `A2`'s, so `B` is timely for `A2.m.c`, which is re-rooted at it: the result is exactly `import A1 …`, U+000A, `import <X> from "../specs/B.xspec"`, U+000A, `<X>.m`, U+000A, `import B …`, U+000A, `import A2 …`, U+000A, `B.m.c`, U+000A, `A1.k`, U+000A, `A2.k`, U+000A, `B.b`, U+000A, both former bindings kept by `A1.k` and `A2.k`, the preview reporting two `reference-rewrite` edits, the `import-addition` at 34, and no `import-removal`, and `query edges` the markers' `references` edges from `src/c.ts` to `specs/B.mdx#m` and `specs/B.mdx#m.c`; a product that, once a module needs an added declaration, roots every spelling of that module at the added binding writes `<X>.m.c`, failing the byte contract; (n) nested statement lists — (d)'s file with `f` spread over lines, `src/c.ts` = `import O from "../specs/origin.xspec" // note`, U+000A, `export function f() {`, U+000A, ` O.x`, U+000A, ` O.w`, U+000A, `}`, U+000A, and separately the same file with `namespace N {` in place of `export function f() {`: TypeScript 5.9.3's parser derives an import declaration inside a function body or a namespace body, the restrictions on where one may stand being post-parse grammar checks 14.20 excludes, so a declaration added at a line start inside either body would leave the file well-formed; yet every such line start lies inside the statement `f` or `N`, and a declaration there is no top-level one (6.5), while the start of line 2 follows the comment and every offset after the statement is untimely, the statement standing between it and `O`'s declaration — so the only admissible offsets are 37 and 38, the declaration's end and the offset after the space before `//`, both mid-line, and the result is exactly the file with U+000A, the declaration, and U+000A inserted at one of them (6.5's latitude between them), the marker rewritten in place to `<X>.y`; a product judging statement boundaries within the innermost statement list alone finds a line start inside the body admissible — the one after `O.x`'s end, say — and takes it under the line-start preference, as does, in the second staging, one taking a namespace body, a module block where TypeScript also admits import-equals declarations, for a declaration site, each failing the byte contract; (o) timeliness, a TypeScript source's condition alone — 6.5 requires a held binding to be timely only in a TypeScript source: `specs/third.mdx` = `import O from "./origin.xspec"`, U+000A, U+000A, `<S id="p" d={O.x} />`, U+000A, U+000A, `import T from "./target.xspec"`, U+000A, U+000A, `<S id="q" d={T.z} />`, U+000A, a third spec source, `specs/target.mdx` holding `z`, under the arms' move: the reference `O.x` is re-rooted at `T`, a binding the file holds and no local declaration shadows, though `T`'s ESM block follows `O`'s with the section `p` between them, so nothing is added, and `O`'s declaration, its last use gone, is removed with its line — the file becoming exactly U+000A, `<S id="p" d={T.y} />`, U+000A, U+000A, `import T …`, U+000A, U+000A, `<S id="q" d={T.z} />`, U+000A, which derives (S-9) — the preview reporting under it one `reference-rewrite` spanning `O.x`, [45, 48), one `import-removal` spanning [0, 31), and no `import-addition`, and `query edges` the `depends` edge from `specs/third.mdx#p` to `specs/target.mdx#y`; a product judging timeliness in a spec source too, the flow content between two ESM blocks counted as a statement standing between their declarations, adds a declaration of the target module and roots the reference at its binding, failing the byte contract; (p) whitespace between a statement's end and its line's terminator — the members of 1.4's class that are no line terminator (3), where (d) stages characters outside the class: `src/c.ts` = `import O from "../specs/origin.xspec"`, one such character, U+000A, `f`, U+000A, staged once with each of U+0020, U+0009, U+000B, and U+000C as that character, each whitespace under ECMAScript's lexical grammar as well, `O`'s declaration at [0, 37), the character at 37, and `f` at [39, 71), its marker `O.x` at [61, 64): offsets 37, 38, and 39 each follow the declaration's end with nothing but whitespace between, and are timely, 39 — the start of line 2 — the only line start among them, while offset 0 follows no statement's end, every offset inside `O`'s declaration or `f` lies inside a statement, and every offset after `f` is untimely, so the result is exactly the file with the declaration and U+000A inserted at 39 — the character kept at the end of line 1, the marker rewritten in place to `<X>.y` — the preview reporting the `import-addition` at 39, one `reference-rewrite` spanning [61, 64), and no `import-removal`; a product admitting a line start only where the line terminator before it directly follows a statement's end takes 37 or 38, mid-line, in every staging, and one judging whitespace over a set narrower than 1.4's — space, tab, CR, and LF, say — takes 37 in the U+000B and U+000C stagings, each failing the byte contract. + +### 6.6 Previews + +(T6.6-1 is retired.) + +* **T6.6-2 Modifies nothing.** A rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed: exit 0, findings `[]`, and every byte of the workspace identical afterward — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its performed-operation document (12.7, T6.4-1) carrying `findings` `[]` and a `mapping` byte-equal to the preview's. 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, T6.5-4, T6.5-6, T6.5-16, T6.5-17, T6.5-20, and T6.5-21 — the invalid-workspace precondition, the exact self-move of either form, and the same-file after-removal collision 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 — ordered by `from` bytes (12.7), the fixture's descendants `a.z` and `a.c` standing in document order opposite to byte order so that a product emitting document order fails, and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset — for a self-closing target parent, exactly at the end of the parent's tag range, where the rewrite of 6.5 places the closing tag the insertion precedes (6.6) — `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 — where a collapsed origin deletion or import removal makes two pre-operation offsets, its start and its end, one composed position, the bytes the same either way, the assertion admits whichever of the two 6.5 admits — both in T6.5-13(i)/(k), and in T6.5-11(a)/(b) and T6.5-18, whose removed TypeScript declaration follows another statement's end, the choice between them 6.5's latitude; the end alone in T6.5-23(h) through (j), whose removed declaration follows no statement's end, 6.5 judging statement ends over the file before the edit, so that a product reporting the start there fails), 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, and its `mapping` one entry per node of the moved file — the root's bare-path identities (`from` the old path, `to` the new) and every section's, ordered by `from` bytes (6.6, 12.7); (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) — is observable only between zero-length insertion points: distinct nonzero-range edits rewrite or remove distinct constructs and never share both endpoints, so an identical-range pair arises only among zero-length insertion points, in two forms: across classes, where an import addition's offset coincides with the target insertion's — which 6.5's preference fixes wherever the target insertion's offset is the chosen admissible one, among them: the end of a self-closing target parent's tag range (T6.5-13(b): `target-insertion` and `import-addition` both zero-length there) and the end of a file whose last line is a paragraph line, for a top-level `<new-id>`, that line terminated or not (T6.5-13(d), both variants) — and within one class, where several declarations are added at one offset (T6.5-13(g): two `import-addition` entries, which the tie-break cannot order and the comparator leaves adjacent, their count the observation); an ESM block derives inside a section element (14.20), but an addition there is inadmissible (6.5; T6.5-19), so no coincidence arises inside a section construct. The harness asserts the full 12.7 comparator over every emitted edit list (`import-addition` ordering before `target-insertion` on the cross-class coincidence), the coincidence arms making the tie-break a pinned observation rather than one implementation latitude decides. +* **T6.6-5 Delta.** After a build, a file-form move preview reports the derived-file delta both directions: under `generated` the destination's module, companion, and (emission enabled) Markdown paths — nothing recorded there — and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated (6.6); a rename preview on the same workspace reports `[]` in both directions (regeneration rewrites recorded paths in place), and the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`. Record-based, not presence-based, with exact expectations in both directions (12.7: form-exact, paths in byte order): with graph data deleted (T13.3-2's operational definition) — the record then missing, read as empty (11.6: empty before any generation has run), never as unreadable (14.23, T6.6-6) — the same move preview's `generated` is exactly every derived path the post-operation workspace generates, nothing being recorded: the module and companions of every discovered spec source, the moved file's under its destination path, plus each source's Markdown emit destination with emission enabled — composed from the `recorded` set the inventory reported after the build (11.6; the companion suffixes are the product's own, H-4), the moved file's entries re-based to its destination — and its `removed` is exactly `[]`; and the preview still writes nothing: no refresh, graph data still absent afterward. Lagging-record counterpart: with emission enabled in the configuration after the build and no rebuild, the same preview's `generated` is exactly the destination's module, companions, and Markdown together with every other discovered spec source's Markdown emit destination — the paths the current configuration generates that the stale record lacks — and its `removed` exactly the recorded pre-move module and companions. +* **T6.6-6 Unreadable record.** Corrupt the product-written graph data shape-blind (truncation or garbage over T13.3-2's operational path set; H-3/H-4 staging discipline, as T10.1-4 stages sessions): a move `--preview` whose plan is otherwise valid emits the full preview — `mapping` and `files` complete — with `delta` explicitly unavailable as one datum, never read as an empty record, the condition-23 finding (`unreadable-record`, concerned path the graph-data area, no path inside it named) in `findings`, exit 1 (14.23). The real operation on the same state is not refused — it proceeds, its finishing regeneration replacing the corrupt record (`check` clean afterward, T12.2-2) — and a refused preview staged on the same corrupt-record state (an identity-unchanged rename) reports the refusal findings alone, `mapping`/`files`/`delta` `null`, never a condition-23 finding (6.6: a refused preview consults no record). + +### 6.7 Manual restructuring + +* **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 <path>` (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-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-1 Location.** Configuration found by upward search from a nested working directory; `--config <path>` (resolved against the working directory, 12.0) overrides the search; no configuration reachable → configuration error (14.14, exit 2) — by a failed upward search, and by `--config` naming a nonexistent file (missing configuration, 14.14; concerned paths per T12.7-3), the latter never a plain usage error. Occupancy (7, 14.14): the upward search stops at the nearest directory holding an entry named `xspec.config.ts`, whatever occupies it — with a valid configuration at the root and, in the working directory beneath it, an entry `xspec.config.ts` that is a directory, and separately a symbolic link to that valid root configuration, every command but `version` exits 2 with 14.14, the error document's concerned path that entry (`xspec.config.ts`, the working directory's own) — never the file above, never read through the link (a product following the link loads the valid configuration and exits 0, failing the arm); `--config` naming such a directory or link exits 2 likewise. +* **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. The import carries no `type` or `defer` modifier and no import attributes (7): `import type { defineConfig } from "xspec"`, `import { type defineConfig } from "xspec"`, `import defer { defineConfig } from "xspec"`, and `import { defineConfig } from "xspec" with { type: "json" }`, each followed by an otherwise valid `export default defineConfig({…})`, each → 14.14, exit 2 — every one text accepted by TypeScript 5.9.3 both as module code and as script code (14.20), so the declarative form of 7 decides it, never a parse failure; a product reading the binding loosely accepts them all. 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). Verbatim literals (7, 2.4): a glob spelled with an escape sequence (`"specs/\u002A.mdx"`) is read as its characters — matching no discovered file, its group discovering zero sources — and a group name so spelled (`"prod\u0075ct"`) names a group whose spelling contains `\`: a profile's `target: "product"` is then an unknown group (14.14) while `target: "prod\u0075ct"` resolves. Encoding (7, 14.14): a configuration file that is not valid UTF-8, and one beginning with a byte-order mark, each → 14.14, exit 2. Object-literal keys (7, 14.14): a key repeated within one object literal — `specs: {…}, specs: {…}` at top level, and `product: […], "product": […]` inside `specs`, an identifier key and a string-literal key spelling the same name — each → 14.14, whatever TypeScript's own diagnosis of the repetition; an empty name — a spec group `""`, a profile `name: ""`, a rule `name: ""`, one arm each — → 14.14. Comments (7): line and block comments anywhere — before the import, inside the argument between keys and inside a glob list, after the export — contribute nothing: the configuration loads and `inventory`'s `configuration` is byte-identical to its comment-free twin's. +* **T7-3 Keys.** `specs` missing → 14.14. Omitted optional keys, each with its stated observation: `code` — no code groups: a marker-bearing `.ts` file is undiscovered, the unfiltered `query edges` list carries no edge from it, and naming it in `--from` is unknown (exit 2, 11); `markdown` — no emission: no `.md` is written for any source (T3-6); `coverage` — no profiles: `xspec coverage` reports zero profiles and exits 0; `policy` — no rules: `check` on a workspace whose edges would violate T7.5-2's rule, with the rule omitted, reports no policy findings and exits 0. Empty lists (7): `coverage: []` and `policy: []` are valid and equivalent to omitting the key — zero profiles reported, no policy findings. Unknown keys at top level, in `markdown`, in a profile, in a rule, and in a selector each → 14.14. Value shapes (7, 7.1, 7.2; 14.14: a configuration that does not conform, an otherwise invalid group shape) — each → 14.14, one arm each: a spec group and a code group whose value is a single string rather than a list (`product: "specs/**/*.mdx"`); a glob list holding a non-string element (`[true]` — a literal 7 admits in form, never as a glob); `coverage` and `policy` given as an object rather than a list (`coverage: {}`); and `specs` and `code` given as a list rather than a map of groups — discriminating a product that reads the declarative form loosely, accepting whatever its own loader tolerates. Names containing U+FFFD (7, 14.14): a spec group, a profile, and a rule named with a U+FFFD (one arm each) → 14.14, exit 2. +* **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`; and `\` is a literal byte, never an escape (Linux leg, where a file name can hold it): a code group globbing `src/a\*.ts` — the configuration literal read verbatim (2.4, T7-2) — discovers `src/a\b.ts` and not a sibling `src/ab.ts`, where a product reading `\` as a glob escape discovers neither and one interpreting the literal's escape discovers both (the `--file` twin: T12.0-5). Outside-root decision by spelling alone (7): `a/../../x/*.mdx` and `**/../x/*.mdx` (the depth falling below zero) and `/specs/*.mdx` (a leading `/`) each → 14.14, exit 2, even when the root's parent holds a matching file; `a/../b/*.mdx`, `./specs/*.mdx`, `specs//*.mdx`, and `specs/*.mdx/` are inside the root and match nothing — a group holding only such a glob discovers zero sources, exit 0, `inventory` reporting the glob as configured — since discovered paths carry no `.`, `..`, or empty segment; a drive-qualified spelling (`C:/specs/*.mdx`) is ordinary segments, inside and matching nothing on the Linux leg; and `a**b.mdx` treats each `*` as the single-segment wildcard, matching `axxb.mdx` and never `a/b.mdx` (7: `**` means any segments only as a whole pattern segment). * **T7-5 Symbolic links.** A symlinked file matched by a glob is not discovered; a symlinked directory is not traversed (contents undiscovered); broken links ignored; a symlink cycle does not hang discovery; workspace-external content behind a link never enters the discovered set. -* **T7-6 Discovery boundaries.** Derived files are never discovered as sources even when globs match them (`.xspec.` names, `.xspec/` paths, Markdown emit destinations while emission is enabled, 13.4); an import never adds an unmatched file to the workspace (2.1: the target must already be discovered, else 14.15); a group with no matches and an empty `specs`/`code` map are valid with zero sources. -* **T7.1-1 Spec groups.** A file in two spec groups is valid (and coverage/policy see it in both); a spec-group match without `.mdx` → 14.19. +* **T7-6 Discovery boundaries.** Derived files are never discovered as sources even when globs match them (`.xspec.` names, `.xspec/` paths, Markdown emit destinations while emission is enabled, 13.4) — an invalid source's emit destination included, per-source derived paths following the `NAME.mdx` name shape alone (13.1, 7.3). The invalid-source arm stages emission next to sources, a spec glob `specs/*.mdx`, a code group globbing `specs/*.md`, and `specs/a'b.mdx` (holding `<S id="a">A</S>`; invalid by 7.1's bar, T7.1-1) beside a plain file `specs/a'b.md` holding `)`, which is not well-formed TypeScript (14.20). `check` exits 1 with exactly one finding, `specs/a'b.mdx`'s condition 19 — never a condition-20 finding for `specs/a'b.md`, which a product deriving emit destinations for valid sources alone discovers as a code source and reports. Control: with emission disabled the path is no emit destination (7.3), and `check` reports `specs/a'b.md`'s condition-20 finding beside the condition-19 one. An import never adds an unmatched file to the workspace (2.1: the target must already be discovered, else 14.15); a group with no matches and an empty `specs`/`code` map are valid with zero sources. +* **T7.1-1 Spec groups.** A file in two spec groups is valid (and coverage/policy see it in both); a spec-group match without `.mdx` → 14.19. Path characters (7.1): a spec-group file whose workspace-relative path contains `"`, `'`, `\`, U+000A, U+000D, U+2028, or U+2029 → 14.19, one arm per character in the file name (`specs/a'b.mdx`, …) plus one with `'` in a directory component (`specs/it's/a.mdx`) — the `"`, `\`, U+000A, and U+000D arms staged on the Linux leg, as T1.5-2 stages its non-UTF-8 arm — each file still discovered and reachable as T11.2-3's invalid-path files are: the finding concerns its path, and a glob-reached `view` serves its tree with every identity unavailable. Control: a code-group file `src/it's\x.ts` — `'` and `\` in a code source's path — is valid, 7.1 binding spec groups alone (14.19's code-source forms are `#`, U+FFFD, and non-UTF-8): `build` and `check` exit 0, a marker in it recording its edge from that whole-file location, discriminating a product that applies the bar to every source. * **T7.2-1 Code groups.** Code groups act as coverage boundaries and the impacted-code population (asserted in 8/9); a file matched by both a spec and a code group → 14.14. -* **T7.3-1 Markdown config.** `markdown` absent → no emission; `emit: false` → none; `emit: true` → emission next to each source; `markdown` present without `emit` → 14.14 (7.3: `emit` is required when `markdown` is present); `outDir` redirects preserving workspace-relative paths; `outDir` resolving outside the root → 14.14; emit-destination classification follows `emit` (with emission off, a path that would be a destination can be a discovered source; with it on, it cannot — 7.3/13.4, asserted via discovery and via the import rule of T4-2); classification is by configuration alone, "whether or not emission has yet run" (7.3): in a workspace where no emission has ever run — emission enabled, source `specs/A.mdx`, a user-authored file at its destination `specs/A.md`, a spec-group glob matching both — a read command (`ids`, representative; its 13.3 refresh never emits) succeeds treating the destination as no source: the `.md` file is not discovered (no 14.19 from the non-`.mdx` match) and its bytes are untouched, discriminating a product that classifies destinations by existing emitted output rather than by configuration. -* **T7.4-1 Profile validation.** Two profiles sharing a `name` → 14.14 (7.4: unique profile name); a profile lacking any one of the required `name`, `target`, `boundary`, or `mode` fields (one fixture per field) → 14.14; `targets` other than `"leaves"`/`"all"`, `mode` other than `"direct"`/`"transitive"`, `boundaryKind` other than `"spec"`/`"code"` → each 14.14; unknown `target` group name → 14.14; `target` naming an existing code-only group → 14.14 (7.4: `target` must be a configured spec group's name — the wrong-kind reference of 14.14, discriminating against validation over all group names); unknown `boundary` group name → 14.14; `boundaryKind: "spec"` naming a code-only group and `boundaryKind: "code"` naming a spec-only group (the referenced name is not of the required kind, either direction) → each 14.14; empty `targetTags` → 14.14; empty `edgeKinds` → 14.14; `boundaryKind` required when the boundary name is both a spec and a code group (absent → 14.14) and inferred when unambiguous; unknown profile name at `coverage <name>` → usage error (12.0). +* **T7.3-1 Markdown config.** `markdown` absent → no emission; `emit: false` → none; `emit: true` → emission next to each source; `markdown` present without `emit` → 14.14 (7.3: `emit` is required when `markdown` is present); `outDir` redirects preserving workspace-relative paths; `outDir` not in plain workspace-relative form → 14.14, one arm each (7.3): `""`, `"/out"`, `"./out"`, `"out/../x"`, `"out//x"`, and `"out/"`, while `"out/sub"` is valid; an `outDir` naming the graph-data area or a path under it → 14.14, exit 2, one arm each (7.3): `".xspec"` and `".xspec/md"` — while the look-alikes `".xspec2"` and `".xspecs/md"` are valid, emission writing each destination under them (T13.4-8), anchoring 11.6's claim that graph data is the only derived file under the area; emit-destination classification follows `emit` (with emission off, a path that would be a destination can be a discovered source; with it on, it cannot — 7.3/13.4, asserted via discovery and via the import rule of T4-2); classification is by configuration alone, "whether or not emission has yet run" (7.3): in a workspace where no emission has ever run — emission enabled, source `specs/A.mdx`, a user-authored file at its destination `specs/A.md`, a spec-group glob matching both — a read command (`ids`, representative; its 13.3 refresh never emits) succeeds treating the destination as no source: the `.md` file is not discovered (no 14.19 from the non-`.mdx` match) and its bytes are untouched, discriminating a product that classifies destinations by existing emitted output rather than by configuration. +* **T7.4-1 Profile validation.** Two profiles sharing a `name` → 14.14 (7.4: unique profile name); a profile lacking any one of the required `name`, `target`, `boundary`, or `mode` fields (one fixture per field) → 14.14; `targets` other than `"leaves"`/`"all"`, `mode` other than `"direct"`/`"transitive"`, `boundaryKind` other than `"spec"`/`"code"` → each 14.14; unknown `target` group name → 14.14; `target` naming an existing code-only group → 14.14 (7.4: `target` must be a configured spec group's name — the wrong-kind reference of 14.14, discriminating against validation over all group names); unknown `boundary` group name → 14.14; `boundaryKind: "spec"` naming a code-only group and `boundaryKind: "code"` naming a spec-only group (the referenced name is not of the required kind, either direction) → each 14.14; empty `targetTags` → 14.14; empty `edgeKinds` → 14.14; `edgeKinds` not a subset of `["depends", "embeds", "references"]` (7.4; an otherwise invalid profile shape, 14.14) — holding the non-dependency kind `"contains"`, an unknown token (`"depend"`), and a non-string element (`true`), one arm each → 14.14, discriminating a product that accepts and ignores a stray member or lets `contains` grant coverage (8); a non-string `targetTags` element (`[true]`) → 14.14 likewise; `boundaryKind` required when the boundary name is both a spec and a code group (absent → 14.14) and inferred when unambiguous; unknown profile name at `coverage <name>` → usage error (12.0). Set reading (7.4, 12.7): `targetTags: ["z", "a", "a"]` and `edgeKinds: ["references", "depends", "depends"]` are valid — a repeated element collapses — `inventory` reporting `targetTags` exactly `["a", "z"]` (byte order, collapsed) and `edgeKinds` exactly `["depends", "references"]` (5.2's order, however configured), and the profile's coverage report equal to its `["a", "z"]`/`["depends", "references"]` twin's (T11.6-2). * **T7.4-2 Profile semantics.** `targets` defaults to `"leaves"`; `"all"` includes internal nodes; `targetTags` restricts to nodes carrying at least one listed tag; `edgeKinds` defaults to all three and restricts paths when given (each via coverage runs, 8). -* **T7.5-1 Rule validation.** Two rules sharing a `name` → 14.14 (7.5: unique rule name); a rule lacking any one of the required `name`, `type`, `from`, or `to` fields (one fixture per field) → 14.14; `type` other than `"forbidden"`/`"allowedOnly"` → 14.14; empty `kinds` → 14.14; empty selector `tags` → 14.14; a selector with zero or two of `group`/`files`/`tags` → 14.14; an unknown group name in a selector → 14.14; a selector `kind` mismatching its group's kind (`kind: "spec"` naming a code-only group; `kind: "code"` naming a spec-only group) → each 14.14; ambiguous group name without `kind` → 14.14; a capture wildcard appearing more than once in `from` → 14.14 (a capture violation); `to` referencing a capture absent from `from` → 14.14. +* **T7.5-1 Rule validation.** Two rules sharing a `name` → 14.14 (7.5: unique rule name); a rule lacking any one of the required `name`, `type`, `from`, or `to` fields (one fixture per field) → 14.14; `type` other than `"forbidden"`/`"allowedOnly"` → 14.14; empty `kinds` → 14.14; `kinds` not a subset of the dependency edge kinds (7.5; an otherwise invalid rule shape, 14.14) — holding `"contains"`, an unknown token (`"depend"`), and a non-string element (`true`), one arm each → 14.14; empty selector `tags` → 14.14; a non-string selector `tags` element (`[true]`) → 14.14; a selector with zero or two of `group`/`files`/`tags` → 14.14; an unknown group name in a selector → 14.14; a selector `kind` mismatching its group's kind (`kind: "spec"` naming a code-only group; `kind: "code"` naming a spec-only group) → each 14.14; ambiguous group name without `kind` → 14.14; a capture wildcard appearing more than once in `from` → 14.14 (a capture violation); `to` referencing a capture absent from `from` → 14.14. Set reading (7.5, 12.7): a rule's `kinds: ["embeds", "depends", "embeds"]` and a selector's `tags: ["b", "a", "b"]` are valid, `inventory` reporting `kinds` exactly `["depends", "embeds"]` and the selector's `tags` exactly `["a", "b"]`, and the rule's `check` findings equal to its collapsed twin's. * **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 +342,7 @@ All category tests run `impact --base <ref>` 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 <name>` runs one; counts of required/covered/uncovered/ignored; identity of every covered, uncovered, and ignored node; one shortest covering path per covered node with the 12.0 tie-break (fixture with two equal-length paths asserts the byte-least one); ignored nodes report all applicable reasons in the fixed order (a node that is simultaneously `coverage="none"`, non-leaf, and untagged; and a root with children in a profile with `targets: "leaves"` and `targetTags` — reasons `root node`, non-leaf, and lacking every tag, pinning the `root node` reason's position 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 <name>` runs one; counts of required/covered/uncovered/ignored; identity of every covered, uncovered, and ignored node; one shortest covering path per covered node with the 12.0 tie-break (fixture with two equal-length paths asserts the byte-least one); ignored nodes report all applicable reasons in the fixed order (a node that is simultaneously `coverage="none"`, non-leaf, and untagged; and a root with children in a profile with `targets: "leaves"` and `targetTags` — the root-node reason, non-leaf, and lacking every tag — each reason adapter-located information, never exact wording (H-3: 8.2 names the reasons in prose; the fixed order is the contract) — pinning the root-node reason's position against those two; the fixed order's remaining pair, the root-node reason relative to `coverage="none"`, is unobservable on any node — reasons are reported per node, and no node bears both, a root carrying no coverage attribute (5.5) — recorded here as T6.5-6 records its unstageable clauses); `--check` exits 1 iff any required node is uncovered (0 otherwise); `--json` carries the same information (adapter-asserted equality of information). ## 9. Impact Analysis @@ -310,10 +361,12 @@ All category tests run `impact --base <ref>` against a committed baseline and as ### 10.1 Sessions -* **T10.1-1 Storage.** On a freshly built workspace, `review create` writes exactly `.xspec/reviews/<name>.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/<name>.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. +* **T10.1-6 Session-directory and area occupancy; `create`'s ordering.** `.xspec/reviews/` holds sessions only while a directory occupies its path (10.1, 13.4, 14.22). On a freshly built valid workspace, `.xspec/reviews` staged as a plain file, and separately as a symbolic link to a real directory outside the area holding a valid product-written session `s.json`: `review list` reports no sessions, exit 0; `review status s` exits 2 (unknown session — no command lists through the occupant); `check` is clean, exit 0 (14.22 is `create`'s finding there, never `check`'s or the gate's); `inventory` reports `sessions` `[]`, no finding; `ids` answers, exit 0; and `review create --strategy audit --name n` exits 1 with exactly one finding, condition 22, concerned path `.xspec/reviews`, `locations` `[]`, no session written — the plain file byte-unchanged, the link and its target byte-identical, nothing written through it. Ordering (13.5, 14.22: judged after the gate and refresh): the same `create` on a stale twin (a section's text edited after `build`) still exits 1 with that one finding and graph data has been refreshed — `check` afterwards reports the edited source's per-file staleness and no unit-form finding — where a product examining the session directory before refreshing leaves graph data stale (the unit form then reported); the existing-name refusal (10.7) and the corrupt-existing-name refusal (14.21, T10.1-4) follow the refresh identically: `review create --strategy audit --name s` on a stale twin holding a valid `s`, and on one holding a corrupt `s`, each exit 1 with one finding — the code-less refusal, or the condition-21 finding in its place — graph data refreshed and no session file written or changed. The graph-data area's own path: `.xspec` staged as a plain file, and separately as a symbolic link to a directory holding a journal and valid sessions — `inventory` reports `journal.occupied` `false`, `sessions` `[]`, and `recorded` unavailable with the condition-23 finding (concerned path `.xspec`), exit 1, and that finding alone (11.6, 14.23); `build`, `ids`, and `review list` each report exactly one finding, condition 22 concerning `.xspec` (14.22: a directory component of graph data's write path; the gate for the reads, 13.3), exit 1, nothing written and the link's target byte-identical; `check` reports that finding and, beside it, condition 10 in the unreadable-record unit form (14.10, 14.23: reported whatever the workspace's validity) and nothing else; `view` of a clean file answers finding-free, exit 0 (T11.2-6). ### 10.2 Items @@ -330,16 +383,16 @@ All category tests run `impact --base <ref>` 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. ### 10.5 path-blocks -* **T10.5-1 Generation.** SPEC.md §15's worked change (leaf text edit) yields exactly the four listed items with specified scope/context/origin. Extended fixture: a `changed` node with a `changed` ancestor generates no own item (skipping rule); scope of `subtree-coherence` is the node plus all descendants; multiple changed nodes sharing an ancestor A yield one `parent-consistency` item for A against the union of branches. +* **T10.5-1 Generation.** SPEC.md §15's worked change (leaf text edit) yields exactly the four listed items with specified scope/context/origin. Extended fixture: a `changed` node with a `changed` ancestor generates no own item (skipping rule); scope of `subtree-coherence` is the node plus all descendants; multiple changed nodes sharing an ancestor A yield one `parent-consistency` item for A against the union of branches; for a change two levels beneath A (A → B → C, only C `changed`), A's `parent-consistency` item's context is exactly `{B}` — the branch head, A's child on that branch (10.5: each changed branch enters as one context node) — never `C`, while B's item's context is `{C}`, the identities asserted (T10.4-1's deep-edit sensitivity presupposes this context node). * **T10.5-2 Blocking chains.** A's `parent-consistency` item is blocked by, per changed branch, the child's `subtree-coherence` item (child is the changed node) or the child's `parent-consistency` item (deeper change); chains extend to the root; only those two kinds block `parent-consistency` items; `metadata-consistency`, `dependency-consistency`, and `code-impact` items have empty `blockedBy`. -* **T10.5-3 Metadata/dependency/code items.** One `metadata-consistency` per `metadata-changed` node (context: added and removed `d` targets; `coverage`/`tags` changes described in `reason`); one `dependency-consistency` per node with a dependency edge to a target present on both sides of the baseline (5.6) whose effectiveHash changed (context: those targets; origin: originating nodes) — an edge to a target added since the baseline yields no such item, the change being reviewed at its source (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). @@ -352,64 +405,109 @@ All category tests run `impact --base <ref>` against a committed baseline and as ### 10.7 Commands -* **T10.7-1 create flags.** Exactly one of `--base`, `--strategy audit`, `--coverage` required: none, two, or `--strategy` with any other value → exit 2. `create` with an existing name refused (exit 1). Missing `--name` → exit 2. `--coverage` naming no configured profile → exit 2 (an unknown profile named in arguments, 12.0), nothing created — no session file exists and `list` reports no such session (10.7: `create` records the profile's resolved definition, so an unknown profile leaves nothing to record). +* **T10.7-1 create flags.** Exactly one of `--base`, `--strategy audit`, `--coverage` required: none, two, or `--strategy` with any other value → exit 2. `create` with an existing name refused (exit 1, one code-less finding, nothing modified); naming an existing corrupt session (T10.1-4's stagings), the corruption stands in the refusal's place — exactly one finding, condition 21 `corrupt-session`, no code-less refusal beside it, exit 1 (10.7). Missing `--name` → exit 2. `--coverage` naming no configured profile → exit 2 (an unknown profile named in arguments, 12.0), nothing created — no session file exists and `list` reports no such session (10.7: `create` records the profile's resolved definition, so an unknown profile leaves nothing to record). * **T10.7-2 Recorded parameters.** A `coverage` session records the profile definition with group names replaced by glob lists and kind: after `create`, renaming the profile or editing its group's globs in configuration does not change session behavior (recorded globs still matched against currently discovered sources); a file no longer in any configured group leaves the session's view (as if deleted). A baseline session records the commit (T10.5-6); an audit session records none. * **T10.7-3 Unresolvable baseline.** `create --base` with an unresolvable ref, and any later `review` command whose recorded baseline can no longer be reconstructed, fail per 6.3 as exit 2, modifying nothing. * **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 <name> <item-id>` 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 <glob>`, `--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-2 nodes.** Filters `--group`, `--file <glob>`, `--tag`, `--coverage` combine conjunctively; `--coverage` matches no root; each row carries identity, source range, tags, coverage attribute (absent for roots); a `--file` pattern outside the workspace root by spelling — `../x/*.mdx`, `a/../../x`, `/specs/*.mdx` — → exit 2 (invalid flag value, decided as 7 decides a configured glob, T7-4), while an inside pattern matching nothing (`./specs/*.mdx`, `specs//*.mdx`) → exit 0 with no rows; `--group` naming a code group → exit 2 (invalid flag value — the wrong-kind group reference of 14.14, 11). `--tag` acceptance is syntactic (11.1, as `occurrences --to`): a well-formed tag no node carries matches nothing — exit 0, an empty row set — while a spelling no tag can have exits 2 as a malformed value: the empty string, a whitespace-bearing spelling (`'a b'`), `#`, a forbidden name (`then`), a control character, `"`, `'`, `\`, `&` (one arm each), and U+FFFD (12.0's argument-value rule) — each of the syntax class, reported without loading configuration (T12.0-10). * **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-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 read as a set (11.1): an unknown kind value → exit 2; an empty element — `--kinds depends,`, `--kinds ,depends`, `--kinds depends,,embeds` — → exit 2 (invalid flag value); a repeated element collapses — `--kinds depends,depends` answers byte-identically to `--kinds depends` (T12.0-14). * **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`, `query subtree`, `query ancestors` (11.1: `<node>` is a requirement-node identity for all three), and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. Unknown code units (12.0: an unknown node identity named in arguments, the check judged parse-local over the named file's named units, 4.6): on a discovered code source, `query edges --from <path>#<unspelled-unit>` → exit 2 — likewise `edges --to` and `reachable --from`/`--to` given the same identity — and an out-of-range disambiguator, `<path>#<unit>@2` where the chain occurs once in the file (4.6), is equally unknown, exit 2 — as is `<path>#<unit>@1` at every occurrence count: 4.6 suffixes only occurrences after the first, so no occurrence bears `@1` — the first occurrence's identity is the bare `path#unit`, and identities compare byte-wise (12.0) — staged where the chain occurs once and where it occurs twice, the two-occurrence arm discriminating a product that resolves `@1` to the first occurrence: never a bare edgeless graph node with an empty answer, exit 0, and never a resolved answer (the failing-workspace arm: T12.0-10). Unit names 1.4 forbids as ID segments (4.6; 12.0: a code unit is judged over the file's named units, never by 1.4's segment rules): `query edges --from <path>#C.constructor` — a class `C` whose constructor holds a marker (T4.6-1) — and `--from <path>#C.then`, a method named `then`, each answer with that unit's edges, exit 0; a product pre-validating every `<graph-node>` spelling with 1.4's forbidden-name rule, as `occurrences --to` applies it to requirement identities (T11.3-3), fails. * **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` (`<S id>`, T2.7-3's arm), and no `id` → each spells none, identity explicitly unavailable, the accompanying finding 14.17 for the three invalid forms — never 14.1 for the valueless one — and 14.1 for the absent one (T2.7-3, T1.3-1); two sections both spelling `x` → both unavailable, no winner, while a uniquely spelled `x.y` beneath one of them keeps its defined identity (a defined identity without defined prefix identities); descendants of a no-identity or malformed-identity section are undefined by inheritance; a section uniquely spelling `z` stays defined beside another section's invalid-form `id` attributes (uniqueness compares spelled identities only — an invalid form contests nothing). Interpreted tags and coverage: absent props define the defaults (no tags, coverage-required); a repeated, malformed, or invalid-valued `tags`/`coverage` leaves the interpreted value unavailable, its raw spelling still listed (T11.4-3). +* **T11.2-3 Invalid paths.** (Linux leg) A discovered spec source `a#b.mdx` and a non-UTF-8-named one (14.19): every node identity in each — root included — is explicitly unavailable while tree, ranges, and attributes stay on view; the condition-19 finding accompanies every answer whose domain includes the file; no identity over the invalid path is ever emitted, the non-UTF-8 path itself presented in the marked byte form (12.0, T12.7-1). A code source with `#` in its path defines no identity for its whole-file location or any unit: its spellings still record occurrences, each record's `source` explicitly unavailable (5.7, T11.3-1). Root identity is defined exactly when the file's path is valid. +* **T11.2-4 Resolution and expanded text.** Resolution turns on the referenced identity's own definedness: with duplicate spellings of `a` and a unique `a.b` beneath one bearer, a reference to `a.b` resolves and records its occurrence while a reference to `a` records none — ambiguous, every bearer undefined — reported by its finding's range, never as a record or an unavailable target. Source-side unavailability (5.7, 11.2): resolving spellings themselves live in undefined-identity sections — a `d` entry naming `a.b` on the other duplicate bearer of `a`, and a `{text("a.b")}` embedding inside a section spelling no identity (`id` absent) — and each still records its occurrence: the record carries `file`, its own `range`, `kind`, and `target` (`a.b`), with `source` exactly the unavailability marker — identity and range withheld together as one datum (12.7; enumerated so in T11.3-1), never a picked bearer's identity and never a dropped record — while the view still positions each enclosing construct, its identity unavailable (11.4, T11.2-2); the file's findings — the duplicate-`id` and missing-`id` conditions among them — accompany, exit 1. Expanded text via `view --text`: a chain A embeds B embeds C with an unresolved embedding in C → A's and B's own/subtree text unavailable (one unresolved spelling on the expansion path, or one embedding cycle — staged separately — poisons the whole value; partial expansion never occurs), sibling nodes with resolved expansions staying defined and byte-exact; removal classification is by syntactic form — after deleting an imported file, the importing file's text values are byte-identical to before (the import removed by form, its 14.15 finding notwithstanding), and a stray element (14.16) is content, preserved byte-for-byte in the enclosing text and located by its finding. Enclosure (11.2: a stray element is preserved by its own tags, the sections and embeddings it encloses classified by their own forms): `<div><S id="x">t</S>{text("y")}</div>` staged in flow position at the root level, `y` a section of the same file — exactly one condition-16 finding, `<div>` through `</div>`; node `x` in the view's tree as a child of the root (T11.4-1); the embedding's occurrence recorded with the root as its `source`; and under `view --text` the root's own text carrying `<div>` and `</div>` with `x`'s whole contribution excised (1.6) and the embedding expanded to `y`'s subtree text, its subtree text carrying `x`'s text `t` in place with `x`'s tags removed — each byte-asserted. +* **T11.2-5 Domain, findings, exits.** `view` naming only C (T11.2-1's finding-free file) → finding-free, exit 0, while A and B stay invalid — the domain is the requested files; naming A → A's findings of both levels accompany, exit 1, the full answer still emitted (the document complete and parseable, H-5); a two-file cycle accompanies whole when either participant is in the domain (14.9). Any finding or explicitly-unavailable datum → exit 1 with the full answer; complete and finding-free → exit 0; argument checks precede answering — unknown `<file>`, wrong-kind `<file>`, invalid glob, malformed `--to`, out-of-range offset each exit 2 whatever findings the named files carry (per-surface arms in T11.3-2/3, T11.4-2, T11.5-2). +* **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. An obstructing component that is itself a discovered file attaches nothing (11.2): with `markdown: { emit: true, outDir: "specs/A.mdx" }` — every emit destination lying below the discovered spec source `specs/A.mdx`, a plain file — `build` and `check` report condition 22 concerning `specs/A.mdx`, exit 1, while `view specs/A.mdx` answers finding-free, exit 0. + +### 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 (`../x`, `a/../../x`) → exit 2 (invalid flag value, as 11.1), an inside pattern spelled with a `.` or empty segment (`./specs/*.mdx`) admitting the empty set, exit 0; `--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 segment containing `"`, `'`, `\`, or `&` (`a.mdx#x&y`, `a.mdx#x\y`, one arm each of the four), a trailing empty id part (`a.mdx#`), and any spelling containing U+FFFD — in the path part or the id part — a malformed argument value before any identity reading (11.3, 1.4, 12.0). 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). List orders (12.7): on a file holding several import declarations, several MDX comments, and several reference occurrences, `imports`, `comments`, and `occurrences` are each in document order, form-exact (H-3) — asserted here because P-12 sorts the view's occurrences itself and cannot see a misordered list. +* **T11.4-2 Operands vs restriction.** `<file>` operands assert membership: an undiscovered file → exit 2 (unknown); a discovered code source → exit 2 (wrong-kind operand, 12.0); `--file` restricts the domain: a glob matching nothing (`./specs/*.mdx` included — inside the root, matching nothing, 7), or only code sources, admits the empty set — empty, finding-free answer, exit 0 — and an outside-root glob (`a/../../x`) → exit 2; combining `<file>` operands with `--file` → exit 2; the requested files form a set (a file named twice yields one view). +* **T11.4-3 Attributes and per-node data.** Raw attribute spellings as parsed, one entry per spelled attribute in tag order — a repeated `id` (both entries), an unknown prop, a spread attribute (`name` structurally absent, its text the whole braced construct), a valueless prop (bare name: `tags`, staged as T2.7-3's `<S id="x" tags>` so build and view share one fixture, its 14.17 finding beside the view the condition the build reports) — each with range and source text; inclusion is by form, the invalidity a located finding beside the view, never an omission (14.17). Per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2; a root's `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: a finding-free file's view exits 0 with them `null` (11.4, 12.7). Tag-set form (12.7): a section spelling `tags="b a a"` reports `tags` exactly `["a", "b"]` — byte order, duplicates collapsed — and one spelling `tags="z A"` exactly `["A", "z"]` (bytes, never case-folded), the same form carried by `query node` and `show --json` through the H-3 decode (T12.7-1). +* **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; a `.xspec` specifier designating a discovered code source — an `.mdx` file matched only by a code group, 2.1), the invalidity a located 14.15 finding beside it (11.4). The target is the discovered spec source the specifier designates under 2.1, parseable or not: an import of an unparseable spec source `B` reports the target `specs/B.mdx`, the import valid (no 14.15), B's condition-20 finding accompanying only when B is itself requested (11.4: a masked file is never consulted by an expansion — an embedding into it is then A's own unresolved finding, 14.6); and a non-canonical specifier (`./sub/../BASE.xspec`) reports the designated `specs/BASE.mdx` (T2.1-2). A semicolon-terminated declaration's range ends after its `;` — the terminator among the declaration's own characters (14.20; T3-7's removal arm) — byte-asserted against precomputed offsets. +* **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). `<file>` 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 a malformed value or an unknown file, exit 2 — and so is a U+FFFD-pathed source (either leg): `at 'specs/A�.mdx' 0` is a malformed value (12.0's argument-value rule), exit 2, never the unknown-file error and never an answer — the glob-reached view being the one route to either file's positions (T11.2-3; 11.5). + +### 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. Physical anchoring (11.6), Linux leg: with the root at `R` holding `a/b`, a working directory reached through a symbolic link `R/L` → `R/a/b` reports `root` `../..` and `config` `../../xspec.config.ts` — the physical relation between the working directory and the root, never the link's lexical `..`; a link above both (`/elsewhere/link` → `R`, the working directory `/elsewhere/link/a`) reports `..`, unchanged; the configuration file enters as its own name under the root so spelled. +* **T11.6-2 Configuration, sources, derived map.** The resolved view with every default and inferred kind explicit: `markdown` absent → `{"emit": false, "outDir": null}`; a defaulted profile → `targets` `"leaves"`, `edgeKinds` all three, `boundaryKind` explicit though inferred, `targetTags` `null`; a rule with `kinds` omitted → `kinds` all three dependency kinds (7.5), and a group selector without `kind` naming an unambiguous group → its inferred `kind` reported explicitly, never `null` (12.7: every default and inferred kind explicit); group references inside profiles and rules stay configured names resolving against the reported group list; every discovered source with its group memberships (a two-group file); the derived map per spec source — module path, and Markdown destination exactly while emission is enabled, both present before any build has run (determined by configuration and discovery); a spec-group file without `.mdx` (14.19 staged beside it) → both structurally absent, while an `.mdx` source whose path is invalid keeps both (13.1: per-source derived paths follow the `NAME.mdx` name shape alone; 11.6 answers whatever the sources' validity) — `specs/a'b.mdx` (7.1's bar, T7.1-1) → module `specs/a'b.xspec.ts` and, with emission next to sources, Markdown `specs/a'b.md`; on the Linux leg, a non-UTF-8-named `.mdx` source (T1.5-2) → its entry's `source`, `module`, and `markdown` each in the marked byte form (12.0, T12.7-1), `module` carrying the source's bytes with the final `.mdx` replaced by `.xspec.ts` and `markdown` with it replaced by `.md` — failing a product that derives paths for valid sources alone; with emission disabled → `markdown` `null` for every source (7.3, 12.7). Configured sets in their value forms (12.7, 11.6): `targetTags: ["z", "a", "a"]` → `["a", "z"]`, `edgeKinds: ["references", "depends"]` → `["depends", "references"]`, a rule's `kinds: ["embeds", "depends"]` → `["depends", "embeds"]`, and a `tags` selector `["b", "a", "b"]` → `["a", "b"]` (T7.4-1, T7.5-1), form-exact. +* **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) — each entry exactly the literal workspace-relative path `.xspec/reviews/<name>.json` (12.7: the fixture's `.xspec/reviews/S.json`, byte-asserted); `notes.txt` and `.foo.json` never (10.1); and none below a non-directory at the session directory's or the area's own path (T10.1-6). 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 <path>`; a relative path resolves against the working directory, not the workspace root. * **T12.0-4 Flag repetition.** Repeating a flag on any command → exit 2; list-valued flags take one comma-separated value (`--kinds depends,embeds`). -* **T12.0-5 Argument addressing.** `<node>`, `<graph-node>`, `<file>`, and `--file` arguments are workspace-relative with `/` separators, independent of the working directory (run each representative command from a subdirectory); `--test-hold <path>` 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-5 Argument addressing.** `<node>`, `<graph-node>`, `<file>`, and `--file` arguments are workspace-relative with `/` separators, independent of the working directory (run each representative command from a subdirectory); `--test-hold <path>` resolves against the working directory (13.5). Native-separator and normalization negatives (12.0: read as spelled, compared byte-wise, no normalization): an argument spelled with `\` (`specs\A.mdx`), with a `.` segment (`./specs/A.mdx`), or with an empty segment (`specs//A.mdx`) names no workspace file — discovered paths carry no `.` or empty segment (7), and `\` is an ordinary byte, no separator, so `specs\A.mdx` names only a discovered file of that very path, none staged here, never `specs/A.mdx` (12.0) — and is an unknown-file usage error, exit 2 (`show` and `view` as representatives); the `\` arm discriminates on the Windows leg (E-6), where `\` is the native separator, the other two on either leg. The positive side of `\` (12.0; Linux leg, where a file name can hold it): with a discovered spec-group file `specs/a\b.mdx` staged — an invalid source path (condition 19, 7.1) — `view 'specs/a\b.mdx'` and `at 'specs/a\b.mdx' 0` name that file, membership holding and its identities unavailable, exit 1 with the condition-19 finding (T11.2-3), never the unknown-file exit 2 — as T12.0-13 does for `#`; and with a valid code source `src/a\b.ts` staged beside `src/ab.ts`, each marking a node, `occurrences --file 'src/a\b.ts'` lists the first file's occurrence alone and `occurrences --file 'src/a\*.ts'` likewise — `\` a literal byte of the pattern, never an escape of `*` (7) — where a product reading it as an escape matches neither file (the configured-glob twin: T7-4). Malformed values (12.0, the value-level rule): an argument value that is not valid UTF-8 (raw bytes in the OS argument vector, Linux leg) → usage error, exit 2; an argument value containing U+FFFD — stageable on both legs — → usage error, exit 2, in every position: a `<node>` (`show 'specs/A.mdx#a�'`), a `<file>` operand (`view 'specs/A�.mdx'`), a `--file` glob, a `--tag`, a `--to`, a `<new-id>` (`rename specs/A.mdx a 'b�'`, never `refused-invalid-id`, 6.4), a session name, a `--note` text, a `--base` ref, and a `--config` or `--test-hold` path (the free-text and foreign values 12.0 names as the only ones ever refused for a genuine U+FFFD) — each of the syntax class, reported without loading configuration (T12.0-10), the error document's `code` `null`. +* **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. The exceptions are exactly 12.0's two: the create-time session-name collision (T10.1-2) and 6.5's match of a JSX pragma's name regardless of ASCII case (T6.5-22's `/* @JSX h */` arm). * **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; a write the environment refuses, 14.24, T14-9; a read it refuses, 14.25, T14-10). +* **T12.0-10 Argument-check precedence.** Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, kind, and masking) and T6.3-4. Gated reads (12.0): on one workspace failing `build`'s validations, each gated read given a usage-error argument exits 2 with that error and reports no validation findings — `coverage <unknown-profile>`; `query nodes --group <code-group>`; `review status <unknown-session>`; `show <file>#<unspelled-id>`, `query node <code-source-path>`, and `query edges --from <code-source-path>#<unspelled-unit>` (T11-6) — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file, 11.2, 4.6), with the same names on a valid twin workspace giving the same exit-2 errors. Masking: `show <unparseable-file>#<id>` on the failing workspace → the gated report, exit 1 (as T6.4-4). Past the gate: on a passing workspace, `review resolve <corrupt-session> <any-item-id> --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (10.1; an unknown item ID in a well-formed session stays exit 2, T10.7-10). Within class 2, the syntax class (12.0: every error the arguments alone determine) is reported without loading configuration — identically with the workspace's configuration file invalid or missing, the error document's `code` `null` — one arm per member: an unknown command, subcommand, or flag (`--name=value` included, T12.0-14); a repeated flag; a missing required flag or argument (`review create --name n` with none of `--base`, `--strategy audit`, or `--coverage`, 10.7; `at` given `<file>` alone, 11.5; a value-taking flag as the last token); a surplus operand (`ids extra`; a fourth `rename` operand); a malformed value (`show a#b#c`, the multi-`#` spelling of T12.0-13; a U+FFFD-bearing value, T12.0-5); `--status bogus`; `--strategy bogus`; `query nodes --coverage bogus`; a `--kinds` element outside its vocabulary or empty (`--kinds depends,`); `review create` with two of its exactly-one-of flags; `--test-hold` beside `--preview`; a `<file>` operand beside `--file` on `view`; a session name outside the form of 10.1 (`.x`); an `<offset>` spelled `+7`; a `--to` malformed as an identity and a `--tag` malformed as a tag (`a\b`; and, one arm each, a `--tag` spelled with U+2028 and a `--to` whose id segment carries U+2029, each malformed under 1.4's quote-and-escape bullet, 11.1, 11.3); and a `--file` pattern outside the workspace root (`../x`, decided by spelling alone, 7) — while a configuration error precedes every check that consults configuration or discovery: `coverage <unknown-profile>` and `query nodes --group <code-group>` with invalid configuration each report 14.14, not the unknown or wrong-kind name (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 `<node>`, `<graph-node>`, `--to`, or move-operand value (`a#b#c`) is a malformed value — exit 2 on `show`, `query node`, `occurrences --to`, and `move` (12.0). A bare `<file>` operand or `--file` glob is a whole path or pattern with no delimiter role for `#`: with a discovered source `specs/a#b.mdx` staged (Linux leg; condition 19), `view specs/a#b.mdx` names that discovered file — membership holds, the view served with identities unavailable, exit 1 (T11.2-3), never a `specs/a` + `b.mdx` pair (which would be exit 2, unknown file); `at specs/a#b.mdx 0` resolves the same way, and `occurrences --file 'specs/a#*'` matches it as a pattern. +* **T12.0-14 Invocation grammar.** 12.0's token rules, each arm discriminating a lenient parser. The remaining tokens MUST match the synopsis exactly: `ids extra` and `rename specs/A.mdx a b c` (a fourth operand) exit 2 with nothing done — a surplus token is never accepted and ignored, so no rename occurs, journal and sources byte-unchanged — and `xspec` alone (no command word) and `query bogus` (an unknown subcommand) exit 2. `--name=value` is no flag spelling: `review create --strategy audit --name=n` exits 2 as an unknown flag, no session created. Flag tokens stand anywhere: `xspec --json ids` and `xspec ids --json` emit byte-identical documents, and `xspec --config cfg/xspec.config.ts build` loads that configuration. A value-taking flag takes the whole next token whatever it looks like: `ids --file --json` runs with the glob `--json` — matching nothing, an empty listing, exit 0 — and JSON out of effect, stdout not a JSON document; `ids --config --json` names the path `--json`, which nothing occupies — exit 2, missing configuration, stdout empty (JSON out of effect, the diagnostic on stderr) — where `ids --json --config` exits 2 with the error document on stdout (`--config` lacking its value, `--json` read as a flag), the pair discriminating a parser that recognizes `--json` in a value position; a value-taking flag as the last token (`ids --file`) exits 2. No single-dash short forms: `review create --strategy audit --name -a` creates the session `-a` (`.xspec/reviews/-a.json`, a valid name, 10.1) and `resolve … --note -x` stores the note `-x`, while `ids -j` exits 2 as a surplus operand, never as a flag. `--` ends flag reading and is dropped: `ids --` behaves as `ids`, and `ids -- --json` exits 2 with the surplus operand `--json`, JSON out of effect (stdout empty). A flag's arity is fixed by its name across commands, and a `--` token naming no flag of any command takes no value: `build --file --json` exits 2 (`--file` unknown to `build`) having consumed `--json`, stdout empty; `build --bogus --json` exits 2 with the error document on stdout (`--bogus` takes no value, `--json` a flag); `build --json --file` exits 2 with the error document too. A repeated `--json` is a usage error that still puts JSON in effect: `ids --json --json` exits 2 with the error document as its entire stdout. List-valued flags (11.1): `--kinds depends,`, `--kinds ,depends`, and `--kinds depends,,embeds` each exit 2 (an empty element), while `--kinds depends,depends` collapses to the set `{depends}`, its answer byte-identical to `--kinds depends`'s (T11-4). ### 12.1 `xspec build` * **T12.1-1 Products.** A successful `build` parses and validates sources, resolves dependencies, writes generated modules (13.1), emits Markdown when enabled, and writes graph data under `.xspec/` (existence plus downstream commands succeed without rebuilding). * **T12.1-2 No policy.** T7.5-6. -* **T12.1-3 Regeneration and orphan removal.** After removing a source file (or disabling emission), `build` removes the derived files it no longer generates (module, companions, Markdown); after renaming a source, old derived paths disappear and new ones appear. +* **T12.1-3 Regeneration and orphan removal.** After removing a source file (or disabling emission), `build` removes the derived files it no longer generates (module, companions, Markdown); after renaming a source, old derived paths disappear and new ones appear (the occupants such a removal leaves in place: T13.4-11). * **T12.1-4 Failed build modifies nothing.** Against a workspace with prior derived state: introduce a validation error, run `build` (exit 1) — every derived file and all graph data byte-identical to before; same for a configuration error (exit 2). ### 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. +* **T12.2-4 Staleness and policy on a failing workspace.** 14.10 and 14.12 confine themselves on a workspace failing `build`'s validations. From a freshly built valid workspace with a policy rule and an edge violating it, four stagings, each then given one validation error (an unresolved `d` reference in another file) and run through `check`: (a) a generated module hand-edited — the per-file mismatch form is undetectable there: the validation finding and no condition 10; (b) recorded derived paths orphaned (their source dropped from the configuration's groups without a rebuild) — the recorded-file form is reported: the validation finding beside one condition-10 finding per orphaned path, concerning it — the dropped source's module, each companion `inventory`'s `recorded` set lists for it after the build (T11.6-3), and its Markdown where emitted — and no other condition-10 finding, the mismatch forms undetectable there (14.10: one finding per such path; this form compares the record against the set of generated paths, the discovered sources, and each recorded path's occupant, all defined on any workspace); (c) the record corrupted shape-blind (T6.6-6's staging) — the unreadable-record unit form beside the validation finding, no mismatch form; (d) the violating edge alone beside the validation error — no condition 12 (14.12, 7.5: no violation is detectable on a failing workspace), the violation reported once the validation error is repaired (T7.5-2). Exit 1 throughout; `build` on each failing staging modifies nothing (T12.1-4). ### 12.3 `xspec ids` -* **T12.3-1** IDs grouped by file — files in byte order of workspace-relative path, IDs within a file in document order (12.3; fixture where document order differs from byte order of the IDs and file byte order differs from configuration order); `--tree` renders per-file nesting in the same file and document order; `--file <glob>` restricts (glob rules of 7, outside-root → exit 2); restricted `--tree` (12.3): under a restriction listing a node but not its parent (`--unreferenced` with a referenced parent of an unreferenced child), the node nests under its nearest listed ancestor, or at its file's top level when no ancestor is listed — the tree contains exactly the listed IDs; `--json` parity. +* **T12.3-1** IDs grouped by file — files in byte order of workspace-relative path, IDs within a file in document order (12.3; fixture where document order differs from byte order of the IDs and file byte order differs from configuration order); `--tree` renders per-file nesting in the same file and document order; `--file <glob>` restricts (glob rules of 7: `a/../../x` outside the root → exit 2; `./specs/*.mdx` inside, matching nothing → an empty listing, exit 0); restricted `--tree` (12.3): under a restriction listing a node but not its parent (`--unreferenced` with a referenced parent of an unreferenced child), the node nests under its nearest listed ancestor, or at its file's top level when no ancestor is listed — the tree contains exactly the listed IDs; `--json` parity. * **T12.3-2 --unreferenced.** Lists only nodes with no incoming `depends`/`embeds`/`references` edges — `contains` does not count (a parent whose child is referenced still lists when itself unreferenced); demonstrates unreferenced ≠ uncovered: a node referenced from outside any coverage boundary is referenced yet uncovered in a given profile. ### 12.4 `xspec show` @@ -418,7 +516,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 and derived path (T11.6-2), 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 — `identities` exactly one element, the called module's root identity (`["specs/B.mdx"]`), or exactly `[]` where that module's path is invalid (14.11; T4.4-1) — a refusal reason its concerned identity (T14-7). The remaining value forms of 12.7 bind the same way — universal, form-exact, on pinned and decoded surfaces alike (H-3) — and are asserted where their data arise, indexed here so this entry names every 12.7 value form: an identity is a string (1.5; T1.5-1), never the byte form (12.0; T11.2-3). A coverage attribute, interpreted (11.2), is exactly the string `"required"` or `"none"`: a non-root section spelling no `coverage` attribute reports `"required"` (the interpreted default, T11.2-2) and one spelling `coverage="none"` reports `"none"` — asserted on the `view` node (11.4; the plain state of T11.4-3) and, through the H-3 decode, on `query node` (11.1) and `show --json` (12.4; T12.4-1's non-root arm), so a product encoding the attribute as a boolean, in another casing, or under any other spelling fails. A tag set is an array of tag strings in byte order, duplicates collapsed — a tagless non-root section's `tags` exactly `[]` on its `view` node, `null` never encoding emptiness (12.7: the stated `null` is a root's interpreted tags, T11.4-3, and an absent `targetTags`, T11.6-2) — asserted at T11.4-3 (node tags), T7.4-1 and T11.6-2 (`targetTags`), and T7.5-1 (a `tags` selector's list); a kind set is an array of dependency-kind tokens in 5.2's order, however configured — T7.4-1 and T11.6-2 (`edgeKinds`), T7.5-1 and T11.6-2 (a rule's `kinds`). An occurrence record is `{"file", "range", "kind", "source", "target"}` — `kind` one of `"depends"`, `"embeds"`, `"references"`; `source` `{"identity", "range"}` or unavailable; `target` an identity — asserted at T11.3-1, on a view's `occurrences` (T11.4-1), and as an `at` resolution's `occurrence` (T11.5-3, T12.7-2). +* **T12.7-2 Findings arrays and document forms.** A workspace staging several conditions, and a multi-reason refusal (T14-7) — T6.5-21's two-reason file move among them, `refused-invalid-destination` ordered before `refused-exposed-derived-file`, which 14 lists between it and `refused-invalid-rewrite`: every findings array is ordered by code — numbered conditions in numeric order, then refusal reasons in 14's listed order, then code-less findings — then by locations element-wise (a proper prefix sorting first), then by concerned path (`null` first; byte-form and plain paths in one byte order), then by identities, then by message; identically-staged duplicate findings collapse to one. Document forms: `build`/`check`/gated-read/refused-operation reports are `{"findings": […]}` — and on a clean, freshly built workspace a successful `build --json` and `check --json` each emit exactly `{"findings": []}` as the entire stdout (12.7: the findings-only form with the empty array, its one member and nothing beside it — the pin exercised on the report form itself, not only on the JSON-only surfaces; T12.1-1, T12.2-1); a refused preview keeps `{"findings", "mapping", "files", "delta"}` with the three `null` (T6.6-3); a performed `rename`/`move` is exactly `{"findings", "mapping"}` — `findings` `[]`, `mapping` in the preview's form (T6.4-1, T6.5-1) — and a refused one the findings-only form; `occurrences` is `{"findings", "occurrences"}`; `view` `{"findings", "views"}`, each node `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus `ownText`/`subtreeText` exactly when `--text` is given (the stated conditional presence — absent without the flag), `attributes` entries `{"name", "range", "text"}`, imports `{"range", "name", "target"}`; `at` `{"findings", "resolution"}`, `resolution` `{"section", "occurrence"}` with `occurrence` `null` when the offset lies in none; `inventory` and previews per T11.6-* and T6.6-4/5; `version` `{"product", "interface"}`. Member presence: `null` is never omission (a refused preview still carries all four members; an unset `outDir` is `null`); empty lists are `[]`, never `null` (a finding-free `findings`, a root's `attributes`, an empty delta direction); stated `null`s vs structural absence per surface (a root's `tags`/`coverage`, T11.4-3; an absent `targetTags`, T11.6-2). +* **T12.7-3 Error document.** Exit-2 invocations with JSON in effect emit `{"error": …}` holding one finding form as the entire stdout: a configuration error → stable code `configuration-error` and concerned path in the anchoring form: the configuration file the upward search found; `.` for a failed upward search with no `--config`; and, `--config <path>` given (14: where a configuration file is concerned, the path `--config` names is the concerned path, whatever occupies it; missing configuration is 14.14, never a plain usage error): naming a malformed existing file, that path in 11.6's canonical anchoring spelling — staged from a sibling directory as `--config ../cfg/xspec.config.ts` (reported `../cfg/xspec.config.ts`, T11.6-1's form, never `.`) and, discriminating 11.6's physical resolution (Linux leg), from a working directory `R/L` that is a symbolic link to `R/a/b` with the malformed configuration at `R/a/xspec.config.ts` named as `--config ./../xspec.config.ts`: reported `../xspec.config.ts`, the physical relation between the working directory and the root, never the link's lexical `../a/xspec.config.ts` and never the spelling as given; naming a path nothing occupies, the argument value exactly as given (14: the one concerned path no physical resolution can spell) — staged non-canonically as `--config ./../cfg//xspec.config.ts` (reported byte-for-byte `./../cfg//xspec.config.ts`, never `../cfg/xspec.config.ts`) and as an absolute path (reported as given, 12.0's sole absolute-form echo beside 11.6's drive case); a write the environment refuses → `code` `"write-failure"` and `path` the concerned path (T14-9), a read it refuses → `code` `"read-failure"` and its path (T14-10); 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,42 +544,54 @@ 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). An absent record stays absent (13.3): after a refreshing read on the deletion arm with no source edited, `inventory` reports `recorded` `[]` — the refresh writes graph data beside the absent record and never a record — and, every derived file still matching, `check` is clean (14.10: a lagging record alone is never staleness, graph data present and matching), the pair discriminating a refresh that writes a fresh record and a `check` that reads the absent record as a mismatch. 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 holding an `audit` session created while the workspace was valid — no baseline to resolve, so the gate alone stands between the invocation and an answer; a baseline session yields the same exit 1 only by 13.3's rule that no session is read on a failing workspace, T10.1-5), each of `ids`, `show`, `coverage`, `review status`, and `query` — and, in the obstructed-write staging alone, `impact --base` against a commit taken before the obstruction was staged, its baseline resolving and 14.22 the operative gate finding — reports that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exits 1, answers nothing, and modifies nothing (journal, sessions, derived files, and graph data byte-identical), discriminating a product that gates on source validity alone and answers `query` from a broken journal with exit 0 (refresh consumes the journal for canonical identities, 5.4). `impact` is absent from the journal-error staging by necessity, not oversight: `impact` always takes `--base` (9), baseline resolution precedes source validation (12.0), and the suffix replay of 6.3 meets a garbage line appended after the baseline commit as an unresolvable mapping — exit 2, the usage error T6.3-4 pins — while a garbage line already committed at the baseline ref makes a baseline that cannot be validated as a workspace, exit 2 again (6.3); no journal-error staging reaches this gate at `impact`. The never-gated contrast: on the same failing workspaces `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1, T11.2-6) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. * **T13.3-4 Determinism.** Graph data files are byte-deterministic across rebuilds of an identical workspace (content otherwise unasserted, H-4). ### 13.4 Derived and durable files -* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order (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 (a pin non-discriminating over ASCII keys, where byte, UTF-16 code-unit, and code-point orders coincide: it adds no requirement in practice unless a product keys an object by non-ASCII strings — recorded so the interpretive label is not read as a new requirement) — asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). * **T13.4-2 Derived reproducibility.** Delete, truncate, and garbage-overwrite each class of derived file (module, companion, Markdown, graph data): `build` restores all byte-exactly. -* **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). -* **T13.4-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-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). The unreadable half of the same boundary (13.4: a record missing or unreadable, 14.23): with the record instead corrupted shape-blind (T6.6-6's staging) before the configuration change, `build` replaces the record (14.23, 13.3) and leaves the now-orphaned file in place, subsequent builds leaving it alone likewise. +* **T13.4-4 Derived paths belong to xspec.** A user-created file at a derived path is replaced by `build`; a symbolic link at a derived file's own path is replaced as the occupant — nothing is written through it (link target byte-identical after build, link gone, plain file present; not an error); and a directory at a derived path holding nothing xspec discovers or generates — empty, and separately holding a file no group matches — is replaced by the derived file, `build` exiting 0 with a plain file there and `check` clean (13.4: writing a derived file replaces whatever exists at its path, 14.22's refusal reaching only a path that is a directory component of a discovered source's path or of another derived path, T13.4-9). * **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 or symbolic-link component under a move's destination or its derived paths reports `refused-invalid-destination` instead, T6.5-4, T14-7), is refused identically: `build` exits 1 with the condition-22 finding, concerned path that component, modifying nothing, and `check` reports it without writing; finding cardinality (14.22: one finding per distinct offending component, whatever write paths it refuses) — one non-directory occupant at a component under which two derived files would be written yields one finding, concerned path that component, and two distinct offending components yield two findings (asserted via `check`); a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. The graph-data area's own path and the session directory occupied by a non-directory are T10.1-6's arms (14.22 concerning `.xspec`; `review create`'s finding concerning `.xspec/reviews`). * **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. +* **T13.4-9 Derived paths above sources and other derived paths.** 13.4 and 14.22 refuse, before any write, a module, companion, or emitted Markdown path that is a directory component of a discovered source's path or of another such path xspec writes, occupied or not — the plain file written there would leave the other write no directory, or replace a directory holding a source. Six stagings — (e) a pair per companion path — each on a workspace otherwise passing `build`'s validations, the first five staged before any build, so nothing but a directory occupies the offending path (T13.4-6's occupied-component relation kept apart), and (f) where that relation and this one meet at one component: (a) emission next to sources, `specs/a.mdx` beside `specs/a.md/b.mdx` — `a.mdx`'s emit path `specs/a.md` a directory component of a discovered source's path (and of `b.mdx`'s derived paths); (b) emission under `markdown.outDir: "out"`, sources `a.mdx` and `a.md/b.mdx` at the workspace root — the emit path `out/a.md` a directory component of the emit path `out/a.md/b.md`; (c) `specs/A.mdx` beside a discovered `specs/A.xspec.ts/B.mdx` (its file name holding no `.xspec.`, so no exclusion applies, 13.4) — the module path `specs/A.xspec.ts` a directory component of a source's path; (d) a code source the only file beneath — emission next to sources, `specs/a.mdx` beside a discovered code source `specs/a.md/x.ts` (a code group globbing `specs/**/*.ts`, the file holding `export const v = 1`) — the emit path `specs/a.md` a directory component of that source's path and of no derived path, a code source generating none, whereas (a) and (c) each place a spec source, and so its derived paths, beneath the offending path, where the relation between derived paths refuses it by itself; (e) the companion leg, one pair of stagings per companion path a build of `specs/A.mdx` records — none for a product writing no companions — the paths read from `inventory`'s `recorded` set after a build of a scratch twin holding, under the staging's configuration, `specs/A.mdx`'s bytes at that path alone (11.6; T11.6-3: the recorded entries `specs/A.xspec.<suffix>` other than the module `specs/A.xspec.ts`, each companion attributable to its source through 13.1's naming; the suffixes are the product's own, as T6.6-5 composes them): `specs/A.mdx` beside a discovered `specs/A.xspec.<suffix>/B.mdx`, as (c) stages the module path, and, separately, beside a discovered code source `specs/A.xspec.<suffix>/c.ts`, the only file beneath, as (d) stages the emit path (a code group globbing `specs/**/*.ts`, the file holding `export const v = 1`) — neither file name holding `.xspec.`, so no exclusion applies (13.4), and the companion path a directory component of a source's path, of `B.mdx`'s derived paths as well in the first; (f) the staging of (b) after a `build` of `a.mdx` alone, `a.md/b.mdx` added after it — `out/a.md` is then the plain file that build emitted, occupying a directory component of the write path `out/a.md/b.md` (T13.4-6's relation) while being the derived path the relation between derived paths names, one offending component under both (14.22). In each, `build` exits 1 with exactly one condition-22 finding concerning the offending derived path — `specs/a.md`, `out/a.md`, `specs/A.xspec.ts`, `specs/a.md`, (e)'s companion path, and `out/a.md`: one finding per distinct offending path, whatever write paths it refuses and whichever relations it meets — `locations` `[]`, and writes and removes nothing (workspace byte-compare); `check` reports the same finding without writing, and a gated read (`ids`, T13.3-3) reports it, exit 1, answering nothing — failing a product that vets occupied components alone and writes the file over the directory's path; by (d), one that vets derived paths against one another and against sources for equality alone, writing `specs/a.md` over the directory and so deleting `x.ts` behind a reported success (13.4: writing a derived file replaces whatever exists at its path); by (e), one whose relations take module and Markdown paths but leave out companions, writing the companion over the directory and so deleting `B.mdx` or `c.ts` alike; and, by (f), one vetting the two relations in separate passes, which reports a finding from each. +* **T13.4-10 Rebuild-obstructing orphans.** An orphan, recorded or not, occupying a workspace-relative directory component of a path the rebuild writes obstructs that write (13.4, 14.22): the rebuild is refused before any write or removal, and the orphan stays until it is deleted manually (13.5: one of the two kinds of orphan 13.4 leaves for manual deletion, which a rerun `build` does not resolve — the other the unrecorded orphan of T13.4-3). Staging: `build` with `markdown: { emit: true, outDir: "out" }` and source `specs/A.mdx`, recording the emitted `out/specs/A.md`; then `outDir` reconfigured to `"out/specs/A.md"`, so the new emit path `out/specs/A.md/specs/A.md` lies below the recorded, no-longer-generated `out/specs/A.md`. `build` exits 1 with exactly one condition-22 finding concerning `out/specs/A.md` and modifies nothing — the orphan, every other derived file, and graph data byte-identical; `check` exits 1 reporting that finding and, beside it, exactly one condition-10 finding in the recorded-file form concerning `out/specs/A.md` whose correction is the file's manual deletion, never a rebuild (14.10; the correction asserted by H-3's robust matching), and no mismatch form — undetectable on a workspace failing `build`'s validations (14.10, T12.2-4); once the file is deleted by hand, `build` exits 0, writing `out/specs/A.md/specs/A.md`, and `check` is clean. The unrecorded twin: the same staging with graph data deleted before the reconfiguration (T13.3-2's operational definition), the orphan now outside xspec's knowledge (T13.4-3), obstructs identically — `build` exits 1 with the one condition-22 finding, modifying nothing — and `check` reports that finding alone: no record lists the path, so no recorded-file finding, and the missing graph data is a mismatch form, undetectable there (14.10). +* **T13.4-11 Removing recorded paths no longer generated.** 13.4 removes a recorded derived path the current sources and configuration no longer generate only where its occupant is neither a directory nor a discovered source — a symbolic link as the link itself — and leaves a path holding a directory, a discovered source, or nothing as it is, making no write; 14.10's recorded-file form reports exactly the occupants that removal would remove. Each arm builds with emission next to sources, stages a change leaving the recorded `specs/A.md` no longer generated together with its occupant, then runs `check`, `build`, and `check` again: (a) a directory — `specs/A.md` replaced by a directory holding a file, then emission disabled: the first `check` reports no condition-10 finding concerning `specs/A.md` — whether the graph-data unit form accompanies it is not asserted: the comparison of 13.3 excludes the record, and graph data's content beyond its enumeration is opaque, so whether disabling emission leaves graph data stale is unpinned (13.3, 14.10) — `build` exits 0 leaving the directory and its content byte-identical, and the last `check` is clean; (b) a discovered source — `specs/A.md` overwritten with well-formed TypeScript (`export const n = 1`) as emission is disabled and a code group globbing `specs/*.md` is added, so the path is a discovered code source once it is no emit destination: no condition-10 finding concerning it, `build` exits 0 with the file byte-identical (a source is never derived), the last `check` clean; (c) a symbolic link — `specs/A.md` replaced by a link to a file outside the workspace, then emission disabled: the first `check` reports the condition-10 recorded-file finding concerning `specs/A.md`, `build` exits 0 with the link itself gone and its target byte-identical, the last `check` clean; (d) nothing to remove — built instead under `outDir: "out"`, recording `out/specs/A.md`, then `out/specs` replaced by a plain file and `outDir` changed to `"md"`: the recorded path lies below a non-directory component and holds nothing (13.4: nothing is read there), so no condition-10 finding concerns it, and `build` exits 0 — no 14.22, the path being no write path's component, and no 14.24, the removal making no write — `out/specs` byte-identical, the last `check` clean; (e) nothing to remove below a symbolic link — (d)'s staging with `out/specs` replaced instead by a symbolic link to a real directory holding a foreign plain file named `A.md`, staged once with that directory inside the workspace, under no group's globs, and once outside the workspace root: the recorded path lies below a component a symbolic link occupies, where nothing is read whatever the link targets (13.4), so the first `check` reports no condition-10 finding concerning `out/specs/A.md`, `build` exits 0 — no 14.22, the link being no write path's component, and no 14.24 — the link, its target directory, and the target's `A.md` byte-identical afterward, and the last `check` clean; a product resolving the recorded path's parent through the link reports the foreign file as a stale recorded file and deletes it, inside or outside the workspace, behind a reported success; (f) no occupant — `specs/A.md` deleted, then emission disabled: the recorded path holds nothing (13.4), so the first `check` reports no condition-10 finding concerning `specs/A.md` (whether the graph-data unit form accompanies it unasserted, as in (a)), `build` exits 0 — no 14.24, the removal making no write — leaving nothing at `specs/A.md`, and the last `check` is clean; a product whose removal fails on the vacant path, reporting 14.24, or whose recorded-file form reports a recorded path holding nothing, fails. Order independence (13.4: a completed regeneration's outcome does not depend on the order of its writes and removals): `build` with a source `specs/B.md/C.mdx`, recording `specs/B.md/C.md` and `C.mdx`'s module and companions; then `specs/B.md/C.mdx` deleted and `specs/B.mdx` added, whose emit path `specs/B.md` is the directory holding those recorded orphans: `build` exits 0 — the write replacing the directory, each orphan's removal finding nothing below the replaced path or removing its file first alike — and the workspace is exactly the regenerated one, `specs/B.md` a plain file holding `B.mdx`'s Markdown and nothing under it, `check` clean; a product failing a removal whose path's component a write replaced (14.24, or 14.22 on a removal) fails. ### 13.5 Concurrency and isolation All mutual-exclusion tests use the `--test-hold <path>` seam for determinism. -* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, then completes normally. 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 — staged on a freshly built workspace, as T10.1-1, so no refresh is pending), proceeds only once the file is deleted, then completes normally. Stale-workspace arm (13.5: the hold precedes every modification, the 13.3 refresh included): on a workspace whose graph data is stale — a section's text edited after `build`, the workspace still valid — `review create --strategy audit --test-hold`: while held, graph data, and every other workspace file, is byte-identical to its pre-invocation state; after release the session is created and the graph data refreshed (T10.1-1) — discriminating a product that refreshes before acquiring exclusivity. Seam neutrality (13.5: the seam changes no other behavior): the final workspace state of a held-then-released run — sources, journal, sessions, derived files, and graph data — is byte-identical to the same operation run without `--test-hold` on an identical twin workspace (the hold path outside the workspace; a product-to-itself comparison under H-4, well-defined across directories per H-6). If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag: 12.0 enumerates the flags a command accepts — its synopsis's, the global `--json` and `--config`, and, for a mutating command alone, `--test-hold` — so on every other command the token names no accepted flag, an unknown flag of the syntax class (12.0, T12.0-14), and a product accepting and ignoring it fails this arm; the flag being value-taking by name on every command (12.0), `build --test-hold --json` consumes `--json` as the path and leaves JSON out of effect (stdout empty, exit 2). * **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). * **T13.5-5 Atomic visibility.** A concurrent reader polling a derived file during repeated builds only ever observes prior content, complete new content, or absence-before-first-write — never a partial file (property-style loop, 16). * **T13.5-6 Workspace isolation.** Two workspaces driven concurrently by parallel harness instances never interfere: results equal serial runs (H-1). -* **T13.5-7 Interrupted mutation.** A mutating command killed mid-operation can leave sources and durable files inconsistent, and `xspec check` reports such states rather than passing silently (13.5, 14) — but no blackbox kill point produces inconsistency on demand: the hold precedes all modification (T13.5-1), so a kill at the held point demonstrably leaves the workspace consistent (asserted: `check` passes there), while kills after the hold's release land nondeterministically. The operative assertion, at the held point and across a spread of post-release kill timings on a multi-file `rename`: `check` never crashes and either passes on a consistent state or reports findings. +* **T13.5-7 Interrupted or write-refused mutation: the pinned write order.** 13.5 fixes the state a mutating command leaves when it stops early — the writes already made, each complete, and no later one — and 14.24 makes stopping stageable on demand: a write the environment refuses stops the command at that write, exit 2 with the error document (`write-failure`, the concerned path; 12.0, 12.7). Refusals are staged by permission removal on the Linux leg under T14-9's discipline — the directory holding the path made read-only and its occupant, where one exists, unwritable, so creation, replacement in place or by renaming, appending, and removal are all refused whatever the product's write strategy (E-1) — and, for a mutating command, applied while the command is held at the seam of 13.5 (T13.5-1), after acquisition and before any modification, so the product's exclusivity mechanism, wherever in the workspace it keeps state, never meets the staging (seam neutrality, T13.5-1). Every arm starts from a freshly built, valid, journal-bearing workspace (no refresh pending) and byte-compares every file the operation would touch; every rewritten-byte expectation is read from an identical twin workspace on which the same operation runs unrefused (H-6). (a) Source edits first, one write per file, in preview `files` order: a rename whose section lives in `specs/b/B.mdx`, referenced from `specs/a/A.mdx` (a `d` reference) and `specs/c/C.mdx` (an embedding) — `files` order by path bytes A, B, C (6.6, 12.7) — with `specs/b` staged unwritable: A is rewritten (byte-equal to the twin's A), B and C are byte-untouched, the journal is byte-unchanged (no entry: the append is the commit point, reached only once every source edit is in place), derived files and graph data are byte-unchanged, the error document concerns `specs/b/B.mdx`, and `check` reports exactly one finding, condition 5 for A's now-unresolved spelling (13.5: a partly applied rewrite manifests as 14.5–14.7; 14.10's mismatch forms are undetectable on a failing workspace, T12.2-4). (b) The journal append as the commit point: the same rename with `.xspec/journal` staged unwritable (`.xspec` read-only, graph data untouched) — every source edit made (A, B, C byte-equal to the twin's), no journal entry (the file byte-unchanged), the error document concerning `.xspec/journal`, derived files and graph data byte-unchanged; `check` then reports condition 10 alone — per-file staleness for B's module and companions and the graph-data unit form, the workspace being valid and consistently rewritten; and once the permissions are restored, `impact --base <pre-rename ref>` reports the old identity deleted and the new one added — manual restructuring (6.7), no entry having been journaled — and the next `build` leaves `check` clean. (c) Stopped after the append, the identity effect complete: a file-form move `specs/A.mdx` → `specs/sub/B.mdx` under `markdown: { emit: true, outDir: "out" }` with `out/specs` staged unwritable — every source edit made, the relocation complete (origin absent, destination present, the importers rewritten), the journal holding exactly one new entry byte-equal to the twin's, and the error document concerning one of the two Markdown writes the regeneration owes under `out/specs` — `out/specs/sub/B.md`'s creation (13.4: the directory it needs is part of that write) or `out/specs/A.md`'s removal, the order among a regeneration's derived-file writes being unpinned (13.5) — with stdout exactly that document: the write failure is reported alone, no `mapping` (6.4); `check` reports condition 10 alone (the stale remainder), and after the permissions are restored `impact --base <pre-move ref>` reports no change categories (6.2: the identity effect is observable through the journal's effects alone) and the next `build` leaves `check` clean. (d) A relocation is two writes, the destination produced and then the origin removed (13.5, 14.24): the same move with `specs/sub` present and writable and `specs` staged unwritable — the destination `specs/sub/B.mdx` present with the moved file's rewritten bytes (the twin's), the origin `specs/A.mdx` still present and byte-unchanged, the error document concerning `specs/A.mdx` (the removal, the relocation's second write), no journal entry, no importer rewritten (the relocation's entry precedes `src/` in `files` order), and `check` reporting condition 10 alone — both files valid, the destination's derived files missing. (e) `review` mutators write the session file once, last: on a stale, valid workspace (a source edited after `build`) with `.xspec/reviews` and the session file staged unwritable and `.xspec` itself writable, `review resolve s <item> --status no-change` exits 2 with the error document concerning `.xspec/reviews/s.json`, the session file byte-unchanged (`status`, once restored, reports the item still `unresolved`), and the refresh already made: `check` afterwards reports the edited source's per-file staleness and no unit-form finding — graph data matching the current sources — where a product writing the session before refreshing, or refusing the refresh, fails; `review create --strategy audit --name n` and `split` on the same staging behave alike, no `n.json` created and no decomposition recorded. (f) `build` and a refresh, each file complete, the order unpinned: a built workspace whose `specs/b/B.mdx` is edited (the workspace valid) before `specs/b` is staged unwritable — `build` exits 2 with the error document concerning a derived path under `specs/b/` (`specs/b/B.xspec.ts` or a companion, the first `specs/b/` write in the product's order); every derived file present afterwards is complete and byte-equal to the twin's, and `check` reports exactly the derived paths whose occupant differs from the twin's derived state — the harness computes that set by comparison, B's module and companions certainly in it, the graph-data unit form exactly when graph data differs — the next `build`, permissions restored, exiting 0 with `check` clean (12.1, 13.4). A refreshing read on the same edited-but-not-rebuilt workspace with `.xspec` staged unwritable: `query nodes` and `view specs/a/A.mdx` each exit 2 with the error document concerning `.xspec` (14.24: a graph-data write concerns the area), stdout exactly that document and no answer (13.3, 11.2), while `check` on the same state exits 1 reporting the staleness (never a 14.24 reporter) and a `move --preview` exits 0 writing nothing (6.6). Kill arm, a robustness check only — a kill lands nondeterministically, the refused write above being the deterministic seam: across a spread of post-release kill timings on (a)'s rename, `check` never crashes and reports exactly a state 13.5 admits — clean, or condition 5–7 findings alone, or condition 10 findings alone — and the journal is byte-equal either to its prior bytes or to the twin's post-operation bytes (13.5: each write complete). +* **T13.5-8 Acquisition before every later check.** 13.5 pins the point of acquisition — once configuration is loaded and sources discovered, before the argument checks of 12.0, baseline resolution (6.3), the gate and refresh of 13.3, and the operation's own validation — so the mutual-exclusion refusal precedes those checks' outcomes and the hold seam engages on an invocation a later check refuses or the gate turns back. Exclusion first: while a `review resolve` is held (`--test-hold`) on a workspace failing `build`'s validations, `review create --strategy audit --name n`, `review resolve s <item> --status skipped`, and `rename specs/A.mdx a b` each fail promptly with the exclusion usage error, exit 2 — never the gate's or the precondition's exit 1 — modifying nothing (T13.5-2's byte-compare); a product that runs the gate before acquiring exclusivity exits 1 there. Seam ordering, each under `--test-hold` with no other holder: `rename specs/A.mdx nope x` (a nonexistent old ID, 12.0), `review create --base <unresolvable-ref> --name n` (6.3), and, on a failing workspace, `review create --strategy audit --name n` (13.3's gate) each create the hold file first — the workspace byte-identical while held — and only after its deletion exit 2, 2, and 1 respectively with their own errors or findings, nothing modified; a product judging arguments, the baseline, or the gate before acquiring exclusivity reports them without ever creating the hold file. The non-mutating boundary: `rename specs/A.mdx nope x --preview` creates no hold file and exits 2 at once (a preview acquires nothing, 6.6; `--test-hold` beside `--preview` is itself exit 2, T6.6-3). The gate arm is 13.3's statement that, for a mutating `review` subcommand, exclusivity acquisition precedes the gate's report. ## 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, T4-5), 14.8 (T2.4-2/3, T4.3-2, T4.5-3, T14-11), 14.9 (T2.1-5, T5.3-1/2), 14.10 (T12.2-2, T13.4-10, T13.4-11), 14.11 (T4.4-1, T4.5-9), 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, T4-5, T4.5-8, T4.5-9), 14.16 (T2.7-1, T2.3-3, T2.7-4, T11.2-4), 14.17 (T2.5-3, T2.7-3), 14.18 (T4.5-5, T4.5-9), 14.19 (T1.5-2, T7.1-1), 14.20 (T1.6-5, T14-3, T14-5, T14-12, T2.3-3, T2.4-2, T2.7-4, T14-11), 14.21 (T10.1-4, T10.1-5), 14.22 (T13.4-6, T13.4-9, T13.4-10, T10.1-6), 14.23 (T6.6-6, T11.6-4, T12.2-2, T13.3-2), 14.24 (T13.5-7, T14-9), 14.25 (T14-10); the refusal reasons and their codes (T14-7; staged at T6.4-3, T6.5-4, T6.5-6, T6.5-16, T6.5-17, T6.5-20, T6.5-21, T6.6-3); the stable-code, location-cardinality, and range contracts (T14-6, T14-8, T14-11); the well-formedness contract (T14-12). * **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-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). Escape-spelled forms are unresolved likewise (2.4: read verbatim): `d={"lo\u0067in"}` → 14.5 and a TS marker `SPEC.lo\u0067in` → 14.7, the latter no type error — 14.7's type-error clause holds only for a spelling free of escape sequences (T2.4-5). +* **T14-3 Masking.** An unparseable file (14.20 — malformed MDX, `d={]}`, its zero-length range at the offset of the `]` (T14-11, T14-12); malformed TS under the grammar its name selects: a TSX-only construct in a `.ts` file; invalid UTF-8; BOM) masks conditions inside itself, and every reference into it from other files reports as unresolved (14.5–14.7); the parse-failure location is reported. A configuration error suppresses all source analysis: only 14.14 is reported (exit 2) even with invalid sources present. +* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings (T10.1-5); 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); 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); 14.24 delivered as an exit-2 error — never a finding — by `build`, `rename`/`move`, every refreshing read of 13.3, and the mutating `review` subcommands, never by `check`, `inventory`, `version`, or a preview (T14-9); 14.25 delivered as an exit-2 error by every command making the refused read, at the read, except where 14.25 assigns the object its own condition (T14-10's table); 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). Among the matrix's stagings: the condition-11 arms of T4.4-1 (`occurrences` carrying the finding beside the call's record), the 14.16 and 14.20 arms of T2.3-3, T2.7-1, T2.7-4, and T14-12 (all three surfaces for their spec-source stagings), and the 14.15 and 14.18 arms of T4-5 and T4.5-9 (`occurrences` alone). * **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 25 conditions, staged via its primary test's fixture and read from its stated reporter (T14-4): conditions 1–23 carry the exact token 14 lists (`missing-id` … `unreadable-record`) as the `code` of their finding in the JSON report form, where 12.7 pins it, and conditions 24 and 25 — usage errors, never findings — carry `write-failure` and `read-failure` as the `code` of the exit-2 error document (12.7, T12.7-3; T14-9, T14-10), appearing in no findings array — 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), form-exact (12.7): a reason concerning an identity carries it as the sole element of `identities`, in 1.5's form over the operation's destination file — `<file>#id` for a rename, `<target-file>#id` for a section move, and the bare `<new-file>`, its root identity, for a file move — whether or not the ID is valid and whether or not the path is a valid source path (14: every entry of a refusal's `identities` is a spelling in 1.5's form over the would-be operation, carried whatever its path's validity and defining no node — the invalid-path arms below), and a reason concerning a path carries it as the finding's `path` with `locations` `[]`: `refused-invalid-id` (concerning the new identity alone — `identities` exactly `["specs/A.mdx#a.then"]` for `rename specs/A.mdx a a.then` and `["specs/B.mdx#x y"]` for `move specs/A.mdx#x 'specs/B.mdx#x y'`, the invalid ID spelled verbatim; 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 (`["specs/A.mdx#a"]`) and by the exact self-move of either form (`["specs/A.mdx#x"]`; the bare `["specs/A.mdx"]` for `move specs/A.mdx specs/A.mdx`) — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer (two in T6.4-3's prefix-replacement arm, `b` and `b.c`) with `identities` exactly the located bearers' identities in location order (`["specs/A.mdx#b", "specs/A.mdx#b.c"]`); `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path in pre-operation coordinates — for a dependency cycle every reference spelling recording a participating edge; for a spec import cycle each participating import declaration existing before the operation and, for an import the move would add, every reference spelling the operation roots at its binding, whether or not the spelling's characters change (14) — the harness computes the located set as the spellings rooted at the added binding, independent of whether each appears as a `reference-rewrite`: a section move carrying `x` from `specs/A.mdx` into `specs/B.mdx`, where `B` imports `A` and a section of `A` outside the moved subtree references `x` in local form — that reference, rooted after the move at a binding of `B`'s module that `A` lacks, requires adding `B`'s import to `A`, closing the cycle — locates `B`'s existing import declaration and that local reference's spelling (its occurrence span, 5.7), never a range for the import that does not yet exist; and a sibling arm in which the characters need not change — `A` imports a third module `specs/C.mdx` as `C`, the moved text carries `d={C.foo}`, and `C` imports `B` — where the move roots that chain at a binding of `C`'s module `B` lacks, the addition closing the cycle `B` → `C` → `B`: the finding locates `C`'s existing import of `B` and the chain's spelling in `A` (pre-operation coordinates, inside the moved text), though a product choosing `C` as the fresh identifier would rewrite nothing there; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included), judged at a path in discovered-path form alone (14): a file-form destination spelled `./a.mdx` for the origin `a.mdx`, `specs//b.mdx`, or `specs/../specs/b.mdx` — each naming an occupied path were it normalized — is refused `refused-invalid-destination` alone (6.5: a spelling carrying a `.`, `..`, or empty segment is no discovered path), never reported occupied, and a section-form target path so spelled likewise; `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path as spelled; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component, in T6.5-4's derived-path arm as a directory component of the destination's `outDir` emit destination, and in T6.5-4's symbolic-link arms a link to a directory at a component of the destination path, of a created target file's path, and of the `outDir` emit destination — the link and its target byte-identical after each refusal — and so do T6.5-4's barred path characters and T6.5-20's derived-path relations and module-linking designation); `refused-exposed-derived-file` (`path` the origin's emit destination, `locations` `[]`, `identities` `[]`: T6.5-21); `refused-invalid-rewrite` (locating the moved construct and, for an addition no offset admits, the spellings rooted at its binding, `identities` the concerned files' paths in byte order, `path` `null`: T6.5-16); `refused-moved-import` (locating each moved declaration by the import range of 11.4, `identities` `[]`: T6.5-17). Identities over invalid paths (1.5, 14): `move specs/A.mdx#x 'specs/new.txt#x y'` — an absent path lacking `.mdx`, an invalid ID — reports `refused-invalid-destination` (`path` `specs/new.txt`) beside `refused-invalid-id` with `identities` exactly `["specs/new.txt#x y"]`; `move specs/A.mdx#x specs/new.txt#p.y` reports `refused-invalid-destination` beside `refused-missing-target-parent` with `identities` exactly `["specs/new.txt#p"]`, the absent file bearing no `p`; and `refused-invalid-rewrite`'s created-target identity over such a path is T6.5-16's — in each the identity is a plain string over the path as spelled (12.7), defining no node: `query node` on it is a usage error, exit 2 — 12.0's unknown-identity error, `specs/new.txt` a path in no configured group (T11-6). No reason exists for a rewritten reference failing to resolve: every rewritten reference resolves by construction (6.4, 6.5), asserted on the success side of every successful operation (T6.4-1, T6.5-3), and a code 14 does not list never appears in any report. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`<new-id>` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). +* **T14-8 Location cardinality.** A condition several constructs jointly violate is one finding locating every participant, each in its containing file: a triple-duplicated ID → one condition-3 finding with three locations (one per bearer, no representative chosen); an import-binding collision → one condition-15 finding locating every colliding declaration; a cross-file dependency cycle → one condition-9 finding locating its full path, every participating reference spelling, and a spec import cycle every participating import declaration; a no-occurrence MDX embedding spelling → its condition-6 finding's range the full braced container — the span its occurrence would occupy (5.7), keeping T11.4-6's byte classification exact; a policy finding → `locations` `[]`, `path` `null`, its context identities alone (T12.7-1). Location order within a finding is file bytes, then start, then end (12.7); the ranges themselves, per condition, are T14-11's. +* **T14-9 Write failures (14.24).** Staging discipline, Linux leg (E-1): an environment refusal is staged by permission removal alone — the directory holding the path made read-only and, where the path is occupied, its occupant made unwritable — so that creation, replacement in place or by renaming, appending, and removal are all refused whatever write strategy the product uses (14.24 pins the effect, never the mechanism); for a mutating command the staging is applied while the command is held at the seam of 13.5 (T13.5-1, T13.5-7), after acquisition and before any modification; the harness first verifies each staging on itself — its own attempt at the staged path is refused — and reports an ineffective staging (a privileged runner) as a harness error (H-11), never a pass or a skip (H-9); symbolic links and non-directory components are never involved (those are 14.22, or the refusal of 6.5: T13.4-6, T6.5-4). Contract: a refused write is a usage error, exit 2, never a finding — stdout the error document under `--json` and on JSON-only surfaces, `code` `"write-failure"`, `path` the concerned path (12.0, 12.7, T12.7-3), stderr the diagnostic — and the command stops at that write, every earlier write complete and no later one attempted (T13.5-7 asserts the per-command states). Concerned paths, one arm each (T13.5-7's stagings): a generated module or companion (the derived path under `specs/b/`, (f)); an emitted Markdown file's creation or removal (`out/specs/sub/B.md`, `out/specs/A.md`, (c)); a rewritten source (`specs/b/B.mdx`, (a)); the journal (`.xspec/journal`, (b)); a session file (`.xspec/reviews/<name>.json`, (e)); a relocation's two writes, each concerning its own path — the origin's removal `specs/A.mdx` in (d), the destination's production `specs/sub/B.mdx` when `specs/sub` is the directory staged unwritable instead (the origin then still present, nothing relocated); and graph data, concerning the graph-data area `.xspec` — never a path inside it (14.24, 11.6; (f)'s refreshing read). Reporter set (14.24, T14-4): `build`, `rename` and `move`, every refreshing read of 13.3 — `ids`, `show`, `coverage`, `impact --base`, `review status`, `query`, `occurrences`, `view`, and `at` on a stale workspace with `.xspec` staged unwritable each exit 2 with the error document — and the mutating `review` subcommands; never `check` (exit 1 with the staleness on the same state), `inventory` (exit 0), `version`, or a `--preview` (exit 0, writing nothing). Precedence (12.0: met only at the write it refuses, after every check and validation): on (f)'s stale staging, `query nodes --group <code-group>` exits 2 with the invalid-flag-value error (`code` `null`) and `query node specs/a/A.mdx#missing` with the unknown-node error, and on a twin whose sources also fail validation `ids` exits 1 with the findings (13.3: nothing is written on a failing workspace, so no write is refused) — a product attempting the refresh first fails each; a rename refused by validation (T6.4-3) under (a)'s staging exits 1 with its refusal, no write attempted; and a hold file that cannot be created is 13.5's usage error, never this condition — `--test-hold` naming a path in a read-only directory: exit 2, `code` `null` (T13.5-1). Recovery (12.1, 13.4): after every arm, restoring the permissions and running `build` exits 0 and `check` is clean. +* **T14-10 Read failures (14.25).** Staging discipline, Linux leg (E-1; the harness verifies each staging on itself as T14-9 does): a refused content read is staged as the file's read permission removed with its write permission kept (mode `-w-------`), so a regeneration that replaces or rewrites the object is never itself refused whatever the product's write strategy; a refused directory listing as the directory's read permission removed with search permission kept (`--x`), so its entries stay reachable by name; nonexistence is never staged as a refusal (14.25: an absent object reads as its own section states — T6.3-2, T11.6-3, T10.1-1, T7-1, and T7-6 cover the absent cases). The object read decides the outcome, one arm per row of 14.25: (a) a discovered source's content — a spec source `specs/B.mdx` referenced from `specs/A.mdx` and, separately, a code source: condition 20 at `build` and `check`, exit 1, its one location the zero-length range `{"start": 0, "end": 0}` (14), the file masked exactly as an unparseable one (T14-3: A's reference into it reports 14.5, nothing inside it reports), the surfaces of 11.2 still answering per file — `view specs/A.mdx specs/B.mdx` serves A's view, B contributing none, B's condition-20 finding accompanying, exit 1; `occurrences` lists no record for B's spellings; and `at specs/B.mdx 0`, `at specs/B.mdx 7`, and `at specs/B.mdx 999999` each report the resolution explicitly unavailable beside the condition-20 finding, exit 1 — never the out-of-range usage error, the offset bound being judged only where the content was read (11.5); (b) the journal's content — condition 13 concerning `.xspec/journal` from `build`, `check`, and the gated reads (`ids` exits 1 answering nothing, 13.3), a `rename` refused with that finding alone (6.4), while `inventory` reports `journal.occupied` `true`, finding-free, exit 0 (11.6: the kind read, permitted, is the only read it makes there); (c) a session file's content — condition 21 from `check`, from `review status <name>` (exit 1, exactly one finding, `corrupt-session`, nothing modified), and from `review list` (the session reported corrupt, exit 1), `inventory` listing the session, exit 0; (d) the configuration file's content — condition 14 from every command but `version` (`build`, `ids`, `inventory`, and `view` as representatives): exit 2, the error document's `code` `"configuration-error"` and `path` the configuration path in the anchoring form (`xspec.config.ts` from the root), `version` answering exit 0 (12.6); (e) a derived file's content — a generated module staged unreadable: `check` reports exactly one condition-10 finding, the per-file form concerning that path, and `build` exits 0 (it reads no derived file; its write replaces the occupant, 13.4); (f) graph data — every path under `.xspec/` other than the journal and the session directory staged unreadable: `inventory` reports `recorded` unavailable with the condition-23 finding (concerned path `.xspec`, T11.6-4's outcome), exit 1; a `move --preview` reports its `delta` unavailable likewise (T6.6-6); `check` reports one condition-10 finding in the unit form alone (14.10: the unreadable-record form, never the mismatch form beside it), exit 1; a refreshing read (`ids`) exits 0 with its answer, regenerating the data rather than failing (14.25, 13.3); and `build` exits 0, `inventory` then reporting `recorded` in full; (g) a directory discovery lists — `specs/sub` staged unlistable under the glob `specs/**/*.mdx`: every command that loads the configuration (`build`, `check`, `ids`, `view`, `inventory`, `review list`, and a `rename`, each representative) exits 2 with the error document, `code` `"read-failure"`, `path` `specs/sub`, nothing modified; precedence (12.0: at the read, in read order — the configuration search and discovery of 7 before every error consulting them): with `specs/a/A.mdx` also failing validation, `build` still exits 2 with the read failure, never the findings; `coverage <unknown-profile>` reports the read failure (discovery precedes every error consulting configuration); a syntax-class error — `ids extra`, `ids --file '../x'`, a malformed value — is reported without loading configuration, `code` `null` (T12.0-10); and with the configuration file itself invalid the configuration error precedes the read (14.14); (h) the session directory's listing — `.xspec/reviews` staged unlistable on a valid workspace holding a session: `review list`, `inventory`, and `check` each exit 2 with the error document concerning `.xspec/reviews`, while `build` (reading no session) and `ids` exit 0; `review status <name>`, which may find its session by name without listing, is asserted nowhere here. Two clauses admit no product-independent staging and are recorded so (as T6.5-6 records its unstageable clauses): a refused read of a path occupant's kind, since whether a product learns a kind by a separate examination the environment can refuse or from a listing it already made is its own (7, 13.4); and a refused read of a directory above the workspace root, which the working directory lies beneath and cannot be entered without traversing. +* **T14-11 Per-condition ranges.** Byte-precise fixtures against precomputed offsets, one arm per range rule of 14 beyond T14-8's: a `d` value's offending expression, first token through last — an unresolved array entry alone (no brackets, commas, or whitespace), a non-array braced expression `d={foo}` (14.8) located as the expression its braces enclose, the braces excluded, `d={(BASE.a)}` (14.8) locating `(BASE.a)`, the enclosing parentheses included, `d={BASE.a, BASE.b}` (14.8) locating the whole comma sequence, and `d={ /* c */ BASE.missing }` (14.5) locating `BASE.missing` — the braces and the whitespace and comment between them and the expression excluded, as are U+00A0, U+FEFF, U+1680, and U+3000 spelled there (ECMAScript whitespace, 1.4, its space separators Unicode 15.1's, 14.20; T5.7-2); a spread entry `d={[...BASE.a]}` (14.8) located as `...BASE.a`, the `...` included; the elisions of one array literal as one finding (14.8) located at the whole array literal, brackets included, however many holes — `d={[BASE.a, , , BASE.b]}` one finding, and two sections each spelling an elided array two findings, one per literal; `d={}` and `d={ /* c */ }` are instead not well-formed MDX (2.7, 14.20) — condition 20, never 14.8, the zero-length range at the offset of the closing brace (the prefix before `}` begins some well-formed file); a non-static bare reference in expression-statement position (`SPEC?.a;`, 14.8) located as the statement's expression exclusive of the `;`; attribute conditions at the attribute's own characters — each bearer's `id` attribute for 14.2 and 14.3, the `id` or `tags` attribute for 14.4 with one finding per violating attribute (a section `id="a b"` with a child `id="a b.c"` reports two findings, the child's at its own `id`), and 14.17's — a repeated prop locating every attribute spelling the name (both `id` attributes of a twice-`id`ed tag), an unknown prop, a spread attribute (its whole braced construct), an invalid `coverage` value; 14.1 at the section's opening-tag range; 14.15 per form — an import declaration, an export declaration (`export * from "./A.xspec"`), and `import X = require("./A.xspec")` by their own characters, `export import X = require("./A.xspec")` from `import`, the leading `export` and what separates it excluded (as 1.7 excludes one), a dynamic `import("./A.xspec")` by its call expression, an import type from `import` through the closing parenthesis of its argument list — `typeof import("./A.xspec").default` and `import("./A.xspec").T<number>` in type positions each locating `import("./A.xspec")`, a `typeof` before it and a qualifier or type arguments after it excluded — a string-named module declaration by its own characters, a leading `export` excluded — `declare module "./A.xspec" { }` whole, `declare` included, and `export declare module "./A.xspec" { }` from `declare` — and a colliding non-import declaration by the construct binding the name — `let SPEC;` at `SPEC` alone, `const { SPEC } = o` at `{ SPEC } = o`, `@dec class SPEC {}` from its `@`, `export class SPEC {}` from `class` (T4.5-8); 14.16 per form — an element from its opening tag through its closing tag (a self-closing one its own tag), a fragment from `<>` through `</>` (T2.7-1), an expression container brace through brace, an export statement whole; 14.18 as the binding's identifier extended by the longest static chain it roots at the use (`const n = SPEC.a.b` → the range of `SPEC.a.b`; `f(text)` → `text`); and 14.20's zero-length range — `{"start": 0, "end": 0}` for a refused read (T14-10) and for a byte-order mark, the byte length of the longest well-formed UTF-8 prefix for an encoding failure — the offset of the first byte of the first ill-formed sequence, never a later byte at which a decoder notices it — staged in a spec source and in a code source alike, each `{"start": n, "end": n}` with code `unparseable-source`: a valid 5-byte prefix then `0xFF` → 5; `41 E2 82 41` → 1 (a three-byte sequence cut short, the `41` where a decoder notices); `C0 80` (overlong) → 0; `ED A0 80` (a surrogate code point) → 0; and `41 E2 82` truncated by the end of the file → 1 — a decoder accepting overlong or surrogate encodings, or reporting where it resynchronizes, failing these arms; and for a syntax failure the byte length of the longest whole-character prefix with which some well-formed file begins — an MDX file ending inside an unclosed section (the whole file such a prefix → its byte length) and a TypeScript file `let x = ;` (the prefix `let x = ` → `{"start": 8, "end": 8}`) — never a line/column pair and never past the file's length, the syntax-failure offsets of T2.3-3, T2.4-2, T2.7-3, T2.7-4, and T14-12 asserted the same way. Per-spelling resolution inside a repeated `d` (11.2): a section spelling `d` twice, one entry resolving and one not, reports the 14.17 repetition (both attributes located), one occurrence for the resolving entry (`occurrences`), and one 14.5 finding at the unresolved entry's expression. +* **T14-12 Well-formedness contract.** 14.20 decides well-formedness by derivability alone under the input languages' grammars — every rule beyond derivability excluded, whether the language's own text calls its violation a syntax error or its tools report it after parsing. Positive arms — each file well-formed, proceeding to its ordinary outcome, never 14.20: in a spec source, ECMAScript's early errors — two imports binding one identifier within one ESM block (14.15, T2.1-3); an export naming no declaration — `export { nope }`, `nope` a binding no declaration introduces, on the line after a valid, used import in one ESM block: exactly one condition-16 finding located at the export statement whole (T14-11), exit 1, no 14.20 and no 14.15 (the statement holds no declaration, which 2.1's collision clause needs), the import beside it proceeding normally — listed under `view`'s `imports` with its resolved target, the statement getting no view entry (11.4), and a `{text(BASE.a)}` embedding rooted at it recorded by `occurrences --file`, the finding accompanying each answer, exit 1 (11.2, T14-4) — a product handing the ESM block to a parser enforcing early errors reports a parse failure instead and fails the arm; an assignment to a non-simple target `{1 = 2}` (14.16); and the strict-mode restrictions `{let}` and a legacy octal `{010}` (each 14.16) — and the expression grammar — a comma sequence `{a, b}` (14.16) and `d={BASE.a, BASE.b}` (14.8, one expression located whole, T14-11), `{await x}` (14.16, `await` admitted), `{function(){}}` (14.16, no statement lookahead restriction), a spread attribute `{...(a, b)}` (14.17, T2.7-3), and an export declaration holding JSX, `export const x = <b/>` (14.16, the statement whole); in a code-group file, TypeScript's post-parse checks — a rest parameter that is not last (`function f(...r: number[], x: number) {}`), a misplaced modifier (`abstract m(): void` in a non-abstract class), a duplicate declaration (`let a; let a;`), and a type error (`const n: number = "x"`) — each leaving the file well-formed: `build` and `check` exit 0 on the otherwise valid workspace, a marker inside one of the file's units attributed to it and its edge recorded. The release pin (14.20: TypeScript's grammar at release 5.9.3, language level ESNext, every text that release accepts both as module code and as script code well-formed): a `.ts` code source holding `{ using x = f(); }`, one holding `async function g() { await using y = h(); }`, and one holding `import a from "./a.json" with { type: "json" };` — each well-formed, `build` and `check` exit 0, a marker beside the construct recording its edge — failing a product whose TypeScript parser predates the releases admitting them (the spec-source twin of the import-attributes form stays 14.20, below). The language level (14.20: ESNext, the level deciding which characters an identifier admits, 1.4), which neither those forms nor the negative `010` and `09` below discriminate, that release judging each alike at ES3, ES5, ES2015, and ESNext — its identifier tables alone separate ESNext from ES3 and ES5, which reject an astral identifier character: a `.ts` code source holding, inside one unit, `const 𮯰 = 1` (U+2EBF0, as in T1.4-5(c)) and the marker `S.𮯰`, `S` the default binding of a spec module holding a section `𮯰` (a valid segment, 1.4) — well-formed, `build` and `check` exit 0, the marker's `references` edge to that section recorded — and, judged by the same grammar (14.20), a configuration file importing `import { defineConfig as 𮯰 } from "xspec"` and exporting `export default 𮯰({…})` over an otherwise valid argument (T7-2's aliased import) — loading without error, `build` exit 0 on the otherwise valid workspace — each failing a product that parses code sources, or the configuration file, at ES3 or ES5 and so reports 14.20, or 14.14, for a well-formed file. And the release's other side, which 14.20 fixes for TypeScript as it does not for MDX text (S-9), no other release's acceptance taking part: `const Ᲊx = 1` in a `.ts` file is 14.20 (below), that release admitting U+1C89 neither to begin nor to continue an identifier at any level, failing a product whose scanner takes identifier characters from its runtime's Unicode tables, which admit it once they postdate 15.1. A code source's whitespace is likewise that release's scanner's, not ECMAScript's — the mirror of `010` and `09` (below), here text TypeScript accepts and ECMAScript's grammar does not derive: a `.ts` code source holding `const`, U+200B, `a = 1` and, on a later line, `const`, U+0085, `b = 1`, each away from any reference spelling, beside a marker recording its edge, is well-formed — that release scanning both code points as whitespace, read as module code and as script code alike, while ECMAScript's grammar takes neither as whitespace (T2.7-4 makes each 14.20 between a spec source's braces) — `build` and `check` exit 0, failing a product that parses code sources by ECMAScript's lexical grammar, or by the whitespace class it applies between braces, and so masks a well-formed file as 14.20. The Unicode pin (14.20: ECMAScript 2024 takes its identifier characters, JSX names' included, and its space separators from Unicode 15.1): a spec source holding, alone on its line, an expression container whose content is the one-character identifier U+2EBF0 — the first character of the CJK Unified Ideographs Extension I block, which Unicode 15.1 added — derives (S-9), reported 14.16 at the container and never 14.20, failing a product whose identifier tables predate Unicode 15.1; and JSX names alike (14.20): a spec source holding, alone on its line, the element `<a𮯰 />` — `<a`, U+2EBF0, a space, `/>` — derives (S-9), reported 14.16 at the element's own tag (T14-11) and never 14.20, and, separately, one holding, alone on its line, the section `<S id="x" a𮯰="v" />` — an attribute whose name is `a`, then U+2EBF0 — derives (S-9), reported 14.17 at that attribute, an unknown prop (T14-11), and never 14.20: a product whose MDX tokenizer judges a JSX name one UTF-16 code unit at a time rejects every astral character there and reports 14.20 for both, failing them while it passes the container arm, whose expression is judged apart. The space separators are T2.7-4's arms: each one Unicode 15.1 places outside Latin-1, spelled alone between braces, makes an MDX comment, while U+180E, a format character under 15.1, is 14.20 there, failing a product whose whitespace is a fixed list or whose tables predate Unicode 6.3. No fixture of this document spells text 5.9.3 accepts read one way only — the top-level `await` forms 14.20 names — nor MDX text deriving only under a Unicode version past 15.1 — U+1C89, a Unicode 16 letter, in an identifier or a JSX name — whose well-formedness SPEC.md leaves unfixed alike (S-9); in a TypeScript source SPEC.md fixes U+1C89 in an identifier instead, as a parse failure (above). Negative arms — 14.20, the one zero-length range at the offset the rule of 14 fixes, precomputed per fixture: `010` and `09` in a `.ts` file (text ECMAScript derives but TypeScript's scanner rejects), each at the literal's second digit (the prefix through its `0` begins a well-formed file); `const Ᲊx = 1` in a `.ts` file, at offset 6, U+1C89's first byte (the prefix `const ` begins a well-formed file, and none under that release begins `const ` then U+1C89); a spread attribute `{...a, b}`, at its comma (T2.7-3); an ESM block holding a statement — an import line followed on the next line, no blank line between, by `const x = 1` — at the start of the `const` line (the block derives import and export declarations only); an import spelled with import attributes, `import A from "./A.xspec" with { type: "json" }`, at the offset of `with` (syntax the edition lacks); `d={]}` at the `]`; `{text(}` at its `}`; and an unbalanced brace, `{text("a")` as the file's last bytes, at the file's byte length (the whole file a prefix of a well-formed one). Each negative arm masks everything inside its file (T14-3) and is reported by `build`, `check`, and — for a spec source — the surfaces of 11.2 (T14-4). The comment-grammar failures are T2.7-4's, the embedding-grammar failure T2.3-3's, and the TypeScript-only-syntax failures in a spec source T2.4-2's. ## 15. Example @@ -476,18 +599,21 @@ Sections 1–13 exercise each numbered condition in its home context; this secti ## 16. Property-Based and Fuzz Tests -Property tests generate inputs from seeded, reproducible generators (H-10), assert spec-derived invariants, and shrink failures. Each property is also anchored by the deterministic fixtures of sections 1–15; properties exist to search the input space, not to replace them. +Property tests generate inputs from seeded, reproducible generators (H-10), assert spec-derived invariants, and shrink failures. Each property is also anchored by the deterministic fixtures of sections 1–15; properties exist to search the input space, not to replace them. Except where a property stages invalid or imperfect input by design — P-1's invalid draws, P-8, P-11 — generated workspaces are valid by construction and generated edits preserve validity: the generators follow the grammar of 2 (prose bytes that open no construct — no `<`, `{`, or import-like line — well-formed sections with structurally valid IDs whose segments and tags draw from 1.4's valid alphabet — `"`, `'`, `\`, `&`, U+2028, U+2029, and U+FFFD excluded — spec-group paths free of the characters 7.1 bars there (`"`, `'`, `\`, U+000A, U+000D, U+2028, U+2029; 14.19), resolving imports and references, each reference segment spelled with dot access only where 1.4's identifier test admits it — TypeScript 5.9.3's identifier characters at ESNext, which ECMAScript 2024 admits alike (1.4) — and otherwise as computed access with a static string literal (2.4), so that no draw's derivability depends on a Unicode version past 15.1, whose well-formedness 14.20 leaves unfixed (S-9)) and compose in-line constructs only as the MDX grammar derives them (14.20; T3-3's staging constraint): an in-line section, tag, or expression container spans paragraph-continuation lines alone — no blank line, no line beginning a construct that interrupts a paragraph, no line holding tags or expression containers alone — an in-line section closes within its paragraph, and P-5 draws a multi-line in-line section for a move only in the shapes T6.2-3 stages — its boundary lines agreeing, both flow-position or both text-position at the destination, never the one-sided spellings 6.2 and 6.5 refuse; S-9 verifies before any product exists that every composed form derives, and each draw is checked the same way before the product is driven on it, a failing draw reported as a harness error with its seed (H-10, H-11's rule), never as a product failure and never as a draw to skip — so every oracle here is evaluated over documents that build and a generator artifact never surfaces as a product failure. Validity being guaranteed by construction, a `build` failure on a draw is a product failure, reported with its seed (H-10), never a draw to skip: conditioning the properties on exit 0 instead would let a rejecting product pass them vacuously. -* **P-1 Segment/tag validity.** Generator over code points (weighted toward boundaries: whitespace/control classes of 1.4, U+00A0/U+0085/U+2028, `.`/`#`, forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): 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 — valid, in neither class — U+2028/U+2029 — in neither class, yet invalid under 1.4's quote-and-escape bullet — `.`/`#`, the quote, escape, and character-reference characters `"` `'` `\` `&` and U+FFFD — each invalid, 1.4 — forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): the segment property judges the staged spelling's resulting split — a draw containing `.` can be spelled as no single segment (1.4: `.` is the ID separator) and stages as that many segments, its bearer nested beneath the ancestor chain the split's prefixes spell, so the structural rule holds whenever the segments are valid (a `.`-free draw stages as one top-level segment; structural-rule outcomes are T1.3-2..4's, never this oracle's) — asserting acceptance by `build` iff every resulting segment satisfies 1.4; likewise tags, with `.` allowed and whitespace never reaching tag validation — a generated value containing whitespace stages as multiple tags (2.6 splits on runs of whitespace), so the tag property asserts acceptance iff every resulting token satisfies 1.4 (zero tokens: accepted as an omitted prop, T2.6-2). Staging discipline (2.7, 2.4: an `id` or `tags` value is a plain single- or double-quoted static string read verbatim, no escape or character-reference form being interpreted): each draw is spelled in the quote kind its content admits — double quotes for a draw containing `'`, single quotes for one containing `"`, either otherwise — and since 1.4 makes every draw containing `"` or `'` invalid, such a draw is staged in the other quote kind and predicted rejected (14.4), never set aside; a draw containing both quote characters admits no static-string spelling and is not staged — invalid under the oracle too, so its exclusion loses no prediction. That the product accepts both quote kinds alike is T2.7-3's deterministic question, not this generator's. +* **P-2 Markdown compilation.** Random documents composed of prose blocks — fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes included (T3-1's grammar boundary: such bytes are content) — nested sections, imports — ESM blocks carrying JavaScript comments beside their imports, content under 3 (T3-7) — comments in every form of 2.7 (`{/* … */}` single- and multi-line, `{}`, block-comment sequences, line-comment containers, and the run-on `{// c}` form, T2.7-4) with ECMAScript-only whitespace between braces, drawn from U+FEFF, U+2028, U+2029, and every Unicode 15.1 space separator but U+0020 (U+00A0, U+1680, U+2000 through U+200A, U+202F, U+205F, and U+3000; 14.20, T2.7-4), and embeddings with whitespace and comments beside the call — block comments before and after it, a line comment before it, and the run-on `{// c}` form holding the call (T2.3-3) — 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. File-move destinations, and the target files that section moves create, are drawn clear of the destination refusals of 6.5 that no derivability check sees — the derived-path relations and module-linking designation of `refused-invalid-destination`, and `refused-exposed-derived-file`: S-9 cannot catch a draw meeting one, so a conforming product would refuse it and the property would misreport that refusal as a product failure. Random section moves — drawn so that every draw is a move the product performs, none of 6.5's refusals (a refused shape drawn would surface in S-9's per-draw check as a harness error, the wrong verdict for a shape the product must refuse, so the generator excludes each, as stated here; S-9 gates the forms): the moved text derives at the destination's line start (6.5, 14.20) — flow-form sections, self-closing sections, single-line sections, and multi-line in-line sections whose boundary lines agree, the opening tag's remainder (what follows it on its line, inside the construct) and the closing tag's lead (what precedes it on its line, inside the construct) each being either nothing, spaces, and tabs alone — grammar whitespace, the tag then a flow-position tag at the destination — or prose the flow attempt cannot take, U+000B or U+000C included (the tag then stays in text position there), and both of one kind (T6.2-3's stagings): the one-sided spellings 6.2 and 6.5 refuse are never drawn, and neither are remainders or leads holding tags or expression containers alone; a section whose moved text opens a flow-position tag at the destination — flow-form, self-closing, flow-boundary multi-line, or single-line with nothing outside its tags and expression containers but spaces and tabs (empty, embedding-only, or nesting only such sections: alone on its line a flow line, 14.20, T3-3's constraint — T6.5-16(c)'s refused shape) — is drawn only into a flow-position parent (its tags alone on their lines) or to top level, never into a parent whose tags stand in text position, while a single-line section holding prose outside them, staying a paragraph line there (T6.5-2's fourth geometry), is drawn into either; no drawn section stands in a block quote; what follows a moved construct on its closing tag's line begins none of the constructs T3-3's staging constraint names as paragraph interrupters — a `>`, a heading, a code fence, a thematic break, a list item, tags or expression containers alone — and no setext underline (6.5's deletion refusals; T6.5-16(d)); no target file's last line belongs to an ESM block, and no moved section holds one (`refused-moved-import`); and every generated spec source begins with an empty line, so offset 0 — an ESM block the empty line ends, judged over the composed text — is a line-start admissible offset for every import addition, which 6.5's preference then places at a line start, leaving every root's own content as it was (6.2): 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 6.2's enumeration — the origin parent, the target parent, the moved subtree's nodes, and each other node with own-content bytes on a line the deletion joins or drops or the insertion splits (the construct's boundary lines at the origin, the insertion point's line at the destination), the import-addition case being undrawn, its deterministic anchors T6.5-13(h) and (j), one per mechanism 6.2 names — 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 (6.2's `may`: T6.2-4's pinned shapes reproduce it, its `changed` twin does not), and every other node iff the lines named, judged by the line-drop rules of 3 on each side (P-2's oracle over the origin's boundary lines, holding prose outside the construct, and the destination's, holding the moved text's remainder and lead alone), change its runs — a boundary line whose within-construct bytes are whitespace under 1.4 (nothing, spaces, tabs, U+000B, U+000C) dropping at the destination whatever the tag's position there, one holding prose kept — 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. Every import a drawn move adds is held to T6.5-22(a)'s assertion, the drawn spec sources' basenames including names from 6.5's barred classes — reserved and strict-mode-barred words, `require`, `exports`, a `__`-prefixed name, global-object properties, Annex B's `escape` and `unescape` included, and `Iterator`, `AsyncIterator`, and `SuppressedError` — so that a basename-derived choice meets them. * **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), its generated spec-group paths free of the characters 7.1 bars there (`"`, `'`, `\`, U+000A, U+000D, U+2028, U+2029) — such a path is an invalid source (14.19), failing `build`'s validations — while code-group paths may carry `'` and `\` (7.1 binds spec groups alone; T7-4's literal `\` arm): 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 — fragments, brace content at the comment/expression/parse-failure boundaries of 2.7 and 14.20, and ESM-block mutations among the mutation classes — 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,21 +622,23 @@ 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-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-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. Certification runs through this same driver (C-2), so a driver defect is a spurious verdict on every fixture, not a deviation any fixture can target. +* **S-4 TypeScript tooling driver.** Detects a known type error, a known definition location, and a known hover text in a hand-written non-xspec fixture project, so section 4's consumer assertions cannot pass vacuously. Certification supplies products, not consumer projects, and reaches the tooling only through whichever violators target section 4 (a selective set, CERTIFICATIONS.md); a driver blind to a diagnostic kind passes conformer and violator alike wherever no violator targets that kind, so each kind's detection is checked directly. +* **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. Every fixture emits conforming shapes (C-2 drives them as products), so an adapter defaulting absent information passes conformer and violator alike — the vacuous pass no fixture is defined to produce. +* **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line shape in T6.2-3's three stagings (the worked shape with spaces before its closing tag, its both-sided U+000B/U+000C spelling, and the `body</S>` variant with such a remainder), each to top level and into a flow-position parent, plus the clean-boundary case of T6.2-3, T6.2-4's pinned final-position shapes and its `changed` twin, and T6.2-3's sibling stagings (d) and (e) for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. An oracle defect makes a conformer fail spuriously — a spurious fail, which no violator can reveal (as S-8). The name analysis behind T6.5-22(a)'s universal assertion — which names a receiving file declares, in any scope and at value or type level, which it references, and which 6.5 bars there — likewise passes its own fixed vector suite before any test that adds an import, P-5's draws included, trusts it: vectors built from T6.5-22's stagings, each a file with candidate names and the verdict the analysis must reach on each, in both directions — counted: `helper` declared only in a function body and, separately, only as a type, `Record` in a type annotation, the undeclared global `test` of a call `test(…)`, `Foo` of `<Foo />`, `x` of `x.foo`, and `t` of `{ text as t }`; not counted: `div` of `<div />`, `foo` of `x.foo`, a label (`L` of `L: for (;;) break L`), and the `text` that `{ text as t }` spells for the other module; and barred, in a TSX file, the factory name of each pragma T6.5-22(b) stages — `h` of `/** @jsx h */`, of `/* @JSX h */`, of `// @jsx h`, and of `/** @jsx h */` inside a function body, `preact` of `/** @jsx preact.h */`, and `Frag` of `/** @jsxFrag Frag */` — and, in every kind of file, though it neither declares nor references them, each name T6.5-22's constraint list spells or ranges over — its reserved and strict-mode-barred words, `require` and `exports`, each global-object property (`decodeURI`, one of clause 19's function properties; each constructor from `AggregateError` through `WeakSet`, `Object` among them; `escape` and `unescape`, Annex B's), `Iterator`, `AsyncIterator`, and `SuppressedError` — and a `__`-prefixed name (`__x`); `React`, barred in each of T6.5-22(b)'s `.tsx` receivers, the one spelling JSX and the one spelling none, and in neither a spec source nor a `.ts` file; and `S`, `Spec`, and `text`, barred in a spec source and not in a `.ts` file — so that each name T6.5-22(b)'s lures target is a vector in every kind of file its lures stage, with the verdict 6.5 gives it there. Certification reaches the analysis only through whichever violators target T6.5-22 (a selective set, CERTIFICATIONS.md): an analysis too narrow passes conformer and violator alike for every class no violator targets, and one too broad fails a conforming product spuriously only when that product picks a name the analysis miscounts, which a conformer may never do — so each class's verdict is checked directly. * **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. +* **S-9 Fixture well-formedness.** Every MDX source this document declares well-formed — each deterministic fixture's files, and every form P-2, P-3, and P-5's generators compose (16), in the fixed vector set of those forms and, at property time, in each draw — derives under the grammar 14.20 fixes (MDX syntax at major version 3, decided by derivability alone, its identifier characters — an expression's and a JSX name's alike — and its space separators those of Unicode 15.1, judged code point by code point), and every MDX source this document declares unparseable (14.20) does not; the check is made by a means independent of the product — it lives in the harness, as S-6's oracles do — and runs before any product exists for the deterministic fixtures and the form vectors (H-8), each draw's failure being reported as a harness error with its seed (16, H-10), never as a product failure or a draw to skip. The forms 14.20 admits by derivability but a grammar-implementing tool rejects under a rule beyond it — ECMAScript's early errors: two imports binding one identifier in one ESM block (T2.1-3, T4.5-8), `export { nope }`, `{1 = 2}`, `{let}`, and `{010}` (T14-12) — are the check's known allowances, each named, so that derivability is what is checked rather than a tool's acceptance; an allowance is never granted to a form 14.20 does not admit, and a declared-unparseable form the check rejects only under such a rule is a declaration defect the check cannot see (T14-12 fixes that boundary). The Unicode version is 14.20's, never the checking tool's: a tool whose tables postdate 15.1 admits U+1C89, a Unicode 16 letter, as an identifier character, one judging a JSX name one UTF-16 code unit at a time rejects U+2EBF0 there, which 14.20 admits, and one whose tables predate Unicode 6.3 takes U+180E, a format character under 15.1, for a space separator — so the check judges T14-12's element and attribute names holding U+2EBF0 well-formed, and T2.7-4's `{` U+180E `}` unparseable, whatever such a tool reports; and no fixture or draw is MDX text deriving only under a Unicode version past 15.1, whose well-formedness 14.20 leaves unfixed, as none is TypeScript text accepted read one way only (below) — the generators spelling a reference segment with dot access only where 1.4's identifier test admits it (16). TypeScript sources alike — 14.20 fixing TypeScript's grammar at release 5.9.3, TSX or plain as the file name selects, at the language level ESNext, derivability there being that release's acceptance: every code source and configuration file this document declares well-formed, deterministic fixture or generated form, is accepted by that release both as module code and as script code, every one it declares unparseable is rejected under both readings, and no fixture is text the release accepts read one way only — the top-level `await` forms 14.20 names, whose well-formedness SPEC.md leaves unfixed (T14-12) — each checked by that release's own parser, a dependency of the harness independent of the product, before any product exists and, for a draw, before the product is driven on it; the rules 14.20 excludes beyond parsing — post-parse grammar checks, name binding, type checking — take no part, so T14-12's post-parse arms, T4-2's relative-name and undeclared module declarations, and T7-2's modifier-bearing configuration imports check as well-formed. Certification cannot exercise this class: CONF-MD's conformer judges 14.20 on its accepting side alone, so an ill-formed fixture — MDX or TypeScript — passes conformer and violators alike — a vacuous pass no fixture is defined to produce — and an ill-formed generator draw surfaces only as a spurious product failure; what S-9 verifies is this document's own declarations of well-formedness. ## 18. Execution and CI -* **E-1 GitHub CI.** The full suite — sections 1–17, certification included — runs in GitHub CI on Linux runners. CI provides no network access guarantees beyond dependency installation; the suite itself MUST pass with network access disabled after setup (xspec performs no network access; the harness needs none). Product invocations run with network access denied, so product behavior that relies on network access fails its tests rather than passing through a silent fallback (SPEC.md preamble) — the enforceable contract is exactly that the suite passes with network denied. -* **E-2 Local-only tests.** Tests that cannot run in GitHub CI are implemented as local-only tests, collected in a separately invocable local suite, and never marked skipped (PROCESS.md). This set is currently empty; symlink, kill/interruption, and concurrency tests run on Linux CI, and case-sensitivity assertions run on Linux CI with their discriminating single-casing probes rerun on the Windows CI leg (E-6). Any future local-only test MUST document why CI cannot run it. +* **E-1 GitHub CI.** The full suite — sections 1–17, certification included — runs in GitHub CI on Linux runners. CI provides no network access guarantees beyond dependency installation; the suite itself MUST pass with network access disabled after setup (xspec performs no network access; the harness needs none). Product invocations run with network access denied, so product behavior that relies on network access fails its tests rather than passing through a silent fallback (SPEC.md preamble) — the enforceable contract is exactly that the suite passes with network denied. The Linux leg runs product invocations as an unprivileged user, so the permission-based stagings of environment refusals (T13.5-7, T14-9, T14-10) take effect; the harness verifies each such staging on itself before invoking the product and treats an ineffective one — a privileged runner — as a harness error (H-11), never a pass or a skip (H-9). +* **E-2 Local-only tests.** Tests that cannot run in GitHub CI are implemented as local-only tests, collected in a separately invocable local suite, and never marked skipped (PROCESS.md). This set is currently empty; symlink, permission-refusal, kill/interruption, and concurrency tests run on Linux CI, and case-sensitivity assertions run on Linux CI with their discriminating single-casing probes rerun on the Windows CI leg (E-6). Any future local-only test MUST document why CI cannot run it. * **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 arms: the non-UTF-8 argument value and the positive side of `\`, whose staged file names no Windows filesystem admits); 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`, a journaled section-form `move` whose moved text lands before a target parent's closing tag in an existing target file that gains an added import, 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. The section-form `move` is the subset's inserted-terminator probe: 6.5 makes every terminator it inserts, after the moved text and after the added declaration, U+000A on every platform, which a product writing the platform's native line terminator into rewritten sources meets on Linux alone; no other operation of the subset inserts a line into a source. 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, native line terminators in inserted lines, 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 00000000..211b995d --- /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:** Tests Specified +- **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 00000000..df7e1786 --- /dev/null +++ b/specs/tmp/FIX_PLAN.md @@ -0,0 +1,89 @@ +# FIX_PLAN — Phase 9 (test harness), re-descent iteration 82 + +Written 2026-10-03 at 44c5dad (branch `claude/xspec-ui-apis-4df8fa`, standing in for `patch/external-ui-apis`). It plans from the re-descent's second compliance determination, which was not clean. Its findings: +- compliance review A (TEST-SPEC's T1–T6 tests): 3 gaps; +- B (T7 and later): 1 gap; +- C (everything outside the T-numbered tests): 3 gaps; +- D (CERTIFICATIONS.md): 1 gap; +- VERIFY V: green — every harness self-test and every certification passes, locally and in CI. + +Task headings cite the gaps as A1–A3, B1, C1–C3, and D1. Governing IP: `specs/patches/0001-external-ui-apis.md` (Stage: Tests Specified); no task changes its stage. No Bug Report applies. + +Why the harness changed in this re-descent: the documents moved after the harness was last green (Phase 9 ended at 3bfedb5). The deltas are `git diff 3311ccd..6780f53 -- specs/TEST-SPEC.md`, `git diff 3311ccd..301f2f9 -- specs/CERTIFICATIONS.md`, and `git diff 9d095d9..f31e100 -- specs/SPEC.md`. Iteration 1's plan (f0d3cd9, 66 tasks) closed every gap the first determination found; 44c5dad deleted it. The eight gaps below predate this re-descent or were left by it, and none blocks on a spec defect. + +## Preamble — read before any task + +**Phase goal and scope guards (Phase 9).** The harness must adhere to `specs/TEST-SPEC.md` and `specs/CERTIFICATIONS.md`. Every harness self-test and every certification passes: each certified test passes against its conformer and fails against each of its violators exactly as the violator's entry states. Product tests may fail, but only as diagnosed assertion failures (H-8): never a harness error, crash, hang, or false pass. Never modify product code (`src/`; `dist/` is built from it). Every task is harness work under `test/` (fixtures under `test/fixtures/` are harness code), plus `AGENTS.md`'s build/run facts and this plan. A spec defect that blocks a task goes to the matching problems file under `specs/tmp/`, never into a silent workaround. + +**Known state at 44c5dad (VERIFY, and CI run 37116437636, "run 854").** +- Self project, run as CI runs it (no network, uid 1000, no capabilities): 25 files, 4185 tests, all passing, 0 skipped. +- Certification: all 27 fixtures pass — CORE 1 conformer and 8 violators, VALID 1 and 3, MD 1 and 2, DISC 1 and 3, AVAIL 1 and 3, ORPHAN 1 and 2. The C-1 gate (`test/self/certification-document.test.ts`, "defines exactly 6 conformers and 21 violators") passes. +- Suite against the built product (CI, no network, unprivileged): 345 tests in 79 files; 317 pass and 28 fail, every failure a diagnosed product failure. The failing IDs: P-1, P-5, T1.4-1, T1.4-4, T4-2, T6.4-3, T6.5-4, T6.5-11, T6.5-20, T6.5-21, T6.5-22, T6.5-23, T6.6-3, T7-2, T7-6, T7.1-1, T7.3-1, T12.0-5, T12.0-10, T12.7-2, T13.4-9, T13.4-10, T13.4-11, T14-4, T14-6, T14-7, T14-11, and T14-12. +- Windows leg (E-6 subset): 3 files, 9 tests, green. +- `npm run typecheck` and `npm run format:check` pass; `dist/` matches a fresh compile of `src/`. +- No self-test reads TEST-SPEC.md, so a green self project does not by itself show compliance: each task carries its own checks. + +**Run mechanics (AGENTS.md holds the recipes; read the bullets a task names before running anything).** +- Run the self project under the unprivileged namespace (`unshare --map-user=1000 --map-group=1000 -- npm run test:self` in this root sandbox; a plain non-root user needs no wrapper). AGENTS.md also records how to reproduce CI's no-network stage. +- Never run the self project, a certification run, and a suite run at the same time, and check the load first (another agent may share the machine). S-2's tower vector ("the largest document the suite stages — T1.3-7's 2048-deep chained-id tower") takes 2.3–2.9 s alone, and VERIFY saw it time out at Vitest's 5000 ms default only under concurrent load (load average about 11 on 4 cores). Such a timeout is not a task failure; rerun alone. +- One registered test: `-t '<ID> '`, with the trailing space and the dots escaped. +- One certification family: `npx vitest run --config test/vitest.config.ts --project self test/self/certification.test.ts -t <FAMILY>` (CORE, VALID, MD, DISC, AVAIL, ORPHAN). +- Red/green checks of a product test: AGENTS.md's stand-in wrapper and mutation recipes (a temporary `test/self/*.test.ts` calling `runProductTests`, deleted before committing). Red checks of a new self-test vector: AGENTS.md's stash-the-helper recipe. +- A fixed-seed replay of a property's draws: `drawFixedSeedTrials(<generator>, <runs>)` from `test/helpers/property.ts`; the fixed seeds are 271828183, 314159265, and 161803399 (E-5). +- Rebuild the product (`npm run build`) only if `dist/` is stale; `src/` does not change in this phase. + +**Spellings.** Take every exact spelling — code points, escape-spelled literals, byte offsets, file contents — from the TEST-SPEC.md or CERTIFICATIONS.md line the task cites, never from this plan or the review reports. The reports' channel decoded escape spellings, and the tool-parameter layer decodes backslash-u spellings inconsistently in edit and Bash payloads, comments included. So this plan names code points as `U+XXXX` and spells no escapes. Build such spellings in code from code points and verify the staged bytes byte-wise (`od -c`, a sha256 compare). + +**Conventions for changed tests.** +- *Registration.* No task adds a registered test. If a split task ever does, the new test goes into its registry module's exported list with its H-7 entry in `test/suite/registry/traceability.ts`, carrying `"14"` whenever it asserts a numbered condition or a stable refusal code. +- *S-9 timing.* A `.mdx` source that a body stages after its first product invocation, or in a workspace it creates after it, is a staged-source record (`test/helpers/staged-mdx.ts`, judged by `test/self/s9-staged-sources.test.ts`). A TypeScript code source or configuration file staged there is a `StagedTs` record (`test/helpers/staged-ts.ts`). Records register at module load only. The undeclared-staging guard refuses plain contents in those places. A new arm adds records, so the self-test's record count rises with it. +- *Never-modifies compares* use the compare-around machinery (`assertLeavesUnchanged` and `snapshotDirectory` in `test/helpers/snapshot.ts`). +- *Free text.* Corrections and other free-text checks use H-3's robust matching: required information only, never exact wording. +- *Product verdicts.* The built product (Phase 10's, at c62f451) predates the SPEC changes of this re-descent. A new or strengthened arm that fails against it counts as a diagnosed product failure only once a hand-staged probe shows the product's answer contradicts the asserted SPEC behavior. A harness error, crash, or hang is a harness defect to fix in the task. An arm that passes against the product proves nothing about its liveness, so red-check it (through a violator, a stand-in wrapper, or a mutation) wherever the task says so. +- *Every task ends with:* + - `npm run typecheck` and `npm run format:check`; + - the touched suite files against the built product; + - the full self project under the namespace, with 0 failures (green at 44c5dad; keep it green); + - a commit message stating the honest results, including each product test's outcome before and after; + - removing the finished task from this plan in the same commit (iteration 1's convention: a done task leaves the plan). +- *AGENTS.md* gets only build/run knowledge a later spawn needs (a recipe, a count or timing a later check relies on), never a task narrative. + +**Standing rulings.** Two rulings stand for this run: AGENTS.md's "Known residual 14.20 location gaps" and "Known SPEC 6.5 gap, deferred to a future SPEC revision" bullets. No task here addresses them, and none may be added for them. + +**Considered and not planned (do not re-raise).** +- The note from iteration 1's Task 19 (a40ce14): S-9 lists five allowances, none for a strict-mode-barred import binding such as `import let`, which T6.5-22 declares derivable. It is latent: no fixture stages such a file (reviewers A and C). +- Reviewer D's note on VIOL-DISC-DERIVED in T7-6: its code-side arm fails at the arm's own `build`, earlier than CERTIFICATIONS.md's narrative says, consistently with the document. It is a matter for a future revision of that document; the harness meets every stated staging constraint. +- The Phase 7 round-3 driver's note on VIOL-ORPHAN-THROUGHLINK (directory components resolve only through links whose target directory lies inside the workspace root): implemented and certified; reviewer D's arm-isolation experiment confirmed it. +- The header comment of `traceability.ts` lists only four refusal-reason staging tests; the map itself is complete (reviewer C). A task touching that file may fix the comment; no task is planned for it. +- P-6 drives only the file form of `move`, and P-9 uses only audit sessions; TEST-SPEC's wording does not clearly require more (reviewer C). + +**Order.** Tasks are in dependency order, and each names what it depends on: +- Part A (Task 1, the certification gap D1): done and removed. +- Part B (harness machinery): done and removed. Tasks 3 and 4 gave S-9's MDX judgement spec-group files not named `.mdx` — Task 3 the mechanism (`mdx.wellFormed` and the other `mdx` lists, the `file()` `mdx` option, or an MDX record at the path declare such a file an MDX source; module header of `test/helpers/workspace.ts`), Task 4 the declarations of the suite's three such files (T7.1-1's `specs/notes.txt` and T11.6-2's `specs/note.txt` as staged-source records, T11.6-4's under `mdx.wellFormed`). Task 2 (P-8's and P-11's capture-limit errors) and Task 2b, split from it (every other conversion of a driver rejection), are done and removed: H-11's capture-limit errors. +- Part C (Tasks 5–9): the T-numbered tests, in TEST-SPEC order. Task 5 (T4.3-2's seven dynamic node-form arms each run `occurrences` on the failing workspace and assert no record) is done and removed. Task 6 (every T4.5-3 arm runs `occurrences --file src/app.ts` on its failing workspace and asserts no record, through `assertArmFailsWith`'s opt-in `noOccurrence` option, which T4.5-5 does not pass) is done and removed. Task 7 (T4.5-3's arm table gains TEST-SPEC's pinned optional-chaining spelling `SPEC?.a;`, optional on the root binding, over the shared `a`/`a.b` source; the extra arms `SPEC.a?.b;`, `SPEC.a!.b;`, and `(SPEC.a).b;` stay) is done and removed. Task 8 (T5.5-2's kind-distinction arm restaged in-line: `foo <S id="p.k">Kid text.</S> baz` at the baseline, `foo {text(B.k)} baz` after the journaled move, both composed by `kindParent` from one line head and tail, with `p`'s subtree text anchored in both states) is done and removed. Task 9 (T13.4-10's correction judged by the pure `judgeManualDeletionCorrection` in `test/helpers/adapters/human.ts`: a manual marker beside a deletion word, or an instruction to the reader to delete or remove the file, accepted; a build or xspec presented as the remover, unless negated, rejected; S-5's "correction judge" vectors) is done and removed. +- Part D (Tasks 10–13): P-8's command sweep. Task 10 is done and removed: `section-16-p8.ts` exports `COMMAND_MENU` and `P8_RUNS_PER_SEED` (12, which `P_8` passes), and `test/self/p8-fixed-seed-draws.test.ts` replays `drawFixedSeedTrials(genFuzzTrial, P8_RUNS_PER_SEED)` and asserts that an MDX section tower at least `GIANT_NESTING_FLOOR` deep is staged intact (TypeScript towers and towers a later mutation undid never count) and that every `COMMAND_MENU` form is drawn (AGENTS.md's "P-8's fixed-seed draw guard" bullet has the recipe and today's draws). Task 11 is done and removed: `COMMAND_MENU` gained eleven read-surface forms (31 in all) — `query reachable --from specs/B.mdx#b --to specs/A.mdx#a` with and without `--json`, `occurrences` unfiltered and under `--file specs/B.mdx`, `view specs/A.mdx`, `view specs/B.mdx --text`, `at specs/A.mdx <offset>` (the offset computed from the base bytes, inside `{text("a.b")}`), and `inventory` and `version` with and without `--json`; `runFuzzArm` holds every invocation for which `jsonOutputInEffect` holds (SPEC 12.0's reading: `--json` read as a flag, or a JSON-only surface, `review export` already among them for Task 12) to `assertJsonOutputConvention`, the rest to the exit partition; the guard self-test gained a fourth test pinning that reading and that every JSON-only surface the menu holds has a form without `--json`; and P-8's per-invocation hang guard is 60 s, its kill unshrunk (AGENTS.md's answer-scale bullet has the derivation and the re-measure recipe). Task 12 is done and removed: the menu holds ten review composites (41 forms) — `review status`, `show`, `split`, `resolve` (`--status no-change`), and `export`, each with and without `--json`, spelled with the `<session>` and `<item-id>` slots — which `armSteps` expands into `review create --strategy audit --name r1 --json`, for an item form a JSON read (`review status r1 --json` for `split`, `review next r1 --json` otherwise) decoded through the review adapter, and the drawn command, the read's item (else `p8-absent-item`) in its slot, each step under `runFuzzArm`'s assertions and the composite rendered step by step in the counterexample; 2–6 forms are drawn per trial (`MAX_COMMANDS_PER_TRIAL`, 2–4 before), P-8's `timeoutMs` is 600 s, and the guard self-test gained a fifth test pinning `armSteps` (AGENTS.md's "P-8's review composites" bullet records the item path the fixed seeds reach and both recipes for re-checking it). Task 13 is done and removed: the menu holds 54 forms — `rename` and file-form `move` previewed with and without `--json`; section-form `move` of the base's `c` into `specs/B.mdx#c` (a file the base holds, the ID kept) and into `specs/C.mdx#e` (one it lacks, created), performed and previewed, each with and without `--json`; and `show specs/A.mdx#a --json`, closing the one command never run under JSON output and making a menu length whose residues the fixed picks all hit (53 forms leave one undrawn wherever they stand) — so the count (2–6), the mutation draws, and the floor's trial are unchanged; `FIXED_BUILD_ARM` is exported, and the guard self-test gained a sixth test pinning every command's run under JSON output and the mutating commands' performed, preview, and section forms (AGENTS.md's guard bullet has the residue fact, its composites bullet the item-path trials). +- Task 14 confirms the result and deletes the plan. + +Take the topmost task unless told otherwise. A task too large for one spawn may be split by inserting follow-up tasks directly after it; never drop a requirement. + +## Tasks + +### Final + +### Task 14 — Confirm locally and in CI; delete this plan + +**Depends on.** Every task above. + +**Status (2026-10-03, TASK iteration 15, recorded in the commit that adds this paragraph).** Every step below but the last is done, at 79d2013's harness and product trees (`dist/` matches a fresh compile of `src/`): +- Self project, alone under the namespace: 26 files, 4206 tests, all passing, 0 skipped (172 s). Certification: all 27 fixtures pass (6 conformers, 21 violators; the runner's lines sum to 154 PASS / 38 FAIL / 0 error / 0 hang, every FAIL a violator's expected outcome); the C-1 gate passes. AGENTS.md's counts and totals are updated. +- Suite project against the built product, alone in CI's whole inner stage (network off, uid 1000, no capabilities): 79 files, 345 tests, 317 passing, 28 failing (1307 s), every failure a `HarnessAssertionError`; the 28 IDs are exactly the 28 recorded in the preamble, so no test changed outcome. The commit message lists each failing test's first failing arm. +- S-2's tower vector: 2.3–2.5 s alone, 3.3 s inside the full self project; AGENTS.md's staged-scale bullet records it. +- CI run 871 at 79d2013 (the same trees): `harness-self` green (26 files, 4206 tests), `suite-linux` red only on the same 28 diagnosed product failures (105 files, 4551 tests), the Windows leg green (3 files, 9 tests). +- Not done: deleting this file. The session's permission system refused the Engineer's `git rm` of it ("Irreversible Local Destruction"); whether and how it is deleted is Developer's decision. + +**Change.** +- Under the namespace, run the full self project alone: expect 0 failures. Update AGENTS.md's self-project file and test counts and its certification totals (still 6 conformers and 21 violators, 27 fixtures, every one passing). +- Then run the suite project against the built product, alone. Every failure must be a diagnosed product failure. List each failing test with its first failing arm in the commit message, and compare the list with the 28 IDs recorded above: say which tests changed outcome and why. +- Record S-2's tower-vector timing in AGENTS.md (2.3–2.9 s alone at 44c5dad, against Vitest's 5000 ms default; it timed out only under concurrent load) if no bullet records it yet. VERIFY could not record it. +- Check CI on the pushed head: the harness-self job and the Windows leg are green; the full-suite job fails only on diagnosed product tests. +- Delete `specs/tmp/FIX_PLAN.md` once no other task remains in it. diff --git a/src/cli/args.ts b/src/cli/args.ts index 507cc418..819d40ad 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -5,33 +5,95 @@ // specification. IMPLEMENTATION (Architecture): the cli layer owns argument // parsing, command dispatch, and the exit-code taxonomy. // -// The grammar, from SPEC 12.0 and the per-command forms (6.4, 6.5, 8.2, 9, -// 10.7, 11, 12.1–12.5): +// The grammar, from SPEC 12.0's "Invocation grammar" and the per-command +// forms (6.4, 6.5, 8.2, 9, 10.7, 11, 12.1–12.5). Arguments are tokens, read +// in stages: // -// - 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). -// - 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. -// - Every command supports the global `--json` and `--config <path>` (12.0). -// - A flag may be given at most once per invocation; repetition is a usage -// error, identical values included (12.0). -// - List-valued flags (`--kinds`) take one comma-separated value (12.0, 11). -// - All other tokens are positional arguments, checked against the command's -// arity; a missing required argument or an unexpected extra argument is a -// usage error (12.0: "missing required flags or arguments"). -// - Argument values are interpreted as UTF-8; a value that is not valid -// UTF-8 is a usage error (12.0). +// 1. The token walk (`walkTokens`). A token beginning `--` is a flag token; +// no other token is (no single-dash short forms). A flag's arity is fixed +// by its name, the same for every command (`FLAG_ARITY`, built from the +// whole command table), because flags may precede the command word: a +// value-taking flag takes the whole next token as its value, whatever it +// looks like, `-`/`--`-prefixed included, and lacks its value when no +// token follows; a flag that takes none, and a `--` token naming no flag +// of any command, takes none. The token `--` ends flag reading and is +// dropped; every later token is a non-flag token. Flag tokens may stand +// anywhere — before the command word, between it and its operands, or +// after them. +// 2. JSON output is in effect exactly when a `--json` token is read as a +// flag by that walk (a repeated one included, itself a usage error) — +// never a `--json` taken as another flag's value or standing after `--` +// — or when the invoked surface is JSON-only (10.7, 11, 12.6). +// 3. The remaining tokens, in order, are the command word from the known +// table (12.5: `build`, `check`, `ids`, `show`, `coverage`, `impact`, +// `review`, `query`, `occurrences`, `view`, `at`, `inventory`, `rename`, +// `move`, `version`), its subcommand (`review`, `query`), and its +// operands, matched to the synopsis exactly: no command word, an unknown +// command or subcommand, a missing operand, or a surplus one is a usage +// error — a surplus token is never accepted and ignored. +// 4. Each flag the walk read is checked against the command's accepted set +// — its own flags plus the global `--json` and `--config <path>` — so +// `--name=value`, or a flag of another command, is an unknown flag; a +// flag may be given at most once (identical values included); list-valued +// flags (`--kinds`) take one comma-separated value (11); and each value +// is judged against its fixed vocabulary or spelling rule. +// 5. The command-level checks: required flags, the co-occurrence rules, the +// operand count, and each operand's spelling rule. +// - Argument values are interpreted as UTF-8; a malformed value is a usage +// error judged before every per-flag and per-operand check (12.0). +// - Stages 3–5 are SPEC 12.0's syntax class, every error the invocation's +// arguments alone determine, so all of it is judged here, before `main` +// locates the configuration: an unknown command, subcommand, or flag, a +// repeated flag, a missing required flag or argument, a surplus operand, +// a malformed value, and every invalid flag value or operand spelling that +// a fixed vocabulary (`allowed`, `list`), a spelling rule (`SpellingRule`) +// or a co-occurrence rule (`exactlyOneOf`, `excludes`, +// `positionalConflicts`, `move`'s operand forms) decides. Every other +// usage error consults configuration, discovery, or the workspace — an +// unknown or wrong-kind name, an `<offset>` beyond the file's length — and +// is the command handler's, after the configuration error of 14.14. // -// 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 (stage 2, +// decided even when the arguments are themselves the error), 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). + +import { nodeSpellingProblem } from "../core/availability.js"; +import { globLiesOutsideRoot } from "../core/glob.js"; +import { sessionNameProblem } from "../core/session-name.js"; +import { describeSegmentViolation, segmentViolation } from "../core/text.js"; + +/** + * SPEC 12.0's syntax class, its spelling rules: "every invalid flag value + * or operand spelling that ... a spelling rule ... decides" is an error the + * invocation's arguments alone determine, reported without loading + * configuration. Each rule judges one value by its spelling alone + * (`spellingProblem`): + * + * - `identity` — a `<node>` or `<graph-node>` value: at most one `#` is + * well-formed (12.0, 1.5); whether it names a node is judged later, + * parse-local against the named file (12.0, 11.1); + * - `requirement-node` — `occurrences --to`, whose acceptance is syntactic + * (11.3): a well-formed requirement-node identity (1.4, 1.5); + * - `tag` — `query nodes --tag`, likewise syntactic (11.1): a spelling + * some tag can have (1.4); + * - `inside-root` — a `--file` glob: inside the workspace root, "decided by + * its spelling alone" (12.0, 7, 11.1, 12.3); + * - `session-name` — the form of 10.1; + * - `offset` — `at`'s `<offset>`: one or more ASCII decimal digits (11.5); + * whether it lies within the file's length is judged later. + */ +type SpellingRule = + | "identity" + | "requirement-node" + | "tag" + | "inside-root" + | "session-name" + | "offset"; /** One flag a command accepts, and how its value (if any) is validated. */ interface FlagSpec { @@ -50,6 +112,18 @@ interface FlagSpec { * list whose every element must be in this set. */ readonly list?: readonly string[]; + /** + * The spelling rule the flag's value must satisfy (SPEC 12.0's syntax + * class): a value it refuses is a usage error judged here, without + * loading configuration. + */ + readonly spelling?: SpellingRule; + /** + * A flag this one excludes, and why — SPEC 12.0's co-occurrence rules: + * given together, the two are a usage error of the syntax class, judged + * here without loading configuration. + */ + readonly excludes?: { readonly flag: string; readonly why: string }; } /** One command (or `review`/`query` subcommand) of the SPEC 12.5 table. */ @@ -60,6 +134,26 @@ interface CommandSpec { readonly positionals: readonly string[]; /** How many trailing positionals are optional (default none). */ readonly optionalPositionals?: number; + /** + * The command accepts any number of positionals beyond `positionals` + * (SPEC 11.4: `view [<file> …]`); the upper arity bound is not checked. + */ + readonly variadicPositionals?: boolean; + /** + * Flags that may not be combined with positional operands — SPEC 11.4: + * combining `<file>` operands with `--file` is a usage error, a defect + * the invocation's syntax alone determines (SPEC 12.0). + */ + readonly positionalConflicts?: readonly string[]; + /** + * The spelling rule of each operand, by position (parallel to + * `positionals`; a null entry, or none, has no rule) — SPEC 12.0's syntax + * class, judged here without loading configuration. A `<node>` operand + * takes the `identity` rule (a multi-`#` spelling is a malformed value); + * a bare `<file>` never does — it is a whole path in which `#` has no + * delimiter role (`view`, `at`, `rename`'s origin). + */ + readonly operandSpellings?: readonly (SpellingRule | null)[]; /** Command-specific flags; the SPEC 12.0 globals are added for every command. */ readonly flags: readonly FlagSpec[]; /** @@ -68,6 +162,13 @@ interface CommandSpec { * none or more than one is a usage error (12.0). */ readonly exactlyOneOf?: readonly (readonly string[])[]; + /** + * SPEC 12.0: the surface is JSON-only — a single JSON document is its + * only output form, with or without `--json` (10.7 `review export`, 11, + * 12.6), so JSON output is in effect for every invocation of it, its + * exit-2 errors included (the 12.7 error document). + */ + readonly jsonOnly?: boolean; } /** SPEC 12.0: every command supports `--json` and `--config <path>` (7). */ @@ -101,10 +202,30 @@ const TEST_HOLD_FLAG: FlagSpec = { valueName: "<path>", }; +/** + * SPEC 6.6: `rename` and `move` accept `--preview` — full validation and + * planning, performed on nothing. Combining it with `--test-hold` is a + * usage error (a preview acquires no exclusivity and does not take the + * acquisition-tied seam) — SPEC 12.0 lists "`--test-hold` beside + * `--preview`" in the syntax class, so it is judged here, before the + * configuration is loaded and before any hold file could be created. + */ +const PREVIEW_FLAG: FlagSpec = { + name: "--preview", + takesValue: false, + excludes: { + flag: "--test-hold", + why: + "a preview acquires no workspace exclusivity and does not take the " + + "acquisition-tied test seam (SPEC 6.6, 13.5, 12.0)", + }, +}; + /** * 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. @@ -117,12 +238,22 @@ const COMMANDS: readonly CommandSpec[] = [ positionals: [], flags: [ { name: "--tree", takesValue: false }, - { name: "--file", takesValue: true, valueName: "<glob>" }, + { + name: "--file", + takesValue: true, + valueName: "<glob>", + spelling: "inside-root", + }, { name: "--unreferenced", takesValue: false }, ], }, // SPEC 12.4: `show <node>`. - { path: "show", positionals: ["<node>"], flags: [] }, + { + path: "show", + positionals: ["<node>"], + operandSpellings: ["identity"], + flags: [], + }, // SPEC 8.2: `coverage` runs all profiles, `coverage <name>` one; `--check`. { path: "coverage", @@ -157,23 +288,46 @@ const COMMANDS: readonly CommandSpec[] = [ allowed: ["audit"], }, { name: "--coverage", takesValue: true, valueName: "<profile>" }, - { name: "--name", takesValue: true, valueName: "<name>", required: true }, + { + name: "--name", + takesValue: true, + valueName: "<name>", + required: true, + spelling: "session-name", + }, TEST_HOLD_FLAG, ], exactlyOneOf: [["--base", "--strategy", "--coverage"]], }, { path: "review list", positionals: [], flags: [] }, - { path: "review status", positionals: ["<name>"], flags: [] }, - { path: "review next", positionals: ["<name>"], flags: [] }, - { path: "review show", positionals: ["<name>", "<item-id>"], flags: [] }, + { + path: "review status", + positionals: ["<name>"], + operandSpellings: ["session-name"], + flags: [], + }, + { + path: "review next", + positionals: ["<name>"], + operandSpellings: ["session-name"], + flags: [], + }, + { + path: "review show", + positionals: ["<name>", "<item-id>"], + operandSpellings: ["session-name", null], + flags: [], + }, { path: "review split", positionals: ["<name>", "<item-id>"], + operandSpellings: ["session-name", null], flags: [TEST_HOLD_FLAG], }, { path: "review resolve", positionals: ["<name>", "<item-id>"], + operandSpellings: ["session-name", null], flags: [ // SPEC 10.7: `--status` accepts `updated`, `no-change`, and `skipped`; // any other value is a usage error. @@ -188,16 +342,38 @@ const COMMANDS: readonly CommandSpec[] = [ TEST_HOLD_FLAG, ], }, - { path: "review export", positionals: ["<name>"], flags: [] }, - // SPEC 11: the six query subcommands. - { path: "query node", positionals: ["<node>"], flags: [] }, + // SPEC 10.7: `export` is JSON-only — the entire session as a single JSON + // document, its only output form with or without `--json` (12.0). + { + path: "review export", + positionals: ["<name>"], + operandSpellings: ["session-name"], + flags: [], + jsonOnly: true, + }, + // SPEC 11: the six query subcommands — JSON-only surfaces (12.0). + { + path: "query node", + positionals: ["<node>"], + operandSpellings: ["identity"], + flags: [], + jsonOnly: true, + }, { path: "query nodes", positionals: [], + jsonOnly: true, flags: [ { name: "--group", takesValue: true, valueName: "<g>" }, - { name: "--file", takesValue: true, valueName: "<glob>" }, - { name: "--tag", takesValue: true, valueName: "<t>" }, + { + name: "--file", + takesValue: true, + valueName: "<glob>", + spelling: "inside-root", + }, + // SPEC 11.1: `--tag` accepts any well-formed tag (1.4) — syntactic + // acceptance, as on `occurrences --to` (11.3). + { name: "--tag", takesValue: true, valueName: "<t>", spelling: "tag" }, // SPEC 11: `--coverage required|none`. { name: "--coverage", @@ -210,9 +386,20 @@ const COMMANDS: readonly CommandSpec[] = [ { path: "query edges", positionals: [], + jsonOnly: true, flags: [ - { name: "--from", takesValue: true, valueName: "<graph-node>" }, - { name: "--to", takesValue: true, valueName: "<graph-node>" }, + { + name: "--from", + takesValue: true, + valueName: "<graph-node>", + spelling: "identity", + }, + { + name: "--to", + takesValue: true, + valueName: "<graph-node>", + spelling: "identity", + }, // SPEC 11: `edges --kinds` filters over all four kinds. { name: "--kinds", @@ -222,23 +409,38 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, - { path: "query subtree", positionals: ["<node>"], flags: [] }, - { path: "query ancestors", positionals: ["<node>"], flags: [] }, + { + path: "query subtree", + positionals: ["<node>"], + operandSpellings: ["identity"], + flags: [], + jsonOnly: true, + }, + { + path: "query ancestors", + positionals: ["<node>"], + operandSpellings: ["identity"], + flags: [], + jsonOnly: true, + }, { path: "query reachable", positionals: [], + jsonOnly: true, flags: [ { name: "--from", takesValue: true, valueName: "<graph-node>", required: true, + spelling: "identity", }, { name: "--to", takesValue: true, valueName: "<graph-node>", required: true, + spelling: "identity", }, { name: "--kinds", @@ -248,15 +450,86 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, - // SPEC 6.4: `rename <file> <old-id> <new-id>`. + // SPEC 11.3: `occurrences [--file <glob>] [--to <node>]` — JSON-only + // (SPEC 11: a single JSON document is its only output form, with or + // without `--json`). + { + path: "occurrences", + positionals: [], + jsonOnly: true, + flags: [ + { + name: "--file", + takesValue: true, + valueName: "<glob>", + spelling: "inside-root", + }, + // SPEC 11.3: acceptance is syntactic — only a spelling malformed as + // a requirement-node identity is a usage error (12.0's syntax class). + { + name: "--to", + takesValue: true, + valueName: "<node>", + spelling: "requirement-node", + }, + ], + }, + // SPEC 11.4: `view [<file> …] [--file <glob>] [--text]` — JSON-only + // (SPEC 11). Operands assert membership while `--file` restricts the + // domain; combining them is a usage error. + { + path: "view", + positionals: ["<file>"], + optionalPositionals: 1, + variadicPositionals: true, + positionalConflicts: ["--file"], + jsonOnly: true, + flags: [ + { + name: "--file", + takesValue: true, + valueName: "<glob>", + spelling: "inside-root", + }, + { name: "--text", takesValue: false }, + ], + }, + // SPEC 11.5: `at <file> <offset>` — JSON-only (SPEC 11). `<offset>` must + // be one or more ASCII decimal digits, a spelling rule judged here (12.0's + // syntax class); `<file>` asserts domain membership exactly as a `view` + // operand does and the offset must lie within the file's byte length — + // checks the handler runs against discovery and the file's bytes, before + // answering (SPEC 11.2, 12.0). + { + path: "at", + positionals: ["<file>", "<offset>"], + operandSpellings: [null, "offset"], + flags: [], + jsonOnly: true, + }, + // SPEC 11.6: `inventory` — JSON-only (SPEC 11: a single JSON document is + // its only output form, with or without `--json`). No flags beyond the + // globals: the inventory is a pure report of the workspace's shape. + { path: "inventory", positionals: [], flags: [], jsonOnly: true }, + // SPEC 6.4: `rename <file> <old-id> <new-id> [--preview]` (6.6). { path: "rename", positionals: ["<file>", "<old-id>", "<new-id>"], - flags: [TEST_HOLD_FLAG], + flags: [TEST_HOLD_FLAG, PREVIEW_FLAG], }, // SPEC 6.5: `move <old-file> <new-file>` or - // `move <file>#<id> <target-file>#<new-id>` — two positionals either way. - { path: "move", positionals: ["<old>", "<new>"], flags: [TEST_HOLD_FLAG] }, + // `move <file>#<id> <target-file>#<new-id>` — two positionals either way, + // `[--preview]` on both forms (6.6). + { + path: "move", + positionals: ["<old>", "<new>"], + flags: [TEST_HOLD_FLAG, PREVIEW_FLAG], + }, + // SPEC 12.6: `version` — JSON-only (a single JSON document is its only + // output form, with or without `--json`); workspace-independent, so + // `--config` (a global) is accepted and never consulted — `main` + // dispatches it before configuration location. + { path: "version", positionals: [], flags: [], jsonOnly: true }, ]; /** Every dispatch key (`CommandSpec.path`), in specification order. */ @@ -264,6 +537,11 @@ export const COMMAND_PATHS: readonly string[] = COMMANDS.map( (spec) => spec.path, ); +/** The dispatch keys of the JSON-only surfaces (SPEC 12.0; 10.7, 11, 12.6). */ +const JSON_ONLY_PATHS: ReadonlySet<string> = new Set( + COMMANDS.filter((spec) => spec.jsonOnly === true).map((spec) => spec.path), +); + /** A parsed flag value: boolean presence, one value, or a `--kinds` list. */ export type FlagValue = true | string | readonly string[]; @@ -273,7 +551,7 @@ export interface Invocation { readonly command: string; /** Positional arguments in order. */ readonly positionals: readonly string[]; - /** SPEC 12.0: the global `--json` flag. */ + /** SPEC 12.0: the global `--json` flag, read as a flag (not a value). */ readonly json: boolean; /** * SPEC 12.0: the global `--config <path>` value, a filesystem path to be @@ -287,26 +565,42 @@ 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 — a `--json` token read as a flag, not as another + * flag's value nor after `--` (even when the arguments are + * themselves the error, a repeated `--json` included), 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 +617,124 @@ export function isValidUtf8ArgumentValue(value: string): boolean { return true; } +/** + * SPEC 6.5: a `move` operand is classified by spelling alone — an operand + * containing `#` is a `<file>#<id>` pair under the split of 12.0, one + * without is a file. SPEC 12.0: at most one `#` is well-formed in any such + * value, so a spelling containing more than one is a malformed value; and + * an invocation mixing the two synopses' forms (one pair operand, one bare + * file) matches neither synopsis. Both are usage errors the invocation's + * syntax alone determines, so they are parse-level: reported without + * loading configuration (12.0), before workspace exclusivity or any hold + * file (13.5). Returns the diagnostic, or null for a well-formed pair of + * operands. + */ +function moveOperandsProblem(positionals: readonly string[]): string | null { + for (const operand of positionals) { + const first = operand.indexOf("#"); + if (first !== -1 && operand.includes("#", first + 1)) { + return ( + `operand '${operand}' contains more than one '#' — at most one is ` + + `well-formed: an operand containing '#' is a <file>#<id> pair and ` + + `one without is a file (SPEC 6.5, 12.0)` + ); + } + } + const [origin, destination] = positionals; + if ( + origin !== undefined && + destination !== undefined && + origin.includes("#") !== destination.includes("#") + ) { + return ( + `operands '${origin}' and '${destination}' mix the two synopses' ` + + `forms — an operand containing '#' is a <file>#<id> pair and one ` + + `without is a file, so the invocation matches neither ` + + `\`move <old-file> <new-file>\` nor ` + + `\`move <file>#<id> <target-file>#<new-id>\` (SPEC 6.5, 12.0)` + ); + } + return null; +} + +/** SPEC 11.5: an `<offset>` is one or more ASCII decimal digits — nothing else. */ +const OFFSET_SPELLING = /^[0-9]+$/; + +/** + * Judge one flag value or operand by its spelling rule (`SpellingRule`) — + * an error of SPEC 12.0's syntax class: the value alone decides it, so it + * is reported here, without loading configuration. `subject` names the + * flag (`'--to'`) or operand (`<node>`) for the diagnostic. Returns the + * diagnostic, or null for a well-formed spelling. + */ +function spellingProblem( + rule: SpellingRule, + value: string, + subject: string, +): string | null { + switch (rule) { + case "identity": { + // SPEC 12.0: at most one `#` is well-formed in a `<node>` or + // `<graph-node>` value — its `#` splits path from id or unit, and no + // identity contains one in path, id segment, or unit name (1.4, 1.5, + // 4.6) — so a spelling containing more than one is a malformed value. + const first = value.indexOf("#"); + if (first === -1 || !value.includes("#", first + 1)) return null; + return ( + `${subject} 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)` + ); + } + case "requirement-node": { + // SPEC 11.3: `--to` acceptance is syntactic — only a spelling + // malformed as a requirement-node identity is a usage error, while + // an unknown or unresolving identity selects nothing. + const problem = nodeSpellingProblem(value); + if (problem === null) return null; + return ( + `invalid value '${value}' for ${subject} — not a well-formed ` + + `requirement-node identity: ${problem} (SPEC 11.3, 1.4, 1.5, 12.0)` + ); + } + case "tag": { + // SPEC 11.1, 1.4: a spelling no tag can have, judged by the one + // shared 1.4 validator; the spelling is quoted as JSON so an + // invisible or line-breaking character shows in the one-line message. + const violation = segmentViolation(value, "tag"); + if (violation === null) return null; + return ( + `invalid value for ${subject} — the tag ${JSON.stringify(value)} ` + + `${describeSegmentViolation(violation)}, so no tag has this ` + + `spelling: a malformed value (SPEC 11.1, 1.4, 12.0)` + ); + } + case "inside-root": + // SPEC 7, 11.1, 12.3: a `--file` pattern outside the workspace root + // is an invalid flag value, "decided by its spelling alone" (12.0) — + // the pure depth count 7 applies to a configured glob. + if (!globLiesOutsideRoot(value)) return null; + return ( + `invalid value '${value}' for ${subject} — the pattern lies ` + + `outside the workspace root, decided by its spelling alone ` + + `(SPEC 7, 11.1, 12.3, 12.0)` + ); + case "session-name": + // SPEC 10.1: any name outside the form is a usage error (12.0). + return sessionNameProblem(value); + case "offset": + // SPEC 11.5: a sign, whitespace, or any other character is not a + // non-negative integer's spelling. + if (OFFSET_SPELLING.test(value)) return null; + return ( + `invalid ${subject} value '${value}' — 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)` + ); + } +} + /** `"build, check, ids, …"` for diagnostics, in specification order. */ function commandNameList(): string { const names: string[] = []; @@ -363,142 +775,248 @@ function buildTable(): ReadonlyMap< 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). + * SPEC 12.0: "A flag's arity is fixed by its name, the same for every + * command — known before the command word is identified, since flags may + * precede it". Every flag name of every command, the globals included, + * mapped to whether it takes a value; a name absent here — a `--` token + * naming no flag of any command — takes none. Built from the command table + * itself, so each flag carries one arity everywhere; a name declared with + * two arities is a table defect, refused at module load. */ -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). - const nonUtf8 = (indexInArgv: number): ParseResult => - usageError( - `argument ${String(indexInArgv + 1)} is not valid UTF-8 — argument ` + - `values are interpreted as UTF-8`, - ); +const FLAG_ARITY: ReadonlyMap<string, boolean> = buildArityTable(); - if (argv.length === 0) { - return usageError( - `missing command (expected one of: ${commandNameList()})`, - ); +function buildArityTable(): ReadonlyMap<string, boolean> { + const arity = new Map<string, boolean>(); + const flags = [...GLOBAL_FLAGS, ...COMMANDS.flatMap((spec) => spec.flags)]; + for (const flag of flags) { + const known = arity.get(flag.name); + if (known !== undefined && known !== flag.takesValue) { + throw new Error( + `flag '${flag.name}' is declared both with and without a value — ` + + `SPEC 12.0 fixes a flag's arity by its name`, + ); + } + arity.set(flag.name, flag.takesValue); } - const commandToken = argv[0]!; - if (!isValidUtf8ArgumentValue(commandToken)) { - return nonUtf8(0); + return arity; +} + +/** One token the walk read as a flag (SPEC 12.0), with its value. */ +interface WalkedFlag { + /** The flag token as spelled, leading `--` included. */ + readonly token: string; + /** + * The whole next token, for a value-taking flag followed by one; absent + * for a flag that takes none and for a value-taking flag standing last, + * which lacks its value. + */ + readonly value: string | undefined; +} + +/** The token walk of SPEC 12.0's invocation grammar. */ +interface TokenWalk { + /** Every token read as a flag, in argument order. */ + readonly flags: readonly WalkedFlag[]; + /** + * The non-flag tokens in order — the command word, its subcommand where + * it has one, and its operands — once the flags, their values, and any + * `--` are removed. + */ + readonly words: readonly string[]; + /** Whether a `--json` token was read as a flag (a repeated one included). */ + readonly json: boolean; +} + +/** + * SPEC 12.0: read the argument tokens. While flag reading lasts, a token + * beginning `--` is a flag token, wherever it stands — before the command + * word, between it and its operands, or after them; a value-taking flag + * (by its name: `FLAG_ARITY`) takes the whole next token as its value, + * whatever that token looks like, a `-`- or `--`-prefixed one included. + * The token `--` ends flag reading and is dropped: every later token is a + * non-flag token, `--`-prefixed spellings included. The walk needs no + * command word, so it completes on every argument vector, and JSON-in-effect + * is decided from it even when the arguments are themselves the error. + */ +function walkTokens(argv: readonly string[]): TokenWalk { + const flags: WalkedFlag[] = []; + const words: string[] = []; + let json = false; + let readingFlags = true; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (readingFlags && token === "--") { + readingFlags = false; + continue; + } + if (!readingFlags || !token.startsWith("--")) { + words.push(token); + continue; + } + if (token === "--json") json = true; + if (FLAG_ARITY.get(token) === true && index + 1 < argv.length) { + index += 1; + flags.push({ token, value: argv[index]! }); + } else { + flags.push({ token, value: undefined }); + } } - if (commandToken.startsWith("--")) { - return usageError( - `expected a command before any flags (expected one of: ` + - `${commandNameList()})`, - ); + return { flags, words, json }; +} + +/** + * SPEC 12.0, 12.5: the walk's non-flag tokens matched to the command table + * — the command word, then the subcommand of `review` or `query` — with the + * operands after them. A failure carries the usage diagnostic and whether + * the surface, as far as the words identify one, is JSON-only. + */ +type CommandMatch = + | { + readonly ok: true; + readonly spec: CommandSpec; + readonly operands: readonly string[]; + } + | { + readonly ok: false; + readonly message: string; + readonly jsonOnly: boolean; + }; + +function matchCommand(words: readonly string[]): CommandMatch { + const commandWord = words[0]; + if (commandWord === undefined) { + return { + ok: false, + message: `missing command (expected one of: ${commandNameList()})`, + jsonOnly: false, + }; } - const entry = TABLE.get(commandToken); + const entry = TABLE.get(commandWord); if (entry === undefined) { - return usageError( - `unknown command '${commandToken}' (expected one of: ` + + return { + ok: false, + message: + `unknown command '${commandWord}' (expected one of: ` + `${commandNameList()})`, - ); + jsonOnly: false, + }; + } + if (!(entry instanceof Map)) { + return { ok: true, spec: entry, operands: words.slice(1) }; } + // 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. + const groupJsonOnly = [...entry.values()].every( + (subcommand) => subcommand.jsonOnly === true, + ); + const subcommandWord = words[1]; + if (subcommandWord === undefined) { + return { + ok: false, + message: + `${commandWord}: missing subcommand (expected one of: ` + + `${subcommandNameList(entry)})`, + jsonOnly: groupJsonOnly, + }; + } + const subcommand = entry.get(subcommandWord); + if (subcommand === undefined) { + return { + ok: false, + message: + `${commandWord}: unknown subcommand '${subcommandWord}' (expected ` + + `one of: ${subcommandNameList(entry)})`, + jsonOnly: groupJsonOnly, + }; + } + return { ok: true, spec: subcommand, operands: words.slice(2) }; +} - let spec: CommandSpec; - let tokens: readonly string[]; - if (entry instanceof Map) { - const subToken = argv.length > 1 ? argv[1]! : undefined; - if (subToken === undefined || subToken.startsWith("--")) { - return usageError( - `${commandToken}: missing subcommand (expected one of: ` + - `${subcommandNameList(entry)})`, - ); - } - if (!isValidUtf8ArgumentValue(subToken)) { - return nonUtf8(1); - } - const subcommand = entry.get(subToken); - if (subcommand === undefined) { - return usageError( - `${commandToken}: unknown subcommand '${subToken}' (expected one ` + - `of: ${subcommandNameList(entry)})`, +/** + * Parse one invocation's argv (the elements after the executable name) + * against SPEC 12.0's invocation grammar and the SPEC 12.5 command table, + * in the stages the module header lists. Returns 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 { + // Stages 1–2 (SPEC 12.0): the token walk, and JSON-in-effect decided from + // it — a `--json` read as a flag, or a JSON-only surface as far as the + // remaining tokens identify one — before any check, so every usage error + // below is delivered in the one output form the arguments select. + const walk = walkTokens(argv); + const match = matchCommand(walk.words); + const jsonInEffect = + walk.json || (match.ok ? match.spec.jsonOnly === true : match.jsonOnly); + const refuse = (message: string): ParseResult => + usageError(message, jsonInEffect); + + // SPEC 12.0: argument values are interpreted as UTF-8, and a malformed + // value is judged before every per-flag and per-operand check — every + // token, `move`'s positional operands included (no argument value may + // name a non-UTF-8 path, 12.0). + for (let index = 0; index < argv.length; index += 1) { + if (!isValidUtf8ArgumentValue(argv[index]!)) { + return refuse( + `argument ${String(index + 1)} is not valid UTF-8 — argument ` + + `values are interpreted as UTF-8`, ); } - spec = subcommand; - tokens = argv.slice(2); - } else { - spec = entry; - tokens = argv.slice(1); } + // Stage 3 (SPEC 12.0): the command word and, for `review` and `query`, + // the subcommand; the operands are the words after them. + if (!match.ok) { + return refuse(match.message); + } + const { spec, operands: positionals } = match; + + // Stage 4 (SPEC 12.0): each flag the walk read, in argument order, against + // the command's accepted set — its own flags plus the globals. const flagSpecs = new Map<string, FlagSpec>(); for (const flag of GLOBAL_FLAGS) flagSpecs.set(flag.name, flag); for (const flag of spec.flags) flagSpecs.set(flag.name, flag); const seen = new Set<string>(); const flags = new Map<string, FlagValue>(); - const positionals: string[] = []; - let json = false; let config: string | undefined; - // Argv index of a token: `tokens` is argv minus the command (and - // 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)) { - return nonUtf8(tokenOffset + index); - } - positionals.push(token); - continue; - } - if (!isValidUtf8ArgumentValue(token)) { - return nonUtf8(tokenOffset + index); - } + for (const { token, value } of walk.flags) { const flag = flagSpecs.get(token); if (flag === undefined) { - // SPEC 12.0: unknown flags are usage errors. - return usageError(`${spec.path}: unknown flag '${token}'`); + // SPEC 12.0: a `--` token naming no flag the command accepts — + // `--name=value`, a flag of another command — is an unknown flag. + return refuse(`${spec.path}: unknown flag '${token}'`); } // SPEC 12.0: a flag may be given at most once per invocation; repeating a // flag is a usage error — identical values included. if (seen.has(token)) { - return usageError( + return refuse( `${spec.path}: flag '${token}' given more than once — a flag may be ` + `given at most once per invocation`, ); } seen.add(token); if (!flag.takesValue) { - if (token === "--json") json = true; - else flags.set(token, true); + if (token !== "--json") flags.set(token, true); continue; } - index += 1; - if (index >= tokens.length) { - return usageError( + if (value === undefined) { + // SPEC 12.0: a value-taking flag with no token after it lacks its value. + return refuse( `${spec.path}: flag '${token}' requires a value` + (flag.valueName === undefined ? "" : ` ${flag.valueName}`), ); } - const value = tokens[index]!; - if (!isValidUtf8ArgumentValue(value)) { - return nonUtf8(tokenOffset + index); - } if (flag.list !== undefined) { // SPEC 12.0: list-valued flags take one comma-separated value; an // element outside the flag's set is an invalid flag value. const elements = value.split(","); for (const element of elements) { if (!flag.list.includes(element)) { - return usageError( + return refuse( `${spec.path}: invalid value '${value}' for '${token}' — one ` + `comma-separated list of: ${flag.list.join(", ")}`, ); @@ -509,11 +1027,21 @@ export function parseArgv(argv: readonly string[]): ParseResult { } if (flag.allowed !== undefined && !flag.allowed.includes(value)) { // SPEC 12.0: invalid flag values are usage errors. - return usageError( + return refuse( `${spec.path}: invalid value '${value}' for '${token}' (expected ` + `one of: ${flag.allowed.join(", ")})`, ); } + if (flag.spelling !== undefined) { + // SPEC 12.0's syntax class: a value its spelling rule refuses — a + // multi-`#` identity, a `--to` or `--tag` malformed as an identity + // or tag, a `--file` glob outside the workspace root, a session name + // outside 10.1's form — is decided by the value alone. + const problem = spellingProblem(flag.spelling, value, `'${token}'`); + if (problem !== null) { + return refuse(`${spec.path}: ${problem}`); + } + } if (token === "--config") config = value; else flags.set(token, value); } @@ -521,7 +1049,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { // SPEC 12.0: missing required flags are usage errors. for (const flag of spec.flags) { if (flag.required === true && !seen.has(flag.name)) { - return usageError( + return refuse( `${spec.path}: missing required flag '${flag.name}'` + (flag.valueName === undefined ? "" : ` ${flag.valueName}`), ); @@ -531,31 +1059,95 @@ export function parseArgv(argv: readonly string[]): ParseResult { for (const group of spec.exactlyOneOf ?? []) { const given = group.filter((name) => seen.has(name)); if (given.length !== 1) { - return usageError( + return refuse( `${spec.path}: exactly one of ${group.join(", ")} is required` + (given.length === 0 ? "" : ` (got ${given.join(" and ")})`), ); } } + // SPEC 12.0's co-occurrence rules (via `excludes`): `--test-hold` beside + // `--preview` (6.6) — reported before the configuration is loaded, so no + // hold file is ever created for it. + for (const flag of spec.flags) { + const excluded = flag.excludes; + if ( + excluded !== undefined && + seen.has(flag.name) && + seen.has(excluded.flag) + ) { + return refuse( + `${spec.path}: ${excluded.flag} cannot be combined with ` + + `${flag.name}: ${excluded.why}`, + ); + } + } // 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( + return refuse( `${spec.path}: missing required argument ` + `${spec.positionals[positionals.length]!}`, ); } - if (positionals.length > spec.positionals.length) { - return usageError( + if ( + spec.variadicPositionals !== true && + positionals.length > spec.positionals.length + ) { + return refuse( `${spec.path}: unexpected argument ` + `'${positionals[spec.positionals.length]!}'`, ); } + // 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 refuse( + `${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`, + ); + } + } + // SPEC 12.0's syntax class, operand side (via `operandSpellings`): a + // multi-`#` `<node>` (`show`, `query node`, `query subtree`, `query + // ancestors`), a session name outside 10.1's form (`review`), an + // `<offset>` spelled other than in decimal digits (`at`, 11.5). + const operandRules = spec.operandSpellings ?? []; + for (let index = 0; index < positionals.length; index += 1) { + const rule = operandRules[index] ?? null; + if (rule === null) continue; + const problem = spellingProblem( + rule, + positionals[index]!, + spec.positionals[index]!, + ); + if (problem !== null) { + return refuse(`${spec.path}: ${problem}`); + } + } + // 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 refuse(`move: ${problem}`); + } + } return { ok: true, - invocation: { command: spec.path, positionals, json, config, flags }, + invocation: { + command: spec.path, + positionals, + json: walk.json, + config, + flags, + }, }; } @@ -586,6 +1178,17 @@ export function flagPresent(invocation: Invocation, name: string): boolean { return true; } +/** + * SPEC 12.0: whether JSON output is in effect for a parsed invocation — a + * `--json` token read as a flag, 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 00000000..9dc0da46 --- /dev/null +++ b/src/cli/commands/at-common.ts @@ -0,0 +1,39 @@ +// `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 diagnostic +// composition for every usage error of SPEC 11.5 that consults the +// workspace (the `<offset>` spelling itself is the parser's, cli/args.ts, +// judged before the configuration is loaded: 12.0's syntax class). This +// module stays light on purpose: cli/main.ts reaches it through the fast +// path before the TypeScript compiler is loaded. + +/** The unknown-`<file>` diagnostic (SPEC 11.5, 11.4, 7, 12.0). */ +export function unknownFileMessage(file: string): string { + return ( + `unknown file '${file}' — the <file> operand names a discovered spec ` + + `source, and no configured group discovers this path ` + + `(SPEC 11.5, 11.4, 7, 12.0)` + ); +} + +/** The wrong-kind-`<file>` diagnostic (SPEC 11.5, 11.4, 12.0). */ +export function wrongKindFileMessage(file: string): string { + return ( + `wrong-kind file '${file}' — the operand names a discovered code ` + + `source, and \`at\` resolves positions in spec sources; name a ` + + `discovered spec source (SPEC 11.5, 11.4, 12.0)` + ); +} + +/** The out-of-range-`<offset>` diagnostic (SPEC 11.5, 12.0). */ +export function offsetOutOfRangeMessage( + spelling: string, + byteLength: number, +): string { + return ( + `offset ${spelling} is out of range — only the offsets 0 through the ` + + `file's byte length (${String(byteLength)}) resolve (SPEC 11.5, 12.0)` + ); +} diff --git a/src/cli/commands/at-fast.ts b/src/cli/commands/at-fast.ts new file mode 100644 index 00000000..bfc6e8e5 --- /dev/null +++ b/src/cli/commands/at-fast.ts @@ -0,0 +1,178 @@ +// `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, the parser's (cli/args.ts), +// 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 { + offsetOutOfRangeMessage, + unknownFileMessage, + wrongKindFileMessage, +} from "./at-common.js"; +import { rangeJson, usageError } from "./common.js"; + +/** The stored source range of `identity`, or undefined when unknown. */ +function rangeOfIdentity( + snapshot: GraphSnapshot, + identity: string, +): { readonly start: number; readonly end: number } | undefined { + for (const node of snapshot.requirements) { + if (node.identity === identity) return node.range; + } + for (const location of snapshot.codeLocations) { + if (location.identity === identity) return location.range; + } + return undefined; +} + +/** + * Answer `at` from the verified store, or return null when no store + * verifies (the caller falls back to the full path). SPEC 11: a single + * JSON document is `at`'s only output form; the argument checks of + * SPEC 11.5 precede the answer exactly as on the full path. + */ +export async function tryFastAt( + invocation: Invocation, + located: LocatedWorkspace, + stdout: CliWriter, + stderr: CliWriter, +): Promise<ExitCode | null> { + const io = { stdout, stderr }; + const file = invocation.positionals[0]!; + const offsetSpelling = invocation.positionals[1]!; + + // SPEC 11.5/12.0: the <offset> is decimal digits — the parser judged its + // spelling (cli/args.ts) before the configuration was located. + 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 00000000..6e102729 --- /dev/null +++ b/src/cli/commands/at.ts @@ -0,0 +1,255 @@ +// `xspec at <file> <offset>` (SPEC 11.5). +// +// Resolves a byte position in a discovered spec source: the innermost +// section construct whose range (SPEC 1.7) contains the offset — the root +// when no narrower section does — reported with its construct range and, +// per SPEC 11.2, its node identity; and, when the offset lies within a +// reference occurrence's range, that occurrence's full record (SPEC 5.7). +// JSON-only (SPEC 11): a single JSON document — the 12.7 +// `{"findings", "resolution"}` form — is its only output form, with or +// without `--json`. +// +// The argument checks precede answering and the refresh (SPEC 11.2, 12.0), +// each a usage error at exit 2 whatever findings the workspace or the named +// file carry: +// +// - `<offset>` must be one or more ASCII decimal digits, read in decimal — +// leading zeros permitted; a sign, whitespace, or any other character is +// not a non-negative integer's spelling (SPEC 11.5). A purely syntactic +// check — 12.0's syntax class — so the parser judges it (cli/args.ts), +// before any configuration or source is consulted. +// - `<file>` asserts domain membership exactly as a `view` operand does +// (SPEC 11.4): a file outside the discovered set is unknown and a +// discovered code source is a wrong-kind operand; a `#`-containing +// operand is a whole path, never a `path#id` split (SPEC 12.0), so an +// invalid-path spec member with a UTF-8 spelling is addressable (a +// non-UTF-8-pathed one is nameable by no argument value — the glob-reached +// view is the one route to its positions, SPEC 11.5). +// - An offset greater than the file's byte length is a usage error; equal +// (the EOF caret) resolves to the root (SPEC 11.5). The byte length is a +// property of the file's bytes, not of its parse, so the bound is judged +// on unparseable files too — read from the parse where one exists, from +// the filesystem otherwise. +// +// Resolution is by range containment and total over the file (SPEC 11.5): +// every within-file offset resolves through the same positional tree the +// view serves (SPEC 11.4), so `at` adds convenience, not information. The +// consulted domain (SPEC 11.2) is the named file: its findings accompany +// the answer, any finding or explicitly-unavailable datum exits 1 with the +// full document still emitted, and on an unparseable file the resolution is +// exactly the unavailability marker, the parse-failure finding beside it. + +import { + accompanyingFindings, + availabilityExit, + ConsultedDomain, + selectOccurrences, +} from "../../core/availability.js"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { JsonObject, JsonValue } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import { orderFindings } from "../../core/findings.js"; +import type { SpecFileAnalysis } from "../../core/graph.js"; +import type { SpecSection } from "../../core/mdx.js"; +import { definedIdentitySections } from "../../core/mdx.js"; +import { pathTextKey } from "../../core/path-text.js"; +import { + finishAvailabilityRefresh, + readSourceByteLength, +} from "../../workspace/availability.js"; +import type { Invocation } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { analyzeAnalysisForAvailability } from "../prepare.js"; +import { + findingToJson, + occurrenceRecordJson, + unavailableJson, +} from "../report.js"; +import { + offsetOutOfRangeMessage, + unknownFileMessage, + wrongKindFileMessage, +} from "./at-common.js"; +import { rangeJson, usageError } from "./common.js"; + +/** The `at` command handler (SPEC 11.5). */ +export async function atCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + const file = invocation.positionals[0]!; + const offsetSpelling = invocation.positionals[1]!; + + // SPEC 11.5, 12.0: the offset's spelling — one or more ASCII decimal + // digits — was judged by the parser (cli/args.ts), a syntax-class usage + // error reported before the configuration is loaded. + const offset = Number.parseInt(offsetSpelling, 10); + + // --- the analysis half of the SPEC 11.2 pre-answer step (a pure read) --- + const prepared = await analyzeAnalysisForAvailability(invocation, context); + if (!prepared.ok) { + return prepared.exit; + } + const { analysis } = prepared; + const { classification } = analysis; + + // --- operand membership (SPEC 11.5: exactly as a `view` operand, 11.4): + // judged against the discovered set — discovery is controlled exclusively + // by configuration (SPEC 7), so an on-disk file no group discovers is + // unknown — before any answer or refresh side effect (SPEC 11.2). + const discoveredKinds = new Map<string, "spec" | "code">(); + for (const source of classification.specSources) { + discoveredKinds.set(pathTextKey(source.path), "spec"); + } + for (const source of classification.codeSources) { + discoveredKinds.set(pathTextKey(source.path), "code"); + } + for (const source of classification.invalidSources) { + // SPEC 11.2/14.19: invalid-path members are discovered files of their + // kind — a spec-kind member is addressable where its path has a UTF-8 + // spelling; a code-kind member is a wrong-kind operand like any other + // discovered code source. + discoveredKinds.set(pathTextKey(source.path), source.kind); + } + const kind = discoveredKinds.get(pathTextKey(file)); + if (kind === undefined) { + return usageError(invocation, context, unknownFileMessage(file)); + } + if (kind === "code") { + return usageError(invocation, context, wrongKindFileMessage(file)); + } + + // The named file's parse, where one exists: an unparseable file (masked, + // SPEC 14.20) has none — its resolution is explicitly unavailable below. + const key = pathTextKey(file); + let requested: + | { readonly spec: SpecFileAnalysis; readonly pathValid: boolean } + | undefined; + for (const spec of analysis.specs) { + if (pathTextKey(spec.document.file) === key) { + requested = { spec, pathValid: true }; + break; + } + } + if (requested === undefined) { + for (const spec of analysis.invalidPathSpecs) { + if (pathTextKey(spec.document.file) === key) { + // SPEC 11.2/14.19: parse-local structure stays on view while no + // node of the file has a defined identity. + requested = { spec, pathValid: false }; + break; + } + } + } + + // --- the offset bound (SPEC 11.5): greater than the file's byte length + // is a usage error; equal resolves to the root. The length is the parsed + // root's construct end (the entire file, SPEC 1.7) or, for a file the + // analysis holds no parse for, the file's bytes read directly — with + // unreadable content there is no byte length to judge against, and the + // resolution below is explicitly unavailable regardless. + const byteLength = + requested !== undefined + ? requested.spec.document.root.range.end + : await readSourceByteLength(context.workspace, file); + if (byteLength !== null && offset > byteLength) { + return usageError( + invocation, + context, + offsetOutOfRangeMessage(offsetSpelling, byteLength), + ); + } + + // --- the refresh half (SPEC 13.3, 11.2): the invocation is valid, so + // the surface participates in read-time refresh on a passing workspace + // and touches nothing on a failing one. + await finishAvailabilityRefresh(context.workspace, analysis); + + // --- the answer (SPEC 11.5, 11.2): the consulted domain is the named + // file — its findings alone accompany. + const domain = new ConsultedDomain([file]); + const findings = orderFindings( + accompanyingFindings(analysis.findings, domain), + ); + + let carriesUnavailable = false; + let resolution: JsonValue; + if (requested === undefined) { + // SPEC 11.5/11.2: on an unparseable file the resolution is reported + // explicitly unavailable — never a fabricated root resolution — the + // parse-failure finding accompanying it. + carriesUnavailable = true; + resolution = unavailableJson(); + } else { + const { spec, pathValid } = requested; + const document = spec.document; + + // The innermost section construct whose range contains the offset + // (SPEC 1.7: start-inclusive, end-exclusive), descending the same + // positional tree the view serves (SPEC 11.4) — the root remains where + // no section contains the offset, which also realizes the EOF-caret + // rule: the byte-length offset lies in no end-exclusive range. + let node: SpecSection = document.root; + let descended = true; + while (descended) { + descended = false; + for (const child of node.children) { + if (child.range.start <= offset && offset < child.range.end) { + node = child; + descended = true; + break; + } + } + } + + // SPEC 11.2: the node identity datum — defined per the spelling, chain, + // and uniqueness rules on a valid path (the root's exactly when the + // path is valid), explicitly unavailable otherwise. + const defined = pathValid ? definedIdentitySections(document) : null; + const isRoot = node.parent === null; + let identity: JsonValue; + if (defined === null) { + carriesUnavailable = true; + identity = unavailableJson(); + } else if (isRoot) { + identity = document.path; + } else if (defined.has(node)) { + identity = `${document.path}#${node.id ?? ""}`; + } else { + carriesUnavailable = true; + identity = unavailableJson(); + } + + // SPEC 11.5/5.7: the containing occurrence's full record — the named + // file's records in occurrence order, the first (only: occurrence + // spans are disjoint) whose range contains the offset — or null when + // the offset lies within none. + const records = selectOccurrences(analysis.graph, domain); + const containing = records.find( + (record) => record.range.start <= offset && offset < record.range.end, + ); + let occurrence: JsonValue; + if (containing === undefined) { + occurrence = null; + } else { + if (containing.source === null) { + // The record's source datum is the unavailability marker + // (SPEC 11.2) — an explicitly-unavailable datum in the answer. + carriesUnavailable = true; + } + occurrence = occurrenceRecordJson(containing); + } + + const section: JsonObject = { identity, range: rangeJson(node.range) }; + resolution = { section, occurrence }; + } + + const document: JsonValue = { + findings: findings.map(findingToJson), + resolution, + }; + context.stdout.write(canonicalJson(document)); + // SPEC 11.2: any finding or explicitly-unavailable datum → exit 1 with + // the full document emitted; complete and finding-free → exit 0. + return availabilityExit(findings, carriesUnavailable); +} diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 40174783..13cb9e45 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -11,19 +11,24 @@ // that fails — validation errors (exit 1, findings on standard output) or a // configuration error (exit 2, diagnostics on standard error) — modifies // nothing: every write happens strictly after all validation, including the -// SPEC 14.22 pre-write check over the complete write set. +// SPEC 14.22 pre-write check over the complete write set, judged beside the +// source and journal findings (workspace/build-validation.ts). import type { BuildOutputs } from "../../core/build.js"; import { computeBuildOutputs } from "../../core/build.js"; import type { ExitCode } from "../../core/findings.js"; import { executeBuildOutputs } from "../../workspace/build.js"; -import { loadGraphData } from "../../workspace/graph-data.js"; +import { buildValidationFindings } from "../../workspace/build-validation.js"; +import { + readDerivedFileRecord, + recordedPathsOf, +} from "../../workspace/graph-data.js"; import { analyzeWorkspace, workspaceInputsOf, } from "../../workspace/pipeline.js"; -import { symlinkWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; import type { CommandContext } from "../io.js"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; @@ -38,34 +43,28 @@ 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); - return 2; - } - - const findings = [...analysis.findings]; - 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). - const stored = await loadGraphData(workspace.root); - outputs = computeBuildOutputs( - workspace.configuration, - analysis.specs, - analysis.graph, - analysis.textModel, - analysis.hashes, - stored.data, - workspaceInputsOf(workspace, analysis), - ); - findings.push( - ...(await symlinkWritePathFindings(workspace.root, outputs.writePaths)), + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + analysis.configurationErrors, ); + return 2; } + // SPEC 12.1, 13.3, 14: the validations of `build` — source validation + // errors, journal errors (14.13), and refused writes (14.22) alike, each + // condition reported beside the others. The 14.22 examination runs over + // the write paths discovery and configuration define (13.1, 7.3, 13.3), + // so a refused write reports beside the source and journal findings, + // not only on an otherwise valid workspace: 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 findings = await buildValidationFindings(workspace, analysis); if (findings.length > 0) { // SPEC 12.1/12.0: a failing build's validation errors are the report — // standard-output content, exit 1 — and the build modifies nothing. @@ -73,11 +72,20 @@ export async function buildCommand( return 1; } - // outputs is non-null here: it is computed exactly when the analysis had - // no findings, and the 14.22 check added none. - if (outputs === null) { - throw new Error("xspec internal error: build outputs missing"); - } + // Valid workspace: derive the complete output set (core) — its write + // paths exactly the set just examined (core/build.ts). + // SPEC 12.1/13.4: the stored record's paths are the orphan-removal + // domain — none where the record is absent or unreadable. + const record = await readDerivedFileRecord(workspace.root); + const outputs: BuildOutputs = computeBuildOutputs( + workspace.configuration, + analysis.specs, + analysis.graph, + analysis.textModel, + analysis.hashes, + recordedPathsOf(record), + workspaceInputsOf(workspace, analysis), + ); await executeBuildOutputs(workspace.root, outputs); if (invocation.json) { // SPEC 12.0: every command supports `--json`, emitting a single JSON diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 96961c08..63008f8c 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -13,11 +13,15 @@ // - the journal is well-formed and replayable with no conflicting mappings // (SPEC 14.13 — likewise); // - no policy violations exist (SPEC 7.5 → 14.12, `check`-only; -// core/policy.ts); +// core/policy.ts) — evaluated on a workspace passing `build`'s +// validations alone; // - 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 -// without writing (SPEC 14.22). +// - 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), judged over the paths discovery and +// configuration define whatever the sources' validity, beside the +// source and journal findings (workspace/build-validation.ts). // // `check` never refreshes (SPEC 13.3): it reports staleness instead of // rewriting graph data, and it writes nothing whatsoever — every probe here @@ -25,18 +29,30 @@ // load by every command (SPEC 14.14) and is a usage error, not a finding. import type { BuildOutputs } from "../../core/build.js"; -import { computeBuildOutputs } from "../../core/build.js"; +import { + computeBuildOutputs, + discoveredGeneratedPaths, + orphanedRecordedPaths, +} from "../../core/build.js"; import type { ExitCode, Finding } from "../../core/findings.js"; import { evaluatePolicy } from "../../core/policy.js"; -import { stalenessFindings } from "../../workspace/check.js"; -import { loadGraphData } from "../../workspace/graph-data.js"; +import { buildValidationFindings } from "../../workspace/build-validation.js"; +import { + mismatchStalenessFindings, + recordStalenessFindings, +} from "../../workspace/check.js"; +import { + loadGraphData, + readDerivedFileRecord, + recordedPathsOf, +} from "../../workspace/graph-data.js"; import { analyzeWorkspace, workspaceInputsOf, } from "../../workspace/pipeline.js"; import { loadAllSessions } from "../../workspace/reviews.js"; -import { symlinkWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; import type { CommandContext } from "../io.js"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; @@ -50,20 +66,55 @@ 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; } - const findings: Finding[] = [...analysis.findings]; + // SPEC 14.10: the recorded-file form compares the record against the set + // of generated paths alone — a set discovery and configuration define on + // any workspace (13.1, 7.3, 11.6; core/build.ts) — so, like the + // unreadable-record unit form, it is detectable whatever the sources' + // validity: beside source validation errors, journal errors (14.13), and + // refused writes (14.22) alike. + const record = await readDerivedFileRecord(workspace.root); + const orphans = orphanedRecordedPaths( + recordedPathsOf(record), + new Set( + discoveredGeneratedPaths( + workspace.configuration, + analysis.classification, + ), + ), + ); + + // SPEC 12.2 → 12.1, 13.3: all build validations — source validation + // findings and journal errors (the analysis) and the refused writes of + // 14.22, which `check` reports without writing, judging exactly + // `build`'s write paths: the set discovery and configuration define on + // any workspace (13.1, 7.3, 13.3; workspace/build-validation.ts), so a + // refused write reports beside the source and journal findings (SPEC + // 14). Whether the workspace passes them — none present — is the one + // state in which the content the current sources and configuration + // generate (14.10) and the graph policy constrains (14.12) are defined. + const buildFindings = await buildValidationFindings(workspace, analysis); + const findings: Finding[] = [...buildFindings]; + const passesBuildValidations = buildFindings.length === 0; - // SPEC 14.10/14.22 — judged against the pure build derivation, which is - // defined only for a workspace that passes build validation: with - // findings present, "what the current sources and configuration - // generate" is undefined and the validation errors mask staleness - // (SPEC 14); the write set is equally undefined for 14.22. - if (analysis.findings.length === 0) { + // SPEC 14.10 — the mismatch forms, per file and graph data, judged + // against the pure build derivation (core/build.ts), computed only over + // a workspace passing `build`'s validations: with a source validation + // finding, a journal error, or a refused write (14.22) present, "what + // the current sources and configuration generate" is undefined and the + // mismatch forms are undetectable (SPEC 14). + if (passesBuildValidations) { const stored = await loadGraphData(workspace.root); const outputs: BuildOutputs = computeBuildOutputs( workspace.configuration, @@ -71,24 +122,33 @@ export async function checkCommand( analysis.graph, analysis.textModel, analysis.hashes, - stored.data, + recordedPathsOf(record), workspaceInputsOf(workspace, analysis), ); 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. - findings.push( - ...(await symlinkWritePathFindings(workspace.root, outputs.writePaths)), + ...(await mismatchStalenessFindings( + workspace.root, + outputs, + stored, + record, + )), ); } - // SPEC 7.5 → 14.12 (`check`-only): policy is evaluated over the workspace - // graph — detectable, and therefore reported (SPEC 14), whether or not - // other findings are present: every flagged edge is a resolved edge of - // the current graph. - findings.push(...evaluatePolicy(workspace.configuration, analysis.graph)); + // SPEC 14.10: the forms consulting no generated content — the + // unreadable-record unit form and the recorded-file form — are reported + // whatever the sources' validity. + findings.push( + ...(await recordStalenessFindings(workspace.root, record, orphans)), + ); + + // SPEC 7.5 → 14.12 (`check`-only): the rules are evaluated only over a + // workspace passing `build`'s validations, the one state in which the + // graph they constrain is defined — on a failing workspace no violation + // is detectable, and none is reported (SPEC 14). + if (passesBuildValidations) { + findings.push(...evaluatePolicy(workspace.configuration, analysis.graph)); + } // SPEC 14.21: review sessions are not internally corrupt — every session // is loaded read-only, in byte order of session name (SPEC 12.0), and diff --git a/src/cli/commands/common.ts b/src/cli/commands/common.ts index 37ca7a15..cac792ad 100644 --- a/src/cli/commands/common.ts +++ b/src/cli/commands/common.ts @@ -10,29 +10,35 @@ 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 { CompiledGlob } from "../../core/glob.js"; +import { compileGlob } from "../../core/glob.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 (a `--json` read as a flag, 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; } @@ -60,34 +66,30 @@ export function testHoldSpecOf( } /** - * 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. + * The invocation's `--file <glob>` value compiled under the glob rules of + * SPEC 7 (plain mode), or undefined when the flag was not given (11.1, + * 11.3, 11.4, 12.3). The parser has already refused a pattern outside the + * workspace root — plain mode's one compile error — as a usage error of + * 12.0's syntax class, decided by its spelling alone before the + * configuration is loaded (cli/args.ts), so every value reaching a handler + * compiles. */ -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`; - } +export function compileFileFlag( + invocation: Invocation, +): CompiledGlob | undefined { + const pattern = flagValue(invocation, "--file"); + if (pattern === undefined) { + return undefined; + } + const compiled = compileGlob(pattern, "plain"); + if (!compiled.ok) { + // Unreachable: the parser refuses every outside-root `--file` value. + throw new Error( + `xspec internal error: '${invocation.command}' received a --file ` + + `value the parser should have refused (${compiled.error.kind})`, + ); } - return null; + return compiled.glob; } /** A source range (SPEC 1.7) as JSON data. */ diff --git a/src/cli/commands/coverage.ts b/src/cli/commands/coverage.ts index db6dbb71..82d61601 100644 --- a/src/cli/commands/coverage.ts +++ b/src/cli/commands/coverage.ts @@ -24,7 +24,7 @@ import type { ExitCode } from "../../core/findings.js"; import type { Invocation } from "../args.js"; import { flagPresent } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, finishGraphForRead } from "../prepare.js"; import { emitDocument, usageError } from "./common.js"; /** One profile's report as JSON data (SPEC 8.2: the same information). */ @@ -90,9 +90,19 @@ export async function coverageCommand( const { stdout, stderr } = context; const { configuration } = context.workspace; + // SPEC 12.0: the configuration search and the discovery of 7 precede + // every error consulting them — a configuration error, a discovery-level + // one included (14.14), and a discovery read the environment refuses + // (14.25, thrown from the walk) are each met before the profile name is + // judged against the configuration. + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed.exit; + } + // SPEC 8.2/12.0: `coverage <name>` runs one profile; an unknown profile - // name is a usage error. A configuration-level check, preceding source - // analysis like its 14.14 counterparts. + // name is a usage error — an argument check, judged against the + // configuration, so it precedes the invalid-workspace report of 13.3. let profiles: readonly CoverageProfile[] = configuration.coverage; if (invocation.positionals.length > 0) { const name = invocation.positionals[0]; @@ -101,8 +111,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)`, ); @@ -110,8 +120,12 @@ export async function coverageCommand( profiles = [named]; } - // SPEC 13.3: refresh-on-read, then answer. - const prepared = await prepareGraphForRead(invocation, context); + // SPEC 13.3: the gate and refresh-on-read, then answer. + const prepared = await finishGraphForRead( + invocation, + context, + analyzed.analysis, + ); if (!prepared.ok) { return prepared.exit; } diff --git a/src/cli/commands/gated-args.ts b/src/cli/commands/gated-args.ts new file mode 100644 index 00000000..f273015a --- /dev/null +++ b/src/cli/commands/gated-args.ts @@ -0,0 +1,184 @@ +// Parse-local argument checks of the gated reads (SPEC 12.0, 13.3). +// +// SPEC 12.0: the reads 13.3 gates (`ids`, `show`, `coverage`, `impact`, +// `review`, `query`) run their argument checks before the invalid-workspace +// report of 13.3 — a usage-error argument exits 2 whatever findings the +// workspace carries. A requirement-node or graph-node identity is judged +// parse-local against the named file, as 6.4 judges rename's old ID: +// +// - the path part must be a discovered path of the identity's kind +// (SPEC 11.1) — for `<node>` a spec source, a code source being the +// wrong-kind operand of 12.0; for `<graph-node>` either kind; +// - an id is judged over the named file's spelled identities (SPEC 11.2) — +// a section spells an identity exactly when exactly one `id` attribute +// occurs on its tag with a quoted static-string value, that value the +// spelled identity, well-formed or not (core/mdx.ts `SpecSection.id`); +// - a code unit is judged over the named file's named units (SPEC 4.6); +// - an unparseable named file masks the id/unit half of the check as in +// 6.4: the check passes here and the gated report of 13.3 exits 1. +// +// Each check is judged from what it consults — discovery and the named +// file's parse — identically on valid and failing workspaces (SPEC 12.0). +// On a valid workspace a spelled identity is a defined identity and a named +// unit a code location (SPEC 11.2, 12.1), so these judgments agree exactly +// with the graph-based resolution the answer then runs (query-core.ts) — +// and they share its message builders, so the store-backed fast path +// (query-fast.ts), which judges against the verified store, reports +// byte-identically (SPEC 12.0). + +import type { CodeAnalysis } from "../../core/code-analysis.js"; +import type { SpecDocument } from "../../core/mdx.js"; +import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; +import { + codeLocationNodeMessage, + unknownGraphNodeMessage, + unknownNodeMessage, +} from "./query-core.js"; + +/** A `<node>`/`<graph-node>` value split at its `#` (SPEC 12.0, 1.5). */ +interface SplitIdentity { + readonly path: string; + /** The id or unit part — undefined for a bare path. */ + readonly rest: string | undefined; +} + +/** Split at the `#` (the parser rejects multi-`#` spellings, SPEC 12.0). */ +function splitIdentity(raw: string): SplitIdentity { + const hash = raw.indexOf("#"); + if (hash === -1) { + return { path: raw, rest: undefined }; + } + return { path: raw.slice(0, hash), rest: raw.slice(hash + 1) }; +} + +/** The parse-local view of the named file the checks consult. */ +interface NamedFileDomain { + /** Discovered spec-source paths (valid paths only, SPEC 14.19/12.0). */ + readonly specPaths: ReadonlySet<string>; + /** Discovered code-source paths (valid paths only). */ + readonly codePaths: ReadonlySet<string>; + /** Parsed spec documents by path — absent = unparseable (SPEC 14.20). */ + readonly spec: (path: string) => SpecDocument | undefined; + /** Parsed code analyses by path — absent = unparseable (SPEC 14.20). */ + readonly code: (path: string) => CodeAnalysis | undefined; +} + +/** The checks' domain over the analyzed workspace (pipeline.ts). */ +function domainOf(analysis: WorkspaceAnalysis): NamedFileDomain { + const { classification } = analysis; + const specs = new Map( + analysis.specs.map((spec) => [spec.document.path, spec.document]), + ); + const code = new Map(analysis.code.map((entry) => [entry.path, entry])); + return { + specPaths: new Set(classification.specSources.map((source) => source.path)), + codePaths: new Set(classification.codeSources.map((source) => source.path)), + spec: (path) => specs.get(path), + code: (path) => code.get(path), + }; +} + +/** + * SPEC 11.2: whether the parsed file spells `id` — some section's exactly-one + * quoted-static `id` attribute carries this exact value (well-formed or not; + * `SpecSection.id` is null in every other case, and null for the root). + */ +function spellsIdentity(document: SpecDocument, id: string): boolean { + return document.sections.some((section) => section.id === id); +} + +/** + * SPEC 4.6: whether the value names one of the file's named units — the + * whole-file location for a bare path, else a unit whose `path#chain` + * (`@N`-disambiguated) identity equals the value. Judged over the parse + * where one exists; the kind itself is discovery's (an unparseable code + * file still classifies as a code location for the wrong-kind judgment — + * the id/unit half is what an unparseable file masks). + */ +function namesCodeLocation( + analysis: CodeAnalysis | undefined, + raw: string, + split: SplitIdentity, +): boolean { + if (split.rest === undefined) { + return true; + } + if (analysis === undefined) { + return true; // masked: the unit cannot be judged (SPEC 12.0, 14.20) + } + return analysis.units.some((unit) => unit.identity === raw); +} + +/** + * The `<node>` argument check of `show` and `query node`/`subtree`/ + * `ancestors` (SPEC 12.4, 11.1 → 12.0), parse-local per the module header. + * Returns the usage-error diagnostic, or null when the check passes — an + * unknown name or wrong-kind operand exits 2 whatever findings the + * workspace carries; a masked (unparseable) named file passes, the gated + * report of 13.3 then exiting 1. + */ +export function nodeOperandProblem( + analysis: WorkspaceAnalysis, + raw: string, +): string | null { + const domain = domainOf(analysis); + const split = splitIdentity(raw); + if (domain.specPaths.has(split.path)) { + const document = domain.spec(split.path); + if (document === undefined) { + return null; // masked: an unparseable named file (SPEC 12.0, 14.20) + } + if (split.rest === undefined || spellsIdentity(document, split.rest)) { + return null; + } + return unknownNodeMessage(raw); + } + if (domain.codePaths.has(split.path)) { + // SPEC 12.0: a code source named where a requirement-node identity is + // required is the wrong-kind operand — the kind is discovery's, never + // masked. The diagnostic mirrors the graph-based resolution exactly + // (query-core.ts `resolveRow`): a value naming a code location gets the + // wrong-kind message, one naming no unit of the file the unknown one. + return namesCodeLocation(domain.code(split.path), raw, split) + ? codeLocationNodeMessage(raw) + : unknownNodeMessage(raw); + } + return unknownNodeMessage(raw); +} + +/** + * The `<graph-node>` flag-value check of `query edges`/`reachable` + * (SPEC 11.1 → 12.0), parse-local per the module header: any graph-node + * identity — a requirement node or a code location. Returns the + * usage-error diagnostic, null when the check passes (a masked named file + * passing as above). + */ +export function graphNodeValueProblem( + analysis: WorkspaceAnalysis, + flag: string, + raw: string, +): string | null { + const domain = domainOf(analysis); + const split = splitIdentity(raw); + if (domain.specPaths.has(split.path)) { + const document = domain.spec(split.path); + if (document === undefined) { + return null; // masked (SPEC 12.0, 14.20) + } + if (split.rest === undefined || spellsIdentity(document, split.rest)) { + return null; + } + return unknownGraphNodeMessage(flag, raw); + } + if (domain.codePaths.has(split.path)) { + const parsed = domain.code(split.path); + if (parsed === undefined) { + return null; // masked (SPEC 12.0, 14.20) + } + if (split.rest === undefined || namesCodeLocation(parsed, raw, split)) { + return null; + } + return unknownGraphNodeMessage(flag, raw); + } + return unknownGraphNodeMessage(flag, raw); +} diff --git a/src/cli/commands/ids.ts b/src/cli/commands/ids.ts index 92a4e90a..2659d78f 100644 --- a/src/cli/commands/ids.ts +++ b/src/cli/commands/ids.ts @@ -24,14 +24,12 @@ import type { JsonObject } from "../../core/canonical-json.js"; import type { ExitCode } from "../../core/findings.js"; -import type { CompiledGlob } from "../../core/glob.js"; -import { compileGlob } from "../../core/glob.js"; import type { RequirementNode, WorkspaceGraph } from "../../core/graph.js"; import type { Invocation } from "../args.js"; -import { flagPresent, flagValue } from "../args.js"; +import { flagPresent } from "../args.js"; import type { CommandContext } from "../io.js"; import { prepareGraphForRead } from "../prepare.js"; -import { emitDocument, usageError } from "./common.js"; +import { compileFileFlag, emitDocument } from "./common.js"; /** The requirement ID of a listed node — never a root (see the filter). */ function requirementIdOf(node: RequirementNode): string { @@ -168,25 +166,10 @@ export async function idsCommand( ): Promise<ExitCode> { const { stdout, stderr } = context; - // SPEC 12.3/11: `--file` compiles under the glob rules of 7, where a - // pattern resolving outside the workspace root is an invalid flag value, - // exit 2 like its configuration-time counterpart (14.14). Like those - // counterparts, this check precedes source analysis. - 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: outside-root (SPEC 7). - return usageError( - stderr, - invocation.command, - `invalid value '${filePattern}' for '--file' — the pattern ` + - `resolves outside the workspace root (SPEC 12.3, 7, 12.0)`, - ); - } - fileGlob = compiled.glob; - } + // SPEC 12.3/11: `--file` compiles under the glob rules of 7; a pattern + // outside the workspace root is an invalid flag value, which the parser + // has already refused by its spelling alone (12.0's syntax class). + const fileGlob = compileFileFlag(invocation); // SPEC 13.3: refresh-on-read, then answer. const prepared = await prepareGraphForRead(invocation, context); diff --git a/src/cli/commands/impact.ts b/src/cli/commands/impact.ts index e4b6f0d6..0144dac9 100644 --- a/src/cli/commands/impact.ts +++ b/src/cli/commands/impact.ts @@ -3,17 +3,28 @@ // // 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). +// 0. Analyze the current workspace (cli/prepare.ts `analyzeGraphForRead`): +// the configuration search and the discovery of 7 precede every error +// consulting them (SPEC 12.0) — a configuration error, a discovery-level +// one included, exits 2 before the baseline is read (14.14), and a +// discovery read the environment refuses stops the command (14.25). +// 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 +40,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 +145,49 @@ 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 12.0, 14.14, 14.25: analyze the current workspace first — the + // configuration search and the discovery of 7 precede every error + // consulting them, and a configuration error (a discovery-level one + // included) precedes every other error of exit class 2, a baseline's + // included: a configuration error exits 2, and a discovery read the + // environment refuses stops the command at that read (exit 2). + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed.exit; + } + + // 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: 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 { 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 00000000..808a36cf --- /dev/null +++ b/src/cli/commands/inventory.ts @@ -0,0 +1,257 @@ +// `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 { 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"}` — the list a + * tag set, as the configuration holds it (core/config.ts reads it as a set, + * SPEC 7.5, 12.7: byte order, duplicates collapsed). + */ +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. `targetTags`, + * `edgeKinds`, and a rule's `kinds`, configured or defaulted, are in their + * 12.7 set forms (tag sets in byte order, kind sets in 5.2's order, + * duplicates collapsed) because the configuration holds them so + * (core/config.ts reads them as sets, SPEC 7.4, 7.5). + */ +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<ExitCode> { + const { workspace } = context; + const { configuration } = workspace; + + // SPEC 11.6: discovery — the walk and glob classification, no parsing. + const classification = await discoverSources( + workspace.root, + configuration, + workspace.rootAnchor, + ); + + // 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 record 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), each spelled once when the + // configuration was located (workspace/locate.ts). + const document: JsonValue = { + findings: findings.map(findingToJson), + root: workspace.rootAnchor, + 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 1a5f31f6..3203d5ed 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -6,9 +6,15 @@ // files' imports of its generated module rewritten so all references // resolve; the full mapping appended to the journal (SPEC 6.1); finishing // regeneration exactly as `xspec build` (SPEC 12.1, 6.4) — which cannot -// fail, because move only ever rewrites a valid workspace. The form is -// selected by the origin argument: an origin containing `#` names a section -// (the second form), a bare origin names a file. +// fail, because move only ever rewrites a valid workspace. A move operand +// is classified by spelling alone (SPEC 6.5): an operand containing `#` is +// a `<file>#<id>` pair under the split of 12.0, one without is a file — and +// the parser (cli/args.ts) has already rejected, as syntax-determined usage +// errors reported without loading configuration (SPEC 12.0), every +// invocation this classification cannot serve: a non-UTF-8 operand value, a +// multi-`#` operand (a malformed value), and an invocation mixing the two +// synopses' forms. The handler therefore only ever sees two operands of one +// form. // // The section form extracts the section subtree with the exact text edits // of SPEC 6.5 (deletion with the SPEC 3 line-drop rule; insertion before @@ -19,15 +25,22 @@ // 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 +// 1. Configuration errors (SPEC 14.14): usage class, exit 2, preceding all +// source analysis — the configuration file's (cli/main.ts) and then +// discovery's (a file matched by both a spec and a code group), met +// with discovery's refused reads (14.25) before exclusivity is acquired +// (SPEC 13.5, 12.0; ./mutation.ts). +// 2. Workspace exclusivity (SPEC 13.5): `move` is a mutating command — while // another one runs, it fails promptly with a usage error (exit 2) // modifying nothing; with `--test-hold <path>`, the hold file is created // immediately after acquiring exclusivity and before modifying anything, -// and the command proceeds only once it has been deleted. -// 2. Configuration errors (SPEC 14.14): usage class, exit 2, preceding all -// source analysis. +// and the command proceeds only once it has been deleted. A preview +// acquires nothing (SPEC 6.6). // 3. Argument existence (SPEC 6.5 → 12.0): a nonexistent origin file (either // form) or origin ID is a usage error (exit 2) — checked before source // validation, so it is reported even when the sources also fail build @@ -36,99 +49,127 @@ // reported and the command exits 1. // 4. Valid-workspace precondition (SPEC 6.5 → 6.4): when the current // workspace fails the validations of `xspec build`, the move refuses -// (exit 1) before modifying anything, reporting those findings. -// 5. Move-specific refusals (SPEC 6.5), each exit 1 before modifying -// anything — for the file form: a destination path that is not valid -// UTF-8, contains `#`, is not a well-formed workspace-relative path, -// already exists, belongs to no configured spec group, belongs to a code -// group as well (14.14), would be excluded as a derived-file path (13.4), -// or lacks the `.mdx` extension (14.19). For the section form: a target -// file that is neither a discovered spec source nor a creatable valid -// spec-source path (the same destination-validity family); the exact -// self-move; an invalid `<new-id>` (1.4); a `<new-id>` colliding with an -// ID remaining in the target file after the removal; a missing target -// parent or one inside the moved subtree; a moved reference targeting the -// target file's root node (the local form cannot name it, 2.2). -// 6. The rewritten workspace is re-validated in memory — realizing "all -// rewritten references resolve" and the no-new-cycles rule (import and -// dependency cycles alike, 5.3, 2.1) — and the complete write set passes -// the SPEC 14.22 symlink check; any finding refuses (exit 1) before -// modifying anything. +// (exit 1) before modifying anything, reporting those findings alone — +// no refusal reason evaluated or reported beside them (SPEC 14). +// 5. The refusal contract (SPEC 6.5, 14): every applicable refusal reason +// is evaluated together over the valid workspace (core/refusal.ts) — +// the mirrored identity checks (intrinsic form, identity change, +// collisions after the removal), the target parent, destination +// occupancy and validity (obstructed destination-side directory +// components included), would-be dependency and spec-import cycles, +// the section form's would-be text — each judged file well-formed, each +// added import at an admissible offset (`refused-invalid-rewrite`) — +// and moved text holding an import declaration (no reason exists for a +// rewritten reference: each resolves by construction, SPEC 6.4, 6.5) — +// 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 the no-new-cycles rule for the user-facing contract, and +// every rewritten reference resolves by construction); any finding +// refuses (exit 1) before modifying anything. `--preview` runs these +// guards too and reports its plan only past them, refused exactly when +// the real operation would be (SPEC 6.6; ./rewrite-validation.ts). // // 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 { orderSourceWrites } from "../../core/edits.js"; +import type { ExitCode } 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 { appendedJournalBytes } 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 { buildValidationFindings } from "../../workspace/build-validation.js"; import type { LoadedWorkspace } from "../../workspace/config.js"; -import { loadGraphData } from "../../workspace/graph-data.js"; import { appendJournalEntry, journalFromBytes, - readJournalBytes, } from "../../workspace/journal.js"; -import { withMutationExclusivity } from "../../workspace/lock.js"; import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; import { analyzeWorkspace, analyzeWorkspaceContent, - workspaceInputsOf, } from "../../workspace/pipeline.js"; -import { classifyOccupant, describeOccupant } from "../../workspace/writes.js"; import { - removeSourceFile, - symlinkWritePathFindings, - writeSourceFile, + nonDirectoryComponents, + performSourceWrites, + probeOccupant, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; -import { isValidUtf8ArgumentValue } from "../args.js"; -import type { CliWriter, CommandContext } from "../io.js"; -import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; -import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; +import { flagPresent, isValidUtf8ArgumentValue } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { emitAppliedMappingReport } from "../report.js"; +import { usageError } from "./common.js"; +import { runMutatingCommand } from "./mutation.js"; +import { emitSuccessfulPreview } from "./preview.js"; +import { + emitFindingsRefusal, + validateRewrittenWorkspace, +} from "./rewrite-validation.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). + * 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). */ -function emitRefusal( - json: boolean, - stdout: CliWriter, - message: string, -): ExitCode { - if (json) { - stdout.write(canonicalJson({ refused: { command: "move", message } })); - } else { - stdout.write(`move refused: ${message}\n`); - } - return 1; -} - -/** SPEC 6.5: refusals reported as findings (workspace validation, 14.22). */ -function emitFindingsRefusal( - json: boolean, - stdout: CliWriter, - findings: readonly Finding[], -): ExitCode { - emitFindingsReport(json, stdout, findings); - return 1; +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`. */ @@ -139,8 +180,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("#"); @@ -151,286 +194,31 @@ function parseMoveArgument(raw: string): MoveArgument { } /** - * 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. + * 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. `discovered` is the + * workspace's classification, made before acquisition, its configuration + * errors already reported (SPEC 13.5, 14.14; ./mutation.ts): the analysis + * here reads the sources' content and the journal. */ -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; - for (const part of parts) { - total += part.length; - } - const out = new Uint8Array(total); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -} - -/** The move operation, run under workspace exclusivity (SPEC 13.5). */ async function runMove( invocation: Invocation, context: CommandContext, originArg: string, destinationArg: string, + preview: boolean, + discovered: SourceClassification, ): Promise<ExitCode> { - const { workspace, stdout, stderr } = context; + const { workspace, stdout } = 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 <old-file> <new-file>\` and \`move <file>#<id> ` + - `<target-file>#<new-id>\` (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); - return 2; - } + const analysis = await analyzeWorkspace(workspace, discovered); // SPEC 6.5 → 12.0: the argument existence checks precede source // validation. The origin file must name a discovered spec source @@ -439,8 +227,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)`, @@ -449,13 +237,20 @@ async function runMove( // SPEC 12.0/14: an origin ID inside an unparseable origin file (14.20) is // masked — the origin was discovered but yielded no document, so the - // validation findings are reported and the command exits 1. The file form - // takes the same path: an unparseable origin fails build validation. + // workspace fails `build`'s validations and the invalid-workspace + // refusal below is the report: the workspace's findings, exit 1. The + // file form takes the same path: an unparseable origin fails build + // validation. const originSpec = analysis.specs.find( (s) => s.document.path === origin.file, ); if (originSpec === undefined) { - return emitFindingsRefusal(invocation.json, stdout, analysis.findings); + return emitFindingsRefusal( + preview, + invocation.json, + stdout, + await buildValidationFindings(workspace, analysis), + ); } // SPEC 6.5 → 12.0: a nonexistent origin ID (section form) is a usage @@ -466,8 +261,8 @@ async function runMove( ); if (section === undefined) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown ID '${origin.id}' in '${origin.file}' — <id> must name an ` + `existing requirement ID of that file (SPEC 6.5, 12.0)`, ); @@ -475,14 +270,28 @@ async function runMove( } // SPEC 6.5 → 6.4: refuse, before modifying anything, when the current - // 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); + // 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 (SPEC 13.3; + // workspace/build-validation.ts) — move only ever rewrites a valid + // workspace. The findings are the report (SPEC 12.0), alone: no refusal + // reason is evaluated or reported beside them (SPEC 14) — a destination + // under a component that already obstructs the current workspace's + // write paths is this refusal, never `refused-invalid-destination`. + const workspaceFindings = await buildValidationFindings(workspace, analysis); + if (workspaceFindings.length > 0) { + return emitFindingsRefusal( + preview, + invocation.json, + stdout, + workspaceFindings, + ); } 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 +302,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 +329,36 @@ async function runMoveFile( analysis: WorkspaceAnalysis, originPath: string, destination: string, + preview: boolean, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; - // SPEC 6.5: the destination refusals — each refuses (exit 1) before - // modifying anything. - const destinationResult = await fileDestinationProblem( + // SPEC 6.5/14: evaluate every applicable refusal reason together over + // the valid workspace — destination occupancy and validity, identity + // change, and the would-be cycles, one finding per reason — and refuse + // (exit 1) with the 12.7 findings report, nothing modified. `--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,84 +366,78 @@ async function runMoveFile( destination, ); - // 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 - // is modeled as it will stand after the append — hashes take the journal - // as an input (SPEC 5.4), and the file form is pure (SPEC 6.2), so the - // regenerated graph data matches a fresh build of the moved workspace - // byte for byte (SPEC 6.5, 12.0). + // Re-validate the rewritten workspace in memory and vet the complete + // write set — the rewritten sources, the destination included — before + // touching anything (SPEC 6.5: all rewritten references resolve, no + // import or dependency cycle arises, and the finishing regeneration + // cannot fail). The journal is modeled as it will stand after the append + // — hashes take the journal as an input (SPEC 5.4), and the file form is + // pure (SPEC 6.2), so the regenerated graph data matches a fresh build of + // the moved workspace byte for byte (SPEC 6.5, 12.0); the stored record's + // paths for the origin's generated files are no longer generated and + // become orphans, so no stale output (14.10) remains. The preview runs + // the same validation, refused exactly when the real operation would be + // (SPEC 6.6; ./rewrite-validation.ts). const rewritten = await reanalyzeMoved( workspace, analysis, 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); - 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); - } - - // SPEC 6.5/6.4/12.1: the finishing regeneration's outputs, derived - // exactly as `xspec build` derives them — over the rewritten analyses. - // The stored record's paths for the origin's generated files are no - // longer generated and become orphans, so no stale output (14.10) - // remains. - const stored = await loadGraphData(workspace.root); - const outputs = computeBuildOutputs( - workspace.configuration, - rewritten.specs, - rewritten.graph, - rewritten.textModel, - rewritten.hashes, - stored.data, - // SPEC 13.3/6.5: the regenerated store records the rewritten workspace's - // inputs — the post-move source set and bytes, and the journal as it - // will stand after the append (the rewritten analysis models exactly - // those bytes). - workspaceInputsOf(workspace, rewritten), + const verdict = await validateRewrittenWorkspace( + invocation, + context, + rewritten, + plan.rewrites.map((rewrite) => rewrite.path), + preview, ); + if (!verdict.proceeds) { + return verdict.exit; + } - // 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, [ - ...plan.rewrites.map((rewrite) => rewrite.path), - JOURNAL_PATH, - ...outputs.writePaths, - ]); - if (writeFindings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, writeFindings); + // 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, + ), + ); } // All validation passed — modify: write the rewritten sources (atomic per - // file, SPEC 13.5; the moved content lands at the destination), remove - // the origin (SPEC 6.5: the file is relocated), append the mapping to the - // journal (SPEC 6.1, 6.5), and regenerate derived files exactly as - // `xspec build` does (SPEC 6.5, 6.4). - for (const rewrite of plan.rewrites) { - await writeSourceFile(workspace.root, rewrite.path, rewrite.content); - } - await removeSourceFile(workspace.root, originPath); - await appendJournalEntry(workspace.root, plan.entry); - await executeBuildOutputs(workspace.root, outputs); + // file, SPEC 13.5), in the preview's `files` order — the relocation, + // under the origin's path, producing the destination and then removing + // the origin (SPEC 6.5: the file is relocated; 13.5) — append the mapping + // to the journal (SPEC 6.1, 6.5), and regenerate derived files exactly as + // `xspec build` does (SPEC 6.5, 6.4). A write the environment refuses + // stops the operation there (SPEC 14.24, 13.5). + await performSourceWrites( + workspace.root, + orderSourceWrites(plan.rewrites, { origin: originPath, destination }), + ); + await appendJournalEntry( + workspace.root, + analysis.journal.rawBytes, + plan.entry, + ); + await executeBuildOutputs(workspace.root, verdict.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,16 +483,26 @@ 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); - const entryLine = encoder.encode(serializeJournalEntry(plan.entry) + "\n"); - const journalBytes = concatBytes( - currentJournal === null ? [entryLine] : [currentJournal, entryLine], + // SPEC 6.4, 5.4: the journal as validated — the bytes this analysis + // loaded (null for an absent journal, SPEC 6.1) — plus the new entry on + // a line of its own, composed exactly as the append will write it + // (`appendedJournalBytes`, SPEC 6.1, 13.3). Validation passed, so it bore + // no 14.13 finding: an unreadable journal, its content refused + // (SPEC 14.25) included, never reaches this point. + const journalBytes = appendedJournalBytes( + analysis.journal.rawBytes, + plan.entry, ); 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 +516,58 @@ async function runMoveSection( oldId: string, targetPath: string, newId: string, + preview: boolean, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; const originPath = originSpec.document.path; const sameFile = targetPath === originPath; - const inMovedSubtree = (id: string): boolean => - id === oldId || id.startsWith(`${oldId}.`); // SPEC 6.5: resolve the target file — the origin itself, another - // discovered spec source, or a creatable spec-source path (the - // destination-validity refusal family; each reason refuses, exit 1, - // before modifying anything). - let targetSpec: SpecFileAnalysis | null; - let createGroups: readonly string[] | null = null; - if (sameFile) { - targetSpec = originSpec; - } else { - const found = analysis.specs.find( - (spec) => spec.document.path === targetPath, - ); - if (found !== undefined) { - targetSpec = found; - } else { - const result = await sectionDestinationProblem(workspace, targetPath); - if ("problem" in result) { - return emitRefusal(invocation.json, stdout, result.problem); - } - targetSpec = null; - createGroups = result.specGroups; - } - } - - // SPEC 6.5 (identity terms): the new identity must differ from the old — - // the exact self-move is refused and appends no journal entry, while a - // cross-file move keeping its ID is valid. - if (sameFile && newId === oldId) { - return emitRefusal( - invocation.json, - stdout, - `'${targetPath}#${newId}' is the moved section's own identity — the ` + - `exact self-move is refused (SPEC 6.5)`, - ); - } - - // SPEC 6.5 → 1.4: the new ID must be valid. A `<new-id>` that is not - // valid UTF-8 cannot be written into a source file faithfully (argv bytes - // that do not decode are irrecoverable; see cli/args.ts). - if (!isValidUtf8ArgumentValue(newId)) { - return emitRefusal( - invocation.json, - stdout, - `the new ID is not valid UTF-8 — requirement IDs are decoded UTF-8 ` + - `content (SPEC 6.5, 1.6)`, - ); - } - const invalid = requirementIdProblem(newId); - if (invalid !== null) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} is not a valid requirement ID: ` + - `${invalid} (SPEC 1.4, 6.5)`, - ); - } - - // SPEC 6.5: `<new-id>` must collide with no ID remaining in the target - // file after the removal — the moved subtree's own IDs are vacated by it. - if ( - targetSpec !== null && - targetSpec.document.sections.some( - (section) => - section.id === newId && !(sameFile && inMovedSubtree(section.id)), - ) - ) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} collides with an ID remaining ` + - `in '${targetPath}' after the removal — IDs are unique within a ` + - `source file (SPEC 1.3, 6.5)`, - ); - } - - // SPEC 6.5: the target parent — the target file's section bearing - // `<new-id>` minus its final segment, needed whenever `<new-id>` has more - // than one segment — must exist and lie outside the moved subtree, - // leaving an insertion point after the removal (the mirrored structural - // parent rule, SPEC 1.3). - const newSegments = newId.split("."); - if (newSegments.length > 1) { - const parentId = newSegments.slice(0, -1).join("."); - const parent = targetSpec?.document.sections.find( - (section) => section.id === parentId, - ); - if (parent === undefined) { - return emitRefusal( - invocation.json, - stdout, - `the target parent '${targetPath}#${parentId}' — the section ` + - `bearing the new ID minus its final segment — does not exist in ` + - `the target file (SPEC 6.5, 1.3)`, - ); - } - if (sameFile && inMovedSubtree(parentId)) { - return emitRefusal( - invocation.json, - stdout, - `the target parent '${targetPath}#${parentId}' lies within the ` + - `moved subtree, leaving no insertion point after the removal ` + - `(SPEC 6.5)`, - ); - } - } - - // SPEC 6.5 → 2.2: a moved reference targeting the target file's root node - // has no rewritable spelling — the local form names IDs in the same file, - // never its root — so the move refuses rather than leave an unresolvable - // rewrite ("all rewritten references resolve"). - if (!sameFile) { - const targetsRootOfTarget = ( - section: SpecSection, - reference: SpecReference, - ): boolean => - section.id !== null && - inMovedSubtree(section.id) && - reference.target.kind === "external" && - reference.target.modulePath === targetPath && - reference.target.segments.length === 0; - const offends = - originSpec.references.dependencies.some((dependency) => - targetsRootOfTarget(dependency.section, dependency.reference), - ) || - originSpec.references.embeddings.some( - (embedding) => - embedding.reference !== null && - targetsRootOfTarget(embedding.embedding.section, embedding.reference), - ); - if (offends) { - return emitRefusal( - invocation.json, - stdout, - `a reference within the moved subtree targets the target file's ` + - `root node — the local reference form names IDs in its own file, ` + - `never the file's root, so no rewrite of it can resolve after ` + - `the move (SPEC 6.5, 2.2)`, - ); - } + // 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, the would-be text's + // well-formedness and import additions, and would-be cycles, one + // finding per reason (no reason exists for an unresolvable rewritten + // reference: each resolves by construction, SPEC 6.4) — 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, + code: analysis.code, + 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,11 +577,17 @@ async function runMoveSection( newId, ); - // 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 - // regeneration cannot fail). The journal is modeled as it will stand - // after the append (SPEC 5.4). + // Re-validate the rewritten workspace in memory and vet the complete + // write set — the rewritten sources, a created target included — 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 regeneration cannot fail). The journal is + // modeled as it will stand after the append (SPEC 5.4). The refusal + // evaluation above realizes every reason a move can be refused for — + // would-be cycles included, a moved reference to the target file's own + // root among them — so these guards refuse only a regression. The + // preview runs the same validation, refused exactly when the real + // operation would be (SPEC 6.6; ./rewrite-validation.ts). const rewritten = await reanalyzeSectionMoved( workspace, analysis, @@ -851,64 +595,57 @@ async function runMoveSection( targetPath, createGroups, ); - if (rewritten.configurationErrors.length > 0) { - // 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); - 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); - } - - // SPEC 6.5/6.4/12.1: the finishing regeneration's outputs, derived - // exactly as `xspec build` derives them — over the rewritten analyses. - const stored = await loadGraphData(workspace.root); - const outputs = computeBuildOutputs( - workspace.configuration, - rewritten.specs, - rewritten.graph, - rewritten.textModel, - rewritten.hashes, - stored.data, - // SPEC 13.3/6.5: the regenerated store records the rewritten workspace's - // inputs — the post-move source set and bytes, and the journal as it - // will stand after the append (the rewritten analysis models exactly - // those bytes). - workspaceInputsOf(workspace, rewritten), + const verdict = await validateRewrittenWorkspace( + invocation, + context, + rewritten, + plan.rewrites.map((rewrite) => rewrite.path), + preview, ); + if (!verdict.proceeds) { + return verdict.exit; + } - // 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, [ - ...plan.rewrites.map((rewrite) => rewrite.path), - JOURNAL_PATH, - ...outputs.writePaths, - ]); - if (writeFindings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, writeFindings); + // 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] : []), + ], + ); } // All validation passed — modify: write the rewritten sources (atomic per - // file, SPEC 13.5; the origin keeps its path, the target gains the moved - // text), append the mapping to the journal (SPEC 6.1, 6.5), and - // regenerate derived files exactly as `xspec build` does (SPEC 6.5, 6.4). - for (const rewrite of plan.rewrites) { - await writeSourceFile(workspace.root, rewrite.path, rewrite.content); - } - await appendJournalEntry(workspace.root, plan.entry); - await executeBuildOutputs(workspace.root, outputs); + // file, in the preview's `files` order, SPEC 13.5; the origin keeps its + // path, the target gains the moved text), append the mapping to the + // journal (SPEC 6.1, 6.5), and regenerate derived files exactly as + // `xspec build` does (SPEC 6.5, 6.4). A write the environment refuses + // stops the operation there (SPEC 14.24, 13.5). + await performSourceWrites( + workspace.root, + orderSourceWrites(plan.rewrites, null), + ); + await appendJournalEntry( + workspace.root, + analysis.journal.rawBytes, + plan.entry, + ); + await executeBuildOutputs(workspace.root, verdict.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,22 +690,32 @@ 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, }; } - const currentJournal = await readJournalBytes(workspace.root); - const entryLine = encoder.encode(serializeJournalEntry(plan.entry) + "\n"); - const journalBytes = concatBytes( - currentJournal === null ? [entryLine] : [currentJournal, entryLine], + // SPEC 6.4, 5.4: the journal as validated — the bytes this analysis + // loaded (null for an absent journal, SPEC 6.1) — plus the new entry on + // a line of its own, composed exactly as the append will write it + // (`appendedJournalBytes`, SPEC 6.1, 13.3). Validation passed, so it bore + // no 14.13 finding: an unreadable journal, its content refused + // (SPEC 14.25) included, never reaches this point. + const journalBytes = appendedJournalBytes( + analysis.journal.rawBytes, + plan.entry, ); 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,17 +725,25 @@ 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 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), - // modifying nothing. - const outcome = await withMutationExclusivity( - context.workspace.root, - testHoldSpecOf(invocation, context.cwd), - () => runMove(invocation, context, originArg, destinationArg), + // 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. `--test-hold` together with `--preview` + // never reaches here: the parser refuses the pair (cli/args.ts), a + // syntax-class usage error reported before the configuration is loaded, + // no hold file created, nothing modified (SPEC 12.0). + const preview = flagPresent(invocation, "--preview"); + // SPEC 13.5: discovery, then workspace exclusivity around every later + // check and read, with the `--test-hold` seam immediately after + // acquisition; a workspace held by another mutating command fails + // promptly as a usage error (12.0), modifying nothing (./mutation.ts). + return runMutatingCommand(invocation, context, !preview, (discovered) => + runMove( + invocation, + context, + originArg, + destinationArg, + preview, + discovered, + ), ); - if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); - } - return outcome.value; } diff --git a/src/cli/commands/mutation.ts b/src/cli/commands/mutation.ts new file mode 100644 index 00000000..2436dbdb --- /dev/null +++ b/src/cli/commands/mutation.ts @@ -0,0 +1,76 @@ +// The entry sequence of the mutating commands (SPEC 13.5, 12.0, 14.14, +// 14.25): `rename` and `move` — their `--preview` invocations included, +// which acquire nothing (6.6) — and the mutating `review` subcommands +// (`create`, `resolve`, `split`). +// +// SPEC 13.5: "A mutating command acquires exclusivity once its +// configuration is loaded and its sources discovered (7, 14.14) and before +// every later check and read — the argument checks of 12.0, baseline +// resolution (6.3), the gate and refresh of 13.3, and its own validation — +// so the workspace it validates is the one it rewrites". The configuration +// is loaded before any handler runs (cli/main.ts). Here the sources are +// discovered: a listing or kind read the environment refuses stops the +// command at that read with its read failure (14.25, thrown from the walk +// and rendered by cli/main.ts), and a discovery-level configuration error +// (7.2 → 14.14: a file matched by both a spec and a code group) is +// reported as the exit-2 configuration error — SPEC 12.0: a configuration +// error precedes every other error of exit class 2, the exclusivity and +// hold-file errors of 13.5 included. Neither waits on the `--test-hold` +// seam nor yields to another holder's exclusion error. Only then is +// exclusivity acquired, with the seam immediately after acquisition +// (workspace/lock.ts), and the operation runs over the classification +// made here: every later read — source content, the journal, sessions, +// the baseline, the gate and refresh — follows acquisition. + +import type { SourceClassification } from "../../core/discovery.js"; +import type { ExitCode } from "../../core/findings.js"; +import { withMutationExclusivity } from "../../workspace/lock.js"; +import { + discoverWorkspace, + discoveryConfigurationErrors, +} from "../../workspace/pipeline.js"; +import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { emitConfigurationErrors } from "../report.js"; +import { testHoldSpecOf, usageError } from "./common.js"; + +/** + * Run a mutating command's operation (module header): discover the + * sources, report a discovery-level configuration error (exit 2), then — + * when `exclusive` — acquire workspace exclusivity, failing promptly with + * the usage error while another mutating command holds it (SPEC 13.5, + * 12.0), and run `operation` over the classification. A `--preview` + * passes `exclusive` false: it acquires nothing and takes no seam + * (SPEC 6.6), in the same order otherwise. + */ +export async function runMutatingCommand( + invocation: Invocation, + context: CommandContext, + exclusive: boolean, + operation: (discovered: SourceClassification) => Promise<ExitCode>, +): Promise<ExitCode> { + const discovered = await discoverWorkspace(context.workspace); + const configurationErrors = discoveryConfigurationErrors(discovered); + if (configurationErrors.length > 0) { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + configurationErrors, + ); + return 2; + } + if (!exclusive) { + return operation(discovered); + } + const outcome = await withMutationExclusivity( + context.workspace.root, + testHoldSpecOf(invocation, context.cwd), + () => operation(discovered), + ); + if (!outcome.ok) { + 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 00000000..4e868e64 --- /dev/null +++ b/src/cli/commands/occurrences.ts @@ -0,0 +1,86 @@ +// `xspec occurrences [--file <glob>] [--to <node>]` (SPEC 11.3). +// +// Enumerates reference occurrences (SPEC 5.7) in occurrence order, one +// record per occurrence carrying every datum of 5.7 — the source graph node +// per SPEC 11.2 where its source node's identity is undefined. JSON-only +// (SPEC 11): a single JSON document — the 12.7 `{"findings", +// "occurrences"}` form — is its only output form, with or without `--json`. +// +// `--file` admits the discovered source files — spec and code alike — that +// the glob matches (the rules of SPEC 7): a set restriction, not an +// existence assertion — the consulted domain (SPEC 11.2) is the discovered +// files it admits, a glob admitting none admits the empty set (an empty, +// finding-free answer, exit 0), and no unknown-file usage error exists on +// this filter. A pattern outside the workspace root is an invalid flag +// value, exit 2 (SPEC 11.3, 11.1, 12.0), decided by its spelling alone. +// 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. +// +// Both argument checks — the `--file` pattern's root and the `--to` +// spelling — are SPEC 12.0's syntax class, the arguments alone deciding +// them, so the parser judges them (cli/args.ts) before the configuration +// is loaded: each exits 2 whatever the configuration or the workspace. +// 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, + 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 { 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 { compileFileFlag } from "./common.js"; + +/** The `occurrences` command handler (SPEC 11.3). */ +export async function occurrencesCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + // --- the arguments, already judged by the parser (module header) -------- + const fileGlob = compileFileFlag(invocation); + // SPEC 11.3: `--to` is a well-formed requirement-node identity spelling; + // whether it resolves is no check — an unknown one selects nothing. + const to = flagValue(invocation, "--to"); + + // --- 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 00000000..60186a62 --- /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_OWN_PATHS, + unreadableRecordFinding, +} from "../../core/graph-data.js"; +import type { IdentityMapping } from "../../core/journal.js"; +import type { PreviewFileEdits } from "../../core/preview.js"; +import { derivedFileDelta } from "../../core/preview.js"; +import type { LoadedWorkspace } from "../../workspace/config.js"; +import { readDerivedFileRecord } from "../../workspace/graph-data.js"; +import type { CliWriter } from "../io.js"; +import { emitPreviewReport } from "../report.js"; + +/** + * SPEC 6.6/12.7: a refused preview keeps the preview document form — the + * refusal findings (workspace-precondition findings and refusal-reason + * findings alike, exactly what the real operation would report) with + * `mapping`, `files`, and `delta` null — and exits 1. No record is + * consulted (SPEC 6.6). + */ +export function emitRefusedPreview( + json: boolean, + stdout: CliWriter, + findings: readonly Finding[], +): ExitCode { + emitPreviewReport(json, stdout, findings, null); + return 1; +} + +/** + * Complete a preview whose operation would proceed (SPEC 6.6): read the + * recorded derived-file paths (the one record consult, SPEC 13.3, 14.23), + * derive the delta against the post-operation generation set over + * `postSpecPaths` (the spec source paths as they would stand after the + * operation), and emit the full preview report. Exit 0 for the complete, + * finding-free answer; exit 1 with everything emitted in full where the + * record exists but cannot be read (SPEC 14.23, 12.0). + */ +export async function emitSuccessfulPreview( + json: boolean, + stdout: CliWriter, + workspace: LoadedWorkspace, + mapping: readonly IdentityMapping[], + files: readonly PreviewFileEdits[], + postSpecPaths: readonly string[], +): Promise<ExitCode> { + const record = await readDerivedFileRecord(workspace.root); + if (record.state === "unreadable") { + emitPreviewReport(json, stdout, [unreadableRecordFinding()], { + mapping, + files, + delta: "unavailable", + }); + return 1; + } + // SPEC 6.6: an absent record records nothing — the empty-record success + // path, never condition 23. Graph data's own paths are never recorded + // (SPEC 13.3); a record naming one anyway is dropped defensively, as the + // build's orphan domain drops it. + const recorded = + record.state === "readable" + ? record.paths.filter((path) => !GRAPH_DATA_OWN_PATHS.includes(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 3c11f002..be8f5254 100644 --- a/src/cli/commands/query-core.ts +++ b/src/cli/commands/query-core.ts @@ -17,7 +17,6 @@ import type { ByteRange } from "../../core/bytes.js"; import type { JsonObject } from "../../core/canonical-json.js"; import type { CompiledGlob } from "../../core/glob.js"; -import { compileGlob } from "../../core/glob.js"; import type { GraphEdge, GraphEdgeKind } from "../../core/graph.js"; import { DEPENDENCY_EDGE_KINDS } from "../../core/graph.js"; import type { NodeHashes } from "../../core/hashes.js"; @@ -25,8 +24,13 @@ 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 { emitDocument, rangeJson, usageError } from "./common.js"; +import type { CliWriter, CommandIo } from "../io.js"; +import { + compileFileFlag, + emitDocument, + rangeJson, + usageError, +} from "./common.js"; /** One requirement node as the query subcommands consume it (SPEC 11). */ export interface QueryRow { @@ -150,6 +154,40 @@ export type RowResolution = | { readonly ok: true; readonly row: QueryRow } | { readonly ok: false; readonly message: string }; +/** + * SPEC 11.1/12.4/12.0: the wrong-kind `<node>` diagnostic — the value names + * a code location where a requirement-node identity is required. Shared by + * the graph-based resolution below and the parse-local pre-gate check + * (./gated-args.ts), so the two judgments — identical by construction on + * valid workspaces (SPEC 12.0) — report byte-identically. + */ +export function codeLocationNodeMessage(raw: string): string { + return ( + `'${raw}' names a code location — <node> takes a requirement-node ` + + `identity: path#id, or a bare path for a file's root node ` + + `(SPEC 11, 1.5)` + ); +} + +/** SPEC 11/12.0: the unknown-`<node>` diagnostic (shared as above). */ +export function unknownNodeMessage(raw: string): string { + return ( + `unknown requirement node '${raw}' — expected path#id, or a bare ` + + `path for a file's root node; a path in no configured group is ` + + `unknown (SPEC 11, 1.5, 12.0)` + ); +} + +/** SPEC 11/4.6/12.0: the unknown-`<graph-node>` diagnostic (shared as above). */ +export function unknownGraphNodeMessage(flag: string, raw: string): string { + return ( + `unknown graph node '${raw}' for '${flag}' — expected a requirement ` + + `node (path#id, or a bare path for a spec file's root node) or a code ` + + `location (path, path#unit, or path#unit@N); a path in no configured ` + + `group is unknown (SPEC 11, 1.5, 4.6, 12.0)` + ); +} + /** * Resolve a `<node>` argument: a requirement-node identity — `path#id`, or * a bare path for a file's root node (SPEC 11, 12.4, 1.5). A code-location @@ -161,21 +199,9 @@ export function resolveRow(view: QueryView, raw: string): RowResolution { return { ok: true, row }; } if (view.isCodeLocation(raw)) { - return { - ok: false, - message: - `'${raw}' names a code location — <node> takes a requirement-node ` + - `identity: path#id, or a bare path for a file's root node ` + - `(SPEC 11, 1.5)`, - }; + return { ok: false, message: codeLocationNodeMessage(raw) }; } - return { - ok: false, - message: - `unknown requirement node '${raw}' — expected path#id, or a bare ` + - `path for a file's root node; a path in no configured group is ` + - `unknown (SPEC 11, 1.5, 12.0)`, - }; + return { ok: false, message: unknownNodeMessage(raw) }; } /** @@ -183,7 +209,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 +217,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. */ @@ -215,10 +236,12 @@ type NodesFiltersResult = * Validate the configuration-level flag values of `query nodes` (SPEC 11): * `--group` accepts only a configured spec group's name — a code group's * name is an invalid flag value, the wrong-kind group reference of 14.14, - * and an unknown name is a usage error (12.0) — and `--file` compiles under - * the glob rules of 7, where a pattern resolving outside the workspace root - * is an invalid flag value, exit 2 like its configuration-time counterpart - * (14.14). Like those counterparts, these checks precede source analysis. + * and an unknown name is a usage error (12.0), a check preceding source + * analysis like its 14.14 counterparts — and `--file` compiles under the + * glob rules of 7. A `--file` pattern outside the workspace root is an + * invalid flag value decided by its spelling alone, which the parser has + * already refused (12.0's syntax class, before the configuration is + * loaded), so it never reaches this check. */ function resolveNodesFilters( invocation: Invocation, @@ -243,26 +266,14 @@ function resolveNodesFilters( } groupGlobs = specGroup.globs; } - 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: outside-root (SPEC 7). - return { - ok: false, - message: - `invalid value '${filePattern}' for '--file' — the pattern ` + - `resolves outside the workspace root (SPEC 11, 7, 12.0)`, - }; - } - fileGlob = compiled.glob; - } + const fileGlob = compileFileFlag(invocation); return { ok: true, filters: { groupGlobs, fileGlob, + // SPEC 11.1: already judged well-formed (1.4) at parse level + // (cli/args.ts); a well-formed tag no node carries matches nothing. tag: flagValue(invocation, "--tag"), coverage: flagValue(invocation, "--coverage"), }, @@ -352,7 +363,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 +372,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 +391,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 +405,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 +426,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 +442,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 +452,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 +474,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<string, Set<string>>(); diff --git a/src/cli/commands/query-fast.ts b/src/cli/commands/query-fast.ts index 27be7c5c..a55ff80e 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 ec5b1d54..ba44309c 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -10,15 +10,62 @@ // it runs when no verified store can answer (cli/main.ts tries the fast // path first), prepares the refreshed analysis, and answers through the // analysis-backed view (./analysis-view.ts). +// +// SPEC 12.0: the argument checks precede the invalid-workspace report of +// 13.3 — the configuration-level flag checks of `query nodes` +// (query-core.ts), then the `<node>`/`<graph-node>` identity checks, +// judged parse-local against the named file (./gated-args.ts) — so a +// usage-error argument exits 2 whatever findings the workspace carries, +// while configuration errors keep their precedence over every check +// (SPEC 14.14, surfaced by the analysis step). import type { ExitCode } from "../../core/findings.js"; +import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; import type { Invocation } from "../args.js"; +import { flagValue } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, finishGraphForRead } from "../prepare.js"; import { analysisQueryView } from "./analysis-view.js"; +import { usageError } from "./common.js"; +import { graphNodeValueProblem, nodeOperandProblem } from "./gated-args.js"; import { answerQuery, prevalidateQuery } from "./query-core.js"; import { groupsViewOfConfiguration } from "./query-groups.js"; +/** + * The subcommand's identity-argument checks (SPEC 12.0), parse-local per + * ./gated-args.ts: the `<node>` positional of `node`/`subtree`/`ancestors`, + * the `<graph-node>` values of `edges`/`reachable` — `--from` then `--to`, + * the order the graph-based answering checks them in (query-core.ts). + * Returns the usage-error diagnostic, or null. + */ +function queryIdentityProblem( + invocation: Invocation, + analysis: WorkspaceAnalysis, +): string | null { + switch (invocation.command) { + case "query node": + case "query subtree": + case "query ancestors": + return nodeOperandProblem(analysis, invocation.positionals[0]); + case "query edges": + case "query reachable": { + for (const flag of ["--from", "--to"] as const) { + const raw = flagValue(invocation, flag); + if (raw === undefined) { + continue; + } + const problem = graphNodeValueProblem(analysis, flag, raw); + if (problem !== null) { + return problem; + } + } + return null; + } + default: + return null; + } +} + /** The `query` command handler — all six subcommands (SPEC 11). */ export async function queryCommand( invocation: Invocation, @@ -27,21 +74,33 @@ export async function queryCommand( const { stdout, stderr } = context; const groups = groupsViewOfConfiguration(context.workspace.configuration); - // SPEC 11: configuration-level flag validation precedes source analysis, - // like its 14.14 counterparts (query-core.ts). - 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 b9961570..671c4160 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -9,13 +9,17 @@ // // Outcome precedence (SPEC 6.4, 12.0, 13.5, 14): // -// 1. Workspace exclusivity (SPEC 13.5): `rename` is a mutating command — +// 1. Configuration errors (SPEC 14.14): usage class, exit 2, preceding all +// source analysis — the configuration file's (cli/main.ts) and then +// discovery's (a file matched by both a spec and a code group), met +// with discovery's refused reads (14.25) before exclusivity is acquired +// (SPEC 13.5, 12.0; ./mutation.ts). +// 2. Workspace exclusivity (SPEC 13.5): `rename` is a mutating command — // while another one runs, it fails promptly with a usage error (exit 2) // modifying nothing; with `--test-hold <path>`, the hold file is created // immediately after acquiring exclusivity and before modifying anything, -// and the command proceeds only once it has been deleted. -// 2. Configuration errors (SPEC 14.14): usage class, exit 2, preceding all -// source analysis. +// and the command proceeds only once it has been deleted. A preview +// acquires nothing (SPEC 6.6). // 3. Argument existence (SPEC 6.4 → 12.0): a `<file>` that is not a // discovered spec source, or an old ID absent from the origin file, is a // usage error (exit 2) — checked before source validation, so it is @@ -25,150 +29,89 @@ // 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 (every rewritten reference resolves +// by construction, SPEC 6.4, and the refusal evaluation above realizes +// the user-facing contract); any finding refuses (exit 1) before +// modifying anything. `--preview` runs these guards too and reports its +// plan only past them, refused exactly when the real operation would be +// (SPEC 6.6; ./rewrite-validation.ts). // // 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 type { SourceClassification } from "../../core/discovery.js"; +import { orderSourceWrites } from "../../core/edits.js"; +import type { ExitCode } from "../../core/findings.js"; +import { appendedJournalBytes } from "../../core/journal.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"; +import { buildValidationFindings } from "../../workspace/build-validation.js"; import type { LoadedWorkspace } from "../../workspace/config.js"; -import { loadGraphData } from "../../workspace/graph-data.js"; import { appendJournalEntry, journalFromBytes, - readJournalBytes, } from "../../workspace/journal.js"; -import { withMutationExclusivity } from "../../workspace/lock.js"; import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; import { analyzeWorkspace, analyzeWorkspaceContent, - workspaceInputsOf, } from "../../workspace/pipeline.js"; -import { - symlinkWritePathFindings, - writeSourceFile, -} from "../../workspace/writes.js"; +import { performSourceWrites } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; -import type { CliWriter, CommandContext } from "../io.js"; -import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; -import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; - -/** - * SPEC 6.4/12.0: a refused rename is a validation failure — exit 1, the - * refusal report on standard output (SPEC 12.0: reports are standard-output - * content; with `--json`, one JSON document as the entire standard output). - */ -function emitRefusal( - json: boolean, - stdout: CliWriter, - message: string, -): ExitCode { - if (json) { - stdout.write(canonicalJson({ refused: { command: "rename", message } })); - } else { - stdout.write(`rename refused: ${message}\n`); - } - return 1; -} - -/** SPEC 6.4: refusals reported as findings (workspace validation, 14.22). */ -function emitFindingsRefusal( - json: boolean, - stdout: CliWriter, - findings: readonly Finding[], -): ExitCode { - emitFindingsReport(json, stdout, findings); - return 1; -} +import { flagPresent } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { emitAppliedMappingReport } from "../report.js"; +import { usageError } from "./common.js"; +import { runMutatingCommand } from "./mutation.js"; +import { emitSuccessfulPreview } from "./preview.js"; +import { + emitFindingsRefusal, + validateRewrittenWorkspace, +} from "./rewrite-validation.js"; /** - * 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. + * 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. `discovered` is the + * workspace's classification, made before acquisition, its configuration + * errors already reported (SPEC 13.5, 14.14; ./mutation.ts): the analysis + * here reads the sources' content and the journal. */ -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; - for (const part of parts) { - total += part.length; - } - const out = new Uint8Array(total); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -} - -/** The rename operation, run under workspace exclusivity (SPEC 13.5). */ async function runRename( invocation: Invocation, context: CommandContext, file: string, oldId: string, newId: string, + preview: boolean, + discovered: SourceClassification, ): Promise<ExitCode> { - const { workspace, stdout, stderr } = context; - 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); - return 2; - } + const { workspace, stdout } = context; + const analysis = await analyzeWorkspace(workspace, discovered); // SPEC 6.4 → 12.0: the argument existence checks precede source // validation. `<file>` must name a discovered spec source // (workspace-relative, SPEC 12.0, 1.5; byte-wise comparison). if (!analysis.classification.specSources.some((s) => s.path === file)) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown file '${file}' — <file> must name a discovered source file ` + `of a configured spec group, workspace-relative (SPEC 6.4, 12.0)`, ); @@ -176,19 +119,24 @@ async function runRename( // SPEC 12.0/14: an old ID inside an unparseable origin file (14.20) is // masked — the origin was discovered but yielded no document, so the - // validation findings are reported and the command exits 1. + // workspace fails `build`'s validations and the invalid-workspace + // refusal below is the report: the workspace's findings, exit 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, + await buildValidationFindings(workspace, analysis), + ); } // 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}' — <old-id> must name an existing ` + `requirement ID of that file (SPEC 6.4, 12.0)`, ); @@ -196,108 +144,98 @@ 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). - 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( - invocation.json, - stdout, - `the new ID must differ from the old ID ${JSON.stringify(oldId)} ` + - `(SPEC 6.4)`, - ); - } - const invalid = requirementIdProblem(newId); - if (invalid !== null) { - return emitRefusal( + // valid workspace. Those validations are source validation errors, + // journal errors (14.13), and refused writes (14.22) alike — the + // findings a `build` would now report (SPEC 13.3; refused writes judged + // over `build`'s write paths as discovery and configuration define them, + // workspace/build-validation.ts). The invalid-workspace refusal reports + // the workspace's numbered findings alone: no refusal reason is + // evaluated or reported beside them (SPEC 14). + const workspaceFindings = await buildValidationFindings(workspace, analysis); + if (workspaceFindings.length > 0) { + return emitFindingsRefusal( + preview, invocation.json, stdout, - `the new ID ${JSON.stringify(newId)} is not a valid requirement ID: ` + - `${invalid} (SPEC 1.4, 6.4)`, + workspaceFindings, ); } - const structural = structuralProblem(section, newId); - if (structural !== null) { - return emitRefusal(invocation.json, stdout, `${structural} (SPEC 6.4)`); - } - if (origin.document.sections.some((s) => s.id === newId)) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} collides with an existing ID in ` + - `'${file}' — IDs are unique within a source file (SPEC 1.3, 6.4)`, - ); + + // 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); } - // The pure plan: the identity mapping, the journal entry, and the minimal - // in-place rewrites of every affected source (SPEC 6.4, 6.1). + // The pure plan: the identity mapping, the journal entry, the minimal + // in-place rewrites of every affected source, and the classed preview + // edits — one plan for the real operation and its preview (SPEC 6.4, + // 6.1, 6.6). const plan = planRename(analysis.specs, analysis.code, file, oldId, newId); - // 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 - // journal is modeled as it will stand after the append — hashes take the - // journal as an input (SPEC 5.4), so the regenerated graph data matches a - // fresh build of the rewritten workspace byte for byte (SPEC 6.4, 12.0). + // Re-validate the rewritten workspace in memory and vet the complete + // write set before touching anything (SPEC 6.4: structural rules remain + // satisfied and all rewritten references resolve; the finishing + // regeneration cannot fail). The journal is modeled as it will stand + // after the append — hashes take the journal as an input (SPEC 5.4), so + // the regenerated graph data matches a fresh build of the rewritten + // workspace byte for byte (SPEC 6.4, 12.0). The preview runs the same + // validation, refused exactly when the real operation would be (SPEC + // 6.6; ./rewrite-validation.ts). const rewritten = await reanalyzeRewritten(workspace, analysis, plan); - 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); - 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); - } - - // SPEC 6.4/12.1: the finishing regeneration's outputs, derived exactly as - // `xspec build` derives them — over the rewritten analyses. - const stored = await loadGraphData(workspace.root); - const outputs = computeBuildOutputs( - workspace.configuration, - rewritten.specs, - rewritten.graph, - rewritten.textModel, - rewritten.hashes, - stored.data, - // SPEC 13.3/6.4: the regenerated store records the rewritten workspace's - // inputs — the rewritten source bytes and the journal as it will stand - // after the append (reanalyzeRewritten models exactly those bytes). - workspaceInputsOf(workspace, rewritten), + const verdict = await validateRewrittenWorkspace( + invocation, + context, + rewritten, + plan.rewrites.map((rewrite) => rewrite.path), + preview, ); + if (!verdict.proceeds) { + return verdict.exit; + } - // 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, [ - ...plan.rewrites.map((rewrite) => rewrite.path), - JOURNAL_PATH, - ...outputs.writePaths, - ]); - if (writeFindings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, writeFindings); + // SPEC 6.6: a preview reports the plan and performs it on nothing — the + // complete identity mapping the operation would journal (the journal + // entry's canonical `from`-byte order), the per-file edits, and the + // record-based derived-file delta (a rename regenerates every derived + // path in place, so the post-operation generation set is the current + // source set's). + if (preview) { + return emitSuccessfulPreview( + invocation.json, + stdout, + workspace, + plan.entry.mapping, + plan.previewFiles, + analysis.classification.specSources.map((source) => source.path), + ); } // All validation passed — modify: rewrite the sources (atomic per file, - // SPEC 13.5), append the mapping to the journal (SPEC 6.1, 6.4), and - // regenerate derived files exactly as `xspec build` does (SPEC 6.4). - for (const rewrite of plan.rewrites) { - await writeSourceFile(workspace.root, rewrite.path, rewrite.content); - } - await appendJournalEntry(workspace.root, plan.entry); - await executeBuildOutputs(workspace.root, outputs); + // in the preview's `files` order, SPEC 13.5), append the mapping to the + // journal (SPEC 6.1, 6.4), and regenerate derived files exactly as + // `xspec build` does (SPEC 6.4). A write the environment refuses stops + // the operation there (SPEC 14.24, 13.5). + await performSourceWrites( + workspace.root, + orderSourceWrites(plan.rewrites, null), + ); + await appendJournalEntry( + workspace.root, + analysis.journal.rawBytes, + plan.entry, + ); + await executeBuildOutputs(workspace.root, verdict.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; } @@ -323,19 +261,27 @@ async function reanalyzeRewritten( for (const rewrite of plan.rewrites) { byPath.set(rewrite.path, rewrite.content); } - const currentJournal = await readJournalBytes(workspace.root); - const entryLine = encoder.encode(serializeJournalEntry(plan.entry) + "\n"); - const journalBytes = concatBytes( - currentJournal === null ? [entryLine] : [currentJournal, entryLine], + // SPEC 6.4, 5.4: the journal as validated — the bytes this analysis + // loaded (null for an absent journal, SPEC 6.1) — plus the new entry on + // a line of its own, composed exactly as the append will write it + // (`appendedJournalBytes`, SPEC 6.1, 13.3). Validation passed, so it bore + // no 14.13 finding: an unreadable journal, its content refused + // (SPEC 14.25) included, never reaches this point. + const journalBytes = appendedJournalBytes( + analysis.journal.rawBytes, + plan.entry, ); 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,17 +291,18 @@ 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 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), - // modifying nothing. - const outcome = await withMutationExclusivity( - context.workspace.root, - testHoldSpecOf(invocation, context.cwd), - () => runRename(invocation, context, file, oldId, newId), + // 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. `--test-hold` together with `--preview` + // never reaches here: the parser refuses the pair (cli/args.ts), a + // syntax-class usage error reported before the configuration is loaded, + // no hold file created, nothing modified (SPEC 12.0). + const preview = flagPresent(invocation, "--preview"); + // SPEC 13.5: discovery, then workspace exclusivity around every later + // check and read, with the `--test-hold` seam immediately after + // acquisition; a workspace held by another mutating command fails + // promptly as a usage error (12.0), modifying nothing (./mutation.ts). + return runMutatingCommand(invocation, context, !preview, (discovered) => + runRename(invocation, context, file, oldId, newId, preview, discovered), ); - if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); - } - return outcome.value; } diff --git a/src/cli/commands/review-mutate.ts b/src/cli/commands/review-mutate.ts index e2991b64..0f99b164 100644 --- a/src/cli/commands/review-mutate.ts +++ b/src/cli/commands/review-mutate.ts @@ -1,16 +1,20 @@ // The mutating `xspec review` subcommands beyond `create`: `split` and // `resolve` (SPEC 10.7). Both modify the session's durable file (SPEC 13.4, // 13.5), so both run under workspace mutual exclusion with the `--test-hold` -// seam, exactly like `create`, `rename`, and `move`. +// seam, exactly like `create`, `rename`, and `move`: acquired once the +// sources are discovered — a discovery-level configuration error or refused +// read reported first, exit 2 — and before every later check and read +// (SPEC 13.5, 12.0; ./mutation.ts). // // Outcome precedence, per subcommand (shared steps through -// review-session.ts's `loadSessionForCommand` — name validity, load with +// review-session.ts's `loadSessionForCommand` — session existence, load with // the corrupt check, recorded-baseline resolution, refresh-on-read): // // `split <name> <item-id>`: -// 1. session name validity (SPEC 10.1 → 12.0, exit 2), unknown session or -// corrupt session (SPEC 10.7 → 12.0 exit 2; 14.21 exit 1), baseline -// resolution (SPEC 6.3 → 12.0), refresh (SPEC 13.3); +// 1. unknown session or corrupt session (SPEC 10.7 → 12.0 exit 2; 14.21 +// exit 1), baseline resolution (SPEC 6.3 → 12.0), refresh (SPEC 13.3) +// — the session name's form being the parser's, a syntax-class usage +// error reported before the configuration is loaded (SPEC 10.1, 12.0); // 2. an unknown item id is a usage error (SPEC 10.7 → 12.0, exit 2); // 3. `split` on an item of any other kind than `subtree-coherence`, or on // one whose scope root has no children, is refused — exit 1, nothing @@ -32,6 +36,7 @@ // (SPEC 10.5, every strategy), the write path is validated // (SPEC 14.22), and the session file is rewritten. +import type { SourceClassification } from "../../core/discovery.js"; import type { ExitCode } from "../../core/findings.js"; import type { ResolveStatus } from "../../core/review.js"; import { sessionFilePath } from "../../core/review.js"; @@ -40,15 +45,15 @@ import { resolveSessionItem, splitItemDecomposition, } from "../../core/review-derive.js"; -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"; import { emitFindingsReport } from "../report.js"; -import { emitDocument, testHoldSpecOf, usageError } from "./common.js"; +import { emitDocument, usageError } from "./common.js"; +import { runMutatingCommand } from "./mutation.js"; import type { SessionGenerationRun } from "./review-session.js"; import { buildSessionReadView, @@ -65,8 +70,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 +98,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 +109,7 @@ async function writeSessionChecked( name: string, session: Parameters<typeof writeSession>[2], ): Promise<ExitCode | null> { - const findings = await symlinkWritePathFindings(context.workspace.root, [ + const findings = await obstructedWritePathFindings(context.workspace.root, [ sessionFilePath(name), ]); if (findings.length > 0) { @@ -118,14 +124,23 @@ async function writeSessionChecked( // `review split` (SPEC 10.7) // --------------------------------------------------------------------------- -/** The `split` operation, run under workspace exclusivity (SPEC 13.5). */ +/** + * The `split` operation, run under workspace exclusivity (SPEC 13.5) over + * the classification made before acquisition (./mutation.ts). + */ async function runSplit( invocation: Invocation, context: CommandContext, name: string, itemId: string, + discovered: SourceClassification, ): Promise<ExitCode> { - const loaded = await loadSessionForCommand(name, invocation, context); + const loaded = await loadSessionForCommand( + name, + invocation, + context, + discovered, + ); if (!loaded.ok) { return loaded.exit; } @@ -153,13 +168,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 @@ -195,25 +209,23 @@ export async function reviewSplitCommand( // Unreachable: the parser enforces the positionals (SPEC 10.7). throw new Error("xspec internal error: review split without its arguments"); } - // SPEC 13.5: workspace exclusivity around the whole operation, with the - // `--test-hold` seam immediately after acquisition; a held workspace - // fails promptly as a usage error, modifying nothing. - const outcome = await withMutationExclusivity( - context.workspace.root, - testHoldSpecOf(invocation, context.cwd), - () => runSplit(invocation, context, name, itemId), + // SPEC 13.5: discovery, then workspace exclusivity around every later + // check and read, with the `--test-hold` seam immediately after + // acquisition; a held workspace fails promptly as a usage error, + // modifying nothing (./mutation.ts). + return runMutatingCommand(invocation, context, true, (discovered) => + runSplit(invocation, context, name, itemId, discovered), ); - if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); - } - return outcome.value; } // --------------------------------------------------------------------------- // `review resolve` (SPEC 10.7) // --------------------------------------------------------------------------- -/** The `resolve` operation, run under workspace exclusivity (SPEC 13.5). */ +/** + * The `resolve` operation, run under workspace exclusivity (SPEC 13.5) + * over the classification made before acquisition (./mutation.ts). + */ async function runResolve( invocation: Invocation, context: CommandContext, @@ -221,8 +233,14 @@ async function runResolve( itemId: string, status: ResolveStatus, note: string | undefined, + discovered: SourceClassification, ): Promise<ExitCode> { - const loaded = await loadSessionForCommand(name, invocation, context); + const loaded = await loadSessionForCommand( + name, + invocation, + context, + discovered, + ); if (!loaded.ok) { return loaded.exit; } @@ -246,15 +264,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 @@ -312,23 +331,18 @@ export async function reviewResolveCommand( throw new Error("xspec internal error: review resolve without --status"); } const note = flagValue(invocation, "--note"); - // SPEC 13.5: workspace exclusivity around the whole operation, with the - // `--test-hold` seam immediately after acquisition. - const outcome = await withMutationExclusivity( - context.workspace.root, - testHoldSpecOf(invocation, context.cwd), - () => - runResolve( - invocation, - context, - name, - itemId, - status as ResolveStatus, - note, - ), + // SPEC 13.5: discovery, then workspace exclusivity around every later + // check and read, with the `--test-hold` seam immediately after + // acquisition (./mutation.ts). + return runMutatingCommand(invocation, context, true, (discovered) => + runResolve( + invocation, + context, + name, + itemId, + status as ResolveStatus, + note, + discovered, + ), ); - if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); - } - return outcome.value; } diff --git a/src/cli/commands/review-session.ts b/src/cli/commands/review-session.ts index d8881733..d53633c1 100644 --- a/src/cli/commands/review-session.ts +++ b/src/cli/commands/review-session.ts @@ -7,20 +7,29 @@ // baseline.ts, refresh.ts). This module composes them into the one flow // every session-naming subcommand shares: // -// 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 +// 1. Session-name validity is the parser's (cli/args.ts): a name outside +// the form of 10.1 is a usage error of 12.0's syntax class, exit 2, +// reported before the configuration is loaded — so every name reaching +// this flow is well-formed. +// 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 +53,9 @@ 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 { SourceClassification } from "../../core/discovery.js"; +import type { ExitCode, Finding } from "../../core/findings.js"; import { generatePathBlocksItems } from "../../core/path-blocks.js"; import type { ItemKind, @@ -59,7 +68,6 @@ import type { import { isItemBlocked, recordedStateToJson, - sessionNameProblem, sessionStrategy, } from "../../core/review.js"; import type { @@ -82,11 +90,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 +259,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 +315,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 +325,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 +354,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; @@ -358,17 +367,56 @@ export async function loadSessionForCommand( name: string, invocation: Invocation, context: CommandContext, + discovered?: SourceClassification, ): Promise< LoadedSessionForCommand | { readonly ok: false; readonly exit: ExitCode } > { - // Step 1 — SPEC 10.1 → 12.0: an invalid session name is a usage error. - const nameCheck = requireValidSessionName(name, invocation, context); - if (nameCheck !== null) { - return { ok: false, exit: nameCheck }; + // Step 1 — the name's form (SPEC 10.1) was judged by the parser + // (cli/args.ts) before the configuration was loaded: a name outside it + // is a syntax-class usage error (SPEC 12.0). + + // 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. A mutating + // subcommand hands in the classification it made before acquiring + // exclusivity, its configuration errors already reported there + // (SPEC 13.5; ./mutation.ts). + const analyzed = await analyzeGraphForRead(invocation, context, discovered); + if (!analyzed.ok) { + return { ok: false, exit: analyzed.exit }; } - // 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 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 +429,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 +444,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,33 +454,17 @@ 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, }; } -/** SPEC 10.1 → 12.0: report an invalid session name, or pass (null). */ -export function requireValidSessionName( - name: string, - invocation: Invocation, - context: CommandContext, -): ExitCode | null { - const problem = sessionNameProblem(name); - if (problem === null) { - return null; - } - return usageError(context.stderr, invocation.command, problem); -} - /** SPEC 10.7 → 12.0: an unknown session named in arguments. */ export function unknownSessionError( name: string, @@ -437,11 +472,13 @@ export function unknownSessionError( context: CommandContext, ): ExitCode { return usageError( - context.stderr, - invocation.command, - `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)`, + invocation, + context, + `unknown session '${name}' — the session directory .xspec/reviews/ ` + + `holds no session file ${name}.json (it holds sessions only while ` + + `directories occupy its path and .xspec, never through a symbolic ` + + `link or other non-directory occupant, SPEC 10.1, 13.4); session ` + + `names compare byte-wise and case-sensitively (SPEC 10.1, 10.7, 12.0)`, ); } @@ -506,14 +543,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 +565,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 +609,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 +631,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 +734,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 +834,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 62d59795..fd144232 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -4,16 +4,29 @@ // // Outcome precedence, per subcommand: // -// `create` (mutating, SPEC 13.5) — under workspace exclusivity with the -// `--test-hold` seam: -// 1. session-name validity (SPEC 10.1 → 12.0, exit 2); -// 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; +// `create` (mutating, SPEC 13.5) — its session name's form (SPEC 10.1), +// its exactly-one-of rule (SPEC 10.7), and its `--strategy` vocabulary are +// the parser's, judged before the configuration is loaded (12.0's syntax +// class); then the configuration search and the discovery of 7, which +// precede every error consulting them (SPEC 12.0) and exclusivity's +// acquisition (SPEC 13.5; ./mutation.ts): a configuration error, a +// discovery-level one included, exits 2 (14.14), and a discovery read the +// environment refuses stops the command (14.25, exit 2); then, under +// workspace exclusivity with the `--test-hold` seam: +// 0. the analysis of the current workspace over that discovery — the +// sources' content and the journal read; +// 1. `--coverage`: the named profile must be configured (SPEC 10.7 → +// 12.0 "unknown profiles named in arguments", exit 2) — an argument +// check judged against the configuration, preceding the gate; +// 2. `--base`: read the baseline — ref resolution and the journal +// prefix/replay (SPEC 6.3 → 12.0, exit 2) — preceding source +// validation; +// 3. the SPEC 13.3 gate: invalid sources report the validation errors, +// exit 1, nothing created; +// 4. `--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); // 5. 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); @@ -23,11 +36,12 @@ // `list` (read) — refresh-on-read, then every session in byte order of // name with its name, strategy, and item counts by stored status (no // read-time invalidation, SPEC 10.7); corrupt sessions by name as corrupt -// in place of those fields; exit 1 iff any is corrupt. +// in place of those fields, each also reported as its 14.21 finding under +// `findings` (SPEC 14, 12.7); exit 1 iff any is corrupt. // // `status`, `next`, `show`, `export` (reads) — the shared open flow of -// review-session.ts (name validity, load, recorded-baseline resolution, -// refresh), then the read-time view (SPEC 10.4: invalidation applied, +// review-session.ts (session existence, load, recorded-baseline +// resolution, refresh — the name's form being the parser's), then the read-time view (SPEC 10.4: invalidation applied, // never persisted). `next` returns the first item in item order that needs // review and is unblocked, or reports the session fully resolved (exit 0, // the JSON payload with no item). `export` emits the entire session as a @@ -35,7 +49,9 @@ // (SPEC 10.7). import type { JsonObject } from "../../core/canonical-json.js"; -import type { ExitCode } from "../../core/findings.js"; +import type { SourceClassification } from "../../core/discovery.js"; +import type { ExitCode, Finding } from "../../core/findings.js"; +import { orderFindings } from "../../core/findings.js"; import type { ReviewSession, SessionParameters } from "../../core/review.js"; import { countsByStoredStatus, @@ -43,7 +59,6 @@ import { parametersToJson, recordCoverageProfile, sessionFilePath, - sessionNameProblem, sessionStrategy, statusNeedsReview, } from "../../core/review.js"; @@ -52,22 +67,33 @@ 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 { withMutationExclusivity } from "../../workspace/lock.js"; +import type { + BaselineRead, + ResolvedBaseline, +} from "../../workspace/baseline.js"; +import { + readBaseline, + validateBaselineContent, +} from "../../workspace/baseline.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 { emitFindingsReport } from "../report.js"; -import { emitDocument, testHoldSpecOf, usageError } from "./common.js"; +import { analyzeGraphForRead, prepareGraphForRead } from "../prepare.js"; +import { + emitFindingsReport, + findingToJson, + renderFindingsHuman, +} from "../report.js"; +import { emitDocument, usageError } from "./common.js"; +import { runMutatingCommand } from "./mutation.js"; import { countsJson, effectiveTotals, @@ -83,18 +109,33 @@ import { // `review create` (SPEC 10.7) // --------------------------------------------------------------------------- -/** The `create` operation, run under workspace exclusivity (SPEC 13.5). */ +/** + * The `create` operation, run under workspace exclusivity (SPEC 13.5) over + * the classification made before acquisition (./mutation.ts). + */ async function runCreate( invocation: Invocation, context: CommandContext, name: string, + discovered: SourceClassification, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; - // 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); + // The name's form (SPEC 10.1) was judged by the parser (cli/args.ts), a + // syntax-class usage error reported before the configuration is loaded + // and exclusivity acquired (SPEC 12.0, 13.5). + + // SPEC 12.0, 14.14, 14.25, 13.5: the configuration search and the + // discovery of 7 precede every error consulting them and exclusivity's + // acquisition, so a configuration error (a discovery-level one included; + // exit 2) and a discovery read the environment refuses (exit 2, thrown + // from the walk) were met before acquisition (./mutation.ts). Analyze + // the current workspace over that discovery first — the creation + // parameters are judged past it. Nothing is reported past this until + // the gate below. + const analyzed = await analyzeGraphForRead(invocation, context, discovered); + if (!analyzed.ok) { + return analyzed.exit; } // SPEC 10.7: exactly one of `--base`, `--strategy audit`, `--coverage` @@ -105,18 +146,18 @@ 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 - // for `coverage <name>` (SPEC 8.2, 14.14). + // error — an argument check judged against the configuration, as for + // `coverage <name>` (SPEC 8.2), preceding the gate of 13.3. const profile = workspace.configuration.coverage.find( (candidate) => candidate.name === profileName, ); 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 +167,51 @@ 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: 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 { analysis } = analyzed; + const assessed = await assessWorkspaceRead(workspace, analysis); + if (assessed.kind === "findings") { + emitFindingsReport(invocation.json, stdout, assessed.findings); + return 1; } - const { analysis } = prepared; + + // SPEC 6.3 → 12.0: past the gate, a baseline whose content cannot be + // parsed and validated as a workspace is a usage error (exit 2) — + // reported before the refresh write commits, so nothing is modified. + let baseline: ResolvedBaseline | undefined; + if (baselineRead !== undefined) { + const resolution = await validateBaselineContent(baselineRead); + if (!resolution.ok) { + return usageError(invocation, context, resolution.message); + } + baseline = resolution.baseline; + } + + // SPEC 13.3: the one refresh write (a no-op when the store already + // matches), every pre-creation check of the workspace passed. + await assessed.commit(); // SPEC 10.1/10.7: `create` with the name of an existing session is // refused (exit 1, nothing created); a name matching an existing @@ -165,12 +224,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 +239,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 +287,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) { @@ -262,19 +327,13 @@ export async function reviewCreateCommand( // Unreachable: the parser enforces the required flag (SPEC 10.7, 12.0). throw new Error("xspec internal error: review create without --name"); } - // SPEC 13.5: `create` is a mutating subcommand — workspace exclusivity - // around the whole operation, with the `--test-hold` seam immediately - // after acquisition; a held workspace fails promptly as a usage error, - // modifying nothing. - const outcome = await withMutationExclusivity( - context.workspace.root, - testHoldSpecOf(invocation, context.cwd), - () => runCreate(invocation, context, name), + // SPEC 13.5: `create` is a mutating subcommand — discovery, then + // workspace exclusivity around every later check and read, with the + // `--test-hold` seam immediately after acquisition; a held workspace + // fails promptly as a usage error, modifying nothing (./mutation.ts). + return runMutatingCommand(invocation, context, true, (discovered) => + runCreate(invocation, context, name, discovered), ); - if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); - } - return outcome.value; } // --------------------------------------------------------------------------- @@ -294,14 +353,18 @@ export async function reviewListCommand( // SPEC 10.7: every session in byte order of session name; item counts by // stored status — no read-time invalidation — and each corrupt session - // (14.21) by name as corrupt in place of those fields. + // (14.21) by name as corrupt in place of those fields. SPEC 14.21, 14, + // 12.7: `list` reports each corrupt session as its condition-21 finding + // too — the stable code, the session file as the concerned path — in the + // document's `findings` member (`[]` when none is corrupt), and the + // human form names the code, presenting the same information (12.0). const loaded = await loadAllSessions(context.workspace.root); - let anyCorrupt = false; + const corruptions: Finding[] = []; const entries: JsonObject[] = []; let human = ""; for (const entry of loaded) { if (entry.state === "corrupt") { - anyCorrupt = true; + corruptions.push(entry.finding); entries.push({ corrupt: true, name: entry.name }); human += `${entry.name} corrupt\n`; continue; @@ -317,13 +380,22 @@ export async function reviewListCommand( `${entry.name} ${sessionStrategy(entry.session)} ` + `${renderCountsHuman(counts)}\n`; } + // SPEC 12.7: findings in the pinned order, identical findings collapsed — + // each corrupt session's finding concerns its own session file, so none + // collapses. + const findings = orderFindings(corruptions); if (invocation.json) { - emitDocument(context.stdout, { sessions: entries }); + emitDocument(context.stdout, { + findings: findings.map(findingToJson), + sessions: entries, + }); } else { - context.stdout.write(human); + context.stdout.write( + findings.length > 0 ? human + renderFindingsHuman(findings) : human, + ); } - // SPEC 10.7: `list` exits 1 when any session is corrupt, else 0. - return anyCorrupt ? 1 : 0; + // SPEC 10.7, 14.21: `list` exits 1 when any session is corrupt, else 0. + return findings.length > 0 ? 1 : 0; } // --------------------------------------------------------------------------- @@ -457,8 +529,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 +559,15 @@ export async function reviewExportCommand( // Unreachable: the parser enforces the positional (SPEC 10.7). throw new Error("xspec internal error: review export without <name>"); } - const opened = await openSessionForRead(name, invocation, context); + // SPEC 10.7/12.0: `export` is a JSON-only surface — a single JSON + // document is its only output form, with or without `--json`, the + // findings report of a failed gate or a corrupt session included — so + // every report path runs with JSON output forced on (as `query` does). + const opened = await openSessionForRead( + name, + { ...invocation, json: true }, + context, + ); if (!opened.ok) { return opened.exit; } diff --git a/src/cli/commands/rewrite-validation.ts b/src/cli/commands/rewrite-validation.ts new file mode 100644 index 00000000..0f66eed6 --- /dev/null +++ b/src/cli/commands/rewrite-validation.ts @@ -0,0 +1,157 @@ +// The would-succeed validation `rename` and `move` share with their +// `--preview` (SPEC 6.4, 6.5, 6.6). +// +// Past the refusal evaluation (core/refusal.ts) and the plan, the operation +// re-validates its rewritten workspace in memory — the journal modeled as +// it will stand after the append, since hashes take the journal as an input +// (SPEC 5.4) — and vets its complete write set against SPEC 14.22, before +// modifying anything. Both are internal-consistency guards: the refusal +// evaluation realizes every reason SPEC 14 gives, every rewritten reference +// resolves by construction (SPEC 6.4, 6.5), and the valid-workspace +// precondition and the destination checks vet every write path (SPEC 6.5, +// 14.22), so a correct plan trips neither. Whatever they refuse, the +// preview refuses alike — SPEC 6.6: "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." So the real +// operation and its preview run this one validation, and a preview emits +// its successful report only past it; the preview still modifies nothing +// and takes no exclusivity (SPEC 6.6, 13.5). + +import type { BuildOutputs } from "../../core/build.js"; +import { computeBuildOutputs } from "../../core/build.js"; +import type { ExitCode, Finding } from "../../core/findings.js"; +import { JOURNAL_PATH } from "../../core/journal.js"; +import { + readDerivedFileRecord, + recordedPathsOf, +} from "../../workspace/graph-data.js"; +import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; +import { workspaceInputsOf } from "../../workspace/pipeline.js"; +import { obstructedWritePathFindings } from "../../workspace/writes.js"; +import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; +import type { CliWriter, CommandContext } from "../io.js"; +import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; +import { emitRefusedPreview } from "./preview.js"; + +/** + * SPEC 6.4/6.5/12.0/12.7: a refused rename or 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). + */ +export 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; +} + +/** + * The verdict of the would-succeed validation: refused — already + * reported, with the exit code the invocation ends with — or proceeding, + * with the finishing regeneration's outputs the real operation executes. + */ +export type RewrittenWorkspaceVerdict = + | { readonly proceeds: false; readonly exit: ExitCode } + | { readonly proceeds: true; readonly outputs: BuildOutputs }; + +/** + * Validate an operation's rewritten workspace before anything is modified + * (SPEC 6.4, 6.5), for the real operation and its preview alike (SPEC + * 6.6). `rewritten` is the in-memory analysis of the workspace as the + * operation would leave it, the journal as it will stand after the append; + * `writtenPaths` are the workspace-relative paths the plan writes — the + * rewritten sources, a relocated or created file included. + */ +export async function validateRewrittenWorkspace( + invocation: Invocation, + context: CommandContext, + rewritten: WorkspaceAnalysis, + writtenPaths: readonly string[], + preview: boolean, +): Promise<RewrittenWorkspaceVerdict> { + const { workspace, stdout } = context; + if (rewritten.configurationErrors.length > 0) { + // Unreachable: the configuration is untouched, and a relocated or + // created file joins the source set under the group rules discovery + // applies (SPEC 6.5, 7). Guarded so a regression reports rather than + // corrupts — its preview reporting the same (SPEC 6.6). + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + rewritten.configurationErrors, + ); + return { proceeds: false, exit: 2 }; + } + if (rewritten.findings.length > 0) { + // Unreachable for a correct plan: the refusal evaluation realizes + // every reason an operation can be refused for, and every rewritten + // reference resolves by construction (SPEC 6.4, 6.5), so a validated + // plan leaves a valid workspace. Guarded so a regression refuses (exit + // 1, nothing modified) rather than corrupts — and its preview refuses + // with the same findings and exit (SPEC 6.6). + return { + proceeds: false, + exit: emitFindingsRefusal( + preview, + invocation.json, + stdout, + rewritten.findings, + ), + }; + } + + // SPEC 6.4/6.5/12.1: the finishing regeneration's outputs, derived + // exactly as `xspec build` derives them — over the rewritten analyses; + // the regenerated store records the rewritten workspace's inputs, the + // journal as it will stand after the append (SPEC 13.3). The stored + // record supplies the orphan set alone (SPEC 13.3, 13.4), which no + // validation reads: the write set is the generated files and graph data. + // A preview consults the record for its delta only, past this validation + // (SPEC 6.6: a refused preview consults no record), so it derives the + // outputs over none. + const recorded = preview + ? [] + : recordedPathsOf(await readDerivedFileRecord(workspace.root)); + const outputs = computeBuildOutputs( + workspace.configuration, + rewritten.specs, + rewritten.graph, + rewritten.textModel, + rewritten.hashes, + recorded, + workspaceInputsOf(workspace, rewritten), + ); + + // SPEC 14.22: vet the complete write set — the rewritten sources, the + // journal, and every regenerated file — before modifying anything. + const writeFindings = await obstructedWritePathFindings(workspace.root, [ + ...writtenPaths, + JOURNAL_PATH, + ...outputs.writePaths, + ]); + if (writeFindings.length > 0) { + return { + proceeds: false, + exit: emitFindingsRefusal( + preview, + invocation.json, + stdout, + writeFindings, + ), + }; + } + return { proceeds: true, outputs }; +} diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index dfa01cca..3d75634c 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -8,15 +8,17 @@ // report document (./query-core.ts `nodeReportOf` — one shape, one place), // so the two commands can never disagree. The answer comes from the // refreshed graph (SPEC 13.3, via cli/prepare.ts); an unknown node identity -// is a usage error, exit 2 (SPEC 12.0). +// is a usage error, exit 2 (SPEC 12.0) — judged parse-local against the +// named file before the invalid-workspace report of 13.3 (./gated-args.ts). import type { ExitCode } from "../../core/findings.js"; import type { GraphEdge } from "../../core/graph.js"; import type { Invocation } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, finishGraphForRead } from "../prepare.js"; import { analysisQueryView } from "./analysis-view.js"; import { emitDocument, usageError } from "./common.js"; +import { nodeOperandProblem } from "./gated-args.js"; import type { QueryRow, QueryView } from "./query-core.js"; import { nodeReportOf, resolveRow } from "./query-core.js"; @@ -79,8 +81,29 @@ export async function showCommand( ): Promise<ExitCode> { const { stdout, stderr } = context; - // SPEC 13.3: refresh-on-read, then answer. - const prepared = await prepareGraphForRead(invocation, context); + // SPEC 12.0/13.3: the `<node>` argument check precedes the + // invalid-workspace report — judged parse-local against the named file, + // identically on valid and failing workspaces (./gated-args.ts), so an + // unknown or wrong-kind name exits 2 whatever findings the workspace + // carries, and a failing invocation writes nothing. + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed.exit; + } + const problem = nodeOperandProblem( + analyzed.analysis, + invocation.positionals[0], + ); + if (problem !== null) { + return usageError(invocation, context, problem); + } + + // SPEC 13.3: the gate report, then refresh-on-read, then answer. + const prepared = await finishGraphForRead( + invocation, + context, + analyzed.analysis, + ); if (!prepared.ok) { return prepared.exit; } @@ -88,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 00000000..37ececdb --- /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 00000000..3886c5bb --- /dev/null +++ b/src/cli/commands/view.ts @@ -0,0 +1,409 @@ +// `xspec view [<file> …] [--file <glob>] [--text]` (SPEC 11.4). +// +// Returns, per requested file, everything needed to overlay structure on +// the raw MDX bytes: the root and the full positional section tree with +// construct ranges, tag-range decompositions, raw attribute spellings, and +// the per-node interpreted datums of SPEC 11.2 (identity, tags, coverage — +// each plain, structurally absent, or explicitly unavailable), every +// import declaration, the file's reference occurrences, and every MDX +// comment's range — with `--text`, each node's own and subtree text +// (SPEC 1.6), defined or explicitly unavailable per SPEC 11.2. JSON-only +// (SPEC 11): a single JSON document — the 12.7 `{"findings", "views"}` +// form — is its only output form, with or without `--json`. +// +// The view's domain is the discovered spec sources (SPEC 11.4). `<file>` +// operands assert membership — a file outside the discovered set is an +// unknown file and a discovered code source a wrong-kind operand, each a +// usage error (exit 2, SPEC 12.0); a `#`-containing operand is a whole +// path, never a `path#id` split (SPEC 12.0). `--file` is instead a set +// restriction under the glob rules of SPEC 7 — a glob admitting no +// discovered spec source admits the empty set (an empty, finding-free +// answer, exit 0) — and combining operands with `--file` is a usage error +// (rejected at parse time). With neither, the request covers every +// discovered spec source. The argument checks precede answering +// (SPEC 11.2, 12.0): membership is judged against discovery, before the +// SPEC 13.3 refresh participation, so a failing invocation writes nothing. +// +// The consulted domain (SPEC 11.2) is the requested files plus, with +// `--text`, every file the requested expansions transitively consult +// (core/availability.ts `expansionConsultedFiles`); the domain's findings +// accompany the answer, and any finding or explicitly-unavailable datum +// exits 1 with the full document still emitted. An unparseable requested +// file contributes no view entry — its parse-failure finding reports it — +// while an invalid-path (SPEC 14.19) requested file keeps its view, every +// node identity explicitly unavailable. + +import { + accompanyingFindings, + availabilityExit, + ConsultedDomain, + expansionConsultedFiles, + selectOccurrences, + TextAvailability, +} from "../../core/availability.js"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { JsonObject, JsonValue } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import { orderFindings } from "../../core/findings.js"; +import type { 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 } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { analyzeAnalysisForAvailability } from "../prepare.js"; +import { + findingToJson, + occurrenceRecordJson, + unavailableJson, +} from "../report.js"; +import { compileFileFlag, rangeJson, usageError } from "./common.js"; + +/** One requested file's parsed analysis and its path validity (SPEC 14.19). */ +interface RequestedSpec { + readonly spec: SpecFileAnalysis; + /** + * Whether the file's own path is valid — false for a 14.19 member, whose + * every node identity is explicitly unavailable (SPEC 11.2) while its + * parse-local structure stays on view. + */ + readonly pathValid: boolean; +} + +/** The `view` command handler (SPEC 11.4). */ +export async function viewCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + // --- the arguments (SPEC 11.2: their checks precede answering) --------- + // A `--file` pattern outside the workspace root, and `<file>` operands + // beside `--file`, are the parser's: syntax-class usage errors reported + // before the configuration is loaded (SPEC 11.4, 11.1, 7, 12.0). + const withText = flagPresent(invocation, "--text"); + const fileGlob = compileFileFlag(invocation); + + // --- the analysis half of the SPEC 11.2 pre-answer step (a pure read) --- + const prepared = await analyzeAnalysisForAvailability(invocation, context); + if (!prepared.ok) { + return prepared.exit; + } + const { analysis } = prepared; + const { classification } = analysis; + + // --- operand membership checks (SPEC 11.4, 12.0): judged against the + // discovered set — discovery is controlled exclusively by configuration + // (SPEC 7), so an on-disk file no group discovers is unknown — before + // any answer or refresh side effect (SPEC 11.2). + const discoveredKinds = new Map<string, "spec" | "code">(); + for (const source of classification.specSources) { + discoveredKinds.set(pathTextKey(source.path), "spec"); + } + for (const source of classification.codeSources) { + discoveredKinds.set(pathTextKey(source.path), "code"); + } + for (const source of classification.invalidSources) { + // SPEC 11.2/14.19: invalid-path members are discovered files of their + // kind — a spec-kind member keeps its view; a code-kind member is a + // wrong-kind operand like any other discovered code source. + discoveredKinds.set(pathTextKey(source.path), source.kind); + } + + const requested: PathText[] = []; + const requestedKeys = new Set<string>(); + const addRequested = (file: PathText): void => { + const key = pathTextKey(file); + if (!requestedKeys.has(key)) { + requestedKeys.add(key); + requested.push(file); + } + }; + if (invocation.positionals.length > 0) { + for (const operand of invocation.positionals) { + // SPEC 12.0: a bare <file> operand is a whole path — `#` has no + // delimiter role in it — so the operand names the discovered file of + // exactly that spelling. + const kind = discoveredKinds.get(pathTextKey(operand)); + if (kind === undefined) { + return usageError( + invocation, + context, + `unknown file '${operand}' — a <file> operand names a ` + + `discovered spec source, and no configured group discovers ` + + `this path (SPEC 11.4, 7, 12.0)`, + ); + } + if (kind === "code") { + return usageError( + invocation, + context, + `wrong-kind file '${operand}' — the operand names a discovered ` + + `code source, which has no structural view; name a discovered ` + + `spec source (SPEC 11.4, 12.0)`, + ); + } + addRequested(operand); + } + } else { + // SPEC 11.4: `--file` admits the discovered spec sources it matches — + // matching is byte-wise against the workspace-relative path (SPEC 7); + // with neither operands nor `--file`, every discovered spec source. + for (const source of classification.specSources) { + if (fileGlob === undefined || fileGlob.matches(source.path)) { + addRequested(source.path); + } + } + for (const source of classification.invalidSources) { + if (source.kind !== "spec") continue; + if (fileGlob === undefined || fileGlob.matches(source.bytes)) { + addRequested(source.path); + } + } + } + // SPEC 11.4: the requested files form a set; per-file views are ordered + // by byte order of workspace-relative path. + requested.sort(comparePathTexts); + + // --- the refresh half (SPEC 13.3, 11.2): the invocation is valid, so + // the surface participates in read-time refresh on a passing workspace + // and touches nothing on a failing one. + await finishAvailabilityRefresh(context.workspace, analysis); + + // --- the answer (SPEC 11.4, 11.2) --------------------------------------- + const parsedByKey = new Map<string, RequestedSpec>(); + for (const spec of analysis.specs) { + parsedByKey.set(pathTextKey(spec.document.file), { spec, pathValid: true }); + } + for (const spec of analysis.invalidPathSpecs) { + parsedByKey.set(pathTextKey(spec.document.file), { + spec, + pathValid: false, + }); + } + // An unparseable requested file has no parsed analysis: it contributes + // no view entry, its parse-failure finding reporting it (SPEC 11.2). + const requestedSpecs: RequestedSpec[] = []; + for (const file of requested) { + const entry = parsedByKey.get(pathTextKey(file)); + if (entry !== undefined) { + requestedSpecs.push(entry); + } + } + + // The consulted domain: the requested files plus, with `--text`, every + // file the requested expansions transitively consult (SPEC 11.4). + const domainFiles: PathText[] = [...requested]; + if (withText) { + domainFiles.push( + ...expansionConsultedFiles( + analysis.graph, + requestedSpecs.map((entry) => entry.spec.document), + ), + ); + } + const domain = new ConsultedDomain(domainFiles); + const findings = orderFindings( + accompanyingFindings(analysis.findings, domain), + ); + + const renderer = new ViewRenderer( + analysis.graph, + analysis.textModel, + withText, + ); + const views = requestedSpecs.map((entry) => renderer.fileView(entry)); + + const document: JsonValue = { + findings: findings.map(findingToJson), + views, + }; + context.stdout.write(canonicalJson(document)); + // SPEC 11.2: any finding or explicitly-unavailable datum → exit 1 with + // the full document emitted; complete and finding-free → exit 0. + return availabilityExit(findings, renderer.carriesUnavailable); +} + +/** + * Renders per-file views in the 12.7 document form, tracking whether any + * emitted datum is the explicit unavailability marker (the SPEC 11.2 exit + * input). Structure is parse-local; the interpreted datums follow + * SPEC 11.2's three states — plain value, stated `null` where 11.4 defines + * structural absence, or `{"unavailable": true}` — and with `--text` the + * own/subtree text values are defined exactly per the expansion rules + * (core/availability.ts `TextAvailability`). + */ +class ViewRenderer { + carriesUnavailable = false; + private readonly textAvailability: TextAvailability; + + constructor( + private readonly graph: WorkspaceGraph, + private readonly textModel: WorkspaceTextModel, + private readonly withText: boolean, + ) { + this.textAvailability = new TextAvailability(graph); + } + + /** The 12.7 unavailability marker, counted toward the exit (SPEC 11.2). */ + private unavailable(): JsonObject { + this.carriesUnavailable = true; + return unavailableJson(); + } + + /** One `{"file", "root", "imports", "occurrences", "comments"}` entry. */ + fileView(entry: RequestedSpec): JsonObject { + const { spec, pathValid } = entry; + const document = spec.document; + // SPEC 11.2: a section's node identity is defined per the spelling and + // chain rules — and in a file whose own path is invalid (SPEC 14.19) + // no node has a defined identity, whatever the content spells. + const defined = pathValid ? definedIdentitySections(document) : null; + // SPEC 11.4: the file's own occurrence records, in document order — + // the graph's occurrence order restricted to one file (SPEC 5.7). + const records = selectOccurrences( + this.graph, + new ConsultedDomain([document.file]), + ); + if (records.some((record) => record.source === null)) { + // The source datum is reported explicitly unavailable (SPEC 11.2). + this.carriesUnavailable = true; + } + return { + file: pathTextJson(document.file), + root: this.nodeJson(document, document.root, defined), + imports: spec.imports.imports.map((declaration) => ({ + range: rangeJson(declaration.statement.range), + // SPEC 11.4: the default binding's identifier — structurally + // absent (null, never unavailable) where the declaration binds no + // default. + name: declaration.bindingName, + // SPEC 11.4/11.2: the resolved target where specifier form and + // discovery define one, explicitly unavailable otherwise. + target: + declaration.designatedFile === null + ? this.unavailable() + : pathTextJson(declaration.designatedFile), + })), + occurrences: records.map(occurrenceRecordJson), + comments: document.comments.map((comment) => rangeJson(comment.range)), + }; + } + + /** + * One node of the positional section tree (SPEC 11.4, 12.7): the + * `{"identity", "range", "opening", "closing", "attributes", "tags", + * "coverage", "children"}` form plus `"ownText"`/`"subtreeText"` exactly + * when `--text` is given. The tree is built iteratively — children before + * parents over an explicit stack — so a pathologically deep nesting tower + * cannot exhaust the call stack (the answer covers any parseable file). + */ + private nodeJson( + document: SpecDocument, + section: SpecSection, + defined: ReadonlySet<SpecSection> | null, + ): JsonObject { + // Pre-order collection (parents before descendants), then a reverse + // build pass so every node's children are built when the node is. + const order: SpecSection[] = []; + const pending: SpecSection[] = [section]; + while (pending.length > 0) { + const current = pending.pop() as SpecSection; + order.push(current); + for (const child of current.children) { + pending.push(child); + } + } + const built = new Map<SpecSection, JsonObject>(); + for (let index = order.length - 1; index >= 0; index -= 1) { + const current = order[index]; + const children = current.children.map( + (child) => built.get(child) as JsonObject, + ); + built.set( + current, + this.sectionJson(document, current, defined, children), + ); + } + return built.get(section) as JsonObject; + } + + /** The one-node body of `nodeJson`, its children already rendered. */ + private sectionJson( + document: SpecDocument, + section: SpecSection, + defined: ReadonlySet<SpecSection> | null, + children: readonly JsonObject[], + ): JsonObject { + const isRoot = section.parent === null; + // SPEC 11.2: the identity datum — the root's is defined exactly when + // 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 a50f4bea..fbb3162b 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 64f125dc..9ce68e44 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -19,12 +19,19 @@ // then dispatch, exactly as before. import type { ExitCode } from "../core/findings.js"; +import { EnvironmentRefusal } from "../workspace/environment-refusal.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, + emitEnvironmentRefusal, + emitErrorDocument, + usageErrorFinding, +} from "./report.js"; /** One command's implementation, dispatched by `Invocation.command`. */ export type CommandHandler = ( @@ -35,129 +42,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<string, () => Promise<CommandHandler>> = new Map( - COMMAND_PATHS.map((path): [string, () => Promise<CommandHandler>] => { - switch (path) { - case "build": - // SPEC 12.1. - return [ - path, - async () => (await import("./commands/build.js")).buildCommand, - ]; - case "check": - // SPEC 12.2. - return [ - path, - async () => (await import("./commands/check.js")).checkCommand, - ]; - case "ids": - // SPEC 12.3. - return [ - path, - async () => (await import("./commands/ids.js")).idsCommand, - ]; - case "show": - // SPEC 12.4. - return [ - path, - async () => (await import("./commands/show.js")).showCommand, - ]; - case "coverage": - // SPEC 8.2. - return [ - path, - async () => (await import("./commands/coverage.js")).coverageCommand, - ]; - case "impact": - // SPEC 9. - return [ - path, - async () => (await import("./commands/impact.js")).impactCommand, - ]; - case "query node": - case "query nodes": - case "query edges": - case "query subtree": - case "query ancestors": - case "query reachable": - // SPEC 11. - return [ - path, - async () => (await import("./commands/query.js")).queryCommand, - ]; - case "review create": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review.js")).reviewCreateCommand, - ]; - case "review list": - // SPEC 10.7. - return [ - path, - async () => (await import("./commands/review.js")).reviewListCommand, - ]; - case "review status": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review.js")).reviewStatusCommand, - ]; - case "review next": - // SPEC 10.7. - return [ - path, - async () => (await import("./commands/review.js")).reviewNextCommand, - ]; - case "review show": - // SPEC 10.7. - return [ - path, - async () => (await import("./commands/review.js")).reviewShowCommand, - ]; - case "review split": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review-mutate.js")).reviewSplitCommand, - ]; - case "review resolve": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review-mutate.js")).reviewResolveCommand, - ]; - case "review export": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review.js")).reviewExportCommand, - ]; - case "rename": - // SPEC 6.4. - return [ - path, - async () => (await import("./commands/rename.js")).renameCommand, - ]; - case "move": - // SPEC 6.5. - return [ - path, - async () => (await import("./commands/move.js")).moveCommand, - ]; - default: - // Unreachable: every SPEC 12.5 command path is cased above. - // Guarded so a command-table addition without a handler fails - // loudly at module load. - throw new Error(`no handler implemented for command '${path}'`); - } - }), + COMMAND_PATHS.filter((path) => path !== "version").map( + (path): [string, () => Promise<CommandHandler>] => { + switch (path) { + case "build": + // SPEC 12.1. + return [ + path, + async () => (await import("./commands/build.js")).buildCommand, + ]; + case "check": + // SPEC 12.2. + return [ + path, + async () => (await import("./commands/check.js")).checkCommand, + ]; + case "ids": + // SPEC 12.3. + return [ + path, + async () => (await import("./commands/ids.js")).idsCommand, + ]; + case "show": + // SPEC 12.4. + return [ + path, + async () => (await import("./commands/show.js")).showCommand, + ]; + case "coverage": + // SPEC 8.2. + return [ + path, + async () => + (await import("./commands/coverage.js")).coverageCommand, + ]; + case "impact": + // SPEC 9. + return [ + path, + async () => (await import("./commands/impact.js")).impactCommand, + ]; + case "query node": + case "query nodes": + case "query edges": + case "query subtree": + case "query ancestors": + case "query reachable": + // SPEC 11. + return [ + path, + async () => (await import("./commands/query.js")).queryCommand, + ]; + case "review create": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewCreateCommand, + ]; + case "review list": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewListCommand, + ]; + case "review status": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewStatusCommand, + ]; + case "review next": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewNextCommand, + ]; + case "review show": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewShowCommand, + ]; + case "review split": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review-mutate.js")).reviewSplitCommand, + ]; + case "review resolve": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review-mutate.js")) + .reviewResolveCommand, + ]; + case "review export": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewExportCommand, + ]; + case "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). */ @@ -178,13 +221,36 @@ export async function main( ): Promise<ExitCode> { const result = parseArgv(argv); 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`); + // SPEC 12.0: the syntax class — every error the invocation's arguments + // alone determine: unknown commands, subcommands, or flags, repeated + // flags, missing required flags or arguments, surplus operands, + // malformed values, and every invalid flag value or operand spelling a + // fixed vocabulary, a spelling rule, or a co-occurrence rule decides — + // is reported here, before the configuration is located, so no + // configuration state changes it: exit 2 with the diagnostic on + // standard error. With JSON output in effect (a `--json` token read as + // a flag, 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 @@ -193,15 +259,61 @@ export async function main( `no handler registered for command '${result.invocation.command}'`, ); } + try { + return await dispatchInWorkspace( + result.invocation, + loadHandler, + cwd, + stdout, + stderr, + ); + } catch (error) { + // SPEC 14.24/12.0: a write the environment refuses stops the command at + // that write — thrown there by the workspace write layer, so no later + // write was attempted and every earlier one stands complete (13.5) — + // and is a usage error, not a finding: exit 2, the diagnostic on + // standard error and, with JSON output in effect, the 12.7 error + // document carrying `write-failure` and the concerned path as the + // entire standard output. Every writing command answers only after its + // writes, so nothing of an answer precedes the document. + if (error instanceof EnvironmentRefusal) { + emitEnvironmentRefusal( + { stdout, stderr }, + jsonOutputInEffect(result.invocation), + error.finding, + ); + return 2; + } + throw error; + } +} + +/** + * The workspace-bound part of an invocation: locate and load the + * configuration, then answer from a verified store or dispatch to the + * command's handler. + */ +async function dispatchInWorkspace( + invocation: Invocation, + loadHandler: () => Promise<CommandHandler>, + cwd: string, + stdout: CliWriter, + stderr: CliWriter, +): Promise<ExitCode> { // SPEC 7/14.14: every command locates and loads the configuration — // upward search from the working directory, or the `--config <path>` // value resolved against it (12.0). A missing or invalid configuration // is a configuration error, reported as a usage error (exit 2) preceding - // all source analysis; with `--json`, the exit-2 error prevents emitting - // the single JSON document, so standard output stays empty (12.0). - const location = await locateWorkspace(cwd, result.invocation.config); + // 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, invocation.config); if (!location.ok) { - emitConfigurationErrors(stderr, location.findings); + emitConfigurationErrors( + { stdout, stderr }, + jsonOutputInEffect(invocation), + location.concernedPath, + location.findings, + ); return 2; } @@ -209,9 +321,9 @@ export async function main( // current workspace bytes (module header) — the recorded configuration // parse stands in for re-parsing, so a fast answer never needs the // parser. Anything unverified falls through to the full path below. - if (isQueryCommand(result.invocation.command)) { + if (isQueryCommand(invocation.command)) { const fast = await tryFastQuery( - result.invocation, + invocation, location.located, stdout, stderr, @@ -221,16 +333,33 @@ 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 (invocation.command === "at") { + const fast = await tryFastAt(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(invocation), + location.located.configAnchor, + loaded.findings, + ); return 2; } const handler = await loadHandler(); - return handler(result.invocation, { + return handler(invocation, { cwd, workspace: loaded.workspace, stdout, diff --git a/src/cli/prepare.ts b/src/cli/prepare.ts index d2c8c5d2..90b6789a 100644 --- a/src/cli/prepare.ts +++ b/src/cli/prepare.ts @@ -10,15 +10,25 @@ // 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 { SourceClassification } from "../core/discovery.js"; 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"; @@ -28,7 +38,7 @@ export type ReadPreparation = readonly ok: true; /** The analyzed current workspace (graph, text model, hashes, journal). */ readonly analysis: WorkspaceAnalysis; - /** The stored graph data — current snapshot, retained record. */ + /** The stored graph data — the current snapshot with its inputs. */ readonly graphData: GraphData; } | { @@ -37,30 +47,147 @@ 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. `discovered` is the + * classification a mutating `review` subcommand made before acquiring + * exclusivity (SPEC 13.5; commands/mutation.ts), its configuration errors + * already reported there. + */ +export async function analyzeGraphForRead( + invocation: Invocation, + context: CommandContext, + discovered?: SourceClassification, +): Promise<ReadAnalysisPreparation> { + const analyzed = await analyzeWorkspaceForRead(context.workspace, discovered); + if (analyzed.kind === "configuration") { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + analyzed.errors, + ); + return { ok: false, exit: 2 }; + } + return { ok: true, analysis: analyzed.analysis }; +} + +/** + * The gate-and-refresh half of the SPEC 13.3 pre-answer step, over an + * analysis from `analyzeGraphForRead`: on a workspace failing `build`'s + * validations, the findings report on standard output with exit 1 and + * nothing modified; on a passing one the refresh write, then the ready + * analysis to answer from. + */ +export async function finishGraphForRead( + invocation: Invocation, + context: CommandContext, + analysis: WorkspaceAnalysis, +): Promise<ReadPreparation> { + const assessed = await assessWorkspaceRead(context.workspace, analysis); + if (assessed.kind === "findings") { + emitFindingsReport(invocation.json, context.stdout, assessed.findings); + return { ok: false, exit: 1 }; + } + await assessed.commit(); + return { ok: true, analysis, graphData: assessed.graphData }; +} + /** * SPEC 13.3: refresh-on-read, then answer. Runs the shared pre-answer step * and either hands back the fresh analysis or emits the failure — findings * report on standard output with exit 1, or configuration diagnostics on * standard error with exit 2 (SPEC 12.0) — leaving the caller to return - * the exit code unchanged. + * the exit code unchanged. The composition of `analyzeGraphForRead` and + * `finishGraphForRead` for commands whose argument checks consult nothing + * past the loaded configuration. */ export async function prepareGraphForRead( invocation: Invocation, context: CommandContext, ): Promise<ReadPreparation> { - const prepared = await prepareWorkspaceForRead(context.workspace); - switch (prepared.kind) { - case "configuration": - emitConfigurationErrors(context.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<AvailabilityAnalysis> { + const prepared = await prepareWorkspaceForAvailability(context.workspace); + if (prepared.kind === "configuration") { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + prepared.errors, + ); + return { ok: false, exit: 2 }; + } + return { ok: true, analysis: prepared.analysis }; +} + +/** + * The analysis half of the SPEC 11.2 pre-answer step alone — a pure read, + * configuration errors rendered exactly as `prepareAnalysisForAvailability` + * renders them (SPEC 14.14, 12.0). For surfaces whose argument checks + * consult discovery (`view`'s operand membership, SPEC 11.4): the caller + * runs those checks against the returned analysis, then — the invocation + * valid — performs the SPEC 13.3 refresh participation + * (workspace/availability.ts `finishAvailabilityRefresh`) before + * answering, so a failing invocation writes nothing. + */ +export async function analyzeAnalysisForAvailability( + invocation: Invocation, + context: CommandContext, +): Promise<AvailabilityAnalysis> { + const prepared = await analyzeWorkspaceForAvailability(context.workspace); + if (prepared.kind === "configuration") { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + prepared.errors, + ); + return { ok: false, exit: 2 }; } + return { ok: true, analysis: prepared.analysis }; } diff --git a/src/cli/report.ts b/src/cli/report.ts index 3ee92f69..e6a69703 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,282 @@ 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 (a `--json` token read as a flag, 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 found or named + * configuration path in the anchoring form of 11.6, relative to the + * invocation working directory, whatever occupies it; `.` for a failed + * upward search with no `--config`; or a `--config` path nothing occupies + * as the argument value exactly as given (SPEC 14, 12.0). Locations stay + * empty — a configuration error is an unlocated condition (SPEC 14). + */ +export function configurationErrorFinding( + findings: readonly Finding[], + concernedPath: 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: concernedPath, + 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`; +} + +/** + * SPEC 14.24/12.0: render the environment's refusal of a write as a + * diagnostic line — a usage error, standard-error content — naming the + * concerned path, as a configuration error's line does. + */ +export function renderEnvironmentRefusal(finding: Finding): string { + const label = finding.code === "read-failure" ? "read" : "write"; + const location = + finding.path === null ? "" : `${renderPathText(finding.path)}: `; + return `xspec: ${label} failure: ${location}${finding.message}\n`; +} + +/** + * Report a write the environment refused (SPEC 14.24) the way every command + * must: a usage error (12.0), not a finding — the diagnostic on standard + * error and, when JSON output is in effect, the exit-2 error document of + * 12.0/12.7 as the entire standard output, its one finding carrying the + * condition's stable code and concerned path; without JSON output standard + * output stays empty. The caller exits 2. + */ +export function emitEnvironmentRefusal( + io: CommandIo, + jsonInEffect: boolean, + finding: Finding, +): void { + io.stderr.write(renderEnvironmentRefusal(finding)); + if (jsonInEffect) { + emitErrorDocument(io.stdout, finding); + } } /** * 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, `.`, or an unoccupied `--config` value as + * given (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, + concernedPath: 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, concernedPath), + ); } } diff --git a/src/core/availability.ts b/src/core/availability.ts new file mode 100644 index 00000000..0ec60365 --- /dev/null +++ b/src/core/availability.ts @@ -0,0 +1,357 @@ +// 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 { describeSegmentViolation, idSegmentViolations } from "./text.js"; + +/** + * The consulted domain of one availability answer (SPEC 11.2): a set of + * discovered files, membership by exact path bytes (SPEC 12.0 — one byte + * space over both path presentation forms, so an invalid-path file's marked + * byte form and a plain string never collide or diverge). + */ +export class ConsultedDomain { + private readonly keys: ReadonlySet<string>; + + constructor(files: Iterable<PathText>) { + const keys = new Set<string>(); + for (const file of files) { + keys.add(pathTextKey(file)); + } + this.keys = keys; + } + + /** Whether `path` names a domain file (exact byte membership). */ + has(path: PathText): boolean { + return this.keys.has(pathTextKey(path)); + } +} + +/** + * The discovered files a `--file` restriction admits (SPEC 11.3): the + * discovered source files — spec and code alike, invalid-path (14.19) + * members included: they are discovered files (SPEC 11.2) — that the glob + * matches, under the glob rules of 7 (byte-wise against the + * workspace-relative path). Without a glob, the entire discovered set. A + * glob admitting nothing admits the empty set — a set restriction, not an + * existence assertion (SPEC 11.3); discovery is controlled exclusively by + * configuration (SPEC 7), so an on-disk file no group discovers is never + * admitted, whatever patterns match it. + */ +export function discoveredDomain( + classification: SourceClassification, + glob?: CompiledGlob, +): ConsultedDomain { + const files: PathText[] = []; + for (const source of classification.specSources) { + if (glob === undefined || glob.matches(source.path)) { + files.push(source.path); + } + } + for (const source of classification.codeSources) { + if (glob === undefined || glob.matches(source.path)) { + files.push(source.path); + } + } + for (const source of classification.invalidSources) { + // SPEC 7: matching is byte-wise against the workspace-relative path — + // an invalid path's exact bytes, which may have no plain string form. + if (glob === undefined || glob.matches(source.bytes)) { + files.push(source.path); + } + } + return new ConsultedDomain(files); +} + +/** + * The findings accompanying an answer over `domain` (SPEC 11.2): every + * finding one of whose locations lies in a domain file or whose concerned + * path is a domain file. A jointly-violated condition carries a location + * for every participating construct (SPEC 14), so it accompanies whole + * whenever any participant is in the domain; a condition with neither an + * in-domain location nor an in-domain concerned path — the journal's 14.13, + * a write path's 14.22, a policy violation's 14.12 — accompanies no answer. + * Input order is preserved (the emitters re-order per SPEC 12.7). + */ +export function accompanyingFindings( + findings: readonly Finding[], + domain: ConsultedDomain, +): Finding[] { + return findings.filter( + (finding) => + finding.locations.some((location) => domain.has(location.file)) || + (finding.path !== null && domain.has(finding.path)), + ); +} + +/** + * Why `spelling` is not a syntactically well-formed requirement-node + * identity — `path#id`, or a bare `path` for a root (SPEC 1.5) — or null + * when it is (SPEC 11.3): well-formed exactly when it contains at most one + * `#`, its path part (the whole spelling, or the part before the `#`) is + * non-empty, and, when a `#` is present, the part after it is one or more + * non-empty segments joined by `.`, each satisfying the segment rules of + * 1.4. Acceptance is syntactic: whether the named identity resolves is no + * part of this check (SPEC 11.3, 12.0). + */ +export function nodeSpellingProblem(spelling: string): string | null { + const firstHash = spelling.indexOf("#"); + if (firstHash !== -1 && spelling.indexOf("#", firstHash + 1) !== -1) { + return 'it contains more than one "#" (SPEC 12.0: at most one is well-formed)'; + } + const pathPart = firstHash === -1 ? spelling : spelling.slice(0, firstHash); + if (pathPart.length === 0) { + return "its path part is empty"; + } + if (firstHash === -1) { + return null; + } + const idPart = spelling.slice(firstHash + 1); + if (idPart.length === 0) { + return 'its id part after "#" is empty (one or more segments required)'; + } + // The shared SPEC 1.4 validator (text.ts); the no-"." rule is structural + // under the split, and a "#" inside a segment is impossible under the + // at-most-one-"#" rule above. + const first = idSegmentViolations(idPart)[0]; + if (first === undefined) { + return null; + } + if (first.violation.rule === "empty") { + return "its id part has an empty segment"; + } + return ( + `its id segment ${JSON.stringify(first.segment)} ` + + `${describeSegmentViolation(first.violation)} (SPEC 1.4)` + ); +} + +/** + * 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<string>(); + const visited = new Set<SpecSection>(); + + const visitTarget = (document: SpecDocument, section: SpecSection): void => { + if (visited.has(section)) return; + visited.add(section); + for (const embedding of document.embeddings) { + if (!rangeWithin(section.range, embedding.range)) continue; + const target = graph.embeddingTarget(embedding); + if (target === null) continue; // no occurrence — the boundary + const key = pathTextKey(target.document.file); + if (!fileKeys.has(key)) { + fileKeys.add(key); + files.push(target.document.file); + } + visitTarget(target.document, target.section); + } + }; + + for (const document of requested) { + // With `--text` every node's text is computed, the root's subtree + // covering the whole file (SPEC 1.2), so every embedding of a + // requested file starts an expansion. + visitTarget(document, document.root); + } + return files; +} + +/** + * Per-node definedness of the SPEC 11.2 expanded-text values: a node's own + * (respectively subtree) text is defined exactly when every embedding the + * expansion transitively reaches — each `{text(...)}` spelling in the + * node's own contribution (respectively anywhere in its subtree), and + * recursively each one anywhere in every embedded target's subtree — + * records an occurrence (resolved through the graph's embedding index) and + * the recursion re-enters no node already being expanded (an embedding + * cycle). One unresolved spelling or one cycle on the expansion path makes + * the whole value unavailable — partial expansion never occurs. Where + * defined, the text model's values are exact (SPEC 11.2). + */ +export class TextAvailability { + /** Per-section verdict; "visiting" marks a subtree expansion in progress. */ + private readonly state = new Map<SpecSection, "visiting" | boolean>(); + + constructor(private readonly graph: WorkspaceGraph) {} + + /** Whether the node's own text (SPEC 1.6) is defined (SPEC 11.2). */ + ownTextDefined(document: SpecDocument, section: SpecSection): boolean { + for (const embedding of document.embeddings) { + // The node's own contribution: the embeddings whose innermost + // section is the node itself (children's are excised, SPEC 1.6). + if (embedding.section !== section) continue; + const target = this.graph.embeddingTarget(embedding); + if (target === null) return false; // records no occurrence + if (!this.subtreeTextDefined(target.document, target.section)) { + return false; + } + } + return true; + } + + /** Whether the node's subtree text (SPEC 1.6) is defined (SPEC 11.2). */ + subtreeTextDefined(document: SpecDocument, section: SpecSection): boolean { + const memo = this.state.get(section); + if (memo === "visiting") { + // The recursion re-entered a node already being expanded: an + // embedding cycle — the value is undefined for every node on or + // reaching the cycle (the false return propagates up the chain). + return false; + } + if (typeof memo === "boolean") return memo; + this.state.set(section, "visiting"); + let defined = true; + for (const embedding of document.embeddings) { + // Anywhere in the subtree: the embeddings within the construct range + // (the whole file for the root, SPEC 1.2). + if (!rangeWithin(section.range, embedding.range)) continue; + const target = this.graph.embeddingTarget(embedding); + if ( + target === null || + !this.subtreeTextDefined(target.document, target.section) + ) { + defined = false; + break; + } + } + this.state.set(section, defined); + return defined; + } +} diff --git a/src/core/build.ts b/src/core/build.ts index 31ab31a9..13e5a5c3 100644 --- a/src/core/build.ts +++ b/src/core/build.ts @@ -10,9 +10,10 @@ // true (SPEC 7.3): `NAME.mdx` emits `NAME.md`, placed per `markdown.outDir` // preserving workspace-relative paths, the default next to each source // (SPEC 3, 13.2); -// - the graph data (SPEC 13.3): the current snapshot with the derived-file -// record set to exactly the paths generated by this build (13.4 — the -// record orphan removal relies on); +// - the graph data (SPEC 13.3): the current snapshot with its derivation +// inputs, and the derived-file record set to exactly the paths generated +// by this build (13.4 — the record orphan removal relies on), the two +// stored apart so a refresh can write the one without the other; // - the orphans (SPEC 12.1, 13.3, 13.4): recorded derived files the current // sources and configuration no longer generate, removed via their recorded // paths only — a missing or malformed record records nothing, and such @@ -25,17 +26,15 @@ import { compareBytes, sortByBytes } from "./bytes.js"; import type { Configuration } from "./config.js"; -import { canonicalOutDirPrefix } from "./discovery.js"; +import type { SourceClassification } from "./discovery.js"; +import { canonicalOutDirPrefix, specSourceDerivedPaths } 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, - GRAPH_DATA_PATH, - recordedDerivedFiles, -} from "./graph-data.js"; +import { buildGraphSnapshot, GRAPH_DATA_OWN_PATHS } from "./graph-data.js"; import type { SpecFileAnalysis, WorkspaceGraph } from "./graph.js"; import type { NodeHashes } from "./hashes.js"; +import type { PathText } from "./path-text.js"; import type { WorkspaceTextModel } from "./text-model.js"; /** Everything one `xspec build` writes and removes (SPEC 12.1). */ @@ -49,10 +48,16 @@ export interface BuildOutputs { */ readonly files: readonly GeneratedFile[]; /** - * SPEC 13.3: the graph data to store — the current snapshot, with the - * derived-file record holding exactly the paths of `files`. + * SPEC 13.3: the graph data to store — the current snapshot with its + * derivation inputs, exactly what a refresh writes too. */ readonly graphData: GraphData; + /** + * SPEC 13.3/13.4: the derived-file record to store — exactly the paths of + * `files`, in byte order: the paths of the derived files most recently + * generated, written by generation alone (never by a refresh). + */ + readonly record: readonly string[]; /** * SPEC 12.1, 13.3, 13.4: recorded derived files the current sources and * configuration no longer generate, in byte order — removed via these @@ -61,18 +66,19 @@ export interface BuildOutputs { readonly orphans: readonly string[]; /** * The complete workspace-relative write set — every path of `files` plus - * the graph-data path — for the SPEC 14.22 pre-write validation (writes - * never traverse symbolic links; a command refuses the write and reports - * it before modifying anything). + * the graph data's own paths (the snapshot's and the record's) — for the + * SPEC 14.22 pre-write validation (writes never traverse symbolic links; + * a command refuses the write and reports it before modifying anything). */ readonly writePaths: readonly string[]; } /** - * Derive the SPEC 12.1 build outputs of a validated workspace. `stored` is - * the previously stored graph data (null when missing or malformed): its - * recorded derived-file paths are the orphan-removal domain (SPEC 13.3, - * 13.4). `hashes` must be the SPEC 5.5 computation over `graph`. + * Derive the SPEC 12.1 build outputs of a validated workspace. `recorded` + * is the stored record's derived-file paths — empty when the record is + * absent or cannot be read as a record: such orphans are outside xspec's + * knowledge (SPEC 13.4) — the orphan-removal domain (SPEC 13.3, 13.4). + * `hashes` must be the SPEC 5.5 computation over `graph`. */ export function computeBuildOutputs( configuration: Configuration, @@ -80,7 +86,7 @@ export function computeBuildOutputs( graph: WorkspaceGraph, textModel: WorkspaceTextModel, hashes: ReadonlyMap<string, NodeHashes>, - stored: GraphData | null, + recorded: readonly string[], inputs: StoredInputs, ): BuildOutputs { // SPEC 12.0: deterministic output order — sources by byte order of @@ -112,13 +118,8 @@ export function computeBuildOutputs( } const generated = new Set(files.map((file) => file.path)); - // SPEC 12.1/13.3/13.4: orphan removal via recorded paths only. The - // graph-data path is never recorded (13.3 records the generated derived - // files); a record naming it anyway is dropped defensively — the store is - // rewritten, never removed, by a build. - const orphans = [...new Set(recordedDerivedFiles(stored))] - .filter((path) => !generated.has(path) && path !== GRAPH_DATA_PATH) - .sort(compareBytes); + // SPEC 12.1/13.3/13.4: orphan removal via recorded paths only. + const orphans = orphanedRecordedPaths(recorded, generated); return { files, @@ -127,11 +128,149 @@ export function computeBuildOutputs( // SPEC 13.3: the recorded derivation inputs certify the snapshot for // byte-identical current inputs (core/graph-data.ts). inputs, - // SPEC 13.3: the paths of the derived files most recently generated — - // updated only by generation. - derivedFiles: [...generated].sort(compareBytes), }, + // SPEC 13.3: the paths of the derived files most recently generated — + // updated only by generation. + record: [...generated].sort(compareBytes), orphans, - writePaths: [...files.map((file) => file.path), GRAPH_DATA_PATH], + writePaths: [...files.map((file) => file.path), ...GRAPH_DATA_OWN_PATHS], }; } + +/** + * 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) and the set of + * generated paths on any workspace (`discoveredGeneratedPaths`, SPEC + * 14.10): 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 spec-group file without the extension (14.19, + // reaching here only through `discoveredGeneratedPaths`) generates no + // module and emits no Markdown. + 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); +} + +/** + * SPEC 14.10's "set of generated paths alone, a set discovery and + * configuration define on any workspace (13.1, 7.3, 11.6)": the derived + * paths every discovered spec source generates by its `NAME.mdx` name shape + * alone — its module and companions (13.1) and, while emission is enabled, + * its Markdown emit destination (7.3) — through `generatedDerivedPaths`, in + * byte order, graph data excluded. It parses no source, so it is defined on + * a workspace failing `build`'s validations as on a passing one, where it + * is exactly the paths `computeBuildOutputs` generates (every discovered + * spec source then valid and parsed). A spec-group file whose own path + * 14.19 rejects is a discovered spec source all the same — the inventory's + * derived-file map gives it a module path and an emit destination (11.6), + * and the configured emit destinations count it (7.3) — so it contributes + * its derived paths, a non-`.mdx` one none (13.1). A rejected path with no + * plain string form (not valid UTF-8, SPEC 12.0) contributes nothing here: + * its derived paths keep its stem, an ASCII suffix following it, so they + * are no more valid UTF-8 than it is, and no recorded path — a string, the + * record written by a successful build over valid sources (13.3) — can + * equal one. + */ +export function discoveredGeneratedPaths( + configuration: Configuration, + classification: SourceClassification, +): readonly string[] { + const specPaths = classification.specSources.map((source) => source.path); + for (const source of classification.invalidSources) { + if (source.kind === "spec" && typeof source.path === "string") { + specPaths.push(source.path); + } + } + return generatedDerivedPaths(configuration, specPaths); +} + +/** + * SPEC 14.22's write paths of `build` on any workspace: "the derived files + * the current sources and configuration generate (13.1, 13.2) and graph + * data (13.3)" — per-source derived paths defined by the `NAME.mdx` name + * shape alone (13.1), so the set, like 14.10's set of generated paths, is + * one discovery and configuration define on a workspace failing `build`'s + * validations as on a passing one, needing no source to parse. The paths + * are `discoveredGeneratedPaths`; then, for each discovered spec source + * whose path has no plain string form (not valid UTF-8, 14.19), which that + * set omits, its module path and Markdown emit destination in the byte + * form (`specSourceDerivedPaths`, the inventory's derived-file map, 11.6): + * the destination under `markdown.outDir` crosses directory components no + * other write path need cross, some of them without a string form (7.3), + * while the companions lie beside the module (13.1), crossing no further + * component; then graph data's own paths. On a passing workspace — every + * discovered spec source valid and parsed — this is exactly the set of + * `computeBuildOutputs`' `writePaths`. Unordered: the 14.22 examination + * (`obstructedWritePathFindings`) deduplicates and orders the paths itself. + */ +export function discoveredWritePaths( + configuration: Configuration, + classification: SourceClassification, +): readonly PathText[] { + const paths: PathText[] = [ + ...discoveredGeneratedPaths(configuration, classification), + ]; + for (const source of classification.invalidSources) { + if (source.kind === "spec" && typeof source.path !== "string") { + const derived = specSourceDerivedPaths(source.bytes, configuration); + if (derived.module !== null) paths.push(derived.module); + if (derived.markdown !== null) paths.push(derived.markdown); + } + } + paths.push(...GRAPH_DATA_OWN_PATHS); + return paths; +} + +/** + * SPEC 12.1, 13.3, 13.4, 14.10: the recorded derived-file paths the current + * sources and configuration no longer generate — `recorded` (a readable + * record's paths; none where the record is absent or cannot be read as a + * record, files orphaned then being outside xspec's knowledge, 13.4) + * outside `generated`, duplicate-free, in byte order. Graph data's own + * paths are never recorded (13.3: graph data records no paths of its own); + * a record naming one anyway is dropped defensively — the store is + * rewritten, never removed, by a build. The one rule behind generation's + * orphan removal and `check`'s recorded-file form. + */ +export function orphanedRecordedPaths( + recorded: readonly string[], + generated: ReadonlySet<string>, +): readonly string[] { + return [...new Set(recorded)] + .filter( + (path) => !generated.has(path) && !GRAPH_DATA_OWN_PATHS.includes(path), + ) + .sort(compareBytes); +} diff --git a/src/core/bytes.ts b/src/core/bytes.ts index 3547bd21..7b93c6b9 100644 --- a/src/core/bytes.ts +++ b/src/core/bytes.ts @@ -56,6 +56,26 @@ export function sortByBytes<T>( return [...items].sort((a, b) => compareBytes(key(a), key(b))); } +/** + * The set form of a string list (SPEC 12.7's tag-set value form): each + * distinct string once, in byte order (SPEC 12.0) — a repeated element + * collapses, whatever the input's order. + */ +export function byteOrderedSet(items: readonly string[]): string[] { + return [...new Set(items)].sort(compareBytes); +} + +/** + * Whether `items` is already in its set form (`byteOrderedSet`): strictly + * ascending in byte order, so no element repeats. + */ +export function isByteOrderedSet(items: readonly string[]): boolean { + for (let index = 1; index < items.length; index += 1) { + if (compareBytes(items[index - 1]!, items[index]!) >= 0) return false; + } + return true; +} + /** The length in bytes of one code point's UTF-8 encoding. */ function utf8CodePointLength(codePoint: number): number { if (codePoint <= 0x7f) return 1; diff --git a/src/core/canonical-json.ts b/src/core/canonical-json.ts index ab7369d9..e6a6f6f4 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 1ba0d00d..6231e67d 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -24,21 +24,31 @@ // // Masking (SPEC 14): an unparseable file (14.20) masks the conditions // inside itself — decode or parse failure yields only the 14.20. A chain -// rooted at a binding of an invalid import, or at an identifier bound by -// colliding imports, is masked by that import's 14.15. A chain rooted at -// a shadowing local declaration or a type-only binding records no edge -// 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"; +// rooted at a binding of an invalid import is masked by that import's +// 14.15. A chain rooted at a shadowing local declaration or a type-only +// binding records no edge and falls under no condition (SPEC 4.5); its +// value-level misuse is the consumer's TypeScript error, outside xspec's +// validations. An identifier a spec module import binds that another +// import, or a value-level declaration of the module scope, also binds +// roots no resolving chain (SPEC 2.4, 4.5), whether or not either import +// is type-only: beside the collision's 14.15, its chains report as +// unresolved (14.7) and a call through it as `text` is a plain call. + +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 { classifyReference, stringLiteralValue } from "./references.js"; import { decodeSourceBytes } from "./source-text.js"; +import { tsSyntaxFailureOffset } from "./ts-syntax-failure.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 +59,16 @@ import { resolveImportSpecifier } from "./spec-references.js"; /** What the workspace provides the analysis of one code source. */ export interface CodeAnalysisContext { /** - * The discovered spec-source paths (SPEC 7.1): a spec module import must - * designate one of them (SPEC 4 → 14.15). Whether the designated file - * parses does not matter here — references through it report as - * unresolved during resolution (SPEC 14.20, 14.7). + * Designation of spec module import specifiers over the entire + * discovered spec-source set (SPEC 7.1, 2.1; `SpecSourceDomain`): a + * spec module import must designate a discovered member (SPEC 4 → + * 14.15). Whether the designated file parses does not matter here — + * references through it report as unresolved during resolution + * (SPEC 14.20, 14.7) — and a member whose own path is invalid + * (SPEC 14.19) is designated validly, references through it never + * resolving (SPEC 11.2 → 14.7, reported by this analysis). */ - readonly specPaths: ReadonlySet<string>; + readonly designate: DesignateSpecifier; /** * The configured Markdown emit destinations (SPEC 7.3, * `markdownEmitDestinations`) — empty while emission is disabled — for @@ -73,6 +87,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). */ @@ -92,7 +117,10 @@ export interface CodeImport { readonly range: ByteRange; /** The declaration's exact text (SPEC 6.5 rewrites). */ readonly text: string; - /** The module specifier's cooked value. */ + /** + * The module specifier's value: the characters between its delimiters + * exactly as spelled, no escape sequence interpreted (SPEC 2.4, 4). + */ readonly specifier: string; /** The quote character of the specifier literal (SPEC 6.5 rewrites). */ readonly specifierQuote: '"' | "'"; @@ -100,12 +128,28 @@ 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 default-export binding, when present (SPEC 4). */ - readonly defaultBinding: CodeImportBinding | 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; + /** + * Every binding of the module's default export, in written order + * (SPEC 4: the default export, optionally aliased): the default + * clause's (`import X from`), then each named `{ default as X }` + * element's — both bind the default export, so either roots a chain + * (6.5 "Reference spellings"). + */ + readonly defaultBindings: readonly CodeImportBinding[]; /** The named `text` bindings, in written order (SPEC 4). */ readonly textBindings: readonly CodeImportBinding[]; /** Whether the import is valid (collisions are reported pairwise). */ @@ -131,14 +175,106 @@ export interface CodeReference { readonly segments: readonly string[]; /** The chain's exact spelling, for in-place rewrites (SPEC 6.4). */ readonly spelling: ReferenceSpelling; - /** The reference expression's bytes (finding locations, SPEC 14.7). */ + /** + * The index, in the file's `imports`, of the spec module import + * declaration whose binding the chain is rooted at — as the language + * resolves the root identifier (SPEC 4.5): the occurrence uses that + * binding (SPEC 6.5 "Import edits"). + */ + readonly rootImport: number; + /** + * For a `text(...)` call, the index, in the file's `imports`, of the + * declaration whose `text` binding the callee is — the occurrence uses + * that binding too (SPEC 6.5 "Import edits", 4.5); null for a marker. + */ + readonly calleeImport: number | null; + /** + * For a `text(...)` call, its callee identifier: the name the language + * reads (escapes interpreted, as for a chain's root, SPEC 2.4) and the + * identifier's own characters, which a section move re-rooting the call + * at another module's `text` binding rewrites in place (SPEC 6.5 + * "Reference spellings", 6.4); null for a marker. + */ + readonly callee: { + readonly name: string; + readonly range: ByteRange; + } | null; + /** The reference chain's own bytes, the argument of a `text(...)` call. */ 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. A reference that + * does not resolve records no occurrence, and its finding (SPEC 14.7) + * is located here, the span the occurrence would occupy (SPEC 14). + */ + readonly occurrenceRange: ByteRange; + /** + * Whether the reference is spelled free of escape sequences (SPEC 2.4): + * no `\` among the characters of its chain's root identifier and + * segment names, or of a `text` call's callee. Only such a spelling that + * does not resolve is also a type error against the generated module + * (SPEC 14.7) — TypeScript reads an escape interpreted, xspec as spelled. + */ + readonly escapeFree: boolean; + /** + * The called module of a cross-module `text(...)` call (SPEC 4.4, + * 14.11): the spec module whose `text` export the callee binds, when it + * is not the argument's own module; null for a marker and for a call + * into the argument's own module. The condition needs an argument that + * resolves, which only resolution decides: a resolving call records its + * edge and occurrence and reports 14.11 beside them (SPEC 5.7), an + * unresolved one is 14.7 alone (SPEC 14.11). + */ + readonly calledModule: CalledModule | null; + /** + * The identifiers the file's spec module imports bind (SPEC 4: default, + * `text`, and `{ default as X }` bindings alike, as the language reads + * them) that a local declaration shadows at the occurrence (SPEC 4.5): + * each one TypeScript scoping, resolving it at value level at the + * chain's root, resolves to something other than that import binding. + * A `text(...)` call's callee stands in the root's scope — no scope + * lies between a call's callee and its argument — so the set holds for + * both. SPEC 6.5 "Reference spellings": a re-rooted spelling uses a + * binding the file already holds only where no local declaration + * shadows it at the occurrence. + */ + readonly shadowedImportNames: ReadonlySet<string>; +} + +/** The foreign module of a cross-module `text(...)` call (SPEC 14.11). */ +export interface CalledModule { + /** + * The called module's root identity (SPEC 1.5), its workspace-relative + * path, or null where that path is invalid (SPEC 14.19): its root + * identity is then undefined, and no identity over an invalid path is + * ever emitted (SPEC 14.11, 11.2), so the finding carries none. + */ + readonly identity: string | null; + /** The module's deterministic display spelling (messages only). */ + readonly display: string; } /** 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 @@ -151,7 +287,25 @@ export interface CodeAnalysis { readonly imports: readonly CodeImport[]; /** Every extracted reference, in document order (SPEC 4.3, 4.5). */ readonly references: readonly CodeReference[]; - /** The 14.8/14.11/14.15/14.18 findings, ordered by location. */ + /** + * Every identifier the file spells, each as the language reads it + * (escapes interpreted): every name a declaration of any scope binds, + * value- or type-level — variables and their destructuring patterns, + * parameters, functions, classes, enums, interfaces, type aliases, type + * parameters, namespaces, `declare` forms, and every binding of every + * import — and every name the file reads, property names included. + * SPEC 6.5 "Import edits": an added import binds fresh identifiers + * colliding with no binding already in the file (2.1, 4); an identifier + * outside this set collides with no module-scope binding, is shadowed by + * no inner declaration wherever it roots a spelling (4.5), and captures + * no use the file spells of a name bound outside it. + */ + readonly spelledNames: ReadonlySet<string>; + /** + * The per-file 14.7/14.8/14.15/14.18 findings, ordered by location — a + * cross-module call's 14.11 is reported at resolution (graph.ts), since + * the condition needs an argument that resolves (SPEC 14.11). + */ readonly findings: readonly Finding[]; } @@ -176,8 +330,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,6 +353,7 @@ export function analyzeCodeSource( return { kind: "unparseable", finding: parseFailureFinding( + file, path, sourceFile, offsets, @@ -208,6 +364,7 @@ export function analyzeCodeSource( } const analyzer = new CodeAnalyzer( path, + file, sourceFile, offsets, program.getTypeChecker(), @@ -224,22 +381,68 @@ 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"), }; } } +/** + * SPEC 6.5 "Import edits", 14.20: a would-be code source — a discovered + * code source as a move's edits leave it — judged exactly as + * `analyzeCodeSource` judges a discovered one: decoded (1.6) and parsed + * under the grammar its file name selects, well-formed exactly when + * scanning and parsing report no syntax error. For a well-formed text, the + * byte range of each top-level import declaration, in document order — + * the `import` keyword through its last token, a statement terminator the + * grammar reads into it included; null for a text that is not well-formed + * or that the parser cannot process. + */ +export function topLevelImportRanges( + path: string, + bytes: Uint8Array, +): ByteRange[] | null { + const decoded = decodeSourceBytes(path, bytes); + if (!decoded.ok) { + return null; + } + // SPEC 14.20: `.tsx` parses as TSX, any other name as plain TypeScript. + const tsx = path.endsWith(".tsx"); + try { + const sourceFile = ts.createSourceFile( + path, + decoded.text, + ts.ScriptTarget.Latest, + /* setParentNodes */ false, + tsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const program = createSingleFileProgram(sourceFile, tsx); + if (program.getSyntacticDiagnostics(sourceFile).length > 0) { + return null; + } + const offsets = new Utf8Offsets(decoded.text); + return sourceFile.statements + .filter((statement) => ts.isImportDeclaration(statement)) + .map((statement) => ({ + start: offsets.byteOffset(statement.getStart(sourceFile)), + end: offsets.byteOffset(statement.end), + })); + } catch (error) { + // As in `analyzeCodeSource`: a call-stack overflow is a text the + // parser cannot process, never a crash. + if (!(error instanceof RangeError)) throw error; + return null; + } +} + /** 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 +451,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 +466,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,65 +483,176 @@ function createSingleFileProgram( /** The 14.20 finding for a parse failure, locating it (SPEC 14.20). */ function parseFailureFinding( + file: PathText, path: string, - sourceFile: ts.SourceFile, + sourceFile: tst.SourceFile, offsets: Utf8Offsets, tsx: boolean, - diagnostic: ts.DiagnosticWithLocation, + diagnostic: tst.Diagnostic, ): Finding { const reason = ts.flattenDiagnosticMessageText(diagnostic.messageText, " "); const grammar = tsx ? "TSX" : "plain TypeScript"; - const start = Math.min(diagnostic.start, sourceFile.text.length); - const end = Math.min( - 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: one zero-length range at the failure's offset — the byte + // length of the longest prefix with which some well-formed file begins + // (ts-syntax-failure.ts), each probe judged by the same grammar. + const at = tsSyntaxFailureOffset(sourceFile.text, (text) => { + const probe = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + /* setParentNodes */ false, + tsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + return { + sourceFile: probe, + diagnostics: createSingleFileProgram(probe, tsx).getSyntacticDiagnostics( + probe, + ), + }; + }); + const byte = offsets.byteOffset(at); + 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: byte, end: byte } }], + ); } // --------------------------------------------------------------------------- // 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; + /** The index, in `imports`, of the declaration giving the binding. */ + readonly importIndex: number; + } + | { + readonly kind: "text"; + readonly target: SpecModuleTarget; + /** The index, in `imports`, of the declaration giving the binding. */ + readonly importIndex: number; + } | { /** SPEC 4: a binding introduced type-only is a type-level name. */ readonly kind: "type-level"; } | { /** - * A binding of an invalid import, or an identifier bound by more - * than one import: the 14.15 accounts for it, and references rooted - * here are masked (SPEC 14). + * A binding of an invalid import (one not colliding): the 14.15 + * accounts for it, and references rooted here are masked (SPEC 14). */ readonly kind: "poisoned"; - }; + } + | CollidingBinding; -/** One import-bound identifier, for the collision rule (SPEC 4, 2.1). */ +/** A `text` binding of a valid spec module import (SPEC 4, 4.5). */ +type TextBinding = Extract<TrackedBinding, { readonly kind: "text" }>; + +/** + * SPEC 2.4, 4.5: an identifier a spec module import binds that another + * import, or a value-level declaration of the module scope, also binds — + * whether or not either import is type-only (4.5: the type-only exemption + * reaches a chain the language roots at one binding, and a colliding + * identifier roots it at none). The language names no single binding, so + * a chain rooted here names no target: in a marker or a `text` call's + * argument it is unresolved (14.7), recording no edge and no occurrence + * (5.7), and a call through it as a `text` binding is no `text` call + * (4.5). Its other uses are those of the bindings its spec module imports + * give it — `node` where one binds the default export, `text` where one + * binds the `text` export, at least one of the two: a binding of no + * permitted form (14.15) roots chains as a default binding would. + */ +interface CollidingBinding { + readonly kind: "colliding"; + readonly node: boolean; + readonly text: boolean; + /** The colliding binders, as messages name them ("2 imports", …). */ + readonly binders: string; +} + +/** One import-bound identifier, for the collision rules (SPEC 4, 2.1, 2.4). */ 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; + /** + * Whether this is a spec module import declaration's value-level + * binding — none introduced type-only (SPEC 4) — the binding a + * value-level declaration of the same scope collides with (SPEC 2.4). + */ + readonly valueLevelSpec: boolean; + /** + * The binding's role where its form is a permitted one (SPEC 4): the + * default export ("node") or the `text` export ("text"); null for any + * other binding. + */ + readonly role: "node" | "text" | null; + readonly statement: tst.Statement; +} + +/** + * One module-scope non-import declaration binding a name at value level + * (SPEC 2.4): a variable, function, class, or enum declaration, or a + * namespace declaration binding a value. + */ +interface ValueDeclaration { + readonly name: string; + /** The declaration node TypeScript binds the name to (use resolution). */ + readonly declaration: tst.Node; + /** The construct binding the name, as a 14.15 locates it (SPEC 14, 1.7). */ + readonly construct: + | tst.VariableDeclaration + | tst.FunctionDeclaration + | tst.ClassDeclaration + | tst.EnumDeclaration + | tst.ModuleDeclaration; } class CodeAnalyzer { @@ -347,15 +661,25 @@ class CodeAnalyzer { private readonly imports: CodeImport[] = []; private readonly units: CodeUnit[] = []; /** Declaration node → what a use resolving to it means (SPEC 4.5). */ - private readonly declarations = new Map<ts.Node, TrackedBinding>(); + private readonly declarations = new Map<tst.Node, TrackedBinding>(); /** Named-unit construct → its unit (attribution, SPEC 4.6). */ - private readonly unitByNode = new Map<ts.Node, CodeUnit>(); + private readonly unitByNode = new Map<tst.Node, CodeUnit>(); + /** + * Every binding of every spec module import, in document order: the + * bound identifier and the declaration node the checker resolves its + * uses to (the shadowing judgement, SPEC 4.5, 6.5). + */ + private readonly importBindings: { + readonly name: string; + readonly declaration: tst.Node; + }[] = []; 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,12 +689,14 @@ class CodeAnalyzer { this.walk(this.sourceFile); return { path: this.path, + file: this.file, text: this.sourceFile.text, units: this.units, imports: this.imports, references: [...this.references].sort( (a, b) => a.range.start - b.range.start || a.range.end - b.range.end, ), + spelledNames: spelledIdentifierNames(this.sourceFile), findings: sortFindings(this.findings), }; } @@ -378,29 +704,52 @@ 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()), }; } + /** + * A string literal's value as SPEC 2.4 reads it: the characters between + * its delimiters exactly as spelled, no escape sequence interpreted. + */ + private literalValue(literal: tst.StringLiteral): string { + return stringLiteralValue(literal, this.sourceFile); + } + + /** + * The export a named import binding names — SPEC 2.4: a string-literal + * export name is read as spelled; an identifier names the export the + * language reads it as. + */ + private importedName(element: tst.ImportSpecifier): string { + const exported = element.propertyName ?? element.name; + return ts.isStringLiteral(exported) + ? this.literalValue(exported) + : exported.text; + } + 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 +760,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 = @@ -421,13 +770,35 @@ class CodeAnalyzer { return this.bindingOfSymbol(symbol); } + /** + * SPEC 4.5, 6.5 "Reference spellings": the identifiers the file's spec + * module imports bind that a local declaration shadows at `location` — + * each one TypeScript scoping, resolving it there at value level, + * resolves to something other than that import binding's declaration. + */ + private shadowedImportNamesAt(location: tst.Node): ReadonlySet<string> { + const shadowed = new Set<string>(); + for (const { name, declaration } of this.importBindings) { + const symbol = this.checker.resolveName( + name, + location, + ts.SymbolFlags.Value, + false, + ); + if (!(symbol?.declarations ?? []).some((node) => node === declaration)) { + shadowed.add(name); + } + } + return shadowed; + } + /** * 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 +808,30 @@ 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. `callee` is a `text` + * call's callee identifier, part of the spelling judged for escapes + * (SPEC 14.7) and recorded for a move's re-rooting (SPEC 6.5), and + * `calledModule` a cross-module call's called module (SPEC 14.11). + * `rootImport` and `calleeImport` index the import declarations whose + * bindings the root and the callee are (SPEC 6.5 "Import edits"), and + * `root` is the chain's root identifier, where the import bindings a + * local declaration shadows are judged (SPEC 4.5, 6.5). + */ private chainReference( kind: "references" | "embeds", classified: ClassifiedChain, + root: tst.Identifier, modulePath: string, + rootImport: number, location: string, + occurrenceRange?: ByteRange, + callee?: tst.Identifier, + calledModule: CalledModule | null = null, + calleeImport: number | null = null, ): CodeReference { const spanRange = (span: { readonly start: number; @@ -451,6 +840,22 @@ class CodeAnalyzer { start: this.offsets.byteOffset(span.start), end: this.offsets.byteOffset(span.end), }); + const range = spanRange(classified.span); + const calleeSpan = + callee === undefined + ? null + : { start: callee.getStart(this.sourceFile), end: callee.getEnd() }; + // SPEC 2.4, 14.7: a spelling free of escape sequences — no `\` in + // the root identifier, a segment's name token, or the callee. + const tokens = [ + classified.rootSpan, + ...classified.segments.map((segment) => segment.nameSpan), + ...(calleeSpan === null ? [] : [calleeSpan]), + ]; + const escapeFree = tokens.every( + (token) => + !this.sourceFile.text.slice(token.start, token.end).includes("\\"), + ); return { kind, location, @@ -468,7 +873,17 @@ class CodeAnalyzer { accessRange: spanRange(segment.accessSpan), })), }, - range: spanRange(classified.span), + rootImport, + calleeImport, + callee: + callee === undefined || calleeSpan === null + ? null + : { name: callee.text, range: spanRange(calleeSpan) }, + range, + occurrenceRange: occurrenceRange ?? range, + escapeFree, + calledModule, + shadowedImportNames: this.shadowedImportNamesAt(root), }; } @@ -496,31 +911,153 @@ class CodeAnalyzer { this.scanImportEquals(statement, bound); } } - // 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. + // SPEC 4/2.1/2.4 → 14.15: no import may bind an identifier already + // bound by ANOTHER import, when either import is a spec module import, + // and no spec module import's value-level binding may share its + // identifier with a value-level declaration of the module scope — one + // condition the declarations jointly violate: ONE finding per collided + // identifier, locating every colliding declaration (SPEC 14 location + // cardinality; no representative chosen). A collided identifier, with + // another import or with a value-level declaration alike, roots no + // resolving chain (SPEC 2.4, 4.5). const byName = new Map<string, BoundName[]>(); for (const entry of bound) { const entries = byName.get(entry.name); if (entries === undefined) byName.set(entry.name, [entry]); else entries.push(entry); } + const declaredByName = new Map<string, ValueDeclaration[]>(); + for (const declared of moduleValueDeclarations(this.sourceFile)) { + const entries = declaredByName.get(declared.name); + if (entries === undefined) declaredByName.set(declared.name, [declared]); + else entries.push(declared); + } 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 between imports (SPEC 4: "already bound by another + // import") needs two distinct declarations. + const importCollision = + statements.length >= 2 && entries.some((entry) => entry.spec); + // SPEC 2.4: the import's binding being value-level (4), a + // non-import declaration binding the identifier at value level in + // the same scope collides with it. + const declared = entries.some((entry) => entry.valueLevelSpec) + ? (declaredByName.get(name) ?? []) + : []; + const constructs: ValueDeclaration["construct"][] = []; + for (const entry of declared) { + if (!constructs.includes(entry.construct)) { + constructs.push(entry.construct); + } + } + if (!importCollision && constructs.length === 0) continue; + const message = + constructs.length === 0 + ? `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, and the language names no single binding, ` + + `so no chain rooted at it resolves; rename all but one binding ` + + `(SPEC 4, 2.1, 2.4, 4.5, 14.15)` + : `invalid import: the identifier ${JSON.stringify(name)} is ` + + `bound by ` + + (importCollision + ? `${String(statements.length)} imports` + : `a spec module import`) + + ` and by ` + + (constructs.length === 1 + ? `a value-level declaration` + : `${String(constructs.length)} value-level declarations`) + + ` of the same module scope — the language names no single ` + + `binding, so no chain rooted at it resolves; rename the import ` + + `binding or the declaration (SPEC 2.4, 4.5, 14.15)`; + this.findings.push( + locatedFinding(15, message, [ + ...statements.map((declaration) => ({ + file: this.file, + range: this.rangeOf(declaration), + })), + ...constructs.map((construct) => ({ + file: this.file, + range: this.collidingConstructRange(construct), + })), + ]), + ); + // SPEC 2.4, 4.5 root no chain at a colliding identifier — whether + // the collision is with another import or with a value-level + // declaration, whether or not either import is type-only, and + // whatever the imports' own validity: its chains report as + // unresolved (14.7) beside this 14.15, never masked by it. Its other + // uses are those of the bindings its spec module imports give it; a + // binding of no permitted form (14.15) roots chains as a default + // binding would. + const roles = entries.flatMap((entry) => + entry.spec && entry.role !== null ? [entry.role] : [], + ); + const text = roles.includes("text"); + const binding: CollidingBinding = { + kind: "colliding", + node: roles.includes("node") || !text, + text, + binders: + (importCollision + ? `${String(statements.length)} imports` + : `a spec module import`) + + (constructs.length === 0 + ? `` + : constructs.length === 1 + ? ` and a value-level declaration of the same module scope` + : ` and ${String(constructs.length)} value-level ` + + `declarations of the same module scope`), + }; + // Whichever symbol TypeScript resolves a use to — the first import's, + // a later colliding import's own, the import's merged with a + // declaration's, or the declaration's own export symbol — names the + // colliding identifier. for (const entry of entries) { - this.declarations.set(entry.declaration, { kind: "poisoned" }); + this.declarations.set(entry.declaration, binding); } + for (const entry of declared) { + this.declarations.set(entry.declaration, binding); + } + } + } + + /** + * SPEC 14, 1.7: the construct binding a colliding declaration's name — + * a variable declarator by its own characters, its name or binding + * pattern through its initializer, the enclosing statement excluded; a + * function, class, enum, or namespace declaration by its own + * characters, a decorator list included and a leading `export` or + * `export default`, with whatever separates it from the construct's + * first token, excluded — only what leads, so `@dec export class C {}` + * spans whole from its `@`. + */ + private collidingConstructRange( + construct: ValueDeclaration["construct"], + ): ByteRange { + if (ts.isVariableDeclaration(construct)) return this.rangeOf(construct); + const modifiers = construct.modifiers ?? []; + const first = modifiers.at(0); + if (first === undefined || first.kind !== ts.SyntaxKind.ExportKeyword) { + return this.rangeOf(construct); } + const second = modifiers.at(1); + const cut = + second !== undefined && second.kind === ts.SyntaxKind.DefaultKeyword + ? second + : first; + return { + start: this.offsets.byteOffset( + firstTokenStartAfter(construct, cut.end, this.sourceFile), + ), + end: this.offsets.byteOffset(construct.getEnd()), + }; } /** @@ -532,42 +1069,56 @@ 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; if (!ts.isStringLiteral(literal)) return; // grammar guarantees a literal - const specifier = literal.text; + // SPEC 2.4, 4: the specifier is read as spelled, no escape sequence + // interpreted — so whether it ends in `.xspec`, and what it designates, + // are judged over its characters as spelled. + const specifier = this.literalValue(literal); const spec = specifier.endsWith(XSPEC_SUFFIX); const clause = statement.importClause; - // Track every bound identifier for the collision rule (SPEC 4, 2.1). + // Track every bound identifier for the collision rules (SPEC 4, 2.1, + // 2.4): a binding introduced type-only is a type-level name (SPEC 4). + const track = ( + name: string, + declaration: tst.Node, + typeOnly: boolean, + role: "node" | "text" | null, + ): void => { + bound.push({ + name, + declaration, + spec, + valueLevelSpec: spec && !typeOnly, + role: spec ? role : null, + statement, + }); + }; if (clause !== undefined) { if (clause.name !== undefined) { - bound.push({ - name: clause.name.text, - declaration: clause, - spec, - statement, - }); + track(clause.name.text, clause, clause.isTypeOnly, "node"); } const named = clause.namedBindings; if (named !== undefined) { if (ts.isNamespaceImport(named)) { - bound.push({ - name: named.name.text, - declaration: named, - spec, - statement, - }); + track(named.name.text, named, clause.isTypeOnly, null); } else { for (const element of named.elements) { - bound.push({ - name: element.name.text, - declaration: element, - spec, - statement, - }); + const imported = this.importedName(element); + track( + element.name.text, + element, + clause.isTypeOnly || element.isTypeOnly, + imported === "text" + ? "text" + : imported === "default" + ? "node" + : null, + ); } } } @@ -588,24 +1139,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) { @@ -616,22 +1173,22 @@ class CodeAnalyzer { // each optionally aliased, optionally type-only. A side-effect-only // import binds nothing — no forbidden binding — and records nothing. const clauseTypeOnly = clause?.isTypeOnly === true; - let defaultBinding: CodeImportBinding | null = null; + const defaultBindings: CodeImportBinding[] = []; const textBindings: CodeImportBinding[] = []; /** Registered once validity is known: declaration → role. */ const roles: { - declaration: ts.Node; + declaration: tst.Node; binding: CodeImportBinding; role: "node" | "text"; }[] = []; if (clause !== undefined) { if (clause.name !== undefined) { - defaultBinding = { name: clause.name.text, typeOnly: clauseTypeOnly }; - roles.push({ - declaration: clause, - binding: defaultBinding, - role: "node", - }); + const binding: CodeImportBinding = { + name: clause.name.text, + typeOnly: clauseTypeOnly, + }; + defaultBindings.push(binding); + roles.push({ declaration: clause, binding, role: "node" }); } const named = clause.namedBindings; if (named !== undefined) { @@ -642,7 +1199,7 @@ class CodeAnalyzer { ); } else { for (const element of named.elements) { - const imported = (element.propertyName ?? element.name).text; + const imported = this.importedName(element); const binding: CodeImportBinding = { name: element.name.text, typeOnly: clauseTypeOnly || element.isTypeOnly, @@ -652,6 +1209,7 @@ class CodeAnalyzer { roles.push({ declaration: element, binding, role: "text" }); } else if (imported === "default") { // SPEC 4: the default export, aliased through the named form. + defaultBindings.push(binding); roles.push({ declaration: element, binding, role: "node" }); } else { defects.push( @@ -679,14 +1237,18 @@ class CodeAnalyzer { `"./NAME.xspec" (SPEC 4, 2.1, 14.15)`, ); } + // The declaration's index in `imports`, pushed below: the import whose + // binding a recorded occurrence uses (SPEC 6.5 "Import edits"). + const importIndex = this.imports.length; for (const { declaration, binding, role } of roles) { + this.importBindings.push({ name: binding.name, declaration }); this.declarations.set( declaration, - !valid || targetPath === null + !valid || target === null ? { kind: "poisoned" } : binding.typeOnly ? { kind: "type-level" } - : { kind: role, modulePath: targetPath }, + : { kind: role, target, importIndex }, ); } @@ -701,7 +1263,8 @@ class CodeAnalyzer { specifierQuote: quote === "'" ? "'" : '"', specifierRange: this.rangeOf(literal), targetPath: valid ? targetPath : null, - defaultBinding, + targetFile: valid ? targetFile : null, + defaultBindings, textBindings, valid, }); @@ -714,10 +1277,12 @@ 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)) { + // SPEC 2.4: the specifier is read as spelled. + const specifier = this.literalValue(literal); + if (specifier.endsWith(XSPEC_SUFFIX)) { this.addFinding( 15, statement, @@ -728,11 +1293,7 @@ class CodeAnalyzer { ); return; } - this.checkDerivedSpecifier( - literal.text, - statement, - "an export declaration", - ); + this.checkDerivedSpecifier(specifier, statement, "an export declaration"); } /** @@ -742,18 +1303,24 @@ 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; if (!ts.isExternalModuleReference(reference)) return; const expression = reference.expression; if (expression === undefined || !ts.isStringLiteral(expression)) return; - const spec = expression.text.endsWith(XSPEC_SUFFIX); + // SPEC 2.4: the specifier is read as spelled. + const specifier = this.literalValue(expression); + const spec = specifier.endsWith(XSPEC_SUFFIX); + // Not a spec module import (SPEC 4: only an import declaration is + // one), so no binding here is one a declaration collides with (2.4). bound.push({ name: statement.name.text, declaration: statement, spec, + valueLevelSpec: false, + role: null, statement, }); if (spec) { @@ -769,7 +1336,7 @@ class CodeAnalyzer { return; } this.checkDerivedSpecifier( - expression.text, + specifier, statement, "an import ... = require(...) declaration", ); @@ -784,10 +1351,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,19 +1393,21 @@ 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 => { - // SPEC 4.6: a named unit binds a name to *executable code*; ambient - // (`declare`) declarations bind none and enclose no statements. + // SPEC 4.6: a named unit binds a name to *executable code*; a + // declaration in an ambient context binds none and encloses no + // statements — whether a `declare` modifier introduces it or its file + // is a declaration file (below). const nowAmbient = ambient || hasModifier(node, ts.SyntaxKind.DeclareKeyword); let chain = enclosing; if (!nowAmbient) { - const name = unitName(node); + const name = unitName(node, this.sourceFile); if (name !== null) { chain = [...enclosing, name]; records.push({ @@ -845,8 +1421,12 @@ class CodeAnalyzer { visit(child, chain, nowAmbient); }); }; + // SPEC 4.6: a declaration file is ambient by kind — every declaration + // in it is in an ambient context, so the file binds no unit and + // occupies no document-order slot; its markers attribute to the file. + const ambientFile = isDeclarationFileName(this.path); ts.forEachChild(this.sourceFile, (child) => { - visit(child, [], false); + visit(child, [], ambientFile); }); records.sort((a, b) => a.start - b.start); const occurrences = new Map<string, number>(); @@ -858,15 +1438,97 @@ 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); } } - // -- value-level use analysis (SPEC 4.3, 4.5 → 14.8, 14.11, 14.18) -------- + /** + * 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 — by its own + * characters: a decorator list is part of the declaration it decorates, + * and a LEADING `export` or `export default`, with whatever separates it + * from the construct's first token, is excluded (`declarationRange`). + * 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 — only ever the merged declaration + * form (`export default function f() {}`), the declaration with its + * leading `export default ` excluded, since a function or class + * expression is exported only wrapped and so binds no unit (SPEC 4.6, + * `unitName`) — while the `default` unit an anonymous exported construct + * derives takes the whole export declaration, a decorator list preceding + * its `export` included, and for an exported arrow function (`export + * default () => {};`, the export assignment itself) its terminator too; + * 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.declarationRange(outer); + } + if ( + (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && + node.name !== undefined + ) { + return this.declarationRange(node); + } + // The anonymous merged default export (`export default function () {}`, + // `@dec export default class {}`) and an exported arrow function's + // export assignment (`export default () => {};`) are each the construct + // deriving the `default` unit: the whole export declaration, a spelled + // terminator included (SPEC 1.7). + return this.rangeOf(node); + } - private walk(node: ts.Node): void { + /** + * SPEC 1.7: a named declaration's own characters, a leading `export` or + * `export default` — and whatever separates it from the construct's + * first token — excluded. Only what LEADS is excluded: the modifier list + * (decorators included) comes in source order, so `export @dec class C + * {}` spans `@dec class C {}`, while `@dec export class C {}` and `@dec + * export default class C {}` span whole from their `@`, the `export` + * inside; a further modifier (`async`, `abstract`) is the construct's own. + */ + private declarationRange( + node: + tst.FunctionDeclaration | tst.ClassDeclaration | tst.ModuleDeclaration, + ): ByteRange { + const modifiers = node.modifiers ?? []; + let leading: tst.Node | undefined; + if (modifiers[0]?.kind === ts.SyntaxKind.ExportKeyword) { + leading = modifiers[0]; + if (modifiers[1]?.kind === ts.SyntaxKind.DefaultKeyword) { + leading = modifiers[1]; + } + } + const start = + leading === undefined + ? node.getStart(this.sourceFile) + : firstTokenStartAfter(node, leading.end, this.sourceFile); + return { + start: this.offsets.byteOffset(start), + end: this.offsets.byteOffset(node.getEnd()), + }; + } + + // -- value-level use analysis (SPEC 4.3, 4.5 → 14.7, 14.8, 14.18) --------- + + 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,10 +1579,12 @@ 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)) { + // SPEC 2.4: the static specifier is read as spelled. + const specifier = this.literalValue(argument); + if (specifier.endsWith(XSPEC_SUFFIX)) { this.addFinding( 15, call, @@ -930,7 +1594,7 @@ class CodeAnalyzer { ); return; } - this.checkDerivedSpecifier(argument.text, call, "a dynamic import()"); + this.checkDerivedSpecifier(specifier, call, "a dynamic import()"); } /** @@ -938,7 +1602,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 +1635,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 +1658,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 @@ -1003,6 +1667,22 @@ class CodeAnalyzer { // and is no condition; a poisoned root is masked by its 14.15. return; } + if (binding.kind === "colliding") { + // SPEC 2.4, 4.5: the uses of the bindings its spec module imports + // give it, naming no target — as a callee a `text` binding's where + // one binds the `text` export (a plain call, no `text` call); + // elsewhere a node binding's where one binds the default export (a + // marker's chain unresolved, 14.7). + const parent = identifier.parent; + const callee = + ts.isCallExpression(parent) && parent.expression === identifier; + if (binding.text && (callee || !binding.node)) { + this.visitTextBindingUse(identifier, null); + } else { + this.visitNodeBindingUse(identifier, binding); + } + return; + } if (binding.kind === "text") { this.visitTextBindingUse(identifier, binding); return; @@ -1015,11 +1695,12 @@ class CodeAnalyzer { * static chain in expression-statement position — or as the sole * argument of a call whose callee is a spec module's `text` export. * Everything else is 14.18 (or 14.8 for a non-static chain in marker - * position). + * position). A colliding identifier (SPEC 2.4, 4.5) names no target: + * its static chain in marker position is unresolved (14.7). */ private visitNodeBindingUse( - identifier: ts.Identifier, - binding: { readonly kind: "node"; readonly modulePath: string }, + identifier: tst.Identifier, + binding: Extract<TrackedBinding, { readonly kind: "node" | "colliding" }>, ): void { const use = climbUseExpression(identifier); const parent = use.parent; @@ -1029,14 +1710,47 @@ 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.kind === "colliding") { + // SPEC 2.4, 4.5 → 14.7: rooted at an identifier the language + // binds more than once, the chain names no target — no edge, no + // occurrence (5.7) — located as its occurrence would be, the + // bare chain exclusive of the terminator (SPEC 14). + this.addFinding( + 7, + use, + `unknown TypeScript reference: the marker's chain is rooted ` + + `at ${JSON.stringify(identifier.text)}, which ` + + `${binding.binders} each bind — the language names no ` + + `single binding, so the chain names no target; resolve the ` + + `collision (SPEC 2.4, 4.5, 14.7)`, + ); + } else if (binding.target.defined) { + this.references.push( + this.chainReference( + "references", + classified, + identifier, + binding.target.path, + binding.importIndex, + 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 +1785,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; } @@ -1089,23 +1803,30 @@ class CodeAnalyzer { const binding = this.bindingOfSymbol( this.checker.getSymbolAtLocation(callee), ); - // A poisoned callee masks its arguments too: the import's 14.15 - // already accounts for the whole call (SPEC 14). + // A poisoned callee — an invalid import's binding — masks its + // arguments too: the import's 14.15 already accounts for the whole + // call (SPEC 14). A colliding callee's call is no `text` call (SPEC + // 4.5): its arguments are ordinary uses. return binding?.kind === "text" || binding?.kind === "poisoned"; } /** * SPEC 4.5: a `text` binding appears only as the callee of a call; * that call is an ordinary expression, valid in expression-statement - * position too, recording its `embeds` edge (4.3) — never a marker. + * position too, recording its `embeds` edge (4.3) — never a marker. A + * null `binding` is a colliding identifier's (SPEC 2.4, 4.5): a call + * through it is no spec module's `text` call — no edge, no occurrence + * (5.7), no condition of a `text` call (7, 8, 11) — and the walk visits + * its arguments as those of any other call, where a spec module binding + * or node is used outside the sanctioned uses (14.18). */ private visitTextBindingUse( - identifier: ts.Identifier, - binding: { readonly kind: "text"; readonly modulePath: string }, + identifier: tst.Identifier, + binding: TextBinding | null, ): void { const parent = identifier.parent; if (ts.isCallExpression(parent) && parent.expression === identifier) { - this.analyzeTextCall(parent, binding); + if (binding !== null) this.analyzeTextCall(parent, identifier, binding); return; } this.addFinding( @@ -1120,12 +1841,21 @@ class CodeAnalyzer { /** * One `text(...)` call (SPEC 4.3, 4.5): exactly one argument, a static * property chain rooted at a spec module import binding; the string - * form is MDX-only (4.3 → 14.8); a cross-module node is 14.11 (4.4). + * form is MDX-only (4.3 → 14.8); a cross-module node is recorded with + * its called module, resolution reporting 14.11 (4.4, graph.ts). + * `calleeIdentifier` is the call's callee and `callee` the `text` + * binding it resolves to. SPEC 14: every condition of the call as a + * reference spelling — unresolved (14.7), non-static or of wrong arity + * (14.8), cross-module (14.11) — is located by the span its occurrence + * occupies or would occupy (5.7), the entire call expression, callee + * through closing parenthesis, whichever part of it offends. */ private analyzeTextCall( - call: ts.CallExpression, - calleeBinding: { readonly kind: "text"; readonly modulePath: string }, + call: tst.CallExpression, + calleeIdentifier: tst.Identifier, + callee: TextBinding, ): void { + const calleeTarget = callee.target; if (call.questionDotToken !== undefined) { this.addFinding( 8, @@ -1156,10 +1886,13 @@ class CodeAnalyzer { return; } const argument = call.arguments[0]; + // SPEC 14, 5.7: the 14.8 findings below, and the undefined-member 14.7 + // past them, locate the call — callee through closing parenthesis — + // never the argument alone. if (ts.isSpreadElement(argument)) { this.addFinding( 8, - argument, + call, `invalid argument: a spread element is not a static reference ` + `(SPEC 2.4, 14.8)`, ); @@ -1169,7 +1902,7 @@ class CodeAnalyzer { // SPEC 4.3: the string form of text(...) is MDX-only. this.addFinding( 8, - argument, + call, `invalid argument: a string argument to text(...) in a TypeScript ` + `file — the string form is MDX-only; pass the node itself ` + `(SPEC 4.3, 14.8)`, @@ -1182,7 +1915,7 @@ class CodeAnalyzer { ) { this.addFinding( 8, - argument, + call, `invalid argument: a template literal is not a static string ` + `literal, and the string form of text(...) is MDX-only anyway ` + `(SPEC 2.4, 4.3, 14.8)`, @@ -1195,6 +1928,7 @@ class CodeAnalyzer { ? undefined : this.bindingOfSymbol(this.checker.getSymbolAtLocation(root)); if ( + root === null || rootBinding === undefined || rootBinding.kind === "type-level" || rootBinding.kind === "poisoned" @@ -1206,14 +1940,18 @@ class CodeAnalyzer { // TypeScript error against the branded signature (SPEC 4.4). return; } - if (rootBinding.kind === "text") { - this.addFinding( - 18, - argument, - `unsupported node usage: a spec module "text" binding is passed ` + - `as a value — it appears only as the callee of a text(...) call ` + - `(SPEC 4.5, 14.18)`, - ); + if ( + rootBinding.kind === "text" || + (rootBinding.kind === "colliding" && !rootBinding.node) + ) { + // SPEC 4.5: a `text` binding appears only as a `text` call's callee, + // so an argument rooted at one — directly, or through the accesses + // and wrappers `leftmostIdentifier` climbs — names no node and the + // call records nothing. Its use is 14.18, reported once, by the walk + // (visitIdentifier → visitTextBindingUse), which reaches every such + // root: SPEC 14 locates it at the binding's identifier alone — no + // property chain extends a `text` binding's spelling — never at the + // whole argument, and never twice. return; } const classified = classifyReference(argument, this.sourceFile); @@ -1224,34 +1962,81 @@ class CodeAnalyzer { : "it is not a bare property chain"; this.addFinding( 8, - argument, + call, `invalid argument: ${reason} — the text(...) argument must be a ` + `static property chain rooted at a spec module import binding ` + `(SPEC 4.5, 2.4, 14.8)`, ); return; } - if (rootBinding.modulePath !== calleeBinding.modulePath) { - // SPEC 4.4 → 14.11: a node passed to another module's text export. + if (rootBinding.kind === "colliding") { + // SPEC 2.4, 4.5 → 14.7: a chain rooted at an identifier the + // language binds more than once names no target — no edge, no + // occurrence (5.7) — so the call is located as its occurrence would + // be, callee through closing parenthesis (SPEC 14); needing a + // resolving argument, it is never 14.11 (SPEC 14.11). + this.addFinding( + 7, + call, + `unknown TypeScript reference: the text(...) argument is rooted ` + + `at ${JSON.stringify(classified.rootName)}, which ` + + `${rootBinding.binders} each bind — the language names no ` + + `single binding, so the chain names no target; resolve the ` + + `collision (SPEC 2.4, 4.5, 14.7)`, + ); + 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 call, callee + // through closing parenthesis, as the call's would-be occurrence + // does and an unresolved defined-member argument's finding does + // (SPEC 14, 5.7). It is condition 7 alone whatever module the + // callee's `text` export belongs to: 14.11 needs an argument that + // resolves. this.addFinding( - 11, + 7, 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)`, + `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.4 → 14.11: a node passed to another module's `text` export + // — modules compared as their files, byte-exact (SPEC 12.0). The + // condition needs a node, an argument that resolves (11.2), which + // resolution alone decides (graph.ts): the call is recorded like any + // other, its called module beside it — the foreign module, identity + // data on the finding rather than a further location (SPEC 14, + // 12.7) — so its edge and occurrence stand beside its 14.11 (5.7), + // while an argument that does not resolve is 14.7 alone (14.11). + const calledModule: CalledModule | null = + moduleTargetKey(rootBinding.target) === moduleTargetKey(calleeTarget) + ? null + : { + identity: calleeTarget.defined ? calleeTarget.path : null, + display: moduleTargetDisplay(calleeTarget), + }; // 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, + root, + rootBinding.target.path, + rootBinding.importIndex, this.attributionOf(call), + this.rangeOf(call), + calleeIdentifier, + calledModule, + callee.importIndex, ), ); } @@ -1262,15 +2047,296 @@ 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) ); } +/** + * SPEC 2.4: every non-import declaration binding a name at value level in + * the module scope, in document order — each name a variable declarator's + * name or binding pattern binds, a named function, class, or enum + * declaration, and a namespace declaration binding a value. A `var` in a + * nested statement binds in the module scope too (JavaScript's hoisting); + * a block-scoped declaration there binds in an inner scope, which shadows + * instead (4.5), as does anything inside a function, class, or namespace. + * Type-level declarations (interfaces, type aliases, namespaces binding no + * value) bind nothing here. + */ +function moduleValueDeclarations( + sourceFile: tst.SourceFile, +): ValueDeclaration[] { + const found: ValueDeclaration[] = []; + const declarators = (list: tst.VariableDeclarationList): void => { + for (const declarator of list.declarations) { + const names: [string, tst.Node][] = []; + bindingNames(declarator.name, declarator, names); + for (const [name, declaration] of names) { + found.push({ name, declaration, construct: declarator }); + } + } + }; + const isVar = (list: tst.VariableDeclarationList): boolean => + (list.flags & ts.NodeFlags.BlockScoped) === 0; + const hoisted = (statement: tst.Statement | undefined): void => { + if (statement === undefined) return; + if (ts.isVariableStatement(statement)) { + if (isVar(statement.declarationList)) { + declarators(statement.declarationList); + } + } else if (ts.isBlock(statement)) { + for (const inner of statement.statements) hoisted(inner); + } else if (ts.isIfStatement(statement)) { + hoisted(statement.thenStatement); + hoisted(statement.elseStatement); + } else if ( + ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) + ) { + const initializer = statement.initializer; + if ( + initializer !== undefined && + ts.isVariableDeclarationList(initializer) && + isVar(initializer) + ) { + declarators(initializer); + } + hoisted(statement.statement); + } else if ( + ts.isWhileStatement(statement) || + ts.isDoStatement(statement) || + ts.isLabeledStatement(statement) || + ts.isWithStatement(statement) + ) { + hoisted(statement.statement); + } else if (ts.isTryStatement(statement)) { + hoisted(statement.tryBlock); + hoisted(statement.catchClause?.block); + hoisted(statement.finallyBlock); + } else if (ts.isSwitchStatement(statement)) { + for (const clause of statement.caseBlock.clauses) { + for (const inner of clause.statements) hoisted(inner); + } + } + }; + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement)) { + declarators(statement.declarationList); + } else if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement)) && + statement.name !== undefined + ) { + found.push({ + name: statement.name.text, + declaration: statement, + construct: statement, + }); + } else if (ts.isEnumDeclaration(statement)) { + found.push({ + name: statement.name.text, + declaration: statement, + construct: statement, + }); + } else if (ts.isModuleDeclaration(statement)) { + // `declare module "m"` names a module, and `declare global` binds + // in the global scope — neither binds a module-scope name. + if ( + ts.isIdentifier(statement.name) && + (statement.flags & ts.NodeFlags.GlobalAugmentation) === 0 && + namespaceBindsValue(statement, new Map()) + ) { + found.push({ + name: statement.name.text, + declaration: statement, + construct: statement, + }); + } + } else { + hoisted(statement); + } + } + return found; +} + +/** + * Every identifier a binding name binds, with the declaration node + * TypeScript binds it to: the declarator for a plain name, the binding + * element for a name inside a binding pattern. + */ +function bindingNames( + name: tst.BindingName, + declaration: tst.Node, + into: [string, tst.Node][], +): void { + if (ts.isIdentifier(name)) { + into.push([name.text, declaration]); + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) continue; + bindingNames(element.name, element, into); + } +} + +/** + * Every identifier the file spells, as the language reads it (SPEC 6.5 + * "Import edits": the names an added import's fresh identifiers avoid — + * `CodeAnalysis.spelledNames`). Every binding name is an identifier the + * tree holds, whatever its scope or declaration kind, so collecting every + * identifier node covers them all. The traversal keeps its own stack: a + * nesting the parser builds iteratively (a long chain of array-type + * suffixes or binary operators) must not exhaust the call stack here. + */ +function spelledIdentifierNames(sourceFile: tst.SourceFile): Set<string> { + const names = new Set<string>(); + const pending: tst.Node[] = [sourceFile]; + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + if (ts.isIdentifier(node)) { + names.add(node.text); + continue; + } + // The callback returns nothing: a truthy result would end the visit. + ts.forEachChild(node, (child) => { + pending.push(child); + }); + } + return names; +} + +/** Each judged namespace's verdict; null while it is under judgement. */ +type NamespaceJudgements = Map<tst.Node, boolean | null>; + +/** + * SPEC 2.4: whether a namespace declaration binds a value — TypeScript's + * own instantiation rule: a namespace binds none when its body holds only + * type-level members — interfaces, type aliases, import aliases not + * exported, namespaces binding none, and export lists naming only such + * members — and binds one otherwise, a const enum included, as TypeScript + * binds it. `judged` memoizes each namespace's verdict and breaks a cycle + * through export lists: a namespace under judgement (null) counts as + * binding none, as TypeScript counts it. + */ +function namespaceBindsValue( + declaration: tst.ModuleDeclaration, + judged: NamespaceJudgements, +): boolean { + const known = judged.get(declaration); + if (known !== undefined) return known ?? false; + judged.set(declaration, null); + const body = declaration.body; + const binds = + body === undefined + ? true + : ts.isModuleDeclaration(body) + ? namespaceBindsValue(body, judged) + : ts.isModuleBlock(body) + ? body.statements.some((statement) => + statementBindsValue(statement, judged), + ) + : true; + judged.set(declaration, binds); + return binds; +} + +/** One namespace member under TypeScript's instantiation rule (above). */ +function statementBindsValue( + statement: tst.Statement, + judged: NamespaceJudgements, +): boolean { + if ( + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) + ) { + return false; + } + if ( + (ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement)) && + !hasModifier(statement, ts.SyntaxKind.ExportKeyword) + ) { + return false; + } + if (ts.isModuleDeclaration(statement)) { + return namespaceBindsValue(statement, judged); + } + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier === undefined && + statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause) + ) { + return statement.exportClause.elements.some((specifier) => + exportedMemberBindsValue(specifier, judged), + ); + } + return true; +} + +/** + * Whether the local an export list names binds a value: the innermost + * enclosing statement list declaring the name decides — an import alias + * counting as a value, as TypeScript counts it — and a name no enclosing + * list declares may be a value. + */ +function exportedMemberBindsValue( + specifier: tst.ExportSpecifier, + judged: NamespaceJudgements, +): boolean { + const name = specifier.propertyName ?? specifier.name; + if (!ts.isIdentifier(name)) return true; + for ( + let scope: tst.Node | undefined = specifier.parent; + scope !== undefined; + scope = scope.parent + ) { + if ( + !ts.isBlock(scope) && + !ts.isModuleBlock(scope) && + !ts.isSourceFile(scope) + ) { + continue; + } + let declared = false; + for (const statement of scope.statements) { + if (!statementDeclaresName(statement, name.text)) continue; + if ( + ts.isImportEqualsDeclaration(statement) || + statementBindsValue(statement, judged) + ) { + return true; + } + declared = true; + } + if (declared) return false; + } + return true; +} + +/** Whether a statement is a declaration naming `name` (TypeScript's test). */ +function statementDeclaresName( + statement: tst.Statement, + name: string, +): boolean { + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.some( + (declarator) => + ts.isIdentifier(declarator.name) && declarator.name.text === name, + ); + } + const declared = (statement as { readonly name?: tst.Node }).name; + return ( + declared !== undefined && + ts.isIdentifier(declared) && + declared.text === name + ); +} + /** 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,32 +2344,138 @@ function isFunctionOrClassExpression(expression: ts.Expression): boolean { ); } -function stripParentheses(expression: ts.Expression): ts.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` or `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 decorator or modifier following the prefix (`@dec`, + * `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: whether a constructor declaration's name is the plain + * identifier `constructor` — the keyword token spelled exactly so. The + * declaration carries no name node, so its token is found among its + * children, past any JSDoc and the modifier list. TypeScript also reads a + * string-literal `"constructor"` member as the constructor: a + * string-literal member name, which binds no unit. A name spelled with an + * escape sequence would bind none either (2.4), but TypeScript's parser + * rejects that spelling of the keyword, so its file is unparseable (14.20) + * and the spelling comparison is only defensive. + */ +function constructorNameIsPlain( + node: tst.ConstructorDeclaration, + sourceFile: tst.SourceFile, +): boolean { + for (const child of node.getChildren(sourceFile)) { + if (child.kind === ts.SyntaxKind.StringLiteral) return false; + if (child.kind === ts.SyntaxKind.ConstructorKeyword) { + const start = child.getStart(sourceFile); + return sourceFile.text.slice(start, child.getEnd()) === "constructor"; + } + } + return false; // defensive: every constructor spells its name token +} + +/** + * SPEC 4.6, 2.4: a unit's name is a plain identifier — spelled without + * escape sequences, read as spelled. TypeScript's `Identifier.text` is + * cooked (`f\u006Fo` reads `foo`), so the name is plain exactly when its + * source characters equal that text; an escape-spelled name binds no unit + * (null), never the unit its interpreted name would spell. + */ +function plainName( + name: tst.Identifier, + sourceFile: tst.SourceFile, +): string | null { + const spelled = sourceFile.text.slice(name.getStart(sourceFile), name.end); + return spelled === name.text ? spelled : null; +} + +/** + * SPEC 4.6: whether a code source is a declaration file, ambient by kind — + * by TypeScript's file-name rule, a name ending in `.d.mts` or `.d.cts`, + * or ending in `.ts` with `.d.` earlier in its last path segment (`.d.ts`, + * `.d.css.ts`; never `x.dts.ts`). A path's segments are its `/`-joined + * directory-entry names (SPEC 7), so the rule reads the last one; the + * comparisons are case-sensitive, as TypeScript's are. + */ +function isDeclarationFileName(path: string): boolean { + const segment = path.slice(path.lastIndexOf("/") + 1); + return ( + segment.endsWith(".d.mts") || + segment.endsWith(".d.cts") || + (segment.endsWith(".ts") && segment.includes(".d.")) + ); } /** * 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 * exact: a function declaration; a class declaration; a class member with - * a non-computed identifier name (a method, getter, setter, or a property - * whose initializer is a function, arrow, or class expression); a - * variable declaration with a plain identifier name and such an - * initializer; a namespace declaration (`namespace A.B` nests one - * declaration per name in the AST already); or a default export, named - * `default` when the exported construct is anonymous. Signature-only - * declarations (overloads, abstract members) bind no executable code. + * a non-computed identifier name (a constructor — a unit named + * `constructor` — a method, getter, setter, or a property whose + * initializer is a function, arrow, or class expression); a variable + * declaration with a plain identifier name and such an initializer; a + * namespace declaration (`namespace A.B` nests one declaration per name in + * the AST already); or a default export, named `default` when the exported + * construct is anonymous. Each construct is read by its own form: an + * initializer or exported expression that merely wraps one of these forms + * (parenthesized, cast, `satisfies`-qualified, non-null-asserted) binds no + * unit. Every name is plain, spelled without escape sequences + * (`plainName`): an escape-spelled one binds no unit. + * Signature-only declarations (overloads, body-less methods) and abstract + * members, whatever they spell, bind no executable code, and neither does + * an ambient declaration, which the caller never asks about + * (`collectUnits`). */ -function unitName(node: ts.Node): string | null { +function unitName(node: tst.Node, sourceFile: tst.SourceFile): string | null { + // SPEC 4.6: an abstract member binds no executable code and is no unit, + // occupying no document-order slot, read by its own form — even where it + // spells a body (`abstract m(): void {}`), a function-valued initializer + // (`abstract p = () => {}`), or is a constructor (`abstract + // constructor() {}`): TypeScript rejects each only in its post-parse + // grammar checks, which leave the file well-formed (14.20). A class's own + // `abstract` (`abstract class A`) marks no member: the class is a unit. + if ( + ts.isClassLike(node.parent) && + hasModifier(node, ts.SyntaxKind.AbstractKeyword) + ) { + return null; + } + if (ts.isConstructorDeclaration(node)) { + // SPEC 4.6: a constructor is a class member unit named `constructor` + // (`path#C.constructor`) — its implementation alone, an overload + // signature binding no executable code. + if (!ts.isClassLike(node.parent) || node.body === undefined) return null; + return constructorNameIsPlain(node, sourceFile) ? "constructor" : null; + } if (ts.isFunctionDeclaration(node)) { if (node.body === undefined) return null; - if (node.name !== undefined) return node.name.text; + if (node.name !== undefined) return plainName(node.name, sourceFile); return hasModifier(node, ts.SyntaxKind.DefaultKeyword) ? "default" : null; } if (ts.isClassDeclaration(node)) { - if (node.name !== undefined) return node.name.text; + if (node.name !== undefined) return plainName(node.name, sourceFile); return hasModifier(node, ts.SyntaxKind.DefaultKeyword) ? "default" : null; } if ( @@ -1313,34 +2485,36 @@ function unitName(node: ts.Node): string | null { ) { if (!ts.isClassLike(node.parent)) return null; // class members only if (node.body === undefined) return null; - return ts.isIdentifier(node.name) ? node.name.text : null; + return ts.isIdentifier(node.name) ? plainName(node.name, sourceFile) : null; } if (ts.isPropertyDeclaration(node)) { if (!ts.isIdentifier(node.name)) return null; const initializer = node.initializer; return initializer !== undefined && isFunctionOrClassExpression(initializer) - ? node.name.text + ? plainName(node.name, sourceFile) : null; } if (ts.isVariableDeclaration(node)) { if (!ts.isIdentifier(node.name)) return null; const initializer = node.initializer; return initializer !== undefined && isFunctionOrClassExpression(initializer) - ? node.name.text + ? plainName(node.name, sourceFile) : null; } if (ts.isModuleDeclaration(node)) { - return ts.isIdentifier(node.name) ? node.name.text : null; + return ts.isIdentifier(node.name) ? plainName(node.name, sourceFile) : null; } if (ts.isExportAssignment(node) && node.isExportEquals !== true) { - const expression = stripParentheses(node.expression); - if ( - ts.isFunctionExpression(expression) || - ts.isClassExpression(expression) - ) { - return expression.name !== undefined ? expression.name.text : "default"; - } - return ts.isArrowFunction(expression) ? "default" : null; + // SPEC 4.6: the exported expression is read by its own form, as + // spelled — one that merely wraps a function, arrow, or class + // (parenthesized, `as`-cast, `satisfies`-qualified, non-null-asserted) + // is another expression and binds no unit. TypeScript parses an + // unwrapped `export default function …` or `export default class …` as + // a declaration (the branches above), so a function or class + // expression reaches an export assignment only wrapped: the one + // construct left binding a unit here is an unwrapped arrow function, + // always anonymous and so named `default`. + return ts.isArrowFunction(node.expression) ? "default" : null; } return null; } @@ -1350,7 +2524,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 +2590,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 +2617,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 +2642,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-data.ts b/src/core/config-data.ts index fe8dca88..dede775e 100644 --- a/src/core/config-data.ts +++ b/src/core/config-data.ts @@ -13,7 +13,12 @@ // policy `files` selector in capture-from mode for `from`, capture-to for // `to` — core/config.ts). Anything structurally off — or any pattern that // no longer compiles — yields null, and the caller falls back to the full -// parse; the fast path treats null as "no recorded parse". +// parse; the fast path treats null as "no recorded parse". A recorded tag +// or kind list must also be in the set form the parser now produces +// (SPEC 7.4, 7.5, 12.7: tag sets in byte order, kind sets in 5.2's order, +// duplicates collapsed): a record written in any other form is not what +// parsing these bytes yields, so it too falls back, and the full path's +// compare-and-refresh replaces it (SPEC 13.3). // // Pure core (IMPLEMENTATION Architecture): data in, data out, no I/O — and // deliberately free of core/config.ts value imports, so loading this module @@ -29,6 +34,8 @@ import type { PolicyRule, PolicySelector, } from "./config.js"; +import { isByteOrderedSet } from "./bytes.js"; +import { outDirSpellingProblem } from "./discovery.js"; import type { GlobMode } from "./glob.js"; import { compileGlob } from "./glob.js"; @@ -112,21 +119,39 @@ function parseStringArray(value: unknown): string[] | null { return out; } -const EDGE_KIND_VALUES: ReadonlySet<string> = new Set([ - "depends", - "embeds", - "references", -]); +/** SPEC 5.2: the dependency edge kinds, in the order 5.2 lists them. */ +const EDGE_KIND_ORDER: readonly string[] = ["depends", "embeds", "references"]; +/** + * A recorded kind list (SPEC 7.4 `edgeKinds`, 7.5 rule `kinds`): non-empty + * and in its 12.7 kind-set form — dependency kinds only, strictly in 5.2's + * order, so none repeats. + */ function parseEdgeKinds(value: unknown): DependencyEdgeKind[] | null { const items = parseStringArray(value); if (items === null || items.length === 0) return null; + let previous = -1; for (const item of items) { - if (!EDGE_KIND_VALUES.has(item)) return null; + const position = EDGE_KIND_ORDER.indexOf(item); + if (position <= previous) return null; + previous = position; } return items as DependencyEdgeKind[]; } +/** + * A recorded tag list (SPEC 7.4 `targetTags`, 7.5 selector `tags`): + * non-empty and in its 12.7 tag-set form — strictly ascending byte order, + * so none repeats. + */ +function parseTagSet(value: unknown): string[] | null { + const items = parseStringArray(value); + if (items === null || items.length === 0 || !isByteOrderedSet(items)) { + return null; + } + return items; +} + function parseStoredGroup(value: unknown): StoredGroup | null { if (!isRecord(value)) return null; const name = value["name"]; @@ -171,8 +196,8 @@ function selectorFromStored( return { selector: "files", pattern, glob: compiled.glob }; } case "tags": { - const tags = parseStringArray(value["tags"]); - if (tags === null || tags.length === 0) return null; + const tags = parseTagSet(value["tags"]); + if (tags === null) return null; return { selector: "tags", tags }; } default: @@ -203,8 +228,8 @@ function coverageFromStored(value: unknown): CoverageProfile | null { } let targetTags: readonly string[] | undefined; if (targetTagsRaw !== null) { - const parsed = parseStringArray(targetTagsRaw); - if (parsed === null || parsed.length === 0) return null; + const parsed = parseTagSet(targetTagsRaw); + if (parsed === null) return null; targetTags = parsed; } return { @@ -278,6 +303,11 @@ export function configurationFromStored(value: unknown): Configuration | null { const outDir = markdownRaw["outDir"]; if (typeof emit !== "boolean") return null; if (outDir !== null && typeof outDir !== "string") return null; + // SPEC 7.3, 14.14: a recorded `outDir` the parser refuses is not what + // parsing these bytes yields — fall back, so the full parse reports it. + if (outDir !== null && outDirSpellingProblem(outDir) !== null) { + return null; + } markdown = { emit, ...(outDir === null ? {} : { outDir }) }; } diff --git a/src/core/config.ts b/src/core/config.ts index 084ea6d6..b7359bbf 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -7,7 +7,10 @@ // 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. IMPLEMENTATION (Key libraries): the file is parsed with +// computed value. Every string literal it holds, and every key, is read as +// spelled (SPEC 2.4: "every configuration literal (7)"; SPEC 7: a key is +// repeated when two keys spell the same name) — no escape sequence is +// interpreted. IMPLEMENTATION (Key libraries): the file is parsed with // the TypeScript compiler API as an AST reduced to data, never executed or // imported. A file that is not well-formed TypeScript or does not conform, // and every schema violation of SPEC 7.1–7.5, is a configuration error @@ -18,10 +21,15 @@ // `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 { byteOrderedSet } from "./bytes.js"; +import { outDirSpellingProblem } from "./discovery.js"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; import type { CompiledGlob } from "./glob.js"; import { compileGlob, unboundToCaptures } from "./glob.js"; +import { identifierSpelling, stringLiteralValue } from "./references.js"; // --------------------------------------------------------------------------- // The validated configuration model @@ -37,6 +45,17 @@ export const DEPENDENCY_EDGE_KINDS: readonly DependencyEdgeKind[] = [ "references", ]; +/** + * The set form of a dependency-kind list (SPEC 12.7's kind-set value form): + * each distinct kind once, in the order SPEC 5.2 lists them — `"depends"`, + * `"embeds"`, `"references"` — however the list was spelled. + */ +export function dependencyKindSet( + kinds: readonly DependencyEdgeKind[], +): DependencyEdgeKind[] { + return DEPENDENCY_EDGE_KINDS.filter((kind) => kinds.includes(kind)); +} + /** One configured spec or code group (SPEC 7.1, 7.2): a named glob list. */ export interface ConfiguredGroup { readonly name: string; @@ -61,7 +80,11 @@ export interface CoverageProfile { readonly name: string; /** Spec group whose requirements must be covered (SPEC 7.4). */ readonly target: string; - /** When present, restricts the target set by tags (SPEC 7.4); never empty. */ + /** + * When present, restricts the target set by tags (SPEC 7.4); never empty. + * Read as a set (SPEC 7.4): held in its 12.7 tag-set form — byte order, + * duplicates collapsed. + */ readonly targetTags?: readonly string[]; /** SPEC 7.4: default `"leaves"`. */ readonly targets: "leaves" | "all"; @@ -69,7 +92,11 @@ export interface CoverageProfile { /** Resolved kind: inferred when unambiguous, else as given (SPEC 7.4). */ readonly boundaryKind: "spec" | "code"; readonly mode: "direct" | "transitive"; - /** SPEC 7.4: defaults to all three dependency edge kinds; never empty. */ + /** + * SPEC 7.4: defaults to all three dependency edge kinds; never empty. + * Read as a set (SPEC 7.4): held in its 12.7 kind-set form — 5.2's order, + * duplicates collapsed. + */ readonly edgeKinds: readonly DependencyEdgeKind[]; } @@ -93,7 +120,11 @@ export type PolicySelector = } | { readonly selector: "tags"; - /** Matching means carrying at least one listed tag (SPEC 7.5); never empty. */ + /** + * Matching means carrying at least one listed tag (SPEC 7.5); never + * empty. Read as a set (SPEC 7.4, 7.5): held in its 12.7 tag-set + * form — byte order, duplicates collapsed. + */ readonly tags: readonly string[]; }; @@ -103,7 +134,11 @@ export interface PolicyRule { readonly type: "forbidden" | "allowedOnly"; readonly from: PolicySelector; readonly to: PolicySelector; - /** SPEC 7.5: defaults to all three dependency edge kinds; never empty. */ + /** + * SPEC 7.5: defaults to all three dependency edge kinds; never empty. + * Read as a set (SPEC 7.4): held in its 12.7 kind-set form — 5.2's order, + * duplicates collapsed. + */ readonly kinds: readonly DependencyEdgeKind[]; } @@ -141,7 +176,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 +192,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 +245,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,15 +287,22 @@ 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; const specifier = decl.moduleSpecifier; - if (!ts.isStringLiteral(specifier) || specifier.text !== "xspec") { + // SPEC 2.4: the specifier is a configuration literal, its value the + // characters between its delimiters as spelled — an escape-spelled + // "xspec" is some other specifier. + if ( + !ts.isStringLiteral(specifier) || + stringLiteralValue(specifier, sourceFile) !== "xspec" + ) { findings.add( - `the import must be from the module specifier "xspec" (SPEC 7)`, + `the import must be from the module specifier "xspec", read as ` + + `spelled with no escape sequence interpreted (SPEC 7, 2.4)`, lineOf(specifier, sourceFile), ); ok = false; @@ -323,7 +368,13 @@ function checkImport( ); ok = false; } - const importedName = (element.propertyName ?? element.name).text; + // SPEC 2.4: a string-literal export name is a configuration literal, read + // as spelled; an identifier names the export the language reads it as + // (which binding an import makes is the language's question). + const exported = element.propertyName ?? element.name; + const importedName = ts.isStringLiteral(exported) + ? stringLiteralValue(exported, sourceFile) + : exported.text; if (importedName !== "defineConfig") { findings.add( `the import must bind defineConfig (found "${importedName}") (SPEC 7)`, @@ -339,11 +390,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 +479,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 +509,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); @@ -481,8 +532,15 @@ function reduceLiteral( } const name = property.name; let key: string; - if (ts.isIdentifier(name) || ts.isStringLiteral(name)) { - key = name.text; + // SPEC 7, 2.4: a key is the name it spells — an identifier key's own + // characters, a string-literal key's characters between its + // delimiters, no escape sequence interpreted — so an identifier key + // and a string-literal key spelling the same name repeat one key, + // and an escape-spelled key names what it spells. + if (ts.isIdentifier(name)) { + key = identifierSpelling(name, sourceFile); + } else if (ts.isStringLiteral(name)) { + key = stringLiteralValue(name, sourceFile); } else { findings.add( `${LITERAL_EXPECTATION}; object keys must be non-computed ` + @@ -534,8 +592,16 @@ function reduceLiteral( } if (ts.isStringLiteral(expr)) { // SPEC 2.4: a static string literal is a plain single- or double-quoted - // string; both parse as StringLiteral. Template literals do not. - return { kind: "string", value: expr.text, line }; + // string; both parse as StringLiteral. Template literals do not. Its + // value is the characters between its delimiters exactly as spelled — + // a configuration literal (7) like every other — so a glob or name + // spelled with an escape sequence carries the escape's characters, and + // whatever rule they then break decides the outcome. + return { + kind: "string", + value: stringLiteralValue(expr, sourceFile), + line, + }; } if (expr.kind === ts.SyntaxKind.TrueKeyword) { return { kind: "boolean", value: true, line }; @@ -590,6 +656,40 @@ function requireString( return { value: node.value, line: node.line }; } +/** U+FFFD (REPLACEMENT CHARACTER), which no argument value carries (12.0). */ +const REPLACEMENT_CHARACTER = String.fromCodePoint(0xfffd); + +/** + * SPEC 7, 14.14: a group, profile, or rule name is non-empty — `""` is a + * configuration error — and contains no U+FFFD, since no argument value + * carries the character (12.0) and configured names are named in + * arguments. `name` is the name as spelled (a key's or a string literal's + * characters, no escape interpreted, 2.4), so only the encoded character + * counts. Reports the violation, if any, at `line`. + */ +function checkConfiguredName( + name: string, + what: "group" | "profile" | "rule", + where: string, + line: number | undefined, + findings: ConfigFindings, +): void { + if (name.length === 0) { + findings.add( + `${where}: the ${what} name is empty ("") — group, profile, and ` + + `rule names are non-empty (SPEC 7, 14.14)`, + line, + ); + } else if (name.includes(REPLACEMENT_CHARACTER)) { + findings.add( + `${where}: the ${what} name "${name}" contains U+FFFD (REPLACEMENT ` + + `CHARACTER) — no argument value carries the character, and ` + + `configured names are named in arguments (SPEC 7, 12.0, 14.14)`, + line, + ); + } +} + /** * Optional enumerated string field; reports invalid values. Returns * undefined when absent or invalid. @@ -618,7 +718,10 @@ function optionalEnum<T extends string>( /** * Optional tag-list field (SPEC 7.4 `targetTags`, 7.5 selector `tags`): a - * list of strings; an empty list is a configuration error (14.14). + * list of strings; an empty list is a configuration error (14.14). The list + * is read as a set (SPEC 7.4, 7.5) — a repeated element collapses, as on a + * list-valued flag (11.1) — and returned in its 12.7 tag-set form, byte + * order (SPEC 12.0), so every consumer and every output sees the set. */ function optionalTagList( object: ObjectNode, @@ -649,14 +752,16 @@ function optionalTagList( } tags.push(element.value); } - return tags; + return byteOrderedSet(tags); } /** * Optional dependency-edge-kind list (SPEC 7.4 `edgeKinds`, 7.5 rule * `kinds`): a subset of depends/embeds/references; empty is a configuration * error (14.14). Returns undefined when absent (caller applies the - * all-three default) or invalid. + * all-three default) or invalid. The list is read as a set (SPEC 7.4) and + * returned in its 12.7 kind-set form: 5.2's order, duplicates collapsed, + * however it was spelled. */ function optionalKindList( object: ObjectNode, @@ -692,9 +797,9 @@ function optionalKindList( ); continue; } - if (!kinds.includes(element.value)) kinds.push(element.value); + kinds.push(element.value); } - return kinds; + return dependencyKindSet(kinds); } /** @@ -765,6 +870,16 @@ function validateGroups( } const groups: ConfiguredGroup[] = []; for (const [name, value] of node.entries) { + // SPEC 7, 14.14: the group's key is its name — non-empty, free of + // U+FFFD. The group stays configured either way, so references to it + // resolve and the name is its one reported defect. + checkConfiguredName( + name, + "group", + label, + node.keyLines.get(name), + findings, + ); const patterns: string[] = []; const globs: CompiledGlob[] = []; if (value.kind !== "array") { @@ -804,8 +919,9 @@ function validateGroups( /** * SPEC 7.3: `markdown.emit` is a required boolean; `markdown.outDir` is an - * optional path resolving relative to the workspace root that MUST resolve - * within it; unknown keys are configuration errors (14.14). + * optional directory path relative to the workspace root, spelled in plain + * workspace-relative form; any other spelling and unknown keys are + * configuration errors (14.14). */ function validateMarkdown( node: ConfigNode, @@ -852,40 +968,28 @@ function validateMarkdown( `"markdown.outDir" must be a path string (SPEC 7.3)`, outDirNode.line, ); - } else if (!resolvesInsideRoot(outDirNode.value)) { - findings.add( - `"markdown.outDir" ("${outDirNode.value}") resolves outside the ` + - `workspace root — it must resolve within it (SPEC 7.3, 14.14)`, - outDirNode.line, - ); } else { - outDir = outDirNode.value; + // SPEC 7.3, 14.14: the verbatim literal (2.4) must spell a plain + // workspace-relative directory path; nothing is resolved, collapsed, + // or dropped, so `./out`, `out/.`, `out//x`, and `out/` are refused + // like `/out` and `../out`. + const problem = outDirSpellingProblem(outDirNode.value); + if (problem !== null) { + findings.add( + `"markdown.outDir" ("${outDirNode.value}") ${problem} — spell it ` + + `as a directory path relative to the workspace root: one or ` + + `more non-empty "/"-separated segments, none "." or ".." ` + + `(SPEC 7.3, 14.14)`, + outDirNode.line, + ); + } else { + outDir = outDirNode.value; + } } } return { emit, outDir }; } -/** - * SPEC 7.3 (and SPEC 7 for globs): lexical containment in the workspace - * root — an absolute path, or a `..` stepping above the root, resolves - * outside it. - */ -function resolvesInsideRoot(relativePath: string): boolean { - const segments = relativePath.split("/"); - if (segments.length > 1 && segments[0] === "") return false; // absolute - let depth = 0; - for (const segment of segments) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - if (depth === 0) return false; - depth -= 1; - continue; - } - depth += 1; - } - return true; -} - const PROFILE_KEYS: readonly string[] = [ "name", "target", @@ -938,6 +1042,13 @@ function validateCoverage( findings, ); if (name !== undefined) { + checkConfiguredName( + name.value, + "profile", + `${where}.name`, + name.line, + findings, + ); if (names.has(name.value)) { findings.add( `${where}: duplicate profile name "${name.value}" — profile ` + @@ -1197,6 +1308,13 @@ function validatePolicy( findings, ); if (name !== undefined) { + checkConfiguredName( + name.value, + "rule", + `${where}.name`, + name.line, + findings, + ); if (names.has(name.value)) { findings.add( `${where}: duplicate rule name "${name.value}" — rule names are ` + @@ -1345,8 +1463,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 +1509,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 bcc488f8..9fd17893 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -18,13 +18,31 @@ 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) +/** + * SPEC 7 → 14.19: U+FFFD (REPLACEMENT CHARACTER), which no argument value + * carries (SPEC 12.0) — so a path containing it could be named by no + * command. Built from its code point, never spelled literally. + */ +const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd); + +/** + * SPEC 7 → 14.19: whether a workspace-relative path, read as decoded + * characters, contains U+FFFD — so it is never a valid source path. The + * one U+FFFD rule every source-path judgement applies: discovery's + * (`classifySources`, over the decoded form of a UTF-8-valid path) and + * the journal's recorded paths (core/journal.ts, SPEC 6.1, 14.13). + */ +export function containsReplacementCharacter(path: string): boolean { + return path.includes(REPLACEMENT_CHARACTER); +} 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,12 +51,41 @@ 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 `#`- or U+FFFD-containing path, or a + * 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 { /** * Workspace-relative `/`-separated path (SPEC 1.5). Valid discovered - * sources always have UTF-8-valid, `#`-free paths (SPEC 7 → 14.19), so - * the string form re-encodes to the exact matched bytes. + * sources always have UTF-8-valid paths free of `#` and U+FFFD (SPEC 7 + * → 14.19), so the string form re-encodes to the exact matched bytes. */ readonly path: string; /** @@ -55,12 +102,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 +186,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). @@ -158,26 +204,40 @@ function matchingGroupNames( } /** - * SPEC 7.3: the validated `markdown.outDir` (resolves within the workspace - * root) reduced to its canonical workspace-relative prefix, trailing `/` - * included — or null when absent or naming the root itself (default - * placement next to each source). + * SPEC 7.3: why a `markdown.outDir` spelling is not a directory path + * relative to the workspace root — "one or more non-empty `/`-separated + * segments, none `.` or `..` — the form of every workspace-relative path + * (1.5, 7)" — or null when it is. Decided by the verbatim spelling alone + * (2.4): "any other spelling — empty, beginning with `/`, or carrying a + * `.`, `..`, or empty segment — is a configuration error (14.14)", whether + * or not it would resolve inside the root. */ -export function canonicalOutDirPrefix( - outDir: string | undefined, -): string | null { - if (outDir === undefined) return null; - const kept: string[] = []; +export function outDirSpellingProblem(outDir: string): string | null { + if (outDir.length === 0) return "is empty"; + if (outDir.startsWith("/")) return `begins with "/"`; for (const segment of outDir.split("/")) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - kept.pop(); // validated to resolve within the root (SPEC 7.3, 14.14) - continue; + if (segment === "") { + return `carries an empty segment (a doubled or trailing "/")`; + } + if (segment === "." || segment === "..") { + return `carries a "${segment}" segment`; } - kept.push(segment); } - if (kept.length === 0) return null; - return kept.join("/") + "/"; + return null; +} + +/** + * SPEC 7.3: the prefix every emit destination carries — the validated + * `markdown.outDir`, already a plain workspace-relative path + * ({@link outDirSpellingProblem}), with the joining `/` appended, since each + * destination is "`outDir` joined by `/` to the emitted file's default + * workspace-relative path (13.2)" — or null when absent (default placement + * next to each source). + */ +export function canonicalOutDirPrefix( + outDir: string | undefined, +): string | null { + return outDir === undefined ? null : `${outDir}/`; } /** The byte encoding of `canonicalOutDirPrefix` for the byte-wise matcher. */ @@ -211,6 +271,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"; @@ -276,9 +399,9 @@ interface MatchedCandidate { * yet run — classification is by configuration alone, SPEC 7.3). * 3. Path validation on what remains: matched by both a spec and a code * group → configuration error (SPEC 7.2, 14.14); a path containing `#` - * or not valid UTF-8 → 14.19 (SPEC 7); a spec-group match without the - * `.mdx` extension → 14.19 (SPEC 7.1). Each condition present is - * reported (SPEC 14). + * or U+FFFD, or not valid UTF-8 → 14.19 (SPEC 7); a spec-group match + * without the `.mdx` extension → 14.19 (SPEC 7.1). Each condition + * present is reported (SPEC 14). */ export function classifySources( candidates: readonly Uint8Array[], @@ -322,72 +445,114 @@ 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 && containsReplacementCharacter(decoded)) { + // SPEC 7 → 14.19: a discovered path containing U+FFFD is invalid — + // judged on the decoded characters of a UTF-8-valid path (a path + // that is not valid UTF-8 is invalid on that count, below). It keeps + // its plain string form (SPEC 12.0, 12.7). + valid = false; + findings.push( + pathFinding( + 19, + `the workspace-relative path contains U+FFFD (REPLACEMENT ` + + `CHARACTER), which no argument value carries, so no command ` + + `could name the file — rename the file to a path without ` + + `U+FFFD (SPEC 7, 12.0, 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/edits.ts b/src/core/edits.ts index bea5e823..bb89b653 100644 --- a/src/core/edits.ts +++ b/src/core/edits.ts @@ -8,6 +8,7 @@ // that produce them live in ./rename.ts and ./move.ts. import type { ByteRange } from "./bytes.js"; +import { compareBytes } from "./bytes.js"; /** One in-place edit: replace the bytes of `range` with `replacement`. */ export interface SourceEdit { @@ -23,6 +24,67 @@ export interface SourceRewrite { readonly content: Uint8Array; } +/** + * One source write of a rewriting operation (SPEC 6.4, 6.5, 13.5): a file's + * complete new bytes at its path, or the removal of a relocation's origin. + */ +export type SourceWrite = + | { + readonly kind: "write"; + readonly path: string; + readonly content: Uint8Array; + } + | { readonly kind: "remove"; readonly path: string }; + +/** + * A file-form move's relocation (SPEC 6.5): the moved file produced at the + * destination, then the origin removed. + */ +export interface Relocation { + readonly origin: string; + readonly destination: string; +} + +/** + * SPEC 13.5: the order of a rewriting operation's source writes — one write + * per file the operation rewrites, relocates, or creates, in the order the + * preview's `files` lists them (6.6, 12.7: by the file's current, + * pre-operation path bytes — a relocated file's entry under its origin + * path, a created file's under the path its creation occupies), a + * relocation producing the destination and then removing the origin. A + * command stopped by a refused write (14.24) therefore leaves exactly the + * writes before it in this order, each complete. + */ +export function orderSourceWrites( + rewrites: readonly SourceRewrite[], + relocation: Relocation | null, +): SourceWrite[] { + if ( + relocation !== null && + !rewrites.some((rewrite) => rewrite.path === relocation.destination) + ) { + // Unreachable: a file-form move always produces its destination (SPEC + // 6.5). Guarded so the origin's removal can never go unordered. + throw new Error( + "xspec internal error: a relocation without the destination's write", + ); + } + const entries = rewrites.map((rewrite) => { + const write: SourceWrite = { + kind: "write", + path: rewrite.path, + content: rewrite.content, + }; + if (relocation !== null && rewrite.path === relocation.destination) { + const removal: SourceWrite = { kind: "remove", path: relocation.origin }; + return { key: relocation.origin, writes: [write, removal] }; + } + return { key: rewrite.path, writes: [write] }; + }); + entries.sort((a, b) => compareBytes(a.key, b.key)); + return entries.flatMap((entry) => entry.writes); +} + const encoder = new TextEncoder(); /** @@ -82,40 +144,55 @@ export class EditCollector { } /** - * A JavaScript string literal holding exactly `value`, written with `quote` - * (SPEC 6.4: quote style preserved; the fallback form is double-quoted). - * Only the quote character and the backslash need escaping: the rewritten - * values — ID segments (SPEC 1.4) and workspace-relative import specifiers - * (SPEC 2.1) — contain no characters whose escape sequence differs, and the - * literal re-parses to `value`. + * A JavaScript string literal whose value is exactly `value` (SPEC 6.4, 6.5 + * rewrites and added imports). The value of a static string literal is the + * characters between its delimiters exactly as spelled — no escape sequence + * is interpreted (SPEC 2.4) — so the value is written verbatim: an escaped + * spelling would read back as its escape's characters. `quote` is the style + * the edit keeps (SPEC 6.4: quote style preserved; the fallback form and an + * added import's specifier are double-quoted, 6.4, 6.5). Rewritten + * identities hold no quote character, `\`, or control character (1.4), so + * they always take `quote` itself; a rewritten or added import specifier is + * a path (2.1), which may hold a quote character. Where the value holds + * `quote`, no literal in that style has it as its value, and the other + * quote character is taken — SPEC 6.4 and 6.5 prescribe the style for + * values it can delimit, and a path holding the prescribed quote has no + * spelling in it at all. A value holding both quote characters, or a line + * feed or carriage return (which no string literal holds unescaped), has + * no verbatim spelling: an internal error, never a malformed rewrite. */ export function jsStringLiteral(value: string, quote: '"' | "'"): string { - let escaped = ""; - for (const character of value) { - escaped += - character === "\\" || character === quote ? `\\${character}` : character; + const other = quote === '"' ? "'" : '"'; + const chosen = !value.includes(quote) + ? quote + : !value.includes(other) + ? other + : null; + if (chosen === null || /[\n\r]/u.test(value)) { + throw new Error( + `xspec internal error: the string value ${JSON.stringify(value)} has ` + + `no verbatim string-literal spelling (SPEC 2.4)`, + ); } - return `${quote}${escaped}${quote}`; + return `${chosen}${value}${chosen}`; } /** * The characters of a quoted MDX attribute value holding exactly `value` * under `quote` (SPEC 2.7: quoted attribute form; SPEC 6.4: the quote style - * is preserved). MDX decodes character references in attribute values, so - * `&` and the quote character are written as character references — every - * other character is written verbatim — and the attribute re-parses to - * exactly `value`. + * is preserved). A quoted attribute value is the characters between its + * quotes exactly as spelled — no character reference is interpreted + * (SPEC 2.4) — so the value is written verbatim and reads back as itself. + * The rewritten values are identities, whose segments hold no quote + * character (SPEC 1.4); a value holding `quote` has no spelling in that + * form at all. */ export function attributeValueText(value: string, quote: '"' | "'"): string { - let encoded = ""; - for (const character of value) { - if (character === "&") { - encoded += "&"; - } else if (character === quote) { - encoded += quote === '"' ? """ : "'"; - } else { - encoded += character; - } + if (value.includes(quote)) { + throw new Error( + `xspec internal error: the attribute value ${JSON.stringify(value)} ` + + `holds its own quote character ${quote} and has no quoted spelling`, + ); } - return encoded; + return value; } diff --git a/src/core/findings.ts b/src/core/findings.ts index 0f60cee5..c177aa61 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–25. */ export type ConditionNumber = | 1 | 2 @@ -39,96 +45,274 @@ 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 + | 24 + | 25; + +/** + * 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 + "write-failure", // 14.24 — a usage error (exit 2), never a finding + "read-failure", // 14.25 — a usage error (exit 2), never a finding +] 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). Stable codes cover exactly the numbered conditions and + * these ten reasons, and no more (SPEC 14): every rewritten reference + * resolves by construction, so no reason exists for an unresolvable one + * (SPEC 6.4, 6.5). + */ +export const REFUSAL_CODES = [ + "refused-invalid-id", + "refused-identity-unchanged", + "refused-id-collision", + "refused-structural-parent", + "refused-cycle", + "refused-destination-exists", + "refused-missing-target-parent", + "refused-invalid-destination", + "refused-invalid-rewrite", + "refused-moved-import", +] as const; +export type RefusalCode = (typeof REFUSAL_CODES)[number]; -/** The SPEC 14 condition table: short name and exit class per condition. */ -export const CONDITIONS: Readonly<Record<ConditionNumber, ConditionInfo>> = { - 1: { name: "missing ID", exitClass: 1 }, - 2: { name: "invalid structural ID", exitClass: 1 }, - 3: { name: "duplicate ID", exitClass: 1 }, - 4: { name: "invalid segment or tag", exitClass: 1 }, - 5: { name: "unknown dependency", exitClass: 1 }, - 6: { name: "unknown text target", exitClass: 1 }, - 7: { name: "unknown TypeScript reference", exitClass: 1 }, - 8: { name: "invalid argument", exitClass: 1 }, - 9: { name: "cycle", exitClass: 1 }, - 10: { name: "stale generated output", exitClass: 1 }, - 11: { name: "cross-module text call", exitClass: 1 }, - 12: { name: "policy violation", exitClass: 1 }, - 13: { name: "journal error", exitClass: 1 }, - 14: { name: "configuration error", exitClass: 2 }, - 15: { name: "invalid import", exitClass: 1 }, - 16: { name: "invalid construct", exitClass: 1 }, - 17: { name: "invalid prop", exitClass: 1 }, - 18: { name: "unsupported node usage", exitClass: 1 }, - 19: { name: "invalid source path", exitClass: 1 }, - 20: { name: "unparseable source", exitClass: 1 }, - 21: { name: "corrupt review session", exitClass: 1 }, - 22: { name: "symbolic link in a write path", exitClass: 1 }, -}; +/** Every stable code SPEC 14 assigns: numbered conditions, then refusals. */ +export type FindingCode = ConditionCode | RefusalCode; + +/** Condition N's stable code token (SPEC 14: `1` → `"missing-id"`). */ +export function conditionCode(condition: ConditionNumber): ConditionCode { + return CONDITION_CODES[condition - 1]; +} /** - * SPEC 5.2/7.5 → 14.12: the offending edge a policy-violation finding - * carries, endpoints as graph-node identities. Structurally identical to - * the graph layer's `GraphEdge` (core/graph.ts), restated here so the - * finding model stays dependency-free. + * One offending construct's location: the containing file (workspace- + * relative, `/`-separated, SPEC 1.5) and its byte range (SPEC 1.7). The + * observable form is `{"file", "range"}` (SPEC 12.7). 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), and for conditions 24 and 25, the environment's refusal of + * a write or a read — usage errors, never findings (SPEC 14.24, 14.25); 1 + * for every other finding, refusals and code-less findings included. + */ +export function codeExitClass(code: FindingCode | null): 1 | 2 { + return code === "configuration-error" || + code === "write-failure" || + code === "read-failure" + ? 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<T>( + a: readonly T[], + b: readonly T[], + compareElement: (x: T, y: T) => number, +): number { + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const byElement = compareElement(a[index]!, b[index]!); + if (byElement !== 0) return byElement; + } + return a.length - b.length; +} + +/** + * The total findings order of SPEC 12.7: by code (numbered conditions in + * numeric order, then refusal reasons in 14's listed order, then code-less + * findings), then by locations element-wise (file path bytes, range start, + * range end; proper prefix first), then by concerned path (null before any + * path; 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/glob.ts b/src/core/glob.ts index c25ae17c..58c55533 100644 --- a/src/core/glob.ts +++ b/src/core/glob.ts @@ -16,10 +16,23 @@ // adjacent `*` runs. A path segment beginning with `.` is matched only by a // pattern segment written with a leading `.` — read from the pattern as // written, so `*`, `?`, `**`, captures, and capture references never match -// a dot-initial segment, and `**` never consumes one. A pattern that -// resolves outside the workspace root is a configuration error (14.14); an -// outside-root `--file` value is the flag-level counterpart, a usage error -// (SPEC 11, 12.0) — this module only reports the condition as data. +// a dot-initial segment, and `**` never consumes one. Whether a pattern +// lies outside the workspace root is decided by its spelling alone, a pure +// depth count over its segments ({@link globLiesOutsideRoot}), separate from +// matching. An outside-root configured glob or policy selector is a +// configuration error (14.14); an outside-root `--file` value is the +// flag-level counterpart, a usage error (SPEC 11, 12.0, 12.3) — this module +// only reports the condition as data. Every other pattern is inside the +// root and is matched exactly as spelled: no segment is resolved, +// collapsed, or dropped. SPEC 7: "every other glob is inside, its `.`, +// `..`, and empty segments matching nothing" — a discovered file's +// workspace-relative path is the directory-entry names descending from the +// root, joined with `/`, so it carries no such segment — and SPEC 12.0: +// `./specs/A.mdx` and `specs//A.mdx` "name or match no discovered file". +// Each such segment matches no path segment, and every pattern segment but +// `**` consumes exactly one, so a pattern holding one matches no path at +// all: `./specs/*.mdx`, `specs//*.mdx`, `specs/*.mdx/`, `a/../b/*.mdx`, and +// `specs/**/../*.mdx` match nothing. // // SPEC 7.5: a `from` pattern MAY contain capture wildcards `$1`…`$9`, each // appearing at most once; a capture matches one or more bytes within a @@ -62,11 +75,12 @@ export type CaptureValues = ReadonlyMap<number, Uint8Array>; /** * Why a pattern does not compile, as data (IMPLEMENTATION cross-cutting - * rules): "outside-root" — the pattern resolves outside the workspace root - * (SPEC 7); "duplicate-capture" — a `from` pattern uses a capture wildcard - * more than once (SPEC 7.5). Both are configuration errors (14.14) when the - * pattern comes from configuration; an outside-root `--file` value is a - * usage error (SPEC 11, 12.0). The caller assigns the exit class. + * rules): "outside-root" — the pattern lies outside the workspace root by + * its spelling alone (SPEC 7, {@link globLiesOutsideRoot}); + * "duplicate-capture" — a `from` pattern uses a capture wildcard more than + * once (SPEC 7.5). Both are configuration errors (14.14) when the pattern + * comes from configuration; an outside-root `--file` value is a usage error + * (SPEC 11, 12.0). The caller assigns the exit class. */ export type GlobCompileError = | { readonly kind: "outside-root" } @@ -84,6 +98,9 @@ type SegmentToken = type PatternSegment = | { readonly kind: "globstar" } // the whole segment written exactly `**` + // A segment written `.`, `..`, or empty (a doubled or trailing `/`, or + // the empty pattern): SPEC 7 — it matches nothing, no path segment at all. + | { readonly kind: "never" } | { readonly kind: "tokens"; readonly tokens: readonly SegmentToken[]; @@ -123,63 +140,69 @@ function isDotDotSegment(segment: Uint8Array): boolean { return segment.length === 2 && segment[0] === DOT && segment[1] === DOT; } +function isGlobstarSegment(segment: Uint8Array): boolean { + return segment.length === 2 && segment[0] === STAR && segment[1] === STAR; +} + /** - * Resolve a pattern's `.` and `..` segments lexically against the - * workspace root (SPEC 7: configured paths and globs resolve relative to - * the configuration file's directory, which is the workspace root). - * Wildcard segments count as ordinary names to resolution; matched paths - * are canonical workspace-relative paths and never contain dot segments, - * so matching uses the resolved form. Returns null when the pattern - * resolves outside the workspace root (SPEC 7 → 14.14): an absolute - * pattern, or a `..` with no preceding segment left to cancel. Interior - * empty segments (`//` runs) collapse; a trailing slash keeps one final - * empty segment — no real path has one, so such a pattern matches nothing. + * SPEC 7: whether a glob lies outside the workspace root is decided by its + * spelling alone — for configured group globs and policy `files` selectors + * (14.14) and for the `--file` patterns of 11 and 12.3 (a usage error, + * 12.0) alike. Reading its `/`-separated segments in order from a depth of + * zero, a `..` segment lowers the depth by one; a `.` segment, an empty + * segment (a doubled or trailing `/`), and a `**` segment, which may match + * no segment at all, leave it unchanged; and every other segment raises it + * by one (a drive-qualified spelling is ordinary segments). A glob + * beginning with `/`, or whose depth ever falls below zero, is outside the + * root; every other glob is inside. Pure: no filesystem, no matching. */ -function resolveSegments( - raw: readonly Uint8Array[], -): readonly Uint8Array[] | null { - const first = raw[0]; - if (raw.length > 1 && first.length === 0) { - return null; // absolute: not workspace-root-relative - } - const resolved: Uint8Array[] = []; - for (let index = 0; index < raw.length; index += 1) { - const segment = raw[index]; - if (segment.length === 0) { - if (index > 0 && index === raw.length - 1) { - resolved.push(segment); - } - continue; - } - if (isDotSegment(segment)) { - continue; - } +export function globLiesOutsideRoot(pattern: PathInput): boolean { + const bytes = toBytes(pattern); + if (bytes.length > 0 && bytes[0] === SLASH) return true; + let depth = 0; + for (const segment of splitOnSlash(bytes)) { if (isDotDotSegment(segment)) { - if (resolved.length === 0) { - return null; // steps above the workspace root - } - resolved.pop(); - continue; + depth -= 1; + if (depth < 0) return true; + } else if ( + segment.length > 0 && + !isDotSegment(segment) && + !isGlobstarSegment(segment) + ) { + depth += 1; } - resolved.push(segment); } - return resolved; + return false; } /** - * Tokenize one pattern segment: the whole-segment `**` wildcard, or a run - * of literal bytes, `*`, `?`, and — in capture modes — `$1`…`$9` tokens. - * Every other byte is a literal (SPEC 7); `$` not followed by `1`…`9`, and - * every `$` in plain mode, is a literal too (SPEC 7.5: capture wildcards - * exist only in policy `files` selectors). + * Tokenize one pattern segment, as spelled — nothing is resolved (SPEC 7, + * 12.0): the whole-segment `**` wildcard; a segment matching nothing, + * written `.`, `..`, or empty; or a run of literal bytes, `*`, `?`, and — + * in capture modes — `$1`…`$9` tokens. Every other byte is a literal + * (SPEC 7); `$` not followed by `1`…`9`, and every `$` in plain mode, is a + * literal too (SPEC 7.5: capture wildcards exist only in policy `files` + * selectors). */ function parseSegment( segment: Uint8Array, capturesEnabled: boolean, ): PatternSegment { - if (segment.length === 2 && segment[0] === STAR && segment[1] === STAR) { + if (isGlobstarSegment(segment)) { return { kind: "globstar" }; } + if ( + segment.length === 0 || + isDotSegment(segment) || + isDotDotSegment(segment) + ) { + // SPEC 7: an inside glob's `.`, `..`, and empty segments match nothing + // — never resolved against their neighbours, so `specs/../specs/*.mdx` + // and `./specs/*.mdx` match no path, as `specs//A.mdx` names none + // (12.0). The outside-root decision reads these same spellings apart, + // by depth alone ({@link globLiesOutsideRoot}). + return { kind: "never" }; + } const tokens: SegmentToken[] = []; let literalStart = -1; const endLiteral = (end: number): void => { @@ -342,7 +365,8 @@ function matchTokenSegment( * Match the pattern segments against the path segments. `**` takes as few * whole segments as possible while the remainder still matches (SPEC 7.5 * disambiguation; fewest whole segments is fewest bytes), and never - * consumes a dot-initial segment (SPEC 7 dot rule). Every other pattern + * consumes a dot-initial segment (SPEC 7 dot rule). A segment written `.`, + * `..`, or empty matches no path segment (SPEC 7). Every other pattern * segment matches exactly one whole path segment — which is what confines * `*`, `?`, and captures within a single segment (never `/`). A token * segment consumes its whole path segment whatever internal assignment is @@ -383,7 +407,11 @@ function matchSegments( if (next.length > 0 && next[0] === DOT) break; end += 1; } - } else if (pathIndex === pathSegments.length) { + } else if ( + segment.kind === "never" || + pathIndex === pathSegments.length + ) { + // SPEC 7: a `.`, `..`, or empty pattern segment matches nothing. matched = false; } else { const pathSegment = pathSegments[pathIndex]; @@ -430,6 +458,12 @@ export class CompiledGlob { */ readonly captures: ReadonlySet<number>; private readonly segments: readonly PatternSegment[]; + /** + * SPEC 7: the pattern holds a segment written `.`, `..`, or empty, which + * matches no path segment — so, every pattern segment but `**` consuming + * exactly one path segment, the pattern matches no path at all. + */ + private readonly matchesNothing: boolean; private constructor( source: string, @@ -441,16 +475,23 @@ export class CompiledGlob { this.mode = mode; this.captures = captures; this.segments = segments; + this.matchesNothing = segments.some((segment) => segment.kind === "never"); } /** @internal Use {@link compileGlob}. */ static compileInternal(pattern: string, mode: GlobMode): GlobCompileResult { - const resolved = resolveSegments(splitOnSlash(utf8Encoder.encode(pattern))); - if (resolved === null) { + const bytes = utf8Encoder.encode(pattern); + // SPEC 7: the outside-root decision is the spelling's depth count alone, + // made before and apart from matching. + if (globLiesOutsideRoot(bytes)) { return { ok: false, error: { kind: "outside-root" } }; } + // Every other pattern is inside, matched as spelled (SPEC 7, 12.0): its + // segments are never resolved, so a `.`, `..`, or empty one stays in + // place, matching nothing. Captures are read from the whole spelling + // too, those beside such a segment included (SPEC 7.5). const capturesEnabled = mode !== "plain"; - const segments = resolved.map((segment) => + const segments = splitOnSlash(bytes).map((segment) => parseSegment(segment, capturesEnabled), ); const written = writtenCaptures(segments); @@ -546,9 +587,12 @@ export class CompiledGlob { * dot-initial path segments — then asks whether pattern segments remain * to consume at least one further path segment (a file under the prefix * always adds one). Captures, in capture modes, act as anonymous - * one-plus-byte wildcards here, as in {@link CompiledGlob.matches}. + * one-plus-byte wildcards here, as in {@link CompiledGlob.matches}. A + * pattern holding a `.`, `..`, or empty segment matches no path (SPEC 7), + * so it enters no directory. */ mayMatchWithin(prefix: PathInput): boolean { + if (this.matchesNothing) return false; const prefixSegments = splitOnSlash(toBytes(prefix)); const scratch = new Map<number, Uint8Array>(); const noValues = new Map<number, Uint8Array>(); @@ -580,6 +624,7 @@ export class CompiledGlob { if (patternSegment.kind === "globstar") { if (!dotInitial) addWithClosure(next, index); } else if ( + patternSegment.kind === "tokens" && (!dotInitial || patternSegment.writtenLeadingDot) && matchTokenSegment( patternSegment.tokens, diff --git a/src/core/graph-data.ts b/src/core/graph-data.ts index c175ff9f..dba70948 100644 --- a/src/core/graph-data.ts +++ b/src/core/graph-data.ts @@ -3,40 +3,52 @@ // 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; +// graph (./graph.ts) with its derivation inputs, and, apart from it, the +// recorded derived-file paths; // - `buildGraphSnapshot` — the pure derivation of the snapshot from the // graph and its computed hashes (./hashes.ts); -// - `serializeGraphData`/`parseGraphData` — the byte encoding through the -// one canonical serializer (./canonical-json.ts; IMPLEMENTATION: stored -// JSON goes through one canonical serializer — sorted keys, stable -// ordering, trailing newline), byte-deterministic for a given workspace -// (SPEC 12.0: no wall-clock values, no randomness, no absolute paths — -// every stored path is workspace-relative, SPEC 1.5); +// - `serializeGraphData`/`parseGraphData` and +// `serializeDerivedFileRecord`/`parseDerivedFileRecord` — the byte +// encodings of the two parts through the one canonical serializer +// (./canonical-json.ts; IMPLEMENTATION: stored JSON goes through one +// canonical serializer — sorted keys, stable ordering, trailing newline), +// byte-deterministic for a given workspace (SPEC 12.0: no wall-clock +// values, no randomness, no absolute paths — every stored path is +// workspace-relative, SPEC 1.5); // - the compare-with-current predicate `graphDataMatchesCurrent` — shared // by refresh-on-read (SPEC 13.3) and `check`'s staleness finding // (SPEC 14.10), so both judge the store by one rule. // -// The two parts age differently (SPEC 13.3): the snapshot is a pure -// function of the current sources, configuration, and journal, and -// "graph data does not match the current sources and configuration" -// exactly when the stored bytes differ from a re-serialization holding the -// current snapshot; the recorded derived-file paths are updated only by -// generation (`xspec build`, and the commands that regenerate as `build` -// does) — a refresh leaves them unchanged, and the record legitimately -// 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. +// The two parts age differently (SPEC 13.3), so each has its own file in +// the graph-data area (the layout is the product's own, deliberately +// unenumerated to consumers, SPEC 13.3, 11.6): the snapshot with its +// inputs (`GRAPH_DATA_PATH`) is a pure function of the current sources, +// configuration, and journal, and "graph data does not match the current +// sources and configuration" exactly when its stored bytes differ from the +// serialization of the current snapshot — the comparison of 13.3, from +// which the recorded derived-file paths are excluded by construction. The +// record (`DERIVED_FILE_RECORD_PATH`) is written only by generation +// (`xspec build`, and the commands that regenerate as `build` does) and +// legitimately 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 that the +// record is left unchanged (SPEC 13.3): it writes the snapshot file alone +// and never the record's, so the record keeps whatever state it has — an +// absent record stays absent, the empty record (11.6), whatever graph data +// the refresh writes beside it; a readable one stays byte-for-byte; and +// 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 content is otherwise opaque (SPEC 13.3): its observable contract is // its location under `.xspec/`, its classification as a derived file @@ -48,19 +60,93 @@ 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/13.4: the graph-data file's workspace-relative path. */ +/** + * 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 workspace-relative path of the graph data proper — + * the snapshot with its derivation inputs, the part a refresh writes. + */ export const GRAPH_DATA_PATH = ".xspec/graph.json"; /** - * The stored format version: a parsed file of any other version is - * malformed (parse yields null), so it reads as not matching the current - * sources and configuration and is refreshed or rebuilt (SPEC 13.3). + * SPEC 13.3/13.4: the workspace-relative path of the recorded derived-file + * paths — the record, written by generation alone and never by a refresh, + * so the record is left unchanged in every state (SPEC 13.3). Nothing + * occupying it — the area absent, or a directory without it, whatever else + * the area holds, graph data a refresh wrote included — is the empty + * record (SPEC 11.6, 14.23). + */ +export const DERIVED_FILE_RECORD_PATH = ".xspec/record.json"; + +/** + * The graph data's own paths, both derived files (SPEC 13.4) that `build` + * writes (its write set, SPEC 12.1, 14.22). Graph data records no paths of + * its own (SPEC 13.3): neither is ever a recorded derived-file path, so + * neither is ever an orphan. + */ +export const GRAPH_DATA_OWN_PATHS: readonly string[] = [ + GRAPH_DATA_PATH, + DERIVED_FILE_RECORD_PATH, +]; + +/** + * 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. */ -const GRAPH_DATA_VERSION = 2; +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 graph-data format version: a parsed file of any other version + * is malformed (parse yields null) — graph data that does not match the + * current sources and configuration: the refreshing reads rewrite it, + * `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); version 5 moved the recorded + * derived-file paths to their own file (`DERIVED_FILE_RECORD_PATH`), which + * no refresh writes (SPEC 13.3). + */ +const GRAPH_DATA_VERSION = 5; + +/** + * The derived-file record's format version: a record file of any other + * version is malformed (parse yields null) — recorded state that exists but + * cannot be read as a record (SPEC 14.23). + */ +const DERIVED_FILE_RECORD_VERSION = 1; /** One recorded derivation input: a discovered source and its fingerprint. */ export interface StoredSourceInput { @@ -115,7 +201,10 @@ export interface StoredRequirementNode { readonly range: ByteRange; /** SPEC 2.5: the effective coverage attribute — null for a root. */ readonly coverage: "required" | "none" | null; - /** SPEC 2.6: the node's tags, in first-occurrence order. */ + /** + * SPEC 2.6, 12.7: the node's tags — a tag set, in byte order (SPEC 12.0), + * duplicates collapsed; `[]` for a section carrying none and for a root. + */ readonly tags: readonly string[]; /** SPEC 5.5: the node's four hashes. */ readonly hashes: NodeHashes; @@ -131,6 +220,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,21 +262,25 @@ 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). */ +/** + * The stored graph data proper (SPEC 13.3), the part a refresh writes: the + * snapshot with its derivation inputs. The recorded derived-file paths — + * the paths of the derived files most recently generated (SPEC 13.3, + * 13.4), on which orphan removal (12.1) and 14.10's recorded-orphan form + * rely — are stored apart (`serializeDerivedFileRecord`), written by + * generation alone. + */ export interface GraphData { readonly snapshot: GraphSnapshot; /** The recorded derivation inputs of the snapshot (see `StoredInputs`). */ readonly inputs: StoredInputs; - /** - * SPEC 13.3/13.4: the workspace-relative paths of the derived files most - * recently generated — generated TypeScript modules and companions - * (13.1) and emitted Markdown (13.2). Updated only by generation; - * refresh preserves it. Orphan removal (12.1) and 14.10's - * recorded-orphan finding rely on exactly this record. - */ - readonly derivedFiles: readonly string[]; } /** @@ -167,7 +288,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,81 +324,59 @@ 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 }; -} - -/** - * The graph data a refresh writes (SPEC 13.3): exactly what `xspec build` - * 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 - * 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. - */ -export function refreshedGraphData( - stored: GraphData | null, - build: GraphData, -): GraphData { - return stored === null - ? build - : { - snapshot: build.snapshot, - inputs: build.inputs, - derivedFiles: stored.derivedFiles, - }; -} - -/** - * The recorded derived-file paths of a loaded store (SPEC 13.3), for - * orphan removal (SPEC 12.1, 13.4) and 14.10's recorded-orphan arm. A - * missing or malformed store records nothing: such orphans are outside - * xspec's knowledge and are never removed (SPEC 13.4). - */ -export function recordedDerivedFiles( - data: GraphData | null, -): readonly string[] { - return data === null ? [] : data.derivedFiles; + // 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 }; } /** * The compare-with-current predicate (SPEC 13.3, 14.10): whether the * stored graph data matches the current sources and configuration — - * 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). + * operationally, whether the stored bytes are exactly the serialization of + * `build`, the graph data `xspec build` would write for the current + * sources and configuration (core/build.ts, `BuildOutputs.graphData`), + * which is exactly what a refresh writes: the recorded derived-file paths + * are stored apart (`DERIVED_FILE_RECORD_PATH`), so the comparison + * excludes them by construction and a lagging record alone is never + * staleness (SPEC 13.3, 14.10). False when no plain file's bytes were read + * (`storedBytes` null — missing, or occupied by anything else). 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 and the record is readable or absent + * (SPEC 14.10: an unreadable record reports under its own unit form + * alone) — the same rule for both. */ export function graphDataMatchesCurrent( storedBytes: Uint8Array | null, - storedData: GraphData | null, build: GraphData, ): boolean { if (storedBytes === null) { return false; } - const expected = utf8Encoder.encode( - serializeGraphData(refreshedGraphData(storedData, build)), - ); - return bytesEqual(storedBytes, expected); + return bytesEqual(storedBytes, utf8Encoder.encode(serializeGraphData(build))); } // --------------------------------------------------------------------------- @@ -303,8 +403,8 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { * the versioned shape (IMPLEMENTATION: one canonical serializer — sorted * keys, stable ordering, trailing newline). Byte-deterministic for a given * workspace (SPEC 13.3, 12.0): the snapshot enters in the graph's fixed - * order and the recorded derived-file paths enter deduplicated in byte - * order. + * order. The recorded derived-file paths are no part of it (they are + * stored apart, `serializeDerivedFileRecord`). */ export function serializeGraphData(data: GraphData): string { const value: JsonValue = { @@ -323,17 +423,24 @@ export function serializeGraphData(data: GraphData): string { hash: source.hash, })), }, - derivedFiles: [...new Set(data.derivedFiles)].sort(compareBytes), requirements: data.snapshot.requirements.map(requirementToJson), 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); } @@ -345,7 +452,12 @@ function requirementToJson(node: StoredRequirementNode): JsonValue { id: node.id, range: { start: node.range.start, end: node.range.end }, coverage: node.coverage, - tags: [...node.tags], + // SPEC 12.7: a tag set — byte order (SPEC 12.0), duplicates collapsed. + // The snapshot already carries it (core/mdx.ts forms the set); + // serializing the normal form also makes a store that records tags in + // any other order non-canonical, so the store-backed fast path + // (workspace/fast-read.ts, step 1) falls back instead of serving it. + tags: [...new Set(node.tags)].sort(compareBytes), hashes: { ownHash: node.hashes.ownHash, subtreeHash: node.hashes.subtreeHash, @@ -372,11 +484,13 @@ const EDGE_KINDS: ReadonlySet<string> = new Set([ /** * Parse stored graph-data text. Returns null — malformed — for anything * that is not the versioned shape `serializeGraphData` writes: not JSON, - * a different version, or structurally invalid fields. A malformed store - * never matches the current sources and configuration (SPEC 13.3), so it - * is refreshed by the reading commands and reported stale by `check` - * (SPEC 14.10); its derived-file record is unrecoverable, leaving any - * orphans outside xspec's knowledge (SPEC 13.4). + * a different version, or structurally invalid fields. Malformed graph + * data is graph data that does not match the current sources and + * configuration (SPEC 13.3): the refreshing reads rewrite it, `check` + * reports it as staleness (SPEC 14.10) — under the unreadable-record unit + * form alone where the record cannot be read either — and a successful + * `build` or finishing regeneration replaces it. The record it sits beside + * is judged on its own (`parseDerivedFileRecord`). */ export function parseGraphData(text: string): GraphData | null { let raw: unknown; @@ -389,26 +503,66 @@ export function parseGraphData(text: string): GraphData | null { return null; } const inputs = parseInputs(raw["inputs"]); - const derivedFiles = parseStringArray(raw["derivedFiles"]); 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, }; } +/** + * Serialize the recorded derived-file paths (SPEC 13.3, 13.4) — the paths + * of the derived files most recently generated: generated TypeScript + * modules and companions (13.1) and emitted Markdown (13.2) — to the + * record's stored text through the one canonical serializer, the paths + * deduplicated in byte order (SPEC 12.0). Written by generation alone + * (`xspec build` and the finishing regeneration of `rename`/`move`), never + * by a refresh (SPEC 13.3). + */ +export function serializeDerivedFileRecord(paths: readonly string[]): string { + return canonicalJson({ + version: DERIVED_FILE_RECORD_VERSION, + derivedFiles: [...new Set(paths)].sort(compareBytes), + }); +} + +/** + * Parse the record's stored text: the recorded derived-file paths, or null + * — malformed — for anything that is not the versioned shape + * `serializeDerivedFileRecord` writes. A malformed record is recorded state + * that exists but cannot be read as a record (SPEC 14.23): the + * record-consulting surfaces report their record-supplied datum explicitly + * unavailable beside the condition-23 finding (SPEC 11.6, 6.6), `check` + * reports it under 14.10's unreadable-record unit form, the refreshing + * reads neither read, repair, nor replace it (SPEC 13.3), and a successful + * `build` or finishing regeneration replaces it; the paths it held are + * unrecoverable, leaving any orphans outside xspec's knowledge (SPEC 13.4). + */ +export function parseDerivedFileRecord(text: string): readonly string[] | null { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return null; + } + if (!isRecord(raw) || raw["version"] !== DERIVED_FILE_RECORD_VERSION) { + return null; + } + return parseStringArray(raw["derivedFiles"]); +} + function parseSourceInput(value: unknown): StoredSourceInput | null { if (!isRecord(value)) { return null; @@ -559,10 +713,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 +741,32 @@ function parseEdge(value: unknown): GraphEdge | null { } return { kind: kind as GraphEdgeKind, source, target }; } + +/** SPEC 5.7: the dependency edge kinds occurrences record. */ +const OCCURRENCE_KINDS: ReadonlySet<string> = new Set([ + "depends", + "embeds", + "references", +]); + +function parseOccurrence(value: unknown): StoredOccurrence | null { + if (!isRecord(value)) { + return null; + } + const file = value["file"]; + const kind = value["kind"]; + const source = value["source"]; + const target = value["target"]; + const range = parseRange(value["range"]); + if ( + typeof file !== "string" || + typeof kind !== "string" || + !OCCURRENCE_KINDS.has(kind) || + (source !== null && typeof source !== "string") || + typeof target !== "string" || + range === null + ) { + return null; + } + return { file, range, kind: kind as DependencyEdgeKind, source, target }; +} diff --git a/src/core/graph.ts b/src/core/graph.ts index efbc3438..46a5abbd 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -13,9 +13,14 @@ // kind a set: duplicate declarations collapse to a single edge; // - reference resolution — every `d` reference, `{text(...)}` target, and // TypeScript reference is resolved; unknown targets report 14.5, 14.6, -// 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; +// and 14.7, and a resolving cross-module `text(...)` call reports 14.11 +// beside its standing edge and occurrence (SPEC 14.11, 5.7) — an +// unresolved one is 14.7 alone. 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 +36,19 @@ // 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 { + CalledModule, + CodeAnalysis, + CodeReference, +} from "./code-analysis.js"; +import type { Finding, FindingLocation } from "./findings.js"; +import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, SpecSection } from "./mdx.js"; +import { definedIdentitySections } from "./mdx.js"; +import type { PathText } from "./path-text.js"; +import { comparePathTexts, pathTextKey, renderPathText } from "./path-text.js"; import type { ReferenceTarget, SpecImportModel, @@ -67,6 +80,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 +114,48 @@ export interface GraphEdge { readonly target: string; } +/** SPEC 5.2/5.7: the dependency edge kinds — the kinds occurrences record. */ +export type DependencyEdgeKind = Exclude<GraphEdgeKind, "contains">; + +/** + * SPEC 5.7: one reference occurrence — one textual spelling of a + * dependency-kind reference whose target resolves (SPEC 11.2): each `d` + * array entry separately (2.2), each MDX `{text(...)}` embedding (2.3), + * each TypeScript `text(...)` call (4.3), and each TypeScript dependency + * marker (4.5). Edges are sets; occurrences are the positions behind them + * — duplicate references collapsing to a single edge each remain distinct + * occurrences. A construct that records no edge records no occurrence. + */ +export interface ReferenceOccurrence { + /** + * The referencing file's real path (SPEC 12.0, 12.7 path value form + * capable — the marked byte form for an invalid-path file's occurrence, + * SPEC 14.19; such occurrences arise only on failing workspaces). + */ + readonly file: PathText; + /** + * The occurrence's own span (SPEC 5.7), exact per kind: a `d` reference's + * own expression; an MDX embedding's entire braced container, opening + * brace through closing brace; a TS `text(...)` call's entire call + * expression, callee through closing parenthesis; a marker's bare + * reference chain, exclusive of any statement terminator. + */ + readonly range: ByteRange; + readonly kind: DependencyEdgeKind; + /** + * The source graph node's identity — null exactly where SPEC 11.2 leaves + * the containing node's identity undefined (a section without a usable + * identity; every node of an invalid-path file, SPEC 14.19): the source + * datum is then reported explicitly unavailable (SPEC 5.7). The datum's + * other half — the source node's own range (SPEC 1.7) — travels with the + * identified node itself (a requirement node's section range; a code + * location's range), joined at presentation (SPEC 11.3, 12.7). + */ + readonly source: string | null; + /** The resolved target's identity (SPEC 1.5) — always a requirement node. */ + readonly target: string; +} + /** One parsed spec source with its per-file analyses (T6–T8 outputs). */ export interface SpecFileAnalysis { readonly document: SpecDocument; @@ -112,6 +175,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 +205,7 @@ interface GraphParts { readonly requirementNodes: readonly RequirementNode[]; readonly codeLocations: readonly CodeLocationNode[]; readonly edges: readonly GraphEdge[]; + readonly occurrences: readonly ReferenceOccurrence[]; readonly findings: readonly Finding[]; readonly requirementIndex: ReadonlyMap<string, RequirementNode>; readonly codeIndex: ReadonlyMap<string, CodeLocationNode>; @@ -141,16 +217,19 @@ interface GraphParts { * The assembled workspace graph (SPEC 5). Node lists are ordered by file * path (byte order, SPEC 12.0) and within a file by document order, the * root (or the whole-file code location) first; `edges` is the collapsed - * edge set (SPEC 5.2) ordered by (source, kind, target); `findings` holds - * the graph's own conditions — unresolved references (14.5–14.7) and - * cycles (14.9) — deterministically ordered. Everything else (structural, - * prop, import, argument, and code-usage findings) belongs to the - * per-file analyses this graph was built from. + * edge set (SPEC 5.2) ordered by (source, kind, target); `occurrences` + * holds every reference occurrence (SPEC 5.7) in occurrence order — + * referencing file path bytes, then range start, then range end; + * `findings` holds the graph's own conditions — unresolved references + * (14.5–14.7) and cycles (14.9) — deterministically ordered. Everything + * else (structural, prop, import, argument, and code-usage findings) + * belongs to the per-file analyses this graph was built from. */ export class WorkspaceGraph { readonly requirementNodes: readonly RequirementNode[]; readonly codeLocations: readonly CodeLocationNode[]; readonly edges: readonly GraphEdge[]; + readonly occurrences: readonly ReferenceOccurrence[]; readonly findings: readonly Finding[]; private readonly requirementIndex: ReadonlyMap<string, RequirementNode>; @@ -167,6 +246,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 +358,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 +387,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 +427,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 +440,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 +455,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 +475,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<string, FindingLocation[]>(); + const addSpelling = ( + source: string, + target: string, + file: string, + range: ByteRange, + ): void => { + const key = `${source}\u0000${target}`; + let spellings = edgeSpellings.get(key); + if (spellings === undefined) edgeSpellings.set(key, (spellings = [])); + spellings.push({ file, range }); + }; + // SPEC 5.2/2.2: `depends` — declared by the `d` prop; unknown targets // are 14.5. for (const spec of specs) { @@ -407,8 +531,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 +573,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,14 +591,35 @@ 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, + ); } } } // SPEC 5.2/4.3/4.5: `references` from TypeScript markers and `embeds` - // from TypeScript `text(...)` calls; unknown targets are 14.7. + // from TypeScript `text(...)` calls; unknown targets are 14.7, located + // by the span the occurrence would occupy (SPEC 14, 5.7): a marker's + // bare chain, a `text(...)` call's entire call expression. for (const analysis of code) { for (const reference of analysis.references) { const resolved = resolution.resolveExternal( @@ -467,28 +633,200 @@ export function buildWorkspaceGraph( unresolvedFinding( 7, analysis.path, - reference.range, + reference.occurrenceRange, `unknown TypeScript reference: the ${construct} referencing ` + `${describeExternal(reference.modulePath, reference.segments)} ` + - `does not resolve — ${resolved.reason}; this is also a type ` + - `error against the generated module; correct or remove the ` + - `reference (SPEC 4.5, 14.7)`, + `does not resolve — ${resolved.reason}` + + // SPEC 14.7: the type-error clause holds only for a spelling + // free of escape sequences (2.4). + (reference.escapeFree + ? `; this is also a type error against the generated module` + : "") + + `; correct or remove the reference (SPEC 4.5, 14.7)`, ), ); continue; } + if (reference.calledModule !== null) { + // SPEC 14.11: the argument resolves, so the call passes a node to + // another module's `text` export — its edge and occurrence stand + // beside the finding (5.7). + findings.push( + crossModuleTextFinding( + analysis.file, + reference, + reference.calledModule, + ), + ); + } 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) { + if (reference.calledModule !== null) { + // SPEC 14.11: a resolving cross-module call, judged on its own + // terms in a file whose own path is invalid (SPEC 11.2). + findings.push( + crossModuleTextFinding( + analysis.file, + reference, + reference.calledModule, + ), + ); + } + 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)`, + // SPEC 14, 5.7: located as the occurrence would be — a + // `text(...)` call callee through closing parenthesis. + [{ file: analysis.file, range: reference.occurrenceRange }], + ), + ); + } + } + + // 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 +837,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 +879,30 @@ function unresolvedFinding( range: ByteRange, message: string, ): Finding { - return { condition, file, range, message }; + return locatedFinding(condition, message, [{ file, range }]); +} + +/** + * The 14.11 finding of a resolving cross-module `text(...)` call (SPEC + * 4.4, 14.11): located at the call, callee through closing parenthesis — + * the span its standing occurrence occupies (SPEC 14, 5.7) — its + * `identities` exactly the called module's root identity, or none where + * that module's path is invalid (SPEC 14.11, 12.7). + */ +function crossModuleTextFinding( + file: PathText, + reference: CodeReference, + called: CalledModule, +): Finding { + return locatedFinding( + 11, + `cross-module text call: the argument is a node of module ` + + `${JSON.stringify(reference.modulePath)} but the "text" export ` + + `called belongs to module ${JSON.stringify(called.display)} — pass ` + + `a node only to its own module's "text" export (SPEC 4.4, 14.11)`, + [{ file, range: reference.occurrenceRange }], + called.identity === null ? [] : [called.identity], + ); } /** A human description of an external reference's target (messages only). */ @@ -539,6 +917,21 @@ function describeExternal( return JSON.stringify(`${modulePath}#${segments.join(".")}`); } +/** + * SPEC 2.4, 1.4: a reference is read as spelled — a string literal's value + * and a chain segment's name are their characters, no escape sequence or + * character reference interpreted — so a spelling holding `\` or `&` + * names no valid identity. The note that makes such an unresolved + * reference actionable (SPEC 14); empty for any other spelling. + */ +function verbatimSpellingNote(spelled: string): string { + return spelled.includes("\\") || spelled.includes("&") + ? ` — the reference is read as spelled, no escape sequence or ` + + `character reference interpreted, and no ID segment contains "\\" ` + + `or "&"; spell the ID's characters plainly (SPEC 2.4, 1.4)` + : ""; +} + /** Reference resolution against the parsed documents (SPEC 2.2, 2.3, 4.5). */ class Resolver { constructor( @@ -560,7 +953,8 @@ class Resolver { ok: false, reason: `no section with ID ${JSON.stringify(target.idPath)} exists ` + - `in ${JSON.stringify(document.path)}`, + `in ${JSON.stringify(document.path)}` + + verbatimSpellingNote(target.idPath), }; } return { ok: true, node }; @@ -614,7 +1008,8 @@ class Resolver { ok: false, reason: `no section with ID ${JSON.stringify(id)} exists in ` + - JSON.stringify(modulePath), + JSON.stringify(modulePath) + + verbatimSpellingNote(id), }; } return { ok: true, node }; @@ -638,12 +1033,16 @@ class Resolver { * `depends`, and `embeds` edges on requirement nodes (`references` edges * and code-sourced `embeds` edges have code-location sources and do not * participate). One 14.9 finding per cyclic strongly connected component, - * carrying a full cycle path within it. + * carrying a full cycle path within it — located in source through every + * reference spelling recording a participating dependency edge (SPEC 14 + * location cardinality; `contains` steps arise from document structure and + * spell nothing), the full identity path carried in the message. */ function dependencyCycleFindings( requirementNodes: readonly RequirementNode[], requirementIndex: ReadonlyMap<string, RequirementNode>, edges: readonly GraphEdge[], + edgeSpellings: ReadonlyMap<string, readonly FindingLocation[]>, ): Finding[] { const adjacency = new Map<string, Set<string>>(); for (const edge of edges) { @@ -661,18 +1060,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 +1090,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<string, SpecFileAnalysis>, -): Finding[] { +function importCycleFindings(specs: readonly SpecFileAnalysis[]): Finding[] { + /** Byte key of one parsed file (both `PathText` forms, SPEC 12.0). */ + const keyed = new Map<string, SpecFileAnalysis>(); + for (const spec of specs) { + keyed.set(pathTextKey(spec.document.file), spec); + } const adjacency = new Map<string, Set<string>>(); for (const spec of specs) { + const sourceKey = pathTextKey(spec.document.file); for (const declared of spec.imports.imports) { - if (declared.targetPath === null) continue; - let targets = adjacency.get(spec.document.path); + if (declared.targetFile === null) continue; + let targets = adjacency.get(sourceKey); if (targets === undefined) { - adjacency.set(spec.document.path, (targets = new Set())); + adjacency.set(sourceKey, (targets = new Set())); } - targets.add(declared.targetPath); + targets.add(pathTextKey(declared.targetFile)); } } - const paths = specs.map((spec) => spec.document.path); - const cycles = findCycles(paths, adjacency); + const cycles = findCycles([...keyed.keys()], adjacency); return cycles.map((cycle) => { - // 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 +1173,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, ReadonlySet<string>>, ): string[][] { diff --git a/src/core/journal.ts b/src/core/journal.ts index 77353897..2b4eac59 100644 --- a/src/core/journal.ts +++ b/src/core/journal.ts @@ -44,13 +44,11 @@ import type { ByteRange } from "./bytes.js"; import { compareBytes, sortByBytes } from "./bytes.js"; import { compactJson } from "./canonical-json.js"; +import { containsReplacementCharacter } from "./discovery.js"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; import { firstInvalidUtf8 } from "./source-text.js"; -import { - containsControl, - containsWhitespace, - FORBIDDEN_SEGMENT_NAMES, -} from "./text.js"; +import { describeSegmentViolation, idSegmentViolations } from "./text.js"; /** SPEC 6.1: the journal's workspace-relative path. */ export const JOURNAL_PATH = ".xspec/journal"; @@ -90,12 +88,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[]; } /** @@ -171,10 +183,13 @@ export function serializeJournalEntry(entry: JournalEntry): string { const LF = 0x0a; const decoder = new TextDecoder(); +const encoder = new TextEncoder(); /** * Parse a journal file's bytes (SPEC 6.1): one entry per line, the final - * line's terminator optional (the product always writes it). Every line that + * line's terminator optional (the product always writes it, and an append + * terminates an unterminated last line first, `appendedJournalBytes`, + * so the new entry never joins it). Every line that * is not the canonical byte form of a valid entry yields one condition-13 * finding naming the line (SPEC 14.13); the remaining lines' entries are * still returned, in file order, so diagnostics can describe the rest of the @@ -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,30 +210,68 @@ 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; } return { entries, findings }; } +/** + * The journal as it stands once `entry` is appended to `prior` (SPEC 6.1: + * append-only, one entry per line; 13.4: line-oriented, so concurrent + * additions merge textually), on the line model `parseJournal` reads. + * `prior` is the journal as loaded and validated — null for an absent + * journal (SPEC 6.1). The result is `prior`'s bytes unchanged, then a line + * feed where they are nonempty and do not end with one — a last line left + * unterminated by hand or by a merge tool, which `parseJournal` reads as + * an entry, so a valid journal — then the entry's canonical line and its + * terminator: the new entry always stands on a line of its own, never + * joined to the prior last line, and the prior bytes stay a byte prefix. + * An absent or empty journal, and one ending in its terminator, gain the + * entry's line alone. + * + * This is the one composition of the post-append journal: the rewritten- + * workspace analyses of `rename` and `move` validate against it and derive + * the graph data's recorded inputs from it (SPEC 6.4, 6.5, 5.4, 13.3), and + * the append writes it (workspace/journal.ts `appendJournalEntry`), so the + * file written is byte-identical with the journal so validated. + */ +export function appendedJournalBytes( + prior: Uint8Array | null, + entry: JournalEntry, +): Uint8Array { + const line = encoder.encode(serializeJournalEntry(entry) + "\n"); + const before = prior ?? new Uint8Array(0); + const separator = + before.length > 0 && before[before.length - 1] !== LF ? 1 : 0; + const out = new Uint8Array(before.length + separator + line.length); + out.set(before, 0); + if (separator === 1) { + out[before.length] = LF; + } + out.set(line, before.length + separator); + return out; +} + /** 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, }; } @@ -485,9 +538,10 @@ function splitIdentity( /** * Why `path` is not a workspace-relative spec source path, or null when it * is. SPEC 1.5: identity paths are workspace-relative and `/`-separated on - * every platform; SPEC 14.19/7.1: a discovered spec source contains no `#` - * and carries the `.mdx` extension — journaled operations act on discovered - * spec sources only (SPEC 6.4, 6.5). + * every platform; SPEC 14.19/7.1: a valid discovered spec source's path + * contains no `#` and no U+FFFD and carries the `.mdx` extension — + * journaled operations act on valid discovered spec sources only (SPEC + * 6.4, 6.5). */ function pathProblem(path: string): string | null { if (path.length === 0) { @@ -496,6 +550,11 @@ function pathProblem(path: string): string | null { if (path.includes("#")) { return `contains "#" (SPEC 14.19)`; } + if (containsReplacementCharacter(path)) { + // SPEC 7 → 14.19: no valid source path contains U+FFFD, the same rule + // discovery applies (core/discovery.ts). + return `contains U+FFFD (SPEC 7, 14.19)`; + } if (path.startsWith("/")) { return "is not workspace-relative (SPEC 1.5)"; } @@ -516,29 +575,22 @@ function pathProblem(path: string): string | null { return null; } -/** Why `id` is not a valid requirement ID (SPEC 1.3, 1.4), or null. */ +/** + * Why `id` is not a valid requirement ID (SPEC 1.3, 1.4), or null — judged + * by the shared SPEC 1.4 validator (text.ts), segment by segment. + */ function idProblem(id: string): string | null { - for (const segment of id.split(".")) { - if (segment.length === 0) { - return "has an ID with an empty segment (SPEC 1.4)"; - } - if (FORBIDDEN_SEGMENT_NAMES.has(segment)) { - return ( - `has an ID with the forbidden segment ${JSON.stringify(segment)} ` + - `(SPEC 1.4)` - ); - } - if (segment.includes("#")) { - return `has an ID segment containing "#" (SPEC 1.4)`; - } - if (containsWhitespace(segment)) { - return "has an ID segment containing whitespace (SPEC 1.4)"; - } - if (containsControl(segment)) { - return "has an ID segment containing a control character (SPEC 1.4)"; - } + const first = idSegmentViolations(id)[0]; + if (first === undefined) { + return null; } - return null; + if (first.violation.rule === "empty") { + return "has an ID with an empty segment (SPEC 1.4)"; + } + return ( + `has an ID whose segment ${JSON.stringify(first.segment)} ` + + `${describeSegmentViolation(first.violation)} (SPEC 1.4)` + ); } // --------------------------------------------------------------------------- @@ -828,10 +880,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 +930,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/js-syntax-failure.ts b/src/core/js-syntax-failure.ts new file mode 100644 index 00000000..d7bc543e --- /dev/null +++ b/src/core/js-syntax-failure.ts @@ -0,0 +1,329 @@ +// The longest viable prefix of a spec source's brace or ESM-block content +// (SPEC 14's location rule for 14.20, over the grammar of SPEC 14.20). +// +// SPEC 14.20: an expression container's or attribute value expression's +// content derives exactly one ECMAScript 2024 `Expression` beside +// whitespace and comments (the container's empty expression, whitespace +// and comments alone, is a viable prefix of that too); a spread +// attribute's content derives `...` and one `AssignmentExpression`; an ESM +// block derives one `Module` holding import and export declarations only. +// SPEC 14 locates a syntax failure at the byte length of the longest +// prefix with which some well-formed file begins. Within such content that +// is the longest prefix some continuation completes: `jsViablePrefix` +// measures it with the parser remark-mdx is handed (`mdxAcorn`, early +// errors excluded), from where acorn fails: +// +// - a failure while reading a token is at the character the token could +// not continue with (acorn's `raisedAt`: the line terminator ending an +// unterminated string, the character after `1e`); an unterminated block +// comment runs to the end; +// - a failure while parsing is at the start of the token the parser met +// (a check the parser makes only after reading on — a cover grammar's +// refinement — is at the token that forced it: the `=>` whose parameters +// do not derive), and runs on into that token through whatever some +// acceptable token begins with (`extendIntoToken`); +// - in an ESM block, a statement other than an import or export +// declaration fails at its start, where only `import` or `export` could +// have stood. + +import type { Options, Parser, TokenType } from "acorn"; +import { tokTypes } from "acorn"; +import { MDX_ACORN_OPTIONS, mdxAcorn } from "./mdx-acorn.js"; +import { closingTagDivergence, extendIntoToken } from "./viable-prefix.js"; + +/** + * The grammar content is judged by (SPEC 14.20): one `Expression` (an + * expression container or attribute value expression), `...` and one + * `AssignmentExpression` (a spread attribute), or a `Module` of import and + * export declarations (an ESM block). + */ +export type JsContentKind = "expression" | "spread" | "module"; + +/** + * ECMAScript 2024's token vocabulary: its punctuators (12.8), reserved + * words (12.7.2), and the words its productions spell contextually. + */ +const ES2024_VOCABULARY: readonly string[] = [ + ..."{ ( ) [ ] . ... ; , < > <= >= == != === !== + - * % ** ++ -- << >> >>> & | ^ ! ~ && || ?? ? ?. : = += -= *= %= **= <<= >>= >>>= &= |= ^= &&= ||= ??= => / /= }".split( + " ", + ), + ..."await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield".split( + " ", + ), + ..."let static implements interface package private protected public as async from get of set target meta".split( + " ", + ), +]; + +/** The statements an ESM block may hold (SPEC 14.20). */ +const ESM_STATEMENTS: ReadonlySet<string> = new Set([ + "ImportDeclaration", + "ExportNamedDeclaration", + "ExportDefaultDeclaration", + "ExportAllDeclaration", +]); + +/** An acorn syntax error (acorn 8.17): `pos` as raised, `raisedAt` read to. */ +interface AcornSyntaxError extends SyntaxError { + readonly pos: number; + readonly raisedAt: number; +} + +/** The top-level statement being parsed, and whether it is a declaration. */ +interface TopStatement { + readonly start: number; + declaration: boolean; +} + +/** + * Structural view of acorn's parser state and methods (not in its public + * types), verified against acorn 8.17. + */ +interface DiagnosingParser { + readonly start: number; + readonly type: TokenType; + readonly lastTokStart: number; + readonly lastTokEnd: number; + /** Whether the tokenizer is reading a token (this module's state). */ + xspecReading?: boolean; + /** The module's top-level statement in progress (this module's state). */ + xspecTop?: TopStatement; + /** The tokenizer's context: a template's or JSX's, or a plain one. */ + curContext(): { readonly token: string }; + nextToken(): void; + parse(): { readonly body: readonly { type: string; start: number }[] }; + parseExpression(): unknown; + parseMaybeAssign(): unknown; + expect(type: TokenType): void; + unexpected(): never; +} + +/** acorn's methods overridden below, as its prototype holds them. */ +interface DiagnosingMethods { + nextToken(this: DiagnosingParser): void; + parseStatement( + this: DiagnosingParser, + context: unknown, + topLevel?: boolean, + exports?: unknown, + ): unknown; + parseImport(this: DiagnosingParser, node: unknown): unknown; + parseExport(this: DiagnosingParser, node: unknown, exports: unknown): unknown; +} + +/** + * The acorn plugin recording what a failure is judged by: whether the + * tokenizer was reading a token, and — in a module — the top-level + * statement in progress and whether it is an import or export declaration. + * Nothing it records changes what the parser derives. + */ +function diagnosing(BaseParser: typeof Parser): typeof Parser { + const Extended = class extends (BaseParser as unknown as new ( + ...args: never[] + ) => object) {}; + const prototype = Extended.prototype as unknown as DiagnosingMethods; + const base = BaseParser.prototype as unknown as DiagnosingMethods; + prototype.nextToken = function () { + this.xspecReading = true; + base.nextToken.call(this); + this.xspecReading = false; + }; + prototype.parseStatement = function (context, topLevel, exports) { + if (topLevel === true) { + this.xspecTop = { start: this.start, declaration: false }; + } + return base.parseStatement.call(this, context, topLevel, exports); + }; + prototype.parseImport = function (node) { + if (this.xspecTop !== undefined) this.xspecTop.declaration = true; + return base.parseImport.call(this, node); + }; + prototype.parseExport = function (node, exports) { + if (this.xspecTop !== undefined) this.xspecTop.declaration = true; + return base.parseExport.call(this, node, exports); + }; + return Extended as unknown as typeof Parser; +} + +/** `mdxAcorn`, deriving the same grammar, its failures diagnosable. */ +const DiagnosingAcorn = mdxAcorn.extend(diagnosing) as unknown as new ( + options: Options, + input: string, +) => DiagnosingParser; + +/** Where content fails, and whether the failure starts a token. */ +interface JsFailure { + /** The failure position in the content (UTF-16). */ + readonly point: number; + /** Whether `point` starts a token the prefix may run on into. */ + readonly atToken: boolean; +} + +/** acorn-jsx's closing-tag mismatch message, naming the open element. */ +const JSX_CLOSING_MISMATCH = + /^Expected corresponding JSX closing tag for <(.*)> \(\d+:\d+\)$/; + +/** acorn-jsx's message for an attribute value's empty braces. */ +const JSX_EMPTY_ATTRIBUTE = + /^JSX attributes must only be assigned a non-empty expression/; + +/** + * A whole token of the class a token beginning with `first` belongs to — a + * string, template, regular expression, number, identifier, or private + * name — or undefined for any other character. + */ +function classRepresentative(first: string): string | undefined { + switch (first) { + case '"': + case "'": + case "`": + return first + first; + case "/": + return "/x/"; + case ".": + return ".0"; + case "#": + return "#x"; + default: + if (/^[0-9]$/.test(first)) return "0"; + if (/^[\p{ID_Start}$_\\]$/u.test(first)) return "x"; + return undefined; + } +} + +/** Where acorn's failure lies, by the parser state it was raised in. */ +function failureOf( + parser: DiagnosingParser, + error: AcornSyntaxError, + content: string, + kind: JsContentKind, + checkClass: boolean, +): JsFailure { + let failure: JsFailure; + const mismatch = JSX_CLOSING_MISMATCH.exec(error.message); + if (parser.xspecReading === true) { + // The tokenizer stopped at the character its token cannot continue + // with — unless no token of its class could stand where it began (a + // string after an expression, unterminated or not), which is then the + // failure. A block comment, raised while skipping trivia (before the + // token start moves), fails only at the content's end. + if (error.message.startsWith("Unterminated comment")) { + failure = { point: content.length, atToken: false }; + } else { + failure = { point: error.raisedAt, atToken: false }; + // A template's text and JSX's are read in their construct's own + // context, admitted with it. + const context = parser.curContext().token; + const start = parser.start; + const representative = classRepresentative(content.charAt(start)); + if ( + checkClass && + context !== "`" && + !context.startsWith("<") && + error.raisedAt > start && + representative !== undefined + ) { + const probe = jsFailure( + content.slice(0, start) + representative, + kind, + false, + ); + if (probe !== null && probe.point <= start) { + failure = { point: start, atToken: true }; + } + } + } + } else if (mismatch !== null) { + failure = { + point: closingTagDivergence(content, error.pos, mismatch[1]), + atToken: false, + }; + } else if (JSX_EMPTY_ATTRIBUTE.test(error.message)) { + // The empty braces' closing brace, the parser's last token. + failure = { point: parser.lastTokStart, atToken: false }; + } else if ( + error.pos < parser.lastTokStart && + content.slice(parser.lastTokStart, parser.lastTokEnd) === "=>" + ) { + // Arrow parameters that do not derive fail at the `=>` read before + // they were refined. + failure = { point: parser.lastTokStart, atToken: true }; + } else { + failure = { point: parser.start, atToken: true }; + } + const top = parser.xspecTop; + if ( + kind === "module" && + top !== undefined && + !top.declaration && + top.start <= failure.point + ) { + // A statement no import or export declaration begins fails at its + // start (SPEC 14.20: an ESM block holds those declarations only). + failure = { point: top.start, atToken: true }; + } + return failure; +} + +/** + * Where `content` fails to derive as `kind`, or null when it derives; + * `checkClass` false for a probe of a token class (no probe within it). + */ +function jsFailure( + content: string, + kind: JsContentKind, + checkClass = true, +): JsFailure | null { + const parser = new DiagnosingAcorn({ ...MDX_ACORN_OPTIONS }, content); + try { + if (kind === "module") { + const statement = parser + .parse() + .body.find((node) => !ESM_STATEMENTS.has(node.type)); + return statement === undefined + ? null + : { point: statement.start, atToken: true }; + } + parser.nextToken(); + if (kind === "spread") { + parser.expect(tokTypes.ellipsis); + parser.parseMaybeAssign(); + } else { + parser.parseExpression(); + } + if (parser.type !== tokTypes.eof) parser.unexpected(); + return null; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + return failureOf( + parser, + error as AcornSyntaxError, + content, + kind, + checkClass, + ); + } +} + +/** + * SPEC 14, 14.20: the length (UTF-16) of the longest prefix of `content` + * that some continuation completes to content deriving as `kind` — the + * whole content when it derives or fails only at its end. + */ +export function jsViablePrefix(content: string, kind: JsContentKind): number { + const failure = jsFailure(content, kind); + if (failure === null || failure.point >= content.length) { + return content.length; + } + if (!failure.atToken) return failure.point; + const head = content.slice(0, failure.point); + return extendIntoToken( + content, + failure.point, + ES2024_VOCABULARY, + (spelling) => { + const probe = jsFailure(head + spelling, kind); + return probe === null || probe.point >= head.length + spelling.length; + }, + ); +} diff --git a/src/core/mdx-acorn.ts b/src/core/mdx-acorn.ts new file mode 100644 index 00000000..b67258c9 --- /dev/null +++ b/src/core/mdx-acorn.ts @@ -0,0 +1,992 @@ +// The ECMAScript 2024 grammar of MDX 3's braces and ESM blocks, judged by +// derivability alone (SPEC 14.20). +// +// IMPLEMENTATION (Key libraries): remark-mdx parses every expression +// container, attribute value expression, spread attribute, and ESM block of a +// spec source with acorn, JSX added by acorn-jsx (SPEC 14.20 admits JSX in all +// three). SPEC 14.20 decides well-formedness "by derivability alone": the +// language's productions, and the supplemental grammars that refine what a +// covering production matches — an object or array literal read as an +// assignment pattern, a parenthesized list read as arrow parameters — derive +// the text or do not, and no rule beyond them takes part. ECMAScript's +// static-semantic early errors are such rules: a duplicate lexically declared +// name, an export naming no declaration, an assignment to a target that is not +// simple (`1 = 2`), a strict-mode restriction (`let` as an identifier +// reference, a legacy octal literal) — the SPEC's list is illustrative. Stock +// acorn enforces them while it parses, so a file failing only such a rule would +// report 14.20 where it is well-formed and must reach its ordinary outcome +// (14.8, 14.15, 14.16, 14.17). +// +// `mdxAcorn` is acorn extended to decide exactly the grammar. Every early error +// acorn raises is suppressed — the parse goes on as though the rule were absent +// — while every raise that reports a derivation failure still throws, at the +// position and in the order acorn raises it. Each rule below was verified +// against acorn 8.17's source (`node_modules/acorn/dist/acorn.js`): most early +// errors are told apart by acorn's own message; where acorn spells an early +// error and a derivation failure alike — "Unexpected token", "Assigning to +// rvalue", "Invalid number", a reserved word used as an identifier — the +// parser's state or the offending node tells them apart, as each rule records. +// Where acorn's grammar itself departs from ECMAScript 2024's, the extension +// restores the edition's: an escape-spelled reserved word is an identifier +// (its restriction an early error), a regular expression's pattern and flags +// are judged by early errors alone, and a class static block reads `await` +// as ECMAScript's [Await] parameter does there. One departure remains: +// acorn reads `await` where that parameter is on, and `yield` in a +// generator, as an AwaitExpression or YieldExpression before it can see a +// following `=>`, so `await => 1` and a generator's `yield => 1` — arrows +// whose one parameter derives, an early error — stay unparseable. +// +// `mdxAcorn` also judges "whitespace and comments alone" — a container's +// empty expression, and what may follow a container's one expression — as +// SPEC 14.20 does: by the comment deletions remark-mdx applies and, as +// spelled, by lexing to no token (`judgeCommentsAsSpelled`, below). + +import type { Expression, Options, Program, TokenType } from "acorn"; +import { Parser, tokTypes } from "acorn"; +import acornJsx from "acorn-jsx"; + +/** + * SPEC 14.20: the edition and goal MDX 3 parses with — ECMAScript 2024, a + * module's top-level code (`await` admitted, `yield` an identifier). Two + * acorn conveniences beyond the edition are turned off: a `#!` line is a + * hashbang comment only at a Script's or Module's start, never at the start + * of an expression container's content, which derives one `Expression`; and + * private-name existence (`#x` declared by an enclosing class) is an early + * error, never a derivation failure. + */ +export const MDX_ACORN_OPTIONS = { + ecmaVersion: 2024, + sourceType: "module", + allowHashBang: false, + checkPrivateFields: false, +} as const; + +// acorn's scope flags (acorn 8.17, `SCOPE_*`). +const SCOPE_FUNCTION = 2; +const SCOPE_ASYNC = 4; +const SCOPE_CLASS_STATIC_BLOCK = 256; +const SCOPE_CLASS_FIELD_INIT = 512; + +// acorn's binding types (acorn 8.17, `BIND_*`): an assignment target. +const BIND_NONE = 0; + +/** A node as acorn builds it: the members the rules below read. */ +interface AcornNode { + readonly type: string; + readonly start: number; + readonly end: number; + /** A ParenthesizedExpression's operand. */ + readonly expression?: AcornNode; + /** An ExportSpecifier's local name. */ + readonly local?: AcornNode; + /** A MethodDefinition's kind, key, and function. */ + readonly kind?: string; + readonly key?: AcornNode; + readonly value?: AcornNode; + /** A function's parameters. */ + readonly params?: readonly AcornNode[]; +} + +/** + * Structural view of acorn's parser (not in its public types), verified + * against acorn 8.17: the state the rules read, the methods overridden, and + * this module's own per-parse state (one parser instance parses one text). + */ +interface DerivingParser { + readonly input: string; + /** The tokenizer's position. */ + readonly pos: number; + /** The current token's type and start. */ + readonly type: TokenType; + readonly start: number; + /** Whether the current word was spelled with a `\u` escape. */ + readonly containsEsc: boolean; + /** The edition's keywords, as acorn tokenizes them. */ + readonly keywords: RegExp; + readonly scopeStack: readonly { readonly flags: number }[]; + readonly inGenerator: boolean; + readWord1(): string; + finishToken(type: TokenType, value?: unknown): unknown; + raiseRecoverable(pos: number, message: string): void; + /** Whether a line terminator (or `}` or the end) precedes the token. */ + canInsertSemicolon(): boolean; + + /** Depth of `toAssignable` calls in progress (the "Unexpected token" rule). */ + xspecAssignableDepth?: number; + /** The token after a `const` declarator's binding, pending its initializer. */ + xspecConstAfterBinding?: number; + /** The start of the labelled item being parsed. */ + xspecLabelledItem?: number; + /** Whether an identifier parsed now is a binding or a reference. */ + xspecNames?: "binding" | "reference"; + /** The local names of an export's specifier list, by start. */ + xspecExportLocals?: Set<number>; + /** Accessor keys named `constructor`, by start (their arity rule). */ + xspecAccessorConstructors?: Set<number>; +} + +/** acorn's parser methods overridden below, as its prototype holds them. */ +interface ParserMethods { + raise(this: DerivingParser, pos: number, message: string): void; + raiseRecoverable(this: DerivingParser, pos: number, message: string): void; + readWord(this: DerivingParser): unknown; + validateRegExpFlags(this: DerivingParser, state: unknown): void; + validateRegExpPattern(this: DerivingParser, state: unknown): void; + checkExpressionErrors( + this: DerivingParser, + refDestructuringErrors: unknown, + andThrow?: boolean, + ): boolean; + toAssignable( + this: DerivingParser, + node: AcornNode | null, + isBinding: boolean, + refDestructuringErrors?: unknown, + ): AcornNode | null; + checkLValSimple( + this: DerivingParser, + expr: AcornNode, + bindingType?: number, + checkClashes?: unknown, + ): void; + parseVarId(this: DerivingParser, decl: unknown, kind: string): void; + parseLabeledStatement(this: DerivingParser, ...args: unknown[]): unknown; + parseClassElement(this: DerivingParser, ...args: unknown[]): AcornNode | null; + parseExportSpecifiers(this: DerivingParser, ...args: unknown[]): AcornNode[]; + parseExprAtom(this: DerivingParser, ...args: unknown[]): unknown; + parseBindingAtom(this: DerivingParser, ...args: unknown[]): unknown; + parseFunction(this: DerivingParser, ...args: unknown[]): unknown; + parseClassId(this: DerivingParser, ...args: unknown[]): unknown; + parseBreakContinueStatement( + this: DerivingParser, + ...args: unknown[] + ): unknown; +} + +/** + * acorn's messages that report an early error and nothing else, wherever + * raised (each the ECMAScript 2024 rule named beside it). acorn continues + * correctly after each: it raises them from checks whose parse goes on. + */ +const EARLY_ERROR_MESSAGES: ReadonlySet<string> = new Set([ + // PropertyDefinition : CoverInitializedName, outside a pattern. + "Shorthand property assignments are valid only in destructuring patterns", + // Duplicate `__proto__: …` entries of one object literal. + "Redefinition of __proto__ property", + // Arrow and generator parameters containing YieldExpression/AwaitExpression. + "Yield expression cannot be a default value", + "Await expression cannot be a default value", + // ContainsUndefinedBreakTarget / ContainsUndefinedContinueTarget and the + // enclosing-statement rules of `break` and `continue`. + "Unsyntactic break", + "Unsyntactic continue", + // WithStatement in strict mode code. + "'with' in strict mode", + // Class bodies: constructor, `#constructor`, and `prototype` rules (an + // accessor named `constructor` has its own rule, `isEarlyError`). + "Duplicate constructor in the same class", + "Classes can't have an element named '#constructor'", + "Constructor can't be a generator", + "Constructor can't be an async method", + "Classes may not have a static property named prototype", + "Classes can't have a field named 'constructor'", + "Classes can't have a static field named 'prototype'", + // ExportDeclaration : export NamedExports ; — a string ReferencedBinding. + "A string literal cannot be used as an exported binding without `from`.", + // ModuleExportName : StringLiteral — IsStringWellFormedUnicode. + "An export name cannot include a lone surrogate.", + // AssignmentTargetType of an OptionalExpression (as a binding, a + // derivation failure: `OPTIONAL_CHAIN_TARGET`). + "Optional chaining cannot appear in left-hand side", + // BoundNames of a LexicalDeclaration containing "let". + "let is disallowed as a lexically bound name", + // Duplicate formal parameters. + "Argument name clash", + // `delete` of an identifier (strict mode) or a private member. + "Deleting local variable in strict mode", + "Private fields can not be deleted", + // NewTarget, SuperProperty, SuperCall outside what may contain them. + "'new.target' can only be used in functions and class static block", + "'super' keyword outside a method", + "super() call outside constructor of a subclass", + // OptionalChain : ?. TemplateLiteral / OptionalChain TemplateLiteral. + "Optional chaining cannot appear in the tag of tagged template expressions", + // NotEscapeSequence in an untagged template. + "Bad escape sequence in untagged template literal", + // "use strict" in a function with non-simple parameters. + "Illegal 'use strict' directive in function with non-simple parameter list", + // ContainsArguments of a field initializer or static block. + "Cannot use 'arguments' in class field initializer", + "Cannot use arguments in class static initialization block", + // LegacyOctalEscapeSequence and NonOctalDecimalEscapeSequence in strict + // mode code. + "Octal literal in strict mode", + "Invalid escape sequence", + // IdentifierStart/IdentifierPart :: \ UnicodeEscapeSequence whose code + // point is no IdentifierStartChar/IdentifierPartChar. + "Invalid Unicode escape", +]); + +/** Early-error messages naming a binding, label, or export (acorn 8.17). */ +const EARLY_ERROR_PATTERNS: readonly RegExp[] = [ + // A duplicate lexically declared name (private names included). + /^Identifier '.+' has already been declared$/u, + // ExportedBindings not declared in the module. + /^Export '.+' is not defined$/u, + // Duplicate ExportedNames. + /^Duplicate export '.+'$/u, + // ContainsDuplicateLabels. + /^Label '.+' is already declared$/u, + // AllPrivateIdentifiersValid. + /^Private field '#.+' must be declared in an enclosing class$/u, + // `eval`, `arguments`, and the strict-mode reserved words assigned to or + // bound in strict mode code. + /^(?:Assigning to|Binding) \S+ in strict mode$/u, +]; + +/** + * The strict-mode reserved words (ECMAScript 2024, 13.1.1): an Identifier's + * StringValue among them is an early error in strict mode code, never a + * derivation failure. `yield` has its own rule below. + */ +const STRICT_RESERVED: ReadonlySet<string> = new Set([ + "implements", + "interface", + "let", + "package", + "private", + "protected", + "public", + "static", +]); + +/** acorn's messages rejecting an identifier's name (`checkUnreserved`). */ +const NAME_MESSAGES: readonly { + readonly pattern: RegExp; + readonly name?: string; +}[] = [ + { pattern: /^Unexpected keyword '(.+)'$/u }, + { pattern: /^The keyword '(.+)' is reserved$/u }, + { + pattern: /^Cannot use keyword 'await' outside an async function$/u, + name: "await", + }, + { + pattern: /^Cannot use 'await' as identifier inside an async function$/u, + name: "await", + }, + { + pattern: /^Cannot use await in class static initialization block$/u, + name: "await", + }, + { + pattern: /^Cannot use 'yield' as identifier inside a generator$/u, + name: "yield", + }, +]; + +/** acorn's message for an accessor named `constructor` (see its rule). */ +const ACCESSOR_CONSTRUCTOR = "Constructor can't have get/set modifier"; + +/** + * acorn's message for an optional chain as an assignment target — an early + * error (13.15.1) — and as a binding, a derivation failure (`toAssignable`). + */ +const OPTIONAL_CHAIN_TARGET = + "Optional chaining cannot appear in left-hand side"; + +/** acorn's message for `await` bound or referenced in an async function. */ +const AWAIT_IN_ASYNC = + "Cannot use 'await' as identifier inside an async function"; + +/** + * LeftHandSideExpression forms acorn does not convert into a pattern and + * that are no simple assignment target: an assignment to one derives, its + * AssignmentTargetType an early error (ECMAScript 2024, 13.15.1, 13.4). + */ +const NON_SIMPLE_LHS_TYPES: ReadonlySet<string> = new Set([ + "Literal", + "ThisExpression", + "TemplateLiteral", + "TaggedTemplateExpression", + "FunctionExpression", + "ClassExpression", + "CallExpression", + "NewExpression", + "MetaProperty", + "ImportExpression", + "JSXElement", + "JSXFragment", +]); + +/** The simple assignment targets: acorn's own checks apply to them. */ +const SIMPLE_TARGET_TYPES: ReadonlySet<string> = new Set([ + "Identifier", + "MemberExpression", +]); + +/** + * UnaryExpression forms that are no LeftHandSideExpression: one derives as a + * target only as the operand of a prefix `++`/`--` (ECMAScript 2024, 13.4). + */ +const UNARY_OPERAND_TYPES: ReadonlySet<string> = new Set([ + "UnaryExpression", + "UpdateExpression", + "AwaitExpression", +]); + +/** The keyword token type of `word` (acorn's `keywordTypes`, by name). */ +function keywordType(word: string): TokenType { + const types = tokTypes as unknown as Readonly< + Record<string, TokenType | undefined> + >; + return types[`_${word}`] ?? tokTypes.name; +} + +/** + * Whether a raise of acorn's at `pos` with `message` reports an early error + * — suppressed — rather than a derivation failure (SPEC 14.20). + * `recoverable` tells acorn's `raiseRecoverable` from its `raise`. + */ +function isEarlyError( + parser: DerivingParser, + pos: number, + message: string, + recoverable: boolean, +): boolean { + if (message === ACCESSOR_CONSTRUCTOR) { + // A `get`/`set` method named `constructor` is an early error + // (ECMAScript 2024, 15.7.1), but acorn then parses it as the + // constructor and skips the accessor's parameter grammar, which + // `parseClassElement` below applies instead. + (parser.xspecAccessorConstructors ??= new Set()).add(pos); + return true; + } + if ( + EARLY_ERROR_MESSAGES.has(message) || + EARLY_ERROR_PATTERNS.some((pattern) => pattern.test(message)) + ) { + return true; + } + switch (message) { + case "Assigning to rvalue": + // acorn raises it recoverably for a parenthesized non-simple target + // inside a pattern (`[(a + b)] = c`): a ParenthesizedExpression is a + // LeftHandSideExpression, so an early error. Every other raise of it + // is one `toAssignable`/`checkLValSimple` below leave to acorn: a + // target no LeftHandSideExpression derives (`a + b = c`). + return recoverable; + case "Invalid number": + // A legacy octal-like integer (`010`, `09`) in strict mode code is an + // early error (ECMAScript 2024, 12.9.3.1), raised at the literal's + // start once its digits are read; acorn raises the same message for + // an exponent with no digits (`1e`), after reading past the `e`. + return /^0[0-9]+$/u.test(parser.input.slice(pos, parser.pos)); + case "Unexpected token": + return unexpectedTokenIsEarlyError(parser, pos); + case AWAIT_IN_ASYNC: + if (!recoverable) { + // Raised (not recoverably) for an identifier already admitted by + // acorn's reserved-word check: an assignment target or an async + // arrow's parameter named `await` — a binding. + return true; + } + break; + default: + break; + } + for (const { pattern, name } of NAME_MESSAGES) { + const match = pattern.exec(message); + if (match !== null) { + return nameIsEarlyError(parser, pos, name ?? match[1]); + } + } + return false; +} + +/** + * The "Unexpected token" raises of acorn's that report an early error: each + * identified by the parser's state at the raise, since acorn spells every + * derivation failure of a token the same way. + */ +function unexpectedTokenIsEarlyError( + parser: DerivingParser, + pos: number, +): boolean { + if ((parser.xspecAssignableDepth ?? 0) > 0) { + // Converting an assignment target into a pattern, acorn raises it for + // an AssignmentRestProperty whose target is an object or array literal + // (`({...{a}} = b)`) — an early error (ECMAScript 2024, 13.15.5.1) — + // and for nothing else. + return true; + } + if (pos === parser.start && pos === parser.xspecConstAfterBinding) { + // A `const` binding with no initializer is an early error (14.3.1.1); + // acorn raises at the token after the binding, once (the statement's + // own terminator check raises there again, a derivation failure). + parser.xspecConstAfterBinding = -1; + return true; + } + // LabelledItem : FunctionDeclaration is an early error (14.13.1); acorn + // raises at the `function` token beginning the labelled item. + return ( + pos === parser.start && + pos === parser.xspecLabelledItem && + parser.type === tokTypes._function + ); +} + +/** + * The raises of acorn's reserved-word check for an identifier named `name` + * at `pos` (ECMAScript 2024, 13.1): a ReservedWord matches its code points + * as spelled, and an identifier whose StringValue is reserved is an early + * error — so a name spelled with an escape derives, as does every + * restriction of strict mode code. A plainly spelled ReservedWord is no + * Identifier at all — a derivation failure — except as an export's + * IdentifierName reference (`export { if }`, 16.2.3.1). `await` and `yield` + * are identifiers wherever the edition's [Await] or [Yield] parameter is + * off; where it is on, BindingIdentifier still derives them (an early + * error) while IdentifierReference and LabelIdentifier do not. + */ +function nameIsEarlyError( + parser: DerivingParser, + pos: number, + name: string, +): boolean { + if (!parser.input.startsWith(name, pos)) { + return true; + } + if (parser.xspecExportLocals?.has(pos) === true) { + return true; + } + // An identifier followed by `=>` is an arrow's one parameter, a + // BindingIdentifier (`async await => 1`); acorn reads on to the arrow. + const binds = + parser.xspecNames !== "reference" || parser.type === tokTypes.arrow; + if (name === "await") { + return awaitIsIdentifier(parser) || binds; + } + if (name === "yield") { + return !parser.inGenerator || binds; + } + return STRICT_RESERVED.has(name); +} + +/** + * Whether ECMAScript's [Await] parameter is off at the parser's position — + * `await` an identifier: inside a function that is not async (its + * parameters included; an arrow's parameters are read before its scope + * opens). It is on at a module's top level, in async functions, and in + * class static blocks, and a class field initializer inherits it from + * the class's context (ECMAScript 2024, 15.7). + */ +function awaitIsIdentifier(parser: DerivingParser): boolean { + for (let index = parser.scopeStack.length - 1; index >= 0; index -= 1) { + const flags = parser.scopeStack[index].flags; + if ((flags & SCOPE_CLASS_STATIC_BLOCK) !== 0) { + return false; + } + if ((flags & SCOPE_CLASS_FIELD_INIT) !== 0) { + continue; + } + if ((flags & SCOPE_FUNCTION) !== 0) { + return (flags & SCOPE_ASYNC) === 0; + } + } + return false; +} + +/** + * ECMAScript 2024, 13.15.1: whether an `=` assignment's target (or a + * for-in/of head's) derives while its AssignmentTargetType is not simple — + * an early error (`1 = 2`, `f() = 1`, `(a + b) = 1`, `({a}) = 1`). A + * parenthesized expression is a LeftHandSideExpression whatever it + * encloses, never refined into a pattern; an unparenthesized object or + * array literal is refined (acorn's conversion applies), an identifier or + * member access is simple (acorn's checks apply), and a target that is no + * LeftHandSideExpression (`a + b = 1`) does not derive (acorn raises). + */ +function assignmentTargetIsEarlyError(node: AcornNode): boolean { + if (node.type === "ParenthesizedExpression") { + let inner: AcornNode = node; + while ( + inner.type === "ParenthesizedExpression" && + inner.expression !== undefined + ) { + inner = inner.expression; + } + return !SIMPLE_TARGET_TYPES.has(inner.type); + } + return NON_SIMPLE_LHS_TYPES.has(node.type); +} + +/** + * ECMAScript 2024, 13.4 and 13.15.1: whether a target of `++`/`--` or of a + * compound or logical assignment derives while its AssignmentTargetType is + * not simple — an early error. A compound assignment's or postfix + * operator's target is any LeftHandSideExpression (an object or array + * literal included, never refined there); a prefix operator's operand is + * any UnaryExpression (`++-a`, `++ ++a`). acorn checks a prefix operand + * once the operand is read, the token after it current; it checks an + * assignment's target with the assignment operator current, and a postfix + * operand with the postfix `++`/`--` current (no line terminator before + * it, else it would be no postfix operator) — and no UnaryExpression that + * is no LeftHandSideExpression derives as either (`-a += 1`, `a-- ++`). + * A complete prefix operand is never followed by a postfix operator. + */ +function simpleTargetIsEarlyError( + parser: DerivingParser, + expr: AcornNode, +): boolean { + if ( + expr.type === "ObjectExpression" || + expr.type === "ArrayExpression" || + expr.type === "ObjectPattern" || + expr.type === "ArrayPattern" + ) { + return true; + } + if (UNARY_OPERAND_TYPES.has(expr.type)) { + const current = parser.type as TokenType & { readonly isAssign?: boolean }; + const postfix = + parser.type === tokTypes.incDec && !parser.canInsertSemicolon(); + return current.isAssign !== true && !postfix; + } + return assignmentTargetIsEarlyError(expr); +} + +/** + * The accessor grammar of a `get`/`set` method named `constructor` + * (ECMAScript 2024, 15.4): `get` takes no parameter, `set` exactly one that + * is no rest parameter — derivation failures, raised as acorn raises them + * for every other accessor. + */ +function checkAccessorParameters( + parser: DerivingParser, + modifier: string, + method: AcornNode | undefined, +): void { + const params = method?.params ?? []; + const at = method?.start ?? parser.start; + if (modifier === "get" && params.length !== 0) { + parser.raiseRecoverable(at, "getter should have no params"); + } else if (modifier === "set" && params.length !== 1) { + parser.raiseRecoverable(at, "setter should have exactly one param"); + } else if (modifier === "set" && params[0].type === "RestElement") { + parser.raiseRecoverable(params[0].start, "Setter cannot use rest params"); + } +} + +/** + * Wrap a parse method so the identifiers it parses directly are bindings or + * references (`nameIsEarlyError`): expression atoms and `break`/`continue` + * labels refer; binding atoms, function names, and class names bind. + * Outside every such method — an ESM block's own declarations — names bind. + */ +function namingAs( + kind: "binding" | "reference", + method: (this: DerivingParser, ...args: unknown[]) => unknown, +): (this: DerivingParser, ...args: unknown[]) => unknown { + return function (this: DerivingParser, ...args: unknown[]): unknown { + const saved = this.xspecNames; + this.xspecNames = kind; + try { + return method.apply(this, args); + } finally { + this.xspecNames = saved; + } + }; +} + +/** The acorn plugin excluding early errors (see the module comment). */ +function excludeEarlyErrors(BaseParser: typeof Parser): typeof Parser { + // One more derivation level, so the class handed in stays untouched. + const Extended = class extends (BaseParser as unknown as new ( + ...args: never[] + ) => object) {}; + const prototype = Extended.prototype as unknown as ParserMethods; + const base = BaseParser.prototype as unknown as ParserMethods; + + prototype.raise = function (pos, message) { + if (!isEarlyError(this, pos, message, false)) { + base.raise.call(this, pos, message); + } + }; + prototype.raiseRecoverable = function (pos, message) { + if (!isEarlyError(this, pos, message, true)) { + base.raiseRecoverable.call(this, pos, message); + } + }; + + // ECMAScript 2024, 12.7: a ReservedWord matches its code points as + // spelled, so an escape-spelled one (`if`) is an IdentifierName and + // never the keyword; acorn tokenizes it as the keyword. Its use as an + // identifier is then an early error, raised by acorn's reserved-word + // check (`nameIsEarlyError`); its use as the keyword derives nothing. + prototype.readWord = function () { + const word = this.readWord1(); + const type = + !this.containsEsc && this.keywords.test(word) + ? keywordType(word) + : tokTypes.name; + return this.finishToken(type, word); + }; + + // A regular expression literal whose pattern or flags the RegExp grammar + // rejects is an early error (13.2.7: IsValidRegularExpressionLiteral); + // the literal's own lexical grammar is acorn's tokenizer's. + prototype.validateRegExpFlags = function () {}; + prototype.validateRegExpPattern = function () {}; + + // CoverInitializedName (`{a = 1}`) and duplicate `__proto__` entries are + // early errors outside a pattern (13.2.5.1). acorn stops reading + // operators after an object literal holding one, `=` its only + // continuation, where the grammar derives every other (`{a = 1} ? b : c`). + prototype.checkExpressionErrors = function ( + refDestructuringErrors, + andThrow, + ) { + return andThrow === true + ? base.checkExpressionErrors.call(this, refDestructuringErrors, true) + : false; + }; + + prototype.toAssignable = function (node, isBinding, refDestructuringErrors) { + if (isBinding && node?.type === "ChainExpression") { + // An optional chain as an arrow's parameter (`(a?.b) => 1`): no + // binding element derives it — a derivation failure, where acorn's + // message is the assignment target's early error. + base.raise.call(this, node.start, OPTIONAL_CHAIN_TARGET); + } + if (isBinding || node === null) { + return base.toAssignable.call( + this, + node, + isBinding, + refDestructuringErrors, + ); + } + if (assignmentTargetIsEarlyError(node)) { + return node; + } + const depth = this.xspecAssignableDepth ?? 0; + this.xspecAssignableDepth = depth + 1; + try { + return base.toAssignable.call(this, node, false, refDestructuringErrors); + } finally { + this.xspecAssignableDepth = depth; + } + }; + + prototype.checkLValSimple = function (expr, bindingType, checkClashes) { + if ( + (bindingType ?? BIND_NONE) === BIND_NONE && + simpleTargetIsEarlyError(this, expr) + ) { + return; + } + base.checkLValSimple.call(this, expr, bindingType, checkClashes); + }; + + prototype.parseVarId = function (decl, kind) { + base.parseVarId.call(this, decl, kind); + const declarator = decl as { readonly id?: AcornNode; init?: unknown }; + // The token after a `const` binding identifier, where acorn raises a + // missing initializer (`unexpectedTokenIsEarlyError`) — a binding + // pattern's initializer the grammar requires. A declarator acorn reads + // no initializer for has the ESTree `init: null`. + this.xspecConstAfterBinding = + kind === "const" && declarator.id?.type === "Identifier" + ? this.start + : -1; + declarator.init = null; + }; + + prototype.parseLabeledStatement = function (...args) { + const saved = this.xspecLabelledItem; + // acorn has read the label and its colon: the labelled item begins. + this.xspecLabelledItem = this.start; + try { + return base.parseLabeledStatement.apply(this, args); + } finally { + this.xspecLabelledItem = saved; + } + }; + + prototype.parseClassElement = function (...args) { + const start = this.start; + const element = base.parseClassElement.apply(this, args); + if ( + element !== null && + element.kind === "constructor" && + element.key !== undefined && + this.xspecAccessorConstructors?.has(element.key.start) === true + ) { + // The element began with its `get` or `set` (a constructor is never + // static, async, or a generator). + checkAccessorParameters( + this, + this.input.slice(start, start + 3), + element.value, + ); + } + return element; + }; + + prototype.parseExportSpecifiers = function (...args) { + const specifiers = base.parseExportSpecifiers.apply(this, args); + const locals = (this.xspecExportLocals ??= new Set()); + for (const specifier of specifiers) { + if (specifier.local !== undefined) { + locals.add(specifier.local.start); + } + } + return specifiers; + }; + + // ECMAScript 2024, 15.7.1: a class static block's statements have the + // [Await] parameter set — `await x` there is an AwaitExpression, whose + // presence is an early error — where acorn reads `await` as an + // identifier. Elsewhere, acorn's own rule. + const baseCanAwait = Object.getOwnPropertyDescriptor( + Parser.prototype, + "canAwait", + )?.get; + Object.defineProperty(prototype, "canAwait", { + configurable: true, + get(this: DerivingParser): boolean { + for (let index = this.scopeStack.length - 1; index >= 0; index -= 1) { + const flags = this.scopeStack[index].flags; + if ((flags & SCOPE_CLASS_STATIC_BLOCK) !== 0) { + return true; + } + if ((flags & (SCOPE_CLASS_FIELD_INIT | SCOPE_FUNCTION)) !== 0) { + break; + } + } + return baseCanAwait?.call(this) === true; + }, + }); + + prototype.parseExprAtom = namingAs("reference", base.parseExprAtom); + prototype.parseBreakContinueStatement = namingAs( + "reference", + base.parseBreakContinueStatement, + ); + prototype.parseBindingAtom = namingAs("binding", base.parseBindingAtom); + prototype.parseFunction = namingAs("binding", base.parseFunction); + prototype.parseClassId = namingAs("binding", base.parseClassId); + + return Extended as unknown as typeof Parser; +} + +// --------------------------------------------------------------------------- +// Whitespace and comments alone (SPEC 14.20) +// --------------------------------------------------------------------------- + +// SPEC 14.20: whether `text` is whitespace and comments alone by the +// comment deletions — each block comment, `/*` through the nearest `*/`, +// deleted first, then each line comment, `//` through the first U+000A or +// U+000D (U+2028 and U+2029 notwithstanding; one not so ended stays put) — +// nothing but whitespace and line terminators remaining (JavaScript's `\s`, +// exactly ECMAScript 2024's WhiteSpace and LineTerminator). These are +// remark-mdx's own deletions (micromark-util-events-to-acorn), which decide +// the brace closing a container: one on a commented-out line closes none. +// Text they empty begins, past whitespace, with a comment's `/` (an ESM +// block's `import` or `export` never does), so their regular expressions — +// quadratic on a long text holding many an unclosed `/*` or unended `//` — +// run on such text alone. +export function commentDeletionsEmpty(text: string): boolean { + if (!/^\s*(?:\/|$)/u.test(text)) return false; + return /^\s*$/u.test( + text + .replace(/\/\*[\s\S]*?\*\//gu, "") + .replace(/\/\/[^\n\r]*(?:\r\n|\n|\r)/gu, ""), + ); +} + +/** + * SPEC 14.20: whether `text`, as spelled, lexes to no token under the + * grammar — comments ended where ECMAScript 2024 ends them (a line comment + * at U+2028 and U+2029 as well), whitespace and line terminators the + * edition's (U+00A0, U+FEFF, U+2028, and U+2029 included, U+0085 and + * U+200B not). Text failing to lex (an unterminated comment) does not. + */ +function lexesToNoToken(text: string): boolean { + try { + const tokenizer = mdxAcorn.tokenizer(text, { ...MDX_ACORN_OPTIONS }); + return tokenizer.getToken().type === tokTypes.eof; + } catch { + return false; + } +} + +/** acorn's parser as `deriveExpression` drives it (acorn 8.17). */ +interface ExpressionParser { + /** The current token: after an expression, the one following it. */ + readonly type: TokenType; + readonly start: number; + nextToken(): void; + parseExpression(): Expression; +} + +/** + * acorn's own `raise`, which always throws: a failure raised through it + * is a derivation failure, never an early error to suppress. + */ +const raiseDerivationFailure = ( + Parser.prototype as unknown as { + raise(this: ExpressionParser, pos: number, message: string): never; + } +).raise; + +/** + * SPEC 14.20: the one expression `input` derives from `pos` (acorn's + * `parseExpressionAt`), beside nothing that lexes to a token: where what + * follows it holds a token that the comment deletions hide — so remark-mdx + * would take what follows for whitespace and comments alone — the + * derivation fails at that token. What follows, the deletions not emptying + * it, is remark-mdx's own failure ("Unexpected content after expression"). + */ +function deriveExpression( + ParserClass: typeof Parser, + input: string, + pos: number, + options: Options, +): { readonly parser: ExpressionParser; readonly expression: Expression } { + const parser = new ( + ParserClass as unknown as new ( + options: Options, + input: string, + startPos: number, + ) => ExpressionParser + )(options, input, pos); + parser.nextToken(); + const expression = parser.parseExpression(); + if ( + parser.type !== tokTypes.eof && + commentDeletionsEmpty(input.slice(expression.end)) + ) { + raiseDerivationFailure.call(parser, parser.start, "Unexpected token"); + } + return { parser, expression }; +} + +// The acorn plugin judging whitespace and comments alone as SPEC 14.20 +// does: by the comment deletions and, as spelled, by lexing to no token. +// remark-mdx takes the deletions' verdict alone — content they empty is +// parsed as a whole Program (`parse`, its empty-expression path), and what +// follows a container's one expression passes when they empty it — while a +// `/*` inside a line comment reaches a later line's `*/`, hiding the tokens +// between from them (`{// /*` LF `x; y /* */` LF `}`). So `parse`, handed +// content the deletions empty but that holds a token (only the +// empty-expression path hands it such content: an ESM block begins with +// `import` or `export`, and remark-mdx refuses an attribute's such content +// before calling acorn — a refusal undone by respelling, +// `respellRefusedContent` in ./mdx-syntax-failure.ts), derives it as any +// other container content — one +// expression beside whitespace and comments alone, or none (14.20) — +// shaped as remark-mdx shapes a derived expression: a Program holding one +// ExpressionStatement. `parseExpressionAt` fails where what follows its +// expression holds a token (`{x // /*` LF `y /* */` LF `}`). +function judgeCommentsAsSpelled(BaseParser: typeof Parser): typeof Parser { + // One more derivation level, so the class handed in stays untouched. + const Extended = class extends (BaseParser as unknown as new ( + ...args: never[] + ) => object) {} as unknown as typeof Parser; + + Extended.parseExpressionAt = function ( + this: typeof Parser, + input: string, + pos: number, + options: Options, + ): Expression { + return deriveExpression(this, input, pos, options).expression; + }; + + Extended.parse = function ( + this: typeof Parser, + input: string, + options: Options, + ): Program { + if (!commentDeletionsEmpty(input) || lexesToNoToken(input)) { + return BaseParser.parse.call(this, input, options); + } + const { parser, expression } = deriveExpression(this, input, 0, options); + if (!commentDeletionsEmpty(input.slice(expression.end))) { + // What follows the expression, judged as remark-mdx judges it after + // `parseExpressionAt`. + raiseDerivationFailure.call( + parser, + expression.end, + "Unexpected content after expression", + ); + } + return { + type: "Program", + start: 0, + end: input.length, + body: [ + { + type: "ExpressionStatement", + expression, + start: 0, + end: input.length, + }, + ], + sourceType: "module", + comments: [], + } as unknown as Program; + }; + + return Extended; +} + +/** + * The parser remark-mdx is handed (SPEC 14.20): acorn with JSX, deriving the + * ECMAScript 2024 grammar alone — early errors excluded — and judging + * whitespace and comments alone by the comment deletions and, as spelled, + * by lexing to no token. + */ +export const mdxAcorn: typeof Parser = Parser.extend( + acornJsx(), + excludeEarlyErrors, + judgeCommentsAsSpelled, +); + +/** + * SPEC 2.7, 14.20: whether an expression container's content is the empty + * expression — whitespace and comments alone, judged by the comment + * deletions and, as spelled, by lexing to no token under the grammar + * (whitespace and line terminators ECMAScript 2024's: U+00A0, U+FEFF, + * U+2028, and U+2029 included, U+0085 and U+200B not). Content holding a + * token, or failing to lex (an unterminated comment), is no empty + * expression. + */ +export function isEmptyExpression(content: string): boolean { + return commentDeletionsEmpty(content) && lexesToNoToken(content); +} + +/** + * SPEC 14.20: the one expression MDX 3 derives from an expression + * container's or attribute value expression's content, as remark-mdx + * derives it — `parseExpressionAt` from the content's start, parentheses + * preserved (micromark's events-to-acorn) — so each node spans its own + * characters, first token through last, a parenthesized expression its + * parentheses included (SPEC 14's reference-spelling locations). Positions + * are UTF-16 offsets into `content`. Null where the content derives no + * expression, which a parsed document's container never holds. + */ +export function deriveContentExpression(content: string): Expression | null { + try { + return mdxAcorn.parseExpressionAt(content, 0, { + ...MDX_ACORN_OPTIONS, + preserveParens: true, + }); + } catch (error) { + if (error instanceof SyntaxError) { + return null; + } + throw error; + } +} diff --git a/src/core/mdx-syntax-failure.ts b/src/core/mdx-syntax-failure.ts new file mode 100644 index 00000000..d4cf095c --- /dev/null +++ b/src/core/mdx-syntax-failure.ts @@ -0,0 +1,2103 @@ +// Where a spec source fails to be well-formed MDX (SPEC 14's location rule +// for 14.20). +// +// SPEC 14: a syntax failure's offset is "the byte length of the longest +// whole-character prefix of the file with which some well-formed file +// begins — the file's byte length when the whole file is such a prefix". +// remark-mdx's stock grammar decides well-formedness (SPEC 14.20; +// IMPLEMENTATION), but where it throws is not that offset: it tries every +// `}` as a container's closing brace and, at the end of the file, throws +// the last attempt's failure; it judges a whole ESM block at once; it pairs +// JSX tags only after tokenizing the whole file; and it places a failure at +// a construct, not at the character that made the prefix unviable. So the +// offset is computed here from the failure the grammar throws, class by +// class: +// +// - braces (expression containers, attribute value expressions, spread +// attributes): the container's content, from its opening brace to the +// end of the file, is measured by its own grammar (`jsViablePrefix`) — +// `}` appended to the file makes the stock grammar hand that content, as +// it collects it, to the acorn recorded here; an attribute value's empty +// braces fail at the brace that closes them; a lazy line met inside a +// flow construct's container ends the content at the line before it, +// measured so, the lazy line failing only after content viable there, +// and so does a line leaving the block container that holds it, the +// grammar placing the end of the file at that line's start; content a +// `}` closes on a line that then fails — a flow expression's brace +// followed by text, its content spanning a blank line no paragraph +// holds — fails at the first character past the brace that the grammar +// rejects; and content opened by a construct begun right below a +// paragraph line holding an open text element or text-level expression — +// a brace line, a tag holding the brace, a brace after a tag or text on +// that line, a brace inside a tag or expression running on from it; the +// flow reading doomed, the text reading the paragraph's — fails at the +// latest at the terminator of its first line of container syntax and +// whitespace alone, and below an open expression, whose content that +// reading goes on with, where that content fails; +// - an ESM block: the block's text, as recorded, measured as a module of +// import and export declarations; +// - a JSX tag's own syntax: the stock tokenizer's character; +// - tag pairing: a closing tag where its name departs from the open +// element's; a construct ending, or an emphasis closing, with an element +// still open inside it — the longest prefix a closing tag (or one more +// character) can still complete, found by probing the grammar, a prefix +// ending inside a tag probed with the tag finished; +// - an element left open at the end of the file: the file's length. +// +// Since the grammar reports tag pairing only after tokenizing the whole +// file, the prefix before a computed offset may itself fail: it is parsed +// in turn, and the offset moves down until the prefix fails at its own end +// or not at all. +// +// Every text — the file and each probe — is parsed as SPEC 14.20 judges +// it, as `parseMdx` in mdx.ts parses: the stock grammar refuses an +// attribute's content its comment deletions empty before calling acorn, +// though that content may hold a token they hide, and such a refusal is +// undone by respelling (`parseAsJudged`, the last section below). + +import type { Options } from "acorn"; +import { tokTypes } from "acorn"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { jsViablePrefix } from "./js-syntax-failure.js"; +import type { JsContentKind } from "./js-syntax-failure.js"; +import { + commentDeletionsEmpty, + MDX_ACORN_OPTIONS, + mdxAcorn, +} from "./mdx-acorn.js"; +import { closingTagDivergence } from "./viable-prefix.js"; + +// --------------------------------------------------------------------------- +// The recording parser +// --------------------------------------------------------------------------- + +/** One call the stock grammar made to acorn: the text, and where it failed. */ +interface AcornCall { + readonly value: string; + /** acorn's failure position in `value`, before remark-mdx remaps it. */ + errorPos?: number; +} + +/** The calls of the parse in progress (one synchronous parse at a time). */ +let recording: AcornCall[] | null = null; + +function recorded<T>(value: string, run: () => T): T { + const call: AcornCall = { value }; + recording?.push(call); + try { + return run(); + } catch (error) { + const pos = (error as { readonly pos?: unknown }).pos; + if (typeof pos === "number") call.errorPos = pos; + throw error; + } +} + +/** `mdxAcorn`, each call recorded; what it derives is `mdxAcorn`'s. */ +const recordingAcorn = { + parse: (value: string, options: Options): unknown => + recorded(value, () => mdxAcorn.parse(value, options)), + parseExpressionAt: (value: string, pos: number, options: Options): unknown => + recorded(value, () => mdxAcorn.parseExpressionAt(value, pos, options)), +}; + +/** The production grammar (`mdx.ts`), its acorn calls recorded. */ +const analysisParser = unified() + .use(remarkParse) + .use(remarkMdx, { + acorn: recordingAcorn as unknown as typeof mdxAcorn, + acornOptions: MDX_ACORN_OPTIONS, + }) + .freeze(); + +/** A stock grammar failure, structurally (a `VFileMessage`). */ +interface MdxFailure { + readonly source?: unknown; + readonly ruleId?: unknown; + readonly reason?: unknown; + readonly place?: unknown; +} + +/** One parse: its failure (null when the text is well-formed) and calls. */ +interface RecordedParse { + readonly failure: MdxFailure | null; + readonly calls: readonly AcornCall[]; +} + +/** One parse as SPEC 14.20 judges its text (`analysisParse`). */ +interface AnalysisParse extends RecordedParse { + /** + * The text the grammar parsed and the calls were made on: the text + * judged, respelled where the stock grammar refused an attribute's + * content as empty although it holds a token (`respellRefusedContent`) — + * the same length and lines. + */ + readonly parsed: string; +} + +/** An unexpected throw (not the grammar's): the analysis gives up. */ +class AnalysisAbandoned extends Error {} + +/** One parse of `text` by the stock grammar, its acorn calls recorded. */ +function recordedParse(text: string): RecordedParse { + const calls: AcornCall[] = []; + recording = calls; + try { + analysisParser.parse(text); + return { failure: null, calls }; + } catch (error) { + const failure = error as MdxFailure; + if (typeof error !== "object" || error === null) { + throw new AnalysisAbandoned(); + } + if (typeof failure.source !== "string") throw new AnalysisAbandoned(); + return { failure, calls }; + } finally { + recording = null; + } +} + +/** + * One parse of `text` as SPEC 14.20 judges it — as `parseMdx` in mdx.ts + * parses (`parseAsJudged`): the stock grammar's, each refusal of an + * attribute's content as empty that holds a token undone by respelling. + */ +function analysisParse(text: string): AnalysisParse { + let parsed = text; + for (let round = 0; ; round += 1) { + const { failure, calls } = recordedParse(parsed); + const respelled = + failure === null || round >= RESPELLINGS + ? null + : respellRefusedContent(parsed, failure); + if (respelled === null) return { failure, calls, parsed }; + parsed = respelled.text; + } +} + +// --------------------------------------------------------------------------- +// Places and content mapping +// --------------------------------------------------------------------------- + +interface PointLike { + readonly offset?: unknown; +} + +/** A failure place's start offset (UTF-16), if it has one. */ +function placeStart(failure: MdxFailure): number | undefined { + const place = failure.place as + (PointLike & { readonly start?: PointLike }) | null | undefined; + const point = place?.start ?? place; + return typeof point?.offset === "number" ? point.offset : undefined; +} + +/** A failure place's end offset (UTF-16), if it has one. */ +function placeEnd(failure: MdxFailure): number | undefined { + const place = failure.place as + (PointLike & { readonly end?: PointLike }) | null | undefined; + const point = place?.end ?? place; + return typeof point?.offset === "number" ? point.offset : undefined; +} + +/** + * A text's lines as [start, end) pairs, terminators excluded — a + * terminator being CR, LF, or CRLF, Markdown's line endings, the ones the + * stock grammar keeps in collected content. + */ +function lineSpans(text: string): [number, number][] { + const spans: [number, number][] = []; + let start = 0; + let index = 0; + while (index < text.length) { + const code = text.charCodeAt(index); + if (code === 0x0a || code === 0x0d) { + spans.push([start, index]); + index += code === 0x0d && text.charCodeAt(index + 1) === 0x0a ? 2 : 1; + start = index; + } else { + index += 1; + } + } + spans.push([start, text.length]); + return spans; +} + +function lineIndexOf(spans: readonly [number, number][], at: number): number { + let line = 0; + while (line + 1 < spans.length && spans[line + 1][0] <= at) line += 1; + return line; +} + +/** + * Map an offset in collected content to the file, given an anchor known in + * both: on the anchor's own content line, by its distance from the anchor; + * on an earlier line, by that line's end — the stock grammar collects a + * container's content line by line, each line's leading container prefix + * and indentation dropped, so a content line ending at a line terminator + * is a suffix of its file line, the lines corresponding by their distance + * from the anchor's. A later line's start in the file is not known. + */ +function contentToFile( + text: string, + content: string, + anchorInContent: number, + anchorInFile: number, + at: number, +): number | undefined { + const contentLines = lineSpans(content); + const atLine = lineIndexOf(contentLines, at); + const anchorLine = lineIndexOf(contentLines, anchorInContent); + let mapped: number; + if (atLine === anchorLine) { + mapped = anchorInFile + (at - anchorInContent); + } else if (atLine < anchorLine) { + const fileLines = lineSpans(text); + const line = lineIndexOf(fileLines, anchorInFile) - (anchorLine - atLine); + if (line < 0) return undefined; + const [contentStart, contentEnd] = contentLines[atLine]; + mapped = + fileLines[line][1] - (contentEnd - contentStart) + (at - contentStart); + } else { + return undefined; + } + return mapped >= 0 && mapped <= text.length ? mapped : undefined; +} + +// --------------------------------------------------------------------------- +// Per-class offsets +// --------------------------------------------------------------------------- + +/** + * What the stock grammar drops before a container's content line: block + * quote markers and indentation (a list item's continuation is spaces). + */ +const LINE_SYNTAX = /^[ \t>]*$/; + +/** A container's content as collected, and the grammar it is judged by. */ +interface Collected { + readonly content: string; + readonly kind: JsContentKind; +} + +/** + * Where `content` begins in `head` when, as the stock grammar collects a + * container's content, it runs to `head`'s end — its lines each a suffix of + * the file line it corresponds to (the last ending `head`), each line after + * the first past what the grammar drops before a content line: container + * syntax and indentation (`LINE_SYNTAX`) — or undefined when it does not. + * Content ending with a line terminator was closed by a `}` that begins a + * line past its container syntax, so it runs to the end of no `head` whose + * last line holds more: an empty last content line is a suffix of every + * line. + */ +function contentStart(head: string, content: string): number | undefined { + const contentLines = lineSpans(content); + const fileLines = lineSpans(head); + const first = fileLines.length - contentLines.length; + if (first < 0) return undefined; + for (let line = 0; line < contentLines.length; line += 1) { + const [lineStart, lineEnd] = contentLines[line]; + const [fileStart, fileEnd] = fileLines[first + line]; + const fileLine = head.slice(fileStart, fileEnd); + const contentLine = content.slice(lineStart, lineEnd); + if ( + !fileLine.endsWith(contentLine) || + (line > 0 && + !LINE_SYNTAX.test( + fileLine.slice(0, fileLine.length - contentLine.length), + )) + ) { + return undefined; + } + } + const [start, end] = contentLines[0]; + return fileLines[first][1] - (end - start); +} + +/** + * Whether `content`, as the stock grammar collects a container's content, + * is the content of a container whose opening brace lies in `head` and + * which runs to `head`'s end: its lines, each a suffix of the file line it + * corresponds to (the last ending `head`), begin right after a `{`. + */ +function endsAtBrace(head: string, content: string): boolean { + const start = contentStart(head, content); + return start !== undefined && start > 0 && head.charAt(start - 1) === "{"; +} + +/** + * The content of the container a `}` appended to `head` falls in — the + * stock grammar collects it and hands it to acorn, a spread attribute's + * wrapped as `({…})` — or undefined when no container runs to `head`'s + * end (its block container, a list item or block quote, ended first). The + * grammar tokenizes flow before paragraph text, so a container closing + * there may be followed by other calls: the one sought is the call whose + * content maps to `head`'s end — in the text the grammar parsed, which a + * refusal's respelling may have changed (`analysisParse`), so the content + * is as spelled there: only leading comments differ, blanked to whitespace + * without a token or line moving, which leaves every measure taken of it + * below unchanged. + */ +function collectedAtEnd(head: string): Collected | undefined { + const { calls, parsed } = analysisParse(head + "}"); + const spelled = parsed.slice(0, head.length); + for (let index = calls.length - 1; index >= 0; index -= 1) { + const value = calls[index].value; + if (endsAtBrace(spelled, value)) { + return { content: value, kind: "expression" }; + } + const inner = value.slice(2, -2); + if ( + value.startsWith("({") && + value.endsWith("})") && + endsAtBrace(spelled, inner) + ) { + return { content: inner, kind: "spread" }; + } + } + return undefined; +} + +/** Line prefixes a line continuing a container's content may begin with. */ +const LINE_PREFIXES: readonly string[] = ["", " ", " ", "> "]; + +/** + * A line's leading block quote markers, list markers (each followed by a + * space, a tab, or the line's end), and indentation. + */ +const CONTAINER_RUN = /^(?:[ \t>]|(?:[-*+]|[0-9]{1,9}[.)])(?=[ \t]|$))*/; + +/** `spelled` with each list marker blanked: `- ` becomes two spaces. */ +function blankedListMarkers(spelled: string): string { + return spelled.replace(/[-*+]|[0-9]{1,9}[.)]/g, (marker) => + " ".repeat(marker.length), + ); +} + +/** + * The continuation prefix a container's content lines give: the leading + * block quote markers, list markers, and indentation of the last line of + * `text` holding more than those, list markers blanked as `containerPrefix` + * blanks them — `> - ` gives `> `, `- > ` gives ` > `; undefined when + * no line holds more. Inside nested containers (a list item in a block + * quote, a block quote in a list item) a line continues them all only so, + * which no fixed prefix spells (SPEC 14's location rule for 14.20) — however + * many lines of that syntax alone (the content's blank lines) follow it. + * The grammar judges every probe, so a wrong candidate (content spelled + * like a marker) only fails to collect. + */ +function contentLinePrefix(text: string): string | undefined { + const spans = lineSpans(text); + for (let line = spans.length - 1; line >= 0; line -= 1) { + const spelled = text.slice(spans[line][0], spans[line][1]); + const run = CONTAINER_RUN.exec(spelled)?.[0] ?? ""; + if (run.length < spelled.length) return blankedListMarkers(run); + } + return undefined; +} + +/** + * The content of the container `head`'s last line — spelled as it stands, + * so a blank line stays blank and a lone tag stays a flow tag — still + * belongs to: a `}` on a further line (after a continuation prefix) falls + * in it. The content returned ends with that last line, the further line's + * terminator and prefix cut off. Whichever prefix the `}` follows, the + * content through that last line is the same; the prefix the content's own + * lines give (`contentLinePrefix`) is tried first, then the fixed ones. An + * attribute's content that is whitespace alone so far is refused before + * acorn sees it, so the `}` is also tried after an `x`, a token on the + * further line that leaves the content through the last line unchanged. + */ +function collectedPastLine(head: string): Collected | undefined { + const own = contentLinePrefix(head); + const prefixes = + own === undefined + ? LINE_PREFIXES + : [own, ...LINE_PREFIXES.filter((prefix) => prefix !== own)]; + for (const prefix of prefixes) { + const probe = head + "\n" + prefix; + const collected = collectedAtEnd(probe) ?? collectedAtEnd(probe + "x"); + if (collected === undefined) continue; + const { content, kind } = collected; + const cut = Math.max(content.lastIndexOf("\n"), content.lastIndexOf("\r")); + if (cut === -1) continue; + const through = content.slice(0, cut).replace(/\r$/, ""); + return { content: through, kind }; + } + return undefined; +} + +/** The offset (in `head`) where collected content running to its end fails. */ +function measured(head: string, collected: Collected): number { + const { content, kind } = collected; + const viable = jsViablePrefix(content, kind); + if (viable >= content.length) return head.length; + return ( + contentToFile(head, content, content.length, head.length, viable) ?? + head.length + ); +} + +/** The line terminator (CR, LF, or CRLF) at `at`, or "" at none. */ +function terminatorAt(text: string, at: number): string { + if (text.startsWith("\r\n", at)) return "\r\n"; + const code = text.charCodeAt(at); + return code === 0x0a || code === 0x0d ? text.charAt(at) : ""; +} + +/** The line terminator (CR, LF, or CRLF) ending at `at`, or "" at none. */ +function terminatorBefore(text: string, at: number): string { + if (at >= 2 && text.startsWith("\r\n", at - 2)) return "\r\n"; + const code = text.charCodeAt(at - 1); + return code === 0x0a || code === 0x0d ? text.charAt(at - 1) : ""; +} + +/** The ends (at their terminators) of `text`'s lines from `from`'s on. */ +function lineEndsFrom(text: string, from: number): number[] { + let end = lineEndFrom(text, from); + const ends = [end]; + while (end < text.length) { + end = lineEndFrom(text, end + terminatorAt(text, end).length); + ends.push(end); + } + return ends; +} + +/** The lines past a failure's place probed one by one before galloping. */ +const LINEAR_LINES = 8; + +/** + * The last of `count` lines, numbered from 0, past which `collect` still + * collects a container's content (`collectedPastLine`), with that content — + * line 0 known to collect `first`. Collection is monotone: a container + * still open past a line is open past every line before it. So the lines + * are searched, not walked: one by one through the first `LINEAR_LINES` + * (content mostly ends within a few lines of the place, and a probe past + * its end, trying every prefix, costs several probes within it), then + * galloping by doubling steps to the first line that collects nothing, and + * bisecting between it and the last line that collects — O(log count) + * probes, each a few parses of the prefix, where a walk costs a probe per + * line of content and a bounded walk ends early inside longer content + * (SPEC 14's location rule for 14.20). + */ +function lastCollectedLine( + count: number, + collect: (line: number) => Collected | undefined, + first: Collected, +): { readonly line: number; readonly collected: Collected } { + let low = 0; + let collected = first; + let high = count; + let step = 1; + while (low + 1 < high) { + const line = Math.min(low + step, high - 1); + const probed = collect(line); + if (probed === undefined) { + high = line; + break; + } + low = line; + collected = probed; + if (low >= LINEAR_LINES) step *= 2; + } + while (low + 1 < high) { + const line = low + Math.floor((high - low) / 2); + const probed = collect(line); + if (probed === undefined) { + high = line; + } else { + low = line; + collected = probed; + } + } + return { line: low, collected }; +} + +/** + * A container the stock grammar reached the end of without its content + * deriving (an expression container, attribute value expression, or spread + * attribute; `placed` the grammar's place for the failure, inside it): its + * content, from the opening brace on, measured by its own grammar. When it + * runs to the end of `text`, a `}` appended there collects it. When the + * block container holding it ended first, or a line past the place ends the + * content otherwise (a brace closing it that text follows), the content + * ends with the last line past which a `}` on a further line still falls + * in the container, however far past the place (`lastCollectedLine`) — + * and, the content viable through that line and its terminator, the failure + * is the next line's first character that no continuation of the content + * could begin with, or past the brace closing it there + * (`measuredThroughLine`). + */ +function openContainerOffset(text: string, placed: number | undefined): number { + const closed = analysisParse(text + "}"); + if ( + closed.failure !== null && + closed.failure.ruleId === "unexpected-empty-expression" + ) { + // The attribute open at the end holds whitespace and comments alone — + // content that holds a token is respelled, never refused so + // (`analysisParse`) — which a token and its closing brace complete: + // the whole file is viable, but for the text reading's bound (the + // refusal is placed where the content opens). + return textReadingBound(text, placeStart(closed.failure), text.length); + } + const atEnd = collectedAtEnd(text); + if (atEnd !== undefined) { + return textReadingBound( + text, + openingOf(text, atEnd), + measured(text, atEnd), + ); + } + if (placed === undefined) return 0; + const ends = lineEndsFrom(text, placed); + const collect = (line: number): Collected | undefined => + collectedPastLine(text.slice(0, ends[line])); + const first = collect(0); + if (first === undefined) { + // The line holding the failure's place is the container's, whatever + // follows it. + const same = collectedAtEnd(text.slice(0, ends[0])); + return same === undefined + ? lineLeavingOffset(text, placed) + : measuredThroughLine(text, ends[0], same, 0); + } + const last = lastCollectedLine(ends.length, collect, first); + return measuredThroughLine(text, ends[last.line], last.collected, 0); +} + +/** + * A container whose content the grammar ended at the start of the line + * holding `placed`, no line from there on its own: a line that leaves the + * content's block container — beginning a new list item or block quote, or + * a sibling of the list item holding the content — ends the content at the + * line before it (micromark's `closeFlow`), the end of the file placed at + * this line's start. The content runs through the line before, collected + * as an open container's is (`collectedPastLine`), and is measured as that + * container's is (`measuredThroughLine`): this line's container syntax may + * still continue the content — a lone `>` is a blank line of a list item in + * a block quote — so the failure is its first character that no + * continuation of the content could begin with (SPEC 14's location rule for + * 14.20). `placed` itself where it lies elsewhere or nothing is collected. + */ +function lineLeavingOffset(text: string, placed: number): number { + const terminator = terminatorBefore(text, placed); + if (terminator === "") return placed; + const end = placed - terminator.length; + const head = text.slice(0, end); + const collected = collectedPastLine(head) ?? collectedAtEnd(head); + if (collected === undefined) return placed; + return measuredThroughLine(text, end, collected, 0); +} + +/** + * A container's content collected through the line ending at `end` (at + * its terminator), measured by its own grammar: where it fails within, that + * offset; where it cannot take the line's terminator, `end`; otherwise the + * next line's first character — from `from` on, where that lies further — + * other than those a continuation of the content could still begin with, + * or, where that line's `}` closes the content, the first character past + * it the grammar rejects (`pastClosingBrace`) — each bounded where the + * construct holding the content's brace begins right below a paragraph + * line holding an open text element (`textReadingBound`). + */ +function measuredThroughLine( + text: string, + end: number, + collected: Collected, + from: number, +): number { + const opening = openingOf(text.slice(0, end), collected); + return textReadingBound( + text, + opening, + flowMeasuredThroughLine(text, end, collected, from, opening), + ); +} + +/** + * `measuredThroughLine` as a flow construct's content goes on: past blank + * lines, and past a closing brace only by what follows it. + */ +function flowMeasuredThroughLine( + text: string, + end: number, + collected: Collected, + from: number, + opening: number | undefined, +): number { + const within = measured(text.slice(0, end), collected); + if (within < end) return within; + const terminator = terminatorAt(text, end); + const { content, kind } = collected; + if ( + jsViablePrefix(content + terminator, kind) < + content.length + terminator.length + ) { + return end; + } + // The next line may still continue the content — a lazy line, deeper + // indentation, a block quote's marker — character by character until + // it has become something else (a new list item, a blank line) or its + // `}` has closed the content. + let at = Math.max(end + terminator.length, from); + const bound = Math.min(text.length, at + PROBE_REACH); + while (at < bound && terminatorAt(text, at) === "") { + if (continues(text.slice(0, at + 1), opening, kind)) { + at += 1; + } else if ( + text.charAt(at) === "}" && + opening !== undefined && + closesAt(text, at, opening, kind) + ) { + return pastClosingBrace(text, at); + } else { + break; + } + } + return at; +} + +/** + * Where collected content running to `head`'s end opens in `head` (right + * after its opening brace), or undefined where that is not known. + */ +function openingOf(head: string, collected: Collected): number | undefined { + const { content } = collected; + return contentToFile(head, content, content.length, head.length, 0); +} + +/** + * Whether the `}` at `brace` closes the container whose content, of + * `kind`, opens at `opening`: the grammar tries every `}` and closes the + * container at the first whose content derives, so the content before this + * one is collected at it (a `}` appended there falls in the container) and + * none runs past it (a `}` appended past it falls in no content opening + * there). + */ +function closesAt( + text: string, + brace: number, + opening: number, + kind: JsContentKind, +): boolean { + const collectedTo = (end: number): boolean => { + const head = text.slice(0, end); + const collected = collectedAtEnd(head); + return ( + collected !== undefined && + collected.kind === kind && + openingOf(head, collected) === opening + ); + }; + return collectedTo(brace) && !collectedTo(brace + 1); +} + +/** How deeply scans past closing braces may nest (through `goesOn`). */ +const PAST_BRACE_DEPTH = 4; + +/** The scans past closing braces in progress (`goesOn`). */ +let pastBraceDepth = 0; + +/** + * A container's content closed by the `}` at `brace`, the line it closes + * on having gone on so far only as its content: the prefix through the + * brace is viable (a line ending, or `>` after a tag's attribute, may + * follow), and the failure is the first character past it that the + * grammar, reading the prefix through that character, rejects (`goesOn`) — + * past a flow expression's closing brace, a flow construct goes on only + * with whitespace, tags, and expressions after tags before its line ends, + * and the paragraph the grammar reads its line as otherwise cannot hold + * content spanning a blank line (SPEC 14's location rule for 14.20). A tag + * or expression past the brace may go on to further lines, whose container + * syntax the prefix may end inside (`goesOnPrefixed`). + */ +function pastClosingBrace(text: string, brace: number): number { + const prefix = contentLinePrefix(text.slice(0, brace + 1)); + const bound = Math.min(text.length, brace + 1 + PROBE_REACH); + let at = brace + 1; + while (at < bound) { + const head = text.slice(0, at + 1); + if (!goesOn(head, brace) && !goesOnPrefixed(head, brace, prefix)) break; + at += 1; + } + return at; +} + +/** + * Whether `head`, whose last line — past the one holding the `}` at + * `brace` — spells container syntax alone so far, goes on (`goesOn`) once + * that line spells the rest of the container prefix the brace's line gives + * (`prefix`; `prefixRests`), then `x` or nothing: the grammar places the + * end of a file ending so at that line's start, where no construct's + * content has begun (a lone space of a list item's two, a block quote's + * `>` short of its list item's indentation). + */ +function goesOnPrefixed( + head: string, + brace: number, + prefix: string | undefined, +): boolean { + if (prefix === undefined) return false; + const lineStart = + Math.max(head.lastIndexOf("\n"), head.lastIndexOf("\r")) + 1; + const spelled = head.slice(lineStart); + if ( + lineStart <= brace || + (CONTAINER_RUN.exec(spelled)?.[0] ?? "").length < spelled.length + ) { + return false; + } + return prefixRests(prefix, spelled).some( + (rest) => + (rest !== "" && goesOn(head + rest, brace)) || + goesOn(head + rest + "x", brace), + ); +} + +/** + * Whether `head`, a prefix of the file ending past a `}` at `brace` that + * closed a container, goes on as far as the grammar's reading tells: it + * derives, fails only by tag pairing (located by the checks of prefixes, + * `hiddenFailure`), or fails by a construct's class only at or after its + * end (`classOffset`). A failure placed at or before the brace belongs to + * a reading that gave way: the construct holding the brace failed on what + * follows it — a flow expression on text after it — and the paragraph the + * grammar reads instead failed before it, content spanning a blank line + * being no text expression's. Nested scans (a probe's own failure past a + * later closing brace) are bounded by `PAST_BRACE_DEPTH`, beyond which a + * failure counts by its place alone. + */ +function goesOn(head: string, brace: number): boolean { + const { failure, calls } = analysisParse(head); + if (failure === null || failure.source === "mdast-util-mdx-jsx") return true; + const start = placeStart(failure); + if (start === undefined || start <= brace) return false; + if (pastBraceDepth >= PAST_BRACE_DEPTH) return start >= head.length; + pastBraceDepth += 1; + try { + return classOffset(head, failure, calls) >= head.length; + } finally { + pastBraceDepth -= 1; + } +} + +// The stock grammar tries a line whose content begins with `{` or `<` as a +// flow construct first — a flow expression, a flow tag — which interrupts +// the paragraph above it, and gives the line to that paragraph, the brace +// a text expression's or a text tag's, only where text after the +// construct on its line makes it no flow construct (an attempt meeting the +// end of the file or a lazy line throws, and a tag, once past `<` and a +// character other than a space or a line ending, is read as a tag in +// either reading). Below a paragraph line holding an open text element the +// flow reading never derives: the paragraph it interrupts ends with the +// element open. The text reading alone is left, and a paragraph holds no +// line of container syntax and whitespace alone — such a line is blank, or +// begins a block quote, which interrupts a paragraph — so neither does its +// text expression or text tag, whatever a flow construct's content may +// span. The same holds for a brace further along the line below the +// paragraph line, after a tag or text, and for a brace on a later line +// inside a tag or expression begun on that line: the paragraph, once past +// its line, cannot end inside a construct unfinished there, so it holds +// the brace. Where the content is still open at such a line, no completion +// of a prefix past that line's terminator derives (SPEC 14's location rule +// for 14.20); a failure the flow reading's measures place before it fails +// the text reading too, the two collecting the same content there. A code +// span, a link title, or a definition's label or title opened in the +// paragraph may close past the brace, hiding it — and the element — in the +// text reading (`[a <S id="s">` LF `` {` `` LF `]: u` LF LF `` `} b `` +// derives, a definition), so a paragraph holding a backtick or a bracket +// is left to the flow reading's measures; one opened past the paragraph +// line, before the brace, is the grammar's own reading there, a line that +// does not begin with `{` or `<` being text alone. +// +// Below a paragraph line holding an open text-level expression — a text +// expression, or an attribute value expression or spread attribute of a +// text tag — the flow reading is doomed alike, and in the text reading the +// line below is the expression's content, the brace no container's: the +// two readings collect different content, so the text reading's is +// collected as that reading has it, the line below respelled so that no +// flow construct begins it (`textExpressionBound`). A code span, link, or +// definition hiding the expression ends by the paragraph's end, so that +// probe sees it. + +/** The stock grammar's report of an element a paragraph's end left open. */ +const PARAGRAPH_LEFT_OPEN = /before the end of `paragraph`$/; + +/** The end of the line holding `from` (its terminator's start). */ +function lineEndFrom(text: string, from: number): number { + let at = from; + while (at < text.length && terminatorAt(text, at) === "") at += 1; + return at; +} + +/** The start of the line whose end (its terminator's start) is `end`. */ +function lineStartAt(text: string, end: number): number { + let at = end; + while (at > 0 && terminatorBefore(text, at) === "") at -= 1; + return at; +} + +/** + * Whether `failure`, the grammar's for a prefix ending at `end`, is the + * end of the file met inside a JSX tag or an expression — a construct the + * prefix leaves unfinished at its end. + */ +function endsInsideConstruct(failure: MdxFailure, end: number): boolean { + return ( + (failure.source === "micromark-extension-mdx-jsx" || + failure.source === "micromark-extension-mdx-expression") && + failure.ruleId === "unexpected-eof" && + placeStart(failure) === end + ); +} + +/** + * Whether `failure`, the grammar's for a prefix ending at `end`, is a + * paragraph ending there with a text element open — the paragraph holding + * no backtick or bracket (above). + */ +function leavesParagraphOpen( + text: string, + failure: MdxFailure, + end: number, +): boolean { + const reason = String(failure.reason); + const paragraphStart = placeStart(failure); + return ( + failure.source === "mdast-util-mdx-jsx" && + EXPECTED_CLOSING_TAG.test(reason) && + PARAGRAPH_LEFT_OPEN.test(reason) && + placeEnd(failure) === end && + paragraphStart !== undefined && + !/[`[\]]/.test(text.slice(paragraphStart, end)) + ); +} + +/** + * Whether `failure`, the grammar's for a prefix ending at `end` inside an + * expression (`endsInsideConstruct`), is a text-level one's — a text + * expression, or an attribute value expression or spread attribute of a + * text tag — held open by a paragraph (or heading) line ending at `end`, + * `terminator` the line's: the prefix through the terminator fails there + * too, the paragraph ending at the line, where a flow construct's content + * would run on past it (the end of the file placed past the terminator, or + * the empty line past it lazy). + */ +function holdsOpenTextExpression( + text: string, + failure: MdxFailure, + end: number, + terminator: string, +): boolean { + if (failure.source !== "micromark-extension-mdx-expression") return false; + const through = analysisParse(text.slice(0, end + terminator.length)); + return ( + through.failure !== null && + through.failure.source === "micromark-extension-mdx-expression" && + through.failure.ruleId === "unexpected-eof" && + placeStart(through.failure) === end + ); +} + +/** How many lines of a construct `openParagraphAbove` walks back over. */ +const CONSTRUCT_LINES = 64; + +/** + * A paragraph line above the construct holding a brace (its end), and what + * it holds open at its end: a text element, or a text-level expression + * whose content the lines below go on with in the text reading. + */ +interface ParagraphLine { + readonly end: number; + readonly open: "element" | "expression"; +} + +/** + * `openParagraphAbove`'s findings in the analysis in progress, by the text + * before the brace's line — all they depend on: the probes of one analysis + * ask of the same lines again and again, and each step of the walk parses + * a prefix. + */ +let paragraphsAbove: Map<string, readonly ParagraphLine[]> | null = null; + +/** + * The paragraph lines holding an open text element or text-level + * expression right above the line where the construct holding a brace + * begins — the brace's line starting at `lineStart`, or, where the prefix + * through the line above ends inside a JSX tag or an expression, the first + * of the lines such constructs run over, walking back line by line + * (above), at most `CONSTRUCT_LINES` of them: the first line found whose + * prefix ends inside a text-level expression a paragraph holds + * (`holdsOpenTextExpression`), walked over all the same, and the line where + * the walk ends if the grammar reads a paragraph ending there with a text + * element open (`leavesParagraphOpen`) — none where the walk meets a blank + * line, the file's first line, or a prefix the grammar reads otherwise + * first, or runs over more lines. + */ +function openParagraphAbove( + text: string, + lineStart: number, +): readonly ParagraphLine[] { + const key = text.slice(0, lineStart); + const known = paragraphsAbove?.get(key); + if (known !== undefined) return known; + const found = walkToOpenParagraph(text, lineStart); + paragraphsAbove?.set(key, found); + return found; +} + +/** `openParagraphAbove`'s walk. */ +function walkToOpenParagraph( + text: string, + lineStart: number, +): readonly ParagraphLine[] { + const found: ParagraphLine[] = []; + let start = lineStart; + for (let line = 0; line <= CONSTRUCT_LINES; line += 1) { + const terminator = terminatorBefore(text, start); + if (terminator === "") return found; + const end = start - terminator.length; + start = lineStartAt(text, end); + if (LINE_SYNTAX.test(text.slice(start, end))) return found; + const { failure } = analysisParse(text.slice(0, end)); + if (failure === null) return found; + if (!endsInsideConstruct(failure, end)) { + if (leavesParagraphOpen(text, failure, end)) { + found.push({ end, open: "element" }); + } + return found; + } + if ( + found.length === 0 && + holdsOpenTextExpression(text, failure, end, terminator) + ) { + found.push({ end, open: "expression" }); + } + } + return found; +} + +/** + * `result`, measured for the container whose content opens at `opening`, + * bounded by the text reading (above) where the construct holding the + * container's `{` begins right below a paragraph line holding an open text + * element or text-level expression (`openParagraphAbove`): below an open + * element, at the terminator of the first line past the brace's of + * container syntax and whitespace alone, where the content is still open + * at that line — a `}` ending the line before it falls in the container + * (the two readings collect the same content); below an open expression, + * whose content the text reading goes on with instead, where that content, + * as that reading collects it through the lines past the brace's before + * such a line and `result`, fails, or at that line's terminator + * (`textExpressionBound`). + */ +function textReadingBound( + text: string, + opening: number | undefined, + result: number, +): number { + if (opening === undefined || text.charAt(opening - 1) !== "{") { + return result; + } + let before = lineEndFrom(text, opening); + let blankEnd: number | undefined; + // The lines past the brace's up to `result`: `before` the end of the + // last one before the first of container syntax and whitespace alone + // (ending at `blankEnd`), the file's unterminated last line counting as + // neither where it is empty. + for (;;) { + const terminator = terminatorAt(text, before); + if (terminator === "") break; + const start = before + terminator.length; + const end = lineEndFrom(text, start); + if (end > result || start === text.length) break; + if (end < text.length && LINE_SYNTAX.test(text.slice(start, end))) { + blankEnd = end; + break; + } + before = end; + } + // An attribute's content that is whitespace alone so far is refused + // before acorn sees it, so the `}` is also tried after an `x`: no text + // past the line reopens a container a `}` on it closed. + const openAt = (head: string): boolean => { + const collected = collectedAtEnd(head); + return collected !== undefined && openingOf(head, collected) === opening; + }; + const head = text.slice(0, before); + let bound = result; + const paragraphs = openParagraphAbove(text, lineStartBefore(text, opening)); + for (const { end, open } of paragraphs) { + if (open === "expression") { + bound = Math.min(bound, textExpressionBound(text, end, before, blankEnd)); + } else if (blankEnd !== undefined && (openAt(head) || openAt(head + "x"))) { + bound = Math.min(bound, blankEnd); + } + } + return bound; +} + +/** + * The bound the text reading sets where a paragraph line ending at + * `paragraphEnd` holds a text-level expression open and the construct + * holding a brace begins on the line right below it: `before` the end of + * the last line to measure through, and `blankEnd` the end of the first + * line past the brace's of container syntax and whitespace alone, where + * there is one right after it. The flow reading of the line below — a flow + * expression or tag interrupting the paragraph — leaves the expression + * open at the paragraph's end, so it never derives, and so does a flow + * expression whose content never closes; in the text reading that line and + * the ones after it are the paragraph's, their characters the expression's + * content, which cannot span the blank line (SPEC 14's location rule for + * 14.20). That reading is probed with the line below respelled past its + * container syntax with U+00A0 — no flow construct begins with it, and to + * acorn it is whitespace, or a character of the string, comment, template, + * or JSX text it falls in — and the expression's content collected at a + * `}` appended at `before`: where it fails within, that offset; where it + * cannot take the line ending there, `before`; otherwise `blankEnd`, if + * any. `Infinity` where the probe collects no content of that expression: + * it closed before, or a code span, link, or definition begun before its + * brace and ending there hides it, or a line interrupts the paragraph. + */ +function textExpressionBound( + text: string, + paragraphEnd: number, + before: number, + blankEnd: number | undefined, +): number { + const below = paragraphEnd + terminatorAt(text, paragraphEnd).length; + const spelled = text.slice(below, lineEndFrom(text, below)); + const at = below + (CONTAINER_RUN.exec(spelled)?.[0] ?? "").length; + if (at > before) return Infinity; + const head = text.slice(0, at) + RESPELLED + text.slice(at, before); + const collected = collectedAtEnd(head); + if (collected === undefined) return Infinity; + const opening = openingOf(head, collected); + if (opening === undefined || opening > paragraphEnd) return Infinity; + const within = measured(head, collected); + if (within < head.length) return within > at ? within - 1 : within; + const terminator = terminatorAt(text, before); + const { content, kind } = collected; + if ( + jsViablePrefix(content + terminator, kind) < + content.length + terminator.length + ) { + return before; + } + return blankEnd ?? Infinity; +} + +/** + * A lazy line met inside a flow-position container's content — a flow + * expression, or an attribute value expression or spread attribute of a + * flow tag; `placed` the grammar's place for the failure, on the lazy line + * past the container prefix it matched there. The grammar throws at the + * lazy line without trying a brace beyond it, so the content before it may + * already have failed: when the content never derives, the grammar tries + * every `}` to the end of the file, and a file ending with a line ending in + * a block quote ends with an empty lazy line. The content runs through the + * line before the lazy one, collected as an open container's is with a `}` + * on a further line (`collectedPastLine`), and is measured as that + * container's is (`measuredThroughLine`): the failure is where it fails + * within, or its terminator where it cannot take that; only content viable + * through its terminator leaves the lazy line the failure — at its first + * character, past the prefix the grammar matched, that no continuation of + * the content could begin with (SPEC 14's location rule for 14.20). + */ +function lazyLineOffset(text: string, placed: number | undefined): number { + if (placed === undefined) return 0; + const lineStart = lineStartBefore(text, placed + 1); + const terminator = terminatorBefore(text, lineStart); + if (terminator === "") return placed; + const end = lineStart - terminator.length; + const head = text.slice(0, end); + const collected = collectedPastLine(head) ?? collectedAtEnd(head); + if (collected === undefined) return placed; + return measuredThroughLine(text, end, collected, placed); +} + +/** Line continuations a container's content may take (the `x` a probe). */ +const CONTINUATIONS: readonly string[] = [ + "x", + " x", + " x", + " x", + " x", + "> x", +]; + +/** + * What may follow a line spelled so far as `spelled` to continue the + * containers a continuation prefix (`prefix`) continues: the prefix's rest + * past what the line spells of it, then each of its suffixes, the whole + * first, since the line may have spelled any leading part of it, and + * spelled it otherwise (`> ` where the prefix spells `>>`). A suffix + * beginning inside a run of spaces and tabs is left out: the one beginning + * at the run's start continues wherever it does, since more indentation + * keeps a line in its containers (indented code is disabled). + */ +function prefixRests(prefix: string, spelled: string): string[] { + const rests = prefix.startsWith(spelled) + ? [prefix.slice(spelled.length)] + : []; + for (let from = 0; from < prefix.length; from += 1) { + if (from > 0 && /[ \t]{2}/.test(prefix.slice(from - 1, from + 1))) { + continue; + } + rests.push(prefix.slice(from)); + } + return rests; +} + +/** + * The continuations of `head`'s last line, partly spelled, to probe: the + * rests (`prefixRests`) of the prefix the content's lines before it give + * (`contentLinePrefix`), then the fixed ones, each followed by the probe's + * `x`. + */ +function continuationsOf(head: string): readonly string[] { + const lineStart = lineStartBefore(head, head.length); + if (lineStart === 0) return CONTINUATIONS; + const before = head.slice( + 0, + lineStart - terminatorBefore(head, lineStart).length, + ); + const own = contentLinePrefix(before); + if (own === undefined) return CONTINUATIONS; + const derived = prefixRests(own, head.slice(lineStart)).map( + (rest) => rest + "x", + ); + return [...new Set([...derived, ...CONTINUATIONS])]; +} + +/** + * Whether `head` — its last line partly spelled — still continues the + * content of the container opening at `opening`: some continuation of the + * line leaves that content collected, viable up to the probe's `x`. + */ +function continues( + head: string, + opening: number | undefined, + kind: JsContentKind, +): boolean { + if (opening === undefined) return false; + for (const continuation of continuationsOf(head)) { + const probe = head + continuation; + const collected = collectedAtEnd(probe); + if (collected === undefined || collected.kind !== kind) continue; + const { content } = collected; + if ( + openingOf(probe, collected) === opening && + jsViablePrefix(content, kind) >= content.length - 1 + ) { + return true; + } + } + return false; +} + +/** + * An attribute value whose braces hold whitespace and comments alone, + * placed at its content's start (`start`): SPEC 14.20 admits no empty + * expression there, so the failure is the brace the grammar found closing + * them — the first `}` whose prefix the grammar rejects so. + */ +function emptyAttributeOffset(text: string, start: number): number { + let brace = text.indexOf("}", start); + for (let tried = 0; brace !== -1 && tried < 64; tried += 1) { + const { failure } = analysisParse(text.slice(0, brace + 1)); + if ( + failure !== null && + failure.ruleId === "unexpected-empty-expression" && + placeStart(failure) === start + ) { + return brace; + } + brace = text.indexOf("}", brace + 1); + } + return start; +} + +/** + * A spread attribute whose content derived as an object literal other than + * one spread element: the content, as recorded, measured as a spread; the + * construct the grammar placed the failure at anchors it in the file. + */ +function spreadOffset( + text: string, + failure: MdxFailure, + calls: readonly AcornCall[], +): number { + const at = placeStart(failure); + const last = calls.at(-1); + if (at === undefined || last === undefined) return at ?? 0; + const content = last.value.slice(2, -2); + const program = mdxAcorn.parse(last.value, { ...MDX_ACORN_OPTIONS }) as { + readonly body: readonly { + readonly type: string; + readonly start: number; + readonly expression?: { + readonly type: string; + readonly properties?: readonly { readonly start: number }[]; + }; + }[]; + }; + const head = program.body[0]; + const properties = head?.expression?.properties; + const placed = + head === undefined || + head.type !== "ExpressionStatement" || + head.expression?.type !== "ObjectExpression" || + properties === undefined + ? head + : (properties[1] ?? properties[0]); + if (placed === undefined) return at; + // The grammar places a node before the content (the wrapping `({`) at + // the content's start. + const anchor = Math.max(0, placed.start - 2); + const viable = jsViablePrefix(content, "spread"); + return contentToFile(text, content, anchor, at, viable) ?? at; +} + +/** The ESM statement kinds (SPEC 14.20), as the stock grammar allows. */ +const ESM_STATEMENTS: ReadonlySet<string> = new Set([ + "ImportDeclaration", + "ExportNamedDeclaration", + "ExportDefaultDeclaration", + "ExportAllDeclaration", +]); + +/** + * An ESM block that fails: its text, as recorded, measured as a module of + * import and export declarations. The grammar may prefix the text with a + * `var` line naming earlier blocks' imports; blocks are never indented, so + * the text is the file's own from the block's start. + */ +function esmOffset( + text: string, + failure: MdxFailure, + calls: readonly AcornCall[], +): number { + const at = placeStart(failure); + const last = calls.at(-1); + if (at === undefined || last === undefined) return at ?? 0; + const prefix = last.value.startsWith("var ") + ? last.value.indexOf("\n") + 1 + : 0; + const block = last.value.slice(prefix); + let anchor: number | undefined; + if (failure.ruleId === "acorn") { + anchor = last.errorPos === undefined ? undefined : last.errorPos - prefix; + } else { + const program = mdxAcorn.parse(block, { ...MDX_ACORN_OPTIONS }) as { + readonly body: readonly { readonly type: string; start: number }[]; + }; + anchor = program.body.find((node) => !ESM_STATEMENTS.has(node.type))?.start; + } + if (anchor === undefined || anchor < 0 || anchor > at) return at; + return Math.min(text.length, at - anchor + jsViablePrefix(block, "module")); +} + +/** + * How `probe`, a prefix of the file ending at `at` plus a completion, + * completes: `"whole"` where it derives, `"paired"` where tag pairing fails + * only at or after `at`, `"class"` where a construct fails only there by + * its class (`classOffset`), null where it fails before `at`. A pairing + * failure in the probe counts by the construct it concerns (no probing + * within a probe). A class failure stops the grammar before it pairs tags, + * so a `"class"` completion leaves unjudged the pairing before `at`. + */ +function completion( + probe: string, + at: number, +): "whole" | "paired" | "class" | null { + const { failure, calls } = analysisParse(probe); + if (failure === null) return "whole"; + if (failure.source === "mdast-util-mdx-jsx") { + const place = String(failure.reason).startsWith( + "Expected a closing tag for", + ) + ? placeEnd(failure) + : placeStart(failure); + return place === undefined || place >= at ? "paired" : null; + } + return classOffset(probe, failure, calls) >= at ? "class" : null; +} + +/** + * Whether `probe`, a prefix of the file ending at `at` plus a completion, + * fails only at or after `at`: its grammar failure, if any, is the + * completion's or the end's (`completion`). + */ +function completes(probe: string, at: number): boolean { + return completion(probe, at) !== null; +} + +/** How far past a construct's end the probes below look. */ +const PROBE_REACH = 256; + +/** + * The continuation a line inside the element's container begins with: the + * text before the element on its opening line (`line`, `column` 1-based), + * list markers blanked — `> ` stays `> `, `- ` becomes two spaces. + */ +function containerPrefix(text: string, line: number, column: number): string { + const spans = lineSpans(text); + const span = spans[line - 1]; + if (span === undefined) return ""; + const before = text.slice(span[0], Math.min(span[1], span[0] + column - 1)); + return blankedListMarkers(before); +} + +/** + * The stock tokenizer's failure at the end of `head` where `head` ends + * inside a JSX tag (its reason); null where it does not. The grammar pairs + * a tag only once it has read it whole. + */ +function tagEndingAt(head: string): string | null { + const { failure } = analysisParse(head); + return failure !== null && + failure.source === "micromark-extension-mdx-jsx" && + placeStart(failure) === head.length + ? String(failure.reason) + : null; +} + +/** A quoted attribute value a tag is left inside: its quote. */ +const IN_QUOTED_VALUE = + / in attribute value, expected a corresponding closing quote `(.)`/u; +/** A tag left inside its name, or before it, where a closer may go on. */ +const IN_TAG_NAME = / (?:before|in) (?:member |local )?name,/u; +/** A closing tag a prefix ends inside, its name so far (the tag's own). */ +const CLOSING_SO_FAR = /<\/[ \t]*([^\s<>/{}"'=]*)$/u; +/** mdast-util-mdx-jsx's failures of a tag's own syntax, not its pairing. */ +const TAG_SYNTAX: ReadonlySet<string> = new Set([ + "unexpected-attribute", + "unexpected-self-closing-slash", +]); + +/** + * The ways to finish the JSX tag a prefix ends inside, by where the stock + * tokenizer leaves it (`reason`): a quoted attribute value closed by its + * quote, a missing value given as `""`, and a name begun after `.` or `:` + * given a character, before the tag ends as a self-closing and an opening + * tag; a tag left after its self-closing slash ended; any other ended by + * `>`, `/>`, and `x>`. + */ +function tagFinishes(reason: string): string[] { + const quote = IN_QUOTED_VALUE.exec(reason)?.[1]; + if (quote !== undefined) return [quote + "/>", quote + ">"]; + if (reason.includes(" before attribute value,")) return ['""/>', '"">']; + if (reason.includes(" after self-closing slash,")) return [">"]; + if (/ before (?:member |local |local attribute )name,/u.test(reason)) { + return ["x/>", "x>"]; + } + return [">", "/>", "x>"]; +} + +/** + * The rest of the closing tag of the element named `name` past what the + * end of `head` — inside a tag's name or before it — spells of it: past + * `<`, or past `</` and part of the name; null where `head` spells no part + * of it. + */ +function closerRest(head: string, name: string): string | null { + if (head.endsWith("<")) return "/" + name + ">"; + const spelled = CLOSING_SO_FAR.exec(head)?.[1]; + return spelled !== undefined && name.startsWith(spelled) + ? name.slice(spelled.length) + ">" + : null; +} + +/** How many lines back an earlier construct taking a tag in is sought. */ +const ABSORBER_LINES = 64; +/** How many backtick run lengths close such a code span, at most. */ +const ABSORBER_RUNS = 4; + +/** + * Finishes that take the JSX tag a prefix (`head`) ends inside into an + * earlier construct the rest of the file may still close, which makes its + * `<` no tag: a code span opened by a backtick run (closed by a run as + * long), a link resource past `](` (its pointy or raw destination, or its + * title, closed with the resource), and a definition past `]:` (its pointy + * destination closed, or its raw one ended) — sought in the lines since the + * last blank one, where such a construct would begin. + */ +function absorberFinishes(head: string): string[] { + const lines = head.split(/\r\n|\r|\n/u); + let first = lines.length - 1; + while ( + first > 0 && + lines.length - first < ABSORBER_LINES && + !/^[ \t>]*$/u.test(lines[first - 1] ?? "") + ) { + first -= 1; + } + const block = lines.slice(first).join("\n"); + const runs = new Set<number>(); + for (const [run] of block.matchAll(/`+/gu)) runs.add(run.length); + const finishes = [...runs] + .slice(0, ABSORBER_RUNS) + .map((length) => "`".repeat(length)); + if (block.includes("](")) finishes.push(">)", ")", '")', "')", "))"); + if (block.includes("]:")) finishes.push(">", ""); + return finishes; +} + +/** + * Whether `head`, a prefix of the file ending at `at` inside a JSX tag + * (`reason`, the stock tokenizer's failure there; `tagEndingAt`), goes on + * to close the element whose closing tag is `closer` (its container prefix + * `prefix`): the tag finished (`tagFinishes` — and, where the prefix ends + * inside a tag's name or before it, the rest of `closer`, or of the closing + * tag of the element the grammar pairs a finished closing tag with), then + * `closer`, directly or after a paragraph's `x`; or the tag taken into an + * earlier construct (`absorberFinishes`), then `closer` so or on the next + * line — derives or fails only by tag pairing at or after `at` (SPEC 14's + * location rule for 14.20). A failure of the finished tag's own syntax (a + * closing tag given an attribute or a self-closing slash) or of a + * construct's class judges nothing: the grammar has not paired the tag. + */ +function finishedTagCompletes( + head: string, + at: number, + reason: string, + closer: string, + prefix: string, +): boolean { + const tagStart = CLOSING_SO_FAR.exec(head)?.index; + const nameFinishes = IN_TAG_NAME.test(reason); + const finishes: string[] = []; + const add = (finish: string | null): void => { + if (finish !== null && !finishes.includes(finish)) finishes.push(finish); + }; + const addCloserRest = (name: string): void => { + if (nameFinishes) add(closerRest(head, name)); + }; + const judged = (probe: string): boolean => { + const { failure } = analysisParse(probe); + if (failure === null) return true; + if ( + failure.source !== "mdast-util-mdx-jsx" || + TAG_SYNTAX.has(String(failure.ruleId)) + ) { + return false; + } + const failed = String(failure.reason); + const place = failed.startsWith("Expected a closing tag for") + ? placeEnd(failure) + : placeStart(failure); + if (place === undefined || place >= at) return true; + // The finished closing tag's name departs from the open element's: + // that element's name may still finish it. + const expected = UNEXPECTED_CLOSING_TAG.exec(failed); + if (expected !== null && place === tagStart) { + addCloserRest(expected[1] ?? ""); + } + return false; + }; + // A closing tag is most likely finished by its element's name; an + // opening tag by ending it. + if (tagStart !== undefined) addCloserRest(closer.slice(2, -1)); + for (const finish of tagFinishes(reason)) add(finish); + addCloserRest(closer.slice(2, -1)); + for (let index = 0; index < finishes.length; index += 1) { + const finish = finishes[index] ?? ""; + if ( + judged(head + finish + "x" + closer) || + judged(head + finish + closer) + ) { + return true; + } + } + const tails = ["x" + closer, closer, "\n" + prefix + closer]; + return absorberFinishes(head).some((finish) => + tails.some((tail) => judged(head + finish + tail)), + ); +} + +/** + * An element left open when the construct holding it ended (at `end`): the + * longest prefix the element's closing tag still completes — directly, or + * after one more character so a line the construct's end hangs on can go + * on, and on a line that so far spells container syntax alone (a line + * start, a lone `>`, indentation short of a list item's) also after the + * rests of the element's container prefix (`prefixRests`): its rest past + * what the line spells, then each of its suffixes (SPEC 14's location rule + * for 14.20). The grammar judges every probe, so a wrong candidate only + * fails to complete. Past a line's container syntax, a prefix ending inside + * a JSX tag is judged with the tag finished (`finishedTagCompletes`), since + * the grammar pairs a tag only once it has read it whole: a closing tag + * that cannot pair — typed in a paragraph, where it closes no flow + * element, or naming no open element — fails there, unless a code span, + * link, or definition begun before it may still take it in. Past the + * container syntax of a line after the construct's end, a probe failing by + * any other construct's class (an expression, an attribute value's braces + * among them, that the line leaves open and the closer cannot finish) + * leaves the pairing unjudged, so it counts only where the line's content + * begins inside the element's container — where the closer, a paragraph's + * `x` and the closer, or such a line and the closer on the next line + * (after the container prefix) completes; content beginning outside it has + * ended the container with the element open. + */ +function constructEndOffset( + text: string, + end: number, + closer: string, + prefix: string, +): number { + const bound = Math.min(text.length, end + PROBE_REACH); + let judged = -1; + let inside = false; + const contentInside = (content: number): boolean => { + if (judged !== content) { + const before = text.slice(0, content); + judged = content; + inside = [closer, "x" + closer, "x\n" + prefix + closer].some((tail) => + completes(before + tail, content), + ); + } + return inside; + }; + for (let at = end + 1; at <= bound; at += 1) { + const head = text.slice(0, at); + const lineStart = + Math.max(head.lastIndexOf("\n"), head.lastIndexOf("\r")) + 1; + const spelled = head.slice(lineStart); + const run = CONTAINER_RUN.exec(spelled)?.[0] ?? ""; + let viable: boolean; + if (run.length === spelled.length) { + const bare = completion(head + closer, at); + if (bare === "whole") { + // The closer closes the element here, and after more spaces and + // tabs too where the line ends so far with none of a list marker's + // characters: more indentation keeps a line in its containers. + if (/(?:^|[ \t>])$/.test(spelled)) { + while (at < bound && /[ \t]/.test(text.charAt(at))) at += 1; + } + continue; + } + if (bare !== null) continue; + const completions = new Set(["x" + closer]); + for (const rest of prefixRests(prefix, spelled)) { + completions.add(rest + closer); + completions.add(rest + "x" + closer); + } + completions.delete(closer); + viable = [...completions].some((tail) => completes(head + tail, at)); + } else { + const counts = (how: ReturnType<typeof completion>): boolean => + how === "whole" || + how === "paired" || + (how === "class" && + (lineStart <= end || contentInside(lineStart + run.length))); + // A prefix ending inside a tag, which the closer meets as the tag's + // class, is judged with the tag finished (`finishedTagCompletes`). + const bare = completion(head + closer, at); + const reason = bare === "class" ? tagEndingAt(head) : null; + viable = + reason !== null + ? finishedTagCompletes(head, at, reason, closer, prefix) + : counts(bare) || counts(completion(head + "x" + closer, at)); + } + if (!viable) { + return at - 1; + } + } + return bound; +} + +/** + * A closing tag met inside a construct opened after its element (an + * emphasis crossing it): the longest prefix that, as it stands or with one + * more character, the grammar does not yet reject so — a prefix ending + * inside a tag judged with the tag finished, since the grammar pairs a tag + * only once it has read it whole. + */ +function crossingOffset(text: string, at: number, closer: string): number { + const bound = Math.min(text.length, at + PROBE_REACH); + for (let end = at + 1; end <= bound; end += 1) { + const head = text.slice(0, end); + const inTag = tagEndingAt(head) !== null; + const tail = head.slice(head.lastIndexOf("<")); + const completions = inTag + ? [ + ">", + "x>", + ...(closer.startsWith(tail) ? [closer.slice(tail.length)] : []), + ] + : ["", "x"]; + if (!completions.some((completion) => completes(head + completion, end))) { + return end - 1; + } + } + return bound; +} + +/** mdast-util-mdx-jsx's pairing failures, by message. */ +const UNEXPECTED_CLOSING_TAG = + /^Unexpected closing tag `[^`]*`, expected corresponding closing tag for `<([^`>]*)>`/; +const CROSSING_CLOSING_TAG = /^Expected the closing tag `<\/([^`>]*)>`/; +const EXPECTED_CLOSING_TAG = + /^Expected a closing tag for `<([^`>]*)>` \((\d+):(\d+)-\d+:\d+\)/; + +/** An element left open when the construct holding it ended. */ +interface LeftOpen { + /** Where the construct ended. */ + readonly end: number; + /** The element's closing tag. */ + readonly closer: string; + /** The element's container prefix (`containerPrefix`). */ + readonly prefix: string; +} + +/** + * The element a pairing failure of `text` reports left open when the + * construct holding it ended — the stock "Expected a closing tag for" with + * an end; null for any other pairing failure, and for an element open at + * the end of `text`. + */ +function leftOpen(text: string, failure: MdxFailure): LeftOpen | null { + if (failure.ruleId !== "end-tag-mismatch") return null; + const expected = EXPECTED_CLOSING_TAG.exec(String(failure.reason)); + const end = placeEnd(failure); + if (expected === null || end === undefined) return null; + return { + end, + closer: `</${expected[1]}>`, + prefix: containerPrefix(text, Number(expected[2]), Number(expected[3])), + }; +} + +function pairingOffset(text: string, failure: MdxFailure): number { + const reason = String(failure.reason); + const start = placeStart(failure); + if (failure.ruleId !== "end-tag-mismatch") return start ?? 0; + const unexpected = UNEXPECTED_CLOSING_TAG.exec(reason); + if (unexpected !== null && start !== undefined) { + // The open element's closing tag, where it could stand, shares the + // spelled one's characters up to their names' divergence; where it + // could not (the element is outside the construct the tag is in), no + // closing tag can, and the `/` fails. + const closer = `</${unexpected[1]}>`; + if (completes(text.slice(0, start) + closer, start + closer.length)) { + return closingTagDivergence(text, start, unexpected[1]); + } + let slash = start + 1; + while (slash < text.length && /\s/u.test(text.charAt(slash))) slash += 1; + return slash; + } + if (EXPECTED_CLOSING_TAG.test(reason)) { + const open = leftOpen(text, failure); + // Open at the end of the file: the whole file is a viable prefix. + if (open === null) return text.length; + return constructEndOffset(text, open.end, open.closer, open.prefix); + } + const crossing = CROSSING_CLOSING_TAG.exec(reason); + if (crossing !== null && start !== undefined) { + return crossingOffset(text, start, `</${crossing[1]}>`); + } + return start ?? 0; +} + +/** The offset of one stock failure of `text`, by its class. */ +function classOffset( + text: string, + failure: MdxFailure, + calls: readonly AcornCall[], +): number { + const start = placeStart(failure); + switch (failure.source) { + case "micromark-extension-mdx-expression": + switch (failure.ruleId) { + case "acorn": + case "unexpected-eof": + return openContainerOffset(text, start); + case "unexpected-empty-expression": + return emptyAttributeOffset(text, start ?? 0); + case "unexpected-lazy": + return lazyLineOffset(text, start); + case "non-spread": + case "spread-extra": + return spreadOffset(text, failure, calls); + default: + return start ?? 0; + } + case "micromark-extension-mdxjs-esm": + return esmOffset(text, failure, calls); + case "micromark-extension-mdx-jsx": + return start ?? text.length; + case "mdast-util-mdx-jsx": + return pairingOffset(text, failure); + default: + return start ?? 0; + } +} + +/** How many times the checks below may move the offset down. */ +const PREFIX_CHECKS = 64; +/** How many line starts a hidden-failure check backs up through. */ +const LINE_BACKUPS = 64; + +/** The start of the line holding the character before `at`. */ +function lineStartBefore(text: string, at: number): number { + let index = at - 1; + while (index > 0) { + const code = text.charCodeAt(index - 1); + if (code === 0x0a || code === 0x0d) break; + index -= 1; + } + return Math.max(0, index); +} + +/** + * A failure before `offset` that the grammar's order hides: it tokenizes + * first and pairs tags only after, so a prefix ending inside an unfinished + * construct — a container or tag the offset lies in — reports that + * construct's end and nothing before it. The prefix is cut at line starts, + * backing up until it no longer ends inside such a construct, and a failure + * it reports before the cut is returned — a tag-pairing failure located by + * the file's own characters, returned where it lies before `offset`; null + * when there is none. + */ +function hiddenFailure(text: string, offset: number): number | null { + let cut = offset; + for (let backup = 0; backup < LINE_BACKUPS; backup += 1) { + const prefix = text.slice(0, cut); + const parsed = analysisParse(prefix); + if (parsed.failure === null) return null; + if (parsed.failure.source === "mdast-util-mdx-jsx") { + // Pairing ran, so nothing before the cut failed to tokenize. An + // element the prefix leaves open when the construct holding it ends + // is located by the file's own characters, past the cut too: the + // line the cut begins may already have ended that construct (SPEC + // 14's location rule for 14.20). An element open at the prefix's end + // tells nothing of the file past the cut. + const open = leftOpen(prefix, parsed.failure); + if (open === null) { + const at = pairingOffset(prefix, parsed.failure); + return at < cut ? at : null; + } + const at = constructEndOffset(text, open.end, open.closer, open.prefix); + return at < offset ? at : null; + } + const at = classOffset(prefix, parsed.failure, parsed.calls); + if (at < cut) return at; + if (cut === 0) return null; + cut = lineStartBefore(text, cut); + } + return null; +} + +/** + * SPEC 14, 14.20: the UTF-16 length of the longest prefix of `text` — a + * spec source the stock grammar rejects — with which some well-formed MDX + * file begins; `fallback` where the analysis cannot proceed (a throw that + * is not the grammar's own failure, or nesting too deep to re-parse). + */ +export function mdxSyntaxFailureOffset(text: string, fallback: number): number { + paragraphsAbove = new Map(); + try { + const whole = analysisParse(text); + if (whole.failure === null) return fallback; + let offset = Math.max( + 0, + Math.min(text.length, classOffset(text, whole.failure, whole.calls)), + ); + for (let check = 0; check < PREFIX_CHECKS; check += 1) { + const earlier = hiddenFailure(text, offset); + if (earlier === null || earlier >= offset) break; + offset = Math.max(0, earlier); + } + return offset; + } catch (error) { + if ( + error instanceof AnalysisAbandoned || + error instanceof RangeError || + error instanceof SyntaxError + ) { + return fallback; + } + throw error; + } finally { + paragraphsAbove = null; + } +} + +// --------------------------------------------------------------------------- +// The grammar's refusal of an attribute's content as empty (SPEC 14.20) +// --------------------------------------------------------------------------- +// +// SPEC 14.20 judges whether brace content is whitespace and comments alone +// by the comment deletions and, as spelled, by lexing to no token, and the +// braces of an attribute value or spread attribute admit no empty +// expression. remark-mdx takes the deletions' verdict alone: it refuses an +// attribute's content they empty before calling acorn — the +// `unexpected-empty-expression` of micromark-util-events-to-acorn, thrown +// through the whole parse — though such content may hold a token they +// hide: a `/*` inside a line comment reaching a later line's `*/`, a line +// comment the grammar ends at U+2028 or U+2029 while the deletions run on +// to the next LF or CR. Such content is no empty expression: it derives +// one expression beside whitespace and comments alone, or the brace closes +// no container (SPEC 14.20). The refusal is undone by respelling the text +// and parsing again: the refused content's leading comments — those among +// the whitespace before its first token, or before the character at which +// it fails to lex — are blanked to U+00A0, their line terminators kept. +// The deletions then leave that token or character in place, so the +// grammar hands the content to acorn (`mdxAcorn`), which judges it as +// SPEC 14.20 does; what follows the leading comments — whose comments the +// deletions judge (what follows the one expression) — stays as spelled. +// No offset moves and the content lexes to the same tokens (U+00A0 is +// ECMAScript whitespace), and no Markdown line changes: U+00A0 is neither +// a line ending nor Markdown's space or tab, so every line keeps its +// container prefix, its indentation, and its blankness. A brace inside +// those comments, which the grammar tried to no avail (the refused brace +// is the first whose content the deletions empty), is no longer tried. +// Content that is whitespace and comments alone keeps the refusal, located +// at its brace (`emptyAttributeOffset`). + +/** How many refusals one parse may undo — one per attribute at most. */ +const RESPELLINGS = 1024; + +/** How many braces past a refused content's start are searched. */ +const REFUSAL_BRACES = 4096; + +/** The braces tried one by one before the search halves. */ +const LINEAR_BRACES = 4; + +/** The character a respelling writes (SPEC 14.20 whitespace). */ +const RESPELLED = "\u00a0"; + +/** A text respelled so the stock grammar judges it as SPEC 14.20 does. */ +interface Respelling { + /** The respelled text: the same length and line endings. */ + readonly text: string; + /** Each respelled offset (UTF-16), with the character it held. */ + readonly originals: ReadonlyMap<number, string>; +} + +/** + * The content of the attribute whose content begins at `place`, from there + * to the brace at `brace`, as the stock grammar collects it when it tries + * that brace — undefined when it never does, having refused an earlier + * brace's content. An `x` spelled before the brace makes the grammar hand + * the content to acorn, which records it: no deletion empties content + * ending so. + */ +function contentBefore( + text: string, + place: number, + brace: number, +): string | undefined { + const head = text.slice(0, brace) + "x"; + const { calls } = recordedParse(head + "}"); + // micromark reads U+0000 as U+FFFD. + const spelled = head.replace(/\0/gu, "\ufffd"); + for (let index = calls.length - 1; index >= 0; index -= 1) { + const value = calls[index].value; + const contents = + value.startsWith("({") && value.endsWith("})") + ? [value, value.slice(2, -2)] + : [value]; + for (const content of contents) { + if (content.endsWith("x") && contentStart(spelled, content) === place) { + return content.slice(0, -1); + } + } + } + return undefined; +} + +/** + * The content the stock grammar refused, placed at `place`: the first brace + * past it whose content, as collected, the deletions empty, and that + * content. A single-line content is collected as spelled; otherwise the + * grammar is asked (`contentBefore`), brace by brace and then by halving — + * before the refused brace each content is collected and not emptied, past + * it none is collected. + */ +function refusedContent( + text: string, + place: number, +): { readonly brace: number; readonly content: string } | undefined { + const braces: number[] = []; + for ( + let at = text.indexOf("}", place); + at !== -1 && braces.length < REFUSAL_BRACES; + at = text.indexOf("}", at + 1) + ) { + braces.push(at); + } + if (braces.length === 0) return undefined; + let low = 0; + const line = text.slice(place, braces[0]); + if (!/[\n\r]/u.test(line)) { + const content = line.replace(/\0/gu, "\ufffd"); + if (commentDeletionsEmpty(content)) return { brace: braces[0], content }; + low = 1; + } + let high = braces.length - 1; + while (low <= high) { + const middle = low < LINEAR_BRACES ? low : low + ((high - low) >> 1); + const content = contentBefore(text, place, braces[middle]); + if (content === undefined) { + high = middle - 1; + } else if (commentDeletionsEmpty(content)) { + return { brace: braces[middle], content }; + } else { + low = middle + 1; + } + } + return undefined; +} + +/** + * The comments among the whitespace `content` begins with, as the grammar + * lexes it — each [start, end) — up to its first token or the character at + * which it fails to lex; null when it lexes to no token, being whitespace + * and comments alone. + */ +function leadingComments(content: string): [number, number][] | null { + const comments: [number, number][] = []; + try { + const tokenizer = mdxAcorn.tokenizer(content, { + ...MDX_ACORN_OPTIONS, + onComment: ( + _block: boolean, + _text: string, + start: number, + end: number, + ): void => { + comments.push([start, end]); + }, + }); + if (tokenizer.getToken().type === tokTypes.eof) return null; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + return comments; +} + +/** + * The file offset of each character of `content` — an attribute's content + * from `place` to the brace at `brace`, as the grammar collects it — or + * null where they disagree. The grammar collects each line after the first + * less its Markdown container prefix and indentation, so each content line + * is a suffix of its file line, and the lines correspond from the last. + */ +function contentPositions( + text: string, + place: number, + brace: number, + content: string, +): number[] | null { + const positions = new Array<number>(content.length); + let at = brace; + for (let index = content.length - 1; index >= 0; index -= 1) { + const code = content.charCodeAt(index); + if (code === 0x0a || code === 0x0d) { + // Past the file line's uncollected prefix, to its own ending. + while (at > place && text.charCodeAt(at - 1) !== code) at -= 1; + } + at -= 1; + const spelled = text.charCodeAt(at); + if ( + at < place || + (spelled !== code && !(spelled === 0 && code === 0xfffd)) + ) { + return null; + } + positions[index] = at; + } + return content.length === 0 || positions[0] === place ? positions : null; +} + +/** ECMAScript 2024's line terminators: LF, CR, U+2028, U+2029. */ +function isLineTerminator(code: number): boolean { + return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029; +} + +/** + * SPEC 14.20: `text` respelled where the stock grammar refused an + * attribute's content as empty (`failure`, placed at the content's start) + * although it holds a token or fails to lex — its leading comments blanked + * to U+00A0 — or null where the refusal stands: the content is whitespace + * and comments alone, or the failure is another. + */ +function respellRefusedContent( + text: string, + failure: MdxFailure, +): Respelling | null { + if ( + failure.source !== "micromark-extension-mdx-expression" || + failure.ruleId !== "unexpected-empty-expression" + ) { + return null; + } + const place = placeStart(failure); + if (place === undefined) return null; + try { + const refused = refusedContent(text, place); + if (refused === undefined) return null; + const leading = leadingComments(refused.content); + if (leading === null || leading.length === 0) return null; + const positions = contentPositions( + text, + place, + refused.brace, + refused.content, + ); + if (positions === null) return null; + const units = text.split(""); + const originals = new Map<number, string>(); + for (const [start, end] of leading) { + for (let index = start; index < end; index += 1) { + if (isLineTerminator(refused.content.charCodeAt(index))) continue; + const at = positions[index]; + originals.set(at, text.charAt(at)); + units[at] = RESPELLED; + } + } + return originals.size === 0 ? null : { text: units.join(""), originals }; + } catch (error) { + // Nesting too deep to probe: the refusal stands. + if (error instanceof AnalysisAbandoned || error instanceof RangeError) { + return null; + } + throw error; + } +} + +/** + * `parse` — remark-mdx's stock grammar with `mdxAcorn`, as parsed here — + * applied to `text` as SPEC 14.20 judges it: each refusal of an + * attribute's content as empty that holds a token undone by respelling + * (`respellRefusedContent`). The result comes with each respelled offset's + * original character (none where the grammar refused no such content); + * where the text is not well-formed, the last failure is thrown. + */ +export function parseAsJudged<T>( + text: string, + parse: (text: string) => T, +): { readonly result: T; readonly originals: ReadonlyMap<number, string> } { + let parsed = text; + const originals = new Map<number, string>(); + for (let round = 0; ; round += 1) { + try { + return { result: parse(parsed), originals }; + } catch (error) { + const respelled = + round < RESPELLINGS && typeof error === "object" && error !== null + ? respellRefusedContent(parsed, error as MdxFailure) + : null; + if (respelled === null) throw error; + for (const [at, original] of respelled.originals) { + if (!originals.has(at)) originals.set(at, original); + } + parsed = respelled.text; + } + } +} diff --git a/src/core/mdx.ts b/src/core/mdx.ts index 2559fd27..1a59e289 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -18,41 +18,60 @@ // conditions inside it go unreported. Within a parsed file, every detectable // condition is reported, with 14.2's own masking rule (14.2) applied. // -// Grammar widenings (SPEC 14.20): "well-formed MDX" is remark-mdx's grammar -// *as extended here* — the toolchain stays remark-mdx (IMPLEMENTATION Key -// libraries); each widening is a surgical, documented extension preserving -// exact source offsets, admitting source shapes that are valid xspec sources -// (SPEC 1–3) but that the stock grammar rejects: -// 1. Expression grammar (acorn): `xspecAcornExtension` below. -// 2. ESM block boundary: the stock ESM construct ends only at a blank line -// or EOF, so an import directly followed by a non-blank line feeds both -// lines to acorn and fails; `widenedEsmConstruct` below ends the block -// at the first line boundary where the accumulated text is a complete -// valid program. -// 3. Section-tag pairing: stock MDX pairs JSX tags inside one construct, -// so an opening tag with trailing same-line content whose closing tag -// sits on a later line ("Expected a closing tag … before the end of -// `paragraph`"), and content directly preceding a closing tag on its -// line, are rejected; `flatJsxTagExtension` below turns each tag token -// into a leaf node and the document builder pairs tags itself across -// construct boundaries. Genuinely malformed sources — unclosed or -// mismatched elements, bad expressions — still fail the parse (14.20). - -import type { Comment, Program } from "acorn"; -import { Parser, tokTypes } from "acorn"; -import acornJsx from "acorn-jsx"; +// Braces and ESM blocks (SPEC 14.20): their content derives by ECMAScript +// 2024 with JSX alone, "decided by derivability alone" — the acorn remark-mdx +// is handed (`mdxAcorn`, ./mdx-acorn.ts) excludes every early error, so a +// file failing only such a rule is well-formed and reaches its ordinary +// outcome (`export { nope }` 14.16, `{1 = 2}` 14.16, `d={010}` 14.8), while +// TypeScript-only syntax (`BASE.a!`, `BASE.a as X`) is a derivation failure +// (SPEC 2.4). The static-reference analyzer reads each brace's content as MDX +// 3 derives it (`derivedContent` below), never the raw document slice. +// Whitespace and comments alone are judged as SPEC 14.20 judges them: where +// remark-mdx refuses an attribute's content as empty before calling acorn, +// though it holds a token the comment deletions hide, the refusal is undone +// by respelling the content's leading comments (`parseAsJudged`, +// ./mdx-syntax-failure.ts) and the collected content restored. +// +// Section tags are not widened: they pair exactly as stock MDX 3 pairs them +// (SPEC 14.20; 6.5 "Validation and refusals" spells the consequences out). +// remark-mdx's stock handlers build `mdxJsxFlowElement` and +// `mdxJsxTextElement` nodes, and an element opened inside a construct must +// close inside that same construct: a text-position tag closes within its +// paragraph ("Expected a closing tag for `<S>` … before the end of +// `paragraph`"), and a flow-position opening tag closes at a flow-position +// closing tag beside it, never inside a paragraph line or any other +// construct opened after it — otherwise the file is unparseable (14.20). The one addition beside those +// handlers records each tag token's exact span (`tagSpanExtension` below), +// which the stock nodes do not carry (SPEC 1.7: a section's opening and +// closing tags are its own characters); it changes no verdict. +// +// ESM blocks are not widened: remark-mdx's stock `mdxjsEsm` construct bounds +// them exactly as MDX 3 does (SPEC 14.20; 6.5 "Import edits" spells the +// bounds out). A block begins with `import` or `export` at a line's start, +// interrupts no paragraph (the line after a paragraph line is paragraph +// text), and runs to the next blank line or the file's end — so a line +// directly following a block's line joins the block — and the whole block +// must derive as one ECMAScript module holding import and export +// declarations only, else the file is unparseable (14.20). A block may +// therefore hold several declarations and JavaScript comments beside them; +// the comments are the block's, never MDX comments (SPEC 2.7, 3). + import remarkMdx from "remark-mdx"; import remarkParse from "remark-parse"; import { unified } from "unified"; import type { ByteRange } from "./bytes.js"; -import { Utf8Offsets } from "./bytes.js"; +import { sortByBytes, 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 { isEmptyExpression, MDX_ACORN_OPTIONS, mdxAcorn } from "./mdx-acorn.js"; +import { mdxSyntaxFailureOffset, parseAsJudged } from "./mdx-syntax-failure.js"; import { decodeSourceBytes } from "./source-text.js"; import { - containsControl, - containsWhitespace, - FORBIDDEN_SEGMENT_NAMES, + describeSegmentViolation, + idSegmentViolations, isWhitespaceCodePoint, + segmentViolation, } from "./text.js"; // --------------------------------------------------------------------------- @@ -66,9 +85,10 @@ import { */ export interface SpecAttributeValue { /** - * The attribute's value — the decoded characters of the quoted form - * (MDX decodes character references in attribute values; the declared ID - * and tag values are these decoded characters). + * The attribute's value: the characters between its quotes exactly as + * spelled in the source (SPEC 2.4) — no character reference or escape + * sequence is interpreted, so `id="a.b"` declares the segment + * `a.b`, never `a.b`. The parser's decoded value is never read. */ readonly value: string; /** Byte range of the value's characters, between (excluding) the quotes. */ @@ -86,7 +106,12 @@ export interface SpecAttributeValue { * spans and TypeScript sources), not here. */ export interface SpecDependencyAttribute { - /** Exact source characters between (excluding) the braces. */ + /** + * The content between (excluding) the braces as MDX 3 derives it + * (SPEC 14.20): the source characters, each Markdown container line + * prefix the expression spans (a block quote's `>`, a list item's + * indentation) blanked to spaces, so offsets are the document's own. + */ readonly expressionText: string; /** Byte range of `expressionText` within the file. */ readonly expressionRange: ByteRange; @@ -94,6 +119,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; @@ -101,10 +142,11 @@ export interface SpecDependencyAttribute { */ export interface SpecSection { /** - * The declared ID (SPEC 1.3), decoded — or null for the implicit root and - * for a section whose ID is unusable: missing (14.1) or declared in an - * invalid form (14.17, repeated or not a quoted string). A null ID on a - * non-root section always has a finding accounting for it. + * The declared ID (SPEC 1.3), as spelled between the `id` attribute's + * quotes (SPEC 2.4: no character reference interpreted) — or null for the + * implicit root and for a section whose ID is unusable: missing (14.1) or + * declared in an invalid form (14.17, repeated or not a quoted string). A + * null ID on a non-root section always has a finding accounting for it. */ readonly id: string | null; /** @@ -140,14 +182,45 @@ export interface SpecSection { readonly coverage: "required" | "none" | null; /** * The node's tags (SPEC 2.6): the whitespace-split tokens of the `tags` - * value with duplicates collapsed, in first-occurrence order; empty when - * the prop is absent or yields no tags. + * value as a tag set (SPEC 12.7) — byte order (SPEC 12.0), duplicates + * collapsed — on every surface that reports them and in graph data; + * empty when the prop is absent or yields no tags. */ readonly tags: readonly string[]; /** The `id` attribute's recorded value/spans, when usably declared. */ readonly idAttribute: SpecAttributeValue | null; - /** The `d` attribute's recorded expression span, when validly braced. */ - readonly dependency: SpecDependencyAttribute | null; + /** + * Every validly braced `d` attribute's recorded expression span, in tag + * order — empty where the tag spells none. SPEC 11.2 "Resolution": + * resolution is per spelling, whatever the validity of the attribute + * holding it, so each entry of every `d` attribute of a section that + * repeats the prop (14.17) resolves or not on its own, exactly as the + * entries of a single `d` do; a quoted or valueless `d` holds no entries. + */ + readonly dependencies: readonly SpecDependencyAttribute[]; + /** + * 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). */ @@ -156,13 +229,21 @@ export interface SpecEmbedding { readonly section: SpecSection; /** The whole expression container, braces included. */ readonly range: ByteRange; - /** Exact source characters between (excluding) the braces. */ + /** + * The content between (excluding) the braces as MDX 3 derives it + * (SPEC 14.20): the source characters, each Markdown container line + * prefix the expression spans (a block quote's `>`, a list item's + * indentation) blanked to spaces, so offsets are the document's own. + */ readonly expressionText: string; /** Byte range of `expressionText` within the file. */ readonly expressionRange: ByteRange; } -/** One MDX comment — an expression container holding only block comments (SPEC 2.7). */ +/** + * One MDX comment — the empty expression: an expression container whose + * content is whitespace and comments alone (SPEC 2.7, 14.20). + */ export interface SpecComment { /** The innermost section containing the comment (the root included). */ readonly section: SpecSection; @@ -179,19 +260,54 @@ export interface SpecImportStatement { } /** - * One top-level ESM block (imports; export statements are invalid and - * reported, SPEC 2.7 → 14.16). The block's range is what Markdown - * compilation removes (SPEC 3: imports are removed). + * One top-level ESM block as MDX 3 bounds it (SPEC 14.20): from a line + * start through the last line before the next blank line or the file's + * end, one ECMAScript module holding one or more import and export + * declarations and any JavaScript comments beside them. `imports` lists + * its import declarations in document order; an export statement is + * invalid and reported (SPEC 2.7 → 14.16). Markdown compilation removes + * each import declaration's own characters alone — the block's comments + * and whitespace stay as content (SPEC 3). `exportedBindings` lists, in + * document order, the identifiers the block's export statements declare — + * an import sharing one collides with it (SPEC 2.1, 2.4 → 14.15). */ export interface SpecEsmBlock { readonly range: ByteRange; readonly imports: readonly SpecImportStatement[]; + readonly exportedBindings: readonly SpecExportedBinding[]; +} + +/** + * One identifier a declaration held by an export statement binds (SPEC + * 2.1, 2.4, 2.7), with the construct binding it, as SPEC 14 locates a + * colliding declaration (1.7): a variable declarator by its own + * characters, its name or binding pattern through its initializer; a + * function or class declaration by its own characters, the leading + * `export` or `export default` excluded. + */ +export interface SpecExportedBinding { + readonly name: string; + readonly range: ByteRange; } /** 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). */ @@ -243,11 +359,43 @@ interface EstreeNode { readonly expression?: EstreeNode; readonly callee?: EstreeNode; readonly name?: string; + /** A `CallExpression`'s optional-call flag (`text?.(…)`). */ + readonly optional?: boolean; +} + +/** A JavaScript comment acorn records beside an expression's nodes. */ +interface EstreeComment { + /** "Block" or "Line". */ + readonly type: string; + /** Document-absolute UTF-16 offsets, as for `EstreeNode`. */ + readonly start?: number; + readonly end?: number; } interface EstreeProgram { readonly body?: readonly EstreeNode[]; - readonly comments?: readonly unknown[]; + readonly comments?: readonly EstreeComment[]; +} + +/** + * The estree shape of an ESM statement's declarations and binding + * patterns, as acorn builds them (SPEC 2.1, 2.4): identifiers carry the + * name the language reads. + */ +interface EstreeDeclarationNode { + readonly type: string; + readonly start?: number; + readonly end?: number; + readonly name?: string; + readonly id?: EstreeDeclarationNode | null; + readonly declaration?: EstreeDeclarationNode | null; + readonly declarations?: readonly EstreeDeclarationNode[]; + readonly properties?: readonly EstreeDeclarationNode[]; + readonly elements?: readonly (EstreeDeclarationNode | null)[]; + readonly left?: EstreeDeclarationNode; + readonly argument?: EstreeDeclarationNode; + /** A pattern property's value (a pattern node). */ + readonly value?: unknown; } interface MdxAttributeNode { @@ -263,702 +411,218 @@ interface MdxTreeNode { readonly type: string; readonly position?: MdxPosition; readonly children?: readonly MdxTreeNode[]; + /** + * An expression container's content as remark-mdx collected it: the + * characters between its braces, less the Markdown container line + * prefixes (`derivedContent`). + */ + readonly value?: string; /** JSX element name; null for a fragment. */ readonly name?: string | null; readonly attributes?: readonly MdxAttributeNode[]; - readonly data?: { readonly estree?: EstreeProgram }; - /** `xspecJsxTag` only: this leaf is a closing tag (`</…>`). */ - readonly close?: boolean; - /** `xspecJsxTag` only: this leaf is a self-closing tag (`<…/>`). */ - readonly selfClosing?: boolean; -} - -/** The thrown parse failure's observed shape (a unified VFileMessage). */ -interface ParseFailureLike { - readonly reason?: unknown; - readonly message?: unknown; - readonly line?: unknown; - readonly column?: unknown; - readonly place?: unknown; -} - -/** - * Structural view of acorn's internal parser surface (not in its public - * types), verified against acorn 8: the two overridden methods below and - * the state they touch. `parseSubscript` is acorn's per-access step in a - * member/call chain; `declareName` records one declared binding and - * raises on redeclaration after recording it. - */ -interface AcornInternalParser { - /** The current token's type and value. */ - type: unknown; - value: unknown; - /** True when a newline precedes the current token. */ - canInsertSemicolon(): boolean; - startNodeAt(pos: number, loc: unknown): { expression?: unknown }; - finishNode(node: object, type: string): unknown; - next(): void; - scopeStack: readonly unknown[]; - parseSubscript(...args: unknown[]): unknown; - declareName(name: string, bindingType: unknown, pos: number): void; -} - -/** - * SPEC 2.1/2.4 require certain files to parse so their defects report as - * import or reference findings rather than as parse failures: a postfix - * non-null assertion (`BASE!.auth`) is a *dynamic reference* (SPEC 2.4 → - * 14.8), and two imports binding one identifier are an *invalid import* - * (SPEC 2.1 → 14.15) — both conditions of a parsed file. Stock acorn - * rejects both outright, so the expression grammar is widened by exactly - * these two rules and nothing else; remark-mdx's grammar, so extended, - * defines well-formed MDX (IMPLEMENTATION; SPEC 14.20): - * - * - a postfix `!` with no preceding newline parses as a - * `TSNonNullExpression` chain node (the shared static-reference - * analyzer then classifies the reference dynamic, SPEC 2.4); - * - a module-scope redeclaration — duplicate import bindings — does not - * abort the parse (import validation reports 14.15, SPEC 2.1); acorn - * records the binding before raising, so swallowing the raise leaves - * consistent parser state. Redeclarations in inner scopes still fail. - */ -function xspecAcornExtension(BaseParser: typeof Parser): typeof Parser { - // One more derivation level, so the class handed in stays untouched. - const Extended = class extends (BaseParser as unknown as new () => object) {}; - const prototype = Extended.prototype as AcornInternalParser; - const superParseSubscript = prototype.parseSubscript; - const superDeclareName = prototype.declareName; - - prototype.parseSubscript = function ( - this: AcornInternalParser, - ...args: unknown[] - ): unknown { - if ( - this.type === tokTypes.prefix && - this.value === "!" && - !this.canInsertSemicolon() - ) { - const node = this.startNodeAt(args[1] as number, args[2]); - node.expression = args[0]; - this.next(); - return this.finishNode(node, "TSNonNullExpression"); - } - return superParseSubscript.apply(this, args); - }; - - prototype.declareName = function ( - this: AcornInternalParser, - name: string, - bindingType: unknown, - pos: number, - ): void { - try { - superDeclareName.call(this, name, bindingType, pos); - } catch (error) { - if ( - this.scopeStack.length === 1 && - error instanceof SyntaxError && - error.message.includes("has already been declared") - ) { - return; - } - throw error; - } + readonly data?: { + readonly estree?: EstreeProgram; + /** The root only: every JSX tag token's span (`tagSpanExtension`). */ + readonly xspecTagSpans?: TagSpans; }; - - return Extended as unknown as typeof Parser; } -/** The extended acorn: JSX plus the two SPEC-required widenings above. */ -const specAcorn = Parser.extend(acornJsx(), xspecAcornExtension); - -// --------------------------------------------------------------------------- -// Grammar widening 2: the ESM block boundary (SPEC 2.1, 3 → 14.20) -// --------------------------------------------------------------------------- - /** - * A parse failure raised by the widened grammar layers below, shaped like - * the unified `VFileMessage`s the stock toolchain throws so - * `parseFailureFinding` locates it the same way: `reason` plus a `place` - * that is either a point (`{line, column, offset}`) or a position - * (`{start, end}`), offsets in UTF-16 indices. + * The exact span of every JSX tag token in one parsed file — `<` through + * `>`, in UTF-16 indices — recorded by `tagSpanExtension` below. Tag + * tokens never overlap, so a tag is identified by its start as by its end. + * An element node spans its opening tag's start through its closing tag's + * end (a self-closing element: its one tag), so these maps give each + * element's tags (SPEC 1.7). */ -class MdxGrammarError extends Error { - readonly reason: string; - readonly place: object; - readonly line?: number; - readonly column?: number; - - constructor( - reason: string, - place: { line: number; column: number; offset: number } | MdxPosition, - ) { - super(reason); - this.reason = reason; - this.place = place; - if ("line" in place) { - this.line = place.line; - this.column = place.column; - } - } +interface TagSpans { + /** A tag's end, by its start. */ + readonly endByStart: Map<number, number>; + /** A tag's start, by its end. */ + readonly startByEnd: Map<number, number>; } -/** - * Structural view of micromark's tokenizer surface (not a declared - * dependency's public API), verified against micromark 4: the code - * classes, effects, and context members the widened ESM construct uses. - * Micromark represents line endings as the virtual codes CR −5, LF −4, - * CRLF −3, a tab as −2 followed by virtual spaces −1, and EOF as null. - */ -type MicromarkCode = number | null; -type MicromarkState = (code: MicromarkCode) => MicromarkState | undefined; - -interface MicromarkPoint { - readonly line: number; - readonly column: number; - readonly offset: number; -} - -interface MicromarkEffects { - enter(type: string): unknown; - exit(type: string): object; - consume(code: MicromarkCode): void; - check( - construct: object, - ok: MicromarkState, - nok: MicromarkState, - ): MicromarkState; -} - -interface MicromarkTokenizeContext { - readonly interrupt?: boolean; - readonly parser: { definedModuleSpecifiers?: string[] }; - now(): MicromarkPoint; - sliceSerialize( - range: { start: MicromarkPoint; end: MicromarkPoint }, - expandTabs?: boolean, - ): string; -} - -const isLineEnding = (code: MicromarkCode): boolean => - code !== null && code >= -5 && code <= -3; -const isAsciiAlpha = (code: MicromarkCode): boolean => - code !== null && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)); -const isMarkdownSpace = (code: MicromarkCode): boolean => - code === -2 || code === -1 || code === 32; - -/** - * Partial construct mirroring the stock extension's next-line-blank check - * (micromark-core-commonmark `blankLine` behind one consumed line ending): - * used only under `effects.check`, so everything it consumes is unwound. - */ -const blankLineBefore = { tokenize: tokenizeNextBlank, partial: true }; - -function tokenizeNextBlank( - effects: MicromarkEffects, - ok: MicromarkState, - nok: MicromarkState, -): MicromarkState { - return start; - - function start(code: MicromarkCode): MicromarkState | undefined { - effects.enter("lineEndingBlank"); - effects.consume(code); - effects.exit("lineEndingBlank"); - return inside; - } - function inside(code: MicromarkCode): MicromarkState | undefined { - if (isMarkdownSpace(code)) { - effects.enter("linePrefix"); - effects.consume(code); - return prefix; - } - return after(code); - } - function prefix(code: MicromarkCode): MicromarkState | undefined { - if (isMarkdownSpace(code)) { - effects.consume(code); - return prefix; - } - effects.exit("linePrefix"); - return after(code); - } - function after(code: MicromarkCode): MicromarkState | undefined { - return code === null || isLineEnding(code) ? ok(code) : nok(code); - } -} - -/** The acorn options the stock ESM construct uses (micromark-extension-mdxjs). */ -const ESM_ACORN_OPTIONS = { - ecmaVersion: 2024, - sourceType: "module", - locations: true, -} as const; - -/** The statement kinds the stock ESM construct admits in a block. */ -const ALLOWED_ESM_TYPES: ReadonlySet<string> = new Set([ - "ExportAllDeclaration", - "ExportDefaultDeclaration", - "ExportNamedDeclaration", - "ImportDeclaration", -]); - -/** The structural shape of an acorn parse failure (verified against acorn 8). */ -interface AcornFailureLike { +/** The thrown parse failure's observed shape (a unified VFileMessage). */ +interface ParseFailureLike { + /** The grammar component that raised it (absent on any other throw). */ + readonly source?: unknown; + readonly reason?: unknown; readonly message?: unknown; - readonly pos?: unknown; - readonly raisedAt?: unknown; - readonly loc?: { readonly line?: unknown; readonly column?: unknown }; -} - -type EsmParseAttempt = - | { - readonly ok: true; - readonly program: Program; - readonly comments: Comment[]; - readonly prefix: string; - readonly source: string; - } - | { - readonly ok: false; - readonly failure: AcornFailureLike; - readonly swallow: boolean; - readonly prefix: string; - }; - -/** - * Rebase an estree fragment parsed from a document slice onto document - * positions: every numeric `start`/`end` shifts by `offsetDelta`, every - * `loc` line by `lineDelta` (the slice is contiguous document text, so a - * plain shift is exact; the construct starts at column 1, so columns - * never shift). - */ -function rebaseEstree( - value: unknown, - offsetDelta: number, - lineDelta: number, -): void { - if (typeof value !== "object" || value === null) { - return; - } - if (Array.isArray(value)) { - for (const item of value) { - rebaseEstree(item, offsetDelta, lineDelta); - } - return; - } - const node = value as Record<string, unknown>; - if (typeof node["start"] === "number") { - node["start"] = node["start"] + offsetDelta; - } - if (typeof node["end"] === "number") { - node["end"] = node["end"] + offsetDelta; - } - const loc = node["loc"]; - if (typeof loc === "object" && loc !== null) { - for (const key of ["start", "end"]) { - const point = (loc as Record<string, unknown>)[key]; - if (typeof point === "object" && point !== null) { - const record = point as Record<string, unknown>; - if (typeof record["line"] === "number") { - record["line"] = record["line"] + lineDelta; - } - } - } - } - for (const [key, child] of Object.entries(node)) { - if (key !== "loc") { - rebaseEstree(child, offsetDelta, lineDelta); - } - } -} - -/** - * Grammar widening 2 (SPEC 14.20; SPEC 2.1/3 fix the accepted shape): a - * clone of the stock `mdxjsEsm` tokenizer (micromark-extension-mdxjs-esm, - * verified against its source) whose one behavioral change is in - * `lineStart` — at each line boundary the accumulated text is tried as a - * program, and a complete valid one ends the block there, so an import - * line directly followed by a non-blank line parses. The stock construct - * ends a block only before a blank line or EOF, feeding the follow-on - * lines to acorn; every other behavior — swallowing incomplete - * statements across lines, the blank-line check, the import/export-only - * rule, `definedModuleSpecifiers`, the `addResult` estree (rebased to - * document positions) — is mirrored. Registered before the stock - * construct, which therefore never runs. Adjacent import lines become - * one block per line rather than one shared block; every consumer of - * `SpecEsmBlock` is per-statement, so the observable model is unchanged. - */ -const widenedEsmConstruct = { - tokenize: tokenizeWidenedEsm, - concrete: true, -}; - -function tokenizeWidenedEsm( - this: MicromarkTokenizeContext, - effects: MicromarkEffects, - ok: MicromarkState, - nok: MicromarkState, -): MicromarkState { - const self = this; - const defined = - self.parser.definedModuleSpecifiers ?? - (self.parser.definedModuleSpecifiers = []); - // Re-captured in `start`; initialized here only for definite assignment. - let startPoint: MicromarkPoint = self.now(); - let keyword = ""; - return self.interrupt === true ? nok : start; - - function start(code: MicromarkCode): MicromarkState | undefined { - // Only at the start of a line, not in a container (as stock). - if (self.now().column > 1) { - return nok(code); - } - startPoint = self.now(); - effects.enter("mdxjsEsm"); - effects.enter("mdxjsEsmData"); - effects.consume(code); - keyword += String.fromCharCode(code as number); - return word; - } - - function word(code: MicromarkCode): MicromarkState | undefined { - if (isAsciiAlpha(code)) { - effects.consume(code); - keyword += String.fromCharCode(code as number); - return word; - } - if ((keyword === "import" || keyword === "export") && code === 32) { - effects.consume(code); - return inside; - } - return nok(code); - } - - function inside(code: MicromarkCode): MicromarkState | undefined { - if (code === null || isLineEnding(code)) { - effects.exit("mdxjsEsmData"); - return lineStart(code); - } - effects.consume(code); - return inside; - } - - function lineStart(code: MicromarkCode): MicromarkState | undefined { - if (code === null) { - return atEnd(code); - } - // The widening: a complete valid program ends the block at this line - // boundary (before the pending line ending). Otherwise exactly the - // stock path: end before a blank line, else continue accumulating. - if (parseAccumulated().ok) { - return atEnd(code); - } - return effects.check(blankLineBefore, atEnd, continuationStart)(code); - } - - function continuationStart(code: MicromarkCode): MicromarkState | undefined { - effects.enter("lineEnding"); - effects.consume(code); - effects.exit("lineEnding"); - return lineStart; - } - - /** - * Parse the accumulated block text — the contiguous document slice from - * the construct's start to the current point, prefixed (as stock) with - * `var` declarations of previously imported bindings so later blocks - * referencing them parse. `swallow` mirrors - * micromark-util-events-to-acorn: the failure is at the accumulated - * text's end, so more content may complete it. - */ - function parseAccumulated(): EsmParseAttempt { - const source = self.sliceSerialize( - { start: startPoint, end: self.now() }, - false, - ); - const prefix = defined.length > 0 ? "var " + defined.join(",") + "\n" : ""; - const comments: Comment[] = []; - try { - const program = specAcorn.parse(prefix + source, { - ...ESM_ACORN_OPTIONS, - onComment: comments, - }); - return { ok: true, program, comments, prefix, source }; - } catch (error) { - const failure = ( - typeof error === "object" && error !== null ? error : {} - ) as AcornFailureLike; - if (typeof failure.pos !== "number" || failure.loc === undefined) { - throw error; // not an acorn parse failure — a genuine crash - } - const swallow = - (typeof failure.raisedAt === "number" && - failure.raisedAt >= prefix.length + source.length) || - (typeof failure.message === "string" && - failure.message.startsWith("Unterminated comment")); - return { ok: false, failure, swallow, prefix }; - } - } - - function atEnd(code: MicromarkCode): MicromarkState | undefined { - const attempt = parseAccumulated(); - const prefixLines = attempt.prefix.length > 0 ? 1 : 0; - if (!attempt.ok) { - if (code !== null && attempt.swallow) { - return continuationStart(code); - } - // Mirror the stock failure message and its document-rebased place. - const failure = attempt.failure; - const pos = failure.pos as number; - const relLine = (failure.loc?.line as number) - prefixLines; - const relColumn = failure.loc?.column as number; - throw new MdxGrammarError("Could not parse import/exports with acorn", { - line: startPoint.line + relLine - 1, - column: (relLine === 1 ? startPoint.column - 1 : 0) + relColumn + 1, - offset: startPoint.offset + (pos - attempt.prefix.length), - }); - } - const program = attempt.program; - const delta = startPoint.offset - attempt.prefix.length; - const lineDelta = startPoint.line - 1 - prefixLines; - rebaseEstree(program, delta, lineDelta); - rebaseEstree(attempt.comments, delta, lineDelta); - if (attempt.prefix.length > 0) { - program.body.shift(); // drop the `var` prefix declaration (as stock) - } - program.start = startPoint.offset; - program.end = startPoint.offset + attempt.source.length; - (program as Program & { comments: Comment[] }).comments = attempt.comments; - for (const statement of program.body) { - if (!ALLOWED_ESM_TYPES.has(statement.type)) { - throw new MdxGrammarError( - "Unexpected `" + - statement.type + - "` in code: only import/exports are supported", - { - start: { - line: statement.loc?.start.line, - column: - statement.loc === undefined || statement.loc === null - ? undefined - : statement.loc.start.column + 1, - offset: statement.start, - }, - end: { - line: statement.loc?.end.line, - column: - statement.loc === undefined || statement.loc === null - ? undefined - : statement.loc.end.column + 1, - offset: statement.end, - }, - }, - ); - } - if (statement.type === "ImportDeclaration" && self.interrupt !== true) { - for (const specifier of statement.specifiers) { - defined.push(specifier.local.name); - } - } - } - Object.assign(effects.exit("mdxjsEsm"), { estree: program }); - return ok(code); - } + readonly place?: unknown; } // --------------------------------------------------------------------------- -// Grammar widening 3: flat section-tag pairing (SPEC 1.1, 3, 6.5 → 14.20) +// Tag spans beside the stock JSX handlers (SPEC 1.7; no verdict changes) // --------------------------------------------------------------------------- /** * Structural view of mdast-util-from-markdown's compile context (verified - * against mdast-util-from-markdown 2): the members the flat-tag handlers - * use. `data.mdxJsxTag` is the tag snapshot the stock mdast-util-mdx-jsx - * handlers (which stay registered) accumulate per tag token. + * against mdast-util-from-markdown 2): the members the tag-span handler + * uses. `data.mdxJsxTag` is the tag state the stock mdast-util-mdx-jsx + * handlers keep while a tag token is open — its `start` and `end` are the + * whole tag token's points — and `stack[0]` is the tree's root. */ interface FromMarkdownContextLike { readonly data: { mdxJsxTag?: { - readonly name?: string | null; - readonly close?: boolean; - readonly selfClosing?: boolean; - readonly attributes?: readonly MdxAttributeNode[]; + readonly start?: MdxPoint; + readonly end?: MdxPoint; }; }; - resume(): unknown; - enter(node: object, token: object): unknown; - exit(token: object): unknown; + readonly stack: readonly { data?: { xspecTagSpans?: TagSpans } }[]; } /** - * Replacement for the stock `exitMdxJsxTag`: instead of pairing tags into - * `mdxJsxFlowElement`/`mdxJsxTextElement` nodes within one construct — - * which rejects a section opened with trailing same-line content and - * closed on a later line, and content directly preceding a closing tag — - * every tag token becomes one `xspecJsxTag` leaf node carrying the tag's - * name, kind, and attributes at the token's exact source positions. The - * document builder pairs the leaves across construct boundaries - * (SPEC 14.20 widening 3) and reports unclosed or mismatched tags as - * parse failures. + * Record the current tag token's span on the root (`TagSpans`). Registered + * for the tag's `<` and `>` marker tokens, which no stock handler reads, so + * every stock handler — tag names, attributes, and the element pairing + * that decides well-formedness (SPEC 14.20) — stays in place; the second + * call per tag records the same span again. */ -function exitFlatJsxTag(this: FromMarkdownContextLike, token: object): void { - const tag = this.data.mdxJsxTag; - if (tag === undefined) { - throw new Error("xspec internal error: JSX tag exit without tag state"); +function recordTagSpan(this: FromMarkdownContextLike): void { + const start = this.data.mdxJsxTag?.start?.offset; + const end = this.data.mdxJsxTag?.end?.offset; + const root = this.stack[0]; + if (typeof start !== "number" || typeof end !== "number" || !root) { + throw new Error("xspec internal error: JSX tag marker without tag state"); } - this.resume(); // drop the tag's text buffer, as the stock handler does - this.enter( - { - type: "xspecJsxTag", - name: tag.name ?? null, - close: tag.close === true, - selfClosing: tag.selfClosing === true, - attributes: tag.attributes ?? [], - children: [], - }, - token, - ); - this.exit(token); -} - -/** - * Replacement for the stock `enterMdxJsxTagClosingMarker`, which throws on - * a closing tag with no same-construct open element; pairing (and the - * corresponding failure) is the document builder's. - */ -function ignoreClosingMarker(): void { - // Intentionally empty. + const data = (root.data ??= {}); + const spans = (data.xspecTagSpans ??= { + endByStart: new Map<number, number>(), + startByEnd: new Map<number, number>(), + }); + spans.endByStart.set(start, end); + spans.startByEnd.set(end, start); } /** - * The fromMarkdown override. Registered after mdast-util-mdx-jsx's - * extension, so these handlers replace the stock ones per token type - * (mdast-util-from-markdown merges `enter`/`exit` maps by assignment, - * later extensions winning) while every other stock handler — tag names, - * attributes and their decoded values, expression attributes — stays. - * Handlers that reject genuinely malformed tags (attributes or a - * self-closing slash in a closing tag) also stay, so those remain parse - * failures (SPEC 14.20). + * The fromMarkdown addition: handlers for token types the stock + * extensions leave unhandled (mdast-util-from-markdown merges handler + * maps by assignment per token type, so no stock handler is replaced). */ -const flatJsxTagExtension = { - enter: { - mdxJsxFlowTagClosingMarker: ignoreClosingMarker, - mdxJsxTextTagClosingMarker: ignoreClosingMarker, - }, +const tagSpanExtension = { exit: { - mdxJsxFlowTag: exitFlatJsxTag, - mdxJsxTextTag: exitFlatJsxTag, + mdxJsxFlowTagMarker: recordTagSpan, + mdxJsxTextTagMarker: recordTagSpan, }, }; /** - * Register the grammar widenings. Placed after `remarkMdx` deliberately: - * micromark's `combineExtensions` splices a later extension's constructs - * *before* earlier ones at the same character (verified against - * micromark 4), so `widenedEsmConstruct` is attempted before — and fully - * shadows — the stock ESM construct at `e`/`i`, and the fromMarkdown - * merge above replaces the stock tag handlers. + * Register the tag-span recorder. No micromark construct is added and no + * stock handler replaced: every construct is tokenized, and every JSX + * element paired, by remark-mdx's stock grammar (SPEC 14.20). */ -function xspecGrammarWidenings(this: { data(): unknown }): void { +function xspecTagSpans(this: { data(): unknown }): void { const data = this.data() as { - micromarkExtensions?: unknown[]; fromMarkdownExtensions?: unknown[]; }; - (data.micromarkExtensions ??= []).push({ - flow: { - 101: widenedEsmConstruct, // `e` - 105: widenedEsmConstruct, // `i` - }, - }); - (data.fromMarkdownExtensions ??= []).push(flatJsxTagExtension); + (data.fromMarkdownExtensions ??= []).push(tagSpanExtension); } /** * The MDX parser (IMPLEMENTATION: remark-mdx defines well-formed MDX, - * SPEC 14.20 — with the grammar widened per `xspecAcornExtension`, - * `widenedEsmConstruct`, and `flatJsxTagExtension` above). Frozen once; + * SPEC 14.20): MDX 3's grammar, its braces and ESM blocks derived by + * ECMAScript 2024 alone, early errors excluded (`mdxAcorn`). Frozen once; * `parse` is pure. */ const mdxParser = unified() .use(remarkParse) - .use(remarkMdx, { acorn: specAcorn }) - .use(xspecGrammarWidenings) + .use(remarkMdx, { acorn: mdxAcorn, acornOptions: MDX_ACORN_OPTIONS }) + .use(xspecTagSpans) .freeze(); // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- +/** A spec source's parse: its decoded text and MDX tree, or its 14.20. */ +type MdxParse = + | { + readonly kind: "tree"; + readonly text: string; + readonly offsets: Utf8Offsets; + readonly tree: MdxTreeNode; + } + | { readonly kind: "unparseable"; readonly finding: Finding }; + /** - * Parse one discovered spec source into its document model (SPEC 1, 2). - * `path` is the workspace-relative `/`-separated path (SPEC 1.5); `bytes` - * the file's exact content. An unparseable file — BOM, invalid UTF-8 - * (SPEC 1.6), or not well-formed MDX — yields the single 14.20 finding that - * masks the conditions inside it (SPEC 14). + * Decode and parse one spec source (SPEC 1.6, 14.20): valid UTF-8 with no + * byte-order mark, then MDX 3's grammar — the whole verdict on + * well-formedness, which the document builder never revises. */ -export function parseSpecSource( - path: string, - bytes: Uint8Array, -): SpecSourceResult { - const decoded = decodeSourceBytes(path, bytes); +function parseMdx(file: PathText, bytes: Uint8Array): MdxParse { + const decoded = decodeSourceBytes(file, bytes); if (!decoded.ok) { return { kind: "unparseable", finding: decoded.finding }; } const text = decoded.text; const offsets = new Utf8Offsets(text); - - let tree: MdxTreeNode; try { - // SPEC 14.20: remark-mdx's grammar defines well-formed MDX. - tree = mdxParser.parse(text) as unknown as MdxTreeNode; + // SPEC 14.20: remark-mdx's grammar defines well-formed MDX, its refusal + // of an attribute's content as empty undone where that content holds + // a token (`parseAsJudged`). + const { result, originals } = parseAsJudged( + text, + (spelled) => mdxParser.parse(spelled) as unknown as MdxTreeNode, + ); + if (originals.size > 0) restoreRespelledContent(result, text, originals); + return { kind: "tree", text, offsets, tree: result }; } catch (error) { return { kind: "unparseable", - finding: parseFailureFinding(path, error, text, offsets), + finding: parseFailureFinding(file, error, text, offsets), }; } +} - const builder = new DocumentBuilder(path, text, offsets); - try { - builder.walk(tree); - builder.finishTags(); - } catch (error) { - if (error instanceof MdxGrammarError) { - // A tag-pairing failure (SPEC 14.20 widening 3): unclosed or - // mismatched tags make the file unparseable, masking its contents. - return { - kind: "unparseable", - finding: parseFailureFinding(path, error, text, offsets), - }; - } - if (error instanceof RangeError) { - // SPEC 14.20: nesting beyond what the recursive walk can process (a - // call-stack overflow surfaces as a RangeError) makes the file - // unparseable — a finding, never a crash (SPEC 12.0: exit codes - // partition all outcomes). `mdxParser.parse` above is guarded the - // 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 ` + - `exceeds what the parser can process, so no location inside ` + - `it can be analyzed; simplify or split the file (SPEC 14.20)`, - }, - }; - } - throw error; +/** + * Parse one discovered spec source into its document model (SPEC 1, 2). + * `path` is the workspace-relative `/`-separated path (SPEC 1.5); `bytes` + * the file's exact content. An unparseable file — BOM, invalid UTF-8 + * (SPEC 1.6), or not well-formed MDX — yields the single 14.20 finding that + * masks the conditions inside it (SPEC 14). + */ +export function parseSpecSource( + path: string, + bytes: Uint8Array, + file: PathText = path, +): SpecSourceResult { + const parsed = parseMdx(file, bytes); + if (parsed.kind === "unparseable") { + return parsed; } + const builder = new DocumentBuilder(path, file, parsed.text, parsed.offsets); + builder.walk(parsed.tree); builder.validateStructure(); return { kind: "document", document: builder.finish() }; } +/** + * SPEC 14.20: whether a spec source's bytes are well-formed — exactly when + * `parseSpecSource` reaches a document rather than the 14.20 finding. The + * verdict is reached without locating a failure or building the document + * model: a pure judgement over a file's bytes, for texts no discovered file + * holds yet — a move's would-be files (SPEC 6.5 "Validation and refusals", + * `refused-invalid-rewrite`), whose refusal locates the moved construct + * rather than the failure. + */ +export function isWellFormedSpecSource(bytes: Uint8Array): boolean { + // The path only labels a finding this verdict never reports. + const decoded = decodeSourceBytes("", bytes); + if (!decoded.ok) { + return false; + } + try { + parseAsJudged(decoded.text, (spelled) => mdxParser.parse(spelled)); + return true; + } catch { + return false; + } +} + /** 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, @@ -973,109 +637,388 @@ function parseFailureFinding( ? failure.message : String(error); - // 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; - let line: number | undefined; - let column: number | undefined; - const place = failure.place as - (MdxPoint & Partial<MdxPosition>) | null | undefined; - const startPoint: MdxPoint | undefined = - place == null ? undefined : (place.start ?? place); - const endPoint: MdxPoint | undefined = - place == null ? undefined : (place.end ?? place); - if (startPoint !== undefined && typeof startPoint.offset === "number") { - range = pointRange(startPoint.offset, endPoint?.offset, text, offsets); + // SPEC 14: one zero-length range at the failure's offset — the byte + // length of the longest prefix with which some well-formed file begins + // (mdx-syntax-failure.ts). A throw that is not the grammar's own failure + // (a VFileMessage, carrying its source) — nesting too deep to parse — + // locates at the file start. + let at = 0; + if (typeof failure.source === "string") { + const place = failure.place as + (MdxPoint & Partial<MdxPosition>) | null | undefined; + const placed = place == null ? undefined : (place.start ?? place).offset; + const fallback = + typeof placed === "number" + ? Math.max(0, Math.min(text.length, placed)) + : 0; + at = mdxSyntaxFailureOffset(text, fallback); + } + const byte = offsets.byteOffset(at); + const { line, column } = lineAndColumn(text, at); + return locatedFinding( + 20, + `unparseable source: not well-formed MDX at line ${String(line)}, ` + + `column ${String(column)} — ${reason}. Correct the syntax at the ` + + `reported location (SPEC 14.20)`, + [{ file, range: { start: byte, end: byte } }], + ); +} + +/** + * The 1-based line and column (in code points) of a UTF-16 index, lines + * ended by CR, LF, or CRLF — for a finding's message text. + */ +function lineAndColumn( + text: string, + index: number, +): { readonly line: number; readonly column: number } { + let line = 1; + let lineStart = 0; + for (let at = 0; at < index; at += 1) { + const code = text.charCodeAt(at); + if (code === 0x0a || (code === 0x0d && text.charCodeAt(at + 1) !== 0x0a)) { + line += 1; + lineStart = at + 1; + } } - if (typeof failure.line === "number") { - line = failure.line; - } else if (startPoint !== undefined && typeof startPoint.line === "number") { - line = startPoint.line; + let column = 1; + for (let at = lineStart; at < index; at += 1) { + const code = text.charCodeAt(at); + if (code < 0xdc00 || code > 0xdfff) column += 1; } - if (typeof failure.column === "number") { - column = failure.column; - } else if ( - startPoint !== undefined && - typeof startPoint.column === "number" - ) { - column = startPoint.column; + return { line, column }; +} + +/** + * SPEC 14.20, 2.3, 2.4: the content between an expression's braces as MDX 3 + * derives it, at the document's own offsets. `raw` is the document text + * between the braces; `collected` the content remark-mdx gathered for the + * expression (the node's `value`): the same characters less each + * continuation line's Markdown container prefix — a block quote's `>`, a + * list item's indentation — which the expression does not hold (a tab the + * prefix splits leaves its remaining columns as spaces, and micromark reads + * U+0000 as U+FFFD). On each line after the first, what precedes the + * longest ending it shares with the collected line is that prefix, and is + * blanked to spaces: prefixes are ASCII, so UTF-16 indices and byte offsets + * stay put for every span the analyzer reports — `> {text("a")` over `> }` + * is the call `text("a")`, never `text("a") >`. + */ +function derivedContent(raw: string, collected: unknown): string { + if (typeof collected !== "string" || collected === raw) { + return raw; + } + // Markdown line endings (CommonMark): the parts alternate line, ending. + const rawParts = raw.split(/(\r\n|\r|\n)/u); + const collectedParts = collected.split(/(\r\n|\r|\n)/u); + if (rawParts.length !== collectedParts.length) { + return raw; + } + for (let index = 2; index < rawParts.length; index += 2) { + const line = rawParts[index]; + const prefix = line.length - sharedEnding(line, collectedParts[index]); + if (prefix > 0 && /^[\t >]+$/u.test(line.slice(0, prefix))) { + rawParts[index] = " ".repeat(prefix) + line.slice(prefix); + } } + return rawParts.join(""); +} - const where = - 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}. ` + - `Correct the syntax at the reported location (SPEC 14.20)`, - ...(range !== undefined ? { range } : {}), - ...(line !== undefined ? { line } : {}), - ...(column !== undefined ? { column } : {}), - }; - return finding; +/** The length of the longest ending `line` and `collected` share. */ +function sharedEnding(line: string, collected: string): number { + let count = 0; + while (count < line.length && count < collected.length) { + const spelled = line.charCodeAt(line.length - 1 - count); + const read = collected.charCodeAt(collected.length - 1 - count); + if (spelled !== read && !(spelled === 0 && read === 0xfffd)) { + break; + } + count += 1; + } + return count; } -/** A byte range from a parse failure's UTF-16 point (and optional end). */ -function pointRange( - startIndex: number, - endIndex: number | undefined, +/** + * SPEC 14.20: on each attribute a respelling touched (`parseAsJudged`: the + * leading comments of an attribute's content, blanked where the stock + * grammar refused that content as empty although it holds a token), the + * content remark-mdx collected — its `value`, which `derivedContent` reads + * — as collected from `text`, the original. The respelled content's estree + * (which nothing here reads) keeps the respelled spelling: those comments + * are whitespace there. + */ +function restoreRespelledContent( + node: MdxTreeNode, text: string, - offsets: Utf8Offsets, -): ByteRange { - const clamp = (index: number): number => - Math.max(0, Math.min(text.length, index)); - const start = clamp(startIndex); - let end: number; - if (endIndex !== undefined && clamp(endIndex) > start) { - end = clamp(endIndex); - } else if (start < text.length) { - const codePoint = text.codePointAt(start)!; - end = start + (codePoint > 0xffff ? 2 : 1); - } else { - end = start; + originals: ReadonlyMap<number, string>, +): void { + for (const attribute of node.attributes ?? []) { + const start = attribute.position?.start.offset; + const end = attribute.position?.end.offset; + if (typeof start !== "number" || typeof end !== "number") continue; + if (![...originals.keys()].some((at) => at >= start && at < end)) { + continue; + } + // The content's last line ends at the closing brace, `end - 1`. + const holder = ( + attribute.type === "mdxJsxExpressionAttribute" + ? attribute + : attribute.value + ) as { value?: unknown } | null | undefined; + if (typeof holder === "object" && typeof holder?.value === "string") { + holder.value = restoredContent(holder.value, text, end - 1, originals); + } + } + for (const child of node.children ?? []) { + restoreRespelledContent(child, text, originals); } - return { start: offsets.byteOffset(start), end: offsets.byteOffset(end) }; } -// --------------------------------------------------------------------------- -// Segment and tag validity (SPEC 1.4) -// --------------------------------------------------------------------------- +/** + * `collected` — content remark-mdx collected from a respelled text, its last + * line ending at `end` — with each respelled character restored from + * `originals` (micromark reads U+0000 as U+FFFD). Each content line is a + * suffix of its file line (the Markdown container prefix and indentation + * not collected), so characters correspond from the end, a content line + * ending passing the file line's uncollected prefix to its own ending. + */ +function restoredContent( + collected: string, + text: string, + end: number, + originals: ReadonlyMap<number, string>, +): string { + const units = collected.split(""); + let at = end; + for (let index = units.length - 1; index >= 0; index -= 1) { + const code = collected.charCodeAt(index); + if (code === 0x0a || code === 0x0d) { + while (at > 0 && text.charCodeAt(at - 1) !== code) at -= 1; + } + at -= 1; + const original = originals.get(at); + if (original !== undefined) { + units[index] = original === "\0" ? "\ufffd" : original; + } + } + return units.join(""); +} + +/** An estree node's or comment's document-absolute UTF-16 offsets. */ +function positionsOf(node: { + readonly start?: number; + readonly end?: number; +}): { + readonly start: number; + readonly end: number; +} { + const { start, end } = node; + if (typeof start !== "number" || typeof end !== "number") { + throw new Error("xspec internal error: estree node without a position"); + } + return { start, end }; +} /** - * Why `value` violates SPEC 1.4 as an ID segment or a tag (`"."` allowed in - * tags only), or null when valid. Segments arrive from splitting an ID on - * `"."`, so the segment path never sees a `"."`. + * SPEC 2.1, 2.4: each identifier a declaration held by an export + * statement binds, with the UTF-16 span of the construct binding it — a + * variable declarator, or the function or class declaration itself, whose + * own characters exclude the leading `export` or `export default` (SPEC + * 14, 1.7). A statement holding no declaration — an export list, a + * re-export, a default-exported expression or anonymous construct — binds + * nothing. */ -function valueViolation(value: string, kind: "segment" | "tag"): string | null { - if (value.length === 0) { - return "it is empty (SPEC 1.4: segments are non-empty)"; +function exportedDeclarationBindings( + statement: EstreeDeclarationNode, +): { readonly name: string; readonly start: number; readonly end: number }[] { + const declaration = + statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : null; + if (declaration === null || declaration === undefined) return []; + if (declaration.type === "VariableDeclaration") { + const held: { name: string; start: number; end: number }[] = []; + for (const declarator of declaration.declarations ?? []) { + const { start, end } = positionsOf(declarator); + const names: string[] = []; + patternNames(declarator.id, names); + for (const name of names) held.push({ name, start, end }); + } + return held; } - if (FORBIDDEN_SEGMENT_NAMES.has(value)) { - return `${JSON.stringify(value)} is a forbidden name (SPEC 1.4)`; + const name = declaration.id?.name; + if ( + (declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration") && + name !== undefined + ) { + return [{ name, ...positionsOf(declaration) }]; } - if (kind === "segment" && value.includes(".")) { - return 'it contains "." (SPEC 1.4)'; + return []; +} + +/** Every identifier an ECMAScript binding pattern binds, in source order. */ +function patternNames( + pattern: EstreeDeclarationNode | null | undefined, + into: string[], +): void { + if (pattern === null || pattern === undefined) return; + switch (pattern.type) { + case "Identifier": + if (pattern.name !== undefined) into.push(pattern.name); + return; + case "ObjectPattern": + for (const property of pattern.properties ?? []) { + if (property.type === "RestElement") { + patternNames(property.argument, into); + } else if (typeof property.value === "object") { + patternNames(property.value as EstreeDeclarationNode | null, into); + } + } + return; + case "ArrayPattern": + for (const element of pattern.elements ?? []) { + patternNames(element, into); + } + return; + case "AssignmentPattern": + patternNames(pattern.left, into); + return; + case "RestElement": + patternNames(pattern.argument, into); + return; } - if (value.includes("#")) { - return 'it contains "#" (SPEC 1.4)'; +} + +/** + * Whether an expression is a call — optional or not — whose callee acorn + * reads as the identifier `text`, parenthesized or escape-spelled alike: + * the near misses of an embedding (SPEC 2.3), whose 14.16 finding names + * the plain spelling. + */ +function callsCookedText(expression: EstreeNode): boolean { + const call = + expression.type === "ChainExpression" ? expression.expression : expression; + return ( + call !== undefined && + call.type === "CallExpression" && + call.callee !== undefined && + call.callee.type === "Identifier" && + call.callee.name === "text" + ); +} + +// --------------------------------------------------------------------------- +// Segment and tag validity (SPEC 1.4) +// --------------------------------------------------------------------------- + +/** + * The SPEC 1.4 problems of one `id` or `tags` value, as phrases for its + * attribute's one 14.4 finding (SPEC 14.4: one finding per `id` or `tags` + * attribute whose value violates 1.4, however many of its segments or tags + * do), judged by the shared validator (text.ts). `values` are the value's + * segments (split on `"."`, SPEC 1.3) or tags (split per 2.6), taken from + * the characters between the attribute's quotes exactly as spelled + * (SPEC 2.4) — so an authored U+0000 is judged as the control character it + * is, and a character reference as the `&` it contains. + */ +function attributeProblems( + kind: "segment" | "tag", + values: readonly string[], +): string[] { + const problems: string[] = []; + for (const value of values) { + const violation = segmentViolation(value, kind); + if (violation !== null) { + problems.push( + `the ${kind} ${JSON.stringify(value)} ` + + describeSegmentViolation(violation), + ); + } } - if (containsWhitespace(value)) { - return "it contains whitespace (SPEC 1.4)"; + return problems; +} + +/** + * SPEC 11.2: the sections of a parsed document whose node identities are + * defined, over a valid file path (an invalid-path file defines no identity + * whatever this returns — the caller's concern, SPEC 14.19). A section's + * node identity is defined exactly when it and each enclosing section spell + * an identity, each spelled identity in the chain is well-formed (SPEC 1.4) + * and satisfies the structural rules (SPEC 1.3), and no other section of + * the file spells the same identity as it does. The chain conditions are + * inherited — a descendant of a section that spells no identity, or whose + * spelled identity is malformed or structurally invalid, has no defined + * identity — but uniqueness is not: it constrains the section's own spelled + * identity alone, so duplicate spellings leave every bearer undefined (no + * winner picked) while a uniquely spelled descendant of duplicate-`id` + * ancestors keeps its defined identity. Parse-local (SPEC 11.2): shared by + * graph node construction (core/graph.ts) — only defined identities are + * formed, emitted, or resolved against (SPEC 1.5) — and the availability + * surfaces (SPEC 11.3–11.5). + */ +export function definedIdentitySections( + document: SpecDocument, +): ReadonlySet<SpecSection> { + // Uniqueness compares spelled identities only (SPEC 11.2): a section + // spelling no identity (`id` absent, repeated, or in invalid value form — + // SpecSection.id null) contests no other section's. + const spelled = new Map<string, number>(); + for (const section of document.sections) { + if (section.id !== null) { + spelled.set(section.id, (spelled.get(section.id) ?? 0) + 1); + } } - if (containsControl(value)) { - return "it contains a control character (SPEC 1.4)"; + + // 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 => + idSegmentViolations(id).length === 0; + const chain = new Map<SpecSection, boolean>(); + const chainOk = (section: SpecSection): boolean => { + if (section.parent === null) return true; // the root spells no identity + const memo = chain.get(section); + if (memo !== undefined) return memo; + let ok = false; + if (section.id !== null && wellFormed(section.id)) { + const segments = section.id.split("."); + const parent = section.parent; + if (parent.parent === null) { + // SPEC 1.3: a top-level section's ID is exactly one segment. + ok = segments.length === 1; + } else if (parent.id !== null) { + // SPEC 1.3: the parent's spelled ID plus exactly one segment. A + // parent spelling no identity fails the chain regardless. + const parentSegments = parent.id.split("."); + ok = + segments.length === parentSegments.length + 1 && + parentSegments.every((segment, index) => segments[index] === segment); + } + ok = ok && chainOk(parent); + } + chain.set(section, ok); + return ok; + }; + + const defined = new Set<SpecSection>(); + for (const section of document.sections) { + if (section.id === null) continue; + if (spelled.get(section.id) !== 1) continue; + if (!chainOk(section)) continue; + defined.add(section); } - return null; + 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 - * first-occurrence order. A value yielding no tags is equivalent to an - * omitted prop. + * first-occurrence order — the order the value spells them, which the + * 14.4 finding's problem list follows; the interpreted tags recorded on + * the section are these tokens as a byte-ordered set (SPEC 12.7). A value + * yielding no tags is equivalent to an omitted prop. */ export function splitTags(value: string): string[] { const tokens: string[] = []; @@ -1116,23 +1059,39 @@ interface MutableSection { coverage: "required" | "none" | null; tags: readonly string[]; idAttribute: SpecAttributeValue | null; - dependency: SpecDependencyAttribute | null; + dependencies: SpecDependencyAttribute[]; + attributes: SpecRawAttribute[]; + tagsDefined: boolean; + coverageDefined: boolean; /** Whether an `id` prop occurred at all (14.1 is only for absence). */ idPresent: boolean; } -/** One open tag awaiting its closing tag during the walk. */ -interface OpenTagFrame { - /** The tag's name — null for a fragment. */ - readonly name: string | null; - /** UTF-16 span of the opening tag. */ - readonly span: { readonly start: number; readonly end: number }; - /** The opening tag's position (failure reporting). */ - readonly position: MdxPosition; - /** The section the tag opened, or null for a non-section element. */ +/** + * One quoted `id`, `coverage`, or `tags` spelling judged on its own + * (SPEC 2.7): its value exactly as spelled (SPEC 2.4) and whether that value + * satisfies the prop's value rule — SPEC 1.4 for `id` and `tags` (else + * 14.4), 2.5 for `coverage` (else 14.17). + */ +interface JudgedStringProp extends SpecAttributeValue { + readonly valid: boolean; +} + +/** The props whose value is a quoted static string literal (SPEC 2.7). */ +function isStringPropName(name: string): name is "id" | "coverage" | "tags" { + return name === "id" || name === "coverage" || name === "tags"; +} + +/** One element whose children the walk is visiting. */ +interface OpenElementFrame { + /** The section the element is, or null for a non-section element. */ readonly section: MutableSection | null; } +/** The walk's pending work: a node to visit, or an element to leave. */ +type WalkItem = + { readonly visit: MdxTreeNode } | { readonly leave: OpenElementFrame }; + class DocumentBuilder { readonly root: MutableSection; private readonly sections: MutableSection[] = []; @@ -1140,10 +1099,14 @@ class DocumentBuilder { private readonly embeddings: SpecEmbedding[] = []; private readonly comments: SpecComment[] = []; private readonly findings: Finding[] = []; - private readonly tagStack: OpenTagFrame[] = []; + /** The elements enclosing the node being visited, outermost first. */ + private readonly elementStack: OpenElementFrame[] = []; + /** The file's tag spans; set by `walk` from the tree's root. */ + private tagSpans: TagSpans | undefined; constructor( private readonly path: string, + private readonly file: PathText, private readonly text: string, private readonly offsets: Utf8Offsets, ) { @@ -1161,7 +1124,10 @@ class DocumentBuilder { coverage: null, tags: [], idAttribute: null, - dependency: null, + dependencies: [], + attributes: [], + tagsDefined: true, + coverageDefined: true, idPresent: false, }; } @@ -1179,7 +1145,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. */ @@ -1198,47 +1166,57 @@ class DocumentBuilder { /** * Walk the mdast tree in document order: requirement sections nest by * document containment, whatever Markdown structure lies between (SPEC - * 1.1–1.3). Under grammar widening 3 every `<S>`/`<Spec>` (and other - * JSX) tag arrives as a flat `xspecJsxTag` leaf; this walk pairs them - * on a stack, so a section opened with trailing same-line content may - * close on a later line, and content may directly precede a closing - * tag on its line (SPEC 14.20). + * 1.1–1.3). Every JSX element arrives as the stock grammar paired it + * (SPEC 14.20) — an `mdxJsxFlowElement` or `mdxJsxTextElement` spanning + * its opening tag through its closing tag, its content as its children — + * so the section tree follows the element tree. The walk keeps its own + * stack of pending work instead of recursing: sections nest as deep as a + * file stacks them (the suite stages towers 4096 deep), and each level + * must cost heap, never call stack. */ - walk(node: MdxTreeNode): void { - switch (node.type) { - case "xspecJsxTag": { - this.handleTag(node); - return; - } - case "mdxJsxFlowElement": - case "mdxJsxTextElement": { - // flatJsxTagExtension replaces element construction wholesale. - throw new Error( - "xspec internal error: unflattened JSX element in the MDX tree", - ); - } - case "mdxFlowExpression": - case "mdxTextExpression": { - this.classifyExpression(node, this.currentSection()); - return; - } - case "mdxjsEsm": { - this.processEsm(node); - return; + walk(tree: MdxTreeNode): void { + this.tagSpans = tree.data?.xspecTagSpans; + const pending: WalkItem[] = [{ visit: tree }]; + for (let item = pending.pop(); item !== undefined; item = pending.pop()) { + if ("leave" in item) { + if (this.elementStack.pop() !== item.leave) { + throw new Error("xspec internal error: unbalanced element walk"); + } + continue; } - default: { - for (const child of node.children ?? []) { - this.walk(child); + const node = item.visit; + switch (node.type) { + case "mdxJsxFlowElement": + case "mdxJsxTextElement": { + const frame = this.enterElement(node); + this.elementStack.push(frame); + pending.push({ leave: frame }); + break; + } + case "mdxFlowExpression": + case "mdxTextExpression": { + this.classifyExpression(node, this.currentSection()); + continue; + } + case "mdxjsEsm": { + this.processEsm(node); + continue; } - return; + default: { + break; + } + } + const children = node.children ?? []; + for (let index = children.length - 1; index >= 0; index -= 1) { + pending.push({ visit: children[index] }); } } } - /** The innermost section whose opening tag is still open (SPEC 1.1). */ + /** The innermost section whose element encloses the walk (SPEC 1.1). */ private currentSection(): MutableSection { - for (let index = this.tagStack.length - 1; index >= 0; index -= 1) { - const section = this.tagStack[index].section; + for (let index = this.elementStack.length - 1; index >= 0; index -= 1) { + const section = this.elementStack[index].section; if (section !== null) { return section; } @@ -1246,77 +1224,40 @@ class DocumentBuilder { return this.root; } - /** The node's position; every parsed mdast node carries one. */ - private positionOf(node: MdxTreeNode): MdxPosition { - const position = node.position; - if (position === undefined) { - throw new Error("xspec internal error: MDX node without a position"); - } - return position; - } - /** - * One flat tag leaf (SPEC 14.20 widening 3): `<S>`/`<Spec>` tags build - * the section tree (SPEC 1.1); any other element is invalid (SPEC 2.7 - * → 14.16) but participates in pairing all the same. An unmatched or - * mismatched tag is a parse failure, keeping genuinely malformed - * sources 14.20-unparseable exactly as under the stock grammar. + * One JSX element as the stock grammar paired it (SPEC 14.20): + * `<S>`/`<Spec>` builds a section (SPEC 1.1); any other element is + * invalid (SPEC 2.7 → 14.16), reported once over its whole construct, + * while the sections inside it nest by containment all the same. The + * element's tags are the recorded tag tokens at its two ends (SPEC 1.7): + * a self-closing element is its one tag. */ - private handleTag(node: MdxTreeNode): void { + private enterElement(node: MdxTreeNode): OpenElementFrame { const span = this.spanOf(node); - const name = node.name ?? null; - if (node.close === true) { - const frame = this.tagStack.pop(); - if (frame === undefined) { - throw new MdxGrammarError( - `Unexpected closing tag \`</${name ?? ""}>\`, expected an open ` + - `tag first`, - this.positionOf(node), - ); - } - if (frame.name !== name) { - throw new MdxGrammarError( - `Unexpected closing tag \`</${name ?? ""}>\`, expected ` + - `corresponding closing tag for \`<${frame.name ?? ""}>\``, - this.positionOf(node), - ); - } - if (frame.section !== null) { - // SPEC 1.7: the construct's own characters end with the last - // character of its closing tag. - frame.section.closingTagRange = this.byteRange(span.start, span.end); - frame.section.range = this.byteRange(frame.span.start, span.end); - } else { - this.reportForeignElement(frame.name, frame.span.start, span.end); - } - return; + const openingEnd = this.tagSpans?.endByStart.get(span.start); + const selfClosing = openingEnd === span.end; + const closingStart = selfClosing + ? span.start + : this.tagSpans?.startByEnd.get(span.end); + if (openingEnd === undefined || closingStart === undefined) { + throw new Error("xspec internal error: JSX element without tag spans"); } + const name = node.name ?? null; if (name === "S" || name === "Spec") { // SPEC 1.1: `<S>` and `<Spec>` are equivalent requirement sections // (compared byte-wise, SPEC 12.0 — no other casing). - const section = this.buildSection(node, span); - if (node.selfClosing !== true) { - this.tagStack.push({ - name, - span, - position: this.positionOf(node), - section, - }); - } - return; - } - // SPEC 2.7 → 14.16: any other JSX element is invalid — reported once - // per element when it pairs (or immediately when self-closing). - if (node.selfClosing === true) { - this.reportForeignElement(name, span.start, span.end); - return; + return { + section: this.buildSection(node, { + start: span.start, + openingEnd, + closingStart, + end: span.end, + selfClosing, + }), + }; } - this.tagStack.push({ - name, - span, - position: this.positionOf(node), - section: null, - }); + this.reportForeignElement(name, span.start, span.end); + return { section: null }; } /** SPEC 2.7 → 14.16: a JSX element other than `<S>`/`<Spec>`. */ @@ -1336,21 +1277,6 @@ class DocumentBuilder { ); } - /** - * After the walk: every opened tag must have closed — an unclosed - * element is a parse failure (SPEC 14.20), as under the stock grammar. - */ - finishTags(): void { - const frame = this.tagStack[this.tagStack.length - 1]; - if (frame !== undefined) { - throw new MdxGrammarError( - `Expected a closing tag for \`<${frame.name ?? ""}>\` before the ` + - `end of the file`, - frame.position, - ); - } - } - /** * SPEC 2.7: an expression container is a `{text(...)}` embedding (2.3), * an MDX comment, or invalid (14.16). @@ -1360,20 +1286,22 @@ class DocumentBuilder { const range = this.byteRange(span.start, span.end); const program = node.data?.estree; const body = program?.body ?? []; - if (program !== undefined && body.length === 0) { - if ((program.comments ?? []).length > 0) { - // An MDX comment (`{/* … */}`): a pure annotation (SPEC 2.7). - this.comments.push({ section, range }); - return; - } - this.addFinding( - 16, - range, - `invalid construct: an empty expression container — beyond ` + - `Markdown content, only spec-module imports, <S>/<Spec> ` + - `sections, {text(...)} embeddings, and MDX comments are ` + - `permitted; remove it (SPEC 2.7, 14.16)`, - ); + if ( + program !== undefined + ? body.length === 0 + : isEmptyExpression(node.value ?? "") + ) { + // An MDX comment — the empty expression: content of whitespace and + // comments alone (SPEC 2.7, 14.20), whitespace and line terminators + // ECMAScript's (U+00A0, U+FEFF, U+2028, U+2029 included), so `{}`, + // `{ }`, `{/* … */}`, and line comments ended before the closing + // brace alike. A pure annotation (SPEC 2.7, 3), never 14.16. + // remark-mdx derives such content as a whole Program (the + // empty-expression path of micromark-util-events-to-acorn), its body + // empty exactly when the content lexes to no token; acorn is + // configured, so an estree is always attached — an absent one is + // judged from the content itself. + this.comments.push({ section, range }); return; } const statement = body.length === 1 ? body[0] : undefined; @@ -1383,10 +1311,7 @@ class DocumentBuilder { : undefined; if ( expression !== undefined && - expression.type === "CallExpression" && - expression.callee !== undefined && - expression.callee.type === "Identifier" && - expression.callee.name === "text" + this.isEmbeddingCall(expression, span, program?.comments ?? []) ) { // A `{text(...)}` embedding (SPEC 2.3). Its argument is analyzed by // the static-reference analyzer (SPEC 2.4 → 14.8), not here; `text` @@ -1395,7 +1320,10 @@ class DocumentBuilder { this.embeddings.push({ section, range, - expressionText: this.text.slice(span.start + 1, span.end - 1), + expressionText: derivedContent( + this.text.slice(span.start + 1, span.end - 1), + node.value, + ), expressionRange: this.byteRange(span.start + 1, span.end - 1), }); return; @@ -1403,20 +1331,100 @@ class DocumentBuilder { this.addFinding( 16, range, - `invalid construct: an expression container that is neither a ` + - `{text(...)} embedding nor an MDX comment — remove it or replace ` + - `it with a permitted construct (SPEC 2.7, 14.16)`, + expression !== undefined && callsCookedText(expression) + ? `invalid construct: an expression container calling text that ` + + `is no {text(...)} embedding — an embedding's one expression ` + + `is a call of text spelled plainly, beside nothing but ` + + `whitespace and comments: its callee neither parenthesized nor ` + + `escaped, the call neither optional nor parenthesized; spell ` + + `it {text(<reference>)} (SPEC 2.3, 2.4, 14.16)` + : `invalid construct: an expression container that is neither a ` + + `{text(...)} embedding nor an MDX comment — remove it or ` + + `replace it with a permitted construct (SPEC 2.7, 14.16)`, ); } /** - * One top-level ESM block: record import declarations for import - * validation and reference analysis (SPEC 2.1); any export statement is - * invalid (SPEC 2.7 → 14.16). MDX admits no other statement kind here. + * SPEC 2.3: whether a container's one expression is an embedding's call — + * a call, optional chaining excluded, whose callee is the identifier + * `text` itself, spelled plainly, neither parenthesized nor escaped + * (2.4), whatever whitespace and comments stand beside the call. The + * estree alone cannot tell: acorn cooks an escape-spelled name to `text`, + * and micromark's events-to-acorn removes every `ParenthesizedExpression` + * node, leaving the inner node at its inner offsets. So the callee's own + * characters must be exactly `text` (2.4: read as spelled), the call must + * begin at its callee (`(text)("a")` begins at its `(`), and no + * parenthesis may stand beside the call outside a comment (`(text("a"))`) + * — the grammar lets nothing else stand there but whitespace, comments, + * and a Markdown container's line prefixes (`>`), which the expression + * does not hold. + */ + private isEmbeddingCall( + expression: EstreeNode, + span: { readonly start: number; readonly end: number }, + comments: readonly EstreeComment[], + ): boolean { + const callee = expression.callee; + if ( + expression.type !== "CallExpression" || + expression.optional === true || + callee === undefined || + callee.type !== "Identifier" + ) { + return false; + } + const call = positionsOf(expression); + const name = positionsOf(callee); + return ( + this.text.slice(name.start, name.end) === "text" && + call.start === name.start && + !this.parenthesisBeside(span.start + 1, call.start, comments) && + !this.parenthesisBeside(call.end, span.end - 1, comments) + ); + } + + /** + * Whether a parenthesis stands in the document text [from, to) outside + * every comment of `comments` (a comment may itself spell one). + */ + private parenthesisBeside( + from: number, + to: number, + comments: readonly EstreeComment[], + ): boolean { + const spanned = comments + .map((comment) => positionsOf(comment)) + .sort((left, right) => left.start - right.start); + let index = from; + for (const comment of spanned) { + if (comment.start >= to) { + break; + } + if (comment.end <= index) { + continue; + } + if ( + /[()]/u.test(this.text.slice(index, Math.max(index, comment.start))) + ) { + return true; + } + index = Math.max(index, comment.end); + } + return index < to && /[()]/u.test(this.text.slice(index, to)); + } + + /** + * One top-level ESM block: record each of its import declarations for + * import validation and reference analysis (SPEC 2.1); any export + * statement is invalid (SPEC 2.7 → 14.16). The stock construct admits no + * other statement kind (SPEC 14.20), and its estree carries + * document-absolute offsets, so each declaration's range is its own + * characters — a spelled `;` included, the comments beside it excluded. */ private processEsm(node: MdxTreeNode): void { const span = this.spanOf(node); const imports: SpecImportStatement[] = []; + const exportedBindings: SpecExportedBinding[] = []; for (const statement of node.data?.estree?.body ?? []) { const start = statement.start; const end = statement.end; @@ -1437,11 +1445,20 @@ class DocumentBuilder { `invalid construct: an export statement — xspec source files ` + `export nothing; remove it (SPEC 2.7, 14.16)`, ); + // SPEC 2.1, 2.4: what the statement's declaration binds, which an + // import sharing the identifier collides with (14.15). + for (const held of exportedDeclarationBindings(statement)) { + exportedBindings.push({ + name: held.name, + range: this.byteRange(held.start, held.end), + }); + } } } this.esmBlocks.push({ range: this.byteRange(span.start, span.end), imports, + exportedBindings, }); } @@ -1450,34 +1467,43 @@ class DocumentBuilder { // ------------------------------------------------------------------------- /** - * Build one `<S>`/`<Spec>` section from its opening (or self-closing) - * tag leaf, with validated props (SPEC 1.1, 2.5–2.7). The tag token - * covers the tag's exact characters, so the opening-tag range is the - * leaf's span; for a paired section, the closing-tag range and the - * construct range (SPEC 1.7) are completed when its closing tag pairs. + * Build one `<S>`/`<Spec>` section from its element, with validated props + * (SPEC 1.1, 2.5–2.7). `tags` gives the element's UTF-16 bounds: its + * start and end, its opening tag's end, and its closing tag's start — for + * a self-closing element, whose one tag is the whole construct, the + * element's start (SPEC 1.7). */ private buildSection( node: MdxTreeNode, - span: { start: number; end: number }, + tags: { + readonly start: number; + readonly openingEnd: number; + readonly closingStart: number; + readonly end: number; + readonly selfClosing: boolean; + }, ): MutableSection { // SPEC 1.1: a self-closing section element is an empty leaf; its tag // is the whole construct (SPEC 1.7). - const selfClosing = node.selfClosing === true; + const selfClosing = tags.selfClosing; const parent = this.currentSection(); const section: MutableSection = { id: null, // SPEC 1.7: opening tag through closing tag, or the self-closing - // tag's own characters (paired sections are completed above). - range: this.byteRange(span.start, span.end), - openingTagRange: this.byteRange(span.start, span.end), - closingTagRange: this.byteRange(span.start, span.end), + // tag's own characters. + range: this.byteRange(tags.start, tags.end), + openingTagRange: this.byteRange(tags.start, tags.openingEnd), + closingTagRange: this.byteRange(tags.closingStart, tags.end), selfClosing, parent, children: [], coverage: "required", // SPEC 2.5: the default tags: [], idAttribute: null, - dependency: null, + dependencies: [], + attributes: [], + tagsDefined: true, + coverageDefined: true, idPresent: false, }; this.processAttributes(node, section); @@ -1488,12 +1514,28 @@ class DocumentBuilder { /** Validate and record one section element's props (SPEC 2.5–2.7). */ private processAttributes(node: MdxTreeNode, section: MutableSection): void { - const seen = new Set<string>(); + /** + * Each prop name the tag spells → the attribute range of every + * spelling of it, in tag order, the first included (SPEC 14 location + * cardinality: a repeated prop locates every attribute spelling the + * name). + */ + const spellings = new Map<string, ByteRange[]>(); let idUnusable = false; 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( @@ -1506,31 +1548,64 @@ class DocumentBuilder { continue; } const name = attribute.name ?? ""; - if (seen.has(name)) { + const earlier = spellings.get(name); + if (earlier !== undefined) { // SPEC 2.7 → 14.17: no prop name may occur more than once on one - // element — defined or unknown. - this.addFinding( - 17, - attrRange, - `invalid prop: the prop ${JSON.stringify(name)} is repeated on ` + - `one element — no prop name may occur more than once; remove ` + - `the repetition (SPEC 2.7, 14.17)`, - ); - if (name === "id") { - idUnusable = true; // ambiguous declaration — no usable ID - } - continue; + // element — defined or unknown. Reported once per name, locating + // every spelling, after the loop. The repetition exempts no + // spelling from the per-attribute rules (SPEC 2.7, 14.4, 14.17): + // each is judged below as if it stood alone. + earlier.push(attrRange); + } else { + spellings.set(name, [attrRange]); } - seen.add(name); if (name === "d") { + // SPEC 2.7 → 14.17: a quoted or valueless `d` is invalid, every + // spelling judged on its own. SPEC 11.2 "Resolution": resolution + // is per spelling, whatever the validity of the attribute holding + // it — each entry of every `d` attribute of a section repeating + // the prop resolves or not on its own, beside the repetition's one + // 14.17 (below), exactly as the entries of a single `d` do — so + // every braced spelling is recorded, in tag order. this.processDependencyProp(attribute, attrSpan, section); - } else if (name === "id" || name === "coverage" || name === "tags") { - this.processStringProp(name, attribute, attrSpan, section, () => { + } else if (earlier !== undefined && isStringPropName(name)) { + // SPEC 2.7, 14.4, 14.17: a later spelling of `id`, `coverage`, or + // `tags` reports its own value-form, invalid-value, and 1.4 + // findings, but records nothing (SPEC 11.2): a repeated `id` + // spells no identity — the ambiguous declaration leaves no usable + // ID — and a repeated `tags`/`coverage` prop leaves the + // interpreted value undefined, no spelling picked. + this.judgeStringProp(name, attribute, attrSpan); + if (name === "id") { idUnusable = true; - }); + } else if (name === "tags") { + section.tagsDefined = false; + } else { + section.coverageDefined = false; + } + } else if (isStringPropName(name)) { + const interpreted = this.processStringProp( + name, + attribute, + attrSpan, + section, + () => { + idUnusable = true; + }, + ); + // SPEC 11.2: a malformed (braced/valueless) or invalid-valued + // `tags`/`coverage` prop leaves the interpreted value undefined, + // its raw spelling still listed. + if (!interpreted && name === "tags") { + section.tagsDefined = false; + } + if (!interpreted && name === "coverage") { + section.coverageDefined = false; + } } else { // SPEC 2.7 → 14.17: the props defined on <S>/<Spec> are id, d, - // coverage, and tags. + // coverage, and tags — every spelling of an unknown prop is one, + // a repeated one beside the repetition's finding. this.addFinding( 17, attrRange, @@ -1540,50 +1615,100 @@ class DocumentBuilder { ); } } + for (const [name, ranges] of spellings) { + if (ranges.length < 2) { + continue; + } + // SPEC 2.7 → 14.17: a repeated prop, defined or unknown, is invalid. + // SPEC 14 location cardinality: the spellings jointly violate it, so + // it is ONE finding per repeated name carrying a location for every + // attribute spelling the name, the first included — no + // representative is chosen (12.7 orders the locations). + this.findings.push( + locatedFinding( + 17, + `invalid prop: the prop ${JSON.stringify(name)} is spelled ` + + `${String(ranges.length)} times on one element — no prop name ` + + `may occur more than once; keep one spelling and remove the ` + + `others (SPEC 2.7, 14.17)`, + ranges.map((range) => ({ file: this.file, range })), + ), + ); + } if (idUnusable) { section.id = null; section.idAttribute = null; } } - /** SPEC 2.7: `d` MUST be a braced expression; record its span for 2.2/2.4. */ + /** + * SPEC 2.7: `d` MUST be a braced expression — every spelling judged on + * its own (14.17) — and each braced spelling's span is recorded for + * 2.2/2.4 (SPEC 11.2 "Resolution"). + */ private processDependencyProp( attribute: MdxAttributeNode, attrSpan: { start: number; end: number }, section: MutableSection, ): void { - const attrRange = this.byteRange(attrSpan.start, attrSpan.end); - const value = attribute.value ?? null; - if (value === null || typeof value === "string") { + const dependency = this.dependencyAttribute(attribute, attrSpan); + if (dependency === null) { // SPEC 2.7 → 14.17: a quoted or valueless `d` is invalid. + const form = + (attribute.value ?? null) === null + ? "a valueless prop" + : "a quoted string"; this.addFinding( 17, - attrRange, + this.byteRange(attrSpan.start, attrSpan.end), `invalid prop: the d prop must be a braced expression holding a ` + `static reference or an array literal of static references — ` + - `e.g. d={BASE.auth.login} or d={["local.id"]} — not ` + - `${value === null ? "a valueless prop" : "a quoted string"} ` + + `e.g. d={BASE.auth.login} or d={["local.id"]} — not ${form} ` + `(SPEC 2.7, 2.2, 14.17)`, ); return; } + section.dependencies.push(dependency); + } + + /** + * The spans of one `d` attribute in the braced-expression form of SPEC + * 2.7, for the analyzer of 2.2/2.4 — null for a quoted or valueless `d`, + * which holds no entries (its 14.17 is the caller's to report). + */ + private dependencyAttribute( + attribute: MdxAttributeNode, + attrSpan: { start: number; end: number }, + ): SpecDependencyAttribute | null { + const value = attribute.value ?? null; + if (value === null || typeof value === "string") { + return null; + } const open = this.valueOpenIndex(attribute, attrSpan); if (open === null || this.text[open] !== "{") { throw new Error( "xspec internal error: braced d value without a brace in source", ); } - section.dependency = { - expressionText: this.text.slice(open + 1, attrSpan.end - 1), + return { + expressionText: derivedContent( + this.text.slice(open + 1, attrSpan.end - 1), + (value as { readonly value?: unknown }).value, + ), expressionRange: this.byteRange(open + 1, attrSpan.end - 1), - attributeRange: attrRange, + attributeRange: this.byteRange(attrSpan.start, attrSpan.end), }; } /** * 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). + * string literal in quoted attribute form. Judges the prop's first + * spelling (`judgeStringProp`, SPEC 14.4, 14.17) and records its value on + * the section (SPEC 1.3, 2.5, 2.6). 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,13 +1716,70 @@ class DocumentBuilder { attrSpan: { start: number; end: number }, section: MutableSection, onIdUnusable: () => void, - ): void { - const attrRange = this.byteRange(attrSpan.start, attrSpan.end); + ): boolean { if (name === "id") { section.idPresent = true; } - const value = attribute.value ?? null; - if (typeof value !== "string") { + const judged = this.judgeStringProp(name, attribute, attrSpan); + if (judged === null) { + if (name === "id") { + onIdUnusable(); + } + return false; + } + if (name === "coverage") { + if (!judged.valid) { + return false; + } + section.coverage = judged.value === "none" ? "none" : "required"; + return true; + } + if (name === "tags") { + // SPEC 2.6: whitespace splitting, duplicate collapse; a value + // yielding no tags is equivalent to omitting the prop. SPEC 12.7: + // the interpreted tags are a tag set, in byte order (SPEC 12.0) — + // formed here, so every surface and the graph data carry the set. + // SPEC 11.2: an invalid-valued prop (14.4) leaves the interpreted + // value undefined. + section.tags = sortByBytes(splitTags(judged.value), (tag) => tag); + return judged.valid; + } + // name === "id" (SPEC 1.3): record the declared ID; the structural + // checks (14.1–14.3) run in validateStructure once the tree is complete. + // The spelled identity stays spelled whatever its segments (SPEC 11.2); + // its definedness is judged by `definedIdentitySections`. + section.id = judged.value; + section.idAttribute = { + value: judged.value, + valueRange: judged.valueRange, + quote: judged.quote, + attributeRange: judged.attributeRange, + }; + return true; + } + + /** + * Judge one `id`, `coverage`, or `tags` spelling as if it stood alone + * (SPEC 2.7), reporting its own findings and recording nothing: a braced + * or valueless value form is 14.17; a `coverage` value other than + * "required" or "none" is 14.17 (SPEC 2.5); an `id` or `tags` value + * violating 1.4 is 14.4 — one finding per `id` or `tags` attribute whose + * value violates it. Every spelling of a repeated prop is judged so, + * beside the repetition's one 14.17 (SPEC 14.17). Returns the quoted + * value as spelled, or null for a braced or valueless form. + */ + private judgeStringProp( + name: "id" | "coverage" | "tags", + attribute: MdxAttributeNode, + attrSpan: { start: number; end: number }, + ): JudgedStringProp | null { + const attrRange = this.byteRange(attrSpan.start, attrSpan.end); + // The parser's value says only which form the attribute takes: a string + // for the quoted form, an object for a braced one, null when valueless. + // Its characters are never read — they are character-reference decoded, + // and SPEC 2.4 reads a quoted value exactly as spelled (below). + const form = attribute.value ?? null; + if (typeof form !== "string") { // SPEC 2.7 → 14.17: braced (e.g. id={"login"}) and valueless forms // are invalid for id, coverage, and tags. this.addFinding( @@ -1606,13 +1788,10 @@ class DocumentBuilder { `invalid prop: the ${name} value must be a static string literal ` + `in quoted attribute form, single- or double-quoted — e.g. ` + `${name}="…" — not ` + - `${value === null ? "a valueless prop" : "a braced expression"} ` + + `${form === null ? "a valueless prop" : "a braced expression"} ` + `(SPEC 2.7, 14.17)`, ); - if (name === "id") { - onIdUnusable(); - } - return; + return null; } const open = this.valueOpenIndex(attribute, attrSpan); const quoteCharacter = open === null ? null : this.text[open]; @@ -1625,12 +1804,16 @@ class DocumentBuilder { "xspec internal error: quoted attribute value without quotes", ); } - // The raw characters between the quotes. MDX replaces U+0000 with - // U+FFFD while decoding, so the control-character rule of SPEC 1.4 is - // checked against the raw characters as authored. - const rawValue = this.text.slice(open + 1, attrSpan.end - 1); - const rawHasNul = rawValue.includes("\u0000"); - + // SPEC 2.4: the value of a quoted attribute is the characters between + // its delimiters exactly as spelled — no character reference (nor any + // escape sequence) is interpreted — for `id`, `coverage`, and `tags` + // alike. So `coverage="none"` is neither "required" nor "none" + // (14.17), and `id="a.b"` or `tags="xy"` spells a segment or + // tag containing `&`, which SPEC 1.4 forbids (14.4). + const valueStart = open + 1; + const valueEnd = attrSpan.end - 1; + const value = this.text.slice(valueStart, valueEnd); + let valid = true; if (name === "coverage") { // SPEC 2.5/2.7 → 14.17: the only defined values are "required" // (the default) and "none". @@ -1642,74 +1825,43 @@ class DocumentBuilder { `only defined values are "required" (the default) and "none" ` + `(SPEC 2.5, 2.7, 14.17)`, ); - return; + valid = false; } - section.coverage = value; - return; - } - - if (name === "tags") { - // SPEC 2.6: whitespace splitting, duplicate collapse; a value - // yielding no tags is equivalent to omitting the prop. - const tags = splitTags(value); - section.tags = tags; - if (rawHasNul) { - // SPEC 1.4 → 14.4: U+0000 is a control character. + } else if (name === "tags") { + // SPEC 2.6: tags split on runs of whitespace (1.4); SPEC 14.4: one + // finding per `tags` attribute whose value violates 1.4. + const problems = attributeProblems("tag", splitTags(value)); + if (problems.length > 0) { this.addFinding( 4, attrRange, - `invalid tag: the tags value contains the control character ` + - `U+0000 — tags contain no control characters; remove it ` + + `invalid tag: ${problems.join("; ")} — tags follow the ` + + `ID-segment rules with "." allowed; correct or remove the tag ` + `(SPEC 1.4, 2.6, 14.4)`, ); + valid = false; } - for (const tag of tags) { - const violation = valueViolation(tag, "tag"); - if (violation !== null) { - this.addFinding( - 4, - attrRange, - `invalid tag ${JSON.stringify(tag)}: ${violation} — tags ` + - `follow the ID-segment rules with "." allowed; correct or ` + - `remove the tag (SPEC 1.4, 2.6, 14.4)`, - ); - } - } - return; - } - - // name === "id" (SPEC 1.3): record the declared ID and validate its - // segments (SPEC 1.4 → 14.4); the structural checks (14.1–14.3) run in - // validateStructure once the tree is complete. - section.id = value; - section.idAttribute = { - value, - valueRange: this.byteRange(open + 1, attrSpan.end - 1), - quote: quoteCharacter, - attributeRange: attrRange, - }; - if (rawHasNul) { - // SPEC 1.4 → 14.4: U+0000 is a control character. - this.addFinding( - 4, - attrRange, - `invalid segment: the id value contains the control character ` + - `U+0000 — segments contain no control characters; remove it ` + - `(SPEC 1.4, 14.4)`, - ); - } - for (const segment of value.split(".")) { - const violation = valueViolation(segment, "segment"); - if (violation !== null) { + } else { + // SPEC 1.3: the segments are the value's "."-separated parts; SPEC + // 14.4: one finding per `id` attribute whose value violates 1.4. + const problems = attributeProblems("segment", value.split(".")); + if (problems.length > 0) { this.addFinding( 4, attrRange, - `invalid segment ${JSON.stringify(segment)} in id ` + - `${JSON.stringify(value)}: ${violation} — correct the segment ` + - `(SPEC 1.4, 14.4)`, + `invalid segment in id: ${problems.join("; ")} — correct the ` + + `segment (SPEC 1.4, 14.4)`, ); + valid = false; } } + return { + value, + valueRange: this.byteRange(valueStart, valueEnd), + quote: quoteCharacter, + attributeRange: attrRange, + valid, + }; } /** @@ -1755,9 +1907,15 @@ class DocumentBuilder { * the immediate children of a section without a usable ID it is masked, * while their other conditions, and the check for their own children * (against their declared IDs), report normally. + * + * Location cardinality (SPEC 14): a duplicated ID is one condition the + * bearers jointly violate — ONE 14.3 finding per duplicated identity, + * carrying a location for every bearer, the first included; no + * representative is chosen. */ validateStructure(): void { - const seen = new Set<string>(); + /** Declared ID → the location of every bearer, in document order. */ + const bearers = new Map<string, ByteRange[]>(); for (const section of this.sections) { if (!section.idPresent) { // SPEC 1.3 → 14.1: a non-root section without `id`. @@ -1801,33 +1959,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 bc81c9bf..d05229f9 100644 --- a/src/core/move.ts +++ b/src/core/move.ts @@ -34,8 +34,9 @@ // form; the subtree is re-identified by prefix replacement; references // convert between local and imported forms as the rewrite requires, spec // module imports are added (binding fresh, non-colliding, deterministic -// identifiers) and removed exactly when a binding had references and the -// rewrite leaves it with none (SPEC 6.5, 2.1); the full mapping is the +// identifiers) and removed — in spec and code sources alike — exactly when +// an occurrence used a binding of the import before the rewrite and none +// uses any binding of it after (SPEC 6.5, 2.1); the full mapping is the // journal entry. // // Rewrites are minimal in-place edits (SPEC 6.4, 6.5), preserving quote @@ -55,7 +56,13 @@ import type { ByteRange } from "./bytes.js"; import { compareBytes } from "./bytes.js"; -import type { CodeAnalysis } from "./code-analysis.js"; +import type { + CodeAnalysis, + CodeImport, + CodeImportBinding, + CodeReference, +} from "./code-analysis.js"; +import { topLevelImportRanges } from "./code-analysis.js"; import type { SourceEdit, SourceRewrite } from "./edits.js"; import { applyEdits, @@ -63,10 +70,19 @@ import { EditCollector, jsStringLiteral, } from "./edits.js"; +import type { FindingLocation } from "./findings.js"; 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 { + SpecDocument, + SpecEsmBlock, + SpecImportStatement, + SpecSection, +} from "./mdx.js"; +import { isWellFormedSpecSource, parseSpecSource } from "./mdx.js"; +import type { PreviewFileEdits } from "./preview.js"; +import { PreviewCollector } from "./preview.js"; import { isDotAccessSegmentName, replaceIdPrefix, @@ -96,6 +112,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 +228,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 +252,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 +370,7 @@ export function planMoveFile( mapping, ), rewrites, + previewFiles: preview.files(), }; } @@ -423,6 +460,12 @@ function deletionEditsWithLineDrops( const sorted = [...ranges].sort((a, b) => a.start - b.start); for (let index = 1; index < sorted.length; index += 1) { if (sorted[index]!.start < sorted[index - 1]!.end) { + // Unreachable guard: the one overlap a plan could hold — an import + // removal inside the origin deletion's construct — is a moved text + // holding an import declaration, refused before any planning + // (`refused-moved-import`, SPEC 6.5, 14; core/refusal.ts) and + // judged with those removals left to the origin deletion + // (`judgeMoveSectionRewrite`). throw new Error("xspec internal error: overlapping move deletions"); } } @@ -488,11 +531,92 @@ 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)); +} + +/** The keyword every import declaration's own characters begin with. */ +const IMPORT_KEYWORD_LENGTH = "import".length; +const SPACE = 0x20; + +/** + * SPEC 6.5 "Import edits": whether the import removals `removed` leave the + * spec source's ESM block `block` still deriving as one — judged together, + * over the block as all of them would leave it: each removed declaration's + * own characters deleted, the lines that leaves empty or whitespace-only + * dropped with their terminators (3). The grammar bounds the block + * line-sensitively (14.20): its construct opens only at a line's start + * spelling `import` then U+0020, so the first line left must start with a + * kept declaration spelled so. Anything else heading it — a JavaScript + * comment (`// note` below a removed first line, or trailing it on that + * line), an indented declaration — leaves the remaining lines deriving as + * paragraph text, and the block's first declaration must stay. A block + * left with no line at all, every line dropped, is headed by nothing: its + * removals stand, the first declaration's included. + */ +function removalsLeaveBlockHeaded( + bytes: Uint8Array, + block: SpecEsmBlock, + removed: ReadonlySet<SpecImportStatement>, +): boolean { + const edits = deletionEditsWithLineDrops( + bytes, + block.imports + .filter((statement) => removed.has(statement)) + .map((statement) => statement.range), + ); + // The block's first byte no edit deletes: the start of the first line + // the removals leave (the edits come in document order, a dropped line + // spanning its terminator, so each line the block keeps starts a line). + let head = block.range.start; + for (const edit of edits) { + if (edit.range.start > head) { + break; + } + head = Math.max(head, edit.range.end); + } + if (head >= block.range.end) { + return true; + } + return ( + block.imports.some( + (statement) => !removed.has(statement) && statement.range.start === head, + ) && bytes[head + IMPORT_KEYWORD_LENGTH] === SPACE + ); +} + /** * ECMAScript reserved words, which an import binding can never use — the - * fresh-identifier chooser (SPEC 6.5) skips them. + * fresh-identifier chooser (SPEC 6.5) skips them — and `arguments` and + * `eval`, which no binding of module (strict) code may bind. */ const RESERVED_BINDING_NAMES: ReadonlySet<string> = new Set([ + "arguments", "await", "break", "case", @@ -506,6 +630,7 @@ const RESERVED_BINDING_NAMES: ReadonlySet<string> = new Set([ "do", "else", "enum", + "eval", "export", "extends", "false", @@ -569,17 +694,25 @@ function stemIdentifierBase(modulePath: string): string { return base; } +/** + * The suffix a fresh `text` binding's identifier adds to the module's stem + * base (SPEC 6.5: identifier choice is deterministic), so the binding is + * aliased as 4.4 advises where a file consumes several spec modules. + */ +const TEXT_BINDING_SUFFIX = "Text"; + /** * A fresh import binding name for `modulePath` colliding with no name in * `taken` (SPEC 6.5, 2.1: fresh, non-colliding, deterministic): the stem - * base, then base2, base3, … — skipping reserved words and the - * compiler-provided names. + * base followed by `suffix`, then that with 2, 3, … appended — skipping + * reserved words and the compiler-provided names. */ function freshBindingName( modulePath: string, taken: ReadonlySet<string>, + suffix = "", ): string { - const base = stemIdentifierBase(modulePath); + const base = `${stemIdentifierBase(modulePath)}${suffix}`; const usable = (name: string): boolean => !taken.has(name) && !RESERVED_BINDING_NAMES.has(name) && @@ -603,6 +736,12 @@ function bump(counts: Map<string, number>, key: string): void { interface LocatedReference { readonly section: SpecSection; readonly reference: SpecReference; + /** + * The occurrence span (SPEC 5.7): a `d` entry's own expression; an MDX + * embedding's full braced container — the construct a preview's + * `reference-rewrite` edit spans (SPEC 6.6). + */ + readonly occurrence: ByteRange; } /** Every reference of a spec file, `d` and `text(...)` alike, in document order. */ @@ -612,6 +751,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 +764,7 @@ function locatedReferencesOf(spec: SpecFileAnalysis): LocatedReference[] { references.push({ section: embedding.embedding.section, reference: embedding.reference, + occurrence: embedding.embedding.range, }); } return references; @@ -643,6 +784,8 @@ class SpecImportPlan { private readonly arrivals = new Map<string, number>(); private readonly taken = new Set<string>(); private readonly additions = new Map<string, string>(); + /** Per added module: the spellings rooted at its added binding. */ + private readonly addedSpellings = new Map<string, FindingLocation[]>(); /** `spec` is null for a target file the move creates (SPEC 6.5). */ constructor(private readonly spec: SpecFileAnalysis | null) { @@ -664,8 +807,13 @@ class SpecImportPlan { * The binding name a rewritten reference to `modulePath` roots at in this * file (SPEC 6.5: an import is added when a rewritten reference needs a * module binding its file lacks). Counts one arrival per call. + * `spelling` is the reference's occurrence in pre-operation coordinates + * (5.7), recorded, per module, where the binding is an added one: the + * spellings a `refused-invalid-rewrite` locates when no offset admits the + * addition, and a `refused-cycle` when the addition closes a would-be + * spec import cycle (SPEC 14), whether or not their characters change. */ - bindingFor(modulePath: string): string { + bindingFor(modulePath: string, spelling: FindingLocation): string { if (this.spec !== null) { for (const imported of this.spec.imports.imports) { if ( @@ -677,6 +825,12 @@ class SpecImportPlan { } } } + let spellings = this.addedSpellings.get(modulePath); + if (spellings === undefined) { + spellings = []; + this.addedSpellings.set(modulePath, spellings); + } + spellings.push(spelling); const added = this.additions.get(modulePath); if (added !== undefined) { return added; @@ -693,15 +847,20 @@ class SpecImportPlan { } /** - * SPEC 6.5/2.1: the imports removed — exactly those whose binding had - * references and the rewrite leaves with none; a binding that was already - * unreferenced stays. + * SPEC 6.5/2.1: the imports removed — those whose binding had references + * and the rewrite leaves with none (a binding that was already + * unreferenced stays), the removals in one ESM block judged together + * (`removalsLeaveBlockHeaded`, over the file's `bytes`): where they would + * leave the block headed by anything but a declaration at the start of + * its first line, the block's first declaration stays — its binding + * unused (2.1), no removal reported for it (6.6) — and the others are + * removed, the block it still heads deriving as before. */ - removedImports(): SpecImport[] { + removedImports(bytes: Uint8Array): SpecImport[] { if (this.spec === null) { return []; } - const removed: SpecImport[] = []; + const removed = new Set<SpecImportStatement>(); for (const imported of this.spec.imports.imports) { const name = imported.bindingName; if (name === null) { @@ -716,18 +875,51 @@ class SpecImportPlan { (this.departures.get(name) ?? 0) + (this.arrivals.get(name) ?? 0); if (before > 0 && after === 0) { - removed.push(imported); + removed.add(imported.statement); } } - return removed; + for (const block of this.spec.document.esmBlocks) { + const first = block.imports[0]; + if ( + first !== undefined && + removed.has(first) && + !removalsLeaveBlockHeaded(bytes, block, removed) + ) { + removed.delete(first); + } + } + return this.spec.imports.imports.filter((imported) => + removed.has(imported.statement), + ); } - /** The added imports, ordered by module path bytes (deterministic). */ - addedImports(): { readonly modulePath: string; readonly name: string }[] { + /** + * The added imports, ordered by module path bytes (deterministic), each + * with every reference spelling the operation roots at its binding, at + * its pre-operation occurrence (SPEC 6.5, 14, 5.7). + */ + addedImports(): { + readonly modulePath: string; + readonly name: string; + readonly spellings: readonly FindingLocation[]; + }[] { return [...this.additions.entries()] - .map(([modulePath, name]) => ({ modulePath, name })) + .map(([modulePath, name]) => ({ + modulePath, + name, + spellings: this.addedSpellings.get(modulePath) ?? [], + })) .sort((a, b) => compareBytes(a.modulePath, b.modulePath)); } + + /** + * Every reference spelling the operation roots at a binding this file's + * added declarations give it, at its pre-operation occurrence (SPEC 6.5, + * 14, 5.7). + */ + addedBindingSpellings(): readonly FindingLocation[] { + return this.addedImports().flatMap((addition) => addition.spellings); + } } /** @@ -892,6 +1084,465 @@ function assembleWithInsertion( return out; } +/** + * A file's content as every edit of a section move but its added import + * declarations leaves it, and where declarations added at a + * pre-operation offset stand in that content (SPEC 6.5 "Composition and + * admissibility": composition in pre-operation coordinates). + */ +interface FileComposition { + readonly content: Uint8Array; + /** + * The composed position of declarations added at pre-operation + * `offset`: after every edit whose range ends there, before every edit + * whose range begins there, and after the target insertion — with the + * appended closing tag, where one applies — when the insertion shares + * the offset; null where `offset` lies strictly inside an edit's range, + * where no addition may stand (SPEC 6.5). + */ + positionOf(offset: number): number | null; +} + +/** Map an original-byte offset through edits; null inside one's range. */ +function composedPosition( + offset: number, + edits: readonly SourceEdit[], +): number | null { + let delta = 0; + for (const edit of edits) { + if (edit.range.end <= offset) { + delta += + encoder.encode(edit.replacement).length - + (edit.range.end - edit.range.start); + } else if (edit.range.start < offset) { + return null; + } + } + return offset + delta; +} + +/** The composition of a file receiving no moved text. */ +function editsComposition( + bytes: Uint8Array, + edits: readonly SourceEdit[], +): FileComposition { + return { + content: applyEdits(bytes, edits), + positionOf: (offset) => composedPosition(offset, edits), + }; +} + +/** + * The composition of the existing target file: its edits applied and the + * moved text inserted (SPEC 6.5), declarations added at the insertion's + * own offset standing after it and after the appended closing tag. + */ +function insertionComposition( + bytes: Uint8Array, + edits: readonly SourceEdit[], + insertion: SectionInsertion, +): FileComposition { + const content = assembleWithInsertion(bytes, edits, insertion); + const inserted = content.length - applyEdits(bytes, edits).length; + return { + content, + positionOf: (offset) => { + const position = composedPosition(offset, edits); + if (position === null) { + return null; + } + return offset >= insertion.pos ? position + inserted : position; + }, + }; +} + +/** The start of the line after the one holding `position` (SPEC 3). */ +function nextLineStart(bytes: Uint8Array, position: number): number { + return terminatorEndAt(bytes, lineContentEndAfter(bytes, position)); +} + +/** + * Every line start of a file in document order (SPEC 3: U+000D U+000A is + * one terminator), the file's end after a final terminator included. + */ +function lineStartsOf(bytes: Uint8Array): number[] { + const starts = [0]; + for (let start = 0; start < bytes.length;) { + const next = nextLineStart(bytes, start); + if (next === start || next > bytes.length) { + break; + } + if (isTerminatorByte(bytes[next - 1]!)) { + starts.push(next); + } + start = next; + } + return starts; +} + +/** + * Every line's end in document order — the offset of its terminator, or + * the file's end for a final line without one. + */ +function lineEndsOf(bytes: Uint8Array): number[] { + const ends: number[] = []; + for (const start of lineStartsOf(bytes)) { + const end = lineContentEndAfter(bytes, start); + if (end < bytes.length || end > start) { + ends.push(end); + } + } + return ends; +} + +/** The ESM block ranges of a spec source's content; none if unparseable. */ +function esmBlockRangesOf(path: string, content: Uint8Array): ByteRange[] { + const parsed = parseSpecSource(path, content); + return parsed.kind === "document" + ? parsed.document.esmBlocks.map((block) => block.range) + : []; +} + +/** + * SPEC 6.5 "Import edits": whether `content` — a spec source as every edit + * of the rewrite leaves it, the added declarations' own characters at + * `added` and the whole addition, a U+000A before it included, at + * `inserted` — admits the addition: the file is well-formed (14.20); the + * added lines are import declarations of one ESM block standing inside no + * section construct of the file so left; and every other line of that + * block was a line of an ESM block before the addition (`before`: the + * block ranges of the content without it), so the addition turns no + * paragraph line, or any other content, into the block's. + */ +function admitsAddedDeclarations( + path: string, + content: Uint8Array, + added: readonly ByteRange[], + inserted: ByteRange, + before: readonly ByteRange[], +): boolean { + const parsed = parseSpecSource(path, content); + if (parsed.kind !== "document") { + return false; + } + const document = parsed.document; + const block = document.esmBlocks.find((candidate) => + added.every((range) => + candidate.imports.some( + (statement) => + statement.range.start === range.start && + statement.range.end === range.end, + ), + ), + ); + if (block === undefined) { + return false; + } + if ( + document.sections.some( + (section) => + section.range.start <= block.range.start && + block.range.end <= section.range.end, + ) + ) { + return false; + } + const shift = inserted.end - inserted.start; + for ( + let line = block.range.start; + line < block.range.end; + line = nextLineStart(content, line) + ) { + if (line >= inserted.start && line < inserted.end) { + continue; // an added line + } + const original = line < inserted.start ? line : line - shift; + if ( + !before.some((range) => range.start <= original && original < range.end) + ) { + return false; + } + } + return true; +} + +/** Whether the line starting at `position` is blank (spaces and tabs). */ +function isBlankLineAt(bytes: Uint8Array, position: number): boolean { + for (let index = position; index < bytes.length; index += 1) { + const byte = bytes[index]!; + if (isTerminatorByte(byte)) { + return true; + } + if (byte !== 0x20 && byte !== 0x09) { + return false; + } + } + return true; +} + +/** + * A file's content with added declarations inserted (`composeAddition`): + * the byte ranges of the declarations' own characters and of the whole + * insertion, a U+000A before it included. + */ +interface ComposedAddition { + readonly content: Uint8Array; + readonly added: readonly ByteRange[]; + readonly inserted: ByteRange; +} + +/** + * `composed` with the added declaration lines inserted at a candidate's + * composed position — each line followed by U+000A, the first preceded by + * one when the position is not at a line start — with the byte ranges of + * the declarations' own characters and of the whole insertion. + */ +function composeAddition( + composed: Uint8Array, + candidate: { readonly position: number; readonly atLineStart: boolean }, + lineBytes: readonly Uint8Array[], +): ComposedAddition { + let length = candidate.atLineStart ? 0 : 1; + for (const line of lineBytes) { + length += line.length + 1; + } + const content = new Uint8Array(composed.length + length); + content.set(composed.subarray(0, candidate.position), 0); + let cursor = candidate.position; + if (!candidate.atLineStart) { + content[cursor] = LF; + cursor += 1; + } + const added: ByteRange[] = []; + for (const line of lineBytes) { + content.set(line, cursor); + added.push({ start: cursor, end: cursor + line.length }); + cursor += line.length; + content[cursor] = LF; + cursor += 1; + } + content.set(composed.subarray(candidate.position), cursor); + return { + content, + added, + inserted: { start: candidate.position, end: cursor }, + }; +} + +/** + * A candidate offset for a file's added import declarations (SPEC 6.5): + * the pre-operation `offset`, the `position` at which declarations added + * there stand in the composed text, and whether that position is at the + * start of a line — judged over the composed text with the addition + * absent (SPEC 6.5 "Composition and admissibility"). + */ +interface AdditionCandidate { + readonly offset: number; + readonly position: number; + readonly atLineStart: boolean; +} + +/** + * The candidate offsets for a file's added import declarations, in the + * fixed order they are tried (SPEC 6.5: the choice among admissible + * offsets is implementation latitude, exercised deterministically): + * `preferred`, then every line start of the pre-operation file, then every + * line's end, each once — those at the start of a line before every + * other, an admissible offset at a line start being taken over any other. + * An offset strictly inside another edit's range, where no addition may + * stand, or one `excluded` names, is no candidate. + */ +function additionCandidates( + bytes: Uint8Array, + composition: FileComposition, + preferred: readonly number[], + excluded: (offset: number) => boolean, +): AdditionCandidate[] { + const candidates: AdditionCandidate[] = []; + const seen = new Set<number>(); + for (const offset of [ + ...preferred, + ...lineStartsOf(bytes), + ...lineEndsOf(bytes), + ]) { + if (seen.has(offset)) { + continue; + } + seen.add(offset); + if (excluded(offset)) { + continue; + } + const position = composition.positionOf(offset); + if (position === null) { + continue; + } + const atLineStart = + position === 0 || isTerminatorByte(composition.content[position - 1]!); + candidates.push({ offset, position, atLineStart }); + } + return [ + ...candidates.filter((candidate) => candidate.atLineStart), + ...candidates.filter((candidate) => !candidate.atLineStart), + ]; +} + +/** Where a file's added declarations stand (SPEC 6.5, 6.6). */ +interface PlacedAdditions { + /** The pre-operation offset, exactly the one the preview reports. */ + readonly offset: number; + /** The file's content, every edit and the addition applied. */ + readonly content: Uint8Array; + /** + * Whether the offset is admissible — false only where the file holds + * no admissible offset at all, the declarations then standing at the + * first candidate: a text the refused move only judges, never writes. + */ + readonly admissible: boolean; +} + +/** + * Place a file's added import declarations (SPEC 6.5 "Import edits" and + * "Composition and admissibility"): `lines`, contiguous, each followed by + * U+000A and the first preceded by one when its insertion point — judged + * over the composed text — is not at the start of a line, inserted at the + * first of `candidates` whose composition `admits`. + */ +function placeImportAdditions( + composition: FileComposition, + lines: readonly string[], + candidates: readonly AdditionCandidate[], + admits: (candidate: AdditionCandidate, composed: ComposedAddition) => boolean, +): PlacedAdditions { + const lineBytes = lines.map((line) => encoder.encode(line)); + let fallback: PlacedAdditions | null = null; + for (const candidate of candidates) { + const composed = composeAddition(composition.content, candidate, lineBytes); + fallback ??= { + offset: candidate.offset, + content: composed.content, + admissible: false, + }; + if (admits(candidate, composed)) { + return { + offset: candidate.offset, + content: composed.content, + admissible: true, + }; + } + } + // SPEC 6.5 refuses a move leaving a file no admissible offset for an + // addition it needs (`refused-invalid-rewrite`), decided from + // `admissible` by the refusal evaluation (`judgeMoveSectionRewrite`). + // The file's start is always a candidate, so the fallback exists. + if (fallback === null) { + throw new Error("xspec internal error: no offset for an import addition"); + } + return fallback; +} + +/** + * Place a spec source's added import declarations (SPEC 6.5 "Import + * edits"): at the first candidate (`additionCandidates`) that admits them + * (`admitsAddedDeclarations`). An offset strictly inside a section + * construct of the pre-operation file stays inside that construct as every + * edit leaves the file, so it is never admissible and is not tried. + */ +function placeSpecImportAdditions( + document: SpecDocument, + bytes: Uint8Array, + composition: FileComposition, + lines: readonly string[], + preferred: readonly number[], +): PlacedAdditions { + const candidates = additionCandidates( + bytes, + composition, + preferred, + (offset) => + document.sections.some( + (section) => section.range.start < offset && offset < section.range.end, + ), + ); + const before = esmBlockRangesOf(document.path, composition.content); + return placeImportAdditions( + composition, + lines, + candidates, + (candidate, composed) => { + // The added lines' block runs on through the line after them: at a + // line start whose line is neither blank nor an ESM block's, that + // line would join the block — never admissible, so no parse is spent + // on it. (A line-end candidate is followed by its line's empty + // remainder.) + if ( + candidate.atLineStart && + !isBlankLineAt(composition.content, candidate.position) && + !before.some( + (range) => + range.start <= candidate.position && candidate.position < range.end, + ) + ) { + return false; + } + return admitsAddedDeclarations( + document.path, + composed.content, + composed.added, + composed.inserted, + before, + ); + }, + ); +} + +/** + * SPEC 6.5 "Import edits": whether `composed` — a code source as every + * edit of the rewrite leaves it, the added declarations' own characters at + * `composed.added` — admits the addition: the file is well-formed under + * the grammar its name selects (14.20), and each added line is a + * top-level declaration of it, an import declaration spanning exactly the + * added characters. So no construct absorbs a declaration — a comment, a + * template literal, JSX text, or a block (TypeScript's parser derives an + * import declaration inside a function body, the top-level rule being a + * post-parse check) — and it absorbs nothing (a `;` heading the line after + * it, which the grammar reads as its terminator). + */ +function admitsAddedCodeDeclarations( + path: string, + composed: ComposedAddition, +): boolean { + const declarations = topLevelImportRanges(path, composed.content); + return ( + declarations !== null && + composed.added.every((range) => + declarations.some( + (declaration) => + declaration.start === range.start && declaration.end === range.end, + ), + ) + ); +} + +/** + * Place a code source's added import declarations (SPEC 6.5 "Import + * edits"): at the first candidate (`additionCandidates`) that admits them + * (`admitsAddedCodeDeclarations`). + */ +function placeCodeImportAdditions( + path: string, + bytes: Uint8Array, + composition: FileComposition, + lines: readonly string[], + preferred: readonly number[], +): PlacedAdditions { + return placeImportAdditions( + composition, + lines, + additionCandidates(bytes, composition, preferred, () => false), + (_, composed) => admitsAddedCodeDeclarations(path, composed), + ); +} + /** Everything a validated section-form move changes in the sources. */ export interface MoveSectionPlan { /** The full identity mapping the operation produces (SPEC 6.5, 6.1). */ @@ -902,19 +1553,170 @@ 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[]; +} + +/** + * The bindings one declaration added to a file gives it of one module + * (SPEC 6.5 "Import edits"): exactly the lacked ones — the default binding + * a chain is rooted at, and, in a TypeScript source, the `text` binding a + * call's callee is (2.1, 4); null where the file holds the binding. + */ +interface AddedBindings { + defaultName: string | null; + textName: string | null; } -/** An import declaration line for a spec file (SPEC 2.1, 6.5 additions). */ -function specImportLine( +/** + * An added import declaration, spelled exactly as SPEC 6.5 gives it: + * `import X from "…"` in a spec source; in a TypeScript source + * `import X from "…"`, `import { text as Y } from "…"`, or + * `import X, { text as Y } from "…"`, as the lacked bindings require, the + * named binding `{ text }` where its identifier is `text` itself — single + * spaces, no statement terminator, the specifier double-quoted in the + * canonical relative spelling from the importing file's directory + * (SPEC 2.1, 6.5 "Import edits"). `composeAddition` puts it on a line of + * its own. + */ +function importDeclarationLine( filePath: string, modulePath: string, - name: string, + bindings: Readonly<AddedBindings>, + judging = false, ): string { const specifier = relativeModuleSpecifier( filePath, - moduleSpecifierTargetOf(modulePath), + judging && !modulePath.endsWith(MDX_SUFFIX) + ? // Judging a target path that is no spec source path (refused as + // `refused-invalid-destination` beside, SPEC 6.5, 14): its module + // has no `.xspec` spelling, and any specifier judges the added + // line alike. + modulePath + : moduleSpecifierTargetOf(modulePath), + ); + const clauses: string[] = []; + if (bindings.defaultName !== null) { + clauses.push(bindings.defaultName); + } + if (bindings.textName !== null) { + clauses.push( + bindings.textName === "text" + ? "{ text }" + : `{ text as ${bindings.textName} }`, + ); + } + if (clauses.length === 0) { + throw new Error("xspec internal error: an added import binding nothing"); + } + return `import ${clauses.join(", ")} from ${jsStringLiteral(specifier, '"')}`; +} + +/** An added default import declaration, `import X from "…"` (SPEC 6.5). */ +function defaultImportLine( + filePath: string, + modulePath: string, + name: string, + judging = false, +): string { + return importDeclarationLine( + filePath, + modulePath, + { defaultName: name, textName: null }, + judging, ); - return `import ${name} from ${jsStringLiteral(specifier, '"')}`; +} + +/** + * What a section-form move's exact edits leave invalid (SPEC 6.5 + * "Validation and refusals", 14 `refused-invalid-rewrite`), over the files + * the refusal judges: the origin always, the target only where an + * insertion point exists, and every other spec source and every code + * source for the additions it needs. + */ +export interface MoveSectionRewriteVerdict { + /** + * The judged files whose would-be text — as every edit but the added + * declarations leaves it, a created target as its creation composes it — + * is not well-formed MDX (14.20), in the order judged. + */ + readonly illFormed: readonly string[]; + /** + * Each spec or code source holding no admissible offset for the + * declarations it needs added, with every reference spelling the + * operation roots at their bindings, at its pre-operation occurrence + * (5.7), whether or not its characters change — a `text(...)` call in a + * code source by the whole call. + */ + readonly inadmissible: readonly { + readonly path: string; + readonly spellings: readonly FindingLocation[]; + }[]; +} + +/** + * One declaration of the spec import relation a section move's rewrite + * leaves (SPEC 6.5 "Import edits", 2.1; 14 `refused-cycle`): the importing + * spec source and the one it designates, by path — a section move + * relocates no file, and the target file is a created one where the move + * creates it — located in pre-operation coordinates: a declaration + * existing before the operation that the rewrite keeps, by its own + * characters; one the rewrite adds, which exists in no pre-operation + * coordinates, by every reference spelling the operation roots at its + * binding, whether or not the spelling's characters change (SPEC 14). + */ +export interface WouldBeSpecImport { + readonly importer: string; + readonly imported: string; + readonly locations: readonly FindingLocation[]; +} + +/** + * What the refusal evaluation reads of a section move's composition + * (SPEC 6.5, 14): the would-be text's verdict (`refused-invalid-rewrite`) + * and the spec import relation the rewrite leaves (`refused-cycle`). + */ +export interface MoveSectionJudgement { + readonly verdict: MoveSectionRewriteVerdict; + /** + * The spec import relation the rewrite leaves, read from the + * composition's own import bookkeeping, so the refusal and the rewrite + * cannot disagree: every declaration it keeps — an import whose + * bindings were already unused, and a block's first declaration the + * joint-removal rule keeps, its binding left unused, included — and + * every one it adds, a declaration it removes standing in no + * post-operation file (SPEC 6.5 "Import edits"). Spec sources in the + * analyses' order, each file's kept declarations in source order, then + * its additions in module-path byte order; a created target file's + * additions last. + */ + readonly imports: readonly WouldBeSpecImport[]; +} + +/** + * How the refusal evaluation composes a section move's would-be files + * (SPEC 6.5 "Validation and refusals"): beside every other applicable + * reason — the exact self-move, a moved text holding an import + * declaration, a moved reference to the target file's own root (refused + * as the dependency cycle it closes) — so none of those preconditions is + * asserted; and the target composed only where an insertion point exists. + */ +interface MoveSectionJudging { + /** + * Whether the target insertion point exists: the target path a + * discovered spec source or an absent path, and the target parent, where + * `<new-id>` needs one, present outside the moved subtree (SPEC 6.5). + */ + readonly insertionPoint: boolean; } /** @@ -930,6 +1732,78 @@ export function planMoveSection( targetPath: string, newId: string, ): MoveSectionPlan { + const { plan } = composeMoveSection( + specs, + code, + originPath, + oldId, + targetPath, + newId, + null, + ); + if (plan === null) { + throw new Error("xspec internal error: a section move composed no plan"); + } + return plan; +} + +/** + * SPEC 6.5 "Validation and refusals", 14 `refused-invalid-rewrite` and + * `refused-cycle`: judge a section-form move's exact edits over a + * workspace passing `build`'s validations — the would-be text of the + * origin, and of the target where `insertionPoint` holds, judged + * well-formed or not (14.20), and every spec and code source judged for + * an admissible offset for the additions it needs — composed exactly as + * the plan composes them, beside the spec import relation the rewrite + * leaves. The verdict means something under an intrinsically valid + * `<new-id>` alone; the relation reads nothing of `<new-id>`: which + * references the move re-roots, at which bindings, and which declarations + * it adds and removes are fixed by the moved subtree, the origin, and the + * target file (SPEC 6.5 "Import edits"). No code source takes part in a + * spec import cycle (2.1). + */ +export function judgeMoveSectionRewrite( + specs: readonly SpecFileAnalysis[], + code: readonly CodeAnalysis[], + originPath: string, + oldId: string, + targetPath: string, + newId: string, + insertionPoint: boolean, +): MoveSectionJudgement { + const { verdict, imports } = composeMoveSection( + specs, + code, + originPath, + oldId, + targetPath, + newId, + { insertionPoint }, + ); + return { verdict, imports }; +} + +/** + * The section-form composition behind `planMoveSection` (`judging` null: + * the preconditions asserted, every file composed, the plan returned) and + * `judgeMoveSectionRewrite` (the preconditions another refusal reason + * reports tolerated, the verdict judged and the would-be spec import + * relation read, and no plan). + */ +function composeMoveSection( + specs: readonly SpecFileAnalysis[], + code: readonly CodeAnalysis[], + originPath: string, + oldId: string, + targetPath: string, + newId: string, + judging: MoveSectionJudging | null, +): { + readonly plan: MoveSectionPlan | null; + readonly verdict: MoveSectionRewriteVerdict; + /** Judging, the would-be spec import relation; empty for a plan. */ + readonly imports: readonly WouldBeSpecImport[]; +} { const origin = specs.find((spec) => spec.document.path === originPath); if (origin === undefined) { throw new Error( @@ -946,12 +1820,27 @@ export function planMoveSection( `${originPath} — the caller validated its existence`, ); } - if (originPath === targetPath && oldId === newId) { + if (judging === null && originPath === targetPath && oldId === newId) { throw new Error( "xspec internal error: the exact self-move — the caller refused it " + "(SPEC 6.5)", ); } + // The would-be target is composed where an insertion point exists — + // always for a plan, whose caller refused every move lacking one. + const composesTarget = judging === null || judging.insertionPoint; + const illFormed: string[] = []; + const inadmissible: { + readonly path: string; + readonly spellings: readonly FindingLocation[]; + }[] = []; + // SPEC 6.5/14.20: the judged would-be texts, as every edit but the added + // declarations leaves them. + const judgeWellFormed = (path: string, content: Uint8Array): void => { + if (judging !== null && !isWellFormedSpecSource(content)) { + illFormed.push(path); + } + }; const sameFile = originPath === targetPath; const target = sameFile ? origin @@ -984,9 +1873,10 @@ export function planMoveSection( // The target parent: the target file's section bearing `<new-id>` minus // its final segment — the file's root (insertion at end of file) for a // top-level `new-id` (SPEC 6.5). The caller validated its existence and - // that it lies outside the moved subtree. + // that it lies outside the moved subtree — or, judging, reports that no + // insertion point exists, and no target is composed. let parentSection: SpecSection | null = null; - if (newSegments.length > 1) { + if (composesTarget && newSegments.length > 1) { const parentId = newSegments.slice(0, -1).join("."); const found = target?.document.sections.find( (section) => section.id === parentId, @@ -1010,6 +1900,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 ( @@ -1045,6 +1941,13 @@ export function planMoveSection( if (mapped === null) { continue; } + if (mapped === section.id) { + // SPEC 6.5: a rewrite is made, and reported (6.6), exactly when it + // changes the construct's characters — an `id` attribute already + // spelling its node's new ID (a cross-file section move keeping its + // ID) is neither rewritten nor reported. + continue; + } const attribute = section.idAttribute; if (attribute === null) { throw new Error( @@ -1056,6 +1959,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 +1972,14 @@ 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; + // The spelling at its pre-operation occurrence (5.7) — a moved + // text's inside the origin's construct — whatever file it will + // stand in (SPEC 14 `refused-invalid-rewrite`). + const spelling: FindingLocation = { + file: spec.document.file, + range: occurrence, + }; const declaredInMoved = spec === origin && section.id !== null && @@ -1090,13 +2004,21 @@ export function planMoveSection( if (mappedLocal !== null) { // Within the moved subtree: stays local, re-identified by // prefix replacement, quote style preserved (SPEC 6.5, 6.4). - addInner({ - range: reference.spelling.range, - replacement: jsStringLiteral( - mappedLocal, - reference.spelling.quote, - ), - }); + // A rewrite is made, and reported (6.6), exactly when it + // changes the construct's characters (SPEC 6.5): a local-form + // spelling already naming its target's new ID — a cross-file + // move keeping its ID, read in the target file (2.2) — already + // resolves to the new identity: neither rewritten nor reported. + if (mappedLocal !== reference.target.idPath) { + addInner({ + range: reference.spelling.range, + replacement: jsStringLiteral( + mappedLocal, + 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 @@ -1104,7 +2026,7 @@ export function planMoveSection( const name = planFor( createsTargetFile ? null : (target ?? null), targetPath, - ).bindingFor(originPath); + ).bindingFor(originPath, spelling); addInner({ range: reference.spelling.range, replacement: renderChain( @@ -1112,6 +2034,7 @@ export function planMoveSection( reference.target.idPath.split("."), ), }); + preview.add(originPath, "reference-rewrite", occurrence); } continue; } @@ -1128,12 +2051,16 @@ export function planMoveSection( // A remaining reference to the moved subtree: local → imported, // rooted at the origin file's binding of the target module // (SPEC 6.5). - const name = planFor(origin, originPath).bindingFor(targetPath); + const name = planFor(origin, originPath).bindingFor( + targetPath, + spelling, + ); outerEdits.add(path, { range: reference.spelling.range, replacement: renderChain(name, mappedLocal.split(".")), }); } + preview.add(path, "reference-rewrite", occurrence); continue; } @@ -1155,6 +2082,13 @@ export function planMoveSection( // The moved text references the file it moves into: imported → // local (SPEC 6.5); a file never imports itself (SPEC 2.1). if (segments.length === 0) { + if (judging !== null) { + // Refused as the dependency cycle it closes (`refused-cycle`, + // core/refusal.ts): no form spells it in the target file, so + // the would-be text keeps the expression as spelled — any + // expression judges alike (SPEC 6.5, 14.20). + continue; + } throw new Error( "xspec internal error: a moved reference targets the target " + "file's root node — the caller refused this move (SPEC 6.5)", @@ -1164,6 +2098,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 @@ -1171,12 +2106,13 @@ export function planMoveSection( const name = planFor( createsTargetFile ? null : (target ?? null), targetPath, - ).bindingFor(modulePath); + ).bindingFor(modulePath, spelling); if (name !== reference.spelling.rootName) { addInner({ range: reference.spelling.rootRange, replacement: name, }); + preview.add(originPath, "reference-rewrite", occurrence); } } continue; @@ -1201,18 +2137,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 @@ -1220,26 +2161,114 @@ export function planMoveSection( // (SPEC 6.5). const filePlan = planFor(spec, path); filePlan.depart(reference.spelling.rootName); - const rootName = filePlan.bindingFor(targetPath); - for (const edit of chainPrefixEdits( + const rootName = filePlan.bindingFor(targetPath, spelling); + 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); + } } } // SPEC 6.5: TypeScript markers and `text(...)` calls into the moved - // subtree, re-rooted at a binding of the target module (an existing - // spec-module import's default binding, or a fresh added import) with the - // segment prefix re-identified. Type-level references record no edges - // (SPEC 4.5) and are absent from the analyzed references. - const codeAdditions = new Map<string, Map<string, string>>(); + // subtree. Under a cross-file move each is rooted at bindings of the + // target module — a chain (a marker, a call's argument) at its default + // binding, a call's callee at its `text` binding (4.3, 4.4) — each one + // the file already holds that no local declaration shadows at the + // occurrence (4.5; the first such in document order, deterministic) or + // else one the declaration added to the file gives: one per module, + // binding exactly the lacked ones, its fresh identifiers shadowed + // nowhere (they avoid every identifier the file spells). A call is so + // rewritten whole, over its occurrence's span (5.7), and is never the + // cross-module call of 14.11. Under a same-file move the module is kept, + // so only a chain's segment prefix is re-identified and no callee is + // touched. Type-level references record no edges (SPEC 4.5) and are + // absent from the analyzed references. + // + // SPEC 6.5 "Import edits": an occurrence uses a binding when its chain is + // rooted at it or, for a `text(...)` call, its callee is it (4.5); a spec + // module import is removed exactly when an occurrence used a binding of + // its before the rewrite and none uses any binding of its after it — an + // import whose bindings were already unused stays (2.1). Uses are counted + // per import declaration, the one the analysis resolved each root and + // callee to, before the rewrite and as the re-rooting leaves them. + const codeAdditions = new Map<string, Map<string, AddedBindings>>(); + const codeRemovals = new Map<string, readonly CodeImport[]>(); + // Per code file: every occurrence (5.7) the operation roots at a binding + // its added declaration gives it, in pre-operation coordinates — the + // spellings a `refused-invalid-rewrite` locates when no offset admits + // the addition (SPEC 14), a `text(...)` call's by the whole call, whether + // its argument's root, its callee, or both take an added binding. + const codeAddedSpellings = new Map<string, FindingLocation[]>(); for (const analysis of code) { + const usesBefore = analysis.imports.map(() => 0); + for (const reference of analysis.references) { + usesBefore[reference.rootImport]! += 1; + if (reference.calleeImport !== null) { + usesBefore[reference.calleeImport]! += 1; + } + } + const usesAfter = [...usesBefore]; + // The target module's value-level bindings the file already holds that + // no local declaration shadows at the occurrence (SPEC 6.5 "Reference + // spellings", 4.5): the first such in document order — declarations, + // then a declaration's bindings — deterministic; null where the file + // holds none there, and the added declaration's binding roots the + // spelling instead. + const holdsTarget = (imported: CodeImport): boolean => + imported.valid && imported.targetPath === targetPath; + const existingBinding = ( + reference: CodeReference, + bindingsOf: (imported: CodeImport) => readonly CodeImportBinding[], + ): { readonly importIndex: number; readonly name: string } | null => { + for (const [importIndex, imported] of analysis.imports.entries()) { + if (!holdsTarget(imported)) continue; + const binding = bindingsOf(imported).find( + (candidate) => + !candidate.typeOnly && + !reference.shadowedImportNames.has(candidate.name), + ); + if (binding !== undefined) return { importIndex, name: binding.name }; + } + return null; + }; + // A chain's root: any binding of the module's default export — the + // default clause's or a named `{ default as X }` element's (SPEC 4). + const defaultBindingsOf = ( + imported: CodeImport, + ): readonly CodeImportBinding[] => imported.defaultBindings; + const textBindingsOf = ( + imported: CodeImport, + ): readonly CodeImportBinding[] => imported.textBindings; + // The declaration the rewrite adds, and the names its fresh identifiers + // avoid (SPEC 6.5 "Import edits": colliding with no binding already in + // the file, 2.1, 4): every identifier the file spells — each binding of + // every scope, value- or type-level, imports included, and each name + // the file reads — so an added binding collides with no module-scope + // declaration, no inner declaration shadows it at an occurrence it + // roots (4.5), and it captures no use of an outer name. + let added: AddedBindings | null = null; let taken: Set<string> | null = null; + const addition = (): { added: AddedBindings; taken: Set<string> } => { + taken ??= new Set(analysis.spelledNames); + if (added === null) { + added = { defaultName: null, textName: null }; + let additions = codeAdditions.get(analysis.path); + if (additions === undefined) { + additions = new Map(); + codeAdditions.set(analysis.path, additions); + } + additions.set(targetPath, added); + } + return { added, taken }; + }; for (const reference of analysis.references) { if ( reference.modulePath !== originPath || @@ -1253,51 +2282,96 @@ export function planMoveSection( ); } let rootName: string | null = null; + let calleeName: string | null = null; + let rootedAtAddition = false; if (!sameFile) { - const existing = analysis.imports.find( - (imported) => - imported.valid && - imported.targetPath === targetPath && - imported.defaultBinding !== null && - !imported.defaultBinding.typeOnly, - ); - if (existing !== undefined) { - rootName = existing.defaultBinding!.name; + // The chain leaves its origin-module root for a binding of the + // target module — another module, so another import. + usesAfter[reference.rootImport]! -= 1; + const existingDefault = existingBinding(reference, defaultBindingsOf); + if (existingDefault !== null) { + rootName = existingDefault.name; + usesAfter[existingDefault.importIndex]! += 1; } else { - let additions = codeAdditions.get(analysis.path); - if (additions === undefined) { - additions = new Map(); - codeAdditions.set(analysis.path, additions); + const fresh = addition(); + fresh.added.defaultName ??= freshBindingName(targetPath, fresh.taken); + fresh.taken.add(fresh.added.defaultName); + rootName = fresh.added.defaultName; + rootedAtAddition = true; + } + if (reference.callee !== null) { + // SPEC 6.5, 4.4: the callee leaves the origin module's `text` + // for the target module's — the call is rewritten whole. + if (reference.calleeImport === null) { + throw new Error( + "xspec internal error: a text(...) call without its callee's " + + "import", + ); } - const added = additions.get(targetPath); - if (added !== undefined) { - rootName = added; + usesAfter[reference.calleeImport]! -= 1; + const existingText = existingBinding(reference, textBindingsOf); + if (existingText !== null) { + calleeName = existingText.name; + usesAfter[existingText.importIndex]! += 1; } else { - if (taken === null) { - taken = new Set(); - for (const imported of analysis.imports) { - if (imported.defaultBinding !== null) { - taken.add(imported.defaultBinding.name); - } - for (const binding of imported.textBindings) { - taken.add(binding.name); - } - } - } - rootName = freshBindingName(targetPath, taken); - taken.add(rootName); - additions.set(targetPath, rootName); + const fresh = addition(); + fresh.added.textName ??= freshBindingName( + targetPath, + fresh.taken, + TEXT_BINDING_SUFFIX, + ); + fresh.taken.add(fresh.added.textName); + calleeName = fresh.added.textName; + rootedAtAddition = true; } } } - for (const edit of chainPrefixEdits( + if (rootedAtAddition) { + let spellings = codeAddedSpellings.get(analysis.path); + if (spellings === undefined) { + spellings = []; + codeAddedSpellings.set(analysis.path, spellings); + } + spellings.push({ + file: analysis.file, + range: reference.occurrenceRange, + }); + } + const referenceEdits = chainPrefixEdits( reference.spelling, oldSegments, newSegments, rootName, - )) { + ); + if ( + reference.callee !== null && + calleeName !== null && + calleeName !== reference.callee.name + ) { + referenceEdits.push({ + range: reference.callee.range, + replacement: calleeName, + }); + } + for (const edit of referenceEdits) { outerEdits.add(analysis.path, edit); } + if (referenceEdits.length > 0) { + // SPEC 6.6/5.7: a marker occurrence spans the bare chain, a TS + // `text(...)` occurrence the whole call expression — one rewrite + // however many of its parts change. + preview.add( + analysis.path, + "reference-rewrite", + reference.occurrenceRange, + ); + } + } + const removed = analysis.imports.filter( + (_, index) => usesBefore[index]! > 0 && usesAfter[index] === 0, + ); + if (removed.length > 0) { + codeRemovals.set(analysis.path, removed); } } @@ -1315,63 +2389,122 @@ 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). - interface ImportEditSet { - readonly deletionRanges: ByteRange[]; - readonly additionEdit: SourceEdit | null; - } - const importEditsFor = ( + // Per-file import edits (cross-file only). Which declarations a spec + // source loses is judged per ESM block (`SpecImportPlan.removedImports`, + // SPEC 6.5); removals are line-dropped like every 6.5 deletion, each + // reported with every byte it removes (SPEC 6.6: an import removal's + // range spans the declaration plus the leftover whitespace and terminator + // of each line its drop empties, judged per declaration). + // + // Judging a moved text that holds an import declaration — refused as + // `refused-moved-import` (SPEC 6.5, 14), never planned — the origin + // deletion removes each declaration inside the construct with the moved + // text: no removal of its own, and no survivor. + const deletedWithMoved = ( + spec: SpecFileAnalysis, + imported: SpecImport, + ): boolean => + judging !== null && + spec === origin && + imported.statement.range.start >= movedRange.start && + imported.statement.range.end <= movedRange.end; + const removedImportsOf = ( + spec: SpecFileAnalysis, + plan: SpecImportPlan, + bytes: Uint8Array, + ): SpecImport[] => + plan + .removedImports(bytes) + .filter((imported) => !deletedWithMoved(spec, imported)); + const importRemovalsFor = ( + spec: SpecFileAnalysis, + plan: SpecImportPlan, + bytes: Uint8Array, + ): ByteRange[] => { + const removed = removedImportsOf(spec, plan, bytes); + for (const imported of removed) { + preview.add( + spec.document.path, + "import-removal", + removalSpan(bytes, imported.statement.range), + ); + } + return removed.map((imported) => imported.statement.range); + }; + + // Additions (SPEC 6.5 "Import edits"): the added declarations stand at + // an admissible offset of the file as every other edit leaves it — first + // tried after the last surviving import's line, then at the first + // removed import's line start, then at the start of the file — one + // deterministic offset, shared with the preview (SPEC 6.6: in a + // pre-existing file the real insertion offset is exactly the previewed + // one). Returns the file's final content, or null when it adds nothing. + const withImportAdditions = ( spec: SpecFileAnalysis, plan: SpecImportPlan, bytes: Uint8Array, - ): ImportEditSet => { - const removed = plan.removedImports(); + composition: FileComposition, + ): Uint8Array | null => { + const path = spec.document.path; const added = plan.addedImports(); - const removedSet = new Set(removed); - const deletionRanges = removed.map((imported) => imported.statement.range); if (added.length === 0) { - return { deletionRanges, additionEdit: null }; + return null; } - const path = spec.document.path; const lines = added.map((addition) => - specImportLine(path, addition.modulePath, addition.name), + defaultImportLine( + path, + addition.modulePath, + addition.name, + judging !== null, + ), ); + const removed = removedImportsOf(spec, plan, bytes); + const removedSet = new Set(removed); const survivors = spec.imports.imports.filter( - (imported) => !removedSet.has(imported), + (imported) => + !removedSet.has(imported) && !deletedWithMoved(spec, 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 preferred = [ + ...(lastSurvivor === undefined + ? [] + : [offsetAfterLine(bytes, lastSurvivor.statement.range.end)]), + ...(firstRemoved === undefined + ? [] + : [lineStartBefore(bytes, firstRemoved.statement.range.start)]), + 0, + ]; + const placed = placeSpecImportAdditions( + spec.document, + bytes, + composition, + lines, + preferred, + ); + if (!placed.admissible) { + // SPEC 6.5/14 `refused-invalid-rewrite`: the file holds no + // admissible offset for the declarations it needs, located by every + // spelling rooted at their bindings — a refusal the plan's caller + // has already reported, judging the same composition. + if (judging === null) { + throw new Error( + `xspec internal error: ${path} holds no admissible offset for an ` + + `import addition — the caller refused this move (SPEC 6.5)`, + ); + } + inadmissible.push({ path, spellings: plan.addedBindingSpellings() }); } - return { - deletionRanges, - additionEdit: { - range: { start: 0, end: 0 }, - replacement: `${lines.map((line) => `${line}\n`).join("")}\n`, - }, - }; + // SPEC 6.6: each added declaration is one import addition, reported as + // a zero-length insertion point at the exact offset the real operation + // then inserts at (SPEC 6.5). + for (let index = 0; index < lines.length; index += 1) { + preview.add(path, "import-addition", { + start: placed.offset, + end: placed.offset, + }); + } + return placed.content; }; // Assemble every rewritten file. @@ -1436,45 +2569,83 @@ 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. + // rewrite of a self-closing target parent, and the insertion — judged + // with no insertion point, the deletion and the rewrites alone (SPEC + // 6.5: the origin as its deletion leaves it). const edits: SourceEdit[] = [ ...deletionEditsWithLineDrops(originBytes, [movedRange]), ...(outerEdits.editsFor(originPath) ?? []), ...(pairedFormEdit === null ? [] : [pairedFormEdit]), ]; - rewrites.push({ - path: originPath, - content: assembleWithInsertion(originBytes, edits, insertion), - }); + const content = composesTarget + ? assembleWithInsertion(originBytes, edits, insertion) + : applyEdits(originBytes, edits); + judgeWellFormed(originPath, content); + rewrites.push({ path: originPath, content }); } else { // The origin file: construct deletion, remaining-reference rewrites, // import removals and additions (SPEC 6.5). - const originImports = importEditsFor( - origin, - planFor(origin, originPath), - originBytes, - ); + const originPlan = planFor(origin, originPath); const originEdits: SourceEdit[] = [ ...deletionEditsWithLineDrops(originBytes, [ movedRange, - ...originImports.deletionRanges, + ...importRemovalsFor(origin, originPlan, originBytes), ]), ...(outerEdits.editsFor(originPath) ?? []), - ...(originImports.additionEdit === null - ? [] - : [originImports.additionEdit]), ]; + const originComposition = editsComposition(originBytes, originEdits); + judgeWellFormed(originPath, originComposition.content); rewrites.push({ path: originPath, - content: applyEdits(originBytes, originEdits), + content: + withImportAdditions( + origin, + originPlan, + originBytes, + originComposition, + ) ?? originComposition.content, }); // The target file: created empty before insertion (SPEC 6.5), or the // existing file with its conversions, import edits, the paired-form - // rewrite, and the insertion. - if (target === undefined) { + // rewrite, and the insertion — composed only where an insertion point + // exists (judging: SPEC 6.5 judges the target's text exactly then). + if (composesTarget && target === undefined) { const plan = planFor(null, targetPath); const added = plan.addedImports(); const importBlock = @@ -1482,7 +2653,11 @@ export function planMoveSection( ? "" : `${added .map((addition) => - specImportLine(targetPath, addition.modulePath, addition.name), + defaultImportLine( + targetPath, + addition.modulePath, + addition.name, + ), ) .map((line) => `${line}\n`) .join("")}\n`; @@ -1494,27 +2669,33 @@ export function planMoveSection( content.set(head, 0); content.set(movedBody, head.length); content.set(tail, head.length + movedBody.length); + judgeWellFormed(targetPath, content); rewrites.push({ path: targetPath, content }); - } else { - const targetImports = importEditsFor( - target, - planFor(target, targetPath), - targetBytes, - ); + } else if (composesTarget && target !== undefined) { + const targetPlan = planFor(target, targetPath); const targetEdits: SourceEdit[] = [ ...deletionEditsWithLineDrops( targetBytes, - targetImports.deletionRanges, + importRemovalsFor(target, targetPlan, targetBytes), ), ...(outerEdits.editsFor(targetPath) ?? []), - ...(targetImports.additionEdit === null - ? [] - : [targetImports.additionEdit]), ...(pairedFormEdit === null ? [] : [pairedFormEdit]), ]; + const targetComposition = insertionComposition( + targetBytes, + targetEdits, + insertion, + ); + judgeWellFormed(targetPath, targetComposition.content); rewrites.push({ path: targetPath, - content: assembleWithInsertion(targetBytes, targetEdits, insertion), + content: + withImportAdditions( + target, + targetPlan, + targetBytes, + targetComposition, + ) ?? targetComposition.content, }); } @@ -1528,15 +2709,18 @@ export function planMoveSection( const fileEdits: SourceEdit[] = [...(outerEdits.editsFor(path) ?? [])]; if (plan !== undefined) { const bytes = encoder.encode(spec.document.text); - const imports = importEditsFor(spec, plan, bytes); fileEdits.push( - ...deletionEditsWithLineDrops(bytes, imports.deletionRanges), + ...deletionEditsWithLineDrops( + bytes, + importRemovalsFor(spec, plan, bytes), + ), ); - if (imports.additionEdit !== null) { - fileEdits.push(imports.additionEdit); - } - if (fileEdits.length > 0) { - rewrites.push({ path, content: applyEdits(bytes, fileEdits) }); + const composition = editsComposition(bytes, fileEdits); + const content = withImportAdditions(spec, plan, bytes, composition); + if (content !== null) { + rewrites.push({ path, content }); + } else if (fileEdits.length > 0) { + rewrites.push({ path, content: composition.content }); } continue; } @@ -1566,15 +2750,45 @@ 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). + // Code files: chain and callee retargets, import removals, and added + // imports (SPEC 6.5, 4). A removal is line-dropped like every 6.5 deletion and + // reported with every byte it removes (SPEC 6.6, judged per + // declaration). An addition stands at an admissible offset: one at which + // the file, as every edit of the rewrite leaves it, is well-formed under + // the grammar its name selects (14.20) with each added line a top-level + // import declaration (`admitsAddedCodeDeclarations`). Candidates are + // tried in a fixed order — 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), the first removed import's line + // start, the file's start, then every other line start and every line's + // end — an offset at the start of a line, judged over the composed text, + // taken over any other: the one deterministic offset the preview reports + // (SPEC 6.5, 6.6), an addition at the end of a removal's range reading + // what that removal leaves (SPEC 6.5 "Composition and admissibility"). for (const analysis of code) { + const bytes = encoder.encode(analysis.text); + const removed = codeRemovals.get(analysis.path) ?? []; + for (const imported of removed) { + preview.add( + analysis.path, + "import-removal", + removalSpan(bytes, imported.range), + ); + } const fileEdits: SourceEdit[] = [ ...(outerEdits.editsFor(analysis.path) ?? []), + ...deletionEditsWithLineDrops( + bytes, + removed.map((imported) => imported.range), + ), ]; - const additions = codeAdditions.get(analysis.path); - if (additions !== undefined && additions.size > 0) { + const additions = [...(codeAdditions.get(analysis.path) ?? new Map())]; + if (fileEdits.length === 0 && additions.length === 0) { + continue; + } + const composition = editsComposition(bytes, fileEdits); + let content = composition.content; + if (additions.length > 0) { const anchor = analysis.imports[analysis.imports.length - 1]; if (anchor === undefined) { throw new Error( @@ -1582,43 +2796,141 @@ export function planMoveSection( `moved subtree but has no spec module import`, ); } - const lines = [...additions.entries()] + // SPEC 6.5: each added declaration binds exactly the lacked + // bindings, spelled `import X from "…"`, `import { text as Y } from + // "…"`, or `import X, { text as Y } from "…"`, no statement + // terminator, on a line of its own. + const lines = additions .sort((a, b) => compareBytes(a[0], b[0])) - .map( - ([modulePath, name]) => - `\nimport ${name} from ${jsStringLiteral( - relativeModuleSpecifier( - analysis.path, - moduleSpecifierTargetOf(modulePath), - ), - '"', - )};`, - ) - .join(""); - fileEdits.push({ - range: { start: anchor.range.end, end: anchor.range.end }, - replacement: lines, - }); + .map(([modulePath, bindings]) => + importDeclarationLine( + analysis.path, + modulePath, + bindings, + judging !== null, + ), + ); + const firstRemoved = removed[0]; + const placed = placeCodeImportAdditions( + analysis.path, + bytes, + composition, + lines, + [ + offsetAfterLine(bytes, anchor.range.end), + ...(firstRemoved === undefined + ? [] + : [lineStartBefore(bytes, firstRemoved.range.start)]), + 0, + ], + ); + if (!placed.admissible) { + // SPEC 6.5/14 `refused-invalid-rewrite`: the file holds no + // admissible offset for the declaration it needs, located by every + // occurrence rooted at its bindings — a refusal the plan's caller + // has already reported, judging the same composition. + if (judging === null) { + throw new Error( + `xspec internal error: ${analysis.path} holds no admissible ` + + `offset for an import addition — the caller refused this ` + + `move (SPEC 6.5)`, + ); + } + inadmissible.push({ + path: analysis.path, + spellings: codeAddedSpellings.get(analysis.path) ?? [], + }); + } + content = placed.content; + // SPEC 6.6: each added declaration is one import addition, its + // zero-length insertion point at the exact offset the real operation + // then inserts at (SPEC 6.5). + for (let index = 0; index < lines.length; index += 1) { + preview.add(analysis.path, "import-addition", { + start: placed.offset, + end: placed.offset, + }); + } } - if (fileEdits.length > 0) { - rewrites.push({ - path: analysis.path, - content: applyEdits(encoder.encode(analysis.text), fileEdits), + rewrites.push({ path: analysis.path, content }); + } + + // SPEC 6.5 "Import edits", 14 `refused-cycle`: judging, the spec import + // relation the rewrite leaves, read from this composition's own import + // bookkeeping, so the refusal and the rewrite cannot disagree — each + // spec source's declarations but those its removals take (the + // joint-removal rule of `removedImports` included: a block's first + // declaration stays, its binding unused, where the others' removal would + // leave the block headed by anything else) and those the origin deletion + // takes with a moved text holding them (`refused-moved-import`), each by + // its own characters; then the declarations the rewrite adds to it, each + // by the spellings rooted at its binding; a created target file's + // additions last. A same-file move plans no import edit, every + // declaration staying. + const imports: WouldBeSpecImport[] = []; + const pushAdditions = (path: string): void => { + for (const addition of importPlans.get(path)?.addedImports() ?? []) { + imports.push({ + importer: path, + imported: addition.modulePath, + locations: addition.spellings, }); } + }; + if (judging !== null) { + for (const spec of specs) { + const path = spec.document.path; + const plan = importPlans.get(path); + const removed = new Set( + plan === undefined + ? [] + : removedImportsOf(spec, plan, encoder.encode(spec.document.text)), + ); + for (const declared of spec.imports.imports) { + if ( + declared.targetPath === null || + removed.has(declared) || + deletedWithMoved(spec, declared) + ) { + continue; + } + imports.push({ + importer: path, + imported: declared.targetPath, + locations: [ + { file: spec.document.file, range: declared.statement.range }, + ], + }); + } + pushAdditions(path); + } + if (createsTargetFile) { + pushAdditions(targetPath); + } } return { - mapping, - // SPEC 6.1/6.5: the appended entry records the operation and the full - // mapping it produced. - entry: createJournalEntry( - "move-section", - `${originPath}#${oldId}`, - `${targetPath}#${newId}`, - mapping, - ), - rewrites, - createsTargetFile, + // Judging, no plan exists: the operation is refused wherever the + // verdict, or any other reason, finds cause — its target path perhaps + // no spec source path, which no journal entry records (SPEC 6.1, 7.1). + plan: + judging !== null + ? null + : { + mapping, + // SPEC 6.1/6.5: the appended entry records the operation and + // the full mapping it produced. + entry: createJournalEntry( + "move-section", + `${originPath}#${oldId}`, + `${targetPath}#${newId}`, + mapping, + ), + rewrites, + createsTargetFile, + previewFiles: preview.files(), + }, + verdict: { illFormed, inadmissible }, + imports, }; } diff --git a/src/core/path-text.ts b/src/core/path-text.ts new file mode 100644 index 00000000..f103c370 --- /dev/null +++ b/src/core/path-text.ts @@ -0,0 +1,155 @@ +// 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"; + +// SPEC 7, 1.5: a path is the directory-entry names joined with `/`, matched +// and compared as its exact UTF-8 bytes (12.0), and a valid-UTF-8 path is +// presented as that string (12.7). A leading U+FEFF (`EF BB BF`) is an +// ordinary character of such a path, never a byte-order mark to discard: +// `ignoreBOM` keeps it, so a path's string form always re-encodes to the +// path's exact bytes (the decoder's default would silently drop it). +const strictUtf8Decoder = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: 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, + * `<bytes HEX>`, distinguishable from every plain workspace-relative path + * (which never contains `<` at a spelling boundary the renderer produces + * and is never spelled this way by xspec). SPEC.md fixes no human spelling + * for such paths; the hex form is chosen because it is injective and + * mirrors the JSON marked byte form's information exactly. + */ +export function renderPathText(path: PathText): string { + return typeof path === "string" + ? path + : `<bytes ${lowercaseHex(path.bytes)}>`; +} diff --git a/src/core/policy.ts b/src/core/policy.ts index 6e68b71b..d6999c0e 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 00000000..ec85cc30 --- /dev/null +++ b/src/core/preview.ts @@ -0,0 +1,130 @@ +// The preview plan surface (SPEC 6.6, 12.7) — the pure edit model. +// +// Pure core (IMPLEMENTATION Architecture: deterministic and I/O-free): a +// `rename`/`move` preview reports every file the operation would rewrite, +// relocate, or create, with every edit the operation would make in it, +// classed as exactly one of the ten SPEC 6.6 classes and located by a +// source range (SPEC 1.7) in current, pre-operation coordinates — no +// replacement text anywhere (the preview is a safety report, not an edit +// script). The plan derivations (./rename.ts, ./move.ts) collect these +// entries in the same pass that derives the applied edits, so the real +// operation and the preview share one plan (SPEC 6.6, 6.5). +// +// Ordering (SPEC 12.7): file entries by file path bytes; within a file, +// edits by range start, then range end, then class-name bytes. Ranges MAY +// nest (SPEC 6.6: containment is geometry, not double-reporting) and +// coinciding zero-length insertion points MAY tie, resolved by the +// class-name byte comparison. + +import type { ByteRange } from "./bytes.js"; +import { compareBytes } from "./bytes.js"; + +/** The ten SPEC 6.6/12.7 preview edit classes, exactly. */ +export type PreviewEditClass = + | "reference-rewrite" + | "id-rewrite" + | "import-specifier-rewrite" + | "import-addition" + | "import-removal" + | "origin-deletion" + | "target-insertion" + | "target-parent-rewrite" + | "file-relocation" + | "file-creation"; + +/** One classed preview edit (SPEC 6.6, 12.7): class plus range only. */ +export interface PreviewEdit { + readonly class: PreviewEditClass; + /** + * Pre-operation coordinates (SPEC 6.6): a rewrite spans the construct it + * rewrites, a removal every byte its edit removes, an insertion point is + * zero-length at its offset; target-file creation's insertion point at + * the start of the new file is the one location without pre-operation + * coordinates. + */ + readonly range: ByteRange; +} + +/** One `files` entry (SPEC 12.7): a file with its classed edits. */ +export interface PreviewFileEdits { + /** + * The file's current, pre-operation workspace-relative path — for + * target-file creation, the path the creation would occupy (SPEC 6.6). + * Plans are derived over validated workspaces (SPEC 6.4, 6.5), whose + * discovered paths are all valid UTF-8 (SPEC 14.19), so a plain string. + */ + readonly path: string; + /** The edits, in the pinned SPEC 12.7 order. */ + readonly edits: readonly PreviewEdit[]; +} + +/** The pinned SPEC 12.7 edit order: start, end, class-name bytes. */ +export function comparePreviewEdits(a: PreviewEdit, b: PreviewEdit): number { + if (a.range.start !== b.range.start) { + return a.range.start - b.range.start; + } + if (a.range.end !== b.range.end) { + return a.range.end - b.range.end; + } + return compareBytes(a.class, b.class); +} + +/** + * Collects preview edits per file while a plan derivation runs, and yields + * the `files` entries in the pinned SPEC 12.7 order — file entries by path + * bytes, edits by range start, then range end, then class-name bytes. + */ +export class PreviewCollector { + private readonly editsByPath = new Map<string, PreviewEdit[]>(); + + add(path: string, editClass: PreviewEditClass, range: ByteRange): void { + let edits = this.editsByPath.get(path); + if (edits === undefined) { + edits = []; + this.editsByPath.set(path, edits); + } + edits.push({ class: editClass, range: { ...range } }); + } + + /** The collected entries in the pinned SPEC 12.7 order. */ + files(): readonly PreviewFileEdits[] { + return [...this.editsByPath.entries()] + .sort((a, b) => compareBytes(a[0], b[0])) + .map(([path, edits]) => ({ + path, + edits: [...edits].sort(comparePreviewEdits), + })); + } +} + +/** The two-direction derived-file delta (SPEC 6.6), each in byte order. */ +export interface PreviewDelta { + /** Derived paths the operation would newly generate (SPEC 6.6). */ + readonly generated: readonly string[]; + /** Recorded derived paths left no longer generated (SPEC 6.6). */ + readonly removed: readonly string[]; +} + +/** + * The record-based delta rule (SPEC 6.6): `generated` is the post-operation + * generation set minus the recorded paths — the paths where nothing is + * currently recorded as generated — and `removed` the recorded paths the + * operation would leave no longer generated. Both directions consult the + * record alone; presence on disk decides neither (SPEC 6.6: presence at a + * path cannot tell a generated occupant from a foreign one). Paths in byte + * order (SPEC 12.7). + */ +export function derivedFileDelta( + recordedPaths: readonly string[], + postGenerationPaths: readonly string[], +): PreviewDelta { + const recorded = new Set(recordedPaths); + const post = new Set(postGenerationPaths); + const generated = [...post] + .filter((path) => !recorded.has(path)) + .sort(compareBytes); + const removed = [...recorded] + .filter((path) => !post.has(path)) + .sort(compareBytes); + return { generated, removed }; +} diff --git a/src/core/references.ts b/src/core/references.ts index 3d8878ad..58e31517 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 @@ -36,7 +37,11 @@ export interface TextSpan { */ export interface ClassifiedString { readonly kind: "string"; - /** The literal's cooked value (the named ID path, SPEC 2.2). */ + /** + * The literal's value (the named ID path, SPEC 2.2): the characters + * between its delimiters exactly as spelled, no escape sequence + * interpreted (SPEC 2.4). + */ readonly value: string; /** The quote character the author used (SPEC 6.4: preserved on rewrite). */ readonly quote: '"' | "'"; @@ -47,7 +52,11 @@ export interface ClassifiedString { /** One segment of a static property chain (SPEC 2.4). */ export interface ClassifiedSegment { /** - * The segment name. Exactly one chain segment (SPEC 2.4); never split — + * The segment name, read as spelled (SPEC 2.4): the identifier's own + * characters for dot access, the index literal's characters between its + * delimiters for computed access — no escape sequence interpreted, so a + * name spelled with one contains `\`, which no ID segment does (1.4), + * and names no node. Exactly one chain segment (SPEC 2.4); never split — * a name containing `.` can equal no ID segment (SPEC 1.4), so a dotted * computed index resolves to nothing (TEST-SPEC T2.4-4). */ @@ -80,7 +89,12 @@ export interface ClassifiedSegment { */ export interface ClassifiedChain { readonly kind: "chain"; - /** The root identifier's text (an import binding, when valid). */ + /** + * The name the root identifier binds by the language's reading, escape + * sequences interpreted (an import binding, when valid): which binding + * roots a chain is the language's scoping question, not a spelling one + * (SPEC 2.4, 2.1, 4.5). + */ readonly rootName: string; /** The root identifier token. */ readonly rootSpan: TextSpan; @@ -108,14 +122,52 @@ 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() }; } +/** + * SPEC 2.4: the value of a static string literal — the characters between + * its delimiters exactly as spelled, no escape sequence interpreted — for + * every string literal the specification reads: `d` and `text(...)` string + * arguments and computed-access indices (here), and import specifiers in + * spec and TypeScript sources alike (./spec-references.ts, + * ./code-analysis.ts). The parser's own `text` is the interpreted value and + * is never read. (An unterminated literal occurs only in a source failing + * to parse, 14.20; its value runs to the token's end.) + */ +export function stringLiteralValue( + literal: tst.StringLiteral, + sourceFile: tst.SourceFile, +): string { + const start = literal.getStart(sourceFile); + const end = literal.getEnd(); + const quote = sourceFile.text[start]; + const terminated = end - start >= 2 && sourceFile.text[end - 1] === quote; + return sourceFile.text.slice(start + 1, terminated ? end - 1 : end); +} + +/** + * SPEC 2.4: a chain segment's identifier read as spelled — its own + * characters; one carrying a Unicode escape sequence spells a name + * containing `\`, which no segment contains (1.4), so the reference names + * no node (14.5–14.7). The configuration reads its identifier keys the + * same way (SPEC 7, ./config.ts): a key is the name its spelling spells. + */ +export function identifierSpelling( + identifier: tst.Identifier, + sourceFile: tst.SourceFile, +): string { + return sourceFile.text.slice( + identifier.getStart(sourceFile), + identifier.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 !== "'") { @@ -124,6 +176,11 @@ function quoteOf( return quote; } +/** SPEC 2.4: why an expression of any other form is dynamic. */ +const OTHER_FORM_REASON = + "it is neither a static string literal nor a static property chain " + + "rooted at an import binding"; + /** * Classify one expression per the static argument rule (SPEC 2.4): a * static string literal (plain single- or double-quoted; template @@ -134,8 +191,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 => ({ @@ -145,10 +202,11 @@ export function classifyReference( }); if (ts.isStringLiteral(expression)) { - // SPEC 2.4: a plain single- or double-quoted string is static. + // SPEC 2.4: a plain single- or double-quoted string is static; its + // value is its characters as spelled. return { kind: "string", - value: expression.text, + value: stringLiteralValue(expression, sourceFile), quote: quoteOf(expression, sourceFile), span: whole, }; @@ -167,11 +225,13 @@ 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 { kind: "chain", + // SPEC 2.4: the root binds by the language's reading (scoping, 2.1, + // 4.5), escapes interpreted; only segments are read as spelled. rootName: node.text, rootSpan: spanOf(node, sourceFile), segments: collected.reverse(), @@ -192,7 +252,8 @@ export function classifyReference( ); } collected.push({ - name: node.name.text, + // SPEC 2.4: the segment's identifier is read as spelled. + name: identifierSpelling(node.name, sourceFile), access: "dot", quote: null, nameSpan: spanOf(node.name, sourceFile), @@ -217,7 +278,8 @@ export function classifyReference( ); } collected.push({ - name: index.text, + // SPEC 2.4: the index literal's value is its characters as spelled. + name: stringLiteralValue(index, sourceFile), access: "computed", quote: quoteOf(index, sourceFile), nameSpan: spanOf(index, sourceFile), @@ -238,38 +300,112 @@ export function classifyReference( "parentheses do not participate in a static property chain", ); } - return dynamic( - "it is neither a static string literal nor a static property chain " + - "rooted at an import binding", - ); + return dynamic(OTHER_FORM_REASON); } } +/** One expression's text parsed by `parseExpressionText`. */ +export interface ParsedExpression { + /** The standalone source the text was parsed in. */ + readonly sourceFile: tst.SourceFile; + /** + * The one expression the text holds — its own characters first token + * through last, parentheses the text spells around it included, the + * whitespace and comments beside it excluded — or null where the reading + * yields none closed by the enclosing parenthesis. + */ + readonly expression: tst.Expression | null; + /** + * The UTF-16 offset in `sourceFile.text` at which the text begins: every + * position in the AST is that offset plus the text's own. + */ + readonly textStart: number; +} + +/** What `parseExpressionText` sets before the text: expression position. */ +const EXPRESSION_OPENER = "("; + +/** + * What follows the text: a line terminator, so that a line comment ending + * the text cannot swallow the closing parenthesis, then that parenthesis. + */ +const EXPRESSION_CLOSER = "\n)"; + /** - * Parse one expression span's exact source text (an MDX `d` value or - * `{...}` container content, SPEC 2.2, 2.3) into a standalone AST for the - * analyzer. `expression` is null when the text is not a single expression - * statement — an object literal (parsed as a block), several statements, - * or nothing — every such value is dynamic for the caller's purposes - * (SPEC 2.7 → 14.8). Positions in the returned AST are UTF-16 offsets - * into `text`. + * Parse one expression's exact source text — a spec source's reference as + * MDX 3 derives it (SPEC 14.20: a `d` value, an entry of its array literal, + * or a `text(...)` argument), or a probe's spelling, through + * `classifyReferenceText` — into a standalone AST for the analyzer, in + * expression position: the text is parenthesized, so no statement's + * lookahead restriction applies (`{a: 1}`, `function(){}`, `class {}`, and + * `async function(){}` are expressions, never a block or declarations). + * The grammar is TypeScript's JavaScript-with-JSX reading, ECMAScript's + * with JSX (SPEC 14.20, 2.7): JSX is read as JSX, and no type assertion or + * type argument list is read (`a<b, c>(d)` is two comparisons joined by a + * comma, as ECMAScript derives it). `expression` is the parenthesized + * operand — null where the reading yields no operand closed by the + * enclosing parenthesis, the text not being one expression as this + * grammar reads it. Positions are offsets into `sourceFile.text`, the text + * beginning at `textStart`. */ -export function parseExpressionText(text: string): { - readonly sourceFile: ts.SourceFile; - readonly expression: ts.Expression | null; -} { +export function parseExpressionText(text: string): ParsedExpression { const sourceFile = ts.createSourceFile( - "xspec-expression.ts", - text, + "xspec-expression.jsx", + EXPRESSION_OPENER + text + EXPRESSION_CLOSER, ts.ScriptTarget.Latest, /* setParentNodes */ true, - ts.ScriptKind.TS, + ts.ScriptKind.JSX, ); + const textStart = EXPRESSION_OPENER.length; const statement = sourceFile.statements.length === 1 ? sourceFile.statements[0] : undefined; + const parenthesized = + statement !== undefined && + ts.isExpressionStatement(statement) && + ts.isParenthesizedExpression(statement.expression) && + statement.expression.getStart(sourceFile) === 0 && + statement.expression.getEnd() === sourceFile.text.length + ? statement.expression.expression + : null; const expression = - statement !== undefined && ts.isExpressionStatement(statement) - ? statement.expression + parenthesized !== null && + parenthesized.getStart(sourceFile) < parenthesized.getEnd() + ? parenthesized : null; - return { sourceFile, expression }; + return { sourceFile, expression, textStart }; +} + +/** A reference's classification, spans offset by the parsed text's start. */ +export interface ClassifiedReferenceText { + readonly classified: ClassifiedReference; + /** Where the classified text begins in the text it was parsed in. */ + readonly textStart: number; +} + +/** + * Classify one reference's own characters (SPEC 2.4) — first token through + * last, as the reference-spelling locations of SPEC 14 take them: a spec + * source's `d` value or entry, or `text(...)` argument, as MDX 3 derives it + * (SPEC 14.20), or a probe's spelling (./rename.ts). Where the reading of + * `parseExpressionText` is not one expression spanning exactly those + * characters — a construct ECMAScript with JSX derives that the reading + * does not: a JSX element as the object of a member access, a call, a + * tagged template, `new`, or a postfix update (`<b/>.x`) — the text is no + * static string literal or property chain, both of which the reading + * always derives, so the reference is dynamic, spanning the whole text. + */ +export function classifyReferenceText(text: string): ClassifiedReferenceText { + const { sourceFile, expression, textStart } = parseExpressionText(text); + const whole = { start: textStart, end: textStart + text.length }; + if ( + expression === null || + expression.getStart(sourceFile) !== whole.start || + expression.getEnd() !== whole.end + ) { + return { + classified: { kind: "dynamic", reason: OTHER_FORM_REASON, span: whole }, + textStart, + }; + } + return { classified: classifyReference(expression, sourceFile), textStart }; } diff --git a/src/core/refusal.ts b/src/core/refusal.ts new file mode 100644 index 00000000..70f8a4bb --- /dev/null +++ b/src/core/refusal.ts @@ -0,0 +1,1329 @@ +// 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 reason `refused-cycle` is evaluated over the +// post-operation workspace, never by reanalyzing rewritten text: the +// dependency half 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), the spec-import half over the +// import relation the operation leaves — a file move's declarations, the +// paths mapped; a section move's as its composition's own import +// bookkeeping keeps and adds them (core/move.ts `judgeMoveSectionRewrite`), +// so the refusal and the rewrite cannot disagree. Its one finding, however +// many cycles the move would close, locates 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). +// +// `refused-invalid-rewrite` alone reads would-be text: the section form's +// exact edits composed exactly as the move plan composes them +// (core/move.ts `judgeMoveSectionRewrite`), the origin's and the target's +// would-be texts judged well-formed or not (SPEC 14.20) and every spec +// source's added declarations judged for an admissible offset (SPEC 6.5), +// the finding locating the moved construct and the spellings rooted at an +// addition no offset admits at their current, pre-operation coordinates. +// +// SPEC 14's ten reasons are the whole refusal vocabulary: every rewritten +// reference resolves by construction, so no reason exists for one that +// would not (SPEC 6.4, 6.5). A moved reference to the target file's own +// root — which neither form could spell there: the local form names IDs +// of its own file (2.2), the imported form would be a self-import (2.1) — +// makes the moved node depend on its own ancestor, so the move is refused +// as that would-be dependency cycle (SPEC 5.3), `refused-cycle`. + +import type { ByteRange } from "./bytes.js"; +import { sortByBytes } from "./bytes.js"; +import type { CodeAnalysis } from "./code-analysis.js"; +import type { Configuration, ConfiguredGroup } from "./config.js"; +import { specSourceDerivedPaths } from "./discovery.js"; +import type { Finding, FindingLocation, RefusalCode } from "./findings.js"; +import { compareLocations, sortLocations } from "./findings.js"; +import { findCycles } from "./graph.js"; +import type { SpecFileAnalysis, WorkspaceGraph } from "./graph.js"; +import type { SpecSection } from "./mdx.js"; +import type { MoveSectionRewriteVerdict, WouldBeSpecImport } from "./move.js"; +import { judgeMoveSectionRewrite } from "./move.js"; +import type { PathText } from "./path-text.js"; +import { replaceIdPrefix } from "./rename.js"; +import { describeSegmentViolation, idSegmentViolations } 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 — judged by the + * shared SPEC 1.4 validator (text.ts), splitting on `.` making the no-`.` + * rule structural. Used by the `refused-invalid-id` evaluation (SPEC 6.4, + * 6.5). + */ +export function intrinsicIdProblem(id: string): string | null { + const first = idSegmentViolations(id)[0]; + if (first === undefined) { + return null; + } + if (first.violation.rule === "empty") { + return "it has an empty segment"; + } + return ( + `its segment ${JSON.stringify(first.segment)} ` + + describeSegmentViolation(first.violation) + ); +} + +/** One refusal-reason finding (SPEC 14): stable code, concerned data. */ +function refusalFinding( + code: RefusalCode, + message: string, + parts: { + readonly locations?: readonly FindingLocation[]; + readonly path?: string; + readonly identities?: readonly string[]; + } = {}, +): Finding { + return { + code, + message, + locations: sortLocations(parts.locations ?? []), + path: parts.path ?? null, + identities: parts.identities ?? [], + }; +} + +// --------------------------------------------------------------------------- +// Destination-path assessment (SPEC 6.5: the destination-validity family) +// --------------------------------------------------------------------------- + +/** + * The pure half of `refused-invalid-destination` (SPEC 6.5, 14): whether a + * destination path could be a valid discovered spec source at all, judged + * from its spelling and the configuration alone, plus the derived paths it + * would generate — the paths whose workspace-relative directory components + * the workspace layer must probe for non-directory occupants (SPEC 6.5: + * "or a workspace-relative directory component of the destination path, + * or of a derived path it would generate, occupied by anything other than + * a directory"). + */ +export interface DestinationPathAssessment { + /** + * Why the path would not be a valid discovered spec source (SPEC 6.5 → + * 7, 7.1, 14.19, 13.4), in a fixed evaluation order; empty when the + * spelling and configuration accept it. However many causes hold, they + * feed ONE `refused-invalid-destination` finding (SPEC 14: one finding + * per reason). + */ + readonly causes: readonly string[]; + /** The configured spec groups whose globs match the path (SPEC 7). */ + readonly specGroups: readonly string[]; + /** + * Whether the path is a well-formed workspace-relative path that may be + * probed on disk: a malformed spelling (absolute, `.`/`..` segments, + * empty segments, non-UTF-8) is never resolved against the workspace + * root, so no occupant or component probe runs for it. + */ + readonly probeable: boolean; + /** + * The destination path together with the derived paths it would + * generate (SPEC 13.1, 13.2, 7.3): the generated module and its + * companions share the destination's directory, so probing the + * destination's own components covers them; the Markdown emit + * destination adds its own components while emission is enabled. The + * workspace layer probes the directory components of exactly these. + */ + readonly componentProbePaths: readonly string[]; +} + +/** + * Why `destination` is not a well-formed workspace-relative source-path + * shape (SPEC 1.5: workspace-relative, `/`-separated, no `.`/`..` + * segments — the shape every discovered source path has), or null when it + * is. + */ +function destinationShapeProblem(destination: string): string | null { + if (destination.length === 0) { + return "it is empty"; + } + if (destination.startsWith("/")) { + return "it is not workspace-relative (SPEC 1.5, 12.0)"; + } + for (const segment of destination.split("/")) { + if (segment === "") { + return "it has an empty path segment"; + } + if (segment === "." || segment === "..") { + return ( + `it has a ${JSON.stringify(segment)} path segment — discovered ` + + `source paths are workspace-relative without "." or ".." (SPEC 1.5)` + ); + } + } + return null; +} + +const utf8Encoder = new TextEncoder(); + +/** The configured groups whose globs match `bytes` (SPEC 7). */ +function matchingGroups( + groups: readonly ConfiguredGroup[], + bytes: Uint8Array, +): string[] { + const names: string[] = []; + for (const group of groups) { + if (group.globs.some((glob) => glob.matches(bytes))) { + names.push(group.name); + } + } + return names; +} + +/** + * Assess a move destination path (SPEC 6.5): the file form's `<new-file>` + * or the section form's to-be-created `<target-file>`. `utf8` is whether + * the argument value decoded as valid UTF-8 (cli/args.ts marks + * undecodable argv with U+FFFD); a non-UTF-8 spelling is normally an + * exit-2 usage error first (SPEC 12.0), leaving this cause a dead letter, + * but the reason holds on its own terms (SPEC 14.19: such a path is never + * a valid source path). + */ +export function assessDestinationPath( + destination: string, + utf8: boolean, + configuration: Configuration, +): DestinationPathAssessment { + const causes: string[] = []; + if (!utf8) { + causes.push( + `the path is not valid UTF-8 — a discovered source file's ` + + `workspace-relative path must be valid UTF-8 (SPEC 7, 14.19)`, + ); + } + if (destination.includes("#")) { + causes.push( + `the path contains "#", which node identities reserve (path#id) — ` + + `it would never be a valid discovered spec source (SPEC 1.5, 14.19)`, + ); + } + const shape = destinationShapeProblem(destination); + if (shape !== null) { + causes.push( + `the path is not a well-formed workspace-relative path: ${shape}`, + ); + } + if (causes.length > 0) { + // Malformed spellings match no group and are never probed: a + // `..`-bearing argument must not resolve outside the workspace root. + return { + causes, + specGroups: [], + probeable: false, + componentProbePaths: [], + }; + } + + const bytes = utf8Encoder.encode(destination); + const specGroups = matchingGroups(configuration.specGroups, bytes); + // SPEC 6.5: a path belonging to no configured spec group — a move never + // takes a node out of the workspace. + if (specGroups.length === 0) { + causes.push( + `the path belongs to no configured spec group — a move never takes ` + + `a node out of the workspace; choose a destination a spec group's ` + + `globs match (SPEC 7)`, + ); + } + // SPEC 6.5 → 7.2/14.14: belonging to a code group as well. + const codeGroups = matchingGroups(configuration.codeGroups, bytes); + if (specGroups.length > 0 && codeGroups.length > 0) { + causes.push( + `the path is matched by spec group ${JSON.stringify(specGroups[0]!)} ` + + `and code group ${JSON.stringify(codeGroups[0]!)} alike — no file ` + + `may belong to both a spec and a code group (SPEC 7.2, 14.14)`, + ); + } + // SPEC 6.5 → 7.1/14.19: lacking the `.mdx` extension. + if (!destination.endsWith(".mdx")) { + causes.push( + `the path lacks the .mdx extension — every spec-group source must ` + + `end ".mdx" (SPEC 7.1, 14.19)`, + ); + } + // SPEC 13.4: derived-file paths are never sources — a file name + // containing `.xspec.` or a path under `.xspec/` is excluded from every + // group, so such a destination would never be discovered. (A configured + // Markdown emit destination always ends ".md" and can never collide + // with a ".mdx" destination.) + const fileName = destination.slice(destination.lastIndexOf("/") + 1); + if (fileName.includes(".xspec.") || destination.startsWith(".xspec/")) { + causes.push( + `the path is a derived-file path (a file name containing ".xspec." ` + + `or a path under ".xspec/") — derived-file paths are never ` + + `discovered as sources (SPEC 13.4)`, + ); + } + + // SPEC 6.5/13.1/13.2/7.3: the derived paths the destination would + // generate. The module and companions share the destination's directory + // (13.1: "in the source file's directory"), so the destination path + // itself covers their components; the Markdown emit destination (13.2) + // adds its own. `specSourceDerivedPaths` is total over any byte shape; + // the destination is valid UTF-8 here, so its results are plain strings. + const componentProbePaths: string[] = [destination]; + const derived = specSourceDerivedPaths(bytes, configuration); + if (typeof derived.markdown === "string") { + componentProbePaths.push(derived.markdown); + } + return { causes, specGroups, probeable: true, componentProbePaths }; +} + +/** + * The one `refused-invalid-destination` finding (SPEC 14: one finding per + * reason, concerning the destination path) over the pure causes and the + * probed component obstructions — or null when the destination is valid. + */ +function invalidDestinationFinding( + destination: string, + causes: readonly string[], + obstructedComponents: readonly string[], +): Finding | null { + const all = [...causes]; + for (const component of obstructedComponents) { + all.push( + `its workspace-relative directory component ` + + `${JSON.stringify(component)} (of the destination path or of a ` + + `derived path the destination would generate, SPEC 13.1, 13.2, ` + + `7.3) is occupied by something other than a directory — writes ` + + `never traverse or replace such an occupant (SPEC 13.4, 14.22)`, + ); + } + if (all.length === 0) return null; + return refusalFinding( + "refused-invalid-destination", + `invalid destination ${JSON.stringify(destination)}: the destination ` + + `file path would not be a valid discovered spec source after the ` + + `move, or could not be written and regenerated — ${all.join("; ")} ` + + `(SPEC 6.5)`, + { path: destination }, + ); +} + +// --------------------------------------------------------------------------- +// Identity mappings (the would-be operations, SPEC 6.4, 6.5) +// --------------------------------------------------------------------------- + +/** The identity-space mapping a would-be operation applies (SPEC 6.1). */ +type IdentityMap = (identity: string) => string; + +/** A rename's mapping: `file#oldId(.rest)` → `file#newId(.rest)` (SPEC 6.4). */ +function renameIdentityMap( + file: string, + oldId: string, + newId: string, +): IdentityMap { + const prefix = `${file}#`; + return (identity) => { + if (!identity.startsWith(prefix)) return identity; + const mapped = replaceIdPrefix(identity.slice(prefix.length), oldId, newId); + return mapped === null ? identity : `${prefix}${mapped}`; + }; +} + +/** A file move's mapping: identities change only in the file part (SPEC 6.5). */ +function moveFileIdentityMap(origin: string, destination: string): IdentityMap { + const prefix = `${origin}#`; + return (identity) => { + if (identity === origin) return destination; + if (identity.startsWith(prefix)) { + return `${destination}#${identity.slice(prefix.length)}`; + } + return identity; + }; +} + +/** A section move's mapping: prefix replacement into the target (SPEC 6.5). */ +function moveSectionIdentityMap( + origin: string, + oldId: string, + target: string, + newId: string, +): IdentityMap { + const prefix = `${origin}#`; + return (identity) => { + if (!identity.startsWith(prefix)) return identity; + const mapped = replaceIdPrefix(identity.slice(prefix.length), oldId, newId); + return mapped === null ? identity : `${target}#${mapped}`; + }; +} + +// --------------------------------------------------------------------------- +// Shared reason evaluations +// --------------------------------------------------------------------------- + +/** + * `refused-invalid-id` (SPEC 14): the new ID is not in intrinsic ID form + * (one or more segments joined by `.`, each satisfying SPEC 1.4) — one + * finding concerning the new identity alone, or null. Its `identities` + * hold exactly `<file>#<new-id>` over the operation's destination file + * (SPEC 1.5, 14: the file for a rename, the target file for a section + * move), the ID spelled verbatim. An ID the prefix replacement produces + * (SPEC 6.4, 6.5) is in intrinsic form exactly when the new ID is — the + * suffix it appends is valid on a workspace passing `build`'s + * validations — so no produced identity reports separately (SPEC 14). + */ +function invalidIdFinding(targetFile: string, newId: string): Finding | null { + const problem = intrinsicIdProblem(newId); + if (problem === null) return null; + return refusalFinding( + "refused-invalid-id", + `invalid new ID: ${JSON.stringify(newId)} is not in intrinsic ID form ` + + `(one or more segments joined by ".", each satisfying SPEC 1.4) — ` + + `${problem}; choose a valid new ID (SPEC 1.4, 14)`, + { identities: [`${targetFile}#${newId}`] }, + ); +} + +/** + * `refused-id-collision` (SPEC 14): the new ID, or an ID the prefix + * replacement produces, collides with an ID remaining after the + * operation's removals — one finding locating every colliding bearer, or + * null. `remaining` holds the target file's sections minus the vacated + * ones (SPEC 6.4: the old ID and its descendants'; SPEC 6.5: the moved + * subtree, for a same-file move). + */ +function idCollisionFinding( + targetFile: string, + targetFilePath: PathText, + producedIds: readonly string[], + remaining: readonly SpecSection[], +): Finding | null { + const produced = new Set(producedIds); + const locations: FindingLocation[] = []; + const colliding = new Set<string>(); + for (const section of remaining) { + if (section.id !== null && produced.has(section.id)) { + colliding.add(section.id); + locations.push({ file: targetFilePath, range: section.range }); + } + } + if (locations.length === 0) return null; + const ids = sortByBytes([...colliding], (id) => id); + return refusalFinding( + "refused-id-collision", + `ID collision: the operation would produce ` + + `${ids.map((id) => JSON.stringify(id)).join(", ")}, which collide${ + ids.length === 1 ? "s" : "" + } with the located ID${ids.length === 1 ? "" : "s"} remaining in ` + + `${JSON.stringify(targetFile)} after the operation's removals — IDs ` + + `are unique within a source file (SPEC 1.3); choose a new ID that ` + + `collides with nothing (SPEC 6.4, 6.5, 14)`, + { + locations, + identities: ids.map((id) => `${targetFile}#${id}`), + }, + ); +} + +// --------------------------------------------------------------------------- +// Would-be cycles (SPEC 6.5 → 5.3, 2.1; refused-cycle) +// --------------------------------------------------------------------------- + +/** The section form's re-parenting of the moved node (SPEC 6.5). */ +interface Reparent { + /** The pre-operation `contains` edge to drop: parent → moved root. */ + readonly removed: { readonly parent: string; readonly child: string }; + /** The post-operation `contains` edge to add (mapped identities). */ + readonly added: { readonly parent: string; readonly child: string }; +} + +/** + * One cycle a move would create (SPEC 6.5): which relation it closes, its + * full path as a closed walk, and the constructs locating that path in + * pre-operation coordinates (SPEC 14 location cardinality). + */ +interface WouldBeCycle { + readonly kind: "dependency" | "import"; + readonly path: readonly string[]; + readonly locations: readonly FindingLocation[]; +} + +/** + * `refused-cycle` (SPEC 14): one reason, so one finding however many + * cycles the move would create — a dependency cycle beside a spec import + * cycle, or cycles in distinct strongly connected components — "one + * finding per reason, never only the first found". It locates every + * cycle's full path: the union of each cycle's located constructs, each + * construct once (one spelling can record a dependency edge of one cycle + * and root a would-be import closing another; SPEC 12.7: one location per + * offending construct), in 12.7's within-finding order; its message names + * each cycle's path. Null when the move closes no cycle. Build's condition + * 14.9 is not this rule: there each cycle stays its own finding + * (core/graph.ts). + */ +function refusedCycleFinding(cycles: readonly WouldBeCycle[]): Finding | null { + if (cycles.length === 0) return null; + const dependency = cycles.filter((cycle) => cycle.kind === "dependency"); + const imports = cycles.filter((cycle) => cycle.kind === "import"); + const named = [ + ...dependency.map( + (cycle) => `a dependency cycle (${cycle.path.join(" → ")})`, + ), + ...imports.map( + (cycle) => `a spec import cycle (${cycle.path.join(" → ")})`, + ), + ]; + const explanations: string[] = []; + if (dependency.length > 0) { + explanations.push( + `the combined contains/depends/embeds graph over requirement ` + + `nodes must be acyclic (SPEC 5.3), the located reference ` + + `spellings recording the participating dependency edges`, + ); + } + if (imports.length > 0) { + explanations.push( + `import cycles among spec source files are invalid (SPEC 2.1), the ` + + `rewrite adding the imports that close ` + + `${imports.length === 1 ? "it" : "them"} — each located by the ` + + `reference spellings the move roots at its binding, beside the ` + + `participating existing import declarations`, + ); + } + const sorted = sortLocations(cycles.flatMap((cycle) => cycle.locations)); + const locations = sorted.filter( + (location, index) => + index === 0 || compareLocations(sorted[index - 1]!, location) !== 0, + ); + return refusalFinding( + "refused-cycle", + `the move would create ${englishList(named)} — ` + + `${explanations.join("; ")} — so the move is refused; choose a ` + + `target that closes no cycle (SPEC 6.5, 14)`, + { locations }, + ); +} + +/** `a`, `a and b`, `a, b, and c`: a message's list of named items. */ +function englishList(items: readonly string[]): string { + if (items.length <= 2) return items.join(" and "); + return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]!}`; +} + +/** + * The dependency half of `refused-cycle` (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 carries 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 wouldBeDependencyCycles( + graph: WorkspaceGraph, + map: IdentityMap, + reparent: Reparent | null, + extraNodes: readonly string[], +): WouldBeCycle[] { + const adjacency = new Map<string, Set<string>>(); + const addEdge = (source: string, target: string): void => { + let targets = adjacency.get(source); + if (targets === undefined) adjacency.set(source, (targets = new Set())); + targets.add(target); + }; + for (const edge of graph.edges) { + if (edge.kind === "references") continue; + if (graph.requirementNode(edge.source) === undefined) continue; + if ( + reparent !== null && + edge.kind === "contains" && + edge.source === reparent.removed.parent && + edge.target === reparent.removed.child + ) { + continue; + } + addEdge(map(edge.source), map(edge.target)); + } + if (reparent !== null) { + addEdge(reparent.added.parent, reparent.added.child); + } + + // The current reference spellings behind each would-be dependency edge, + // keyed by mapped (source, target): a cycle locates its full path in + // source at pre-operation coordinates (SPEC 14, 6.6). + const spellings = new Map<string, FindingLocation[]>(); + for (const occurrence of graph.occurrences) { + if (occurrence.kind === "references") continue; + if (occurrence.source === null) continue; + if (graph.requirementNode(occurrence.source) === undefined) continue; + const key = `${map(occurrence.source)}�${map(occurrence.target)}`; + let list = spellings.get(key); + if (list === undefined) spellings.set(key, (list = [])); + list.push({ file: occurrence.file, range: occurrence.range }); + } + + const nodes = [ + ...graph.requirementNodes.map((node) => map(node.identity)), + ...extraNodes, + ]; + return findCycles(nodes, adjacency).map((cycle) => { + const locations: FindingLocation[] = []; + for (let step = 0; step + 1 < cycle.length; step += 1) { + const list = spellings.get(`${cycle[step]!}�${cycle[step + 1]!}`); + if (list !== undefined) locations.push(...list); + } + return { kind: "dependency", path: cycle, locations }; + }); +} + +/** + * The spec import relation a file-form move leaves (SPEC 6.5 "Import + * edits", 2.1): every declaration, none removed and none added — each + * specifier designating the moved file, and each of the moved file's own, + * rewritten exactly where it would no longer designate its source, so + * every declaration keeps designating what it designated — the paths + * mapped, each declaration located by its own characters. + */ +function fileMoveSpecImports( + specs: readonly SpecFileAnalysis[], + originPath: string, + destination: string, +): WouldBeSpecImport[] { + const postPathOf = (path: string): string => + path === originPath ? destination : path; + const imports: WouldBeSpecImport[] = []; + for (const spec of specs) { + for (const declared of spec.imports.imports) { + if (declared.targetPath === null) continue; // valid workspaces only + imports.push({ + importer: postPathOf(spec.document.path), + imported: postPathOf(declared.targetPath), + locations: [ + { file: spec.document.file, range: declared.statement.range }, + ], + }); + } + } + return imports; +} + +/** + * The spec-import half of `refused-cycle` (SPEC 14, 2.1): cycles in the + * would-be file-level import relation among spec source files, `imports` + * — the relation the operation leaves: every declaration its rewrite + * keeps and every one it adds (SPEC 6.5 "Import edits"), a declaration it + * removes standing in no post-operation file, so taking part in no cycle + * and located by none. Each cycle carries its full path located in + * pre-operation coordinates (SPEC 14 location cardinality), step by step: + * for a step H → T, each participating declaration of H designating T — + * one existing before the operation by its own characters, one the + * operation would add, which exists in no pre-operation coordinates, by + * every reference spelling the operation roots at its binding, whether or + * not the spelling's characters change (a moved-text spelling inside the + * origin's moved construct). + */ +function wouldBeImportCycles( + imports: readonly WouldBeSpecImport[], +): WouldBeCycle[] { + const adjacency = new Map<string, Set<string>>(); + const located = new Map<string, Map<string, FindingLocation[]>>(); + const nodes: string[] = []; + for (const declared of imports) { + const { importer, imported } = declared; + // No valid workspace holds a self-import (SPEC 2.1), and no rewrite + // adds one: a moved spelling targeting its new file turns local + // (6.5). One arises only where a file move's destination is another + // spec source's path, refused as occupied, the two files' paths + // merged — no would-be workspace holds both files there. + if (importer === imported) continue; + nodes.push(importer, imported); + let targets = adjacency.get(importer); + if (targets === undefined) adjacency.set(importer, (targets = new Set())); + targets.add(imported); + let byImported = located.get(importer); + if (byImported === undefined) { + located.set(importer, (byImported = new Map())); + } + let locations = byImported.get(imported); + if (locations === undefined) byImported.set(imported, (locations = [])); + locations.push(...declared.locations); + } + return findCycles(nodes, adjacency).map((cycle) => { + const locations: FindingLocation[] = []; + for (let step = 0; step + 1 < cycle.length; step += 1) { + locations.push( + ...(located.get(cycle[step]!)?.get(cycle[step + 1]!) ?? []), + ); + } + return { kind: "import", path: cycle, locations }; + }); +} + +// --------------------------------------------------------------------------- +// Rename (SPEC 6.4) +// --------------------------------------------------------------------------- + +/** The inputs of a rename's refusal evaluation (SPEC 6.4, 14). */ +export interface RenameRefusalInputs { + /** The origin file's analysis (a discovered, parsed spec source). */ + readonly origin: SpecFileAnalysis; + readonly oldId: string; + readonly newId: string; +} + +/** + * Evaluate every applicable rename refusal reason together (SPEC 6.4, 14) + * over a workspace passing `build`'s validations: the new ID's intrinsic + * form, identity change, collisions against the IDs remaining after the + * vacated ones are removed, and the structural parent rules at the + * renamed section's place. A rename maps identities one-to-one within one + * file and preserves every reference's form (SPEC 6.4), so it can create + * no cycle and leave no rewritten reference unresolved — those reasons + * are move-only (SPEC 14) and the "all rewritten references resolve" + * clause is the always-passing side here. + */ +export function evaluateRenameRefusals(inputs: RenameRefusalInputs): Finding[] { + const { origin, oldId, newId } = inputs; + const file = origin.document.path; + const section = origin.document.sections.find((s) => s.id === oldId); + if (section === undefined) { + throw new Error( + `xspec internal error: rename origin ID ${oldId} is not a section of ` + + `${file} — the caller validated its existence (SPEC 6.4)`, + ); + } + const findings: Finding[] = []; + + // SPEC 14 `refused-identity-unchanged`: the new identity equals the old, + // concerning it. + if (newId === oldId) { + findings.push( + refusalFinding( + "refused-identity-unchanged", + `identity unchanged: the new ID ${JSON.stringify(newId)} equals ` + + `the old ID — a rename must change the identity (SPEC 6.4, 14)`, + { identities: [`${file}#${newId}`] }, + ), + ); + } + + // The produced IDs (SPEC 6.4): the new ID plus each descendant's + // prefix-replaced ID; the vacated IDs: the old ID and its descendants'. + const producedIds: string[] = []; + const vacated = new Set<string>(); + for (const candidate of origin.document.sections) { + if (candidate.id === null) continue; + const mapped = replaceIdPrefix(candidate.id, oldId, newId); + if (mapped !== null) { + vacated.add(candidate.id); + producedIds.push(mapped); + } + } + + // SPEC 14 `refused-invalid-id`: intrinsic form only, concerning the new + // identity alone — every produced ID shares the new ID's form. + const invalidId = invalidIdFinding(file, newId); + if (invalidId !== null) findings.push(invalidId); + + // SPEC 14 `refused-id-collision`: against the IDs remaining once the + // vacated ones are removed (SPEC 6.4) — an identity-unchanged rename + // therefore collides with nothing. + const remaining = origin.document.sections.filter( + (candidate) => candidate.id !== null && !vacated.has(candidate.id), + ); + const collision = idCollisionFinding( + file, + origin.document.file, + producedIds, + remaining, + ); + if (collision !== null) findings.push(collision); + + // SPEC 14 `refused-structural-parent`: positional conformance (1.3) at + // the renamed section's unchanged place, evaluated only over + // intrinsically valid IDs — no identity reports under both. + if (invalidId === null) { + const parentId = section.parent === null ? null : section.parent.id; + let violated = false; + if (parentId === null) { + // Top-level (the implicit root, SPEC 1.2): exactly one segment. + violated = newId.includes("."); + } else { + const prefix = `${parentId}.`; + violated = + !newId.startsWith(prefix) || newId.slice(prefix.length).includes("."); + } + if (violated) { + findings.push( + refusalFinding( + "refused-structural-parent", + `structural parent violation: the renamed section keeps its ` + + `place in the tree, so its new ID must be ` + + (parentId === null + ? `exactly one segment (it is top-level)` + : `${JSON.stringify(parentId)} plus "." plus exactly one ` + + `segment (it is nested inside ${JSON.stringify(parentId)})`) + + ` (SPEC 1.3); ${JSON.stringify(newId)} is not (SPEC 6.4, 14)`, + { identities: [`${file}#${newId}`] }, + ), + ); + } + } + + return findings; +} + +// --------------------------------------------------------------------------- +// Move (SPEC 6.5) +// --------------------------------------------------------------------------- + +/** The probed destination-side filesystem facts (workspace/writes.ts). */ +export interface DestinationProbe { + /** + * What occupies the destination path itself, judged by `lstat` — never + * through a symbolic link (SPEC 13.4) — "absent" also for a path + * unreachable through a non-directory component (nothing occupies it; + * the component itself reports through `obstructedComponents`). + */ + readonly occupant: "absent" | "file" | "directory" | "symlink" | "other"; + /** + * The workspace-relative directory components of the assessment's + * `componentProbePaths` occupied by anything other than a directory + * (SPEC 6.5), distinct, in byte order; nonexistent components are never + * listed (writes create those, SPEC 13.4). + */ + readonly obstructedComponents: readonly string[]; +} + +/** A destination that was never probed (shape-invalid, SPEC 1.5). */ +export const UNPROBED_DESTINATION: DestinationProbe = { + occupant: "absent", + obstructedComponents: [], +}; + +/** Human words for an occupant kind (diagnostics). */ +function describeOccupantKind( + occupant: Exclude<DestinationProbe["occupant"], "absent">, +): string { + switch (occupant) { + case "file": + return "a plain file"; + case "directory": + return "a directory"; + case "symlink": + return "a symbolic link"; + case "other": + return "a non-plain file"; + } +} + +/** The inputs of a file-form move's refusal evaluation (SPEC 6.5, 14). */ +export interface MoveFileRefusalInputs { + readonly specs: readonly SpecFileAnalysis[]; + readonly graph: WorkspaceGraph; + readonly originPath: string; + readonly destination: string; + readonly assessment: DestinationPathAssessment; + readonly probe: DestinationProbe; +} + +/** + * Evaluate every applicable file-form move refusal reason together (SPEC + * 6.5, 14) over a workspace passing `build`'s validations. A file move + * maps identities one-to-one (file part only) and preserves the shapes of + * both the dependency graph and the import relation, so the would-be + * cycle evaluation runs on principle and finds nothing new on a valid + * workspace; no rewritten reference can fail to resolve (import + * specifiers are rewritten to keep designating the files they designated, + * SPEC 6.5). + */ +export function evaluateMoveFileRefusals( + inputs: MoveFileRefusalInputs, +): Finding[] { + const { specs, graph, originPath, destination, assessment, probe } = inputs; + const findings: Finding[] = []; + + // SPEC 14 `refused-identity-unchanged` (the mirrored identity check, + // SPEC 6.5: the new identity differs from the old — for the file form, + // in its file part): the exact self-move maps every identity to itself. + if (destination === originPath) { + findings.push( + refusalFinding( + "refused-identity-unchanged", + `identity unchanged: the destination equals the origin ` + + `${JSON.stringify(originPath)}, so every identity would map to ` + + `itself — a move must change the identities (SPEC 6.5, 14)`, + { identities: [originPath] }, + ), + ); + } + + // SPEC 14 `refused-destination-exists`: the file form's destination path + // is already occupied, whatever kind of filesystem object occupies it — + // unless it is the origin path itself, compared byte-wise (SPEC 12.0): + // the exact self-move, its only occupant the origin the relocation + // would remove, is `refused-identity-unchanged`'s alone (SPEC 6.5, 14). + // A spelling such as `./a.mdx` for the origin `a.mdx` is not in + // discovered-path form, so it is never probed as occupied here (SPEC 7, + // 14): `refused-invalid-destination` reports it. + if (probe.occupant !== "absent" && destination !== originPath) { + 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) — one + // finding for every cycle, dependency and spec import alike. + const map = moveFileIdentityMap(originPath, destination); + const cycle = refusedCycleFinding([ + ...wouldBeDependencyCycles(graph, map, null, []), + ...wouldBeImportCycles(fileMoveSpecImports(specs, originPath, destination)), + ]); + if (cycle !== null) findings.push(cycle); + + return findings; +} + +/** + * SPEC 6.5, 14 `refused-moved-import`: the import declarations the moved + * text holds — the moved text being the section construct's own + * characters, from the first character of its opening tag through the + * last character of its closing tag (SPEC 6.5, 1.7). An ESM block derives + * inside a section element too (SPEC 14.20), a descendant's inside every + * enclosing construct, and a block lies wholly inside or wholly outside an + * element, so a declaration is held exactly when its own characters lie + * inside the construct's range. Each is located in the origin file by its + * own characters — the import range of 11.4, a spelled `;` included, its + * line terminator excluded — in start order (SPEC 14, 12.7). + */ +function movedImportLocations( + origin: SpecFileAnalysis, + construct: ByteRange, +): FindingLocation[] { + const locations: FindingLocation[] = []; + for (const declared of origin.imports.imports) { + const range = declared.statement.range; + if (range.start >= construct.start && range.end <= construct.end) { + locations.push({ file: origin.document.file, range }); + } + } + return locations; +} + +/** + * SPEC 14 `refused-invalid-rewrite` from the would-be files' verdict + * (core/move.ts), or null when every judged file is well-formed and every + * addition admitted: one finding, locating the moved section's construct + * in the origin file (1.7) and, for each addition no offset admits, every + * reference spelling the operation roots at its binding, whether or not + * its characters change — as `refused-cycle` locates an import the + * operation would add — its `identities` the workspace-relative paths of + * the files concerned — each whose would-be text is not well-formed MDX, a + * target file to be created included, spelled whatever its path's + * validity, and each holding no admissible offset for an addition it + * needs — in byte order, its `path` null. + */ +function invalidRewriteFinding( + verdict: MoveSectionRewriteVerdict, + origin: SpecFileAnalysis, + construct: ByteRange, + from: string, + to: string, +): Finding | null { + if (verdict.illFormed.length === 0 && verdict.inadmissible.length === 0) { + return null; + } + const concerned = sortByBytes( + [ + ...new Set([ + ...verdict.illFormed, + ...verdict.inadmissible.map((entry) => entry.path), + ]), + ], + (path) => path, + ); + const locations: FindingLocation[] = [ + { file: origin.document.file, range: construct }, + ]; + for (const entry of verdict.inadmissible) { + for (const spelling of entry.spellings) { + if (!locations.some((known) => compareLocations(known, spelling) === 0)) { + locations.push(spelling); + } + } + } + const causes: string[] = []; + if (verdict.illFormed.length > 0) { + causes.push( + `leave ${sortByBytes([...verdict.illFormed], (path) => path) + .map((path) => JSON.stringify(path)) + .join(" and ")} not well-formed MDX (SPEC 14.20)`, + ); + } + if (verdict.inadmissible.length > 0) { + causes.push( + `add an import to ${sortByBytes( + verdict.inadmissible.map((entry) => entry.path), + (path) => path, + ) + .map((path) => JSON.stringify(path)) + .join(" and ")}, ` + + `${verdict.inadmissible.length === 1 ? "which holds" : "each holding"} ` + + `no admissible offset for it — located by the reference spellings ` + + `rooted at its binding`, + ); + } + return refusalFinding( + "refused-invalid-rewrite", + `invalid rewrite: moving ${JSON.stringify(from)} to ` + + `${JSON.stringify(to)} would ${causes.join(", and would ")} — the ` + + `exact edits read line-sensitively, so the moved section's shape ` + + `must derive where it would stand; reshape the section or choose ` + + `another target (SPEC 6.5, 14)`, + { locations, identities: concerned }, + ); +} + +/** The inputs of a section-form move's refusal evaluation (SPEC 6.5, 14). */ +export interface MoveSectionRefusalInputs { + readonly specs: readonly SpecFileAnalysis[]; + /** + * The code sources, judged for an admissible offset for the import + * additions each needs (SPEC 6.5, 14 `refused-invalid-rewrite`). + */ + readonly code: readonly CodeAnalysis[]; + 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, an import declaration the moved text holds, the would-be + * text (well-formed files, admissible import additions), and the would-be + * cycles (dependency and spec-import). No reason exists for an + * unresolvable rewritten reference (SPEC 6.4, 14): a moved reference + * targeting the target file's root node is refused as the dependency + * cycle it closes (the module header). + */ +export function evaluateMoveSectionRefusals( + inputs: MoveSectionRefusalInputs, +): Finding[] { + const { + specs, + code, + graph, + origin, + oldId, + targetPath, + newId, + target, + probe, + } = inputs; + const originPath = origin.document.path; + const sameFile = targetPath === originPath; + const movedSection = origin.document.sections.find((s) => s.id === oldId); + if (movedSection === undefined) { + throw new Error( + `xspec internal error: move origin ID ${oldId} is not a section of ` + + `${originPath} — the caller validated its existence (SPEC 6.5)`, + ); + } + const inMovedSubtree = (id: string): boolean => + id === oldId || id.startsWith(`${oldId}.`); + const findings: Finding[] = []; + + // SPEC 14 `refused-identity-unchanged`: the exact self-move — + // `<target-file>#<new-id>` equal to `<file>#<id>` (SPEC 6.5). + if (sameFile && newId === oldId) { + findings.push( + refusalFinding( + "refused-identity-unchanged", + `identity unchanged: ${JSON.stringify(`${targetPath}#${newId}`)} ` + + `is the moved section's own identity — the exact self-move is ` + + `refused and appends no journal entry (SPEC 6.5, 14)`, + { identities: [`${targetPath}#${newId}`] }, + ), + ); + } + + // The produced IDs (SPEC 6.5): the new ID plus each moved descendant's + // prefix-replaced ID. + const producedIds: string[] = []; + for (const candidate of origin.document.sections) { + if (candidate.id === null) continue; + const mapped = replaceIdPrefix(candidate.id, oldId, newId); + if (mapped !== null) producedIds.push(mapped); + } + + // SPEC 14 `refused-invalid-id`: intrinsic form only, concerning the new + // identity alone over the target file — every produced ID shares the new + // ID's form. + const invalidId = invalidIdFinding(targetPath, newId); + if (invalidId !== null) findings.push(invalidId); + + // SPEC 14 `refused-id-collision`: against the IDs remaining in the + // target file after the removal — the moved subtree's own IDs are + // vacated by it (a same-file move), and a distinct target file loses + // nothing (SPEC 6.5). + if (target !== null) { + const remaining = target.document.sections.filter( + (candidate) => + candidate.id !== null && !(sameFile && inMovedSubtree(candidate.id)), + ); + const collision = idCollisionFinding( + targetPath, + target.document.file, + producedIds, + remaining, + ); + if (collision !== null) findings.push(collision); + } + + // SPEC 14 `refused-destination-exists` (section form): the target path + // is occupied by anything other than a discovered spec source — neither + // an insertion target nor an absent path to create (SPEC 6.5). + if (target === null && probe.occupant !== "absent") { + findings.push( + refusalFinding( + "refused-destination-exists", + `destination exists: the target path ` + + `${JSON.stringify(targetPath)} is occupied by ` + + `${describeOccupantKind(probe.occupant)} that is not a ` + + `discovered spec source — neither an insertion target nor an ` + + `absent path to create (SPEC 6.5, 7, 14)`, + { path: targetPath }, + ), + ); + } + + // SPEC 14 `refused-invalid-destination`: the path-validity causes apply + // to a target that is no discovered spec source (a discovered one IS a + // valid source path — the caller passes a cause-free assessment); the + // component obstructions apply to every target's destination-side + // write paths (SPEC 6.5, 14.22). + const invalidDestination = invalidDestinationFinding( + targetPath, + inputs.assessment.causes, + probe.obstructedComponents, + ); + if (invalidDestination !== null) findings.push(invalidDestination); + + // SPEC 14 `refused-missing-target-parent`: the target file's section + // bearing `<new-id>` minus its final segment — needed whenever + // `<new-id>` has more than one segment — is missing or lies within the + // moved subtree, leaving no insertion point after the removal + // (SPEC 6.5), concerning the target-parent identity. + const newSegments = newId.split("."); + let parentUsable = true; + let parentSection: SpecSection | null = null; + if (newSegments.length > 1) { + const parentId = newSegments.slice(0, -1).join("."); + parentSection = + target?.document.sections.find((s) => s.id === parentId) ?? null; + if (parentSection === null) { + parentUsable = false; + findings.push( + refusalFinding( + "refused-missing-target-parent", + `missing target parent: the target parent ` + + `${JSON.stringify(`${targetPath}#${parentId}`)} — the section ` + + `bearing the new ID minus its final segment — does not exist ` + + `in the target file (SPEC 6.5, 1.3, 14)`, + { identities: [`${targetPath}#${parentId}`] }, + ), + ); + } else if (sameFile && inMovedSubtree(parentId)) { + parentUsable = false; + findings.push( + refusalFinding( + "refused-missing-target-parent", + `missing target parent: the target parent ` + + `${JSON.stringify(`${targetPath}#${parentId}`)} lies within ` + + `the moved subtree, leaving no insertion point after the ` + + `removal (SPEC 6.5, 14)`, + { identities: [`${targetPath}#${parentId}`] }, + ), + ); + } + } + + // SPEC 14 `refused-moved-import`: the moved text holds an import + // declaration — judged over the moved text as it stands, whatever the + // edits would leave (SPEC 6.5): the exact edits would carry the + // declaration into the target file, where its specifier resolves from + // that file's directory (2.1) and its binding may collide with one the + // file holds (14.15), while every origin-kept reference rooted at its + // binding would lose it (2.4). Its terms read nothing of the new ID, the + // target path, or the insertion point, so it applies beside every other + // reason, under no intrinsic-validity qualifier: one finding locating + // each such declaration in the origin file by its own characters (the + // import range of 11.4), its `identities` empty (SPEC 14). + const movedImports = movedImportLocations(origin, movedSection.range); + if (movedImports.length > 0) { + const held = + movedImports.length === 1 + ? "an import declaration" + : `${String(movedImports.length)} import declarations`; + findings.push( + refusalFinding( + "refused-moved-import", + `moved import: the moved section ` + + `${JSON.stringify(`${originPath}#${oldId}`)} holds ${held} — a ` + + `move carrying an import declaration into the target file is ` + + `refused: its specifier would resolve from that file's ` + + `directory, its binding could collide with one the file holds, ` + + `and every reference the origin keeps rooted at its binding ` + + `would lose it; move each declaration into an ESM block outside ` + + `the section, then move the section (SPEC 6.5, 2.1, 14)`, + { locations: movedImports }, + ), + ); + } + + // SPEC 14 `refused-invalid-rewrite`: the section form's exact edits would + // leave the origin or the target file other than well-formed MDX + // (14.20), or a file the rewrite must add an import to holds no + // admissible offset for it (SPEC 6.5). Like `refused-structural-parent`, + // it is evaluated only over an intrinsically valid new ID — an invalid + // one, spelled verbatim, leaves the would-be text undefined — and beside + // every other applicable reason, judged over the would-be text: the + // origin's, and the additions every other file needs, always; the + // target's — its well-formedness and its additions alike — exactly when + // an insertion point exists: the target path a discovered spec source or + // an absent path (`refused-destination-exists` otherwise), and the target + // parent present outside the moved subtree + // (`refused-missing-target-parent` otherwise). + // + // One composition (core/move.ts `judgeMoveSectionRewrite`) serves this + // verdict and `refused-cycle`'s would-be spec import relation (below), + // which reads nothing of the new ID — which references the move + // re-roots, at which bindings, and which declarations it adds and + // removes are fixed by the moved subtree, the origin, and the target + // file (SPEC 6.5 "Import edits") — so where the new ID is invalid, the + // relation alone read, the old ID stands in for it, composing no target: + // spelled verbatim, an invalid ID leaves the would-be text undefined + // (6.5), perhaps without any spelling at all. + const judgement = + invalidId === null || parentUsable + ? judgeMoveSectionRewrite( + specs, + code, + originPath, + oldId, + targetPath, + invalidId === null ? newId : oldId, + invalidId === null && + parentUsable && + (target !== null || probe.occupant === "absent"), + ) + : null; + if (invalidId === null && judgement !== null) { + const invalidRewrite = invalidRewriteFinding( + judgement.verdict, + origin, + movedSection.range, + `${originPath}#${oldId}`, + `${targetPath}#${newId}`, + ); + if (invalidRewrite !== null) findings.push(invalidRewrite); + } + + const map = moveSectionIdentityMap(originPath, oldId, targetPath, newId); + + // SPEC 14 `refused-cycle`: the would-be dependency graph — the moved + // root re-parented from its current parent to the target parent (the + // target file's root for a single-segment `<new-id>`, SPEC 6.5) — and + // the would-be spec import relation, the composition's (above): every + // declaration the rewrite keeps and every one it adds, each located as + // SPEC 14 locates a participant — every cycle of either reported + // together as the one finding of this one reason. It needs a definable + // post-operation shape: with the insertion point missing (above) there + // is no would-be graph to judge. + if (!parentUsable || judgement === null) 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; + const cycle = refusedCycleFinding([ + ...wouldBeDependencyCycles( + graph, + map, + reparent, + // A created target file's root node exists in no current graph. + createdTarget ? [targetPath] : [], + ), + ...wouldBeImportCycles(judgement.imports), + ]); + if (cycle !== null) findings.push(cycle); + + return findings; +} diff --git a/src/core/rename.ts b/src/core/rename.ts index 139bd98c..c48c98c9 100644 --- a/src/core/rename.ts +++ b/src/core/rename.ts @@ -41,11 +41,14 @@ 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 { classifyReference, parseExpressionText } from "./references.js"; +import type { PreviewFileEdits } from "./preview.js"; +import { PreviewCollector } from "./preview.js"; +import { classifyReferenceText } from "./references.js"; import type { ReferenceSpelling, SegmentSpelling, @@ -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[]; } // --------------------------------------------------------------------------- @@ -71,28 +81,23 @@ export interface RenamePlan { /** * Whether `name` can be written as a dot-access segment (`.name`) that the * static-reference grammar (SPEC 2.4) reads back as exactly this segment. - * Decided by the analyzer itself — parse `a.<name>` and require a one- - * segment dot chain naming `name` — so a kept dot form always round-trips: + * Decided by the analyzer itself — classify `a.<name>` and require a one- + * segment dot chain naming `name`, which `classifyReferenceText` gives only + * where the chain spans the whole spelling — so a kept dot form always + * round-trips: * keywords are valid property names (TypeScript's IdentifierName), while * any name needing quoting fails and falls back to computed access * (SPEC 6.4: dot access for segments that are valid TypeScript * identifiers, double-quoted computed access otherwise). */ export function isDotAccessSegmentName(name: string): boolean { - const text = `a.${name}`; - const { sourceFile, expression } = parseExpressionText(text); - if (expression === null) { - return false; - } - const classified = classifyReference(expression, sourceFile); + const { classified } = classifyReferenceText(`a.${name}`); return ( classified.kind === "chain" && classified.rootName === "a" && classified.segments.length === 1 && classified.segments[0].access === "dot" && - classified.segments[0].name === name && - classified.span.start === 0 && - classified.span.end === text.length + classified.segments[0].name === name ); } @@ -207,15 +212,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 +299,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 +326,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 +339,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 +363,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 +377,7 @@ export function planRename( ); if (edit !== null) { edits.add(path, edit); + preview.add(path, "reference-rewrite", occurrence); } } } @@ -365,6 +398,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 +444,6 @@ export function planRename( mapping, ), rewrites, + previewFiles: preview.files(), }; } diff --git a/src/core/review.ts b/src/core/review.ts index f859d3c5..550c6bb5 100644 --- a/src/core/review.ts +++ b/src/core/review.ts @@ -64,7 +64,7 @@ // values. File I/O — reading `.xspec/reviews/`, classifying occupants, // writing session files — lives in src/workspace/reviews.ts. -import { compareBytes } from "./bytes.js"; +import { byteOrderedSet, compareBytes } from "./bytes.js"; import { canonicalJson } from "./canonical-json.js"; import type { JsonObject, JsonValue } from "./canonical-json.js"; import { parseCanonicalIdentity } from "./journal.js"; @@ -74,8 +74,9 @@ import type { CoverageProfile, DependencyEdgeKind, } from "./config.js"; -import { DEPENDENCY_EDGE_KINDS } from "./config.js"; +import { DEPENDENCY_EDGE_KINDS, dependencyKindSet } 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"; @@ -89,34 +90,9 @@ export function sessionFilePath(name: string): string { // Session names (SPEC 10.1) // --------------------------------------------------------------------------- -/** - * SPEC 10.1: a session name consists of one or more characters from `A–Z`, - * `a–z`, `0–9`, `.`, `_`, and `-`, and does not begin with `.`. - */ -export function isValidSessionName(name: string): boolean { - return /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/.test(name); -} - -/** - * The usage-error diagnostic for an invalid session name, or null for a - * valid one (SPEC 10.1 → 12.0: any other name is a usage error). - */ -export function sessionNameProblem(name: string): string | null { - if (isValidSessionName(name)) { - return null; - } - const reason = - name.length === 0 - ? "it is empty" - : name.startsWith(".") - ? "it begins with `.`" - : "it contains a character outside `A-Z a-z 0-9 . _ -`"; - return ( - `invalid session name ${JSON.stringify(name)}: ${reason} — a session ` + - `name must consist of one or more characters from A-Z, a-z, 0-9, ` + - `\`.\`, \`_\`, and \`-\`, and must not begin with \`.\` (SPEC 10.1)` - ); -} +// The session-name form itself (`isValidSessionName`, `sessionNameProblem`) +// lives in ./session-name.ts, import-free, so the argument parser judges it +// without loading this module (SPEC 12.0 syntax class). /** ASCII case folding (A–Z → a–z); no other character is touched. */ export function asciiCaseFold(text: string): string { @@ -485,19 +461,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 +484,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), + ); } // --------------------------------------------------------------------------- @@ -1158,14 +1130,19 @@ function parseRecordedProfile( if (!complete || problems.list.length > before) { return null; } + // SPEC 7.4: `targetTags` and `edgeKinds` are read as sets — a recorded + // list in any order, a repeated element included, means its set, held in + // the 12.7 value forms (tag sets in byte order, kind sets in 5.2's order) + // like the configured lists a `create` records (core/config.ts). return { name: name as string, target: target as RecordedGroup, - targetTags, + targetTags: + targetTags === undefined ? undefined : byteOrderedSet(targetTags), targets: targets as "leaves" | "all", boundary: boundary as RecordedGroup, mode: mode as "direct" | "transitive", - edgeKinds, + edgeKinds: dependencyKindSet(edgeKinds), }; } diff --git a/src/core/session-name.ts b/src/core/session-name.ts new file mode 100644 index 00000000..833f366a --- /dev/null +++ b/src/core/session-name.ts @@ -0,0 +1,37 @@ +// Session names (SPEC 10.1). +// +// Pure core, and deliberately free of imports: the CLI's argument parser +// (cli/args.ts) judges a session name's form on every invocation naming +// one — SPEC 12.0 places "a session name outside the form of 10.1" in the +// syntax class, reported without loading configuration — so this rule must +// not pull the review model (core/review.ts, which reaches the TypeScript +// compiler through core/config.ts) into the parser's load. + +/** + * SPEC 10.1: a session name consists of one or more characters from `A–Z`, + * `a–z`, `0–9`, `.`, `_`, and `-`, and does not begin with `.`. + */ +export function isValidSessionName(name: string): boolean { + return /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/.test(name); +} + +/** + * The usage-error diagnostic for an invalid session name, or null for a + * valid one (SPEC 10.1 → 12.0: any other name is a usage error). + */ +export function sessionNameProblem(name: string): string | null { + if (isValidSessionName(name)) { + return null; + } + const reason = + name.length === 0 + ? "it is empty" + : name.startsWith(".") + ? "it begins with `.`" + : "it contains a character outside `A-Z a-z 0-9 . _ -`"; + return ( + `invalid session name ${JSON.stringify(name)}: ${reason} — a session ` + + `name must consist of one or more characters from A-Z, a-z, 0-9, ` + + `\`.\`, \`_\`, and \`-\`, and must not begin with \`.\` (SPEC 10.1)` + ); +} diff --git a/src/core/source-text.ts b/src/core/source-text.ts index 2e71a534..9277ae29 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 }); @@ -70,6 +72,21 @@ export function firstInvalidUtf8(bytes: Uint8Array): number { return -1; } +/** + * Whether `bytes` begin with the UTF-8 byte-order mark, `EF BB BF` — the + * leading bytes a source file (SPEC 1.6, 14.20) and the configuration file + * (SPEC 7, 14.14) must not begin with. Judged on the bytes, never on + * decoded text: a decoder may strip the mark silently. + */ +export function beginsWithByteOrderMark(bytes: Uint8Array): boolean { + return ( + bytes.length >= 3 && + bytes[0] === 0xef && + bytes[1] === 0xbb && + bytes[2] === 0xbf + ); +} + /** The decoded text, or the file's 14.20 finding (SPEC 1.6). */ export type DecodedSource = | { readonly ok: true; readonly text: string } @@ -82,42 +99,38 @@ 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. - if ( - bytes.length >= 3 && - bytes[0] === 0xef && - bytes[1] === 0xbb && - bytes[2] === 0xbf - ) { + if (beginsWithByteOrderMark(bytes)) { 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)", - }, + // SPEC 14: one zero-length range at the failure's offset, 0 for a + // byte-order mark. + [{ file: path, range: { start: 0, end: 0 } }], + ), }; } 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)`, - }, + // SPEC 14: one zero-length range at the byte length of the longest + // well-formed UTF-8 prefix — the first byte of the first ill-formed + // sequence, malformed or truncated by the file's end. + [{ file: path, range: { start: invalidAt, end: invalidAt } }], + ), }; } return { ok: true, text: utf8Decoder.decode(bytes) }; diff --git a/src/core/spec-references.ts b/src/core/spec-references.ts index 098a4c6c..184c3118 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -13,31 +13,43 @@ // recorded import targets is the graph's (SPEC 2.1, 5.3 → 14.9). // // Masking (SPEC 14): a reference whose chain is rooted at a binding -// introduced by an *invalid* import (or by colliding imports) is masked — -// the import's own 14.15 already accounts for it, and its target is -// undetectable — while a chain rooted at an identifier no import binds is -// 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). - -import ts from "typescript"; +// introduced by an *invalid* import is masked — the import's own 14.15 +// already accounts for it, and its target is undetectable — while a chain +// rooted at an identifier no import binds is 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); +// 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. So is that of a chain rooted at an identifier +// an import binds that another import, or a declaration an export +// statement holds, also binds (SPEC 2.4): it names no target, beside the +// collision's 14.15. + +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, + SpecExportedBinding, SpecImportStatement, SpecSection, } from "./mdx.js"; +import { deriveContentExpression } from "./mdx-acorn.js"; import type { ClassifiedChain, ClassifiedReference, ClassifiedString, TextSpan, } from "./references.js"; -import { classifyReference, parseExpressionText } from "./references.js"; +import { classifyReferenceText, stringLiteralValue } from "./references.js"; // --------------------------------------------------------------------------- // The import model (SPEC 2.1) @@ -49,7 +61,10 @@ export interface SpecImport { readonly statement: SpecImportStatement; /** The single default binding, when the form permits one (SPEC 2.1). */ readonly bindingName: string | null; - /** The module specifier's cooked value. */ + /** + * The module specifier's value: the characters between its delimiters + * exactly as spelled, no escape sequence interpreted (SPEC 2.4, 2.1). + */ readonly specifier: string; /** The quote character of the specifier literal (SPEC 6.5 rewrites). */ readonly specifierQuote: '"' | "'"; @@ -58,10 +73,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; } @@ -75,11 +111,33 @@ export type SpecImportBinding = } | { /** - * A binding of an invalid import, or an identifier bound by more - * than one import: the 14.15 accounts for it, and references rooted - * here are masked (SPEC 14). + * 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 (one not colliding): the 14.15 + * accounts for it, and references rooted here are masked (SPEC 14). */ readonly kind: "poisoned"; + } + | { + /** + * SPEC 2.1, 2.4: an identifier an import binds that another import, + * or a declaration an export statement holds, also binds — the + * language names no single binding, so a chain rooted here names no + * target: it reports as unresolved (14.5/14.6) beside the + * collision's 14.15, whatever the imports' own validity, recording + * no edge and no occurrence (5.7). + */ + readonly kind: "colliding"; + /** The colliding binders, as messages name them ("2 imports", …). */ + readonly binders: string; }; /** The analyzed imports of one xspec source file (SPEC 2.1). */ @@ -120,6 +178,190 @@ export function resolveImportSpecifier( return segments.join("/"); } +/** + * SPEC 2.1: `resolveImportSpecifier` over exact path bytes, for an + * importing file whose own path has no plain string form (SPEC 14.19): + * the importer's directory bytes joined with the specifier's segments — + * the specifier itself is decoded source text, so its segments enter as + * their UTF-8 bytes. Returns null when the specifier climbs out of the + * workspace root. For a valid-UTF-8 importer this computes exactly what + * the string form computes. + */ +export function resolveImportSpecifierBytes( + importerBytes: Uint8Array, + specifier: string, +): Uint8Array | null { + const SLASH = 0x2f; + const segments: Uint8Array[] = []; + let start = 0; + for (let index = 0; index <= importerBytes.length; index += 1) { + if (index === importerBytes.length || importerBytes[index] === SLASH) { + segments.push(importerBytes.subarray(start, index)); + start = index + 1; + } + } + segments.pop(); // the importing file's own name — resolve from its directory + const encoder = new TextEncoder(); + for (const part of specifier.split("/")) { + if (part === "" || part === ".") { + continue; + } + if (part === "..") { + if (segments.length === 0) { + return null; // resolves outside the workspace root + } + segments.pop(); + continue; + } + segments.push(encoder.encode(part)); + } + let length = 0; + for (const segment of segments) length += segment.length; + const joined = new Uint8Array( + length + (segments.length > 0 ? segments.length - 1 : 0), + ); + let offset = 0; + for (let index = 0; index < segments.length; index += 1) { + if (index > 0) { + joined[offset] = SLASH; + offset += 1; + } + joined.set(segments[index], offset); + offset += segments[index].length; + } + return joined; +} + +/** + * The outcome of designating the file an in-form import specifier names + * (SPEC 2.1: a relative `./`/`../` specifier ending `.xspec`, resolved + * against the importing file's directory; `DIR/NAME.xspec` designates + * `DIR/NAME.mdx`). Membership is over the entire discovered spec-source + * set — an import designating a discovered member whose path is invalid + * (SPEC 14.19) is valid (no 14.15), while the member's identities are all + * undefined (SPEC 11.2), so references through it never resolve. + */ +export type SpecifierDesignation = + | { readonly kind: "outside-root" } + | { + /** Not a discovered spec-group member; `designated` is its + * deterministic display spelling for the 14.15 message. */ + readonly kind: "undiscovered"; + readonly designated: string; + } + | { + /** A member with defined identities: a valid source path. */ + readonly kind: "defined-member"; + readonly path: string; + } + | { + /** A 14.19 member: import valid, every identity undefined (11.2). */ + readonly kind: "undefined-member"; + readonly file: PathText; + }; + +/** + * Designate the member an in-form specifier names from one importing + * file. Callers check the specifier's form first (relative, `.xspec`); + * the designator owns resolution and membership. + */ +export type DesignateSpecifier = (specifier: string) => SpecifierDesignation; + +const XSPEC_SUFFIX_LENGTH = 6; // ".xspec" +const MDX_SUFFIX_BYTES = [0x2e, 0x6d, 0x64, 0x78]; // ".mdx" + +/** One discovered spec source's designation record, either path form. */ +interface SpecMemberRecord { + readonly path: PathText; + readonly defined: boolean; +} + +/** + * The discovered spec-source domain import designation consults (SPEC 2.1, + * 7.1): every discovered spec source, valid or invalid-path (14.19), + * indexed for the two resolution spaces — string space for importing + * files with a plain string path, byte space for importers whose own path + * has none (only reachable inside 14.19 analyses). + */ +export class SpecSourceDomain { + private readonly byString = new Map<string, SpecMemberRecord>(); + private readonly byKey = new Map<string, SpecMemberRecord>(); + + constructor( + definedPaths: Iterable<string>, + invalidSpecPaths: Iterable<{ + readonly path: PathText; + readonly bytes: Uint8Array; + }>, + ) { + for (const path of definedPaths) { + const record: SpecMemberRecord = { path, defined: true }; + this.byString.set(path, record); + this.byKey.set(pathTextKey(path), record); + } + for (const source of invalidSpecPaths) { + const record: SpecMemberRecord = { path: source.path, defined: false }; + if (typeof source.path === "string") { + this.byString.set(source.path, record); + } + this.byKey.set(pathTextKey(source.path), record); + } + } + + private static memberDesignation( + record: SpecMemberRecord | undefined, + display: () => string, + ): SpecifierDesignation { + if (record === undefined) { + return { kind: "undiscovered", designated: display() }; + } + return record.defined && typeof record.path === "string" + ? { kind: "defined-member", path: record.path } + : { kind: "undefined-member", file: record.path }; + } + + /** The designator for an importing file with a plain string path. */ + designatorFor(importerPath: string): DesignateSpecifier { + return (specifier) => { + const resolved = resolveImportSpecifier(importerPath, specifier); + if (resolved === null) { + return { kind: "outside-root" }; + } + // SPEC 2.1: `DIR/NAME.xspec` designates `DIR/NAME.mdx`. + const designated = resolved.slice(0, -XSPEC_SUFFIX_LENGTH) + ".mdx"; + return SpecSourceDomain.memberDesignation( + this.byString.get(designated), + () => designated, + ); + }; + } + + /** + * The designator for an importing file whose own path has no plain + * string form (SPEC 14.19): resolution and membership over exact bytes. + */ + designatorForBytes(importerBytes: Uint8Array): DesignateSpecifier { + return (specifier) => { + const resolved = resolveImportSpecifierBytes(importerBytes, specifier); + if (resolved === null) { + return { kind: "outside-root" }; + } + // SPEC 2.1: `DIR/NAME.xspec` designates `DIR/NAME.mdx`. + const designated = new Uint8Array( + resolved.length - XSPEC_SUFFIX_LENGTH + MDX_SUFFIX_BYTES.length, + ); + designated.set( + resolved.subarray(0, resolved.length - XSPEC_SUFFIX_LENGTH), + ); + designated.set(MDX_SUFFIX_BYTES, resolved.length - XSPEC_SUFFIX_LENGTH); + return SpecSourceDomain.memberDesignation( + this.byKey.get(pathTextKey(pathTextOf(designated))), + () => renderPathText(pathTextOf(designated)), + ); + }; + } +} + /** SPEC 2.1: the compiler-provided names an import may never bind. */ const COMPILER_PROVIDED_NAMES: ReadonlySet<string> = new Set([ "S", @@ -129,15 +371,21 @@ const COMPILER_PROVIDED_NAMES: ReadonlySet<string> = new Set([ const XSPEC_SUFFIX = ".xspec"; -/** Translates analyzer spans of one re-parsed slice into byte ranges. */ +/** + * Translates analyzer spans of one re-parsed slice into byte ranges: the + * slice begins at `sliceStartByte` in the document and at `parsedStart` in + * the text its spans index (0 for the slice itself; a text parsed by + * `classifyReferenceText` begins at its `textStart`). + */ class SpanTranslator { private readonly baseIndex: number; constructor( private readonly offsets: Utf8Offsets, sliceStartByte: number, + parsedStart = 0, ) { - this.baseIndex = offsets.indexOfByteOffset(sliceStartByte); + this.baseIndex = offsets.indexOfByteOffset(sliceStartByte) - parsedStart; } range(span: TextSpan): ByteRange { @@ -146,10 +394,22 @@ class SpanTranslator { end: this.offsets.byteOffset(this.baseIndex + span.end), }; } + + /** + * The translator of the part of this slice beginning at its offset + * `start`, re-parsed in a text where it begins at `parsedStart`. + */ + part(start: number, parsedStart: number): SpanTranslator { + return new SpanTranslator( + this.offsets, + this.offsets.byteOffset(this.baseIndex + start), + parsedStart, + ); + } } /** 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 +432,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<string>, + designate: DesignateSpecifier, ): SpecImportModel { const imports: SpecImport[] = []; const bindings = new Map<string, SpecImportBinding>(); const findings: Finding[] = []; - /** name → whether any import already bound it (duplicate rule). */ - const seenNames = new Set<string>(); + /** name → the distinct import declarations binding it (duplicate rule). */ + const declarationsByName = new Map<string, SpecImportStatement[]>(); for (const block of document.esmBlocks) { for (const statement of block.imports) { @@ -236,7 +500,11 @@ export function analyzeSpecImports( "xspec internal error: import with a non-literal specifier", ); } - const specifier = specifierLiteral.text; + // SPEC 2.4: the specifier is read as spelled, no escape sequence + // interpreted — one spelled with an escape designates the path its + // characters spell, the escape's included, never the path the + // interpreted value would name. + const specifier = stringLiteralValue(specifierLiteral, parsed.sourceFile); const relative = specifier.startsWith("./") || specifier.startsWith("../"); if (!relative) { @@ -252,24 +520,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 +564,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 +615,96 @@ 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, 2.4 → 14.15: an identifier bound by more than one import, + // or by an import and a declaration an export statement holds, is one + // condition the declarations jointly violate — ONE finding per collided + // identifier, locating every colliding declaration (SPEC 14: no + // representative is chosen), each export statement's by the construct + // binding the name. A collided identifier — bound by another import or + // by an export statement's declaration alike — roots no resolving chain + // (SPEC 2.4): its chains report as unresolved (14.5, 14.6) beside the + // collision, never masked by it. + const exportedByName = new Map<string, SpecExportedBinding[]>(); + for (const block of document.esmBlocks) { + for (const held of block.exportedBindings) { + const entries = exportedByName.get(held.name); + if (entries === undefined) { + exportedByName.set(held.name, [held]); + } else if ( + !entries.some( + (entry) => + entry.range.start === held.range.start && + entry.range.end === held.range.end, + ) + ) { + entries.push(held); + } + } + } + for (const [name, declared] of declarationsByName) { + const exported = exportedByName.get(name) ?? []; + if (declared.length < 2 && exported.length === 0) continue; + findings.push( + locatedFinding( + 15, + exported.length === 0 + ? `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, and the language names no single binding, so no ` + + `chain rooted at it resolves; rename all but one binding ` + + `(SPEC 2.1, 2.4, 14.15)` + : `invalid import: the identifier ${JSON.stringify(name)} is ` + + `bound by ` + + (declared.length === 1 + ? `an import` + : `${String(declared.length)} imports`) + + ` and by ` + + (exported.length === 1 + ? `a declaration an export statement holds` + : `${String(exported.length)} declarations export ` + + `statements hold`) + + ` — the language names no single binding, so no chain rooted ` + + `at it resolves; remove the export statement or rename the ` + + `import binding (SPEC 2.1, 2.4, 14.15)`, + [ + ...declared.map((decl) => ({ + file: document.file, + range: decl.range, + })), + ...exported.map((held) => ({ + file: document.file, + range: held.range, + })), + ], + ), + ); + // SPEC 2.4: whatever the imports' own validity, the identifier roots + // no chain at any binding. + bindings.set(name, { + kind: "colliding", + binders: + (declared.length === 1 + ? `an import` + : `${String(declared.length)} imports`) + + (exported.length === 0 + ? `` + : exported.length === 1 + ? ` and a declaration an export statement holds` + : ` and ${String(exported.length)} declarations export ` + + `statements hold`), + }); + } + return { imports, bindings, @@ -353,10 +714,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). */ @@ -459,8 +820,10 @@ export interface DependencyReference { /** * One `{text(...)}` embedding's analysis (SPEC 2.3). `reference` is null - * when the embedding yields none: a 14.8 finding accounts for it, or its - * chain root is a poisoned import binding (masked, SPEC 14). + * when the embedding yields none: a 14.8 finding accounts for it, its + * chain root is an invalid import's poisoned binding (masked, SPEC 14), + * or its chain names no target decidably per file, a 14.6 reported here + * (SPEC 14.19, 11.2, 2.4). */ export interface EmbeddingReference { readonly embedding: SpecEmbedding; @@ -481,8 +844,43 @@ export interface SpecReferenceModel { type ResolvedReference = | { readonly outcome: "reference"; readonly reference: SpecReference } | { readonly outcome: "finding"; readonly finding: Finding } + | { + /** + * A static chain that never resolves, decidably per file — rooted + * at a valid import of a member whose path is invalid (SPEC 14.19: + * every identity of that file is undefined, 11.2), or at an + * identifier a declaration an export statement holds also binds + * (SPEC 2.4: the language names no single binding). The caller + * reports its 14.5/14.6 with the span rules of its construct kind. + */ + readonly outcome: "unresolved"; + /** What the reference names, for messages ("to …", "rooted at …"). */ + readonly subject: string; + /** Why it names no node, for messages. */ + readonly reason: 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 @@ -499,12 +897,16 @@ export function analyzeSpecReferences( const analyzer = new ReferenceAnalyzer(document, importModel.bindings); const dependencies: DependencyReference[] = []; for (const section of document.sections) { - const dependency = section.dependency; - if (dependency === null) { - continue; - } - for (const reference of analyzer.analyzeDependencyValue(dependency)) { - dependencies.push({ section, reference }); + // SPEC 11.2 "Resolution": every braced `d` spelling of the section, in + // tag order — each spelling of a repeated prop included (14.17) — is + // analyzed exactly as a single `d` is, so each of its entries records + // an occurrence, reports 14.5 through the graph, or reports 14.8 on its + // own; the graph's duplicate-target collapse (SPEC 2.2, 5.2) applies + // across spellings as within one array literal. + for (const dependency of section.dependencies) { + for (const reference of analyzer.analyzeDependencyValue(dependency)) { + dependencies.push({ section, reference }); + } } } const embeddings: EmbeddingReference[] = []; @@ -531,31 +933,33 @@ 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 }]), + ); } /** * SPEC 2.2: a `d` value is a single reference or an array literal of * references, external and local forms mixed freely; `d={[]}` declares * no dependencies. Any other value is a dynamic argument (SPEC 2.7 → - * 14.8), as is any dynamic element (SPEC 2.4). + * 14.8), as is any dynamic entry (SPEC 2.4). The value is the expression + * MDX 3 derives from the braces' content (SPEC 14.20) — an array literal + * exactly where that expression is one, unparenthesized — and each + * reference, the whole value or one entry, is classified by its own + * characters, first token through last, which its occurrence or finding + * spans (SPEC 14, 5.7). */ analyzeDependencyValue(dependency: { readonly expressionText: string; readonly expressionRange: ByteRange; readonly attributeRange: ByteRange; }): SpecReference[] { - const { sourceFile, expression } = parseExpressionText( - dependency.expressionText, - ); - if (expression === null) { - // Not a single expression at all (an object literal parses as a - // block, for instance): a dynamic argument (SPEC 2.7 → 14.8). + const text = dependency.expressionText; + const value = deriveContentExpression(text); + if (value === null) { + // A parsed document's braced `d` always derives one expression + // (SPEC 14.20); were it not to, the value would be no static + // reference: a dynamic argument (SPEC 2.7 → 14.8). this.addFinding( dependency.attributeRange, `invalid argument: the d value is not a static reference or an ` + @@ -564,40 +968,42 @@ class ReferenceAnalyzer { ); return []; } - const translate = new SpanTranslator( + const valueTranslate = new SpanTranslator( this.document.offsets, dependency.expressionRange.start, ); + const entries = value.type === "ArrayExpression" ? value.elements : [value]; + if (entries.includes(null)) { + // SPEC 14: the elisions of one array literal — holes among its + // entries, spelling no expression — are one finding, however many, + // located by the whole array literal, brackets included (SPEC 2.2 → + // 14.8: a hole is no reference). + this.addFinding( + valueTranslate.range(value), + `invalid argument: the d array contains an elided element — ` + + `each element must be a static reference (SPEC 2.2, 2.4, 14.8)`, + ); + } const references: SpecReference[] = []; - const elements = ts.isArrayLiteralExpression(expression) - ? expression.elements - : [expression]; - for (const element of elements) { - if (ts.isOmittedExpression(element)) { - // An array hole is no reference (SPEC 2.2 → 14.8). - this.addFinding( - translate.range({ - start: expression.getStart(sourceFile), - end: expression.getEnd(), - }), - `invalid argument: the d array contains an elided element — ` + - `each element must be a static reference (SPEC 2.2, 2.4, 14.8)`, - ); + for (const entry of entries) { + if (entry === null) { continue; } - if (ts.isSpreadElement(element)) { + if (entry.type === "SpreadElement") { + // SPEC 14: a spread entry by its own characters, `...` included. this.addFinding( - translate.range({ - start: element.getStart(sourceFile), - end: element.getEnd(), - }), + valueTranslate.range(entry), `invalid argument: a spread element is not a static reference ` + `(SPEC 2.2, 2.4, 14.8)`, ); continue; } + const { classified, textStart } = classifyReferenceText( + text.slice(entry.start, entry.end), + ); + const translate = valueTranslate.part(entry.start, textStart); const resolved = this.resolveClassified( - classifyReference(element, sourceFile), + classified, translate, `each d reference must be a static string literal naming a ` + `same-file ID or a static property chain rooted at an imported ` + @@ -607,6 +1013,23 @@ class ReferenceAnalyzer { references.push(resolved.reference); } else if (resolved.outcome === "finding") { this.findings.push(resolved.finding); + } else if (resolved.outcome === "unresolved") { + // SPEC 14.5: a d reference that does not resolve, decidably per + // file (SPEC 14.19, 11.2, 2.4). The finding spans the reference's + // own expression (SPEC 14). + this.findings.push( + locatedFinding( + 5, + `unknown dependency: the d reference ${resolved.subject} ` + + `does not resolve — ${resolved.reason} (SPEC 2.2, 14.5)`, + [ + { + file: this.document.file, + range: translate.range(resolved.span), + }, + ], + ), + ); } } return references; @@ -614,19 +1037,21 @@ class ReferenceAnalyzer { /** * SPEC 2.3, 2.4: an embedding is a `text(...)` call with exactly one - * argument, following the same external/local duality as `d`. + * argument, following the same external/local duality as `d`. The call + * is the expression MDX 3 derives from the container's content (SPEC + * 14.20), so its arguments are the ones ECMAScript reads (`text(<b/>, + * "a")` has two), and the one argument is classified by its own + * characters. */ analyzeEmbedding(embedding: SpecEmbedding): SpecReference | null { - const { sourceFile, expression } = parseExpressionText( - embedding.expressionText, - ); + const text = embedding.expressionText; + const expression = deriveContentExpression(text); const call = expression !== null && - ts.isCallExpression(expression) && - expression.questionDotToken === undefined && - expression.typeArguments === undefined && - ts.isIdentifier(expression.expression) && - expression.expression.text === "text" + expression.type === "CallExpression" && + !expression.optional && + expression.callee.type === "Identifier" && + expression.callee.name === "text" ? expression : null; if (call === null) { @@ -651,52 +1076,82 @@ class ReferenceAnalyzer { return null; } const argument = call.arguments[0]; - const translate = new SpanTranslator( - this.document.offsets, - embedding.expressionRange.start, - ); - if (ts.isSpreadElement(argument)) { + if (argument.type === "SpreadElement") { + // 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)`, ); return null; } + const { classified, textStart } = classifyReferenceText( + text.slice(argument.start, argument.end), + ); + const translate = new SpanTranslator( + this.document.offsets, + embedding.expressionRange.start, + ).part(argument.start, textStart); const resolved = this.resolveClassified( - classifyReference(argument, sourceFile), + classified, translate, `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 === "unresolved") { + // SPEC 14.6: a text(...) reference that does not resolve, decidably + // per file (SPEC 14.19, 11.2, 2.4). 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 ${resolved.subject} ` + + `does not resolve — ${resolved.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 +1166,48 @@ 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: "unresolved", + subject: `to ${describeUndefinedTarget( + binding.modulePath, + classified.segments.map((segment) => segment.name), + )}`, + reason: UNDEFINED_TARGET_REASON, + span: classified.span, + }; + } + if (binding.kind === "colliding") { + // SPEC 2.4: the language names no single binding, so the chain + // names no target — no edge, no occurrence (5.7) — and reports as + // unresolved beside the collision's 14.15, decidably per file. + const root = JSON.stringify(classified.rootName); + return { + outcome: "unresolved", + subject: `rooted at ${root}`, + reason: + `${root} is bound by ${binding.binders} — the language names no ` + + `single binding, so the chain names no target (SPEC 2.4, 2.1); ` + + `resolve the collision`, + span: classified.span, }; } if (binding.kind === "poisoned") { @@ -777,12 +1265,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/text.ts b/src/core/text.ts index e827d849..ce73ae95 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -40,37 +40,149 @@ export function isWhitespaceOnly(text: string): boolean { return true; } -/** True when `text` contains at least one SPEC 1.4 whitespace character. */ -export function containsWhitespace(text: string): boolean { - for (let index = 0; index < text.length; index += 1) { - if (isWhitespaceCodePoint(text.charCodeAt(index))) { - return true; +/** + * SPEC 1.4: the five forbidden segment (and tag) names, judged by + * `segmentViolation` below. + */ +const FORBIDDEN_SEGMENT_NAMES: ReadonlySet<string> = new Set([ + "$", + "__proto__", + "prototype", + "constructor", + "then", +]); + +/** + * SPEC 1.4: the quote, escape, and character-reference characters — `"`, + * `'`, `\`, and `&` — which no segment or tag contains, so that every + * segment is spelled verbatim in every form (2.4, 2.7, 6.4). Keyed by code + * unit; each value names the character for diagnostics. + */ +const VERBATIM_BREAKING_CHARACTERS: ReadonlyMap<number, string> = new Map([ + [0x22, 'a double quote (")'], + [0x27, "a single quote (')"], + [0x5c, "a backslash (\\)"], + [0x26, "an ampersand (&)"], +]); + +/** SPEC 1.4: U+FFFD (REPLACEMENT CHARACTER), which no argument value carries (12.0). */ +const REPLACEMENT_CHARACTER = 0xfffd; + +/** + * Which rule of SPEC 1.4 a value breaks as an ID segment or a tag — one + * variant per rule of 1.4's list — as data, so each validating site keeps + * its own message frame around `describeSegmentViolation`'s wording. + */ +export type SegmentViolation = + | { readonly rule: "empty" } + | { readonly rule: "dot" } + | { readonly rule: "hash" } + | { readonly rule: "whitespace" } + | { readonly rule: "control" } + | { readonly rule: "verbatim"; readonly character: string } + | { readonly rule: "replacement" } + | { readonly rule: "forbidden-name" }; + +/** + * The one SPEC 1.4 validator: the first rule `value` breaks as an ID + * segment (`kind` `"segment"`) or a tag (`"tag"`, which MAY contain `"."`, + * 1.4's last paragraph), or null when it satisfies every rule. A segment: + * is non-empty; contains no `"."`, no `"#"`, no whitespace or control + * character (this module's exact classes), none of `"`, `'`, `\`, `&`, and + * no U+FFFD; and is none of the forbidden names. Every site that judges a + * segment or a tag goes through here — MDX `id`/`tags` props (14.4), + * rename and move's `<new-id>` (`refused-invalid-id`, 14), `occurrences + * --to` spellings (11.3), `query nodes --tag` spellings (11.1), and journal + * entries (14.13). + */ +export function segmentViolation( + value: string, + kind: "segment" | "tag", +): SegmentViolation | null { + if (value.length === 0) { + return { rule: "empty" }; + } + // A single code-unit scan is exact: every character the rules name is + // one UTF-16 code unit, and no surrogate half is any of them. + let violation: SegmentViolation | null = null; + for (let index = 0; index < value.length && violation === null; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x2e) { + if (kind === "segment") violation = { rule: "dot" }; + } else if (code === 0x23) { + violation = { rule: "hash" }; + } else if (isWhitespaceCodePoint(code)) { + violation = { rule: "whitespace" }; + } else if (isControlCodePoint(code)) { + violation = { rule: "control" }; + } else if (code === REPLACEMENT_CHARACTER) { + violation = { rule: "replacement" }; + } else { + const character = VERBATIM_BREAKING_CHARACTERS.get(code); + if (character !== undefined) { + violation = { rule: "verbatim", character }; + } } } - return false; + if (violation !== null) { + return violation; + } + return FORBIDDEN_SEGMENT_NAMES.has(value) ? { rule: "forbidden-name" } : null; } -/** True when `text` contains at least one SPEC 1.4 control character. */ -export function containsControl(text: string): boolean { - for (let index = 0; index < text.length; index += 1) { - if (isControlCodePoint(text.charCodeAt(index))) { - return true; +/** + * Every segment of the dotted ID `id` (SPEC 1.3: segments joined by `"."`) + * that breaks SPEC 1.4, in order, with the first rule each breaks; empty + * exactly when `id` is well-formed. The split makes 1.4's no-`"."` rule + * structural: a doubled, leading, or trailing `"."` yields an empty + * segment. + */ +export function idSegmentViolations( + id: string, +): { readonly segment: string; readonly violation: SegmentViolation }[] { + const violations: { + readonly segment: string; + readonly violation: SegmentViolation; + }[] = []; + for (const segment of id.split(".")) { + const violation = segmentViolation(segment, "segment"); + if (violation !== null) { + violations.push({ segment, violation }); } } - return false; + return violations; } /** - * SPEC 1.4: the five forbidden segment (and tag) names. Shared by MDX prop - * validation (mdx.ts) and journal-entry validation (journal.ts). + * The shared wording of a SPEC 1.4 violation: a predicate completing "the + * segment …" or "the tag …" (e.g. `contains "#"`). */ -export const FORBIDDEN_SEGMENT_NAMES: ReadonlySet<string> = new Set([ - "$", - "__proto__", - "prototype", - "constructor", - "then", -]); +export function describeSegmentViolation(violation: SegmentViolation): string { + switch (violation.rule) { + case "empty": + return "is empty"; + case "dot": + return 'contains "."'; + case "hash": + return 'contains "#"'; + case "whitespace": + return "contains whitespace"; + case "control": + return "contains a control character"; + case "verbatim": + return ( + `contains ${violation.character}, one of the quote, escape, and ` + + `character-reference characters` + ); + case "replacement": + return "contains U+FFFD (REPLACEMENT CHARACTER)"; + case "forbidden-name": + return ( + 'is one of the forbidden names ("$", "__proto__", "prototype", ' + + '"constructor", "then")' + ); + } +} /** A SPEC 3 line terminator: CRLF (one terminator), lone LF, or lone CR. */ export type LineTerminator = "\r\n" | "\n" | "\r"; diff --git a/src/core/ts-module.ts b/src/core/ts-module.ts new file mode 100644 index 00000000..3e3e83ea --- /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/core/ts-syntax-failure.ts b/src/core/ts-syntax-failure.ts new file mode 100644 index 00000000..6fa6d9db --- /dev/null +++ b/src/core/ts-syntax-failure.ts @@ -0,0 +1,131 @@ +// Where a code source fails to be well-formed TypeScript (SPEC 14's +// location rule for 14.20). +// +// SPEC 14.20: a code source is well-formed exactly when TypeScript's +// scanning and parsing of it report no syntax error. SPEC 14 locates the +// failure at "the byte length of the longest whole-character prefix of the +// file with which some well-formed file begins". TypeScript reports every +// syntax error of a file at once, the earliest first in position, so the +// failure is the earliest diagnostic's — except where TypeScript places a +// diagnostic away from the character that made the prefix unviable: an +// unterminated regular expression literal is reported over the literal, +// the failure being where it stopped (a line terminator); an element +// without a closing tag is reported at its opening tag, the failure being +// where the closing tag that ended it departs from its name; and a +// diagnostic at or past +// the end-of-file token is the end's, the whole file a viable prefix. From +// there the prefix may run on into the offending token (`extendIntoToken`): +// TypeScript flags `010` at its start, while the prefix `0` begins a +// well-formed file and `01` none. + +import ts from "./ts-module.js"; +import type * as tst from "typescript"; +import { closingTagDivergence, extendIntoToken } from "./viable-prefix.js"; + +/** A text's parse: its syntactic diagnostics, beside its tree. */ +export interface SyntaxDiagnosis { + readonly sourceFile: tst.SourceFile; + readonly diagnostics: readonly tst.Diagnostic[]; +} + +/** TypeScript's token vocabulary: its punctuators and keywords. */ +const TYPESCRIPT_VOCABULARY: readonly string[] = (() => { + const spellings: string[] = []; + const add = (first: tst.SyntaxKind, last: tst.SyntaxKind): void => { + for (let kind = first; kind <= last; kind += 1) { + const spelling = ts.tokenToString(kind); + if (spelling !== undefined) spellings.push(spelling); + } + }; + add(ts.SyntaxKind.FirstPunctuation, ts.SyntaxKind.LastPunctuation); + add(ts.SyntaxKind.FirstKeyword, ts.SyntaxKind.LastKeyword); + return spellings; +})(); + +/** "Unterminated regular expression literal." — reported over the literal. */ +const UNTERMINATED_REGULAR_EXPRESSION = 1161; +/** "JSX element '{0}' has no corresponding closing tag." — at its opening. */ +const ELEMENT_WITHOUT_CLOSING_TAG = 17008; +/** "Merge conflict marker encountered." — trivia, yet never completed. */ +const CONFLICT_MARKER = 1185; + +/** + * A JSX element reported without a closing tag at its opening tag's name + * (`start`): TypeScript closes it where its children end, at the closing + * tag its parent's name took — the failure is where that tag's name + * departs from the element's (`<a><b></` is viable, `<a><b></a` is not), + * or the end when the file ends first. + */ +function unclosedElementPoint( + sourceFile: tst.SourceFile, + start: number, +): number { + let point = start; + const visit = (node: tst.Node): void => { + if ( + ts.isJsxElement(node) && + node.openingElement.tagName.getStart(sourceFile) === start + ) { + point = closingTagDivergence( + sourceFile.text, + node.children.end, + node.openingElement.tagName.getText(sourceFile), + ); + return; + } + if (node.pos <= start && start < node.end) ts.forEachChild(node, visit); + }; + visit(sourceFile); + return point; +} + +/** Where a diagnosed text fails, or null when it is well-formed. */ +function failurePoint(text: string, diagnosis: SyntaxDiagnosis): number | null { + const { sourceFile, diagnostics } = diagnosis; + if (diagnostics.length === 0) return null; + const at = (diagnostic: tst.Diagnostic): number => { + const start = diagnostic.start ?? 0; + switch (diagnostic.code) { + case UNTERMINATED_REGULAR_EXPRESSION: + return start + (diagnostic.length ?? 0); + case ELEMENT_WITHOUT_CLOSING_TAG: + return unclosedElementPoint(sourceFile, start); + default: + return start; + } + }; + let point = Number.POSITIVE_INFINITY; + let conflict = false; + for (const diagnostic of diagnostics) { + const here = at(diagnostic); + if (here < point) { + point = here; + conflict = false; + } + if (here === point && diagnostic.code === CONFLICT_MARKER) conflict = true; + } + // A failure in the trivia before the end is the end's — unless it is a + // conflict marker, which no continuation completes. + if (point >= sourceFile.endOfFileToken.pos && !conflict) return text.length; + return Math.min(point, text.length); +} + +/** + * SPEC 14, 14.20: the UTF-16 length of the longest prefix of `text` — a + * code source TypeScript's parse rejects — with which some well-formed + * file begins. `diagnose` parses a text under the grammar the file name + * selects. + */ +export function tsSyntaxFailureOffset( + text: string, + diagnose: (text: string) => SyntaxDiagnosis, +): number { + const point = failurePoint(text, diagnose(text)); + if (point === null || point >= text.length) return text.length; + const head = text.slice(0, point); + return extendIntoToken(text, point, TYPESCRIPT_VOCABULARY, (spelling) => { + const probe = head + spelling; + const failure = failurePoint(probe, diagnose(probe)); + return failure === null || failure >= probe.length; + }); +} diff --git a/src/core/viable-prefix.ts b/src/core/viable-prefix.ts new file mode 100644 index 00000000..d55241e3 --- /dev/null +++ b/src/core/viable-prefix.ts @@ -0,0 +1,135 @@ +// SPEC 14's location rule for a syntax failure: the shared step that +// carries a parser's failure position into the offending token. +// +// SPEC 14: an unparseable source (14.20) carries one zero-length range at +// the failure's offset — for a syntax failure, "the byte length of the +// longest whole-character prefix of the file with which some well-formed +// file begins", an offset the grammar alone fixes. A parser reports a +// syntax failure at the start of the first token it cannot accept, but the +// longest such prefix may run on into that token: through every character +// that some token the grammar accepts there begins with. `!` in `a!}` +// begins `!=`, so the prefix ending after `!` is viable and the `}` is the +// failure; TypeScript flags `010` at its start, while `0` is itself a +// literal and `01` begins none TypeScript accepts. The language modules +// (`js-syntax-failure.ts`, `ts-syntax-failure.ts`) supply the parser that +// judges each probe and the language's fixed token vocabulary. + +/** The length of the longest common prefix of `spelling` and `text` at `at`. */ +export function commonPrefixLength( + spelling: string, + text: string, + at: number, +): number { + let length = 0; + while ( + length < spelling.length && + at + length < text.length && + spelling.charCodeAt(length) === text.charCodeAt(at + length) + ) { + length += 1; + } + return length; +} + +/** An identifier's first character (ECMAScript and TypeScript alike). */ +const IDENTIFIER_START = /^[\p{ID_Start}$_]/u; +/** An identifier's characters: the longest run at a position. */ +const IDENTIFIER_RUN = /^[\p{ID_Continue}$\u200C\u200D]+/u; +/** A numeric literal's characters, generously: digits, letters, `_`, `.`. */ +const NUMERIC_RUN = /^\.?[0-9][0-9A-Za-z_.]*/; + +/** + * The spellings the token at `at` could have begun, each probed whole: every + * vocabulary entry — the language's punctuators and reserved and contextual + * words — sharing its first character; for an identifier-like token (a + * private name's `#` included), an identifier extending it, so that a word + * the grammar rejects only as a whole (`class` where an identifier may + * stand) keeps its characters; for a numeric token, each of its own + * prefixes, the literals it begins; a whole token of the classes a `.` or + * `#` alone may begin — a numeric literal (`.0`), a private name (`#x`); + * and, where a numeric literal's separator is reported (TypeScript flags a + * trailing `_` itself), the separator with the digit it must precede. + */ +function candidateSpellings( + text: string, + at: number, + vocabulary: readonly string[], +): string[] { + const rest = text.slice(at); + const first = rest.charAt(0); + const spellings = vocabulary.filter((entry) => entry.charAt(0) === first); + const hash = first === "#" ? 1 : 0; + const word = rest.slice(hash); + if (IDENTIFIER_START.test(word)) { + const run = IDENTIFIER_RUN.exec(word); + if (run !== null) spellings.push(rest.slice(0, hash) + run[0] + "_"); + } + const numeric = NUMERIC_RUN.exec(rest); + if (numeric !== null) { + for (let length = numeric[0].length; length >= 1; length -= 1) { + spellings.push(numeric[0].slice(0, length)); + } + } + if (first === ".") spellings.push(".0"); + if (first === "#") spellings.push("#x"); + if (first === "_") spellings.push("_0"); + return spellings; +} + +/** + * SPEC 14: the end of the longest viable prefix, given a failure at the + * start of the token at `at` — `at` itself, extended by the longest run of + * that token's characters some acceptable spelling begins with. + * `accepts(spelling)` reports whether `text.slice(0, at) + spelling` is a + * viable prefix: its parse fails at its own end, if at all. + */ +export function extendIntoToken( + text: string, + at: number, + vocabulary: readonly string[], + accepts: (spelling: string) => boolean, +): number { + let best = 0; + for (const spelling of candidateSpellings(text, at, vocabulary)) { + const common = commonPrefixLength(spelling, text, at); + if (common > best && accepts(spelling)) best = common; + } + return at + best; +} + +/** + * A closing tag at `at` (`</name>`, whitespace allowed around its parts) + * that cannot close the open element named `expected`: the position where + * its name departs from `expected` — the viable prefix runs through the + * characters they share (`<a></` is viable, `<a></b` is not). A fragment's + * name is empty. + */ +export function closingTagDivergence( + text: string, + at: number, + expected: string, +): number { + let index = at; + const skipSpace = (): void => { + while (index < text.length && /\s/u.test(text.charAt(index))) { + index += 1; + } + }; + if (text.charAt(index) === "<") index += 1; + skipSpace(); + if (text.charAt(index) === "/") index += 1; + let matched = 0; + for (;;) { + skipSpace(); + if ( + matched < expected.length && + index < text.length && + text.charAt(index) === expected.charAt(matched) + ) { + index += 1; + matched += 1; + continue; + } + return index; + } +} diff --git a/src/workspace/anchor.ts b/src/workspace/anchor.ts new file mode 100644 index 00000000..6bea61cf --- /dev/null +++ b/src/workspace/anchor.ts @@ -0,0 +1,201 @@ +// 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). +// +// SPEC 11.6: "The working directory and the workspace root enter this +// spelling as physical directory paths, every symbolic link among their +// components resolved, and the configuration file as its own name under +// the root so spelled." `physicalDirectory` below is that resolution — the +// anchoring resolution of SPEC 14.25, whose examined directories a refused +// read concerns — walked component by component as the filesystem resolves +// a path, so a `--config` value (a filesystem path resolved against the +// working directory, 12.0) names the entry the filesystem finds there and +// is spelled by the physical relation. + +import type { Stats } from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { + isAbsenceFailure, + isFilesystemFailure, +} from "./environment-refusal.js"; + +/** + * 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("/"); +} + +/** + * The separators a path spelling's segments are split on: `/` on every + * platform, and the platform's own separator — a `--config` value is a + * filesystem path (SPEC 12.0), spelled as the platform reads one. + */ +const SEPARATORS: ReadonlySet<string> = new Set(["/", path.sep]); + +/** + * The segments of a path spelling in order, `.` and `..` kept as spelled + * and empty segments (a doubled or trailing separator) dropped. The + * spelling carries no root: callers split an absolute path's root off + * first (`path.parse`). + */ +export function pathSegments(spelling: string): string[] { + const segments: string[] = []; + let segment = ""; + for (const character of spelling) { + if (SEPARATORS.has(character)) { + if (segment !== "") segments.push(segment); + segment = ""; + } else { + segment += character; + } + } + if (segment !== "") segments.push(segment); + return segments; +} + +/** + * The most symbolic links one physical resolution follows before it meets + * the loop the filesystem itself reports (ELOOP) — Linux's own bound. + */ +const LINK_FOLLOW_LIMIT = 40; + +/** + * A physical resolution's outcome: the directory's physical path, or + * nothing there — a component missing, not a directory, or a symbolic link + * whose target is either, the absence every reader of SPEC 14.25 reads as + * its own section states (never a refused read). + */ +export type PhysicalDirectory = + { readonly found: true; readonly path: string } | { readonly found: false }; + +/** + * SPEC 11.6: resolve the directory `segments` reach from the physical + * directory `start`, every symbolic link among the components resolved — + * as the filesystem resolves a path: each `.` the directory itself, each + * `..` its physical parent (the directory reached so far, links already + * resolved, never the lexical parent of the spelling), and each link + * followed where it stands, a relative target read against the directory + * holding the link and an absolute one from its own root. Component + * spellings are otherwise kept as given: nothing but a symbolic link is + * resolved. + * + * SPEC 14.25: a lookup the environment refuses in a directory the + * resolution examines — the directory's entry, or the link found there — + * throws `refused(examined, cause)`, the examined directory's physical + * path beside the filesystem's failure; a loop of links is the failure the + * filesystem reports for one (ELOOP). Nonexistence is never refused: it is + * the `found: false` outcome. + */ +export async function physicalDirectory( + start: string, + segments: readonly string[], + refused: (examined: string, cause: NodeJS.ErrnoException) => Error, +): Promise<PhysicalDirectory> { + let resolved = start; + const pending = [...segments]; + let followed = 0; + for (;;) { + const segment = pending.shift(); + if (segment === undefined) return { found: true, path: resolved }; + if (segment === ".") continue; + if (segment === "..") { + resolved = path.dirname(resolved); + continue; + } + const examined = resolved; + const candidate = path.join(examined, segment); + const stats: Stats | undefined = await examine( + () => fsp.lstat(candidate), + examined, + refused, + ); + if (stats === undefined) return { found: false }; + if (stats.isSymbolicLink()) { + followed += 1; + if (followed > LINK_FOLLOW_LIMIT) { + throw refused(examined, linkLoopFailure(candidate)); + } + const target = await examine( + () => fsp.readlink(candidate), + examined, + refused, + ); + if (target === undefined) return { found: false }; + let rest = target; + if (path.isAbsolute(target)) { + resolved = path.parse(target).root; + rest = target.slice(resolved.length); + } + pending.unshift(...pathSegments(rest)); + continue; + } + // Only a directory has entries to descend into: a plain file or any + // other object here leaves nothing below it (ENOTDIR, absence). + if (!stats.isDirectory()) return { found: false }; + resolved = candidate; + } +} + +/** + * One read of the physical resolution: absence (`isAbsenceFailure`) is + * `undefined`, a failure the filesystem reports is the refusal of the + * examined directory (SPEC 14.25), and anything else — a defect of the + * product's own — propagates unchanged. + */ +async function examine<T>( + read: () => Promise<T>, + examined: string, + refused: (examined: string, cause: NodeJS.ErrnoException) => Error, +): Promise<T | undefined> { + try { + return await read(); + } catch (error) { + if (isAbsenceFailure(error)) return undefined; + if (isFilesystemFailure(error)) throw refused(examined, error); + throw error; + } +} + +/** + * The failure the filesystem reports for a path whose resolution follows + * more symbolic links than it allows — a loop among them included. + */ +function linkLoopFailure(link: string): NodeJS.ErrnoException { + const failure: NodeJS.ErrnoException = new Error( + "ELOOP: too many symbolic links encountered", + ); + failure.code = "ELOOP"; + failure.syscall = "lstat"; + failure.path = link; + return failure; +} diff --git a/src/workspace/availability.ts b/src/workspace/availability.ts new file mode 100644 index 00000000..b012dcda --- /dev/null +++ b/src/workspace/availability.ts @@ -0,0 +1,186 @@ +// 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 } 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<AvailabilityPreparation> { + const analysis = await analyzeWorkspace(workspace); + if (analysis.configurationErrors.length > 0) { + return { kind: "configuration", errors: analysis.configurationErrors }; + } + return { kind: "answer", analysis }; +} + +/** + * The refresh half of the SPEC 11.2 pre-answer step: on a workspace whose + * current sources fail `build`'s validations — source findings, journal + * errors, and refused writes alike (SPEC 13.3) — do nothing (no store + * read, no journal consequence, no write); on a passing one participate in + * read-time refresh exactly as the reads of 13.3 do. The answer itself + * always comes from `analysis`, never from the store. + */ +export async function finishAvailabilityRefresh( + workspace: LoadedWorkspace, + analysis: WorkspaceAnalysis, +): Promise<void> { + if (analysis.findings.length > 0) { + // SPEC 11.2/13.3: the current sources fail build validation — answer + // from them; no store read, no journal consequence, no write. + return; + } + + // 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 (the record feeds orphan removal alone, which no refresh + // performs), so the record is never consulted and the store stays unread + // until the workspace has passed the complete gate below. + const build = computeBuildOutputs( + workspace.configuration, + analysis.specs, + analysis.graph, + analysis.textModel, + analysis.hashes, + [], + 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; missing, mismatched, or + // unreadable graph data is rewritten as `build` would write it, the + // snapshot file alone. The record is left unchanged in every state + // (SPEC 13.3, 14.23): absent stays absent, readable stays byte-for-byte, + // and recorded state that exists but cannot be read as a record is + // neither read, repaired, nor replaced, no finding reported for it, + // until a successful `build` or a finishing `rename`/`move` regeneration + // replaces the record. + const stored = await loadGraphData(workspace.root); + if (!graphDataMatchesCurrent(stored.bytes, build.graphData)) { + await writeGraphData(workspace.root, 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<number | null> { + try { + const bytes = await fsp.readFile( + path.join(workspace.root, ...rel.split("/")), + ); + return bytes.length; + } catch { + return null; + } +} + +/** + * The SPEC 11.2 pre-answer step: analyze the current workspace; on + * configuration errors fail with exit-2 precedence; on a workspace failing + * `build`'s validations answer from the analysis consulting nothing and + * writing nothing; on a passing one participate in read-time refresh + * exactly as the reads of 13.3 do, then answer from the same analysis. + */ +export async function prepareWorkspaceForAvailability( + workspace: LoadedWorkspace, +): Promise<AvailabilityPreparation> { + const prepared = await analyzeWorkspaceForAvailability(workspace); + if (prepared.kind === "answer") { + await finishAvailabilityRefresh(workspace, prepared.analysis); + } + return prepared; +} diff --git a/src/workspace/baseline.ts b/src/workspace/baseline.ts index 0b110eea..bd315ccd 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`, @@ -28,20 +48,24 @@ // the baseline analysis and the current analysis can never drift apart. import { Buffer } from "node:buffer"; -import * as fsp from "node:fs/promises"; -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 { journalFromBytes, occupiedJournal } from "./journal.js"; +import type { LoadedJournal } from "./journal.js"; +import { + journalFromBytes, + occupiedJournal, + readJournalContent as readCurrentJournalContent, +} from "./journal.js"; import type { WorkspaceAnalysis } from "./pipeline.js"; import { analyzeWorkspaceContent } from "./pipeline.js"; import type { PathOccupant } from "./writes.js"; -import { classifyOccupant, describeOccupant } from "./writes.js"; +import { describeOccupant } from "./writes.js"; /** A successfully reconstructed baseline (SPEC 6.3). */ export interface ResolvedBaseline { @@ -70,13 +94,64 @@ export type BaselineResolution = * SPEC 6.3 → 12.0: the baseline cannot be read or reconstructed — a * usage error. `message` is the actionable diagnostic naming the * offending entries or files; callers report it on standard error - * and exit 2, before source validation of the current workspace. + * and exit 2. + */ + readonly ok: false; + readonly message: string; + }; + +/** + * A read baseline (SPEC 6.3): the ref resolved to a commit, the workspace + * tree at it listed, and the journal prefix/replay computed — everything + * of baseline resolution except the content's parse and validation, which + * `validateBaselineContent` performs on this value past the SPEC 13.3 + * gate (module header). + */ +export interface BaselineRead { + /** The full hash of the commit the ref resolved to. */ + readonly commit: string; + /** SPEC 6.3: the replay Journal (see `ResolvedBaseline.replay`). */ + readonly replay: Journal; + /** The content-validation continuation's inputs (internal to this + * module — consumed by `validateBaselineContent` verbatim). */ + readonly content: BaselineContentInputs; +} + +/** What `validateBaselineContent` consumes — read once by `readBaseline` + * so the content stage re-reads nothing but the source blobs. */ +export interface BaselineContentInputs { + readonly ref: string; + readonly root: string; + readonly configFileName: string; + /** The configuration blob's bytes at the ref — undefined when no regular + * file occupies the configuration path there (`configIrregular` says + * whether a non-regular tree entry does). */ + readonly configBytes: Uint8Array | undefined; + readonly configIrregular: boolean; + /** Every regular file at the ref, as raw path bytes. */ + readonly files: readonly Buffer[]; + /** Blob object name per file, keyed by `byteKey` of the path bytes. */ + readonly oidByPath: ReadonlyMap<string, string>; + /** The journal content at the ref as a loaded journal (absent = empty; + * a non-plain occupant carries its 14.13 finding, never read). */ + readonly journal: LoadedJournal; +} + +/** The outcome of the read half of baseline resolution (SPEC 6.3). */ +export type BaselineReadResolution = + | { readonly ok: true; readonly read: BaselineRead } + | { + /** + * SPEC 6.3 → 12.0: the baseline cannot be read, or the journal + * prefix/replay fails — a usage error naming the offending entries + * or files, preceding source validation of the current workspace + * (SPEC 12.0): callers report it and exit 2 before the gate. */ readonly ok: false; readonly message: string; }; -function failure(message: string): BaselineResolution { +function failure(message: string): { ok: false; message: string } { return { ok: false, message }; } @@ -186,10 +261,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 +271,33 @@ function invalidBaselineMessage( ); } +/** The blob-read failure message (repository corruption, shallow clone). */ +function unreadableMessage(ref: string): string { + return ( + `the workspace content at baseline ref '${ref}' cannot be read from ` + + `the repository — git object reads failed; the repository may be ` + + `corrupt or a shallow clone missing the ref's objects (SPEC 6.3)` + ); +} + /** - * Resolve a baseline git ref (SPEC 6.3): reconstruct and validate the - * workspace content as it stood at the ref — sources, configuration, and - * journal — and compute the replay mapping from baseline identities to - * current identities. Reads the repository through read-only git plumbing - * and the current journal from the filesystem; modifies nothing. + * The read half of baseline resolution (SPEC 6.3): resolve the ref to a + * commit, list the workspace tree at it, read the configuration and + * journal blobs, and compute the journal prefix/replay against the current + * journal. Reads the repository through read-only git plumbing and the + * current journal from the filesystem; modifies nothing, and parses no + * baseline source content — `validateBaselineContent` does, past the + * SPEC 13.3 gate (module header). * - * Callers run this before analyzing the current sources: baseline - * resolution precedes source validation (SPEC 12.0), and every failure - * here is a usage error (exit 2) with `message` as the standard-error - * diagnostic. + * Callers run this before analyzing the current sources: these failures + * precede source validation (SPEC 12.0), each a usage error (exit 2) with + * `message` as the standard-error diagnostic — even when the current + * workspace also fails `build`'s validations. */ -export async function resolveBaseline( +export async function readBaseline( workspace: LoadedWorkspace, ref: string, -): Promise<BaselineResolution> { +): Promise<BaselineReadResolution> { const { root, configFileName } = workspace; // --- locate the workspace within its repository ----------------------- @@ -289,7 +374,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 +408,114 @@ 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 current = await readCurrentJournalContent(root); + let currentJournalBytes: Uint8Array; + if (current.state === "absent") { + // SPEC 6.3: a journal file absent in the current workspace is read as + // an empty journal — and so is one below an area path holding no + // directory, where nothing is read (SPEC 13.4). + currentJournalBytes = new Uint8Array(0); + } else if (current.state === "read") { + currentJournalBytes = current.bytes; + } else if (current.state === "occupied") { + return failure( + `the current journal ${JOURNAL_PATH} is occupied by ` + + `${describeOccupant(current.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)`, + ); + } else { + // SPEC 14.25: the refusal is one more way the journal cannot be read + // (14.13) — replay fails exactly as for an occupied journal path. + return failure( + `the environment refused to read the current journal ` + + `${JOURNAL_PATH} (${current.cause.code ?? "an unknown error"}) — ` + + `the journal entries appended since baseline ref '${ref}' cannot ` + + `be read for replay (SPEC 6.1, 6.3, 14.25)`, + ); + } + const replay = computeJournalReplay( + baselineJournalBytes ?? new Uint8Array(0), + currentJournalBytes, + ); + if (!replay.ok) { + return failure( + `cannot map baseline identities at ref '${ref}' to current ` + + `identities: ${replay.problem}`, + ); + } + + return { + ok: true, + read: { + commit, + replay: replay.replay, + content: { + ref, + root, + configFileName, + configBytes, + configIrregular, + files, + oidByPath, + journal, + }, + }, + }; +} + +/** + * The validation half of baseline resolution (SPEC 6.3): parse and + * validate the read baseline's content as a workspace — configuration, + * sources, and journal findings, through the same pure core the current + * workspace's pipeline composes. A baseline that cannot be parsed and + * validated is a usage error (exit 2, SPEC 12.0); callers run this only + * past the SPEC 13.3 gate and before the refresh write commits, so a + * failing current workspace reports the gate's findings instead (module + * header) and a failing invocation modifies nothing. + */ +export async function validateBaselineContent( + read: BaselineRead, +): Promise<BaselineResolution> { + const { + ref, + root, + configFileName, + configBytes, + configIrregular, + files, + oidByPath, + journal, + } = read.content; + + if (configBytes === undefined) { return failure( configIrregular ? `the configuration file '${configFileName}' is not a regular ` + @@ -336,20 +528,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 +536,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 +552,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<string, string>(); + for (const source of classification.invalidSources) { + const oid = oidByPath.get(byteKey(Buffer.from(source.bytes))); + if (oid === undefined) { + // Impossible: classified sources come from the same listing. + throw new Error( + "xspec internal error: baseline invalid-path source without a blob", + ); + } + oidForInvalidSource.set(byteKey(Buffer.from(source.bytes)), oid); + } + const sourceBlobs = await readBlobs(root, [ + ...oidForSource.values(), + ...oidForInvalidSource.values(), + ]); + if (sourceBlobs === null) return failure(unreadableMessage(ref)); const analysis = await analyzeWorkspaceContent(configParse.configuration, { classification, @@ -400,6 +584,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 +606,28 @@ export async function resolveBaseline( return failure(invalidBaselineMessage(ref, analysis.findings)); } - // --- replay: current journal entries absent at the ref (SPEC 6.3) ----- - const currentJournalAbsolute = path.join(root, ".xspec", "journal"); - const occupant = await classifyOccupant(currentJournalAbsolute); - let currentJournalBytes: Uint8Array; - if (occupant === "absent") { - // SPEC 6.3: a journal file absent in the current workspace is read as - // an empty journal. - currentJournalBytes = new Uint8Array(0); - } else if (occupant === "file") { - currentJournalBytes = await fsp.readFile(currentJournalAbsolute); - } else { - return failure( - `the current journal ${JOURNAL_PATH} is occupied by ` + - `${describeOccupant(occupant)}, not a plain file — the journal ` + - `entries appended since baseline ref '${ref}' cannot be read for ` + - `replay (SPEC 6.1, 6.3, 13.4)`, - ); - } - const replay = computeJournalReplay( - baselineJournalBytes ?? new Uint8Array(0), - currentJournalBytes, - ); - if (!replay.ok) { - return failure( - `cannot map baseline identities at ref '${ref}' to current ` + - `identities: ${replay.problem}`, - ); - } - return { ok: true, - baseline: { commit, analysis, replay: replay.replay }, + baseline: { commit: read.commit, analysis, replay: read.replay }, }; } + +/** + * Resolve a baseline git ref whole (SPEC 6.3): `readBaseline` composed + * with `validateBaselineContent`. For the post-gate call site — a + * session's recorded baseline (review-session.ts), resolved after the + * SPEC 13.3 gate has passed — every failure a usage error (exit 2) with + * `message` as the standard-error diagnostic. The baseline-taking commands + * (`impact --base`, `review create --base`) call the halves separately, + * sequencing the gate between them (module header). + */ +export async function resolveBaseline( + workspace: LoadedWorkspace, + ref: string, +): Promise<BaselineResolution> { + const read = await readBaseline(workspace, ref); + if (!read.ok) { + return read; + } + return validateBaselineContent(read.read); +} diff --git a/src/workspace/build-validation.ts b/src/workspace/build-validation.ts new file mode 100644 index 00000000..4cfa3c18 --- /dev/null +++ b/src/workspace/build-validation.ts @@ -0,0 +1,50 @@ +// The validations of `xspec build` (SPEC 12.1) as one report — the +// findings a `build` would now report (SPEC 13.3). +// +// SPEC 13.3: a workspace fails `build`'s validations on "source validation +// errors, journal errors (14.13), and refused writes (14.22) alike". SPEC +// 14: "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". A refused write is detectable on a +// workspace whose sources or journal fail: 14.22 judges paths, never +// generated content — `build`'s write paths are "the derived files the +// current sources and configuration generate (13.1, 13.2) and graph data +// (13.3)", per-source derived paths "defined by this `NAME.mdx` name shape +// alone" (13.1), a set discovery and configuration define on any +// workspace (14.10; `discoveredWritePaths`, core/build.ts). So the refused +// writes are judged over that set whatever the sources' validity and +// reported beside the analysis's findings. +// +// Shared by every surface reporting or gating on `build`'s validations: +// `build` itself (12.1), `check` (12.2, which judges exactly `build`'s +// write paths, 14.22), the gate of 13.3 (./refresh.ts), and the +// invalid-workspace refusal of `rename` and `move` (6.4, 6.5: "the +// workspace's findings alone", 14). The examination makes kind reads only +// — nothing is modified (SPEC 12.1: a failing `build` modifies nothing) — +// and a kind read the environment refuses stops the command at that read +// (SPEC 14.25, ./writes.ts). + +import { discoveredWritePaths } from "../core/build.js"; +import type { Finding } from "../core/findings.js"; +import type { LoadedWorkspace } from "./config.js"; +import type { WorkspaceAnalysis } from "./pipeline.js"; +import { obstructedWritePathFindings } from "./writes.js"; + +/** + * The findings a `build` would now report over `analysis` (SPEC 13.3, + * 12.1): its source validation findings and journal errors, then the + * refused writes (SPEC 14.22) over `build`'s write paths as discovery and + * configuration define them — one finding per distinct offending + * component. Empty exactly when the workspace passes `build`'s + * validations. Unordered: every findings emitter applies 12.7's order. + */ +export async function buildValidationFindings( + workspace: LoadedWorkspace, + analysis: WorkspaceAnalysis, +): Promise<readonly Finding[]> { + const refusedWrites = await obstructedWritePathFindings( + workspace.root, + discoveredWritePaths(workspace.configuration, analysis.classification), + ); + return [...analysis.findings, ...refusedWrites]; +} diff --git a/src/workspace/build.ts b/src/workspace/build.ts index 4def69c5..7a9b7cc7 100644 --- a/src/workspace/build.ts +++ b/src/workspace/build.ts @@ -5,21 +5,21 @@ // (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). import type { BuildOutputs } from "../core/build.js"; -import { writeGraphData } from "./graph-data.js"; +import { writeDerivedFileRecord, writeGraphData } from "./graph-data.js"; import { removeDerivedFile, writeDerivedFile } from "./writes.js"; /** * Execute one validated build (SPEC 12.1): regenerate every derived file, * remove the recorded derived files no longer generated (via recorded paths - * only, SPEC 13.3, 13.4), and store the graph data last — the record that - * names the generated set updates only once the set exists, so an - * interrupted build leaves at worst a stale store, which `check` reports - * (14.10) and rebuilding resolves (SPEC 13.4). + * only, SPEC 13.3, 13.4), and store the graph data last, the record after + * the snapshot — the record that names the generated set updates only once + * the set exists, so an interrupted build leaves at worst a stale store, + * which `check` reports (14.10) and rebuilding resolves (SPEC 13.4). */ export async function executeBuildOutputs( root: string, @@ -35,4 +35,5 @@ export async function executeBuildOutputs( await removeDerivedFile(root, orphan); } await writeGraphData(root, outputs.graphData); + await writeDerivedFileRecord(root, outputs.record); } diff --git a/src/workspace/check.ts b/src/workspace/check.ts index 0a29a74c..430c855c 100644 --- a/src/workspace/check.ts +++ b/src/workspace/check.ts @@ -9,25 +9,32 @@ // rebuilding. SPEC 13.3: `check` never refreshes — this module only reads // and compares, writing nothing; the graph data itself is judged by the // same compare-with-current predicate the refreshing reads use -// (core/graph-data.ts, `graphDataMatchesCurrent`), so the retained -// derived-file record never reads as staleness. +// (core/graph-data.ts, `graphDataMatchesCurrent`), over the snapshot file +// alone, so the retained derived-file record — lagging, or absent where a +// refresh wrote graph data beside no record — never reads as staleness. // -// The comparison is against the pure build derivation (core/build.ts): the -// caller computes the current `BuildOutputs` over a workspace that passed -// build validation — with invalid sources "what the current sources and -// configuration generate" is undefined, and the validation findings mask -// staleness (SPEC 14). +// The comparison is against the pure build derivation (core/build.ts). The +// mismatch forms — per file and graph data — are consulted on a workspace +// passing `build`'s validations alone: with source validation errors, +// journal errors, or refused writes (14.22) alike, "what the current +// sources and configuration generate" is undefined, and those forms go +// unreported (SPEC 14.10, 13.3, 14). The two forms consulting no generated +// content — the unreadable-record unit form and the recorded-file form — +// are reported whatever the sources' validity (SPEC 14.10). 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"; -import { classifyOccupant, describeOccupant } from "./writes.js"; +import { isFilesystemFailure } from "./environment-refusal.js"; +import type { DerivedFileRecord, LoadedGraphData } from "./graph-data.js"; +import type { PathOccupant } from "./writes.js"; +import { classifyOccupant, describeOccupant, readsReach } from "./writes.js"; const utf8Encoder = new TextEncoder(); @@ -45,56 +52,129 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { return true; } +/** + * What occupies a derived-file path `check` compares (SPEC 14.10), judged + * by `lstat` (`classifyOccupant`), or "refused" where the environment + * refuses the kind read: SPEC 14.25 makes a derived file's content or kind + * that `check` compares condition 10, the path stale — never the read + * failure of condition 25, and never an internal error. + */ +async function comparedOccupant( + absolute: string, +): Promise<PathOccupant | "refused"> { + try { + return await classifyOccupant(absolute); + } catch (error) { + if (isFilesystemFailure(error)) return "refused"; + throw error; + } +} + /** 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, + ); } /** - * The SPEC 14.10 findings of `check` (SPEC 12.2), reading and comparing - * only — nothing is written: + * 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, + ); +} + +/** + * SPEC 14.10's mismatch forms — per file and graph data — the findings of + * `check` (SPEC 12.2) that compare against the content the current sources + * and configuration generate, reading and comparing only (nothing is + * written): * * - each derived file the current sources and configuration generate whose * path holds different bytes, no plain file, or nothing at all; - * - the graph data, judged by the shared compare-with-current predicate - * (SPEC 13.3 — the retained derived-file record is never staleness); - * - each recorded derived file remaining (anything occupying its path) at - * a path the current build no longer generates (`outputs.orphans`). + * - the graph data's missing-or-mismatch unit form, one finding whose + * concerned path is the graph-data area itself, no path inside it named, + * by the shared compare-with-current predicate over the snapshot file + * (SPEC 13.3 — the record, stored apart, is never staleness: lagging, or + * absent beside graph data a refresh wrote). The unit forms are + * exclusive (SPEC 14.10): while the record cannot be read as a record + * (SPEC 14.23) the unreadable-record form alone reports + * (`recordStalenessFindings`), never this one beside it. * - * `outputs` is the pure build derivation over the current, validated - * workspace; `stored` the loaded graph data it was derived against. - * Deterministic order: generated files in the build's output order, then - * the graph data, then the orphans (byte order, SPEC 12.0). + * Detectable only on a workspace passing `build`'s validations (SPEC + * 14.10, 13.3): with source validation errors, journal errors, or refused + * writes (14.22) alike, "what the current sources and configuration + * generate" is undefined, and these forms go unreported (SPEC 14) — the + * caller consults them on a passing workspace alone. `outputs` is the pure + * build derivation over the current workspace, `stored` the loaded graph + * data, and `record` the loaded derived-file record. Deterministic order: + * generated files in the build's output order, then the graph data (SPEC + * 12.0). */ -export async function stalenessFindings( +export async function mismatchStalenessFindings( root: string, outputs: BuildOutputs, stored: LoadedGraphData, + record: DerivedFileRecord, ): Promise<Finding[]> { const findings: Finding[] = []; for (const file of outputs.files) { const absolute = absoluteOf(root, file.path); - const occupant = await classifyOccupant(absolute); + const occupant = await comparedOccupant(absolute); + if (occupant === "refused") { + // SPEC 14.25 → 14.10: a refused kind read leaves the path stale. + findings.push( + staleFinding(file.path, "cannot be examined — it does not match"), + ); + continue; + } if (occupant === "absent") { findings.push(staleFinding(file.path, "is missing — it does not match")); continue; @@ -115,8 +195,9 @@ export async function stalenessFindings( try { bytes = await fsp.readFile(absolute); } catch { - // Vanished between classification and read (SPEC 13.5 concurrency): - // it no longer matches. + // SPEC 14.25 → 14.10: content the environment refuses to read leaves + // the path stale — as does a file gone between classification and + // read (SPEC 13.5 concurrency): it no longer matches. findings.push( staleFinding(file.path, "cannot be read — it does not match"), ); @@ -127,20 +208,71 @@ 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 mismatch unit form: `check` reports the graph data stale + // exactly when the refreshing reads would refresh it — the shared + // predicate (SPEC 13.3) — unless the record is unreadable, whose own + // form reports alone (the unit forms are exclusive). + if ( + record.state !== "unreadable" && + !graphDataMatchesCurrent(stored.bytes, 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. - for (const rel of outputs.orphans) { - if ((await classifyOccupant(absoluteOf(root, rel))) !== "absent") { + return findings; +} + +/** + * SPEC 14.10's two forms consulting no generated content, reported by + * `check` (SPEC 12.2) whatever the sources' validity — reading only, + * nothing written: + * + * - the unreadable-record unit form: recorded generation state that exists + * but cannot be read as a record (SPEC 14.23), one finding whose + * concerned path is the graph-data area itself (exclusive with the + * mismatch unit form of `mismatchStalenessFindings`); + * - the recorded-file form: each recorded derived file remaining (anything + * occupying its path) at a path the current sources and configuration no + * longer generate — `orphans`, the record's paths outside the set of + * generated paths that discovery and configuration define on any + * workspace, in byte order (core/build.ts: `orphanedRecordedPaths` over + * `discoveredGeneratedPaths`); one whose path is vacant remains nowhere, + * no finding. Each path is judged by the read side of SPEC 13.4 + * (`readsReach`): its workspace-relative directory components shallowest + * first, so below a component that is absent or occupied by anything + * other than a directory — a plain file, or a symbolic link whatever it + * targets, never read through — the path holds nothing and remains + * nowhere (14.25's absence, never its refusal), as `build`'s removal + * leaves such a path untouched (`removeDerivedFile`). A component's kind + * read the environment refuses is the read failure concerning that + * component (SPEC 14.25: a path occupant's kind examined under 13.4, no + * derived file's), thrown so `check` stops at it, exit 2 — as the same + * refusal among a generated path's components stops it at the 14.22 + * examination. While the unreadable-record state holds this form is + * undetectable (SPEC 14.10): it consults no readable record, and an + * unreadable record records nothing (`orphans` is empty by + * construction, `recordedPathsOf`). A path reached whose own kind the + * environment refuses to read is stale all the same (SPEC 14.25 → 14.10: + * a derived file's kind that `check` compares, `comparedOccupant`). + * + * Deterministic order: the graph data, then the orphans (SPEC 12.0). + */ +export async function recordStalenessFindings( + root: string, + record: DerivedFileRecord, + orphans: readonly string[], +): Promise<Finding[]> { + const findings: Finding[] = []; + if (record.state === "unreadable") { + findings.push(unreadableRecordStaleFinding()); + } + for (const rel of orphans) { + // SPEC 13.4: reads traverse no non-directory component — below one the + // recorded path holds nothing, and no derived file remains there. + if (!(await readsReach(root, rel))) continue; + if ((await comparedOccupant(absoluteOf(root, rel))) !== "absent") { findings.push(orphanFinding(rel)); } } - return findings; } diff --git a/src/workspace/config.ts b/src/workspace/config.ts index 044dfd13..649356d7 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,7 +25,12 @@ 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 { + beginsWithByteOrderMark, + firstInvalidUtf8, +} from "../core/source-text.js"; import type { LocatedWorkspace } from "./locate.js"; import { locateWorkspace } from "./locate.js"; @@ -35,8 +43,20 @@ 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; + /** + * The workspace root in the anchoring form of 11.6, relative to the + * invocation working directory — the inventory's `root` (SPEC 11.6) and + * the concerned path of a refused read of the root directory (SPEC 14.25). + */ + readonly rootAnchor: 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 +77,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 +92,8 @@ export function parseLocatedWorkspace( workspace: { root: located.root, configFileName: located.configFileName, + configAnchor: located.configAnchor, + rootAnchor: located.rootAnchor, configHash: sha256Hex(located.configBytes), configuration: parsed.configuration, }, @@ -95,33 +120,49 @@ 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, configFileName: string, ): ConfigurationResult { - let text: string; - try { - text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return { - ok: false, - findings: [ - { - condition: 14, - file: configFileName, - message: - `not valid UTF-8 — the configuration must be well-formed ` + - `TypeScript (SPEC 7, 14.14)`, - }, - ], - }; + // SPEC 7: the file's bytes MUST be valid UTF-8 and MUST NOT begin with a + // byte-order mark, as a source file's must (1.6); a file violating this + // encoding rule is a configuration error (14.14) — whatever TypeScript, + // which skips a leading mark, would make of the text. The mark is judged + // first, on the bytes: it is the file's first byte. + if (beginsWithByteOrderMark(bytes)) { + return encodingFailure( + `the configuration file begins with a UTF-8 byte-order mark (bytes ` + + `EF BB BF) — its bytes must be UTF-8 without a byte-order mark; ` + + `remove the mark (SPEC 7, 14.14)`, + configFileName, + ); } - // A leading byte-order mark is valid in a TypeScript file; strip it so - // the parser sees the module text. (The SPEC 14.20 BOM rule constrains - // discovered sources, not the configuration.) - if (text.startsWith("\uFEFF")) text = text.slice(1); - + const invalidAt = firstInvalidUtf8(bytes); + if (invalidAt !== -1) { + return encodingFailure( + `the configuration file is not valid UTF-8 (first invalid byte at ` + + `offset ${String(invalidAt)}) — its bytes must be valid UTF-8; ` + + `re-encode the file as UTF-8 (SPEC 7, 14.14)`, + configFileName, + ); + } + // Validated above: decoding strips nothing and replaces nothing, so the + // text is exactly the file's characters. + const text = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes); return parseConfiguration(text, configFileName); } + +/** A configuration file's one encoding-rule finding (SPEC 7, 14.14). */ +function encodingFailure( + message: string, + configFileName: string, +): ConfigurationResult { + return { ok: false, findings: [pathFinding(14, message, configFileName)] }; +} diff --git a/src/workspace/discovery.ts b/src/workspace/discovery.ts index b1b724fc..8700b4db 100644 --- a/src/workspace/discovery.ts +++ b/src/workspace/discovery.ts @@ -12,6 +12,15 @@ // filesystem reports is matched verbatim (byte-wise, case-sensitive, // SPEC 7/12.0), never re-derived through filesystem lookups. // +// SPEC 14.25: a directory the walk lists, or an entry whose kind it must +// examine, that the environment refuses to read — permission denied, an +// I/O error — stops the command at that read with the exit-2 read failure +// (./environment-refusal.ts), concerning the directory's or entry's +// workspace-relative path, the root itself in its anchoring form (11.6). +// Nonexistence is never that condition: a directory or entry gone since +// its parent was listed holds and is nothing — an absent path is matched +// by no glob (SPEC 7, 14.25). +// // Group matching, derived-file exclusion (SPEC 13.4), and path validation // (14.14/14.19) are the pure core's (src/core/discovery.ts). @@ -21,6 +30,9 @@ import type { Configuration } from "../core/config.js"; import type { SourceClassification } from "../core/discovery.js"; import { classifySources } from "../core/discovery.js"; import type { CompiledGlob } from "../core/glob.js"; +import type { PathText } from "../core/path-text.js"; +import { pathTextOf } from "../core/path-text.js"; +import { performRead } from "./environment-refusal.js"; const SLASH = Buffer.from("/"); /** SPEC 13.4/13.3: the workspace-root graph-data directory name. */ @@ -32,11 +44,14 @@ const XSPEC_DIR = Buffer.from(".xspec"); * plain file found against the configured groups (core/discovery.ts) — * exclusions (13.4) and path conditions (14.14, 14.19) included. `root` is * the workspace root's absolute filesystem path (SPEC 7: the configuration - * file's directory; the walk is independent of the working directory). + * file's directory; the walk is independent of the working directory), and + * `rootAnchor` the root in its anchoring form (SPEC 11.6) — the concerned + * path of a refused listing of the root itself (SPEC 14.25). */ export async function discoverSources( root: string, configuration: Configuration, + rootAnchor: string, ): Promise<SourceClassification> { const globs: readonly CompiledGlob[] = [ ...configuration.specGroups, @@ -47,27 +62,35 @@ export async function discoverSources( // SPEC 7: with no configured globs nothing can match — an empty // `specs`/`code` configuration discovers zero sources without touching // the filesystem. - await walk(Buffer.from(root), null, globs, files); + await walk(Buffer.from(root), null, rootAnchor, globs, files); } return classifySources(files, configuration); } /** * Recursive walk of one directory. `relative` is the directory's - * workspace-relative byte path (null for the root). Entries are visited in - * byte order of their names, classified without following symbolic links, - * and plain files are collected as workspace-relative byte paths. + * workspace-relative byte path (null for the root, whose concerned path is + * `rootAnchor`). Entries are visited in byte order of their names, + * classified without following symbolic links, and plain files are + * collected as workspace-relative byte paths. */ async function walk( absolute: Buffer, relative: Buffer | null, + rootAnchor: string, globs: readonly CompiledGlob[], files: Uint8Array[], ): Promise<void> { - const entries = await fsp.readdir(absolute, { - withFileTypes: true, - encoding: "buffer", - }); + // SPEC 14.25: a listing the environment refuses stops the command here; + // a directory gone since its parent was listed lists nothing (SPEC 7). + const concerned: PathText = + relative === null ? rootAnchor : pathTextOf(relative); + const entries = await performRead( + concerned, + "listing", + () => fsp.readdir(absolute, { withFileTypes: true, encoding: "buffer" }), + () => [], + ); entries.sort((a, b) => Buffer.compare(a.name, b.name)); for (const entry of entries) { const name: Buffer = entry.name; @@ -84,13 +107,16 @@ async function walk( if (!isFile && !isDirectory) { // The directory entry reported no type (a filesystem without d_type // support). Classify with lstat, which never follows a symbolic - // link (SPEC 7) — stat would. - let stats; - try { - stats = await fsp.lstat(entryAbsolute); - } catch { - continue; // vanished between readdir and lstat: not a source - } + // link (SPEC 7) — stat would. An entry gone since the listing is not + // a source; a kind read the environment refuses is the read failure + // concerning the entry (SPEC 14.25). + const stats = await performRead( + pathTextOf(entryRelative), + "kind", + () => fsp.lstat(entryAbsolute), + () => null, + ); + if (stats === null) continue; if (stats.isSymbolicLink()) continue; isFile = stats.isFile(); isDirectory = stats.isDirectory(); @@ -114,7 +140,7 @@ async function walk( // dot-directories (`.git`, caches) unless a pattern spells the // segment with its leading dot. if (globs.some((glob) => glob.mayMatchWithin(entryRelative))) { - await walk(entryAbsolute, entryRelative, globs, files); + await walk(entryAbsolute, entryRelative, rootAnchor, globs, files); } } } diff --git a/src/workspace/environment-refusal.ts b/src/workspace/environment-refusal.ts new file mode 100644 index 00000000..2e0a3325 --- /dev/null +++ b/src/workspace/environment-refusal.ts @@ -0,0 +1,189 @@ +// Environment refusals — a write (SPEC 14.24) or a read (SPEC 14.25) the +// environment refuses, carried from the operation that meets it to the +// CLI's exit-2 report. +// +// SPEC 14.24: a write xspec makes — a file's creation, replacement, append, +// relocation, or removal (13.4, 12.1) — that the environment refuses +// (permission denied, a read-only filesystem, exhausted storage, or any +// other failure the filesystem reports for a write the rules of 13.4 +// permit) is reported by the command making the write as a usage error +// (12.0), not a finding: the command stops at the refused write, attempting +// no later one and leaving every write already made, each complete (13.5), +// and exits 2. +// +// SPEC 14.25: so is a read the environment refuses (permission denied, an +// I/O error, any other failure the filesystem reports for a read) wherever +// the object read has no condition of its own — a directory discovery lists +// (7), the session directory (10.1), the directories the upward search +// examines (7), and a path occupant's kind wherever else xspec examines one +// (6.5, 7, 11.6, 13.4), the journal's, a session file's, and the +// configuration path's included: the command stops at the read, attempting +// nothing further, and exits 2. An object's nonexistence is never this +// condition — each reader reads absence as its own section states. The +// refused content reads that have conditions of their own (a source's 14.20, +// the journal's 14.13, a session file's 14.21, the configuration file's +// 14.14, a derived file's 14.10 to `check`, graph data's 14.23 state) are +// their readers' to report, never raised through here. +// +// Either refusal travels as the typed error below, thrown at the operation +// itself so nothing after it runs, and carrying the condition as data — the +// 12.7 finding form with its stable code and concerned path (IMPLEMENTATION +// cross-cutting rules) — which the CLI renders once, as the stderr +// diagnostic and, with JSON output in effect, the 12.0/12.7 error document +// (cli/main.ts, cli/report.ts). +// +// Messages stay byte-deterministic (SPEC 12.0): the concerned path, the +// filesystem's error code (`EACCES`, `EROFS`, `ENOSPC`, `EIO`, …), and +// static text — never the filesystem's own message, which carries absolute +// and temporary paths. + +import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; +import type { PathText } from "../core/path-text.js"; +import { renderPathText } from "../core/path-text.js"; + +/** + * A write (SPEC 14.24) or a read (SPEC 14.25) the environment refused, as + * the one finding of the exit-2 error document (SPEC 12.7): stable code, + * message, and concerned path, no locations. + */ +export class EnvironmentRefusal extends Error { + readonly finding: Finding; + + constructor(finding: Finding) { + super(finding.message); + this.name = "EnvironmentRefusal"; + this.finding = finding; + } +} + +/** + * Whether `error` is a failure the filesystem reported — a system error + * carrying the errno code and the failed call (SPEC 14.24, 14.25: "any + * other failure the filesystem reports"), as opposed to a defect of the + * product's own, which carries no system call. + */ +export function isFilesystemFailure( + error: unknown, +): error is NodeJS.ErrnoException { + if (!(error instanceof Error)) return false; + const { code, syscall } = error as NodeJS.ErrnoException; + return typeof code === "string" && typeof syscall === "string"; +} + +/** + * The write a refusal stopped, as its diagnostic names it (SPEC 14.24): + * creating or replacing a file, appending to a durable file, removing a + * file, or writing graph data, which concerns the graph-data area. + */ +export type RefusedWrite = "write" | "append" | "remove" | "graph-data"; + +/** + * The SPEC 14.24 write failure for one refused write: its concerned path is + * the workspace-relative path of the file the write would have produced or + * removed — each of a relocation's two writes concerning its own path — or, + * for a graph-data write, the graph-data area itself, no path inside it + * named (SPEC 14.24, 11.6). + */ +export function writeFailure( + concerned: string, + write: RefusedWrite, + cause: NodeJS.ErrnoException, +): EnvironmentRefusal { + const code = cause.code ?? "an unknown error"; + let what: string; + switch (write) { + case "write": + what = `to write ${concerned}`; + break; + case "append": + what = `an append to ${concerned}`; + break; + case "remove": + what = `the removal of ${concerned}`; + break; + case "graph-data": + what = `a graph-data write in the graph-data area ${concerned}`; + break; + } + return new EnvironmentRefusal( + pathFinding( + 24, + `the environment refused ${what} (${code}); the command stopped at ` + + `this write, every earlier write complete and no later one made — ` + + `make the path writable, then rerun the command (SPEC 14.24, 13.5)`, + concerned, + ), + ); +} + +/** + * Whether a failed read found nothing to read: no entry of that name, or a + * path component that is not a directory. SPEC 14.25: an object's + * nonexistence is never a read failure — an absent object reads as its own + * section states (an absent journal is empty, 6.1; an absent session + * directory holds no sessions, 10.1; an absent configuration path is + * missing configuration, 14.14; an absent path is matched by no glob, 7). + */ +export function isAbsenceFailure(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +/** + * The read a refusal stopped, as its diagnostic names it (SPEC 14.25): a + * directory's entries — a listing, or a lookup the upward search makes in + * a directory it examines (7) — or a path occupant's kind (7, 13.4). + */ +export type RefusedRead = "listing" | "kind"; + +/** + * The SPEC 14.25 read failure for one refused read: its concerned path is + * the object's workspace-relative path — for a directory above the + * workspace root, or examined before the root is known (7), its anchoring + * form (11.6); a caller passes whichever the object has. + */ +export function readFailure( + concerned: PathText, + read: RefusedRead, + cause: NodeJS.ErrnoException, +): EnvironmentRefusal { + const code = cause.code ?? "an unknown error"; + const spelled = renderPathText(concerned); + const what = + read === "listing" + ? `to read the entries of the directory ${spelled}` + : `to read what occupies ${spelled}`; + return new EnvironmentRefusal( + pathFinding( + 25, + `the environment refused ${what} (${code}); the command stopped at ` + + `this read, attempting nothing further — make the path readable, ` + + `then rerun the command (SPEC 14.25)`, + concerned, + ), + ); +} + +/** + * SPEC 14.25: run one read that has no condition of its own, turning a + * failure the filesystem reports into the read failure concerning + * `concerned`, thrown so the command stops at this read. A read that found + * nothing (`isAbsenceFailure`) is never refused: `absent` supplies the + * answer absence reads as, the caller's own section deciding it. Anything + * else — a defect of the product's own — propagates unchanged. + */ +export async function performRead<T>( + concerned: PathText, + read: RefusedRead, + reading: () => Promise<T>, + absent: () => T, +): Promise<T> { + try { + return await reading(); + } catch (error) { + if (isAbsenceFailure(error)) return absent(); + if (isFilesystemFailure(error)) throw readFailure(concerned, read, error); + throw error; + } +} diff --git a/src/workspace/fast-read.ts b/src/workspace/fast-read.ts index 5e3312c2..d58a7555 100644 --- a/src/workspace/fast-read.ts +++ b/src/workspace/fast-read.ts @@ -28,26 +28,44 @@ // 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 -// reads never modify anything (SPEC 13.4). +// reads never modify anything (SPEC 13.4). A read the environment refuses +// during verification (SPEC 14.25) falls back too: the full path makes its +// reads in its own order and meets the refusal there, so the command's +// outcome never depends on which path met it first. 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"; import { - GRAPH_DATA_PATH, - parseGraphData, + GRAPH_DATA_OWN_PATHS, serializeGraphData, } from "../core/graph-data.js"; import { sha256Hex } from "../core/hash.js"; import { discoverSources } from "./discovery.js"; -import { readJournalBytes } from "./journal.js"; +import { + EnvironmentRefusal, + isFilesystemFailure, +} from "./environment-refusal.js"; +import type { LoadedGraphData } from "./graph-data.js"; +import { loadGraphData } from "./graph-data.js"; +import { readJournalContent } 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 { @@ -63,26 +81,48 @@ function absoluteOf(root: string, rel: string): string { /** * Load and verify the stored graph data against the current workspace * bytes (module header). Null — fall back to the full path — whenever - * anything at all fails to verify. + * anything at all fails to verify, a read the environment refuses (SPEC + * 14.25) included. */ export async function verifyStoreForRead( located: LocatedWorkspace, ): Promise<VerifiedStore | null> { - // 1. Store bytes: present, parseable, canonical. - let storedBytes: Buffer; try { - storedBytes = await fsp.readFile(absoluteOf(located.root, GRAPH_DATA_PATH)); - } catch { - return null; + return await verifyStore(located); + } catch (error) { + if (error instanceof EnvironmentRefusal || isFilesystemFailure(error)) { + return null; + } + throw error; } - let storedText: string; +} + +/** The verification of `verifyStoreForRead`, in the module header's order. */ +async function verifyStore( + located: LocatedWorkspace, +): Promise<VerifiedStore | null> { + // 1. Store bytes: present, parseable, canonical — the snapshot file, + // read by the one graph-data read every surface shares (./graph-data.ts), + // so the store is read only as a plain file under an area path holding a + // directory, never through a symbolic link or a non-directory occupant + // (SPEC 13.4, 14.23); the record, which no read answer needs, is left + // unconsulted as by the full path's refresh (SPEC 13.3). A read + // that throws falls back like every other failure here: the full path + // meets it, and answers or reports, as it would without this path. + let stored: LoadedGraphData; try { - storedText = new TextDecoder("utf-8", { fatal: true }).decode(storedBytes); + stored = await loadGraphData(located.root); } catch { return null; } - const data = parseGraphData(storedText); - if (data === null || serializeGraphData(data) !== storedText) { + if (stored.state !== "readable" || stored.bytes === null) { + return null; + } + const data = stored.data; + if ( + data === null || + !Buffer.from(serializeGraphData(data), "utf8").equals(stored.bytes) + ) { return null; } @@ -95,15 +135,26 @@ export async function verifyStoreForRead( return null; } - // 3. Journal: recorded content hash (null = absent, SPEC 6.1). - const journalBytes = await readJournalBytes(located.root); - const journalHash = journalBytes === null ? null : sha256Hex(journalBytes); + // 3. Journal: recorded content hash (null = absent, SPEC 6.1). A journal + // the full path would report — a non-plain occupant, or content the + // environment refuses to read (SPEC 13.4, 14.25 → 14.13) — falls back, + // whatever the store recorded, so the full path's gate meets it. + const journal = await readJournalContent(located.root); + if (journal.state === "occupied" || journal.state === "refused") { + return null; + } + const journalHash = + journal.state === "read" ? sha256Hex(journal.bytes) : null; if (journalHash !== data.inputs.journalHash) { return null; } // 4. Discovery: the same walk the pipeline runs, then byte fingerprints. - const classification = await discoverSources(located.root, configuration); + const classification = await discoverSources( + located.root, + configuration, + located.rootAnchor, + ); if (classification.findings.length > 0) { return null; } @@ -133,6 +184,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_OWN_PATHS, + ]; + 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 8fd5baec..2e404949 100644 --- a/src/workspace/graph-data.ts +++ b/src/workspace/graph-data.ts @@ -1,101 +1,326 @@ // Graph-data storage — the I/O half (SPEC 13.3, 13.4; IMPLEMENTATION // Architecture: storage is workspace-layer I/O). // -// The graph data lives at `.xspec/graph.json` under the workspace root -// (SPEC 13.3: under `.xspec/`; content otherwise opaque). It is a derived -// file (SPEC 13.4): fully reproducible from sources, configuration, and the -// journal via `xspec build`; its path belongs to xspec, so a write replaces -// whatever occupies it — a symbolic link is replaced as itself and never -// written through — and a conflicted, corrupted, deleted, or orphaned store -// is correctly resolved by rebuilding. Only the reading side is here plus -// the one write, through the workspace write layer (writes.ts) like every -// product file write, so it is atomic in its observable effect (SPEC 13.5). +// Graph data lives in the graph-data area `.xspec` under the workspace root +// (SPEC 13.3: under `.xspec/`; content otherwise opaque), in two files that +// age differently (core/graph-data.ts): the snapshot with its derivation +// inputs at `.xspec/graph.json` — what a refresh writes — and the recorded +// derived-file paths at `.xspec/record.json` — the record, written by +// generation alone, so a refresh leaves it unchanged in every state +// (SPEC 13.3). Both are derived files (SPEC 13.4): fully reproducible from +// sources, configuration, and the journal via `xspec build`; their paths +// belong to xspec, so a write replaces whatever occupies them — a symbolic +// link is replaced as itself and never written through — and a conflicted, +// corrupted, deleted, or orphaned store is correctly resolved by +// rebuilding. Only the reading side is here plus the writes, through the +// workspace write layer (writes.ts) like every product file write, so each +// is atomic in its observable effect (SPEC 13.5). // // 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). +// pure core's (src/core/graph-data.ts). Each file loads by one occupant +// classification (`readStoredFile`), with lstat, the area's own path +// first, into one of three states (SPEC 13.3, 14.23): absent — nothing +// occupies the path, the area absent included; readable — a plain file +// holding the stored shape; or unreadable — a non-plain occupant, bytes +// that are not valid UTF-8 or not the stored shape, the area's own path +// `.xspec` occupied by a non-directory, below which nothing is read +// (SPEC 13.4), or a read the environment refuses — the area's own +// occupant's kind, the file's kind, or its content (SPEC 14.25: the state +// of condition 23, never the read failure and never absence). For the +// record, absent is the empty record (SPEC 11.6, +// 14.23: never a condition, whatever else the area holds — graph data a +// refresh wrote included) and unreadable is recorded state that exists but +// cannot be read as a record (14.23 — never an empty record): the +// record-consulting surfaces meet it (SPEC 11.6, 6.6), `check` reports it +// as staleness (SPEC 14.10), and no refreshing read reads, repairs, or +// replaces it (SPEC 13.3) — it persists until a successful `build` or a +// finishing `rename`/`move` regeneration replaces the record. For the +// snapshot file, absent and unreadable alike are graph data that is +// missing or does not match: the refreshing reads rewrite it (SPEC 13.3). 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 { + DERIVED_FILE_RECORD_PATH, + GRAPH_DATA_AREA, GRAPH_DATA_PATH, + parseDerivedFileRecord, parseGraphData, + serializeDerivedFileRecord, serializeGraphData, } from "../core/graph-data.js"; -import { classifyOccupant, writeDerivedFile } from "./writes.js"; +import { + isAbsenceFailure, + isFilesystemFailure, +} from "./environment-refusal.js"; +import type { PathOccupant } from "./writes.js"; +import { + classifyOccupant, + graphDataAreaOccupant, + writeDerivedFile, +} from "./writes.js"; const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); -/** The loaded store: raw bytes and, when they parse, the model. */ +/** The absolute filesystem path of a workspace-relative `/`-path. */ +function absoluteOf(root: string, rel: string): string { + return path.join(root, ...rel.split("/")); +} + +/** + * One stored file of the graph-data area as read: nothing there, something + * there that cannot be read as stored text (its bytes, where a plain file + * held them), or its text, strictly UTF-8-decoded. + */ +type StoredFileRead = + | { readonly state: "absent" } + | { readonly state: "unreadable"; readonly bytes: Uint8Array | null } + | { + readonly state: "text"; + readonly bytes: Uint8Array; + readonly text: string; + }; + +/** + * Read one stored file of the graph-data area (`GRAPH_DATA_PATH` or + * `DERIVED_FILE_RECORD_PATH`), classifying occupants by lstat — the + * record's container first, then the file's own path (SPEC 13.4, 14.23): + * + * - the graph-data area's own path `.xspec` — absent, nothing is stored + * (SPEC 14.23: "the area absent"); occupied by anything other than a + * directory — a plain file, a symbolic link whatever it targets, any + * other non-directory occupant — the file, whose container the area is, + * is unreadable (SPEC 13.4, 14.23): nothing is read below the occupant; + * - under a directory, the file's own path: nothing there is absent; only + * a plain file is read, anything else there existing but unreadable; and + * bytes that are not valid UTF-8 are unreadable. + * + * SPEC 14.25: a read the environment refuses here — the area's own + * occupant's kind (`graphDataAreaOccupant`), the file's kind, or its + * content — is one more way the stored file cannot be read: unreadable, + * the state of condition 23 (the record's 14.23, the snapshot's graph + * data that does not match), never the read failure of condition 25 and + * never absence. Only nonexistence is absence. + */ +async function readStoredFile( + root: string, + rel: string, +): Promise<StoredFileRead> { + const area = await graphDataAreaOccupant(root); + if (area === "absent") { + return { state: "absent" }; + } + if (area !== "directory") { + return { state: "unreadable", bytes: null }; + } + const absolute = absoluteOf(root, rel); + let occupant: PathOccupant; + try { + occupant = await classifyOccupant(absolute); + } catch (error) { + if (isFilesystemFailure(error)) return { state: "unreadable", bytes: null }; + throw error; + } + if (occupant === "absent") { + return { state: "absent" }; + } + if (occupant !== "file") { + return { state: "unreadable", bytes: null }; + } + let bytes: Uint8Array; + try { + bytes = await fsp.readFile(absolute); + } catch (error) { + // Vanished between classification and read (SPEC 13.5: concurrent + // commands, last-write-wins): nothing exists to read. Any other + // failure the filesystem reports is a refused content read (SPEC + // 14.25): the file exists and cannot be read. + if (isAbsenceFailure(error)) return { state: "absent" }; + if (isFilesystemFailure(error)) return { state: "unreadable", bytes: null }; + throw error; + } + try { + return { state: "text", bytes, text: strictUtf8Decoder.decode(bytes) }; + } catch { + return { state: "unreadable", bytes }; + } +} + +/** + * The loaded graph data's three-way state (SPEC 13.3): nothing stored, the + * stored snapshot readable, or something stored that cannot be read as + * graph data — which, like absence, does not match the current sources and + * configuration. + */ +export type GraphDataState = "absent" | "readable" | "unreadable"; + +/** The loaded graph data: 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: "absent" — nothing occupies the snapshot file's path (graph + * data missing); "readable" — a plain file parsing as the stored shape + * (`data` non-null); "unreadable" — something that cannot be read as + * graph data: a non-plain occupant, bytes that are not valid UTF-8, not + * JSON, or not the stored shape, the area's own path occupied by a + * non-directory, or a read the environment refuses (SPEC 14.25) — graph + * data that does not match. The refreshing reads + * rewrite the file in both non-readable states (SPEC 13.3) and `check` + * reports them as the graph data's 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 - * with `bytes` to `graphDataMatchesCurrent` (core) for the staleness - * predicate, and to `recordedDerivedFiles` (core) for orphan handling. + * The parsed model — non-null exactly in the "readable" state. Feed + * `bytes` to `graphDataMatchesCurrent` (core) for the staleness + * predicate. */ readonly data: GraphData | null; } -/** The graph-data file's absolute path under the workspace root. */ -function graphDataAbsolutePath(root: string): string { - return path.join(root, ...GRAPH_DATA_PATH.split("/")); -} - /** - * 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 + * Load the workspace's graph data — the snapshot with its derivation + * inputs (SPEC 13.3). Never throws on the expected states — each loads as + * its `GraphDataState` (`readStoredFile`'s classification), and the * refresh, failure, and staleness behaviors are the callers' (SPEC 13.3, - * 14.10). + * 14.10). The record is read apart (`readDerivedFileRecord`). */ export async function loadGraphData(root: string): Promise<LoadedGraphData> { - const absolute = graphDataAbsolutePath(root); - if ((await classifyOccupant(absolute)) !== "file") { - return { bytes: null, data: null }; + const read = await readStoredFile(root, GRAPH_DATA_PATH); + if (read.state === "absent") { + return { state: "absent", 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 }; + if (read.state === "unreadable") { + return { state: "unreadable", bytes: read.bytes, data: null }; } - let text: string; - try { - text = strictUtf8Decoder.decode(bytes); - } catch { - return { bytes, data: null }; + const data = parseGraphData(read.text); + if (data === null) { + return { state: "unreadable", bytes: read.bytes, data: null }; } - return { bytes, data: parseGraphData(text) }; + return { state: "readable", bytes: read.bytes, data }; } /** - * Write the graph data (SPEC 13.3): the canonical serialization (core) at - * `.xspec/graph.json`, through the derived-file write primitive — atomic + * 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, bytes that are not the stored + * shape (corrupt, merge-conflicted or otherwise), the area's own + * path occupied by a non-directory (SPEC 13.4), or graph data the + * environment refuses to read (SPEC 14.25). 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 form and recorded-file form, 14.10) and of + * generation's orphan removal (`build`, the finishing regeneration of + * `rename`/`move`; SPEC 12.1, 13.4). Never repairs, replaces, or otherwise + * writes: the state persists until a successful `build` or a finishing + * regeneration replaces the record (SPEC 13.3). + */ +export async function readDerivedFileRecord( + root: string, +): Promise<DerivedFileRecord> { + const read = await readStoredFile(root, DERIVED_FILE_RECORD_PATH); + if (read.state === "absent") { + return { state: "absent" }; + } + const paths = + read.state === "text" ? parseDerivedFileRecord(read.text) : null; + if (paths === null) { + return { state: "unreadable" }; + } + // SPEC 11.6/12.0: the recorded paths as one byte-ordered, duplicate-free + // list (the canonical serialization already writes them so; sorting here + // keeps the datum canonical whatever bytes parsed). + return { + state: "readable", + paths: [...new Set(paths)].sort(compareBytes), + }; +} + +/** + * The recorded derived-file paths generation's orphan removal and 14.10's + * recorded-file form consult (SPEC 12.1, 13.3, 13.4): a readable record's + * paths; none where the record is absent or cannot be read as a record — + * files orphaned then are outside xspec's knowledge (SPEC 13.4), and the + * recorded-file form is undetectable while the record is unreadable + * (SPEC 14.10). + */ +export function recordedPathsOf(record: DerivedFileRecord): readonly string[] { + return record.state === "readable" ? record.paths : []; +} + +/** + * Write the graph data (SPEC 13.3) — the snapshot with its derivation + * inputs, the refresh's one write — as the canonical serialization (core) + * at `.xspec/graph.json`, through the derived-file write primitive: atomic * 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). + * 13.3). A write the environment refuses concerns the graph-data area, no + * path inside it named (SPEC 14.24, 11.6). */ export async function writeGraphData( root: string, data: GraphData, ): Promise<void> { - await writeDerivedFile(root, GRAPH_DATA_PATH, serializeGraphData(data)); + await writeDerivedFile( + root, + GRAPH_DATA_PATH, + serializeGraphData(data), + GRAPH_DATA_AREA, + ); +} + +/** + * Write the derived-file record (SPEC 13.3, 13.4) — generation's write + * alone (`xspec build`, the finishing regeneration of `rename`/`move`), + * never a refresh's — as the canonical serialization (core) at + * `.xspec/record.json`, through the derived-file write primitive like the + * graph data, replacing whatever occupies the path, an unreadable record + * included (SPEC 14.23). The record is graph data (SPEC 13.3), so a refused + * write of it concerns the graph-data area (SPEC 14.24). + */ +export async function writeDerivedFileRecord( + root: string, + paths: readonly string[], +): Promise<void> { + await writeDerivedFile( + root, + DERIVED_FILE_RECORD_PATH, + serializeDerivedFileRecord(paths), + GRAPH_DATA_AREA, + ); } diff --git a/src/workspace/journal.ts b/src/workspace/journal.ts index d538cf4e..006f2939 100644 --- a/src/workspace/journal.ts +++ b/src/workspace/journal.ts @@ -7,7 +7,13 @@ // modified or deleted by other commands. An absent file is an empty journal // (SPEC 6.1). A journal path occupied by anything other than a plain file — // a symbolic link included — is never read, appended to, or replaced: it is -// a journal error (SPEC 13.4 → 14.13). +// a journal error (SPEC 13.4 → 14.13), and so is a journal whose content +// the environment refuses to read (SPEC 14.25 → 14.13), while a refused +// read of the journal path's kind is the read failure of condition 25 +// (SPEC 14.25). Below an area path `.xspec` holding no directory — a plain +// file, or a symbolic link whatever it targets — nothing is read: the +// journal so placed is empty (SPEC 6.1) and unoccupied to the inventory +// (SPEC 11.6), never a journal error (13.4). // // Parsing, validation, and the canonical-identity walk are the pure core's // (src/core/journal.ts); this module classifies the occupant and reads @@ -17,22 +23,31 @@ 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 { + appendedJournalBytes, Journal, JOURNAL_PATH, parseJournal, - serializeJournalEntry, } from "../core/journal.js"; +import { + isAbsenceFailure, + isFilesystemFailure, +} from "./environment-refusal.js"; import type { PathOccupant } from "./writes.js"; import { appendDurableFile, - classifyOccupant, describeOccupant, + readableOccupant, } from "./writes.js"; -/** What occupies the journal's path (SPEC 6.1, 13.4). */ -export type JournalFileState = "absent" | "plain" | "occupied"; +/** + * What occupies the journal's path (SPEC 6.1, 13.4), and whether its + * content could be read: "refused" is a plain file whose content the + * environment refuses to read (SPEC 14.25 → 14.13). + */ +export type JournalFileState = "absent" | "plain" | "occupied" | "refused"; /** The loaded journal: parse results plus the file-state classification. */ export interface LoadedJournal { @@ -44,12 +59,16 @@ export interface LoadedJournal { */ readonly journal: Journal; readonly entries: readonly PositionedJournalEntry[]; - /** The journal's 14.13 findings: bad lines, or a non-plain-file occupant. */ + /** + * The journal's 14.13 findings: bad lines, a non-plain-file occupant, or + * content the environment refuses to read (SPEC 14.25). + */ readonly findings: readonly Finding[]; /** * The exact bytes the journal was loaded from — null for an absent file - * (an empty journal, SPEC 6.1) and for a non-plain occupant (never read, - * SPEC 13.4). The journal is a derivation input (SPEC 5.4), so its + * (an empty journal, SPEC 6.1), for a non-plain occupant (never read, + * SPEC 13.4), and for refused content (SPEC 14.25). The journal is a + * derivation input (SPEC 5.4), so its * content fingerprint enters the graph data's recorded inputs * (SPEC 13.3; core/graph-data.ts). */ @@ -61,6 +80,33 @@ function journalAbsolutePath(root: string): string { return path.join(root, ".xspec", "journal"); } +/** + * What occupies the journal's path as reads see it (SPEC 6.1, 13.4) — the + * one classification every current-journal read derives from: the path's + * own occupant, judged by lstat so a link there is judged itself, never + * probed through; and "absent" below an area path holding no directory — + * `.xspec` a plain file, a symbolic link whatever it targets, or any other + * non-directory occupant — where nothing is read, so the journal so placed + * is empty (SPEC 6.1) and unoccupied to the inventory (SPEC 11.6), never a + * journal error (`readableOccupant`, writes.ts). + */ +export async function journalOccupant(root: string): Promise<PathOccupant> { + return readableOccupant(root, JOURNAL_PATH); +} + +/** + * 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) — none below an area + * path holding no directory (`journalOccupant`). No content is read: an + * absent journal is an empty journal (SPEC 6.1), and the inventory reports + * no 14.13 for whatever the occupant holds. + */ +export async function journalOccupied(root: string): Promise<boolean> { + return (await journalOccupant(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 +141,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([]), @@ -116,66 +161,133 @@ export function occupiedJournal(occupant: PathOccupant): LoadedJournal { } /** - * Load the workspace's journal (SPEC 6.1): an absent file is an empty - * journal; a plain file is parsed and validated (core); anything else at the - * path — symbolic link, directory, or other non-plain occupant — is never - * read and reports a journal error (SPEC 13.4 → 14.13). Classification uses - * lstat (writes.ts), so a symbolic link is judged itself, never through its - * target. + * The journal whose content the environment refuses to read — permission + * denied, an I/O error, any other failure the filesystem reports for the + * read (SPEC 14.25 → 14.13: "a journal the environment refuses to read"): + * one 14.13 finding concerning the journal, no entries, exactly as a + * journal that cannot be read for any other reason fails the workspace's + * validation (SPEC 14.13, 13.3). The message carries the filesystem's + * error code, never its own text, which names absolute paths (SPEC 12.0). */ -export async function loadJournal(root: string): Promise<LoadedJournal> { - const absolute = journalAbsolutePath(root); - const occupant = await classifyOccupant(absolute); +export function refusedJournal(cause: NodeJS.ErrnoException): LoadedJournal { + const code = cause.code ?? "an unknown error"; + const finding: Finding = pathFinding( + 13, + `journal error: the environment refused to read the journal ` + + `${JOURNAL_PATH} (${code}) — its entries cannot be read, so no ` + + `identity it maps can be resolved (SPEC 6.1, 14.25); make the ` + + `journal readable, then rerun the command (SPEC 14.13)`, + JOURNAL_PATH, + ); + return { + fileState: "refused", + journal: new Journal([]), + entries: [], + findings: [finding], + rawBytes: null, + }; +} + +/** + * The current journal's content as one read finds it (SPEC 6.1, 13.4, + * 14.25) — the one content read every current-journal reader shares: + * `absent` where nothing occupies the path as reads see it + * (`journalOccupant`: below an area path holding no directory included), + * or where the file vanished between classification and read (SPEC 13.5); + * `occupied` where anything but a plain file occupies it, never read + * (SPEC 13.4 → 14.13); `refused` where the environment refuses the content + * read (SPEC 14.25 → 14.13); and `read`, the file's exact bytes. A refused + * kind read is the read failure of condition 25 (`journalOccupant`), thrown. + */ +export type JournalContent = + | { readonly state: "absent" } + | { readonly state: "occupied"; readonly occupant: PathOccupant } + | { readonly state: "refused"; readonly cause: NodeJS.ErrnoException } + | { readonly state: "read"; readonly bytes: Uint8Array }; + +/** Read the current journal's content (`JournalContent`). */ +export async function readJournalContent( + root: string, +): Promise<JournalContent> { + const occupant = await journalOccupant(root); if (occupant === "absent") { - return journalFromBytes(null); + return { state: "absent" }; } if (occupant !== "file") { - return occupiedJournal(occupant); + return { state: "occupied", occupant }; + } + try { + return { + state: "read", + bytes: await fsp.readFile(journalAbsolutePath(root)), + }; + } catch (error) { + if (isAbsenceFailure(error)) return { state: "absent" }; + if (isFilesystemFailure(error)) return { state: "refused", cause: error }; + throw error; } - return journalFromBytes(await fsp.readFile(absolute)); } /** - * The journal file's raw bytes — null when the path holds no plain file (an - * absent journal is empty, SPEC 6.1; a non-plain occupant is never read, - * SPEC 13.4, and the caller's validation has already reported it, 14.13). - * `rename` and `move` read these to model the journal as it will stand - * after their append (SPEC 6.4, 6.5: the post-operation analysis hashes - * with the journal including the new entry, SPEC 5.4). + * Load the workspace's journal (SPEC 6.1): an absent file is an empty + * journal; a plain file is parsed and validated (core); anything else at the + * path — symbolic link, directory, or other non-plain occupant — is never + * read and reports a journal error (SPEC 13.4 → 14.13), as does content the + * environment refuses to read (SPEC 14.25 → 14.13, `refusedJournal`). + * Classification uses lstat (writes.ts), so a symbolic link is judged + * itself, never through its target; below an area path holding no + * directory the journal is absent, so empty (SPEC 13.4, `journalOccupant`). */ -export async function readJournalBytes( - root: string, -): Promise<Uint8Array | null> { - const absolute = journalAbsolutePath(root); - if ((await classifyOccupant(absolute)) !== "file") { - return null; - } - try { - return await fsp.readFile(absolute); - } catch { - return null; +export async function loadJournal(root: string): Promise<LoadedJournal> { + const content = await readJournalContent(root); + switch (content.state) { + case "absent": + return journalFromBytes(null); + case "occupied": + return occupiedJournal(content.occupant); + case "refused": + return refusedJournal(content.cause); + case "read": + return journalFromBytes(content.bytes); } } /** * Append one entry to the journal as its canonical line (SPEC 6.1: * append-only, one entry per line, byte-deterministic; the file comes into - * existence with the first journaled operation). The write goes through the - * workspace write layer (writes.ts): one O_APPEND write of the whole line, - * 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. + * existence with the first journaled operation). `prior` is the journal as + * the caller loaded and validated it (`LoadedJournal.rawBytes`: null for + * an absent journal, SPEC 6.1), and the journal becomes the post-append + * journal core composes from it (`appendedJournalBytes`): `prior`, a line + * feed terminating its last line where that line lacks one, then the + * entry's line — the very bytes the caller validated the rewritten + * workspace against and derived its outputs from (SPEC 6.4, 6.5, 5.4, + * 13.3), since it composes them with the same function over the same + * inputs. The write goes through the workspace write + * layer's atomic append (writes.ts `appendDurableFile`): the complete new + * journal replaces the file by one rename, so a concurrent reader observes + * the prior journal or the complete new one, and an append refused or + * interrupted — exhausted storage part-way through the line included — + * leaves the journal byte-for-byte as it was, no entry (SPEC 13.5, 14.24); + * the content stays line-oriented, 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, a journal whose content + * the environment refused to read, 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, + prior: Uint8Array | null, entry: JournalEntry, ): Promise<void> { + const next = appendedJournalBytes(prior, entry); + // `prior` is a byte prefix of `next`, so the addition is `next` past it. await appendDurableFile( root, JOURNAL_PATH, - serializeJournalEntry(entry) + "\n", + prior, + next.subarray(prior === null ? 0 : prior.length), ); } diff --git a/src/workspace/locate.ts b/src/workspace/locate.ts index 053b3aa7..8218633b 100644 --- a/src/workspace/locate.ts +++ b/src/workspace/locate.ts @@ -5,9 +5,45 @@ // search for `xspec.config.ts` from the working directory, or uses the // path given by the global `--config <path>` option — a filesystem path // resolved against the working directory (SPEC 12.0). The configuration -// file's directory is the workspace root. A missing configuration is a -// configuration error (14.14), reported by every command as a usage error -// (exit 2, 12.0) preceding all source analysis. +// file's directory is the workspace root. The configuration file is the +// occupant of the path so found or named, read only when it is a plain +// file: a missing configuration, or any other occupant — a directory, a +// symbolic link whatever it targets — is a 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 path the upward search found or `--config` +// named, whatever occupies it, or `.` for a failed upward search with no +// `--config` — except that a `--config` path nothing occupies is reported +// as the argument value exactly as given (12.0). 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. +// +// SPEC 11.6: the working directory and the workspace root enter that +// spelling as physical directory paths, every symbolic link among their +// components resolved, and the configuration file as its own name under +// the root so spelled. The working directory is resolved first +// (`physicalWorkingDirectory`); the upward search ascends from it, so every +// directory it examines is physical, and a `--config` value is resolved +// against it component by component as the filesystem resolves a path +// (`namedConfigurationEntry`): through a link at a directory component the +// entry is the one the filesystem finds, spelled by the physical relation +// (`L/xspec.config.ts`, `L` a link to `a/b`, is `a/b/xspec.config.ts`, the +// root `a/b`), while the entry itself is never resolved (7: whatever +// occupies it). Every location read below goes through the physical path, +// so the file read is the file anchored. +// +// SPEC 14.25: a read the environment refuses here — a directory the upward +// search or the anchoring resolution examines, or the named configuration +// path's kind — is condition 25, thrown as the typed read failure +// (./environment-refusal.ts) that the CLI reports as exit 2, its concerned +// path in the anchoring form (the root is not yet known); the configuration +// file's refused content read is invalid configuration instead (14.14). +// Nonexistence is never a refusal: it is the missing configuration of +// 14.14. // // 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 @@ -15,9 +51,22 @@ // re-parsing (SPEC 12.0 determinism — identical bytes parse identically), // which is what lets a fresh-store read skip the parser module entirely. +import type { Stats } from "node:fs"; 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, + pathSegments, + physicalDirectory, +} from "./anchor.js"; +import type { EnvironmentRefusal } from "./environment-refusal.js"; +import { + isAbsenceFailure, + isFilesystemFailure, + readFailure, +} from "./environment-refusal.js"; /** SPEC 7: the configuration file name the upward search looks for. */ export const CONFIG_FILE_NAME = "xspec.config.ts"; @@ -29,39 +78,200 @@ 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 workspace root in the anchoring form of 11.6 — the inventory's + * `root`, and the concerned path of a refused read of the root directory + * itself (SPEC 14.25: a directory the upward search examined before the + * root was known, which has no workspace-relative spelling of its own). + */ + readonly rootAnchor: 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 — the found or named + * configuration path in the 11.6 anchoring form, whatever occupies it + * (7); `.` for a failed upward search with no `--config`; and a + * `--config` path nothing occupies as the argument value exactly as + * given (12.0). + */ + readonly concernedPath: string; + }; -function failure(message: string, file?: string): WorkspaceLocateResult { - return { ok: false, findings: [{ condition: 14, message, file }] }; +function failure( + message: string, + concernedPath: string, +): WorkspaceLocateResult { + // SPEC 14: configuration errors carry the file or path they concern — + // the anchored configuration path, `.`, or an unoccupied `--config` + // value as given — with no in-source location. + return { + ok: false, + findings: [pathFinding(14, message, concernedPath)], + concernedPath, + }; } -/** Whether a plain-stat of the path reaches a regular file. */ -async function isFile(candidate: string): Promise<boolean> { +/** + * What occupies a configuration path (SPEC 7): nothing, a plain file — the + * one occupant ever read — or any other filesystem object, described for + * the diagnostic. + */ +type ConfigOccupant = + | { readonly kind: "absent" } + | { readonly kind: "file" } + | { readonly kind: "other"; readonly description: string }; + +/** + * SPEC 7: classify the occupant of a configuration path by the entry + * itself, never by what a symbolic link at that path targets — `lstat` + * follows no link in the final component. A failed read that found + * nothing — no entry of that name, or a path component that is not a + * directory — is absence: an absent configuration path is missing + * configuration (SPEC 14.25, 14.14). Any other refused read — of the + * occupant's kind, or of a directory the upward search examines + * (permission denied, an I/O error) — is condition 25 (SPEC 14.25), never + * absence: `refused` builds the read failure, thrown so the command stops + * at the read and the search never continues past it. + */ +async function occupantOf( + candidate: string, + refused: (cause: NodeJS.ErrnoException) => EnvironmentRefusal, +): Promise<ConfigOccupant> { + let stats: Stats; try { - return (await fsp.stat(candidate)).isFile(); - } catch { - return false; + stats = await fsp.lstat(candidate); + } catch (error) { + if (isAbsenceFailure(error)) return { kind: "absent" }; + if (isFilesystemFailure(error)) throw refused(error); + throw error; } + if (stats.isFile()) return { kind: "file" }; + const description = stats.isSymbolicLink() + ? "a symbolic link" + : stats.isDirectory() + ? "a directory" + : "a filesystem object other than a plain file"; + return { kind: "other", description }; } /** - * SPEC 7: upward search for `xspec.config.ts` from the working directory. - * Returns the found file's absolute path, or undefined when the search - * exhausts at the filesystem root. + * SPEC 11.6: the invocation working directory as a physical directory + * path, every symbolic link among its components resolved — the directory + * every anchoring spelling ascends from, the upward search starts at (7), + * and a `--config` value resolves against (12.0). SPEC 14.25: a lookup the + * environment refuses among its components is a directory the anchoring + * resolution examines — the read failure concerning that directory, + * spelled against the working directory as given, since the resolution + * that would spell it physically is the one refused. */ -async function searchUpward(startDir: string): Promise<string | undefined> { - let dir = startDir; +async function physicalWorkingDirectory(cwd: string): Promise<string> { + const given = path.resolve(cwd); + const { root } = path.parse(given); + const resolved = await physicalDirectory( + root, + pathSegments(given.slice(root.length)), + (examined, cause) => + readFailure(anchoredPathSpelling(given, examined), "listing", cause), + ); + // A working directory removed since the command started leaves nothing + // to resolve: its own spelling stands, and every read below it reads the + // absence (SPEC 14.25: nonexistence is never a refused read). + return resolved.found ? resolved.path : given; +} + +/** + * SPEC 12.0, 11.6: the configuration entry a `--config` value names — a + * filesystem path resolved against the physical working directory as the + * filesystem resolves one: each `.` the directory itself, each `..` the + * physical parent of the directory reached so far, and every symbolic link + * among the directory components followed where it stands, so the entry + * is spelled by the physical relation (`L/xspec.config.ts` through a link + * `L` to `a/b` names `a/b/xspec.config.ts`). The entry itself — the last + * segment — is never resolved: whatever occupies it is the configuration + * path's occupant (7), a symbolic link included. A value ending in `.` or + * `..` names the directory it reaches, never a link. Undefined when + * nothing can occupy the path — a directory component missing, not a + * directory, or a link whose target is either — the unoccupied path + * echoed as given (SPEC 14). SPEC 14.25: a refused lookup in a directory + * the resolution examines is the read failure concerning that directory in + * its anchoring form. + */ +async function namedConfigurationEntry( + workingDirectory: string, + configFlag: string, +): Promise<string | undefined> { + let start = workingDirectory; + let rest = configFlag; + if (path.isAbsolute(configFlag)) { + start = path.parse(configFlag).root; + rest = configFlag.slice(start.length); + } + const segments = pathSegments(rest); + const last = segments[segments.length - 1]; + const entryName = + last === undefined || last === "." || last === ".." ? undefined : last; + const directory = await physicalDirectory( + start, + entryName === undefined ? segments : segments.slice(0, -1), + (examined, cause) => + readFailure( + anchoredPathSpelling(workingDirectory, examined), + "listing", + cause, + ), + ); + if (!directory.found) return undefined; + return entryName === undefined + ? directory.path + : path.join(directory.path, entryName); +} + +/** + * SPEC 7: upward search for `xspec.config.ts` from the working directory — + * the working directory itself first. The search stops at the nearest + * directory holding an entry of that name, whatever occupies it, so a + * directory or a symbolic link of that name ends the search as surely as a + * plain file does (the caller reads only a plain file). Returns the entry's + * absolute path and occupant, or undefined when the search exhausts at the + * filesystem root. SPEC 14.25: a directory the search examines that the + * environment refuses to read stops the command at that read — the read + * failure concerning the directory in its anchoring form (11.6), examined + * before the root is known. + */ +async function searchUpward( + workingDirectory: string, +): Promise< + { readonly configPath: string; readonly occupant: ConfigOccupant } | undefined +> { + // Ascending from the physical working directory, each directory is its + // physical parent's child: the search examines physical paths alone. + let dir = workingDirectory; for (;;) { - const candidate = path.join(dir, CONFIG_FILE_NAME); - if (await isFile(candidate)) return candidate; + const configPath = path.join(dir, CONFIG_FILE_NAME); + const examined = dir; + const occupant = await occupantOf(configPath, (cause) => + readFailure( + anchoredPathSpelling(workingDirectory, examined), + "listing", + cause, + ), + ); + if (occupant.kind !== "absent") return { configPath, occupant }; const parent = path.dirname(dir); if (parent === dir) return undefined; dir = parent; @@ -70,52 +280,115 @@ async function searchUpward(startDir: string): Promise<string | undefined> { /** * Locate and read the project configuration file (SPEC 7, 14.14) without - * parsing it. `configFlag` is the `--config <path>` value when given, - * resolved against `cwd` (SPEC 12.0); otherwise the upward search from - * `cwd` applies. + * parsing it. `cwd` is the invocation working directory, resolved + * physically first (SPEC 11.6). `configFlag` is the `--config <path>` + * value when given, resolved against it (SPEC 12.0); otherwise the upward + * search from it applies. */ export async function locateWorkspace( cwd: string, configFlag: string | undefined, ): Promise<WorkspaceLocateResult> { + // SPEC 11.6: every spelling below is anchored on the physical working + // directory, and every configuration path is found from it. + const workingDirectory = await physicalWorkingDirectory(cwd); let configPath: string; let configFileName: string; + let occupant: ConfigOccupant; if (configFlag !== undefined) { - configPath = path.resolve(cwd, configFlag); - configFileName = path.basename(configPath); - if (!(await isFile(configPath))) { + const named = await namedConfigurationEntry(workingDirectory, configFlag); + // SPEC 14.25: the named path's refused kind read is condition 25, + // concerning the path in its anchoring form (11.6) — examined before + // the root is known; only nonexistence is the missing configuration + // below. Occupancy is judged before anything is spelled: a path + // nothing occupies has no physical spelling (SPEC 14). + const namedOccupant: ConfigOccupant = + named === undefined + ? { kind: "absent" } + : await occupantOf(named, (cause) => + readFailure( + anchoredPathSpelling(workingDirectory, named), + "kind", + cause, + ), + ); + if (named === undefined || namedOccupant.kind === "absent") { + // SPEC 14: missing configuration WITH `--config` given concerns the + // named path (never `.` — that is the failed upward search's case). + // A path nothing occupies is the one concerned path no physical + // resolution (11.6) can spell, so it is reported as the argument + // value exactly as given (12.0) — never canonicalized, and an + // absolute value stays absolute: `./../cfg//xspec.config.ts` is + // reported byte-for-byte, never `../cfg/xspec.config.ts`. Once the + // path is occupied, whatever occupies it, the anchoring form below + // applies: existence, not spelling, decides the form. return failure( `--config ${configFlag}: no configuration file exists at this ` + `path, resolved against the working directory (SPEC 7, 12.0)`, + configFlag, ); } + configPath = named; + configFileName = path.basename(named); + occupant = namedOccupant; } else { - const found = await searchUpward(path.resolve(cwd)); + const found = await searchUpward(workingDirectory); if (found === undefined) { + // SPEC 14: a failed upward search with no `--config` concerns the + // directory it started from — the invocation working directory, + // spelled `.` (11.6). return failure( `no ${CONFIG_FILE_NAME} found by upward search from the working ` + `directory — create one in the project root or pass --config ` + `<path> (SPEC 7)`, + ".", ); } - configPath = found; + configPath = found.configPath; configFileName = CONFIG_FILE_NAME; + occupant = found.occupant; } + // SPEC 14: the concerned path of every configuration error from here on + // is the found or named configuration path itself, whatever occupies it + // (7) — the entry, never what a symbolic link there targets. + const configAnchor = anchoredPathSpelling(workingDirectory, configPath); + if (occupant.kind === "other") { + // SPEC 7, 14.14: the configuration file is read only when it is a plain + // file; any other occupant — a directory, a symbolic link whatever it + // targets, or anything else — is missing or invalid configuration, + // never read through, and the upward search never continues past it. + return failure( + (configFlag === undefined + ? `the upward search from the working directory stops at the ` + + `nearest entry named ${CONFIG_FILE_NAME}, and this one is ` + : `--config ${configFlag}: this path holds `) + + `${occupant.description}, not a plain file — the configuration ` + + `file is read only as a plain file, never through a directory or ` + + `a symbolic link; put the configuration file itself at this path ` + + `(SPEC 7)`, + configAnchor, + ); + } let bytes: Uint8Array; try { bytes = await fsp.readFile(configPath); } catch { + // SPEC 7, 14.25: a plain configuration file the environment refuses to + // read is invalid configuration (14.14). return failure( - `the configuration file cannot be read (SPEC 7)`, - configFileName, + `the configuration file cannot be read (SPEC 7, 14.25)`, + configAnchor, ); } + const root = path.dirname(configPath); return { ok: true, located: { - root: path.dirname(configPath), + root, configFileName, + configAnchor, + rootAnchor: anchoredPathSpelling(workingDirectory, root), configBytes: bytes, }, }; diff --git a/src/workspace/pipeline.ts b/src/workspace/pipeline.ts index f680b9ba..41d77528 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<string, string>; /** @@ -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<Uint8Array | null>; + /** + * Read one invalid-path discovered source's exact bytes (SPEC 14.19), + * addressed by its exact path bytes — such a path may have no plain + * string form (SPEC 12.0). Null when the content cannot be read + * (SPEC 14.20). Called only for `classification.invalidSources` + * entries, so content sourced from a workspace that passed `build`'s + * validations (which discovers none) may answer null unconditionally. + */ + readonly readInvalidSource: (bytes: Uint8Array) => Promise<Uint8Array | null>; /** * Load the journal (SPEC 6.1). Called only when analysis proceeds past * configuration errors — those precede all source analysis (SPEC 14). @@ -136,21 +156,59 @@ export interface WorkspaceContent { readonly loadJournal: () => Promise<LoadedJournal>; } +/** + * Discover the workspace's sources (SPEC 7): the classification every + * analysis of the filesystem workspace starts from. A listing or kind read + * the environment refuses stops the command here, thrown from the walk as + * its read failure (SPEC 14.25); a discovery-level configuration error is + * carried as data (`discoveryConfigurationErrors`). A mutating command + * discovers before acquiring exclusivity and hands the classification to + * `analyzeWorkspace` (SPEC 13.5; cli/commands/mutation.ts). + */ +export function discoverWorkspace( + workspace: LoadedWorkspace, +): Promise<SourceClassification> { + return discoverSources( + workspace.root, + workspace.configuration, + workspace.rootAnchor, + ); +} + +/** + * A classification's discovery-level configuration errors (SPEC 7.2 → + * 14.14: a file matched by both a spec and a code group) — usage class + * (exit 2, SPEC 12.0), preceding all source analysis (SPEC 14). + */ +export function discoveryConfigurationErrors( + classification: SourceClassification, +): readonly Finding[] { + return classification.findings.filter( + (finding) => codeExitClass(finding.code) === 2, + ); +} + /** * Analyze the workspace (see the module header): discover, parse, and * validate every configured source, load the journal, assemble the graph, * and compute the text model and hashes. Total over invalid workspaces — * every condition arrives as data in `findings`/`configurationErrors`, and - * only I/O failures throw. + * only I/O failures throw. `discovered`, when given, is this workspace's + * classification from `discoverWorkspace`, made earlier by the caller: the + * analysis then reads only the discovered sources' content and the journal + * (SPEC 13.5: a mutating command's reads after discovery follow its + * acquisition of exclusivity). */ export async function analyzeWorkspace( workspace: LoadedWorkspace, + discovered?: SourceClassification, ): Promise<WorkspaceAnalysis> { const { root, configuration } = workspace; - const classification = await discoverSources(root, configuration); + const classification = discovered ?? (await discoverWorkspace(workspace)); return analyzeWorkspaceContent(configuration, { classification, readSource: (rel) => readSourceBytes(root, rel), + readInvalidSource: (bytes) => readInvalidSourceBytes(root, bytes), loadJournal: () => loadJournal(root), }); } @@ -171,9 +229,7 @@ export async function analyzeWorkspaceContent( // SPEC 14/14.14: discovery-level configuration errors are usage-class and // 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, - ); + const configurationErrors = discoveryConfigurationErrors(classification); if (configurationErrors.length > 0) { const graph = buildWorkspaceGraph({ specs: [], code: [] }); const textModel = new WorkspaceTextModel(graph.embeddingResolver()); @@ -182,6 +238,8 @@ export async function analyzeWorkspaceContent( markdownDestinations: new Set(), specs: [], code: [], + invalidPathSpecs: [], + invalidPathCode: [], graph, textModel, hashes: new Map(), @@ -205,6 +263,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 +298,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 +314,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 +337,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 +348,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 +440,8 @@ export async function analyzeWorkspaceContent( markdownDestinations, specs, code, + invalidPathSpecs, + invalidPathCode, graph, textModel, hashes, @@ -332,6 +478,16 @@ 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. A leading U+FEFF is kept as a character of the path (SPEC 7, + * 1.5: a path is its names' exact bytes), as `pathTextOf` keeps it, so a + * path beginning with it and its twin without it keep distinct stand-ins. + */ +const lossyDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); + /** * Read one discovered source's exact bytes from the filesystem, null when * unreadable — the reader `analyzeWorkspace` hands the shared body. @@ -347,6 +503,25 @@ async function readSourceBytes( } } +/** + * Read one invalid-path discovered source's exact bytes (SPEC 14.19) — + * such a workspace-relative path may have no plain string form, so the + * filesystem is addressed with the exact bytes (`/`-separated, as the + * walk produced them; every platform Node supports accepts `/` here). + */ +async function readInvalidSourceBytes( + root: string, + bytes: Uint8Array, +): Promise<Uint8Array | null> { + try { + return await fsp.readFile( + Buffer.concat([Buffer.from(root), Buffer.from("/"), Buffer.from(bytes)]), + ); + } catch { + return null; + } +} + /** * SPEC 14.20: a discovered source whose content cannot be read. On the * filesystem that means the file vanished (or became unreadable) between @@ -354,13 +529,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 16ee1cc1..ebfcbd25 100644 --- a/src/workspace/refresh.ts +++ b/src/workspace/refresh.ts @@ -6,11 +6,24 @@ // 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 — an absent record stays absent, +// the empty record (11.6), whatever graph data the refresh writes beside +// it — and recorded state that exists but cannot be read as a record +// (SPEC 14.23) is neither read, repaired, nor replaced: the refresh writes +// the snapshot file alone and never the record's (./graph-data.ts), never +// consults the record, and reports no finding for it, the unreadable 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, reported together (SPEC 14): +// the findings a `build` would now report (./build-validation.ts) — 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 @@ -26,18 +39,15 @@ // so refresh and build agree by construction, byte for byte — SPEC 12.0). import { computeBuildOutputs } from "../core/build.js"; +import type { SourceClassification } from "../core/discovery.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"; +import { graphDataMatchesCurrent } from "../core/graph-data.js"; +import { buildValidationFindings } from "./build-validation.js"; import type { LoadedWorkspace } from "./config.js"; import { loadGraphData, writeGraphData } from "./graph-data.js"; import type { WorkspaceAnalysis } from "./pipeline.js"; import { analyzeWorkspace, workspaceInputsOf } from "./pipeline.js"; -import { symlinkWritePathFindings } from "./writes.js"; /** The outcome of the SPEC 13.3 pre-answer step. */ export type WorkspacePreparation = @@ -45,17 +55,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); the record, whatever its state, left unchanged + * (SPEC 13.3, 14.23). Answer from `analysis`. */ readonly kind: "ready"; readonly analysis: WorkspaceAnalysis; - /** The graph data as stored — current snapshot, retained record. */ + /** + * The stored graph data — the current snapshot with its derivation + * inputs (the answer's source is `analysis`). + */ 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), reported together + * (SPEC 14) — the command reports * these findings as its report (standard output, SPEC 12.0) and * exits 1 without answering; nothing was modified. */ @@ -72,65 +88,145 @@ 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. `discovered` is the + * classification a mutating `review` subcommand made before acquiring + * exclusivity (SPEC 13.5; `analyzeWorkspace`), its configuration errors + * already reported. */ -export async function prepareWorkspaceForRead( +export async function analyzeWorkspaceForRead( workspace: LoadedWorkspace, -): Promise<WorkspacePreparation> { - const analysis = await analyzeWorkspace(workspace); + discovered?: SourceClassification, +): Promise<ReadAnalysis> { + const analysis = await analyzeWorkspace(workspace, discovered); if (analysis.configurationErrors.length > 0) { return { kind: "configuration", errors: analysis.configurationErrors }; } - 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 }; + return { kind: "analysis", analysis }; +} + +/** + * The gate-and-refresh assessment (SPEC 13.3), decision separated from + * write: `findings` is the invalid-workspace report — validation findings + * and refused writes over build's complete write set (SPEC 14.22) + * together: 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, the snapshot file's (a no-op when + * the stored graph data already matches; the record, which no refresh + * reads, repairs, or replaces, is never written, 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<void>; + }; + +export async function assessWorkspaceRead( + workspace: LoadedWorkspace, + analysis: WorkspaceAnalysis, +): Promise<ReadRefreshAssessment> { + // SPEC 13.3: the current workspace fails `build`'s validations — source + // validation errors, journal errors (14.13), and refused writes (14.22) + // alike: the findings a `build` would now report, each condition beside + // the others (SPEC 14), refused writes judged over the write paths + // discovery and configuration define whatever the sources' validity + // (./build-validation.ts). 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 findings = await buildValidationFindings(workspace, analysis); + if (findings.length > 0) { + return { kind: "findings", 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 is independent of the stored + // record (the record feeds orphan removal alone, which no refresh + // performs), so the record is never consulted. The refresh generates and + // removes no TypeScript or Markdown — only the snapshot file is ever + // written. const build = computeBuildOutputs( workspace.configuration, analysis.specs, analysis.graph, analysis.textModel, analysis.hashes, - stored.data, + [], workspaceInputsOf(workspace, analysis), - ).graphData; + ); - if (graphDataMatchesCurrent(stored.bytes, stored.data, build)) { - // Matching data is served as is — no write, nothing modified. - return { - kind: "ready", - analysis, - graphData: refreshedGraphData(stored.data, build), - }; + // SPEC 13.3: graph data missing or not matching the current sources and + // configuration — nothing stored, a stale snapshot, or something stored + // that cannot be read as graph data — is rewritten as `build` would + // write it; matching data is served as is, nothing to commit. The record + // is left unchanged in every state (SPEC 13.3, 14.23): the refresh + // writes the snapshot file alone, so an absent record stays absent (the + // empty record, 11.6), a readable one stays byte-for-byte, and recorded + // state that cannot be read as a record is neither read, repaired, nor + // replaced, no finding reported for it — 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 it. + const stored = await loadGraphData(workspace.root); + const graphData = build.graphData; + if (graphDataMatchesCurrent(stored.bytes, graphData)) { + return { kind: "ready", graphData, commit: async () => {} }; } + return { + kind: "ready", + graphData, + commit: () => writeGraphData(workspace.root, graphData), + }; +} - // 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 }; +/** + * The shared pre-answer step (SPEC 13.3): analyze the current workspace; + * on validation findings or configuration errors, fail without modifying + * anything; otherwise ensure the stored graph data matches the current + * sources and configuration — refreshing it if missing or mismatched, + * writing exactly what `xspec build` would write except that no TypeScript + * or Markdown is generated or removed and the recorded derived-file paths + * are left unchanged — and hand back the analysis to answer from. The + * composition of `analyzeWorkspaceForRead` and `assessWorkspaceRead` for + * callers whose argument checks all precede the analysis. + */ +export async function prepareWorkspaceForRead( + workspace: LoadedWorkspace, +): Promise<WorkspacePreparation> { + const analyzed = await analyzeWorkspaceForRead(workspace); + if (analyzed.kind === "configuration") { + return analyzed; } - - const graphData = refreshedGraphData(stored.data, build); - await writeGraphData(workspace.root, graphData); - return { kind: "ready", analysis, graphData }; + 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 21193fed..ca377d6a 100644 --- a/src/workspace/reviews.ts +++ b/src/workspace/reviews.ts @@ -19,35 +19,65 @@ 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 { corruptSessionFinding, corruptSessionOccupantFinding, - isValidSessionName, parseSessionBytes, REVIEWS_DIRECTORY, serializeSession, sessionFilePath, sortSessionNames, } from "../core/review.js"; +import { isValidSessionName } from "../core/session-name.js"; import { - classifyOccupant, describeOccupant, + probeOccupant, + readableDirectory, + readableDirectoryEntries, writeDurableFile, } from "./writes.js"; /** The extension a session file bears, byte-exact (SPEC 10.1, 12.0). */ const SESSION_EXTENSION = ".json"; -/** The reviews directory's absolute path under the workspace root. */ -function reviewsAbsolutePath(root: string): string { - return path.join(root, ...REVIEWS_DIRECTORY.split("/")); -} - /** A session file's absolute path under the workspace root. */ function sessionAbsolutePath(root: string, name: string): string { - return path.join(reviewsAbsolutePath(root), `${name}${SESSION_EXTENSION}`); + return path.join(root, ...sessionFilePath(name).split("/")); +} + +/** + * The session directory's entry names (SPEC 10.1), once + * `sessionDirectoryHoldsSessions` has judged it listable. SPEC 14.25: a + * listing the environment refuses is the read failure concerning + * `.xspec/reviews` — the command making it stops there, exit 2 — never a + * directory holding no sessions; a directory gone since lists nothing. + */ +async function sessionDirectoryEntries(root: string): Promise<string[]> { + return readableDirectoryEntries(root, REVIEWS_DIRECTORY); +} + +/** + * SPEC 10.1/13.4: whether the session directory holds sessions at all — + * only while a directory occupies `.xspec/reviews` and the graph-data + * area's own path `.xspec` above it, each judged by `lstat` + * (`readableDirectory`). Absent, occupied by anything other than a + * directory — a plain file, or a symbolic link whatever it targets — or + * lying below such an occupant of the area's path, it holds none: no + * command lists through such an occupant, so every session name is unknown + * there (SPEC 10.7, 12.0) and nothing below it is read. The one judge + * shared by every session read — `list`, the name-taking subcommands + * (`status`, `next`, `show`, `export`, `resolve`, `split`), `create`'s + * existing-name checks, `check`'s 14.21 sweep, and `inventory`. SPEC + * 14.25: a kind read the environment refuses here is the read failure + * concerning the path read (`readableDirectory`), never absence. + */ +export async function sessionDirectoryHoldsSessions( + root: string, +): Promise<boolean> { + return readableDirectory(root, REVIEWS_DIRECTORY); } /** One loaded session: readable, corrupt (SPEC 14.21), or absent. */ @@ -63,7 +93,8 @@ export type LoadedSession = * parsed, or violates a session invariant. Every `review` subcommand * naming the session reports `finding` and exits 1, modifying * nothing; `list` reports the session corrupt in place of its fields - * (SPEC 10.7); `check` reports it as condition 21. + * (SPEC 10.7), with `finding` beside the listing (SPEC 14.21, 12.7); + * `check` reports it as condition 21. */ readonly state: "corrupt"; readonly name: string; @@ -86,20 +117,15 @@ export type LoadedSession = * Whether the entry is a plain file is judged at load: a candidate occupied * by a directory or symbolic link is a corrupt session (SPEC 13.4 → 14.21), * while entries not matching the name pattern are not sessions at all and - * are ignored. An absent or non-directory `.xspec/reviews` yields no - * sessions; reads never traverse symbolic links (SPEC 13.4). + * are ignored. An absent or non-directory `.xspec/reviews`, or one below a + * non-directory `.xspec`, yields no sessions; reads never traverse + * symbolic links (SPEC 13.4, `sessionDirectoryHoldsSessions`). */ export async function listSessionNames(root: string): Promise<string[]> { - const directory = reviewsAbsolutePath(root); - if ((await classifyOccupant(directory)) !== "directory") { - return []; - } - let entries: string[]; - try { - entries = await fsp.readdir(directory); - } catch { + if (!(await sessionDirectoryHoldsSessions(root))) { return []; } + const entries = await sessionDirectoryEntries(root); const names: string[] = []; for (const entry of entries) { if (!entry.endsWith(SESSION_EXTENSION)) continue; @@ -110,12 +136,64 @@ export async function listSessionNames(root: string): Promise<string[]> { return sortSessionNames(names); } +/** + * SPEC 11.6: every directory entry directly under the review-session + * directory whose name is a well-formed session file name + * (`<valid-name>.json`, byte-exact on the extension), selected by name + * alone, whatever kind of filesystem object occupies it — corrupt sessions, + * directories, and symbolic links included: no content is read, so no + * 14.21 arises here. Returned as workspace-relative session file paths in + * byte order of file name (SPEC 11.6's pinned order — the file name, not + * the bare session name). An entry with any other name is not a session + * and is never listed; an absent or non-directory `.xspec/reviews`, or + * one below a non-directory `.xspec`, yields no sessions, and a symbolic + * link at either path is never traversed (SPEC 13.4, + * `sessionDirectoryHoldsSessions`). + */ +export async function listSessionFilePaths(root: string): Promise<string[]> { + if (!(await sessionDirectoryHoldsSessions(root))) { + return []; + } + const entries = await sessionDirectoryEntries(root); + 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). A session directory holding no + * sessions (`sessionDirectoryHoldsSessions`) occupies no session's path. + */ +export async function sessionOccupied( + root: string, + name: string, +): Promise<boolean> { + if (!(await sessionDirectoryHoldsSessions(root))) { + return false; + } + const entries = await sessionDirectoryEntries(root); + 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 - * path's occupant decides: nothing → absent; a plain file → parsed and - * validated (core); anything else — a directory or symbolic link included — - * is never read and the session is corrupt (SPEC 13.4 → 14.21). + * (an invalid name is a usage error before any lookup, SPEC 12.0). A + * session directory holding no sessions (`sessionDirectoryHoldsSessions`) + * makes every name absent. Otherwise the path's occupant decides: + * nothing → absent; a plain file → parsed and validated (core); anything + * else — a directory or symbolic link included — is never read and the + * session is corrupt (SPEC 13.4 → 14.21). * Classification uses lstat (writes.ts), so a symbolic link is judged * itself, never through its target. */ @@ -130,17 +208,20 @@ export async function loadSession( // entry names first: no byte-identical entry, no session — `NAME.JSON` // is not a session file and `Foo` never resolves to `foo`. const entryName = `${name}${SESSION_EXTENSION}`; - let entries: string[]; - try { - entries = await fsp.readdir(reviewsAbsolutePath(root)); - } catch { + // SPEC 10.1/13.4: a session directory that holds no sessions — absent, + // a non-directory occupant, or below one at `.xspec` — names none. + if (!(await sessionDirectoryHoldsSessions(root))) { return { state: "absent", name }; } + const entries = await sessionDirectoryEntries(root); if (!entries.includes(entryName)) { return { state: "absent", name }; } const absolute = sessionAbsolutePath(root, name); - const occupant = await classifyOccupant(absolute); + // SPEC 14.25: a session file's kind read the environment refuses is the + // read failure concerning the session file's path (`probeOccupant`) — + // where its refused content read, below, is condition 21. + const occupant = await probeOccupant(root, sessionFilePath(name)); if (occupant === "absent") { return { state: "absent", name }; } @@ -155,11 +236,15 @@ export async function loadSession( try { bytes = await fsp.readFile(absolute); } catch (error) { + // SPEC 14.25 → 14.21: a session file the environment refuses to read + // is corrupt. The message names the filesystem's error code alone — + // never its own message, which carries the absolute path (SPEC 12.0). + const code = (error as NodeJS.ErrnoException).code ?? "an unknown error"; return { state: "corrupt", name, finding: corruptSessionFinding(name, [ - `the session file cannot be read: ${(error as Error).message}`, + `the session file cannot be read (${code}; SPEC 14.25)`, ]), }; } diff --git a/src/workspace/writes.ts b/src/workspace/writes.ts index 28f6e583..a7964dac 100644 --- a/src/workspace/writes.ts +++ b/src/workspace/writes.ts @@ -20,21 +20,71 @@ // 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). +// +// SPEC 13.4's read side shares the occupant classification: reads traverse +// no non-directory component either, and `readableDirectory` is the one +// judge of whether a read may list a workspace directory at all — +// `readableOccupant`, over it, of what a read finds at a file's path, and +// `readableDirectoryEntries` the listing itself. +// +// SPEC 14.25: every one of those reads — a path occupant's kind wherever +// xspec examines one (6.5, 7, 11.6, 13.4), a directory's entries — is made +// through `probeOccupant` or `readableDirectoryEntries`, which read absence +// as absence and turn any other failure the filesystem reports into the +// typed read failure (./environment-refusal.ts) concerning the object's +// workspace-relative path, thrown so the command stops at that read (exit +// 2, SPEC 12.0). `classifyOccupant` stays the raw judgement beneath them, +// for the readers whose refused read is a condition of their own — among +// them the graph-data area's own path, whose refused kind read is the +// state of condition 23 (`graphDataAreaOccupant`): the record unreadable, +// nothing read below the area, no obstruction established there; only a +// write under the area, examining it first, stops at that read. +// +// SPEC 14.24: a write the environment refuses — a file's creation, +// replacement, append, relocation, or removal — stops the command making +// it. Every write primitive here runs its filesystem mutations through +// `performWrite`, which turns any failure the filesystem reports into the +// typed write failure (./environment-refusal.ts) concerning the file the +// write would have produced or removed, or the graph-data area for graph +// data; thrown at the write, it leaves every earlier write complete and +// attempts no later one (SPEC 13.5), and the CLI reports it as the exit-2 +// usage error (SPEC 12.0, 12.7). import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import * as process from "node:process"; import { compareBytes } from "../core/bytes.js"; +import type { SourceWrite } from "../core/edits.js"; import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; +import { GRAPH_DATA_AREA } from "../core/graph-data.js"; +import type { PathBytes, PathText } from "../core/path-text.js"; +import { + comparePathTexts, + pathTextKey, + pathTextOf, + renderPathText, +} from "../core/path-text.js"; +import type { RefusedWrite } from "./environment-refusal.js"; +import { + isFilesystemFailure, + performRead, + readFailure, + writeFailure, +} from "./environment-refusal.js"; /** * What occupies a filesystem path, judged by `lstat` — a symbolic link is @@ -43,15 +93,29 @@ 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. Any + * other failure is thrown as the filesystem reported it: the raw judgement, + * for the readers whose refused kind read is a condition of their own + * (SPEC 14.25: a derived file's kind that `check` compares, 14.10; the + * graph-data area's, 14.23); every other kind read goes through + * `probeOccupant`, which makes the refusal condition 25. + */ export async function classifyOccupant( - absolute: string, + absolute: string | Buffer, ): Promise<PathOccupant> { let stats; 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 +124,100 @@ export async function classifyOccupant( return "other"; } +/** + * Classify the occupant of a workspace-relative path (SPEC 13.4) — every + * kind read xspec makes of a path in the workspace, the `rename`/`move` + * destination probes' included (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 (SPEC 6.5 `nonDirectoryComponents`, 14.22). SPEC 14.25: a kind + * read the environment refuses — permission denied, an I/O error — is the + * read failure concerning `rel`, thrown so the command stops at the read. + */ +export async function probeOccupant( + root: string, + rel: string, +): Promise<PathOccupant> { + try { + return await classifyOccupant(absoluteOf(root, rel)); + } catch (error) { + if (isFilesystemFailure(error)) throw readFailure(rel, "kind", error); + throw error; + } +} + +/** + * What occupies the graph-data area's own path `.xspec` (SPEC 11.6, 13.3) + * as a read judges it: its occupant by `lstat` (`classifyOccupant`), or + * "refused" where the environment refuses the kind read. SPEC 14.25: a + * refused read of the area's own occupant is the state of condition 23, + * never the read failure of condition 25 — no record can be read under it + * (14.23; `readStoredFile`, graph-data.ts), and, as below an area path + * holding no directory, nothing below it is read (13.4): the journal is + * empty and unoccupied to the inventory, the session directory holds no + * sessions (`readableDirectory`; SPEC 11.6: "a refused read of the area's + * own occupant is condition 23"), and no obstruction is established there + * (`obstructedComponentOf`). A write under the area still examines it + * first and stops at the refused read (`assertUnobstructedParent`: a write + * never passes through a component it cannot judge, SPEC 13.4). + */ +export async function graphDataAreaOccupant( + root: string, +): Promise<PathOccupant | "refused"> { + try { + return await classifyOccupant(absoluteOf(root, GRAPH_DATA_AREA)); + } catch (error) { + if (isFilesystemFailure(error)) return "refused"; + throw error; + } +} + +/** + * The occupant of a workspace-relative directory component as a read, or + * the 14.22 examination of a write path, judges it: the graph-data area's + * own path by `graphDataAreaOccupant`, its refused kind read "refused" + * (the state of condition 23, SPEC 14.25); every other component by + * `probeOccupant`, a refused kind read there the read failure (SPEC 14.25). + */ +async function componentOccupant( + root: string, + component: string, +): Promise<PathOccupant | "refused"> { + return component === GRAPH_DATA_AREA + ? graphDataAreaOccupant(root) + : probeOccupant(root, component); +} + +/** + * SPEC 6.5: the workspace-relative directory components of `rels` occupied + * by anything other than a directory — a plain file, a symbolic link + * (whatever it targets: writes never traverse one, SPEC 13.4), or any + * other non-directory occupant. Distinct components, probed once each, in + * byte order; nonexistent components are never listed (writes create + * those, SPEC 13.4). The `refused-invalid-destination` evaluation + * (core/refusal.ts) consumes this for the destination path and the + * derived paths it would generate. + */ +export async function nonDirectoryComponents( + root: string, + rels: readonly string[], +): Promise<string[]> { + const components = new Set<string>(); + for (const rel of rels) { + for (const component of directoryComponents(rel)) { + components.add(component); + } + } + const obstructed: string[] = []; + for (const component of [...components].sort(compareBytes)) { + const occupant = await probeOccupant(root, component); + if (occupant !== "absent" && occupant !== "directory") { + obstructed.push(component); + } + } + return obstructed; +} + /** Human words for an occupant kind, for diagnostics. */ export function describeOccupant(occupant: PathOccupant): string { switch (occupant) { @@ -96,77 +254,275 @@ 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; in the + * byte form where it has no plain string form (SPEC 12.0, 12.7). + */ + readonly component: PathText; + /** 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. A kind read the environment + * refuses is the read failure concerning the component (SPEC 14.25) — + * except the graph-data area's own path, whose refused kind read is the + * state of condition 23 (SPEC 14.25; `graphDataAreaOccupant`), which + * establishes no obstruction: examination stops there, and a write under + * the area meets the refusal at its own examination + * (`assertUnobstructedParent`). */ -export async function symlinkComponentOf( +export async function obstructedComponentOf( root: string, rel: string, -): Promise<string | null> { +): Promise<ObstructedComponent | null> { for (const component of directoryComponents(rel)) { - const occupant = await classifyOccupant(absoluteOf(root, component)); - if (occupant === "symlink") return component; - if (occupant === "absent") return null; - // A plain-file or other non-directory occupant is not a symbolic link: - // not this condition (SPEC 14.22). The write itself fails on it. + const occupant = await componentOccupant(root, component); + if (occupant === "absent" || occupant === "refused") return null; + 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 `/` separating workspace-relative path segments, as a byte. */ +const SLASH_BYTE = 0x2f; + +/** + * `obstructedComponentOf` over a write path with no plain string form + * (SPEC 12.0) — a derived path of a discovered spec source whose own path + * is not valid UTF-8 (14.19; `discoveredWritePaths`, core/build.ts): its + * workspace-relative directory components, the byte prefixes ending before + * each `/`, examined alike, shallowest first, stopping at the first + * non-directory or missing one. A component with a string form is judged + * exactly as `obstructedComponentOf` judges it; one without is addressed + * by its exact bytes (`/`-separated, as discovery's walk addresses such a + * path) and, obstructed, concerned in the byte form (SPEC 12.7), its + * refused kind read the read failure concerning it (SPEC 14.25). + */ +async function obstructedByteComponentOf( + root: string, + bytes: Uint8Array, +): Promise<ObstructedComponent | null> { + for ( + let end = bytes.indexOf(SLASH_BYTE); + end !== -1; + end = bytes.indexOf(SLASH_BYTE, end + 1) + ) { + const component = pathTextOf(bytes.subarray(0, end)); + const occupant = + typeof component === "string" + ? await componentOccupant(root, component) + : await probeByteOccupant(root, component); + if (occupant === "absent" || occupant === "refused") return null; + if (occupant !== "directory") return { component, occupant }; + } + return null; +} + +/** + * `probeOccupant` for a workspace-relative path with no plain string form, + * addressed by its exact bytes: a kind read the environment refuses is the + * read failure concerning the path in the byte form (SPEC 14.25, 12.7). + */ +async function probeByteOccupant( + root: string, + rel: PathBytes, +): Promise<PathOccupant> { + try { + return await classifyOccupant( + Buffer.concat([ + Buffer.from(root), + Buffer.from("/"), + Buffer.from(rel.bytes), + ]), + ); + } catch (error) { + if (isFilesystemFailure(error)) throw readFailure(rel, "kind", error); + throw error; + } +} + +/** + * SPEC 13.4, the read side ("Reads traverse none either"): whether reads + * may list the directory at the workspace-relative path `rel` — true + * exactly when `rel` itself and every workspace-relative directory + * component above it are occupied by directories, each judged by `lstat` + * shallowest first, so a symbolic link is judged itself and never + * traversed, whatever it targets. Absent, occupied by anything other than + * a directory — a plain file, a symbolic link, any other non-directory + * occupant — or lying below such an occupant, the directory holds nothing + * (14.25's absence, never its refusal): no read lists through it, and the + * caller reads nothing there. For `.xspec/reviews`, that is the session + * directory holding no sessions (SPEC 10.1). Components above the + * workspace root are unrestricted (SPEC 13.4) and never examined. A kind + * read the environment refuses is the read failure concerning the + * component read (SPEC 14.25, `probeOccupant`) — except the graph-data + * area's own path, whose refused kind read is the state of condition 23 + * (SPEC 14.25, `graphDataAreaOccupant`): nothing below it is read, as + * below an area path holding no directory (SPEC 13.4). + */ +export async function readableDirectory( + root: string, + rel: string, +): Promise<boolean> { + for (const component of [...directoryComponents(rel), rel]) { + const occupant = await componentOccupant(root, component); + if (occupant !== "directory") return false; + } + return true; +} + +/** + * SPEC 13.4, the read side, for one file: what occupies the + * workspace-relative path `rel` as reads see it. Below a workspace-relative + * directory component occupied by anything other than a directory — a + * plain file, a symbolic link whatever it targets, any other non-directory + * occupant — nothing is read and the path holds nothing (14.25's absence, + * never its refusal), so it classifies "absent" without being probed + * through the component; otherwise the path's own occupant, judged by + * `lstat` (`probeOccupant`). For `.xspec/journal`, that is the journal + * below an area path holding no directory: empty (SPEC 6.1) and unoccupied + * to the inventory (SPEC 11.6). A kind read the environment refuses — the + * path's own or a component's — is the read failure concerning the path + * read (SPEC 14.25: the journal's and a session file's kind included), + * the graph-data area's own path excepted (`readableDirectory`: the state + * of condition 23, nothing below it read). + */ +export async function readableOccupant( + root: string, + rel: string, +): Promise<PathOccupant> { + if (!(await readsReach(root, rel))) return "absent"; + return probeOccupant(root, rel); +} + +/** + * SPEC 13.4, the read side, for one path: whether reads reach the + * workspace-relative path `rel` — true exactly when every + * workspace-relative directory component of `rel` is occupied by a + * directory, each judged by `lstat` shallowest first (`readableDirectory` + * over its parent), so a symbolic link is judged itself and never + * traversed, whatever it targets. False below a component that is absent + * or occupied by anything other than a directory — a plain file, a + * symbolic link, any other non-directory occupant: nothing is read there, + * and the path holds nothing (14.25's absence, never its refusal). The + * path's own occupant is not examined; a top-level path has no directory + * component and is always reached. A kind read the environment refuses + * is `readableDirectory`'s: the read failure concerning the component + * (SPEC 14.25) — the graph-data area's own path excepted (the state of + * condition 23, nothing below it read). + */ +export async function readsReach(root: string, rel: string): Promise<boolean> { + const components = directoryComponents(rel); + const parent = components[components.length - 1]; + return parent === undefined || (await readableDirectory(root, parent)); +} + +/** + * SPEC 13.4, 14.25: the entry names of the workspace directory at `rel`, + * as a read lists them — the caller has judged the directory listable + * (`readableDirectory`). A directory gone since lists nothing: its + * nonexistence is absence, never a refusal. A listing the environment + * refuses — permission denied, an I/O error — is the read failure + * concerning `rel`, thrown so the command stops at the read. + */ +export async function readableDirectoryEntries( + root: string, + rel: string, +): Promise<string[]> { + return performRead( + rel, + "listing", + () => fsp.readdir(absoluteOf(root, rel)), + () => [], + ); +} + +/** 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)`; + const component = renderPathText(obstructed.component); + return pathFinding( + 22, + `obstructed write path: the workspace-relative directory component ` + + `${component} of a path xspec writes is occupied by ` + + `${occupant}; replace ${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 + * 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), a path with no plain + * string form (a derived path of a source whose path is not valid UTF-8, + * 14.19) examined by its exact bytes in the same order + * (`obstructedByteComponentOf`). 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). + * reports it before modifying anything"); `check` reports the same + * findings without writing (SPEC 14.22). */ -export async function symlinkWritePathFindings( +export async function obstructedWritePathFindings( root: string, - rels: Iterable<string>, + rels: Iterable<PathText>, ): Promise<Finding[]> { - const unique = [...new Set(rels)].sort(compareBytes); - const findings: Finding[] = []; - for (const rel of unique) { - const component = await symlinkComponentOf(root, rel); - if (component !== null) findings.push(symlinkFinding(rel, component)); + const unique = new Map<string, PathText>(); + for (const rel of rels) unique.set(pathTextKey(rel), rel); + const obstructions = new Map<string, ObstructedComponent>(); + for (const rel of [...unique.values()].sort(comparePathTexts)) { + const obstructed = + typeof rel === "string" + ? await obstructedComponentOf(root, rel) + : await obstructedByteComponentOf(root, rel.bytes); + if (obstructed === null) continue; + const key = pathTextKey(obstructed.component); + if (!obstructions.has(key)) obstructions.set(key, obstructed); } - return findings; + return [...obstructions.values()] + .sort((a, b) => comparePathTexts(a.component, b.component)) + .map(obstructionFinding); } /** * Terminal defense shared by the write primitives: verify no * workspace-relative directory component of `rel` is a symbolic link - * (callers report SPEC 14.22 gracefully before ever calling a write), then - * create the missing parent directories. Throws on a symlinked component - * and on a component occupied by a non-directory, which no directory - * creation can cure. + * (callers report SPEC 14.22 gracefully before ever calling a write). + * Throws on a symlinked component and on a component occupied by a + * non-directory, which no directory creation can cure; the missing + * components below the last existing one are the write's own to create + * (`createParentDirectories`). Every component is judged by `probeOccupant` + * — the graph-data area's own path included: a write never passes through + * a component it cannot judge (SPEC 13.4), so a refused kind read here is + * the read failure, the command stopping at it (SPEC 14.25). */ -async function ensureWritableParent(root: string, rel: string): Promise<void> { +async function assertUnobstructedParent( + root: string, + rel: string, +): Promise<void> { for (const component of directoryComponents(rel)) { - const occupant = await classifyOccupant(absoluteOf(root, component)); + const occupant = await probeOccupant(root, component); if (occupant === "absent") break; // mkdir supplies the rest if (occupant === "symlink") { throw new Error( @@ -182,7 +538,41 @@ async function ensureWritableParent(root: string, rel: string): Promise<void> { ); } } - await fsp.mkdir(path.dirname(absoluteOf(root, rel)), { recursive: true }); +} + +/** + * Bring the nonexistent directory components of a write path into + * existence as directories (SPEC 13.4: a missing intermediate directory + * never refuses or fails a write) — part of the write itself, so a refused + * creation is the write's own failure (SPEC 14.24). + */ +async function createParentDirectories(absolute: string): Promise<void> { + await fsp.mkdir(path.dirname(absolute), { recursive: true }); +} + +/** + * SPEC 14.24: perform one write's filesystem mutations, turning any failure + * the filesystem reports for them — permission denied, a read-only + * filesystem, exhausted storage, any other — into the write failure + * concerning `concerned`, thrown so the command stops at this write: no + * later write is attempted, and every earlier one stays complete (SPEC + * 13.5). The occupant classifications around a write are reads, never run + * through here. Anything else — a defect of the product's own — propagates + * unchanged. + */ +async function performWrite<T>( + concerned: string, + write: RefusedWrite, + mutation: () => Promise<T>, +): Promise<T> { + try { + return await mutation(); + } catch (error) { + if (isFilesystemFailure(error)) { + throw writeFailure(concerned, write, error); + } + throw error; + } } let temporaryCounter = 0; @@ -213,28 +603,42 @@ function contentBytes(content: Uint8Array | string): Uint8Array { * replaced as itself — rename never follows the destination — and nothing * is ever written through it (SPEC 13.4). A directory occupant, which * rename cannot replace, is removed and the rename retried: derived-file - * paths belong to xspec, whatever exists at them (SPEC 13.4). + * paths belong to xspec, whatever exists at them (SPEC 13.4). On any + * failure — the temp file's own write included, which exhausted storage + * can cut short — the temp file is removed, best effort, and the failure + * rethrown: the target keeps its prior state (SPEC 13.5), and the failure + * is the write's to report (SPEC 14.24). */ async function replaceWithFile( absolute: string, content: Uint8Array | string, ): Promise<void> { const temporary = temporaryPathBeside(absolute); - await fsp.writeFile(temporary, contentBytes(content)); + try { + await fsp.writeFile(temporary, contentBytes(content)); + await renameOnto(temporary, absolute); + } catch (error) { + // Never leave the temp behind; where even its removal is refused, the + // write's own failure is still the one reported. + await fsp.rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +/** + * Rename the complete temp file onto its target (SPEC 13.5), replacing a + * directory occupant — which rename cannot replace — by removing it and + * retrying. Where the target's occupant cannot even be classified, the + * rename's own failure stands. + */ +async function renameOnto(temporary: string, absolute: string): Promise<void> { try { await fsp.rename(temporary, absolute); } catch (renameError) { - try { - if ((await classifyOccupant(absolute)) === "directory") { - await fsp.rm(absolute, { recursive: true, force: true }); - await fsp.rename(temporary, absolute); - return; - } - throw renameError; - } catch (error) { - await fsp.rm(temporary, { force: true }); // never leave the temp behind - throw error; - } + const occupant = await classifyOccupant(absolute).catch(() => null); + if (occupant !== "directory") throw renameError; + await fsp.rm(absolute, { recursive: true, force: true }); + await fsp.rename(temporary, absolute); } } @@ -244,16 +648,28 @@ 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. A write the environment refuses is the + * write failure concerning `rel` — or, for a graph-data file, the + * graph-data area `area` it lies in, no path inside the area named (SPEC + * 14.24, 11.6). */ export async function writeDerivedFile( root: string, rel: string, content: Uint8Array | string, + area?: string, ): Promise<void> { - await ensureWritableParent(root, rel); - await replaceWithFile(absoluteOf(root, rel), content); + await assertUnobstructedParent(root, rel); + const absolute = absoluteOf(root, rel); + await performWrite( + area ?? rel, + area === undefined ? "write" : "graph-data", + async () => { + await createParentDirectories(absolute); + await replaceWithFile(absolute, content); + }, + ); } /** @@ -262,15 +678,20 @@ export async function writeDerivedFile( * its observable effect (SPEC 13.5), like every product write. The path * holds a discovered source — a plain file — and its rewritten content * replaces it; callers have validated the write path (SPEC 14.22) and run - * under workspace exclusivity (SPEC 13.5). + * under workspace exclusivity (SPEC 13.5). A refused write is the write + * failure concerning `rel` (SPEC 14.24). */ export async function writeSourceFile( root: string, rel: string, content: Uint8Array | string, ): Promise<void> { - await ensureWritableParent(root, rel); - await replaceWithFile(absoluteOf(root, rel), content); + await assertUnobstructedParent(root, rel); + const absolute = absoluteOf(root, rel); + await performWrite(rel, "write", async () => { + await createParentDirectories(absolute); + await replaceWithFile(absolute, content); + }); } /** @@ -279,15 +700,39 @@ 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. A refused removal is the write failure concerning `rel` — a + * relocation's second write, concerning the origin (SPEC 14.24, 13.5). */ export async function removeSourceFile( root: string, rel: string, ): Promise<void> { - if ((await symlinkComponentOf(root, rel)) !== null) return; - await fsp.rm(absoluteOf(root, rel), { force: true }); + if ((await obstructedComponentOf(root, rel)) !== null) return; + const absolute = absoluteOf(root, rel); + await performWrite(rel, "remove", () => fsp.rm(absolute, { force: true })); +} + +/** + * Perform a rewriting operation's source writes (SPEC 6.4, 6.5) in the + * order SPEC 13.5 pins (core/edits.ts `orderSourceWrites`), each atomic in + * its observable effect (SPEC 13.5); a write the environment refuses stops + * the operation there, the writes before it complete and none after it + * attempted (SPEC 14.24). + */ +export async function performSourceWrites( + root: string, + writes: readonly SourceWrite[], +): Promise<void> { + for (const write of writes) { + if (write.kind === "write") { + await writeSourceFile(root, write.path, write.content); + } else { + await removeSourceFile(root, write.path); + } + } } /** @@ -299,20 +744,25 @@ 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. A refused removal + * is the write failure concerning `rel` (SPEC 14.24). */ export async function removeDerivedFile( root: string, rel: string, ): Promise<void> { - if ((await symlinkComponentOf(root, rel)) !== null) return; + if ((await obstructedComponentOf(root, rel)) !== null) return; const absolute = absoluteOf(root, rel); - const occupant = await classifyOccupant(absolute); + const occupant = await probeOccupant(root, rel); if (occupant === "absent") return; - await fsp.rm(absolute, { - recursive: occupant === "directory", - force: true, - }); + await performWrite(rel, "remove", () => + fsp.rm(absolute, { + recursive: occupant === "directory", + force: true, + }), + ); } /** @@ -323,10 +773,10 @@ export async function removeDerivedFile( * is the terminal defense. */ async function requireDurableWritable( - absolute: string, + root: string, rel: string, ): Promise<void> { - const occupant = await classifyOccupant(absolute); + const occupant = await probeOccupant(root, rel); if (occupant !== "absent" && occupant !== "file") { throw new Error( `cannot write the durable file ${rel}: its path is occupied by ` + @@ -341,54 +791,57 @@ async function requireDurableWritable( * Write a durable file (SPEC 13.4: the journal, review sessions) atomically * (SPEC 13.5), by its owning command only. The path must hold a plain file * or nothing: any other occupant refuses the write (SPEC 13.4; terminal - * defense — the read side reports it as 14.13/14.21 first). + * defense — the read side reports it as 14.13/14.21 first). A write the + * environment refuses is the write failure concerning `rel` (SPEC 14.24). */ export async function writeDurableFile( root: string, rel: string, content: Uint8Array | string, ): Promise<void> { - await ensureWritableParent(root, rel); + await assertUnobstructedParent(root, rel); const absolute = absoluteOf(root, rel); - await requireDurableWritable(absolute, rel); - await replaceWithFile(absolute, content); + await performWrite(rel, "write", () => createParentDirectories(absolute)); + await requireDurableWritable(root, rel); + await performWrite(rel, "write", () => replaceWithFile(absolute, content)); } /** - * Append to a line-oriented durable file (SPEC 6.1: the journal is - * append-only and comes into existence with the first append; SPEC 13.4: - * line-oriented so concurrent additions merge textually). Atomic in its - * observable effect (SPEC 13.5): the first append — the file absent — - * creates it as a complete file (temp beside the target, one rename), so a - * concurrent reader only ever observes absence or the complete first entry, - * never an empty or partial file (opening with O_CREAT and then writing - * would expose an empty file between the two). Appending callers run under - * workspace exclusivity (SPEC 13.5), so no concurrent appender races the - * absence classification. Later appends are one O_APPEND write of the - * complete bytes. The same non-plain-occupant refusal applies as for - * `writeDurableFile`. + * Append `addition` to a line-oriented durable file (SPEC 6.1: the journal + * is append-only and comes into existence with the first append; SPEC 13.4: + * line-oriented so concurrent additions merge textually), atomic in its + * observable effect (SPEC 13.5): the file's content after the append — + * `prior`, the content the caller validated (null: the file absent), then + * `addition` — replaces the file as one complete file, the temp file beside + * it and then one rename (`replaceWithFile`), the first append's creation + * and every later append alike. So at every moment a concurrent reader + * observes the prior state or the complete new content, and an append the + * environment refuses — exhausted storage cutting the temp file's write + * short included — or an interrupted command leaves the file byte-for-byte + * as it was, never a partial line (SPEC 13.5, 14.24; the journal append + * is the operation's commit point, so an operation stopped there leaves + * no entry, 13.5). Writing the addition in place would not be: an + * O_APPEND write that exhausted storage cuts short leaves part of the + * line, and a reader can observe it partly written. + * The content is composed from `prior`, not read again: appending callers + * run under workspace exclusivity (SPEC 13.5), so no rival appender changes + * what they validated — "the workspace it validates is the one it + * rewrites" — and the append makes no read the environment could refuse + * past the operation's earlier writes. The same non-plain-occupant refusal + * applies as for `writeDurableFile`, and an append the environment refuses + * is the write failure concerning `rel` (SPEC 14.24). */ export async function appendDurableFile( root: string, rel: string, - content: Uint8Array | string, + prior: Uint8Array | null, + addition: Uint8Array | string, ): Promise<void> { - await ensureWritableParent(root, rel); + await assertUnobstructedParent(root, rel); const absolute = absoluteOf(root, rel); - await requireDurableWritable(absolute, rel); - const bytes = Buffer.from(contentBytes(content)); - if ((await classifyOccupant(absolute)) === "absent") { - await replaceWithFile(absolute, bytes); - return; - } - const handle = await fsp.open(absolute, "a"); - try { - let written = 0; - while (written < bytes.length) { - const result = await handle.write(bytes, written); - written += result.bytesWritten; - } - } finally { - await handle.close(); - } + await performWrite(rel, "append", () => createParentDirectories(absolute)); + await requireDurableWritable(root, rel); + const added = contentBytes(addition); + const bytes = prior === null ? added : Buffer.concat([prior, added]); + await performWrite(rel, "append", () => replaceWithFile(absolute, bytes)); } diff --git a/test/fixtures/conf-avail/bin-nofile.mjs b/test/fixtures/conf-avail/bin-nofile.mjs new file mode 100644 index 00000000..c7f1c41d --- /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 00000000..714d9e99 --- /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 00000000..d7f1b895 --- /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 00000000..fcc59d72 --- /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 00000000..99dae785 --- /dev/null +++ b/test/fixtures/conf-avail/product.mjs @@ -0,0 +1,2736 @@ +// 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, resolved lexically: +// a non-canonical specifier designates the same discovered source), `d` +// props, and `{text(...)}` embeddings as the in-scope fixtures stage +// them; a spec source unparseable by encoding — a file beginning with a +// byte-order mark (1.6), its 14.20 the zero-length range at offset 0 — +// masked: no view, its finding accompanying only when it is itself +// requested, an import designating it valid with the plain target, an +// embedding into it the embedding file's own 14.6 (T11.4-4's +// masked-target arm); one code group (7.2) only as T11.4-4's +// wrong-kind-target arm stages it — its glob matching one `.mdx` file no +// spec glob matches, a discovered code source known by path alone (a +// specifier designating it names no spec source, 14.15; no view domain +// holds it; its content is never read); no `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 14.20 at offset 0 for the +// byte-order-mark file); and the exit discipline of 11.2 +// (any finding or explicitly-unavailable datum in the emitted answer → +// exit 1 with the full answer still emitted; complete and finding-free → +// exit 0). Graph data and refresh behavior are out of scope: the two +// commands read sources and write NOTHING. +// +// Key mechanisms: +// - Configuration, glob matching, and discovery are ports of the CONF-MD / +// CONF-DISC fixtures' machinery (SPEC 7): patterns resolve relative to the +// configuration file's directory; `*`, `?`, `**`, the dot-segment rule, +// byte-wise case-sensitive matching, every other character a literal; +// discovery walks plain files (symbolic links never discovered, never +// traversed) and applies the 13.4 derived-path exclusion. +// - Sources are scanned by an MDX-lite parser for exactly the scope's +// constructs: spec-module import declarations at MDX ESM block positions +// (file start, after a blank line, or continuing a run of import lines — +// an `import` line inside a paragraph is prose, never a declaration), +// `<S>`/`<Spec>` sections (paired and self-closing) with every spelled +// attribute recorded `{name, range, text}` in tag order (quoted, braced, +// valueless, and spread forms alike), invalid non-section elements +// (`<div>`, `<em>`, …: 14.16 — no view node, content preserved +// byte-for-byte, sections inside them parenting to the innermost +// enclosing SECTION construct), MDX comments `{/* … */}`, and +// `{text(...)}` embeddings (local string and external property-chain +// forms). Unbalanced or malformed construct syntax is 14.20 (masking the +// file's other conditions; the file contributes no view). +// - Identity (11.2): a section SPELLS an identity exactly when exactly one +// `id` attribute occurs on its tag with a quoted static-string value — +// repeated (agreeing or not), braced, and valueless forms spell none +// (14.17; absence alone is 14.1). A node identity is DEFINED exactly when +// the file's path is valid and every section of its positional chain +// (itself and each enclosing section) spells a well-formed (1.4), +// structurally conformant (1.3; masked where the parent spells none) +// identity, and the section's OWN spelled identity is spelled by no other +// section of the file (uniqueness contests spelled identities only — +// duplication is not a chain condition, and an invalid `id` form contests +// nothing). Roots: identity is the workspace-relative path. +// - Resolution (11.2): a local spelling names the sections of its own file +// spelling exactly that identity; an external spelling names them through +// a valid default-binding import's resolved target (an empty chain names +// the target's root). The reference resolves exactly when it names +// exactly one target whose own node identity is defined; it then records +// an occurrence (5.7) — `file`, its own `range` (the string literal +// quotes included for a local `d` entry, the property chain's characters +// for an external one, the whole braced container for an embedding), +// `kind`, `source` (the enclosing section's `{identity, range}` or the +// unavailability marker where 11.2 leaves that identity undefined — one +// datum, never null, never a picked bearer), and `target`. A +// non-resolving spelling records nothing and is reported by its finding +// (14.5 for `d`, 14.6 for `text(...)`, located at the reference). +// - Cycles (5.3, 14.9): strongly connected components over the recorded +// reference edges (self-loops included); one finding per cycle, locating +// every participating reference spelling. +// - Text (11.2, 1.6, 3): own and subtree text ride the CONF-MD fixture's +// attributed line-model compile — removals (import declarations by FORM, +// section tags, MDX comments) deleted in place, embedding containers +// replaced by their targets' subtree texts, and a line that contained +// non-whitespace in the source but is left empty or whitespace-only +// purely by removals dropped with its terminator. A value is defined +// exactly when every embedding its expansion transitively reaches +// records an occurrence and the recursion re-enters no node already +// being expanded — one unresolved spelling or one cycle on the expansion +// path poisons the WHOLE value (the unavailability marker; partial +// expansion never occurs). Same-file embedding targets close before +// their embeddings in every staged fixture; a self, enclosing, or +// forward same-file target is always poisoned (cycle or staging outside +// the scope), so its fabricated empty expansion is never read. +// - Emission (12.0, 12.7): both commands are JSON-only — one JSON document +// is the entire stdout, with or without `--json`, serialized with +// byte-sorted keys; findings carry exactly {"code", "message", +// "locations", "path", "identities"} with SPEC 14's stable tokens, in the +// pinned 12.7 order with identical findings collapsed; the exit code is +// computed from the pre-serialization document (findings present, or any +// unavailability marker in the answer → 1; else 0) so the datum-form +// deviations below change bytes, never exits. +// +// Determinism (SPEC 12.0): no wall clock, no randomness, no absolute paths +// in any output; files in byte order of workspace-relative path; all JSON +// serialized with byte-sorted keys. +// +// Deviation seam: runXspec(argv, cwd, options) assigns `options` onto the +// module-level `deviations` switches (all off = this conformer). Each +// VIOL-AVAIL-* violator entry is a bin-<name>.mjs passing exactly one +// switch, consumed at the hook points pinned below: +// - §VIOL-AVAIL-NULLMARKER (bin-nullmarker.mjs): `nullMarkers`, consumed +// in `materializeValue` — the single serialization point every emitted +// document passes through — carrying every undefined datum as `null` in +// place of {"unavailable": true}. Which data are undefined, all defined +// values, findings, exit codes, and every other member are unchanged +// (the exit scan reads the pre-serialization document). +// - §VIOL-AVAIL-OMIT (bin-omit.mjs): `omitNullMembers`, consumed in +// `materializeValue` — every object member whose value would be the +// stated `null` is absent from the emitted document. Members with +// plain, marker, or list values, which findings exist, and exit codes +// are unchanged. +// - §VIOL-AVAIL-NOFILE (bin-nofile.mjs): `ignoreFileRestriction`, +// consumed in `commandOccurrences`' domain computation — the `--file` +// flag and its argument are still accepted as specified, but the +// consulted domain is the entire discovered set, exactly as with the +// flag absent; `--to` selection, `view`, and every other behavior are +// unchanged. + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// Outcome carriers and deviation switches +// --------------------------------------------------------------------------- + +/** + * Usage or configuration error (SPEC 12.0 exit 2): message on stderr; the + * served surfaces are JSON-only, so the single 12.7 error document is the + * entire stdout whenever one of them errs (12.0). `code`/`path` are the + * error finding's stable code and concerned path — set for configuration + * errors (14.14), `null` for plain usage errors (SPEC 12.7). + */ +class UsageError extends Error { + /** @param {string} message + * @param {{ code?: string | null, path?: string | null }} [finding] */ + constructor(message, { code = null, path = null } = {}) { + super(message); + this.code = code; + this.path = path; + } +} + +/** See the module header for the three switches and their hook points. */ +let deviations = {}; + +// --------------------------------------------------------------------------- +// The unavailability marker (SPEC 12.7) as an in-memory sentinel +// --------------------------------------------------------------------------- + +/** + * The one in-memory sentinel every undefined datum is carried as until + * serialization. Reference-compared (`value === UNAVAILABLE`), so no data + * value can collide with it; `materializeValue` renders it as the literal + * 12.7 marker — or as `null` under §VIOL-AVAIL-NULLMARKER's switch. + */ +const UNAVAILABLE = Object.freeze({ unavailableSentinel: true }); + +/** Whether the pre-serialization document carries any unavailable datum. */ +function containsUnavailable(value) { + if (value === UNAVAILABLE) return true; + if (Array.isArray(value)) return value.some(containsUnavailable); + if (value !== null && typeof value === "object") { + return Object.values(value).some(containsUnavailable); + } + return false; +} + +/** + * Render a document value for emission: byte-sorted keys (SPEC 12.0 + * determinism), the marker sentinel as the literal 12.7 form. The two + * datum-form deviation switches hook exactly here (module header): + * `nullMarkers` (§VIOL-AVAIL-NULLMARKER) carries the sentinel as `null`; + * `omitNullMembers` (§VIOL-AVAIL-OMIT) drops every object member whose + * rendered value is `null` (list elements are never members and stay). + */ +function materializeValue(value) { + if (value === UNAVAILABLE) { + return deviations.nullMarkers ? null : { unavailable: true }; + } + if (Array.isArray(value)) return value.map(materializeValue); + if (value !== null && typeof value === "object") { + /** @type {Record<string, unknown>} */ + const out = {}; + for (const key of Object.keys(value).sort()) { + const rendered = materializeValue(value[key]); + if (rendered === null && deviations.omitNullMembers) continue; + out[key] = rendered; + } + return out; + } + return value; +} + +/** Serialize one emitted document (the entire stdout, SPEC 12.0). */ +function renderDocument(doc) { + return JSON.stringify(materializeValue(doc)) + "\n"; +} + +// --------------------------------------------------------------------------- +// Configuration (SPEC 7): upward search + declarative literal parse +// --------------------------------------------------------------------------- + +const CONFIG_NAME = "xspec.config.ts"; + +/** + * The anchoring form of SPEC 11.6/14 for a path identified relative to the + * invocation working directory (used by configuration-error findings). + */ +function anchoringPath(cwd, absPath) { + const rel = path.relative(path.resolve(cwd), absPath); + if (rel === "") return "."; + return rel.split(path.sep).join("/"); +} + +async function pathOccupied(absPath) { + try { + await fsp.lstat(absPath); + return true; + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } +} + +async function findConfigPath(cwd, configFlag) { + if (configFlag !== undefined) { + const abs = path.resolve(cwd, configFlag); + if (!(await pathOccupied(abs))) { + throw new UsageError( + `configuration file not found: --config ${configFlag}`, + { code: "configuration-error", path: anchoringPath(cwd, abs) }, + ); + } + return abs; + } + let dir = path.resolve(cwd); + for (;;) { + const candidate = path.join(dir, CONFIG_NAME); + if (await pathOccupied(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) { + throw new UsageError( + `configuration error: no ${CONFIG_NAME} found by upward search from the working directory`, + { code: "configuration-error", path: "." }, + ); + } + dir = parent; + } +} + +/** + * Parse the declarative configuration (SPEC 7): exactly an import of + * `defineConfig` from "xspec" (optionally aliased) and a default export of + * one call whose sole argument is statically literal. Returns the argument + * as data. Any other form is a configuration error (SPEC 14.14, exit 2). + */ +function parseConfigSource(text) { + const importMatch = + /import\s*\{\s*defineConfig(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*\}\s*from\s*(["'])xspec\2\s*;?/.exec( + text, + ); + if (!importMatch) { + throw new UsageError( + 'configuration error: xspec.config.ts must import { defineConfig } from "xspec" (SPEC 7, 14.14)', + ); + } + const binding = importMatch[1] ?? "defineConfig"; + const callMatch = new RegExp( + `export\\s+default\\s+${binding.replace(/\$/g, "\\$")}\\s*\\(`, + ).exec(text); + if (!callMatch) { + throw new UsageError( + "configuration error: xspec.config.ts must default-export one defineConfig(...) call (SPEC 7, 14.14)", + ); + } + const parser = new LiteralParser(text, callMatch.index + callMatch[0].length); + const value = parser.parseValue(); + parser.skipWs(); + if (parser.text[parser.pos] !== ")") { + throw new UsageError( + "configuration error: the defineConfig argument must be one static literal (SPEC 7, 14.14)", + ); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new UsageError( + "configuration error: defineConfig takes an object literal (SPEC 7)", + ); + } + return value; +} + +/** Recursive-descent parser for the static-literal subset of SPEC 7. */ +class LiteralParser { + constructor(text, pos) { + this.text = text; + this.pos = pos; + } + + fail(what) { + throw new UsageError( + `configuration error: ${what} at offset ${String(this.pos)} (SPEC 7, 14.14)`, + ); + } + + skipWs() { + while (this.pos < this.text.length && /\s/.test(this.text[this.pos])) + this.pos += 1; + } + + parseValue() { + this.skipWs(); + const c = this.text[this.pos]; + if (c === "{") return this.parseObject(); + if (c === "[") return this.parseArray(); + if (c === '"' || c === "'") return this.parseString(); + if (this.text.startsWith("true", this.pos)) { + this.pos += 4; + return true; + } + if (this.text.startsWith("false", this.pos)) { + this.pos += 5; + return false; + } + return this.fail("expected an object, array, string, or boolean literal"); + } + + parseObject() { + this.pos += 1; // "{" + const obj = {}; + this.skipWs(); + if (this.text[this.pos] === "}") { + this.pos += 1; + return obj; + } + for (;;) { + this.skipWs(); + let key; + const c = this.text[this.pos]; + if (c === '"' || c === "'") { + key = this.parseString(); + } else { + const match = /^[A-Za-z_$][\w$]*/.exec(this.text.slice(this.pos)); + if (!match) this.fail("expected an object key"); + key = match[0]; + this.pos += key.length; + } + this.skipWs(); + if (this.text[this.pos] !== ":") + this.fail("expected ':' after an object key"); + this.pos += 1; + obj[key] = this.parseValue(); + this.skipWs(); + if (this.text[this.pos] === ",") { + this.pos += 1; + this.skipWs(); + if (this.text[this.pos] === "}") { + this.pos += 1; + return obj; + } + continue; + } + if (this.text[this.pos] === "}") { + this.pos += 1; + return obj; + } + this.fail("expected ',' or '}' in an object literal"); + } + } + + parseArray() { + this.pos += 1; // "[" + const arr = []; + this.skipWs(); + if (this.text[this.pos] === "]") { + this.pos += 1; + return arr; + } + for (;;) { + arr.push(this.parseValue()); + this.skipWs(); + if (this.text[this.pos] === ",") { + this.pos += 1; + this.skipWs(); + if (this.text[this.pos] === "]") { + this.pos += 1; + return arr; + } + continue; + } + if (this.text[this.pos] === "]") { + this.pos += 1; + return arr; + } + this.fail("expected ',' or ']' in an array literal"); + } + } + + parseString() { + const quote = this.text[this.pos]; + this.pos += 1; + let out = ""; + while (this.pos < this.text.length) { + const c = this.text[this.pos]; + if (c === quote) { + this.pos += 1; + return out; + } + if (c === "\\") { + const next = this.text[this.pos + 1]; + if (next === undefined) break; + if (next === "n") out += "\n"; + else if (next === "t") out += "\t"; + else if (next === "r") out += "\r"; + else out += next; + this.pos += 2; + continue; + } + out += c; + this.pos += 1; + } + return this.fail("unterminated string literal"); + } +} + +/** + * Load and validate the configuration; returns the workspace root and the + * spec groups. The in-scope shape (CERTIFICATIONS.md §CONF-AVAIL) is spec + * groups of glob strings and nothing else — no `code`, `markdown`, + * `coverage`, or `policy` keys; anything else is refused loudly as a + * configuration error rather than half-implemented (SPEC 7, 14.14). + */ +async function loadConfig(cwd, configFlag) { + const configPath = await findConfigPath(cwd, configFlag); + let text; + try { + text = await fsp.readFile(configPath, "utf8"); + } catch (error) { + throw new UsageError( + `configuration error: cannot read ${CONFIG_NAME}: ${error.message}`, + { code: "configuration-error", path: anchoringPath(cwd, configPath) }, + ); + } + const data = parseConfigSource(text); + for (const key of Object.keys(data)) { + if (key !== "specs" && key !== "code") { + 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)", + ); + } + /** Validate one `specs`/`code` map of named glob lists (SPEC 7.1, 7.2). */ + const readGroups = (map, kind, section) => { + /** @type {Record<string, string[]>} */ + const groups = {}; + for (const [name, globs] of Object.entries(map)) { + if (!Array.isArray(globs) || globs.some((g) => typeof g !== "string")) { + throw new UsageError( + `configuration error: ${kind} group ${name} must be a list of glob strings (SPEC ${section})`, + ); + } + 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 groups; + }; + const groups = readGroups(specs, "spec", "7.1"); + // One code group only as T11.4-4's wrong-kind-target arm stages it + // (§CONF-AVAIL): its glob matches an `.mdx` file no spec glob matches — a + // discovered code source, no spec source (SPEC 7.2), whose content no + // in-scope invocation reads. + const code = data.code; + if ( + code !== undefined && + (code === null || typeof code !== "object" || Array.isArray(code)) + ) { + throw new UsageError( + "configuration error: `code` must be a map of groups (SPEC 7.2)", + ); + } + const codeGroups = code === undefined ? {} : readGroups(code, "code", "7.2"); + return { root: path.dirname(configPath), groups, codeGroups }; +} + +// --------------------------------------------------------------------------- +// 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 paths and tag strings (SPEC 12.7, 12.0). */ +function compareRelBytes(a, b) { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +/** + * Discovery (SPEC 7): the spec sources (matched by a spec group's glob) and + * the code sources (matched by a code group's glob), each in byte order of + * path. A file matched by both kinds is a configuration error (SPEC 7.2, + * 14.14) — outside the scope's stagings, refused loudly. + */ +async function discoverSources(root, groups, codeGroups) { + const all = (await walkPlainFiles(root)).sort(compareRelBytes); + const matchedBy = (kindGroups, rel) => + Object.values(kindGroups).some((globs) => + globs.some((glob) => globMatches(glob, rel)), + ); + const specs = []; + const code = []; + for (const rel of all) { + if (isDerivedPath(rel)) continue; + const isSpec = matchedBy(groups, rel); + const isCode = matchedBy(codeGroups, rel); + if (isSpec && isCode) { + throw new UsageError( + `configuration error: ${rel} is matched by both a spec group and a code group (SPEC 7.2, 14.14)`, + { code: "configuration-error", path: null }, + ); + } + if (isSpec) specs.push(rel); + else if (isCode) code.push(rel); + } + return { specs, code }; +} + +// --------------------------------------------------------------------------- +// 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; the caller collapses the tokens to + * the 12.7 tag set — byte order, duplicates collapsed. + */ +function splitTags(value) { + const tokens = []; + let current = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + if (isValidityWhitespace(codePoint)) { + if (current !== "") { + tokens.push(current); + current = ""; + } + } else { + current += character; + } + } + if (current !== "") tokens.push(current); + return tokens; +} + +// --------------------------------------------------------------------------- +// Line model (SPEC 3) and byte offsets (SPEC 1.7) +// --------------------------------------------------------------------------- + +/** The drop rule's whitespace class: exactly SPEC 1.4's (no deviation here). */ +function isDropWhitespaceCode(code) { + return (code >= 0x0009 && code <= 0x000d) || code === 0x0020; +} + +/** True when `text` is empty or consists only of drop-rule whitespace. */ +function isWhitespaceOnlyForDrop(text) { + for (let i = 0; i < text.length; i += 1) { + if (!isDropWhitespaceCode(text.charCodeAt(i))) return false; + } + return true; +} + +/** + * The line terminator starting at `index`, or null: U+000D U+000A is one + * terminator, a lone U+000A one, a lone U+000D one (SPEC 3). + */ +function terminatorAt(text, index) { + const code = text.charCodeAt(index); + if (code === 0x000a) return "\n"; + if (code === 0x000d) { + if (text.charCodeAt(index + 1) === 0x000a) return "\r\n"; + return "\r"; + } + return null; +} + +/** + * Map string (code-unit) indices to UTF-8 byte offsets (SPEC 1.7). ASCII + * sources take the identity fast path; the multi-byte prose prefixes of the + * staged fixtures take the general path. + */ +function byteOffsetMapper(text, byteLength) { + if (byteLength === text.length) return (i) => i; + const offsets = new Array(text.length + 1); + let bytes = 0; + let i = 0; + while (i < text.length) { + offsets[i] = bytes; + const code = text.codePointAt(i); + const units = code > 0xffff ? 2 : 1; + if (units === 2) offsets[i + 1] = bytes; + bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4; + i += units; + } + offsets[text.length] = bytes; + return (index) => offsets[index]; +} + +// --------------------------------------------------------------------------- +// MDX-lite parser: imports, sections with full attribute records, invalid +// elements, comments, `{text(...)}` embeddings +// --------------------------------------------------------------------------- + +/** Inter-attribute whitespace inside a tag (the SPEC 1.4 class, verbatim). */ +const TAG_WHITESPACE = new Set(["\t", "\n", "\v", "\f", "\r", " "]); + +const EMBED_OPEN_RE = /^\{[ \t]*text[ \t]*\(/; +const IDENTIFIER_RE = /^[$_\p{L}][$_\p{L}\p{N}]*/u; +const ATTR_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*/; + +/** + * Parse one source file. Returns + * `{ root, sections, elements, imports, comments, embeds, pieces, failure }`: + * - `root`/`sections`: the positional section tree — per section the + * construct extents (open/close tag index ranges, self-closing flag), + * the positional SECTION parent (invalid element frames are skipped: + * SPEC 11.4's innermost-enclosing-section parenting), and every spelled + * attribute in tag order as `{name, form, value, start, end, valueStart}` + * (name `null` for a spread attribute; `form` one of "quoted", "braced", + * "none", "spread"); + * - `elements`: each invalid non-section element's whole construct extent + * (14.16 — content preserved byte-for-byte, no view node); + * - `imports`: each declaration at an MDX ESM block position with its + * extent, default-binding identifier (or null), binding-form validity, + * and specifier; + * - `comments`: each MDX comment container's extent; + * - `embeds`: each `{text(...)}` container with its extent, reference, and + * owning section (or root); + * - `pieces`: the whole file in document order as content / removal / + * embed pieces for the SPEC 3 compile (invalid elements' tags are + * CONTENT — they match no removal rule's form); + * - `failure`: null, or `{ at, message }` (14.20 — an unparseable source, + * masking the conditions inside). + */ +function parseMdx(text) { + const root = { + isRoot: true, + parent: null, + children: [], + attrs: [], + openStart: 0, + openEnd: 0, + closeStart: text.length, + closeEnd: text.length, + selfClosing: false, + }; + const sections = []; + const elements = []; + const imports = []; + const comments = []; + const embeds = []; + const pieces = []; + /** Frames: sections and invalid elements interleaved (proper nesting). */ + const frames = [{ kind: "section", node: root }]; + /** @type {{ at: number, message: string } | null} */ + let failure = null; + let i = 0; + let contentStart = 0; + // The MDX ESM block rule (SPEC 2.1; the FP-094 lesson): an `import` line + // is a declaration only at a block position — file start, after a blank + // line, or continuing a run of import declarations — and only at top + // level. `importRunUntil` marks the line start reached by consuming a + // declaration plus its terminator. + let importRunUntil = -1; + + const innermostSection = () => { + for (let f = frames.length - 1; f >= 0; f -= 1) { + if (frames[f].kind === "section") return frames[f].node; + } + return root; + }; + const flushContent = (end) => { + if (end > contentStart) { + pieces.push({ + kind: "content", + text: text.slice(contentStart, end), + owner: innermostSection(), + }); + } + }; + const result = () => ({ + root, + sections, + elements, + imports, + comments, + embeds, + pieces, + failure, + }); + const fail20 = (at, message) => { + failure = { at, message }; + }; + + /** Whether `i` is a line start whose PREVIOUS line is blank. */ + const afterBlankLine = (index) => { + if (index === 0) return true; + // The character(s) before `index` must be a terminator; then the line + // before that terminator must be empty or whitespace-only. + let lineEnd = index - 1; + if (text[lineEnd] === "\n" && text[lineEnd - 1] === "\r") lineEnd -= 1; + if (text[lineEnd] !== "\n" && text[lineEnd] !== "\r") return false; + let lineStart = lineEnd; + while ( + lineStart > 0 && + text[lineStart - 1] !== "\n" && + text[lineStart - 1] !== "\r" + ) { + lineStart -= 1; + } + return isWhitespaceOnlyForDrop(text.slice(lineStart, lineEnd)); + }; + + /** Scan a tag's attribute region; record entries when `record` given. */ + const scanAttributes = (start, record) => { + let j = start; + for (;;) { + while (j < text.length && TAG_WHITESPACE.has(text[j])) j += 1; + if (j >= text.length) return { end: -1, selfClosing: false, at: j }; + if (text[j] === ">") return { end: j + 1, selfClosing: false, at: j }; + if (text[j] === "/" && text[j + 1] === ">") { + return { end: j + 2, selfClosing: true, at: j }; + } + if (text[j] === "{") { + // A spread attribute (SPEC 2.7): its `name` is structurally absent + // and its source text is the whole braced construct. + const scanned = scanBracedValue(j); + if (scanned === null) return { end: -1, selfClosing: false, at: j }; + record?.push({ + name: null, + form: "spread", + value: undefined, + start: j, + end: scanned.end, + valueStart: j + 1, + }); + j = scanned.end; + continue; + } + const attr = ATTR_NAME_RE.exec(text.slice(j)); + if (!attr) return { end: -1, selfClosing: false, at: j }; + const name = attr[0]; + const nameStart = j; + j += name.length; + if (text[j] !== "=") { + // Valueless bare-name attribute: the entry is the name alone. + record?.push({ + name, + form: "none", + value: undefined, + start: nameStart, + end: j, + valueStart: j, + }); + continue; + } + j += 1; + const open = text[j]; + if (open === '"' || open === "'") { + const valueStart = j + 1; + const end = text.indexOf(open, valueStart); + if (end === -1) return { end: -1, selfClosing: false, at: j }; + record?.push({ + name, + form: "quoted", + value: text.slice(valueStart, end), + start: nameStart, + end: end + 1, + valueStart, + }); + j = end + 1; + continue; + } + if (open === "{") { + const scanned = scanBracedValue(j); + if (scanned === null) return { end: -1, selfClosing: false, at: j }; + record?.push({ + name, + form: "braced", + value: text.slice(j + 1, scanned.end - 1), + start: nameStart, + end: scanned.end, + valueStart: j + 1, + }); + j = scanned.end; + continue; + } + return { end: -1, selfClosing: false, at: j }; + } + }; + + /** Quote-aware brace scan from an opening `{`; returns { end } or null. */ + const scanBracedValue = (start) => { + let depth = 0; + let k = start; + for (;;) { + if (k >= text.length) return null; + const c = text[k]; + if (c === '"' || c === "'") { + const end = text.indexOf(c, k + 1); + if (end === -1) return null; + k = end + 1; + continue; + } + if (c === "{") depth += 1; + else if (c === "}") { + depth -= 1; + if (depth === 0) return { end: k + 1 }; + } + k += 1; + } + }; + + /** Parse one import declaration at `start`; returns record or null. */ + const parseImportAt = (start) => { + let j = start + "import".length; + const skipSpaces = () => { + while (text[j] === " " || text[j] === "\t") j += 1; + }; + const readString = () => { + const q = text[j]; + if (q !== '"' && q !== "'") return null; + const end = text.indexOf(q, j + 1); + if (end === -1) return null; + const value = text.slice(j + 1, end); + if (/[\r\n]/.test(value)) return null; + j = end + 1; + return value; + }; + skipSpaces(); + let defaultName = null; + let hasNamed = false; + let hasNamespace = false; + let sideEffect = false; + if (text[j] === '"' || text[j] === "'") { + sideEffect = true; // side-effect-only form: no binding clause at all + } else { + const readClause = () => { + if (text[j] === "{") { + const close = text.indexOf("}", j); + if (close === -1) return false; + if (/[\r\n]/.test(text.slice(j, close))) return false; + hasNamed = true; + j = close + 1; + return true; + } + if (text[j] === "*") { + j += 1; + skipSpaces(); + if (!text.startsWith("as", j)) return false; + j += 2; + skipSpaces(); + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident) return false; + hasNamespace = true; + j += ident[0].length; + return true; + } + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident || ident[0] === "from") return false; + defaultName = ident[0]; + j += ident[0].length; + return true; + }; + if (!readClause()) return null; + skipSpaces(); + if (text[j] === ",") { + j += 1; + skipSpaces(); + if (!readClause()) return null; + skipSpaces(); + } + if (!text.startsWith("from", j)) return null; + j += "from".length; + skipSpaces(); + } + const specifier = readString(); + if (specifier === null) return null; + if (text[j] === ";") j += 1; + return { + start, + end: j, + name: defaultName, + // The 2.1 form is a SINGLE default binding: any named clause, + // namespace clause, or side-effect-only spelling is an invalid + // binding form (14.15) — the declaration is still listed (11.4). + formValid: defaultName !== null && !hasNamed && !hasNamespace, + sideEffect, + specifier, + }; + }; + + while (i < text.length) { + const ch = text[i]; + const atLineStart = i === 0 || text[i - 1] === "\n" || text[i - 1] === "\r"; + if ( + ch === "i" && + atLineStart && + frames.length === 1 && + /^import[ \t"'{*]/.test(text.slice(i, i + 8)) && + (i === importRunUntil || afterBlankLine(i)) + ) { + const declaration = parseImportAt(i); + if (declaration === null) { + fail20(i, "malformed import declaration at an ESM block position"); + return result(); + } + flushContent(i); + imports.push(declaration); + pieces.push({ + kind: "removal", + text: text.slice(declaration.start, declaration.end), + }); + i = declaration.end; + contentStart = i; + const terminator = terminatorAt(text, i); + importRunUntil = terminator === null ? -1 : i + terminator.length; + continue; + } + if (ch === "<") { + const closeSection = /^<\/(S|Spec)[ \t\r\n\v\f]*>/.exec(text.slice(i)); + if (closeSection) { + const frame = frames[frames.length - 1]; + if (frame.kind !== "section" || frame.node.isRoot) { + fail20(i, "closing section tag without a matching open section"); + return result(); + } + flushContent(i); + frame.node.closeStart = i; + frame.node.closeEnd = i + closeSection[0].length; + pieces.push({ kind: "removal", text: closeSection[0] }); + frames.pop(); + i = frame.node.closeEnd; + contentStart = i; + continue; + } + const openSection = /^<(S|Spec)(?=[ \t\r\n\v\f/>])/.exec(text.slice(i)); + if (openSection) { + flushContent(i); + /** @type {object[]} */ + const attrs = []; + const scanned = scanAttributes(i + openSection[0].length, attrs); + if (scanned.end === -1) { + fail20(scanned.at, "malformed or unterminated section tag"); + return result(); + } + const node = { + isRoot: false, + parent: innermostSection(), + children: [], + attrs, + openStart: i, + openEnd: scanned.end, + closeStart: scanned.selfClosing ? scanned.end : -1, + closeEnd: scanned.selfClosing ? scanned.end : -1, + selfClosing: scanned.selfClosing, + }; + node.parent.children.push(node); + sections.push(node); + pieces.push({ kind: "removal", text: text.slice(i, scanned.end) }); + if (!scanned.selfClosing) frames.push({ kind: "section", node }); + i = scanned.end; + contentStart = i; + continue; + } + const closeElement = /^<\/([A-Za-z][A-Za-z0-9]*)[ \t\r\n\v\f]*>/.exec( + text.slice(i), + ); + if (closeElement) { + const frame = frames[frames.length - 1]; + if (frame.kind !== "element" || frame.name !== closeElement[1]) { + fail20(i, `mismatched closing tag </${closeElement[1]}>`); + return result(); + } + // The element's whole construct is one invalid construct (14.16): + // located by its finding, no view entry, and CONTENT to the compile + // (it matches no removal rule's form) — so its tags stay in the + // pending content run, preserved byte-for-byte. + elements.push({ start: frame.start, end: i + closeElement[0].length }); + frames.pop(); + i += closeElement[0].length; + continue; + } + const openElement = /^<([A-Za-z][A-Za-z0-9]*)(?=[ \t\r\n\v\f/>])/.exec( + text.slice(i), + ); + if (openElement) { + const scanned = scanAttributes(i + openElement[0].length, null); + if (scanned.end === -1) { + fail20(scanned.at, "malformed or unterminated element tag"); + return result(); + } + if (scanned.selfClosing) { + elements.push({ start: i, end: scanned.end }); + } else { + frames.push({ kind: "element", name: openElement[1], start: i }); + } + i = scanned.end; + continue; + } + i += 1; // a plain `<` is ordinary content in this scope + continue; + } + if (ch === "{") { + if (text.startsWith("{/*", i)) { + const end = text.indexOf("*/}", i + 3); + if (end === -1) { + fail20(i, "unterminated MDX comment"); + return result(); + } + flushContent(i); + comments.push({ start: i, end: end + 3 }); + pieces.push({ kind: "removal", text: text.slice(i, end + 3) }); + i = end + 3; + contentStart = i; + continue; + } + const embedMatch = EMBED_OPEN_RE.exec(text.slice(i)); + if (embedMatch) { + let j = i + embedMatch[0].length; + const skipWs = () => { + while (j < text.length && TAG_WHITESPACE.has(text[j])) j += 1; + }; + skipWs(); + let ref; + const q = text[j]; + if (q === '"' || q === "'") { + const end = text.indexOf(q, j + 1); + if (end === -1) { + fail20(j, "unterminated text(...) string argument"); + return result(); + } + ref = { form: "local", id: text.slice(j + 1, end) }; + j = end + 1; + } else { + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident) { + fail20(j, "malformed text(...) argument"); + return result(); + } + const binding = ident[0]; + j += binding.length; + const segments = []; + for (;;) { + if (text[j] === ".") { + const seg = IDENTIFIER_RE.exec(text.slice(j + 1)); + if (!seg) { + fail20(j, "malformed property chain in text(...)"); + return result(); + } + segments.push(seg[0]); + j += 1 + seg[0].length; + continue; + } + if (text[j] === "[") { + const qq = text[j + 1]; + if (qq !== '"' && qq !== "'") { + fail20(j, "malformed computed access in text(...)"); + return result(); + } + const end = text.indexOf(qq, j + 2); + if (end === -1 || text[end + 1] !== "]") { + fail20(j, "malformed computed access in text(...)"); + return result(); + } + segments.push(text.slice(j + 2, end)); + j = end + 2; + continue; + } + break; + } + ref = { form: "external", binding, segments }; + } + skipWs(); + if (text[j] !== ")") { + fail20(j, "text(...) takes exactly one argument"); + return result(); + } + j += 1; + skipWs(); + if (text[j] !== "}") { + fail20(j, "unterminated text(...) expression container"); + return result(); + } + j += 1; + flushContent(i); + const embed = { + start: i, + end: j, + ref, + owner: innermostSection(), + target: null, + }; + embeds.push(embed); + pieces.push({ + kind: "embed", + text: text.slice(i, j), + owner: embed.owner, + embed, + }); + i = j; + contentStart = i; + continue; + } + i += 1; // a stray `{` is ordinary content in this scope + continue; + } + i += 1; + } + flushContent(text.length); + if (frames.length !== 1) { + const frame = frames[frames.length - 1]; + fail20( + Math.max(0, text.length - 1), + frame.kind === "section" ? "unclosed section tag" : "unclosed element", + ); + } + return result(); +} + +// --------------------------------------------------------------------------- +// `d` reference parsing (SPEC 2.2 — resolution and occurrence positions) +// --------------------------------------------------------------------------- + +/** + * Parse a braced `d` value's body (offsets relative to the body): a single + * static reference or an array literal of them, each a string literal + * (local form — the occurrence spans the literal, quotes included) or a + * property chain rooted at an import binding (external form — the + * occurrence spans the chain's characters). Returns the reference list with + * per-reference `exprStart`/`exprEnd`, or null when malformed. + */ +function parseDReferences(body) { + let j = 0; + const skipWs = () => { + while (j < body.length && TAG_WHITESPACE.has(body[j])) j += 1; + }; + const parseOne = () => { + const exprStart = j; + const q = body[j]; + if (q === '"' || q === "'") { + const end = body.indexOf(q, j + 1); + if (end === -1) return null; + const id = body.slice(j + 1, end); + j = end + 1; + return { form: "local", id, exprStart, exprEnd: j }; + } + const ident = IDENTIFIER_RE.exec(body.slice(j)); + if (!ident) return null; + const binding = ident[0]; + j += binding.length; + const segments = []; + for (;;) { + if (body[j] === ".") { + const seg = IDENTIFIER_RE.exec(body.slice(j + 1)); + if (!seg) return null; + segments.push(seg[0]); + j += 1 + seg[0].length; + continue; + } + if (body[j] === "[") { + const qq = body[j + 1]; + if (qq !== '"' && qq !== "'") return null; + const end = body.indexOf(qq, j + 2); + if (end === -1 || body[end + 1] !== "]") return null; + segments.push(body.slice(j + 2, end)); + j = end + 2; + continue; + } + break; + } + return { form: "external", binding, segments, exprStart, exprEnd: j }; + }; + const refs = []; + skipWs(); + if (body[j] === "[") { + j += 1; + skipWs(); + if (body[j] === "]") { + j += 1; // `d={[]}`: no dependencies (SPEC 2.2) + } else { + for (;;) { + const ref = parseOne(); + if (ref === null) return null; + refs.push(ref); + skipWs(); + if (body[j] === ",") { + j += 1; + skipWs(); + continue; + } + if (body[j] === "]") { + j += 1; + break; + } + return null; + } + } + } else { + const ref = parseOne(); + if (ref === null) return null; + refs.push(ref); + } + skipWs(); + return j >= body.length ? refs : null; +} + +// --------------------------------------------------------------------------- +// Import specifier resolution (SPEC 2.1) +// --------------------------------------------------------------------------- + +/** Import specifier → designated source path, or null where form defines none. */ +function resolveImportTarget(fromRel, specifier) { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) return null; + if (!specifier.endsWith(".xspec")) return null; + const joined = path.posix.normalize( + path.posix.join(path.posix.dirname(fromRel), specifier), + ); + if (joined === ".." || joined.startsWith("../")) return null; + return joined.slice(0, -".xspec".length) + ".mdx"; +} + +// --------------------------------------------------------------------------- +// Workspace analysis: identities, interpreted data, findings, occurrences +// --------------------------------------------------------------------------- + +// SPEC 14's stable code tokens by condition ordinal ("14.N" → token). The +// JSON report carries the token string alone (SPEC 12.7, 14); the ordinal +// orders findings and is no part of the value. Only the conditions this +// conformer's scope reports appear. +const CODE_TOKENS = { + 14.1: "missing-id", + 14.2: "invalid-structural-id", + 14.3: "duplicate-id", + 14.4: "invalid-segment-or-tag", + 14.5: "unknown-dependency", + 14.6: "unknown-text-target", + 14.8: "invalid-argument", + 14.9: "cycle", + 14.15: "invalid-import", + 14.16: "invalid-construct", + 14.17: "invalid-prop", + "14.20": "unparseable-source", +}; + +/** Analyze one discovered source's bytes into a file record. */ +function analyzeFile(rel, bytes) { + const base = { + rel, + bytes, + text: "", + byteOf: (index) => index, + parsed: null, + /** spelling → sections spelling it (uniqueness + resolution). */ + idMap: new Map(), + /** binding identifier → target rel (valid default imports only). */ + bindings: new Map(), + /** per-section derived data (Map section → info). */ + info: new Map(), + failure: null, + }; + // A byte-order mark is judged on the raw bytes (EF BB BF): a UTF-8 + // decoder strips a leading BOM from its output unless told otherwise, so a + // check on the decoded text would never see it. The file is unparseable + // (SPEC 1.6, 14.20), its one zero-length location at offset 0 (SPEC 14) — + // the masked target T11.4-4's masked-target arm stages (§CONF-AVAIL). + if ( + bytes.length >= 3 && + bytes[0] === 0xef && + bytes[1] === 0xbb && + bytes[2] === 0xbf + ) { + return { + ...base, + failure: { + at: 0, + message: `${rel} begins with a byte-order mark (SPEC 1.6)`, + }, + }; + } + let text; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + return { + ...base, + failure: { at: 0, message: `${rel} is not valid UTF-8 (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 discovered = await discoverSources( + config.root, + config.groups, + config.codeGroups, + ); + /** @type {{condition: string, message: string, locations: {file: string, range: {start: number, end: number}}[]}[]} */ + const findings = []; + const files = new Map(); + for (const rel of discovered.specs) { + const bytes = await fsp.readFile(path.join(config.root, ...rel.split("/"))); + files.set(rel, analyzeFile(rel, bytes)); + } + // Discovered code sources: known by path alone — no spec source, so no + // view domain holds one and no `.xspec` specifier designates one (SPEC + // 2.1, 11.4) — their content never read (§CONF-AVAIL's staging + // constraint: no in-scope invocation consults a code source's content). + const codeSources = new Set(discovered.code); + + 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) { + // One zero-length range at the failure's offset (SPEC 14): 0 for a + // byte-order mark or an encoding failure at the file's start — the + // encoding failures the scope admits — and the parser's failure + // index, mapped to bytes, for a syntax failure (out of scope). + const at = byteRange(record, record.failure.at, record.failure.at).start; + addFinding( + "14.20", + `unparseable source: ${record.failure.message} (SPEC 14.20)`, + [{ file: record.rel, range: { start: at, end: at } }], + ); + 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]) }], + ); + } + } + // The 12.7 tag set: UTF-8 byte order (never UTF-16 code-unit + // order, never case-folded), duplicates collapsed. + info.tags = valid + ? [...new Set(tokens)].sort(compareRelBytes) + : 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, codeSources, findings, records, nodeIdentity }; +} + +/** The size of a key's SCC (helper for the cycle pass above). */ +function sccSize(sccOf, key) { + const target = sccOf.get(key); + let size = 0; + for (const value of sccOf.values()) { + if (value === target) size += 1; + } + return size; +} + +// --------------------------------------------------------------------------- +// Attributed compilation (SPEC 3 + 1.6) and expansion definedness (11.2) +// --------------------------------------------------------------------------- + +/** Whether `owner` is `node` or one of its descendants. */ +function ownerWithin(owner, node) { + for (let n = owner; n !== null && n !== undefined; n = n.parent) { + if (n === node) return true; + } + return false; +} + +/** The subtree text of `node` over an atom list (SPEC 1.6). */ +function textOfSubtreeAtoms(atoms, node) { + let out = ""; + for (const atom of atoms) { + if (ownerWithin(atom.owner, node)) out += atom.text; + } + return out; +} + +/** The own text of `node` over an atom list (SPEC 1.6). */ +function textOfOwnAtoms(atoms, node) { + let out = ""; + for (const atom of atoms) { + if (atom.owner === node) out += atom.text; + } + return out; +} + +/** + * Compile one parsed file to attributed output atoms per SPEC 3 — the + * CONF-MD fixture's line model with ownership tracked per atom. + * `expansionFor(piece, atoms)` supplies each embedding's expansion (the + * target's compiled subtree text; the empty string where no complete + * expansion exists — read only from poisoned nodes' values, which are + * emitted as the marker, never these bytes). It receives the running atom + * list, whose finalized lines a same-file backward target's subtree is + * read from (the target closed on an earlier line, so its atoms are final + * by the time its embedding compiles — true of every staged fixture). + */ +function compileAttributed(record, expansionFor) { + const atoms = []; + let survivors = []; + let sourceHadNonWhitespace = false; + let expansionContributed = false; + + const finalizeLine = (terminator, terminatorOwner) => { + let remaining = ""; + for (const survivor of survivors) remaining += survivor.text; + const dropped = + sourceHadNonWhitespace && + !expansionContributed && + isWhitespaceOnlyForDrop(remaining); + if (!dropped) { + for (const survivor of survivors) { + if (survivor.text !== "") atoms.push(survivor); + } + if (terminator !== "") { + atoms.push({ text: terminator, owner: terminatorOwner }); + } + } + survivors = []; + sourceHadNonWhitespace = false; + expansionContributed = false; + }; + + const consumeSourceChunk = (chunk, owner) => { + if (chunk.length === 0) return; + survivors.push({ text: chunk, owner }); + if (!isWhitespaceOnlyForDrop(chunk)) sourceHadNonWhitespace = true; + }; + + for (const piece of record.parsed.pieces) { + if (piece.kind === "content") { + const text = piece.text; + let start = 0; + let i = 0; + while (i < text.length) { + const code = text.charCodeAt(i); + if (code !== 0x0a && code !== 0x0d) { + i += 1; + continue; + } + const terminator = terminatorAt(text, i); + if (terminator === null) { + i += 1; + continue; + } + consumeSourceChunk(text.slice(start, i), piece.owner); + finalizeLine(terminator, piece.owner); + i += terminator.length; + start = i; + } + consumeSourceChunk(text.slice(start), piece.owner); + } else { + // The construct's own characters are source characters of the current + // logical line: their non-whitespace counts for "contained + // non-whitespace in the source". They are deleted — internal + // terminators included. + if (!isWhitespaceOnlyForDrop(piece.text)) sourceHadNonWhitespace = true; + if (piece.kind === "embed") { + const expansion = expansionFor(piece, atoms); + if (expansion.length > 0) { + survivors.push({ text: expansion, owner: piece.owner }); + expansionContributed = true; + } + } + } + } + finalizeLine("", record.parsed.root); + return atoms; +} + +/** + * Per-workspace text engine: expansion definedness (the poisoning rules of + * SPEC 11.2 — a value is defined exactly when every embedding its expansion + * transitively reaches records an occurrence and the recursion re-enters no + * node already being expanded) plus the attributed compile per file. + * Returns per-node own/subtree text datums (a byte-exact string or the + * unavailability sentinel). + */ +function buildTextEngine(ws) { + // subtreeExpansionOk, memoized tri-state: can `node`'s subtree be fully + // expanded? A re-entry while computing is a cycle: poisoned. + const subtreeMemo = new Map(); + const subtreeExpansionOk = (record, node) => { + const memo = subtreeMemo.get(node); + if (memo === "computing") return false; + if (memo !== undefined) return memo; + subtreeMemo.set(node, "computing"); + let ok = true; + for (const embed of record.parsed.embeds) { + if (!ownerWithin(embed.owner, node)) continue; + if (embed.target === null) { + ok = false; + break; + } + if (!subtreeExpansionOk(embed.target.record, embed.target.node)) { + ok = false; + break; + } + } + subtreeMemo.set(node, ok); + return ok; + }; + const ownExpansionOk = (record, node) => { + for (const embed of record.parsed.embeds) { + if (embed.owner !== node) continue; + if (embed.target === null) return false; + if (!subtreeExpansionOk(embed.target.record, embed.target.node)) { + return false; + } + } + return true; + }; + + // Per-file attributed compile, memoized. Cross-file expansions compile + // the target's file first; a same-file target must close before its + // embedding (true of every staged fixture) — a self, enclosing, forward, + // or cross-file-cyclic target yields no expansion, and such an embedding + // is always poisoned (its owner's values are the marker), so the + // fabricated bytes are never read (module header). + const compiled = new Map(); + const inProgress = new Set(); + const compileFile = (rel) => { + const memo = compiled.get(rel); + if (memo !== undefined) return memo; + if (inProgress.has(rel)) return null; // cross-file cycle: poisoned + inProgress.add(rel); + const record = ws.files.get(rel); + const result = compileAttributed(record, (piece, runningAtoms) => { + const target = piece.embed.target; + if (target === null) return ""; + if (target.record.rel === rel) { + // A same-file target must have closed on an earlier line for its + // atoms to be final in the running list; a self, enclosing, or + // forward target yields no expansion and is always poisoned. + if (!(target.node.closeEnd <= piece.embed.start)) return ""; + return textOfSubtreeAtoms(runningAtoms, target.node); + } + const targetAtoms = compileFile(target.record.rel); + if (targetAtoms === null) return ""; + return textOfSubtreeAtoms(targetAtoms, target.node); + }); + inProgress.delete(rel); + compiled.set(rel, result); + return result; + }; + + return { + textsFor(record) { + if (record.failure !== null) return null; + const atoms = compileFile(record.rel) ?? []; + const texts = new Map(); + const nodes = [record.parsed.root, ...record.parsed.sections]; + for (const node of nodes) { + texts.set(node, { + ownText: ownExpansionOk(record, node) + ? textOfOwnAtoms(atoms, node) + : UNAVAILABLE, + subtreeText: subtreeExpansionOk(record, node) + ? textOfSubtreeAtoms(atoms, node) + : UNAVAILABLE, + }); + } + return texts; + }, + }; +} + +// --------------------------------------------------------------------------- +// Findings documents (SPEC 12.7, 14) +// --------------------------------------------------------------------------- + +/** A condition's ordinal (the `N` of `14.N`), ordering findings (SPEC 12.7). */ +function conditionOrdinal(condition) { + return Number(condition.slice(3)); +} + +/** + * The pinned findings order (SPEC 12.7): by code (numbered conditions in + * numeric order — this scope reports no refusal or code-less findings), + * then locations element-wise (file path bytes, range start, range end; a + * proper prefix first), then concerned path (null before any path), then + * identities, then message — this scope's identities are always empty. + */ +function compareFindingDocs(a, b) { + const byOrdinal = + conditionOrdinal(a.internalCondition) - + conditionOrdinal(b.internalCondition); + if (byOrdinal !== 0) return byOrdinal; + const shared = Math.min(a.locations.length, b.locations.length); + for (let i = 0; i < shared; i += 1) { + const byFile = compareRelBytes(a.locations[i].file, b.locations[i].file); + if (byFile !== 0) return byFile; + if (a.locations[i].range.start !== b.locations[i].range.start) { + return a.locations[i].range.start - b.locations[i].range.start; + } + if (a.locations[i].range.end !== b.locations[i].range.end) { + return a.locations[i].range.end - b.locations[i].range.end; + } + } + if (a.locations.length !== b.locations.length) { + return a.locations.length - b.locations.length; + } + if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; + if (a.path !== null && b.path !== null) { + const byPath = compareRelBytes(a.path, b.path); + if (byPath !== 0) return byPath; + } + return Buffer.compare( + Buffer.from(a.message, "utf8"), + Buffer.from(b.message, "utf8"), + ); +} + +/** + * Render internal findings as the 12.7 `findings` array value: one + * `{"code", "message", "locations", "path", "identities"}` per finding — + * every scope condition locates in source, so `path` is null and + * `locations` non-empty, each finding's locations already in file/range + * order — in the pinned order, identical findings collapsed to one. + */ +function findingsValue(findings) { + const docs = findings.map((finding) => ({ + code: CODE_TOKENS[finding.condition], + message: finding.message, + locations: finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + path: null, + identities: [], + internalCondition: finding.condition, + })); + docs.sort(compareFindingDocs); + const collapsed = []; + for (const doc of docs) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindingDocs(previous, doc) === 0) { + continue; + } + collapsed.push(doc); + } + return collapsed.map(({ code, message, locations, path: p, identities }) => ({ + code, + message, + locations, + path: p, + identities, + })); +} + +/** + * The findings of a consulted domain (SPEC 11.2, 11.3, 11.4): a finding + * accompanies exactly the answers whose domain includes a file it locates + * in (every scope condition is located; a cross-file finding accompanies + * when any participant's file is in the domain). + */ +function domainFindings(ws, domain) { + return ws.findings.filter((finding) => + finding.locations.some((location) => domain.has(location.file)), + ); +} + +// --------------------------------------------------------------------------- +// Argument parsing (SPEC 12.0) +// --------------------------------------------------------------------------- + +/** + * Parse flags per command. `flagSpec` maps flag names to "bool" | "value"; + * unknown and repeated flags are usage errors (SPEC 12.0). + */ +function parseArgs(argv, flagSpec, positionalRange) { + const flags = {}; + const positionals = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const kind = flagSpec[arg]; + if (kind === undefined) + throw new UsageError(`unknown flag ${arg} (SPEC 12.0)`); + if (Object.hasOwn(flags, arg)) { + throw new UsageError( + `repeated flag ${arg}: a flag may be given at most once (SPEC 12.0)`, + ); + } + if (kind === "bool") { + flags[arg] = true; + } else { + const value = argv[i + 1]; + if (value === undefined) + throw new UsageError(`missing value for ${arg} (SPEC 12.0)`); + flags[arg] = value; + i += 1; + } + } else { + positionals.push(arg); + } + } + const [min, max] = positionalRange; + if (positionals.length < min || positionals.length > max) { + throw new UsageError( + `expected ${min === max ? String(min) : `${String(min)}-${String(max)}`} argument(s), got ${String(positionals.length)} (SPEC 12.0)`, + ); + } + return { flags, positionals }; +} + +// --------------------------------------------------------------------------- +// `xspec view` (SPEC 11.4) +// --------------------------------------------------------------------------- + +/** + * One per-file view (SPEC 11.4, 12.7): `{"file", "root", "imports", + * "occurrences", "comments"}` — the full positional tree with per-node + * identity/tags/coverage datums (and own/subtree text under `--text`), + * every import declaration, the file's own occurrence records, and the + * comment ranges, all in document order. + */ +function fileViewDoc(ws, record, fileRecords, texts) { + const { parsed } = record; + const nodeDoc = (node) => { + const doc = { + identity: ws.nodeIdentity(record, node), + range: node.isRoot + ? { start: 0, end: record.byteOf(record.text.length) } + : byteRange(record, node.openStart, node.closeEnd), + opening: node.isRoot + ? null + : byteRange(record, node.openStart, node.openEnd), + closing: + node.isRoot || node.selfClosing + ? null + : byteRange(record, node.closeStart, node.closeEnd), + attributes: node.attrs.map((attr) => ({ + name: attr.name, + range: byteRange(record, attr.start, attr.end), + text: record.text.slice(attr.start, attr.end), + })), + tags: node.isRoot ? null : record.info.get(node).tags, + coverage: node.isRoot ? null : record.info.get(node).coverage, + children: node.children.map(nodeDoc), + }; + if (texts !== null) { + const nodeTexts = texts.get(node); + doc.ownText = nodeTexts.ownText; + doc.subtreeText = nodeTexts.subtreeText; + } + return doc; + }; + return { + file: record.rel, + root: nodeDoc(parsed.root), + imports: parsed.imports.map((declaration) => ({ + range: byteRange(record, declaration.start, declaration.end), + name: declaration.name, + target: + declaration.resolvedTarget === null + ? UNAVAILABLE + : declaration.resolvedTarget, + })), + occurrences: fileRecords.map((occurrence) => occurrenceDoc(ws, occurrence)), + comments: parsed.comments.map((comment) => + byteRange(record, comment.start, comment.end), + ), + }; +} + +/** One occurrence record in the 12.7 form (SPEC 5.7, 11.2). */ +function occurrenceDoc(ws, occurrence) { + const sourceIdentity = ws.nodeIdentity( + occurrence.sourceRecord, + occurrence.sourceNode, + ); + return { + file: occurrence.file, + range: { start: occurrence.range.start, end: occurrence.range.end }, + kind: occurrence.kind, + // Source: the graph node `{identity, range}` — or the unavailability + // marker where 11.2 leaves that identity undefined: identity and range + // withheld together as ONE datum, never a picked bearer, never null. + source: + sourceIdentity === UNAVAILABLE + ? UNAVAILABLE + : { + identity: sourceIdentity, + range: occurrence.sourceNode.isRoot + ? { + start: 0, + end: occurrence.sourceRecord.byteOf( + occurrence.sourceRecord.text.length, + ), + } + : byteRange( + occurrence.sourceRecord, + occurrence.sourceNode.openStart, + occurrence.sourceNode.closeEnd, + ), + }, + target: ws.nodeIdentity(occurrence.targetRecord, occurrence.targetNode), + }; +} + +async function commandView(io, cwd, argv) { + const { flags, positionals } = parseArgs( + argv, + { + "--json": "bool", + "--config": "value", + "--text": "bool", + "--file": "value", + }, + [0, Number.POSITIVE_INFINITY], + ); + if (positionals.length > 0 && flags["--file"] !== undefined) { + throw new UsageError( + "`view` takes `<file>` operands or `--file`, not both (SPEC 11.4, 12.0)", + ); + } + const ws = await loadWorkspace(cwd, flags["--config"]); + const discovered = [...ws.files.keys()]; + + // The requested files (SPEC 11.4): operands assert membership in the + // discovered spec-source domain and form a set; `--file` is a set + // restriction over the domain; neither means the whole domain. + let requested; + if (positionals.length > 0) { + const set = new Set(); + for (const operand of positionals) { + if (ws.codeSources.has(operand)) { + // A discovered code source has no structural view: a wrong-kind + // operand, a usage error (SPEC 11.4, 12.0). + throw new UsageError( + `wrong kind: ${operand} is a discovered code source, and \`view\` takes spec sources (SPEC 11.4, 12.0)`, + ); + } + 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"]; + // `--file` admits spec and code sources alike (SPEC 11.3), but a code + // source's occurrences are its TypeScript analysis (4.x), which this + // fixture does not carry: no in-scope staging drives `occurrences` on a + // workspace holding a code source (§CONF-AVAIL), so such a glob is a + // fixture-scope breach, failed loudly rather than answered incompletely. + if (restriction !== undefined) { + for (const rel of ws.codeSources) { + if (globMatches(restriction, rel)) { + throw new Error( + `§CONF-AVAIL scope breach: --file ${restriction} admits the code source ${rel}, whose occurrences this fixture does not analyze`, + ); + } + } + } + const domain = new Set( + restriction === undefined + ? ws.files.keys() + : [...ws.files.keys()].filter((rel) => globMatches(restriction, rel)), + ); + + // The enumeration: the domain files' records, in occurrence order (5.7: + // file path bytes, then range start, then range end), selected by `--to` + // where given (acceptance is syntactic: an empty selection is an answer). + let selected = ws.records.filter((occurrence) => domain.has(occurrence.file)); + if (flags["--to"] !== undefined) { + selected = selected.filter((occurrence) => { + const target = ws.nodeIdentity( + occurrence.targetRecord, + occurrence.targetNode, + ); + return target === flags["--to"]; + }); + } + selected = [...selected].sort( + (a, b) => + compareRelBytes(a.file, b.file) || + a.range.start - b.range.start || + a.range.end - b.range.end, + ); + + const doc = { + findings: findingsValue(domainFindings(ws, domain)), + occurrences: selected.map((occurrence) => occurrenceDoc(ws, occurrence)), + }; + const exitCode = doc.findings.length > 0 || containsUnavailable(doc) ? 1 : 0; + io.stdout(renderDocument(doc)); + return exitCode; +} + +// --------------------------------------------------------------------------- +// Entry: deviation seam + dispatch +// --------------------------------------------------------------------------- + +/** + * Run one xspec invocation. Returns the exit code (SPEC 12.0 partition). + * `options` is the seam through which each violator fixture's bin-<name>.mjs + * entry threads exactly one deviation switch (the conformer's bin.mjs passes + * none); see the `deviations` doc in the module header for where + * §VIOL-AVAIL-NULLMARKER, §VIOL-AVAIL-OMIT, and §VIOL-AVAIL-NOFILE hook. + */ +export async function runXspec(argv, cwd, options = {}) { + deviations = options; + const io = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + }; + return await dispatchCommand(io, cwd, argv); +} + +/** Dispatch one parsed invocation and map its outcome to SPEC 12.0's codes. */ +async function dispatchCommand(io, cwd, argv) { + const command = argv[0]; + // The served surfaces are JSON-only (SPEC 11): JSON output is in effect + // for them whatever the arguments, so their usage errors emit the single + // 12.7 error document; an unknown command emits it only under `--json`. + const jsonInEffect = + command === "view" || command === "occurrences" || argv.includes("--json"); + try { + const rest = argv.slice(1); + switch (command) { + case "view": + return await commandView(io, cwd, rest); + case "occurrences": + return await commandOccurrences(io, cwd, rest); + default: + throw new UsageError( + `unknown command ${String(command)} (SPEC 12.0; this fixture's surface is view and occurrences, CERTIFICATIONS.md §CONF-AVAIL)`, + ); + } + } catch (error) { + if (error instanceof UsageError) { + // Usage/configuration errors (SPEC 12.0): the message is stderr + // content; with JSON output in effect the single 12.7 error document + // — {"error": …} holding one finding form — is the entire stdout. + if (jsonInEffect) { + io.stdout( + renderDocument({ + error: { + code: error.code, + message: error.message, + locations: [], + path: error.path, + identities: [], + }, + }), + ); + } + io.stderr(`xspec: ${error.message}\n`); + return 2; + } + // A crash is a fixture bug: exit outside the 12.0 partition so every + // exit-code assertion fails loudly and the diagnosis carries the stack. + io.stderr( + `xspec: internal fixture error: ${error?.stack ?? String(error)}\n`, + ); + return 70; + } +} diff --git a/test/fixtures/conf-core/bin-earlyrefresh.mjs b/test/fixtures/conf-core/bin-earlyrefresh.mjs new file mode 100644 index 00000000..96efbbbe --- /dev/null +++ b/test/fixtures/conf-core/bin-earlyrefresh.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// VIOL-CORE-EARLYREFRESH violator executable (CERTIFICATIONS.md +// §VIOL-CORE-EARLYREFRESH). The CONF-CORE conformer with exactly one +// behavioral deviation: the 13.3 refresh a mutating `review` subcommand +// performs on a stale workspace (T10.1-1) runs before workspace exclusivity +// is acquired, so stale graph data is rewritten before the hold file is +// created — one ordering rule of 13.5 (the hold precedes every modification, +// the refresh included) broken for the refresh alone; the hold file is still +// created after exclusivity and before every other write, and a workspace +// whose graph data is current is refreshed by nothing. Certifies T13.5-1 +// (C-1): exactly that test fails against this fixture, on its stale-workspace +// arm's while-held compare; every other §CONF-CORE in-scope test passes. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + refreshBeforeExclusivity: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-core/bin-latelock.mjs b/test/fixtures/conf-core/bin-latelock.mjs new file mode 100644 index 00000000..b7e7521f --- /dev/null +++ b/test/fixtures/conf-core/bin-latelock.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// VIOL-CORE-LATELOCK violator executable (CERTIFICATIONS.md +// §VIOL-CORE-LATELOCK). The CONF-CORE conformer with exactly one behavioral +// deviation: workspace exclusivity is acquired late — a mutating command +// acquires it, and creates its hold file, only once the argument checks of +// SPEC 12.0 and baseline resolution (6.3) have passed, instead of before +// them (13.5) — the two checks 12.0 places ahead of source validation, and +// the only ones this deviation moves. The gate and refresh of 13.3, the +// valid-workspace precondition of `rename`/`move` (6.4, 6.5), the +// operation's own validation, and every modification still follow +// acquisition and the hold; a second mutating command is still refused on +// acquisition — now after those two checks. An invocation an argument check +// or baseline resolution refuses exits 2 with that usage error at once, +// having acquired nothing and created no hold file, `--test-hold` or not; +// every invocation passing both checks — the gate's and the precondition's +// refusals included — acquires, holds, and proceeds or is refused exactly as +// the conformer's does. Certifies T13.5-8 (C-1): exactly it fails against +// this fixture, on its two seam-ordering arms refused ahead of the gate +// (`rename specs/A.mdx nope x` and `review create --base <ref> --name n` +// under `--test-hold`); every other §CONF-CORE in-scope test passes. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + lateAcquisition: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-core/product.mjs b/test/fixtures/conf-core/product.mjs index 36417007..db1d938a 100644 --- a/test/fixtures/conf-core/product.mjs +++ b/test/fixtures/conf-core/product.mjs @@ -12,12 +12,31 @@ // 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 SPEC 6.3/12.0). -// - `rename` and file-form `move` with journal append (SPEC 6.1, 6.2). +// - `rename` and file-form `move` with journal append (SPEC 6.1, 6.2), +// their argument checks of 12.0 and valid-workspace precondition (6.4, +// 6.5) judged after acquisition and the hold; `rename --preview` only as +// T13.5-8's non-mutating boundary drives it — the refused form (a +// nonexistent old ID's usage error) acquiring nothing and creating no +// hold file (SPEC 6.6), `--test-hold` beside `--preview` the +// syntax-class usage error of 12.0 — a performable preview lying outside +// this surface (a fixture scope error, exit 70). +// - Validation within scope: the one condition an in-scope failing workspace +// stages — a spec source beginning with a byte-order mark (SPEC 14.20, +// 1.6), its finding the zero-length range at offset 0 (SPEC 14) — is +// detected and reported by `build`, `check`, the gate of 13.3, and the +// precondition of `rename`/`move`, exit 1, nothing written. // - `review` with the `audit` strategy (SPEC 10.6) through `create`, // `resolve`, `split`, and the read subcommands, including read-time -// invalidation over the recorded state of SPEC 10.4. +// invalidation over the recorded state of SPEC 10.4, and — on a workspace +// whose graph data is stale but whose sources are valid — the SPEC 13.3 +// refresh a mutating `review` subcommand performs after the hold and +// before its own writes, writing graph data byte-identical to what +// `build` writes (T13.5-1's stale-workspace arm, T10.1-1). // - SPEC 13.4 durable protection and SPEC 13.5 in full, `--test-hold` -// included. +// included: acquisition and the hold precede every later check — the +// argument checks of 12.0, baseline resolution (6.3), the gate and refresh +// of 13.3, and the operation's own validation (T13.5-8) — so the seam +// engages on an invocation a later check refuses or the gate turns back. // // Key mechanisms: // - Exclusivity (SPEC 13.5): a lock file in the OS temp directory keyed by @@ -31,6 +50,12 @@ // with O_EXCL (anything already there — a symbolic link included — fails // the command exit 2 without modifying anything); the command proceeds // only once that file has been deleted. +// - 13.3 refresh (SPEC 13.3): a mutating `review` subcommand, after the hold +// and before its own writes, compares the stored graph data against the +// current sources (the recorded derived-file paths excluded) and rewrites +// stale graph data alone, exactly as `build` writes it — see +// refreshStaleGraphData; `rename` and `move` regenerate every derived file +// at their end instead (SPEC 6.4, 6.5). // - Atomic visibility (SPEC 13.5): every derived and durable write goes // through a temp file in the target's directory renamed over the target, // so a concurrent reader observes prior content, complete new content, or @@ -81,6 +106,14 @@ class FindingsError extends Error { */ class RefusalError extends Error {} +/** + * An invocation outside this fixture's certified surface (CERTIFICATIONS.md + * §CONF-CORE): refused loudly with exit 70 — outside the 12.0 partition — + * never answered and never misreported as a usage error, so a test driving + * it fails on the fixture side rather than passing vacuously. + */ +class FixtureScopeError extends Error {} + /** * @typedef {{ condition: string, message: string, file?: string, * location?: { start: number, end: number } }} Finding @@ -152,10 +185,11 @@ const PARTIAL_WRITE_INTERVAL_MS = 50; * and only then receives the remainder, completing the content. The path is * never unlinked, so a concurrent reader observes the partial file — never * absence — and after the call resolves the path holds the complete bytes - * (each completed command still leaves the conformer's final state). Used by - * regenerate() alone, under the `partialDerivedWrites` deviation: derived - * files only — durable files (journal, sessions, sources) keep the - * conformer's atomic writes everywhere. + * (each completed command still leaves the conformer's final state). Used + * through derivedWriter() alone — regenerate() and refreshStaleGraphData() — + * under the `partialDerivedWrites` deviation: derived files only — durable + * files (journal, sessions, sources) keep the conformer's atomic writes + * everywhere. */ async function writeFilePartialThenComplete(absPath, data) { const bytes = Buffer.from(data); @@ -985,7 +1019,12 @@ async function loadGraph(root, groups) { const bytes = await fsp.readFile(path.join(root, rel)); let text; try { - text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + // A leading byte-order mark is kept (ignoreBOM), so it is seen below + // as the unparseable-source condition it is (SPEC 1.6, 14.20) rather + // than silently stripped by the decoder's default. + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); } catch { findings.push({ condition: "14.20", @@ -1000,7 +1039,9 @@ async function loadGraph(root, groups) { condition: "14.20", message: `unparseable source: begins with a byte-order mark (SPEC 1.6): ${rel}`, file: rel, - location: { start: 0, end: 3 }, + // One zero-length range at the failure's offset — 0 for a byte-order + // mark (SPEC 14; CERTIFICATIONS.md §CONF-CORE's staging constraint). + location: { start: 0, end: 0 }, }); continue; } @@ -1061,7 +1102,18 @@ function moduleContent(model) { ); } -function graphDataContent(graph) { +/** Derived-file paths as `build` records them: the modules generated now. */ +function currentDerivedPaths(graph) { + return graph.files.map((model) => modulePathFor(model.rel)).sort(); +} + +/** + * Graph data exactly as `build` writes it (SPEC 13.3, 13.4): per-file + * section ids, node hashes, and source ranges, plus the recorded derived-file + * paths — the paths generated now or, for the 13.3 refresh, the stored + * record left unchanged (`derived`). + */ +function graphDataContent(graph, derived = currentDerivedPaths(graph)) { const filesData = {}; for (const model of graph.files) { const nodes = {}; @@ -1081,7 +1133,6 @@ function graphDataContent(graph) { nodes, }; } - const derived = graph.files.map((model) => modulePathFor(model.rel)).sort(); return canonicalJson({ derived, files: filesData }) + "\n"; } @@ -1103,6 +1154,27 @@ async function readRecordedDerived(root) { return []; } +/** + * The writer every derived-file write goes through — regenerate() and the + * 13.3 refresh (refreshStaleGraphData) are this fixture's only derived-file + * writers; durable files (journal, sessions, sources) are written atomically + * everywhere. + * + * VIOL-CORE-PARTIALWRITE (CERTIFICATIONS.md): derived-file writes are not + * atomic in their observable effect — while a derived file is being written, + * its path holds a strict prefix of the new content for a sustained interval, + * long relative to a concurrent reader's polling cadence, before the + * complete content appears (see writeFilePartialThenComplete). Switching the + * writer here deviates every derived write — generated modules and graph + * data — and nothing else: durable files are unaffected, orphan removal and + * each command's completed final bytes are unchanged. + */ +function derivedWriter() { + return deviations.partialDerivedWrites + ? writeFilePartialThenComplete + : writeFileAtomic; +} + /** * Regenerate every derived file exactly as `build` writes it: modules per * source, orphan removal via the recorded derived paths, graph data @@ -1110,19 +1182,7 @@ async function readRecordedDerived(root) { * regeneration (SPEC 6.4, 6.5). */ async function regenerate(graph) { - // VIOL-CORE-PARTIALWRITE (CERTIFICATIONS.md): derived-file writes are not - // atomic in their observable effect — while a derived file is being - // written, its path holds a strict prefix of the new content for a - // sustained interval, long relative to a concurrent reader's polling - // cadence, before the complete content appears (see - // writeFilePartialThenComplete). regenerate() is this fixture's only - // derived-file writer, so switching the writer here deviates every derived - // write — generated modules and graph data — and nothing else: durable - // files (journal, sessions, sources) are unaffected, orphan removal and - // each command's completed final bytes are unchanged. - const write = deviations.partialDerivedWrites - ? writeFilePartialThenComplete - : writeFileAtomic; + const write = derivedWriter(); const recorded = await readRecordedDerived(graph.root); const current = new Set(graph.files.map((model) => modulePathFor(model.rel))); for (const orphan of recorded) { @@ -1139,6 +1199,64 @@ async function regenerate(graph) { await write(path.join(graph.root, GRAPH_DATA_REL), graphDataContent(graph)); } +/** + * The stored graph data as the 13.3 refresh consults it: `missing` (no + * file), `unreadable` (present but not readable as a record, SPEC 14.23 — a + * state refresh neither repairs nor replaces), or `readable` with its exact + * bytes and its recorded derived-file paths. + */ +async function readGraphDataRecord(root) { + let text; + try { + text = await fsp.readFile(path.join(root, GRAPH_DATA_REL), "utf8"); + } catch (error) { + return { state: error.code === "ENOENT" ? "missing" : "unreadable" }; + } + try { + const parsed = JSON.parse(text); + if ( + Array.isArray(parsed?.derived) && + parsed.derived.every((entry) => typeof entry === "string") && + typeof parsed.files === "object" && + parsed.files !== null + ) { + return { state: "readable", text, derived: parsed.derived }; + } + } catch { + // Not JSON: not readable as a record. + } + return { state: "unreadable" }; +} + +/** + * The 13.3 refresh of stale graph data, as a mutating `review` subcommand + * performs it (CERTIFICATIONS.md §CONF-CORE Scope: "on a workspace whose + * graph data is stale but whose sources are valid … the 13.3 refresh a + * mutating `review` subcommand performs after the hold and before its own + * writes, writing graph data byte-identical to what `build` writes (13.3, + * T10.1-1), as T13.5-1's stale-workspace arm observes it"). Graph data is + * stale when it is missing or does not match the current sources — a + * comparison from which the recorded derived-file paths are excluded — and + * is then rewritten exactly as `build` writes it, except that no module is + * generated or removed and the recorded derived-file paths are left + * unchanged (SPEC 13.3); nothing else is written. Current graph data is + * refreshed by nothing (its bytes untouched), and a record that exists but + * cannot be read (SPEC 14.23) is neither repaired nor replaced. `graph` is + * the current sources' graph as the gate of 13.3 loaded it (runMutating's + * `judgeWorkspace`): an invalid workspace reported its findings there, before + * anything is written (SPEC 13.3, 12.0), so the refresh only ever writes. + */ +async function refreshStaleGraphData(config, graph) { + const record = await readGraphDataRecord(config.root); + if (record.state === "unreadable") return; + const content = graphDataContent( + graph, + record.state === "readable" ? record.derived : undefined, + ); + if (record.state === "readable" && record.text === content) return; + await derivedWriter()(path.join(config.root, GRAPH_DATA_REL), content); +} + // --------------------------------------------------------------------------- // Journal (SPEC 6.1, 6.3): append-only JSON lines; forward identity mapping // --------------------------------------------------------------------------- @@ -1768,6 +1886,11 @@ function descendantIdentities(node) { // Output helpers (SPEC 12.0 streams) // --------------------------------------------------------------------------- +/** Byte order of two identity strings (SPEC 12.0: strings compare bytewise). */ +function compareIdentityBytes(a, b) { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + function emitDoc(io, json, doc, humanLines) { if (json) { io.stdout(canonicalJson(doc) + "\n"); @@ -1781,19 +1904,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, + }), + ), }; } @@ -1864,22 +2086,93 @@ const MUTATING_FLAGS = { ...READ_FLAGS, "--test-hold": "value" }; // --------------------------------------------------------------------------- /** - * Run one mutating command (SPEC 13.5): acquire exclusivity, honor the - * `--test-hold` seam before modifying anything, perform the operation, and - * release. The lock is released on every path; a killed process releases by - * dying (the next command detects the dead holder). + * Run a mutating command (SPEC 13.5): configuration, then workspace + * exclusivity, then the `--test-hold` seam, then the checks a command judges + * ahead of the refresh and its own writes, in the order 12.0 and 13.5 fix — + * first the usage-class argument checks of 12.0 and baseline resolution, + * 6.3 (`judgeArguments`: each judged from what it consults — the origin + * file, the session directory, the configuration, the absent repository — + * identically on valid and failing workspaces, so they precede source + * validation), then, over the current sources, the gate of 13.3 binding the + * mutating `review` subcommands or the valid-workspace precondition of + * `rename`/`move`, 6.4/6.5 (`judgeWorkspace`; its result, the loaded graph + * included, is what the refresh and the operation act on) — then, for the + * mutating `review` subcommands (`refreshesGraphData`), the refresh of + * 13.3, then the operation's own validation and writes (`operate`); + * exclusivity ends with the command — released on every path, a killed + * process releasing by dying (the next command detects the dead holder). + * Acquisition and the hold precede every later check (SPEC 13.5; T13.5-8), + * so an invocation a later check refuses or the gate turns back creates the + * hold file too and exits with its own outcome only after the hold's + * deletion, having written nothing. Acquisition, the hold, the two judged + * phases, and the refresh are each position-independent so that one + * deviation can move one of them. */ -async function runMutating(cwd, configFlag, holdFlag, operate) { +async function runMutating( + cwd, + configFlag, + holdFlag, + operate, + { + refreshesGraphData = false, + judgeArguments = async () => {}, + judgeWorkspace, + }, +) { const config = await loadConfig(cwd, configFlag); - // VIOL-CORE-NOLOCK (CERTIFICATIONS.md): mutating commands do not exclude - // one another — exclusivity is neither acquired nor checked, so a second - // mutating command started while another runs or is held proceeds normally - // instead of failing with the usage error of 13.5/12.0. Everything else, - // the hold file created below before any modification and honored - // included, is exactly the conformer's behavior. - const lock = deviations.noMutualExclusion - ? { release: async () => {} } - : await acquireExclusivity(config.root); + // The 13.3 refresh of stale graph data (CERTIFICATIONS.md §CONF-CORE Scope: + // "the 13.3 refresh a mutating `review` subcommand performs after the hold + // and before its own writes, writing graph data byte-identical to what + // `build` writes"): only the mutating `review` subcommands refresh — + // `rename` and file-form `move` finish with a full regeneration instead + // (SPEC 6.4, 6.5) — and the conformer runs it below, after exclusivity is + // acquired and the hold has been released, before the operation's writes, + // over the graph the gate loaded. The closure is position-independent so + // that one deviation can move it. + const refreshIfStale = async (graph) => { + if (refreshesGraphData) await refreshStaleGraphData(config, graph); + }; + let refreshAfterHold = refreshIfStale; + if (deviations.refreshBeforeExclusivity) { + // VIOL-CORE-EARLYREFRESH (CERTIFICATIONS.md): the 13.3 refresh a + // mutating `review` subcommand performs on a stale workspace runs here, + // before workspace exclusivity is acquired — before the lock and the + // hold — so stale graph data is rewritten before the hold file is + // created; the refresh after the hold (below) becomes a no-op, so the + // refresh runs exactly once, at this position. Everything else — the + // lock; the hold file created after exclusivity and before every other + // write (the session write, `rename`/`move`'s edits, journal appends, + // the finishing regeneration of 6.4/6.5); the refresh's own bytes — is + // exactly the conformer's behavior, and a workspace whose graph data is + // current is refreshed by nothing, where the deviation is unobservable — + // as is a workspace failing `build`'s validations, on which the refresh + // writes nothing early or late (SPEC 13.3): the early refresh's gate + // findings are set aside here, and the gate's findings, like every usage + // error, are reported only after acquisition and the hold, at the + // conformer's position below (T13.5-8's failing-workspace arms). + try { + if (refreshesGraphData) { + await refreshIfStale(await loadGraph(config.root, config.groups)); + } + } catch (error) { + if (!(error instanceof FindingsError)) throw error; + } + refreshAfterHold = async () => {}; + } + // Workspace exclusivity (SPEC 13.5), acquired at the conformer's position + // below — before the hold and every later check; released on every path + // (a lock never acquired releases nothing). + let lock = { release: async () => {} }; + const acquire = async () => { + // VIOL-CORE-NOLOCK (CERTIFICATIONS.md): mutating commands do not exclude + // one another — exclusivity is neither acquired nor checked, so a second + // mutating command started while another runs or is held proceeds + // normally instead of failing with the usage error of 13.5/12.0. + // Everything else, the hold file created before any modification and + // honored included, is exactly the conformer's behavior. + if (deviations.noMutualExclusion) return; + lock = await acquireExclusivity(config.root); + }; const holdIfRequested = async () => { if (holdFlag === undefined) return; try { @@ -1891,26 +2184,92 @@ async function runMutating(cwd, configFlag, holdFlag, operate) { ); } }; + // Everything that follows the judged checks, in the conformer's order: the + // refresh of 13.3 over the graph the gate loaded, then the operation's own + // validation and writes. + const proceed = async (judged) => { + await refreshAfterHold(judged.graph); + return await operate(config, judged); + }; try { + if (deviations.lateAcquisition) { + // VIOL-CORE-LATELOCK (CERTIFICATIONS.md): workspace exclusivity is + // acquired late — a mutating command acquires it, and creates its hold + // file, only once the argument checks of 12.0 and baseline resolution + // (6.3) have passed, instead of before them (13.5) — the two checks + // 12.0 places ahead of source validation, and the only ones this + // deviation moves. A single deviation: 13.5's acquisition point moved + // past those two checks and no further; the gate and refresh of 13.3, + // the valid-workspace precondition of rename/move (6.4, 6.5), the + // operation's own validation, and every modification still follow + // acquisition and the hold as 13.5 orders them, a second mutating + // command is still refused on acquisition — now after those two + // checks — and the hold file still precedes every write. Observable + // exactly where an argument check or baseline resolution refuses: such + // an invocation exits 2 with that usage error at once, having acquired + // nothing and created no hold file, `--test-hold` or not, and, started + // while another mutating command is held, is refused for that reason + // in the exclusion's place — exit 2 either way; an invocation passing + // both checks — every performable one, and every one the gate or the + // precondition turns back — acquires, holds, and proceeds or is + // refused exactly as the conformer's does (T13.5-8: its two + // seam-ordering arms refused ahead of the gate fail; every other arm + // is conforming). + await judgeArguments(config); + await acquire(); + await holdIfRequested(); + return await proceed(await judgeWorkspace(config)); + } + await acquire(); if (deviations.writesBeforeHold) { // VIOL-CORE-EARLYWRITE (CERTIFICATIONS.md): the mutating command // performs its workspace modifications before creating the hold file — // it acquires exclusivity (above, unchanged), completes the operation's - // writes (journal append included), then creates the hold file, waits - // for its deletion, and exits normally with the operation's outcome. - // The hold seam's own semantics (empty file, occupied path fails - // exit 2) and everything else are exactly the conformer's behavior. - const code = await operate(config); + // writes (the 13.3 refresh of stale graph data and the journal append + // included), then creates the hold file, waits for its deletion, and + // exits normally with the operation's outcome. The argument checks of + // 12.0, baseline resolution (6.3), the gate of 13.3, and the + // valid-workspace precondition of rename/move (6.4, 6.5) are judged + // after acquisition and before the writes they gate, as the conformer + // judges them, while the exit they refuse with is deferred as the + // writes' completion is: an invocation they refuse creates the hold + // file having written nothing, waits for its deletion, and only then + // exits with its error. The hold seam's own semantics (empty file, + // occupied path fails exit 2) and everything else are exactly the + // conformer's behavior. + let code; + try { + await judgeArguments(config); + code = await proceed(await judgeWorkspace(config)); + } catch (error) { + if (!isRefusedOutcome(error)) throw error; + await holdIfRequested(); + throw error; + } await holdIfRequested(); return code; } await holdIfRequested(); - return await operate(config); + await judgeArguments(config); + return await proceed(await judgeWorkspace(config)); } finally { await lock.release(); } } +/** + * Whether an error is one of the outcomes a check or validation refuses an + * invocation with (SPEC 12.0: a usage error, exit 2; findings or a refused + * operation, exit 1) — as opposed to a fixture crash. + */ +function isRefusedOutcome(error) { + return ( + error instanceof UsageError || + error instanceof FindingsError || + error instanceof RefusalError + ); +} + // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- @@ -1920,7 +2279,10 @@ async function commandBuild(io, cwd, argv) { const config = await loadConfig(cwd, flags["--config"]); const graph = await loadGraph(config.root, config.groups); await regenerate(graph); - emitDoc(io, flags["--json"] === true, { ok: true }, ["build: ok"]); + // A `build` report's defined content is findings alone, so a successful + // build's JSON document is the findings report of 12.7 with none: + // `{"findings": []}` (SPEC 12.7, 12.0). + emitDoc(io, flags["--json"] === true, findingsDoc([]), ["build: ok"]); return 0; } @@ -2204,25 +2566,93 @@ async function commandImpact(io, cwd, argv) { // --- rename / move (SPEC 6.4, 6.5) --- +const RENAME_FLAGS = { ...MUTATING_FLAGS, "--preview": "bool" }; + +/** + * The argument checks of `rename` and `move` over the origin file (SPEC 6.4, + * 6.5, 12.0) — runMutating's `judgeArguments`: the file must be a discovered + * spec source (an unknown file a usage error, exit 2) and, for `rename` + * (`oldId` given), the old id must exist, judged parse-locally over the + * origin file's spelled identities (11.2): it exists exactly when a section + * of the file spells it, and an unparseable origin file masks the check — + * the precondition then reports the file's finding, exit 1. Consulting the + * discovered paths and the origin file alone, the checks are judged + * identically on valid and failing workspaces and precede source validation + * (12.0): T13.5-8's excluded `rename specs/A.mdx a b` on the failing + * workspace passes them. Every discovered source is a spec source in this + * scope (§CONF-CORE: no code groups), so no wrong-kind origin arises. + */ +function judgeOriginFile(file, oldId, section) { + return async (config) => { + const sourcePaths = await discoverSources(config.root, config.groups); + if (!sourcePaths.includes(file)) { + throw new UsageError( + `unknown file ${file}: not a discovered spec source (SPEC ${section}, 12.0)`, + ); + } + if (oldId === undefined) return; + const sections = await spelledSectionsOf(config.root, file); + if (sections !== null && !sections.some((node) => node.id === oldId)) { + throw new UsageError( + `unknown id ${oldId} in ${file} (SPEC ${section}, 12.0)`, + ); + } + }; +} + +/** + * The sections a discovered source spells, parsed locally (SPEC 11.2), or + * null where the file is an invalid source path or unparseable — the + * conditions loadGraph reports for it (14.19, 14.20), masking every check + * judged over its content (12.0). The decode keeps a leading byte-order mark + * (ignoreBOM) so that it masks as the 14.20 condition it is, as in loadGraph. + */ +async function spelledSectionsOf(root, rel) { + if (!rel.endsWith(".mdx") || rel.includes("#")) return null; + const bytes = await fsp.readFile(path.join(root, rel)); + let text; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + return null; + } + if (text.charCodeAt(0) === 0xfeff) return null; + const parsed = parseMdx(text, rel); + return parsed.findings.length > 0 ? null : parsed.sections; +} + async function commandRename(io, cwd, argv) { - const { flags, positionals } = parseArgs(argv, MUTATING_FLAGS, [3, 3]); + const { flags, positionals } = parseArgs(argv, RENAME_FLAGS, [3, 3]); const [file, oldId, newId] = positionals; + if (flags["--preview"] === true) { + return await previewRename(cwd, flags, file, oldId, newId); + } + // The checks judged ahead of the operation, in 12.0's order: first the + // argument checks of 12.0 (runMutating's `judgeArguments`: an unknown file + // or old id a usage error, exit 2, judged parse-locally over the origin + // file — judgeOriginFile), then the valid-workspace precondition of 6.4 + // (`judgeWorkspace`: the graph loaded over the current sources, findings + // exit 1); the origin file and its old-id bearer are then read off the + // loaded graph — both exist, the argument checks having passed over the + // same file, or the precondition has already reported the file. + const judgeWorkspace = async (config) => { + const graph = await loadGraph(config.root, config.groups); + const model = graph.files.find((candidate) => candidate.rel === file); + const target = model?.sections.find((section) => section.id === oldId); + if (model === undefined || target === undefined) { + throw new Error( + `fixture invariant: ${file}#${oldId} passed the argument checks but is absent from the loaded graph`, + ); + } + return { graph, model, target }; + }; return await runMutating( cwd, flags["--config"], flags["--test-hold"], - async (config) => { - const graph = await loadGraph(config.root, config.groups); - const model = graph.files.find((candidate) => candidate.rel === file); - if (model === undefined) { - throw new UsageError( - `unknown file ${file}: not a discovered spec source (SPEC 6.4, 12.0)`, - ); - } - const target = model.sections.find((section) => section.id === oldId); - if (target === undefined) { - throw new UsageError(`unknown id ${oldId} in ${file} (SPEC 6.4, 12.0)`); - } + async (config, { model, target }) => { // Validation (SPEC 6.4): new id valid, differs, collides with nothing, // structural parent rules remain satisfied. const refusal = (message) => new RefusalError(message); @@ -2289,11 +2719,60 @@ async function commandRename(io, cwd, argv) { // Finishing regeneration exactly as `build` (SPEC 6.4). const regenerated = await loadGraph(config.root, config.groups); await regenerate(regenerated); - emitDoc(io, flags["--json"] === true, { ok: true }, [ + // The applied mapping in the performed-operation form of 12.7 (SPEC + // 6.4): exactly {"findings", "mapping"} — `findings` [] and `mapping` + // one {"from", "to"} per mapped identity, the renamed node and its + // descendants by prefix replacement, ordered by `from` bytes. + const mapping = model.sections + .filter( + (section) => + section.id === oldId || section.id.startsWith(`${oldId}.`), + ) + .map((section) => ({ + from: `${file}#${section.id}`, + to: `${file}#${rewrittenOf(section.id)}`, + })) + .sort((a, b) => compareIdentityBytes(a.from, b.from)); + emitDoc(io, flags["--json"] === true, { findings: [], mapping }, [ `rename: ${file} ${oldId} -> ${newId}`, ]); return 0; }, + { judgeArguments: judgeOriginFile(file, oldId, "6.4"), judgeWorkspace }, + ); +} + +/** + * `rename --preview` (SPEC 6.6), within this fixture's surface only as + * T13.5-8's non-mutating boundary drives it (CERTIFICATIONS.md §CONF-CORE). + * A preview is a non-mutating command under 13.5: it acquires no workspace + * exclusivity, creates no hold file, and does not take the acquisition-tied + * seam — `--test-hold` beside `--preview` is a usage error of the syntax + * class (SPEC 12.0), judged before configuration is loaded — and it is + * refused exactly as the real operation would be: the argument checks of + * 12.0 (a nonexistent origin file or old ID, 6.4) exit 2 at once, modifying + * nothing. A preview of a performable rename lies outside this surface and + * is refused loudly, outside the 12.0 partition, never answered. + */ +async function previewRename(cwd, flags, file, oldId, newId) { + if (flags["--test-hold"] !== undefined) { + throw new UsageError( + "--test-hold is excluded under --preview: a preview acquires no exclusivity and takes no seam (SPEC 6.6, 12.0)", + ); + } + const config = await loadConfig(cwd, flags["--config"]); + const graph = await loadGraph(config.root, config.groups); + const model = graph.files.find((candidate) => candidate.rel === file); + if (model === undefined) { + throw new UsageError( + `unknown file ${file}: not a discovered spec source (SPEC 6.4, 12.0)`, + ); + } + if (!model.sections.some((section) => section.id === oldId)) { + throw new UsageError(`unknown id ${oldId} in ${file} (SPEC 6.4, 12.0)`); + } + throw new FixtureScopeError( + `rename --preview of a performable operation (${file} ${oldId} -> ${newId}) lies outside this fixture's certified surface (§CONF-CORE)`, ); } @@ -2303,21 +2782,24 @@ async function commandMove(io, cwd, argv) { if (oldPath.includes("#") || newPath.includes("#")) { // The section form is outside this fixture's certified scope // (CERTIFICATIONS.md §CONF-CORE: file-form move only). - throw new UsageError( + throw new FixtureScopeError( "this fixture implements the file form of move only (§CONF-CORE scope)", ); } + // The checks judged ahead of the operation, in 12.0's order: first the + // argument check of 12.0 (runMutating's `judgeArguments`: an unknown file + // a usage error, exit 2, judged over the discovered sources — + // judgeOriginFile), then the valid-workspace precondition of 6.5 + // (`judgeWorkspace`: the graph loaded over the current sources, findings + // exit 1). + const judgeWorkspace = async (config) => ({ + graph: await loadGraph(config.root, config.groups), + }); return await runMutating( cwd, flags["--config"], flags["--test-hold"], - async (config) => { - const graph = await loadGraph(config.root, config.groups); - if (!graph.files.some((model) => model.rel === oldPath)) { - throw new UsageError( - `unknown file ${oldPath}: not a discovered spec source (SPEC 6.5, 12.0)`, - ); - } + async (config, { graph }) => { const refusal = (message) => new RefusalError(message); if (await pathOccupied(path.join(config.root, newPath))) { throw refusal( @@ -2351,11 +2833,28 @@ async function commandMove(io, cwd, argv) { }); const regenerated = await loadGraph(config.root, config.groups); await regenerate(regenerated); - emitDoc(io, flags["--json"] === true, { ok: true }, [ + // The applied mapping in the performed-operation form of 12.7 (SPEC + // 6.5): one pair per node of the moved file — the root's bare-path + // pair (old path to new) and every section, its ID kept and its file + // part changed — ordered by `from` bytes (the bare path a proper + // prefix of every `<path>#<id>` identity, so it sorts first). + const moved = graph.files.find((model) => model.rel === oldPath); + const mapping = [ + { from: oldPath, to: newPath }, + ...moved.sections.map((section) => ({ + from: `${oldPath}#${section.id}`, + to: `${newPath}#${section.id}`, + })), + ].sort((a, b) => compareIdentityBytes(a.from, b.from)); + emitDoc(io, flags["--json"] === true, { findings: [], mapping }, [ `move: ${oldPath} -> ${newPath}`, ]); return 0; }, + { + judgeArguments: judgeOriginFile(oldPath, undefined, "6.5"), + judgeWorkspace, + }, ); } @@ -2428,22 +2927,32 @@ async function reviewCreate(io, cwd, argv) { `unknown strategy ${flags["--strategy"]} (SPEC 10.7, 12.0)`, ); } - if (flags["--base"] !== undefined) { - throw new UsageError( - `cannot read the baseline ${flags["--base"]}: the workspace has no git repository (SPEC 6.3, 12.0)`, - ); - } - if (flags["--coverage"] !== undefined) { - throw new UsageError( - `unknown coverage profile ${flags["--coverage"]} (SPEC 12.0)`, - ); - } + // The checks judged ahead of the refresh, in 12.0's order: first baseline + // resolution (SPEC 6.3) and the profile name under `--coverage` (SPEC 7.4, + // 12.0) — runMutating's `judgeArguments`, consulting the workspace's + // absent repository and the configuration, judged after acquisition and + // the hold and before the gate and refresh of 13.3 (SPEC 13.5, 12.0; + // T13.5-8's baseline arm); in this git-less scope no ref can be read — the + // exit-2 unreadable-baseline case of 6.3, as `impact --base` is — and no + // coverage profile is configured — then the gate of 13.3 (`judgeWorkspace`, + // judgeGate): the graph loaded over the current sources, findings exit 1. + const judgeArguments = async () => { + if (flags["--base"] !== undefined) { + throw new UsageError( + `cannot read the baseline ${flags["--base"]}: the workspace has no git repository (SPEC 6.3, 12.0)`, + ); + } + if (flags["--coverage"] !== undefined) { + throw new UsageError( + `unknown coverage profile ${flags["--coverage"]} (SPEC 12.0)`, + ); + } + }; return await runMutating( cwd, flags["--config"], flags["--test-hold"], - async (config) => { - const graph = await loadGraph(config.root, config.groups); + async (config, { graph }) => { // Create-time restriction (SPEC 10.1): a name matching an existing // session's name ignoring ASCII case is refused. const existing = await listSessionNames(config.root); @@ -2503,9 +3012,45 @@ async function reviewCreate(io, cwd, argv) { ]); return 0; }, + { judgeArguments, judgeWorkspace: judgeGate, refreshesGraphData: true }, ); } +/** + * The workspace check of a mutating `review` subcommand (runMutating's + * `judgeWorkspace`): exactly the gate of 13.3 — the graph loaded over the + * current sources, findings exit 1 (SPEC 13.3, 12.0); the subcommand's own + * validation (10.7) follows the refresh. + */ +async function judgeGate(config) { + return { graph: await loadGraph(config.root, config.groups) }; +} + +/** + * The argument check of `review resolve` and `review split` ahead of the + * gate (runMutating's `judgeArguments`; SPEC 12.0: a session name is judged + * against the session directory, identically on valid and failing + * workspaces, before the invalid-workspace report of 13.3): the name must be + * in the form of 10.1 and name a session file in the directory — a usage + * error, exit 2, otherwise. The session file itself is read only past the + * gate and refresh (requireSession), where its corruption is reported (13.3, + * 14.21) and its item id judged (12.0). T13.5-8's held `review resolve` on + * the failing workspace names an existing session, so it passes here and + * holds ahead of the gate. + */ +function judgeSessionName(name) { + return async (config) => { + if (!sessionNameValid(name)) { + throw new UsageError( + `invalid session name ${JSON.stringify(name)} (SPEC 10.1, 12.0)`, + ); + } + if (!(await listSessionNames(config.root)).includes(name)) { + throw new UsageError(`unknown session ${name} (SPEC 10.1, 12.0)`); + } + }; +} + async function reviewResolve(io, cwd, argv) { const { flags, positionals } = parseArgs( argv, @@ -2523,8 +3068,7 @@ async function reviewResolve(io, cwd, argv) { cwd, flags["--config"], flags["--test-hold"], - async (config) => { - const graph = await loadGraph(config.root, config.groups); + async (config, { graph }) => { const session = await requireSession(config.root, name); const item = requireItem(session, itemId); const journal = await readJournal(config.root); @@ -2546,6 +3090,11 @@ async function reviewResolve(io, cwd, argv) { ]); return 0; }, + { + judgeArguments: judgeSessionName(name), + judgeWorkspace: judgeGate, + refreshesGraphData: true, + }, ); } @@ -2621,8 +3170,7 @@ async function reviewSplit(io, cwd, argv) { cwd, flags["--config"], flags["--test-hold"], - async (config) => { - const graph = await loadGraph(config.root, config.groups); + async (config, { graph }) => { const session = await requireSession(config.root, name); const original = requireItem(session, itemId); const journal = await readJournal(config.root); @@ -2740,6 +3288,11 @@ async function reviewSplit(io, cwd, argv) { ]); return 0; }, + { + judgeArguments: judgeSessionName(name), + judgeWorkspace: judgeGate, + refreshesGraphData: true, + }, ); } @@ -2935,9 +3488,23 @@ async function commandReview(io, cwd, argv) { * * - `noMutualExclusion` (VIOL-CORE-NOLOCK): mutating commands do not exclude * one another; see runMutating. + * - `lateAcquisition` (VIOL-CORE-LATELOCK): workspace exclusivity is + * acquired — and the hold file created — only once the argument checks of + * 12.0 and baseline resolution (6.3) have passed, instead of before them — + * the two checks 12.0 places ahead of source validation, and the only ones + * moved: the gate of 13.3, the valid-workspace precondition of rename/move + * (6.4, 6.5), the refresh, and every modification still follow + * acquisition and the hold; an invocation one of the two moved checks + * refuses exits 2 at once, having acquired nothing and created no hold + * file; see runMutating. * - `writesBeforeHold` (VIOL-CORE-EARLYWRITE): a mutating command performs * its workspace modifications before creating the hold file; see * runMutating. + * - `refreshBeforeExclusivity` (VIOL-CORE-EARLYREFRESH): the 13.3 refresh a + * mutating `review` subcommand performs on a stale workspace runs before + * workspace exclusivity is acquired, so stale graph data is rewritten + * before the hold file is created; the hold file is still created after + * exclusivity and before every other write; see runMutating. * - `staleLockBlocks` (VIOL-CORE-STALELOCK): workspace exclusivity is not * released by abnormal termination — a lock file left by a killed holder * refuses every later mutating command; see acquireExclusivity. @@ -3087,6 +3654,13 @@ async function dispatchCommand(io, cwd, argv) { } return 1; } + if (error instanceof FixtureScopeError) { + // Outside this fixture's scope (CERTIFICATIONS.md §CONF-CORE): refused + // loudly, outside the 12.0 partition — never answered, never + // misreported as a usage error. + io.stderr(`xspec: fixture scope error: ${error.message}\n`); + return 70; + } // A crash is a fixture bug: exit outside the 12.0 partition so every // exit-code assertion fails loudly and the diagnosis carries the stack. io.stderr( diff --git a/test/fixtures/conf-disc/bin-derived.mjs b/test/fixtures/conf-disc/bin-derived.mjs index fec16d86..b5686107 100644 --- a/test/fixtures/conf-disc/bin-derived.mjs +++ b/test/fixtures/conf-disc/bin-derived.mjs @@ -3,11 +3,26 @@ // §VIOL-DISC-DERIVED). The CONF-DISC conformer with exactly one behavioral // deviation: discovery does not apply the source exclusion of 13.4 — a path // whose file name contains `.xspec.`, a file under `.xspec/`, or a file at -// an enabled Markdown emit destination, when matched by a spec-group glob, -// is treated as an ordinary match (a non-`.mdx` occupant then surfaces as -// 14.19). Glob semantics, the dot-segment rule, link behavior, and the -// import and empty-map rules are unchanged. Certifies T7-6 (C-1): exactly it -// fails against this fixture — on its exclusion arms — while every other +// an enabled Markdown emit destination, when matched by a spec-group or +// code-group glob, is treated as an ordinary match of its group's kind: on +// the spec side an `.mdx` name is parsed as MDX and any other name is +// reported as 14.19; on the code side it is a discovered code source whose +// content is parsed as plain TypeScript (14.20: the grammar its name +// selects) — an edgeless whole-file location where it parses (4.6), a +// condition-20 finding where it does not. So `query edges --from` no longer +// refuses such a path as one in no configured group (12.0): it answers exit +// 0 where every discovered file parses and exit 1 at the gate of 13.3 where +// one does not or a spec-side 14.19 shares the workspace; and in T7-6's +// invalid-source arm the code glob's match at the invalid source's emit +// destination `specs/a'b.md` enters the code set, `check` reporting its +// condition-20 finding beside the condition-19 one. A single deviation: one +// rule of 13.4 (derived files are never sources) dropped, consumed at +// product.mjs's one exclusion filter that both group kinds pass through. +// Everything else is the conformer's: glob semantics, the dot-segment rule, +// link behavior, 14.19 for non-`.mdx` matches, the parse of a discovered +// source (14.20), the gate of 13.3, and the import and empty-map rules. +// Certifies T7-6 (C-1): exactly it fails against this fixture — on its +// exclusion arms, spec-group and code-group sides alike — while every other // §CONF-DISC in-scope test passes. import { runXspec } from "./product.mjs"; diff --git a/test/fixtures/conf-disc/product.mjs b/test/fixtures/conf-disc/product.mjs index 3a580b64..7b5603b8 100644 --- a/test/fixtures/conf-disc/product.mjs +++ b/test/fixtures/conf-disc/product.mjs @@ -12,20 +12,44 @@ // 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` and default destinations, +// with 14.15; `markdown` with `emit: true` and default destinations, and +// emission disabled (`markdown` absent or `emit: false`), destinations // classified by configuration alone (7.3); symbolic links present in the -// tree; no code groups (`code` appears only as the empty map), `coverage`, -// `policy`, or git; content of derived and emitted files beyond path is out -// of scope. +// tree; code groups (7.2) of well-formed `.ts` sources spelling no marker, +// spec-module import, or `text` call — each discovered code source an +// edgeless whole-file code location (4.6), nothing in scope giving a code +// file an edge — under the same glob grammar; as T7-6's invalid-source arm +// stages them, a spec source at a path 7.1 bars and a code-group file that +// is no well-formed TypeScript; no `coverage`, `policy`, or git; content of +// derived and emitted files beyond path is out of scope. // - Command surface: `build` and `ids` (12.3) as the observation of the -// discovered set, the configuration-error behavior of 14.14/12.0 for -// patterns resolving outside the workspace root, and the source-error -// reporting of 14.15. -// - Contracts under certification: glob semantics of 7 — `*`, `?`, `**`, -// byte-wise case-sensitive matching, the dot-segment rule, every other -// character a literal — discovery's refusal to follow symbolic links, and +// discovered spec set, and `inventory` (11.6) in its full 12.7 document +// form — T7-4's observation that a glob matching nothing is configured as +// spelled; `query edges --from <path>` (11.1) as the +// observation of the discovered code set — a discovered code source's +// whole-file location answers exit 0 with its empty edge enumeration, and +// a path in no configured group (an excluded derived path included) is the +// usage error of 12.0, a check preceding the gate of 13.3; `check` (12.2) +// over T7-6's validation-failing invalid-source workspaces alone; the +// configuration-error behavior of 14.14/12.0 for patterns resolving +// outside the workspace root, and the source-error reporting of 14.15, +// 14.19 (`#` and U+FFFD on either side; 7.1's path-character bar and a +// missing `.mdx` on the spec side), and 14.20 (spec sources by the MDX-lite +// lexer below, code sources by TypeScript 5.9.3, encoding on both sides). +// - Contracts under certification: glob semantics of 7 — `*`, `?`, `**` +// (any segments only as a whole pattern segment; each `*` of an in-segment +// `**` the single-segment wildcard), byte-wise case-sensitive matching, +// the dot-segment rule, every other character a literal (the backslash +// included, the configuration literal read verbatim, 2.4), and the +// outside-root decision by spelling alone (a leading `/` or a depth +// falling below zero a configuration error, 14.14; a `.`, `..`, or empty +// segment inside the root matching nothing; a drive-qualified spelling +// ordinary segments) — discovery's refusal to follow symbolic links, and // the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and -// enabled Markdown emit destinations in no group). +// enabled Markdown emit destinations in no spec or code group — an +// invalid source's destination included, derived paths following the +// `NAME.mdx` name shape alone, 13.1, 7.3), with 7.1's path-character bar +// (14.19) as T7-6's invalid-source arm stages it. // // Key mechanisms: // - The glob matcher is a port of the harness oracle's discovery half @@ -40,21 +64,62 @@ // `**`), and every character outside `*`/`?`/`**` a literal — bracket, // brace, bang, and extglob characters included. // - Pattern resolution (SPEC 7): patterns resolve relative to the -// configuration file's directory (the workspace root). `.` and `..` -// segments resolve lexically (wildcard segments count as ordinary names); -// an absolute pattern, or one whose resolution escapes the root, is a +// configuration file's directory (the workspace root), and whether one +// lies outside the root is decided by its spelling alone — reading its +// `/`-separated segments from a depth of zero, `..` lowers the depth by +// one, `.`, an empty segment, and `**` leave it unchanged, and every other +// segment (a drive-qualified `C:` included) raises it by one; a pattern +// beginning with `/`, or whose depth ever falls below zero, 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. Every other +// pattern is inside the root and is matched exactly as spelled, never +// normalized: its `.`, `..`, and empty segments are literal segments no +// discovered path carries (7), so they match nothing. // - Discovery pipeline order (SPEC 7, 13.4): walk plain files (symbolic links // never discovered, never traversed — so link cycles cannot hang the walk), -// match the union of all groups' globs, then apply the 13.4 source -// exclusion to the matches — paths whose file name contains `.xspec.`, -// files under `.xspec/`, and, exactly while `markdown.emit` is true, the -// default emit destinations (`X.md` beside each discovered `X.mdx` source; +// match the spec groups' globs and the code groups' globs — one matcher, +// the same grammar, dot-segment rule, and byte-wise comparison on both +// sides — then apply the 13.4 source exclusion to the matches of either +// kind — paths whose file name contains `.xspec.`, files under `.xspec/`, +// and, exactly while `markdown.emit` is true, the default emit +// destinations (`X.md` beside each discovered `X.mdx` spec source; // destinations exist by configuration alone, whether or not emission has -// run, 7.3). A surviving match without the `.mdx` extension, or whose path -// contains `#`, is a 14.19 finding; exclusion precedes that check, so an -// excluded occupant of an emit destination is silently no source. +// run, 7.3) — so the module `build` generates beside a source is excluded +// from a code glob exactly as from a spec glob. Derived paths follow the +// `NAME.mdx` name shape alone (13.1, 7.3), so a spec match 14.19 reports +// — `specs/a'b.mdx` above all — still has its emit destination, which +// stays excluded. A surviving match of either kind whose path contains +// `#` or U+FFFD (an ill-formed UTF-8 name reads as U+FFFD), and a +// surviving spec match without the `.mdx` extension or holding a +// character 7.1 bars anywhere in its path (the quotes, the backslash, +// U+000A, U+000D, U+2028, U+2029), is one 14.19 finding and no source to +// parse; exclusion precedes that check, so an excluded occupant of an emit +// destination is silently no source. A surviving code match is a +// discovered code source: nothing in scope gives a code file an edge, so +// its whole-file location is the only graph node it contributes (4.6), and +// it is read only to judge its well-formedness (below). The both-groups +// rule of 14.14 stays dormant (the staged code globs match no spec-group +// file); no check for it is implemented here. +// - Code sources are judged by the harness's own TypeScript 5.9.3 +// (`typescript-5.9.3`, never the product's `typescript`), loaded lazily +// through `createRequire` by the first invocation that discovers a code +// source: valid UTF-8 with no byte-order mark (1.6), then parsed at ESNext, +// TSX for a `.tsx` name and plain TypeScript for any other, read both as a +// module's code and as a script's (the reading forced through +// `setExternalModuleIndicator`); well-formed only when neither reading +// reports a syntax error — the release's scanning and parsing alone +// (14.20). An ill-formed one is a 14.20 finding: one zero-length range at +// the earliest syntax diagnostic's byte offset, or, for an encoding +// failure, at the first ill-formed byte (0 for a byte-order mark). It +// fails `build`'s validations, so `check` reports it and the gate of 13.3 +// turns `ids` and `query edges` back with it (exit 1). +// - Configuration literals are read verbatim (2.4, 7): a string literal's +// value is the characters between its quotes exactly as spelled, no +// escape sequence interpreted, so a glob spelled with a backslash reaches +// the matcher with it and `inventory` reports it as spelled. // - Sources are scanned by a hand-rolled MDX-lite lexer for exactly the // scope's constructs: spec module imports at line start (2.1) and // `<S>`/`<Spec>` opening/self-closing/closing tags with quoted or braced @@ -75,10 +140,39 @@ // `ids` recomputes from sources on every run (13.3's refresh, minus the // unobservable-in-scope stored form), reporting validation findings with // exit 1 without writing anything when the sources are invalid. +// - `check` (12.2, scoped): `build`'s validations, writing nothing — over +// T7-6's invalid-source workspaces, staged from scratch with no `build` +// succeeding, so no record exists (13.3) and the findings are exactly +// `build`'s, exit 1. A workspace passing the validations would need +// 14.10's derived-file and graph-data verification, which this conformer +// does not keep: refused loudly (exit 70), never a false clean answer. // - `ids` (12.3): requirement IDs grouped by file — files in byte order of // workspace-relative path (UTF-8 byte comparison, not code-unit order), IDs // within a file in document order; `--json` emits the single JSON document // as the entire stdout (12.0). +// - `inventory` (11.6): the full 12.7 inventory document — anchoring, the +// resolved configuration view with every glob exactly as configured, the +// discovered sources with their group memberships, the derived-file map, +// the (empty) record, the graph-data area, the journal's occupancy, and +// the (empty) session list — parsing no source and writing nothing. +// - `query edges [--from <graph-node>]` (11.1): JSON-only — the single edge +// enumeration `{"edges": [{"from", "to", "kind"}, …]}` is the entire +// stdout with or without `--json`, and so is the 12.7 error document on +// exit 2 (12.0). The `--from` check runs after configuration loading and +// before the 13.3 gate (12.0): a graph node is a discovered code source's +// whole-file location (its path), a discovered spec source's root (its +// path), or a section it spells (`path#id`); any other spelling — a +// derived path the exclusion kept out of every group above all — is the +// unknown-graph-node usage error, exit 2, whatever findings the workspace +// carries; an unparseable named spec file masks the identity check and +// the gate reports the findings (exit 1). The answer is recomputed from +// sources as `ids` does: a code location has no edges; a spec root's +// outgoing edges are `contains` to its top-level sections and a section's +// `contains` to its direct children (5.2) — the scope's sources spell no +// reference, so no other kind arises; without `--from`, every edge, files +// in byte order. `--to`, `--kinds`, named code units, and the other +// `query` subcommands are outside this fixture's scope and are refused +// loudly (exit 70, outside the 12.0 partition, never a false answer). // // Determinism (SPEC 12.0): no wall clock, no randomness, no absolute paths in // any output; files in byte order of workspace-relative path; all JSON is @@ -99,22 +193,47 @@ // broken links stay ignored and directory links stay untraversed, so the // walk still terminates and T7-5 fails by assertion, not by hang. // - §VIOL-DISC-DERIVED (CERT-17, bin-derived.mjs): `noDerivedExclusion`, -// consumed in `discoverSources`' exclusion filter — the 13.4 source -// exclusion is not applied to glob matches, so `.xspec.`-named files, -// files under `.xspec/` (where a pattern spells the dot segment), and -// occupants of enabled emit destinations are treated as ordinary matches -// (a non-`.mdx` occupant then surfaces as 14.19). +// consumed in `discoverSources`' exclusion filter — the one filter both +// group kinds pass through — so the 13.4 source exclusion is not applied +// to glob matches of either kind: `.xspec.`-named files, files under +// `.xspec/` (where a pattern spells the dot segment), and occupants of +// enabled emit destinations are treated as ordinary matches — on the +// spec side a non-`.mdx` occupant then surfaces as 14.19; on the code +// side each such path enters the discovered code set and is parsed as +// every discovered code source is (plain TypeScript for every non-`.tsx` +// name, a 14.20 finding where it does not parse), so `query edges +// --from` answers it exit 0 where every discovered file parses and exit +// 1 at the gate of 13.3 where one does not or a spec-side 14.19 shares +// the workspace — never the conformer's exit 2; and in T7-6's +// invalid-source arm the emit destination `specs/a'b.md` enters the code +// set, `check` reporting its 14.20 beside the 14.19. import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; +import { createRequire } from "node:module"; 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 { @@ -125,6 +244,16 @@ class FindingsError extends Error { } } +/** + * An invocation outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC) + * that a conforming product would answer — a `query` subcommand, flag, or + * graph-node form no in-scope observation uses. Refused loudly with an exit + * code outside the 12.0 partition (as a crash is, below), so a test reaching + * it fails on its exit-code assertion with the cause on stderr — never on a + * fabricated answer or a false usage error. + */ +class ScopeError extends Error {} + /** * @typedef {{ condition: string, message: string, file?: string, * location?: { start: number, end: number } }} Finding @@ -150,9 +279,13 @@ function sortKeysDeep(value) { return value; } -/** One canonical serializer for emitted JSON. */ +/** + * One canonical serializer for emitted JSON: byte-sorted keys, two-space + * indentation — the real product's form, so the empty edge enumeration of + * `query edges` (`{"edges": []}`) matches it byte for byte. + */ function canonicalJson(value) { - return JSON.stringify(sortKeysDeep(value)); + return JSON.stringify(sortKeysDeep(value), null, 2); } /** Whether anything (file, directory, or symlink) occupies the path. */ @@ -172,7 +305,8 @@ async function pathOccupied(absPath) { // header for the hook point each named switch is consumed at: // `dialectMetachars` (§VIOL-DISC-DIALECT → parseSegment), // `followFileSymlinks` (§VIOL-DISC-SYMLINK → walkPlainFiles), -// `noDerivedExclusion` (§VIOL-DISC-DERIVED → discoverSources). +// `noDerivedExclusion` (§VIOL-DISC-DERIVED → discoverSources, both group +// kinds). // --------------------------------------------------------------------------- let deviations = {}; @@ -183,12 +317,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 +350,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; @@ -354,29 +506,36 @@ class LiteralParser { } } + /** + * A static string literal (SPEC 2.4, 7): its value is the characters + * between its delimiters exactly as spelled — no escape sequence is + * interpreted, so a glob spelled `src/a`, backslash, `*.ts` reaches the + * matcher with its backslash, and a key so spelled names a group whose + * spelling holds one. The lexical extent is TypeScript's: a backslash + * still keeps the character after it (a quote or a line terminator, the + * CR LF pair as one) from ending the literal, both kept in the value as + * spelled, and an unescaped line feed or carriage return before the + * closing quote leaves the literal unterminated — not well-formed + * TypeScript, so a configuration error (14.20, 14.14). + */ parseString() { const quote = this.text[this.pos]; - this.pos += 1; - let out = ""; - while (this.pos < this.text.length) { - const c = this.text[this.pos]; + const start = this.pos + 1; + let i = start; + while (i < this.text.length) { + const c = this.text[i]; if (c === quote) { - this.pos += 1; - return out; + this.pos = i + 1; + return this.text.slice(start, i); } + if (c === "\n" || c === "\r") break; 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; + i += this.text.startsWith("\r\n", i + 1) ? 3 : 2; continue; } - out += c; - this.pos += 1; + i += 1; } + this.pos = i; return this.fail("unterminated string literal"); } } @@ -389,6 +548,21 @@ class LiteralParser { * pattern, or one resolving outside the workspace root, is a configuration * error (14.14) — reported at load, before any source analysis. */ +/** + * Validate one configured glob (SPEC 7): a non-empty string, kept exactly + * as spelled — never normalized. Whether it lies outside the workspace root + * is decided by its spelling alone: reading its `/`-separated segments in + * order from a depth of zero, a `..` segment lowers the depth by one; a `.` + * segment, an empty segment (a doubled or trailing `/`), and a `**` segment + * (which may match no segment at all) leave it unchanged; every other + * segment — a drive-qualified spelling such as `C:` included — raises it by + * one. A glob beginning with `/`, or whose depth ever falls below zero, is + * outside the root, a configuration error (14.14); every other glob is + * inside, its `.`, `..`, and empty segments matching nothing — they are + * literal segments no discovered path carries (7: a discovered file's + * workspace-relative path is the directory-entry names descending from the + * root), so the unnormalized spelling matches exactly what 7 says it does. + */ function validatedPattern(glob, groupName) { if (typeof glob !== "string" || glob === "") { throw new UsageError( @@ -397,29 +571,76 @@ function validatedPattern(glob, groupName) { } if (glob.startsWith("/")) { throw new UsageError( - `configuration error: pattern ${glob} is absolute and resolves outside the workspace root (SPEC 7, 14.14)`, + `configuration error: pattern ${glob} begins with "/" and lies outside the workspace root (SPEC 7, 14.14)`, ); } - const normalized = path.posix.normalize(glob); - if (normalized === ".." || normalized.startsWith("../")) { - throw new UsageError( - `configuration error: pattern ${glob} resolves outside the workspace root (SPEC 7, 14.14)`, - ); + let depth = 0; + for (const segment of glob.split("/")) { + if (segment === "..") { + depth -= 1; + if (depth < 0) { + throw new UsageError( + `configuration error: pattern ${glob} lies outside the workspace root — its depth falls below zero at a ".." segment (SPEC 7, 14.14)`, + ); + } + } else if (segment !== "." && segment !== "" && segment !== "**") { + depth += 1; + } } - return normalized; + return glob; +} + +/** + * Validate one group map (SPEC 7.1 spec groups, 7.2 code groups — the same + * glob grammar and pattern rules on both sides): each group a list of glob + * strings, each pattern validated and resolved by {@link validatedPattern}. + * @param {Record<string, unknown>} map @param {string} kind + * @param {string} section @returns {Record<string, string[]>} + */ +function validatedGroups(map, kind, section) { + /** @type {Record<string, string[]>} */ + const groups = {}; + for (const [name, globs] of Object.entries(map)) { + if (!Array.isArray(globs)) { + throw new UsageError( + `configuration error: ${kind} group ${name} must be a list of glob strings (SPEC ${section})`, + ); + } + groups[name] = globs.map((glob) => validatedPattern(glob, name)); + } + return groups; } /** * Load and validate the configuration; returns the workspace root, the spec - * groups (patterns validated and resolved), and the emission switch. The - * in-scope shape (CERTIFICATIONS.md §CONF-DISC) is spec groups of glob - * strings, an optional `code` that MUST be the empty map, and an optional + * groups and the code groups (patterns validated and resolved under one + * grammar, SPEC 7), and the emission switch. The in-scope shape + * (CERTIFICATIONS.md §CONF-DISC) is spec groups and code groups of glob + * strings (either map may be empty) and an optional * `markdown: { emit: boolean }` with default destinations — no `outDir`, no * `coverage` or `policy` keys; anything else is refused loudly as a * configuration error rather than half-implemented (SPEC 7, 14.14). */ 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"); @@ -447,16 +668,9 @@ async function loadConfig(cwd, configFlag) { "configuration error: `specs` is required and must be a map of groups (SPEC 7)", ); } + const groups = validatedGroups(specs, "spec", "7.1"); /** @type {Record<string, string[]>} */ - const groups = {}; - for (const [name, globs] of Object.entries(specs)) { - if (!Array.isArray(globs)) { - throw new UsageError( - `configuration error: spec group ${name} must be a list of glob strings (SPEC 7.1)`, - ); - } - groups[name] = globs.map((glob) => validatedPattern(glob, name)); - } + let codeGroups = {}; if (data.code !== undefined) { const code = data.code; if (code === null || typeof code !== "object" || Array.isArray(code)) { @@ -464,14 +678,7 @@ async function loadConfig(cwd, configFlag) { "configuration error: `code` must be a map of groups (SPEC 7.2)", ); } - if (Object.keys(code).length > 0) { - // SPEC 7 allows code groups; §CONF-DISC's scope does not (`code` - // appears only as the empty map). Refuse loudly rather than - // half-implement code discovery. - throw new UsageError( - "configuration error: non-empty `code` groups are outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC; SPEC 7.2, 14.14)", - ); - } + codeGroups = validatedGroups(code, "code", "7.2"); } let emit = false; if (data.markdown !== undefined) { @@ -499,7 +706,13 @@ async function loadConfig(cwd, configFlag) { } emit = markdown.emit; } - return { root: path.dirname(configPath), groups, emit }; + return { + root: path.dirname(configPath), + configPath, + groups, + codeGroups, + emit, + }; } // --------------------------------------------------------------------------- @@ -938,6 +1151,37 @@ function compareUtf8(a, b) { return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } +/** + * The characters 7.1 bars from a spec-group file's workspace-relative path, + * anywhere in it, directory components included (14.19): the double and + * single quote, the backslash, line feed, carriage return, and the line and + * paragraph separators — built from code points, never escape-spelled. + */ +const SPEC_PATH_BARRED = new Set( + [0x22, 0x27, 0x5c, 0x0a, 0x0d, 0x2028, 0x2029].map((cp) => + String.fromCodePoint(cp), + ), +); + +/** U+FFFD, built from its code point (SPEC 7, 14.19). */ +const REPLACEMENT_CHARACTER = String.fromCodePoint(0xfffd); + +/** + * The 14.19 reasons binding a discovered source of either kind (SPEC 7): a + * `#` or U+FFFD in its workspace-relative path, or a path that is not valid + * UTF-8 — the walk reads entry names as strings, so an ill-formed byte + * sequence surfaces as U+FFFD and the one check covers both. + * @param {string} rel @returns {string[]} + */ +function pathReasons(rel) { + const reasons = []; + if (rel.includes("#")) reasons.push('contains "#"'); + if (rel.includes(REPLACEMENT_CHARACTER)) { + reasons.push("contains U+FFFD or is not valid UTF-8"); + } + return reasons; +} + /** SPEC 13.4: `.xspec.`-bearing file names and files under `.xspec/`. */ function isXspecClassified(rel) { const base = rel.split("/").at(-1) ?? rel; @@ -948,31 +1192,44 @@ function isXspecClassified(rel) { /** * Discover the workspace's sources (SPEC 7, 13.4): walk plain files, match - * the union of all spec groups' globs, apply the 13.4 source exclusion, and - * validate surviving matches' paths (14.19). Returns byte-ordered sources - * plus any 14.19 findings. + * the spec groups' globs and the code groups' globs, apply the 13.4 source + * exclusion to the matches of either kind, and validate surviving matches' + * paths (14.19: `#` and U+FFFD on either side; on the spec side also the + * characters 7.1 bars, anywhere in the path, and a missing `.mdx`). Returns + * the byte-ordered spec sources and code sources plus any 14.19 findings; an + * invalid path is no source to parse. Nothing in scope gives a code file an + * edge, so a code source's whole-file location is its only graph node (4.6); + * {@link loadWorkspace} still parses it (14.20). * * §VIOL-DISC-DERIVED hook (CERT-17, bin-derived.mjs): the exclusion filter - * below is the deviation's single consumption point — under - * `noDerivedExclusion` the 13.4 exclusion is skipped and every glob match is - * an ordinary match, so an excluded-under-the-conformer path enters the - * discovered set (or, lacking `.mdx`, surfaces as 14.19); glob semantics, - * the dot-segment rule, link behavior, and the import and empty-map rules - * are unchanged. + * below — the one filter both group kinds pass through — is the deviation's + * single consumption point: under `noDerivedExclusion` the 13.4 exclusion is + * skipped and every glob match of either kind is an ordinary match, so an + * excluded-under-the-conformer path enters the discovered spec set (or, + * lacking `.mdx`, surfaces as 14.19) or the discovered code set (parsed as + * every code source is — 14.20 where it does not parse — and otherwise an + * edgeless whole-file location answered by `query edges --from`); glob + * semantics, the dot-segment rule, link behavior, 14.19 for non-`.mdx` + * matches, the parse of a discovered source, and the import and empty-map + * rules are unchanged. */ async function discoverSources(config) { const walked = await walkPlainFiles(config.root); walked.sort(compareUtf8); - const allPatterns = Object.values(config.groups).flat(); - const matched = walked.filter((rel) => - allPatterns.some((pattern) => globMatches(pattern, rel)), + const matchingAny = (patterns) => (rel) => + patterns.some((pattern) => globMatches(pattern, rel)); + const specMatched = walked.filter( + matchingAny(Object.values(config.groups).flat()), + ); + const codeMatched = walked.filter( + matchingAny(Object.values(config.codeGroups).flat()), ); // The enabled Markdown emit destinations exist by configuration alone // (SPEC 7.3): `X.md` beside each discovered `X.mdx` spec source, whether or // not emission has run. Destination paths never end in `.mdx`, so this - // exclusion can never remove a source and the provisional set below is the - // final source set. - const provisional = matched.filter( + // exclusion can never remove a spec source and the provisional set below + // is the final spec source set. + const provisional = specMatched.filter( (rel) => !isXspecClassified(rel) && rel.endsWith(".mdx"), ); const destinations = new Set( @@ -982,34 +1239,59 @@ async function discoverSources(config) { ); const excluded = (rel) => isXspecClassified(rel) || destinations.has(rel); // §VIOL-DISC-DERIVED (CERT-17): under `noDerivedExclusion` the 13.4 - // exclusion is skipped entirely — every glob match is an ordinary match. - const kept = deviations.noDerivedExclusion - ? matched - : matched.filter((rel) => !excluded(rel)); + // exclusion is skipped entirely — every glob match of either kind is an + // ordinary match. + const kept = (matched) => + deviations.noDerivedExclusion + ? matched + : matched.filter((rel) => !excluded(rel)); /** @type {Finding[]} */ const findings = []; + // One 14.19 finding per invalid discovered path, naming every reason it + // carries; an invalid path is no source to parse. + const invalidPath = (rel, reasons) => ({ + condition: "14.19", + message: `invalid source path: the discovered path ${JSON.stringify(rel)} ${reasons.join("; ")} (SPEC 7, 7.1, 14.19)`, + file: rel, + }); + const keptSpec = kept(specMatched); + const keptCode = kept(codeMatched); /** @type {string[]} */ const sources = []; - for (const rel of kept) { - if (rel.includes("#")) { - findings.push({ - condition: "14.19", - message: `invalid source path: the discovered path ${JSON.stringify(rel)} contains "#" (SPEC 7, 1.5, 14.19)`, - file: rel, - }); - continue; + for (const rel of keptSpec) { + const reasons = pathReasons(rel); + const barred = [...rel].filter((c) => SPEC_PATH_BARRED.has(c)); + if (barred.length > 0) { + const named = [...new Set(barred)].map( + (c) => + `U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`, + ); + reasons.push( + `holds ${named.join(", ")}, which 7.1 bars from a spec-group path`, + ); } if (!rel.endsWith(".mdx")) { - findings.push({ - condition: "14.19", - message: `invalid source path: the spec-group match ${JSON.stringify(rel)} does not have the .mdx extension (SPEC 7.1, 14.19)`, - file: rel, - }); + reasons.push("is a spec-group match without the .mdx extension"); + } + if (reasons.length > 0) { + findings.push(invalidPath(rel, reasons)); continue; } sources.push(rel); } - return { sources, findings }; + /** @type {string[]} */ + const codeSources = []; + for (const rel of keptCode) { + const reasons = pathReasons(rel); + if (reasons.length > 0) { + findings.push(invalidPath(rel, reasons)); + continue; + } + codeSources.push(rel); + } + // `keptSpec`/`keptCode`: every surviving match of each kind, the 14.19 + // paths included — the discovered set the inventory lists (11.6). + return { sources, codeSources, findings, keptSpec, keptCode }; } // --------------------------------------------------------------------------- @@ -1057,7 +1339,10 @@ const IMPORT_RE = * { at, message } (an unparseable source, SPEC 14.20). */ function parseMdx(text) { - /** @type {{ id: string | null, openStart: number, openEnd: number }[]} */ + // `depth` is the section's nesting depth — the number of open sections + // enclosing it; 0 for a top-level section — from which `query edges` + // derives the `contains` edges (SPEC 5.2). + /** @type {{ id: string | null, openStart: number, openEnd: number, depth: number }[]} */ const sections = []; /** @type {{ binding: string, specifier: string, start: number, end: number }[]} */ const imports = []; @@ -1180,7 +1465,7 @@ function parseMdx(text) { } if (name === "id" && quoted !== undefined) id = quoted; } - sections.push({ id, openStart: i, openEnd: j }); + sections.push({ id, openStart: i, openEnd: j, depth }); if (!selfClosing) depth += 1; i = j; continue; @@ -1196,6 +1481,72 @@ function parseMdx(text) { return result(); } +/** + * The byte length of the longest prefix of `bytes` that is well-formed + * UTF-8 (Unicode's table of well-formed byte sequences: no overlong form, no + * surrogate, nothing above U+10FFFF) — the offset of the first byte of the + * first ill-formed sequence, malformed or truncated by the end, or the + * whole length when every sequence is well-formed (SPEC 14.20's offset for + * an encoding failure: `41 E2 82 41` locates 1, `C0 80` and `ED A0 80` 0). + * @param {Uint8Array} bytes @returns {number} + */ +function wellFormedUtf8PrefixLength(bytes) { + let i = 0; + while (i < bytes.length) { + const lead = bytes[i]; + if (lead < 0x80) { + i += 1; + continue; + } + let length; + let low = 0x80; + let high = 0xbf; + if (lead >= 0xc2 && lead <= 0xdf) length = 2; + else if (lead === 0xe0) [length, low] = [3, 0xa0]; + else if (lead === 0xed) [length, high] = [3, 0x9f]; + else if (lead >= 0xe1 && lead <= 0xef) length = 3; + else if (lead === 0xf0) [length, low] = [4, 0x90]; + else if (lead === 0xf4) [length, high] = [4, 0x8f]; + else if (lead >= 0xf1 && lead <= 0xf3) length = 4; + else return i; + if (i + length > bytes.length) return i; + if (bytes[i + 1] < low || bytes[i + 1] > high) return i; + for (let k = 2; k < length; k += 1) { + if (bytes[i + k] < 0x80 || bytes[i + k] > 0xbf) return i; + } + i += length; + } + return bytes.length; +} + +/** + * A discovered source's encoding failure (SPEC 1.6, 14.20), or null: a + * leading byte-order mark — judged on the raw bytes, since a UTF-8 + * TextDecoder strips one — at offset 0, else the first ill-formed UTF-8 + * sequence at its byte offset. `byteAt` is the failure's byte offset. + * @param {string} rel @param {Uint8Array} bytes + * @returns {{ byteAt: number, message: string } | null} + */ +function encodingFailure(rel, bytes) { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return { + byteAt: 0, + message: `${rel} begins with a byte-order mark (SPEC 1.6)`, + }; + } + const valid = wellFormedUtf8PrefixLength(bytes); + if (valid < bytes.length) { + return { + byteAt: valid, + message: `${rel} is not valid UTF-8 from byte ${String(valid)} (SPEC 1.6)`, + }; + } + return null; +} + +/** The decoder for content {@link encodingFailure} has passed. */ +const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + /** Analyze one source file's bytes into a file record. */ function analyzeFile(rel, bytes) { const base = { @@ -1206,25 +1557,9 @@ function analyzeFile(rel, bytes) { imports: [], 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, - text, - failure: { - at: 0, - message: `${rel} begins with a byte-order mark (SPEC 1.6)`, - }, - }; - } + const encoding = encodingFailure(rel, bytes); + if (encoding !== null) return { ...base, failure: encoding }; + const text = UTF8.decode(bytes); const byteOf = byteOffsetMapper(text, bytes.length); const parsed = parseMdx(text); return { @@ -1246,6 +1581,96 @@ function byteRange(record, startIndex, endIndex) { }; } +// --------------------------------------------------------------------------- +// Code-source well-formedness (SPEC 14.20): TypeScript at release 5.9.3 +// --------------------------------------------------------------------------- + +/** The harness's own TypeScript 5.9.3, loaded on first use. */ +let typeScript = null; + +/** + * Load the harness's `typescript-5.9.3` (an npm alias of + * `typescript@5.9.3`, the release SPEC 14.20 fixes; never the product's + * `typescript`) through `createRequire` — a plain CommonJS load, cheaper + * than the ESM loader's — lazily, so only an invocation that discovers a + * code source pays for it. + */ +function loadTypeScript() { + typeScript ??= createRequire(import.meta.url)("typescript-5.9.3"); + return typeScript; +} + +/** + * Judge `text` under TypeScript 5.9.3's scanning and parsing at ESNext, TSX + * or plain as `tsx` selects, read both as a module's code and as a script's + * — the reading forced through `setExternalModuleIndicator`, which decides + * whether the parser takes top-level `await` as an operator (a module) or + * an identifier (a script). A text is well-formed only when both readings + * report no syntax error (`parseDiagnostics`, the scanner's and parser's + * own — never the post-parse grammar checks, binding, or type checking); + * one 14.20 leaves open counts as ill-formed here, the conservative side. + * Returns null when well-formed, else the earliest diagnostic's string + * index — the fixture's approximation of 14.20's offset rule. The neutral + * file name keeps a `.d.ts`-like name from switching the parser into a + * declaration file's ambient context. + * @param {string} text @param {boolean} tsx @returns {number | null} + */ +function typeScriptFailureIndex(text, tsx) { + const ts = loadTypeScript(); + let earliest = null; + for (const asModule of [true, false]) { + const file = ts.createSourceFile( + tsx ? "source.tsx" : "source.ts", + text, + { + languageVersion: ts.ScriptTarget.ESNext, + setExternalModuleIndicator: (sourceFile) => { + sourceFile.externalModuleIndicator = asModule ? true : undefined; + }, + }, + false, + tsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const diagnostics = file.parseDiagnostics; + if (!Array.isArray(diagnostics)) { + throw new Error( + "typescript-5.9.3 exposed no parseDiagnostics on a parsed source file", + ); + } + for (const diagnostic of diagnostics) { + if (earliest === null || diagnostic.start < earliest) { + earliest = diagnostic.start; + } + } + } + return earliest; +} + +/** + * Analyze one discovered code source (SPEC 7.2, 14.20): valid UTF-8 with no + * byte-order mark (1.6), then well-formed TypeScript under the grammar its + * name selects — `.tsx` as TSX, any other name as plain TypeScript. Returns + * `{ rel, failure }`, `failure` null or `{ byteAt, message }` with the + * failure's byte offset. A well-formed source is used no further: nothing in + * scope gives a code file an edge (4.6). + * @param {string} rel @param {Uint8Array} bytes + */ +function analyzeCodeFile(rel, bytes) { + const encoding = encodingFailure(rel, bytes); + if (encoding !== null) return { rel, failure: encoding }; + const text = UTF8.decode(bytes); + const index = typeScriptFailureIndex(text, rel.endsWith(".tsx")); + if (index === null) return { rel, failure: null }; + const byteOf = byteOffsetMapper(text, bytes.length); + return { + rel, + failure: { + byteAt: byteOf(Math.max(0, Math.min(index, text.length))), + message: `${rel} is not well-formed TypeScript (release 5.9.3, ${rel.endsWith(".tsx") ? "TSX" : "plain TypeScript"}, read both as a module and as a script) — correct the syntax at the located offset`, + }, + }; +} + // --------------------------------------------------------------------------- // Workspace loading: discovery, parse, import resolution (SPEC 2.1, 14.15) // --------------------------------------------------------------------------- @@ -1265,10 +1690,13 @@ function resolveImportTarget(fromRel, specifier) { const RESERVED_BINDINGS = new Set(["S", "Spec", "text"]); /** - * Load the workspace: configuration, discovery, every discovered source's - * analysis, and import resolution. Files in byte order of workspace-relative - * path — deterministic (SPEC 12.0). An unparseable file (14.20) masks the - * conditions inside itself (SPEC 14). + * Load the workspace: configuration, discovery, every discovered spec + * source's analysis, and import resolution; every discovered code source is + * parsed for its well-formedness alone (14.20) — otherwise each is an + * edgeless whole-file location (4.6). Files in byte order of + * workspace-relative path — deterministic (SPEC 12.0). An unparseable file + * (14.20) masks the conditions inside itself (SPEC 14). The findings are + * exactly `build`'s validations over the scope (12.1, 12.2). */ async function loadWorkspace(cwd, configFlag) { const config = await loadConfig(cwd, configFlag); @@ -1280,14 +1708,23 @@ async function loadWorkspace(cwd, configFlag) { const bytes = await fsp.readFile(path.join(config.root, ...rel.split("/"))); files.set(rel, analyzeFile(rel, bytes)); } + // An unparseable source (14.20) carries one zero-length range at the + // failure's byte offset: an encoding failure's `byteAt`, or a parse + // failure's string index mapped to bytes. + const unparseable = (record) => { + const at = + record.failure.byteAt ?? + byteRange(record, record.failure.at, record.failure.at).start; + return { + condition: "14.20", + message: `unparseable source: ${record.failure.message} (SPEC 14.20)`, + file: record.rel, + location: { start: at, end: at }, + }; + }; for (const record of files.values()) { if (record.failure !== null) { - findings.push({ - condition: "14.20", - message: `unparseable source: ${record.failure.message} (SPEC 14.20)`, - file: record.rel, - location: byteRange(record, record.failure.at, record.failure.at + 1), - }); + findings.push(unparseable(record)); continue; } for (const section of record.sections) { @@ -1325,22 +1762,119 @@ async function loadWorkspace(cwd, configFlag) { } } } - return { config, files, findings }; + // Every discovered code source is parsed (SPEC 7.2, 14.20): an ill-formed + // one is a build validation failure, so `build` and `check` report it and + // the gate of 13.3 turns `ids` and `query` back with it. + for (const rel of discovery.codeSources) { + const bytes = await fsp.readFile(path.join(config.root, ...rel.split("/"))); + const record = analyzeCodeFile(rel, bytes); + if (record.failure !== null) findings.push(unparseable(record)); + } + return { config, files, codeSources: discovery.codeSources, findings }; } // --------------------------------------------------------------------------- // 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<string, unknown>} */ - 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, + }), + ), }; } @@ -1445,6 +1979,29 @@ async function commandBuild(io, cwd, argv) { return 0; } +/** + * `xspec check` (SPEC 12.2, scoped): `build`'s validations, writing nothing. + * §CONF-DISC serves `check` over validation-failing workspaces alone — T7-6's + * invalid-source arm and its control, each staged from scratch with no + * `build` succeeding on it, so no record exists (13.3) and `check` reports + * exactly `build`'s findings (12.2): 14.10's mismatch forms are undetectable + * on a failing workspace and its recorded-file form meets no record. So the + * findings report, exit 1. A workspace passing the validations would need + * 14.10's verification of derived files and graph data, which this + * conformer keeps none of: outside the scope, refused loudly (exit 70) — + * never a false clean answer. + */ +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); + } + throw new ScopeError( + "check on a workspace passing build's validations is outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: check over T7-6's validation-failing workspaces alone; 14.10's derived-file and graph-data verification is not implemented)", + ); +} + /** * `xspec ids` (SPEC 12.3, scoped): requirement IDs grouped by file — files * in byte order of workspace-relative path, IDs within a file in document @@ -1481,6 +2038,268 @@ async function commandIds(io, cwd, argv) { return 0; } +// The graph-data area and its durable paths (SPEC 13.3, 6.1, 10.1), reported +// by `inventory` (11.6); this conformer never writes under the area. +const GRAPH_DATA_AREA = ".xspec"; +const JOURNAL_PATH = ".xspec/journal"; +const SESSION_DIRECTORY = ".xspec/reviews"; + +/** + * Whether the workspace-relative `rel` is occupied by anything at all: + * presence alone, whatever occupies it (SPEC 11.6). ENOENT and ENOTDIR — a + * path below an area path holding no directory (13.4) — alike mean nothing + * is there. + */ +async function occupiedUnderRoot(rootAbs, rel) { + try { + await fsp.lstat(path.join(rootAbs, ...rel.split("/"))); + return true; + } catch (error) { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return false; + throw error; + } +} + +/** + * `xspec inventory` (SPEC 11.6, scoped): the machine-readable shape of the + * workspace in the full 12.7 document form — the anchoring (root and + * configuration file relative to the working directory), the resolved + * configuration view (groups with their globs exactly as configured, the + * emission state, and the empty coverage and policy lists), every discovered + * source with its group memberships in configuration order, the derived-file + * map (the module `X.xspec.ts` and, while emission is enabled, `X.md` beside + * each `.mdx` spec source; both structurally absent for a spec-group file + * without the extension, 14.19/13.1), the recorded derived paths, the + * graph-data area, the journal's occupancy, and the session files. It parses + * no source, so it answers whatever the sources' validity — configuration + * errors keep their precedence (14.14) — and it writes nothing. This + * conformer keeps no graph data, so the record is empty (11.6: empty + * wherever none exists) and no 14.23 arises; review sessions are outside the + * scope, so a present session directory is refused loudly, never answered. + */ +async function commandInventory(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const config = await loadConfig(cwd, flags["--config"]); + const discovery = await discoverSources(config); + const groupView = (map) => + Object.entries(map).map(([name, globs]) => ({ name, globs: [...globs] })); + const memberships = (rel) => { + const groups = []; + for (const [kind, map] of [ + ["spec", config.groups], + ["code", config.codeGroups], + ]) { + for (const [name, globs] of Object.entries(map)) { + if (globs.some((pattern) => globMatches(pattern, rel))) { + groups.push({ kind, name }); + } + } + } + return groups; + }; + const discovered = [ + ...new Set([...discovery.keptSpec, ...discovery.keptCode]), + ].sort(compareUtf8); + const sources = discovered.map((rel) => ({ + path: rel, + groups: memberships(rel), + })); + const derived = [...discovery.keptSpec].sort(compareUtf8).map((rel) => { + if (!rel.endsWith(".mdx")) { + return { source: rel, module: null, markdown: null }; + } + const stem = rel.slice(0, -".mdx".length); + return { + source: rel, + module: stem + ".xspec.ts", + markdown: config.emit ? stem + ".md" : null, + }; + }); + if (await occupiedUnderRoot(config.root, SESSION_DIRECTORY)) { + throw new ScopeError( + `a review-session directory (${SESSION_DIRECTORY}) is present: review sessions are outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC; SPEC 10.1, 11.6)`, + ); + } + const doc = { + findings: [], + root: anchoringPath(cwd, config.root), + config: anchoringPath(cwd, config.configPath), + configuration: { + specs: groupView(config.groups), + code: groupView(config.codeGroups), + markdown: { emit: config.emit, outDir: null }, + coverage: [], + policy: [], + }, + sources, + derived, + recorded: [], + graphData: GRAPH_DATA_AREA, + journal: { + path: JOURNAL_PATH, + occupied: await occupiedUnderRoot(config.root, JOURNAL_PATH), + }, + sessions: [], + }; + if (flags["--json"]) { + io.stdout(canonicalJson(doc) + "\n"); + } else { + const lines = [`root: ${doc.root}`, `config: ${doc.config}`]; + for (const entry of sources) { + const groups = entry.groups.map((g) => `${g.kind}:${g.name}`).join(", "); + lines.push(`${entry.path} [${groups}]`); + } + io.stdout(lines.map((line) => line + "\n").join("")); + } + return 0; +} + +const QUERY_FLAGS = { + "--from": "value", + "--to": "value", + "--kinds": "value", + "--json": "bool", + "--config": "value", +}; + +/** JSON-only surfaces (SPEC 11): the single document is the entire stdout. */ +function emitJsonOnly(io, doc) { + io.stdout(canonicalJson(doc) + "\n"); +} + +/** + * The `contains` edges (SPEC 5.2) a parsed spec source contributes: root → + * each top-level section, and each section → its direct children, in + * document order of the containing node, then of the contained one. Section + * identities are `path#id` over the spelled (chain-form, 1.3) IDs; callers + * answer only past the 13.3 gate, so every section here spells one. + * @param {{ rel: string, sections: { id: string | null, depth: number }[] }} record + * @returns {{ from: string, to: string, kind: string }[]} + */ +function containsEdges(record) { + const edges = []; + const identity = (section) => `${record.rel}#${String(section.id)}`; + const { sections } = record; + for (let i = -1; i < sections.length; i += 1) { + const from = i < 0 ? record.rel : identity(sections[i]); + const depth = i < 0 ? -1 : sections[i].depth; + for (let j = i + 1; j < sections.length; j += 1) { + if (sections[j].depth <= depth) break; + if (sections[j].depth === depth + 1) { + edges.push({ from, to: identity(sections[j]), kind: "contains" }); + } + } + } + return edges; +} + +/** The unknown-graph-node usage error of SPEC 11.1/12.0 for `--from`. */ +function unknownGraphNode(spelling) { + return new UsageError( + `query edges: unknown graph node ${JSON.stringify(spelling)} for --from — expected a requirement node (path#id, or a bare path for a spec source's root) or a discovered code source's whole-file location; a path in no configured group is unknown (SPEC 11.1, 1.5, 4.6, 12.0)`, + ); +} + +/** + * `xspec query edges [--from <graph-node>]` (SPEC 11.1, scoped): the + * observation of the discovered code set. JSON-only (11, 12.0). The `--from` + * check (12.0) runs after configuration loading — a configuration error + * precedes it — and before the 13.3 gate: the spelling must name a graph + * node of the current discovery — a discovered code source's whole-file + * location (its path, 4.6), a discovered spec source's root (its path), or + * a section it spells (`path#id`) — else it is the unknown-graph-node usage + * error, exit 2, whatever findings the workspace carries; a derived path the + * 13.4 exclusion kept out of every group is such an unknown path. An + * unparseable named spec file masks its identity check, the gate then + * reporting the findings (exit 1, 12.0). Past the gate the answer is + * recomputed from sources as `ids` does: a code location has no edges + * (nothing in scope gives a code file one), a spec node's outgoing edges are + * its `contains` edges (5.2), and without `--from` every edge is enumerated, + * files in byte order. `--to`, `--kinds`, named code units, and the other + * `query` subcommands are outside this fixture's scope (ScopeError, exit + * 70). + */ +async function commandQuery(io, cwd, argv) { + const subcommand = argv[0]; + if (subcommand === undefined) { + throw new UsageError("query: missing subcommand (SPEC 11.1, 12.0)"); + } + if (subcommand !== "edges") { + const known = ["node", "nodes", "subtree", "ancestors", "reachable"]; + if (known.includes(subcommand)) { + throw new ScopeError( + `query ${subcommand} is outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: query edges --from <path> only)`, + ); + } + throw new UsageError( + `query: unknown subcommand ${subcommand} (SPEC 11.1, 12.0)`, + ); + } + const { flags } = parseArgs(argv.slice(1), QUERY_FLAGS, [0, 0]); + for (const flag of ["--to", "--kinds"]) { + if (flags[flag] !== undefined) { + throw new ScopeError( + `query edges ${flag} is outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: query edges --from <path> only)`, + ); + } + } + // Syntax alone (SPEC 12.0): a graph-node spelling holds at most one `#`, a + // non-empty path part, and, when a `#` is present, a non-empty id part — + // judged before configuration is loaded. + const from = flags["--from"]; + let fromPath = null; + let fromId = null; + if (from !== undefined) { + const parts = from.split("#"); + if (parts.length > 2 || parts[0] === "" || parts[1] === "") { + throw new UsageError( + `query edges: malformed graph-node identity ${JSON.stringify(from)} for --from (SPEC 1.5, 12.0)`, + ); + } + fromPath = parts[0]; + fromId = parts[1] ?? null; + } + const ws = await loadWorkspace(cwd, flags["--config"]); + // The argument check precedes the gate (SPEC 12.0), judged from the + // current discovery: a path in no configured group is unknown (11.1). + let record = null; + if (fromPath !== null) { + if (ws.codeSources.includes(fromPath)) { + if (fromId !== null) { + throw new ScopeError( + `named code units (${JSON.stringify(from)}) are outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: whole-file code locations only)`, + ); + } + } else if (ws.files.has(fromPath)) { + record = ws.files.get(fromPath); + // An unparseable named file masks the identity check; the gate below + // reports its findings (12.0). + if ( + fromId !== null && + record.failure === null && + !record.sections.some((section) => section.id === fromId) + ) { + throw unknownGraphNode(from); + } + } else { + throw unknownGraphNode(from); + } + } + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); + } + let edges; + if (fromPath === null) { + edges = [...ws.files.values()].flatMap(containsEdges); + } else if (record === null) { + edges = []; // a whole-file code location: edgeless in scope (4.6) + } else { + edges = containsEdges(record).filter((edge) => edge.from === from); + } + emitJsonOnly(io, { edges }); + return 0; +} + // --------------------------------------------------------------------------- // Entry: deviation seam + dispatch // --------------------------------------------------------------------------- @@ -1502,23 +2321,50 @@ export async function runXspec(argv, cwd, options = {}) { /** Dispatch one parsed invocation and map its outcome to SPEC 12.0's codes. */ async function dispatchCommand(io, cwd, argv) { - const wantsJson = argv.includes("--json"); + // JSON output is in effect with `--json`, or when the invoked surface is + // JSON-only (SPEC 12.0) — `query` (11) — governing error delivery too. + const wantsJson = argv.includes("--json") || argv[0] === "query"; try { const command = argv[0]; const rest = argv.slice(1); switch (command) { case "build": return await commandBuild(io, cwd, rest); + case "check": + return await commandCheck(io, cwd, rest); case "ids": return await commandIds(io, cwd, rest); + case "inventory": + return await commandInventory(io, cwd, rest); + case "query": + return await commandQuery(io, cwd, rest); default: throw new UsageError( - `unknown command ${String(command)} (SPEC 12.0; this fixture's surface is build and ids, CERTIFICATIONS.md §CONF-DISC)`, + `unknown command ${String(command)} (SPEC 12.0; this fixture's surface is build, check, ids, inventory, and query edges, CERTIFICATIONS.md §CONF-DISC)`, ); } } 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; } @@ -1526,6 +2372,13 @@ async function dispatchCommand(io, cwd, argv) { emitFindings(io, wantsJson, error.findings); return 1; } + if (error instanceof ScopeError) { + // Outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC): refused + // loudly, outside the 12.0 partition — never answered, never + // misreported as a usage error. + io.stderr(`xspec: fixture scope error: ${error.message}\n`); + return 70; + } // A crash is a fixture bug: exit outside the 12.0 partition so every // exit-code assertion fails loudly and the diagnosis carries the stack. io.stderr( diff --git a/test/fixtures/conf-md/product.mjs b/test/fixtures/conf-md/product.mjs index 83eed4ce..935fadf3 100644 --- a/test/fixtures/conf-md/product.mjs +++ b/test/fixtures/conf-md/product.mjs @@ -7,30 +7,78 @@ // 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). +// reporting identity, source range (SPEC 1.7), own and subtree text (1.6, +// defined through the rules of 3), and its `contains` edges (5.2): the +// outgoing ones naming its children in document order (a root's naming its +// top-level sections), the incoming one from its parent (none for a root). +// Hashes, tags, the coverage attribute, and dependency edges are consulted +// by no in-scope test and are not reported (their content is out of scope). +// - 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 -// the scope's constructs: spec module imports at line start, `<S>`/`<Spec>` +// the scope's constructs: spec module imports at a Markdown line start (the +// MDX ESM position) or continuing an open ESM block after ECMAScript +// whitespace and line terminators alone — U+2028/U+2029 beside LF/CR, the +// block deriving under 14.20 by ECMAScript's own line model, as T3-3's +// ESM-block arm spells two imports on one physical line separated by +// U+2028 — the separator staying content, since SPEC 3 removes a +// declaration's own characters alone; `<S>`/`<Spec>` // opening/closing/self-closing tags with the 2.7 prop set (quoted `id`, -// `coverage`, `tags`; quote-aware braced `d`), MDX comments (single- and -// multi-line), and `{text(...)}` embeddings with local (string) or external -// (property chain) arguments. Deliberately no stock MDX parser: the -// committed SUITE-11 fixtures stage shapes remark-mdx cannot parse — an -// import line directly followed by a non-blank line (T3-3), and an opening -// tag with trailing same-line content whose closing tag sits on a later -// line (T3-1's `gamma`) — and the line-drop fixtures depend on exact exotic -// bytes (boundary code points, lone-CR terminators) that tooling silently -// normalizes. That mis-staging hazard is exactly what §CONF-MD certifies -// against. +// `coverage`, `tags`; quote-aware braced `d`), and expression containers +// judged as 14.20's accepting side fixes them for the scope's forms: once +// ECMAScript's whitespace, line terminators, and comments are deleted — a +// brace on a commented-out line closing nothing, so the run-on `{// c}` +// form runs on to a later `}` — nothing left is an MDX comment (`{/* … */}` +// single- and multi-line, `{}`, block-comment sequences, line-comment +// containers ended by U+000A or U+000D, ECMAScript-only whitespace between +// the braces; T2.7-4's positive forms) and exactly one `text(...)` call +// with local (string) or external (property chain) argument is an +// embedding whatever whitespace and comments stand beside the call +// (T2.3-3's positive forms), replaced whole; and JavaScript comments beside +// an import in its ESM block are content (T3-7). Deliberately no stock MDX +// parser: a committed +// SUITE-11 fixture once staged a shape remark-mdx cannot parse — an opening +// tag with trailing same-line content whose closing tag sat alone on a +// later line (T3-1's `gamma`, since restaged under S-9 to close within its +// paragraph; T3-3's import line directly followed by a non-blank line was +// restaged with a blank line ending its block) — and the line-drop +// fixtures depend on exact exotic 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 @@ -76,7 +124,10 @@ // so a lone U+000D is an ordinary in-line character while CRLF and lone // U+000A remain terminators. // Both points feed the single attributed compile, so a deviation applied -// there is consistent across output and text values by construction. +// there is consistent across output and text values by construction. The +// `contains` edges `query node` reports come from the parse's section tree, +// which neither switch touches, so every violator reports the conformer's +// edges unchanged. import * as fsp from "node:fs/promises"; import * as path from "node:path"; @@ -657,7 +708,135 @@ const TAG_WHITESPACE = new Set(["\t", "\n", "\v", "\f", "\r", " "]); const IMPORT_RE = /^import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+(?:"([^"\r\n]*)"|'([^'\r\n]*)');?/; -const EMBED_OPEN_RE = /^\{[ \t]*text[ \t]*\(/; +/** + * ECMAScript's LineTerminator code points — U+000A, U+000D, U+2028, U+2029 — + * the line model of an ESM block's own grammar (SPEC 14.20), under which two + * imports separated by U+2028 on one physical line derive (T3-3's ESM-block + * arm). Distinct from SPEC 3's line model, where U+2028 and U+2029 are + * ordinary characters (1.4): the compile never consults this set. + */ +const ECMASCRIPT_LINE_TERMINATOR_CODE_POINTS = new Set([ + 0x000a, 0x000d, 0x2028, 0x2029, +]); + +/** ECMAScript's WhiteSpace — U+0009, U+000B, U+000C, U+FEFF, and every + * Space_Separator (U+0020 and U+00A0 among them) — again the ESM block's own + * class (14.20), never SPEC 1.4's. */ +const SPACE_SEPARATOR_RE = /\p{Zs}/u; +function isEcmascriptWhitespaceCode(code) { + return ( + code === 0x0009 || + code === 0x000b || + code === 0x000c || + code === 0xfeff || + SPACE_SEPARATOR_RE.test(String.fromCharCode(code)) + ); +} + +/** + * 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. @@ -673,6 +852,144 @@ const EMBED_OPEN_RE = /^\{[ \t]*text[ \t]*\(/; * pieces, failure } where `failure` is null or { at, message } (an * unparseable source, SPEC 14.20 — masking the conditions inside). */ +/** + * ECMAScript's trivia between braces and within an ESM block — WhiteSpace, + * LineTerminator, and comments, the characters 14.20's judgement deletes: + * skip from `from` and report where the next token would begin, whether a + * LineTerminator lay among the skipped characters (outside a comment, or a + * block comment spanning one — ECMAScript's syntactic grammar treats such a + * comment as a LineTerminator, automatic semicolon insertion included), and, + * when a block comment lacks its `*` `/` or a line comment runs to the end + * of the text, the unterminated comment's start. A line comment runs through + * the first U+000A or U+000D — the deletion judgement of 14.20 — U+2028 and + * U+2029 notwithstanding: no form staged in scope spells either inside a + * line comment (T2.7-4's negative arms lie outside §CONF-MD), so the lexical + * grammar's own brace-token check on the undeleted content stays dormant. + */ +function skipEcmascriptTrivia(text, from) { + let j = from; + let sawTerminator = false; + while (j < text.length) { + const code = text.charCodeAt(j); + if (ECMASCRIPT_LINE_TERMINATOR_CODE_POINTS.has(code)) { + sawTerminator = true; + j += 1; + continue; + } + if (isEcmascriptWhitespaceCode(code)) { + j += 1; + continue; + } + if (text.startsWith("/*", j)) { + const end = text.indexOf("*/", j + 2); + if (end === -1) { + return { at: j, sawTerminator, unterminated: "block comment" }; + } + for (let k = j + 2; k < end; k += 1) { + if (ECMASCRIPT_LINE_TERMINATOR_CODE_POINTS.has(text.charCodeAt(k))) { + sawTerminator = true; + break; + } + } + j = end + 2; + continue; + } + if (text.startsWith("//", j)) { + let k = j + 2; + while ( + k < text.length && + text.charCodeAt(k) !== 0x000a && + text.charCodeAt(k) !== 0x000d + ) { + k += 1; + } + if (k >= text.length) { + return { at: j, sawTerminator, unterminated: "line comment" }; + } + j = k; // the terminator itself is skipped on the next round + continue; + } + break; + } + return { at: j, sawTerminator, unterminated: null }; +} + +const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + +/** + * The expression container opening at `i` (`text[i] === "{"`), judged as + * 14.20's accepting side fixes it for the forms in scope (2.7, 2.3): once + * ECMAScript's whitespace, line terminators, and comments are deleted from + * the content between the braces — a brace on a commented-out line closing + * nothing, so the run-on `{// c}` form runs on to a later `}` — what remains + * is either nothing (an MDX comment: the usual block-comment form, `{}`, a + * block-comment sequence, a line-comment container, ECMAScript-only + * whitespace between the braces) or exactly one `text(...)` call with + * whitespace and comments + * alone beside it (an embedding, replaced whole). Returns the container's + * end and kind (the embedding's parsed reference with it), a failure for an + * unterminated comment or a line comment reaching the end of the text (the + * container never closes: 14.20), or null when the content is anything else + * — outside the fixture's scope, left as content exactly as a stray brace + * is. + */ +function scanExpressionContainer(text, i) { + const failure = (trivia) => ({ + failure: { + at: trivia.at, + message: `unterminated ${trivia.unterminated} in an expression container`, + }, + }); + const lead = skipEcmascriptTrivia(text, i + 1); + if (lead.unterminated !== null) return failure(lead); + let j = lead.at; + if (text[j] === "}") return { kind: "comment", end: j + 1 }; + if (!text.startsWith("text", j)) return null; + j = skipEcmascriptTrivia(text, j + 4).at; + if (text[j] !== "(") return null; + j = skipEcmascriptTrivia(text, j + 1).at; + let ref; + const q = text[j]; + if (q === '"' || q === "'") { + const end = text.indexOf(q, j + 1); + if (end === -1) return null; + ref = { form: "local", id: text.slice(j + 1, end) }; + j = end + 1; + } else { + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident) return null; + const binding = ident[0]; + j += binding.length; + const segments = []; + for (;;) { + if (text[j] === ".") { + const seg = IDENTIFIER_RE.exec(text.slice(j + 1)); + if (!seg) return null; + segments.push(seg[0]); + j += 1 + seg[0].length; + continue; + } + if (text[j] === "[") { + const qq = text[j + 1]; + if (qq !== '"' && qq !== "'") return null; + const end = text.indexOf(qq, j + 2); + if (end === -1 || text[end + 1] !== "]") return null; + segments.push(text.slice(j + 2, end)); + j = end + 2; + continue; + } + break; + } + ref = { form: "external", binding, segments }; + } + j = skipEcmascriptTrivia(text, j).at; + if (text[j] !== ")") return null; + const trail = skipEcmascriptTrivia(text, j + 1); + if (trail.unterminated !== null) return failure(trail); + if (text[trail.at] !== "}") return null; + return { kind: "embed", end: trail.at + 1, ref }; +} + function parseMdx(text) { const root = { isRoot: true, @@ -693,6 +1010,34 @@ 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; + // The open ESM block (SPEC 14.20): the end of its last import declaration, + // or -1 when none is open. A block runs on through ECMAScript's trivia + // alone — WhiteSpace and LineTerminator code points (U+2028 and U+2029 + // beside LF and CR) and JavaScript comments beside the declarations, `//` + // and `/* */` alike (T3-7) — and ECMAScript admits the next + // ImportDeclaration there when a LineTerminator lies between (automatic + // semicolon insertion) or the previous declaration spelled its `;`; any + // other character closes the block for good, so the scan costs each closed + // block once. The trivia stays content: SPEC 3 removes a declaration's own + // characters alone (T3-3's ESM-block arm: the line left holding U+2028; + // T3-7: `// note` after an import, an own-line `// note` between two + // imports, a block comment before one). + let lastImportEnd = -1; + const continuesEsmBlock = (at) => { + if (lastImportEnd === -1) return false; + const trivia = skipEcmascriptTrivia(text, lastImportEnd); + if (trivia.unterminated !== null || trivia.at < at) { + lastImportEnd = -1; // a token (or a broken comment) closed the block + return false; + } + if (trivia.at > at) return false; // `at` lies inside a comment + return trivia.sawTerminator || text[lastImportEnd - 1] === ";"; + }; const flushContent = (end) => { if (end > contentStart) { @@ -709,12 +1054,31 @@ 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" && - (i === 0 || text[i - 1] === "\n" || text[i - 1] === "\r") + (i === 0 || + text[i - 1] === "\n" || + text[i - 1] === "\r" || + continuesEsmBlock(i)) ) { - // A spec module import (SPEC 2.1) at line start — the MDX ESM position. + // A spec module import (SPEC 2.1) at a Markdown line start — the MDX + // ESM position — or continuing the open ESM block after ECMAScript + // whitespace and line terminators alone (two imports on one physical + // line separated by U+2028: T3-3's ESM-block arm). const m = IMPORT_RE.exec(text.slice(i)); if (m) { flushContent(i); @@ -727,6 +1091,7 @@ function parseMdx(text) { pieces.push({ kind: "removal", text: m[0] }); i += m[0].length; contentStart = i; + lastImportEnd = i; continue; } i += 1; @@ -862,100 +1227,30 @@ function parseMdx(text) { 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); - pieces.push({ kind: "removal", text: text.slice(i, end + 3) }); - i = end + 3; - contentStart = i; + const container = scanExpressionContainer(text, i); + if (container === null) { + i += 1; // a stray `{` is ordinary content in this scope 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 = /^[A-Za-z_$][A-Za-z0-9_$]*/.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 = /^[A-Za-z_$][A-Za-z0-9_$]*/.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); + if (container.failure !== undefined) { + fail20(container.failure.at, container.failure.message); + return result(); + } + flushContent(i); + if (container.kind === "comment") { + pieces.push({ kind: "removal", text: text.slice(i, container.end) }); + } else { pieces.push({ kind: "embed", - text: text.slice(i, j), + text: text.slice(i, container.end), owner: stack[stack.length - 1], - ref, + ref: container.ref, start: i, target: null, }); - i = j; - contentStart = i; - continue; } - i += 1; // a stray `{` is ordinary content in this scope + i = container.end; + contentStart = i; continue; } i += 1; @@ -1445,14 +1740,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, + }), + ), }; } @@ -1543,17 +1919,159 @@ async function commandBuild(io, cwd, argv) { } /** - * `xspec query node <node>` (SPEC 11, scoped): a single JSON document — with - * or without `--json` — reporting the node's own and subtree text (SPEC 1.6, - * the §CONF-MD query surface); identity and source range ride along in the - * natural SPEC 11 shape. `<node>` is `path#id`, or a bare `path` for a file's - * root node (SPEC 1.5). + * `xspec check` (SPEC 12.2, scoped): validate without writing anything. + * Findings are the exit-1 report exactly as `build` reports them; a valid + * workspace exits 0 — T3-1's grammar-boundary arm asserts exactly that over + * fenced/code-span construct-like bytes. (This scope records no graph data, + * so there is no staleness to check beyond validation.) + */ +async function commandCheck(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const ws = await loadWorkspace(cwd, flags["--config"]); + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); + } + compileWorkspace(ws); // surfaces an in-scope cycle exactly as `build` does + if (flags["--json"]) { + io.stdout(canonicalJson(findingsDoc([])) + "\n"); + } + return 0; +} + +/** A requirement node's identity (SPEC 1.5): bare path for a root. */ +function nodeIdentity(rel, node) { + return node.isRoot ? rel : `${rel}#${node.id}`; +} + +/** + * `xspec query nodes` (SPEC 11.1, scoped to T3-1's grammar-boundary arm): a + * single JSON document — with or without `--json` — listing every + * requirement node of the valid workspace, files in byte order of + * workspace-relative path, the root then sections in document order per + * file. Each row carries the node's identity (SPEC 1.5) with its construct + * byte range riding along; the scoped observation is that no node arises + * from construct-like bytes inside fences or code spans. + */ +async function commandQueryNodes(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const ws = await loadWorkspace(cwd, flags["--config"]); + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); // SPEC 13.3: reads gate on validity + } + compileWorkspace(ws); // an in-scope cycle refuses here too, never answers + const rows = []; + for (const record of ws.files.values()) { + rows.push({ + identity: record.rel, + sourceRange: { start: 0, end: record.byteOf(record.text.length) }, + }); + for (const section of record.sections) { + rows.push({ + identity: nodeIdentity(record.rel, section), + sourceRange: { + start: record.byteOf(section.openStart), + end: record.byteOf(section.closeEnd), + }, + }); + } + } + io.stdout(canonicalJson({ nodes: rows }) + "\n"); + return 0; +} + +/** + * `xspec query edges` (SPEC 11.1, scoped to T3-1's grammar-boundary arm): a + * single JSON document — with or without `--json` — listing every edge of + * the valid workspace's graph (SPEC 5.2): `contains` from each parent to + * each child section (the file root parenting top-level sections), `depends` + * from `d` props, `embeds` from `{text(...)}` embeddings; `references` never + * (no code groups in scope). Edges of each kind form a set — duplicates + * collapse — ordered deterministically by kind, source, then target (byte + * order). The scoped observation is that no edge arises from construct-like + * bytes inside fences or code spans. + */ +async function commandQueryEdges(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const ws = await loadWorkspace(cwd, flags["--config"]); + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); // SPEC 13.3: reads gate on validity + } + compileWorkspace(ws); // an in-scope cycle refuses here too, never answers + const edges = []; + const seen = new Set(); + const push = (kind, from, to) => { + const key = `${kind}\u0000${from}\u0000${to}`; + if (seen.has(key)) return; // edges of each kind form a set (SPEC 5.2) + seen.add(key); + edges.push({ from, kind, to }); + }; + for (const record of ws.files.values()) { + for (const section of record.sections) { + push( + "contains", + nodeIdentity(record.rel, section.parent), + nodeIdentity(record.rel, section), + ); + } + for (const section of record.sections) { + if (section.dRaw === undefined) continue; + const refs = parseDReferences(section.dRaw); + if (refs === null) continue; // unreachable: gated as 14.8 above + for (const ref of refs) { + const resolved = resolveRef(ws.files, record, ref); + if (resolved === null) continue; // unreachable: gated as 14.5 above + push( + "depends", + nodeIdentity(record.rel, section), + nodeIdentity(resolved.rel, resolved.node), + ); + } + } + for (const piece of record.pieces) { + if (piece.kind !== "embed") continue; + if (piece.target === null) continue; // unreachable: gated as 14.6 above + push( + "embeds", + nodeIdentity(record.rel, piece.owner), + nodeIdentity(piece.target.rel, piece.target.node), + ); + } + } + const byBytes = (a, b) => + Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); + edges.sort( + (a, b) => + byBytes(a.kind, b.kind) || byBytes(a.from, b.from) || byBytes(a.to, b.to), + ); + io.stdout(canonicalJson({ edges }) + "\n"); + return 0; +} + +/** + * `xspec query node <node>` (SPEC 11.1, scoped): a single JSON document — + * with or without `--json` — reporting the §CONF-MD query surface in the + * natural SPEC 11 shape: the node's identity, source range (SPEC 1.7), own + * and subtree text (1.6), and its `contains` edges (5.2) as + * `"edges": {"incoming": [Edge], "outgoing": [Edge]}`, Edge + * `{"from", "kind", "to"}` — outgoing, one edge to each child in document + * order (a root's to each top-level section); incoming, the one edge from + * its parent (none for a root). Hashes, tags, the coverage attribute, and + * dependency edges are consulted by no in-scope test and are left out. + * `<node>` is `path#id`, or a bare `path` for a file's 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]); @@ -1581,6 +2099,13 @@ async function commandQuery(io, cwd, argv) { ); } const atoms = compiled.get(rel); + // SPEC 5.2: `contains` runs parent → child; `children` holds a node's + // child sections in document order (pushed as each opening tag parses). + const contains = (parent, child) => ({ + from: nodeIdentity(rel, parent), + kind: "contains", + to: nodeIdentity(rel, child), + }); io.stdout( canonicalJson({ identity, @@ -1590,6 +2115,10 @@ async function commandQuery(io, cwd, argv) { }, ownText: textOfOwnAtoms(atoms, node), subtreeText: textOfSubtreeAtoms(atoms, node), + edges: { + incoming: node.parent === null ? [] : [contains(node.parent, node)], + outgoing: node.children.map((child) => contains(node, child)), + }, }) + "\n", ); return 0; @@ -1623,6 +2152,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-orphan/bin-linktarget.mjs b/test/fixtures/conf-orphan/bin-linktarget.mjs new file mode 100644 index 00000000..0a519d9f --- /dev/null +++ b/test/fixtures/conf-orphan/bin-linktarget.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// VIOL-ORPHAN-LINKTARGET violator executable (CERTIFICATIONS.md +// §VIOL-ORPHAN-LINKTARGET). The CONF-ORPHAN conformer with exactly one +// behavioral deviation: removal of a recorded derived path no longer +// generated, where the path's occupant is a symbolic link, deletes in place +// of the link the plain file the link resolves to — nothing where it +// resolves to no plain file — and leaves the link standing. Unchanged: the +// occupant judged as itself, so 14.10's recorded-file form still reports the +// link concerning the recorded path; nothing read below a workspace-relative +// directory component occupied by anything other than a directory; and +// derived-file writes still replacing a symbolic link at a derived file's +// path as the occupant, traversing none. Certifies T13.4-11 (C-1): it fails +// on arm (c) alone — `build` exiting 0 having deleted the file outside the +// workspace that the link at `specs/A.md` targets and left the link +// standing, while the first `check`'s recorded-file finding concerning +// `specs/A.md` is unmoved. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + removeLinkTarget: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-orphan/bin-throughlink.mjs b/test/fixtures/conf-orphan/bin-throughlink.mjs new file mode 100644 index 00000000..1e5fdc2d --- /dev/null +++ b/test/fixtures/conf-orphan/bin-throughlink.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +// VIOL-ORPHAN-THROUGHLINK violator executable (CERTIFICATIONS.md +// §VIOL-ORPHAN-THROUGHLINK). The CONF-ORPHAN conformer with exactly one +// behavioral deviation: removal of a recorded derived path no longer +// generated resolves the path's workspace-relative directory components +// through symbolic links to directories inside the workspace root — below a +// component such a link occupies, the occupant is the entry the link's target +// holds under the path's remaining components, judged and removed by 13.4's +// other rules as if it stood at the recorded path, and 14.10's recorded-file +// form, reporting exactly the occupants that removal would remove, reports +// it. Unchanged: 13.4's reads of the journal, the session directory, and the +// record; the occupant at the recorded path itself judged as itself (a +// symbolic link there removed as the link, never its target); a component +// occupied by a plain file, or by a symbolic link to a directory outside the +// workspace root, still leaving the path holding nothing; and derived-file +// writes traversing no symbolic link. Certifies T13.4-11 (C-1): it fails on +// arm (e)'s staging inside the workspace alone — the first `check` reporting +// the foreign `A.md` as a condition-10 recorded-file finding concerning +// `out/specs/A.md`, and `build` deleting it. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + componentLinksInsideRoot: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-orphan/bin.mjs b/test/fixtures/conf-orphan/bin.mjs new file mode 100644 index 00000000..f7790e09 --- /dev/null +++ b/test/fixtures/conf-orphan/bin.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node +// CONF-ORPHAN conformer executable (CERTIFICATIONS.md §CONF-ORPHAN). 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-ORPHAN-*) 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-orphan/product.mjs b/test/fixtures/conf-orphan/product.mjs new file mode 100644 index 00000000..4bb289f9 --- /dev/null +++ b/test/fixtures/conf-orphan/product.mjs @@ -0,0 +1,1563 @@ +// CONF-ORPHAN conformer fixture (CERTIFICATIONS.md §CONF-ORPHAN; TEST-SPEC 17 +// C-1/C-2). A harness-owned executable product implementing §CONF-ORPHAN'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-ORPHAN): +// - Workspaces of one configured spec group of trivial single-section `.mdx` +// sources — an opening `<S id="…">` (or `<Spec …>`) line, plain Markdown +// lines, and the matching closing tag as the last line: no imports, +// embeddings, comments, or props beyond `id` — as T13.4-11 stages them; +// `markdown` absent, `{ emit: false }`, or `{ emit: true }` emitting next to +// sources or under an `outDir` (7.3); a code group (7.2) whose matches are +// well-formed TypeScript of the `export const n = 1` form (14.20); no +// `coverage`, `policy`, or git. Anything outside that shape — another +// source form, another code form, a `coverage` or `policy` rule, another +// command, the `--test-hold` seam — is refused loudly with exit 70 +// (`xspec: fixture scope error: …` on stderr), outside SPEC 12.0's exit +// partition: a fixture-side condition, never a product verdict. +// - Command surface: `build` (12.1) and `check` (12.2), each with `--json` +// and `--config`, read under the invocation grammar of 12.0 (flag tokens +// anywhere, a value-taking flag taking the whole next token, `--` ending +// flag reading, arity fixed by name, a repeated flag, an unknown flag or +// command, and a surplus operand usage errors; JSON output in effect +// exactly when `--json` is read as a flag), with the configuration located +// per 7 (upward search for `xspec.config.ts`, or `--config` resolved +// against the working directory) and its errors reported per 14.14/12.7. +// - Contracts under certification: 13.4's removal of a recorded derived path +// the current sources and configuration no longer generate — its occupant +// judged at the path itself (lstat, never through a link); a directory or +// a discovered source left as it is; anything else removed, a symbolic +// link as the link itself, never its target; nothing read below a +// workspace-relative directory component occupied by anything other than a +// directory, a symbolic link included whatever it targets, the path then +// holding nothing and its removal making no write — and 14.10's +// recorded-file form, reporting exactly the occupants that removal would +// remove. Both consult one function, `recordedOccupant`, so the report and +// the removal cannot drift apart. +// +// Key mechanisms: +// - Discovery (7, 13.4): a walk of plain files under the workspace root that +// never follows or discovers a symbolic link; byte-wise globs (`*`, `?`, +// `**` as a whole segment, the dot-segment rule); the source exclusion of +// 13.4 (`.xspec.` names, `.xspec/` paths, and — while emission is enabled +// — the configured Markdown emit destinations, by configuration alone); +// 14.19's path rules; a file in a spec and a code group a configuration +// error (14.14). +// - `build` (12.1): validates (14.19, and 14.22 over its own write paths — +// every derived path's workspace-relative directory components, and a +// module or Markdown path that is a directory component of another write +// path or of a discovered source's path); on findings exits 1 writing +// nothing. Otherwise it writes each source's module `NAME.xspec.ts` (13.1; +// content beyond the path is out of scope, so fixed bytes naming the +// source; no companion is written) and, while emission is enabled, its +// Markdown per 3 (13.2) — each write replacing its path's occupant (a +// directory with everything it holds, a symbolic link as the link) and +// creating missing directories, never writing through a link (13.4) — +// then removes every recorded path no longer generated (13.4, through +// `recordedOccupant`), and last writes graph data, `.xspec/graph.json`, +// whose record lists the derived paths generated (13.3). Writes precede +// removals, so a removal below a path a write replaced finds nothing +// there (13.4's order independence), and graph data comes last, so a +// regeneration stopped early leaves the previous record naming every +// orphan it has not yet removed. +// - `check` (12.2): the same validations, then 14.10's forms — the per-file +// form (each generated path's occupant judged itself: a plain file holding +// exactly the generated bytes, or stale), the graph-data unit form +// (missing, mismatching the current sources and configuration with the +// record excluded, or unreadable as a record, 14.23 — one finding +// concerning the graph-data area `.xspec`), the mismatch forms only on a +// workspace passing `build`'s validations, and the recorded-file form on +// any workspace whose record reads. Exits 1 on any finding. +// - Graph data is byte-deterministic for the workspace (12.0): the discovered +// sources with their groups, content hashes, and sections, plus the +// record — never history, so a regenerated workspace equals a twin built +// fresh from the same sources and configuration (H-6). +// +// Determinism (SPEC 12.0): no wall clock, no randomness, no absolute paths in +// any output; paths in byte order; 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-ORPHAN-* violator is a bin-<name>.mjs passing exactly one switch, +// consumed where 13.4's removal resolves and removes a recorded path's +// occupant (`recordedOccupant`, `removeRecorded`): +// - `componentLinksInsideRoot` (VIOL-ORPHAN-THROUGHLINK, bin-throughlink.mjs): +// `recordedOccupant` resolves a workspace-relative directory component +// occupied by a symbolic link to a directory inside the workspace root +// through that link, so the removal and 14.10's recorded-file form — both +// consulting it, and nothing else — judge, report, and remove the entry the +// link's target holds under the path's remaining components. +// - `removeLinkTarget` (VIOL-ORPHAN-LINKTARGET, bin-linktarget.mjs): +// `removeRecorded`, given a removable occupant that is a symbolic link, +// deletes the plain file the link resolves to (nothing where it resolves +// to no plain file) and leaves the link standing; `recordedOccupant`, and +// so 14.10's recorded-file form, is untouched, the link still judged as +// itself, and `writeDerived` still replaces a link as the occupant. + +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// Outcome carriers +// --------------------------------------------------------------------------- + +/** + * 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 a configuration error (14.14), `null` for a plain + * usage error (12.7). + */ +class UsageError extends Error { + constructor(message, { code = null, path = null } = {}) { + super(message); + this.code = code; + this.path = path; + } +} + +/** Findings (SPEC 12.0 exit 1): the findings report on stdout. */ +class FindingsError extends Error { + constructor(findings) { + super("findings"); + this.findings = findings; + } +} + +/** + * An invocation or workspace outside this fixture's scope + * (CERTIFICATIONS.md §CONF-ORPHAN) that a conforming product would answer: + * refused loudly with exit 70, outside the 12.0 partition, so a test reaching + * it fails on its exit-code assertion with the cause on stderr — never on a + * fabricated answer. + */ +class ScopeError extends Error {} + +// --------------------------------------------------------------------------- +// Canonical JSON (sorted keys, SPEC 12.0) and byte order +// --------------------------------------------------------------------------- + +function sortKeysDeep(value) { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value !== null && typeof value === "object") { + const sorted = {}; + for (const key of Object.keys(value).sort(compareBytes)) { + sorted[key] = sortKeysDeep(value[key]); + } + return sorted; + } + return value; +} + +function canonicalJson(value) { + return JSON.stringify(sortKeysDeep(value), null, 2); +} + +/** Byte-wise comparison of two strings' UTF-8 encodings (SPEC 12.0). */ +function compareBytes(a, b) { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +function byteSorted(strings) { + return [...strings].sort(compareBytes); +} + +// --------------------------------------------------------------------------- +// Deviation switches (CERTIFICATIONS.md §VIOL-ORPHAN-*), all off in the +// conformer; assigned by runXspec from each violator entry's options. +// --------------------------------------------------------------------------- + +let deviations = {}; + +// --------------------------------------------------------------------------- +// Invocation grammar (SPEC 12.0) +// --------------------------------------------------------------------------- + +const REPLACEMENT_CHARACTER = String.fromCodePoint(0xfffd); +const LINE_SEPARATOR = String.fromCodePoint(0x2028); +const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029); +const BYTE_ORDER_MARK = String.fromCodePoint(0xfeff); + +/** + * Every flag of every command with its arity, fixed by name (12.0): 1 for a + * flag taking the whole next token as its value, 0 for one taking none. A + * `--` token naming no flag of any command takes no value. + */ +const FLAG_ARITY = new Map([ + ["--json", 0], + ["--preview", 0], + ["--tree", 0], + ["--text", 0], + ["--check", 0], + ["--unreferenced", 0], + ["--config", 1], + ["--test-hold", 1], + ["--file", 1], + ["--to", 1], + ["--from", 1], + ["--kinds", 1], + ["--group", 1], + ["--tag", 1], + ["--coverage", 1], + ["--base", 1], + ["--strategy", 1], + ["--name", 1], + ["--status", 1], + ["--note", 1], +]); + +/** Every command of the product (12): only `build` and `check` are served. */ +const PRODUCT_COMMANDS = new Set([ + "build", + "check", + "ids", + "show", + "coverage", + "impact", + "review", + "query", + "occurrences", + "view", + "at", + "inventory", + "rename", + "move", + "version", +]); + +/** + * The flags each served command accepts: the globals `--json` and + * `--config`, plus `--test-hold` for the mutating `build` (13.5). + */ +const COMMAND_FLAGS = { + build: new Set(["--json", "--config", "--test-hold"]), + check: new Set(["--json", "--config"]), +}; + +/** + * An argument value is malformed when it is not valid UTF-8 or contains + * U+FFFD (12.0); Node decodes argv lossily, an ill-formed byte arriving as + * U+FFFD, so the one test covers both. + */ +function isMalformedValue(value) { + return value.includes(REPLACEMENT_CHARACTER); +} + +/** + * Read the arguments under the grammar of 12.0. Returns the flags (name to + * value, `true` for a flag taking none), the remaining non-flag tokens in + * order, whether JSON output is in effect (`--json` read as a flag, even + * when the arguments are themselves the error), and the first syntax-class + * error met, if any. + */ +function readInvocation(argv) { + const flags = new Map(); + const words = []; + let json = false; + let error = null; + const note = (message) => { + if (error === null) error = message; + }; + let flagsEnded = false; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!flagsEnded && token === "--") { + flagsEnded = true; + continue; + } + if (!flagsEnded && token.startsWith("--")) { + if (token === "--json") json = true; + if (flags.has(token)) { + note( + `repeated flag ${token}: a flag may be given at most once (SPEC 12.0)`, + ); + } + if (FLAG_ARITY.get(token) === 1) { + if (i + 1 >= argv.length) { + note(`flag ${token} lacks its value (SPEC 12.0)`); + flags.set(token, true); + } else { + i += 1; + const value = argv[i]; + if (isMalformedValue(value)) { + note( + `malformed value for ${token}: not valid UTF-8, or containing U+FFFD (SPEC 12.0)`, + ); + } + flags.set(token, value); + } + } else { + if (!FLAG_ARITY.has(token)) note(`unknown flag ${token} (SPEC 12.0)`); + flags.set(token, true); + } + continue; + } + if (isMalformedValue(token)) { + note( + "malformed argument: not valid UTF-8, or containing U+FFFD (SPEC 12.0)", + ); + } + words.push(token); + } + return { flags, words, json, error }; +} + +// --------------------------------------------------------------------------- +// Filesystem occupants (SPEC 13.4: judged at the path itself, never through +// a symbolic link) +// --------------------------------------------------------------------------- + +/** + * The kind of a path's occupant, read with lstat: "dir", "file", "symlink", + * "other", or "absent". A path below a non-directory component reads as + * absent (ENOTDIR); callers judging 13.4's components walk them first. + */ +async function lstatKind(absPath) { + try { + const stats = await fsp.lstat(absPath); + if (stats.isDirectory()) return "dir"; + if (stats.isFile()) return "file"; + if (stats.isSymbolicLink()) return "symlink"; + return "other"; + } catch (error) { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return "absent"; + throw error; + } +} + +/** A workspace-relative path's absolute form under the root. */ +function absUnder(rootAbs, rel) { + return path.join(rootAbs, ...rel.split("/")); +} + +/** + * A plain workspace-relative path (1.5, 7.3): one or more non-empty + * `/`-separated segments, none `.` or `..`. + */ +function isPlainRelativePath(value) { + return ( + typeof value === "string" && + value !== "" && + value.split("/").every((s) => s !== "" && s !== "." && s !== "..") + ); +} + +// --------------------------------------------------------------------------- +// Configuration (SPEC 7): location, declarative parse, validation +// --------------------------------------------------------------------------- + +const CONFIG_NAME = "xspec.config.ts"; + +/** + * The anchoring form of SPEC 11.6/14 for a path identified relative to the + * invocation working directory: `/`-joined relative segments, `.` for the + * working directory itself. + */ +function anchoringPath(cwd, absPath) { + const rel = path.relative(path.resolve(cwd), absPath); + if (rel === "") return "."; + return rel.split(path.sep).join("/"); +} + +/** + * Locate, read, parse, and validate the configuration (SPEC 7, 14.14). The + * configuration file is the occupant of the path the upward search finds — + * the nearest directory holding an entry named `xspec.config.ts`, whatever + * occupies it — or the path `--config` names, read only when it is a plain + * file. Every defect is one configuration error concerning that path in the + * anchoring form; a `--config` path nothing occupies is reported as given, + * and missing configuration with no `--config` as `.` (14). + */ +async function loadConfig(cwd, configFlag) { + let configPath; + if (configFlag !== undefined) { + configPath = path.resolve(cwd, configFlag); + if ((await lstatKind(configPath)) === "absent") { + throw new UsageError( + `configuration error: --config ${configFlag} names nothing (SPEC 7, 14.14)`, + { code: "configuration-error", path: configFlag }, + ); + } + } else { + let dir = path.resolve(cwd); + for (;;) { + const candidate = path.join(dir, CONFIG_NAME); + if ((await lstatKind(candidate)) !== "absent") { + configPath = candidate; + break; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new UsageError( + `configuration error: no ${CONFIG_NAME} found by upward search from the working directory (SPEC 7, 14.14)`, + { code: "configuration-error", path: "." }, + ); + } + dir = parent; + } + } + const concerned = anchoringPath(cwd, configPath); + const configError = (what) => + new UsageError( + `configuration error: ${concerned}: ${what} (SPEC 7, 14.14)`, + { + code: "configuration-error", + path: concerned, + }, + ); + if ((await lstatKind(configPath)) !== "file") { + throw configError( + "the configuration path holds something other than a plain file", + ); + } + const bytes = await fsp.readFile(configPath); + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + throw configError("the file begins with a byte-order mark"); + } + let text; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + throw configError("the file is not valid UTF-8"); + } + const data = parseConfigText(text, configError); + return { + ...validateConfig(data, configError), + root: path.dirname(configPath), + configError, + }; +} + +/** ECMAScript whitespace and line terminators, between tokens (14.20). */ +function isConfigSpace(c) { + return ( + c === BYTE_ORDER_MARK || + c === LINE_SEPARATOR || + c === PARAGRAPH_SEPARATOR || + /^[\t\v\f \n\r\p{Zs}]$/u.test(c) + ); +} + +function isLineTerminator(c) { + return ( + c === "\n" || + c === "\r" || + c === LINE_SEPARATOR || + c === PARAGRAPH_SEPARATOR + ); +} + +const WORD_PATTERN = /[A-Za-z_$][A-Za-z0-9_$]*/y; + +/** + * Tokens of the declarative configuration (SPEC 7): punctuators, identifier + * words, and static string literals — whose value is the characters between + * the delimiters exactly as spelled, no escape interpreted (2.4) — with + * whitespace and comments contributing nothing. + */ +function tokenizeConfig(text, configError) { + const tokens = []; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (isConfigSpace(c)) { + i += 1; + continue; + } + if (c === "/" && text[i + 1] === "/") { + i += 2; + while (i < text.length && !isLineTerminator(text[i])) i += 1; + continue; + } + if (c === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + if (end === -1) throw configError("an unterminated block comment"); + i = end + 2; + continue; + } + if ("{}[](),:;".includes(c)) { + tokens.push({ type: "punct", value: c }); + i += 1; + continue; + } + if (c === '"' || c === "'") { + let j = i + 1; + while (j < text.length && text[j] !== c) { + if (text[j] === "\n" || text[j] === "\r") { + throw configError("a line break inside a string literal"); + } + if (text[j] === "\\") { + j += text[j + 1] === "\r" && text[j + 2] === "\n" ? 3 : 2; + } else { + j += 1; + } + } + if (j >= text.length) throw configError("an unterminated string literal"); + tokens.push({ type: "string", value: text.slice(i + 1, j) }); + i = j + 1; + continue; + } + WORD_PATTERN.lastIndex = i; + const word = WORD_PATTERN.exec(text); + if (word !== null) { + tokens.push({ type: "word", value: word[0] }); + i += word[0].length; + continue; + } + throw configError( + `unexpected character at offset ${String(i)}: only the declarative form is admitted`, + ); + } + return tokens; +} + +/** + * Parse the declarative configuration (SPEC 7): exactly an import of + * `defineConfig` from "xspec" (optionally aliased; no `type` or `defer` + * modifier, no import attributes) 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 (a key repeated within one + * object a configuration error), array literals, static string literals, and + * `true`/`false`. Objects are returned as Maps, in spelling order. + */ +function parseConfigText(text, configError) { + const tokens = tokenizeConfig(text, configError); + let i = 0; + const peekIs = (type, value) => + tokens[i] !== undefined && + tokens[i].type === type && + (value === undefined || tokens[i].value === value); + const take = (type, value, what) => { + if (!peekIs(type, value)) throw configError(`expected ${what}`); + const token = tokens[i]; + i += 1; + return token.value; + }; + take("word", "import", "the import of defineConfig"); + take("punct", "{", '"{" in the import of defineConfig'); + take("word", "defineConfig", "the name defineConfig"); + let binding = "defineConfig"; + if (peekIs("word", "as")) { + i += 1; + binding = take("word", undefined, "the import's alias"); + } + if (peekIs("punct", ",")) i += 1; + take("punct", "}", '"}" in the import of defineConfig'); + take("word", "from", '"from"'); + take("string", "xspec", 'the module specifier "xspec"'); + if (peekIs("punct", ";")) i += 1; + take("word", "export", "the default export"); + take("word", "default", '"default"'); + take("word", binding, `a call of ${binding}`); + take("punct", "(", `"(" after ${binding}`); + const parseValue = () => { + if (peekIs("string")) return take("string", undefined, "a string"); + if (peekIs("word", "true") || peekIs("word", "false")) { + return take("word", undefined, "a boolean") === "true"; + } + if (peekIs("punct", "{")) { + i += 1; + const object = new Map(); + while (!peekIs("punct", "}")) { + const key = peekIs("string") + ? take("string", undefined, "a key") + : take("word", undefined, "an object key"); + if (object.has(key)) { + throw configError(`the key ${JSON.stringify(key)} is repeated`); + } + take("punct", ":", `":" after the key ${JSON.stringify(key)}`); + object.set(key, parseValue()); + if (!peekIs("punct", ",")) break; + i += 1; + } + take("punct", "}", '"}" closing an object literal'); + return object; + } + if (peekIs("punct", "[")) { + i += 1; + const array = []; + while (!peekIs("punct", "]")) { + array.push(parseValue()); + if (!peekIs("punct", ",")) break; + i += 1; + } + take("punct", "]", '"]" closing an array literal'); + return array; + } + throw configError( + "expected a static literal: an object, array, string, or boolean", + ); + }; + const value = parseValue(); + if (peekIs("punct", ",")) i += 1; + take("punct", ")", `")" closing the call of ${binding}`); + if (peekIs("punct", ";")) i += 1; + if (i !== tokens.length) { + throw configError("content after the default export"); + } + if (!(value instanceof Map)) { + throw configError("defineConfig takes one object literal"); + } + return value; +} + +/** + * Whether a glob lies outside the workspace root, decided by its spelling + * alone (SPEC 7): a leading `/`, or a depth falling below zero — `..` + * lowering it, `.`, an empty segment, and `**` leaving it, every other + * segment raising it. + */ +function globOutsideRoot(glob) { + if (glob.startsWith("/")) return true; + let depth = 0; + for (const segment of glob.split("/")) { + if (segment === "..") { + depth -= 1; + if (depth < 0) return true; + } else if (segment !== "." && segment !== "" && segment !== "**") { + depth += 1; + } + } + return false; +} + +/** One group map (7.1, 7.2): named lists of globs, in configuration order. */ +function validatedGroups(value, section, configError) { + if (!(value instanceof Map)) { + throw configError(`${section} must be an object literal of named groups`); + } + const groups = []; + for (const [name, globs] of value) { + if (name === "") throw configError(`an empty ${section} group name`); + if (name.includes(REPLACEMENT_CHARACTER)) { + throw configError(`a ${section} group name containing U+FFFD`); + } + if (!Array.isArray(globs) || globs.some((g) => typeof g !== "string")) { + throw configError( + `the ${section} group ${JSON.stringify(name)} must be a list of glob strings`, + ); + } + for (const glob of globs) { + if (globOutsideRoot(glob)) { + throw configError( + `the glob ${JSON.stringify(glob)} lies outside the workspace root`, + ); + } + } + groups.push({ name, globs }); + } + return groups; +} + +/** + * Validate the parsed configuration (SPEC 7, 7.1–7.3): `specs` required; + * `code` and `markdown` optional; unknown keys anywhere a configuration + * error; `markdown.emit` a required boolean and `markdown.outDir` a plain + * workspace-relative path outside the graph-data area. `coverage` and + * `policy` are outside this fixture's scope unless empty (an empty list + * equals omission, 7). + */ +function validateConfig(data, configError) { + const known = ["specs", "code", "markdown", "coverage", "policy"]; + for (const key of data.keys()) { + if (!known.includes(key)) { + throw configError(`the key ${JSON.stringify(key)} is unknown`); + } + } + for (const key of ["coverage", "policy"]) { + const value = data.get(key); + if (value !== undefined && !(Array.isArray(value) && value.length === 0)) { + throw new ScopeError( + `the configuration key ${key} is outside this fixture's scope (CERTIFICATIONS.md §CONF-ORPHAN: no coverage or policy)`, + ); + } + } + if (!data.has("specs")) throw configError("`specs` is required"); + const specGroups = validatedGroups(data.get("specs"), "specs", configError); + const codeGroups = data.has("code") + ? validatedGroups(data.get("code"), "code", configError) + : []; + let emit = false; + let outDir = null; + if (data.has("markdown")) { + const markdown = data.get("markdown"); + if (!(markdown instanceof Map)) { + throw configError("`markdown` must be an object literal"); + } + for (const key of markdown.keys()) { + if (key !== "emit" && key !== "outDir") { + throw configError(`the key markdown.${key} is unknown`); + } + } + if (typeof markdown.get("emit") !== "boolean") { + throw configError("markdown.emit is required and must be true or false"); + } + emit = markdown.get("emit"); + if (markdown.has("outDir")) { + const value = markdown.get("outDir"); + if (!isPlainRelativePath(value)) { + throw configError( + "markdown.outDir must be one or more non-empty segments, none . or .., with no leading /", + ); + } + if (value === ".xspec" || value.startsWith(".xspec/")) { + throw configError("markdown.outDir lies in the graph-data area .xspec"); + } + outDir = value; + } + } + return { specGroups, codeGroups, emit, outDir }; +} + +// --------------------------------------------------------------------------- +// Discovery (SPEC 7, 13.4) +// --------------------------------------------------------------------------- + +const DOT = 0x2e; +const STAR = 0x2a; +const QUESTION = 0x3f; + +/** One pattern segment against one path segment, byte-wise (SPEC 7). */ +function segmentMatches(pattern, name) { + const p = Buffer.from(pattern, "utf8"); + const s = Buffer.from(name, "utf8"); + // A path segment beginning with `.` is matched only by a pattern segment + // written with a leading `.` (SPEC 7). + if (s[0] === DOT && p[0] !== DOT) return false; + const match = (i, j) => { + if (i === p.length) return j === s.length; + if (p[i] === STAR) { + for (let k = j; k <= s.length; k += 1) { + if (match(i + 1, k)) return true; + } + return false; + } + if (j === s.length) return false; + if (p[i] === QUESTION || p[i] === s[j]) return match(i + 1, j + 1); + return false; + }; + return match(0, 0); +} + +/** + * Glob matching (SPEC 7): `*` any possibly empty byte run within a segment, + * `?` one byte, `**` — as a whole pattern segment only — any number of + * whole segments including none (never a dot-initial one), every other + * character a literal; `.`, `..`, and empty pattern segments match nothing, + * since no discovered path carries such a segment. + */ +function globMatches(glob, rel) { + const patterns = glob.split("/"); + const names = rel.split("/"); + const match = (i, j) => { + if (i === patterns.length) return j === names.length; + if (patterns[i] === "**") { + if (match(i + 1, j)) return true; + for (let k = j; k < names.length; k += 1) { + if (names[k].startsWith(".")) return false; + if (match(i + 1, k + 1)) return true; + } + return false; + } + if (j === names.length) return false; + return segmentMatches(patterns[i], names[j]) && match(i + 1, j + 1); + }; + return match(0, 0); +} + +/** + * Every plain file under the root, workspace-relative (the directory-entry + * names descending from the root, `/`-joined). A symbolic link — to a file + * or a directory, broken or not — is never discovered and never traversed, + * and no other non-plain kind is a source (SPEC 7). + */ +async function walkPlainFiles(rootAbs) { + const files = []; + const walk = async (dirAbs, prefix) => { + const entries = await fsp.readdir(dirAbs, { withFileTypes: true }); + for (const entry of entries) { + const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) { + await walk(path.join(dirAbs, entry.name), rel); + } else if (entry.isFile()) { + files.push(rel); + } + } + }; + await walk(rootAbs, ""); + return byteSorted(files); +} + +/** A spec source's generated module path (13.1). */ +function modulePathOf(sourceRel) { + return `${sourceRel.slice(0, -".mdx".length)}.xspec.ts`; +} + +/** A spec source's Markdown emit destination (13.2, 7.3). */ +function markdownPathOf(config, sourceRel) { + const emitted = `${sourceRel.slice(0, -".mdx".length)}.md`; + return config.outDir === null ? emitted : `${config.outDir}/${emitted}`; +} + +/** + * Discover the configured sources (SPEC 7, 13.4): plain files matched by a + * group's globs, less the derived files — names containing `.xspec.`, paths + * under `.xspec/`, and, while emission is enabled, the configured Markdown + * emit destinations of the discovered `.mdx` spec sources (7.3), which exist + * by configuration alone. A file in both a spec and a code group is a + * configuration error (7.2, 14.14). + */ +async function discover(config) { + const groupsMatching = (groups, rel) => + groups + .filter((group) => group.globs.some((glob) => globMatches(glob, rel))) + .map((group) => group.name); + const specMatches = []; + const codeMatches = []; + for (const rel of await walkPlainFiles(config.root)) { + const name = rel.slice(rel.lastIndexOf("/") + 1); + if (name.includes(".xspec.") || rel.startsWith(".xspec/")) continue; + const specGroups = groupsMatching(config.specGroups, rel); + const codeGroups = groupsMatching(config.codeGroups, rel); + if (specGroups.length > 0) specMatches.push({ rel, groups: specGroups }); + if (codeGroups.length > 0) codeMatches.push({ rel, groups: codeGroups }); + } + const emitDestinations = new Set(); + if (config.emit) { + for (const match of specMatches) { + if (match.rel.endsWith(".mdx")) { + emitDestinations.add(markdownPathOf(config, match.rel)); + } + } + } + const specSources = specMatches.filter((m) => !emitDestinations.has(m.rel)); + const codeSources = codeMatches.filter((m) => !emitDestinations.has(m.rel)); + const specPaths = new Set(specSources.map((m) => m.rel)); + const both = codeSources.find((m) => specPaths.has(m.rel)); + if (both !== undefined) { + throw config.configError( + `${both.rel} is matched by both a spec group and a code group`, + ); + } + return { + specSources, + codeSources, + discovered: new Set([...specPaths, ...codeSources.map((m) => m.rel)]), + }; +} + +// --------------------------------------------------------------------------- +// Sources in scope: trivial single-section `.mdx`, and `export const` code +// --------------------------------------------------------------------------- + +/** + * SPEC 3's line model: a terminator is CRLF, a lone LF, or a lone CR; the + * final line may have none. + */ +function splitLines(text) { + const lines = []; + let start = 0; + for (const match of text.matchAll(/\r\n|\n|\r/g)) { + lines.push({ text: text.slice(start, match.index), terminator: match[0] }); + start = match.index + match[0].length; + } + if (start < text.length) { + lines.push({ text: text.slice(start), terminator: "" }); + } + return lines; +} + +/** A source's bytes as text: valid UTF-8 with no byte-order mark (1.6). */ +function sourceText(rel, bytes) { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + throw new ScopeError( + `${rel} begins with a byte-order mark: an unparseable source (14.20) is outside this fixture's scope`, + ); + } + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + throw new ScopeError( + `${rel} is not valid UTF-8: an unparseable source (14.20) is outside this fixture's scope`, + ); + } +} + +/** An ID segment in this fixture's scope: a subset of 1.4's valid ones. */ +function isScopeSegment(id) { + return ( + /^[A-Za-z][A-Za-z0-9_-]*$/.test(id) && + !["then", "constructor", "prototype"].includes(id) + ); +} + +/** + * The scope's spec source shape: `<S id="X">` (or `<Spec id="X">`) alone on + * the first line, plain Markdown lines, and the matching closing tag alone + * on the last line. Returns the section's ID, its source range (1.7: the + * construct from its opening tag's first byte through its closing tag's + * last), and the compiled Markdown (3): the tag lines, left empty by the + * tags' removal, dropped with their terminators, every other line kept. + */ +function parseTrivialSection(rel, bytes) { + const text = sourceText(rel, bytes); + const lines = splitLines(text); + const outOfScope = (why) => + new ScopeError( + `${rel}: ${why} — this fixture's sources are trivial single-section .mdx files (CERTIFICATIONS.md §CONF-ORPHAN)`, + ); + const open = + lines.length >= 2 ? /^<(S|Spec) id="([^"]*)">$/.exec(lines[0].text) : null; + if (open === null) throw outOfScope("no opening tag alone on the first line"); + const last = lines[lines.length - 1]; + if (last.text !== `</${open[1]}>`) { + throw outOfScope("no matching closing tag alone on the last line"); + } + if (!isScopeSegment(open[2])) throw outOfScope(`the ID ${open[2]}`); + const body = lines.slice(1, -1); + for (const line of body) { + if (/[<>{}]/.test(line.text) || /^\s*(import|export)\b/.test(line.text)) { + throw outOfScope(`a construct beyond plain Markdown: ${line.text}`); + } + } + return { + id: open[2], + range: { + start: 0, + end: Buffer.byteLength( + text.slice(0, text.length - last.terminator.length), + "utf8", + ), + }, + markdown: body.map((line) => line.text + line.terminator).join(""), + }; +} + +/** + * The scope's code source shape: well-formed TypeScript (14.20) of + * `export const NAME = DIGITS` statements, one per line, optionally + * terminated by `;`, blank lines between — no leading-zero decimal, which + * TypeScript rejects. Anything else is outside this fixture's scope. + */ +function checkTrivialCode(rel, bytes) { + const text = sourceText(rel, bytes); + for (const line of splitLines(text)) { + if (/^[\t ]*$/.test(line.text)) continue; + if ( + !/^[\t ]*export[\t ]+const[\t ]+[A-Za-z_$][A-Za-z0-9_$]*[\t ]*=[\t ]*(0|[1-9][0-9]*)[\t ]*;?[\t ]*$/.test( + line.text, + ) + ) { + throw new ScopeError( + `${rel}: ${JSON.stringify(line.text)} — this fixture's code sources are \`export const n = 1\` statements (CERTIFICATIONS.md §CONF-ORPHAN)`, + ); + } + } +} + +function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** The fixed module bytes: content beyond the path is out of scope (13.1). */ +function moduleContent(sourceRel) { + return Buffer.from( + `// Generated by xspec from ${sourceRel} (CONF-ORPHAN fixture: module content beyond its path is out of scope).\nexport {};\n`, + "utf8", + ); +} + +// --------------------------------------------------------------------------- +// Findings (SPEC 14, 12.7) +// --------------------------------------------------------------------------- + +const CODE_TOKENS = new Map([ + [10, "stale-output"], + [19, "invalid-source-path"], + [22, "obstructed-write-path"], +]); + +function finding(condition, concerned, message) { + return { condition, path: concerned, message }; +} + +/** + * The pinned findings order (12.7): by code — the numbered conditions in + * numeric order — then locations (empty for every condition here), then + * concerned path bytes, then identities (empty), then message; identical + * findings collapse to one. + */ +function compareFindings(a, b) { + if (a.condition !== b.condition) return a.condition - b.condition; + const byPath = compareBytes(a.path, b.path); + if (byPath !== 0) return byPath; + return compareBytes(a.message, b.message); +} + +function findingsDoc(findings) { + const sorted = [...findings].sort(compareFindings); + const collapsed = sorted.filter( + (f, i) => i === 0 || compareFindings(sorted[i - 1], f) !== 0, + ); + return { + findings: collapsed.map((f) => ({ + code: CODE_TOKENS.get(f.condition), + message: f.message, + locations: [], + path: f.path, + identities: [], + })), + }; +} + +function emitReport(io, json, findings) { + const doc = findingsDoc(findings); + if (json) { + io.stdout(`${canonicalJson(doc)}\n`); + } else { + for (const f of doc.findings) { + io.stdout(`${f.path}: ${f.code}: ${f.message}\n`); + } + } +} + +// --------------------------------------------------------------------------- +// The workspace model: sources, derived files, graph data +// --------------------------------------------------------------------------- + +const GRAPH_DATA_AREA = ".xspec"; +// The fixture's graph-data layout, unenumerated to consumers (13.3). +const GRAPH_DATA_FILE = ".xspec/graph.json"; + +/** Characters 7.1 bars from a spec source's path, beside `#` and U+FFFD. */ +const SPEC_PATH_BARRED = [ + '"', + "'", + "\\", + "\n", + "\r", + LINE_SEPARATOR, + PARAGRAPH_SEPARATOR, +]; + +/** + * Discover, validate, and analyze the workspace: the 14.19 findings, the + * derived files the current sources and configuration generate (each + * `.mdx` spec source's module and, while emission is enabled, its + * Markdown), and the graph as graph data holds it. + */ +async function loadWorkspace(config) { + const discovery = await discover(config); + const findings = []; + const derived = []; + const sources = []; + for (const source of discovery.specSources) { + const rel = source.rel; + if (!rel.endsWith(".mdx")) { + findings.push( + finding( + 19, + rel, + `${rel} is in a spec group without the .mdx extension; rename it or narrow the group's globs (SPEC 7.1)`, + ), + ); + continue; + } + if ( + [REPLACEMENT_CHARACTER, "#", ...SPEC_PATH_BARRED].some((c) => + rel.includes(c), + ) + ) { + findings.push( + finding( + 19, + rel, + `${rel} contains a character a spec source's path may not hold; rename it (SPEC 7, 7.1)`, + ), + ); + } + const bytes = await fsp.readFile(absUnder(config.root, rel)); + const section = parseTrivialSection(rel, bytes); + derived.push({ rel: modulePathOf(rel), content: moduleContent(rel) }); + if (config.emit) { + derived.push({ + rel: markdownPathOf(config, rel), + content: Buffer.from(section.markdown, "utf8"), + }); + } + sources.push({ + path: rel, + kind: "spec", + groups: source.groups, + hash: sha256Hex(bytes), + sections: [{ id: section.id, range: section.range }], + }); + } + for (const source of discovery.codeSources) { + const rel = source.rel; + if ([REPLACEMENT_CHARACTER, "#"].some((c) => rel.includes(c))) { + findings.push( + finding( + 19, + rel, + `${rel} contains a character a source's path may not hold; rename it (SPEC 7)`, + ), + ); + } + const bytes = await fsp.readFile(absUnder(config.root, rel)); + checkTrivialCode(rel, bytes); + sources.push({ + path: rel, + kind: "code", + groups: source.groups, + hash: sha256Hex(bytes), + }); + } + derived.sort((a, b) => compareBytes(a.rel, b.rel)); + sources.sort((a, b) => compareBytes(a.path, b.path)); + return { + findings, + derived, + generated: new Set(derived.map((d) => d.rel)), + discovered: discovery.discovered, + graph: { sources }, + }; +} + +/** + * 14.22 over `build`'s write paths (the derived files generated and graph + * data), as `build` and `check` alike judge them: each workspace-relative + * directory component occupied by anything other than a directory, and each + * module or Markdown path that is a directory component of another write + * path or of a discovered source's path — one finding per offending path. + */ +async function obstructionFindings(config, ws) { + const writePaths = [...ws.derived.map((d) => d.rel), GRAPH_DATA_FILE]; + const offending = new Set(); + for (const rel of writePaths) { + const segments = rel.split("/"); + for (let i = 1; i < segments.length; i += 1) { + const component = segments.slice(0, i).join("/"); + const kind = await lstatKind(absUnder(config.root, component)); + if (kind === "absent") break; + if (kind !== "dir") { + offending.add(component); + break; + } + } + } + const others = [...writePaths, ...ws.discovered]; + for (const item of ws.derived) { + if (others.some((o) => o.startsWith(`${item.rel}/`))) { + offending.add(item.rel); + } + } + return [...offending].map((rel) => + finding( + 22, + rel, + `${rel} obstructs a path xspec writes: a directory component must be a directory, and a derived path no other write's or source's directory; move or delete what occupies it (SPEC 13.4)`, + ), + ); +} + +/** Graph data's bytes: the graph and the record of the paths generated. */ +function graphDataBytes(ws) { + return Buffer.from( + `${canonicalJson({ derivedFiles: byteSorted(ws.generated), graph: ws.graph })}\n`, + "utf8", + ); +} + +/** + * The recorded generation state (13.3, 14.23): "empty" where none exists — + * the area absent, or a directory holding no graph data; "unreadable" where + * state exists that cannot be read as a record — the area's own path held + * by a non-directory, graph data that is no plain file, refuses its read, + * or does not decode; "readable" otherwise, with the recorded paths and the + * stored graph. + */ +async function readRecord(config) { + const areaKind = await lstatKind(absUnder(config.root, GRAPH_DATA_AREA)); + if (areaKind === "absent") return { state: "empty", paths: [], graph: null }; + if (areaKind !== "dir") return { state: "unreadable" }; + const fileAbs = absUnder(config.root, GRAPH_DATA_FILE); + const fileKind = await lstatKind(fileAbs); + if (fileKind === "absent") return { state: "empty", paths: [], graph: null }; + if (fileKind !== "file") return { state: "unreadable" }; + let doc; + try { + doc = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + await fsp.readFile(fileAbs), + ), + ); + } catch { + return { state: "unreadable" }; + } + if ( + doc === null || + typeof doc !== "object" || + !Array.isArray(doc.derivedFiles) || + !doc.derivedFiles.every(isPlainRelativePath) || + doc.graph === undefined + ) { + return { state: "unreadable" }; + } + return { state: "readable", paths: doc.derivedFiles, graph: doc.graph }; +} + +// --------------------------------------------------------------------------- +// 13.4's removal of recorded paths no longer generated, and its report +// --------------------------------------------------------------------------- + +/** + * The occupant 13.4's removal judges for a recorded path: the path's + * workspace-relative directory components are walked from the root, and + * below a component that is absent or occupied by anything other than a + * directory — a symbolic link included, whatever it targets — nothing is + * read: the path holds nothing. Otherwise the occupant is the path's own, + * judged itself (lstat, never through a link). Returns its kind and where + * it stands. + */ +async function recordedOccupant(config, rel) { + if (deviations.componentLinksInsideRoot) { + return recordedOccupantThroughInsideLinks(config, rel); + } + const segments = rel.split("/"); + for (let i = 1; i < segments.length; i += 1) { + const component = segments.slice(0, i).join("/"); + if ((await lstatKind(absUnder(config.root, component))) !== "dir") { + return { kind: "absent", abs: null }; + } + } + const abs = absUnder(config.root, rel); + return { kind: await lstatKind(abs), abs }; +} + +/** + * VIOL-ORPHAN-THROUGHLINK's deviation (CERTIFICATIONS.md + * §VIOL-ORPHAN-THROUGHLINK) — `recordedOccupant` with one clause of 13.4's + * removal rule dropped: a workspace-relative directory component occupied by + * a symbolic link to a directory inside the workspace root is resolved + * through the link, and below it the occupant is the entry the link's target + * holds under the path's remaining components, judged itself (lstat) as if + * it stood at the recorded path. Unchanged: a component that is absent, a + * plain file, or a symbolic link to anything else — a directory outside the + * root, a file, nothing — still leaves the path holding nothing, nothing + * read below it; and the occupant at the recorded path itself is still + * judged as itself, a symbolic link there never followed. + */ +async function recordedOccupantThroughInsideLinks(config, rel) { + const rootReal = await fsp.realpath(config.root); + const segments = rel.split("/"); + let dirAbs = config.root; + for (const segment of segments.slice(0, -1)) { + const componentAbs = path.join(dirAbs, segment); + const kind = await lstatKind(componentAbs); + if (kind === "dir") { + dirAbs = componentAbs; + continue; + } + const targetAbs = + kind === "symlink" + ? await linkedDirectoryInsideRoot(componentAbs, rootReal) + : null; + if (targetAbs === null) return { kind: "absent", abs: null }; + dirAbs = targetAbs; + } + const abs = path.join(dirAbs, segments[segments.length - 1]); + return { kind: await lstatKind(abs), abs }; +} + +/** + * The directory a symbolic link resolves to, where that is a directory + * inside the workspace root (the root's real path or below it); `null` for + * a link resolving to anything else, outside the root, or nowhere (dangling + * or looping). + */ +async function linkedDirectoryInsideRoot(linkAbs, rootReal) { + let resolved; + try { + resolved = await fsp.realpath(linkAbs); + } catch (error) { + if (["ENOENT", "ENOTDIR", "ELOOP"].includes(error.code)) return null; + throw error; + } + if ((await lstatKind(resolved)) !== "dir") return null; + const fromRoot = path.relative(rootReal, resolved); + const outside = + fromRoot === ".." || + fromRoot.startsWith(`..${path.sep}`) || + path.isAbsolute(fromRoot); + return outside ? null : resolved; +} + +/** + * Whether the removal removes the occupant (13.4): anything but a directory + * or a discovered source; a path holding nothing is left as it is. + */ +function isRemovable(occupant, rel, ws) { + return ( + occupant.kind !== "absent" && + occupant.kind !== "dir" && + !ws.discovered.has(rel) + ); +} + +/** + * Remove a recorded path no longer generated (13.4): its occupant, where + * removable, unlinked — a symbolic link as the link itself, never its + * target; anything else left as it is, the removal making no write. + */ +async function removeRecorded(config, rel, ws) { + const occupant = await recordedOccupant(config, rel); + if (!isRemovable(occupant, rel, ws)) return; + if (deviations.removeLinkTarget && occupant.kind === "symlink") { + await removeLinkedPlainFile(occupant.abs); + return; + } + await fsp.unlink(occupant.abs); +} + +/** + * VIOL-ORPHAN-LINKTARGET's deviation (CERTIFICATIONS.md + * §VIOL-ORPHAN-LINKTARGET) — `removeRecorded` with one clause of 13.4's + * removal rule inverted: where the recorded path's occupant is a symbolic + * link, the removal deletes in place of the link the plain file the link + * resolves to — nothing where it resolves to no plain file (dangling, + * looping, or reaching a directory or another kind) — and leaves the link + * standing. The occupant is still judged as itself by `recordedOccupant`, + * so 14.10's recorded-file form is unchanged; nothing is read below a + * non-directory component; derived-file writes (`writeDerived`) still + * replace a link as the occupant. + */ +async function removeLinkedPlainFile(linkAbs) { + let resolved; + try { + resolved = await fsp.realpath(linkAbs); + } catch (error) { + if (["ENOENT", "ENOTDIR", "ELOOP"].includes(error.code)) return; + throw error; + } + if ((await lstatKind(resolved)) !== "file") return; + await fsp.unlink(resolved); +} + +/** + * Write one derived file (13.4): the path's occupant is replaced — a + * directory with everything it holds, a symbolic link as the link itself — + * missing directories are created, and nothing is written through a link + * (the exclusive create fails rather than follow anything left there). + */ +async function writeDerived(config, rel, content) { + const abs = absUnder(config.root, rel); + const kind = await lstatKind(abs); + if (kind === "dir") { + await fsp.rm(abs, { recursive: true, force: true }); + } else if (kind !== "absent") { + await fsp.unlink(abs); + } + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, content, { flag: "wx" }); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * `xspec build` (12.1): on validation findings (14.19, 14.22) exit 1, + * modifying nothing; otherwise write every derived file, remove the + * recorded paths no longer generated (13.4; an unreadable or empty record + * names none — such orphans lie outside xspec's knowledge), then write + * graph data recording the paths generated (13.3). + */ +async function commandBuild(io, cwd, configFlag, json) { + const config = await loadConfig(cwd, configFlag); + const ws = await loadWorkspace(config); + const findings = [...ws.findings, ...(await obstructionFindings(config, ws))]; + if (findings.length > 0) throw new FindingsError(findings); + const record = await readRecord(config); + for (const item of ws.derived) { + await writeDerived(config, item.rel, item.content); + } + if (record.state === "readable") { + for (const rel of byteSorted(new Set(record.paths))) { + if (!ws.generated.has(rel)) await removeRecorded(config, rel, ws); + } + } + await writeDerived(config, GRAPH_DATA_FILE, graphDataBytes(ws)); + if (json) emitReport(io, true, []); + return 0; +} + +/** + * `xspec check` (12.2): the build validations, then 14.10's forms — per + * file and graph data (mismatch forms) on a workspace passing them, the + * unreadable-record unit form and the recorded-file form on any workspace. + * Writes nothing; exits 1 on any finding. + */ +async function commandCheck(io, cwd, configFlag, json) { + const config = await loadConfig(cwd, configFlag); + const ws = await loadWorkspace(config); + const findings = [...ws.findings, ...(await obstructionFindings(config, ws))]; + const passing = findings.length === 0; + const record = await readRecord(config); + if (record.state === "unreadable") { + findings.push( + finding( + 10, + GRAPH_DATA_AREA, + "the recorded generation state under .xspec cannot be read as a record; run `xspec build` to replace it (SPEC 14.10, 14.23)", + ), + ); + } else if ( + passing && + (record.graph === null || + canonicalJson(record.graph) !== canonicalJson(ws.graph)) + ) { + findings.push( + finding( + 10, + GRAPH_DATA_AREA, + "graph data is missing or does not match the current sources and configuration; run `xspec build` (SPEC 14.10, 13.3)", + ), + ); + } + if (passing) { + for (const item of ws.derived) { + const abs = absUnder(config.root, item.rel); + const current = + (await lstatKind(abs)) === "file" && + Buffer.compare(await fsp.readFile(abs), item.content) === 0; + if (!current) { + findings.push( + finding( + 10, + item.rel, + `${item.rel} is missing or does not match what the current sources and configuration generate; run \`xspec build\` (SPEC 14.10)`, + ), + ); + } + } + } + if (record.state === "readable") { + const writePaths = [...ws.generated, GRAPH_DATA_FILE]; + for (const rel of new Set(record.paths)) { + if (ws.generated.has(rel)) continue; + if (!isRemovable(await recordedOccupant(config, rel), rel, ws)) continue; + const obstructs = writePaths.some((w) => w.startsWith(`${rel}/`)); + findings.push( + finding( + 10, + rel, + obstructs + ? `${rel} is a recorded derived file no longer generated that obstructs a path the rebuild writes; delete it manually, then run \`xspec build\` (SPEC 14.10, 13.4)` + : `${rel} is a recorded derived file the current sources and configuration no longer generate; run \`xspec build\` to remove it (SPEC 14.10, 13.4)`, + ), + ); + } + } + if (findings.length > 0) throw new FindingsError(findings); + if (json) emitReport(io, true, []); + return 0; +} + +// --------------------------------------------------------------------------- +// Entry and dispatch (SPEC 12.0 exit partition) +// --------------------------------------------------------------------------- + +/** + * Run one invocation: `argv` without the executable, `cwd` the working + * directory, `options` the deviation switches (none for this conformer). + * Returns the exit code. + */ +export async function runXspec(argv, cwd, options = {}) { + deviations = { ...options }; + const io = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + }; + let json = false; + try { + const invocation = readInvocation(argv); + json = invocation.json; + if (invocation.error !== null) throw new UsageError(invocation.error); + const [command, ...operands] = invocation.words; + if (command === undefined) { + throw new UsageError("expected a command (SPEC 12.0)"); + } + if (!PRODUCT_COMMANDS.has(command)) { + throw new UsageError(`unknown command ${command} (SPEC 12.0)`); + } + const accepted = COMMAND_FLAGS[command]; + if (accepted === undefined) { + throw new ScopeError( + `the ${command} command is outside this fixture's surface: build and check alone (CERTIFICATIONS.md §CONF-ORPHAN)`, + ); + } + for (const name of invocation.flags.keys()) { + if (!accepted.has(name)) { + throw new UsageError(`unknown flag ${name} for ${command} (SPEC 12.0)`); + } + } + if (operands.length > 0) { + throw new UsageError( + `surplus operand ${operands[0]}: ${command} takes no operands (SPEC 12.0)`, + ); + } + if (invocation.flags.has("--test-hold")) { + throw new ScopeError( + "the --test-hold seam (13.5) is outside this fixture's scope (CERTIFICATIONS.md §CONF-ORPHAN)", + ); + } + const configFlag = invocation.flags.get("--config"); + return command === "build" + ? await commandBuild(io, cwd, configFlag, json) + : await commandCheck(io, cwd, configFlag, json); + } catch (error) { + if (error instanceof UsageError) { + // Usage and configuration errors (12.0): the message on stderr in both + // output forms; with JSON output in effect the 12.7 error document is + // the entire stdout, and without it stdout stays empty. + if (json) { + io.stdout( + `${canonicalJson({ + error: { + code: error.code, + message: error.message, + locations: [], + path: error.path, + identities: [], + }, + })}\n`, + ); + } + io.stderr(`xspec: ${error.message}\n`); + return 2; + } + if (error instanceof FindingsError) { + emitReport(io, json, error.findings); + return 1; + } + if (error instanceof ScopeError) { + io.stderr(`xspec: fixture scope error: ${error.message}\n`); + return 70; + } + // A crash is a fixture bug: exit outside the 12.0 partition so every + // exit-code assertion fails loudly and the diagnosis carries the stack. + io.stderr( + `xspec: internal fixture error: ${error?.stack ?? String(error)}\n`, + ); + return 70; + } +} diff --git a/test/fixtures/conf-valid/bin-sep.mjs b/test/fixtures/conf-valid/bin-sep.mjs new file mode 100644 index 00000000..91fd3447 --- /dev/null +++ b/test/fixtures/conf-valid/bin-sep.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +// VIOL-VALID-SEP violator executable (CERTIFICATIONS.md §VIOL-VALID-SEP). +// The CONF-VALID conformer with exactly one behavioral deviation: SPEC 1.4's +// bar on U+2028 and U+2029 is not enforced — a segment or tag containing +// either is accepted as valid. One clause of 1.4's quote-and-escape bullet +// dropped: the quote, escape, and character-reference characters stay +// barred, and neither code point joins the whitespace class, so tag +// splitting (SPEC 2.6) is unchanged — a tag containing either is kept whole — +// as is every other rule and class. Certifies T1.4-1, T1.4-4, and P-1 (C-1): +// exactly they fail against this fixture; every other §CONF-VALID in-scope +// test passes. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + acceptLineSeparators: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-valid/bin-wide.mjs b/test/fixtures/conf-valid/bin-wide.mjs index 4f8aa8d3..5cc1f4c5 100644 --- a/test/fixtures/conf-valid/bin-wide.mjs +++ b/test/fixtures/conf-valid/bin-wide.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node // VIOL-VALID-WIDE violator executable (CERTIFICATIONS.md §VIOL-VALID-WIDE). -// The CONF-VALID conformer with exactly one behavioral deviation: U+00A0, -// U+0085, and U+2028 are treated as whitespace for SPEC 1.4 validity — a -// segment or tag containing any of them is rejected with 14.4. Tag splitting -// (SPEC 2.6) and all other classifications are unchanged. Certifies T1.4-2, -// T1.4-4, and P-1 (C-1): exactly they fail against this fixture; every other -// §CONF-VALID in-scope test passes. +// The CONF-VALID conformer with exactly one behavioral deviation: U+00A0 and +// U+0085, exactly, are treated as whitespace for SPEC 1.4 validity — a +// segment or tag containing either is rejected with 14.4. Tag splitting +// (SPEC 2.6) and all other classifications are unchanged — U+2028 and +// U+2029 stay barred by 1.4's quote-and-escape bullet, as in the conformer, +// and split no tag. Certifies T1.4-2, T1.4-4, and P-1 (C-1): exactly they +// fail against this fixture; every other §CONF-VALID in-scope test passes. import { runXspec } from "./product.mjs"; const code = await runXspec(process.argv.slice(2), process.cwd(), { diff --git a/test/fixtures/conf-valid/product.mjs b/test/fixtures/conf-valid/product.mjs index 39d886fb..8edb974a 100644 --- a/test/fixtures/conf-valid/product.mjs +++ b/test/fixtures/conf-valid/product.mjs @@ -9,16 +9,19 @@ // whose sections carry `id` and `tags` props (multi-file included); no // imports, embeddings, `d` props, code groups, `markdown`, `coverage`, // `policy`, or git. -// - `build` with the error reporting of SPEC 14 for conditions 14.1–14.4: -// file, location, condition identity, 14.2's statement of the expected -// form, exit codes per SPEC 12.0. +// - `build` with the error reporting of SPEC 14 for conditions 14.1–14.4 — +// and 14.17 as T1.3-6's invalid-form arms stage it (a repeated `id` +// attribute and a braced `id={"x"}` value) — file, location, condition +// identity with its stable code, 14.2's statement of the expected form, +// exit codes per SPEC 12.0. // - `query node` / `query nodes` (with `--tag`) reporting identity, tags, // and metadataHash — the scoped query surface; source ranges ride along in // the natural SPEC 11 row shape. // - Contracts under certification: SPEC 1.3, SPEC 1.4 with its exact -// character classes, SPEC 2.6 tag splitting, and the masking rule of -// SPEC 14.2 (condition 1 masks condition 2 for the immediate children of a -// section lacking `id`; everything else reports normally). +// character classes, SPEC 2.6 tag splitting, and the masking rules of +// SPEC 14.1/14.17 over 14.2 (a section spelling no identity — `id` +// missing, repeated, or in invalid value form — masks condition 2 for its +// immediate children; everything else reports normally). // // Key mechanisms: // - Sources are scanned by a hand-rolled MDX-lite lexer: `<S>`/`<Spec>` tags @@ -28,15 +31,40 @@ // 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 // empty segment is a 1.4 violation (14.4), never a structural one: // `<S id="">` is one (empty) top-level segment and `a.` → `a..b` nests by // exactly one segment per level (T1.4-1's staging). -// - Findings carry the offending construct's own byte range per SPEC 1.7 -// (opening tag through closing tag, byte offsets), so every report lands -// within its construct's window and never on a sibling construct. +// - SPEC 1.4's alphabet, exactly: beyond `.`, `#`, the whitespace and +// control classes, and the forbidden names, a segment or tag containing +// `"`, `'`, `\`, or `&`, or U+2028 or U+2029 (1.4's quote-and-escape +// bullet; neither code point is whitespace or a control character, so +// neither splits a tag), or U+FFFD is invalid (14.4). Attribute values are +// read verbatim (SPEC 2.4): no escape sequence or character reference is +// interpreted, so `id="a\u002Eb"` is a one-segment ID containing `\` +// and `id="a.b"` one containing `&` — condition 4, never the +// two-segment ID `a.b` — and `tags="x\u0079"` a tag containing `\`. +// - Findings of 14.1–14.3 and 14.17 carry the offending construct's own +// byte range per SPEC 1.7 (opening tag through closing tag, byte +// offsets); a 14.4 finding is one per offending `id` or `tags` attribute, +// however many of its segments or tokens violate 1.4 (SPEC 14), located +// at the attribute's own characters — name through closing quote +// (T14-11). Every report thus lands within its construct's window and +// never on a sibling construct. +// - Tag sets (`query node`/`query nodes`; the set form of SPEC 12.7): tags +// in byte order — UTF-8 bytes, not UTF-16 code units — duplicates +// collapsed, `[]` when tagless. // - `build` writes nothing: the scope observes validation and the query // surface only, and every query recomputes from the sources, so reads need // no stored graph data. @@ -58,7 +86,10 @@ // `acceptNonWhitespaceControls` (§VIOL-VALID-CTRL, bin-ctrl.mjs, CERT-09) in // `valueViolation`'s control branch; `widenValidityWhitespace` // (§VIOL-VALID-WIDE, bin-wide.mjs, CERT-10) in `valueViolation`'s whitespace -// branch — U+00A0/U+0085/U+2028 treated as whitespace for 1.4 validity only. +// branch — U+00A0 and U+0085, exactly, treated as whitespace for 1.4 +// validity only; `acceptLineSeparators` (§VIOL-VALID-SEP, bin-sep.mjs) in +// `valueViolation`'s U+2028/U+2029 branch — 1.4's bar on the two code points +// not enforced, every other clause of the quote-and-escape bullet kept. import { createHash } from "node:crypto"; import * as fsp from "node:fs/promises"; @@ -481,23 +512,27 @@ async function discoverSources(root, groups) { /** * SPEC 1.4's whitespace class for *validity*, exactly: U+0009–U+000D and - * U+0020; no other code point (U+00A0, U+0085, U+2028 included) belongs to - * it. The VIOL-VALID-WIDE deviation switch (`widenValidityWhitespace`, - * CERT-10) hooks into this class's *enforcement* in `valueViolation` — - * validity classification only, never `splitTags` below. + * U+0020; no other code point (U+00A0, U+0085, U+2028, and U+2029 included) + * belongs to it. The VIOL-VALID-WIDE deviation switch + * (`widenValidityWhitespace`, CERT-10) hooks into this class's *enforcement* + * in `valueViolation` — validity classification only, never `splitTags` + * below. */ function isValidityWhitespace(codePoint) { return (codePoint >= 0x0009 && codePoint <= 0x000d) || codePoint === 0x0020; } /** - * The boundary code points SPEC 1.4 excludes from both character classes: - * U+00A0 (no-break space), U+0085 (next line), U+2028 (line separator). - * Under `widenValidityWhitespace` (§VIOL-VALID-WIDE, bin-wide.mjs) they are - * treated as whitespace for 1.4 validity, so segments and tags containing - * them are rejected with 14.4. + * The valid boundary code points SPEC 1.4 excludes from both character + * classes and bars by no rule, exactly: U+00A0 (no-break space) and U+0085 + * (next line) — TEST-SPEC T1.4-2's. Under `widenValidityWhitespace` + * (§VIOL-VALID-WIDE, bin-wide.mjs) they are treated as whitespace for 1.4 + * validity, so segments and tags containing them are rejected with 14.4. + * U+2028 and U+2029 belong to neither class either, but 1.4's + * quote-and-escape bullet bars them in every fixture but §VIOL-VALID-SEP's + * (`LINE_SEPARATOR_CODE_POINTS` below), so neither is a boundary here. */ -const WIDE_BOUNDARY_CODE_POINTS = new Set([0x00a0, 0x0085, 0x2028]); +const WIDE_BOUNDARY_CODE_POINTS = new Set([0x00a0, 0x0085]); /** * SPEC 1.4's control-character class, exactly: U+0000–U+001F and U+007F. The @@ -517,6 +552,26 @@ const FORBIDDEN_NAMES = new Set([ "then", ]); +/** + * The quote, escape, and character-reference characters SPEC 1.4 excludes + * from segments and tags — `"`, `'`, `\`, `&` — so that every segment and + * tag is spelled verbatim in every form (SPEC 2.4, 2.7, 6.4). + */ +const QUOTE_ESCAPE_REFERENCE_CHARACTERS = new Set(['"', "'", "\\", "&"]); + +/** + * U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR), which the same + * bullet of SPEC 1.4 bars from segments and tags. Neither belongs to the + * whitespace or the control class (SPEC 1.4), so neither splits a tag + * (`splitTags` below): a tag containing either is kept whole, then rejected + * in `valueViolation` — accepted there under `acceptLineSeparators` + * (§VIOL-VALID-SEP, bin-sep.mjs), still kept whole. + */ +const LINE_SEPARATOR_CODE_POINTS = new Map([ + [0x2028, "LINE SEPARATOR"], + [0x2029, "PARAGRAPH SEPARATOR"], +]); + function codePointName(codePoint) { return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`; } @@ -546,7 +601,7 @@ function valueViolation(value, role) { return `the ${role} contains "#" (SPEC 1.4)`; } // §VIOL-VALID-WIDE (bin-wide.mjs): under `widenValidityWhitespace` the - // boundary code points U+00A0/U+0085/U+2028 are treated as whitespace for + // boundary code points U+00A0 and U+0085 are treated as whitespace for // 1.4 validity — segments and tags containing them are rejected with 14.4. // Validity classification only: `splitTags` below stays on the literal // 1.4 whitespace class, and every other classification is unchanged. @@ -568,6 +623,34 @@ function valueViolation(value, role) { ) { return `the ${role} contains the control character ${codePointName(codePoint)} (SPEC 1.4)`; } + // The quote, escape, and character-reference characters (SPEC 1.4): + // attribute values are read verbatim (SPEC 2.4), so an escape- or + // reference-spelled value is a value containing `\` or `&` — condition + // 4, never its interpreted spelling. + if (QUOTE_ESCAPE_REFERENCE_CHARACTERS.has(character)) { + return `the ${role} contains the quote, escape, or character-reference character ${JSON.stringify(character)} (SPEC 1.4)`; + } + // U+2028 and U+2029, barred by the same bullet of SPEC 1.4 though in + // neither the whitespace nor the control class: condition 4, one finding + // per offending attribute, exactly as for the characters above. + // §VIOL-VALID-SEP (bin-sep.mjs): under `acceptLineSeparators` this one + // clause of the bullet is not enforced, so a segment or tag containing + // either code point is accepted. The quote, escape, and + // character-reference characters stay barred by the branch above, and + // neither code point joins the whitespace class: `splitTags` below still + // keeps a tag containing either whole. + if ( + LINE_SEPARATOR_CODE_POINTS.has(codePoint) && + !deviations.acceptLineSeparators + ) { + return `the ${role} contains ${codePointName(codePoint)} (${LINE_SEPARATOR_CODE_POINTS.get(codePoint)}), barred by the quote-and-escape bullet (SPEC 1.4)`; + } + // U+FFFD (REPLACEMENT CHARACTER), which no argument value carries (SPEC + // 1.4, 12.0): only a literal U+FFFD in the source bytes reaches here — + // an undecodable byte is 14.20 (`analyzeFile` decodes fatally). + if (codePoint === 0xfffd) { + return `the ${role} contains U+FFFD (REPLACEMENT CHARACTER) (SPEC 1.4)`; + } } return null; } @@ -599,10 +682,20 @@ function splitTags(value) { return tokens; } -/** The collapsed, sorted tag set of a `tags` value (SPEC 2.6). */ +/** Byte order of two strings: their UTF-8 bytes (SPEC 12.0). */ +function compareBytes(a, b) { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +/** + * The tag set of a `tags` value (SPEC 2.6) in the set form of SPEC 12.7: + * tags in byte order — UTF-8 bytes, never UTF-16 code units (the two orders + * differ between a BMP character above the surrogate range and an astral + * one) — duplicates collapsed, `[]` when tagless. + */ function collapsedTags(tagsRaw) { if (tagsRaw === undefined) return []; - return [...new Set(splitTags(tagsRaw))].sort(); + return [...new Set(splitTags(tagsRaw))].sort(compareBytes); } // --------------------------------------------------------------------------- @@ -638,11 +731,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 +823,14 @@ function parseMdx(text) { const node = { isRoot: false, id: null, + idMissing: false, tagsRaw: undefined, + /** @type {{ start: number, end: number } | null} */ + idAttr: null, + /** @type {{ start: number, end: number } | null} */ + tagsAttr: null, + /** @type {{ name: string, kind: "repeated" | "value-form" }[]} */ + invalidProps: [], parent: stack.at(-1), children: [], openStart: i, @@ -702,6 +840,12 @@ function parseMdx(text) { selfClosing: false, }; let j = i + open[0].length; + /** @type {Map<string, number>} */ + const occurrences = new Map(); + let idValue; + let tagsValue; + let idAttr; + let tagsAttr; for (;;) { while (j < text.length && TAG_WHITESPACE.has(text[j])) j += 1; if (j >= text.length) { @@ -717,6 +861,7 @@ function parseMdx(text) { j += 2; break; } + const attrStart = j; const attr = /^[A-Za-z][\w-]*/.exec(text.slice(j)); if (!attr) { fail20(j, "malformed attribute in a section tag"); @@ -724,29 +869,73 @@ 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") { + // The attribute's own characters, name through closing quote — + // where a 14.4 finding on it is located (SPEC 14; T14-11). + const range = { start: attrStart, end: j }; + if (name === "id") { + idValue = value; + idAttr = range; + } else { + tagsValue = value; + tagsAttr = range; + } + } 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.idAttr = node.id === null ? null : idAttr; + node.idMissing = !idInvalid && idValue === undefined; + node.tagsRaw = node.invalidProps.some((entry) => entry.name === "tags") + ? undefined + : tagsValue; + node.tagsAttr = node.tagsRaw === undefined ? null : tagsAttr; node.openEnd = j; if (node.selfClosing) { node.closeStart = node.openEnd; @@ -777,10 +966,12 @@ function segmentsOf(id) { } /** - * Validate one parsed file's sections in document order. Every finding - * carries the offending construct's own byte range (SPEC 14: file, location, - * condition identity; SPEC 1.7 byte offsets), so it falls within that - * construct's window and never on a sibling. + * Validate one parsed file's sections in document order. A finding of + * 14.1–14.3 or 14.17 carries the offending construct's own byte range (SPEC + * 14: file, location, condition identity; SPEC 1.7 byte offsets); a 14.4 + * finding carries its `id` or `tags` attribute's own characters (SPEC 14, + * T14-11) — so every finding falls within its construct's window and never + * on a sibling. * * @returns {Finding[]} */ @@ -793,7 +984,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,27 +1012,40 @@ 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) { + // Condition 14.4 (SPEC 1.4): one finding per offending `id` attribute, + // however many of its segments violate 1.4 (SPEC 14 — a descendant + // spelling a malformed ancestor segment as its own prefix reports in + // its own attribute too), located at the attribute's own characters + // (T14-11), never at the whole construct. + const segmentViolations = segments.flatMap((segment) => { const violation = valueViolation(segment, "segment"); - if (violation !== null) { - findings.push({ - condition: "14.4", - message: `invalid segment ${JSON.stringify(segment)} in id ${JSON.stringify(node.id)}: ${violation}`, - file: rel, - location, - }); - } + return violation === null + ? [] + : [`segment ${JSON.stringify(segment)}: ${violation}`]; + }); + if (segmentViolations.length > 0) { + findings.push({ + condition: "14.4", + message: `invalid id ${JSON.stringify(node.id)}: ${segmentViolations.join("; ")}`, + file: rel, + location: { + start: byteOf(node.idAttr.start), + end: byteOf(node.idAttr.end), + }, + }); } // Condition 14.2 (SPEC 1.3): the child ID equals the parent ID plus // 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); @@ -860,18 +1081,25 @@ function validateSections(rel, sections, byteOf) { } // Condition 14.4 for tags (SPEC 1.4, 2.6): every token of the 2.6 split // follows the segment rules with `.` allowed. Zero tokens behave as an - // omitted prop and validate nothing. + // omitted prop and validate nothing. One finding per offending `tags` + // attribute, however many tokens violate, located at the attribute. if (node.tagsRaw !== undefined) { - for (const token of splitTags(node.tagsRaw)) { + const tokenViolations = splitTags(node.tagsRaw).flatMap((token) => { const violation = valueViolation(token, "tag"); - if (violation !== null) { - findings.push({ - condition: "14.4", - message: `invalid tag ${JSON.stringify(token)}: ${violation} (SPEC 2.6)`, - file: rel, - location, - }); - } + return violation === null + ? [] + : [`tag ${JSON.stringify(token)}: ${violation}`]; + }); + if (tokenViolations.length > 0) { + findings.push({ + condition: "14.4", + message: `invalid tags ${JSON.stringify(node.tagsRaw)}: ${tokenViolations.join("; ")} (SPEC 2.6)`, + file: rel, + location: { + start: byteOf(node.tagsAttr.start), + end: byteOf(node.tagsAttr.end), + }, + }); } } } @@ -995,14 +1223,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, + }), + ), }; } @@ -1157,9 +1465,13 @@ async function commandQuery(io, cwd, argv) { * consumed in `valueViolation` — non-whitespace control characters are * accepted in segments and tags. * - `widenValidityWhitespace` (§VIOL-VALID-WIDE, bin-wide.mjs): consumed - * in `valueViolation` — U+00A0, U+0085, and U+2028 are treated as + * in `valueViolation` — U+00A0 and U+0085, exactly, are treated as * whitespace for 1.4 validity, so segments and tags containing them are * rejected with 14.4; tag splitting is unchanged. + * - `acceptLineSeparators` (§VIOL-VALID-SEP, bin-sep.mjs): consumed in + * `valueViolation` — 1.4's bar on U+2028 and U+2029 is not enforced, so + * segments and tags containing either are accepted; `"`, `'`, `\`, and + * `&` stay barred, and tag splitting is unchanged. */ let deviations = {}; diff --git a/test/fixtures/s4-tooling/import-conflict.ts b/test/fixtures/s4-tooling/import-conflict.ts new file mode 100644 index 00000000..c041838c --- /dev/null +++ b/test/fixtures/s4-tooling/import-conflict.ts @@ -0,0 +1,14 @@ +// The S-4 known import conflict: an import binding that collides with a +// module-scope local declaration of the same identifier (TS2440 "Import +// declaration conflicts with local declaration"), the diagnostic kind +// T6.5-9's compile-clean observation turns on when a product-chosen import +// identifier collides with the receiving file's own `const`, `function`, or +// `class`. Deliberately broken and therefore excluded from +// `npm run typecheck` (test/tsconfig.json excludes fixtures/); the harness +// compiles this project through the tooling driver at test run time. + +import { greet } from "./greeting.js"; + +const greet = (who: string): string => `Hi, ${who}.`; + +export const conflicted: string = greet("world"); diff --git a/test/fixtures/s4-tooling/import-duplicate.ts b/test/fixtures/s4-tooling/import-duplicate.ts new file mode 100644 index 00000000..e2d5a320 --- /dev/null +++ b/test/fixtures/s4-tooling/import-duplicate.ts @@ -0,0 +1,12 @@ +// The S-4 known duplicate import binding: one identifier bound by two import +// declarations (TS2300 "Duplicate identifier"), the other diagnostic kind +// T6.5-9's compile-clean observation turns on — when a product-chosen import +// identifier collides with an import binding the receiving file already +// holds. Deliberately broken and therefore excluded from +// `npm run typecheck` (test/tsconfig.json excludes fixtures/); the harness +// compiles this project through the tooling driver at test run time. + +import { greet } from "./greeting.js"; +import { greet } from "./other-greeting.js"; + +export const duplicated: string = greet("world"); diff --git a/test/fixtures/s4-tooling/other-greeting.ts b/test/fixtures/s4-tooling/other-greeting.ts new file mode 100644 index 00000000..663847c5 --- /dev/null +++ b/test/fixtures/s4-tooling/other-greeting.ts @@ -0,0 +1,11 @@ +// Second clean module of the S-4 fixture project: exports a `greet` of the +// same signature as greeting.ts's, with a different body, so that a file +// importing `greet` from both modules stages the S-4 known duplicate import +// binding (TS2300) — see import-duplicate.ts. Valid on its own. S-4 pins +// exact offsets, lines, and columns in these files via substring markers — +// any edit here must keep test/self/s4-typescript-tooling.test.ts in step. + +/** Builds an informal greeting for a name. */ +export function greet(name: string): string { + return `Hey there, ${name}.`; +} diff --git a/test/helpers/adapters/decode.ts b/test/helpers/adapters/decode.ts index c2af8a8a..eff1d8e2 100644 --- a/test/helpers/adapters/decode.ts +++ b/test/helpers/adapters/decode.ts @@ -63,8 +63,14 @@ export function describeJsonValue(value: unknown): string { let rendered: string; try { rendered = JSON.stringify(value) ?? String(value); - } catch { - rendered = String(value); + } catch (error) { + // H-11: V8's serializer recurses per nesting level and exhausts its frame + // budget (a RangeError) on the towers the suite stages, 4096 sections deep + // (P-8, P-11); the diagnosis then renders the same text without recursion. + rendered = + error instanceof RangeError + ? renderJsonWithoutRecursion(value) + : String(value); } const LIMIT = 256; if (rendered.length > LIMIT) { @@ -73,6 +79,49 @@ export function describeJsonValue(value: unknown): string { return `${kind} ${rendered}`; } +/** + * `JSON.stringify` for decoded-JSON data (objects, arrays, strings, numbers, + * booleans, `null`) through an explicit stack — the text `JSON.stringify` + * renders, produced without native recursion per nesting level (H-11). + */ +function renderJsonWithoutRecursion(value: unknown): string { + const out: string[] = []; + const work: ({ readonly text: string } | { readonly value: unknown })[] = [ + { value }, + ]; + while (work.length > 0) { + const item = work.pop()!; + if ("text" in item) { + out.push(item.text); + continue; + } + const current = item.value; + if (Array.isArray(current)) { + work.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + work.push({ value: current[index] }); + if (index > 0) work.push({ text: "," }); + } + work.push({ text: "[" }); + } else if (typeof current === "object" && current !== null) { + const entries = Object.entries(current).filter( + ([, member]) => member !== undefined, + ); + work.push({ text: "}" }); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, member] = entries[index]!; + work.push({ value: member }); + work.push({ text: `${JSON.stringify(key)}:` }); + if (index > 0) work.push({ text: "," }); + } + work.push({ text: "{" }); + } else { + out.push(JSON.stringify(current) ?? "null"); + } + } + return out.join(""); +} + /** The decoded value must be a JSON object (not null, not an array). */ export function expectObject( value: unknown, diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts new file mode 100644 index 00000000..f682b018 --- /dev/null +++ b/test/helpers/adapters/forms.ts @@ -0,0 +1,2436 @@ +// 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 tag-set and kind-set value forms of the configured sets those +// views carry (7.4, 7.5, 11.6): tag strings in byte order with +// duplicates collapsed; dependency-kind tokens in 5.2's order +// - 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, + PerformedOperationReport, + PreviewDelta, + PreviewDeltaDatum, + PreviewEdit, + PreviewFileEntry, + PreviewReport, + SourceRange, + VersionDocument, + ViewAttributeEntry, + ViewFilesReport, + ViewImportEntry, + ViewNode, + ViewReport, +} from "./model.js"; +import { + CONDITION_CODE_TOKENS, + USAGE_ERROR_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<string, unknown>, + 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 "<null path>"; + 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 … read-failure) or refusal codes " + + "(refused-invalid-id … refused-moved-import), 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; a code outside 14's list reaching the comparator any other + * way is a harness defect, thrown rather than ranked beside a listed code. + */ +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; + const refusal = (REFUSAL_CODE_TOKENS as readonly string[]).indexOf(code); + if (refusal === -1) { + throw new Error( + `harness defect: the 12.7 findings comparator was handed the code ` + + `${JSON.stringify(code)}, which SPEC 14 does not list (model.ts ` + + `CONDITION_CODE_TOKENS, REFUSAL_CODE_TOKENS) — decode admits only ` + + `14's codes, so no listed rank applies`, + ); + } + return CONDITION_CODE_TOKENS.length + refusal; +} + +function compareSequences<T>( + a: readonly T[], + b: readonly T[], + compareElement: (x: T, y: T) => number, +): number { + const shared = Math.min(a.length, b.length); + for (let 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 whose code is a finding's — never 14.24's + * `write-failure` or 14.25's `read-failure`, usage errors carried only by + * the exit-2 error document (SPEC 14, 12.7) — the array in the pinned + * findings order, findings identical in every member collapsed to one + * (adjacent equality is an uncollapsed duplicate; every violation rejects, + * 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)), + ); + findings.forEach((finding, index) => { + if ( + finding.code !== null && + (USAGE_ERROR_CONDITION_CODE_TOKENS as readonly string[]).includes( + finding.code, + ) + ) { + formFail( + at(at(site, index), "code"), + "a finding's stable code — a numbered condition 14.1–14.23 or a " + + "refusal reason; write-failure (14.24) and read-failure (14.25) " + + "are usage errors carried only as the exit-2 error document's " + + "code, in no findings array (SPEC 14, 12.7)", + finding.code, + ); + } + }); + 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<T> = + | { 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<T>( + value: unknown, + site: DecodeSite, + decodeValue: (value: unknown, site: DecodeSite) => T, +): DecodedDatum<T> { + 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<string, unknown>; + 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<readonly PathValue[]> { + 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<string, unknown>, + key: string, + site: DecodeSite, +): PathValue | null { + const value = requiredMember(obj, key, site); + if (value === null) return null; + return decodePathValue(value, at(site, key)); +} + +/** One group of the view: `{"name", "globs"}` exactly (12.7). */ +function decodeInventoryGroupDef( + value: unknown, + site: DecodeSite, +): InventoryGroupDef { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["name", "globs"], site); + return { + name: expectNonEmptyString( + requiredKey(obj, "name", site), + at(site, "name"), + ), + globs: expectNonEmptyStringArray( + requiredKey(obj, "globs", site), + at(site, "globs"), + ), + }; +} + +/** A group list of the view (`specs`/`code`), each entry `{"name","globs"}`. */ +function decodeInventoryGroupList( + value: unknown, + site: DecodeSite, +): InventoryGroupDef[] { + return expectArray(value, site).map((element, index) => + decodeInventoryGroupDef(element, at(site, index)), + ); +} + +/** + * A kind set member (`edgeKinds`/`kinds`) in SPEC 12.7's value form: an + * array of dependency-kind tokens in the order 5.2 lists them — "depends", + * "embeds", "references" — each at most once, configured or defaulted + * (7.4/7.5 read the configured list as a set, a repeated element + * collapsing; 11.6 reports kind sets in their value forms). A token out of + * that order, or repeated, is a form failure (H-3). + */ +function decodeKindSet(value: unknown, site: DecodeSite): DependencyEdgeKind[] { + const kinds = expectArray(value, site).map((element, index) => + expectToken(element, DEPENDENCY_EDGE_KINDS, at(site, index)), + ); + for (let i = 1; i < kinds.length; i += 1) { + if ( + DEPENDENCY_EDGE_KINDS.indexOf(kinds[i - 1]!) >= + DEPENDENCY_EDGE_KINDS.indexOf(kinds[i]!) + ) { + formFail( + site, + "a kind set — dependency-kind tokens in the order 5.2 lists them " + + '("depends", "embeds", "references"), each at most once (SPEC ' + + "12.7, 7.4, 7.5)", + value, + ); + } + } + return kinds; +} + +/** + * A tag set in SPEC 12.7's value form — a node's interpreted tags (2.6, + * 11.2: a `view` node's `tags` and the `tags` of `query node`, `query + * nodes`, and `show`), a profile's `targetTags`, a selector's `tags` (7.4, + * 7.5): an array of tag strings in byte order (12.0), duplicates collapsed — + * strictly ascending UTF-8 byte order, never case-folded, `[]` for a tagless + * section. 12.7's value forms bind every JSON output (H-3, T12.7-1), so the + * datum is decoded as the product emits it and never re-sorted or + * de-duplicated on the way to an assertion: a tag out of that order, or + * repeated, is a form failure — a diagnosed product failure (T2.6-1, + * T11.4-3). + */ +export function decodeTagSet(value: unknown, site: DecodeSite): string[] { + const tags = expectNonEmptyStringArray(value, site); + for (let i = 1; i < tags.length; i += 1) { + if ( + Buffer.compare( + Buffer.from(tags[i - 1]!, "utf8"), + Buffer.from(tags[i]!, "utf8"), + ) >= 0 + ) { + formFail( + site, + "a tag set — tag strings in byte order, duplicates collapsed (SPEC " + + "12.7, 12.0)", + value, + ); + } + } + return tags; +} + +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 + : decodeTagSet(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: decodeKindSet( + 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: decodeTagSet(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: decodeKindSet(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 PathValue[]>; + readonly graphData: PathValue; + readonly journal: InventoryJournalStatus; + readonly sessions: readonly PathValue[]; +} + +/** The ten members of the inventory document form, exactly (SPEC 12.7). */ +const INVENTORY_DOCUMENT_MEMBERS = [ + "findings", + "root", + "config", + "configuration", + "sources", + "derived", + "recorded", + "graphData", + "journal", + "sessions", +] as const; + +/** The file-name bytes of a 12.7 path value (its bytes after the last `/`). */ +function pathValueFileNameBytes(value: PathValue): Buffer { + const bytes = pathValueBytes(value); + const lastSep = bytes.lastIndexOf(0x2f); + return lastSep === -1 ? bytes : bytes.subarray(lastSep + 1); +} + +/** + * The `journal` member form: `{"path", "occupied"}` exactly — the journal + * path as a 12.7 path value and occupancy as a boolean (SPEC 11.6, 12.7). + */ +function decodeInventoryJournalStatus( + value: unknown, + site: DecodeSite, +): InventoryJournalStatus { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["path", "occupied"], site); + return { + path: decodePathValue(requiredKey(obj, "path", site), at(site, "path")), + occupied: expectBoolean( + requiredKey(obj, "occupied", site), + at(site, "occupied"), + ), + }; +} + +/** + * Full decode of the inventory document (SPEC 11.6, 12.7; the T11.6-3 entry + * completes the member set the T11.6-* tests pin): the top level carries + * exactly the ten members of the pinned form — `null` never omission, no + * member outside the form — decoded through the scoped decoders above (one + * code path per member form) plus the `recorded`, `graphData`, `journal`, + * and `sessions` members: `recorded` the three-state record-supplied datum + * (byte-ordered paths, or the unavailability marker, 14.23); `graphData` a + * path value (the `.xspec` spelling is the caller's byte-exact value + * assertion); `journal` `{"path", "occupied"}`; `sessions` the session file + * paths in byte order of file name with no duplicate (11.6 pins that order; + * decoder-enforced). Form-exact (H-3): never adjustable to a product's + * shape. + */ +export function decodeInventoryDocument( + doc: unknown, + context?: string, +): InventoryDocument { + 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). + * + * H-11: the tree is walked through an explicit stack, never by native + * recursion per nesting level — the suite stages section towers 2048 and + * 4096 deep (P-8, P-11), past V8's frame budget — and no depth cap of any + * kind. The checks run per node in exactly the order a recursive descent + * runs them: the node's own members first, then each child completely + * (subtree included) in document order, then the children's order and the + * text members. + */ +function decodeViewNodeForm( + value: unknown, + site: DecodeSite, + text: boolean, +): ViewNode { + const stack: ViewNodeFrame[] = [enterViewNode(value, site, text)]; + for (;;) { + const top = stack[stack.length - 1]!; + if (top.nextChild < top.rawChildren.length) { + const index = top.nextChild; + top.nextChild += 1; + stack.push( + enterViewNode( + top.rawChildren[index], + at(top.childrenSite, index), + text, + ), + ); + continue; + } + const node = leaveViewNode(top, text); + stack.pop(); + const parent = stack[stack.length - 1]; + if (parent === undefined) return node; + parent.children.push(node); + } +} + +/** One node's decode in flight: its own members decoded, children pending. */ +interface ViewNodeFrame { + readonly site: DecodeSite; + readonly obj: Record<string, unknown>; + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly opening: SourceRange | null; + readonly closing: SourceRange | null; + readonly attributes: ViewAttributeEntry[]; + readonly tags: ViewNode["tags"]; + readonly coverage: ViewNode["coverage"]; + readonly childrenSite: DecodeSite; + readonly rawChildren: readonly unknown[]; + /** The children decoded so far, in document order. */ + readonly children: ViewNode[]; + /** The index of the next raw child to decode. */ + nextChild: number; +} + +/** + * A node's own members, in form order — everything that precedes its + * children's decode: the member allow-list (with or without `--text`), the + * identity datum (never `null`), the range, the opening and closing tag + * ranges, the attribute entries in tag order, the tags and coverage datums, + * and the array form of the children member. + */ +function enterViewNode( + value: unknown, + site: DecodeSite, + text: boolean, +): ViewNodeFrame { + const obj = expectObject(value, site); + const allowed = text + ? [...VIEW_NODE_MEMBERS, ...VIEW_NODE_TEXT_MEMBERS] + : [...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"], + ); + } + } + + // The plain state is the 12.7 tag set — byte order, duplicates collapsed + // (T11.4-3's form arms) — never a bare string array re-sorted later. + const tagsDatum = decodeDatum(obj["tags"], at(site, "tags"), decodeTagSet); + const coverageDatum = decodeDatum( + obj["coverage"], + at(site, "coverage"), + (coverageValue, coverageSite) => + expectToken(coverageValue, COVERAGE_ATTRIBUTE_VALUES, coverageSite), + ); + + const childrenSite = at(site, "children"); + const rawChildren = expectArray( + requiredKey(obj, "children", site), + childrenSite, + ); + + return { + site, + obj, + identity: + identityDatum.state === "value" + ? identityDatum.value + : { unavailable: true as const }, + range, + opening, + closing, + attributes, + tags: + tagsDatum.state === "value" + ? tagsDatum.value + : tagsDatum.state === "null" + ? null + : { unavailable: true as const }, + coverage: + coverageDatum.state === "value" + ? coverageDatum.value + : coverageDatum.state === "null" + ? null + : { unavailable: true as const }, + childrenSite, + rawChildren, + children: [], + nextChild: 0, + }; +} + +/** + * A node's completion once every child is decoded: the children's document + * order (strictly ascending by start), the node itself, and the text + * members exactly when `--text` is given. + */ +function leaveViewNode(frame: ViewNodeFrame, text: boolean): ViewNode { + const { site, obj, childrenSite, children } = frame; + for (let i = 1; i < children.length; i += 1) { + if (children[i - 1]!.range.start >= children[i]!.range.start) { + formFail( + 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: frame.identity, + range: frame.range, + opening: frame.opening, + closing: frame.closing, + attributes: frame.attributes, + tags: frame.tags, + coverage: frame.coverage, + 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 `mapping` value form the preview (6.6) and the performed operation + * (6.4, 6.5) documents share: one `{"from", "to"}` per mapped identity, + * ordered by `from` bytes (SPEC 12.7) — a duplicated identity or an + * out-of-order pair is no 12.7 form. + */ +function decodeMappingArray( + value: unknown, + site: DecodeSite, +): AppliedMappingPair[] { + const mapping = expectArray(value, site).map((element, index) => + decodePreviewMappingPair(element, at(site, 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(site, i), + 'one {"from", "to"} per mapped identity (SPEC 12.7)', + value, + ); + } + if (order > 0) { + formFail( + at(site, i), + "mapping entries ordered by `from` bytes (SPEC 12.7)", + value, + ); + } + } + return mapping; +} + +/** + * 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); + const mapping: AppliedMappingPair[] | null = + mappingValue === null + ? null + : decodeMappingArray(mappingValue, at(site, "mapping")); + + 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 performed rename/move document (6.4, 6.5, 12.7) ----------------------- + +/** + * The performed `rename`/`move` document (SPEC 6.4, 6.5) — on success + * exactly `{"findings", "mapping"}` (SPEC 12.7): `findings` `[]`, a + * successful operation carrying none, and `mapping` the applied mapping in + * the preview's `mapping` form — one `{"from", "to"}` per mapped identity, + * ordered by `from` bytes. Form-exact (H-3; T6.4-1, T6.5-1, T6.6-2, + * T12.7-2): any other member set, a `findings` that is not the empty + * array, a missing or `null` `mapping`, an unordered or duplicated + * `mapping`, or a pair with members beside `from`/`to` rejects. A refused + * operation reports the findings-only form (`decodeFindingsReport`), never + * this one. + */ +export function decodePerformedOperationReport( + doc: unknown, + context?: string, +): PerformedOperationReport { + assertUnavailabilityMarkerForms(doc, context); + const site = rootSite("12.7 performed rename/move document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings", "mapping"], site); + const findingsSite = at(site, "findings"); + const findings = expectArray( + requiredKey(obj, "findings", site), + findingsSite, + ); + if (findings.length !== 0) { + formFail( + findingsSite, + "`findings` [] — a successful operation carries none, and a refused " + + "operation reports the findings-only form (SPEC 6.4, 6.5, 12.7)", + obj["findings"], + ); + } + const mapping = decodeMappingArray( + requiredKey(obj, "mapping", site), + at(site, "mapping"), + ); + return { findings: [], mapping }; +} + +// --- 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), and every adjustable adapter + * (query.ts, review.ts, reports.ts, operations.ts) runs it through + * {@link documentRootSite} at each of its document-decode entries, so it + * runs over every JSON document the suite captures — the pinned 12.7 + * document forms and the unpinned-shape surfaces of H-3 alike, the marker's + * exclusivity being universal like the value forms (T12.7-1; S-5 guards the + * walk and both integrations). Tests may additionally call it directly. + */ +export function assertUnavailabilityMarkerForms( + doc: unknown, + context?: string, +): void { + // H-11: an explicit stack, never native recursion per nesting level — the + // documents this walk covers include `view` towers 4096 sections deep + // (P-8, P-11), past V8's frame budget; no depth cap of any kind. The visit + // order is a recursive descent's: each value before its members, array + // elements by index and object members in property order, each subtree + // completely before the next sibling. + const pending: { readonly value: unknown; readonly site: DecodeSite }[] = [ + { value: doc, site: rootSite("12.7 unavailability-marker walk", context) }, + ]; + while (pending.length > 0) { + const { value, site } = pending.pop()!; + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + pending.push({ value: value[index], site: at(site, index) }); + } + continue; + } + if (typeof value !== "object" || value === null) continue; + const obj = value as Record<string, unknown>; + 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, + ); + } + const entries = Object.entries(obj); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, member] = entries[index]!; + pending.push({ value: member, site: at(site, key) }); + } + } +} + +/** + * The document-decode entry of an adjustable H-3 adapter (query.ts, + * review.ts, reports.ts, operations.ts): run the 12.7 marker walk over the + * whole raw document — the members the adapter reads, the ones it ignores, + * and the product-shaped ones it passes through whole alike — then return + * the root site the adapter decodes from. 12.7's value forms are universal + * (H-3), so the marker's exclusivity binds on an unpinned-shape surface + * exactly as on a pinned document form: an object of any other form + * carrying a member named `unavailable` fails loudly at every adapter, never + * decoding (T12.7-1; S-5 guards the integration). The diagnosis names the + * adapter and its context. + */ +export function documentRootSite( + doc: unknown, + adapter: string, + context?: string, +): DecodeSite { + const site = rootSite(adapter, context); + assertUnavailabilityMarkerForms(doc, site.adapter); + return site; +} diff --git a/test/helpers/adapters/human.ts b/test/helpers/adapters/human.ts index cb96b9c2..d1ff0bd9 100644 --- a/test/helpers/adapters/human.ts +++ b/test/helpers/adapters/human.ts @@ -91,3 +91,207 @@ export function conditionMention(condition: string): RegExp { // trailing period is fine — reports may end a sentence with the number. return new RegExp(`(?:^|[^0-9.])${escaped}(?![0-9])`); } + +/** + * The verdict of `judgeManualDeletionCorrection` on one finding message: + * the correction carried, a clause presenting a build as what removes the + * file, or the required information absent. + */ +export type ManualDeletionVerdict = + | { + /** The message carries the correction: the file's manual deletion. */ + readonly verdict: "manual-deletion"; + /** + * Which accepted form carried it: a deletion or removal word beside + * its manual character, or an instruction to the reader to delete or + * remove the file. + */ + readonly form: "manual-marker" | "instruction"; + } + | { + /** A clause presents a build as what removes the file. */ + readonly verdict: "rebuild-remedy"; + /** The offending clause, as matched. */ + readonly clause: string; + /** The pattern that matched it. */ + readonly pattern: string; + } + | { + /** No manual-deletion instruction: the required information is absent. */ + readonly verdict: "absent"; + }; + +/** A deletion or removal word (the manual-marker form). */ +const DELETION_WORD = /\b(?:delet|remov)/i; + +/** The manual character of the deletion (the manual-marker form). */ +const MANUAL_MARKER = /\bmanual|\bby hand\b|\byourself\b/i; + +/** U+2014 EM DASH: a clause boundary. */ +const EM_DASH = String.fromCodePoint(0x2014); + +/** U+2019 RIGHT SINGLE QUOTATION MARK: a typographic apostrophe. */ +const APOSTROPHE = String.fromCodePoint(0x2019); + +/** The backtick a message may quote a command or a path in. */ +const TICK = "`"; + +/** + * One word of a clause: characters holding no whitespace and no clause + * boundary, a period inside a word (a path's `A.md`) excepted. + */ +const CLAUSE_WORD = String.raw`(?:[^\s;:,.!?()${EM_DASH}]|\.(?=\w))+`; + +/** A build: `xspec build`, a build, or a rebuild. */ +const BUILD = String.raw`${TICK}?(?:xspec\s+)?(?:re-?)?build`; + +/** + * An instruction to build — running, using, or invoking one — or a bare + * build closing its clause ("rebuild."). + */ +const BUILD_INSTRUCTION = + String.raw`(?:(?:(?:re-?)?run(?:ning)?|us(?:e|ing)|invok(?:e|ing))\s+${BUILD}` + + String.raw`|${BUILD}${TICK}?(?:\s+again)?\s*(?=$|[).;,!?\n${EM_DASH}]))`; + +/** + * A reference to the file at `path`: "it", the path itself (whole, never a + * prefix of a longer path; quoted or not, a directory prefix allowed), or + * "the file" ("the", "this", or "that", up to three words, then "file" or + * "orphan"). + */ +function namesFile(path: string): string { + const escaped = path.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + return ( + String.raw`(?:it\b` + + String.raw`|[${TICK}'"]?(?:[^\s${TICK}'"]*\/)?${escaped}(?![\w/-]|\.\w)` + + String.raw`|(?:the|this|that)\s+(?:[\w-]+\s+){0,3}?(?:file|orphan)\b)` + ); +} + +/** + * The clauses presenting a build — or xspec itself — as what removes the + * file at `path` (TEST-SPEC T13.4-10: "never a rebuild"): + * - a build "to remove" or "to delete" it; + * - a build that "removes" or "will remove" it; + * - a removal "by", "via", "through", "with", or "using" a build or xspec + * ("removed by xspec", "remove out/specs/A.md by running `xspec build`"); + * - xspec that "removes" or "will remove" the file, or that the reader is + * to let remove it ("let xspec remove it"); + * - a removal elaborated by a build instruction after a colon, a + * parenthesis, or a dash ("remove it: run `xspec build`"); + * - a build instruction offered as the alternative ("or run `xspec build`", + * ", or rebuild."). + * A clause that negates its removal ("not removed by a rebuild", "do not + * run `xspec build` to remove it") presents no build as the remover + * (`NEGATION`, judged from the clause's start to the match's end). + */ +function rebuildRemedies(path: string): readonly RegExp[] { + const file = namesFile(path); + const adverb = String.raw`(?:(?:itself|then|also|automatically|later)\s+)?`; + return [ + String.raw`\b(?:re-?)?build(?:ing)?${TICK}?(?:\s+again)?\s+(?:in\s+order\s+)?to\s+(?:remove|delete)\b`, + String.raw`\b(?:re-?)?build(?:ing)?${TICK}?\s+(?:removes|deletes|(?:will|would|shall)\s+(?:remove|delete))\b`, + String.raw`\b(?:remov|delet)\w*(?:\s+${CLAUSE_WORD}){0,3}?,?\s+(?:by|via|through|with|using)\s+(?:(?:re-?)?running\s+|invoking\s+)?(?:(?:a|an|the)\s+)?(?:new\s+|fresh\s+)?${TICK}?(?:xspec\b|(?:re-?)?build)`, + String.raw`\bxspec\b${TICK}?\s+${adverb}(?:removes|deletes|(?:will|would|shall)\s+${adverb}(?:remove|delete))\s+${file}`, + String.raw`\b(?:let|have)\s+${TICK}?xspec\b${TICK}?\s+(?:remove|delete)\s+${file}`, + String.raw`\b(?:remove|delete)\b(?:\s+${CLAUSE_WORD}){1,5}?\s*(?:[:(${EM_DASH}]|\s-\s)\s*(?:(?:by|via|through|with)\s+)?${BUILD_INSTRUCTION}`, + String.raw`\bor\s+(?:else\s+)?(?:(?:by|via|through|with)\s+)?(?:(?:re-?)?run(?:ning)?|us(?:e|ing)|invok(?:e|ing))\s+${BUILD}`, + String.raw`,\s*or\s+(?:else\s+)?${BUILD}${TICK}?(?:\s+again)?\s*(?=$|[).;,!?\n${EM_DASH}])`, + ].map((source) => new RegExp(source, "gi")); +} + +/** + * A negation within a clause ("not", "never", "cannot", "nor", "no" but in + * "no longer", and any "n't"). + */ +const NEGATION = new RegExp( + String.raw`\b(?:not|never|cannot|nor)\b|\bno\b(?!\s+longer\b)|n['${APOSTROPHE}]t\b`, + "i", +); + +/** The characters ending a clause (a period too, before whitespace or the end). */ +const CLAUSE_BOUNDARIES: ReadonlySet<string> = new Set([ + ";", + ":", + ",", + "!", + "?", + "(", + ")", + "\n", + EM_DASH, +]); + +/** Where the clause holding `index` starts. */ +function clauseStart(message: string, index: number): number { + for (let at = index - 1; at >= 0; at -= 1) { + const char = message[at]!; + if (CLAUSE_BOUNDARIES.has(char)) return at + 1; + if ( + char === "." && + (at + 1 === message.length || /\s/.test(message[at + 1]!)) + ) { + return at + 1; + } + } + return 0; +} + +/** + * An instruction to the reader to delete or remove the file at `path`: a + * clause opening with "delete" or "remove" (any case) at the message's + * start or after a clause boundary — `;`, `:`, `.`, `,`, `!`, `?`, `(`, an + * em dash, a line break, "then", "and", "so", or "please" — and naming the + * file (`namesFile`). Between the boundary and the verb the clause may + * hold "first", "just", "simply", "instead", or "now", and the reader as + * its subject: "you must", "you should", "you need to", "you have to", + * "you will need to" ("you'll need to"), "you may", or "you can". + */ +function instructionToDelete(path: string): RegExp { + const opener = String.raw`(?:^|[;:.,!?(\n${EM_DASH}]|\b(?:then|and|so|please)\s)\s*`; + const adverb = String.raw`(?:(?:first|just|simply|instead|now)\s+)?`; + const reader = String.raw`(?:you\s+(?:must|should|need\s+to|have\s+to|will\s+need\s+to|may|can)\s+|you['${APOSTROPHE}]ll\s+need\s+to\s+)?`; + return new RegExp( + String.raw`${opener}${adverb}${reader}${adverb}(?:delete|remove)\s+${namesFile(path)}`, + "i", + ); +} + +/** + * Judge whether a finding's human-readable message carries the correction + * "its manual deletion" (SPEC 14.10) for the recorded file at `path`, never + * a rebuild — H-3's robust matching: required information only, never + * exact wording. Pure: the T13.4-10 assertion and the S-5 vectors drive it. + * + * - Rejected, first, when any clause presents a build or xspec as what + * removes the file and does not negate that removal (`rebuildRemedies`). + * - Accepted when the message carries the manual deletion in either form: + * a deletion or removal word beside the deletion's manual character + * ("manual", "by hand", or "yourself"), or an instruction to the reader + * to delete or remove the file (`instructionToDelete`). + * - Otherwise the required information is absent. + */ +export function judgeManualDeletionCorrection( + message: string, + path: string, +): ManualDeletionVerdict { + for (const pattern of rebuildRemedies(path)) { + for (const match of message.matchAll(pattern)) { + const end = match.index + match[0].length; + const clause = message.slice(clauseStart(message, match.index), end); + if (NEGATION.test(clause)) continue; + return { + verdict: "rebuild-remedy", + clause: match[0], + pattern: pattern.toString(), + }; + } + } + if (DELETION_WORD.test(message) && MANUAL_MARKER.test(message)) { + return { verdict: "manual-deletion", form: "manual-marker" }; + } + if (instructionToDelete(path).test(message)) { + return { verdict: "manual-deletion", form: "instruction" }; + } + return { verdict: "absent" }; +} diff --git a/test/helpers/adapters/index.ts b/test/helpers/adapters/index.ts index 73910af3..b28b3bb1 100644 --- a/test/helpers/adapters/index.ts +++ b/test/helpers/adapters/index.ts @@ -5,23 +5,39 @@ // // 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 entry point of a successful +// rename/move (6.4, 6.5) — a thin alias of forms.ts's +// form-exact performed-operation decoder (12.7) // 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 136e2c7b..e571ebde 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 = [ @@ -132,15 +133,23 @@ export interface NodeMetadataSummary { } /** - * Own/subtree text summary of a `query node` document — the CONF-MD-scoped + * Text-algebra summary of a `query node` document — the four things the + * SPEC.md 1.6 algebra reads from one answer (P-3), within the CONF-MD-scoped * query surface (CERTIFICATIONS.md §CONF-MD: fixtures within that scope - * promise `query node` reporting own and subtree text, SPEC.md 1.6), for the - * text-algebra property (P-2/P-3). Either text MAY be empty (an empty leaf + * promise `query node` reporting identity, source range, own and subtree + * text, and its `contains` edges). Either text MAY be empty (an empty leaf * section, SPEC.md 1.1). */ -export interface NodeTextSummary { +export interface NodeTextAlgebraSummary { readonly ownText: string; readonly subtreeText: string; + readonly sourceRange: SourceRange; + /** + * The targets of the answer's outgoing `contains` edges — the node's + * children (SPEC.md 5.2) — in the answer's own order; ordering them by + * their source ranges is the caller's step (P-3). + */ + readonly containsTargets: readonly string[]; } /** `query reachable` (T11-5): existence plus one shortest witness path. */ @@ -173,28 +182,487 @@ 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). Conditions 1–23 + * are findings; 24 (write failure) and 25 (read failure) are usage errors + * carried only as the exit-2 error document's `code`, in no findings array + * (SPEC 14.24, 14.25, 12.7) — `USAGE_ERROR_CONDITION_CODE_TOKENS` below. + */ +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 + "write-failure", // 14.24 — a usage error (exit 2), never a finding + "read-failure", // 14.25 — a usage error (exit 2), never a finding +] as const; +export type ConditionCodeToken = (typeof CONDITION_CODE_TOKENS)[number]; + +/** + * The numbered conditions SPEC.md 14 reports as usage errors (12.0): a + * write failure (14.24) and a read failure (14.25). Each carries its stable + * code only as the exit-2 error document's `code` (12.7, T14-6, T14-9, + * T14-10) — a finding in a `findings` array never carries either, so the + * findings-array decode rejects them (forms.ts). + */ +export const USAGE_ERROR_CONDITION_CODE_TOKENS = [ + "write-failure", // 14.24 + "read-failure", // 14.25 +] as const satisfies readonly ConditionCodeToken[]; + +/** + * 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): + * eleven reasons, `refused-exposed-derived-file` (a file move's exposed + * emit destination, 6.5; T6.5-21) listed after `refused-invalid-destination` + * and before `refused-invalid-rewrite` (T12.7-2). + */ +export const REFUSAL_CODE_TOKENS = [ + "refused-invalid-id", + "refused-identity-unchanged", + "refused-id-collision", + "refused-structural-parent", + "refused-cycle", + "refused-destination-exists", + "refused-missing-target-parent", + "refused-invalid-destination", + "refused-exposed-derived-file", + "refused-invalid-rewrite", + "refused-moved-import", +] 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 +725,105 @@ export interface ImpactedCodeEntry { readonly path: readonly string[]; } +/** + * One identity pair of a `rename`/`move` mapping — the preview's `mapping` + * (SPEC.md 6.6) and the performed operation's applied mapping (6.4, 6.5), + * one `{"from", "to"}` per mapped identity, ordered by `from` bytes (12.7; + * T6.4-1, T6.5-1): the array's order is part of the pinned form, so tests + * assert the ordered array, never a set (adapters/forms.ts). + */ +export interface AppliedMappingPair { + readonly from: string; + readonly to: string; +} + +/** + * The performed `rename`/`move` document (SPEC.md 6.4, 6.5) — on success + * exactly `{"findings", "mapping"}`, a form-exact 12.7 surface (H-3; + * T6.4-1, T6.5-1, T6.6-2, T12.7-2): `findings` `[]` — a successful + * operation carries none, a refused one reporting the findings-only form + * instead — and `mapping` the applied mapping in the preview's `mapping` + * form, one `{"from", "to"}` per mapped identity ordered by `from` bytes. + */ +export interface PerformedOperationReport { + readonly findings: readonly Finding[]; + readonly mapping: readonly AppliedMappingPair[]; +} + +/** + * 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 +872,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 00000000..8562bee0 --- /dev/null +++ b/test/helpers/adapters/operations.ts @@ -0,0 +1,37 @@ +// H-3 output adapters — the applied-mapping report of a successful +// `xspec rename` / `xspec move` (SPEC.md 6.4, 6.5, 12.7; T6.4-1, T6.5-1). +// +// The performed operation's report is a PINNED 12.7 document form — on +// success exactly `{"findings", "mapping"}`, `findings` `[]` and `mapping` +// one `{"from", "to"}` per mapped identity ordered by `from` bytes (SPEC.md +// 12.7 "`rename`/`move` performed"; H-3 lists it among the form-exact +// documents) — so it is decoded by forms.ts's `decodePerformedOperationReport` +// under forms.ts's discipline: never adjustable to a product's shape, output +// differing from 12.7 a conformance failure. This module keeps the +// applied-mapping entry point tests historically imported as a thin alias of +// that form-exact decoder: it accepts exactly what the 12.7 form admits (no +// member beside the two, no non-empty `findings`, no unordered or duplicated +// pair) and returns the decoded `mapping` alone. +// +// NOT here: the refused operation's report (the form-exact 12.7 findings-only +// report) and the `--preview` document (the form-exact 12.7 preview form) — +// both decoded in forms.ts. + +import type { AppliedMappingPair } from "./model.js"; +import { decodePerformedOperationReport } from "./forms.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, in the pinned `from`-byte order — through the form-exact + * performed-operation decoder (`decodePerformedOperationReport`, forms.ts): + * a document of any other 12.7 form rejects loudly (H-3), never defaulting + * to an empty mapping. Callers assert the ordered array + * (`assertAppliedMapping`, suite support). + */ +export function decodeAppliedMappingReport( + doc: unknown, + context?: string, +): readonly AppliedMappingPair[] { + return decodePerformedOperationReport(doc, context).mapping; +} diff --git a/test/helpers/adapters/query.ts b/test/helpers/adapters/query.ts index a8ee6ad4..ee6952be 100644 --- a/test/helpers/adapters/query.ts +++ b/test/helpers/adapters/query.ts @@ -1,13 +1,18 @@ // 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 // (diagnosed test error, never a default) when required information is // absent or malformed. It is one of the only places aware of concrete output // shape (H-3); adjust the ASSUMED SHAPE below when the real product's shape -// legitimately differs — never adjust values. +// legitimately differs — never adjust values. Every document entry first runs +// the 12.7 unavailability-marker walk over the whole raw document +// (`documentRootSite`, forms.ts), and every source range decodes through the +// literal 12.7 range form (`decodeSourceRange` below): 12.7's value forms are +// universal, so they are never adapted here (H-3, T12.7-1). // // ASSUMED SHAPE (per command; `?` marks optional-per-model information): // query node / show → @@ -22,6 +27,10 @@ // ("path" present exactly when reachable) // ids → { "files": [ { "file", "ids": [id...] } ] } // ids --tree → { "files": [ { "file", "nodes": [ { "id", "children": [...] } ] } ] } +// "tags" on every node surface above is a 12.7 tag set — byte order, +// duplicates collapsed — decoded form-exact through forms.ts's decodeTagSet +// and never re-sorted here: the value forms are universal (H-3, T12.7-1; +// T2.6-1's `query node` tags, T12.4-1's `show`). import type { GraphEdge, @@ -35,7 +44,7 @@ import type { NodeReport, NodeRow, NodeSummary, - NodeTextSummary, + NodeTextAlgebraSummary, ReachableReport, SourceRange, } from "./model.js"; @@ -48,35 +57,30 @@ import { expectBoolean, expectNonEmptyString, expectNonEmptyStringArray, - expectNonNegativeInteger, expectObject, expectString, - expectStringArray, expectToken, forbiddenKey, optionalKey, requiredKey, - rootSite, } from "./decode.js"; +import { decodeRangeForm, decodeTagSet, documentRootSite } from "./forms.js"; -/** Decode a source range (SPEC.md 1.7: zero-based byte offsets). */ +/** + * Decode a source range (SPEC.md 1.7: zero-based byte offsets) in the + * literal 12.7 value form — `{"start", "end"}` exactly, non-negative + * integers, no other member. 12.7's value forms bind every JSON output + * (H-3), so this adapter's latitude over its surrounding unpinned shape + * never reaches the range itself: a range carried as `[start, end]`, + * `{"from", "to"}`, or with an extra member fails the form decode here, and + * the decode is never adjusted to admit it (T12.7-1's unpinned-surface + * arms). + */ export function decodeSourceRange( value: unknown, site: DecodeSite, ): SourceRange { - const obj = expectObject(value, site); - const start = expectNonNegativeInteger( - requiredKey(obj, "start", site), - at(site, "start"), - ); - const end = expectNonNegativeInteger( - requiredKey(obj, "end", site), - at(site, "end"), - ); - if (end < start) { - decodeFail(site, "a range with end >= start", value); - } - return { start, end }; + return decodeRangeForm(value, site); } /** Decode one edge: from/to graph-node identities plus a spec-fixed kind. */ @@ -130,7 +134,7 @@ function decodeCoverage( * and incoming and outgoing edges by kind. */ export function decodeNodeReport(doc: unknown, context?: string): NodeReport { - const site = rootSite("query node/show", context); + const site = documentRootSite(doc, "query node/show", context); const obj = expectObject(doc, site); const edgesSite = at(site, "edges"); const edges = expectObject(requiredKey(obj, "edges", site), edgesSite); @@ -152,7 +156,7 @@ export function decodeNodeReport(doc: unknown, context?: string): NodeReport { at(site, "subtreeText"), ), hashes: decodeHashes(requiredKey(obj, "hashes", site), at(site, "hashes")), - tags: expectStringArray(requiredKey(obj, "tags", site), at(site, "tags")), + tags: decodeTagSet(requiredKey(obj, "tags", site), at(site, "tags")), coverage: decodeCoverage(obj, site), incomingEdges: decodeEdgeArray( requiredKey(edges, "incoming", edgesSite), @@ -175,14 +179,18 @@ export function decodeNodeReport(doc: unknown, context?: string): NodeReport { * is ignored, not validated. */ export function decodeNodeSummary(doc: unknown, context?: string): NodeSummary { - const site = rootSite("query node (identity/tags summary)", context); + const site = documentRootSite( + doc, + "query node (identity/tags summary)", + context, + ); const obj = expectObject(doc, site); return { identity: expectNonEmptyString( requiredKey(obj, "identity", site), at(site, "identity"), ), - tags: expectStringArray(requiredKey(obj, "tags", site), at(site, "tags")), + tags: decodeTagSet(requiredKey(obj, "tags", site), at(site, "tags")), }; } @@ -198,7 +206,11 @@ export function decodeNodeMetadataSummary( doc: unknown, context?: string, ): NodeMetadataSummary { - const site = rootSite("query node (identity/tags/metadataHash)", context); + const site = documentRootSite( + doc, + "query node (identity/tags/metadataHash)", + context, + ); const obj = expectObject(doc, site); const hashesSite = at(site, "hashes"); const hashes = expectObject(requiredKey(obj, "hashes", site), hashesSite); @@ -207,7 +219,7 @@ export function decodeNodeMetadataSummary( requiredKey(obj, "identity", site), at(site, "identity"), ), - tags: expectStringArray(requiredKey(obj, "tags", site), at(site, "tags")), + tags: decodeTagSet(requiredKey(obj, "tags", site), at(site, "tags")), metadataHash: expectNonEmptyString( requiredKey(hashes, "metadataHash", hashesSite), at(hashesSite, "metadataHash"), @@ -216,21 +228,55 @@ export function decodeNodeMetadataSummary( } /** - * `query node` decoded to the CONF-MD-scoped text surface — own and subtree - * text only (P-3; P-2's certification scope). CERTIFICATIONS.md §CONF-MD - * pins the fixture product's query surface to reporting own and subtree - * text (SPEC.md 1.6): demanding identity, hashes, or edges would reject a - * document the scope permits. Both texts may legitimately be empty (an - * empty leaf section, SPEC.md 1.1), so plain strings are demanded — absent - * or non-string values still fail loudly (H-3). Everything else in the - * document is ignored, not validated. + * `query node` decoded to the four things the SPEC.md 1.6 text algebra reads + * from one answer (P-3): own and subtree text, the source range (1.7), and + * the targets of the outgoing `contains` edges (5.2) — the node's children, + * in the answer's order. CERTIFICATIONS.md §CONF-MD pins the fixture + * product's `query node` to identity, source range, own and subtree text, + * and its `contains` edges, leaving hashes, tags, the coverage attribute, + * and dependency edges out of scope: demanding them would reject a + * document the scope permits. So every outgoing edge's `kind` is decoded + * (the SPEC.md 5.2 vocabulary, to tell `contains` edges apart) and a + * `contains` edge's `to` is demanded, while an edge of a dependency kind is + * passed over unread, and so are the incoming edges and every other member. + * Both texts may legitimately be empty (an empty leaf section, SPEC.md 1.1), + * so plain strings are demanded; an absent or malformed text, range, + * `edges.outgoing`, edge kind, or `contains` target fails loudly (H-3). The + * range decodes through the literal 12.7 form (`decodeSourceRange`), never + * adapted. */ -export function decodeNodeTextSummary( +export function decodeNodeTextAlgebraSummary( doc: unknown, context?: string, -): NodeTextSummary { - const site = rootSite("query node (own/subtree text summary)", context); +): NodeTextAlgebraSummary { + const site = documentRootSite( + doc, + "query node (text-algebra summary)", + context, + ); const obj = expectObject(doc, site); + const edgesSite = at(site, "edges"); + const edges = expectObject(requiredKey(obj, "edges", site), edgesSite); + const outgoingSite = at(edgesSite, "outgoing"); + const containsTargets: string[] = []; + expectArray(requiredKey(edges, "outgoing", edgesSite), outgoingSite).forEach( + (element, index) => { + const edgeSite = at(outgoingSite, index); + const edge = expectObject(element, edgeSite); + const kind = expectToken( + requiredKey(edge, "kind", edgeSite), + EDGE_KINDS, + at(edgeSite, "kind"), + ); + if (kind !== "contains") return; // a dependency edge: out of scope + containsTargets.push( + expectNonEmptyString( + requiredKey(edge, "to", edgeSite), + at(edgeSite, "to"), + ), + ); + }, + ); return { ownText: expectString( requiredKey(obj, "ownText", site), @@ -240,6 +286,11 @@ export function decodeNodeTextSummary( requiredKey(obj, "subtreeText", site), at(site, "subtreeText"), ), + sourceRange: decodeSourceRange( + requiredKey(obj, "sourceRange", site), + at(site, "sourceRange"), + ), + containsTargets, }; } @@ -255,7 +306,11 @@ export function decodeNodeSummaryRowsReport( doc: unknown, context?: string, ): NodeSummary[] { - const site = rootSite("query nodes (identity/tags summary rows)", context); + const site = documentRootSite( + doc, + "query nodes (identity/tags summary rows)", + context, + ); const obj = expectObject(doc, site); const rowsSite = at(site, "nodes"); return expectArray(requiredKey(obj, "nodes", site), rowsSite).map( @@ -267,7 +322,7 @@ export function decodeNodeSummaryRowsReport( requiredKey(row, "identity", rowSite), at(rowSite, "identity"), ), - tags: expectStringArray( + tags: decodeTagSet( requiredKey(row, "tags", rowSite), at(rowSite, "tags"), ), @@ -276,6 +331,41 @@ 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 decodeNodeTextAlgebraSummary}'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 = documentRootSite( + doc, + "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 { @@ -287,7 +377,7 @@ function decodeNodeRow(value: unknown, site: DecodeSite): NodeRow { requiredKey(obj, "sourceRange", site), at(site, "sourceRange"), ), - tags: expectStringArray(requiredKey(obj, "tags", site), at(site, "tags")), + tags: decodeTagSet(requiredKey(obj, "tags", site), at(site, "tags")), coverage: decodeCoverage(obj, site), }; } @@ -301,7 +391,7 @@ export function decodeNodeRowsReport( doc: unknown, context?: string, ): NodeRow[] { - const site = rootSite("query nodes/subtree/ancestors", context); + const site = documentRootSite(doc, "query nodes/subtree/ancestors", context); const obj = expectObject(doc, site); const rowsSite = at(site, "nodes"); return expectArray(requiredKey(obj, "nodes", site), rowsSite).map( @@ -311,7 +401,7 @@ export function decodeNodeRowsReport( /** `query edges` (T11-4): the edge list in the reported order. */ export function decodeEdgesReport(doc: unknown, context?: string): GraphEdge[] { - const site = rootSite("query edges", context); + const site = documentRootSite(doc, "query edges", context); const obj = expectObject(doc, site); return decodeEdgeArray(requiredKey(obj, "edges", site), at(site, "edges")); } @@ -326,7 +416,7 @@ export function decodeReachableReport( doc: unknown, context?: string, ): ReachableReport { - const site = rootSite("query reachable", context); + const site = documentRootSite(doc, "query reachable", context); const obj = expectObject(doc, site); const reachable = expectBoolean( requiredKey(obj, "reachable", site), @@ -351,9 +441,105 @@ 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, + documentRootSite(doc, "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 = documentRootSite( + doc, + "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(root: unknown, rootSite: DecodeSite): void { + // H-11: an explicit stack, never native recursion per nesting level — the + // suite stages documents past V8's frame budget (P-8, P-11), and no depth + // cap of any kind. Children are pushed last-first so each datum is checked + // in exactly the order a recursive descent checks it: a value's own members + // first, then each element or member completely (subtree included), in + // index and then property-enumeration order — the first failure reported + // is the same one. + const stack: { readonly value: unknown; readonly site: DecodeSite }[] = [ + { value: root, site: rootSite }, + ]; + while (stack.length > 0) { + const { value, site } = stack.pop()!; + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + stack.push({ value: value[index], site: at(site, index) }); + } + continue; + } + if (typeof value !== "object" || value === null) continue; + const obj = value as Record<string, unknown>; + for (const name of ["range", "sourceRange"]) { + if (Object.hasOwn(obj, name)) { + decodeFail( + at(site, name), + "no range datum on an edge surface — everywhere a graph node " + + "appears as an edge endpoint it is a bare identity, requirement " + + "node and code location alike; a code location's source range is " + + "presented in exactly two outputs, occurrence records and review " + + "payloads (SPEC 1.7)", + obj[name], + ); + } + } + if (Object.hasOwn(obj, "start") && Object.hasOwn(obj, "end")) { + decodeFail( + 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, + ); + } + const entries = Object.entries(obj); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, member] = entries[index]!; + stack.push({ value: member, site: at(site, key) }); + } + } +} + /** `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); + const site = documentRootSite(doc, "ids", context); const obj = expectObject(doc, site); const filesSite = at(site, "files"); const files: IdsFileEntry[] = expectArray( @@ -376,15 +562,58 @@ export function decodeIdsReport(doc: unknown, context?: string): IdsReport { return { files }; } +/** + * One `ids --tree` node per section nesting level, decoded through an + * explicit stack. + * + * H-11: never native recursion per nesting level — the suite stages section + * towers 2048 and 4096 deep (P-8, P-11, T1.3-7), past V8's frame budget — + * and no depth cap of any kind. The checks run per node in exactly the order + * a recursive descent runs them: the node's own members first (`id`, then + * the array form of `children`), then each child completely (subtree + * included) in document order. + */ function decodeIdsTreeNode(value: unknown, site: DecodeSite): IdsTreeNode { + const stack: IdsTreeFrame[] = [enterIdsTreeNode(value, site)]; + for (;;) { + const top = stack[stack.length - 1]!; + if (top.nextChild < top.rawChildren.length) { + const index = top.nextChild; + top.nextChild += 1; + stack.push( + enterIdsTreeNode(top.rawChildren[index], at(top.childrenSite, index)), + ); + continue; + } + const node: IdsTreeNode = { id: top.id, children: top.children }; + stack.pop(); + const parent = stack[stack.length - 1]; + if (parent === undefined) return node; + parent.children.push(node); + } +} + +/** One node's decode in flight: its own members decoded, children pending. */ +interface IdsTreeFrame { + readonly id: string; + readonly childrenSite: DecodeSite; + readonly rawChildren: readonly unknown[]; + /** The children decoded so far, in document order. */ + readonly children: IdsTreeNode[]; + /** The index of the next raw child to decode. */ + nextChild: number; +} + +/** A node's own members, in form order — everything before its children. */ +function enterIdsTreeNode(value: unknown, site: DecodeSite): IdsTreeFrame { const obj = expectObject(value, site); const childrenSite = at(site, "children"); - return { - id: expectNonEmptyString(requiredKey(obj, "id", site), at(site, "id")), - children: expectArray(requiredKey(obj, "children", site), childrenSite).map( - (element, index) => decodeIdsTreeNode(element, at(childrenSite, index)), - ), - }; + const id = expectNonEmptyString(requiredKey(obj, "id", site), at(site, "id")); + const rawChildren = expectArray( + requiredKey(obj, "children", site), + childrenSite, + ); + return { id, childrenSite, rawChildren, children: [], nextChild: 0 }; } /** `ids --tree` (T12.3-1): per-file nesting in file and document order. */ @@ -392,7 +621,7 @@ export function decodeIdsTreeReport( doc: unknown, context?: string, ): IdsTreeReport { - const site = rootSite("ids --tree", context); + const site = documentRootSite(doc, "ids --tree", context); const obj = expectObject(doc, site); const filesSite = at(site, "files"); const files: IdsTreeFileEntry[] = expectArray( diff --git a/test/helpers/adapters/record-staging.ts b/test/helpers/adapters/record-staging.ts new file mode 100644 index 00000000..57c90b6b --- /dev/null +++ b/test/helpers/adapters/record-staging.ts @@ -0,0 +1,171 @@ +// H-3 adapter layer — corrupt-record staging for T6.6-6 (TEST-SPEC §0 H-3, +// §6.6), shared by the other 14.23 stagings that reuse "T6.6-6's staging" +// (T12.2-2's unreadable-record arm, T13.3-2's record discipline, T11.6-4). +// +// Graph-data content is opaque (H-4) and its layout deliberately unenumerated +// (SPEC 13.3, 11.6), so the only shape knowledge that exists for the record +// is T13.3-2's operational path set: every path under `.xspec/` except the +// durable `.xspec/journal` and `.xspec/reviews/`. That predicate lives here +// (`isGraphDataKey`; the T13.3-2 machinery in +// test/suite/registry/section-13.3.ts re-exports it), and the corruption is +// shape-blind — TEST-SPEC T6.6-6: "truncation or garbage over T13.3-2's +// operational path set" — realized as a garbage overwrite of every +// product-written plain file in the set, staging "recorded state that exists +// but cannot be read as a record" (SPEC 14.23): the files stay present (an +// absent record is the different, nothing-recorded success path, T6.6-5) +// while their bytes can be read as no structured record at all (not even +// valid UTF-8). +// +// H-3 staging discipline (as T10.1-4's session-staging.ts): the +// transformation applies only to files the product itself wrote — it never +// creates a path, so the harness never fabricates a record file from an +// assumed layout — and fails loudly (diagnosed test error, nothing modified) +// when the workspace holds nothing to corrupt: no graph-data area, no +// graph-data file in it (the caller must run a successful `build` first), or +// a non-plain-file entry in the set (every file xspec writes is a plain file +// and its writes never traverse a symbolic link, SPEC 13.4 — such an +// occupant is not a product-written record file, and writing through it +// could escape the workspace). + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { fail } from "../assertions.js"; + +/** + * The graph-data area: the location under which graph data is kept, spelled + * as its workspace-relative path with no trailing separator (SPEC 11.6) — + * the concerned path of every condition-23 finding and of the 14.10 unit + * form ("no path inside the area is named"). + */ +export const GRAPH_DATA_AREA_PATH = ".xspec"; + +/** + * Whether a workspace-relative, `/`-separated path is graph data: under + * `.xspec/`, excluding the durable `.xspec/journal` and `.xspec/reviews/` + * (SPEC 13.3, 13.4; TEST-SPEC T13.3-2's operational definition — the whole + * shape SPEC.md gives the record). One home for the predicate: the suite's + * graph-data machinery (section-13.3.ts) re-exports it. + */ +export function isGraphDataKey(key: string): boolean { + if (!key.startsWith(`${GRAPH_DATA_AREA_PATH}/`)) return false; + if (key === `${GRAPH_DATA_AREA_PATH}/journal`) return false; + if ( + key === `${GRAPH_DATA_AREA_PATH}/reviews` || + key.startsWith(`${GRAPH_DATA_AREA_PATH}/reviews/`) + ) { + return false; + } + return true; +} + +/** + * The deterministic garbage a corrupted record file holds: readable as no + * record — not one JSON document, not even valid UTF-8 (0xFF and 0xFE occur + * in no UTF-8 sequence; 0xC3 0x28 is a truncated one) — while the file stays + * present, so the staged state is "exists but cannot be read as a record" + * (SPEC 14.23), never the absent-record success path. + */ +export const RECORD_GARBAGE_BYTES: Uint8Array = Uint8Array.from([ + ...Buffer.from("xspec-harness: not a record ", "utf8"), + 0x00, + 0xff, + 0xfe, + 0xc3, + 0x28, +]); + +function stagingFail(context: string, problem: string): never { + fail( + `${context}: corrupt-record staging: ${problem}. H-3: the shape-blind ` + + `corruption applies only to record files the product itself wrote ` + + `(truncation or garbage over T13.3-2's operational path set) and ` + + `fails loudly otherwise — the harness never fabricates a record file ` + + `from an assumed layout. Nothing was modified.`, + ); +} + +/** Recursively collect the graph-data plain files under `rel` (see above). */ +async function collectGraphDataFiles( + rootAbs: string, + rel: string, + context: string, +): Promise<string[]> { + const collected: string[] = []; + const entries = await fsp.readdir(path.join(rootAbs, rel), { + withFileTypes: true, + }); + for (const entry of entries) { + const key = `${rel}/${entry.name}`; + // The durable journal and reviews paths are no part of the record + // (T13.3-2): skipped entirely, whatever occupies them. + if (!isGraphDataKey(key)) continue; + if (entry.isDirectory()) { + collected.push(...(await collectGraphDataFiles(rootAbs, key, context))); + } else if (entry.isFile()) { + collected.push(key); + } else { + stagingFail( + context, + `${key} is not a plain file or directory — every file xspec writes ` + + `is a plain file and its writes never traverse a symbolic link ` + + `(SPEC 13.4), so this occupant is not a product-written record ` + + `file and the harness will not write through it`, + ); + } + } + return collected; +} + +/** + * Corrupt the product-written graph data shape-blind (TEST-SPEC T6.6-6): + * overwrite every plain file of T13.3-2's operational path set — every path + * under `.xspec/` except the durable journal and reviews paths — with + * {@link RECORD_GARBAGE_BYTES}, leaving every path present (no path is + * created or removed; directories keep their structure). Fails loudly, with + * nothing modified, when the graph-data area is missing or not a real + * directory, when the set holds no plain file (nothing product-written to + * corrupt — run a successful `build` first), or when it holds a + * non-plain-file entry (SPEC 13.4). Returns the corrupted files' + * workspace-relative paths in byte order. + */ +export async function corruptGraphDataShapeBlind( + rootAbs: string, + context: string, +): Promise<readonly string[]> { + const areaAbs = path.join(rootAbs, GRAPH_DATA_AREA_PATH); + let areaStats; + try { + areaStats = await fsp.lstat(areaAbs); + } catch { + stagingFail( + context, + `no ${GRAPH_DATA_AREA_PATH} directory exists — the product has ` + + `written no graph data here (SPEC 13.3: xspec maintains graph data ` + + `under .xspec/)`, + ); + } + if (!areaStats.isDirectory()) { + stagingFail( + context, + `${GRAPH_DATA_AREA_PATH} is not a real directory — the graph-data ` + + `area the product writes is one (SPEC 13.3, 13.4)`, + ); + } + const files = ( + await collectGraphDataFiles(rootAbs, GRAPH_DATA_AREA_PATH, context) + ).sort(); + if (files.length === 0) { + stagingFail( + context, + `found no graph-data file to corrupt under ${GRAPH_DATA_AREA_PATH}/ ` + + `(outside the durable journal and reviews paths) — the corruption ` + + `applies to record files the product itself wrote, so run a ` + + `successful \`build\` first (SPEC 12.1, 13.3)`, + ); + } + for (const key of files) { + await fsp.writeFile(path.join(rootAbs, key), RECORD_GARBAGE_BYTES); + } + return files; +} diff --git a/test/helpers/adapters/reports.ts b/test/helpers/adapters/reports.ts index d7f34512..90fc6855 100644 --- a/test/helpers/adapters/reports.ts +++ b/test/helpers/adapters/reports.ts @@ -1,16 +1,15 @@ -// 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. +// contract: each document entry runs the 12.7 unavailability-marker walk +// over the whole raw document first (`documentRootSite`, forms.ts; +// T12.7-1). Adjust the ASSUMED SHAPE below when the real product's output +// 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 +27,6 @@ import type { CoverageProfileReport, CoverageReport, CoveredNode, - Finding, - FindingsReport, IgnoredNode, ImpactCategoryEntry, ImpactReport, @@ -52,86 +49,8 @@ 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.<n>")', - 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 { documentRootSite } from "./forms.js"; +import { decodeEdge } from "./query.js"; function decodeCoveredNode(value: unknown, site: DecodeSite): CoveredNode { const obj = expectObject(value, site); @@ -224,7 +143,7 @@ export function decodeCoverageReport( doc: unknown, context?: string, ): CoverageReport { - const site = rootSite("coverage", context); + const site = documentRootSite(doc, "coverage", context); const obj = expectObject(doc, site); const profilesSite = at(site, "profiles"); const profiles = expectArray( @@ -386,7 +305,7 @@ export function decodeImpactReport( doc: unknown, context?: string, ): ImpactReport { - const site = rootSite("impact", context); + const site = documentRootSite(doc, "impact", context); const obj = expectObject(doc, site); const requirementsSite = at(site, "requirements"); const codeSite = at(site, "code"); diff --git a/test/helpers/adapters/review.ts b/test/helpers/adapters/review.ts index 91e246b3..e2c46c25 100644 --- a/test/helpers/adapters/review.ts +++ b/test/helpers/adapters/review.ts @@ -2,7 +2,11 @@ // `next`, `show`, `export` (SPEC.md 10; TEST-SPEC §10). // // 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 +// contract: every document entry runs the 12.7 unavailability-marker walk +// over the whole raw document first (`documentRootSite`, forms.ts), and +// every source range decodes through the literal 12.7 range form +// (`decodeSourceRange`) — value forms are never adapted (T12.7-1). Adjust +// the ASSUMED SHAPE below when the real product's output // shape legitimately differs; never adjust values. `baseline`, `current`, // `creationParameters`, and `decompositions` are recorded, product-shaped // data: the adapter requires their presence and passes their decoded JSON @@ -25,7 +29,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 +40,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, @@ -64,8 +75,8 @@ import { optionalKey, requiredKey, requiredMember, - rootSite, } from "./decode.js"; +import { documentRootSite } from "./forms.js"; import { decodeSourceRange } from "./query.js"; /** Counts keyed by status: every value a non-negative integer. */ @@ -121,7 +132,7 @@ export function decodeSessionListReport( doc: unknown, context?: string, ): SessionListReport { - const site = rootSite("review list", context); + const site = documentRootSite(doc, "review list", context); const obj = expectObject(doc, site); const sessionsSite = at(site, "sessions"); const sessions = expectArray( @@ -167,7 +178,7 @@ export function decodeSessionStatusReport( doc: unknown, context?: string, ): SessionStatusReport { - const site = rootSite("review status", context); + const site = documentRootSite(doc, "review status", context); const obj = expectObject(doc, site); const itemsSite = at(site, "items"); return { @@ -243,7 +254,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 +270,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). */ @@ -325,7 +358,10 @@ export function decodeReviewItemValue( /** `review show <name> <item-id>` (T10.7-8): the full item. */ export function decodeItemReport(doc: unknown, context?: string): ReviewItem { - return decodeReviewItemValue(doc, rootSite("review show", context)); + return decodeReviewItemValue( + doc, + documentRootSite(doc, "review show", context), + ); } /** @@ -334,7 +370,7 @@ export function decodeItemReport(doc: unknown, context?: string): ReviewItem { * no item. A document claiming both (or neither) is contradictory. */ export function decodeNextReport(doc: unknown, context?: string): NextReport { - const site = rootSite("review next", context); + const site = documentRootSite(doc, "review next", context); const obj = expectObject(doc, site); const fullyResolved = expectBoolean( requiredKey(obj, "fullyResolved", site), @@ -367,7 +403,7 @@ export function decodeExportReport( doc: unknown, context?: string, ): ExportReport { - const site = rootSite("review export", context); + const site = documentRootSite(doc, "review export", context); const obj = expectObject(doc, site); const itemsSite = at(site, "items"); return { diff --git a/test/helpers/adapters/session-staging.ts b/test/helpers/adapters/session-staging.ts index c53a39ae..88c3c834 100644 --- a/test/helpers/adapters/session-staging.ts +++ b/test/helpers/adapters/session-staging.ts @@ -4,15 +4,16 @@ // the product itself wrote and is corrupted here — the one place aware of the // stored session's concrete shape. The transformations are shape-aware and // value-blind: they locate structure (the item list, an item's id, status, -// blockedBy, the recorded creation parameters), never inspect what the values -// are, and fail loudly (diagnosed test error, file untouched) when the shape -// does not match. The harness never writes a session file from an assumed +// blockedBy, the recorded creation parameters and decompositions), never +// inspect what the values are, and fail loudly (diagnosed test error, file +// untouched) when the shape does not match. The harness never writes a session file from an assumed // layout — shape-independent corrupt states (unparseable bytes, truncation, a // directory or symlink at the path) are staged directly by the tests, not // here. // // ASSUMED STORED-SESSION SHAPE (adjustable per H-3, values never): -// { ..., "creationParameters": <recorded>, ..., +// { ..., "creationParameters": <recorded>, "decompositions": <recorded>, +// ..., // "items": [ { "id": string, "status": string, "blockedBy": [id...], // ...per-item fields... }, ... ], ... } // @@ -33,6 +34,7 @@ const SESSION_SHAPE = { statusKey: "status", blockedByKey: "blockedBy", creationParametersKey: "creationParameters", + decompositionsKey: "decompositions", } as const; interface LoadedSession { @@ -296,23 +298,20 @@ export async function stageDeleteItemField( } /** - * T10.1-4 "malformed recorded creation parameters": garble the recorded - * creation parameters by replacing them with a value of a different JSON - * structural type (value-blind: only the stored value's type is examined, so - * the replacement is malformed whatever the recorded content was — a garbage - * *string* where a string is stored could still parse as merely unresolvable, - * which is a different, exit-2 state, T10.7-3). + * Garble a recorded top-level session member by replacing it with a value of + * a different JSON structural type (value-blind: only the stored value's type + * is examined, so the replacement is malformed whatever the recorded content + * was — a garbage *string* where a string is stored could still parse as + * merely unresolvable, which is a different, exit-2 state, T10.7-3). */ -export async function stageGarbleCreationParameters( +async function garbleRecordedMember( absPath: string, + key: string, + what: string, ): Promise<void> { const loaded = await loadSession(absPath); - const key = SESSION_SHAPE.creationParametersKey; if (!Object.hasOwn(loaded.doc, key)) { - shapeFail( - absPath, - `expected a "${key}" member holding the recorded creation parameters`, - ); + shapeFail(absPath, `expected a "${key}" member holding the ${what}`); } const stored = loaded.doc[key]; loaded.doc[key] = @@ -321,3 +320,37 @@ export async function stageGarbleCreationParameters( : { "xspec-harness-garbled": true }; await writeSession(absPath, loaded.doc); } + +/** + * T10.1-4 "malformed recorded creation parameters": garble the recorded + * creation parameters by structural type flip (see + * {@link garbleRecordedMember}). + */ +export async function stageGarbleCreationParameters( + absPath: string, +): Promise<void> { + await garbleRecordedMember( + absPath, + SESSION_SHAPE.creationParametersKey, + "recorded creation parameters", + ); +} + +/** + * T10.1-4 "malformed recorded decompositions": garble the recorded + * decompositions (SPEC 10.7: a `split`'s decomposition — the original's kind + * and scope node — is recorded durably in the session and governs + * re-derivation) by structural type flip (see {@link garbleRecordedMember}). + * Staged over a session in which the product itself performed a `split`, so + * a decomposition is genuinely recorded (T10.1-4's staging discipline: the + * corrupted file starts as one the product wrote). + */ +export async function stageGarbleDecompositions( + absPath: string, +): Promise<void> { + await garbleRecordedMember( + absPath, + SESSION_SHAPE.decompositionsKey, + "recorded decompositions", + ); +} diff --git a/test/helpers/added-import-identifiers.ts b/test/helpers/added-import-identifiers.ts new file mode 100644 index 00000000..189abc58 --- /dev/null +++ b/test/helpers/added-import-identifiers.ts @@ -0,0 +1,768 @@ +// T6.5-22(a)'s universal assertion (TEST-SPEC T6.5-22(a); SPEC 6.5's +// constraints on an added import's identifiers): in every operation the +// suite performs that adds an import, whichever test performs it, each added +// identifier passes every constraint the fixture decides. Harness machinery +// only: no product imports, no test-framework dependence. +// +// Where it runs. The subprocess driver (helpers/subprocess.ts) is the one +// path every invocation takes — the registered bodies, the property runner, +// the certification runner, and S-7's sweep alike — so the assertion lives +// there: before spawning an invocation whose argv reads, under SPEC 12.0's +// invocation grammar, as a performed `move` (the operation 6.5 lets add an +// import; never a `--preview`, which performs nothing, 6.6), the driver has +// `prepareAddedImportCheck` read the pre-operation sources, and once the run +// exits 0 its `verify` reads the post-operation sources and judges them +// before the driver hands the run's result back. A breach is a diagnosed +// product failure (`HarnessAssertionError`) naming the file, the identifier, +// and the clause; a run that does not exit 0 is not judged. +// +// What it reads. The sources the operation could have rewritten — the +// workspace's discovered spec and code sources, found by the harness's own +// means from the configuration the invocation loads (SPEC 7: the `--config` +// path, else the nearest `xspec.config.ts` at or above the working +// directory; its directory the workspace root): the declarative +// configuration read with its literals as spelled (2.4), the globs matched +// by the harness's glob oracle (helpers/oracles/glob.ts), no symbolic link +// followed, and the derived files excluded as 13.4 excludes them (a name +// containing `.xspec.`, anything under `.xspec/`, a configured Markdown emit +// destination). A configuration the harness cannot read so — none found, +// not a plain file, not 7's declarative form — is one no performed move +// loads (14.14), so the operation goes unjudged; a file the environment +// refuses to read is left out on both sides. +// +// What it judges. Each source whose bytes the operation changed or created — +// a file-form move's relocated file compared with its origin's bytes, a +// created target file with empty content: its import declarations before and +// after (a spec source's in its ESM blocks, through S-9's MDX parse, +// helpers/mdx-derivability.ts `readMdxTree`; a code source's top-level ones, +// through the harness's TypeScript 5.9.3, TSX or plain as the name selects, +// 14.20), and the declarations the operation added: those after it that no +// declaration before it accounts for, compared by their characters — under a +// file-form move all but the specifier literal's, which that form's +// specifier rewrites alone change (6.5). The added declarations' identifiers +// (their local bindings, values otherwise unpinned) are judged with S-6's +// name analysis of the pre-operation file (helpers/oracles/name-analysis.ts): +// none barred there, none bound by a declaration of the file or referenced +// in it, all distinct, and in a spec source none of `S`, `Spec`, `text`. A +// source the operation rewrote that is not well-formed under its grammar is +// a diagnosed failure too, after the operation or before it — 6.5 keeps +// every file a successful move rewrites well-formed, and its valid-workspace +// precondition refuses a move over sources that are not (6.4, 14.20) — its +// names being unreadable either way. + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import ts from "typescript-5.9.3"; +import { bytesEqual, fail } from "./assertions.js"; +import { readMdxTree } from "./mdx-derivability.js"; +import { globMatches } from "./oracles/glob.js"; +import { + addedIdentifierBreaches, + analyzeNames, + type ReceivingFileKind, +} from "./oracles/name-analysis.js"; +import type { ArgvValue, RunResult } from "./subprocess.js"; +import { judgeTypeScript } from "./ts-derivability.js"; + +/** A performed move's pending judgement (see the module header). */ +export interface AddedImportCheck { + /** Judges the operation's added imports when `result` exited 0. */ + verify(result: RunResult): Promise<void>; +} + +/** + * Before an invocation spawns: undefined unless `argv` (the tokens after the + * binding's prefix) reads as a performed move under a configuration the + * harness reads; else the check, holding the pre-operation sources. + */ +export async function prepareAddedImportCheck( + cwd: string, + argv: readonly ArgvValue[], + commandLine: string, +): Promise<AddedImportCheck | undefined> { + const move = readPerformedMove(argv); + if (move === undefined) return undefined; + const configPath = await locateConfiguration(cwd, move.config); + if (configPath === undefined) return undefined; + const configuration = await readConfiguration(configPath); + if (configuration === undefined) return undefined; + const before = await readSources(configuration); + return { + verify: async (result: RunResult): Promise<void> => { + if (result.exitCode !== 0) return; + const after = await readSources(configuration); + const problems = judgeAddedImports(move, before, after); + if (problems.length > 0) { + fail( + `T6.5-22(a), held over every operation that adds an import (SPEC 6.5's constraints on an added import's identifiers): ${commandLine} exited 0, and\n` + + problems.map((problem) => ` - ${problem}`).join("\n"), + ); + } + }, + }; +} + +// --------------------------------------------------------------------------- +// The invocation (SPEC 12.0's grammar). + +/** + * The flags that take a value, by name: SPEC 12.0 fixes a flag's arity by + * its name, the same for every command, and every other `--` token takes + * none. Exported for P-8's reading of when JSON output is in effect + * (test/suite/registry/section-16-p8.ts). + */ +// prettier-ignore +export const VALUE_FLAGS: ReadonlySet<string> = new Set([ + "base", "config", "coverage", "file", "from", "group", "kinds", "name", + "note", "status", "strategy", "tag", "test-hold", "to", +]); + +/** A performed `move`, as its argv reads under SPEC 12.0's grammar. */ +export interface PerformedMove { + /** The section form when the operands spell `<file>#<id>` (6.5). */ + readonly form: "file" | "section"; + /** The first operand: `<old-file>`, or `<file>#<id>`. */ + readonly origin: string; + /** The second operand: `<new-file>`, or `<target-file>#<new-id>`. */ + readonly destination: string; + /** The `--config` value, when given. */ + readonly config: string | undefined; +} + +/** + * Reads an invocation's argv under SPEC 12.0's grammar — flag tokens + * anywhere, a value-taking flag taking the whole next token, `--` ending + * flag reading: a performed move, or undefined for anything else (another + * command, a `--preview`, or tokens matching no move synopsis — a usage + * error, exit 2, leaving nothing to judge). + */ +export function readPerformedMove( + argv: readonly ArgvValue[], +): PerformedMove | undefined { + const operands: ArgvValue[] = []; + const flags = new Map<string, ArgvValue | true>(); + let flagsEnded = false; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] as ArgvValue; + if (!flagsEnded && typeof token === "string" && token.startsWith("--")) { + if (token === "--") { + flagsEnded = true; + continue; + } + const name = token.slice(2); + if (flags.has(name)) return undefined; + if (VALUE_FLAGS.has(name)) { + index += 1; + if (index >= argv.length) return undefined; + flags.set(name, argv[index] as ArgvValue); + } else { + flags.set(name, true); + } + continue; + } + operands.push(token); + } + const [command, origin, destination, ...surplus] = operands; + if (command !== "move" || surplus.length > 0) return undefined; + if (typeof origin !== "string" || typeof destination !== "string") { + return undefined; + } + if (flags.has("preview")) return undefined; + const config = flags.get("config"); + if (config !== undefined && typeof config !== "string") return undefined; + const sectionForm = origin.includes("#"); + if (sectionForm !== destination.includes("#")) return undefined; + return { + form: sectionForm ? "section" : "file", + origin, + destination, + config, + }; +} + +// --------------------------------------------------------------------------- +// The configuration (SPEC 7, 7.3) and discovery (7, 13.4). + +const CONFIG_NAME = "xspec.config.ts"; + +interface Configuration { + /** The workspace root: the configuration file's directory. */ + readonly root: string; + readonly specGlobs: readonly string[]; + readonly codeGlobs: readonly string[]; + /** While Markdown emission is enabled, its `outDir` ("" when unset). */ + readonly emitDir: string | undefined; +} + +/** The configuration file the invocation loads (SPEC 7), if any. */ +async function locateConfiguration( + cwd: string, + flag: string | undefined, +): Promise<string | undefined> { + const physical = await fsp.realpath(cwd).catch(() => path.resolve(cwd)); + if (flag !== undefined) return path.resolve(physical, flag); + for (let dir = physical; ;) { + const candidate = path.join(dir, CONFIG_NAME); + // The search stops at an entry of that name, whatever occupies it. + if ((await fsp.lstat(candidate).catch(() => undefined)) !== undefined) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** Reads 7's declarative configuration, or undefined when it is not one. */ +async function readConfiguration( + configPath: string, +): Promise<Configuration | undefined> { + const stats = await fsp.lstat(configPath).catch(() => undefined); + if (stats === undefined || !stats.isFile()) return undefined; + const bytes = await fsp.readFile(configPath).catch(() => undefined); + if (bytes === undefined) return undefined; + const text = decodeUtf8(bytes); + if (text === undefined || text.charCodeAt(0) === 0xfeff) return undefined; + const file = ts.createSourceFile( + CONFIG_NAME, + text, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ); + const exported = file.statements.find( + (statement): statement is ts.ExportAssignment => + ts.isExportAssignment(statement) && statement.isExportEquals !== true, + ); + const call = exported?.expression; + if (call === undefined || !ts.isCallExpression(call)) return undefined; + const [argument, ...rest] = call.arguments; + if (argument === undefined || rest.length > 0) return undefined; + const top = propertiesOf(argument, file); + if (top === undefined) return undefined; + const specs = top.get("specs"); + if (specs === undefined) return undefined; + const specGlobs = groupGlobs(specs, file); + const code = top.get("code"); + const codeGlobs = code === undefined ? [] : groupGlobs(code, file); + if (specGlobs === undefined || codeGlobs === undefined) return undefined; + let emitDir: string | undefined; + const markdown = top.get("markdown"); + if (markdown !== undefined) { + const fields = propertiesOf(markdown, file); + const emit = fields?.get("emit"); + if (emit === undefined) return undefined; + if (emit.kind === ts.SyntaxKind.TrueKeyword) { + const outDir = fields?.get("outDir"); + emitDir = outDir === undefined ? "" : literalValue(outDir, file); + if (emitDir === undefined) return undefined; + } else if (emit.kind !== ts.SyntaxKind.FalseKeyword) { + return undefined; + } + } + return { + root: path.dirname(configPath), + specGlobs, + codeGlobs, + emitDir, + }; +} + +/** A static string literal's value: its characters as spelled (2.4). */ +function literalValue(node: ts.Node, file: ts.SourceFile): string | undefined { + return ts.isStringLiteral(node) ? node.getText(file).slice(1, -1) : undefined; +} + +/** An object literal's properties by key, non-computed keys alone (7). */ +function propertiesOf( + node: ts.Node, + file: ts.SourceFile, +): ReadonlyMap<string, ts.Expression> | undefined { + if (!ts.isObjectLiteralExpression(node)) return undefined; + const properties = new Map<string, ts.Expression>(); + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) return undefined; + const key = ts.isIdentifier(property.name) + ? property.name.getText(file) + : literalValue(property.name, file); + if (key === undefined || properties.has(key)) return undefined; + properties.set(key, property.initializer); + } + return properties; +} + +/** Every glob of a `specs` or `code` group map. */ +function groupGlobs( + node: ts.Expression, + file: ts.SourceFile, +): readonly string[] | undefined { + const groups = propertiesOf(node, file); + if (groups === undefined) return undefined; + const globs: string[] = []; + for (const list of groups.values()) { + if (!ts.isArrayLiteralExpression(list)) return undefined; + for (const element of list.elements) { + const glob = literalValue(element, file); + if (glob === undefined) return undefined; + globs.push(glob); + } + } + return globs; +} + +/** A discovered source, keyed by its workspace-relative path's bytes. */ +interface DiscoveredSource { + readonly kind: ReceivingFileKind; + /** Its bytes; undefined where the environment refused the read. */ + readonly bytes: Uint8Array | undefined; +} + +/** Map keys: a workspace-relative path's exact bytes, latin1-encoded. */ +type Sources = ReadonlyMap<string, DiscoveredSource>; + +const SLASH = Buffer.from("/"); +const DERIVED_INFIX = Buffer.from(".xspec."); +const GRAPH_DATA_AREA = Buffer.from(".xspec"); + +/** + * The workspace's discovered spec and code sources and their bytes (7): + * no symbolic link followed or yielded, the derived files of 13.4 excluded, + * a directory the environment refuses to list holding nothing. + */ +async function readSources(configuration: Configuration): Promise<Sources> { + const { specGlobs, codeGlobs } = configuration; + const matches = (globs: readonly string[], rel: Buffer): boolean => + globs.some((glob) => globMatches(glob, rel)); + // A dot-initial path segment is matched only by a pattern segment written + // with a leading `.` (7), so without one no such entry is reached. + const dotReached = [...specGlobs, ...codeGlobs].some((glob) => + glob.split("/").some((segment) => segment.startsWith(".")), + ); + const spec = new Map<string, Uint8Array | undefined>(); + const code = new Map<string, Uint8Array | undefined>(); + const walk = async (absDir: Buffer, relDir: Buffer | null): Promise<void> => { + const names = await fsp + .readdir(absDir, { encoding: "buffer" }) + .catch(() => [] as Buffer[]); + // Bytewise order, so a diagnosis lists its files the same everywhere. + for (const name of names.sort(Buffer.compare)) { + if (name[0] === 0x2e && !dotReached) continue; + if (relDir === null && name.equals(GRAPH_DATA_AREA)) continue; + const rel = relDir === null ? name : Buffer.concat([relDir, SLASH, name]); + const abs = Buffer.concat([absDir, SLASH, name]); + const stats = await fsp.lstat(abs).catch(() => undefined); + if (stats === undefined || stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + await walk(abs, rel); + continue; + } + if (!stats.isFile() || name.includes(DERIVED_INFIX)) continue; + if (matches(specGlobs, rel)) { + // A spec-group file without the `.mdx` extension is no spec source + // a move rewrites (it makes the workspace invalid, 14.19). + if (rel.toString("latin1").endsWith(".mdx")) { + spec.set(rel.toString("latin1"), await readOrUndefined(abs)); + } + } else if (matches(codeGlobs, rel)) { + code.set(rel.toString("latin1"), await readOrUndefined(abs)); + } + } + }; + await walk(Buffer.from(configuration.root), null); + const sources = new Map<string, DiscoveredSource>(); + for (const [rel, bytes] of spec) { + sources.set(rel, { kind: "spec-source", bytes }); + } + for (const [rel, bytes] of code) { + if (isEmitDestination(rel, configuration.emitDir, spec)) continue; + sources.set(rel, { + kind: rel.endsWith(".tsx") ? "tsx" : "typescript", + bytes, + }); + } + return sources; +} + +/** Whether `rel` is the Markdown emit destination of a discovered spec + * source (13.2, 7.3: `NAME.md` for `NAME.mdx`, under `outDir` when set). */ +function isEmitDestination( + rel: string, + emitDir: string | undefined, + spec: ReadonlyMap<string, unknown>, +): boolean { + if (emitDir === undefined) return false; + let local = rel; + if (emitDir !== "") { + const prefix = `${Buffer.from(emitDir).toString("latin1")}/`; + if (!local.startsWith(prefix)) return false; + local = local.slice(prefix.length); + } + return local.endsWith(".md") && spec.has(`${local.slice(0, -3)}.mdx`); +} + +async function readOrUndefined(abs: Buffer): Promise<Uint8Array | undefined> { + return await fsp.readFile(abs).catch(() => undefined); +} + +// --------------------------------------------------------------------------- +// The judgement. + +/** Every breach and unreadable source the operation left, as text. */ +function judgeAddedImports( + move: PerformedMove, + before: Sources, + after: Sources, +): readonly string[] { + const problems: string[] = []; + const relocated = + move.form === "file" + ? { + from: Buffer.from(move.origin).toString("latin1"), + to: Buffer.from(move.destination).toString("latin1"), + } + : undefined; + for (const [rel, source] of after) { + if (source.bytes === undefined) continue; + let prior = before.get(rel); + if ( + prior === undefined && + relocated !== undefined && + rel === relocated.to + ) { + prior = before.get(relocated.from); + } + if (prior !== undefined) { + if (prior.bytes === undefined) continue; + if (bytesEqual(prior.bytes, source.bytes)) continue; + } + problems.push( + ...judgeFile( + Buffer.from(rel, "latin1").toString("utf8"), + source.kind, + prior?.bytes ?? new Uint8Array(0), + source.bytes, + move.form === "file", + ), + ); + } + return problems; +} + +/** One import declaration as a file spells it. */ +interface DeclarationReading { + /** The declaration's characters. */ + readonly text: string; + /** Its characters, the specifier literal's blanked where asked. */ + readonly key: string; + /** The specifier literal's value, as the file's grammar reads it. */ + readonly specifier: string | undefined; + /** The local bindings it declares. */ + readonly identifiers: readonly string[]; +} + +/** An import declaration an operation added to a file (see + * `judgeAddedImportsOfFile`). */ +export interface AddedImportDeclaration { + /** The declaration's characters. */ + readonly text: string; + /** The specifier literal's value, as the file's grammar reads it. */ + readonly specifier: string | undefined; + /** The local bindings it declares. */ + readonly identifiers: readonly string[]; +} + +/** One file's judgement: the declarations the operation added to it, and + * every problem — a breach, or a side not well-formed (none added then). */ +interface FileJudgement { + readonly added: readonly AddedImportDeclaration[]; + readonly problems: readonly string[]; +} + +/** + * T6.5-22(a)'s judgement of one file a section move rewrote, for a caller + * holding both of its texts (T6.5-22(b)'s lures, one code path with the + * driver's): the import declarations `after` holds that no declaration of + * `before` accounts for, compared by their characters, in `after`'s order, + * and every breach their identifiers make in `before`, worded as the driver + * words it; when either text is not well-formed under its grammar (14.20), + * no declarations and that problem alone. `file` names the file in the + * problems' wording. + */ +export function judgeAddedImportsOfFile( + file: string, + kind: ReceivingFileKind, + before: string, + after: string, +): FileJudgement { + const encoder = new TextEncoder(); + return judgeFileDeclarations( + file, + kind, + encoder.encode(before), + encoder.encode(after), + false, + ); +} + +function judgeFile( + file: string, + kind: ReceivingFileKind, + priorBytes: Uint8Array, + bytes: Uint8Array, + specifiersRewritten: boolean, +): readonly string[] { + return judgeFileDeclarations( + file, + kind, + priorBytes, + bytes, + specifiersRewritten, + ).problems; +} + +function judgeFileDeclarations( + file: string, + kind: ReceivingFileKind, + priorBytes: Uint8Array, + bytes: Uint8Array, + specifiersRewritten: boolean, +): FileJudgement { + const after = readDeclarations(kind, bytes, specifiersRewritten); + if (typeof after === "string") { + return { + added: [], + problems: [ + `${file}, which the operation rewrote, is not well-formed under its grammar after it (SPEC 14.20: ${after}) — 6.5 keeps every file a successful move rewrites well-formed — so the import declarations it added cannot be read`, + ], + }; + } + const before = readDeclarations(kind, priorBytes, specifiersRewritten); + if (typeof before === "string") { + return { + added: [], + problems: [ + `${file}, which the operation rewrote, was not well-formed under its grammar before it (SPEC 14.20: ${before}) — the valid-workspace precondition of 6.4 and 6.5 refuses a move over such a source — so the names an added identifier must avoid cannot be read`, + ], + }; + } + const unmatched = new Map<string, number>(); + for (const declaration of before.declarations) { + unmatched.set(declaration.key, (unmatched.get(declaration.key) ?? 0) + 1); + } + const added: DeclarationReading[] = []; + for (const declaration of after.declarations) { + const count = unmatched.get(declaration.key) ?? 0; + if (count > 0) unmatched.set(declaration.key, count - 1); + else added.push(declaration); + } + const addedDeclarations = added.map( + ({ text, specifier, identifiers }): AddedImportDeclaration => ({ + text, + specifier, + identifiers, + }), + ); + if (added.length === 0) return { added: addedDeclarations, problems: [] }; + const addedBy = new Map<string, string>(); + for (const declaration of added) { + for (const identifier of declaration.identifiers) { + if (!addedBy.has(identifier)) addedBy.set(identifier, declaration.text); + } + } + const breaches = addedIdentifierBreaches( + analyzeNames(kind, before.text), + added.flatMap((declaration) => declaration.identifiers), + ); + return { + added: addedDeclarations, + problems: breaches.map( + (breach) => + `${file}: the added identifier \`${breach.identifier}\` (\`${addedBy.get(breach.identifier) ?? "?"}\`) is ${breach.clause}`, + ), + }; +} + +/** A source's decoded text and import declarations, or why it is not + * well-formed under its grammar (14.20). */ +function readDeclarations( + kind: ReceivingFileKind, + bytes: Uint8Array, + blankSpecifiers: boolean, +): + | { readonly text: string; readonly declarations: DeclarationReading[] } + | string { + const text = decodeUtf8(bytes); + if (text === undefined) return "its bytes are not valid UTF-8 (1.6)"; + const declarations: DeclarationReading[] = []; + const add = ( + start: number, + end: number, + specifier: { readonly start: number; readonly end: number } | undefined, + value: string | undefined, + identifiers: readonly string[], + ): void => { + const declaration = text.slice(start, end); + const key = + blankSpecifiers && specifier !== undefined + ? text.slice(start, specifier.start) + + String.fromCharCode(0) + + text.slice(specifier.end, end) + : declaration; + declarations.push({ + text: declaration, + key, + specifier: value, + identifiers, + }); + }; + if (kind === "spec-source") { + let tree: MdastNode; + try { + tree = readMdxTree(text) as unknown as MdastNode; + } catch (error) { + return (error as Error).message; + } + readMdxDeclarations(tree, add); + } else { + const name = kind === "tsx" ? "receiver.tsx" : "receiver.ts"; + const verdict = judgeTypeScript(text, name); + if (verdict.verdict === "unparseable") return verdict.reason; + readTypeScriptDeclarations(kind, text, add); + } + return { text, declarations }; +} + +type AddDeclaration = ( + start: number, + end: number, + specifier: { readonly start: number; readonly end: number } | undefined, + value: string | undefined, + identifiers: readonly string[], +) => void; + +interface MdastNode { + readonly type: string; + readonly children?: readonly MdastNode[]; + readonly data?: { readonly estree?: unknown }; +} + +interface EsImportDeclaration { + readonly type: "ImportDeclaration"; + readonly start: number; + readonly end: number; + readonly source: { + readonly start: number; + readonly end: number; + readonly value?: unknown; + }; + readonly specifiers: readonly { readonly local: { readonly name: string } }[]; +} + +/** A spec source's import declarations: those of its ESM blocks, wherever + * the blocks stand (14.20), each with its offsets in the document. */ +function readMdxDeclarations(node: MdastNode, add: AddDeclaration): void { + if (node.type === "mdxjsEsm") { + const program = node.data?.estree as + { readonly body?: readonly { readonly type: string }[] } | undefined; + if (program === undefined || !Array.isArray(program.body)) { + throw new Error( + "T6.5-22(a)'s added-import reading: the MDX parse attached no ESTree program to an ESM block", + ); + } + for (const statement of program.body) { + if (statement.type !== "ImportDeclaration") continue; + const declaration = statement as EsImportDeclaration; + add( + declaration.start, + declaration.end, + declaration.source, + typeof declaration.source.value === "string" + ? declaration.source.value + : undefined, + declaration.specifiers.map((specifier) => specifier.local.name), + ); + } + } + for (const child of node.children ?? []) readMdxDeclarations(child, add); +} + +/** A code source's top-level import declarations, read as module code. */ +function readTypeScriptDeclarations( + kind: "typescript" | "tsx", + text: string, + add: AddDeclaration, +): void { + const file = ts.createSourceFile( + kind === "tsx" ? "receiver.tsx" : "receiver.ts", + text, + { + languageVersion: ts.ScriptTarget.ESNext, + setExternalModuleIndicator: (sourceFile) => { + ( + sourceFile as unknown as { externalModuleIndicator?: unknown } + ).externalModuleIndicator = true; + }, + }, + true, + kind === "tsx" ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const rangeOf = (node: ts.Node): { start: number; end: number } => ({ + start: node.getStart(file), + end: node.getEnd(), + }); + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement)) { + const identifiers: string[] = []; + const clause = statement.importClause; + if (clause?.name !== undefined) identifiers.push(clause.name.text); + const bindings = clause?.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + identifiers.push(bindings.name.text); + } else { + for (const element of bindings.elements) { + identifiers.push(element.name.text); + } + } + } + const range = rangeOf(statement); + add( + range.start, + range.end, + rangeOf(statement.moduleSpecifier), + ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined, + identifiers, + ); + } else if (ts.isImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + const range = rangeOf(statement); + const external = ts.isExternalModuleReference(reference) + ? reference.expression + : undefined; + add( + range.start, + range.end, + external === undefined ? undefined : rangeOf(external), + external !== undefined && ts.isStringLiteral(external) + ? external.text + : undefined, + [statement.name.text], + ); + } + } +} + +const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** The bytes' UTF-8 text, a leading byte-order mark kept as U+FEFF, or + * undefined when they are not valid UTF-8. */ +function decodeUtf8(bytes: Uint8Array): string | undefined { + try { + return UTF8.decode(bytes); + } catch { + return undefined; + } +} diff --git a/test/helpers/assertions.ts b/test/helpers/assertions.ts index bab5922e..f84ed548 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 @@ -30,15 +33,33 @@ import { summarizeResult } from "./subprocess.js"; * below); any other exception escaping a test body is a harness error. */ export class HarnessAssertionError extends Error { - constructor(message: string) { + /** + * Whether a property this failure falsifies is shrunk before it is + * reported (helpers/property.ts `checkProperty`); default true. Shrinking + * is bounded in property executions, and that bound stops bounding wall + * clock when every re-observation of the failure costs a full hang guard — + * an invocation the subprocess driver killed (P-11's termination clause, + * TEST-SPEC §16): such a failure declines shrinking, and the drawn + * counterexample is reported as is, with its seed (H-10). + */ + readonly shrinkable: boolean; + + constructor(message: string, options: FailOptions = {}) { super(message); this.name = "HarnessAssertionError"; + this.shrinkable = options.shrinkable ?? true; } } +/** Options of {@link fail}. */ +export interface FailOptions { + /** See {@link HarnessAssertionError.shrinkable}; default true. */ + readonly shrinkable?: boolean; +} + /** Throw a diagnosed assertion failure (H-8). */ -export function fail(message: string): never { - throw new HarnessAssertionError(message); +export function fail(message: string, options?: FailOptions): never { + throw new HarnessAssertionError(message, options); } /** View assertion input as bytes: strings are UTF-8, byte inputs are as-is. */ @@ -167,10 +188,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 +211,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<string, unknown>)["error"] !== "object" || + (doc as Record<string, unknown>)["error"] === null || + Array.isArray((doc as Record<string, unknown>)["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-drive-mismatch.ts b/test/helpers/e6-drive-mismatch.ts new file mode 100644 index 00000000..11c452d4 --- /dev/null +++ b/test/helpers/e6-drive-mismatch.ts @@ -0,0 +1,41 @@ +// The Windows leg's drive-mismatch fixture (TEST-SPEC §18 E-6, S-9's timing +// clause, H-8): the two files test/windows/e6-drive-mismatch.test.ts stages +// at its workspace's creation — the configuration and the one spec source +// of the registered T11.6-1 body's staging. That arm runs outside every +// registered body and S-7's sweep (it is no registry entry, and the sweep +// never runs the windows project), so the undeclared-staging guard cannot +// refuse its staging (helpers/workspace.ts: outside a body context a +// creation never refuses), yet S-9 wants both files judged before any +// product exists. They are staged-source records for that reason, defined +// here rather than in the test file so that the S-9 self-test +// (test/self/s9-staged-sources.test.ts) can import this module BEFORE the +// registry manifest seals the ledgers and judge both records there, as it +// judges the E-6 exchange fixture's (helpers/e6.ts). Named after T11.6-1, +// the registered test whose drive-mismatch arm this is. +// +// 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. + +import { stagedMdx } from "./staged-mdx.js"; +import { stagedTs } from "./staged-ts.js"; + +/** The arm's `xspec.config.ts`: a single spec group. */ +export const ANCHOR_CONFIG = stagedTs( + "T11.6-1 drive-mismatch arm (E-6 Windows leg) xspec.config.ts", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); + +/** The arm's one spec source, `specs/a.mdx`. */ +export const ANCHOR_SOURCE = stagedMdx( + "T11.6-1 drive-mismatch arm (E-6 Windows leg) specs/a.mdx", + '<S id="racine">\nAncrage — contenu stable.\n</S>\n', +); diff --git a/test/helpers/e6.ts b/test/helpers/e6.ts index fdda5aea..6a1b20d1 100644 --- a/test/helpers/e6.ts +++ b/test/helpers/e6.ts @@ -2,14 +2,19 @@ // 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`, a journaled section-form `move` whose moved text lands before a +// target parent's closing tag in an existing target file that gains an added +// import, 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). @@ -31,7 +36,17 @@ // directories in both rewrite directions — the moved file's own import // specifier and two other files' imports of its generated module are // recomputed (SPEC 6.5), which a native-path-API product writes `\`-separated -// only on Windows — and `check` runs clean after it (T6.4-7). The fixture +// only on Windows — and `check` runs clean after it (T6.4-7). The +// section-form `move` is the subset's inserted-terminator probe (E-6): it +// moves the relocated file's leaf into an existing section of another +// existing file, so its moved text lands immediately before that target +// parent's closing tag, and the target file, holding no binding of the module +// the moved embedding is rooted at, gains an added import (as does the code +// file whose marker the move re-roots). SPEC 6.5 makes every terminator it +// inserts — after the moved text, after each added declaration — U+000A on +// every platform, which a product writing the platform's native line +// terminator into rewritten sources meets on Linux alone; no other step +// inserts a line into a source. `check` runs clean after it too. The fixture // depends on no case-sensitive filesystem, no symlink creation, and no POSIX // signal semantics, so it stages identically on both legs. // @@ -57,6 +72,8 @@ import { } from "./assertions.js"; import type { DirectorySnapshot } from "./snapshot.js"; import { assertSnapshotsEqual, snapshotDirectory } from "./snapshot.js"; +import { stagedMdx } from "./staged-mdx.js"; +import { stagedTs } from "./staged-ts.js"; import type { ProductBinding, RunResult } from "./subprocess.js"; import { runProduct } from "./subprocess.js"; import { TestWorkspace } from "./workspace.js"; @@ -100,7 +117,9 @@ export interface E6FixtureRun { // run produces every E-6 output kind: generated modules, emitted Markdown, // graph data, journal, session file, and coverage/impact reports (SPEC 7, // 7.3, 7.4). -const E6_CONFIG = `import { defineConfig } from "xspec" +const E6_CONFIG = stagedTs( + "E-6 xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -120,7 +139,8 @@ export default defineConfig({ } ] }) -`; +`, +); const E6_OTHER = "specs/Other.mdx"; const E6_CORE = "specs/Core.mdx"; @@ -128,12 +148,40 @@ const E6_MOVED = "specs/sub/Moved.mdx"; const E6_REFS = "specs/Refs.mdx"; const E6_APP = "src/app.ts"; +// The leaf's ID after the rename, and the ID the section-form move gives it +// under Refs.mdx's top-level `refs` section, the target parent. +const E6_RENAMED_LEAF_ID = "core.mid.tip"; +const E6_SECTION_TARGET_ID = "refs.tip"; + function otherSource(version: string): string { return ['<S id="oth">', `Other target text, ${version}.`, "</S>", ""].join( "\n", ); } +// The fixture's `.mdx` sources are staged-source records +// (helpers/staged-mdx.ts): its three initial sources and the leaf edit it +// stages between its baseline invocations and `impact`. S-9's check runs +// before any product exists for a deterministic fixture's files, yet this +// fixture is no registry entry — S-7's sweep never runs it, no per-body mark +// is in effect, and the builder's undeclared-staging guard reaches only the +// leaf edit (a staging after product invocations in its workspace), never +// `create()`'s initial files — so nothing but the S-9 self-test judges these +// sources first: test/self/s9-staged-sources.test.ts imports this module +// itself, before the registry manifest seals the ledger, and judges every +// record. The records' names lead with the fixture's §18 ID rather than a +// test ID. Its configuration and code source are TypeScript records +// (helpers/staged-ts.ts) for the same reason, judged by the same self-test +// with S-9's TypeScript check. +const E6_OTHER_VERSION_ONE = stagedMdx( + "E-6 specs/Other.mdx version one — the initial source", + otherSource("version one"), +); +const E6_OTHER_VERSION_TWO = stagedMdx( + "E-6 specs/Other.mdx version two — the leaf edit before `impact`", + otherSource("version two"), +); + // The moved file imports another spec file (its own specifier is recomputed // across the directory change) and its generated module is imported by a spec // file and a code file (their specifiers are recomputed) — both rewrite @@ -164,13 +212,67 @@ const E6_REFS_SOURCE = [ "", ].join("\n"); -const E6_APP_SOURCE = [ - 'import CORE, { text } from "../specs/Core.xspec";', - "", - "CORE.core.mid.leaf;", - "text(CORE.core.mid);", - "", -].join("\n"); +// The two sources above as records; `E6_CORE_SOURCE` stays a string beside +// its record because the `at` step derives its byte offset from it. +const E6_CORE_RECORD = stagedMdx("E-6 specs/Core.mdx", E6_CORE_SOURCE); +const E6_REFS_RECORD = stagedMdx("E-6 specs/Refs.mdx", E6_REFS_SOURCE); + +const E6_APP_SOURCE = stagedTs( + "E-6 src/app.ts", + [ + 'import CORE, { text } from "../specs/Core.xspec";', + "", + "CORE.core.mid.leaf;", + "text(CORE.core.mid);", + "", + ].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)}"; + +// The section-form move's shape, read from the target file after the move so +// the inserted-terminator probe cannot go vacuous unnoticed: the target file +// gained an added import of Other's module — a line of its own, spelled +// exactly as SPEC 6.5 fixes it (`import X from "…"`, single spaces, no +// statement terminator, the canonical specifier double-quoted) and ended by +// U+000A, its identifier X the product's deterministic latitude (6.5), read +// from that line — and the moved text (its `id` re-identified, its embedding +// rooted at X, its other bytes verbatim), then U+000A, stands immediately +// before the target parent's closing tag. Refs.mdx as the earlier steps leave +// it holds no import of Other's module and one closing tag, so neither +// reading can come from the file before the move. +const E6_ADDED_OTHER_IMPORT = /(?:^|\n)import (\S+) from "\.\/Other\.xspec"\n/; + +function assertSectionMoveLanded(targetBytes: Uint8Array): void { + const text = Buffer.from(targetBytes).toString("utf8"); + const binding = E6_ADDED_OTHER_IMPORT.exec(text)?.[1]; + const landed = + binding !== undefined && + text.includes( + [ + `<S id="${E6_SECTION_TARGET_ID}">`, + `Leaf embeds: {text(${binding}.oth)}`, + "</S>", + "</S>", + ].join("\n"), + ); + if (!landed) { + fail( + `E-6 representative fixture, step "move-section": ${E6_REFS} after the ` + + `section-form move lacks the inserted-terminator probe's shape (SPEC ` + + `6.5) — an added \`import X from "./Other.xspec"\` line ended by ` + + `U+000A, and the moved text (\`<S id="${E6_SECTION_TARGET_ID}">\`, its ` + + `embedding rooted at X) followed by U+000A immediately before the ` + + `target parent's closing tag; its bytes: ${JSON.stringify(text)}`, + ); + } +} const GIT_DIR_BYTES = Buffer.from(".git", "utf8"); @@ -191,9 +293,9 @@ export async function runE6RepresentativeFixture( const workspace = await TestWorkspace.create({ files: { "xspec.config.ts": E6_CONFIG, - [E6_OTHER]: otherSource("version one"), - [E6_CORE]: E6_CORE_SOURCE, - [E6_REFS]: E6_REFS_SOURCE, + [E6_OTHER]: E6_OTHER_VERSION_ONE, + [E6_CORE]: E6_CORE_RECORD, + [E6_REFS]: E6_REFS_RECORD, [E6_APP]: E6_APP_SOURCE, }, }); @@ -210,9 +312,10 @@ export async function runE6RepresentativeFixture( argv: readonly string[], expectedExit: number, why: string, + cwd: string = workspace.root, ): Promise<RunResult> => { const result = await runProduct(product, { - cwd: workspace.root, + cwd, argv, }); assertExitCode( @@ -230,6 +333,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"], @@ -276,7 +388,7 @@ export async function runE6RepresentativeFixture( // One leaf edit between the baseline and `impact`, so the report carries // categories; rebuild so derived files match the sources again before the // later clean `check` (SPEC 5.6, 14.13). - await workspace.file(E6_OTHER, otherSource("version two")); + await workspace.file(E6_OTHER, E6_OTHER_VERSION_TWO); await step( "build-after-edit", ["build"], @@ -296,15 +408,63 @@ 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( "rename", - ["rename", E6_CORE, "core.mid.leaf", "core.mid.tip"], + ["rename", E6_CORE, "core.mid.leaf", E6_RENAMED_LEAF_ID], 0, "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). @@ -322,6 +482,36 @@ export async function runE6RepresentativeFixture( "recomputed specifier resolves (SPEC 6.5, 14.10; T6.4-7)", ); + // Journaled section-form move — the inserted-terminator probe (E-6): the + // relocated file's leaf moves under Refs.mdx's existing `refs` section, + // so its moved text lands immediately before that parent's closing tag + // in an existing target file; its embedding is rooted at Other's module, + // of which Refs.mdx holds no binding, so the target file gains an added + // import (and src/app.ts, whose marker of the leaf the move re-roots at + // Refs.mdx's module, gains one too). Each terminator inserted after the + // moved text and after an added declaration is U+000A on every platform + // (SPEC 6.5); the final workspace snapshot carries the rewritten sources + // across legs. + await step( + "move-section", + [ + "move", + `${E6_MOVED}#${E6_RENAMED_LEAF_ID}`, + `${E6_REFS}#${E6_SECTION_TARGET_ID}`, + ], + 0, + "a valid section-form move into an existing target parent succeeds and appends to the journal (SPEC 6.5, 6.1)", + ); + assertSectionMoveLanded(await workspace.readBytes(E6_REFS)); + await step( + "check-post-section-move", + ["check"], + 0, + "the section move's finishing regeneration leaves the workspace clean — " + + "the moved embedding resolves through the target file's added import " + + "(SPEC 6.5, 14.10)", + ); + // Audit review session (SPEC 10): create, next --json, one resolve, and // an export; the session file is stored data compared across legs (1.5). await step( @@ -366,6 +556,23 @@ export async function runE6RepresentativeFixture( "the session exports as one JSON payload (SPEC 10.7)", ); + // Inventory, invoked from the nested directory the file-form move + // created (specs/sub, which still holds the relocated file after the + // section-form move, 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 moves' + // finishing regenerations. + 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/import-insertion.ts b/test/helpers/import-insertion.ts new file mode 100644 index 00000000..af0382aa --- /dev/null +++ b/test/helpers/import-insertion.ts @@ -0,0 +1,686 @@ +// SPEC 6.5's added-import insertion discipline, asserted value-blind in the +// fresh identifier and byte-exact in every other character (TEST-SPEC +// T6.5-8; shared by T6.5-3's third-file arms and T6.5-10's arms (a) and +// (c), whose receiving files, like T6.5-8's, hold a line-start admissible +// offset). Harness machinery only: no product imports. +// +// An added import "is spelled exactly: `import X from "…"` in a spec source +// […] — single spaces as shown, no statement terminator, the specifier +// double-quoted in the canonical spelling a specifier rewrite produces +// […], from the importing file's directory" 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", where "an admissible offset at the start of a line […] is +// taken over any other" (SPEC 6.5); the identifier choice alone is +// latitude. A test therefore composes the receiving file's expected +// post-operation bytes from the rules of 6.4/6.5 and 3 WITHOUT the added +// import (`base`, with the fresh identifier read off the rewritten +// references), and this module isolates, by diff against the product's +// bytes (`actual`), the single byte run whose insertion turns `base` into +// `actual`, then asserts that run is exactly the declaration — `import `, +// the identifier, ` from "`, the canonical specifier, `"` — followed by +// U+000A, at an offset lying at the start of a line of the composed text. +// Line starts are judged by SPEC 3's terminators — U+000D U+000A as one, +// a U+000A not preceded by U+000D, a U+000D not followed by U+000A — so an +// offset is at the start of a line exactly when nothing precedes it or such +// a terminator immediately precedes it (6.5): the offset after a lone CR is +// one, the offset between a CRLF's two characters is not, and a reader +// judging line starts by U+000A alone would fail a conforming insertion +// into a lone-CR file (T6.5-8's terminator re-runs; `atLineStart`). +// +// Offsets are a range, not a point: a run whose end bytes repeat the bytes +// beside it admits several insertion offsets describing the same bytes +// (`A\n` + `import X\n` + `B` equals `A` + `\nimport X` + `\nB`). Bytes are +// the only observable here, so the discipline holds when SOME admissible +// offset reads as the disciplined insertion — a product is never failed for +// output byte-identical to a conforming one — and is violated when none +// does: a declaration joined to a neighbour with `;`, the mid-line form +// (U+000A, the declaration, U+000A) while the receiving file holds a +// line-start admissible offset, a spurious blank line, any terminator but +// U+000A, or any other spelling of the declaration — single quotes, a `;`, +// other spacing, a non-canonical specifier (`./sub/../target.xspec`, no +// `./` prefix, a `.mdx` extension, a `.` segment) — leaves no admissible +// reading (T6.5-8). The forced mid-line form of a file holding no +// line-start admissible offset (T6.5-13) is not this reader's: the +// exact-declaration reader below reads both forms and reports which one it +// read (T6.5-11). + +import { Buffer } from "node:buffer"; +import { posix as posixPath } from "node:path"; +import { fail } from "./assertions.js"; + +const LF = 0x0a; +const CR = 0x0d; + +/** + * Whether `offset` lies at the start of a line of `text` (SPEC 6.5: exactly + * when nothing precedes it or a line terminator of 3 immediately precedes + * it — U+000D U+000A as one terminator, a U+000A not preceded by U+000D, a + * U+000D not followed by U+000A). The offset after a lone CR, the file's + * end after a final terminator of any kind included, is a line start; the + * offset between a CRLF's two characters is not, the CR there being half + * of one terminator. + */ +export function atLineStart(text: Uint8Array, offset: number): boolean { + if (offset === 0) return true; + const before = text[offset - 1]; + if (before === LF) return true; + return before === CR && text[offset] !== LF; +} + +/** The single run whose insertion into `base` yields `actual`. */ +export interface SingleInsertion { + /** Byte length of the inserted run. */ + readonly length: number; + /** Lowest admissible insertion offset into `base`. */ + readonly lowestOffset: number; + /** Highest admissible insertion offset into `base`. */ + readonly highestOffset: number; +} + +/** The accepted reading of an added import (SPEC 6.5, 2.1). */ +export interface AddedImportReading { + /** + * Insertion offset into `base` (pre-insertion coordinates), at the start + * of a line of `base`. + */ + readonly offset: number; + /** The declaration's characters (no terminator), 6.5's exact spelling. */ + readonly declaration: string; + /** The identifier the declaration binds. */ + readonly identifier: string; + /** The specifier literal's text: the canonical relative spelling. */ + readonly specifier: string; +} + +export interface AddedImportOptions { + /** Workspace-relative path of the receiving file, for diagnoses. */ + readonly rel: string; + /** Expected post-operation bytes composed WITHOUT the added import. */ + readonly base: Uint8Array; + /** The product's post-operation bytes. */ + readonly actual: Uint8Array; + /** Workspace-relative POSIX directory of the receiving file. */ + readonly importerDir: string; + /** + * Workspace-relative path of the module the specifier must designate, in + * the `.xspec` spelling an import names it by (SPEC 2.1). + */ + readonly expectedModule: string; + /** The identifier the rewritten references are rooted at. */ + readonly identifier: string; + /** + * The insertion offset into `base` the test pins, with how the diagnosis + * names it — set where the receiving file holds exactly one line-start + * admissible offset (T6.5-8's TS arm: the start of line 2, after the + * terminator ending the origin import's line). Omitted, the choice among + * line-start readings is the product's (6.5's latitude). + */ + readonly pinnedOffset?: { readonly offset: number; readonly where: string }; + /** + * The insertion offsets into `base` the test confines the run to, with + * how the diagnosis names them — set where the receiving file's + * line-start admissible offsets are several and all known (T6.5-9's code + * arm: the start of the line directly after each import declaration + * heading the file), the choice among them the product's (6.5's + * latitude). The run must read as the disciplined insertion at one of + * them. `pinnedOffset` is the one-offset case; a caller sets at most one + * of the two. + */ + readonly pinnedOffsets?: { + readonly offsets: readonly number[]; + readonly where: string; + }; +} + +function firstDifference(a: Uint8Array, b: Uint8Array): number { + const shared = Math.min(a.length, b.length); + for (let i = 0; i < shared; i += 1) { + if (a[i] !== b[i]) return i; + } + return a.length === b.length ? -1 : shared; +} + +function excerpt(bytes: Uint8Array, offset: number, width = 32): string { + return JSON.stringify( + Buffer.from(bytes.subarray(offset, offset + width)).toString("utf8"), + ); +} + +/** + * Isolate the single contiguous byte run whose insertion into `base` yields + * `actual`, failing diagnosed (H-8) when `actual` is not `base` with exactly + * one run inserted — bytes changed elsewhere, nothing inserted, or two + * separate runs. + */ +export function isolateSingleInsertion( + base: Uint8Array, + actual: Uint8Array, + context: string, +): SingleInsertion { + const length = actual.length - base.length; + if (length <= 0) { + const drift = firstDifference(base, actual); + fail( + drift === -1 + ? `${context}: no bytes were inserted — the file is byte-identical ` + + `to its expected bytes without the added import` + : `${context}: the file is ${String(actual.length)} bytes against ` + + `${String(base.length)} expected without the added import, so ` + + `no single run was inserted; the bytes diverge at offset ` + + `${String(drift)} (expected ${excerpt(base, drift)}…, actual ` + + `${excerpt(actual, drift)}…)`, + ); + } + let prefix = 0; + while (prefix < base.length && base[prefix] === actual[prefix]) prefix += 1; + let suffix = 0; + while ( + suffix < base.length && + base[base.length - 1 - suffix] === actual[actual.length - 1 - suffix] + ) { + suffix += 1; + } + const lowestOffset = Math.max(0, base.length - suffix); + const highestOffset = Math.min(prefix, base.length); + if (lowestOffset > highestOffset) { + fail( + `${context}: the file is not its expected bytes with one run ` + + `inserted — the bytes diverge from the expected bytes at offset ` + + `${String(prefix)} (expected ${excerpt(base, prefix)}…, actual ` + + `${excerpt(actual, prefix)}…) and again, counted from the end, ` + + `${String(suffix)} bytes before it: more than one edit, or an edit ` + + `outside the added import`, + ); + } + return { length, lowestOffset, highestOffset }; +} + +/** + * SPEC 6.5's canonical relative spelling of `modulePath` (workspace-relative, + * in its `.xspec` spelling) from `importerDir` (a workspace-relative POSIX + * directory; `""` or `.` for the root): the `..` ascents, then the + * descending segments, joined with `/`, no `.` segments, prefixed `./` when + * there is no ascent (SPEC 6.5, 2.1) — the specifier an added import is + * spelled with and a specifier rewrite produces. + */ +export function canonicalSpecifier( + importerDir: string, + modulePath: string, +): string { + const segments = (path: string): string[] => + path.split("/").filter((segment) => segment !== "" && segment !== "."); + const dir = segments(importerDir); + const target = segments(modulePath); + let shared = 0; + while ( + shared < dir.length && + shared < target.length - 1 && + dir[shared] === target[shared] + ) { + shared += 1; + } + const ascents = dir.length - shared; + const spelled = [ + ...Array.from({ length: ascents }, () => ".."), + ...target.slice(shared), + ].join("/"); + return ascents === 0 ? `./${spelled}` : spelled; +} + +/** The exact declaration an added import must be (SPEC 6.5). */ +interface ExpectedDeclaration { + /** `import <identifier> from "<specifier>"`. */ + readonly text: string; + readonly bytes: Uint8Array; + /** The canonical relative specifier. */ + readonly specifier: string; +} + +/** + * A default import declaration read loosely — any spacing, either quote + * style, an optional `;` — for diagnosing HOW a held declaration deviates + * from the exact spelling; never a ground of acceptance. + */ +const LOOSE_DECLARATION = + /^import([ \t]+)([A-Za-z_$][A-Za-z0-9_$]*)([ \t]+)from([ \t]+)(["'])([^"'\n\r]*)\5([ \t]*;?[ \t]*)$/; + +/** Why `held` (the run between its terminators) is not the declaration. */ +function explainDeclaration( + held: string, + options: AddedImportOptions, + expected: ExpectedDeclaration, +): string { + const shown = JSON.stringify(held); + if (held.endsWith("\r")) { + return ( + `the line holding ${shown} ends in U+000D before its U+000A — 6.5's ` + + `terminator is U+000A alone` + ); + } + if (held.includes("\n")) { + return ( + `the run holds more than one line before its final terminator, ` + + `${shown} — a spurious blank line or a second line beside the ` + + `declaration` + ); + } + const match = LOOSE_DECLARATION.exec(held); + if (match === null) { + return ( + `between its terminators the run must hold exactly the declaration ` + + `${JSON.stringify(expected.text)}; it holds ${shown}, not a default ` + + `import declaration of 2.1's form` + ); + } + const [, gap1, bound, gap2, gap3, quote, spelled, trailing] = + match as unknown as [ + string, + string, + string, + string, + string, + string, + string, + string, + ]; + const deviations: string[] = []; + if (bound !== options.identifier) { + deviations.push( + `it binds ${JSON.stringify(bound)} but the rewritten references are ` + + `rooted at ${JSON.stringify(options.identifier)}`, + ); + } + if (gap1 !== " " || gap2 !== " " || gap3 !== " ") { + deviations.push("its spacing is not the single spaces 6.5 shows"); + } + if (quote !== '"') { + deviations.push( + "its specifier is single-quoted where 6.5 double-quotes it", + ); + } + if (trailing.includes(";")) { + deviations.push( + "it carries a statement terminator, which 6.5 spells none of", + ); + } else if (trailing !== "") { + deviations.push("it carries trailing whitespace"); + } + if (spelled !== expected.specifier) { + const relative = spelled.startsWith("./") || spelled.startsWith("../"); + const importerDir = options.importerDir === "" ? "." : options.importerDir; + const resolved = posixPath.join(importerDir, spelled); + if (!relative || !spelled.endsWith(".xspec")) { + deviations.push( + `its specifier ${JSON.stringify(spelled)} is not a relative path ` + + `beginning with \`./\` or \`../\` and ending in \`.xspec\` (2.1)`, + ); + } else if (resolved !== options.expectedModule) { + deviations.push( + `its specifier ${JSON.stringify(spelled)}, resolved against ` + + `${importerDir}/, designates ${resolved} rather than ` + + `${options.expectedModule}`, + ); + } else { + deviations.push( + `its specifier ${JSON.stringify(spelled)} designates ` + + `${options.expectedModule} but is not the canonical relative ` + + `spelling ${JSON.stringify(expected.specifier)} (6.5: the \`..\` ` + + `ascents, then the descending segments, no \`.\` segments, ` + + `prefixed \`./\` when there is no ascent)`, + ); + } + } + if (deviations.length === 0) deviations.push("it differs in its characters"); + return ( + `the declaration must be byte-exactly ${JSON.stringify(expected.text)} ` + + `(6.5's spelling: single spaces, no statement terminator, the specifier ` + + `double-quoted in its canonical relative spelling); it is ${shown}: ` + + deviations.join("; ") + ); +} + +/** + * Read one admissible offset as the disciplined added import; a reason + * when it does not read as one. + */ +function readInsertion( + options: AddedImportOptions, + offset: number, + length: number, + expected: ExpectedDeclaration, +): { reading: AddedImportReading } | { reason: string } { + const { base, actual, identifier } = options; + if (!atLineStart(base, offset)) { + return { + reason: + actual[offset] === LF + ? "the offset is not at the start of a line of the composed text " + + "and the run is the mid-line form (U+000A, the declaration, " + + "U+000A), but 6.5 takes a line-start admissible offset, which " + + "the receiving file holds, over any other (T6.5-8)" + : "the offset is not at the start of a line of the composed text " + + "and the run begins with no U+000A, so the inserted characters " + + "join the preceding line — a declaration joined to its " + + "neighbour, or a mid-line offset without its preceding " + + "terminator", + }; + } + const run = actual.subarray(offset, offset + length); + if (run.length === 0 || run[run.length - 1] !== LF) { + return { reason: "the run must end with a U+000A line terminator" }; + } + if (offset > 0 && base[offset - 1] === CR && run[0] === LF) { + return { + reason: + "the offset follows a lone U+000D, which ends a line (SPEC 3), so " + + "it is at the start of a line, yet the run begins with U+000A — " + + "the mid-line form a product judging line starts by U+000A alone " + + "writes, that U+000A joining the lone U+000D into one CRLF " + + "terminator (T6.5-8)", + }; + } + const held = run.subarray(0, run.length - 1); + if (Buffer.compare(held, expected.bytes) !== 0) { + return { + reason: explainDeclaration( + Buffer.from(held).toString("utf8"), + options, + expected, + ), + }; + } + return { + reading: { + offset, + declaration: expected.text, + identifier, + specifier: expected.specifier, + }, + }; +} + +/** + * Assert that `actual` is `base` with exactly one import declaration + * inserted under SPEC 6.5's spelling and line discipline: byte-exactly + * `import <identifier> from "<specifier>"` — single spaces, no statement + * terminator, the specifier double-quoted in the canonical relative + * spelling of `expectedModule` from `importerDir` — followed by U+000A, at + * an offset lying at the start of a line of `base` (a line-start + * admissible offset, which the receiving file holds, is taken over any + * other), no other byte inserted. The identifier is the one the caller + * read off the rewritten references, so its value stays the product's + * (6.5's latitude); the offset is the product's among the line-start + * readings (line starts judged by 3's terminators, `atLineStart`), unless + * the caller pins it (`pinnedOffset`: the receiving file's one line-start + * admissible offset), when the run must read as the disciplined insertion + * at exactly that offset, or confines it (`pinnedOffsets`: the receiving + * file's several line-start admissible offsets), when the run must read as + * the disciplined insertion at one of them. The accepted reading is + * returned. + */ +export function assertAddedImportInsertion( + options: AddedImportOptions, + context: string, +): AddedImportReading { + const label = `${context}: ${options.rel}`; + const pins = pinnedSet(options, label); + const insertion = isolateSingleInsertion(options.base, options.actual, label); + const specifier = canonicalSpecifier( + options.importerDir, + options.expectedModule, + ); + const text = `import ${options.identifier} from "${specifier}"`; + const expected: ExpectedDeclaration = { + text, + bytes: Buffer.from(text, "utf8"), + specifier, + }; + // Consecutive offsets sharing one reason are reported as a range. + const reasons: { from: number; to: number; reason: string }[] = []; + let accepted: AddedImportReading | undefined; + for ( + let offset = insertion.highestOffset; + offset >= insertion.lowestOffset; + offset -= 1 + ) { + const read = readInsertion(options, offset, insertion.length, expected); + if ("reading" in read) { + accepted = read.reading; + break; + } + const last = reasons[reasons.length - 1]; + if (last !== undefined && last.reason === read.reason) last.to = offset; + else reasons.push({ from: offset, to: offset, reason: read.reason }); + } + const pinnedNote = + pins === undefined + ? "" + : pins.one + ? ` (the test pins the insertion at ${pins.where}, offset ` + + `${String(pins.offsets[0])} of the composed text)` + : ` (the test confines the insertion to ${pins.where}: ` + + `${describeOffsets(options.base, pins.offsets)} of the composed ` + + `text)`; + if (accepted === undefined) { + const shown = options.actual.subarray( + insertion.highestOffset, + insertion.highestOffset + insertion.length, + ); + fail( + `${label} — the single inserted run ` + + `${JSON.stringify(Buffer.from(shown).toString("utf8"))} is not the ` + + `added import ${JSON.stringify(text)} followed by U+000A at a ` + + `line-start offset (6.5's spelling and line discipline, T6.5-8: ` + + `single spaces, no statement terminator, the specifier ` + + `double-quoted in its canonical relative spelling from ` + + `${options.importerDir || "."}/, the declaration's characters then ` + + `U+000A at a line-start admissible offset, which the receiving file ` + + `holds and 6.5 takes over any other, line starts judged by 3's ` + + `terminators; no other byte inserted — SPEC 6.5, 2.1)${pinnedNote} ` + + `under any admissible reading: ` + + reasons + .map(({ from, to, reason }) => + from === to + ? `at offset ${String(from)}: ${reason}` + : `at offsets ${String(to)}–${String(from)}: ${reason}`, + ) + .join("; "), + ); + } + if (pins === undefined) return accepted; + // Bytes are the only observable: a pin holds exactly when the run read at + // one of its offsets is the disciplined insertion. + for (const offset of pins.offsets) { + if (offset < insertion.lowestOffset || offset > insertion.highestOffset) { + continue; + } + const read = readInsertion(options, offset, insertion.length, expected); + if ("reading" in read) return read.reading; + } + fail( + `${label} — the added import ${JSON.stringify(text)} followed by ` + + `U+000A stands at offset ${String(accepted.offset)}, the start of ` + + `line ${String(lineAt(options.base, accepted.offset))} of the composed ` + + `text, not at ${pins.where} ` + + (pins.one + ? `(offset ${String(pins.offsets[0])}), the one line-start ` + + `admissible offset the receiving file holds` + : `(${describeOffsets(options.base, pins.offsets)}), the line-start ` + + `admissible offsets the receiving file holds`) + + ` — 6.5 takes a line-start admissible offset over any other, and an ` + + `offset elsewhere is not admissible there (SPEC 6.5, 3)`, + ); +} + +/** A caller's pin as one set of offsets (`one`: the `pinnedOffset` form). */ +interface PinnedSet { + readonly offsets: readonly number[]; + readonly where: string; + readonly one: boolean; +} + +/** + * The caller's pin, `pinnedOffset` or `pinnedOffsets`, as one set; throws + * (a harness defect, never a product verdict) when both are set or the set + * is empty. + */ +function pinnedSet( + options: AddedImportOptions, + label: string, +): PinnedSet | undefined { + const { pinnedOffset, pinnedOffsets } = options; + if (pinnedOffset !== undefined && pinnedOffsets !== undefined) { + throw new Error( + `${label}: a caller of assertAddedImportInsertion sets pinnedOffset ` + + "or pinnedOffsets, never both (a harness defect)", + ); + } + if (pinnedOffset !== undefined) { + return { + offsets: [pinnedOffset.offset], + where: pinnedOffset.where, + one: true, + }; + } + if (pinnedOffsets === undefined) return undefined; + if (pinnedOffsets.offsets.length === 0) { + throw new Error( + `${label}: pinnedOffsets names no offset — a confining set holds at ` + + "least one (a harness defect)", + ); + } + return { + offsets: [...pinnedOffsets.offsets], + where: pinnedOffsets.where, + one: false, + }; +} + +/** Offsets of `text` named with their 1-based lines, for diagnoses. */ +function describeOffsets(text: Uint8Array, offsets: readonly number[]): string { + return ( + "offsets " + + offsets + .map( + (offset) => `${String(offset)} (line ${String(lineAt(text, offset))})`, + ) + .join(", ") + ); +} + +/** + * The 1-based line of `text` that `offset` lies on, lines counted by SPEC + * 3's terminators (as `atLineStart` judges them), for diagnoses. + */ +function lineAt(text: Uint8Array, offset: number): number { + let line = 1; + for (let i = 0; i < offset; i += 1) { + if (text[i] === LF || (text[i] === CR && text[i + 1] !== LF)) line += 1; + } + return line; +} + +/** Options for {@link assertExactDeclarationInsertion}. */ +export interface ExactInsertionOptions { + /** Workspace-relative path of the receiving file, for diagnoses. */ + readonly rel: string; + /** Expected post-operation bytes composed WITHOUT the added import. */ + readonly base: Uint8Array; + /** The product's post-operation bytes. */ + readonly actual: Uint8Array; + /** The added declaration's exact characters, no terminator (SPEC 6.5). */ + readonly declaration: string; +} + +/** One admissible reading of an exactly spelled added declaration. */ +export interface ExactInsertionReading { + /** Insertion offset into `base` (pre-insertion coordinates). */ + readonly offset: number; + /** Whether that offset lies at the start of a line of `base` (`atLineStart`). */ + readonly atLineStart: boolean; +} + +/** + * Read one admissible offset as the exactly spelled declaration under 6.5's + * line discipline; `null` with a reason when it does not read as one. + */ +function readExactInsertion( + options: ExactInsertionOptions, + offset: number, + length: number, +): { reading: ExactInsertionReading } | { reason: string } { + const { base, actual, declaration } = options; + const run = actual.subarray(offset, offset + length); + const lineStart = atLineStart(base, offset); + let body = run; + if (!lineStart) { + if (run[0] !== LF) { + return { + reason: + "the offset is not at the start of a line, so the run must begin " + + "with the preceding U+000A terminator", + }; + } + body = run.subarray(1); + } + if (body.length === 0 || body[body.length - 1] !== LF) { + return { reason: "the run must end with a U+000A line terminator" }; + } + const held = body.subarray(0, body.length - 1); + if (Buffer.compare(held, Buffer.from(declaration, "utf8")) !== 0) { + return { + reason: + `between its terminators the run must hold exactly the declaration ` + + `${JSON.stringify(declaration)}; it holds ` + + `${JSON.stringify(Buffer.from(held).toString("utf8"))}`, + }; + } + return { reading: { offset, atLineStart: lineStart } }; +} + +/** + * Assert that `actual` is `base` with exactly one declaration inserted + * under SPEC 6.5's line discipline — the declaration's characters followed + * by U+000A at an offset lying at the start of a line, and U+000A, the + * declaration, then U+000A at one that does not — the declaration + * byte-exactly `declaration` (6.5's spelling rule: single spaces as + * shown, no statement terminator, the specifier double-quoted in its + * canonical spelling — the caller composes it with the fresh identifiers + * substituted), no other byte inserted. Bytes are the only observable, so + * every admissible offset that reads as such an insertion is returned + * (several describe one byte string where the run's terminators repeat the + * bytes beside it); the offset among them is the product's. + */ +export function assertExactDeclarationInsertion( + options: ExactInsertionOptions, + context: string, +): readonly ExactInsertionReading[] { + const label = `${context}: ${options.rel}`; + const insertion = isolateSingleInsertion(options.base, options.actual, label); + const readings: ExactInsertionReading[] = []; + const reasons: string[] = []; + for ( + let offset = insertion.highestOffset; + offset >= insertion.lowestOffset; + offset -= 1 + ) { + const read = readExactInsertion(options, offset, insertion.length); + if ("reading" in read) readings.push(read.reading); + else reasons.push(`at offset ${String(offset)}: ${read.reason}`); + } + if (readings.length > 0) return readings; + const shown = options.actual.subarray( + insertion.highestOffset, + insertion.highestOffset + insertion.length, + ); + fail( + `${label} — the single inserted run ` + + `${JSON.stringify(Buffer.from(shown).toString("utf8"))} is not the ` + + `added declaration ${JSON.stringify(options.declaration)} under 6.5's ` + + `line discipline (the declaration's characters followed by U+000A, ` + + `preceded by one when the insertion point is not at the start of a ` + + `line; no other byte inserted — SPEC 6.5, 2.1) under any admissible ` + + `reading: ${reasons.join("; ")}`, + ); +} diff --git a/test/helpers/mdx-derivability.ts b/test/helpers/mdx-derivability.ts new file mode 100644 index 00000000..3dc327d1 --- /dev/null +++ b/test/helpers/mdx-derivability.ts @@ -0,0 +1,924 @@ +// TEST-SPEC S-9's derivability check: whether an MDX source derives under +// the grammar SPEC 14.20 fixes — MDX syntax at major version 3, decided by +// derivability alone. Harness machinery only: no product imports, no I/O, +// no test-framework dependence; the workspace builder and the property +// runner judge every staged fixture and every generator draw through it, +// and its self-test (test/self/s9-fixture-well-formedness.test.ts) holds +// the document's worked well-formed and declared-unparseable shapes. +// +// The check is made by a means independent of the product (S-9): the stock +// MDX 3 parser declared as the harness's own devDependencies — `fromMarkdown` +// with the `mdxjs()` micromark extension and the `mdxFromMarkdown()` mdast +// extension. JSX tag matching lives in the mdast layer, so the full parse is +// what decides, never the tokenizer alone (which accepts an unclosed or +// mismatched tag). The extension parses expressions and ESM blocks with +// acorn at ecmaVersion 2024 in module mode, the edition 14.20 names; its +// `acorn` option (default `Parser.extend(acornJsx())`, from the `acorn` and +// `acorn-jsx` packages the extension itself depends on) is where the one +// rule beyond derivability the tool applies is taken out, below. +// +// Two rules the parser does not apply are 14.20's: a source that is not +// valid UTF-8, or that begins with a byte-order mark, is unparseable (SPEC +// 1.6, 14.20), so bytes are decoded with `fatal: true` and `ignoreBOM: true` +// and both are non-derivations here — the stock parser itself accepts a +// leading U+FEFF. +// +// The rule the tool applies beyond derivability is ECMAScript's static- +// semantic early errors, which acorn enforces while 14.20 admits the forms +// (a file failing only such a rule is well-formed and proceeds to its +// ordinary finding). S-9 lists the harness's known allowances — two imports +// binding one identifier, `export { nope }`, `{1 = 2}`, `{let}`, `{010}` — +// and the caller names the one a staging relies on. An allowance is applied +// inside the parse, not after it: the extension is given an acorn parser +// whose `raise`/`raiseRecoverable` swallow exactly the named early errors, +// matched on acorn's own message (and, for the legacy octal, the literal at +// acorn's position, since "Invalid number" is also a lexical failure's +// message), so that the parse continues — acorn's code after those raises is +// continuable, `raiseRecoverable` existing for that — and any later +// rejection in the file still surfaces. Hence any other rejection, any +// rejection under an unnamed allowance, and every MDX-syntax rejection are +// non-derivations, wherever in the file they stand. The stock parser judges +// all of a file's ESM blocks as one module, so the duplicate-binding early +// error is raised for a second import in another block exactly as for one +// in the same block; 14.20 admits both spellings (the 2.1 collisions +// "within one ESM block or across blocks" are findings in a well-formed +// file), so `duplicate-import-binding` covers both. +// +// The Unicode version is 14.20's, never the tool's (S-9): ECMAScript 2024 +// takes its identifier characters — an expression's and a JSX name's alike — +// and its space separators from Unicode 15.1, judged code point by code +// point. The stock tools judge otherwise, and are corrected here: acorn 8.17's +// identifier tables are Unicode 17's (U+1C89, a Unicode 16 letter, begins an +// identifier there); micromark-extension-mdx-jsx judges a JSX name one UTF-16 +// code unit at a time, so no astral character (U+2EBF0, which 15.1 added) +// enters a name, and by the runtime's tables otherwise (Unicode 17 on Node +// 22, U+1C89 again); acorn-jsx reads a JSX name inside an expression one code +// unit at a time too. Unicode 15.1's identifier characters are TypeScript +// 5.9.3's ESNext identifier tables (the harness's own `typescript-5.9.3`), +// equal to 15.1's ID_Start and ID_Continue code point for code point (checked, +// when this check was written, against both properties derived from Unicode +// 15.1's character database: its general categories, Other_ID_Start, +// Other_ID_Continue, Pattern_Syntax, and Pattern_White_Space; the self-test +// pins the version-boundary code points). acorn's identifiers are held to +// them as tokens finish (`Unicode151Parser`), the MDX tokenizer's JSX names +// by an adapter presenting each code point to it as 15.1 classes it +// (`withUnicode151Jsx`). Space separators: acorn's are a fixed list, 15.1's +// (the self-test checks it); the JSX adapter presents in-tag whitespace by +// 15.1; and the empty-expression judgement of micromark-util-events-to-acorn +// reads the runtime's `\s`, so the runtime's class is checked to be 15.1's +// before any judgement (`checkRuntimeWhitespace`). + +import { Parser, tokTypes } from "acorn"; +import type { Program } from "acorn"; +import acornJsx from "acorn-jsx"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { mdxFromMarkdown } from "mdast-util-mdx"; +import { mdxjs } from "micromark-extension-mdxjs"; +import ts from "typescript-5.9.3"; + +/** S-9's named allowances — ECMAScript early errors 14.20 admits. */ +export const MDX_ALLOWANCES = [ + // Two imports binding one identifier (T2.1-3, T4.5-8; SPEC 14.15). + "duplicate-import-binding", + // `export { nope }` — an export naming no declaration (T14-12). + "undefined-export", + // `{1 = 2}` — an assignment to a target that is not simple (T14-12). + "invalid-assignment-target", + // `{let}` — `let` as an identifier reference, a strict-mode restriction. + "let-as-identifier", + // `{010}` — a legacy octal literal, a strict-mode restriction (T14-12). + "legacy-octal", +] as const; + +export type MdxAllowance = (typeof MDX_ALLOWANCES)[number]; + +/** Where a rejection lies: the parser's 1-based line and column, and the + * 0-based offset — a byte offset for a decoding failure, and for a parser + * rejection an index into the decoded text as the parser counts it (UTF-16 + * code units, JavaScript string indices). */ +export interface MdxPosition { + readonly line: number; + readonly column: number; + readonly offset: number; +} + +export type MdxVerdict = + | { readonly derives: true } + | { + readonly derives: false; + readonly reason: string; + readonly position?: MdxPosition; + }; + +export interface DeriveMdxOptions { + /** The early errors this source is declared to rely on; nothing else. */ + readonly allowances?: readonly MdxAllowance[]; +} + +interface AllowanceRule { + /** The early error belongs to a program parse — an ESM block — alone. */ + readonly programOnly: boolean; + /** acorn's message for exactly this early error (before its position suffix). */ + readonly message: RegExp; + /** A further test on acorn's input at the raise position where the message is shared. */ + readonly refine?: (input: string, pos: number) => boolean; +} + +const ALLOWANCE_RULES: Readonly<Record<MdxAllowance, AllowanceRule>> = { + "duplicate-import-binding": { + programOnly: true, + message: /^Identifier '.+' has already been declared$/, + }, + "undefined-export": { + programOnly: true, + message: /^Export '.+' is not defined$/, + }, + "invalid-assignment-target": { + programOnly: false, + message: /^Assigning to rvalue$/, + }, + "let-as-identifier": { + programOnly: false, + message: /^The keyword 'let' is reserved$/, + }, + "legacy-octal": { + programOnly: false, + // acorn raises "Invalid number" at a numeric literal's start both for a + // legacy numeric literal in strict mode (a `0` followed by digits) and + // for a lexically malformed number (`1e`): only the former is admitted. + message: /^Invalid number$/, + refine: (input, pos) => /^0[0-9]/.test(input.slice(pos, pos + 2)), + }, +}; + +/** + * The early errors `readMdxTree` admits beyond S-9's named allowances, for a + * file no fixture declares — one a product wrote, whose added imports + * T6.5-22(a) reads (helpers/added-import-identifiers.ts) through S-6's name + * analysis: ECMAScript 2024's static-semantic errors on an identifier its + * grammar derives as a binding or a reference, each naming a word SPEC 6.5 + * bars as an added import's identifier — those strict mode code admits as no + * binding (`let`, `static`, `implements`, `interface`, `package`, `private`, + * `protected`, `public`, `yield`, `eval`, `arguments`) and `await` in module + * code, where `BindingIdentifier : await` derives with its early error — so + * that `import let from "./let.xspec"`, which 14.20 makes well-formed + * (T6.5-22), reads. Never `enum` or another reserved word, which no binding + * derives: acorn reports those under the same messages, so each pattern + * names its words, and an `await` the grammar cannot take as an identifier + * (inside an async function, or an operand-less `await` expression) meets + * other messages. They are no S-9 allowance: S-9's list is exactly its five, + * and `deriveMdx`, judging the fixtures S-9 judges, never admits these. + */ +const READING_RULES: readonly AllowanceRule[] = [ + { + programOnly: false, + message: + /^The keyword '(?:implements|interface|let|package|private|protected|public|static|yield|await)' is reserved$/, + }, + { + programOnly: false, + message: + /^Binding (?:implements|interface|let|package|private|protected|public|static|yield|await|eval|arguments) in strict mode$/, + }, + { + programOnly: false, + message: /^Cannot use keyword 'await' outside an async function$/, + }, + { + programOnly: false, + message: /^let is disallowed as a lexically bound name$/, + }, +]; + +// --------------------------------------------------------------------------- +// Unicode 15.1's identifier characters and whitespace (SPEC 14.20, S-9). + +const ESNEXT = ts.ScriptTarget.ESNext; + +/** ECMAScript 2024's IdentifierStartChar under Unicode 15.1: ID_Start, `$`, + * and `_` — TypeScript 5.9.3's ESNext table, 15.1's code point for code + * point. */ +function isIdentifierStart151(code: number): boolean { + return ts.isIdentifierStart(code, ESNEXT); +} + +/** IdentifierPartChar under Unicode 15.1: ID_Continue (U+200C and U+200D + * among it since 15.1) and `$`. */ +function isIdentifierPart151(code: number): boolean { + return ts.isIdentifierPart(code, ESNEXT); +} + +/** ECMAScript 2024's WhiteSpace and LineTerminator under Unicode 15.1 — TAB, + * VT, FF, ZWNBSP, 15.1's space separators (general category Zs), LF, CR, LS, + * and PS: what the grammar skips between tokens and what `\s` matches. */ +const WHITESPACE_151: ReadonlySet<number> = new Set([ + 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x0020, 0x00a0, 0x1680, 0x2000, + 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, + 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff, +]); + +function isWhitespace151(code: number): boolean { + return WHITESPACE_151.has(code); +} + +let runtimeWhitespaceChecked = false; + +/** + * micromark-util-events-to-acorn judges an empty expression (an MDX comment) + * and the content after an expression's one expression with the runtime's + * `\s`, whose space separators are the runtime's Unicode version's: the + * judgement is 15.1's only where that class is 15.1's, so it is checked once, + * code unit by code unit (the test is a UTF-16 one), before any judgement — a + * runtime whose class differs makes every judgement a harness error, never a + * verdict under another Unicode version. + */ +function checkRuntimeWhitespace(): void { + if (runtimeWhitespaceChecked) return; + const whitespace = /\s/; + for (let code = 0; code <= 0xffff; code++) { + if (whitespace.test(String.fromCharCode(code)) !== isWhitespace151(code)) { + throw new Error( + `S-9's MDX check: this runtime's whitespace class differs from Unicode 15.1's at ${codePointName(code)}, and the stock parser's empty-expression judgement reads it (SPEC 14.20)`, + ); + } + } + runtimeWhitespaceChecked = true; +} + +function codePointName(code: number): string { + return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`; +} + +function codePointLength(code: number): number { + return code > 0xffff ? 2 : 1; +} + +/** The first code point of an identifier's value that Unicode 15.1 does not + * admit where it stands (a JSX name admits `-` after its first), with its + * UTF-16 index, or undefined. */ +function firstInadmissible( + value: string, + jsx: boolean, +): { readonly index: number; readonly code: number } | undefined { + let index = 0; + while (index < value.length) { + const code = value.codePointAt(index) as number; + const admitted = + index === 0 + ? isIdentifierStart151(code) + : isIdentifierPart151(code) || (jsx && code === 0x2d); + if (!admitted) return { index, code }; + index += codePointLength(code); + } + return undefined; +} + +// --------------------------------------------------------------------------- +// The parser the extension parses expressions and ESM blocks with. + +// The stock extension's parser: acorn with JSX, as `mdxjs()` builds it. +const JsxParser = Parser.extend(acornJsx()); + +/** acorn's regular-expression validation state, as the overrides read it. */ +interface RegExpState { + pos: number; + lastIntValue: number; +} + +/** acorn's tokenizer state, as the overrides read and set it. */ +interface TokenizerState { + pos: number; + readonly start: number; + readonly input: string; + finishToken(type: unknown, value?: unknown): void; + raise(pos: number, message: string): never; +} + +type Raise = (this: Parser, pos: number, message: string) => never; +type FinishToken = (this: Parser, type: unknown, value?: unknown) => void; +type EatIdentifierCharacter = (this: Parser, state: RegExpState) => boolean; +// acorn's tokenizer and validator methods are prototype members its +// declarations leave out. +const BASE = JsxParser.prototype as unknown as { + readonly raise: Raise; + readonly raiseRecoverable: Raise; + readonly finishToken: FinishToken; + readonly regexp_eatRegExpIdentifierStart: EatIdentifierCharacter; + readonly regexp_eatRegExpIdentifierPart: EatIdentifierCharacter; +}; + +// acorn-jsx's JSX name token type (built on the parser's own acorn, the one +// `Parser` and `tokTypes` come from). +const JSX_NAME: unknown = ( + JsxParser as unknown as { + readonly acornJsx: { readonly tokTypes: { readonly jsxName: unknown } }; + } +).acornJsx.tokTypes.jsxName; + +/** + * The stock parser with its identifier characters Unicode 15.1's. acorn reads + * an identifier code point by code point by its own tables, Unicode 17's — a + * superset of 15.1's, as Unicode's identifier stability makes every later + * version's (the self-test checks it code point by code point) — so every + * text 15.1 admits tokenizes as before; every identifier token is then held + * to 15.1 as it finishes — a name (its escapes decoded), a private name, a + * JSX name — a code point 15.1 does not admit where it stands being the parse + * failure 15.1's tables make of it, raised at that code point as acorn raises + * an unexpected character. acorn-jsx reads a JSX name one UTF-16 code unit at + * a time, so no astral character enters one; it is read here code point by + * code point. A regular expression's group names (`(?<name>…)`, `\k<name>`) + * are identifier names of the pattern grammar, held to 15.1 alike. + */ +class Unicode151Parser extends JsxParser { + finishToken(type: unknown, value?: unknown): void { + if ( + typeof value === "string" && + (type === tokTypes.name || + type === tokTypes.privateId || + type === JSX_NAME) + ) { + const jsx = type === JSX_NAME; + const inadmissible = firstInadmissible(value, jsx); + if (inadmissible !== undefined) { + const tokenizer = this as unknown as TokenizerState; + // Located in the raw spelling (a private name's follows its `#`); + // a name spelled with an escape sequence, at its start. + const rawStart = + tokenizer.start + (type === tokTypes.privateId ? 1 : 0); + const raw = tokenizer.input.slice(rawStart, tokenizer.pos); + const at = + raw === value ? rawStart + inadmissible.index : tokenizer.start; + tokenizer.pos = at; + tokenizer.raise( + at, + `Unexpected character '${String.fromCodePoint(inadmissible.code)}' (${codePointName(inadmissible.code)}): Unicode 15.1 does not admit it to ${inadmissible.index === 0 ? "begin" : "continue"} ${jsx ? "a JSX name" : "an identifier"} (SPEC 14.20)`, + ); + } + } + BASE.finishToken.call(this, type, value); + } + + // acorn-jsx's tag-context `readToken` has judged the first code point an + // identifier start (acorn's tables, at the full code point); the rest are + // 15.1's identifier parts and `-`, read code point by code point, and + // `finishToken` holds the first to 15.1. + jsx_readWord(): void { + const tokenizer = this as unknown as TokenizerState; + const { input } = tokenizer; + const start = tokenizer.pos; + let pos = start + codePointLength(input.codePointAt(start) as number); + for (;;) { + const code = input.codePointAt(pos); + if (code === undefined) break; + if (code !== 0x2d && !isIdentifierPart151(code)) break; + pos += codePointLength(code); + } + tokenizer.pos = pos; + tokenizer.finishToken(JSX_NAME, input.slice(start, pos)); + } + + regexp_eatRegExpIdentifierStart(state: RegExpState): boolean { + return eatHeldTo151( + this, + state, + BASE.regexp_eatRegExpIdentifierStart, + isIdentifierStart151, + ); + } + + regexp_eatRegExpIdentifierPart(state: RegExpState): boolean { + return eatHeldTo151( + this, + state, + BASE.regexp_eatRegExpIdentifierPart, + isIdentifierPart151, + ); + } +} + +/** acorn's group-name character reader, its character held to 15.1. */ +function eatHeldTo151( + parser: Parser, + state: RegExpState, + eat: EatIdentifierCharacter, + admits: (code: number) => boolean, +): boolean { + const start = state.pos; + if (!eat.call(parser, state)) return false; + if (admits(state.lastIntValue)) return true; + state.pos = start; + return false; +} + +/** The stock parser, its identifiers 15.1's, tolerating exactly the named + * early errors. */ +function lenientParser( + allowances: readonly MdxAllowance[], + reading: boolean, +): typeof Parser { + const rules = [ + ...allowances.map((name) => ALLOWANCE_RULES[name]), + ...(reading ? READING_RULES : []), + ]; + return class LenientParser extends Unicode151Parser { + // Set by the program parse an ESM block gets; an expression parse + // (`parseExpressionAt`) never calls `parse()`. + private program = false; + + override parse(): Program { + this.program = true; + return super.parse(); + } + + private tolerates(pos: number, message: string): boolean { + return rules.some( + (rule) => + (!rule.programOnly || this.program) && + rule.message.test(message) && + (rule.refine === undefined || rule.refine(this.input, pos)), + ); + } + + raise(pos: number, message: string): void { + if (!this.tolerates(pos, message)) BASE.raise.call(this, pos, message); + } + + raiseRecoverable(pos: number, message: string): void { + if (!this.tolerates(pos, message)) + BASE.raiseRecoverable.call(this, pos, message); + } + }; +} + +// --------------------------------------------------------------------------- +// JSX names in the MDX tokenizer, by Unicode 15.1, code point by code point. + +// micromark's types, read off the extension's own (the package declaring them +// is a transitive dependency, not one the harness declares). +type MdxExtension = ReturnType<typeof mdxjs>; +type ConstructRecord = NonNullable<MdxExtension["flow"]>; +type Construct = Exclude< + NonNullable<ConstructRecord[string]>, + readonly unknown[] +>; +type Tokenizer = Construct["tokenize"]; +type Effects = Parameters<Tokenizer>[0]; +type State = Parameters<Tokenizer>[1]; +type Code = Parameters<State>[0]; + +/** The text `fromMarkdown` is reading, while it reads it. */ +let parsedText: string | undefined; + +// Stand-ins the stock tag tokenizer classes, under every Unicode version, as +// 15.1 classes the code points they stand for. +/** ª (Lo): begins and continues a name. */ +const STAND_IN_START = 0x00aa; +/** A combining grave accent (Mn): continues a name, begins none. */ +const STAND_IN_PART = 0x0300; +/** NO-BREAK SPACE (Zs): ECMAScript whitespace. */ +const STAND_IN_SPACE = 0x00a0; +/** ¶ (Po): none of these. */ +const STAND_IN_OTHER = 0x00b6; + +function standIn(code: number): number { + if (isWhitespace151(code)) return STAND_IN_SPACE; + if (isIdentifierStart151(code)) return STAND_IN_START; + if (isIdentifierPart151(code)) return STAND_IN_PART; + return STAND_IN_OTHER; +} + +/** + * The extension with its JSX tag constructs reading Unicode 15.1. Inside a + * tag — its token open — micromark-extension-mdx-jsx sees each non-ASCII code + * point as a stand-in of the class 15.1 gives it (a name's start, a name's + * part, whitespace, or none), an astral one whole: the tokenizer hands its + * UTF-16 code units over one at a time, so its code point is read from the + * text at micromark's offset, the stand-in shown for the first unit, and the + * second consumed after it. A stand-in decides nothing but the tag grammar's + * class tests: what the tag tokenizer consumes is always the actual code + * unit, and every token's text — names, values, the expressions handed to + * acorn — is the source's. Outside a tag (the flow construct's tail, and the + * expression it may attempt there) every code passes as it is. + */ +function withUnicode151Jsx(extension: MdxExtension): MdxExtension { + return { + ...extension, + flow: adaptJsxRecord(extension.flow, "mdxJsxFlowTag"), + text: adaptJsxRecord(extension.text, "mdxJsxTextTag"), + }; +} + +function adaptJsxRecord( + record: ConstructRecord | undefined, + name: string, +): ConstructRecord { + const constructs = record?.[60]; + const list = + constructs === undefined + ? [] + : Array.isArray(constructs) + ? constructs + : [constructs]; + const construct = list[0]; + if (record === undefined || list.length !== 1 || construct?.name !== name) { + throw new Error( + `S-9's MDX check: the stock extension's \`<\` constructs are not the one ${name} its Unicode 15.1 JSX reader adapts`, + ); + } + return { ...record, 60: [adaptJsxConstruct(construct)] }; +} + +function adaptJsxConstruct(construct: Construct): Construct { + const tagType = construct.name; + const tokenize = construct.tokenize; + return { + ...construct, + tokenize(effects, ok, nok) { + const context = this; + // Control has left the construct: its `ok` or `nok` ran. + let left = false; + // A tag token is open: its codes are presented by 15.1. + let inTag = false; + // The code the tokenizer handed over, the one a consume consumes. + let current: Code = null; + const leaving = + (continuation: State): State => + (code) => { + left = true; + return continuation(code); + }; + const okLeaving = leaving(ok); + const nokLeaving = leaving(nok); + const presenting: Effects = { + ...effects, + consume: () => effects.consume(current), + enter: (type, fields) => { + if (type === tagType) inTag = true; + return effects.enter(type, fields); + }, + exit: (type) => { + if (type === tagType) inTag = false; + return effects.exit(type); + }, + }; + return adapt(tokenize.call(context, presenting, okLeaving, nokLeaving)); + + function adapt(state: State): State { + return (code) => { + current = code; + let shown: Code = code; + let actual = code; + let pair = false; + if (inTag && code !== null && code >= 0x80) { + if (code >= 0xd800 && code <= 0xdbff) { + actual = pairedCodePoint(context.now().offset, code); + pair = true; + } + shown = standIn(actual as number); + } + let next: State | undefined; + try { + next = state(shown); + } catch (error) { + if (shown !== actual) { + restoreCharacter(error, shown as number, actual as number); + } + throw error; + } + if ( + left || + next === undefined || + next === okLeaving || + next === nokLeaving + ) { + return next; + } + return pair ? lowSurrogate(next) : adapt(next); + }; + } + + // The second code unit of a pair whose code point the tag tokenizer + // took whole: consumed into the token the first went to. + function lowSurrogate(next: State): State { + return (code) => { + if (code === null || code < 0xdc00 || code > 0xdfff) { + throw new Error( + "S-9's MDX check: a surrogate pair's second code unit did not follow its first (a harness defect in its Unicode 15.1 JSX reader)", + ); + } + current = code; + effects.consume(code); + return adapt(next); + }; + } + }, + }; +} + +/** The code point of the surrogate pair `high` begins at `offset` of the + * text being read (micromark's offsets index it in UTF-16 code units). */ +function pairedCodePoint(offset: number, high: number): number { + const text = parsedText; + const code = text?.codePointAt(offset); + if ( + text === undefined || + text.charCodeAt(offset) !== high || + code === undefined || + code <= 0xffff + ) { + throw new Error( + `S-9's MDX check: no surrogate pair begins at offset ${offset} of the text being read (a harness defect in its Unicode 15.1 JSX reader)`, + ); + } + return code; +} + +/** Puts the character a stand-in was shown for back into the stock tag + * tokenizer's report of it. */ +function restoreCharacter(error: unknown, shown: number, actual: number): void { + if (!isParserMessage(error)) return; + const report = error as unknown as { reason: string; message: string }; + const from = `\`${String.fromCodePoint(shown)}\` (${codePointName(shown)})`; + const to = `\`${String.fromCodePoint(actual)}\` (${codePointName(actual)}, judged by Unicode 15.1)`; + report.reason = report.reason.split(from).join(to); + report.message = report.message.split(from).join(to); +} + +// One extension set per distinct allowance set (the extension captures its +// parser); `fromMarkdown` builds a fresh tokenizer per call. +const EXTENSIONS = new Map<string, MdxExtension>(); +const MDAST_EXTENSIONS = [mdxFromMarkdown()]; + +function extensionsFor( + allowances: readonly MdxAllowance[], + reading = false, +): MdxExtension { + const named = [...new Set(allowances)].sort(); + const key = `${named.join(",")}${reading ? "+reading" : ""}`; + let extension = EXTENSIONS.get(key); + if (extension === undefined) { + extension = withUnicode151Jsx( + mdxjs({ acorn: lenientParser(named, reading) }), + ); + EXTENSIONS.set(key, extension); + } + return extension; +} + +// The parser throws a VFileMessage; it is matched structurally (its package +// is a transitive dependency, not one the harness declares). +interface ParserMessage { + readonly reason: string; + readonly source?: unknown; + readonly ruleId?: unknown; + readonly place?: unknown; + readonly cause?: unknown; +} + +function isParserMessage(error: unknown): error is ParserMessage { + return ( + error instanceof Error && + typeof (error as { reason?: unknown }).reason === "string" && + "source" in error + ); +} + +/** + * A `devlop` assertion — `name` "Assertion", `code` "ERR_ASSERTION" — from + * the development build of the parser stack, which a test runner resolving + * the `development` export condition (Vitest) loads in place of the + * production build. The mdast layer asserts its node stack's consistency at + * each construct's exit, and ill-formed nesting — a setext heading ending + * while the JSX element opened inside its paragraph is still open, as + * T6.5-16(d)'s `===` remainder leaves it — trips that assertion before the + * element-matching rejection the production build raises for the same text + * (verified: the production build rejects it with "Expected a closing tag + * for `<S>` … before the end of `setextHeading`"); the assertion is that + * rejection, a non-derivation. An exhausted stack is a `RangeError`, never + * this. + */ +function isDevelopmentAssertion(error: unknown): error is Error { + return ( + error instanceof Error && + error.name === "Assertion" && + (error as { code?: unknown }).code === "ERR_ASSERTION" + ); +} + +function isPoint(value: unknown): value is MdxPosition { + return ( + typeof value === "object" && + value !== null && + typeof (value as { line?: unknown }).line === "number" && + typeof (value as { column?: unknown }).column === "number" && + typeof (value as { offset?: unknown }).offset === "number" + ); +} + +function pointOf(place: unknown): MdxPosition | undefined { + if (isPoint(place)) { + return { line: place.line, column: place.column, offset: place.offset }; + } + const start = (place as { start?: unknown } | null | undefined)?.start; + return isPoint(start) + ? { line: start.line, column: start.column, offset: start.offset } + : undefined; +} + +/** Decides whether `source` is well-formed MDX under SPEC 14.20: bytes are + * the file's bytes (decoded here as 14.20 reads them); a string is taken as + * already-decoded content, so a leading U+FEFF is its byte-order mark. */ +export function deriveMdx( + source: Uint8Array | string, + options?: DeriveMdxOptions, +): MdxVerdict { + return deriveMdxWith(source, options?.allowances ?? [], false); +} + +/** `deriveMdx`, `READING_RULES` admitted besides when `reading`. */ +function deriveMdxWith( + source: Uint8Array | string, + allowances: readonly MdxAllowance[], + reading: boolean, +): MdxVerdict { + let text: string; + if (typeof source === "string") { + const lone = firstLoneSurrogate(source); + if (lone !== undefined) { + return { + derives: false, + reason: `not encodable as UTF-8: a lone surrogate at index ${lone}`, + position: positionAt(source, lone), + }; + } + text = source; + } else { + const invalid = firstInvalidUtf8(source); + if (invalid !== undefined) { + const prefix = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + source.subarray(0, invalid), + ); + const point = positionAt(prefix, prefix.length); + return { + derives: false, + reason: `not valid UTF-8: an invalid sequence at byte offset ${invalid}`, + position: { line: point.line, column: point.column, offset: invalid }, + }; + } + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + source, + ); + } + if (text.charCodeAt(0) === 0xfeff) { + return { + derives: false, + reason: "begins with a byte-order mark (U+FEFF)", + position: { line: 1, column: 1, offset: 0 }, + }; + } + checkRuntimeWhitespace(); + parsedText = text; + try { + fromMarkdown(text, { + extensions: [extensionsFor(allowances, reading)], + mdastExtensions: MDAST_EXTENSIONS, + }); + return { derives: true }; + } catch (error) { + if (isDevelopmentAssertion(error)) { + return { + derives: false, + reason: `parser development-build assertion: ${error.message}`, + }; + } + if (!isParserMessage(error)) { + // Not a grammar verdict (an internal failure such as exhausted stack): + // never reported as a non-derivation. + throw error; + } + const where = [error.source, error.ruleId] + .filter((part) => typeof part === "string") + .join(" "); + const cause = + error.cause instanceof Error ? `: ${error.cause.message}` : ""; + return { + derives: false, + reason: `${where.length > 0 ? `${where}: ` : ""}${error.reason}${cause}`, + position: pointOf(error.place), + }; + } finally { + parsedText = undefined; + } +} + +/** The mdast tree the 14.20 parse above builds — ESM blocks, expressions, + * and attribute expressions each carrying the ESTree acorn parsed it to + * (`data.estree`). */ +export type MdxTree = ReturnType<typeof fromMarkdown>; + +/** + * The tree of a source that derives under 14.20, read by the parse + * `deriveMdx` judges with — every S-9 allowance admitted, since a well-formed + * file may carry any of those early errors, and the early errors of + * `READING_RULES` besides, which a product-written file may carry (an added + * `import let from "./let.xspec"`, T6.5-22). TEST-SPEC S-6's name analysis + * (helpers/oracles/name-analysis.ts) reads a spec source's names from it, and + * T6.5-22(a)'s check (helpers/added-import-identifiers.ts) a rewritten + * source's import declarations. A source that does not derive so has no tree + * to read: a harness error, its reason the parse's. + */ +export function readMdxTree(text: string): MdxTree { + const verdict = deriveMdxWith(text, MDX_ALLOWANCES, true); + if (!verdict.derives) { + throw new Error( + `S-9's MDX parse: no tree is read from a source that does not derive under SPEC 14.20 (${verdict.reason})`, + ); + } + checkRuntimeWhitespace(); + parsedText = text; + try { + return fromMarkdown(text, { + extensions: [extensionsFor(MDX_ALLOWANCES, true)], + mdastExtensions: MDAST_EXTENSIONS, + }); + } finally { + parsedText = undefined; + } +} + +/** The 1-based line and column of `index` in `text`, counting the line + * endings the parser counts (U+000A, U+000D, and U+000D U+000A as one). */ +function positionAt(text: string, index: number): MdxPosition { + let line = 1; + let lineStart = 0; + for (let i = 0; i < index; i++) { + const code = text.charCodeAt(i); + // A U+000D followed by U+000A is one line ending, counted at the U+000A. + if (code === 0x0a || (code === 0x0d && text.charCodeAt(i + 1) !== 0x0a)) { + line++; + lineStart = i + 1; + } + } + return { line, column: index - lineStart + 1, offset: index }; +} + +function firstLoneSurrogate(text: string): number | undefined { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + i++; + continue; + } + return i; + } + if (code >= 0xdc00 && code <= 0xdfff) { + return i; + } + } + return undefined; +} + +/** The byte offset of the first ill-formed UTF-8 sequence (the Unicode + * well-formed byte sequences of Table 3-7: no overlongs, no surrogates, no + * code points above U+10FFFF, no truncation), or undefined when the bytes + * are valid — the same verdict as a `fatal` TextDecoder's, located. */ +function firstInvalidUtf8(bytes: Uint8Array): number | undefined { + let i = 0; + while (i < bytes.length) { + const b0 = bytes[i] as number; + if (b0 < 0x80) { + i++; + continue; + } + let need: number; + let lo = 0x80; + let hi = 0xbf; + if (b0 >= 0xc2 && b0 <= 0xdf) { + need = 1; + } else if (b0 >= 0xe0 && b0 <= 0xef) { + need = 2; + if (b0 === 0xe0) lo = 0xa0; + if (b0 === 0xed) hi = 0x9f; + } else if (b0 >= 0xf0 && b0 <= 0xf4) { + need = 3; + if (b0 === 0xf0) lo = 0x90; + if (b0 === 0xf4) hi = 0x8f; + } else { + return i; + } + for (let k = 1; k <= need; k++) { + const b = bytes[i + k]; + const min = k === 1 ? lo : 0x80; + const max = k === 1 ? hi : 0xbf; + if (b === undefined || b < min || b > max) { + // A stray continuation byte, an overlong or out-of-range sequence, + // or one truncated at the end of the input. + return i; + } + } + i += need + 1; + } + return undefined; +} diff --git a/test/helpers/oracles/coverage.ts b/test/helpers/oracles/coverage.ts new file mode 100644 index 00000000..6497da11 --- /dev/null +++ b/test/helpers/oracles/coverage.ts @@ -0,0 +1,479 @@ +// In-harness coverage-reachability oracle (TEST-SPEC 16 P-13, 17 S-6): an +// independent implementation of SPEC.md 8.1's required set and SPEC.md 8's +// reachability, used to compute the expected `xspec coverage` result — the +// required, covered, uncovered, and ignored sets, exclusion reasons and one +// shortest covering path per covered node included (8.2, 12.0) — for the +// P-13 property tests. Per S-6, the oracle passes its fixed vector suite +// (test/self/s6-coverage-oracle.test.ts) — derived from SPEC.md 15's worked +// workspace and its transitive-coverage statement — before any property test +// trusts it. Harness machinery only: pure functions, no product imports, no +// I/O, no test-framework dependence. +// +// The oracle parses nothing and resolves no configuration. Its callers — the +// P-13 workspace/profile generator, the S-6 vectors — constructed the +// workspace, so they know its graph and its group memberships: the input is +// the graph (every node with its root flag, contains-children, coverage +// attribute, and tags; the dependency edges with their kinds) plus the +// resolved profile ingredients — the target group's nodes, the boundary +// group's nodes (each group's full membership, roots included: the group +// lists mirror 7.1/7.2 discovery, and the coverage-scoped root exclusions +// below are the oracle's own job), and the profile's `mode`, `targets`, +// `targetTags`, and `edgeKinds`. Feeding the oracle the caller's own +// structure rather than the product's graph output is what keeps it +// independent (P-13: "an independent oracle"). +// +// SPEC.md 8/8.1/8.2 (with 7.4's vocabulary and 12.0's tie-break), as +// implemented here: +// +// * Required set (8.1): the nodes of the target group, restricted to nodes +// carrying at least one `targetTags` tag when `targetTags` is present and +// to childless nodes when `targets` is `"leaves"` (7.4), excluding nodes +// marked `coverage="none"` (2.5 — per node: descendants retain their own +// behavior) and always excluding root nodes. +// * Ignored set (8.2): the target group's nodes excluded from the required +// set, each with all applicable exclusion reasons in the fixed order — +// root node, `coverage="none"`, non-leaf under `targets: "leaves"`, +// lacking every `targetTags` tag. A root carries no coverage attribute and +// no tags (5.5, guarded), so beside `root` it can carry `non-leaf` (when +// it has children under `targets: "leaves"`) and `lacking-tags` (whenever +// `targetTags` is present), never `coverage-none`. +// * Coverage (8): a required node is covered when a permitted path exists +// from a boundary node to it — a single edge in `direct` mode, a path of +// one or more edges in `transitive` mode (boundary membership alone is no +// such path), using only the profile's `edgeKinds`. `contains` edges never +// grant coverage and never appear in paths (children are input, and the +// reachability walk never consults them). Root nodes never appear in +// coverage paths — not as boundary node (the boundary group contributes +// only its non-root members), intermediate, or target: an edge whose +// source or target is a root never extends a covering path. +// * Reported path (8.2, 12.0): per covered node one shortest covering path, +// boundary node first, target last; among equal-length shortest paths the +// least by element-wise comparison of the node-identity sequences, each +// element compared byte-wise as UTF-8 (12.0). The minimum is computed +// greedily over dist-to-target levels: fixing a least prefix that extends +// to a shortest path never forfeits a smaller completion, because the +// element-wise comparison is decided at the first differing position. +// * Counts (8.2): the sizes of the four sets; required = covered ∪ +// uncovered by construction. +// +// Result arrays are sorted by identity bytes (SPEC 8.2 fixes membership and +// per-node information, not row order; callers comparing against a product +// report sort its rows the same way). The ignored-reason tokens are the +// harness's canonical `IGNORED_REASON_KINDS` spellings +// (test/helpers/adapters/reports.ts) — structurally identical literals, kept +// local so the oracle stays free of the adapter layer. +// +// Misuse guards (H-8) — each throws a plain error, a harness defect, never a +// diagnosed product failure: an identity without a node entry (as a child, +// an edge endpoint, or a group member); a duplicate group member (groups are +// sets); a self-edge or a cycle in the combined contains/depends/embeds +// graph (5.3 — such a workspace fails `build`, so it is outside P-13's input +// space; `references` edges cannot cycle: only code locations source them +// and no edge targets a code location); a root carrying tags or a coverage +// attribute (5.5: roots have neither); an empty `edgeKinds` or `targetTags` +// list (a configuration error, 14.14 — coverage never evaluates it). + +import { Buffer } from "node:buffer"; + +// --------------------------------------------------------------------------- +// Input and output model + +/** The dependency edge kinds (SPEC 5.2; 7.4's `edgeKinds` universe). */ +export const COVERAGE_ORACLE_EDGE_KINDS = [ + "depends", + "embeds", + "references", +] as const; +export type CoverageOracleEdgeKind = + (typeof COVERAGE_ORACLE_EDGE_KINDS)[number]; + +/** One graph node (requirement node or code location) the oracle sees. */ +export interface CoverageOracleNode { + /** A file's implicit root requirement node (SPEC 1.2)? Code: never. */ + readonly root: boolean; + /** + * Direct child identities in document order (`contains`, SPEC 5.2) — the + * leaf judgment of 7.4 (`"leaves"` = no children) and never anything + * else: the reachability walk does not consult children (8: `contains` + * never grants coverage). Code locations carry none. + */ + readonly children: readonly string[]; + /** + * The node's spelled coverage attribute (SPEC 2.5), `null` where none is + * spelled (the default is coverage-required). Roots carry `null` (5.5). + */ + readonly coverage: "required" | "none" | null; + /** The node's tags (SPEC 2.6, deduplicated). Roots carry none (5.5). */ + readonly tags: readonly string[]; +} + +/** One dependency edge (SPEC 5.2). Duplicates collapse (edges are sets). */ +export interface CoverageOracleEdge { + readonly source: string; + readonly target: string; + readonly kind: CoverageOracleEdgeKind; +} + +/** + * The resolved profile ingredients (SPEC 7.4) the required-set and + * reachability rules consume. Optional members take 7.4's documented + * defaults; group membership arrives as the separate input lists. + */ +export interface CoverageOracleProfile { + /** `"direct"` or `"transitive"` (7.4, 8). */ + readonly mode: "direct" | "transitive"; + /** `"leaves"` (the 7.4 default when omitted) or `"all"`. */ + readonly targets?: "leaves" | "all"; + /** + * The `targetTags` restriction; omitted or `null` = absent. An empty list + * is a configuration error (14.14) and a misuse here. + */ + readonly targetTags?: readonly string[] | null; + /** + * The permitted edge kinds; omitted = all three (the 7.4 default). An + * empty list is a configuration error (14.14) and a misuse here. + */ + readonly edgeKinds?: readonly CoverageOracleEdgeKind[]; +} + +/** The oracle's whole input (module header). */ +export interface CoverageOracleInput { + /** Every graph node, keyed by identity. */ + readonly nodes: ReadonlyMap<string, CoverageOracleNode>; + /** Every dependency edge (root-sourced and root-targeted ones included). */ + readonly edges: readonly CoverageOracleEdge[]; + /** The target group's full membership, roots included (7.1, 8.2). */ + readonly targetGroup: readonly string[]; + /** The boundary group's full membership, roots included (7.1/7.2, 8). */ + readonly boundaryGroup: readonly string[]; + readonly profile: CoverageOracleProfile; +} + +/** + * SPEC 8.2's exclusion-reason identities, in the fixed reporting order — + * the harness's canonical tokens (module header). + */ +export const COVERAGE_IGNORED_REASONS = [ + "root", + "coverage-none", + "non-leaf", + "lacking-tags", +] as const; +export type CoverageIgnoredReason = (typeof COVERAGE_IGNORED_REASONS)[number]; + +/** One covered node: its identity and its one shortest covering path. */ +export interface CoverageOracleCoveredRow { + readonly identity: string; + /** Boundary node first, target last (8.2, 12.0 tie-break). */ + readonly path: readonly string[]; +} + +/** One ignored node: all applicable reasons in the fixed order (8.2). */ +export interface CoverageOracleIgnoredRow { + readonly identity: string; + readonly reasons: readonly CoverageIgnoredReason[]; +} + +/** The expected result of one profile's coverage run (8.2). */ +export interface CoverageOracleResult { + readonly counts: { + readonly required: number; + readonly covered: number; + readonly uncovered: number; + readonly ignored: number; + }; + /** The required set (8.1), identity-byte order. */ + readonly required: readonly string[]; + /** The covered rows, identity-byte order. */ + readonly covered: readonly CoverageOracleCoveredRow[]; + /** The uncovered identities (required minus covered), byte order. */ + readonly uncovered: readonly string[]; + /** The ignored rows (target group minus required), identity-byte order. */ + readonly ignored: readonly CoverageOracleIgnoredRow[]; +} + +// --------------------------------------------------------------------------- +// Internals + +function misuse(message: string): never { + throw new Error(`coverage oracle misuse: ${message}`); +} + +/** Byte-wise UTF-8 comparison (SPEC 12.0). */ +function compareBytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +function byteLeast(values: Iterable<string>): string | undefined { + let least: string | undefined; + for (const value of values) { + if (least === undefined || compareBytes(value, least) < 0) least = value; + } + return least; +} + +/** Resolve an identity to its node, or throw the incomplete-graph misuse. */ +function nodeAt( + nodes: ReadonlyMap<string, CoverageOracleNode>, + identity: string, + role: string, +): CoverageOracleNode { + const node = nodes.get(identity); + if (node === undefined) { + misuse( + `no node for ${identity} (${role}) — every child identity, edge ` + + `endpoint, and group member must have a node entry`, + ); + } + return node; +} + +/** Validate and deduplicate one group's membership (groups are sets). */ +function groupSet( + nodes: ReadonlyMap<string, CoverageOracleNode>, + members: readonly string[], + label: string, +): Set<string> { + const set = new Set<string>(); + for (const identity of members) { + nodeAt(nodes, identity, `a member of the ${label} group`); + if (set.has(identity)) { + misuse( + `duplicate ${label}-group member ${identity} — a group's nodes ` + + `form a set`, + ); + } + set.add(identity); + } + return set; +} + +/** + * Misuse-guard the combined contains/depends/embeds graph against cycles + * (SPEC 5.3): such a workspace fails validation and is outside the oracle's + * input space. `references` edges are excluded per 5.3 (they cannot cycle: + * only code locations source them and no edge targets a code location). + */ +function guardAcyclic( + nodes: ReadonlyMap<string, CoverageOracleNode>, + edges: readonly CoverageOracleEdge[], +): void { + const successors = new Map<string, Set<string>>(); + for (const identity of nodes.keys()) successors.set(identity, new Set()); + for (const [identity, node] of nodes) { + for (const child of node.children) { + nodeAt(nodes, child, `a child of ${identity}`); + successors.get(identity)?.add(child); + } + } + for (const edge of edges) { + if (edge.kind === "references") continue; + successors.get(edge.source)?.add(edge.target); + } + const done = new Set<string>(); + const visiting = new Set<string>(); + const visit = (identity: string): void => { + if (done.has(identity)) return; + if (visiting.has(identity)) { + misuse( + `contains/depends/embeds cycle through ${identity} — workspace ` + + `graphs are acyclic (SPEC 5.3)`, + ); + } + visiting.add(identity); + for (const next of successors.get(identity) ?? []) visit(next); + visiting.delete(identity); + done.add(identity); + }; + for (const identity of nodes.keys()) visit(identity); +} + +// --------------------------------------------------------------------------- +// The oracle + +/** + * Compute one profile's expected `xspec coverage` result per SPEC 8, 8.1, + * and 8.2 with the 12.0 shortest-path tie-break (module header): the + * required, covered (with one shortest covering path each), uncovered, and + * ignored (with all applicable exclusion reasons in the fixed order) sets, + * plus the four counts. + */ +export function computeCoverage( + input: CoverageOracleInput, +): CoverageOracleResult { + const { nodes, edges, profile } = input; + + // --- input contract (module header) -------------------------------------- + for (const [identity, node] of nodes) { + if (node.root && (node.tags.length > 0 || node.coverage !== null)) { + misuse( + `root node ${identity} carries tags or a coverage attribute — a ` + + `root has neither (SPEC 5.5)`, + ); + } + } + for (const edge of edges) { + nodeAt(nodes, edge.source, `the source of a ${edge.kind} edge`); + nodeAt(nodes, edge.target, `the target of a ${edge.kind} edge`); + if (edge.source === edge.target) { + misuse( + `self-edge on ${edge.source} — a node that depends on or embeds ` + + `itself is a dependency cycle of length one (SPEC 5.3)`, + ); + } + } + guardAcyclic(nodes, edges); + const targetMembers = groupSet(nodes, input.targetGroup, "target"); + const boundaryMembers = groupSet(nodes, input.boundaryGroup, "boundary"); + + const targets = profile.targets ?? "leaves"; + const edgeKinds = profile.edgeKinds ?? COVERAGE_ORACLE_EDGE_KINDS; + if (edgeKinds.length === 0) { + misuse( + `empty edgeKinds — a configuration error (SPEC 7.4, 14.14) coverage ` + + `never evaluates`, + ); + } + const targetTags = + profile.targetTags === undefined || profile.targetTags === null + ? null + : profile.targetTags; + if (targetTags !== null && targetTags.length === 0) { + misuse( + `empty targetTags — a configuration error (SPEC 7.4, 14.14) coverage ` + + `never evaluates`, + ); + } + + // --- required and ignored sets (8.1, 8.2) -------------------------------- + const tagSet = targetTags === null ? null : new Set(targetTags); + const reasonsFor = (identity: string): CoverageIgnoredReason[] => { + const node = nodeAt(nodes, identity, "a target-group member"); + const reasons: CoverageIgnoredReason[] = []; + if (node.root) reasons.push("root"); + if (node.coverage === "none") reasons.push("coverage-none"); + if (targets === "leaves" && node.children.length > 0) { + reasons.push("non-leaf"); + } + if (tagSet !== null && !node.tags.some((tag) => tagSet.has(tag))) { + reasons.push("lacking-tags"); + } + return reasons; + }; + const required: string[] = []; + const ignored: CoverageOracleIgnoredRow[] = []; + for (const identity of targetMembers) { + const reasons = reasonsFor(identity); + if (reasons.length === 0) required.push(identity); + else ignored.push({ identity, reasons }); + } + required.sort(compareBytes); + ignored.sort((a, b) => compareBytes(a.identity, b.identity)); + + // --- permitted reachability structure (8) -------------------------------- + // Boundary nodes: the boundary group's non-root members (8). Permitted + // steps: dependency edges of the profile's kinds with no root endpoint — + // a root is never boundary node, intermediate, or target of a path. + const boundary = new Set( + [...boundaryMembers].filter( + (identity) => !nodeAt(nodes, identity, "a boundary-group member").root, + ), + ); + const kindSet = new Set<CoverageOracleEdgeKind>(edgeKinds); + const forward = new Map<string, Set<string>>(); + const backward = new Map<string, Set<string>>(); + for (const edge of edges) { + if (!kindSet.has(edge.kind)) continue; + if (nodes.get(edge.source)?.root === true) continue; + if (nodes.get(edge.target)?.root === true) continue; + let out = forward.get(edge.source); + if (out === undefined) forward.set(edge.source, (out = new Set())); + out.add(edge.target); + let into = backward.get(edge.target); + if (into === undefined) backward.set(edge.target, (into = new Set())); + into.add(edge.source); + } + + /** + * The unique reported covering path for one required node, or `null` + * where none exists: shortest from any boundary node (one edge in + * `direct` mode, one or more in `transitive`), ties by element-wise + * byte comparison (8, 8.2, 12.0 — module header). + */ + const coveringPath = (target: string): string[] | null => { + if (profile.mode === "direct") { + const sources = backward.get(target); + if (sources === undefined) return null; + const least = byteLeast( + [...sources].filter((source) => boundary.has(source)), + ); + return least === undefined ? null : [least, target]; + } + // Transitive: distance-to-target levels by reverse BFS, then a greedy + // byte-least descent along strictly decreasing distances. + const dist = new Map<string, number>([[target, 0]]); + let frontier = [target]; + while (frontier.length > 0) { + const next: string[] = []; + for (const identity of frontier) { + const level = dist.get(identity) ?? 0; + for (const source of backward.get(identity) ?? []) { + if (dist.has(source)) continue; + dist.set(source, level + 1); + next.push(source); + } + } + frontier = next; + } + const starts = [...boundary].filter( + (identity) => identity !== target && dist.has(identity), + ); + if (starts.length === 0) return null; + const startDistance = Math.min( + ...starts.map((identity) => dist.get(identity) ?? Number.NaN), + ); + const path = [ + byteLeast( + starts.filter((identity) => dist.get(identity) === startDistance), + ) as string, + ]; + for (let remaining = startDistance - 1; remaining >= 0; remaining -= 1) { + const current = path[path.length - 1] as string; + const next = byteLeast( + [...(forward.get(current) ?? [])].filter( + (identity) => dist.get(identity) === remaining, + ), + ); + if (next === undefined) { + throw new Error( + `coverage oracle internal error: no distance-${String(remaining)} ` + + `successor of ${current} on a shortest path to ${target}`, + ); + } + path.push(next); + } + return path; + }; + + // --- covered and uncovered (8, 8.2) -------------------------------------- + const covered: CoverageOracleCoveredRow[] = []; + const uncovered: string[] = []; + for (const identity of required) { + const path = coveringPath(identity); + if (path === null) uncovered.push(identity); + else covered.push({ identity, path }); + } + + return { + counts: { + required: required.length, + covered: covered.length, + uncovered: uncovered.length, + ignored: ignored.length, + }, + required, + covered, + uncovered, + ignored, + }; +} diff --git a/test/helpers/oracles/graph-diff.ts b/test/helpers/oracles/graph-diff.ts new file mode 100644 index 00000000..b7e7cab2 --- /dev/null +++ b/test/helpers/oracles/graph-diff.ts @@ -0,0 +1,349 @@ +// In-harness baseline graph-diff oracle (TEST-SPEC 16 P-6, 17 S-6): an +// independent implementation of SPEC.md 5.6's change categories over two +// workspace graphs — a baseline graph whose identities the caller has +// already mapped forward through the journal suffix into current identities +// (SPEC 6.3; P-6 composes the per-operation mappings it requested) and the +// current graph. Per S-6, the oracle passes its fixed vector suite +// (test/self/s6-graph-diff-oracle.test.ts) — derived from SPEC.md 5.6's +// three worked examples plus the added/deleted convention of T5.6-6 — +// before any property test trusts it. Harness machinery only: pure +// functions, no product imports, no I/O, no test-framework dependence. +// +// The oracle hashes nothing: each side supplies, per node, opaque +// comparable keys standing in for the SPEC 5.5 hash preimages — equal keys +// exactly when the preimage is unchanged — plus the structure the cascades +// walk (children in document order, dependency-edge targets). SPEC 5.6 as +// implemented here, per node: +// +// * `changed`: the node was added or deleted, or its own-content key +// (`ownKey` — the 1.6 sequence: runs plus one reference token per child +// construct and per embedding, references as canonical identities) +// differs; adding, removing, or reordering children changes the parent's +// key, since identities enter the sequence at their positions (5.5: +// structural edits originate at the parent). +// * `metadata-changed`: the node's metadata key (`metaKey`: `d`-target set, +// coverage, tags — the metadataHash preimage, 5.5) differs. +// * `descendant-changed`: a changed node lies among the node's strict +// descendants on either side — an own-changed, added, or deleted +// descendant (5.6's worked examples pin the ancestors of added and of +// deleted children to exactly this category). +// * `upstream-changed`: the node's effective state changed through a +// dependency-edge cause — a both-sides dependency-edge target (of the +// node, or of a both-sides subtree node) whose effective state changed, +// or a strict-subtree node other than the node itself whose +// dependency-edge pair multiset (`pairKey`, one entry per edge, `depends` +// and `embeds` alike, 5.5/5.2) changed. Effective state is the 5.5 +// effectiveHash recursion evaluated as a fixpoint over both-sides nodes: +// own content changed, own pair multiset changed, a both-sides child +// changed effectively, or a both-sides dependency-edge target changed +// effectively (added and removed children and edges surface through +// `ownKey`/`pairKey`). +// * An added or deleted node receives no category through its own hashes — +// exactly `changed`, whatever metadata, children, or dependency edges it +// carries (5.6: baseline hash comparison is defined only for a node +// present on both sides; T5.6-6). Deleted nodes are keyed by their +// baseline (journal-mapped) identities and flagged in `deleted`. +// +// The two-sided tolerance (the ambiguity T6.2-3 documents, met here by +// relocations and by edge-bearing added or deleted subtree members): where +// a node's effective state changed but every dependency-edge cause traces +// only through one-side-only subtree members — a relocated (kept, +// one-side-only) member with a cause, or an added or deleted member +// carrying dependency edges, its edges arriving or departing with the node +// — `upstream-changed` is predicted tolerated-optional (`optionalUpstream`, +// accepted present or absent), while any both-sides cause makes it +// required. No SPEC.md worked material pins those one-sided readings, and +// P-6's generator keeps them out of its input space (its module header). +// +// Misuse guards (H-8) — each throws a plain error, a harness defect, never +// a diagnosed product failure: a relocated originator (a kept `changed` or +// `metadata-changed` node whose kept strict-ancestor sets differ across +// sides) would make `descendant-changed` two-sidedly ambiguous on its +// holders and is outside the oracle's input space; so are incomplete +// graphs (a child or walked dependency-edge target with no node on its +// side), contains or dependency cycles (5.3), and an `ownKey` that fails +// to cover the child reference tokens. + +// --------------------------------------------------------------------------- +// Input and output model + +/** One node of one side's graph, in the diff's shared identity space. */ +export interface GraphDiffNode { + /** Direct child identities in document order. */ + readonly children: readonly string[]; + /** + * Opaque key of the node's own content sequence (SPEC 1.6) — the ownHash + * preimage (5.5): equal keys iff the runs and the child and embedding + * reference tokens, at their positions, are unchanged. It MUST therefore + * cover the `children` list (guarded) and the embedding references. + */ + readonly ownKey: string; + /** + * Opaque key of (`d`-target set, coverage, tags) — the metadataHash + * preimage (SPEC 5.5). + */ + readonly metaKey: string; + /** + * Opaque key of the node's dependency-edge identity-pair multiset — one + * entry per edge, `depends` and `embeds` alike (SPEC 5.5, 5.2). + */ + readonly pairKey: string; + /** Deduplicated dependency-edge target identities (the closure walk). */ + readonly edgeTargets: readonly string[]; +} + +/** One side of the diff: every node of that graph, keyed by identity. */ +export type GraphDiffSide = ReadonlyMap<string, GraphDiffNode>; + +/** SPEC 5.6's category vocabulary. */ +export type GraphDiffCategory = + "changed" | "metadata-changed" | "descendant-changed" | "upstream-changed"; + +/** The oracle's prediction (module header). */ +export interface GraphDiff { + /** + * Exact required category set per node: kept and added nodes under + * current identities, deleted nodes under their baseline identities. + */ + readonly required: ReadonlyMap<string, ReadonlySet<GraphDiffCategory>>; + /** + * Nodes that may additionally carry `upstream-changed` — the documented + * one-sided-cause tolerance (module header), accepted present or absent. + */ + readonly optionalUpstream: ReadonlySet<string>; + /** + * Attribution bound: every originating node — those carrying `changed` + * (added and deleted included) or `metadata-changed` (SPEC 5.6: every + * category MUST be attributed to its originating nodes). + */ + readonly originators: ReadonlySet<string>; + /** Nodes present on the current side only (each required `changed`). */ + readonly added: ReadonlySet<string>; + /** Nodes present on the baseline side only (each required `changed`). */ + readonly deleted: ReadonlySet<string>; +} + +function misuse(message: string): never { + throw new Error(`graph-diff oracle misuse: ${message}`); +} + +/** Memoized strict-descendant sets over one side's `children` lists. */ +function strictDescendants( + side: GraphDiffSide, + label: string, +): Map<string, Set<string>> { + const memo = new Map<string, Set<string>>(); + const visiting = new Set<string>(); + const resolve = (identity: string): Set<string> => { + const cached = memo.get(identity); + if (cached !== undefined) return cached; + if (visiting.has(identity)) { + misuse( + `contains-cycle through ${identity} in the ${label} graph — ` + + `workspace graphs are acyclic (SPEC 5.3)`, + ); + } + visiting.add(identity); + const node = side.get(identity); + if (node === undefined) { + misuse( + `no ${label} node for ${identity} — every child identity must have ` + + `a node on its side`, + ); + } + const descendants = new Set<string>(); + for (const child of node.children) { + descendants.add(child); + for (const inner of resolve(child)) descendants.add(inner); + } + visiting.delete(identity); + memo.set(identity, descendants); + return descendants; + }; + for (const identity of side.keys()) resolve(identity); + return memo; +} + +/** + * Diff two workspace graphs per SPEC 5.6 (module header): the baseline side + * already mapped into current identities (SPEC 6.3), the current side as it + * stands. Returns the exact required category set per node, the + * tolerated-optional `upstream-changed` set, the originating-node + * attribution bound, and the added and deleted identity sets. + */ +export function computeGraphDiff( + before: GraphDiffSide, + after: GraphDiffSide, +): GraphDiff { + const kept = [...before.keys()].filter((identity) => after.has(identity)); + const added = [...after.keys()].filter((identity) => !before.has(identity)); + const deleted = [...before.keys()].filter((identity) => !after.has(identity)); + const beforeAt = (identity: string): GraphDiffNode => { + const node = before.get(identity); + if (node === undefined) misuse(`no baseline node for ${identity}`); + return node; + }; + const afterAt = (identity: string): GraphDiffNode => { + const node = after.get(identity); + if (node === undefined) misuse(`no current node for ${identity}`); + return node; + }; + + const keptSet = new Set(kept); + const ownChanged = new Set( + kept.filter((id) => beforeAt(id).ownKey !== afterAt(id).ownKey), + ); + const metaChanged = new Set( + kept.filter((id) => beforeAt(id).metaKey !== afterAt(id).metaKey), + ); + const pairChanged = new Set( + kept.filter((id) => beforeAt(id).pairKey !== afterAt(id).pairKey), + ); + const changedSet = new Set([...ownChanged, ...added, ...deleted]); + const originators = new Set([...changedSet, ...metaChanged]); + + // Input-contract guard: ownKey covers the child reference tokens (SPEC + // 1.6, 5.5 — identities enter the own-content sequence at their + // positions, so a differing child list forces a differing key). + for (const id of kept) { + if ( + !ownChanged.has(id) && + JSON.stringify(beforeAt(id).children) !== + JSON.stringify(afterAt(id).children) + ) { + misuse( + `the children of ${id} differ across sides while its ownKey ` + + `compares equal — ownKey must cover the child reference tokens ` + + `at their positions (SPEC 1.6, 5.5)`, + ); + } + } + + const descBefore = strictDescendants(before, "baseline"); + const descAfter = strictDescendants(after, "current"); + const descAt = ( + memo: Map<string, Set<string>>, + identity: string, + ): Set<string> => memo.get(identity) ?? new Set<string>(); + + // Misuse guard (module header): an originator never relocates — its + // kept strict-ancestor relation is two-sided — so `descendant-changed` + // is never ambiguous. Added and deleted nodes are one-sided by nature + // (the 5.6 worked examples pin their ancestors' category). + for (const id of kept) { + if (!ownChanged.has(id) && !metaChanged.has(id)) continue; + const beforeHolders = kept.filter((a) => descAt(descBefore, a).has(id)); + const afterHolders = kept.filter((a) => descAt(descAfter, a).has(id)); + if ( + JSON.stringify(beforeHolders.sort()) !== + JSON.stringify(afterHolders.sort()) + ) { + misuse( + `originating node ${id} relocated between baseline and current — ` + + `descendant-changed would be two-sidedly ambiguous on its ` + + `holders (the T6.2-3 ambiguity); the caller must keep changed ` + + `and metadata-changed nodes in place`, + ); + } + } + + // effChanged fixpoint over kept nodes: own content changed, own pair + // multiset changed, a both-sides child changed effectively, or a + // both-sides dependency-edge target changed effectively (SPEC 5.5; added + // or removed children and edges surface through ownKey/pairKey). + const effMemo = new Map<string, boolean>(); + const effVisiting = new Set<string>(); + const commonOf = ( + beforeList: readonly string[], + afterList: readonly string[], + ): string[] => + beforeList.filter((id) => keptSet.has(id) && afterList.includes(id)); + const effChanged = (id: string): boolean => { + const cached = effMemo.get(id); + if (cached !== undefined) return cached; + if (effVisiting.has(id)) { + misuse( + `dependency/contains cycle through ${id} — workspace graphs are ` + + `acyclic (SPEC 5.3)`, + ); + } + effVisiting.add(id); + const result = + ownChanged.has(id) || + pairChanged.has(id) || + commonOf(beforeAt(id).children, afterAt(id).children).some(effChanged) || + commonOf(beforeAt(id).edgeTargets, afterAt(id).edgeTargets).some( + effChanged, + ); + effVisiting.delete(id); + effMemo.set(id, result); + return result; + }; + + // A node's dependency-edge cause (SPEC 5.6 upstream-changed): a common + // dependency-edge target of the node itself or of a subtree node whose + // effective state changed, or a strict-subtree node (not the node itself) + // whose pair multiset changed. Both-sides subtree members give the + // required cause; one-side-only members give the optional tolerance + // (module header). + const targetCause = (id: string): boolean => + commonOf(beforeAt(id).edgeTargets, afterAt(id).edgeTargets).some( + effChanged, + ); + const memberCause = (member: string): boolean => + pairChanged.has(member) || targetCause(member); + + const required = new Map<string, Set<GraphDiffCategory>>(); + const optionalUpstream = new Set<string>(); + for (const id of kept) { + const categories = new Set<GraphDiffCategory>(); + if (ownChanged.has(id)) categories.add("changed"); + if (metaChanged.has(id)) categories.add("metadata-changed"); + const beforeDesc = descAt(descBefore, id); + const afterDesc = descAt(descAfter, id); + const eitherDesc = new Set([...beforeDesc, ...afterDesc]); + if ([...eitherDesc].some((d) => changedSet.has(d))) { + categories.add("descendant-changed"); + } + if (effChanged(id)) { + const bothMembers = [...beforeDesc].filter( + (d) => keptSet.has(d) && afterDesc.has(d), + ); + if (targetCause(id) || bothMembers.some(memberCause)) { + categories.add("upstream-changed"); + } else { + // Only a one-side-only subtree member's dependency cause remains: + // a relocated kept member with a cause, or an added or deleted + // member whose dependency edges arrived or departed with it — + // tolerable but not required (module header). + const oneSidedCause = [...eitherDesc].some((d) => { + if (keptSet.has(d)) { + return !(beforeDesc.has(d) && afterDesc.has(d)) && memberCause(d); + } + return afterDesc.has(d) + ? afterAt(d).edgeTargets.length > 0 + : beforeAt(d).edgeTargets.length > 0; + }); + if (oneSidedCause) optionalUpstream.add(id); + } + } + required.set(id, categories); + } + for (const id of added) { + // An added node is `changed` and receives no category through its own + // hashes (SPEC 5.6, T5.6-6). + required.set(id, new Set<GraphDiffCategory>(["changed"])); + } + for (const id of deleted) { + // A deleted node reports as deleted, under its baseline identity, and + // `changed` only (SPEC 5.6, T5.6-6). + required.set(id, new Set<GraphDiffCategory>(["changed"])); + } + return { + required, + optionalUpstream, + originators, + added: new Set(added), + deleted: new Set(deleted), + }; +} diff --git a/test/helpers/oracles/markdown.ts b/test/helpers/oracles/markdown.ts index 3a269b7e..9d3ccd82 100644 --- a/test/helpers/oracles/markdown.ts +++ b/test/helpers/oracles/markdown.ts @@ -16,6 +16,15 @@ // it independent of the product (P-2: "an independent oracle ... the oracle // lives in the harness"). // +// Grammar boundary (TEST-SPEC T3-1, §16 P-2): constructs exist only where +// the MDX parse yields them — fenced code blocks and inline code spans are +// literal text, so construct-like bytes inside them (`<S id="x">`, `<div>`, +// `import X from "./X.xspec"`, `{text("a")}`) are plain content. Callers +// express that by passing every fence and span byte as a `content` piece; +// the oracle treats content uniformly — preserved verbatim, subject only to +// the drop rule under the 1.4 classes — and never scans content for +// construct-like patterns (the S-6 grammar-boundary vectors pin this). +// // SPEC.md 3, as implemented here: // // * Compilation removes spec module imports, `<S>`/`<Spec>` tags together diff --git a/test/helpers/oracles/name-analysis.ts b/test/helpers/oracles/name-analysis.ts new file mode 100644 index 00000000..c3ef890f --- /dev/null +++ b/test/helpers/oracles/name-analysis.ts @@ -0,0 +1,964 @@ +// In-harness name analysis behind T6.5-22(a)'s universal assertion (TEST-SPEC +// 17 S-6; T6.5-22; SPEC 6.5's constraints on an added import's identifiers): +// for a receiving file — a spec source, a plain TypeScript code source, or a +// TSX one — the names it declares, in any scope and at value or type level, +// the names it references, and the names 6.5 bars there. Per S-6 the analysis +// passes its fixed vector suite (test/self/s6-name-analysis.test.ts), built +// from T6.5-22's stagings, before any test that adds an import trusts it. +// Harness machinery only: pure functions, no product imports, no I/O, no +// test-framework dependence. +// +// Reading. A file is read by the grammar SPEC 14.20 fixes for it, by the +// harness's own means (S-9's), and must be well-formed there — the analysis +// reads names, never judges, so a file outside the grammar is a harness +// error: +// * a spec source through S-9's MDX 3 parse (helpers/mdx-derivability.ts +// `readMdxTree`: ECMAScript 2024 for its ESM blocks and expressions, every +// S-9 allowance admitted, since a well-formed file may carry any of those +// early errors) — its JSX elements' names and attributes from the mdast +// tree, and the ESTree acorn attaches to each ESM block, expression, and +// attribute expression walked node type by node type, an unknown type a +// harness error, never a silent miss; +// * a code source through the harness's TypeScript 5.9.3, TSX or plain as +// the caller names its kind (14.20: a name ending `.tsx` parses as TSX), +// held to S-9's verdict and read as module code — 14.20 holds a well-formed +// file to both readings, which differ only where a top-level `await` +// stands, a name barred in every file. +// +// (a) Declared: the name of every declaration, in any scope, at value or type +// level — variable (`using` included), function, class, and parameter +// bindings, destructured ones included; a named function or class +// expression's own name; a catch binding; an import's local bindings (`t` of +// `{ text as t }`) and `import x = …`; type aliases, interfaces, enums, and +// enum members (TypeScript resolves a member unqualified inside its enum, a +// string-named one by its text); namespaces (`declare global` binds no +// name); type parameters, `infer` and mapped ones included; the parameters +// of signatures and function types; `export as namespace X`. +// +// (b) Referenced, by T6.5-22(a)'s definition: every identifier the file +// spells where name resolution looks it up through scope, at value or type +// level, whatever it resolves to — an expression's identifiers (an +// assignment target, a shorthand property, the local name of an +// `export { … }` without `from`, a decorator, a computed key among them); +// the leftmost identifier of a type reference, a `typeof` query, a heritage +// clause, or `import x = N.y`; and a JSX tag name that is a value reference — +// a plain tag that is not intrinsic (an intrinsic tag begins with an ASCII +// lowercase letter or holds a `-`, as TypeScript and MDX both judge it; a +// namespaced tag is intrinsic too), and the root of a member tag (`<a.b />`). +// Never counted: a property or member name (after `.` in an expression, a +// qualified type name, or an import type's qualifier; an object literal's +// non-shorthand key or a destructuring key; a class, interface, or enum +// member's; a JSX attribute's), a label, a tuple member's label, a type +// predicate's parameter name, the `meta` and `target` of `import.meta` and +// `new.target`, and every name an import or export specifier spells for the +// other module (`text` of `{ text as t }`; both names of a re-export). +// +// (c) Barred, by file kind (6.5; T6.5-22's constraint list): in every kind, +// ECMAScript 2024's reserved words, the words strict mode bars as a binding, +// `require` and `exports`, every `__`-prefixed name, the global object's +// properties (clause 19's value, function, constructor, and other +// properties, and Annex B's `escape` and `unescape`), `Iterator`, +// `AsyncIterator`, and `SuppressedError`; in a TSX source, `React` and the +// leading identifier of the factory any `@jsx` or `@jsxFrag` pragma in any +// of the file's comments names; in a spec source, the compiler-provided `S`, +// `Spec`, and `text` (2.1). A pragma is recognized as TypeScript 5.9.3 spells +// one — its single-line form in a line comment, its multi-line form in a +// block comment, the pragma's name matched regardless of ASCII case, the +// factory its first argument when TypeScript parses that as an entity name — +// but wherever the comment stands and whatever its kind, where TypeScript +// itself reads `@jsx` and `@jsxFrag` from block comments among the file's +// leading comments alone (T6.5-22(b)'s `// @jsx h` and in-function lures). + +import ts from "typescript-5.9.3"; +import { readMdxTree } from "../mdx-derivability.js"; +import { judgeTypeScript } from "../ts-derivability.js"; + +/** The kinds of receiving file SPEC 6.5 distinguishes (14.20). */ +export type ReceivingFileKind = "spec-source" | "typescript" | "tsx"; + +export interface NameAnalysis { + readonly kind: ReceivingFileKind; + /** (a) Every name the file declares, in any scope, at value or type level. */ + readonly declared: ReadonlySet<string>; + /** (b) Every name the file references, at value or type level. */ + readonly referenced: ReadonlySet<string>; + /** In a TSX source, the leading identifier of the factory each `@jsx` or + * `@jsxFrag` pragma in one of its comments names; empty otherwise. */ + readonly pragmaFactories: ReadonlySet<string>; +} + +/** One way an added identifier breaches T6.5-22(a). */ +export interface AddedIdentifierBreach { + readonly identifier: string; + /** The clause breached, in words. */ + readonly clause: string; +} + +/** A name's standing in a receiving file. */ +export interface NameVerdict { + readonly declared: boolean; + readonly referenced: boolean; + /** Why 6.5 bars the name there, or undefined when it does not. */ + readonly barred: string | undefined; +} + +// --------------------------------------------------------------------------- +// (c) The barred names (SPEC 6.5; T6.5-22's constraint list). + +/** ECMAScript 2024's ReservedWord (12.7.2). */ +// prettier-ignore +const RESERVED_WORDS = [ + "await", "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "enum", "export", "extends", "false", + "finally", "for", "function", "if", "import", "in", "instanceof", "new", + "null", "return", "super", "switch", "this", "throw", "true", "try", + "typeof", "var", "void", "while", "with", "yield", +]; + +/** The further words strict code admits as no binding (6.5's list). */ +// prettier-ignore +const STRICT_MODE_BARRED = [ + "let", "static", "implements", "interface", "package", "private", + "protected", "public", "eval", "arguments", +]; + +/** What TypeScript's compiler reserves in a module it emits in any format + * but ECMAScript's. */ +const MODULE_EMIT_RESERVED = ["require", "exports"]; + +/** ECMAScript 2024's global object properties: clause 19's value (19.1), + * function (19.2), constructor (19.3), and other (19.4) properties. */ +// prettier-ignore +const GLOBAL_OBJECT_PROPERTIES = [ + "globalThis", "Infinity", "NaN", "undefined", + "eval", "isFinite", "isNaN", "parseFloat", "parseInt", "decodeURI", + "decodeURIComponent", "encodeURI", "encodeURIComponent", + "AggregateError", "Array", "ArrayBuffer", "BigInt", "BigInt64Array", + "BigUint64Array", "Boolean", "DataView", "Date", "Error", "EvalError", + "FinalizationRegistry", "Float32Array", "Float64Array", "Function", + "Int8Array", "Int16Array", "Int32Array", "Map", "Number", "Object", + "Promise", "Proxy", "RangeError", "ReferenceError", "RegExp", "Set", + "SharedArrayBuffer", "String", "Symbol", "SyntaxError", "TypeError", + "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", "URIError", + "WeakMap", "WeakRef", "WeakSet", + "Atomics", "JSON", "Math", "Reflect", +]; + +/** Annex B's global object properties (B.2.1). */ +const ANNEX_B_GLOBAL_PROPERTIES = ["escape", "unescape"]; + +/** Barred by name, being no ECMAScript 2024 global object property. */ +const NAMED_GLOBALS = ["Iterator", "AsyncIterator", "SuppressedError"]; + +/** A spec source's compiler-provided names (2.1). */ +const SPEC_SOURCE_PROVIDED: ReadonlySet<string> = new Set([ + "S", + "Spec", + "text", +]); + +/** Every barred name 6.5 lists for every kind of file, with its clause; a + * name in two lists keeps the first. */ +const BARRED_EVERYWHERE: ReadonlyMap<string, string> = (() => { + const barred = new Map<string, string>(); + const add = (names: readonly string[], clause: string): void => { + for (const name of names) if (!barred.has(name)) barred.set(name, clause); + }; + add(RESERVED_WORDS, "an ECMAScript 2024 reserved word"); + add(STRICT_MODE_BARRED, "a word strict code admits as no binding"); + add( + MODULE_EMIT_RESERVED, + "reserved by TypeScript's compiler in a module it emits in any format but ECMAScript's", + ); + add( + GLOBAL_OBJECT_PROPERTIES, + "a global object property ECMAScript 2024 defines (clause 19)", + ); + add( + ANNEX_B_GLOBAL_PROPERTIES, + "a global object property ECMAScript 2024's Annex B defines (B.2.1)", + ); + add(NAMED_GLOBALS, "a global 6.5 bars by name"); + return barred; +})(); + +/** Why SPEC 6.5 bars `name` as an added import's identifier in the file + * `analysis` describes, or undefined when it does not. */ +export function barredReason( + analysis: NameAnalysis, + name: string, +): string | undefined { + const everywhere = BARRED_EVERYWHERE.get(name); + if (everywhere !== undefined) return everywhere; + if (name.startsWith("__")) { + return "a name beginning with `__`, as the helpers TypeScript's emit declares do"; + } + if (analysis.kind === "tsx") { + if (name === "React") { + return "`React` in a TSX source, through which TypeScript's classic JSX transform reaches the file's factories"; + } + if (analysis.pragmaFactories.has(name)) { + return "in a TSX source, the leading identifier of a factory a `@jsx` or `@jsxFrag` pragma in one of its comments names"; + } + } + if (analysis.kind === "spec-source" && SPEC_SOURCE_PROVIDED.has(name)) { + return "a compiler-provided name in a spec source (`S`, `Spec`, `text`)"; + } + return undefined; +} + +/** A name's standing in the file `analysis` describes. */ +export function nameVerdict(analysis: NameAnalysis, name: string): NameVerdict { + return { + declared: analysis.declared.has(name), + referenced: analysis.referenced.has(name), + barred: barredReason(analysis, name), + }; +} + +/** + * T6.5-22(a)'s verdict on the identifiers an operation's added import + * declarations bind in one file, `analysis` describing the file before the + * operation: every breach, in the order the identifiers are given — none + * when each is barred nowhere there, bound by no declaration of the file, + * referenced nowhere in it, and distinct from the others added. + */ +export function addedIdentifierBreaches( + analysis: NameAnalysis, + added: readonly string[], +): readonly AddedIdentifierBreach[] { + const breaches: AddedIdentifierBreach[] = []; + const seen = new Set<string>(); + for (const identifier of added) { + const barred = barredReason(analysis, identifier); + if (barred !== undefined) { + breaches.push({ identifier, clause: `barred: ${barred}` }); + } + if (analysis.declared.has(identifier)) { + breaches.push({ + identifier, + clause: + "bound by a declaration of the pre-operation file (in some scope, at value or type level)", + }); + } + if (analysis.referenced.has(identifier)) { + breaches.push({ + identifier, + clause: + "equal to a name the pre-operation file references (at value or type level)", + }); + } + if (seen.has(identifier)) { + breaches.push({ + identifier, + clause: "not distinct from another identifier added to the file", + }); + } + seen.add(identifier); + } + return breaches; +} + +// --------------------------------------------------------------------------- +// The analysis. + +class NameCollector { + readonly declared = new Set<string>(); + readonly referenced = new Set<string>(); + + declare(name: string): void { + this.declared.add(name); + } + + reference(name: string): void { + this.referenced.add(name); + } +} + +/** Analyzes a receiving file's decoded content as a file of `kind`. */ +export function analyzeNames( + kind: ReceivingFileKind, + text: string, +): NameAnalysis { + const names = new NameCollector(); + let pragmaFactories: ReadonlySet<string> = new Set(); + if (kind === "spec-source") { + walkMdast(readMdxTree(text) as unknown as MdastNode, names); + } else { + const file = readCodeSource(kind, text); + walkTypeScript(file, names); + if (kind === "tsx") pragmaFactories = jsxPragmaFactories(file); + } + return { + kind, + declared: names.declared, + referenced: names.referenced, + pragmaFactories, + }; +} + +/** An intrinsic JSX tag name: an ASCII lowercase first letter, or a `-` + * (TypeScript's `isIntrinsicJsxName`; MDX's compiler judges alike). */ +function isIntrinsicJsxName(name: string): boolean { + const first = name.charCodeAt(0); + return (first >= 0x61 && first <= 0x7a) || name.includes("-"); +} + +// --------------------------------------------------------------------------- +// Spec sources: the mdast tree and the ESTree of its ESM and expressions. + +interface MdastNode { + readonly type: string; + readonly children?: readonly MdastNode[]; + readonly name?: string | null; + readonly attributes?: readonly MdastAttribute[]; + readonly data?: { readonly estree?: unknown }; +} + +interface MdastAttribute { + readonly type: string; + readonly value?: unknown; + readonly data?: { readonly estree?: unknown }; +} + +type EsNode = { readonly type: string } & Readonly<Record<string, unknown>>; + +function isEsNode(value: unknown): value is EsNode { + return ( + typeof value === "object" && + value !== null && + typeof (value as { type?: unknown }).type === "string" + ); +} + +function harnessError(message: string): Error { + return new Error(`S-6's name analysis: ${message}`); +} + +/** The ESTree program the parse attached to an mdast node. */ +function estreeOf(node: { + readonly type: string; + readonly data?: { readonly estree?: unknown }; +}): EsNode { + const estree = node.data?.estree; + if (!isEsNode(estree) || estree.type !== "Program") { + throw harnessError( + `the parse attached no ESTree program to a ${node.type}`, + ); + } + return estree; +} + +function walkMdast(node: MdastNode, names: NameCollector): void { + switch (node.type) { + case "mdxjsEsm": + case "mdxFlowExpression": + case "mdxTextExpression": + new EstreeWalker(names).visit(estreeOf(node)); + break; + case "mdxJsxFlowElement": + case "mdxJsxTextElement": + referenceMdxJsxName(node.name, names); + for (const attribute of node.attributes ?? []) { + walkMdxJsxAttribute(attribute, names); + } + break; + default: + break; + } + for (const child of node.children ?? []) walkMdast(child, names); +} + +/** A JSX element's name as the mdast tree spells it: `null` for a fragment, + * `a:b` namespaced, `a.b.c` a member name, else a plain one. */ +function referenceMdxJsxName( + name: string | null | undefined, + names: NameCollector, +): void { + if (name === null || name === undefined || name.includes(":")) return; + if (name.includes(".")) { + const root = name.slice(0, name.indexOf(".")); + if (root !== "this") names.reference(root); + return; + } + if (!isIntrinsicJsxName(name)) names.reference(name); +} + +function walkMdxJsxAttribute( + attribute: MdastAttribute, + names: NameCollector, +): void { + if (attribute.type === "mdxJsxExpressionAttribute") { + new EstreeWalker(names).visit(estreeOf(attribute)); + return; + } + if (attribute.type !== "mdxJsxAttribute") { + throw harnessError( + `no reading of the JSX attribute type ${attribute.type}`, + ); + } + // The attribute's name is never counted; a braced value is an expression. + const value = attribute.value; + if ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "mdxJsxAttributeValueExpression" + ) { + new EstreeWalker(names).visit( + estreeOf(value as Parameters<typeof estreeOf>[0]), + ); + } +} + +/** Reads an ESTree program's names, node type by node type (ECMAScript 2024 + * as acorn builds it, with acorn-jsx's JSX nodes). */ +class EstreeWalker { + constructor(private readonly names: NameCollector) {} + + private child(node: EsNode, key: string): EsNode | null { + const value = node[key]; + if (value === null || value === undefined) return null; + if (isEsNode(value)) return value; + throw harnessError(`${node.type}.${key} holds no ESTree node`); + } + + private list(node: EsNode, key: string): readonly (EsNode | null)[] { + const value = node[key]; + if (!Array.isArray(value)) { + throw harnessError(`${node.type}.${key} holds no node list`); + } + return value.map((entry: unknown) => { + if (entry === null) return null; + if (isEsNode(entry)) return entry; + throw harnessError(`${node.type}.${key} holds a non-node entry`); + }); + } + + private name(node: EsNode): string { + const name = node["name"]; + if (typeof name !== "string") { + throw harnessError(`${node.type} carries no name`); + } + return name; + } + + private visitKeys(node: EsNode, ...keys: string[]): void { + for (const key of keys) this.visit(this.child(node, key)); + } + + private visitList(node: EsNode, key: string): void { + for (const entry of this.list(node, key)) this.visit(entry); + } + + visit(node: EsNode | null): void { + if (node === null) return; + switch (node.type) { + // Nothing here is looked up through scope: literals, `this`, `super`, + // a private name, template text, JSX text, `import.meta`/`new.target`, + // a label, and a re-export's names (the other module's and the + // exported one). + case "Literal": + case "ThisExpression": + case "Super": + case "PrivateIdentifier": + case "TemplateElement": + case "MetaProperty": + case "EmptyStatement": + case "DebuggerStatement": + case "BreakStatement": + case "ContinueStatement": + case "ExportAllDeclaration": + case "JSXText": + case "JSXEmptyExpression": + return; + case "Identifier": + this.names.reference(this.name(node)); + return; + case "Program": + case "BlockStatement": + case "StaticBlock": + case "ClassBody": + this.visitList(node, "body"); + return; + case "ExpressionStatement": + case "ChainExpression": + case "ParenthesizedExpression": + case "JSXExpressionContainer": + case "JSXSpreadChild": + this.visitKeys(node, "expression"); + return; + case "ReturnStatement": + case "ThrowStatement": + case "SpreadElement": + case "UnaryExpression": + case "UpdateExpression": + case "AwaitExpression": + case "YieldExpression": + case "JSXSpreadAttribute": + this.visitKeys(node, "argument"); + return; + case "LabeledStatement": + this.visitKeys(node, "body"); + return; + case "WithStatement": + this.visitKeys(node, "object", "body"); + return; + case "IfStatement": + case "ConditionalExpression": + this.visitKeys(node, "test", "consequent", "alternate"); + return; + case "SwitchStatement": + this.visitKeys(node, "discriminant"); + this.visitList(node, "cases"); + return; + case "SwitchCase": + this.visitKeys(node, "test"); + this.visitList(node, "consequent"); + return; + case "TryStatement": + this.visitKeys(node, "block", "handler", "finalizer"); + return; + case "CatchClause": + this.pattern(this.child(node, "param"), "binding"); + this.visitKeys(node, "body"); + return; + case "WhileStatement": + case "DoWhileStatement": + this.visitKeys(node, "test", "body"); + return; + case "ForStatement": + this.visitKeys(node, "init", "test", "update", "body"); + return; + case "ForInStatement": + case "ForOfStatement": { + const left = this.child(node, "left"); + if (left?.type === "VariableDeclaration") this.visit(left); + else this.pattern(left, "assignment"); + this.visitKeys(node, "right", "body"); + return; + } + case "FunctionDeclaration": + case "FunctionExpression": + case "ArrowFunctionExpression": { + const id = this.child(node, "id"); + if (id !== null) this.names.declare(this.name(id)); + for (const param of this.list(node, "params")) { + this.pattern(param, "binding"); + } + this.visitKeys(node, "body"); + return; + } + case "VariableDeclaration": + this.visitList(node, "declarations"); + return; + case "VariableDeclarator": + this.pattern(this.child(node, "id"), "binding"); + this.visitKeys(node, "init"); + return; + case "ClassDeclaration": + case "ClassExpression": { + const id = this.child(node, "id"); + if (id !== null) this.names.declare(this.name(id)); + this.visitKeys(node, "superClass", "body"); + return; + } + // A key is a property name unless computed; a shorthand property's + // value is the identifier it references. + case "MethodDefinition": + case "PropertyDefinition": + case "Property": + if (node["computed"] === true) this.visitKeys(node, "key"); + this.visitKeys(node, "value"); + return; + case "ImportDeclaration": + for (const specifier of this.list(node, "specifiers")) { + const local = + specifier === null ? null : this.child(specifier, "local"); + if (local === null) { + throw harnessError("an import specifier binds no local name"); + } + this.names.declare(this.name(local)); + } + return; + case "ExportNamedDeclaration": + this.visitKeys(node, "declaration"); + // Without `from`, a specifier's local name is looked up in the file; + // the exported name, and both names of a re-export, never are. + if (this.child(node, "source") === null) { + for (const specifier of this.list(node, "specifiers")) { + const local = + specifier === null ? null : this.child(specifier, "local"); + if (local?.type === "Identifier") + this.names.reference(this.name(local)); + } + } + return; + case "ExportDefaultDeclaration": + this.visitKeys(node, "declaration"); + return; + case "ArrayExpression": + this.visitList(node, "elements"); + return; + case "ObjectExpression": + this.visitList(node, "properties"); + return; + case "BinaryExpression": + case "LogicalExpression": + this.visitKeys(node, "left", "right"); + return; + case "AssignmentExpression": + this.pattern(this.child(node, "left"), "assignment"); + this.visitKeys(node, "right"); + return; + case "CallExpression": + case "NewExpression": + this.visitKeys(node, "callee"); + this.visitList(node, "arguments"); + return; + case "MemberExpression": + this.visitKeys(node, "object"); + if (node["computed"] === true) this.visitKeys(node, "property"); + return; + case "SequenceExpression": + this.visitList(node, "expressions"); + return; + case "TemplateLiteral": + this.visitList(node, "expressions"); + return; + case "TaggedTemplateExpression": + this.visitKeys(node, "tag", "quasi"); + return; + case "ImportExpression": + this.visitKeys(node, "source", "options"); + return; + case "JSXElement": + this.visitKeys(node, "openingElement"); + this.visitList(node, "children"); + this.visitKeys(node, "closingElement"); + return; + case "JSXFragment": + this.visitList(node, "children"); + return; + case "JSXOpeningElement": + this.jsxName(this.child(node, "name")); + this.visitList(node, "attributes"); + return; + case "JSXClosingElement": + this.jsxName(this.child(node, "name")); + return; + case "JSXAttribute": + // The attribute's name is never counted. + this.visitKeys(node, "value"); + return; + default: + throw harnessError(`no reading of the ESTree node type ${node.type}`); + } + } + + /** A binding pattern's names are declared; an assignment target's are + * references. */ + private pattern(node: EsNode | null, mode: "binding" | "assignment"): void { + if (node === null) return; + switch (node.type) { + case "Identifier": + if (mode === "binding") this.names.declare(this.name(node)); + else this.names.reference(this.name(node)); + return; + case "ObjectPattern": + for (const property of this.list(node, "properties")) { + if (property?.type === "RestElement") { + this.pattern(this.child(property, "argument"), mode); + } else if (property?.type === "Property") { + if (property["computed"] === true) this.visitKeys(property, "key"); + this.pattern(this.child(property, "value"), mode); + } else { + throw harnessError("an object pattern holds an unknown entry"); + } + } + return; + case "ArrayPattern": + for (const element of this.list(node, "elements")) { + this.pattern(element, mode); + } + return; + case "RestElement": + this.pattern(this.child(node, "argument"), mode); + return; + case "AssignmentPattern": + this.pattern(this.child(node, "left"), mode); + this.visitKeys(node, "right"); + return; + case "MemberExpression": + if (mode === "assignment") { + this.visit(node); + return; + } + break; + default: + break; + } + throw harnessError(`no reading of a ${mode} pattern of type ${node.type}`); + } + + /** A JSX element name: a plain tag counts unless intrinsic, a member tag + * by its root, a namespaced tag never. */ + private jsxName(node: EsNode | null): void { + if (node === null) throw harnessError("a JSX element carries no name"); + switch (node.type) { + case "JSXIdentifier": { + const name = this.name(node); + if (!isIntrinsicJsxName(name)) this.names.reference(name); + return; + } + case "JSXMemberExpression": { + let root: EsNode | null = node; + while (root?.type === "JSXMemberExpression") { + root = this.child(root, "object"); + } + if (root?.type !== "JSXIdentifier") { + throw harnessError("a JSX member name has no identifier root"); + } + const name = this.name(root); + if (name !== "this") this.names.reference(name); + return; + } + case "JSXNamespacedName": + return; + default: + throw harnessError(`no reading of the JSX name type ${node.type}`); + } + } +} + +// --------------------------------------------------------------------------- +// Code sources: TypeScript 5.9.3's tree. + +// TypeScript keeps the module indicator `setExternalModuleIndicator` sets off +// its public declarations. +interface ModuleIndicated { + externalModuleIndicator?: unknown; +} + +function readCodeSource( + kind: "typescript" | "tsx", + text: string, +): ts.SourceFile { + const name = kind === "tsx" ? "s6-receiver.tsx" : "s6-receiver.ts"; + const verdict = judgeTypeScript(text, name); + if (verdict.verdict !== "well-formed") { + throw harnessError( + `no names are read from a code source that is not well-formed under SPEC 14.20 (${verdict.reason})`, + ); + } + return ts.createSourceFile( + name, + text, + { + languageVersion: ts.ScriptTarget.ESNext, + setExternalModuleIndicator: (sourceFile) => { + (sourceFile as unknown as ModuleIndicated).externalModuleIndicator = + true; + }, + }, + true, + kind === "tsx" ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function walkTypeScript(file: ts.SourceFile, names: NameCollector): void { + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node)) { + const role = identifierRole(node); + if (role === "declared") names.declare(node.text); + else if (role === "referenced") names.reference(node.text); + return; + } + // A string-named enum member is resolved by its text inside its enum. + if (ts.isEnumMember(node) && ts.isStringLiteral(node.name)) { + names.declare(node.name.text); + } + ts.forEachChild(node, visit); + }; + visit(file); +} + +type IdentifierRole = "declared" | "referenced" | "neither"; + +/** What an identifier is, by where its parent holds it. */ +function identifierRole(id: ts.Identifier): IdentifierRole { + const parent = id.parent; + const { SyntaxKind } = ts; + const named = parent as unknown as { readonly name?: ts.Node }; + switch (parent.kind) { + case SyntaxKind.Parameter: + if (named.name !== id) return "referenced"; + return id.text === "this" ? "neither" : "declared"; + case SyntaxKind.VariableDeclaration: + case SyntaxKind.FunctionDeclaration: + case SyntaxKind.FunctionExpression: + case SyntaxKind.ClassDeclaration: + case SyntaxKind.ClassExpression: + case SyntaxKind.InterfaceDeclaration: + case SyntaxKind.TypeAliasDeclaration: + case SyntaxKind.EnumDeclaration: + case SyntaxKind.EnumMember: + case SyntaxKind.TypeParameter: + case SyntaxKind.ImportClause: + case SyntaxKind.NamespaceImport: + case SyntaxKind.ImportEqualsDeclaration: + case SyntaxKind.NamespaceExportDeclaration: + return named.name === id ? "declared" : "referenced"; + case SyntaxKind.BindingElement: { + const element = parent as ts.BindingElement; + if (element.name === id) return "declared"; + return element.propertyName === id ? "neither" : "referenced"; + } + case SyntaxKind.ModuleDeclaration: + if (named.name !== id) return "referenced"; + return (parent.flags & ts.NodeFlags.GlobalAugmentation) !== 0 + ? "neither" + : "declared"; + case SyntaxKind.ImportSpecifier: + return named.name === id ? "declared" : "neither"; + case SyntaxKind.ExportSpecifier: { + const specifier = parent as ts.ExportSpecifier; + if (specifier.parent.parent.moduleSpecifier !== undefined) { + return "neither"; + } + return (specifier.propertyName ?? specifier.name) === id + ? "referenced" + : "neither"; + } + case SyntaxKind.PropertyAccessExpression: + case SyntaxKind.PropertyDeclaration: + case SyntaxKind.PropertySignature: + case SyntaxKind.PropertyAssignment: + case SyntaxKind.MethodDeclaration: + case SyntaxKind.MethodSignature: + case SyntaxKind.GetAccessor: + case SyntaxKind.SetAccessor: + case SyntaxKind.NamedTupleMember: + case SyntaxKind.JsxAttribute: + return named.name === id ? "neither" : "referenced"; + case SyntaxKind.QualifiedName: { + const qualified = parent as ts.QualifiedName; + if (qualified.right === id) return "neither"; + return inImportTypeQualifier(qualified) ? "neither" : "referenced"; + } + case SyntaxKind.TypePredicate: + return (parent as ts.TypePredicateNode).parameterName === id + ? "neither" + : "referenced"; + case SyntaxKind.LabeledStatement: + case SyntaxKind.BreakStatement: + case SyntaxKind.ContinueStatement: + return (parent as unknown as { readonly label?: ts.Node }).label === id + ? "neither" + : "referenced"; + case SyntaxKind.JsxOpeningElement: + case SyntaxKind.JsxSelfClosingElement: + case SyntaxKind.JsxClosingElement: { + const element = parent as unknown as { readonly tagName: ts.Node }; + if (element.tagName !== id) return "referenced"; + return isIntrinsicJsxName(id.text) ? "neither" : "referenced"; + } + case SyntaxKind.ImportType: + case SyntaxKind.NamespaceExport: + case SyntaxKind.ImportAttribute: + case SyntaxKind.MetaProperty: + case SyntaxKind.JsxNamespacedName: + return "neither"; + default: + return "referenced"; + } +} + +/** Whether a qualified name's left side lies in an import type's qualifier + * (`import("m").A.B`): names of the module's exports, never looked up in + * the file. */ +function inImportTypeQualifier(name: ts.QualifiedName): boolean { + let top: ts.Node = name; + while (ts.isQualifiedName(top.parent) && top.parent.left === top) { + top = top.parent; + } + return ts.isImportTypeNode(top.parent) && top.parent.qualifier === top; +} + +// --------------------------------------------------------------------------- +// A TSX source's `@jsx` and `@jsxFrag` pragmas, in any of its comments. + +// TypeScript 5.9.3's pragma spellings (its `extractPragmas`): a line comment +// that is a triple-slash XML directive holds no other pragma; a line +// comment's one pragma begins the comment; a block comment holds a pragma at +// each `@` its multi-line pattern matches. +const TRIPLE_SLASH_XML = /^\/\/\/\s*<(\S+)\s.*?\/>/m; +const SINGLE_LINE_PRAGMA = /^\/\/\/?\s*@([^\s:]+)((?:[^\S\r\n]|:).*)?$/m; +const MULTI_LINE_PRAGMA = /@(\S+)(\s+(?:\S.*)?)?$/gm; + +const JSX_FACTORY_PRAGMAS: ReadonlySet<string> = new Set(["jsx", "jsxfrag"]); + +function jsxPragmaFactories(file: ts.SourceFile): ReadonlySet<string> { + const factories = new Set<string>(); + for (const range of commentRanges(file)) { + const comment = file.text.slice(range.pos, range.end); + for (const match of pragmaMatches(comment, range.kind)) { + if (!JSX_FACTORY_PRAGMAS.has((match[1] ?? "").toLowerCase())) continue; + // The factory is the first argument, an entity name TypeScript parses. + const factory = (match[2] ?? "").trim().split(/\s+/)[0] ?? ""; + if (factory === "") continue; + let entity = ts.parseIsolatedEntityName(factory, ts.ScriptTarget.ESNext); + if (entity === undefined) continue; + while (ts.isQualifiedName(entity)) entity = entity.left; + factories.add(entity.text); + } + } + return factories; +} + +function pragmaMatches( + comment: string, + kind: ts.CommentKind, +): readonly RegExpExecArray[] { + if (kind === ts.SyntaxKind.SingleLineCommentTrivia) { + if (TRIPLE_SLASH_XML.test(comment)) return []; + const match = SINGLE_LINE_PRAGMA.exec(comment); + return match === null ? [] : [match]; + } + return [...comment.matchAll(MULTI_LINE_PRAGMA)]; +} + +/** + * Every comment of the file: the trivia before each token — read from the + * token's full start, both the comments TypeScript calls trailing (before + * the first line break) and leading (after it). A JSX text's content is no + * trivia, and a JSDoc node's comment lies in the trivia of the token after it. + */ +function commentRanges(file: ts.SourceFile): readonly ts.CommentRange[] { + const { text } = file; + const found = new Map<number, ts.CommentRange>(); + const gather = (pos: number): void => { + for (const range of [ + ...(ts.getTrailingCommentRanges(text, pos) ?? []), + ...(ts.getLeadingCommentRanges(text, pos) ?? []), + ]) { + found.set(range.pos, range); + } + }; + const visit = (node: ts.Node): void => { + if (node.kind === ts.SyntaxKind.JsxText) return; + if ( + node.kind >= ts.SyntaxKind.FirstJSDocNode && + node.kind <= ts.SyntaxKind.LastJSDocNode + ) { + return; + } + const children = node.getChildren(file); + if (children.length === 0) { + gather(node.pos); + return; + } + for (const child of children) visit(child); + }; + visit(file); + return [...found.values()].sort((a, b) => a.pos - b.pos); +} diff --git a/test/helpers/oracles/section-move.ts b/test/helpers/oracles/section-move.ts new file mode 100644 index 00000000..93b7237a --- /dev/null +++ b/test/helpers/oracles/section-move.ts @@ -0,0 +1,1392 @@ +// 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 in T6.2-3's stagings, T6.2-3's clean-boundary +// case and sibling stagings, and T6.2-4's final-position shapes — 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 6.2's enumeration of what a +// successful move leaves `changed`: the origin parent, the target parent, +// the moved subtree's nodes, and each other node with own-content bytes +// on a line the deletion joins or drops or the insertion splits — the +// construct's boundary lines at the origin, the insertion point's line at +// the destination (the import-addition member of the enumeration is +// undrawn; its anchors are T6.5-13(h)/(j)) — 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: T6.2-4's pinned shapes reproduce it, its +// `changed` twin does not); +// - every other enumerated node — a moved-subtree node, an ancestor or +// a sibling with bytes on those lines — iff the line-drop rules of 3, +// judged on each side, change its runs: a line kept before and +// dropped after (a sibling's whitespace residue left alone on the +// deletion's merged line, T6.2-3(d); a line the insertion splits +// into a whitespace-only remainder, T6.2-3(e); a boundary line whose +// within-construct bytes are 1.4 whitespace — nothing, spaces, tabs, +// U+000B, U+000C — dropping at the destination whatever the tag's +// position there, T6.2-3(a)–(c)) or dropped before and kept after (a +// residue joined to prose). 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, and U+000B/U+000C count as +// whitespace exactly as 1.4 classifies them. +// The enumeration is complete (6.2: a successful section move leaves +// `changed` no node but these), so a node outside it whose sequence +// differs is an oracle defect — the edit model contradicting 6.2 — and +// throws; it is never a prediction. +// * `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 one two-sided +// tolerance the suite's T6.2-3 body shares: 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, +// attributed within that member), 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): +// a self-closing section has an empty body; a section tag may span lines +// (a terminator among a tag's own characters is deleted with the construct, +// joining the lines it spanned — 3 — which the delegated compile handles as +// any multi-line removal); multi-line in-line sections with agreeing +// boundary lines (T6.2-3's stagings) need no position knowledge — the +// oracle never parses MDX, and the drop rule consults 1.4 whitespace alone, +// whatever the tag's position at the destination; an embedding's +// `expansion` is emptiness-stable across the move — only +// emptiness enters the drop decision (a non-empty expansion keeps its line +// regardless of content), and the P-5 generator stages no empty subtree +// texts, so the before-side expansion decides both sides. + +import { compileMarkdown } from "./markdown.js"; +import type { MarkdownPiece } from "./markdown.js"; + +// --------------------------------------------------------------------------- +// Input model + +/** One own-content token: a text run, or a child/embedding reference. */ +export type SectionMoveOwnToken = readonly [ + kind: "run" | "child" | "embed", + value: string, +]; + +/** One piece of a document, in document order (nested for sections). */ +export type SectionMovePiece = + | { + /** Plain source content: preserved, subject only to the drop rule. */ + readonly kind: "content"; + readonly text: string; + } + | { + /** + * A non-section removed construct's own characters — a spec module + * import declaration or an MDX comment (SPEC.md 3). May contain line + * terminators (a multi-line comment merges its lines when removed). + */ + readonly kind: "removal"; + readonly text: string; + } + | { + /** + * A `text(...)` embedding: `text` is the expression's own characters + * (the braced container included), `expansion` the target's compiled + * subtree text (caller-computed, the markdown oracle's contract), and + * `target` the referenced node's identity in baseline space. + */ + readonly kind: "embedding"; + readonly text: string; + readonly expansion: string; + readonly target: string; + } + | SectionMoveSection; + +/** A requirement-section construct (SPEC 1.1) with its nested body. */ +export interface SectionMoveSection { + readonly kind: "section"; + /** The section's dotted id exactly as spelled (SPEC 1.3). */ + readonly id: string; + /** Opening tag's own characters (the whole tag when self-closing). */ + readonly open: string; + /** Closing tag's own characters; `null` = self-closing (empty body). */ + readonly close: string | null; + readonly body: readonly SectionMovePiece[]; + /** The section's `d`-declared target identities, baseline space. */ + readonly depends: readonly string[]; +} + +/** A document: its workspace-relative path plus its pieces. */ +export interface SectionMoveDocument { + readonly path: string; + readonly pieces: readonly SectionMovePiece[]; +} + +/** + * A node of a file the move does not textually touch, carried for the 5.6 + * cascade computation (dependents live anywhere). Everything in baseline + * identities; the oracle maps reference targets through the move's mapping. + */ +export interface SectionMoveGraphNode { + readonly identity: string; + /** Direct child identities in document order. */ + readonly children: readonly string[]; + /** Dependency-edge target identities (`depends` and `embeds` union). */ + readonly edgeTargets: readonly string[]; +} + +export interface SectionMoveInput { + /** The origin document, before the move; contains the moved section. */ + readonly origin: SectionMoveDocument; + /** + * The target document before the move, or `{ createdPath }` when the + * move creates the target file. A same-file move passes the identical + * document object as both `origin` and `target`. + */ + readonly target: SectionMoveDocument | { readonly createdPath: string }; + /** Dotted id of the moved section in the origin document. */ + readonly movedId: string; + /** Dotted new id (SPEC 6.5); its parent chain locates the target parent. */ + readonly newId: string; + /** Nodes of every file not textually involved in the move. */ + readonly otherNodes?: readonly SectionMoveGraphNode[]; +} + +// --------------------------------------------------------------------------- +// Output model + +export type SectionMoveCategoryName = + "changed" | "descendant-changed" | "upstream-changed"; + +/** The prediction for one category of one node. */ +export interface SectionMoveCategoryPrediction { + /** + * True: the category must be reported. False: tolerated-optional — its + * only cause is a relocated one-side-only member (the T6.2-3 tolerance), + * so it is accepted present or absent. + */ + readonly required: boolean; + /** Sorted bound: the reported attribution must be a subset. */ + readonly attributionWithin: readonly string[]; + /** Sorted; a reported category's attribution must include these. */ + readonly attributionMustInclude: readonly string[]; +} + +/** Per-node prediction: absent category name = must not be reported. */ +export interface SectionMoveNodePrediction { + readonly categories: ReadonlyMap< + SectionMoveCategoryName, + SectionMoveCategoryPrediction + >; +} + +export interface SectionMovePrediction { + /** Baseline → current identities of the moved subtree (others map to themselves). */ + readonly identityMap: ReadonlyMap<string, string>; + /** Every current-graph node's prediction (one entry per node, possibly empty). */ + readonly nodes: ReadonlyMap<string, SectionMoveNodePrediction>; + /** The `changed` set — the originating nodes (added created-root included). */ + readonly changed: ReadonlySet<string>; + /** Current identities added by the move: the created target root, if any. */ + readonly added: ReadonlySet<string>; + /** Per-node own-content token sequences, baseline side, baseline identities. */ + readonly beforeOwnTokens: ReadonlyMap<string, readonly SectionMoveOwnToken[]>; + /** Per-node own-content token sequences, current side, current identities. */ + readonly afterOwnTokens: ReadonlyMap<string, readonly SectionMoveOwnToken[]>; +} + +// --------------------------------------------------------------------------- +// Guards + +function misuse(message: string): never { + throw new Error(`section-move oracle misuse: ${message}`); +} + +function defect(message: string): never { + throw new Error(`section-move oracle defect: ${message}`); +} + +function isTerminatorCode(code: number): boolean { + return code === 0x0a || code === 0x0d; +} + +/** SPEC 1.4 whitespace-only (the classes P-2's oracle pins). */ +function isWhitespaceOnly(text: string): boolean { + return /^[\t\n\v\f\r ]*$/.test(text); +} + +// --------------------------------------------------------------------------- +// Piece-tree utilities + +/** The source text a piece list concatenates to (tags and bodies included). */ +export function sectionMoveSourceText( + pieces: readonly SectionMovePiece[], +): string { + let text = ""; + for (const piece of pieces) { + if (piece.kind === "section") { + text += + piece.open + sectionMoveSourceText(piece.body) + (piece.close ?? ""); + } else { + text += piece.text; + } + } + return text; +} + +interface LocatedSection { + readonly section: SectionMoveSection; + /** Construct-range string indices into the document's source text. */ + readonly start: number; + readonly end: number; +} + +/** Locate the section spelling `id`, with its source-text range. */ +function locateSection( + pieces: readonly SectionMovePiece[], + id: string, + offset: number, +): LocatedSection | null { + let cursor = offset; + for (const piece of pieces) { + if (piece.kind === "section") { + const length = + piece.open.length + + sectionMoveSourceText(piece.body).length + + (piece.close ?? "").length; + if (piece.id === id) { + return { section: piece, start: cursor, end: cursor + length }; + } + const inner = locateSection(piece.body, id, cursor + piece.open.length); + if (inner !== null) return inner; + cursor += length; + } else { + cursor += piece.text.length; + } + } + return null; +} + +/** + * Replace the section spelling `id` with one removal piece holding its full + * source text — 6.5's origin deletion as a rule-of-3 removal: the merged + * straddling line enters the compile with the construct's characters + * counting as source non-whitespace and is dropped exactly when the + * deletion leaves it empty or whitespace-only. + */ +function replaceWithRemoval( + pieces: readonly SectionMovePiece[], + id: string, +): { readonly pieces: SectionMovePiece[]; readonly found: boolean } { + const out: SectionMovePiece[] = []; + let found = false; + for (const piece of pieces) { + if (!found && piece.kind === "section") { + if (piece.id === id) { + out.push({ + kind: "removal", + text: + piece.open + + sectionMoveSourceText(piece.body) + + (piece.close ?? ""), + }); + found = true; + continue; + } + const inner = replaceWithRemoval(piece.body, id); + if (inner.found) { + out.push({ ...piece, body: inner.pieces }); + found = true; + continue; + } + } + out.push(piece); + } + return { pieces: out, found }; +} + +/** Rewrite the moved subtree's section ids by prefix replacement. */ +function mapMovedIds( + section: SectionMoveSection, + mapDotted: (dotted: string) => string, +): SectionMoveSection { + const mapPieces = (pieces: readonly SectionMovePiece[]): SectionMovePiece[] => + pieces.map((piece) => + piece.kind === "section" + ? { ...piece, id: mapDotted(piece.id), body: mapPieces(piece.body) } + : piece, + ); + return { + ...section, + id: mapDotted(section.id), + body: mapPieces(section.body), + }; +} + +/** Map every reference (embedding target, `d` target) through `mapIdentity`. */ +function mapReferencesDeep( + pieces: readonly SectionMovePiece[], + mapIdentity: (identity: string) => string, +): SectionMovePiece[] { + return pieces.map((piece) => { + if (piece.kind === "section") { + return { + ...piece, + depends: piece.depends.map(mapIdentity), + body: mapReferencesDeep(piece.body, mapIdentity), + }; + } + if (piece.kind === "embedding") { + return { ...piece, target: mapIdentity(piece.target) }; + } + return piece; + }); +} + +/** + * The paired form of a self-closing target parent (SPEC 6.5, T6.5-2): the + * `/` and any whitespace immediately before or after it deleted from the + * tag, and the closing tag matching the opening tag's name appended. + */ +function pairSelfClosing(open: string): { + readonly open: string; + readonly close: string; +} { + const nameMatch = /^<\s*(Spec|S)\b/.exec(open); + if (nameMatch === null) { + misuse( + `a section's open tag must begin <S or <Spec (SPEC 1.1); got ${JSON.stringify(open)}`, + ); + } + if (!open.endsWith(">")) { + misuse(`a tag's own characters end with ">"; got ${JSON.stringify(open)}`); + } + const inner = open.slice(0, -1); + const stripped = inner.replace(/[\t\n\v\f\r ]*\/[\t\n\v\f\r ]*$/, ""); + if (stripped === inner) { + misuse( + `pairSelfClosing called on a non-self-closing tag ${JSON.stringify(open)}`, + ); + } + return { open: `${stripped}>`, close: `</${nameMatch[1]}>` }; +} + +/** + * Insert `moved` as the last child of the section spelling `parentId` + * (`null` = the document root): appended to the parent's body immediately + * before its closing tag (at the end of the piece list for the root), + * followed by a U+000A content piece and preceded by one when the insertion + * point is not at a line start (`atLineStart`, judged over the + * post-deletion file bytes). A self-closing parent is first rewritten to + * paired form, the insertion point then following its opening tag's `>` — + * never at a line start (T6.5-2's worked bytes). + */ +function insertMoved( + pieces: readonly SectionMovePiece[], + parentId: string | null, + moved: SectionMoveSection, + atLineStart: boolean, +): { readonly pieces: SectionMovePiece[]; readonly found: boolean } { + const newline: SectionMovePiece = { kind: "content", text: "\n" }; + const splice = (lineStart: boolean): SectionMovePiece[] => [ + ...(lineStart ? [] : [newline]), + moved, + newline, + ]; + if (parentId === null) { + return { pieces: [...pieces, ...splice(atLineStart)], found: true }; + } + const out: SectionMovePiece[] = []; + let found = false; + for (const piece of pieces) { + if (!found && piece.kind === "section") { + if (piece.id === parentId) { + found = true; + if (piece.close === null) { + const paired = pairSelfClosing(piece.open); + out.push({ + ...piece, + open: paired.open, + close: paired.close, + body: splice(false), + }); + } else { + out.push({ ...piece, body: [...piece.body, ...splice(atLineStart)] }); + } + continue; + } + const inner = insertMoved(piece.body, parentId, moved, atLineStart); + if (inner.found) { + out.push({ ...piece, body: inner.pieces }); + found = true; + continue; + } + } + out.push(piece); + } + return { pieces: out, found }; +} + +// --------------------------------------------------------------------------- +// Edit-stage file bytes (for the insertion's line-start decision) +// +// 6.5's insertion is "preceded by [a U+000A] when the insertion point is +// not at the start of a line" — a fact about the file bytes the insertion +// edits: the target document as staged, or (same-file move) the +// post-deletion origin bytes, where the deletion has removed the +// construct's characters and dropped its merged straddling line when the +// deletion left it empty or whitespace-only. + +interface EditStageDeletion { + readonly start: number; + readonly end: number; +} + +/** Whether `position` in `source` starts a line after applying `deletion`. */ +function atLineStartAfterDeletion( + source: string, + position: number, + deletion: EditStageDeletion | null, +): boolean { + const removed: [number, number][] = []; + if (deletion !== null) { + // The deletion's merged line over the original bytes (SPEC 3's line + // model; CRLF pairs never straddle the construct, whose own characters + // begin `<` and end `>`). + let lineStart = deletion.start; + while ( + lineStart > 0 && + !isTerminatorCode(source.charCodeAt(lineStart - 1)) + ) { + lineStart -= 1; + } + let residueEnd = deletion.end; + while ( + residueEnd < source.length && + !isTerminatorCode(source.charCodeAt(residueEnd)) + ) { + residueEnd += 1; + } + let lineEnd = residueEnd; + if (lineEnd < source.length) { + lineEnd += + source.charCodeAt(lineEnd) === 0x0d && + source.charCodeAt(lineEnd + 1) === 0x0a + ? 2 + : 1; + } + const residue = + source.slice(lineStart, deletion.start) + + source.slice(deletion.end, residueEnd); + removed.push( + isWhitespaceOnly(residue) + ? [lineStart, lineEnd] // dropped with its terminator (SPEC 6.5, 3) + : [deletion.start, deletion.end], + ); + } + // Walk backwards from `position` over the post-deletion bytes. + let i = position; + for (;;) { + const skip = removed.find(([from, to]) => i > from && i <= to); + if (skip !== undefined) { + i = skip[0]; + continue; + } + if (i === 0) return true; + return isTerminatorCode(source.charCodeAt(i - 1)); + } +} + +/** + * String index of the insertion point in the concatenation of `pieces`: + * the first character of the target parent's closing tag, or the end of + * the document for a top-level new id. + */ +function insertionPoint( + pieces: readonly SectionMovePiece[], + parentDotted: string | null, + sourceLength: number, +): number { + if (parentDotted === null) return sourceLength; + const parent = locateSection(pieces, parentDotted, 0); + if (parent === null) { + misuse( + `the target document spells no section ${JSON.stringify(parentDotted)} ` + + `(a refused move; the oracle predicts successful moves only)`, + ); + } + return parent.end - (parent.section.close ?? "").length; +} + +// --------------------------------------------------------------------------- +// Attributed compilation: piece tree → per-node own-content sequences +// +// Mirrors the structure of P-2's oracle but delegates every logical line's +// keep/drop decision to it: the line's pieces (content chunks, tag/import/ +// comment removals, embeddings with their expansions) plus its terminator +// are handed to `compileMarkdown`, whose empty output is exactly "dropped" +// (a kept line always retains its terminator and an all-whitespace source +// line is kept; the terminator-less final line borrows a sentinel +// terminator, which cannot change the decision). + +/** One logical line of a compiled document (SPEC 3's line model). */ +interface LineRecord { + /** Source-text span of the line, its terminator (if any) included. */ + readonly start: number; + readonly end: number; + readonly terminated: boolean; + /** + * The nodes owning own-content bytes on the line — content bytes, the + * terminator included — whether the line is kept or dropped. + */ + readonly owners: ReadonlySet<string>; +} + +interface DocumentStructure { + /** Identity → own-content token sequence, this document's nodes. */ + readonly sequences: Map<string, SectionMoveOwnToken[]>; + /** Identity → declared `d` targets, this document's sections. */ + readonly depends: Map<string, readonly string[]>; + /** + * The logical lines in source order — a removed construct's internal + * terminators join the lines it spans into one (SPEC 3) — for 6.2's + * enumeration of the nodes with bytes on a line the move's edits touch. + */ + readonly lines: readonly LineRecord[]; +} + +/** + * The owners of own-content bytes on the line holding source offset + * `offset` (the end of an unterminated last line included); none at the + * file's end after a final terminator, where no line stands. + */ +function ownersOnLineAt( + structure: DocumentStructure, + offset: number, +): ReadonlySet<string> { + for (const line of structure.lines) { + if ( + offset >= line.start && + (offset < line.end || (offset === line.end && !line.terminated)) + ) { + return line.owners; + } + } + return new Set<string>(); +} + +type FlatEntry = + | { readonly kind: "content"; readonly owner: string; readonly text: string } + | { readonly kind: "construct"; readonly piece: MarkdownPiece } + | { + readonly kind: "token"; + readonly owner: string; + readonly token: SectionMoveOwnToken; + }; + +function flattenInto( + pieces: readonly SectionMovePiece[], + path: string, + owner: string, + entries: FlatEntry[], + register: (identity: string, depends: readonly string[]) => void, +): void { + for (const piece of pieces) { + switch (piece.kind) { + case "content": + if (piece.text.length > 0) { + entries.push({ kind: "content", owner, text: piece.text }); + } + break; + case "removal": + entries.push({ + kind: "construct", + piece: { kind: "removal", text: piece.text }, + }); + break; + case "embedding": + entries.push({ kind: "token", owner, token: ["embed", piece.target] }); + entries.push({ + kind: "construct", + piece: { + kind: "embedding", + text: piece.text, + expansion: piece.expansion, + }, + }); + break; + case "section": { + // A tag's own characters may hold terminators (a multi-line tag): + // they are deleted with the construct, joining its lines (SPEC 3), + // exactly as the delegated compile treats any multi-line removal. + const identity = `${path}#${piece.id}`; + register(identity, piece.depends); + entries.push({ kind: "token", owner, token: ["child", identity] }); + entries.push({ + kind: "construct", + piece: { kind: "removal", text: piece.open }, + }); + if (piece.close === null) { + if (piece.body.length > 0) { + misuse( + `a self-closing section has no body (SPEC 1.1); ` + + `${identity} declares ${String(piece.body.length)} piece(s)`, + ); + } + } else { + flattenInto(piece.body, path, identity, entries, register); + entries.push({ + kind: "construct", + piece: { kind: "removal", text: piece.close }, + }); + } + break; + } + } + } +} + +/** Merge strictly-adjacent content entries (always same-owner by grammar). */ +function coalesceEntries(entries: readonly FlatEntry[]): FlatEntry[] { + const out: FlatEntry[] = []; + for (const entry of entries) { + const last = out[out.length - 1]; + if ( + entry.kind === "content" && + last !== undefined && + last.kind === "content" + ) { + if (last.owner !== entry.owner) { + defect( + "adjacent content with distinct owners — a section boundary " + + "always interposes a tag", + ); + } + out[out.length - 1] = { + kind: "content", + owner: last.owner, + text: last.text + entry.text, + }; + continue; + } + out.push(entry); + } + return out; +} + +function compileDocument(document: SectionMoveDocument): DocumentStructure { + const sequences = new Map<string, SectionMoveOwnToken[]>(); + const depends = new Map<string, readonly string[]>(); + const runs = new Map<string, string>(); + const register = (identity: string, deps: readonly string[]): void => { + if (sequences.has(identity)) { + misuse(`duplicate section identity ${identity} in ${document.path}`); + } + sequences.set(identity, []); + depends.set(identity, deps); + runs.set(identity, ""); + }; + // The implicit root (SPEC 1.2): no `d` targets (5.5). + register(document.path, []); + + const entries: FlatEntry[] = []; + flattenInto(document.pieces, document.path, document.path, entries, register); + + const appendRun = (owner: string, text: string): void => { + runs.set(owner, (runs.get(owner) ?? "") + text); + }; + const flushToken = (owner: string, token: SectionMoveOwnToken): void => { + const sequence = sequences.get(owner); + if (sequence === undefined) defect(`no stream for ${owner}`); + sequence.push(["run", runs.get(owner) ?? ""], token); + runs.set(owner, ""); + }; + + type LineEvent = + | { readonly kind: "bytes"; readonly owner: string; readonly text: string } + | { + readonly kind: "token"; + readonly owner: string; + readonly token: SectionMoveOwnToken; + }; + let linePieces: MarkdownPiece[] = []; + let lineEvents: LineEvent[] = []; + const lines: LineRecord[] = []; + // Source-text offsets: the consumed prefix and the current line's start. + let offset = 0; + let lineStart = 0; + + // `offset` already stands past `terminator` when this is called. + 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) === ""; + const owners = new Set<string>(); + for (const event of lineEvents) { + if (event.kind === "token") { + flushToken(event.owner, event.token); + continue; + } + owners.add(event.owner); + if (!dropped) appendRun(event.owner, event.text); + } + if (terminator !== "" && owner !== null) { + owners.add(owner); + if (!dropped) appendRun(owner, terminator); + } + lines.push({ + start: lineStart, + end: offset, + terminated: terminator !== "", + owners, + }); + lineStart = offset; + linePieces = []; + lineEvents = []; + }; + + for (const entry of coalesceEntries(entries)) { + if (entry.kind === "construct") { + linePieces.push(entry.piece); + offset += entry.piece.text.length; + 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 }); + } + offset += chunk.length + terminator.length; + 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 }); + offset += tail.length; + } + } + finalizeLine("", null); + + for (const [identity, sequence] of sequences) { + sequence.push(["run", runs.get(identity) ?? ""]); + } + return { sequences, depends, lines }; +} + +// --------------------------------------------------------------------------- +// Graph derivation and the 5.6 cascade computation + +interface GraphNode { + readonly children: readonly string[]; + readonly edgeTargets: readonly string[]; +} + +function dedupSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function tokensJson(tokens: readonly SectionMoveOwnToken[]): string { + return JSON.stringify(tokens); +} + +function mapTokens( + tokens: readonly SectionMoveOwnToken[], + mapIdentity: (identity: string) => string, +): SectionMoveOwnToken[] { + return tokens.map(([kind, value]) => + kind === "run" ? [kind, value] : [kind, mapIdentity(value)], + ); +} + +function graphNodeOf( + tokens: readonly SectionMoveOwnToken[], + deps: readonly string[], +): GraphNode { + const children: string[] = []; + const embeds: string[] = []; + for (const [kind, value] of tokens) { + if (kind === "child") children.push(value); + else if (kind === "embed") embeds.push(value); + } + return { children, edgeTargets: dedupSorted([...deps, ...embeds]) }; +} + +/** Memoized strict-descendant sets over one side's `children` lists. */ +function strictDescendants( + graph: ReadonlyMap<string, GraphNode>, +): Map<string, Set<string>> { + const memo = new Map<string, Set<string>>(); + const visiting = new Set<string>(); + const resolve = (identity: string): Set<string> => { + const cached = memo.get(identity); + if (cached !== undefined) return cached; + if (visiting.has(identity)) { + defect(`contains-cycle through ${identity}`); + } + visiting.add(identity); + const node = graph.get(identity); + if (node === undefined) { + misuse( + `${identity} is a child of some node but has no node of its own — ` + + `otherNodes must cover every node of every untouched file`, + ); + } + const descendants = new Set<string>(); + for (const child of node.children) { + descendants.add(child); + for (const inner of resolve(child)) descendants.add(inner); + } + visiting.delete(identity); + memo.set(identity, descendants); + return descendants; + }; + for (const identity of graph.keys()) resolve(identity); + return memo; +} + +// --------------------------------------------------------------------------- +// The oracle + +export function predictSectionMoveImpact( + input: SectionMoveInput, +): SectionMovePrediction { + const { origin, movedId, newId } = input; + let targetDocument: SectionMoveDocument | null; + let targetPath: string; + if ("pieces" in input.target) { + targetDocument = input.target; + targetPath = input.target.path; + } else { + targetDocument = null; + targetPath = input.target.createdPath; + } + const created = targetDocument === null; + const coincident = + targetDocument !== null && targetDocument.path === origin.path; + if (coincident && targetDocument !== origin) { + misuse( + "a same-file move passes the identical document object as origin and target", + ); + } + if (created && targetPath === origin.path) { + misuse("the created target path collides with the origin document"); + } + + // --- The identity mapping (prefix replacement, SPEC 6.5) --- + const located = locateSection(origin.pieces, movedId, 0); + if (located === null) { + misuse(`the origin document spells no section ${JSON.stringify(movedId)}`); + } + const mapDotted = (dotted: string): string => { + if (dotted === movedId) return newId; + if (dotted.startsWith(`${movedId}.`)) { + return newId + dotted.slice(movedId.length); + } + misuse( + `section ${JSON.stringify(dotted)} inside the moved subtree does not ` + + `extend the moved id ${JSON.stringify(movedId)} (SPEC 1.3)`, + ); + }; + const identityMap = new Map<string, string>(); + const collectMapping = (section: SectionMoveSection): void => { + identityMap.set( + `${origin.path}#${section.id}`, + `${targetPath}#${mapDotted(section.id)}`, + ); + for (const piece of section.body) { + if (piece.kind === "section") collectMapping(piece); + } + }; + collectMapping(located.section); + const mapIdentity = (identity: string): string => + identityMap.get(identity) ?? identity; + + // --- Parents --- + const parentDottedOf = (dotted: string): string | null => { + const lastDot = dotted.lastIndexOf("."); + return lastDot === -1 ? null : dotted.slice(0, lastDot); + }; + const originParentDotted = parentDottedOf(movedId); + const originParent = + originParentDotted === null + ? origin.path + : `${origin.path}#${originParentDotted}`; + const targetParentDotted = parentDottedOf(newId); + if (created && targetParentDotted !== null) { + misuse( + "a created target file holds no sections, so a move creating it " + + "carries a single-segment new id (SPEC 6.5: the target parent must " + + "exist)", + ); + } + // The created root is `changed` by addition, not comparison (P-5). + const targetParent = created + ? null + : targetParentDotted === null + ? targetPath + : `${targetPath}#${targetParentDotted}`; + + // --- The insertion point (6.5), an offset into the pre-move target + // bytes: the target parent's closing tag, or the end of the document for + // a top-level new id; none when the move creates the target file --- + const targetInsertAt = + targetDocument === null + ? null + : insertionPoint( + targetDocument.pieces, + targetParentDotted, + sectionMoveSourceText(targetDocument.pieces).length, + ); + + // --- Before-side compilation --- + const originBefore = compileDocument(origin); + const targetBefore = + coincident || targetDocument === null + ? null + : compileDocument(targetDocument); + const beforeDocs: DocumentStructure[] = + targetBefore === null ? [originBefore] : [originBefore, targetBefore]; + + // --- 6.2's enumeration of what a successful move leaves `changed`: the + // parents, the moved subtree's nodes, and each other node with + // own-content bytes on a line the deletion joins or drops (the + // construct's boundary lines at the origin) or the insertion splits (the + // insertion point's line at the destination) — in current identities --- + const enumerated = new Set<string>(identityMap.values()); + enumerated.add(originParent); + if (targetParent !== null) enumerated.add(targetParent); + const enumerateOwners = (owners: ReadonlySet<string>): void => { + for (const owner of owners) enumerated.add(mapIdentity(owner)); + }; + enumerateOwners(ownersOnLineAt(originBefore, located.start)); + enumerateOwners(ownersOnLineAt(originBefore, located.end - 1)); + const destinationBefore = coincident ? originBefore : targetBefore; + if (targetInsertAt !== null && destinationBefore !== null) { + enumerateOwners(ownersOnLineAt(destinationBefore, targetInsertAt)); + } + + // --- 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 = + targetInsertAt ?? defect("a same-file move has an insertion point"); + 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 = + targetInsertAt ?? + defect("a move into an existing file has an insertion point"); + const atLineStart = atLineStartAfterDeletion(source, insertAt, null); + const spliced = insertMoved( + targetDocument.pieces, + targetParentDotted, + movedMapped, + atLineStart, + ); + if (!spliced.found) { + misuse( + `the target document spells no section ` + + `${JSON.stringify(targetParentDotted)} (a refused move; the ` + + `oracle predicts successful moves only)`, + ); + } + afterDocs.push( + compileDocument({ + path: targetPath, + pieces: mapReferencesDeep(spliced.pieces, mapIdentity), + }), + ); + } + } + + // --- Merge sides; bring the baseline into current identities --- + const beforeRaw = new Map<string, readonly SectionMoveOwnToken[]>(); + const mappedBefore = new Map<string, readonly SectionMoveOwnToken[]>(); + const mappedBeforeGraph = new Map<string, GraphNode>(); + for (const doc of beforeDocs) { + for (const [identity, tokens] of doc.sequences) { + if (beforeRaw.has(identity)) { + misuse(`identity ${identity} appears in two documents`); + } + beforeRaw.set(identity, tokens); + const mapped = mapIdentity(identity); + const mappedTokens = mapTokens(tokens, mapIdentity); + if (mappedBefore.has(mapped)) { + defect(`the identity map collapsed ${mapped}`); + } + mappedBefore.set(mapped, mappedTokens); + mappedBeforeGraph.set( + mapped, + graphNodeOf( + mappedTokens, + (doc.depends.get(identity) ?? []).map(mapIdentity), + ), + ); + } + } + const after = new Map<string, readonly SectionMoveOwnToken[]>(); + const afterGraph = new Map<string, GraphNode>(); + for (const doc of afterDocs) { + for (const [identity, tokens] of doc.sequences) { + if (after.has(identity)) { + misuse(`identity ${identity} appears in two after-side documents`); + } + after.set(identity, tokens); + afterGraph.set( + identity, + graphNodeOf(tokens, doc.depends.get(identity) ?? []), + ); + } + } + for (const node of input.otherNodes ?? []) { + if (mappedBefore.has(node.identity) || identityMap.has(node.identity)) { + misuse( + `otherNodes entry ${node.identity} belongs to a document of the move`, + ); + } + if (afterGraph.has(node.identity)) { + misuse(`duplicate otherNodes entry ${node.identity}`); + } + const graphNode: GraphNode = { + children: node.children.map(mapIdentity), + edgeTargets: dedupSorted(node.edgeTargets.map(mapIdentity)), + }; + mappedBeforeGraph.set(node.identity, graphNode); + afterGraph.set(node.identity, graphNode); + } + for (const [identity, node] of afterGraph) { + for (const target of node.edgeTargets) { + if (!afterGraph.has(target)) { + misuse( + `${identity} has a dependency-edge target ${target} that is no ` + + `node — otherNodes must cover every node of every untouched file`, + ); + } + } + } + + // --- Kept/added bookkeeping --- + for (const identity of mappedBefore.keys()) { + if (!after.has(identity)) { + defect( + `${identity} is missing on the after side — a section move deletes ` + + `no node`, + ); + } + } + const added = new Set<string>(); + for (const identity of after.keys()) { + if (!mappedBefore.has(identity)) added.add(identity); + } + const expectedAdded = created ? [targetPath] : []; + if (JSON.stringify([...added].sort()) !== JSON.stringify(expectedAdded)) { + defect( + `added identities ${JSON.stringify([...added].sort())}; expected ` + + `exactly ${JSON.stringify(expectedAdded)}`, + ); + } + + // --- The changed set: each enumerated node iff its own-content + // sequence differs across the move (P-5); a differing node outside the + // enumeration contradicts 6.2's completeness — an oracle defect --- + const changed = new Set<string>(); + for (const [identity, beforeTokens] of mappedBefore) { + const afterTokens = after.get(identity); + if (afterTokens === undefined) continue; // unreachable: guarded above + if (tokensJson(beforeTokens) === tokensJson(afterTokens)) continue; + if (!enumerated.has(identity)) { + defect( + `the own-content sequence of ${identity} differs across the move, ` + + `but the node is outside 6.2's enumeration of what a successful ` + + `move leaves changed — the parents, the moved subtree's nodes, ` + + `and each node with own-content bytes on a line the deletion ` + + `joins or drops or the insertion splits — so the oracle's edit ` + + `model contradicts SPEC 6.2 (TEST-SPEC 16 P-5)`, + ); + } + changed.add(identity); + } + for (const identity of added) changed.add(identity); + + // Dependency-edge sets are identity-stable across a section move + // (canonical identities, SPEC 5.4): guard that the two derivations agree. + for (const [identity, beforeNode] of mappedBeforeGraph) { + const afterNode = afterGraph.get(identity); + if (afterNode === undefined) continue; // unreachable: guarded above + if ( + JSON.stringify(beforeNode.edgeTargets) !== + JSON.stringify(afterNode.edgeTargets) + ) { + misuse( + `the dependency-edge target set of ${identity} differs across the ` + + `move (${JSON.stringify([...beforeNode.edgeTargets])} vs ` + + `${JSON.stringify([...afterNode.edgeTargets])}) — a section move ` + + `retargets spellings, never edges (SPEC 5.4, 6.5)`, + ); + } + } + + // --- 5.6 cascades from the changed nodes --- + const keptSet = new Set(mappedBeforeGraph.keys()); + const descBefore = strictDescendants(mappedBeforeGraph); + const descAfter = strictDescendants(afterGraph); + const descAt = ( + memo: Map<string, Set<string>>, + identity: string, + ): Set<string> => memo.get(identity) ?? new Set<string>(); + const commonChildren = (identity: string): string[] => { + const beforeNode = mappedBeforeGraph.get(identity); + const afterNode = afterGraph.get(identity); + if (beforeNode === undefined || afterNode === undefined) return []; + return beforeNode.children.filter( + (child) => keptSet.has(child) && afterNode.children.includes(child), + ); + }; + const edgeTargetsOf = (identity: string): readonly string[] => + (afterGraph.get(identity)?.edgeTargets ?? []).filter((target) => + keptSet.has(target), + ); + + // effCauses(n): the changed originators whose edits the SPEC 5.5 + // effectiveHash recursion propagates to n — n itself when changed, plus + // the causes of its both-sides children and of its dependency-edge + // targets (edge sets are identity-stable, guarded above). + const effCausesMemo = new Map<string, ReadonlySet<string>>(); + const effVisiting = new Set<string>(); + const effCauses = (identity: string): ReadonlySet<string> => { + const cached = effCausesMemo.get(identity); + if (cached !== undefined) return cached; + if (effVisiting.has(identity)) { + defect( + `dependency/contains cycle through ${identity} — staged graphs are ` + + `acyclic (SPEC 5.3)`, + ); + } + effVisiting.add(identity); + const causes = new Set<string>(); + if (changed.has(identity)) causes.add(identity); + for (const child of commonChildren(identity)) { + for (const cause of effCauses(child)) causes.add(cause); + } + for (const target of edgeTargetsOf(identity)) { + for (const cause of effCauses(target)) causes.add(cause); + } + effVisiting.delete(identity); + effCausesMemo.set(identity, causes); + return causes; + }; + + // directCauses(n): originators reaching n through a dependency edge of + // n's own — the 5.6 upstream-changed trigger at one node. + const directCauses = (identity: string): ReadonlySet<string> => { + const causes = new Set<string>(); + for (const target of edgeTargetsOf(identity)) { + for (const cause of effCauses(target)) causes.add(cause); + } + return causes; + }; + + const changedSorted = [...changed].sort(); + const changedEntry: SectionMoveCategoryPrediction = { + required: true, + attributionWithin: changedSorted, + attributionMustInclude: [], + }; + const nodes = new Map<string, SectionMoveNodePrediction>(); + for (const identity of [...afterGraph.keys()].sort()) { + const categories = new Map< + SectionMoveCategoryName, + SectionMoveCategoryPrediction + >(); + if (added.has(identity)) { + // An added node is `changed` and receives no category through its own + // hashes (SPEC 5.6; P-5: by addition, not comparison). + categories.set("changed", changedEntry); + nodes.set(identity, { categories }); + continue; + } + if (changed.has(identity)) categories.set("changed", changedEntry); + + const beforeDesc = descAt(descBefore, identity); + const afterDesc = descAt(descAfter, identity); + const bothDesc = [...beforeDesc].filter((d) => afterDesc.has(d)); + const oneSidedDesc = [...new Set([...beforeDesc, ...afterDesc])].filter( + (d) => keptSet.has(d) && !(beforeDesc.has(d) && afterDesc.has(d)), + ); + + // descendant-changed (SPEC 5.6): a changed descendant present on both + // sides makes it required; a changed relocated (one-side-only) + // descendant alone makes it tolerated-optional (T6.2-3's documented + // two-sided ambiguity), the attribution bounded by those descendants. + const changedBoth = bothDesc.filter((d) => changed.has(d)).sort(); + const changedOneSided = oneSidedDesc.filter((d) => changed.has(d)).sort(); + if (changedBoth.length > 0 || changedOneSided.length > 0) { + categories.set("descendant-changed", { + required: changedBoth.length > 0, + attributionWithin: dedupSorted([...changedBoth, ...changedOneSided]), + attributionMustInclude: changedBoth, + }); + } + + // upstream-changed (SPEC 5.6): a dependency-edge cause at the node + // itself or at a both-sides subtree member is required; a cause carried + // only by a relocated one-side-only member is tolerated-optional. + const requiredCauses = new Set<string>(directCauses(identity)); + for (const member of bothDesc) { + if (!keptSet.has(member)) continue; + for (const cause of directCauses(member)) requiredCauses.add(cause); + } + const optionalCauses = new Set<string>(); + for (const member of oneSidedDesc) { + for (const cause of directCauses(member)) { + if (!requiredCauses.has(cause)) optionalCauses.add(cause); + } + } + if (requiredCauses.size > 0 || optionalCauses.size > 0) { + categories.set("upstream-changed", { + required: requiredCauses.size > 0, + attributionWithin: dedupSorted([...requiredCauses, ...optionalCauses]), + attributionMustInclude: [...requiredCauses].sort(), + }); + } + nodes.set(identity, { categories }); + } + + return { + identityMap, + nodes, + changed, + added, + beforeOwnTokens: beforeRaw, + afterOwnTokens: after, + }; +} diff --git a/test/helpers/permissions.ts b/test/helpers/permissions.ts new file mode 100644 index 00000000..5ca62cb2 --- /dev/null +++ b/test/helpers/permissions.ts @@ -0,0 +1,614 @@ +// Permission-based stagings of environment refusals (TEST-SPEC T14-9, T14-10, +// T13.5-7; E-1). Harness machinery only: no product imports, no test +// framework dependence. +// +// - T14-9 (write refusals): an environment refusal is staged by permission +// removal alone — the directory holding the path made read-only and, where +// the path is occupied, its occupant made unwritable — so that creation, +// replacement in place or by renaming, appending, and removal are all +// refused whatever write strategy the product uses (14.24 pins the effect, +// never the mechanism). `stageWriteRefusal` is that discipline for one +// path; `stageWriteRefusalUnder` applies it to every path beneath a +// directory whose contents the harness does not know by name (graph data +// under `.xspec`, the derived files under `specs/b/`) while leaving the +// directory's own parent untouched, so a product's exclusivity state, +// wherever in the workspace it keeps it, never meets the staging. +// - T14-10 (read refusals): a refused content read is the file's read +// permission removed with its write permission kept (mode `-w-------`, +// 0o200), so a regeneration that replaces or rewrites the object is never +// itself refused; a refused directory listing is the directory's read +// permission removed with search kept (`--x------`, 0o100), so its entries +// stay reachable by name. Nonexistence is never staged as a refusal, and +// symbolic links and non-directory components are never involved: a +// staging naming an absent, symlinked, or otherwise unstageable object is a +// staging error, not a refusal. +// - E-1 self-verification: before returning, every staging verifies itself +// in the harness's own process — its own attempt at the staged object must +// be refused (`EACCES`/`EPERM`), and the permission it keeps must still be +// granted — and reports an ineffective one as `HarnessStagingError`. That +// error is deliberately NOT a `HarnessAssertionError` (H-8): a privileged +// runner, whose CAP_DAC_OVERRIDE writes into a read-only directory and +// reads a mode-0o200 file, is a harness error (H-11), never a diagnosed +// product failure, a pass, or a skip (H-9). CI makes the runner +// unprivileged through .github/scripts/run-without-network.sh; a root +// sandbox reproduces its inner stage with +// `unshare --map-user=<uid> --map-group=<gid> -- <command>` (AGENTS.md). +// - Platform: the Linux leg's (E-1). On any other platform every staging +// throws `HarnessStagingError` at once; the Linux-leg tests are never +// selected into the Windows subset (E-6). +// +// Every staging records the modes it changes and returns a `restore()` that +// reinstates them (idempotent). The workspace builder's disposal chmods its +// tree writable before removal regardless, so a staging left unrestored by a +// failing test never blocks cleanup. + +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * The staging modes, named in every `HarnessStagingError`: the permission + * stagings of this module, the workspace builder's S-9 derivability check + * of a staged MDX source (`mdx-derivability`, helpers/workspace.ts), its + * TypeScript check of a staged code source or configuration file + * (`ts-derivability`, helpers/workspace.ts and helpers/ts-derivability.ts), + * and the builder's undeclared-staging guard (`undeclared-staging`: a plain `.mdx` + * staging after a product invocation, which S-7's sweep never reaches and + * so must be a staged-source record; helpers/workspace.ts, + * helpers/product-invocations.ts). + */ +export type StagingMode = + | "write-refusal" + | "write-refusal-under" + | "read-refusal-of-file" + | "read-refusal-of-directory" + | "mdx-derivability" + | "ts-derivability" + | "undeclared-staging"; + +/** + * An ineffective or impossible staging: a permission staging (E-1, H-11) + * whose object the harness's own attempt reached unrefused (a privileged + * runner), a kept permission not granted, an object that cannot be staged + * (absent, symlinked, wrong kind), a platform that is not the Linux leg's — + * or a staged MDX source contradicting its S-9 declaration (declared + * well-formed yet rejected by the stock parser, or declared unparseable yet + * deriving; helpers/workspace.ts), a staged code source or configuration + * file contradicting its S-9 TypeScript declaration or accepted read one way + * only (`ts-derivability`, helpers/workspace.ts), or an MDX source staged + * with plain contents after a product invocation, outside the staged-source + * ledger the S-9 self-test judges before any product exists + * (`undeclared-staging`, helpers/workspace.ts). Never a + * `HarnessAssertionError`: nothing here is a product verdict — it is a + * harness error, never a diagnosed product failure and never a skip. + */ +export class HarnessStagingError extends Error { + readonly mode: StagingMode; + readonly path: string; + + constructor(mode: StagingMode, stagedPath: string, detail: string) { + super(`${mode} staging of ${stagedPath}: ${detail}`); + this.name = "HarnessStagingError"; + this.mode = mode; + this.path = stagedPath; + } +} + +/** A staging in effect: what it staged, and how to undo it. */ +export interface PermissionStaging { + readonly mode: StagingMode; + readonly path: string; + /** Reinstate every recorded mode, in reverse order of change; idempotent. */ + restore(): Promise<void>; +} + +const WRITE_BITS = 0o222; +const MODE_BITS = 0o7777; +const CONTENT_UNREADABLE = 0o200; // -w------- +const LISTING_UNREADABLE = 0o100; // --x------ + +const PRIVILEGED_HINT = + "the harness's own attempt succeeded, so the staging is ineffective — a " + + "privileged runner (E-1 requires an unprivileged identity: CI runs through " + + ".github/scripts/run-without-network.sh; as root, run under " + + "`unshare --map-user=<uid> --map-group=<gid>`, see AGENTS.md)"; + +interface ModeRecord { + readonly path: string; + readonly mode: number; +} + +function errorCode(thrown: unknown): string | undefined { + if (typeof thrown === "object" && thrown !== null && "code" in thrown) { + const code = (thrown as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; + } + return undefined; +} + +function describeError(thrown: unknown): string { + return errorCode(thrown) ?? (thrown instanceof Error ? thrown.message : ""); +} + +function freshName(): string { + return `.xspec-harness-probe-${randomBytes(6).toString("hex")}`; +} + +/** A probe attempt: resolves to an undo of its effect when not refused. */ +type Attempt = () => Promise<() => Promise<void>>; + +/** + * Run an attempt that the staging must refuse. A resolved attempt is undone + * (best effort) and reported as an ineffective staging; a rejection other + * than `EACCES`/`EPERM` is reported as a staging error too — it proves + * nothing about the permission. + */ +async function expectRefused( + mode: StagingMode, + stagedPath: string, + description: string, + attempt: Attempt, +): Promise<void> { + let undo: (() => Promise<void>) | undefined; + try { + undo = await attempt(); + } catch (thrown) { + const code = errorCode(thrown); + if (code === "EACCES" || code === "EPERM") return; + throw new HarnessStagingError( + mode, + stagedPath, + `${description} failed with ${describeError(thrown) || String(thrown)} rather than a permission refusal`, + ); + } + await undo().catch(() => undefined); + throw new HarnessStagingError( + mode, + stagedPath, + `${description} was not refused: ${PRIVILEGED_HINT}`, + ); +} + +/** Run an attempt the staging must still permit (the kept permission). */ +async function expectAllowed( + mode: StagingMode, + stagedPath: string, + description: string, + attempt: Attempt, +): Promise<void> { + let undo: () => Promise<void>; + try { + undo = await attempt(); + } catch (thrown) { + throw new HarnessStagingError( + mode, + stagedPath, + `${description} failed with ${describeError(thrown) || String(thrown)}: the staging removed more than it may`, + ); + } + await undo(); +} + +function assertLinux(mode: StagingMode, stagedPath: string): void { + if (process.platform !== "linux") { + throw new HarnessStagingError( + mode, + stagedPath, + `permission-based stagings belong to the Linux leg (E-1); this platform is ${process.platform}`, + ); + } + if (!path.isAbsolute(stagedPath)) { + throw new HarnessStagingError( + mode, + stagedPath, + "the staged path must be absolute", + ); + } +} + +type EntryKind = "absent" | "file" | "directory" | "symlink" | "other"; + +async function kindOf(target: string): Promise<EntryKind> { + let stats: fs.Stats; + try { + stats = await fsp.lstat(target); + } catch (thrown) { + if (errorCode(thrown) === "ENOENT") return "absent"; + throw thrown; + } + if (stats.isSymbolicLink()) return "symlink"; + if (stats.isFile()) return "file"; + if (stats.isDirectory()) return "directory"; + return "other"; +} + +async function assertPlainDirectory( + mode: StagingMode, + stagedPath: string, + dir: string, + role: string, +): Promise<void> { + const kind = await kindOf(dir); + if (kind !== "directory") { + throw new HarnessStagingError( + mode, + stagedPath, + `${role} ${dir} is ${kind}, not a directory (symbolic links and non-directory components are never involved)`, + ); + } +} + +async function modeOf(target: string): Promise<number> { + return (await fsp.stat(target)).mode & MODE_BITS; +} + +/** Record `target`'s mode bits, then set them to `next`. */ +async function setMode( + records: ModeRecord[], + target: string, + next: (prior: number) => number, +): Promise<void> { + const prior = await modeOf(target); + records.push({ path: target, mode: prior }); + await fsp.chmod(target, next(prior)); +} + +function makeStaging( + mode: StagingMode, + stagedPath: string, + records: readonly ModeRecord[], +): PermissionStaging { + let restored = false; + return { + mode, + path: stagedPath, + async restore(): Promise<void> { + if (restored) return; + restored = true; + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]!; + try { + await fsp.chmod(record.path, record.mode); + } catch (thrown) { + throw new HarnessStagingError( + mode, + stagedPath, + `restoring mode ${record.mode.toString(8)} of ${record.path} failed with ${describeError(thrown) || String(thrown)}`, + ); + } + } + }, + }; +} + +/** Verify, then hand the staging out — or undo it and rethrow. */ +async function verified( + staging: PermissionStaging, + verify: () => Promise<void>, +): Promise<PermissionStaging> { + try { + await verify(); + } catch (thrown) { + await staging.restore().catch(() => undefined); + throw thrown; + } + return staging; +} + +// --- write-refusal probes --------------------------------------------------- + +/** Creation in `dir`: a fresh file (`wx`) and a fresh directory. */ +async function probeCreation( + mode: StagingMode, + stagedPath: string, + dir: string, +): Promise<void> { + const file = path.join(dir, freshName()); + await expectRefused(mode, stagedPath, `creating ${file}`, async () => { + const handle = await fsp.open(file, "wx"); + return async () => { + await handle.close(); + await fsp.unlink(file); + }; + }); + const sub = path.join(dir, freshName()); + await expectRefused( + mode, + stagedPath, + `creating directory ${sub}`, + async () => { + await fsp.mkdir(sub); + return () => fsp.rmdir(sub); + }, + ); +} + +/** Opening `file` for writing (`r+`, no truncation) and for appending. */ +async function probeWriteOpen( + mode: StagingMode, + stagedPath: string, + file: string, +): Promise<void> { + for (const flags of ["r+", "a"] as const) { + await expectRefused( + mode, + stagedPath, + `opening ${file} with ${JSON.stringify(flags)}`, + async () => { + const handle = await fsp.open(file, flags); + return () => handle.close(); + }, + ); + } +} + +/** + * Renaming a fresh sibling over `target` (absent or a file), and — when the + * occupant is a file — unlinking it. The sibling lives in a scratch + * directory under the OS temp directory, where every `TestWorkspace` is + * created (one filesystem, so the rename reaches the permission check rather + * than `EXDEV`); it carries the occupant's bytes so an unrefused rename + * leaves them in place, and it backs an unrefused unlink. + */ +async function probeReplaceAndRemove( + mode: StagingMode, + stagedPath: string, + target: string, + occupied: boolean, +): Promise<void> { + const scratch = await fsp.mkdtemp( + path.join(os.tmpdir(), "xspec-harness-probe-"), + ); + const sibling = path.join(scratch, "sibling"); + try { + if (occupied) { + await fsp + .copyFile(target, sibling) + .catch(() => fsp.writeFile(sibling, "")); + } else { + await fsp.writeFile(sibling, ""); + } + await expectRefused( + mode, + stagedPath, + `renaming ${sibling} over ${target}`, + async () => { + await fsp.rename(sibling, target); + return async () => { + if (occupied) await fsp.copyFile(target, sibling); + else await fsp.unlink(target); + }; + }, + ); + if (occupied) { + await expectRefused(mode, stagedPath, `unlinking ${target}`, async () => { + await fsp.unlink(target); + return () => fsp.copyFile(sibling, target); + }); + } + } finally { + await fsp.rm(scratch, { recursive: true, force: true }); + } +} + +/** + * E-1 verification of a path-form write refusal (exported for the harness + * self-test, which runs it on an unstaged path to see the ineffective-staging + * report fire): every write the discipline refuses is attempted in this + * process and must be refused. + */ +export async function verifyWriteRefusal(target: string): Promise<void> { + const mode: StagingMode = "write-refusal"; + const dir = path.dirname(target); + await probeCreation(mode, target, dir); + const occupied = (await kindOf(target)) === "file"; + if (occupied) await probeWriteOpen(mode, target, target); + await probeReplaceAndRemove(mode, target, target, occupied); +} + +/** + * Stage a write refusal at `target` (T14-9's discipline): the directory + * holding it made read-only and, where `target` is a regular file, that + * occupant made unwritable. `target` may be absent (a creation the product + * owes) but never a directory (`stageWriteRefusalUnder`), a symbolic link, or + * any other kind. Verified on this process before returning (E-1). + */ +export async function stageWriteRefusal( + target: string, +): Promise<PermissionStaging> { + const mode: StagingMode = "write-refusal"; + assertLinux(mode, target); + const dir = path.dirname(target); + await assertPlainDirectory(mode, target, dir, "the holding directory"); + const kind = await kindOf(target); + if (kind === "directory") { + throw new HarnessStagingError( + mode, + target, + "the target is a directory; stage every path beneath it with stageWriteRefusalUnder", + ); + } + if (kind !== "absent" && kind !== "file") { + throw new HarnessStagingError( + mode, + target, + `the target is ${kind} (symbolic links and non-directory components are never involved)`, + ); + } + const records: ModeRecord[] = []; + await setMode(records, dir, (prior) => prior & ~WRITE_BITS); + if (kind === "file") { + await setMode(records, target, (prior) => prior & ~WRITE_BITS); + } + return verified(makeStaging(mode, target, records), () => + verifyWriteRefusal(target), + ); +} + +interface Subtree { + readonly dirs: string[]; + readonly files: string[]; +} + +/** Every directory (the root first) and regular file beneath `root`. */ +async function walkSubtree(root: string): Promise<Subtree> { + const dirs = [root]; + const files: string[] = []; + for (let i = 0; i < dirs.length; i++) { + const dir = dirs[i]!; + const names = (await fsp.readdir(dir)).sort(); + for (const name of names) { + const entry = path.join(dir, name); + const kind = await kindOf(entry); + if (kind === "directory") dirs.push(entry); + else if (kind === "file") files.push(entry); + } + } + return { dirs, files }; +} + +/** + * E-1 verification of an area write refusal (exported for the self-test): + * creation is attempted in every directory beneath `directory`, a write-open + * on every regular file, a rename into `directory`, and the removal of its + * first regular file. + */ +export async function verifyWriteRefusalUnder( + directory: string, +): Promise<void> { + const mode: StagingMode = "write-refusal-under"; + const { dirs, files } = await walkSubtree(directory); + for (const dir of dirs) await probeCreation(mode, directory, dir); + for (const file of files) await probeWriteOpen(mode, directory, file); + await probeReplaceAndRemove( + mode, + directory, + path.join(directory, freshName()), + false, + ); + if (files.length > 0) { + await probeReplaceAndRemove(mode, directory, files[0]!, true); + } +} + +/** + * Stage a write refusal of every path beneath `directory` — T14-9's + * discipline for an area whose write paths the harness cannot name (`.xspec`, + * `specs/b`): the directory and every directory beneath it made read-only, + * every regular file beneath made unwritable, symbolic links left alone, and + * the directory's own parent untouched. Verified before returning (E-1). + */ +export async function stageWriteRefusalUnder( + directory: string, +): Promise<PermissionStaging> { + const mode: StagingMode = "write-refusal-under"; + assertLinux(mode, directory); + await assertPlainDirectory(mode, directory, directory, "the staged area"); + const { dirs, files } = await walkSubtree(directory); + const records: ModeRecord[] = []; + for (const entry of [...dirs, ...files]) { + await setMode(records, entry, (prior) => prior & ~WRITE_BITS); + } + return verified(makeStaging(mode, directory, records), () => + verifyWriteRefusalUnder(directory), + ); +} + +// --- read-refusal probes ---------------------------------------------------- + +/** + * E-1 verification of a file read refusal (exported for the self-test): the + * read must be refused, a write-open (append, no bytes written) must succeed. + */ +export async function verifyReadRefusalOfFile(target: string): Promise<void> { + const mode: StagingMode = "read-refusal-of-file"; + await expectRefused(mode, target, `reading ${target}`, async () => { + const handle = await fsp.open(target, "r"); + return () => handle.close(); + }); + await expectAllowed( + mode, + target, + `opening ${target} for writing`, + async () => { + const handle = await fsp.open(target, "a"); + return () => handle.close(); + }, + ); +} + +/** + * Stage a refused content read of the regular file `target` (T14-10): mode + * 0o200, its write permission kept so a regeneration replacing or rewriting + * it is never refused. Nonexistence is never staged as a refusal. Verified + * before returning (E-1). + */ +export async function stageReadRefusalOfFile( + target: string, +): Promise<PermissionStaging> { + const mode: StagingMode = "read-refusal-of-file"; + assertLinux(mode, target); + const kind = await kindOf(target); + if (kind !== "file") { + throw new HarnessStagingError( + mode, + target, + `the target is ${kind}, not a regular file (nonexistence is never staged as a refusal; symbolic links are never involved)`, + ); + } + const records: ModeRecord[] = []; + await setMode(records, target, () => CONTENT_UNREADABLE); + return verified(makeStaging(mode, target, records), () => + verifyReadRefusalOfFile(target), + ); +} + +/** + * E-1 verification of a directory read refusal (exported for the self-test): + * the listing must be refused; search must be kept — the directory passes an + * execute-access check and, when `knownEntry` names one of its entries, that + * entry is reachable by name. + */ +export async function verifyReadRefusalOfDirectory( + target: string, + knownEntry?: string, +): Promise<void> { + const mode: StagingMode = "read-refusal-of-directory"; + await expectRefused(mode, target, `listing ${target}`, async () => { + await fsp.readdir(target); + return async () => undefined; + }); + await expectAllowed(mode, target, `searching ${target}`, async () => { + await fsp.access(target, fs.constants.X_OK); + return async () => undefined; + }); + if (knownEntry !== undefined) { + const entry = path.join(target, knownEntry); + await expectAllowed(mode, target, `reaching ${entry} by name`, async () => { + await fsp.lstat(entry); + return async () => undefined; + }); + } +} + +/** + * Stage a refused listing of the directory `target` (T14-10): mode 0o100, + * its search permission kept so its entries stay reachable by name. + * Verified before returning (E-1), the by-name probe using an entry listed + * before the staging when the directory holds one. + */ +export async function stageReadRefusalOfDirectory( + target: string, +): Promise<PermissionStaging> { + const mode: StagingMode = "read-refusal-of-directory"; + assertLinux(mode, target); + await assertPlainDirectory(mode, target, target, "the target"); + const knownEntry = (await fsp.readdir(target)).sort()[0]; + const records: ModeRecord[] = []; + await setMode(records, target, () => LISTING_UNREADABLE); + return verified(makeStaging(mode, target, records), () => + verifyReadRefusalOfDirectory(target, knownEntry), + ); +} diff --git a/test/helpers/product-invocations.ts b/test/helpers/product-invocations.ts new file mode 100644 index 00000000..d0e3d8fd --- /dev/null +++ b/test/helpers/product-invocations.ts @@ -0,0 +1,163 @@ +// Product-invocation tracking for the workspace builder's undeclared-staging +// guard (TEST-SPEC 17 S-9's timing and TypeScript clauses, S-7, §0 H-8; +// helpers/workspace.ts `TestWorkspace.file()`, `copyFrom()`, and +// `TestWorkspace.create()`'s initial `files`). Harness machinery only: this +// module never touches product code, and nothing here is an assertion about +// a product. +// +// S-7's sweep against the empty stub reaches a registered body's stagings +// only up to the body's FIRST product invocation — the body fails there — so +// an `.mdx` source, code source, or configuration file a body stages after +// that invocation is judged first at suite time, against a real product, +// unless it is a staged-source record (helpers/staged-mdx.ts for an MDX +// source — carrying its TypeScript declaration too at a code-group `.mdx` +// path — helpers/staged-ts.ts for a code source or configuration file) the +// S-9 self-test judged before any product existed. The builder enforces +// that form: a plain staging after an invocation at a path S-9 judges — an +// `.mdx` path, or one the TypeScript check judges — a `file()` write, or an +// initial `files` entry of a workspace created after it, throws +// `HarnessStagingError` of mode `undeclared-staging` unless its declaration +// exempts it (`unchecked`, or `per-draw` for a property draw), and so does +// an MDX record carrying no TypeScript declaration at a path the TypeScript +// check judges. This module tells the builder when an invocation has +// happened, two ways, both marked by `startProduct` (helpers/subprocess.ts — +// the one subprocess path every invocation takes, so the mark cannot be +// bypassed): +// +// - Per workspace: `TestWorkspace.create` registers the workspace root, and +// its realpath, while the workspace lives (`dispose` unregisters). An +// invocation whose working directory is a registered root, or a directory +// inside one, marks that workspace; the directory and its realpath are +// both matched, so an invocation through a symbolic link into the +// workspace (T13.4-6) or under a Windows short name resolves to it. +// - Per body: the two places that run a registered test body — the suite's +// Vitest wrapper (test/suite/declare.ts) and the certification runner +// (test/self/certification-runner.ts, which S-7's sweep and certification +// share) — run it inside `runProductTestBody`, an async-local context that +// an invocation anywhere marks. This is the reach S-7 actually has: the +// sweep stops at the body's first invocation in whatever workspace, so a +// staging into a fresh later-arm workspace the product has not touched is +// unreached too, and a per-workspace mark alone would miss it — the +// workspace's initial `files` above all, which only this mark can refuse +// (at creation the workspace's own mark cannot be set yet: its root was +// registered an instant before, and nothing has run in it). Outside such +// a context — a self-test, the E-6 fixture of helpers/e6.ts, the Windows +// leg's drive-mismatch arm (whose initial files are records of +// helpers/e6-drive-mismatch.ts for that reason) — the per-workspace mark +// stands alone, and a creation is never refused. +// +// Every subprocess the driver starts counts, whatever its binding: a +// certification fixture, the empty stub, or a compiled consumer program +// (helpers/tooling.ts) — a body reaches none of them against the stub before +// its first product invocation, so a staging after any of them is one the +// sweep never sees. + +import { AsyncLocalStorage } from "node:async_hooks"; +import * as path from "node:path"; + +/** + * A live workspace's mark: whether a product has been invoked in it. Obtain + * one from `registerWorkspaceRoot`; it is read through `invoked`. + */ +export class WorkspaceInvocationMark { + /** The registry keys this mark is filed under (root and realpath). */ + readonly keys: readonly string[]; + #invoked = false; + + /** @internal — obtain instances via `registerWorkspaceRoot`. */ + constructor(keys: readonly string[]) { + this.keys = keys; + } + + /** Whether a product has been invoked in the workspace since creation. */ + get invoked(): boolean { + return this.#invoked; + } + + /** @internal — set by `noteProductInvocation`. */ + note(): void { + this.#invoked = true; + } +} + +const liveRoots = new Map<string, WorkspaceInvocationMark>(); + +/** + * Register a live workspace under its root and the root's realpath (the + * builder calls this from `TestWorkspace.create`). A stale entry under the + * same key — a workspace never disposed whose directory was removed by other + * means and whose name a later `mkdtemp` reused — is simply replaced. + */ +export function registerWorkspaceRoot( + root: string, + realRoot: string, +): WorkspaceInvocationMark { + const keys = [...new Set([path.resolve(root), path.resolve(realRoot)])]; + const mark = new WorkspaceInvocationMark(keys); + for (const key of keys) { + liveRoots.set(key, mark); + } + return mark; +} + +/** Forget a workspace (the builder calls this from `dispose`); idempotent. */ +export function unregisterWorkspaceRoot(mark: WorkspaceInvocationMark): void { + for (const key of mark.keys) { + if (liveRoots.get(key) === mark) liveRoots.delete(key); + } +} + +interface BodyContext { + readonly id: string; + invoked: boolean; +} + +const bodyContext = new AsyncLocalStorage<BodyContext>(); + +/** + * Run one registered test body inside its own invocation context: an + * invocation anywhere during the body marks the context, and + * `productInvokedInBody` answers from it for the rest of the body. `id` is + * the test's TEST-SPEC ID, named in the guard's diagnosis. A synchronous + * throw from `body` becomes a rejection, as it would from any async body. + */ +export async function runProductTestBody<T>( + id: string, + body: () => Promise<T>, +): Promise<T> { + return await bodyContext.run({ id, invoked: false }, body); +} + +/** + * The ID of the registered test body running in this async context, if a + * product has been invoked during it — else `undefined` (no invocation yet, + * or no body context at all). + */ +export function productInvokedInBody(): string | undefined { + const context = bodyContext.getStore(); + return context !== undefined && context.invoked ? context.id : undefined; +} + +/** + * Record an invocation about to run with working directory `cwd` (whose + * realpath is `realCwd`; the caller resolves it, falling back to `cwd`): + * marks the running body's context, if any, and the live workspace whose + * root is `cwd`, `realCwd`, or an ancestor of either. + */ +export function noteProductInvocation(cwd: string, realCwd: string): void { + const context = bodyContext.getStore(); + if (context !== undefined) context.invoked = true; + for (const start of new Set([path.resolve(cwd), path.resolve(realCwd)])) { + let dir = start; + for (;;) { + const mark = liveRoots.get(dir); + if (mark !== undefined) { + mark.note(); + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } +} diff --git a/test/helpers/property.ts b/test/helpers/property.ts index 216f69b8..a08aa9ee 100644 --- a/test/helpers/property.ts +++ b/test/helpers/property.ts @@ -48,8 +48,37 @@ // rethrows it as a plain `Error` (never a diagnosed assertion failure) with // the seed attached for reproduction, matching the certification runner's // and the S-7 sweep's outcome taxonomy (H-8). +// +// A failure may decline shrinking (`fail(message, { shrinkable: false })`, +// helpers/assertions.ts): its drawn counterexample is reported as is, with +// its seed. The shrink budget is counted in property executions, so it +// bounds wall clock only while an execution is cheap; a failure whose every +// re-observation costs a full hang guard — an invocation the subprocess +// driver killed (P-11's termination clause) — would otherwise turn a bounded +// shrink into hours, past the body's own hang guard. +// +// S-9's per-draw check (TEST-SPEC 16 preamble, 17 S-9): a property whose +// generator composes sources names the files a draw stages through +// `drawSources`. Before the body — and so the product — sees a draw, the +// initial trial and every shrunk candidate alike, each `.mdx` source is +// judged by `deriveMdx` (helpers/mdx-derivability.ts, the check the S-9 +// vectors and the workspace builder use), and each code source and +// configuration file — a name `TS_DEFAULT_SUFFIXES` reaches, or an entry +// marked `"code-source"` (a code group globs any name) — by the TypeScript +// check (`judgeTsDeclaration` under the `per-draw` declaration, +// helpers/workspace.ts and helpers/ts-derivability.ts: TypeScript 5.9.3's +// parser at ESNext, accepted both as module code and as script code). A +// source that does not derive, or is not well-formed TypeScript, is a +// harness error carrying the seed (H-10): never a diagnosed failure, never +// a draw to skip — a generator composing an ill-formed form is a harness +// defect. The workspace builder's own staging-time check is the second +// line: a `HarnessStagingError` it throws while the body runs is rethrown +// as a harness error naming the seed and the refused staging. import { HarnessAssertionError } from "./assertions.js"; +import { deriveMdx } from "./mdx-derivability.js"; +import { HarnessStagingError } from "./permissions.js"; +import { TS_DEFAULT_SUFFIXES, judgeTsDeclaration } from "./workspace.js"; /** Environment variable selecting the seed mode (E-5); see the module header. */ export const PROPERTY_SEED_ENV = "XSPEC_PROPERTY_SEED"; @@ -202,8 +231,46 @@ export interface PropertyOptions<T> { * other mode (H-10). */ readonly entropy?: () => number; + /** + * S-9's per-draw check (TEST-SPEC 16 preamble; module header): the files + * a draw stages, each as `[path, contents]` with an optional label (e.g. + * which edit of the trial stages it) and an optional role. Before the + * property body runs on the draw — the initial trial and each shrunk + * candidate alike — every entry whose path ends in `.mdx` is judged for + * derivability under the grammar 14.20 fixes, and every code source and + * configuration file — an entry whose path ends in one of + * `TS_DEFAULT_SUFFIXES`, or one whose role is `"code-source"` — is judged + * well-formed TypeScript under 14.20 (TypeScript 5.9.3 at ESNext, TSX or + * plain as the path selects, accepted both as module code and as script + * code); other entries are ignored, so a generator may hand over its + * whole staged file map. A failing source is a harness error naming the + * path, the parser's reason, and the seed (H-10) — never a diagnosed + * failure, never a skipped draw. Generated sources carry no S-9 + * allowance: the generators compose valid workspaces by construction + * (16), and no draw is declared unparseable. + */ + readonly drawSources?: (value: T) => Iterable<DrawSource>; } +/** + * One file a draw stages, for {@link PropertyOptions.drawSources}: its + * workspace-relative path, its bytes, an optional label distinguishing + * several stagings of one path within a trial, and an optional role: + * `"code-source"` marks a code source a code group discovers at a name + * `TS_DEFAULT_SUFFIXES` does not reach (P-7's capture sources), judged as + * TypeScript all the same (S-9; SPEC 14.20 selects plain TypeScript for + * any name but `.tsx`). + */ +export type DrawSource = readonly [ + path: string, + contents: string | Uint8Array, + label?: string, + role?: DrawSourceRole, +]; + +/** The role a {@link DrawSource} may declare beyond what its name implies. */ +export type DrawSourceRole = "code-source"; + /** How the effective seed set was chosen; see the module header. */ export type SeedMode = "fixed" | "env" | "randomized"; @@ -235,6 +302,8 @@ export class PropertyFalsifiedError extends HarnessAssertionError { readonly shrinkSteps: number; /** Property executions spent shrinking. */ readonly shrinkExecutions: number; + /** True when the failure declined shrinking (`HarnessAssertionError.shrinkable`). */ + readonly shrinkDeclined: boolean; constructor(details: { readonly propertyName: string; @@ -248,9 +317,11 @@ export class PropertyFalsifiedError extends HarnessAssertionError { readonly assertionMessage: string; readonly shrinkSteps: number; readonly shrinkExecutions: number; + readonly shrinkDeclined: boolean; }) { - const shrinkNote = - details.shrinkSteps > 0 + const shrinkNote = details.shrinkDeclined + ? "\n (reported as drawn: this failure declines shrinking — each re-observation would cost a full hang guard)" + : details.shrinkSteps > 0 ? `\n shrunk from: ${details.renderedInitialValue}\n (${String(details.shrinkSteps)} accepted shrink steps, ${String(details.shrinkExecutions)} property executions)` : "\n (already minimal: no shrink candidate was accepted)"; super( @@ -269,6 +340,7 @@ export class PropertyFalsifiedError extends HarnessAssertionError { this.assertionMessage = details.assertionMessage; this.shrinkSteps = details.shrinkSteps; this.shrinkExecutions = details.shrinkExecutions; + this.shrinkDeclined = details.shrinkDeclined; } } @@ -392,6 +464,17 @@ export async function checkProperty<T>( cause: error, }); } + try { + checkDrawSources(generated.value, options.drawSources); + } catch (error) { + throw harnessError({ + name, + seed, + phase: `checking trial ${String(trial)} of ${String(runs)} (S-9: every MDX source a draw stages derives, and every code source and configuration file it stages is well-formed TypeScript)`, + cause: error, + renderedInput: renderValue(generated.value, options.render), + }); + } try { await property(generated.value); continue; @@ -400,18 +483,28 @@ export async function checkProperty<T>( throw harnessError({ name, seed, - phase: `running trial ${String(trial)} of ${String(runs)}`, + phase: nonAssertionPhase( + `running trial ${String(trial)} of ${String(runs)}`, + error, + ), cause: error, renderedInput: renderValue(generated.value, options.render), }); } - const shrunk = await shrinkFalsification( - generator, - property, - { trial: generated, error }, - maxShrinkExecutions, - { name, seed, render: options.render }, - ); + const shrunk: ShrinkResult<T> = error.shrinkable + ? await shrinkFalsification( + generator, + property, + { trial: generated, error }, + maxShrinkExecutions, + { + name, + seed, + render: options.render, + drawSources: options.drawSources, + }, + ) + : { final: { trial: generated, error }, steps: 0, executions: 0 }; throw new PropertyFalsifiedError({ propertyName: name, seed, @@ -424,12 +517,41 @@ export async function checkProperty<T>( assertionMessage: shrunk.final.error.message, shrinkSteps: shrunk.steps, shrinkExecutions: shrunk.executions, + shrinkDeclined: !error.shrinkable, }); } } } } +/** + * Draw a generator's trials exactly as {@link checkProperty} draws them under + * the fixed seed plan (E-5: the CI seed set) — per seed a fresh PRNG and + * `runs` sequential trials — without running any property body. S-8 replays + * the suite's generators through this to measure the scales the fixed seed + * set actually stages against the harness's derived capacity bounds (H-11). + */ +export function drawFixedSeedTrials<T>( + generator: Gen<T>, + runs: number, + seeds: readonly number[] = DEFAULT_PROPERTY_SEEDS, +): T[] { + if (!Number.isInteger(runs) || runs <= 0) { + throw new Error( + `drawFixedSeedTrials: runs must be a positive integer, got ${String(runs)}`, + ); + } + validateSeeds("drawFixedSeedTrials", seeds); + const values: T[] = []; + for (const seed of seeds) { + const rng = new Mulberry32(seed); + for (let trial = 1; trial <= runs; trial += 1) { + values.push(generateTrial(generator, rng).value); + } + } + return values; +} + // --------------------------------------------------------------------------- // Seeded generation: PRNG, choice source, trials. @@ -687,6 +809,7 @@ async function shrinkFalsification<T>( readonly name: string; readonly seed: number; readonly render: ((value: T) => string) | undefined; + readonly drawSources: ((value: T) => Iterable<DrawSource>) | undefined; }, ): Promise<ShrinkResult<T>> { let current = initial; @@ -704,6 +827,24 @@ async function shrinkFalsification<T>( const replayed = replayTrial(generator, candidate); if (replayed === null) return false; if (!shortlexLess(replayed.tape, current.trial.tape)) return false; + // A shrunk candidate is a draw like any other: its staged sources are + // checked before the body sees them (S-9), and an ill-formed one is a + // harness defect — never "candidate rejected" (a generator composing it + // on any tape is the defect). + try { + checkDrawSources(replayed.value, context.drawSources); + } catch (error) { + throw harnessError({ + name: context.name, + seed: context.seed, + phase: + "shrinking (S-9: an MDX source a shrunk input stages does not " + + "derive, or a code source or configuration file it stages is not " + + "well-formed TypeScript)", + cause: error, + renderedInput: renderValue(replayed.value, context.render), + }); + } executions += 1; try { await property(replayed.value); @@ -715,8 +856,10 @@ async function shrinkFalsification<T>( throw harnessError({ name: context.name, seed: context.seed, - phase: + phase: nonAssertionPhase( "shrinking (the property threw a non-assertion error on a shrunk input)", + error, + ), cause: error, renderedInput: renderValue(replayed.value, context.render), }); @@ -842,6 +985,70 @@ function harnessError(details: { ); } +const UTF8 = new TextEncoder(); + +/** + * S-9's per-draw check (module header): judge every source the draw stages. + * The first `.mdx` source that does not derive throws a + * `HarnessStagingError` of mode `mdx-derivability`, spelled as the workspace + * builder spells its own refusal, so both lines of the check report alike; + * the first code source or configuration file that is not well-formed + * TypeScript throws the builder's own `ts-derivability` refusal of a + * `per-draw` staging (`judgeTsDeclaration`, one code path), which names the + * generator as the defect. + */ +function checkDrawSources<T>( + value: T, + drawSources: ((value: T) => Iterable<DrawSource>) | undefined, +): void { + if (drawSources === undefined) return; + for (const [path, contents, label, role] of drawSources(value)) { + const where = label === undefined ? path : `${path} (${label})`; + if (path.endsWith(".mdx")) checkDrawMdx(where, contents); + if (role === "code-source" || hasTsDefaultSuffix(path)) { + const bytes = + typeof contents === "string" ? UTF8.encode(contents) : contents; + // The staged path selects the grammar (TSX for a `.tsx` name, plain + // TypeScript for any other, SPEC 14.20); the label only names it. + judgeTsDeclaration(where, bytes, "per-draw", path); + } + } +} + +/** Whether S-9's TypeScript default reaches a draw's staged path by name. */ +function hasTsDefaultSuffix(path: string): boolean { + return TS_DEFAULT_SUFFIXES.some((suffix) => path.endsWith(suffix)); +} + +/** One `.mdx` source of a draw, judged by `deriveMdx` (S-9). */ +function checkDrawMdx(where: string, contents: string | Uint8Array): void { + const verdict = deriveMdx(contents); + if (verdict.derives) return; + const at = + verdict.position === undefined + ? "" + : ` at line ${String(verdict.position.line)}, column ${String(verdict.position.column)} (offset ${String(verdict.position.offset)})`; + throw new HarnessStagingError( + "mdx-derivability", + where, + "composed by the generator as well-formed (TEST-SPEC 16: generated " + + "workspaces are valid by construction) but the stock MDX 3 parser " + + `rejects it${at}: ${verdict.reason} — a generator defect (S-9), ` + + "not a product failure", + ); +} + +/** + * The phase a non-assertion error is attributed to: a workspace-builder + * refusal (`HarnessStagingError`, e.g. the S-9 staging-time check) names the + * refused staging; anything else keeps the base phase. + */ +function nonAssertionPhase(base: string, error: unknown): string { + return error instanceof HarnessStagingError + ? `${base} (the workspace builder refused the ${error.mode} staging of ${error.path})` + : base; +} + function describeCause(cause: unknown): string { return cause instanceof Error ? `${cause.name}: ${cause.message}` diff --git a/test/helpers/staged-mdx.ts b/test/helpers/staged-mdx.ts new file mode 100644 index 00000000..4df94202 --- /dev/null +++ b/test/helpers/staged-mdx.ts @@ -0,0 +1,255 @@ +// The staged-source ledger (TEST-SPEC 17 S-9, H-8). Every MDX source a +// registered test body stages after a product invocation in its workspace — +// an edit, a replacement, an arm's variant — is a deterministic fixture file +// the document declares well-formed (or unparseable, 14.20), and S-9's +// derivability check must run for it before any product exists. The builder +// judges every MDX source's staging as it is written (helpers/workspace.ts: +// an `.mdx` path, or a spec-group file of another name its staging declares +// an MDX source), but a staging a body makes after invoking the product is +// first reached at suite time, against a real product: S-7's sweep against +// the empty stub fails the body at that invocation and never gets there. +// The ledger closes the gap: +// +// - A registry module creates each such source at module load as a record +// (`stagedMdx`) carrying its bytes and its S-9 declaration together — the +// same expression the staging used, moved to module level, never re-spelled, +// so the staged bytes are identical. +// - The body passes the record to `TestWorkspace.file()`, which stages the +// record's bytes under the record's declaration (an `mdx` option beside a +// record is a contradiction and throws). +// - The self-test test/self/s9-staged-sources.test.ts loads the whole +// registry and judges every record against its declaration with the +// builder's own judge (`judgeMdxDeclaration`, one code path) — before any +// product exists. +// +// Sealing: the registry manifest (test/suite/registry/index.ts) seals the +// ledger once every registration module has loaded. A record created after +// that — from a test body at run time — would escape the self-test, so the +// registration throws: a harness defect, never tolerated. A record is +// unforgeable — the class's constructor is the registration — so a record +// `file()` accepts is necessarily one the self-test judged, and a record is +// never `unchecked` (that declaration is P-8's fuzz mutations' alone; a +// record exists to be judged). +// +// The initial files of a workspace declaration are records too, wherever +// S-7's sweep does not reach them: a body's FIRST workspace's initial files +// are reached against the stub and may stay plain contents, but the initial +// MDX sources of a workspace the body creates after its first product +// invocation — a later arm's, a helper's twin — are deterministic fixtures +// the sweep never sees, so each is a record passed in the declaration's +// `files` (the record-accepting `InitialFileContents`) and staged by +// `TestWorkspace.create()` under the record's declaration, exactly as +// `file()` stages one (the workspace declaration naming the record's path +// is a contradiction and throws); a plain MDX entry there is refused at +// creation, as a plain `file()` staging after an invocation is. +// +// A record makes its path an MDX source whatever the name: a spec-group +// file not named `.mdx` — invalid (SPEC 7.1, 14.19), yet judged by 14.20 — +// takes a record exactly as an `.mdx` path does, the record carrying the +// declaration the workspace's `mdx` lists (`wellFormed`, `unparseable`, …) +// or a `file()` option would otherwise give the path. +// +// What is NOT a ledger record: a property draw (judged per draw by the +// property runner, S-9's property clause, and staged under the `per-draw` +// declaration — `file()`'s option, or the workspace declaration's `perDraw` +// list for a draw's initial files — by the section-16 modules alone); a +// P-8 mutation (`unchecked`); and an edit of bytes the product itself +// wrote — a rename's or move's rewritten source, which no harness constant +// equals — which `TestWorkspace.edit()` stages from the workspace's current +// bytes, judged at staging time (not a deterministic fixture: before any +// product exists there is nothing to judge) — as `TestWorkspace.copyFrom()` +// carries another workspace's product-written bytes into a fresh one. The +// builder's undeclared-staging guard (helpers/workspace.ts, +// helpers/product-invocations.ts) refuses every other plain MDX staging +// made after a product invocation — a `file()` write, and an initial +// `files` entry of a workspace created after the running body's first +// invocation alike — so an omission from the ledger is a harness error at +// the first run that reaches the site. +// +// Code sources and configuration files have sibling records of their own +// (helpers/staged-ts.ts), sealed by the manifest beside this ledger and +// judged by the same self-test with S-9's TypeScript check. An `.mdx` path a +// code group discovers is an MDX source and a code source at once, so its +// record here carries its TypeScript declaration too (`ts`): the self-test +// judges those bytes as plain TypeScript as well, and the builder stages +// them under both declarations. After a product invocation, a record +// carrying no `ts` at a path the TypeScript check judges is refused by the +// undeclared-staging guard's TypeScript arm: its TypeScript reading was +// never judged before any product existed. + +import { MDX_ALLOWANCES } from "./mdx-derivability.js"; +import type { TsDeclaration } from "./ts-derivability.js"; +import type { FileContents, MdxFileDeclaration } from "./workspace.js"; + +const ledger: StagedMdx[] = []; +const names = new Set<string>(); +let sealed = false; + +/** + * The declarations a record may carry: never `unchecked` (a record exists + * to be judged), never `per-draw` (a property draw's alone, judged per draw + * by the property runner). + */ +export type RecordDeclaration = Exclude< + MdxFileDeclaration, + "unchecked" | "per-draw" +>; + +/** + * A staged MDX source with its S-9 declaration: the exact bytes a test body + * stages after a product invocation, and whether the document declares them + * well-formed (the default), unparseable (14.20), or well-formed under named + * early-error allowances. Constructing one registers it in the ledger (use + * `stagedMdx`); records are immutable and uniquely named. + */ +export class StagedMdx { + /** `"<TEST-ID> <what it stages>"` — unique across the registry. */ + readonly name: string; + /** The staged bytes, exactly as `TestWorkspace.file()` writes them. */ + readonly source: FileContents; + /** The S-9 declaration in effect for the staging. */ + readonly mdx: RecordDeclaration; + /** + * The S-9 TypeScript declaration of the same bytes, when the staged + * `.mdx` path is also a code source — a code group globbing `.mdx` names + * discovers it (SPEC 7.2; T2.1-2's `docs/EXTRA.mdx`) — judged as plain + * TypeScript too (an `.mdx` name selects plain TypeScript, 14.20); the + * record then carries the path's TypeScript declaration as well, in place + * of the workspace declaration's `ts` entry. Undefined for every other + * record: the path is no code source, and S-9's TypeScript check does not + * judge it. + */ + readonly ts: TsDeclaration | undefined; + + constructor( + name: string, + source: FileContents, + mdx: MdxFileDeclaration = "well-formed", + ts?: TsDeclaration, + ) { + if (sealed) { + throw new Error( + `staged-source ledger: the record ${JSON.stringify(name)} is created ` + + "after the ledger was sealed — records are created at module " + + "load by the registry modules, never at run time, so that " + + "test/self/s9-staged-sources.test.ts judges every one of them " + + "before any product exists (S-9, H-8)", + ); + } + if (typeof name !== "string" || name.trim().length === 0) { + throw new Error( + "staged-source ledger: a record needs a non-empty name of the form " + + '"<TEST-ID> <what it stages>"', + ); + } + if (names.has(name)) { + throw new Error( + `staged-source ledger: duplicate record name ${JSON.stringify(name)}`, + ); + } + if (!(typeof source === "string" || source instanceof Uint8Array)) { + throw new Error( + `staged-source ledger: the record ${JSON.stringify(name)} needs ` + + "string or byte contents", + ); + } + if (ts !== undefined && ts !== "well-formed" && ts !== "unparseable") { + throw new Error( + `staged-source ledger: the record ${JSON.stringify(name)} carries ` + + `the TypeScript declaration ${JSON.stringify(ts)} — a record's ` + + 'TypeScript declaration is "well-formed" or "unparseable" (14.20), ' + + "or absent for a path no code group discovers; a record exists to " + + "be judged (S-9)", + ); + } + this.name = name; + this.source = source; + this.mdx = validateDeclaration(name, mdx); + this.ts = ts; + Object.freeze(this); + names.add(name); + ledger.push(this); + } +} + +/** + * Register a staged MDX source in the ledger at module load: `source` is the + * very expression the staging used (moved, never re-spelled), `mdx` the + * declaration in effect for that staging — the former `mdx` option, else the + * workspace declaration's entry for the path, else well-formed — and `ts`, + * for a path a code group also discovers as a code source, the TypeScript + * declaration in effect for it (the former `ts` option, else the workspace + * declaration's `ts` entry). + */ +export function stagedMdx( + name: string, + source: FileContents, + mdx: MdxFileDeclaration = "well-formed", + ts?: TsDeclaration, +): StagedMdx { + return new StagedMdx(name, source, mdx, ts); +} + +/** Every record registered so far, in registration order. */ +export function stagedMdxLedger(): readonly StagedMdx[] { + return ledger; +} + +/** + * Seal the ledger: called once by the registry manifest after every + * registration module has loaded. Any later registration throws. + */ +export function sealStagedMdxLedger(): void { + if (sealed) { + throw new Error("staged-source ledger: sealed twice"); + } + sealed = true; + Object.freeze(ledger); +} + +/** Whether the ledger is sealed (the registry manifest has loaded). */ +export function isStagedMdxLedgerSealed(): boolean { + return sealed; +} + +function validateDeclaration( + name: string, + mdx: MdxFileDeclaration, +): RecordDeclaration { + if (mdx === "well-formed" || mdx === "unparseable") return mdx; + if (mdx === "per-draw") { + throw new Error( + `staged-source ledger: the record ${JSON.stringify(name)} is declared ` + + "`per-draw` — that declaration is a property draw's alone (judged " + + "per draw by the property runner, section-16 modules); a record is " + + "a deterministic fixture, judged by the self-test before any " + + "product exists (S-9), so declare it well-formed, unparseable, or " + + "under named allowances", + ); + } + if (mdx === "unchecked") { + throw new Error( + `staged-source ledger: the record ${JSON.stringify(name)} is declared ` + + "`unchecked` — a record exists to be judged; `unchecked` is for a " + + "source whose derivability the document does not declare (a P-8 " + + "mutation), staged as plain contents (S-9)", + ); + } + if ( + typeof mdx === "object" && + mdx !== null && + Array.isArray(mdx.allowances) && + mdx.allowances.length > 0 && + mdx.allowances.every((allowance) => + (MDX_ALLOWANCES as readonly string[]).includes(allowance), + ) + ) { + return { allowances: [...mdx.allowances] }; + } + throw new Error( + `staged-source ledger: the record ${JSON.stringify(name)} carries an ` + + `invalid declaration ${JSON.stringify(mdx)} — "well-formed", ` + + `"unparseable", or { allowances: [...] } naming allowances among ` + + JSON.stringify(MDX_ALLOWANCES), + ); +} diff --git a/test/helpers/staged-ts.ts b/test/helpers/staged-ts.ts new file mode 100644 index 00000000..7bb2ef68 --- /dev/null +++ b/test/helpers/staged-ts.ts @@ -0,0 +1,211 @@ +// The staged-source ledger's TypeScript records (TEST-SPEC 17 S-9's +// TypeScript clause and its timing clause, H-8) — the sibling of +// helpers/staged-mdx.ts for code sources and configuration files. Every code +// source and configuration file the document declares well-formed (or +// unparseable, 14.20) is a deterministic fixture file S-9's TypeScript check +// must judge before any product exists: accepted by TypeScript 5.9.3 both as +// module code and as script code, or rejected both ways. The builder judges +// every such staging as it is written (helpers/workspace.ts `checkTs`), but +// a staging a registered body makes after its first product invocation — a +// reconfiguration after a `build`, an arm's variant code source, the +// `xspec.config.ts` of a workspace the body creates after that invocation — +// is first reached at suite time, against a real product: S-7's sweep +// against the empty stub fails the body at that invocation and never gets +// there. These records close the gap exactly as the MDX ledger does: +// +// - A registry module creates each such file's contents at module load as a +// record (`stagedTs`) carrying its bytes, its S-9 declaration, and the +// grammar it is judged under — the same expression the staging used, +// moved to module level, never re-spelled, so the staged bytes are +// identical. +// - The body passes the record to `TestWorkspace.file()`, or as an initial +// `files` entry of `TestWorkspace.create()`, which stages the record's +// bytes under the record's declaration (a `ts` option beside the record, +// or a workspace `ts` declaration naming its path, is a contradiction and +// throws, as is a record whose grammar the path does not select). +// - The self-test test/self/s9-staged-sources.test.ts loads the whole +// registry and judges every record against its declaration with the +// builder's own judge (`judgeTsDeclaration`, one code path) — before any +// product exists. +// +// The grammar: SPEC 14.20 selects TSX for a file name ending `.tsx` and +// plain TypeScript for any other, and the record is judged before it has a +// path, so it names its grammar itself (`"ts"`, the default, or `"tsx"`), +// and the builder refuses to stage it at a path selecting the other grammar +// — the self-test's verdict and the staging-time verdict are one verdict. +// +// Sealing: the registry manifest (test/suite/registry/index.ts) seals this +// ledger beside the MDX ledger once every registration module has loaded; a +// record created after that — from a test body at run time — would escape +// the self-test, so the registration throws. A record is never `unchecked` +// (that declaration is for a file whose well-formedness the document does +// not declare — P-8's and P-11's mutations, noise files — staged as plain +// contents; a record exists to be judged). +// +// What is NOT a record: a property draw's composed code source or +// configuration (P-7's configurations and capture sources, P-13's +// configuration and code sources — generated per trial, so no module-level +// record can hold them; judged by the property runner before the body sees +// the draw (helpers/property.ts `drawSources`), their forms by the fixed +// TypeScript vector sets before any product exists, and staged under the +// `per-draw` TypeScript declaration, `ts.perDraw` at creation or +// `{ ts: "per-draw" }` per `file()` call, judged well-formed at staging, by +// the section-16 modules alone — S-9's property clause); an +// `unchecked` mutation, noise file, or tampered product-written module; and +// an edit of bytes the product itself wrote (a rename's or move's rewritten +// code source, which no harness constant equals), staged by +// `TestWorkspace.edit()` or carried into a fresh workspace by +// `TestWorkspace.copyFrom()`. The builder's undeclared-staging guard +// (helpers/workspace.ts, its TypeScript arm; helpers/product-invocations.ts) +// refuses every other plain staging of a code source or configuration file +// made after a product invocation — a `file()` write, an initial `files` +// entry of a workspace created after the running body's first invocation, +// a `copyFrom()` out of a workspace no product touched — and an MDX record +// carrying no TypeScript declaration at a path the TypeScript check judges, +// so an omission from the ledger is a harness error at the first run that +// reaches the site. + +import type { TsDeclaration } from "./ts-derivability.js"; +import type { FileContents } from "./workspace.js"; + +/** + * The grammar SPEC 14.20 selects by file name: `tsx` for a name ending + * `.tsx`, `ts` (plain TypeScript) for any other. + */ +export type TsGrammar = "ts" | "tsx"; + +const ledger: StagedTs[] = []; +const names = new Set<string>(); +let sealed = false; + +/** + * A staged code source or configuration file with its S-9 declaration: the + * exact bytes a test body stages after a product invocation, whether the + * document declares them well-formed (the default) or unparseable (14.20), + * and the grammar they are judged under. Constructing one registers it in + * the ledger (use `stagedTs`); records are immutable and uniquely named. + */ +export class StagedTs { + /** `"<TEST-ID> <what it stages>"` — unique across the TypeScript records. */ + readonly name: string; + /** The staged bytes, exactly as the builder writes them. */ + readonly source: FileContents; + /** The S-9 declaration in effect for the staging. */ + readonly ts: TsDeclaration; + /** The grammar the record is judged under; the staged path must select it. */ + readonly grammar: TsGrammar; + + constructor( + name: string, + source: FileContents, + ts: TsDeclaration = "well-formed", + grammar: TsGrammar = "ts", + ) { + if (sealed) { + throw new Error( + `staged-source ledger: the TypeScript record ${JSON.stringify(name)} ` + + "is created after the ledger was sealed — records are created at " + + "module load by the registry modules, never at run time, so that " + + "test/self/s9-staged-sources.test.ts judges every one of them " + + "before any product exists (S-9, H-8)", + ); + } + if (typeof name !== "string" || name.trim().length === 0) { + throw new Error( + "staged-source ledger: a TypeScript record needs a non-empty name " + + 'of the form "<TEST-ID> <what it stages>"', + ); + } + if (names.has(name)) { + throw new Error( + `staged-source ledger: duplicate TypeScript record name ${JSON.stringify(name)}`, + ); + } + if (!(typeof source === "string" || source instanceof Uint8Array)) { + throw new Error( + `staged-source ledger: the TypeScript record ${JSON.stringify(name)} ` + + "needs string or byte contents", + ); + } + if (ts !== "well-formed" && ts !== "unparseable") { + throw new Error( + `staged-source ledger: the TypeScript record ${JSON.stringify(name)} ` + + `is declared ${JSON.stringify(ts)} — a record is declared ` + + '"well-formed" or "unparseable" (14.20); `unchecked` is for a file ' + + "whose well-formedness the document does not declare (a P-8 or " + + "P-11 mutation, a noise file), staged as plain contents, and a " + + "record exists to be judged (S-9)", + ); + } + if (grammar !== "ts" && grammar !== "tsx") { + throw new Error( + `staged-source ledger: the TypeScript record ${JSON.stringify(name)} ` + + `names the grammar ${JSON.stringify(grammar)} — "ts" (plain ` + + 'TypeScript, any name but `.tsx`) or "tsx" (SPEC 14.20)', + ); + } + this.name = name; + this.source = source; + this.ts = ts; + this.grammar = grammar; + Object.freeze(this); + names.add(name); + ledger.push(this); + } +} + +/** + * Register a staged code source or configuration file at module load: + * `source` is the very expression the staging used (moved, never + * re-spelled), `ts` the declaration in effect for that staging — the former + * `ts` option, else the workspace declaration's entry for the path, else + * well-formed — and `grammar` the one the staged path selects (`"tsx"` for a + * `.tsx` path, else `"ts"`). + */ +export function stagedTs( + name: string, + source: FileContents, + ts: TsDeclaration = "well-formed", + grammar: TsGrammar = "ts", +): StagedTs { + return new StagedTs(name, source, ts, grammar); +} + +/** Every TypeScript record registered so far, in registration order. */ +export function stagedTsLedger(): readonly StagedTs[] { + return ledger; +} + +/** + * Seal the TypeScript records: called once by the registry manifest after + * every registration module has loaded. Any later registration throws. + */ +export function sealStagedTsLedger(): void { + if (sealed) { + throw new Error("staged-source ledger: TypeScript records sealed twice"); + } + sealed = true; + Object.freeze(ledger); +} + +/** Whether the TypeScript records are sealed (the manifest has loaded). */ +export function isStagedTsLedgerSealed(): boolean { + return sealed; +} + +/** + * The grammar SPEC 14.20 selects for a file name: `tsx` for a name ending + * `.tsx` (matched as SPEC spells the suffix), `ts` for any other. + */ +export function tsGrammarOf(name: string): TsGrammar { + return name.endsWith(".tsx") ? "tsx" : "ts"; +} + +/** + * A neutral file name of a grammar, handed to the TypeScript judge in place + * of a path when a record is judged before it is staged (the judge reads + * only the name's `.tsx` suffix; helpers/ts-derivability.ts). + */ +export function tsGrammarFileName(grammar: TsGrammar): string { + return grammar === "tsx" ? "staged-source.tsx" : "staged-source.ts"; +} diff --git a/test/helpers/subprocess.ts b/test/helpers/subprocess.ts index 26eed879..02c05bb0 100644 --- a/test/helpers/subprocess.ts +++ b/test/helpers/subprocess.ts @@ -18,7 +18,12 @@ // - Robustness (H-8): a hanging child is killed and converted into a // diagnosed timeout failure (never a skip, never a harness hang); a missing // executable or working directory is a diagnosed per-test failure, not a -// harness crash; runaway output is capped, killed, and diagnosed. +// harness crash; runaway output is capped and killed, surfacing as a loud +// `ProductRunOutputOverflowError` — an exhausted capture limit is a +// harness error, never a silent truncation (H-11). Every helper that +// converts a run's rejection into a diagnosed failure lets that error +// through unchanged (`rethrowOutputOverflow`), the driver's own +// `waitForFile` included. // - 13.5 support: background start (`startProduct`), hold-file choreography // (`createHoldFile` / `RunningProduct.waitForFile` / `releaseHoldFile`), // process kill, and concurrent invocations (every run is independent). @@ -36,6 +41,21 @@ // merges over the sanitized base, the invocation's env merges last // (`undefined` removes a variable) — tests that vary the environment // deliberately (T12.0-7) set it explicitly. +// - Undeclared-staging guard (S-9, H-8): right before spawning, every +// invocation is noted with its working directory +// (helpers/product-invocations.ts), marking the live workspace it runs in +// and the registered test body running it — after which the workspace +// builder refuses a plain `.mdx` staging that is not a staged-source +// record (helpers/workspace.ts). This is the one path every invocation +// takes, so the mark cannot be bypassed. +// - T6.5-22(a)'s universal assertion (helpers/added-import-identifiers.ts): +// an invocation whose argv reads as a performed `move` has its +// pre-operation sources read before it spawns, and once it exits 0 the +// identifiers of every import declaration it added are judged before +// `waitForExit` resolves — a breach rejects it with a diagnosed failure +// (`HarnessAssertionError`) — so the assertion holds whichever test +// performs the operation. The module loads only for an argv holding the +// token `move`. import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; @@ -45,11 +65,24 @@ import * as os from "node:os"; import * as path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; +import type { AddedImportCheck } from "./added-import-identifiers.js"; +import { noteProductInvocation } from "./product-invocations.js"; /** Hang guard applied to every invocation unless overridden (H-8). */ export const DEFAULT_TIMEOUT_MS = 30_000; -/** Runaway-output guard: combined stdout+stderr cap per invocation. */ -export const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +/** + * Runaway-output guard: combined stdout+stderr cap per invocation. H-11 + * dimensions it to the largest answer SPEC.md permits a conforming product + * over the inputs the suite stages — S-8 (test/self/s8-answer-scale- + * capacity.test.ts) derives that scale from the suite's own generators + * (about 204 MB: `view --text` over two depth-4096 section towers whose + * line feeds a P-8 rewrite turned into U+2028 separators, every level's + * subtree text re-emitting the levels below, spelled JSON-escaped) and gates + * this constant at no less than twice it. Memory is committed only as output + * arrives, so the cap costs ordinary runs nothing; exceeding it is a loud + * `ProductRunOutputOverflowError`, never a silent truncation. + */ +export const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024 * 1024; /** Default bound on hold-file waits (H-8: waits always terminate). */ export const DEFAULT_WAIT_FOR_FILE_TIMEOUT_MS = 10_000; @@ -93,10 +126,17 @@ export class ProductRunTimeoutError extends Error { } /** - * A run killed by the runaway-output guard (H-8): combined stdout+stderr - * exceeded the invocation's byte cap. Typed for the same reason as - * {@link ProductRunTimeoutError}: unbounded output is non-termination within - * budget for a robustness property. + * A run killed by the runaway-output guard (H-8: runaway output never hangs + * the harness): combined stdout+stderr exceeded the invocation's byte cap. + * The cap is the harness's capture limit, dimensioned to the suite's staged + * answer scale (`DEFAULT_MAX_OUTPUT_BYTES`), so exhausting it is a loud + * harness error — never a silent truncation, which is indistinguishable + * from a partial document, and never a diagnosed product failure (H-11). + * Typed so S-8 can pin that an exhausted cap fails loudly, so the + * termination properties (P-8, P-11), which convert exactly the hang-guard + * kill ({@link ProductRunTimeoutError}) into a diagnosed failure, tell the + * two kills apart, and so every other helper converting a run's rejection + * lets this one through ({@link rethrowOutputOverflow}). */ export class ProductRunOutputOverflowError extends Error { constructor(message: string) { @@ -105,6 +145,18 @@ export class ProductRunOutputOverflowError extends Error { } } +/** + * H-11 at a conversion of a driver rejection: rethrow `error` unchanged when + * the capture limit killed the run ({@link ProductRunOutputOverflowError}), + * and return otherwise. A helper that turns a rejected run — the hang-guard + * kill, a premature exit, a spawn failure — into a diagnosed failure (H-8) + * calls it first, so an exhausted capture limit always surfaces as the + * harness error it is, never as a diagnosed product failure. + */ +export function rethrowOutputOverflow(error: unknown): void { + if (error instanceof ProductRunOutputOverflowError) throw error; +} + const repoRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); /** @@ -146,6 +198,16 @@ export interface RunOptions { readonly maxOutputBytes?: number; } +/** + * The driver's two guards — the hang guard and the capture limit — as a + * helper that runs a command for registered bodies takes them, each + * defaulting to that helper's own bound. Registered bodies never pass them; + * S-8 lowers them against stand-ins to pin which kill such a helper turns + * into a diagnosed failure (the hang guard's) and which propagates as a + * harness error (the capture limit's, H-11). + */ +export type RunGuards = Pick<RunOptions, "timeoutMs" | "maxOutputBytes">; + export interface RunResult { /** Exit code, or null when the process died by signal. */ readonly exitCode: number | null; @@ -204,6 +266,21 @@ export async function startProduct( } const invocation = resolveInvocation(binding.command, fullArgs, commandLine); + // T6.5-22(a) (module header): a performed move's pre-operation sources, + // read before anything is spawned. + const addedImportCheck = await prepareAddedImportCheck( + options.cwd, + options.argv ?? [], + commandLine, + ); + // The undeclared-staging guard's mark (helpers/product-invocations.ts, + // module header): from here on, a plain `.mdx` staging in this workspace + // — or anywhere in the registered body running this — is one S-7's sweep + // never reaches. + noteProductInvocation( + options.cwd, + await fsp.realpath(options.cwd).catch(() => options.cwd), + ); const child = spawn(invocation.command, invocation.args, { cwd: options.cwd, env: childEnvironment(binding, options), @@ -215,9 +292,27 @@ export async function startProduct( commandLine, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + addedImportCheck, ); } +/** + * T6.5-22(a)'s check for an invocation (helpers/added-import-identifiers.ts), + * or undefined when its argv cannot read as a performed move — the module, + * which loads the harness's TypeScript and MDX parsers, is loaded only for + * an argv holding the token `move`. + */ +async function prepareAddedImportCheck( + cwd: string, + argv: readonly ArgvValue[], + commandLine: string, +): Promise<AddedImportCheck | undefined> { + if (!argv.includes("move")) return undefined; + const { prepareAddedImportCheck: prepare } = + await import("./added-import-identifiers.js"); + return await prepare(cwd, argv, commandLine); +} + /** Run an invocation to completion — the common foreground path. */ export async function runProduct( binding: ProductBinding, @@ -229,8 +324,9 @@ export async function runProduct( /** * A started invocation. `waitForExit` resolves with the run result (normal - * exits and requested kills alike) and rejects, diagnosed, on timeout, output - * overflow, or spawn failure. + * exits and requested kills alike) and rejects, diagnosed, on timeout, + * spawn failure, or a performed move's breach of T6.5-22(a) — and with the + * harness error `ProductRunOutputOverflowError` on output overflow (H-11). */ export class RunningProduct { readonly commandLine: string; @@ -238,6 +334,8 @@ export class RunningProduct { readonly #child: ChildProcess; readonly #exit: Promise<RunResult>; #settled = false; + /** The capture limit killed the child (its exit may not be seen yet). */ + #overflowed = false; /** @internal — obtain instances via `startProduct`. */ constructor( @@ -245,6 +343,7 @@ export class RunningProduct { commandLine: string, timeoutMs: number, maxOutputBytes: number, + addedImportCheck?: AddedImportCheck, ) { this.#child = child; this.commandLine = commandLine; @@ -252,14 +351,13 @@ export class RunningProduct { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let totalBytes = 0; - let overflowed = false; let timedOut = false; const capture = (sink: Buffer[]) => (chunk: Buffer) => { sink.push(chunk); totalBytes += chunk.length; - if (totalBytes > maxOutputBytes && !overflowed) { - overflowed = true; + if (totalBytes > maxOutputBytes && !this.#overflowed) { + this.#overflowed = true; child.kill("SIGKILL"); } }; @@ -267,11 +365,14 @@ export class RunningProduct { child.stderr?.on("data", capture(stderrChunks)); const timer = setTimeout(() => { + // The capture limit's kill came first: the run settles as that + // harness error (H-11), never relabelled a hang. + if (this.#overflowed) return; timedOut = true; child.kill("SIGKILL"); }, timeoutMs); - this.#exit = new Promise<RunResult>((resolve, reject) => { + const exit = new Promise<RunResult>((resolve, reject) => { let done = false; const settle = (complete: () => void): void => { if (done) return; @@ -299,10 +400,10 @@ export class RunningProduct { ); return; } - if (overflowed) { + if (this.#overflowed) { reject( new ProductRunOutputOverflowError( - `${commandLine} exceeded the output limit of ${maxOutputBytes} bytes and was killed (H-8: runaway output is a failure, not a harness hang).`, + `${commandLine} exceeded the output limit of ${maxOutputBytes} bytes and was killed: an exhausted capture limit is a harness error, never a silent truncation (H-11).`, ), ); return; @@ -321,6 +422,15 @@ export class RunningProduct { }); }); }); + // T6.5-22(a): a performed move's added imports, judged once it exits 0 + // and before any awaiter sees the result (helpers/subprocess.ts header). + this.#exit = + addedImportCheck === undefined + ? exit + : exit.then(async (result) => { + await addedImportCheck.verify(result); + return result; + }); // Mark rejections as observed even when a test aborts before awaiting; // awaiters of waitForExit() still receive the original rejection. this.#exit.catch(() => {}); @@ -342,8 +452,10 @@ export class RunningProduct { /** * The run's outcome. Resolves for normal exits and requested kills; rejects - * with a diagnosed error on timeout, output overflow, or spawn failure. - * Callable any number of times. + * with a diagnosed error on timeout or spawn failure, with the harness + * error `ProductRunOutputOverflowError` on output overflow (H-11), and + * with a diagnosed assertion failure when a performed move exiting 0 added + * an import T6.5-22(a) rejects. Callable any number of times. */ async waitForExit(): Promise<RunResult> { return await this.#exit; @@ -354,7 +466,10 @@ export class RunningProduct { * SPEC.md 13.5 (`--test-hold`). Fails diagnosed, never hangs (H-8): rejects * when the process exits first without creating it (the red-green path for * stub products, carrying the run outcome), and on timeout while the - * process is still running. + * process is still running. A run the capture limit killed rejects with + * that `ProductRunOutputOverflowError` itself, never folded into the + * premature-exit error: an exhausted capture limit is a harness error + * (H-11). */ async waitForFile( absPath: string, @@ -368,11 +483,15 @@ export class RunningProduct { const deadline = Date.now() + timeoutMs; for (;;) { if (await pathExists(absPath)) return; - if (this.hasExited()) { + // A capture-limit kill counts as the exit at once: the run settles + // with that error, which propagates as itself (H-11). + if (this.hasExited() || this.#overflowed) { const outcome = await this.#exit.then( summarizeResult, - (error: unknown) => - error instanceof Error ? error.message : String(error), + (error: unknown) => { + rethrowOutputOverflow(error); + return error instanceof Error ? error.message : String(error); + }, ); throw new Error( `${this.commandLine} exited before creating ${absPath} — ${outcome}`, diff --git a/test/helpers/tooling.ts b/test/helpers/tooling.ts index 5993be5c..ed4ce09d 100644 --- a/test/helpers/tooling.ts +++ b/test/helpers/tooling.ts @@ -41,12 +41,17 @@ // resolved from this repository's own pinned node_modules — a compile-time // affordance that installs nothing into the consumer workspace — and emit // with LF line endings for deterministic bytes. +// +// The standard tooling is TypeScript 5.9.3, the release SPEC.md 14.20 fixes +// (T1.4-5: never a later one), reached through the harness's own pinned +// dependency `typescript-5.9.3` (an npm alias of `typescript@5.9.3`; see +// test/vitest.config.ts) — never the product's `typescript` dependency. import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import ts from "typescript"; +import ts from "typescript-5.9.3"; import { fail } from "./assertions.js"; import type { ProductBinding, RunResult } from "./subprocess.js"; import { runProduct } from "./subprocess.js"; diff --git a/test/helpers/ts-derivability.ts b/test/helpers/ts-derivability.ts new file mode 100644 index 00000000..5d379ad0 --- /dev/null +++ b/test/helpers/ts-derivability.ts @@ -0,0 +1,326 @@ +// TEST-SPEC S-9's TypeScript check: whether a code source or a configuration +// file is well-formed TypeScript under the grammar SPEC 14.20 fixes — +// TypeScript's grammar at release 5.9.3, TSX or plain as the file name +// selects, at the language level ESNext, derivability there being that +// release's acceptance. Harness machinery only: no product imports, no I/O, +// no test-framework dependence. Its self-test is +// test/self/s9-typescript-well-formedness.test.ts; the MDX side of S-9 is +// helpers/mdx-derivability.ts. The workspace builder (helpers/workspace.ts, +// `judgeTsDeclaration`) applies it to every staged code source and +// configuration file at staging time. +// +// The check is made by a means independent of the product (S-9): the +// harness's own TypeScript, `typescript-5.9.3` (an npm alias of +// `typescript@5.9.3`; harness code never imports the product's +// `typescript`). Its parser decides: a reading accepts the text exactly when +// the parse reports no diagnostic — the scanner's errors and the parser's, +// the list a program's `getSyntacticDiagnostics` returns verbatim for a +// TypeScript file — whatever tree the error-tolerant parser builds. No +// program, binder, or checker is created, so the rules 14.20 excludes beyond +// parsing take no part: the checker's post-parse grammar checks (a misplaced +// modifier, a rest parameter that is not last), name binding (a duplicate +// declaration, and the binder's strict-mode errors), and type checking. +// +// Two readings. 14.20 makes a text well-formed when that release accepts it +// both as a module's code and as a script's, and leaves unfixed a text it +// accepts read one way only; the readings differ in how they take top-level +// `await` (`await /re/;` derives only as module code, `let a = await / 2 / +// 1;` only as script code). The release's parser reads a file as script code +// first and, when the file is a module, reparses in an await context each +// statement that may hold a top-level `await`; whether the file is a module +// is its source file's module indicator, which the `setExternalModuleIndicator` +// option of `createSourceFile` exists to set. Forcing the indicator on +// (`true`) and off (`undefined`) gives the two readings, whatever imports or +// exports the text holds; `ts.isExternalModule` confirms each took. +// +// The grammar the name selects: a name ending `.tsx` parses as TSX, any other +// name as plain TypeScript (SPEC 14.20), the suffix matched as SPEC spells it. +// The parser is handed a neutral name of the selected kind and the matching +// script kind, so nothing else of the real name takes part — given a +// declaration file's name (`.d.ts`, `.d.mts`, `.d.css.ts`, …) the release +// would parse in an ambient context and skip the top-level-await reparse. +// +// Two rules the parser does not apply are 14.20's: bytes that are not valid +// UTF-8, or that begin with a byte-order mark, are unparseable (SPEC 1.6: +// "a discovered spec or code source that is not valid UTF-8 or that begins +// with a byte-order mark is unparseable (14.20)") — the release's scanner +// itself skips a leading U+FEFF as whitespace. A string is taken as the +// file's decoded content, so a leading U+FEFF is its byte-order mark, and a +// lone surrogate, which no UTF-8 encodes, makes it unparseable (as +// `deriveMdx` takes a string). +// +// Verdicts: well-formed (both readings accept), unparseable (both reject), +// and one-way (exactly one accepts). No fixture is text accepted read one +// way only (S-9), so a one-way text is a harness error whatever its +// declaration — `tsDeclarationProblem` says so for every declaration. + +import ts from "typescript-5.9.3"; + +/** The two readings of 14.20: as a module's code and as a script's. */ +export const TS_READINGS = ["module", "script"] as const; + +export type TsReading = (typeof TS_READINGS)[number]; + +/** One syntax error a reading reports, located in the decoded text. */ +export interface TsSyntaxError { + /** TypeScript's diagnostic code (TS1121 for a legacy octal literal, …). */ + readonly code: number; + /** The diagnostic's message, flattened. */ + readonly message: string; + /** The error's start as an index into the decoded text (UTF-16 code units). */ + readonly index: number; + /** The same point as a byte offset into the text's UTF-8 encoding. */ + readonly offset: number; + /** 1-based line, counting the line terminators the release's scanner counts. */ + readonly line: number; + /** 1-based column, in UTF-16 code units. */ + readonly column: number; +} + +/** Each reading's syntax errors, in the order the release reports them. */ +export type TsReadingErrors = Readonly< + Record<TsReading, readonly TsSyntaxError[]> +>; + +export type TsVerdict = + | { readonly verdict: "well-formed" } + | { + readonly verdict: "unparseable"; + readonly reason: string; + /** Both empty when the bytes never reach the parser (SPEC 1.6). */ + readonly errors: TsReadingErrors; + } + | { + readonly verdict: "one-way"; + /** The one reading that accepts the text. */ + readonly accepts: TsReading; + readonly reason: string; + readonly errors: TsReadingErrors; + }; + +/** What the document declares of a code source or configuration file. */ +export type TsDeclaration = "well-formed" | "unparseable"; + +// TypeScript 5.9.3 keeps both fields off its public declarations: the module +// indicator `setExternalModuleIndicator` sets, and the parse's diagnostics. +interface ParsedSourceFile { + externalModuleIndicator?: unknown; + readonly parseDiagnostics?: unknown; +} + +const ENCODER = new TextEncoder(); + +/** + * One reading of `text` under the grammar `name` selects: the syntax errors + * that release's scanning and parsing report — none when it accepts. + */ +export function readTypeScript( + text: string, + name: string, + reading: TsReading, +): readonly TsSyntaxError[] { + const tsx = name.endsWith(".tsx"); + const asModule = reading === "module"; + const file = ts.createSourceFile( + tsx ? "s9-judged.tsx" : "s9-judged.ts", + text, + { + languageVersion: ts.ScriptTarget.ESNext, + setExternalModuleIndicator: (sourceFile) => { + (sourceFile as unknown as ParsedSourceFile).externalModuleIndicator = + asModule ? true : undefined; + }, + }, + false, + tsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + if (ts.isExternalModule(file) !== asModule) { + throw new Error( + `S-9's TypeScript check: the ${reading} reading did not take — ` + + `TypeScript ${ts.version} ignored the forced module indicator`, + ); + } + const diagnostics = (file as unknown as ParsedSourceFile).parseDiagnostics; + if (!Array.isArray(diagnostics)) { + throw new Error( + `S-9's TypeScript check: TypeScript ${ts.version}'s source file ` + + "carries no parse diagnostics list", + ); + } + return (diagnostics as readonly ts.Diagnostic[]).map((diagnostic) => + syntaxError(file, text, diagnostic), + ); +} + +/** + * Judges a code source or configuration file — its bytes (or decoded + * content) and its name — under SPEC 14.20's TypeScript grammar. + */ +export function judgeTypeScript( + source: Uint8Array | string, + name: string, +): TsVerdict { + const decoded = decode(source); + if (typeof decoded !== "string") { + return { + verdict: "unparseable", + reason: decoded.reason, + errors: { module: [], script: [] }, + }; + } + const errors: TsReadingErrors = { + module: readTypeScript(decoded, name, "module"), + script: readTypeScript(decoded, name, "script"), + }; + const moduleAccepts = errors.module.length === 0; + const scriptAccepts = errors.script.length === 0; + if (moduleAccepts && scriptAccepts) return { verdict: "well-formed" }; + if (!moduleAccepts && !scriptAccepts) { + return { + verdict: "unparseable", + reason: + `rejected read as module code (${describeErrors(errors.module)}) ` + + `and as script code (${describeErrors(errors.script)})`, + errors, + }; + } + const accepts: TsReading = moduleAccepts ? "module" : "script"; + const rejects: TsReading = moduleAccepts ? "script" : "module"; + return { + verdict: "one-way", + accepts, + reason: + `accepted read as ${accepts} code only, rejected read as ${rejects} ` + + `code (${describeErrors(errors[rejects])})`, + errors, + }; +} + +/** + * S-9's judgement of a declaration: undefined when the verdict agrees with + * what the document declares, else the harness error's text. A one-way text + * is an error whatever the declaration — no fixture is text accepted read + * one way only, SPEC 14.20 leaving its well-formedness unfixed. + */ +export function tsDeclarationProblem( + verdict: TsVerdict, + declared: TsDeclaration, +): string | undefined { + switch (verdict.verdict) { + case "one-way": + return ( + `declared ${declared}, but under TypeScript ${ts.version} it is ` + + `${verdict.reason} — text whose well-formedness SPEC 14.20 leaves ` + + "unfixed, which no fixture or draw may be, whatever its " + + "declaration (S-9)" + ); + case "well-formed": + return declared === "unparseable" + ? `declared unparseable, but TypeScript ${ts.version} accepts it ` + + "both as module code and as script code — SPEC 14.20 makes it " + + "well-formed" + : undefined; + case "unparseable": + return declared === "well-formed" + ? `declared well-formed, but it is not well-formed TypeScript ` + + `(${ts.version}, SPEC 14.20): ${verdict.reason}` + : undefined; + } +} + +function syntaxError( + file: ts.SourceFile, + text: string, + diagnostic: ts.Diagnostic, +): TsSyntaxError { + const index = diagnostic.start ?? 0; + const { line, character } = file.getLineAndCharacterOfPosition(index); + return { + code: diagnostic.code, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), + index, + offset: ENCODER.encode(text.slice(0, index)).length, + line: line + 1, + column: character + 1, + }; +} + +function describeErrors(errors: readonly TsSyntaxError[]): string { + const first = errors[0]; + if (first === undefined) return "no error"; + const more = errors.length > 1 ? `, and ${errors.length - 1} more` : ""; + return ( + `TS${first.code} "${first.message}" at line ${first.line}, column ` + + `${first.column} (byte offset ${first.offset})${more}` + ); +} + +/** The decoded text, or why the content is unparseable before any parse. */ +function decode( + source: Uint8Array | string, +): string | { readonly reason: string } { + let text: string; + if (typeof source === "string") { + const lone = firstLoneSurrogate(source); + if (lone !== undefined) { + return { + reason: `not encodable as UTF-8: a lone surrogate at index ${lone}`, + }; + } + text = source; + } else { + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + source, + ); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + return { + reason: + "not valid UTF-8: an invalid sequence at byte offset " + + `${firstInvalidOffset(source)} (SPEC 1.6)`, + }; + } + } + if (text.charCodeAt(0) === 0xfeff) { + return { reason: "begins with a byte-order mark, U+FEFF (SPEC 1.6)" }; + } + return text; +} + +function firstLoneSurrogate(text: string): number | undefined { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + i++; + continue; + } + return i; + } + if (code >= 0xdc00 && code <= 0xdfff) return i; + } + return undefined; +} + +/** + * Where the first ill-formed UTF-8 sequence begins, for bytes a fatal + * decoder rejects: the same decoder fed one byte at a time throws at the byte + * that makes the pending sequence ill-formed (or at the end, for a truncated + * one), and that sequence began after the last byte completing a character. + */ +function firstInvalidOffset(bytes: Uint8Array): number { + const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + let start = 0; + try { + for (let i = 0; i < bytes.length; i++) { + const chars = decoder.decode(bytes.subarray(i, i + 1), { stream: true }); + if (chars.length > 0) start = i + 1; + } + decoder.decode(); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + } + return start; +} diff --git a/test/helpers/workspace.ts b/test/helpers/workspace.ts index 54f1b61f..c9443b7d 100644 --- a/test/helpers/workspace.ts +++ b/test/helpers/workspace.ts @@ -22,6 +22,141 @@ // identical commit hashes on every platform and CI leg. Every git // invocation runs with ambient configuration disabled: no system or global // config, an isolated HOME, and all inherited `GIT_*` environment dropped. +// - Every staged MDX source is judged by S-9's derivability check +// (`deriveMdx`, helpers/mdx-derivability.ts — the stock MDX 3 parser, +// independent of the product) at staging time, before any product exists +// (H-8). The default reaches a path by its name alone: every file whose +// path ends in `.mdx` is declared well-formed, and a staging declares the +// exceptions per path — `unparseable` for a source TEST-SPEC declares +// unparseable (SPEC 14.20: invalid UTF-8, a byte-order mark, an +// MDX-syntax rejection), `allowances` for a source relying on an +// ECMAScript early error 14.20 admits (S-9's named allowances), and +// `unchecked` only for a source whose derivability the document does not +// declare (a fuzz mutation, a noise file no discovery reaches). A +// spec-group file not named `.mdx` is an MDX source too — invalid (SPEC +// 7.1, 14.19), yet judged by 14.20 whatever its name, its parse-local +// structure kept (11.2) — and its name cannot reveal that, so its staging +// declares it: `mdx: { wellFormed }` (the MDX analogue of +// `ts.wellFormed`), any other `mdx` list naming the path (`unparseable`, +// `unchecked`, `allowances`, `perDraw`), an `mdx` option on a `file()` +// call (for that write), or an MDX staged-source record at the path +// (below). A path so declared is judged exactly as an `.mdx` path is — by +// every staging, under the same verdicts, inside the same guard; an +// undeclared path not named `.mdx` is not judged. A source contradicting +// its declaration throws `HarnessStagingError` (mode `mdx-derivability`, +// naming the path and the parser's reason) — a harness error, never an +// assertion failure, never a skip. The parse is in-process and cheap at +// every scale the suite stages (the 4096-deep tower in ~0.3 s, T1.3-7's +// 4.2 MB document in ~1.4 s), so no staging is exempted for size. +// - Every staged code source and configuration file is judged the same way +// by S-9's TypeScript check (`judgeTypeScript`, helpers/ts-derivability.ts +// — the harness's own `typescript-5.9.3` parser at ESNext, read as module +// code and as script code; SPEC 14.20), at staging time, through the same +// four stagings (`create()`'s initial files, `file()`, `edit()`, +// `copyFrom()`). The default reaches a path by its name alone: every file +// whose name ends in a TypeScript or JavaScript source suffix +// (`TS_DEFAULT_SUFFIXES`: `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, +// `.mjs`, `.cjs` — so `.d.ts` names and every configuration file the suite +// stages, `xspec.config.ts` and each `--config` target, all named `.ts`) +// is declared well-formed and must be accepted both ways. A staging +// declares the exceptions per path in `ts: { unparseable, unchecked, +// wellFormed }` (or per `file()` call, `{ ts: ... }`): `unparseable` for a +// file TEST-SPEC declares unparseable (14.20: a TypeScript syntax error, +// invalid UTF-8, a byte-order mark — rejected both ways), `unchecked` for +// a file whose well-formedness the document does not declare (a fuzz +// mutation, a noise file no discovery reaches, an edit of product-written +// bytes), and `wellFormed` for a code source whose name the default does +// not reach (a code group globs any name: T7-6's `specs/a'b.md` is +// declared `unparseable`, T13.4-11(b)'s `specs/A.md` `wellFormed`). A +// contradiction throws `HarnessStagingError` (mode `ts-derivability`, +// naming the path and the parser's first error), and so does every text +// the release accepts read one way only, whatever its declaration but +// `unchecked` (no fixture is such text, S-9) — a harness error, never an +// assertion failure, never a skip. +// - An MDX source a test body stages after invoking the product in its +// workspace is passed to `file()` as a staged-source record +// (helpers/staged-mdx.ts) carrying the bytes and the S-9 declaration +// together: S-7's sweep never reaches such a staging (the body fails at +// the invocation against the stub), so the self-test +// test/self/s9-staged-sources.test.ts judges every record before any +// product exists (S-9's timing clause, H-8) through the judge the builder +// itself applies at staging time (`judgeMdxDeclaration` — one code path). +// A record makes its path an MDX source whatever the name — the per-write +// form of `mdx.wellFormed` (or `mdx.unparseable`, or named allowances) — +// so a spec-group file not named `.mdx` takes one as an `.mdx` path does. +// An edit of bytes the product itself wrote goes through `edit()`, judged +// at staging time alone: no harness constant equals them, so they are not +// a deterministic fixture. +// - A code source or configuration file a test body stages after its first +// product invocation — a `file()` write, or an initial `files` entry of a +// workspace created after that invocation — is passed as the ledger's +// TypeScript record (helpers/staged-ts.ts) carrying the bytes, the S-9 +// declaration, and the grammar the record is judged under, staged under +// the record's declaration (a `ts` option beside it, a workspace `ts` +// entry beside an initial record, an MDX source's path — an `.mdx` path, +// or one the staging declares an MDX source — and a path selecting the +// other grammar all throw); the same self-test judges every such +// record with `judgeTsDeclaration` before any product exists. A record +// makes its path judged whatever the name, as `ts.wellFormed` does. An +// `.mdx` path a code group discovers is a code source too: its MDX record +// carries the TypeScript declaration (`ts`), and the same self-test +// judges it as TypeScript as well. The undeclared-staging guard below +// holds every such staging to that form, as it holds the `.mdx` ones. +// - The undeclared-staging guard enforces that form: once a product has been +// invoked in a workspace (the subprocess driver marks it, root and +// realpath matched) or anywhere in the running registered body (the +// async-local context test/suite/declare.ts and the certification runner +// establish — S-7's reach is per body: the sweep stops at the body's +// first invocation in whatever workspace, so a staging into a fresh +// later-arm workspace is unreached too; helpers/product-invocations.ts), +// `file()` with plain contents throws `HarnessStagingError` (mode +// `undeclared-staging`) on an MDX source's path (an `.mdx` path, or one +// the staging declares an MDX source, above) unless the effective +// declaration is `unchecked` (P-8's mutations) or `per-draw` (a property +// draw the runner judged before the body saw it, S-9's property clause — +// the section-16 modules' alone), and — the guard's TypeScript arm — on +// a path S-9's TypeScript check judges (a `TS_DEFAULT_SUFFIXES` name, or +// one a `ts` option or the workspace's `ts` declaration names) unless the +// effective TypeScript declaration is `unchecked` (P-8's and P-11's +// mutations, a noise file, a tampered product-written module) or +// `per-draw` (a property draw's composed configuration or code source, +// judged well-formed at staging — the section-16 modules' alone). An MDX +// record carrying no `ts` at a path the TypeScript check judges (a +// code-group `.mdx` path) is refused likewise: the self-test judged it as +// MDX alone. `edit()` is a declared staging by construction, and so is +// `copyFrom()` — another live workspace's current bytes, the product's +// output there, carried into a fresh workspace (the H-6 two-directory +// seeding of T6.4-7, T6.5-1, T6.5-3) — except out of a workspace no +// product has been invoked in, where the bytes are the harness's own +// staging and both arms of the guard apply as to plain contents. A +// workspace declaration's initial `files` take a record too +// (`InitialFileContents`): the initial MDX sources, code sources, and +// configuration of a workspace a body creates AFTER its first product +// invocation — a later arm's, a helper's twin — are deterministic +// fixtures S-7's sweep never reaches (the body fails at that +// invocation), so each is a record `create()` stages under the record's +// declaration (a record at a key of the other kind, or beside a +// workspace-declaration entry for its path, throws as a record beside a +// `file()` option does); a body's first workspace's initial files may +// stay plain contents, reached by the sweep. The guard covers +// `create()`'s initial entries too, one code path with `file()`'s: a +// plain entry of a workspace created after the running body's first +// product invocation is refused at creation (the diagnosis names it an +// initial `files` entry, with its remedies) unless its declaration +// exempts it — `mdx.unchecked` or `mdx.perDraw` for an MDX source's +// path, `ts.unchecked` or `ts.perDraw` for a path the TypeScript check +// judges +// — and the half-built workspace is disposed. At creation only the +// per-body mark can be set — the root was registered an instant before +// and nothing has run in it — so outside a body context (a self-test, +// the E-6 fixture, the Windows leg's drive-mismatch arm) creation never +// refuses. The declaration's `perDraw` lists are the initial-file forms +// of `per-draw`: a section-16 module's draw-derived initial files +// (`mdxPathsOf` and `tsPathsOf` list a rendered map's plain `.mdx` keys +// and plain TypeScript-default keys for them). Every generated draw must +// derive (TEST-SPEC 16, S-9): the document declares no draw unparseable, +// so no per-draw declaration exempts a draw from deriving, nor a +// per-draw configuration or code source from being well-formed. import { Buffer } from "node:buffer"; import { execFile } from "node:child_process"; @@ -29,6 +164,25 @@ import * as fsp from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { promisify } from "node:util"; +import { + MDX_ALLOWANCES, + deriveMdx, + type MdxAllowance, +} from "./mdx-derivability.js"; +import { HarnessStagingError } from "./permissions.js"; +import { + type WorkspaceInvocationMark, + productInvokedInBody, + registerWorkspaceRoot, + unregisterWorkspaceRoot, +} from "./product-invocations.js"; +import { StagedMdx } from "./staged-mdx.js"; +import { StagedTs, tsGrammarOf } from "./staged-ts.js"; +import { + type TsDeclaration, + judgeTypeScript, + tsDeclarationProblem, +} from "./ts-derivability.js"; const execFileAsync = promisify(execFile); @@ -50,14 +204,199 @@ export type RelPath = string | Uint8Array; */ export type FileContents = string | Uint8Array; +/** + * An initial `files` entry of a workspace declaration: plain contents, or a + * staged-source record whose bytes `create()` stages under the record's own + * S-9 declaration — an MDX record (helpers/staged-mdx.ts) at an MDX + * source's key (an `.mdx` key, or a spec-group file's key of another name: + * the record declares its path an MDX source), a TypeScript record + * (helpers/staged-ts.ts) at a code source's or configuration file's key — + * the form of every such initial file of a workspace a body creates after + * its first product invocation, so that the S-9 self-test judged it before + * any product existed (module header). A record belongs at a key the + * workspace's `mdx` (or `ts`) declaration does not name; a body's first + * workspace's initial files may stay plain contents, and a plain entry at + * an MDX source's key (an `.mdx` key, or one the `mdx` declaration names), + * code source, or configuration file of a workspace created after the + * running body's first product invocation is refused at creation (the + * undeclared-staging guard) unless its path is listed `unchecked` or + * `perDraw` (in `mdx`, or in `ts` for a path the TypeScript check judges). + */ +export type InitialFileContents = FileContents | StagedMdx | StagedTs; + +/** + * A staging's S-9 declaration of its MDX sources' well-formedness, by + * workspace-relative path (`/`-separated, as the file is staged; a byte + * path is keyed by its UTF-8 decoding with replacement characters). Every + * staged `.mdx` file not named here is declared well-formed and must derive + * under SPEC 14.20's grammar; a path belongs to at most one list. A path + * not named `.mdx` that a list names is an MDX source of that declaration + * — a spec-group file of another name (SPEC 7.1, 14.19, 14.20; module + * header) — judged exactly as an `.mdx` path; an unnamed one is not judged. + */ +export interface WorkspaceMdxDecl { + /** + * Spec-group files not named `.mdx` that TEST-SPEC's fixtures declare + * well-formed (an invalid path, 14.19, whose content 14.20 still judges): + * each must derive, as a default `.mdx` path must — the MDX analogue of + * `ts.wellFormed`. An `.mdx` path is well-formed by default, so naming + * one here is refused as a declaration defect. + */ + readonly wellFormed?: readonly string[]; + /** + * Sources TEST-SPEC declares unparseable (14.20): each must NOT derive — + * invalid UTF-8, a leading byte-order mark, an MDX-syntax rejection. + */ + readonly unparseable?: readonly string[]; + /** + * Sources whose derivability the document does not declare — fuzz + * mutations (P-8), noise files no discovery reaches, byte-level probes. + * Never a way to hide an ill-formed deterministic fixture. + */ + readonly unchecked?: readonly string[]; + /** + * Sources relying on an ECMAScript early error 14.20 admits (S-9's named + * allowances): each derives under exactly the allowances named for it. + */ + readonly allowances?: Readonly<Record<string, readonly MdxAllowance[]>>; + /** + * Sources whose initial contents are a property draw the runner already + * judged (helpers/property.ts `drawSources`; TEST-SPEC 16, S-9's property + * clause): each is judged well-formed at creation exactly as the default + * is, and a later plain `file()` staging of the path is exempt from the + * undeclared-staging guard — the initial-file form of `per-draw`. Only + * the section-16 modules list a path here; a deterministic test never + * does (a later-arm workspace's initial `.mdx` files are records). + */ + readonly perDraw?: readonly string[]; +} + +/** + * One file's S-9 declaration, for a `file()` call after creation; overrides + * the workspace declaration for that write alone, and at a path not named + * `.mdx` makes the path an MDX source for that write (a spec-group file of + * another name; module header) — the per-write form of the workspace + * declaration's lists, `mdx.wellFormed` included. `per-draw` is a property + * draw's source (TEST-SPEC 16; S-9's property clause): well-formed — judged + * at staging exactly as `well-formed` is — and already judged per draw by the + * property runner before the body saw it (helpers/property.ts `drawSources`), + * so the undeclared-staging guard exempts it. Only the section-16 modules + * pass it — to `file()` (section-16-p4.ts, -p5-p6.ts, -p9.ts), or as the + * workspace declaration's `perDraw` list for a draw's initial files; a + * deterministic test never does, and a staged-source record never carries it. + */ +export type MdxFileDeclaration = + | "well-formed" + | "unparseable" + | "unchecked" + | "per-draw" + | { readonly allowances: readonly MdxAllowance[] }; + +/** + * A staging's S-9 declaration of its TypeScript files' well-formedness — + * code sources and configuration files (SPEC 14.20's TypeScript grammar) — + * by workspace-relative path, keyed as `WorkspaceMdxDecl` keys are. Every + * staged file whose name ends in one of `TS_DEFAULT_SUFFIXES` and is not + * named here is declared well-formed: accepted by TypeScript 5.9.3 both as + * module code and as script code. A path belongs to at most one list. + */ +export interface WorkspaceTsDecl { + /** + * Files TEST-SPEC declares unparseable (14.20): each must be rejected + * both ways — a TypeScript syntax error, invalid UTF-8, a leading + * byte-order mark — whatever its name. + */ + readonly unparseable?: readonly string[]; + /** + * Files whose well-formedness the document does not declare — fuzz + * mutations (P-8, P-11), noise files no discovery reaches, edits of + * product-written bytes. Never a way to hide an ill-formed fixture. + */ + readonly unchecked?: readonly string[]; + /** + * Code sources (or a configuration file) whose names the default does not + * reach — a code group globs any name (`specs/*.md`, `src/*`, `.mdx` + * names under `docs/`): each must be well-formed, as a default path is. + */ + readonly wellFormed?: readonly string[]; + /** + * Configuration files and code sources whose initial contents a property + * draw composed (TEST-SPEC 16, S-9's property clause — P-7's + * configurations and capture sources, P-13's configuration and code + * sources), each already judged by the property runner before the body + * saw the draw (helpers/property.ts `drawSources`): each is judged + * well-formed at creation exactly as the default is — a listed name the + * default does not reach (P-7's capture sources) included — and the + * undeclared-staging guard exempts the path, at creation and for a later + * plain `file()` staging — the initial-file form of `per-draw`. Only the + * section-16 modules list a path here (`tsPathsOf` over a rendered map, + * plus any code source at another name); a deterministic test never does + * (a later-arm workspace's initial code sources and configuration are + * records). + */ + readonly perDraw?: readonly string[]; +} + +/** + * One file's S-9 TypeScript declaration, for a `file()` call after creation; + * overrides the workspace declaration (and the name's default) for that + * write alone. `per-draw` is a property draw's composed configuration or + * code source (TEST-SPEC 16; S-9's property clause): well-formed — judged at + * staging exactly as `well-formed` is — and generated per trial, so no + * module-level record can hold it and the undeclared-staging guard exempts + * it. Only the section-16 modules pass it — to `file()`, or as the + * workspace declaration's `ts.perDraw` list for a draw's initial files; a + * deterministic test never does, and a staged-source record never carries + * it. + */ +export type TsFileDeclaration = TsDeclaration | "unchecked" | "per-draw"; + +/** Options of a single `file()` staging. */ +export interface FileOptions { + /** + * The file's S-9 declaration; defaults to the workspace declaration's, + * else to well-formed for an `.mdx` path (any other path the workspace + * declaration does not name is not judged). Given at a path not named + * `.mdx`, it declares the path an MDX source for this write. + */ + readonly mdx?: MdxFileDeclaration; + /** + * The file's S-9 TypeScript declaration; defaults to the workspace + * declaration's, else to well-formed for a name `TS_DEFAULT_SUFFIXES` + * reaches (any other name is not judged). + */ + readonly ts?: TsFileDeclaration; +} + /** Declarative form of a workspace's initial content. */ export interface WorkspaceDecl { - /** Regular files: workspace-relative path → exact contents. */ - readonly files?: Readonly<Record<string, FileContents>>; + /** + * Regular files: workspace-relative path → exact contents, or a + * staged-source record — an MDX record at an MDX source's path (an `.mdx` + * path, or a spec-group file's of another name), a TypeScript record at + * a code source's or configuration file's path (`InitialFileContents`). + */ + readonly files?: Readonly<Record<string, InitialFileContents>>; /** Symbolic links: workspace-relative link path → verbatim target. */ readonly symlinks?: Readonly<Record<string, string>>; /** Directories created explicitly (parents of files are implicit). */ readonly dirs?: readonly string[]; + /** + * S-9 declaration of the staged MDX sources — those in `files` staged + * as plain contents (a record carries its own declaration, and naming its + * path here contradicts it) and those a later `file()` call stages; every + * `.mdx` path absent from it is declared well-formed, and a path of + * another name it names is an MDX source of that declaration (see the + * module header). + */ + readonly mdx?: WorkspaceMdxDecl; + /** + * S-9 declaration of the staged TypeScript files — code sources and + * configuration files, those in `files` and those later stagings write; + * every path `TS_DEFAULT_SUFFIXES` reaches and absent from it is declared + * well-formed (see the module header). + */ + readonly ts?: WorkspaceTsDecl; } export interface GitPerson { @@ -99,31 +438,68 @@ export class TestWorkspace { private gitCommitCount = 0; private gitScratch: Promise<{ home: string; configFile: string }> | undefined; + /** The S-9 declaration, resolved per normalized path (see `mdxDeclarationOf`). */ + private readonly mdxDeclarations: ReadonlyMap<string, MdxFileDeclaration>; + /** The S-9 TypeScript declaration, resolved per normalized path. */ + private readonly tsDeclarations: ReadonlyMap<string, TsFileDeclaration>; + /** The undeclared-staging guard's mark: has a product been invoked here? */ + private readonly invocationMark: WorkspaceInvocationMark; - private constructor(tempRoot: string, root: string) { + private constructor( + tempRoot: string, + root: string, + mdxDeclarations: ReadonlyMap<string, MdxFileDeclaration>, + tsDeclarations: ReadonlyMap<string, TsFileDeclaration>, + invocationMark: WorkspaceInvocationMark, + ) { this.tempRoot = tempRoot; this.root = root; + this.mdxDeclarations = mdxDeclarations; + this.tsDeclarations = tsDeclarations; + this.invocationMark = invocationMark; } /** * Create a fresh workspace in a unique temporary directory and populate it - * with the declared entries (directories, then files, then symlinks). + * with the declared entries (directories, then files, then symlinks); each + * MDX source (an `.mdx` file, or one the declaration names), code source, + * and configuration file is judged against the staging's S-9 declarations + * as it is written (a contradiction throws `HarnessStagingError`). */ static async create(decl: WorkspaceDecl = {}): Promise<TestWorkspace> { + const mdxDeclarations = resolveMdxDeclaration(decl.mdx ?? {}); + const tsDeclarations = resolveTsDeclaration(decl.ts ?? {}); const tempRoot = await fsp.mkdtemp( path.join(os.tmpdir(), "xspec-harness-"), ); const root = path.join(tempRoot, "work"); await fsp.mkdir(root); - const workspace = new TestWorkspace(tempRoot, root); - for (const dir of decl.dirs ?? []) { - await workspace.dir(dir); - } - for (const [rel, contents] of Object.entries(decl.files ?? {})) { - await workspace.file(rel, contents); - } - for (const [rel, target] of Object.entries(decl.symlinks ?? {})) { - await workspace.symlink(rel, target); + // Registered under the root and its realpath while the workspace lives + // (helpers/product-invocations.ts): an invocation anywhere under either + // marks it for the undeclared-staging guard. + const realRoot = await fsp.realpath(root); + const workspace = new TestWorkspace( + tempRoot, + root, + mdxDeclarations, + tsDeclarations, + registerWorkspaceRoot(root, realRoot), + ); + try { + for (const dir of decl.dirs ?? []) { + await workspace.dir(dir); + } + for (const [rel, contents] of Object.entries(decl.files ?? {})) { + await workspace.stageInitial(rel, contents); + } + for (const [rel, target] of Object.entries(decl.symlinks ?? {})) { + await workspace.symlink(rel, target); + } + } catch (error) { + // A refused staging (an S-9 contradiction, an unwritable entry) leaves + // no temporary directory behind; the error itself is what matters. + await workspace.dispose().catch(() => undefined); + throw error; } return workspace; } @@ -143,12 +519,516 @@ export class TestWorkspace { return abs; } - /** Write a regular file with exactly the declared bytes, creating parents. */ - async file(rel: RelPath, contents: FileContents): Promise<void> { + /** + * Write a regular file with exactly the declared bytes, creating parents. + * An MDX source's path is first judged against its S-9 declaration — the + * option's, else the workspace declaration's, else well-formed for an + * `.mdx` path (a path of another name is an MDX source only when the + * option, the workspace declaration, or a record declares it one; module + * header) — and a contradiction throws `HarnessStagingError` before + * anything is written. A staged-source record (helpers/staged-mdx.ts) + * supplies both the bytes and the declaration of the write, making its + * path an MDX source whatever the name — the form of every MDX staging a + * body makes after a product invocation in this workspace, so that the + * S-9 self-test judged it before any product existed; an `mdx` option + * beside a record contradicts it and throws. Plain contents on an MDX + * source's path after a product invocation — in this workspace, or + * anywhere in the running registered body — throw too + * (`undeclared-staging`, see `guardUndeclaredStaging`), unless the + * effective declaration is `unchecked` or `per-draw`. A code source or + * configuration file is judged against its S-9 TypeScript declaration — + * the `ts` option's, else the workspace declaration's, else well-formed + * for a name `TS_DEFAULT_SUFFIXES` reaches — before anything is written; + * a TypeScript staged-source record (helpers/staged-ts.ts) supplies both + * the bytes and that declaration — the form of every code source and + * configuration file a body stages after a product invocation — and a + * `ts` option beside it, an MDX source's path (an `mdx` option beside it + * included), or a path selecting the other grammar throws. Plain + * contents at a path the TypeScript check judges + * after a product invocation throw `undeclared-staging` too (the guard's + * TypeScript arm, `guardUndeclaredTsStaging`), unless the effective + * TypeScript declaration is `unchecked` or `per-draw`; so does an MDX + * record carrying no `ts` at such a path (a code-group `.mdx` path). + */ + async file( + rel: RelPath, + contents: FileContents | StagedMdx | StagedTs, + options: FileOptions = {}, + ): Promise<void> { + let data: Uint8Array; + let declaration = options.mdx; + let tsDeclaration = options.ts; + if (contents instanceof StagedMdx) { + const staged = this.recordStaging( + rel, + contents, + declaration, + tsDeclaration, + "option", + ); + if (staged.tsDeclaration === undefined) { + this.guardUndeclaredTsStaging( + rel, + tsDeclaration ?? this.tsDeclarationOf(rel), + "write", + contents, + ); + } + data = staged.data; + declaration = staged.declaration; + tsDeclaration = staged.tsDeclaration ?? tsDeclaration; + } else if (contents instanceof StagedTs) { + ({ data, tsDeclaration } = this.tsRecordStaging( + rel, + contents, + tsDeclaration, + "option", + declaration, + )); + } else { + data = toBytes(contents); + const effective = declaration ?? this.mdxDeclarationOf(rel); + if (effective !== undefined) this.guardUndeclaredStaging(rel, effective); + this.guardUndeclaredTsStaging( + rel, + tsDeclaration ?? this.tsDeclarationOf(rel), + ); + } + this.checkMdx(rel, data, declaration); + this.checkTs(rel, data, tsDeclaration); + await this.write(rel, data); + } + + /** + * Whether a product has been invoked in this workspace since its creation + * (the subprocess driver marks it; helpers/product-invocations.ts). + */ + get productInvoked(): boolean { + return this.invocationMark.invoked; + } + + /** + * An initial `files` entry of the workspace declaration: plain contents, + * judged under the workspace declaration like every MDX source's staging, + * or a staged-source record — an MDX record or a TypeScript one — staged + * under the record's own declaration (module header — the form of a + * later-arm workspace's initial MDX sources, code sources, and + * configuration, which S-7's sweep never reaches; a body's first + * workspace's may stay plain). + * Inside the undeclared-staging guard, as `file()` is: a plain entry at + * an MDX source's path (an `.mdx` path, or one the workspace declaration + * names) of a workspace created after the running body's first product + * invocation is refused before anything of it is written, unless the + * workspace declaration lists it `unchecked` or `perDraw` + * (`guardUndeclaredStaging`), and so is a plain entry at a path the + * TypeScript check judges, unless the declaration lists it + * `ts.unchecked` or `ts.perDraw`, and an MDX record carrying no `ts` at + * such a path (`guardUndeclaredTsStaging`); `create()` then disposes the + * half-built workspace. At creation only the per-body mark can be set, + * so outside a body context (a self-test, the E-6 fixture, the Windows + * leg's drive-mismatch arm) creation never refuses. + */ + private async stageInitial( + rel: string, + contents: InitialFileContents, + ): Promise<void> { + let data: Uint8Array; + let declaration: MdxFileDeclaration | undefined; + let tsDeclaration: TsFileDeclaration | undefined; + if (contents instanceof StagedMdx) { + ({ data, declaration, tsDeclaration } = this.recordStaging( + rel, + contents, + this.mdxDeclarations.get(mdxKey(rel)), + this.tsDeclarations.get(mdxKey(rel)), + "declaration entry", + )); + if (tsDeclaration === undefined) { + this.guardUndeclaredTsStaging( + rel, + this.tsDeclarationOf(rel), + "initial entry", + contents, + ); + } + } else if (contents instanceof StagedTs) { + ({ data, tsDeclaration } = this.tsRecordStaging( + rel, + contents, + this.tsDeclarations.get(mdxKey(rel)), + "declaration entry", + undefined, + )); + } else { + data = toBytes(contents); + declaration = undefined; + const effective = this.mdxDeclarationOf(rel); + if (effective !== undefined) { + this.guardUndeclaredStaging(rel, effective, "initial entry"); + } + this.guardUndeclaredTsStaging( + rel, + this.tsDeclarationOf(rel), + "initial entry", + ); + } + this.checkMdx(rel, data, declaration); + this.checkTs(rel, data, tsDeclaration); + await this.write(rel, data); + } + + /** + * A staged-source record's staging — `file()`'s and an initial `files` + * entry's alike: the record's bytes under the record's declaration, the + * one the S-9 self-test verified. The record makes its path an MDX source + * whatever the name — an `.mdx` path, or a spec-group file of another + * name (module header), judged under the record's declaration exactly as + * an `.mdx` path is — and a second declaration for the path beside the + * record (`file()`'s `mdx` option, the workspace declaration's entry) is + * a contradiction that throws before anything is written. A record carrying + * a TypeScript declaration too (the path is a code source a code group + * discovers; helpers/staged-mdx.ts) returns it for the TypeScript check, + * and a `ts` option or workspace `ts` entry beside it is a contradiction + * likewise. + */ + private recordStaging( + rel: RelPath, + record: StagedMdx, + beside: MdxFileDeclaration | undefined, + tsBeside: TsFileDeclaration | undefined, + besideForm: "option" | "declaration entry", + ): { + readonly data: Uint8Array; + readonly declaration: MdxFileDeclaration; + readonly tsDeclaration: TsFileDeclaration | undefined; + } { + const key = mdxKey(rel); + if (beside !== undefined) { + const what = + besideForm === "option" + ? `the \`mdx\` option ${JSON.stringify(beside)} beside it` + : `the workspace declaration's entry ${JSON.stringify(beside)} for the path beside it`; + throw new HarnessStagingError( + "mdx-derivability", + key, + `the staged-source record ${JSON.stringify(record.name)} ` + + `carries its own S-9 declaration ${JSON.stringify(record.mdx)}; ` + + `${what} is a contradiction — the record's declaration is the ` + + `one the S-9 self-test verified, so drop the ${besideForm} (or ` + + "change the record)", + ); + } + if (record.ts !== undefined && tsBeside !== undefined) { + const what = + besideForm === "option" + ? `the \`ts\` option ${JSON.stringify(tsBeside)} beside it` + : `the workspace declaration's \`ts\` entry ${JSON.stringify(tsBeside)} for the path beside it`; + throw new HarnessStagingError( + "ts-derivability", + key, + `the staged-source record ${JSON.stringify(record.name)} ` + + "carries its own S-9 TypeScript declaration " + + `${JSON.stringify(record.ts)} (the path is a code source too); ` + + `${what} is a contradiction — the record's declaration is the ` + + `one the S-9 self-test verified, so drop the ${besideForm} (or ` + + "change the record)", + ); + } + return { + data: toBytes(record.source), + declaration: record.mdx, + tsDeclaration: record.ts, + }; + } + + /** + * A TypeScript staged-source record's staging (helpers/staged-ts.ts) — + * `file()`'s and an initial `files` entry's alike: the record's bytes + * under the record's declaration, the one the S-9 self-test verified under + * the grammar the record names. An MDX source's path — an `.mdx` path, or + * one the workspace declaration or the `mdx` option beside the record + * (`mdxBeside`) declares an MDX source — is an MDX record's (an MDX + * source first: the self-test judged the TypeScript record's bytes as + * TypeScript alone), and a path selecting the other grammar (SPEC 14.20: + * a name ending `.tsx` selects TSX, any other plain TypeScript) would + * judge the bytes otherwise than the self-test did — both are mistakes; + * a second declaration for the path beside the record (`file()`'s `ts` + * option, the workspace declaration's `ts` entry) is a contradiction. + * All throw before anything is written. A record makes its path judged + * whatever its name: it is the per-write form of `ts.wellFormed` (or + * `ts.unparseable`). + */ + private tsRecordStaging( + rel: RelPath, + record: StagedTs, + beside: TsFileDeclaration | undefined, + besideForm: "option" | "declaration entry", + mdxBeside: MdxFileDeclaration | undefined, + ): { + readonly data: Uint8Array; + readonly tsDeclaration: TsFileDeclaration; + } { + const key = mdxKey(rel); + const mdxSource = isMdxPath(rel) + ? "the path is an `.mdx` path" + : mdxBeside !== undefined + ? `the \`mdx\` option ${JSON.stringify(mdxBeside)} beside it declares the path an MDX source` + : this.mdxDeclarations.has(key) + ? "the workspace's `mdx` declaration names the path, an MDX source" + : undefined; + if (mdxSource !== undefined) { + throw new HarnessStagingError( + "ts-derivability", + key, + `the staged-source record ${JSON.stringify(record.name)} is a ` + + `TypeScript record and ${mdxSource} — an MDX source is staged as ` + + "an MDX record (helpers/staged-mdx.ts), carrying its TypeScript " + + "declaration too where a code group discovers it", + ); + } + const selected = tsGrammarOf(key); + if (selected !== record.grammar) { + const named = (grammar: string): string => + grammar === "tsx" ? "TSX" : "plain TypeScript"; + throw new HarnessStagingError( + "ts-derivability", + key, + `the staged-source record ${JSON.stringify(record.name)} is judged ` + + `under the grammar ${JSON.stringify(record.grammar)} ` + + `(${named(record.grammar)}) and the path selects ` + + `${JSON.stringify(selected)} (${named(selected)}; SPEC 14.20: a ` + + "name ending `.tsx` selects TSX, any other plain TypeScript) — " + + "stage the record at a path of its grammar, or register one " + + "naming the path's", + ); + } + if (beside !== undefined) { + const what = + besideForm === "option" + ? `the \`ts\` option ${JSON.stringify(beside)} beside it` + : `the workspace declaration's \`ts\` entry ${JSON.stringify(beside)} for the path beside it`; + throw new HarnessStagingError( + "ts-derivability", + key, + `the staged-source record ${JSON.stringify(record.name)} ` + + `carries its own S-9 declaration ${JSON.stringify(record.ts)}; ` + + `${what} is a contradiction — the record's declaration is the ` + + `one the S-9 self-test verified, so drop the ${besideForm} (or ` + + "change the record)", + ); + } + return { data: toBytes(record.source), tsDeclaration: record.ts }; + } + + /** + * The undeclared-staging guard (module header; TEST-SPEC S-9's timing + * clause, S-7, H-8): a plain staging at an MDX source's path — an `.mdx` + * path, or one the staging declares an MDX source (module header) — + * contents that are not a staged-source record, after a product + * invocation, is first judged at + * suite time, against a real product, because S-7's sweep never reaches + * it; unless its effective declaration exempts it, it is refused with the + * rule to follow. "After a product invocation" is judged both per + * workspace (this one's mark) and per body (the running registered body's + * context), the latter being S-7's actual reach. `site` is the staging's + * kind, named in the diagnosis with its remedies: a write after creation + * (`file()`, `copyFrom()`), or an initial `files` entry `create()` stages — + * where only the per-body mark can refuse, since the workspace's own mark + * cannot be set yet (its root was registered an instant before, and + * nothing has run in it). + */ + private guardUndeclaredStaging( + rel: RelPath, + declaration: MdxFileDeclaration, + site: "write" | "initial entry" = "write", + ): void { + if (declaration === "unchecked" || declaration === "per-draw") return; + const where = this.invocationBefore(); + if (where === undefined) return; + const detail = + site === "initial entry" + ? `an initial \`files\` entry of a workspace created after a product invocation ${where}, staged with plain contents (declared ${JSON.stringify(declaration)}) — S-7's sweep against the empty stub never reaches this creation (the body fails at that invocation), so S-9's check would first run at suite time, against a real product, not before any product exists (H-8). Pass a staged-source record as the entry's value instead (helpers/staged-mdx.ts: \`stagedMdx("<TEST-ID> <workspace or arm> <path>", <the same expression, moved, never re-spelled>, <this declaration>)\` at module level, and the path dropped from the workspace's \`mdx\` declaration — the record carries it), which test/self/s9-staged-sources.test.ts judges before any product exists; a property draw's initial file the runner already judged is listed in \`mdx.perDraw\` (section-16 modules only); a P-8 fuzz mutation or a noise file no discovery reaches is listed in \`mdx.unchecked\`` + : `an MDX source staged with plain contents (declared ${JSON.stringify(declaration)}) after a product invocation ${where} — S-7's sweep against the empty stub never reaches this staging (the body fails at that invocation), so S-9's check would first run at suite time, against a real product, not before any product exists (H-8). Stage it as a staged-source record instead (helpers/staged-mdx.ts: \`stagedMdx("<TEST-ID> <what it stages>", <the same expression, moved, never re-spelled>, <this declaration>)\` at module level, passed to \`file()\`), which test/self/s9-staged-sources.test.ts judges before any product exists; an edit of bytes the product itself wrote goes through \`edit()\`; a P-8 fuzz mutation is declared \`unchecked\`; a property draw the runner already judged is declared \`per-draw\` (section-16 modules only); another workspace's product-written bytes carried into a fresh workspace go through \`copyFrom()\``; + throw new HarnessStagingError("undeclared-staging", mdxKey(rel), detail); + } + + /** + * The undeclared-staging guard's TypeScript arm (module header; TEST-SPEC + * S-9's TypeScript and timing clauses, S-7, H-8): a staging at a path + * S-9's TypeScript check judges — `declaration`, the write's effective + * TypeScript declaration, is defined — after a product invocation is + * first judged at suite time, against a real product, unless its bytes + * are a TypeScript staged-source record (or an MDX record carrying its + * TypeScript declaration) the S-9 self-test judged before any product + * existed. Refused, with the rule to follow: plain contents (`mdxRecord` + * undefined), and an MDX record carrying no `ts` (`mdxRecord` — the + * self-test judged it as MDX alone), unless the effective declaration is + * `unchecked` (a mutation, a noise file, a tampered product-written + * module: nothing to judge) or `per-draw` (a property draw's composed + * configuration or code source, generated per trial). The marks and + * `site` are `guardUndeclaredStaging`'s. + */ + private guardUndeclaredTsStaging( + rel: RelPath, + declaration: TsFileDeclaration | undefined, + site: "write" | "initial entry" = "write", + mdxRecord?: StagedMdx, + ): void { + if ( + declaration === undefined || + declaration === "unchecked" || + declaration === "per-draw" + ) { + return; + } + const where = this.invocationBefore(); + if (where === undefined) return; + const declared = `declared ${JSON.stringify(declaration)}`; + const unreached = `S-7's sweep against the empty stub never reaches this ${site === "initial entry" ? "creation" : "staging"} (the body fails at that invocation), so S-9's TypeScript check would first run at suite time, against a real product, not before any product exists (H-8)`; + const mdxForm = + "a code-group `.mdx` path takes an MDX staged-source record carrying " + + "its TypeScript declaration (`stagedMdx(name, source, mdx, ts)`)"; + let detail: string; + if (mdxRecord !== undefined) { + const staging = + site === "initial entry" + ? `an initial \`files\` entry of a workspace created after a product invocation ${where}` + : `a staging after a product invocation ${where}`; + detail = `${staging}: the MDX staged-source record ${JSON.stringify(mdxRecord.name)} carries no TypeScript declaration, yet S-9's TypeScript check judges the path (${declared}: a code source a code group discovers), so the record's TypeScript reading was never judged before any product existed — ${unreached}. Give the record its TypeScript declaration instead (\`stagedMdx(name, source, mdx, ts)\`, which test/self/s9-staged-sources.test.ts judges as TypeScript too), dropping the path from the workspace's \`ts\` declaration and any \`ts\` option — the record carries it; a file whose well-formedness the document does not declare is declared \`unchecked\` (\`ts.unchecked\`); a property draw's composed file is declared per draw (\`ts.perDraw\`, \`{ ts: "per-draw" }\`; section-16 modules only)`; + } else if (site === "initial entry") { + detail = `an initial \`files\` entry of a workspace created after a product invocation ${where}, a code source or configuration file staged with plain contents (${declared}) — ${unreached}. Pass a TypeScript staged-source record as the entry's value instead (helpers/staged-ts.ts: \`stagedTs("<TEST-ID> <workspace or arm> <path>", <the same expression, moved, never re-spelled>, <this declaration>)\` at module level, and the path dropped from the workspace's \`ts\` declaration — the record carries it), which test/self/s9-staged-sources.test.ts judges before any product exists; ${mdxForm}; a property draw's composed file is listed in \`ts.perDraw\` (section-16 modules only); a mutation or a noise file no discovery reaches is listed in \`ts.unchecked\``; + } else { + detail = `a code source or configuration file staged with plain contents (${declared}) after a product invocation ${where} — ${unreached}. Stage it as a TypeScript staged-source record instead (helpers/staged-ts.ts: \`stagedTs("<TEST-ID> <what it stages>", <the same expression, moved, never re-spelled>, <this declaration>)\` at module level, passed to \`file()\`), which test/self/s9-staged-sources.test.ts judges before any product exists; ${mdxForm}; an edit of bytes the product itself wrote goes through \`edit()\`; a mutation, a noise file, or a tampered product-written module is declared \`unchecked\` (\`{ ts: "unchecked" }\`); a property draw's composed file is declared \`per-draw\` (\`{ ts: "per-draw" }\`, section-16 modules only); another workspace's product-written bytes carried into a fresh workspace go through \`copyFrom()\``; + } + throw new HarnessStagingError("undeclared-staging", mdxKey(rel), detail); + } + + /** + * Where the undeclared-staging guard finds a product invocation before + * a staging: in this workspace (its mark), else in the running registered + * body (its context — S-7's actual reach); undefined when neither mark is + * set, the staging being one S-7's sweep reaches. + */ + private invocationBefore(): string | undefined { + if (this.invocationMark.invoked) return "in this workspace"; + const body = productInvokedInBody(); + if (body === undefined) return undefined; + return `in the running body of ${body} (in another workspace: S-7's sweep stops at the body's first invocation wherever it happens)`; + } + + /** + * Rewrite one spelling in a file's current bytes — `from` replaced by `to` + * once, in the UTF-8 decoding of the bytes as they stand — and stage the + * result under the path's S-9 declarations (the workspace declaration's, + * else well-formed for an `.mdx` path — `mdxDeclarationOf`), judged at + * staging time like every MDX source's write and every code source's or + * configuration file's (`tsDeclarationOf`). This + * stages an edit of bytes the PRODUCT wrote — a rename's or move's + * rewritten source, which no harness constant equals and which nothing can + * judge before the product exists — never of a file whose current bytes + * are the harness's own staging: that edit is a deterministic fixture, + * computed at module level from the constant as a staged-source record + * (helpers/staged-mdx.ts) and staged with `file()`. A `from` the file does + * not contain is a harness staging error, never a product verdict. + */ + async edit(rel: string, from: string, to: string): Promise<void> { + const current = Buffer.from(await this.readBytes(rel)).toString("utf8"); + if (!current.includes(from)) { + throw new Error( + `harness staging: ${rel} does not contain ${JSON.stringify(from)}`, + ); + } + const data = Buffer.from(current.replace(from, to), "utf8"); + this.checkMdx(rel, data, undefined); + this.checkTs(rel, data, undefined); + await this.write(rel, data); + } + + /** + * Stage another live workspace's current bytes of `rel` here, at `destRel` + * (default: the same path), under this workspace's S-9 declarations for + * the destination, judged at staging time like every MDX source's write + * and every code source's or configuration file's. This + * carries the PRODUCT's output — a rename's or move's rewritten sources, + * the configuration and journal beside them — into a fresh workspace (the + * H-6 two-directory protocol of T6.4-7, T6.5-1, T6.5-3): bytes no harness + * constant equals, so not a deterministic fixture, and never a new + * harness-spelled source (whatever the product left untouched was staged, + * and judged, in `source` already). Out of a workspace no product has + * been invoked in, the bytes are the harness's own staging under another + * name, so the undeclared-staging guard applies to a destination that is + * an MDX source's path (an `.mdx` path, or one this workspace's + * declaration names), and to a destination the TypeScript check judges, + * exactly as to plain contents (a deterministic fixture belongs in the + * ledger). + */ + async copyFrom( + source: TestWorkspace, + rel: RelPath, + destRel: RelPath = rel, + ): Promise<void> { + const data = await source.readBytes(rel); + if (!source.productInvoked) { + const effective = this.mdxDeclarationOf(destRel); + if (effective !== undefined) { + this.guardUndeclaredStaging(destRel, effective); + } + this.guardUndeclaredTsStaging(destRel, this.tsDeclarationOf(destRel)); + } + this.checkMdx(destRel, data, undefined); + this.checkTs(destRel, data, undefined); + await this.write(destRel, data); + } + + /** + * The S-9 declaration in effect for a staged path: the workspace + * declaration's entry — a path of any name it names is an MDX source of + * that declaration (module header) — else well-formed for an `.mdx` + * path; undefined for a path nothing judges (an undeclared path not named + * `.mdx`, unless a `file()` option or a record declares it for a write). + */ + mdxDeclarationOf(rel: RelPath): MdxFileDeclaration | undefined { + return ( + this.mdxDeclarations.get(mdxKey(rel)) ?? + (isMdxPath(rel) ? "well-formed" : undefined) + ); + } + + /** + * The S-9 TypeScript declaration in effect for a staged path: the + * workspace declaration's entry, else well-formed for a name + * `TS_DEFAULT_SUFFIXES` reaches; undefined for a path nothing judges. + */ + tsDeclarationOf(rel: RelPath): TsFileDeclaration | undefined { + return ( + this.tsDeclarations.get(mdxKey(rel)) ?? + (isTsDefaultPath(rel) ? "well-formed" : undefined) + ); + } + + private checkTs( + rel: RelPath, + data: Uint8Array, + override: TsFileDeclaration | undefined, + ): void { + const declaration = override ?? this.tsDeclarationOf(rel); + if (declaration === undefined) return; + judgeTsDeclaration(mdxKey(rel), data, declaration); + } + + private checkMdx( + rel: RelPath, + data: Uint8Array, + override: MdxFileDeclaration | undefined, + ): void { + const declaration = override ?? this.mdxDeclarationOf(rel); + if (declaration === undefined) return; + judgeMdxDeclaration(mdxKey(rel), data, declaration); + } + + private async write(rel: RelPath, data: Uint8Array): Promise<void> { const abs = this.resolve(rel); await ensureParent(abs); - const data = - typeof contents === "string" ? Buffer.from(contents, "utf8") : contents; await fsp.writeFile(abs, data); } @@ -263,6 +1143,7 @@ export class TestWorkspace { /** Remove the workspace and all builder scratch. Safe to call twice. */ async dispose(): Promise<void> { + unregisterWorkspaceRoot(this.invocationMark); try { await fsp.rm(this.tempRoot, { recursive: true, @@ -349,6 +1230,336 @@ export class TestWorkspace { } } +/** + * S-9's judge — one code path for the builder (every MDX source's staging, + * as it is written) and the self-tests (every staged-source record, before any + * product exists): `data`, a source's exact bytes, must match `declaration` + * — derive under the stock MDX 3 parser when declared well-formed (under + * exactly the named allowances when it names any), not derive when declared + * unparseable — or a `HarnessStagingError` of mode `mdx-derivability` names + * `key` (the staged path, or a record's name) and the parser's reason. An + * `unchecked` declaration judges nothing; `per-draw` judges as `well-formed` + * (the property runner judged the draw already; the declaration's other + * meaning — exempt from the undeclared-staging guard — is `file()`'s). + */ +export function judgeMdxDeclaration( + key: string, + data: Uint8Array, + declaration: MdxFileDeclaration, +): void { + if (declaration === "unchecked") return; + const allowances = + typeof declaration === "object" ? declaration.allowances : undefined; + if (allowances !== undefined) assertKnownAllowances(key, allowances); + const verdict = deriveMdx( + data, + allowances === undefined ? undefined : { allowances }, + ); + if (declaration === "unparseable") { + if (verdict.derives) { + throw new HarnessStagingError( + "mdx-derivability", + key, + "declared unparseable (`mdx.unparseable`) but the source derives " + + "under the stock MDX 3 parser — SPEC 14.20 admits it; declare " + + "it well-formed (the default for an `.mdx` path, " + + "`mdx.wellFormed` for one of another name) or, if it relies on an early " + + "error 14.20 admits, name its allowance", + ); + } + return; + } + if (!verdict.derives) { + const where = + verdict.position === undefined + ? "" + : ` at line ${verdict.position.line}, column ${verdict.position.column} (offset ${verdict.position.offset})`; + const declared = + declaration === "per-draw" + ? "declared well-formed per draw (`per-draw`: a property draw the runner judged)" + : allowances === undefined + ? "declared well-formed (S-9's default)" + : `declared well-formed under the allowances ${JSON.stringify(allowances)}`; + throw new HarnessStagingError( + "mdx-derivability", + key, + `${declared} but the stock MDX 3 parser rejects it${where}: ` + + `${verdict.reason} — list the path under \`mdx.unparseable\` if ` + + "TEST-SPEC declares the source unparseable (SPEC 14.20), name " + + "its allowance if it relies on an early error 14.20 admits, or " + + "under `mdx.unchecked` only if the document does not declare " + + "its derivability (S-9)", + ); + } +} + +/** + * The name suffixes S-9's TypeScript default reaches: a staged file whose + * name ends in one of them is declared well-formed unless its staging + * declares otherwise — TypeScript's own source names (`.d.ts` and its kin + * included) and JavaScript's, which a code group globbing them discovers as + * code sources parsed as plain TypeScript (SPEC 14.20: any name but `.tsx` + * selects plain TypeScript). Every configuration file the suite stages is + * named `.ts` (`xspec.config.ts`, each `--config` target). Matched + * case-sensitively, as SPEC spells the `.tsx` suffix. + */ +export const TS_DEFAULT_SUFFIXES = [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", +] as const; + +/** + * S-9's TypeScript judge for a staging — one code path for the builder + * (every code source and configuration file, as it is written) and the + * self-tests judging a staging before any product exists (every TypeScript + * staged-source record): `data`, the file's exact bytes, must match + * `declaration` under `judgeTypeScript` (the grammar `fileName` selects — + * the staged path, `key`, by default; a record, judged before it has a + * path, is handed a neutral name of its grammar, `tsGrammarFileName`), or a + * `HarnessStagingError` of mode `ts-derivability` names `key` (the staged + * path, or a record's name) and the parser's first error. A text the + * release accepts read one way only is refused under either declaration + * (S-9: no fixture is such text). An `unchecked` declaration judges nothing; + * `per-draw` judges as `well-formed` (a property draw's composed file; the + * declaration's other meaning — exempt from the undeclared-staging guard — + * is the builder's), its refusal naming the generator. + */ +export function judgeTsDeclaration( + key: string, + data: Uint8Array, + declaration: TsFileDeclaration, + fileName: string = key, +): void { + if (declaration === "unchecked") return; + const verdict = judgeTypeScript(data, fileName); + const perDraw = declaration === "per-draw"; + const problem = tsDeclarationProblem( + verdict, + perDraw ? "well-formed" : declaration, + ); + if (problem === undefined) return; + if (perDraw) { + throw new HarnessStagingError( + "ts-derivability", + key, + `${problem.replace( + /^declared well-formed/, + "declared well-formed per draw (`per-draw`: a property draw's " + + "composed file)", + )} — the generator composed a configuration or code source that is ` + + "not well-formed TypeScript, which no draw may be (TEST-SPEC 16, " + + "S-9): fix the generator", + ); + } + const remedy = + verdict.verdict === "one-way" + ? "restage the fixture as text both readings agree on, or list the " + + "path under `ts.unchecked` only if the document does not declare " + + "its well-formedness (S-9)" + : declaration === "well-formed" + ? "list the path under `ts.unparseable` if TEST-SPEC declares the " + + "file unparseable (SPEC 14.20), or under `ts.unchecked` only if " + + "the document does not declare its well-formedness (a fuzz " + + "mutation, a noise file no discovery reaches, an edit of " + + "product-written bytes; S-9)" + : "drop the path from `ts.unparseable` (well-formed is the default " + + "for a name the default reaches; `ts.wellFormed` declares any " + + "other code source)"; + throw new HarnessStagingError( + "ts-derivability", + key, + `${problem} — ${remedy}`, + ); +} + +/** A declared file's bytes: a string encoded as UTF-8, bytes verbatim. */ +function toBytes(contents: FileContents): Uint8Array { + return typeof contents === "string" + ? Buffer.from(contents, "utf8") + : contents; +} + +const MDX_SUFFIX = Buffer.from(".mdx", "utf8"); + +/** + * Whether S-9's MDX default reaches a staged path by its name: it ends in + * `.mdx`. A path of another name is an MDX source only when its staging + * declares it one (`mdxDeclarationOf`, a `file()` option, a record). + */ +function isMdxPath(rel: RelPath): boolean { + if (typeof rel === "string") return rel.endsWith(".mdx"); + return ( + rel.length >= MDX_SUFFIX.length && + Buffer.from(rel.subarray(rel.length - MDX_SUFFIX.length)).equals(MDX_SUFFIX) + ); +} + +/** Whether S-9's TypeScript default reaches a staged path by its name. */ +function isTsDefaultPath(rel: RelPath): boolean { + const bytes = typeof rel === "string" ? Buffer.from(rel, "utf8") : rel; + return TS_DEFAULT_SUFFIXES.some((suffix) => { + const tail = Buffer.from(suffix, "utf8"); + return ( + bytes.length >= tail.length && + Buffer.from(bytes.subarray(bytes.length - tail.length)).equals(tail) + ); + }); +} + +/** + * The `.mdx` keys of an initial `files` map that stage plain contents, in + * the map's order — the paths a workspace declaration's list may name (a + * section-16 module's `perDraw` list over a map its generator rendered, + * whose `.mdx` keys are the draw's; P-11's `unchecked` mutations). A record + * entry is left out: it carries its own declaration, and a list naming its + * path is the contradiction `create()` refuses. + */ +export function mdxPathsOf( + files: Readonly<Record<string, InitialFileContents>>, +): string[] { + return Object.entries(files) + .filter( + ([rel, contents]) => isMdxPath(rel) && !(contents instanceof StagedMdx), + ) + .map(([rel]) => rel); +} + +/** + * The keys of an initial `files` map that stage plain contents at a path + * S-9's TypeScript default reaches (a `TS_DEFAULT_SUFFIXES` name), in the + * map's order — the paths a section-16 module's `ts.perDraw` list names + * over a map its generator rendered (P-7's configurations, P-13's + * configuration and code sources). A record entry is left out: it carries + * its own declaration, and a list naming its path is the contradiction + * `create()` refuses. + */ +export function tsPathsOf( + files: Readonly<Record<string, InitialFileContents>>, +): string[] { + return Object.entries(files) + .filter( + ([rel, contents]) => + isTsDefaultPath(rel) && + !(contents instanceof StagedTs) && + !(contents instanceof StagedMdx), + ) + .map(([rel]) => rel); +} + +/** + * The declaration key of a staged path: the `/`-separated relative path, + * normalized (`./a`, `a//b` and `a/./b` spell `a`, `a/b`); a byte path is + * decoded as UTF-8 with replacement characters. + */ +function mdxKey(rel: RelPath): string { + const text = + typeof rel === "string" ? rel : Buffer.from(rel).toString("utf8"); + return path.posix.normalize(text); +} + +function assertKnownAllowances( + key: string, + allowances: readonly MdxAllowance[], +): void { + for (const allowance of allowances) { + if (!(MDX_ALLOWANCES as readonly string[]).includes(allowance)) { + throw new HarnessStagingError( + "mdx-derivability", + key, + `unknown allowance ${JSON.stringify(allowance)} — S-9's allowances are ${JSON.stringify(MDX_ALLOWANCES)}`, + ); + } + } +} + +/** + * Resolve a workspace's S-9 declaration to one entry per path, refusing a + * declaration defect: a path in two lists, an `.mdx` path in `wellFormed` + * (its default already), an unknown allowance, an empty allowance list. A + * path of another name that a list names is an MDX source of that + * declaration (a spec-group file not named `.mdx`; module header). + */ +function resolveMdxDeclaration( + decl: WorkspaceMdxDecl, +): ReadonlyMap<string, MdxFileDeclaration> { + const resolved = new Map<string, MdxFileDeclaration>(); + const declare = (rel: string, declaration: MdxFileDeclaration): void => { + const key = mdxKey(rel); + if (resolved.has(key)) { + throw new HarnessStagingError( + "mdx-derivability", + key, + "the S-9 declaration names the path in more than one of " + + "`wellFormed`, `unparseable`, `unchecked`, `perDraw`, and " + + "`allowances`", + ); + } + resolved.set(key, declaration); + }; + for (const rel of decl.wellFormed ?? []) { + if (isMdxPath(rel)) { + throw new HarnessStagingError( + "mdx-derivability", + mdxKey(rel), + "the S-9 declaration's `wellFormed` list names an `.mdx` path, " + + "well-formed by default — omit it (`wellFormed` declares a " + + "spec-group file of another name an MDX source)", + ); + } + declare(rel, "well-formed"); + } + for (const rel of decl.unparseable ?? []) declare(rel, "unparseable"); + for (const rel of decl.unchecked ?? []) declare(rel, "unchecked"); + for (const rel of decl.perDraw ?? []) declare(rel, "per-draw"); + for (const [rel, allowances] of Object.entries(decl.allowances ?? {})) { + if (allowances.length === 0) { + throw new HarnessStagingError( + "mdx-derivability", + rel, + "the S-9 declaration names an empty allowance list — omit the path " + + "instead (well-formed is the default for an `.mdx` path; " + + "`wellFormed` declares a path of another name)", + ); + } + assertKnownAllowances(rel, allowances); + declare(rel, { allowances }); + } + return resolved; +} + +/** + * Resolve a workspace's S-9 TypeScript declaration to one entry per path, + * refusing a declaration defect: a path in two lists. + */ +function resolveTsDeclaration( + decl: WorkspaceTsDecl, +): ReadonlyMap<string, TsFileDeclaration> { + const resolved = new Map<string, TsFileDeclaration>(); + const declare = (rel: string, declaration: TsFileDeclaration): void => { + const key = mdxKey(rel); + if (resolved.has(key)) { + throw new HarnessStagingError( + "ts-derivability", + key, + "the S-9 TypeScript declaration names the path in more than one " + + "of `unparseable`, `unchecked`, `wellFormed`, and `perDraw`", + ); + } + resolved.set(key, declaration); + }; + for (const rel of decl.unparseable ?? []) declare(rel, "unparseable"); + for (const rel of decl.unchecked ?? []) declare(rel, "unchecked"); + for (const rel of decl.wellFormed ?? []) declare(rel, "well-formed"); + for (const rel of decl.perDraw ?? []) declare(rel, "per-draw"); + return resolved; +} + /** Create the parent directory chain for an absolute (string or byte) path. */ async function ensureParent(abs: string | Buffer): Promise<void> { if (typeof abs === "string") { diff --git a/test/self/added-import-identifiers.test.ts b/test/self/added-import-identifiers.test.ts new file mode 100644 index 00000000..abe39744 --- /dev/null +++ b/test/self/added-import-identifiers.test.ts @@ -0,0 +1,582 @@ +// T6.5-22(a)'s universal assertion, held by the subprocess driver over every +// performed move (test/helpers/added-import-identifiers.ts; TEST-SPEC +// T6.5-22(a), SPEC 6.5): its mechanics pinned against a known-behavior +// stand-in before any product-facing test trusts them. The stand-in is a +// tiny Node script that rewrites the workspace as the test's plan says and +// exits as told, driven through the same binding shape product tests use, +// so each vector fixes exactly which import declarations an operation added: +// * the hook fires — a performed move adding a barred identifier (`let`, +// T6.5-22(b)'s first lure) is failed, a diagnosed assertion failure +// naming the file, the identifier, and the clause — and passes a fresh +// one; each clause the name analysis decides fails in its kind of file +// (referenced, bound in some scope, not distinct, `S` in a spec source, +// a TSX pragma's factory and `React`), a created target file judged +// against empty content; +// * what is read: the sources the configuration discovers — a file-form +// move's specifier rewrites added nothing, its relocated file compared +// with its origin; derived files, Markdown emit destinations, and files +// no group discovers are not judged; `--config`'s directory is the root, +// the upward search starts at the working directory, and flags stand +// anywhere (SPEC 12.0); +// * what is not judged: a run exiting non-zero, a `--preview`, another +// command; +// * a rewritten source not well-formed after the operation, or before it, +// is a diagnosed failure too; +// * a spec source binding a word strict mode code admits as no binding, or +// `await` — well-formed under 14.20, an early error alone (T6.5-22: +// `import let from "./let.xspec"` derives) — is read (`readMdxTree`) +// though no S-9 allowance admits it, `deriveMdx`'s verdicts unchanged, +// while a reserved word no binding derives (`enum`) is read nowhere. +// And `judgeAddedImportsOfFile`, the same judgement over one file's two +// texts that T6.5-22(b)'s lures apply to their receiving file after the +// move: the declarations added, each with its specifier's value and local +// bindings, and the breaches, in a spec source and a code source alike; a +// side not well-formed yields that problem alone. + +import { describe, expect, onTestFinished, test } from "vitest"; +import { + judgeAddedImportsOfFile, + readPerformedMove, +} from "../helpers/added-import-identifiers.js"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { + deriveMdx, + MDX_ALLOWANCES, + readMdxTree, +} from "../helpers/mdx-derivability.js"; +import { runProduct } from "../helpers/subprocess.js"; +import type { ProductBinding, RunResult } from "../helpers/subprocess.js"; +import { TestWorkspace, type WorkspaceDecl } from "../helpers/workspace.js"; + +// The stand-in: removes and writes what the plan names (paths relative to +// the working directory), then exits with the plan's code. +const STANDIN_SOURCE = `import fs from "node:fs"; +import path from "node:path"; + +const plan = JSON.parse(process.env.XSPEC_STANDIN_PLAN ?? "{}"); +for (const rel of plan.remove ?? []) fs.rmSync(rel); +for (const [rel, content] of Object.entries(plan.write ?? {})) { + fs.mkdirSync(path.dirname(rel), { recursive: true }); + fs.writeFileSync(rel, content); +} +process.exit(plan.exit ?? 0); +`; + +interface Plan { + readonly write?: Readonly<Record<string, string>>; + readonly remove?: readonly string[]; + readonly exit?: number; +} + +interface Stage { + readonly workspace: TestWorkspace; + readonly binding: ProductBinding; + /** Runs the stand-in with `argv` in `cwd` (the root by default). */ + run(argv: readonly string[], plan: Plan, cwd?: string): Promise<RunResult>; +} + +async function stage(decl: WorkspaceDecl): Promise<Stage> { + const workspace = await TestWorkspace.create({ + ...decl, + files: { "bin/standin.mjs": STANDIN_SOURCE, ...decl.files }, + }); + onTestFinished(() => workspace.dispose()); + const binding: ProductBinding = { + label: "T6.5-22(a) stand-in", + command: process.execPath, + prefixArgs: [workspace.path("bin/standin.mjs")], + }; + return { + workspace, + binding, + run: async (argv, plan, cwd = workspace.root) => + await runProduct(binding, { + cwd, + argv, + env: { XSPEC_STANDIN_PLAN: JSON.stringify(plan) }, + }), + }; +} + +/** The run must fail as a diagnosed assertion failure matching each + * pattern; resolves with the failure's message. */ +async function expectBreach( + run: Promise<RunResult>, + ...patterns: readonly RegExp[] +): Promise<string> { + const error = await run.then( + () => undefined, + (thrown: unknown) => thrown, + ); + expect(error).toBeInstanceOf(HarnessAssertionError); + const message = (error as Error).message; + expect(message).toMatch(/^T6\.5-22\(a\)/); + for (const pattern of patterns) expect(message).toMatch(pattern); + return message; +} + +async function expectExit( + run: Promise<RunResult>, + code: number, +): Promise<void> { + expect((await run).exitCode).toBe(code); +} + +const CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts", "src/**/*.tsx"] + } +}) +`; + +const SECTION_A = '<S id="a">\n\nAlpha.\n</S>\n'; +const SECTION_B = '<S id="b">\n\nBeta.\n</S>\n'; +const SECTION_MOVE = ["move", "specs/A.mdx#a", "specs/B.mdx#b.a"]; + +const BASE_FILES = { + "xspec.config.ts": CONFIG, + "specs/A.mdx": SECTION_A, + "specs/B.mdx": SECTION_B, +}; + +/** `B.mdx` with `declaration` heading it as an ESM block of its own. */ +const bWith = (declaration: string): string => `${declaration}\n\n${SECTION_B}`; + +describe("T6.5-22(a): the driver judges a performed move's added identifiers", () => { + test("a section move adding `import let from …` to a spec source fails, naming the file, the identifier, and the clause; a fresh identifier passes", async () => { + const barred = await stage({ files: BASE_FILES }); + const message = await expectBreach( + barred.run(SECTION_MOVE, { + write: { "specs/B.mdx": bWith('import let from "./let.xspec"') }, + }), + /specs\/B\.mdx: the added identifier `let` \(`import let from "\.\/let\.xspec"`\) is barred: /, + ); + expect(message.split("\n")).toHaveLength(2); + + const fresh = await stage({ files: BASE_FILES }); + await expectExit( + fresh.run(SECTION_MOVE, { + write: { "specs/B.mdx": bWith('import fresh from "./let.xspec"') }, + }), + 0, + ); + }); + + test("in a code source: a name the file references, and one it binds only in a function, are failed", async () => { + const { run } = await stage({ + files: { + ...BASE_FILES, + "src/t.ts": 'test("x", () => {})\n', + "src/h.ts": + "export function g() {\n const helper = 1\n return helper\n}\n", + }, + }); + await expectBreach( + run(SECTION_MOVE, { + write: { + "src/t.ts": + 'import test from "../specs/test.xspec"\ntest("x", () => {})\n', + "src/h.ts": + 'import helper from "../specs/helper.xspec"\nexport function g() {\n const helper = 1\n return helper\n}\n', + }, + }), + /src\/t\.ts: the added identifier `test` .* is equal to a name the pre-operation file references/, + /src\/h\.ts: the added identifier `helper` .* is bound by a declaration of the pre-operation file/, + ); + }); + + test("added identifiers are distinct; `S` is barred in a spec source and not in a code source", async () => { + const twice = await stage({ + files: { ...BASE_FILES, "src/c.ts": "export const v = 1\n" }, + }); + await expectBreach( + twice.run(SECTION_MOVE, { + write: { + "src/c.ts": + 'import X from "../specs/a.xspec"\nimport X from "../specs/c.xspec"\nexport const v = 1\n', + }, + }), + /src\/c\.ts: the added identifier `X` .* is not distinct from another identifier added to the file/, + ); + + const spec = await stage({ files: BASE_FILES }); + await expectBreach( + spec.run(SECTION_MOVE, { + write: { "specs/B.mdx": bWith('import S from "./s.xspec"') }, + }), + /specs\/B\.mdx: the added identifier `S` .* is barred: a compiler-provided name/, + ); + + const code = await stage({ + files: { ...BASE_FILES, "src/c.ts": "export const v = 1\n" }, + }); + await expectExit( + code.run(SECTION_MOVE, { + write: { + "src/c.ts": + 'import S, { text } from "../specs/s.xspec"\nexport const v = 1\n', + }, + }), + 0, + ); + }); + + test("a TSX source bars `React` and the factory its `@jsx` pragma names", async () => { + const { run } = await stage({ + files: { + ...BASE_FILES, + "src/d.tsx": "/** @jsx h */\nexport const v = 1\n", + }, + }); + await expectBreach( + run(SECTION_MOVE, { + write: { + "src/d.tsx": + '/** @jsx h */\nimport h from "../specs/h.xspec"\nimport React from "../specs/React.xspec"\nexport const v = 1\n', + }, + }), + /src\/d\.tsx: the added identifier `h` .* is barred: in a TSX source, the leading identifier of a factory/, + /src\/d\.tsx: the added identifier `React` .* is barred: `React` in a TSX source/, + ); + }); + + test("a created target file is judged against empty pre-operation content", async () => { + const argv = ["move", "specs/A.mdx#a", "specs/N.mdx#n"]; + const created = await stage({ files: BASE_FILES }); + await expectBreach( + created.run(argv, { + write: { + "specs/N.mdx": + 'import Spec from "./a.xspec"\n\n<S id="n">\n\nNu.\n</S>\n', + }, + }), + /specs\/N\.mdx: the added identifier `Spec` .* is barred: a compiler-provided name/, + ); + + const fresh = await stage({ files: BASE_FILES }); + await expectExit( + fresh.run(argv, { + write: { + "specs/N.mdx": + 'import Fresh from "./a.xspec"\n\n<S id="n">\n\nNu.\n</S>\n', + }, + }), + 0, + ); + }); + + test("a file-form move: specifier rewrites add nothing, a kept barred binding included; an import added to the relocated file is judged", async () => { + const files = { + ...BASE_FILES, + "specs/A.mdx": `import Object from "./Object.xspec"\n\n${SECTION_A}`, + "specs/C.mdx": + 'import A from "./A.xspec"\n\n<S id="c" d={A.a}>\n\nGamma.\n</S>\n', + }; + const argv = ["move", "specs/A.mdx", "specs/sub/A.mdx"]; + const rewrittenC = + "import A from './sub/A.xspec'\n\n<S id=\"c\" d={A.a}>\n\nGamma.\n</S>\n"; + + const rewrites = await stage({ files }); + await expectExit( + rewrites.run(argv, { + remove: ["specs/A.mdx"], + write: { + "specs/sub/A.mdx": `import Object from "../Object.xspec"\n\n${SECTION_A}`, + "specs/C.mdx": rewrittenC, + }, + }), + 0, + ); + + const added = await stage({ files }); + await expectBreach( + added.run(argv, { + remove: ["specs/A.mdx"], + write: { + "specs/sub/A.mdx": `import Object from "../Object.xspec"\nimport Math from "../Math.xspec"\n\n${SECTION_A}`, + "specs/C.mdx": rewrittenC, + }, + }), + /specs\/sub\/A\.mdx: the added identifier `Math` .* is barred: /, + ); + }); + + test("a run exiting non-zero, a preview, and another command are not judged", async () => { + const { run } = await stage({ files: BASE_FILES }); + await expectExit( + run(SECTION_MOVE, { + write: { "specs/B.mdx": bWith('import let from "./let.xspec"') }, + exit: 1, + }), + 1, + ); + await expectExit( + run([...SECTION_MOVE, "--preview"], { + write: { "specs/B.mdx": bWith('import eval from "./eval.xspec"') }, + }), + 0, + ); + await expectExit( + run(["rename", "specs/B.mdx", "b", "c"], { + write: { "specs/B.mdx": bWith('import yield from "./yield.xspec"') }, + }), + 0, + ); + await expectExit( + run(["show", "move"], { + write: { "specs/B.mdx": bWith('import static from "./static.xspec"') }, + }), + 0, + ); + }); + + test("`--config`'s directory is the workspace root, flags stand anywhere, and the upward search starts at the working directory", async () => { + const { workspace, run } = await stage({ + files: { + "cfg/xspec.config.ts": CONFIG, + "cfg/specs/A.mdx": SECTION_A, + "cfg/specs/B.mdx": SECTION_B, + }, + }); + await expectBreach( + run( + [ + "--json", + "move", + "--config", + "cfg/xspec.config.ts", + "specs/A.mdx#a", + "specs/B.mdx#b.a", + ], + { + write: { + "cfg/specs/B.mdx": bWith('import eval from "./eval.xspec"'), + }, + }, + ), + /- specs\/B\.mdx: the added identifier `eval` /, + ); + // From `cfg/specs`, the search finds `cfg/xspec.config.ts`; the file + // read before the move holds the `eval` binding the last run wrote. + await expectBreach( + run( + SECTION_MOVE, + { write: { "B.mdx": bWith('import static from "./static.xspec"') } }, + workspace.path("cfg/specs"), + ), + /- specs\/B\.mdx: the added identifier `static` /, + ); + }); + + test("derived files, Markdown emit destinations, and files no group discovers are not judged; a code group's own file is", async () => { + const files = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts", "specs/**/*.ts", "specs/**/*.md", ".xspec/**/*.ts"] + }, + markdown: { emit: true } +}) +`, + "specs/A.mdx": SECTION_A, + "specs/B.mdx": SECTION_B, + }; + const barred = 'import let from "./let.xspec"\n'; + const { run } = await stage({ files }); + await expectExit( + run(SECTION_MOVE, { + write: { + "specs/B.xspec.ts": barred, + "specs/B.md": barred, + ".xspec/g.ts": barred, + "notes/n.ts": barred, + }, + }), + 0, + ); + const message = await expectBreach( + run(SECTION_MOVE, { + write: { "specs/x.ts": barred, "specs/notes.md": barred }, + }), + /- specs\/notes\.md: the added identifier `let` /, + /- specs\/x\.ts: the added identifier `let` /, + ); + expect(message.split("\n")).toHaveLength(3); + }); + + test("a rewritten source not well-formed after the operation, or before it, is a diagnosed failure", async () => { + const after = await stage({ files: BASE_FILES }); + await expectBreach( + after.run(SECTION_MOVE, { + write: { "specs/B.mdx": '<S id="b">\n\nBeta.\n' }, + }), + /specs\/B\.mdx, which the operation rewrote, is not well-formed under its grammar after it/, + ); + + const before = await stage({ + files: { ...BASE_FILES, "specs/B.mdx": '<S id="b">\n\nBeta.\n' }, + mdx: { unparseable: ["specs/B.mdx"] }, + }); + await expectBreach( + before.run(SECTION_MOVE, { write: { "specs/B.mdx": SECTION_B } }), + /specs\/B\.mdx, which the operation rewrote, was not well-formed under its grammar before it/, + ); + }); +}); + +describe("T6.5-22(a): a performed move, read by SPEC 12.0's invocation grammar", () => { + test.each([ + [["move", "a.mdx", "b.mdx"], "file"], + [["move", "a.mdx#x", "b.mdx#y"], "section"], + [["--json", "move", "a.mdx#x", "--test-hold", "h", "b.mdx#y"], "section"], + [["--file", "--json", "move", "a.mdx", "b.mdx"], "file"], + [["move", "--", "--a.mdx", "b.mdx"], "file"], + ] as const)("%j performs a %s-form move", (argv, form) => { + expect(readPerformedMove(argv)?.form).toBe(form); + }); + + test("`--config`'s value is read, whatever it spells", () => { + expect( + readPerformedMove(["move", "--config", "--json", "a.mdx", "b.mdx"]), + ).toEqual({ + form: "file", + origin: "a.mdx", + destination: "b.mdx", + config: "--json", + }); + }); + + test.each([ + [["move", "a.mdx#x", "b.mdx#y", "--preview"]], + [["rename", "a.mdx", "x", "y"]], + [["show", "move"]], + [["--config", "move", "a.mdx", "b.mdx"]], + [["move", "a.mdx#x", "b.mdx"]], + [["move", "a.mdx", "b.mdx", "c.mdx"]], + [["move", "a.mdx"]], + [["move", "a.mdx", "b.mdx", "--config"]], + [["move", "--json", "--json", "a.mdx", "b.mdx"]], + ] as const)("%j performs none", (argv) => { + expect(readPerformedMove(argv)).toBeUndefined(); + }); +}); + +describe("T6.5-22(a): a spec source's strict-mode-barred import bindings read, S-9's verdicts unchanged", () => { + // prettier-ignore + const EARLY_ERROR_BINDINGS = [ + "let", "static", "implements", "interface", "package", "private", + "protected", "public", "eval", "arguments", "yield", "await", + ]; + + test.each(EARLY_ERROR_BINDINGS)( + "`import %s from …` reads, though S-9's allowances admit it nowhere", + (name) => { + const text = bWith(`import ${name} from "./${name}.xspec"`); + expect(deriveMdx(text, { allowances: MDX_ALLOWANCES }).derives).toBe( + false, + ); + expect(() => readMdxTree(text)).not.toThrow(); + }, + ); + + test("a reserved word no binding derives, and an `await` no identifier, read nowhere", () => { + for (const text of [ + bWith('import enum from "./enum.xspec"'), + bWith('import default from "./default.xspec"'), + '<S id="b">\n\n{await}\n</S>\n', + 'import await from "./await.xspec"\n\n<S id="b">\n\nBeta {text(await.a)}.\n</S>\n', + ]) { + expect(() => readMdxTree(text)).toThrow(/does not derive/); + } + }); +}); + +describe("T6.5-22(b): one file's added declarations and breaches (`judgeAddedImportsOfFile`)", () => { + const HOST_BEFORE = + 'import A from "./A.xspec"\n\n<S id="host" d={A.w}>\nHost text, quoting {text(A.a)}.\n</S>\n'; + const hostAfter = (name: string): string => + `import A from "./A.xspec"\nimport ${name} from "./let.xspec"\n\n<S id="host" d={A.w}>\nHost text, quoting {text(${name}.a)}.\n</S>\n`; + + test("a spec source: the added declaration with its specifier's value and binding; a fresh one passes, `let` is barred", () => { + expect( + judgeAddedImportsOfFile( + "specs/host.mdx", + "spec-source", + HOST_BEFORE, + hostAfter("let2"), + ), + ).toEqual({ + added: [ + { + text: 'import let2 from "./let.xspec"', + specifier: "./let.xspec", + identifiers: ["let2"], + }, + ], + problems: [], + }); + const barred = judgeAddedImportsOfFile( + "specs/host.mdx", + "spec-source", + HOST_BEFORE, + hostAfter("let"), + ); + expect(barred.added.map((declaration) => declaration.identifiers)).toEqual([ + ["let"], + ]); + expect(barred.problems).toHaveLength(1); + expect(barred.problems[0]).toMatch( + /^specs\/host\.mdx: the added identifier `let` \(`import let from "\.\/let\.xspec"`\) is barred/, + ); + }); + + test("a code source: `Record`, referenced only in a type annotation, is a breach; an unchanged file adds nothing", () => { + const before = + 'import A from "../specs/A.xspec"\n\nlet r: Record<string, number> = {}\n\nA.m\nA.w\n'; + const after = + 'import A from "../specs/A.xspec"\nimport Record from "../specs/Record.xspec"\n\nlet r: Record<string, number> = {}\n\nRecord.m\nA.w\n'; + const judgement = judgeAddedImportsOfFile( + "src/record.ts", + "typescript", + before, + after, + ); + expect(judgement.added).toEqual([ + { + text: 'import Record from "../specs/Record.xspec"', + specifier: "../specs/Record.xspec", + identifiers: ["Record"], + }, + ]); + expect(judgement.problems).toHaveLength(1); + expect(judgement.problems[0]).toMatch( + /the added identifier `Record` .* is equal to a name the pre-operation file references/, + ); + expect( + judgeAddedImportsOfFile("src/record.ts", "typescript", before, before), + ).toEqual({ added: [], problems: [] }); + }); + + test("a side not well-formed yields that problem alone", () => { + const judgement = judgeAddedImportsOfFile( + "specs/host.mdx", + "spec-source", + HOST_BEFORE, + 'import A from "./A.xspec"\nimport await from "./await.xspec"\n\n<S id="host" d={A.w}>\nHost text, quoting {text(await.a)}.\n</S>\n', + ); + expect(judgement.added).toEqual([]); + expect(judgement.problems).toHaveLength(1); + expect(judgement.problems[0]).toMatch( + /^specs\/host\.mdx, which the operation rewrote, is not well-formed under its grammar after it/, + ); + }); +}); diff --git a/test/self/assertion-protocol.test.ts b/test/self/assertion-protocol.test.ts index b831a7f9..603ad1e5 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 33f46b88..7871dcde 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 = 6; +const EXPECTED_VIOLATORS = 21; /** 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 6 conformers and 21 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 37818598..46df17a5 100644 --- a/test/self/certification-fixtures.ts +++ b/test/self/certification-fixtures.ts @@ -96,7 +96,6 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ "CONF-CORE", "conf-core/bin.mjs", [ - "T6.1-1", "T6.1-2", "T10.4-5", "T13.4-5", @@ -105,6 +104,7 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ "T13.5-3", "T13.5-4", "T13.5-5", + "T13.5-8", ], [ // VIOL-CORE-NOLOCK: mutating commands do not exclude one another — the @@ -112,7 +112,10 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ // a second mutating command started while another runs or is held // proceeds normally instead of failing with the usage error of SPEC // 13.5/12.0. - violator("VIOL-CORE-NOLOCK", "conf-core/bin-nolock.mjs", ["T13.5-2"]), + violator("VIOL-CORE-NOLOCK", "conf-core/bin-nolock.mjs", [ + "T13.5-2", + "T13.5-8", + ]), // VIOL-CORE-EARLYWRITE: a mutating command performs its workspace // modifications before creating the hold file — it acquires // exclusivity, completes the operation's writes (journal append @@ -122,6 +125,17 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ "T13.5-1", "T13.5-4", ]), + // VIOL-CORE-EARLYREFRESH: the 13.3 refresh a mutating `review` + // subcommand performs on a stale workspace runs before workspace + // exclusivity is acquired, so stale graph data is rewritten before the + // hold file is created — one ordering rule of 13.5 (the hold precedes + // every modification, the refresh included) broken for the refresh + // alone; the hold file is still created after exclusivity and before + // every other write, and a workspace whose graph data is current is + // refreshed by nothing. + violator("VIOL-CORE-EARLYREFRESH", "conf-core/bin-earlyrefresh.mjs", [ + "T13.5-1", + ]), // VIOL-CORE-STALELOCK: workspace exclusivity is not released by // abnormal termination — after a mutating command's process is killed, // every later mutating command in that workspace is refused with the @@ -143,7 +157,6 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ // `.xspec/journal`, creating the file when absent. Mutating commands, // and the entries `rename`/`move` append, are unchanged. violator("VIOL-CORE-CHATTYREADS", "conf-core/bin-chattyreads.mjs", [ - "T6.1-1", "T13.4-5", ]), // VIOL-CORE-PERSISTREADS: review reads persist read-time invalidation — @@ -154,6 +167,19 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ violator("VIOL-CORE-PERSISTREADS", "conf-core/bin-persistreads.mjs", [ "T10.4-5", ]), + // VIOL-CORE-LATELOCK: workspace exclusivity is acquired late — a + // mutating command acquires it, and creates its hold file, only once + // the argument checks of 12.0 and baseline resolution (6.3) have + // passed, instead of before them — the two checks 12.0 places ahead of + // source validation, and the only ones moved: the gate of 13.3, the + // valid-workspace precondition of rename/move (6.4, 6.5), and every + // modification still follow acquisition and the hold. An invocation + // one of the two moved checks refuses exits 2 at once having acquired + // nothing and created no hold file — T13.5-8's two seam-ordering arms + // (`rename specs/A.mdx nope x`, `review create --base <ref> --name n` + // under `--test-hold`) — while its failing-workspace arms, whose + // commands pass both checks, hold and are excluded as the conformer's. + violator("VIOL-CORE-LATELOCK", "conf-core/bin-latelock.mjs", ["T13.5-8"]), ], ), // CONF-VALID (§CONF-VALID): segment and tag validity — `build` with the @@ -188,23 +214,39 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ "T1.4-4", "P-1", ]), - // VIOL-VALID-WIDE: U+00A0, U+0085, and U+2028 are treated as - // whitespace for SPEC 1.4 validity — a segment or tag containing any - // of them is rejected with 14.4. Tag splitting and all other + // VIOL-VALID-WIDE: U+00A0 and U+0085, exactly, are treated as + // whitespace for SPEC 1.4 validity — a segment or tag containing + // either is rejected with 14.4. Tag splitting and all other // classifications are unchanged. violator("VIOL-VALID-WIDE", "conf-valid/bin-wide.mjs", [ "T1.4-2", "T1.4-4", "P-1", ]), + // VIOL-VALID-SEP: SPEC 1.4's bar on U+2028 and U+2029 is not + // enforced — a segment or tag containing either is accepted as valid. + // One clause of 1.4's quote-and-escape bullet dropped: the quote, + // escape, and character-reference characters stay barred, and neither + // code point joins the whitespace class, so tag splitting is unchanged. + violator("VIOL-VALID-SEP", "conf-valid/bin-sep.mjs", [ + "T1.4-1", + "T1.4-4", + "P-1", + ]), ], ), // 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 identity, source range (SPEC 1.7), own and subtree text (1.6), + // and its `contains` edges (5.2) — the outgoing ones naming its children, + // whence P-3 takes them — `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 +298,93 @@ 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"]), + ], + ), + // CONF-ORPHAN (§CONF-ORPHAN): removal of recorded derived paths — `build` + // and `check` over one spec group of trivial single-section `.mdx` + // sources, `markdown` emitting next to sources or under an `outDir` and + // then reconfigured (`outDir` changed, or emission disabled by `markdown` + // absent or `emit: false`), a code group globbing `specs/*.md` of + // `export const n = 1`; graph data recording the derived paths generated + // (13.3); 13.4's removal of each recorded path no longer generated — its + // occupant judged at the path itself, a directory or a discovered source + // left in place, anything else removed (a symbolic link as the link), + // nothing read below a non-directory component — and 14.10's per-file, + // graph-data, and recorded-file forms, the last reporting exactly what + // that removal would remove. + conformer( + "CONF-ORPHAN", + "conf-orphan/bin.mjs", + ["T13.4-11"], + [ + // VIOL-ORPHAN-THROUGHLINK: the removal of a recorded path no longer + // generated — and 14.10's recorded-file form, reporting exactly what + // it would remove — resolves the path's workspace-relative directory + // components through symbolic links to directories inside the + // workspace root, judging, reporting, and removing the entry the + // link's target holds under the remaining components as if it stood + // at the recorded path. The occupant at the recorded path itself is + // still judged as itself; a plain-file component, or a link to a + // directory outside the root, still leaves the path holding nothing; + // derived-file writes still traverse no link. + violator("VIOL-ORPHAN-THROUGHLINK", "conf-orphan/bin-throughlink.mjs", [ + "T13.4-11", + ]), + // VIOL-ORPHAN-LINKTARGET: the removal of a recorded path no longer + // generated, where the path's occupant is a symbolic link, deletes the + // plain file the link resolves to (nothing where it resolves to no + // plain file) in place of the link, and leaves the link standing. The + // occupant is still judged as itself, so 14.10's recorded-file form is + // unchanged; nothing is read below a non-directory component; + // derived-file writes still replace a link as the occupant. + violator("VIOL-ORPHAN-LINKTARGET", "conf-orphan/bin-linktarget.mjs", [ + "T13.4-11", + ]), + ], + ), ]; diff --git a/test/self/certification-runner.ts b/test/self/certification-runner.ts index d460ec08..072b0f35 100644 --- a/test/self/certification-runner.ts +++ b/test/self/certification-runner.ts @@ -27,6 +27,7 @@ // wall-clock data. import { HarnessAssertionError } from "../helpers/assertions.js"; +import { runProductTestBody } from "../helpers/product-invocations.js"; import type { ProductTestEntry } from "../helpers/registry.js"; import type { ProductBinding } from "../helpers/subprocess.js"; @@ -169,9 +170,11 @@ async function runOne( entry: ProductTestEntry, budgetMs: number, ): Promise<ProductTestResult> { - const body = (async () => { - await entry.run(binding); - })(); + // The body runs inside its own invocation context + // (helpers/product-invocations.ts): the workspace builder's + // undeclared-staging guard sees the body's first product invocation + // wherever it happens, exactly as under the suite's Vitest wrapper. + const body = runProductTestBody(entry.id, () => entry.run(binding)); // Keep an abandoned body's eventual rejection observed (hang path): the // verdict is already recorded, and an unhandled rejection would crash the // whole run (H-8). diff --git a/test/self/certification.test.ts b/test/self/certification.test.ts index 99e9eefa..deb7e2a6 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 six +// conformers and all twenty-one 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/import-insertion.test.ts b/test/self/import-insertion.test.ts new file mode 100644 index 00000000..556b907d --- /dev/null +++ b/test/self/import-insertion.test.ts @@ -0,0 +1,624 @@ +// Self-checks for the added-import insertion discipline helper +// (`test/helpers/import-insertion.ts`; TEST-SPEC 17 preamble — harness +// machinery certification does not exercise; SPEC 6.5's spelling and line +// discipline, asserted value-blind in the fresh identifier and byte-exact in +// every other character, as T6.5-8 reads it). The disciplined insertion — +// `import <X> from "<canonical specifier>"` followed by U+000A at a +// line-start offset — is accepted, including the ambiguous-offset case where +// the run's own terminator repeats the byte beside it, so several offsets +// describe one byte string; the canonical specifier is spelled by 6.5's +// rule; and every failure spelling T6.5-8 names fails as a diagnosed +// HarnessAssertionError: a declaration joined to a neighbour with `;`, the +// mid-line form while a line-start offset exists (and a mid-line offset +// without its preceding terminator), a spurious blank line, a CRLF +// terminator, single quotes, a `;`, other spacing, a non-canonical +// specifier (`./sub/../Target.xspec`, a `.` segment, an ascent into the +// importer's own directory, no `./` prefix, a `.mdx` extension), a +// declaration binding an identifier the rewritten references are not +// rooted at, a specifier designating the wrong module, nothing inserted, +// and edits beyond one inserted run. Line starts are judged by SPEC 3's +// terminators (T6.5-8's terminator re-runs): the disciplined insertion after +// a CRLF or a lone CR is accepted, one between a CRLF's two characters is +// no line start, and the mid-line form a reader judging line starts by +// U+000A alone writes after a lone CR fails diagnosed; a pinned offset (the +// TS arm's start of line 2) admits that offset alone. + +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { + assertAddedImportInsertion, + assertExactDeclarationInsertion, + atLineStart, + canonicalSpecifier, + isolateSingleInsertion, +} from "../helpers/import-insertion.js"; + +const bytes = (text: string): Uint8Array => Buffer.from(text, "utf8"); + +const BASE = [ + 'import Org from "./Origin.xspec"', + "", + '<S id="th" d={T.tm}>', + "Third text.", + "</S>", + "", +].join("\n"); + +function check(actual: string, identifier = "T") { + return assertAddedImportInsertion( + { + rel: "specs/Third.mdx", + base: bytes(BASE), + actual: bytes(actual), + importerDir: "specs", + expectedModule: "specs/Target.xspec", + identifier, + }, + "self", + ); +} + +function expectDiagnosed(run: () => unknown, ...patterns: string[]): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(HarnessAssertionError); + const message = (error as Error).message; + for (const pattern of patterns) expect(message).toContain(pattern); + return; + } + throw new Error( + "expected a diagnosed assertion failure, but the helper passed", + ); +} + +test("isolateSingleInsertion reports every admissible offset of one run", () => { + // `A\n` + `import X\n` + `B` equals `A` + `\nimport X` + `\nB`: offsets 1..2. + const found = isolateSingleInsertion( + bytes("A\nB"), + bytes("A\nimport X\nB"), + "self", + ); + expect(found).toEqual({ length: 9, lowestOffset: 1, highestOffset: 2 }); + expectDiagnosed( + () => isolateSingleInsertion(bytes("A\nB"), bytes("A\nB"), "self"), + "no bytes were inserted", + ); + expectDiagnosed( + () => + isolateSingleInsertion(bytes("A\nB"), bytes("A\nimport X\nC"), "self"), + "not its expected bytes with one run inserted", + ); + expectDiagnosed( + () => + isolateSingleInsertion(bytes("A\nB\nC"), bytes("A\nX\nB\nY\nC"), "self"), + "more than one edit", + ); +}); + +test("canonicalSpecifier spells 6.5's canonical relative form", () => { + // No ascent: `./` prefixed. + expect(canonicalSpecifier("specs", "specs/Target.xspec")).toBe( + "./Target.xspec", + ); + expect(canonicalSpecifier("specs", "specs/sub/t.xspec")).toBe( + "./sub/t.xspec", + ); + expect(canonicalSpecifier("", "specs/t.xspec")).toBe("./specs/t.xspec"); + expect(canonicalSpecifier(".", "specs/t.xspec")).toBe("./specs/t.xspec"); + // Ascents, then the descending segments. + expect(canonicalSpecifier("src", "specs/target.xspec")).toBe( + "../specs/target.xspec", + ); + expect(canonicalSpecifier("src/a/b", "specs/t.xspec")).toBe( + "../../../specs/t.xspec", + ); + expect(canonicalSpecifier("specs/sub", "specs/t.xspec")).toBe("../t.xspec"); + expect(canonicalSpecifier("specs/sub", "specs/other/t.xspec")).toBe( + "../other/t.xspec", + ); + // The module's basename is a file, never a shared directory segment. + expect(canonicalSpecifier("specs", "specs.xspec")).toBe("../specs.xspec"); + expect(canonicalSpecifier("a/b", "a/b.xspec")).toBe("../b.xspec"); +}); + +test("the disciplined added import is accepted at a line-start offset", () => { + const atTop = check('import T from "./Target.xspec"\n' + BASE); + expect(atTop).toEqual({ + offset: 0, + declaration: 'import T from "./Target.xspec"', + identifier: "T", + specifier: "./Target.xspec", + }); + // After the first line — the ambiguous case: offset 32 (mid-line, before + // the terminator) reads as `\n` + declaration without a trailing + // terminator, offset 33 (line start) as declaration + `\n`; the bytes are + // one string, and the line-start reading is the accepted one. + const second = check( + 'import Org from "./Origin.xspec"\nimport T from "./Target.xspec"\n\n<S id="th" d={T.tm}>\nThird text.\n</S>\n', + ); + expect(second.offset).toBe(33); + // At the file's end after its final terminator — a line-start admissible + // offset (SPEC 6.5), the one every receiving file of the consumers holds. + expect(check(BASE + 'import T from "./Target.xspec"\n').offset).toBe( + BASE.length, + ); + // The identifier's value is the product's: another root, read off the + // rewritten references, is accepted alike. + expect( + check('import Tgt from "./Target.xspec"\n' + BASE, "Tgt").identifier, + ).toBe("Tgt"); + // The TS arm's ascent spelling from `src/` (T6.5-8). + const tsBase = [ + 'import ORG from "../specs/Origin.xspec";', + "", + "T.mv;", + "", + ].join("\n"); + expect( + assertAddedImportInsertion( + { + rel: "src/app.ts", + base: bytes(tsBase), + actual: bytes('import T from "../specs/Target.xspec"\n' + tsBase), + importerDir: "src", + expectedModule: "specs/Target.xspec", + identifier: "T", + }, + "self", + ).specifier, + ).toBe("../specs/Target.xspec"); +}); + +test("every failure spelling T6.5-8 names fails diagnosed", () => { + const head = 'import Org from "./Origin.xspec"'; + const tail = '\n\n<S id="th" d={T.tm}>\nThird text.\n</S>\n'; + const notImport = "is not the added import"; + // Joined to the neighbour with `;` — still parsing, still resolving. + // (The run's `.xspec"` repeats the neighbour's, so offsets 25..32 all + // describe it; same-reason offsets are reported as one range.) + expectDiagnosed( + () => check(head + '; import T from "./Target.xspec"' + tail), + notImport, + "at offsets 25–32: the offset is not at the start of a line", + "join the preceding line", + ); + // The mid-line form while a line-start offset (33) exists: inserted after + // the first declaration's closing quote, before its terminator, as `\n` + + // declaration + `\n` — bytes no line-start insertion produces (a blank + // line more), so 6.5's preference fails it. + expectDiagnosed( + () => check(head + '\nimport T from "./Target.xspec"\n' + tail), + notImport, + "mid-line form", + ); + // Mid-line offset without the preceding terminator. + expectDiagnosed( + () => check(head + 'import T from "./Target.xspec"\n' + tail), + notImport, + "join the preceding line", + ); + // A spurious blank line after the declaration. + expectDiagnosed( + () => check('import T from "./Target.xspec"\n\n' + BASE), + notImport, + "spurious blank line", + ); + // CRLF terminator. + expectDiagnosed( + () => check('import T from "./Target.xspec"\r\n' + BASE), + notImport, + "ends in U+000D", + ); + // Single quotes. + expectDiagnosed( + () => check("import T from './Target.xspec'\n" + BASE), + notImport, + "single-quoted", + ); + // A statement terminator. + expectDiagnosed( + () => check('import T from "./Target.xspec";\n' + BASE), + notImport, + "statement terminator", + ); + // Other spacing: a doubled space, a tab. + expectDiagnosed( + () => check('import T from "./Target.xspec"\n' + BASE), + notImport, + "single spaces", + ); + expectDiagnosed( + () => check('import T\tfrom "./Target.xspec"\n' + BASE), + notImport, + "single spaces", + ); + // Non-canonical specifiers designating the right module: a `..` ascent + // and re-descent, a `.` segment, an ascent into the importer's own + // directory. + for (const spelled of [ + "./sub/../Target.xspec", + "././Target.xspec", + "../specs/Target.xspec", + ]) { + expectDiagnosed( + () => check(`import T from "${spelled}"\n` + BASE), + notImport, + "not the canonical relative spelling", + ); + } + // No `./` prefix; a `.mdx` extension (2.1's form). + expectDiagnosed( + () => check('import T from "Target.xspec"\n' + BASE), + notImport, + "relative path", + ); + expectDiagnosed( + () => check('import T from "./Target.mdx"\n' + BASE), + notImport, + "ending in `.xspec`", + ); + // Binds an identifier the rewritten references are not rooted at. + expectDiagnosed( + () => check('import Tgt from "./Target.xspec"\n' + BASE), + notImport, + 'binds "Tgt"', + ); + // Designates the wrong module. + expectDiagnosed( + () => check('import T from "./Origin.xspec"\n' + BASE), + notImport, + "designates specs/Origin.xspec rather than specs/Target.xspec", + ); + // Nothing added at all. + expectDiagnosed(() => check(BASE), "no bytes were inserted"); +}); + +test("line starts are judged by SPEC 3's terminators", () => { + // After a U+000A, a CRLF, or a lone CR — the file's end after a final + // terminator of any kind included — and at offset 0. + expect(atLineStart(bytes("ab"), 0)).toBe(true); + expect(atLineStart(bytes("a\nb"), 2)).toBe(true); + expect(atLineStart(bytes("a\r\nb"), 3)).toBe(true); + expect(atLineStart(bytes("a\rb"), 2)).toBe(true); + expect(atLineStart(bytes("a\r"), 2)).toBe(true); + expect(atLineStart(bytes("a\r\n"), 3)).toBe(true); + // Mid-line, and between a CRLF's two characters (half of one terminator). + expect(atLineStart(bytes("ab"), 1)).toBe(false); + expect(atLineStart(bytes("a\r\nb"), 2)).toBe(false); +}); + +// T6.5-8's TS arm: `src/c.ts` is the origin import, a terminator, then a +// function `f` holding the rewritten marker and the unmoved one; the start +// of line 2 is its one line-start admissible offset, pinned. +const C_LINE_1 = 'import O from "../specs/origin.xspec"'; +const C_DECL = 'import T from "../specs/target.xspec"'; +const cBase = (t: string): string => + [C_LINE_1, "function f() {", " T.y;", " O.w;", "}", ""].join(t); + +function checkCode(actual: string, t: string, pinned = true) { + return assertAddedImportInsertion( + { + rel: "src/c.ts", + base: bytes(cBase(t)), + actual: bytes(actual), + importerDir: "src", + expectedModule: "specs/target.xspec", + identifier: "T", + ...(pinned + ? { + pinnedOffset: { + offset: C_LINE_1.length + t.length, + where: "the start of line 2", + }, + } + : {}), + }, + "self", + ); +} + +test("the disciplined insertion after a CRLF or a lone CR is at a line start", () => { + for (const t of ["\n", "\r\n", "\r"]) { + const base = cBase(t); + const at = C_LINE_1.length + t.length; + const actual = base.slice(0, at) + C_DECL + "\n" + base.slice(at); + // Pinned and unpinned alike: the run is the declaration followed by + // U+000A (never the file's own terminator style) at line 2's start. + expect(checkCode(actual, t).offset).toBe(at); + expect(checkCode(actual, t, false).offset).toBe(at); + } +}); + +test("a lone-CR or CRLF file's insertion off 3's line starts fails diagnosed", () => { + const notImport = "is not the added import"; + // After a lone CR, the mid-line form (U+000A, the declaration, U+000A) — + // what a product judging line starts by U+000A alone writes there; that + // U+000A joins the lone CR into one CRLF. + { + const base = cBase("\r"); + const at = C_LINE_1.length + 1; + expectDiagnosed( + () => + checkCode( + base.slice(0, at) + "\n" + C_DECL + "\n" + base.slice(at), + "\r", + ), + notImport, + "follows a lone U+000D", + "by U+000A alone", + ); + // The same product's mid-line form before the lone CR, at the + // statement's end: a line-start offset exists, so it is not conforming. + const end = C_LINE_1.length; + expectDiagnosed( + () => + checkCode( + base.slice(0, end) + "\n" + C_DECL + "\n" + base.slice(end), + "\r", + ), + notImport, + "mid-line form", + ); + // A terminator in the file's style after the declaration. + expectDiagnosed( + () => checkCode(base.slice(0, at) + C_DECL + "\r" + base.slice(at), "\r"), + notImport, + ); + } + // Between a CRLF's two characters: no line start, so the declaration + // joins line 1. + { + const base = cBase("\r\n"); + const at = C_LINE_1.length + 1; + expectDiagnosed( + () => + checkCode(base.slice(0, at) + C_DECL + "\n" + base.slice(at), "\r\n"), + notImport, + "join the preceding line", + ); + // The file's own CRLF after the declaration. + const start = C_LINE_1.length + 2; + expectDiagnosed( + () => + checkCode( + base.slice(0, start) + C_DECL + "\r\n" + base.slice(start), + "\r\n", + ), + notImport, + "ends in U+000D", + ); + } +}); + +test("a pinned offset admits that offset alone", () => { + for (const t of ["\n", "\r\n", "\r"]) { + const base = cBase(t); + // At the file's end after the final terminator, and at offset 0: line + // starts both, so the unpinned reader accepts them — the pin does not. + for (const actual of [base + C_DECL + "\n", C_DECL + "\n" + base]) { + expect(checkCode(actual, t, false).declaration).toBe(C_DECL); + expectDiagnosed( + () => checkCode(actual, t), + "not at the start of line 2", + "the one line-start admissible offset", + ); + } + } + // A misspelled declaration at the pinned offset is diagnosed as one, + // the pin named. + const base = cBase("\n"); + const at = C_LINE_1.length + 1; + expectDiagnosed( + () => checkCode(base.slice(0, at) + C_DECL + ";\n" + base.slice(at), "\n"), + "is not the added import", + "statement terminator", + "pins the insertion at the start of line 2", + ); +}); + +// T6.5-9's code arm: the import declarations head the file, so the start of +// the line directly after each one is a line-start admissible offset, and +// the test confines the run to those (`pinnedOffsets`). +const H_LINE_2 = 'import Target, { O2 } from "./util.js"'; +const H_BASE = [ + C_LINE_1, + H_LINE_2, + "const target = Target + O2;", + "function f() {", + " T.y;", + " O.w;", + "}", + "", +].join("\n"); +const H_AFTER_IMPORTS = [ + C_LINE_1.length + 1, + C_LINE_1.length + 1 + H_LINE_2.length + 1, +]; + +function checkHeaded(actual: string, confined = true) { + return assertAddedImportInsertion( + { + rel: "src/c.ts", + base: bytes(H_BASE), + actual: bytes(actual), + importerDir: "src", + expectedModule: "specs/target.xspec", + identifier: "T", + ...(confined + ? { + pinnedOffsets: { + offsets: H_AFTER_IMPORTS, + where: "the start of the line directly after an import", + }, + } + : {}), + }, + "self", + ); +} + +const insertAt = (base: string, at: number, run: string): string => + base.slice(0, at) + run + base.slice(at); + +test("a confining offset set admits each of its offsets and those alone", () => { + // Directly after either import: accepted, at that offset. + for (const at of H_AFTER_IMPORTS) { + expect(checkHeaded(insertAt(H_BASE, at, C_DECL + "\n")).offset).toBe(at); + } + // Offset 0, the start of the line after the `const`, and the file's end: + // line starts all, so the unconfined reader accepts them — the set does + // not, and names its offsets with their lines. + const afterConst = H_BASE.indexOf("function f"); + for (const at of [0, afterConst, H_BASE.length]) { + const actual = insertAt(H_BASE, at, C_DECL + "\n"); + expect(checkHeaded(actual, false).offset).toBe(at); + expectDiagnosed( + () => checkHeaded(actual), + "not at the start of the line directly after an import", + `offsets ${String(H_AFTER_IMPORTS[0])} (line 2), ` + + `${String(H_AFTER_IMPORTS[1])} (line 3)`, + "the line-start admissible offsets the receiving file holds", + ); + } + // A misspelled declaration at a confined offset is diagnosed as one, the + // set named. + expectDiagnosed( + () => + checkHeaded(insertAt(H_BASE, H_AFTER_IMPORTS[1] ?? 0, C_DECL + ";\n")), + "is not the added import", + "statement terminator", + "confines the insertion to the start of the line directly after an import", + ); +}); + +test("pinnedOffset and pinnedOffsets together, or an empty set, are harness defects", () => { + const actual = insertAt(H_BASE, H_AFTER_IMPORTS[0] ?? 0, C_DECL + "\n"); + const common = { + rel: "src/c.ts", + base: bytes(H_BASE), + actual: bytes(actual), + importerDir: "src", + expectedModule: "specs/target.xspec", + identifier: "T", + }; + expect(() => + assertAddedImportInsertion( + { + ...common, + pinnedOffset: { offset: H_AFTER_IMPORTS[0] ?? 0, where: "line 2" }, + pinnedOffsets: { offsets: H_AFTER_IMPORTS, where: "after an import" }, + }, + "self", + ), + ).toThrow(/never both/); + expect(() => + assertAddedImportInsertion( + { ...common, pinnedOffsets: { offsets: [], where: "nowhere" } }, + "self", + ), + ).toThrow(/names no offset/); +}); + +// The exact-declaration reader (T6.5-11): the added declaration's characters +// are pinned byte-exactly by the caller — 6.5's TypeScript spellings with the +// fresh identifiers substituted — and the line discipline is read as the +// default-import reader reads it. +const TS_BASE = [ + "", + "export function f(): string {", + " return txt(Tgt.y);", + "}", + "", +].join("\n"); +const TS_DECL = 'import Tgt, { text as txt } from "../specs/target.xspec"'; + +function checkExact(actual: string, declaration = TS_DECL) { + return assertExactDeclarationInsertion( + { + rel: "src/c.ts", + base: bytes(TS_BASE), + actual: bytes(actual), + declaration, + }, + "self", + ); +} + +test("an exactly spelled declaration is accepted at a line-start offset, every admissible reading returned", () => { + // At the file's start. + expect(checkExact(TS_DECL + "\n" + TS_BASE)).toEqual([ + { offset: 0, atLineStart: true }, + ]); + // After the blank line: the run's terminator repeats the LF beside it, so + // the isolated run admits offsets 0 and 1, of which only 1 reads as the + // declaration followed by U+000A. + expect(checkExact("\n" + TS_DECL + "\n" + TS_BASE.slice(1))).toEqual([ + { offset: 1, atLineStart: true }, + ]); + // The `{ text }` spelling is the caller's to pin. + const textDecl = 'import { text } from "../specs/target.xspec"'; + expect(checkExact(textDecl + "\n" + TS_BASE, textDecl)).toEqual([ + { offset: 0, atLineStart: true }, + ]); +}); + +test("the mid-line form is read with its preceding terminator", () => { + const head = "\nexport"; + expect( + checkExact(head + "\n" + TS_DECL + "\n" + TS_BASE.slice(head.length)), + ).toEqual([{ offset: head.length, atLineStart: false }]); +}); + +test("the exact reader judges line starts by 3's terminators", () => { + // The same file with every terminator a lone CR: the offset after the + // leading lone CR is a line start, so the run is the declaration followed + // by U+000A alone. + const base = TS_BASE.split("\n").join("\r"); + expect( + assertExactDeclarationInsertion( + { + rel: "src/c.ts", + base: bytes(base), + actual: bytes("\r" + TS_DECL + "\n" + base.slice(1)), + declaration: TS_DECL, + }, + "self", + ), + ).toEqual([{ offset: 1, atLineStart: true }]); +}); + +test("every deviation from the exact declaration fails diagnosed", () => { + const notDecl = "is not the added declaration"; + // Joined to the file with a `;`. + expectDiagnosed( + () => checkExact(TS_DECL + ";\n" + TS_BASE), + notDecl, + "must hold exactly the declaration", + ); + // Single-quoted specifier. + expectDiagnosed( + () => checkExact(TS_DECL.replace(/"/g, "'") + "\n" + TS_BASE), + notDecl, + ); + // A spurious blank line after the declaration. + expectDiagnosed(() => checkExact(TS_DECL + "\n\n" + TS_BASE), notDecl); + // CRLF terminator. + expectDiagnosed(() => checkExact(TS_DECL + "\r\n" + TS_BASE), notDecl); + // A second declaration kept beside the added one (an import not removed). + expectDiagnosed( + () => + checkExact( + TS_DECL + "\n" + 'import O from "../specs/origin.xspec";\n' + TS_BASE, + ), + notDecl, + ); + // Nothing added at all. + expectDiagnosed(() => checkExact(TS_BASE), "no bytes were inserted"); + // Edits beyond one inserted run. + expectDiagnosed( + () => checkExact(TS_DECL + "\n" + TS_BASE.replace("}\n", "};\n")), + "more than one edit", + ); +}); diff --git a/test/self/p8-fixed-seed-draws.test.ts b/test/self/p8-fixed-seed-draws.test.ts new file mode 100644 index 00000000..6f3885c0 --- /dev/null +++ b/test/self/p8-fixed-seed-draws.test.ts @@ -0,0 +1,450 @@ +// P-8's fixed-seed draw guard (TEST-SPEC §16 P-8; §18 E-5; §0 H-10). CI runs +// P-8 over a fixed seed set (E-5), so the trials it stages are a pure +// function of its generator, the seed set, and its registered runs per seed +// (`P8_RUNS_PER_SEED`; P-8 passes no `seeds`). They are replayed here exactly +// as `checkProperty` draws them — `drawFixedSeedTrials`, one sequential PRNG +// stream per seed — with no property body and no product (H-8's ordering: +// the guard holds before any product exists). Two test-strength obligations +// of P-8 must hold of those draws, so that a generator, menu, or run-count +// change that moves them cannot drop either silently: +// +// 1. the giant-nesting floor: "its staged draws MUST include section +// nesting at least 2048 levels deep" (a floor on staged inputs, not a +// product bound). A draw counts toward it when it is a nesting mutation +// of an MDX target — the section tower `sectionTowerSource` builds, +// balanced or unclosed — and that tower's bytes survive intact into +// the file the trial stages: a later mutation of the same file (a +// truncation, a garbage or tower replacement, a terminator rewrite, a +// splice into the tower) can undo the nesting, and a TypeScript target's +// parenthesis or bracket tower is no section nesting. S-8's pooled +// replay (test/self/s8-answer-scale-capacity.test.ts) reads the bare +// `depth-<n>` descriptions over P-8's and P-11's draws together at 25 +// runs per seed; it bounds the capacity maxima and cannot see the floor +// leave P-8's own registered runs; +// 2. the command sweep: P-8 holds "every command" to its robustness +// clauses, so every `COMMAND_MENU` form must be drawn at least once by +// P-8's own fixed-seed trials — a form the menu offers that CI never +// draws would go unexercised. Should the fixed seeds miss a form, raise +// the per-trial command count or weight the pick; never weaken this. +// +// A third guard pins how P-8 decides which output contract a drawn form is +// held to: JSON output is in effect, and the never-a-partial-JSON-document +// clause applies, exactly when SPEC 12.0 puts it in effect — a `--json` +// token read as a flag, or a JSON-only surface (10.7, 11, 12.6) with or +// without one (H-5) — and every JSON-only surface the menu holds has a form +// without `--json`, so the by-surface half of the rule is exercised, not +// only the flag half (the sweep guard above then has it drawn). +// +// A fourth pins how P-8 runs the review forms that name a session or an +// item (SPEC 10.7): each as a composite (`armSteps`) over a session its arm +// creates first, a JSON read of the session yielding the item an item form +// names — so a drawn `show`, `split`, or `resolve` can reach a session's +// items rather than only ever meeting the no-session usage error — with +// every slot filled before a step runs. +// +// A fifth pins the rest of the sweep's shape: every command runs with JSON +// output in effect at least once (12.0: every command supports `--json`) — +// `build` in the fixed arm — and the mutating commands run performed and +// previewed (6.4–6.6): `rename` and the file form of `move` previewed with +// and without `--json`, and the section form, out of a base section into a +// target file the base holds and into one it lacks (6.5 creates it), +// performed and previewed, each with and without `--json` — never with +// `--test-hold`, a usage error beside `--preview` (6.6). + +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { + DEFAULT_PROPERTY_SEEDS, + drawFixedSeedTrials, +} from "../helpers/property.js"; +import { + armSteps, + COMMAND_MENU, + FIXED_BUILD_ARM, + FUZZ_BASE_FILES, + genFuzzTrial, + ITEM_ID_SLOT, + jsonOutputInEffect, + P8_RUNS_PER_SEED, + sectionTowerSource, + SESSION_SLOT, +} from "../suite/registry/section-16-p8.js"; +import type { FuzzTrial } from "../suite/registry/section-16-p8.js"; +import { GIANT_NESTING_FLOOR } from "./staged-scale.js"; + +/** The replay's runs per seed: P-8's registered count, never another. */ +const REPLAY_RUNS = P8_RUNS_PER_SEED; + +/** One replayed P-8 trial, labelled with its seed and 1-based trial number. */ +interface ReplayedTrial { + readonly label: string; + readonly trial: FuzzTrial; +} + +/** P-8's own fixed-seed draws, exactly as its registered run stages them. */ +function replayP8Draws(): ReplayedTrial[] { + const trials = drawFixedSeedTrials(genFuzzTrial, REPLAY_RUNS); + return trials.map((trial, index) => { + const seed = DEFAULT_PROPERTY_SEEDS[Math.floor(index / REPLAY_RUNS)]; + const number = (index % REPLAY_RUNS) + 1; + return { label: `seed ${String(seed)}, trial ${String(number)}`, trial }; + }); +} + +/** + * A nesting mutation's log line for an MDX target (`mutateNesting` in + * section-16-p8.ts, prefixed with the target path by `genFuzzTrial`). + */ +const SECTION_TOWER_DRAW = + /^(.+): (?:append|replace with) a depth-(\d+) (balanced|unclosed) section tower$/; + +/** A section-tower draw of one trial, and whether its staged file keeps it. */ +interface SectionTowerDraw { + readonly description: string; + readonly depth: number; + readonly intact: boolean; +} + +function sectionTowerDraws(trial: FuzzTrial): SectionTowerDraw[] { + const staged = new Map(trial.files); + const draws: SectionTowerDraw[] = []; + for (const description of trial.mutations) { + const match = SECTION_TOWER_DRAW.exec(description); + if (match === null) continue; + const [, path = "", depthText = "", shape = ""] = match; + const bytes = staged.get(path); + if (bytes === undefined) { + throw new Error( + `P-8 draw guard: the trial logs "${description}" but stages no file at ${path}`, + ); + } + const depth = Number(depthText); + const tower = Buffer.from( + sectionTowerSource(depth, shape === "balanced"), + "utf8", + ); + const intact = Buffer.from( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).includes(tower); + draws.push({ description, depth, intact }); + } + return draws; +} + +test("P-8's own fixed-seed draws stage its giant-nesting floor — an intact MDX section tower at least 2048 levels deep (TEST-SPEC §16 P-8; E-5 replay at P-8's registered runs per seed)", () => { + let deepest = 0; + const towers: string[] = []; + for (const { label, trial } of replayP8Draws()) { + for (const draw of sectionTowerDraws(trial)) { + towers.push( + `${label}: ${draw.description}${draw.intact ? "" : " (undone by a later mutation)"}`, + ); + if (draw.intact) deepest = Math.max(deepest, draw.depth); + } + } + expect( + deepest, + `P-8's fixed-seed draws (${String(DEFAULT_PROPERTY_SEEDS.length)} seeds × ` + + `${String(REPLAY_RUNS)} runs) must stage section nesting at least ` + + `${String(GIANT_NESTING_FLOOR)} levels deep (TEST-SPEC §16 P-8); their ` + + `section-tower draws: ${JSON.stringify(towers)}`, + ).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); +}); + +test("the floor reading counts an MDX section tower only where its trial stages it intact, and never a TypeScript tower (guard vectors)", () => { + const depth = GIANT_NESTING_FLOOR; + const utf8 = (text: string): Uint8Array => + Uint8Array.from(Buffer.from(text, "utf8")); + const unclosed = utf8(sectionTowerSource(depth, false)); + const replaced = `specs/A.mdx: replace with a depth-${String(depth)} unclosed section tower`; + const appended = `specs/B.mdx: append a depth-${String(depth)} balanced section tower`; + const bracket = `src/app.ts: append a depth-${String(depth)} unbalanced bracket tower`; + const trial = ( + files: ReadonlyArray<readonly [string, Uint8Array]>, + mutations: readonly string[], + ): FuzzTrial => ({ files, mutations, commands: [] }); + // Staged as drawn: the replaced file is the tower; the appended tower + // follows the file's own bytes. + expect( + sectionTowerDraws( + trial( + [ + ["specs/A.mdx", unclosed], + ["specs/B.mdx", utf8(`# B\n\n${sectionTowerSource(depth, true)}`)], + ], + [replaced, appended], + ), + ), + ).toEqual([ + { description: replaced, depth, intact: true }, + { description: appended, depth, intact: true }, + ]); + // A later mutation of the same file undoes the tower: one byte short. + expect( + sectionTowerDraws( + trial( + [["specs/A.mdx", unclosed.subarray(0, unclosed.length - 1)]], + [ + replaced, + `specs/A.mdx: truncate to the first ${String(unclosed.length - 1)} byte(s)`, + ], + ), + ), + ).toEqual([{ description: replaced, depth, intact: false }]); + // A TypeScript target's tower is no section nesting. + expect( + sectionTowerDraws( + trial( + [["src/app.ts", utf8(`const zz = ${"[".repeat(depth)}\n`)]], + [bracket], + ), + ), + ).toEqual([]); +}); + +test("P-8's own fixed-seed draws exercise every command-menu form (TEST-SPEC §16 P-8's command sweep; E-5 replay at P-8's registered runs per seed)", () => { + const drawn = new Set<string>(); + for (const { trial } of replayP8Draws()) { + for (const argv of trial.commands) drawn.add(JSON.stringify(argv)); + } + const undrawn = COMMAND_MENU.filter( + (argv) => !drawn.has(JSON.stringify(argv)), + ).map((argv) => argv.join(" ")); + expect( + undrawn, + `every COMMAND_MENU form must be drawn by P-8's fixed-seed trials ` + + `(${String(DEFAULT_PROPERTY_SEEDS.length)} seeds × ` + + `${String(REPLAY_RUNS)} runs; ${String(drawn.size)} of ` + + `${String(COMMAND_MENU.length)} forms drawn) — raise the per-trial ` + + `command count or weight the pick, never weaken this guard`, + ).toEqual([]); +}); + +test("P-8 holds a form to the JSON contract exactly when SPEC 12.0 puts JSON output in effect, and every JSON-only surface its menu holds has a form without --json (TEST-SPEC §16 P-8, H-5; guard vectors)", () => { + const vectors: ReadonlyArray<readonly [readonly string[], boolean]> = [ + // JSON-only surfaces (SPEC 10.7, 11, 12.6), with or without `--json`. + [["version"], true], + [["version", "--json"], true], + [["inventory"], true], + [["query", "nodes"], true], + [["occurrences", "--file", "specs/B.mdx"], true], + [["view", "specs/A.mdx", "--text"], true], + [["at", "specs/A.mdx", "0"], true], + [["review", "export", "r1"], true], + // Flags stand anywhere: the surface is the first non-flag token left + // once flags and their values are removed (12.0's grammar). + [["--config", "xspec.config.ts", "view"], true], + [["--name", "view", "review", "list"], false], + // Every other surface: `--json` read as a flag, and only so. + [["check"], false], + [["ids", "--tree"], false], + [["review", "list"], false], + [["review", "next", "r1"], false], + // A preview is no JSON-only surface (6.6). + [["rename", "specs/A.mdx", "c", "c2", "--preview"], false], + [["move", "specs/A.mdx#c", "specs/C.mdx#e", "--preview", "--json"], true], + [["check", "--json"], true], + [["--json", "check"], true], + [["check", "--json", "--json"], true], + [["check", "--file", "--json"], false], + [["check", "--", "--json"], false], + ]; + expect( + vectors + .filter(([argv, expected]) => jsonOutputInEffect(argv) !== expected) + .map( + ([argv, expected]) => + `${argv.join(" ")} (expected ${String(expected)})`, + ), + "P-8's reading of when JSON output is in effect (SPEC 12.0)", + ).toEqual([]); + + // Per JSON-only surface the menu holds: whether a form without `--json` + // is among its forms. Menu forms lead with the command word. + const bareForm = new Map<string, boolean>(); + for (const argv of COMMAND_MENU) { + const withoutJson = argv.filter((token) => token !== "--json"); + if (!jsonOutputInEffect(withoutJson)) continue; + const surface = + argv[0] === "review" ? argv.slice(0, 2).join(" ") : (argv[0] ?? ""); + bareForm.set( + surface, + (bareForm.get(surface) ?? false) || withoutJson.length === argv.length, + ); + } + expect([...bareForm.keys()]).toEqual( + expect.arrayContaining([ + "at", + "inventory", + "occurrences", + "query", + "review export", + "version", + "view", + ]), + ); + expect( + [...bareForm].filter(([, bare]) => !bare).map(([surface]) => surface), + "every JSON-only surface P-8's menu holds needs a form without --json, " + + "so the by-surface half of SPEC 12.0's rule is exercised", + ).toEqual([]); +}); + +test("P-8 runs every review form naming a session or an item as a composite over a session it creates first, a JSON read of it yielding the item (TEST-SPEC §16 P-8; SPEC 10.7, 12.0; guard vectors)", () => { + const create = [ + "review", + "create", + "--strategy", + "audit", + "--name", + "r1", + "--json", + ]; + // A static form is its own one step. + expect(armSteps(["check", "--json"])).toEqual([ + { argv: ["check", "--json"] }, + ]); + // A session form: the create, then the form naming the session. + expect(armSteps(["review", "export", SESSION_SLOT])).toEqual([ + { argv: create }, + { argv: ["review", "export", "r1"] }, + ]); + // An item form: the create, the read yielding the item (`status` for + // `split`, `next` otherwise), then the form, its item slot left for the + // read's item. + expect(armSteps(["review", "show", SESSION_SLOT, ITEM_ID_SLOT])).toEqual([ + { argv: create }, + { argv: ["review", "next", "r1", "--json"], yieldsItem: "next" }, + { argv: ["review", "show", "r1", ITEM_ID_SLOT] }, + ]); + expect( + armSteps(["review", "split", SESSION_SLOT, ITEM_ID_SLOT, "--json"]), + ).toEqual([ + { argv: create }, + { argv: ["review", "status", "r1", "--json"], yieldsItem: "status" }, + { argv: ["review", "split", "r1", ITEM_ID_SLOT, "--json"] }, + ]); + + // Over the menu: the composites are exactly the review subcommands that + // name a session (10.7's `status`, `show`, `split`, `resolve`, `export`), + // the item ones exactly `show`, `split`, and `resolve`, each with and + // without `--json` (12.0); no session slot reaches a step, the item slot + // only a composite's last step, after a JSON read that yields it. + const sessionForms = COMMAND_MENU.filter((form) => + form.includes(SESSION_SLOT), + ); + const subcommands = (forms: ReadonlyArray<readonly string[]>): string[] => + [...new Set(forms.map((form) => form[1] ?? ""))].sort(); + expect(subcommands(sessionForms)).toEqual([ + "export", + "resolve", + "show", + "split", + "status", + ]); + expect( + subcommands(sessionForms.filter((form) => form.includes(ITEM_ID_SLOT))), + ).toEqual(["resolve", "show", "split"]); + for (const subcommand of subcommands(sessionForms)) { + const forms = sessionForms.filter((form) => form[1] === subcommand); + expect( + forms.map((form) => form.includes("--json")).sort(), + `review ${subcommand}: one form with --json, one without`, + ).toEqual([false, true]); + } + const misplaced: string[] = []; + for (const form of COMMAND_MENU) { + const steps = armSteps(form); + steps.forEach((step, index) => { + const read = steps[index - 1]; + const itemSlotPlaced = + index === steps.length - 1 && + read?.yieldsItem !== undefined && + jsonOutputInEffect(read.argv); + if ( + step.argv.includes(SESSION_SLOT) || + (step.argv.includes(ITEM_ID_SLOT) && !itemSlotPlaced) + ) { + misplaced.push(`${form.join(" ")}: step ${String(index + 1)}`); + } + }); + } + expect(misplaced, "slots left where no read fills them").toEqual([]); +}); + +test("P-8 runs every command with JSON output in effect, and rename and move performed and previewed — the section form into a target file the base holds and one it lacks — each preview and section form with and without --json (TEST-SPEC §16 P-8; SPEC 6.4–6.6, 12.0; guard vectors)", () => { + // Every command — `review` and `query` per subcommand — runs at least + // once with JSON output in effect (12.0: every command supports + // `--json`); `build`'s run is the fixed arm, the menu holding its bare + // form. + const runs = [FIXED_BUILD_ARM, ...COMMAND_MENU]; + const surface = (argv: readonly string[]): string => + argv[0] === "review" || argv[0] === "query" + ? argv.slice(0, 2).join(" ") + : (argv[0] ?? ""); + const underJson = new Set( + runs.filter((argv) => jsonOutputInEffect(argv)).map(surface), + ); + expect( + [...new Set(runs.map(surface))].filter((name) => !underJson.has(name)), + "commands P-8 never runs with JSON output in effect", + ).toEqual([]); + + // The mutating commands' forms (6.4–6.6), each classified by its operands + // as 12.0 reads them: a `#`-bearing target operand makes a move the + // section form (6.5), whose target file the base workspace holds or lacks. + const baseFiles = new Map(FUZZ_BASE_FILES); + const sectionOrigins: string[] = []; + const variants = COMMAND_MENU.filter( + (form) => form[0] === "rename" || form[0] === "move", + ).map((form) => { + const [command = "", origin = "", target = ""] = form.filter( + (token) => !token.startsWith("--"), + ); + const section = command === "move" && target.includes("#"); + const variant = [ + command === "rename" ? "rename" : `move ${section ? "section" : "file"}`, + form.includes("--preview") ? "preview" : "performed", + form.includes("--json") ? "json" : "human", + ]; + if (section) { + sectionOrigins.push(origin); + const targetFile = target.slice(0, target.indexOf("#")); + variant.push( + baseFiles.has(targetFile) ? "held target" : "created target", + ); + } + return variant.join(", "); + }); + const sectionVariants = ["held target", "created target"].flatMap((target) => + ["performed", "preview"].flatMap((mode) => + ["json", "human"].map( + (output) => `move section, ${mode}, ${output}, ${target}`, + ), + ), + ); + expect(variants).toEqual( + expect.arrayContaining([ + "rename, performed, json", + "rename, preview, json", + "rename, preview, human", + "move file, performed, json", + "move file, preview, json", + "move file, preview, human", + ...sectionVariants, + ]), + ); + // Each section form moves a section the base workspace spells. + for (const origin of sectionOrigins) { + const [file = "", id = ""] = origin.split("#"); + expect(baseFiles.get(file) ?? "", `${origin}: a base section`).toContain( + `<S id="${id}"`, + ); + } + expect( + COMMAND_MENU.filter((form) => form.includes("--test-hold")), + "--test-hold beside --preview is a usage error (6.6), and P-8 drives no seam", + ).toEqual([]); +}); diff --git a/test/self/permission-staging.test.ts b/test/self/permission-staging.test.ts new file mode 100644 index 00000000..0bab15a7 --- /dev/null +++ b/test/self/permission-staging.test.ts @@ -0,0 +1,440 @@ +// E-1 permission-staging self-test (TEST-SPEC 17; T13.5-7, T14-9, T14-10). +// `test/helpers/permissions.ts` stages environment refusals by permission +// removal and verifies each staging on the harness's own process before any +// product is invoked (E-1). This self-test asserts, on the Linux leg: that +// each mode refuses this process's own attempt at exactly the operations the +// discipline names and keeps the permission it must keep; that `restore()` +// reinstates every recorded mode, idempotently; that unstageable objects — a +// directory in the path form, a symbolic link, an absent object for a read +// refusal — are staging errors, never refusals; and that the E-1 +// ineffective-staging report fires: the exported verification functions run +// on an unstaged path see their own attempts succeed, undo them, and throw +// `HarnessStagingError`, which is not a `HarnessAssertionError` (H-8, H-11). +// +// On a privileged runner (root: CAP_DAC_OVERRIDE) the staging tests below +// fail with that same error by design (H-9: never a pass or a skip); CI runs +// the self project unprivileged through .github/scripts/run-without-network.sh +// and a root sandbox runs it under `unshare --map-user`/`--map-group` +// (AGENTS.md). On any other platform every staging throws at once: the guard +// reads `process.platform` when called, so the platform arm below presents it +// a foreign value — redefined for the arm's duration and restored on finish — +// and runs on the Linux leg too, never skipped (H-9). + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { expect, onTestFinished, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { + HarnessStagingError, + stageReadRefusalOfDirectory, + stageReadRefusalOfFile, + stageWriteRefusal, + stageWriteRefusalUnder, + verifyReadRefusalOfDirectory, + verifyReadRefusalOfFile, + verifyWriteRefusal, + verifyWriteRefusalUnder, +} from "../helpers/permissions.js"; +import type { PermissionStaging } from "../helpers/permissions.js"; +import { TestWorkspace } from "../helpers/workspace.js"; + +const onLinux = process.platform === "linux"; + +const B_MDX = '<section id="b">B</section>\n'; +const FILES = { + "specs/b/B.mdx": B_MDX, + "specs/b/B.xspec.ts": "export const b = 1;\n", + "specs/b/sub/C.mdx": '<section id="c">C</section>\n', + ".xspec/journal": "", + ".xspec/graph.json": "{}\n", +}; +const DIRS = ["specs/empty"]; +const SYMLINKS = { "specs/b/link.mdx": "B.mdx", "specs/link.mdx": "b/B.mdx" }; +const B_LISTING = ["B.mdx", "B.xspec.ts", "link.mdx", "sub"]; + +/** A workspace with every mode of interest pinned to a known prior. */ +async function makeWorkspace(): Promise<TestWorkspace> { + const workspace = await TestWorkspace.create({ + dirs: DIRS, + files: FILES, + symlinks: SYMLINKS, + }); + onTestFinished(() => workspace.dispose()); + for (const dir of [ + "specs", + "specs/b", + "specs/b/sub", + "specs/empty", + ".xspec", + ]) { + await fsp.chmod(workspace.path(dir), 0o755); + } + for (const file of Object.keys(FILES)) { + await fsp.chmod(workspace.path(file), 0o644); + } + return workspace; +} + +/** The error code of a rejected attempt, or "allowed" when it resolved. */ +async function outcome(attempt: Promise<unknown>): Promise<string> { + try { + await attempt; + return "allowed"; + } catch (thrown) { + return (thrown as { code?: string }).code ?? "unknown"; + } +} + +async function modeOf(target: string): Promise<number> { + return (await fsp.stat(target)).mode & 0o7777; +} + +async function openAndClose(target: string, flags: string): Promise<void> { + const handle = await fsp.open(target, flags); + await handle.close(); +} + +/** Register the staging's restoration so a failing test leaves nothing. */ +function tracked(staging: PermissionStaging): PermissionStaging { + onTestFinished(() => staging.restore()); + return staging; +} + +/** The staging error a call must reject with — never an assertion error. */ +async function stagingError( + call: Promise<unknown>, +): Promise<HarnessStagingError> { + let thrown: unknown; + try { + await call; + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + return thrown as HarnessStagingError; +} + +test("HarnessStagingError is a harness error, never a diagnosed product failure (H-8, H-11)", () => { + const error = new HarnessStagingError("write-refusal", "/w/specs/x", "why"); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(HarnessAssertionError); + expect(error.name).toBe("HarnessStagingError"); + expect(error.mode).toBe("write-refusal"); + expect(error.path).toBe("/w/specs/x"); + expect(error.message).toBe("write-refusal staging of /w/specs/x: why"); +}); + +test.runIf(onLinux)( + "write refusal, path form (T14-9): the holding directory read-only and the occupant unwritable — creation, replacement, appending, and removal refused, reads kept, siblings untouched; restore reinstates the recorded modes", + async () => { + const workspace = await makeWorkspace(); + const dir = workspace.path("specs/b"); + const target = workspace.path("specs/b/B.mdx"); + const staging = tracked(await stageWriteRefusal(target)); + expect(staging.mode).toBe("write-refusal"); + expect(staging.path).toBe(target); + expect(await modeOf(dir)).toBe(0o555); + expect(await modeOf(target)).toBe(0o444); + // This process's own attempts at every write the discipline refuses. + expect(await outcome(openAndClose(path.join(dir, "D.mdx"), "wx"))).toBe( + "EACCES", + ); + expect(await outcome(fsp.mkdir(path.join(dir, "d")))).toBe("EACCES"); + expect(await outcome(openAndClose(target, "r+"))).toBe("EACCES"); + expect(await outcome(openAndClose(target, "a"))).toBe("EACCES"); + expect(await outcome(fsp.rename(target, path.join(dir, "E.mdx")))).toBe( + "EACCES", + ); + expect(await outcome(fsp.unlink(target))).toBe("EACCES"); + // Reads and listings are kept; siblings and the subdirectory untouched. + expect(Buffer.from(await fsp.readFile(target)).toString("utf8")).toBe( + B_MDX, + ); + expect((await fsp.readdir(dir)).sort()).toEqual(B_LISTING); + expect(await modeOf(workspace.path("specs/b/B.xspec.ts"))).toBe(0o644); + expect(await modeOf(workspace.path("specs/b/sub"))).toBe(0o755); + expect(await modeOf(workspace.path("specs"))).toBe(0o755); + // Restoration reinstates the recorded modes and is idempotent. + await staging.restore(); + expect(await modeOf(dir)).toBe(0o755); + expect(await modeOf(target)).toBe(0o644); + await openAndClose(path.join(dir, "D.mdx"), "wx"); + await fsp.unlink(path.join(dir, "D.mdx")); + await openAndClose(target, "a"); + await staging.restore(); + expect(await modeOf(target)).toBe(0o644); + }, +); + +test.runIf(onLinux)( + "write refusal, path form, absent target: the creation the product owes is refused and nothing appears; restore reinstates the directory's mode", + async () => { + const workspace = await makeWorkspace(); + const dir = workspace.path("specs/b"); + const target = workspace.path("specs/b/D.mdx"); + const staging = tracked(await stageWriteRefusal(target)); + expect(await modeOf(dir)).toBe(0o555); + expect(await outcome(openAndClose(target, "wx"))).toBe("EACCES"); + expect(await outcome(fsp.mkdir(target))).toBe("EACCES"); + expect( + await outcome(fsp.rename(workspace.path("specs/b/B.mdx"), target)), + ).toBe("EACCES"); + expect(await outcome(fsp.lstat(target))).toBe("ENOENT"); + expect((await fsp.readdir(dir)).sort()).toEqual(B_LISTING); + await staging.restore(); + expect(await modeOf(dir)).toBe(0o755); + await openAndClose(target, "wx"); + }, +); + +test.runIf(onLinux)( + "write refusal under a directory: the area and every directory and file beneath it unwritable, symbolic links left alone, the parent untouched; restore reinstates every recorded mode", + async () => { + const workspace = await makeWorkspace(); + const area = workspace.path("specs/b"); + const staging = tracked(await stageWriteRefusalUnder(area)); + expect(staging.mode).toBe("write-refusal-under"); + expect(staging.path).toBe(area); + expect(await modeOf(area)).toBe(0o555); + expect(await modeOf(workspace.path("specs/b/sub"))).toBe(0o555); + expect(await modeOf(workspace.path("specs/b/B.mdx"))).toBe(0o444); + expect(await modeOf(workspace.path("specs/b/B.xspec.ts"))).toBe(0o444); + expect(await modeOf(workspace.path("specs/b/sub/C.mdx"))).toBe(0o444); + expect( + (await fsp.lstat(workspace.path("specs/b/link.mdx"))).isSymbolicLink(), + ).toBe(true); + expect( + await outcome(openAndClose(workspace.path("specs/b/sub/D.mdx"), "wx")), + ).toBe("EACCES"); + expect( + await outcome(openAndClose(workspace.path("specs/b/sub/C.mdx"), "r+")), + ).toBe("EACCES"); + expect( + await outcome(fsp.unlink(workspace.path("specs/b/B.xspec.ts"))), + ).toBe("EACCES"); + // The parent stays writable: a product's exclusivity state kept there is + // never refused. + expect(await modeOf(workspace.path("specs"))).toBe(0o755); + await openAndClose(workspace.path("specs/lock"), "wx"); + await fsp.unlink(workspace.path("specs/lock")); + await staging.restore(); + expect(await modeOf(area)).toBe(0o755); + expect(await modeOf(workspace.path("specs/b/sub"))).toBe(0o755); + for (const file of [ + "specs/b/B.mdx", + "specs/b/B.xspec.ts", + "specs/b/sub/C.mdx", + ]) { + expect(await modeOf(workspace.path(file))).toBe(0o644); + } + await openAndClose(workspace.path("specs/b/sub/D.mdx"), "wx"); + }, +); + +test.runIf(onLinux)( + "read refusal of a file (T14-10): mode 0o200 — the content read refused, a write-open allowed, the holding directory untouched; restore reinstates the mode", + async () => { + const workspace = await makeWorkspace(); + const target = workspace.path(".xspec/graph.json"); + const staging = tracked(await stageReadRefusalOfFile(target)); + expect(staging.mode).toBe("read-refusal-of-file"); + expect(await modeOf(target)).toBe(0o200); + expect(await outcome(fsp.readFile(target))).toBe("EACCES"); + expect(await outcome(openAndClose(target, "r+"))).toBe("EACCES"); + await openAndClose(target, "a"); + expect(await modeOf(workspace.path(".xspec"))).toBe(0o755); + // Still replaceable by name: the directory admits a fresh sibling. + await openAndClose(workspace.path(".xspec/fresh"), "wx"); + await fsp.unlink(workspace.path(".xspec/fresh")); + await staging.restore(); + expect(await modeOf(target)).toBe(0o644); + expect(Buffer.from(await fsp.readFile(target)).toString("utf8")).toBe( + "{}\n", + ); + }, +); + +test.runIf(onLinux)( + "read refusal of a directory (T14-10): mode 0o100 — the listing refused, entries reachable and readable by name, an empty directory stageable too; restore reinstates the mode", + async () => { + const workspace = await makeWorkspace(); + const dir = workspace.path("specs/b"); + const staging = tracked(await stageReadRefusalOfDirectory(dir)); + expect(staging.mode).toBe("read-refusal-of-directory"); + expect(await modeOf(dir)).toBe(0o100); + expect(await outcome(fsp.readdir(dir))).toBe("EACCES"); + expect( + Buffer.from(await fsp.readFile(workspace.path("specs/b/B.mdx"))).toString( + "utf8", + ), + ).toBe(B_MDX); + expect((await fsp.stat(workspace.path("specs/b/sub"))).isDirectory()).toBe( + true, + ); + await staging.restore(); + expect(await modeOf(dir)).toBe(0o755); + expect((await fsp.readdir(dir)).sort()).toEqual(B_LISTING); + const empty = workspace.path("specs/empty"); + const emptyStaging = tracked(await stageReadRefusalOfDirectory(empty)); + expect(await modeOf(empty)).toBe(0o100); + expect(await outcome(fsp.readdir(empty))).toBe("EACCES"); + await emptyStaging.restore(); + expect(await fsp.readdir(empty)).toEqual([]); + }, +); + +test.runIf(onLinux)( + "unstageable objects are staging errors, never refusals: a directory in the path form, a symbolic link, an absent holding directory, nonexistence for a read refusal, a file for a listing refusal, a relative path — nothing changed", + async () => { + const workspace = await makeWorkspace(); + // Thunks: a rejection must not precede its await (an unhandled rejection). + const cases: Array<[string, () => Promise<unknown>]> = [ + ["directory target", () => stageWriteRefusal(workspace.path("specs/b"))], + [ + "symlink target", + () => stageWriteRefusal(workspace.path("specs/link.mdx")), + ], + [ + "absent holding directory", + () => stageWriteRefusal(workspace.path("nowhere/x.mdx")), + ], + [ + "symlinked holding directory", + () => stageWriteRefusal(workspace.path("specs/link.mdx/x")), + ], + [ + "absent file", + () => stageReadRefusalOfFile(workspace.path("specs/b/D.mdx")), + ], + [ + "symlink file", + () => stageReadRefusalOfFile(workspace.path("specs/link.mdx")), + ], + [ + "directory as file", + () => stageReadRefusalOfFile(workspace.path("specs/b")), + ], + [ + "file as directory", + () => stageReadRefusalOfDirectory(workspace.path("specs/b/B.mdx")), + ], + [ + "absent directory", + () => stageReadRefusalOfDirectory(workspace.path("gone")), + ], + ["relative path", () => stageWriteRefusal("specs/b/B.mdx")], + ]; + for (const [label, call] of cases) { + const error = await stagingError(call()); + expect(error.message, label).toContain("staging of "); + } + const directoryError = await stagingError( + stageWriteRefusal(workspace.path("specs/b")), + ); + expect(directoryError.message).toContain("stageWriteRefusalUnder"); + expect(await modeOf(workspace.path("specs"))).toBe(0o755); + expect(await modeOf(workspace.path("specs/b"))).toBe(0o755); + expect(await modeOf(workspace.path("specs/b/B.mdx"))).toBe(0o644); + }, +); + +test.runIf(onLinux)( + "E-1: an ineffective staging is a HarnessStagingError — each verification run on an unstaged object sees its own attempt succeed, undoes it, and reports a privileged runner; a permission removed beyond the mode is reported too", + async () => { + const workspace = await makeWorkspace(); + const dir = workspace.path("specs/b"); + const target = workspace.path("specs/b/B.mdx"); + const notRefused = [ + () => verifyWriteRefusal(target), + () => verifyWriteRefusal(workspace.path("specs/b/D.mdx")), + () => verifyWriteRefusalUnder(dir), + () => verifyReadRefusalOfFile(target), + () => verifyReadRefusalOfDirectory(dir, "B.mdx"), + ]; + for (const call of notRefused) { + const error = await stagingError(call()); + expect(error.message).toContain("was not refused"); + expect(error.message).toContain("privileged runner"); + } + // Every probe undid its effect: the listing and the bytes are unchanged. + expect((await fsp.readdir(dir)).sort()).toEqual(B_LISTING); + expect((await fsp.readdir(workspace.path("specs/b/sub"))).sort()).toEqual([ + "C.mdx", + ]); + expect(Buffer.from(await fsp.readFile(target)).toString("utf8")).toBe( + B_MDX, + ); + expect(await outcome(fsp.lstat(workspace.path("specs/b/D.mdx")))).toBe( + "ENOENT", + ); + // A read staging that also lost the permission it must keep. + await fsp.chmod(target, 0o000); + const fileError = await stagingError(verifyReadRefusalOfFile(target)); + expect(fileError.message).toContain("removed more than it may"); + await fsp.chmod(target, 0o644); + await fsp.chmod(dir, 0o000); + const dirError = await stagingError( + verifyReadRefusalOfDirectory(dir, "B.mdx"), + ); + expect(dirError.message).toContain("removed more than it may"); + await fsp.chmod(dir, 0o755); + }, +); + +// The platform guard (E-1): every staging consults `process.platform` when +// called and, off the Linux leg, throws before touching the filesystem. The +// arm presents the guard the Windows and macOS values in turn — the property +// redefined (it is configurable, not writable) and restored on finish — over a +// target inside a disposable workspace, the one place a bypassed guard could +// stage; so it runs on every platform, the Linux leg included, and is never +// marked skipped (H-9, E-2). +const FOREIGN_PLATFORMS: readonly NodeJS.Platform[] = ["win32", "darwin"]; + +/** The own descriptor of `process.platform`, reinstated as it was. */ +function platformDescriptor(): PropertyDescriptor { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (descriptor === undefined) { + throw new Error("process.platform is not an own property of process"); + } + return descriptor; +} + +test("the platform guard (E-1): presented a non-Linux platform, every staging throws HarnessStagingError at once and touches nothing — run on every platform, never skipped (H-9)", async () => { + const workspace = await TestWorkspace.create(); + onTestFinished(() => workspace.dispose()); + const target = workspace.path("nowhere"); + const holdingMode = await modeOf(workspace.root); + const original = platformDescriptor(); + const restore = (): void => { + Object.defineProperty(process, "platform", original); + }; + onTestFinished(restore); + const stagings = [ + ["write-refusal", () => stageWriteRefusal(target)], + ["write-refusal-under", () => stageWriteRefusalUnder(target)], + ["read-refusal-of-file", () => stageReadRefusalOfFile(target)], + ["read-refusal-of-directory", () => stageReadRefusalOfDirectory(target)], + ] as const; + for (const platform of FOREIGN_PLATFORMS) { + Object.defineProperty(process, "platform", { + ...original, + value: platform, + }); + expect(process.platform).toBe(platform); + for (const [mode, call] of stagings) { + const error = await stagingError(call()); + expect(error.mode).toBe(mode); + expect(error.path).toBe(target); + expect(error.message).toContain("Linux leg"); + expect(error.message).toContain(platform); + } + restore(); + expect(process.platform).toBe(original.value); + } + // At once: the target never came to be and the holding directory keeps + // its mode — no staging reached the filesystem. + expect(await outcome(fsp.stat(target))).toBe("ENOENT"); + expect(await modeOf(workspace.root)).toBe(holdingMode); +}); diff --git a/test/self/property-infrastructure.test.ts b/test/self/property-infrastructure.test.ts index 497307d8..a4959337 100644 --- a/test/self/property-infrastructure.test.ts +++ b/test/self/property-infrastructure.test.ts @@ -18,6 +18,15 @@ // * H-8 classification — generator defects and non-assertion property // errors surface as plain harness errors (with the seed for // reproduction), never as diagnosed assertion failures. +// * S-9 per-draw check (TEST-SPEC 16 preamble) — a draw whose staged MDX +// does not derive, or whose staged code source or configuration file is +// not well-formed TypeScript (a `TS_DEFAULT_SUFFIXES` name, or an entry +// marked `"code-source"`; rejected, or accepted read one way only), is a +// harness error carrying the seed, raised before the body sees the draw +// (the initial trial and shrunk candidates alike), never a diagnosed +// failure and never a skipped draw; a workspace-builder refusal thrown +// inside the body is attributed the same way, naming the refused +// staging. // // Every checkProperty call here injects env (and, where relevant, report and // entropy) — the ambient process environment must not leak into self-test @@ -25,8 +34,10 @@ // unannotated: checkProperty's positional signature must keep inferring T // for exactly this style (see the checkProperty doc comment). +import { Buffer } from "node:buffer"; import { expect, test } from "vitest"; import { fail, HarnessAssertionError } from "../helpers/assertions.js"; +import { HarnessStagingError } from "../helpers/permissions.js"; import { checkProperty, DEFAULT_PROPERTY_SEEDS, @@ -147,6 +158,50 @@ test("a forced failure reports its seed and shrinks to the minimal counterexampl expect(error.assertionMessage).toBe("generated value 100 is >= 100"); }); +test("a failure that declines shrinking is reported as drawn, with its seed (P-11's killed invocations)", async () => { + let executions = 0; + const thrown = await captureRejection( + checkProperty( + "demo: every value stays below 100, unshrunk", + (choices) => choices.intInclusive(0, 100000), + (value) => { + executions += 1; + if (value >= 100) { + fail(`generated value ${String(value)} is >= 100`, { + shrinkable: false, + }); + } + }, + { runs: 50, seeds: [123456], env: {} }, + ), + ); + + // Still a diagnosed falsification naming its seed (H-8, H-10) … + expect(thrown).toBeInstanceOf(PropertyFalsifiedError); + const error = thrown as PropertyFalsifiedError; + expect(error.seed).toBe(123456); + expect(error.message).toContain(`${PROPERTY_SEED_ENV}=123456`); + + // … but no shrink candidate was executed: the property ran exactly once per + // trial up to the failing one, and the counterexample is the drawn value … + expect(error.shrinkDeclined).toBe(true); + expect(error.shrinkSteps).toBe(0); + expect(error.shrinkExecutions).toBe(0); + expect(executions).toBe(error.trial); + expect(error.value).toBe(error.initialValue); + expect(error.value).toBeGreaterThanOrEqual(100); + expect(error.assertionMessage).toBe( + `generated value ${String(error.value)} is >= 100`, + ); + + // … and the report says so rather than claiming minimality. + expect(error.message).toContain("reported as drawn"); + expect(error.message).not.toContain("already minimal"); + + // The default is unchanged: a plain failure shrinks. + expect(new HarnessAssertionError("plain").shrinkable).toBe(true); +}); + test("structured counterexamples shrink, deterministically across runs (TEST-SPEC 16, E-5)", async () => { // Inline generic combinator + inferred property parameter: the composition // style every section-16 property will use. @@ -330,3 +385,261 @@ test("listOf stays within its bounds across generation", async () => { expect(lengths).toContain(2); expect(lengths).toContain(5); }); + +// --------------------------------------------------------------------------- +// S-9's per-draw check (TEST-SPEC 16 preamble, 17 S-9). + +const LF = String.fromCodePoint(0x000a); +const WELL_FORMED_MDX = `<S id="a">ok</S>${LF}`; +const UNCLOSED_MDX = `<S id="a">${LF}${LF}never closed${LF}`; +const WELL_FORMED_CONFIG = + `import { defineConfig } from "xspec"${LF}${LF}` + + `export default defineConfig({${LF} specs: {${LF}` + + ` main: ["specs/**/*.mdx"]${LF} }${LF}})${LF}`; +/** A configuration missing its closing brace: rejected both ways. */ +const UNCLOSED_CONFIG = WELL_FORMED_CONFIG.replace(`})${LF}`, `)${LF}`); +const WELL_FORMED_CODE = `import A from "../specs/A.xspec"${LF}${LF}A.a${LF}`; +const ILL_FORMED_CODE = `import A from${LF}`; +/** Accepted read as module code only (a top-level `await`, SPEC 14.20). */ +const MODULE_ONLY_CODE = `await /re/;${LF}`; + +test("S-9 per-draw check: a draw staging a non-deriving MDX source is a harness error carrying the seed, raised before the body runs on it", async () => { + const seen: number[] = []; + const thrown = await captureRejection( + checkProperty( + "ill-formed draw", + (choices) => choices.intInclusive(0, 3), + (value) => { + seen.push(value); + }, + { + runs: 8, + seeds: [7], + env: {}, + drawSources: (value) => [ + ["notes.txt", "not judged: neither MDX nor a code source"], + [ + "specs/A.mdx", + value === 2 ? UNCLOSED_MDX : WELL_FORMED_MDX, + `draw ${String(value)}`, + ], + ], + }, + ), + ); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error; + expect(error.message).toContain("harness error while checking trial"); + expect(error.message).toContain("S-9"); + expect(error.message).toContain("seed 7"); + expect(error.message).toContain(`${PROPERTY_SEED_ENV}=7`); + expect(error.message).toContain("specs/A.mdx (draw 2)"); + expect(error.cause).toBeInstanceOf(HarnessStagingError); + expect((error.cause as HarnessStagingError).mode).toBe("mdx-derivability"); + // The body never ran on the ill-formed draw: the check precedes it. + expect(seen).not.toContain(2); + expect(seen.length).toBeGreaterThan(0); +}); + +test("S-9 per-draw check: a generator whose every draw is well-formed runs unhindered, unjudged entries ignored", async () => { + let ran = 0; + await checkProperty( + "well-formed draws", + (choices) => choices.pick(["S", "Spec"] as const), + () => { + ran += 1; + }, + { + runs: 5, + seeds: [7], + env: {}, + drawSources: (tag) => [ + ["specs/A.mdx", `<${tag} id="a">ok</${tag}>${LF}`], + ["specs/B.mdx", Buffer.from(WELL_FORMED_MDX, "utf8")], + ["notes.txt", UNCLOSED_MDX], + ["xspec.config.ts", WELL_FORMED_CONFIG], + ["src/app.tsx", `export const a = <${tag} id="a" />;${LF}`], + [ + "src/cap$[x]", + Buffer.from(WELL_FORMED_CODE, "utf8"), + undefined, + "code-source", + ], + ["src/unmarked", ILL_FORMED_CODE], + ], + }, + ); + expect(ran).toBe(5); +}); + +test("S-9 per-draw check: a draw staging a configuration that is not well-formed TypeScript is a harness error carrying the seed, raised before the body runs on it", async () => { + const seen: number[] = []; + const thrown = await captureRejection( + checkProperty( + "ill-formed configuration draw", + (choices) => choices.intInclusive(0, 3), + (value) => { + seen.push(value); + }, + { + runs: 8, + seeds: [7], + env: {}, + drawSources: (value) => [ + ["specs/A.mdx", WELL_FORMED_MDX], + [ + "xspec.config.ts", + value === 2 ? UNCLOSED_CONFIG : WELL_FORMED_CONFIG, + `draw ${String(value)}`, + ], + ], + }, + ), + ); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error; + expect(error.message).toContain("harness error while checking trial"); + expect(error.message).toContain("S-9"); + expect(error.message).toContain("seed 7"); + expect(error.message).toContain(`${PROPERTY_SEED_ENV}=7`); + expect(error.message).toContain("xspec.config.ts (draw 2)"); + expect(error.message).toContain("not well-formed TypeScript"); + expect(error.cause).toBeInstanceOf(HarnessStagingError); + expect((error.cause as HarnessStagingError).mode).toBe("ts-derivability"); + // The body never ran on the ill-formed draw: the check precedes it. + expect(seen).not.toContain(2); + expect(seen.length).toBeGreaterThan(0); +}); + +test("S-9 per-draw check: a code source marked `code-source` is judged at a name the default does not reach — text accepted read one way only is a harness error too", async () => { + let ran = 0; + const thrown = await captureRejection( + checkProperty( + "one-way code source draw", + (choices) => choices.intInclusive(0, 3), + () => { + ran += 1; + }, + { + runs: 4, + seeds: [7], + env: {}, + drawSources: () => [ + ["src/unmarked", MODULE_ONLY_CODE], + ["src/cap$[x]", MODULE_ONLY_CODE, "capture source", "code-source"], + ], + }, + ), + ); + expect(ran).toBe(0); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error; + expect(error.message).toContain("harness error while checking trial 1 of 4"); + expect(error.message).toContain("seed 7"); + expect(error.message).toContain("src/cap$[x] (capture source)"); + expect(error.message).toContain("accepted read as module code only"); + expect(error.cause).toBeInstanceOf(HarnessStagingError); + expect((error.cause as HarnessStagingError).mode).toBe("ts-derivability"); + expect((error.cause as HarnessStagingError).path).toBe( + "src/cap$[x] (capture source)", + ); +}); + +test("S-9 per-draw check: a shrunk candidate's configuration is judged before the body runs on it — an ill-formed one is a harness error, never a rejected candidate", async () => { + const bodies: number[] = []; + const thrown = await captureRejection( + checkProperty( + "shrinks into an ill-formed configuration", + (choices) => choices.intInclusive(0, 9), + (value) => { + bodies.push(value); + fail(`falsified on ${String(value)}`); + }, + { + runs: 1, + seeds: [7], + env: {}, + drawSources: (value) => [ + [ + "xspec.config.ts", + value === 0 ? UNCLOSED_CONFIG : WELL_FORMED_CONFIG, + ], + ], + }, + ), + ); + expect(bodies.length).toBeGreaterThan(0); + expect(bodies[0]).not.toBe(0); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error; + expect(error.message).toContain("harness error while shrinking (S-9"); + expect(error.message).toContain("seed 7"); + expect(error.cause).toBeInstanceOf(HarnessStagingError); + expect((error.cause as HarnessStagingError).mode).toBe("ts-derivability"); + expect(bodies).not.toContain(0); +}); + +test("S-9 per-draw check: a shrunk candidate is checked before the body runs on it — a non-deriving one is a harness error, never a rejected candidate", async () => { + // Value 0 stages an ill-formed source; every other value falsifies the + // property. Seed 7's first draw is non-zero (asserted), so the initial + // trial passes the check, fails the body, and shrinking then tries 0. + const bodies: number[] = []; + const thrown = await captureRejection( + checkProperty( + "shrinks into an ill-formed draw", + (choices) => choices.intInclusive(0, 9), + (value) => { + bodies.push(value); + fail(`falsified on ${String(value)}`); + }, + { + runs: 1, + seeds: [7], + env: {}, + drawSources: (value) => [ + ["specs/A.mdx", value === 0 ? UNCLOSED_MDX : WELL_FORMED_MDX], + ], + }, + ), + ); + expect(bodies.length).toBeGreaterThan(0); + expect(bodies[0]).not.toBe(0); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error; + expect(error.message).toContain("harness error while shrinking (S-9"); + expect(error.message).toContain("seed 7"); + expect(error.cause).toBeInstanceOf(HarnessStagingError); + expect(bodies).not.toContain(0); +}); + +test("a workspace-builder refusal thrown inside the body is a harness error carrying the seed and naming the refused staging (H-8, S-9)", async () => { + const thrown = await captureRejection( + checkProperty( + "builder refusal", + (choices) => choices.intInclusive(0, 9), + () => { + throw new HarnessStagingError( + "mdx-derivability", + "specs/A.mdx", + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + }, + { runs: 3, seeds: [7], env: {} }, + ), + ); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error; + expect(error.message).toContain("harness error while running trial 1 of 3"); + expect(error.message).toContain( + "the workspace builder refused the mdx-derivability staging of specs/A.mdx", + ); + expect(error.message).toContain("seed 7"); + expect(error.message).toContain(`${PROPERTY_SEED_ENV}=7`); + expect(error.cause).toBeInstanceOf(HarnessStagingError); +}); diff --git a/test/self/s1-traceability.test.ts b/test/self/s1-traceability.test.ts index 71f2bc01..115ede95 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 // `## <n>. <title>`, a subsection heading `### <n>.<m> <title>`, numbers diff --git a/test/self/s2-workspace-builder.test.ts b/test/self/s2-workspace-builder.test.ts index 499e89d6..40bb68fd 100644 --- a/test/self/s2-workspace-builder.test.ts +++ b/test/self/s2-workspace-builder.test.ts @@ -4,9 +4,22 @@ // (contents, and byte-string file names on Linux — T1.5-2 staging), symbolic // links (verbatim targets: live, dangling, directory, cyclic, external — // T7-5, T13.4-6), and git fixtures with scripted commits carrying pinned, -// platform-independent identities and timestamps (E-6). Certification cannot -// exercise builder bugs that make fixtures diverge from their declarations, -// so this self-test must pass before any fixture is trusted. +// platform-independent identities and timestamps (E-6) — and scale vectors +// at the suite's staged maxima (`staged-scale.ts`, shared with S-8): the +// 4096-deep section tower P-8's giant-nesting draws stage, the largest +// document any generator draw stages, and the largest document any +// deterministic fixture stages (T1.3-7's 2048-deep chained-id tower — the +// largest document the suite stages), each read back byte-complete, so a +// truncating writer or recursion-limited serializer cannot silently stage +// shallower or smaller inputs than declared (H-11's input side). The +// builder's S-9 TypeScript check at staging time has its vectors here too +// (TEST-SPEC S-9's TypeScript clause): a declared-well-formed ill-formed +// `.ts` file, a declared-unparseable well-formed one, and a text TypeScript +// 5.9.3 accepts read one way only each throw `HarnessStagingError` (mode +// `ts-derivability`) before anything is written. +// Certification cannot exercise builder bugs that make fixtures diverge from +// their declarations, so this self-test must pass before any fixture is +// trusted. // // Platform gates mirror TEST-SPEC's own staging notes, not CI skips: the // `self` project runs on Linux in CI (harness-self job), where every test @@ -20,12 +33,29 @@ import * as fsp from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { expect, onTestFinished, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { HarnessStagingError } from "../helpers/permissions.js"; import { GIT_FIXTURE_EPOCH_SECONDS, GIT_FIXTURE_PERSON, + TS_DEFAULT_SUFFIXES, TestWorkspace, } from "../helpers/workspace.js"; import type { WorkspaceDecl } from "../helpers/workspace.js"; +import { FUZZ_BASE_FILES } from "../suite/registry/section-16-p8.js"; +import { + DEEPEST_STAGED_TOWER, + DEPTH_FLOOR, + depthTower, + GIANT_NESTING_FLOOR, + LARGEST_BASE_FILE, + LARGEST_DETERMINISTIC_INPUT_BYTES, + LARGEST_GENERATED_INPUT_BYTES, + LARGEST_STAGED_INPUT_BYTES, + largestDeterministicDocument, + largestGeneratedDocument, + TOWER_SOURCE, +} from "./staged-scale.js"; const onLinux = process.platform === "linux"; const onPosix = process.platform !== "win32"; @@ -100,6 +130,8 @@ test("writes BOM-prefixed content byte-exactly (string and byte declarations)", "bom-bytes.bin": bytes(0xef, 0xbb, 0xbf, 0x0d), "bom-utf16le.bin": bytes(0xff, 0xfe, 0x41, 0x00), }, + // A byte-level probe of the builder, not a 14.20 fixture (S-9). + mdx: { unchecked: ["bom-string.mdx"] }, }); expectSameBytes( await workspace.readBytes("bom-string.mdx"), @@ -124,6 +156,8 @@ test("writes invalid-UTF-8 blob contents byte-exactly", async () => { "malformed.mdx": malformed, "empty.bin": bytes(), }, + // A byte-level probe of the builder, not a 14.20 fixture (S-9). + mdx: { unchecked: ["malformed.mdx"] }, }); expectSameBytes(await workspace.readBytes("all-bytes.bin"), allByteValues); expectSameBytes(await workspace.readBytes("malformed.mdx"), malformed); @@ -146,7 +180,8 @@ test.runIf(onLinux)( ); const relBytes = concatBytes(utf8("specs/"), nameBytes); const contents = concatBytes(utf8("# Title\n"), bytes(0x80, 0xfe)); - await workspace.file(relBytes, contents); + // A byte-level probe of the builder, not a 14.20 fixture (S-9). + await workspace.file(relBytes, contents, { mdx: "unchecked" }); // The directory holds exactly the declared byte-string name. expect((await workspace.readdirBytes("specs")).map(hex)).toEqual([ @@ -381,3 +416,544 @@ test("dispose() removes the workspace entirely, read-only git objects included", code: "ENOENT", }); }); + +// --------------------------------------------------------------------------- +// Scale vectors (S-2): the suite's staged maxima, read back byte-complete. +// Every vector is built iteratively — string repetition, buffer +// concatenation, one loop over the levels — never by recursion, and compared +// as whole byte arrays read back from disk through plain `fs`, independently +// of the builder's own readers. + +/** Whole-array comparison with a diagnosable first-mismatch report. */ +function expectByteComplete(actual: Uint8Array, expected: Uint8Array): void { + expect(actual.length).toBe(expected.length); + let mismatch = -1; + for (let index = 0; index < expected.length; index += 1) { + if (actual[index] !== expected[index]) { + mismatch = index; + break; + } + } + expect(mismatch, "first differing byte offset (-1 = identical)").toBe(-1); +} + +/** Occurrences of `needle` in `haystack`, scanned iteratively. */ +function countOccurrences(haystack: Uint8Array, needle: string): number { + const buffer = Buffer.from( + haystack.buffer, + haystack.byteOffset, + haystack.length, + ); + let count = 0; + for ( + let index = buffer.indexOf(needle, 0, "utf8"); + index >= 0; + index = buffer.indexOf(needle, index + 1, "utf8") + ) { + count += 1; + } + return count; +} + +test("scale vector: one `.mdx` file nesting sections 4096 deep — P-8's staged tower, read back byte-complete", async () => { + // The tower the suite stages (sectionTowerSource(4096, balanced), the + // bytes a P-8 nesting draw appends): 11 bytes per opener line, one + // six-byte content line, 5 bytes per closer line. + const expected = utf8(TOWER_SOURCE); + expect(DEEPEST_STAGED_TOWER).toBe(4096); + expect(DEEPEST_STAGED_TOWER).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + expect(expected.length).toBe( + DEEPEST_STAGED_TOWER * 11 + 6 + DEEPEST_STAGED_TOWER * 5, + ); + + // Declared through the builder's declarative path (create → file). + const workspace = await makeWorkspace({ + files: { "specs/tower.mdx": TOWER_SOURCE }, + }); + + // The builder's own listing reports the file once, as a regular file, at + // the full declared size. + expect(await workspace.readdirNames()).toEqual(["specs"]); + expect(await workspace.readdirNames("specs")).toEqual(["tower.mdx"]); + expect(await workspace.kind("specs/tower.mdx")).toBe("file"); + const abs = path.join(workspace.root, "specs", "tower.mdx"); + expect((await fsp.stat(abs)).size).toBe(expected.length); + + // Read back from disk with plain fs: byte-complete, and still 4096 levels + // deep — every opener and closer present, so the staged depth is the + // declared depth, at or past P-8's floor. + const actual = new Uint8Array(await fsp.readFile(abs)); + expectByteComplete(actual, expected); + expect(countOccurrences(actual, '<S id="g">\n')).toBe(DEEPEST_STAGED_TOWER); + expect(countOccurrences(actual, "</S>\n")).toBe(DEEPEST_STAGED_TOWER); + expect(countOccurrences(actual, "deep.\n")).toBe(1); +}); + +test("scale vector: the largest document any generator draw stages (fuzz base plus the whole mutation budget of towers), read back byte-complete", async () => { + // Derivation (staged-scale.ts, shared with S-8): the largest file any + // generator draw stages is the largest fuzz base file (`specs/A.mdx`) with + // all three mutations of P-8's budget appending the deepest balanced tower + // — 204 + 3 × 65 542 = 196 830 bytes; every P-2/P-3, P-4, and P-9 draw is + // far smaller. The deterministic fixture T1.3-7 stages a document ~21× + // larger — the largest document the suite stages, the next vector's + // subject. Staged exactly as P-8's driver stages a trial: the base + // workspace declared, then the mutated bytes written over the base file + // through `file` — the imperative path. + const [basePath] = LARGEST_BASE_FILE; + const expected = largestGeneratedDocument(); + expect(expected.length).toBe(LARGEST_GENERATED_INPUT_BYTES); + expect(LARGEST_GENERATED_INPUT_BYTES).toBe(196_830); + expect(basePath).toBe("specs/A.mdx"); + + const workspace = await makeWorkspace({ + files: Object.fromEntries(FUZZ_BASE_FILES), + }); + await workspace.file(basePath, expected); + + // The builder's own listing reports the file once, as a regular file, at + // the full declared size — the base workspace's other entries untouched. + expect(await workspace.readdirNames("specs")).toEqual(["A.mdx", "B.mdx"]); + expect(await workspace.kind(basePath)).toBe("file"); + const abs = path.join(workspace.root, ...basePath.split("/")); + expect((await fsp.stat(abs)).size).toBe(LARGEST_GENERATED_INPUT_BYTES); + + // Read back from disk with plain fs, byte-complete: the base text intact + // at the front, all three towers behind it. + const actual = new Uint8Array(await fsp.readFile(abs)); + expectByteComplete(actual, expected); + expectSameBytes( + actual.subarray(0, Buffer.byteLength(LARGEST_BASE_FILE[1], "utf8")), + utf8(LARGEST_BASE_FILE[1]), + ); + expect(countOccurrences(actual, '<S id="g">\n')).toBe( + 3 * DEEPEST_STAGED_TOWER, + ); + expect(countOccurrences(actual, "deep.\n")).toBe(3); + for (const [rel, text] of FUZZ_BASE_FILES) { + if (rel !== basePath) { + expectSameBytes(await workspace.readBytes(rel), utf8(text)); + } + } +}); + +test("scale vector: the largest document the suite stages — T1.3-7's 2048-deep chained-id tower (4 225 030 bytes), read back byte-complete", async () => { + // Derivation (staged-scale.ts, shared with S-8): the largest document the + // suite stages is deterministic — T1.3-7's `specs/A.mdx`, nesting sections + // DEPTH_FLOOR deep with chained ids (`a`, `a.b`, `a.b.c`, …; + // section-1.3.ts). Every id spells its whole ancestor chain, so the file is + // quadratic in the depth: 9·D + D·(D + 1) + 6 + 5·D = 4 225 030 bytes at + // D = 2048, ~21× the generator maximum above — the truncation window the + // two vectors above cannot see. `expected` is the document byte for byte; + // the exact-size pins move only when DEPTH_FLOOR does — deliberately. + const tower = depthTower(DEPTH_FLOOR); + const expected = largestDeterministicDocument(); + expect(DEPTH_FLOOR).toBe(2048); + expect(DEPTH_FLOOR).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + expect(expected.length).toBe(LARGEST_DETERMINISTIC_INPUT_BYTES); + expect(expected.length).toBe(4_225_030); + expectByteComplete(expected, utf8(tower.source)); + // The vector's subject is the suite's staged maximum, not merely the + // deterministic one: this pin fails the day a generator bound outgrows + // T1.3-7's document, when the title above stops being true and the vector + // must move with it. + expect(LARGEST_STAGED_INPUT_BYTES).toBe(expected.length); + + // Staged exactly as T1.3-7 stages it: `specs/A.mdx` declared through the + // builder's declarative path (create → file), the byte class the fixture + // exercises — its `xspec.config.ts` is irrelevant to the builder. + const workspace = await makeWorkspace({ + files: { "specs/A.mdx": tower.source }, + }); + + // The builder's own listing reports the file once, as a regular file, at + // the full declared size. + expect(await workspace.readdirNames()).toEqual(["specs"]); + expect(await workspace.readdirNames("specs")).toEqual(["A.mdx"]); + expect(await workspace.kind("specs/A.mdx")).toBe("file"); + const abs = path.join(workspace.root, "specs", "A.mdx"); + expect((await fsp.stat(abs)).size).toBe(4_225_030); + + // Read back from disk with plain fs: byte-complete, and still the declared + // chain end to end — not merely DEPTH_FLOOR openers of any ids: every + // opener and closer, the one content line, every line feed, and the + // outermost (`a`) and innermost (4 095-character) ids each exactly once. + const actual = new Uint8Array(await fsp.readFile(abs)); + expectByteComplete(actual, expected); + expect(countOccurrences(actual, '<S id="')).toBe(DEPTH_FLOOR); + expect(countOccurrences(actual, "</S>\n")).toBe(DEPTH_FLOOR); + expect(countOccurrences(actual, "deep.\n")).toBe(1); + expect(countOccurrences(actual, "\n")).toBe(2 * DEPTH_FLOOR + 1); + expect(2 * DEPTH_FLOOR + 1).toBe(4_097); + expect(tower.ids).toHaveLength(DEPTH_FLOOR); + const outermost = tower.ids[0]!; + const innermost = tower.ids[DEPTH_FLOOR - 1]!; + expect(outermost).toBe("a"); + expect(innermost).toHaveLength(2 * DEPTH_FLOOR - 1); + expect(innermost).toHaveLength(4_095); + expect(countOccurrences(actual, `<S id="${outermost}">\n`)).toBe(1); + expect(countOccurrences(actual, `<S id="${innermost}">\n`)).toBe(1); +}); + +// --------------------------------------------------------------------------- +// S-9's TypeScript check at staging time (TEST-SPEC S-9's TypeScript clause, +// H-8; helpers/workspace.ts): the builder judges every staged code source and +// configuration file with `judgeTypeScript` (helpers/ts-derivability.ts — the +// harness's own TypeScript 5.9.3 parser, both readings) as it judges `.mdx` +// sources. A name `TS_DEFAULT_SUFFIXES` reaches is declared well-formed by +// default; a staging declares the exceptions per path (`ts: { unparseable, +// unchecked, wellFormed }`, or per `file()` call). A contradiction, and a +// text accepted read one way only, throw `HarnessStagingError` (mode +// `ts-derivability`) before anything is written — a harness error, never an +// assertion failure and never a skip. + +const TS_WELL_FORMED = "export const n = 1;\n"; +/** Rejected both ways: TS1134 at the `=` (byte offset 13). */ +const TS_ILL_FORMED = "export const = 1;\n"; +/** SPEC 14.20's one-way texts: module code only, and script code only. */ +const TS_MODULE_ONLY = "await /re/;\n"; +const TS_SCRIPT_ONLY = "let a = await / 2 / 1;\n"; +/** A TSX-only construct: well-formed in a `.tsx` file, nowhere else. */ +const TSX_ONLY = "export const v = <b>x</b>;\n"; +const TS_BOM_BYTES = concatBytes(bytes(0xef, 0xbb, 0xbf), utf8(TS_WELL_FORMED)); +const TS_INVALID_UTF8_BYTES = concatBytes( + utf8("export const n = 1; // "), + bytes(0xff, 0x0a), +); +/** T7-2's syntax-error configuration: its braces never close. */ +const UNCLOSED_CONFIG = + 'import { defineConfig } from "xspec"\n\nexport default defineConfig({\n' + + ' specs: {\n main: ["specs/**/*.mdx"]\n'; + +async function expectTsStagingError( + action: () => Promise<unknown>, + stagedPath: string, + ...fragments: readonly string[] +): Promise<HarnessStagingError> { + let thrown: unknown; + try { + await action(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as HarnessStagingError; + expect(error.mode).toBe("ts-derivability"); + expect(error.path).toBe(stagedPath); + expect(error.message).toContain(`ts-derivability staging of ${stagedPath}: `); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } + return error; +} + +test("S-9 TypeScript vector: a declared-well-formed ill-formed `.ts` throws at staging, naming the path and the parser's first error", async () => { + await expectTsStagingError( + () => TestWorkspace.create({ files: { "src/a.ts": TS_ILL_FORMED } }), + "src/a.ts", + "declared well-formed, but it is not well-formed TypeScript (5.9.3", + 'TS1134 "Variable declaration expected." at line 1, column 14 (byte offset 13)', + "`ts.unparseable`", + ); + // Declared well-formed explicitly — the same verdict. + await expectTsStagingError( + () => + TestWorkspace.create({ + files: { "src/a.ts": TS_ILL_FORMED }, + ts: { wellFormed: ["src/a.ts"] }, + }), + "src/a.ts", + "declared well-formed", + ); + // The configuration file is judged by the same grammar (SPEC 7, 14.20). + await expectTsStagingError( + () => + TestWorkspace.create({ files: { "xspec.config.ts": UNCLOSED_CONFIG } }), + "xspec.config.ts", + "declared well-formed", + "TS1005", + ); + // 14.20's encoding rules: a byte-order mark and invalid UTF-8. + await expectTsStagingError( + () => TestWorkspace.create({ files: { "src/a.ts": TS_BOM_BYTES } }), + "src/a.ts", + "byte-order mark", + ); + await expectTsStagingError( + () => + TestWorkspace.create({ files: { "src/a.ts": TS_INVALID_UTF8_BYTES } }), + "src/a.ts", + "not valid UTF-8: an invalid sequence at byte offset 23", + ); + // The name selects the grammar: a TSX-only construct in a `.ts` file. + await expectTsStagingError( + () => TestWorkspace.create({ files: { "src/v.ts": TSX_ONLY } }), + "src/v.ts", + "declared well-formed", + ); + const workspace = await makeWorkspace({ + files: { "src/v.tsx": TSX_ONLY, "src/a.ts": TS_WELL_FORMED }, + }); + expect(Buffer.from(await workspace.readBytes("src/v.tsx")).toString()).toBe( + TSX_ONLY, + ); + expect(workspace.tsDeclarationOf("src/v.tsx")).toBe("well-formed"); +}); + +test("S-9 TypeScript vector: a declared-unparseable well-formed `.ts` throws at staging; an ill-formed one stages", async () => { + await expectTsStagingError( + () => + TestWorkspace.create({ + files: { "src/a.ts": TS_WELL_FORMED }, + ts: { unparseable: ["src/a.ts"] }, + }), + "src/a.ts", + "declared unparseable, but TypeScript 5.9.3 accepts it both as module code and as script code", + ); + await expectTsStagingError( + () => + TestWorkspace.create({ + files: { "src/v.tsx": TSX_ONLY }, + ts: { unparseable: ["src/v.tsx"] }, + }), + "src/v.tsx", + "declared unparseable", + ); + const workspace = await makeWorkspace({ + files: { + "src/a.ts": TS_ILL_FORMED, + "src/v.ts": TSX_ONLY, + "src/bom.ts": TS_BOM_BYTES, + "src/bad.ts": TS_INVALID_UTF8_BYTES, + "xspec.config.ts": UNCLOSED_CONFIG, + }, + ts: { + unparseable: [ + "src/a.ts", + "src/v.ts", + "src/bom.ts", + "src/bad.ts", + "xspec.config.ts", + ], + }, + }); + expect(workspace.tsDeclarationOf("src/a.ts")).toBe("unparseable"); + expectSameBytes(await workspace.readBytes("src/bom.ts"), TS_BOM_BYTES); + expectSameBytes( + await workspace.readBytes("src/bad.ts"), + TS_INVALID_UTF8_BYTES, + ); + expectSameBytes( + await workspace.readBytes("xspec.config.ts"), + utf8(UNCLOSED_CONFIG), + ); +}); + +test("S-9 TypeScript vector: a text accepted read one way only throws under either declaration — module code only and script code only", async () => { + for (const [text, fragment] of [ + [ + TS_MODULE_ONLY, + "accepted read as module code only, rejected read as script code", + ], + [ + TS_SCRIPT_ONLY, + "accepted read as script code only, rejected read as module code", + ], + ] as const) { + await expectTsStagingError( + () => TestWorkspace.create({ files: { "src/a.ts": text } }), + "src/a.ts", + "declared well-formed", + fragment, + "whatever its declaration (S-9)", + "restage the fixture", + ); + await expectTsStagingError( + () => + TestWorkspace.create({ + files: { "src/a.ts": text }, + ts: { unparseable: ["src/a.ts"] }, + }), + "src/a.ts", + "declared unparseable", + fragment, + ); + // A file whose well-formedness the document does not declare is never + // judged, a one-way text included. + const workspace = await makeWorkspace({ + files: { "src/a.ts": text }, + ts: { unchecked: ["src/a.ts"] }, + }); + expect(Buffer.from(await workspace.readBytes("src/a.ts")).toString()).toBe( + text, + ); + } +}); + +test("S-9 TypeScript default: the names `TS_DEFAULT_SUFFIXES` reaches are judged; any other name only when declared", async () => { + expect([...TS_DEFAULT_SUFFIXES]).toEqual([ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ]); + const workspace = await makeWorkspace({ + files: { + // Not judged by default: a code group may glob these names, so a code + // source among them declares itself. + "specs/A.md": TS_ILL_FORMED, + "notes.txt": TS_ILL_FORMED, + "src/a.TS": TS_ILL_FORMED, + "data.json": TS_ILL_FORMED, + }, + }); + for (const rel of [ + "src/a.ts", + "src/a.tsx", + "src/a.mts", + "src/a.cts", + "src/a.d.ts", + "src/a.d.mts", + "src/a.js", + "src/a.jsx", + "src/a.mjs", + "src/a.cjs", + "xspec.config.ts", + "cfg/broken.config.ts", + ]) { + expect(workspace.tsDeclarationOf(rel)).toBe("well-formed"); + await expectTsStagingError( + () => workspace.file(rel, TS_ILL_FORMED), + rel, + "declared well-formed", + ); + } + for (const rel of [ + "specs/A.md", + "notes.txt", + "src/a.TS", + "data.json", + "specs/A.mdx", + ]) { + expect(workspace.tsDeclarationOf(rel)).toBeUndefined(); + } + // A code source whose name the default does not reach is declared: T7-6's + // `specs/a'b.md` holding `)` unparseable, T13.4-11(b)'s `specs/A.md` + // holding `export const n = 1` well-formed. + const declared = await makeWorkspace({ + files: { "specs/a'b.md": ")", "specs/A.md": "export const n = 1\n" }, + ts: { unparseable: ["specs/a'b.md"], wellFormed: ["specs/A.md"] }, + }); + expect(declared.tsDeclarationOf("specs/a'b.md")).toBe("unparseable"); + expect(declared.tsDeclarationOf("specs/A.md")).toBe("well-formed"); + await expectTsStagingError( + () => + TestWorkspace.create({ + files: { "specs/A.md": ")" }, + ts: { wellFormed: ["specs/A.md"] }, + }), + "specs/A.md", + "declared well-formed", + 'TS1128 "Declaration or statement expected."', + ); + await expectTsStagingError( + () => + TestWorkspace.create({ + files: { "specs/a'b.md": "export const n = 1\n" }, + ts: { unparseable: ["specs/a'b.md"] }, + }), + "specs/a'b.md", + "declared unparseable", + ); +}); + +test("S-9 TypeScript declarations: the workspace declaration governs later stagings — `file()`, `edit()`, `copyFrom()` — and a `ts` option overrides it for one write", async () => { + const workspace = await makeWorkspace({ + files: { "src/a.ts": TS_WELL_FORMED }, + ts: { unparseable: ["src/u.ts"], unchecked: ["src/n.ts"] }, + }); + await workspace.file("src/u.ts", TS_ILL_FORMED); + await expectTsStagingError( + () => workspace.file("src/u.ts", TS_WELL_FORMED), + "src/u.ts", + "declared unparseable", + ); + await workspace.file("src/u.ts", TS_WELL_FORMED, { ts: "well-formed" }); + await workspace.file("src/n.ts", TS_ILL_FORMED); + await workspace.file("src/n.ts", TS_MODULE_ONLY); + await workspace.file("src/b.ts", TS_ILL_FORMED, { ts: "unparseable" }); + await workspace.file("src/c.ts", TS_SCRIPT_ONLY, { ts: "unchecked" }); + await workspace.file("specs/A.md", ")", { ts: "unparseable" }); + await expectTsStagingError( + () => workspace.file("specs/B.md", ")", { ts: "well-formed" }), + "specs/B.md", + "declared well-formed", + ); + // A byte path is keyed by its decoding. + await expectTsStagingError( + () => workspace.file(utf8("src/d.ts"), TS_ILL_FORMED), + "src/d.ts", + "declared well-formed", + ); + // `edit()` and `copyFrom()` stage under the destination's declaration. + await expectTsStagingError( + () => workspace.edit("src/a.ts", "n = 1", "= 1"), + "src/a.ts", + "declared well-formed", + ); + expect(Buffer.from(await workspace.readBytes("src/a.ts")).toString()).toBe( + TS_WELL_FORMED, + ); + await workspace.edit("src/n.ts", "await", "await await"); + const source = await makeWorkspace({ + files: { "src/x.ts": TS_ILL_FORMED }, + ts: { unchecked: ["src/x.ts"] }, + }); + await expectTsStagingError( + () => workspace.copyFrom(source, "src/x.ts", "src/y.ts"), + "src/y.ts", + "declared well-formed", + ); + await workspace.copyFrom(source, "src/x.ts", "src/u.ts"); + expect(Buffer.from(await workspace.readBytes("src/u.ts")).toString()).toBe( + TS_ILL_FORMED, + ); +}); + +test("S-9 TypeScript declarations: a refused staging writes nothing; a path in two lists is refused at creation", async () => { + const workspace = await makeWorkspace({ + files: { "src/a.ts": TS_WELL_FORMED }, + }); + await expectTsStagingError( + () => workspace.file("src/a.ts", TS_ILL_FORMED), + "src/a.ts", + ); + expect(Buffer.from(await workspace.readBytes("src/a.ts")).toString()).toBe( + TS_WELL_FORMED, + ); + await expectTsStagingError( + () => workspace.file("src/deep/b.ts", TS_ILL_FORMED), + "src/deep/b.ts", + ); + expect(await workspace.kind("src/deep")).toBe("absent"); + await expectTsStagingError( + () => + TestWorkspace.create({ + ts: { unparseable: ["src/a.ts"], unchecked: ["./src/a.ts"] }, + }), + "src/a.ts", + "more than one", + ); + await expectTsStagingError( + () => + TestWorkspace.create({ + ts: { wellFormed: ["specs/A.md"], unparseable: ["specs//A.md"] }, + }), + "specs/A.md", + "more than one", + ); +}); diff --git a/test/self/s3-subprocess-driver.test.ts b/test/self/s3-subprocess-driver.test.ts index 4d14d591..5914304b 100644 --- a/test/self/s3-subprocess-driver.test.ts +++ b/test/self/s3-subprocess-driver.test.ts @@ -7,6 +7,10 @@ // interpretation, timeout-as-failure for hangs (reported as failures, never // skips — H-8), diagnosed failures for missing executables, and the 13.5 // machinery: background start, hold-file choreography, kill, concurrency. +// The capture limit (H-11) is pinned here too: an overflow is a loud +// `ProductRunOutputOverflowError`, never a truncation, which the hold-file +// wait surfaces as itself and `rethrowOutputOverflow` — the first call of +// every helper converting a run's rejection — lets through unchanged. // // The stand-in is a tiny argv-driven Node script written into a fresh // TestWorkspace per test (the builder itself is certified by S-2) and driven @@ -21,11 +25,15 @@ import * as fsp from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { expect, onTestFinished, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; import { builtProductBinding, createHoldFile, pathExists, + ProductRunOutputOverflowError, + ProductRunTimeoutError, releaseHoldFile, + rethrowOutputOverflow, runProduct, startProduct, } from "../helpers/subprocess.js"; @@ -316,7 +324,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"` @@ -541,7 +549,7 @@ test("concurrent invocations stay isolated: each returns its own argv, output, a expect(exitResults.map((result) => result.exitCode)).toEqual([11, 12, 13]); }); -test("a child exceeding the output cap is killed with a diagnosed failure (H-8: runaway output never hangs the harness)", async () => { +test("a child exceeding the output cap is killed with a loud overflow error, never a silent truncation (H-8: runaway output never hangs the harness; H-11)", async () => { const { workspace, binding } = await standin(); await expect( runProduct(binding, { @@ -551,3 +559,50 @@ test("a child exceeding the output cap is killed with a diagnosed failure (H-8: }), ).rejects.toThrow(/exceeded the output limit of 2048 bytes/); }); + +test("waitForFile surfaces a capture-limit kill as the driver's ProductRunOutputOverflowError itself, never folded into the premature-exit error (H-11)", async () => { + const { workspace, binding } = await standin(); + const running = await startProduct(binding, { + cwd: workspace.root, + argv: ["spam"], + maxOutputBytes: 2048, + }); + const neverCreated = path.join(workspace.tempRoot, "never-created"); + const error = await running.waitForFile(neverCreated).then( + () => null, + (thrown: unknown) => thrown, + ); + expect(error).toBeInstanceOf(ProductRunOutputOverflowError); + expect((error as Error).message).toMatch( + /exceeded the output limit of 2048 bytes/, + ); + expect((error as Error).message).not.toContain("exited before creating"); + // The very rejection the run settled with, not a copy. + const settled = await running.waitForExit().then( + () => null, + (thrown: unknown) => thrown, + ); + expect(settled).toBe(error); +}); + +test("rethrowOutputOverflow rethrows exactly the capture-limit error, unchanged, and returns for every other rejection a helper converts (H-11)", () => { + const overflow = new ProductRunOutputOverflowError("capture limit"); + let rethrown: unknown = null; + try { + rethrowOutputOverflow(overflow); + } catch (thrown) { + rethrown = thrown; + } + expect(rethrown).toBe(overflow); + for (const other of [ + new ProductRunTimeoutError("hang guard"), + new HarnessAssertionError("diagnosed"), + new Error("failed to start"), + "not an error", + undefined, + ]) { + expect(() => { + rethrowOutputOverflow(other); + }).not.toThrow(); + } +}); diff --git a/test/self/s4-typescript-tooling.test.ts b/test/self/s4-typescript-tooling.test.ts index e3d5dc96..9bb98f7f 100644 --- a/test/self/s4-typescript-tooling.test.ts +++ b/test/self/s4-typescript-tooling.test.ts @@ -5,18 +5,30 @@ // against a hand-written, non-xspec fixture project // (test/fixtures/s4-tooling/): a known type error, a known definition // location, and a known hover text must all be detected, so section 4's -// consumer assertions cannot pass vacuously. Alongside the three S-4 probes, -// the driver's remaining surfaces are pinned the same way: compiled -// consumers run under plain Node with no runtime dependency in the consumer -// workspace (SPEC.md 13.1; IMPLEMENTATION.md), an import nothing makes -// resolvable is a diagnosed compile error (the red path for section 4 tests -// against the stub product, H-8), and marker addressing and project loading -// fail loudly rather than vacuously green. +// consumer assertions cannot pass vacuously. A driver blind to a diagnostic +// kind passes conformer and violator alike wherever no violator targets that +// kind, so each kind's detection is checked directly (S-4): beside the +// argument-type error (TS2345), the two collision kinds T6.5-9's +// compile-clean observation turns on when a product-chosen import +// identifier collides with a binding the receiving file already holds — an +// import binding conflicting with a module-scope local declaration (TS2440: +// the file's own `const`, `function`, or `class`) and an import binding +// duplicated by another import binding (TS2300: a non-spec import the file +// already carries) — neither of which any certification fixture targets +// (CERTIFICATIONS.md, Exclusions: "Section 4 consumer-side and type-level +// tests"). Alongside the S-4 probes, the +// driver's remaining surfaces are pinned the same way: compiled consumers +// run under plain Node with no runtime dependency in the consumer workspace +// (SPEC.md 13.1; IMPLEMENTATION.md), an import nothing makes resolvable is a +// diagnosed compile error (the red path for section 4 tests against the +// stub product, H-8), and marker addressing and project loading fail loudly +// rather than vacuously green. import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import ts from "typescript"; +// The harness's own pinned TypeScript, as the tooling driver uses it. +import ts from "typescript-5.9.3"; import { expect, onTestFinished, test } from "vitest"; import { HarnessAssertionError } from "../helpers/assertions.js"; import { @@ -66,6 +78,108 @@ test("S-4: detects the known type error at its exact location", async () => { expect(project.errors()).toHaveLength(1); }); +test("S-4: detects an import binding conflicting with a module-scope local declaration (TS2440, the kind T6.5-9 turns on)", async () => { + // T6.5-9's compile-clean observation rides assertNoCompileErrors over a + // consumer whose receiving file pre-empts the product-chosen import + // identifier with its own `const` — a collision TypeScript reports as + // TS2440 on the import binding. No certification fixture targets the kind, + // so its detection is pinned here directly (S-4), on a fixture file whose + // only defect is that collision. + const project = await loadFixtureProject([ + "greeting.ts", + "import-conflict.ts", + ]); + const marker = project.locate("import-conflict.ts", "import { greet }", { + charOffset: "import { ".length, + }); + const diagnostic = assertCompileErrorAt(project, marker, { + code: 2440, + messageIncludes: [ + "Import declaration conflicts with local declaration", + "greet", + ], + }); + // The error spans exactly the import clause's binding identifier, and the + // location math is pinned against hand-counted ground truth in the frozen + // fixture file. + expect(diagnostic.start).toEqual(marker); + expect(diagnostic.length).toBe("greet".length); + expect(marker.file).toBe("import-conflict.ts"); + expect(marker.line).toBe(10); + expect(marker.column).toBe(10); + // Detection is specific: the import binding is the only error — the local + // declaration it collides with (and the use) carry no diagnostic. + expect(project.errors()).toHaveLength(1); + const local = project.locate("import-conflict.ts", "const greet", { + charOffset: "const ".length, + }); + expect(() => + assertCompileErrorAt(project, local, { code: 2440 }), + ).toThrowError(HarnessAssertionError); + // The clean-compile assertion T6.5-9 rides diagnoses the state instead of + // passing. + expect(() => assertNoCompileErrors(project)).toThrowError( + HarnessAssertionError, + ); + expect(() => assertNoCompileErrors(project)).toThrowError(/TS2440/); +}); + +test("S-4: detects an import binding duplicated by another import binding (TS2300, the other collision kind T6.5-9 turns on)", async () => { + // T6.5-9's pre-empted set also holds a non-spec import binding: a + // product-chosen import identifier equal to one the receiving file already + // imports is a collision TypeScript reports as TS2300 on both import + // bindings. No certification fixture targets the kind, so its detection is + // pinned here directly (S-4), on a fixture file whose only defect is that + // duplication. Fixture self-check first: both imported modules are valid + // on their own, so every diagnostic below is the duplication's. + assertNoCompileErrors( + await loadFixtureProject(["greeting.ts", "other-greeting.ts"]), + "s4-tooling duplicate-import premise", + ); + const project = await loadFixtureProject([ + "greeting.ts", + "other-greeting.ts", + "import-duplicate.ts", + ]); + // The marker occurs once per import declaration, so each binding is + // addressed by occurrence index; the offset lands on the identifier. + const bindings = [ + { index: 0, line: 9 }, + { index: 1, line: 10 }, + ] as const; + for (const { index, line } of bindings) { + const marker = project.locate("import-duplicate.ts", "import { greet }", { + index, + charOffset: "import { ".length, + }); + const diagnostic = assertCompileErrorAt(project, marker, { + code: 2300, + messageIncludes: ["Duplicate identifier", "greet"], + }); + // Each error spans exactly its import clause's binding identifier, and + // the location math is pinned against hand-counted ground truth in the + // frozen fixture file. + expect(diagnostic.start).toEqual(marker); + expect(diagnostic.length).toBe("greet".length); + expect(marker.file).toBe("import-duplicate.ts"); + expect(marker.line).toBe(line); + expect(marker.column).toBe(10); + } + // Detection is specific: the two import bindings are the only errors — the + // use carries no diagnostic. + expect(project.errors()).toHaveLength(2); + const use = project.locate("import-duplicate.ts", 'greet("world")'); + expect(() => assertCompileErrorAt(project, use, { code: 2300 })).toThrowError( + HarnessAssertionError, + ); + // The clean-compile assertion T6.5-9 rides diagnoses the state instead of + // passing. + expect(() => assertNoCompileErrors(project)).toThrowError( + HarnessAssertionError, + ); + expect(() => assertNoCompileErrors(project)).toThrowError(/TS2300/); +}); + test("S-4 control: the fixture's clean files compile with zero errors", async () => { const project = await loadFixtureProject(["greeting.ts", "main.ts"]); assertNoCompileErrors(project, "s4-tooling clean subset"); @@ -232,6 +346,9 @@ test( "", ].join("\n"); const workspace = await makeWorkspace({ + // S-9: doc.mdx is a source-map decoy holding TypeScript, not an MDX + // fixture — no discovery reaches it. + mdx: { unchecked: ["doc.mdx"] }, files: { "gen/orig.ts": original, // The pseudo-original the map will point at: same shape (line/column diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index aaeb361f..c3178aed 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -20,35 +20,68 @@ 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, + ManualDeletionVerdict, + 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, + decodeNodeTextAlgebraSummary, + decodePerformedOperationReport, + decodePreviewReport, decodeReachableReport, decodeSessionListReport, decodeSessionStatusReport, + decodeVersionDocument, + decodeViewFilesReport, + decodeViewReport, + expectNonNegativeInteger, + isGraphDataKey, + judgeManualDeletionCorrection, + rootSite, stageBlockedByAbsentItem, stageBlockedByCycle, stageDeleteItemField, stageDuplicateItemEntry, stageGarbleCreationParameters, + stageGarbleDecompositions, stageUnknownItemStatus, } from "../helpers/adapters/index.js"; import { TestWorkspace } from "../helpers/workspace.js"; @@ -150,6 +183,13 @@ const EDGE_OUT = { kind: "depends", }; +/** A `contains` edge from GOOD_NODE's section to a child (SPEC.md 5.2). */ +const CONTAINS_DETAILS = { + from: "specs/A.mdx#login", + to: "specs/A.mdx#login.details", + kind: "contains", +}; + const GOOD_NODE = { identity: "specs/A.mdx#login", sourceRange: { start: 12, end: 96 }, @@ -207,24 +247,297 @@ 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 the numbered conditions + 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: [], + }, + ], +}; + +// The two findings T6.5-21's multi-reason arm reports, in SPEC 14's listed +// order (12.7, T12.7-2): `move specs/A.mdx "specs/a'b.mdx"` refused +// `refused-invalid-destination` (T6.5-4's barred character) then +// `refused-exposed-derived-file` (the origin's emit destination `specs/A.md` +// exposed to a `specs/*.md` glob) — each a reason concerning a path, carried +// as the finding's `path` with `locations` [] (SPEC 14), `identities` [] +// as T6.5-21 states it. +const INVALID_DESTINATION_FINDING = { + code: "refused-invalid-destination", + message: "the destination path would not be a valid discovered spec source", + locations: [], + path: "specs/a'b.mdx", + identities: [], +}; +const EXPOSED_DERIVED_FILE_FINDING = { + code: "refused-exposed-derived-file", + message: "the origin's emit destination holds a file discovery would yield", + locations: [], + path: "specs/A.md", + 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 +588,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 +725,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 { @@ -402,6 +859,15 @@ const DECODERS: readonly DecoderSpec[] = [ expect(decoded.coverage).toBeUndefined(); }, }, + { + // The 12.7 tag set is byte-ordered, never case-folded (T11.4-3's + // `tags="z A"` → ["A", "z"]): 0x5a sorts before 0x61. + label: "a tag set in byte order across cases (SPEC 12.7, 12.0)", + doc: put(GOOD_NODE, ["Zed", "auth"], "tags"), + verify: (decoded: ReturnType<typeof decodeNodeReport>) => { + expect(decoded.tags).toEqual(["Zed", "auth"]); + }, + }, ], bad: [ { label: "missing identity", doc: omit(GOOD_NODE, "identity") }, @@ -419,6 +885,19 @@ const DECODERS: readonly DecoderSpec[] = [ label: "range with end < start", doc: put(GOOD_NODE, 5, "sourceRange", "end"), }, + { + label: + "range carrying an extra member (12.7 value form: exactly start and end)", + doc: put(GOOD_NODE, { start: 12, end: 96, from: 12 }, "sourceRange"), + }, + { + label: "range as an array [start, end] (never re-mapped, H-3)", + doc: put(GOOD_NODE, [12, 96], "sourceRange"), + }, + { + label: 'range as {"from", "to"} (never re-mapped, H-3)', + doc: put(GOOD_NODE, { from: 12, to: 96 }, "sourceRange"), + }, { label: "missing ownText", doc: omit(GOOD_NODE, "ownText") }, { label: "non-string ownText", doc: put(GOOD_NODE, 7, "ownText") }, { label: "missing subtreeText", doc: omit(GOOD_NODE, "subtreeText") }, @@ -430,6 +909,22 @@ const DECODERS: readonly DecoderSpec[] = [ { label: "empty ownHash", doc: put(GOOD_NODE, "", "hashes", "ownHash") }, { label: "missing tags", doc: omit(GOOD_NODE, "tags") }, { label: "non-string tag", doc: put(GOOD_NODE, [3], "tags") }, + { + label: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put(GOOD_NODE, ["v2", "auth"], "tags"), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put(GOOD_NODE, ["auth", "auth"], "tags"), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put(GOOD_NODE, ["auth", "Zed"], "tags"), + }, { label: "wrong-typed coverage (must reject, not default to absent)", doc: put(GOOD_NODE, 42, "coverage"), @@ -480,6 +975,22 @@ const DECODERS: readonly DecoderSpec[] = [ { label: "empty identity", doc: put(GOOD_NODE, "", "identity") }, { label: "missing tags", doc: omit(GOOD_NODE, "tags") }, { label: "non-string tag", doc: put(GOOD_NODE, [3], "tags") }, + { + label: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put(GOOD_NODE, ["v2", "auth"], "tags"), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put(GOOD_NODE, ["auth", "auth"], "tags"), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put(GOOD_NODE, ["auth", "Zed"], "tags"), + }, ], }, { @@ -515,6 +1026,22 @@ const DECODERS: readonly DecoderSpec[] = [ bad: [ { label: "missing identity", doc: omit(GOOD_NODE, "identity") }, { label: "missing tags", doc: omit(GOOD_NODE, "tags") }, + { + label: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put(GOOD_NODE, ["v2", "auth"], "tags"), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put(GOOD_NODE, ["auth", "auth"], "tags"), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put(GOOD_NODE, ["auth", "Zed"], "tags"), + }, { label: "missing hashes", doc: omit(GOOD_NODE, "hashes") }, { label: "missing metadataHash", @@ -527,25 +1054,67 @@ const DECODERS: readonly DecoderSpec[] = [ ], }, { - name: "query node (own/subtree text summary)", - decode: decodeNodeTextSummary, - good: GOOD_NODE, - verify: (decoded: ReturnType<typeof decodeNodeTextSummary>) => { + name: "query node (text-algebra summary)", + decode: decodeNodeTextAlgebraSummary, + // GOOD_NODE with a `contains` edge to a child beside its dependency + // edge: the child's identity decodes, the dependency edge is passed over. + good: put(GOOD_NODE, [EDGE_OUT, CONTAINS_DETAILS], "edges", "outgoing"), + verify: (decoded: ReturnType<typeof decodeNodeTextAlgebraSummary>) => { expect(decoded.ownText).toBe("Login must work.\n"); expect(decoded.subtreeText).toBe("Login must work.\n\nDetails.\n"); + expect(decoded.sourceRange).toEqual({ start: 12, end: 96 }); + expect(decoded.containsTargets).toEqual(["specs/A.mdx#login.details"]); }, alsoGood: [ { // The point of this summary decoder: a document carrying only the - // CONF-MD-scoped query surface — own and subtree text, nothing else - // — decodes (CERTIFICATIONS.md §CONF-MD; P-2, P-3), and empty texts - // are legitimate values (an empty leaf section, SPEC.md 1.1), never - // rejected and never defaulted. - label: "a document carrying only the scoped text fields (both empty)", - doc: { ownText: "", subtreeText: "" }, - verify: (decoded: ReturnType<typeof decodeNodeTextSummary>): void => { + // CONF-MD-scoped members it reads — own and subtree text, source + // range, outgoing edges; no identity, hashes, tags, coverage, or + // incoming edges — decodes (CERTIFICATIONS.md §CONF-MD; P-3), and + // empty texts and an empty edge list are legitimate values (an + // empty leaf section, SPEC.md 1.1), never rejected and never + // defaulted. + label: + "a document carrying only the scoped members (empty texts, no children)", + doc: { + ownText: "", + subtreeText: "", + sourceRange: { start: 3, end: 3 }, + edges: { outgoing: [] }, + }, + verify: ( + decoded: ReturnType<typeof decodeNodeTextAlgebraSummary>, + ): void => { expect(decoded.ownText).toBe(""); expect(decoded.subtreeText).toBe(""); + expect(decoded.sourceRange).toEqual({ start: 3, end: 3 }); + expect(decoded.containsTargets).toEqual([]); + }, + }, + { + // Several children keep the answer's own order — ordering them by + // their reported ranges is P-3's step, not the adapter's — and an + // edge of a dependency kind is passed over unread: its + // out-of-scope content (here no `to`) is never decoded. + label: + "contains targets in answer order beside an unread dependency edge", + doc: put( + GOOD_NODE, + [ + { ...CONTAINS_DETAILS, to: "specs/A.mdx#login.b" }, + { from: "specs/A.mdx#login", kind: "embeds" }, + { ...CONTAINS_DETAILS, to: "specs/A.mdx#login.a" }, + ], + "edges", + "outgoing", + ), + verify: ( + decoded: ReturnType<typeof decodeNodeTextAlgebraSummary>, + ): void => { + expect(decoded.containsTargets).toEqual([ + "specs/A.mdx#login.b", + "specs/A.mdx#login.a", + ]); }, }, ], @@ -558,6 +1127,73 @@ const DECODERS: readonly DecoderSpec[] = [ label: "non-string subtreeText", doc: put(GOOD_NODE, ["x"], "subtreeText"), }, + { label: "missing sourceRange", doc: omit(GOOD_NODE, "sourceRange") }, + { + label: "sourceRange as a pair (12.7: {start, end} exactly)", + doc: put(GOOD_NODE, [12, 96], "sourceRange"), + }, + { + label: "sourceRange with an extra member (12.7: no other member)", + doc: put(GOOD_NODE, { start: 12, end: 96, length: 84 }, "sourceRange"), + }, + { label: "missing edges", doc: omit(GOOD_NODE, "edges") }, + { + label: "missing outgoing edges", + doc: omit(GOOD_NODE, "edges", "outgoing"), + }, + { + label: "outgoing edges not an array", + doc: put(GOOD_NODE, {}, "edges", "outgoing"), + }, + { + label: "an outgoing edge that is a bare identity, not an edge object", + doc: put(GOOD_NODE, ["specs/A.mdx#login.details"], "edges", "outgoing"), + }, + { + label: "an outgoing edge without a kind", + doc: put( + GOOD_NODE, + [{ from: "specs/A.mdx#login", to: "specs/A.mdx#login.details" }], + "edges", + "outgoing", + ), + }, + { + label: "an outgoing edge of a kind outside SPEC.md 5.2's vocabulary", + doc: put( + GOOD_NODE, + [{ ...CONTAINS_DETAILS, kind: "child" }], + "edges", + "outgoing", + ), + }, + { + label: "a contains edge without a target", + doc: put( + GOOD_NODE, + [{ from: "specs/A.mdx#login", kind: "contains" }], + "edges", + "outgoing", + ), + }, + { + label: "a contains edge with an empty target", + doc: put( + GOOD_NODE, + [{ ...CONTAINS_DETAILS, to: "" }], + "edges", + "outgoing", + ), + }, + { + label: "a contains edge with a non-string target", + doc: put( + GOOD_NODE, + [{ ...CONTAINS_DETAILS, to: 7 }], + "edges", + "outgoing", + ), + }, ], }, { @@ -595,12 +1231,64 @@ const DECODERS: readonly DecoderSpec[] = [ doc: omit(GOOD_ROWS, "nodes", 0, "identity"), }, { label: "row missing tags", doc: omit(GOOD_ROWS, "nodes", 1, "tags") }, + { + label: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put(GOOD_ROWS, ["v2", "auth"], "nodes", 0, "tags"), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put(GOOD_ROWS, ["auth", "auth"], "nodes", 0, "tags"), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put(GOOD_ROWS, ["auth", "Zed"], "nodes", 0, "tags"), + }, { label: "row with a non-string tag", doc: put(GOOD_ROWS, [3], "nodes", 0, "tags"), }, ], }, + { + 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, @@ -625,7 +1313,37 @@ const DECODERS: readonly DecoderSpec[] = [ label: "row missing sourceRange", doc: omit(GOOD_ROWS, "nodes", 0, "sourceRange"), }, + { + label: "row range carrying an extra member (12.7 value form)", + doc: put( + GOOD_ROWS, + { start: 12, end: 96, length: 84 }, + "nodes", + 0, + "sourceRange", + ), + }, + { + label: "row range as an array [start, end] (never re-mapped, H-3)", + doc: put(GOOD_ROWS, [0, 120], "nodes", 1, "sourceRange"), + }, { label: "row missing tags", doc: omit(GOOD_ROWS, "nodes", 1, "tags") }, + { + label: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put(GOOD_ROWS, ["v2", "auth"], "nodes", 0, "tags"), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put(GOOD_ROWS, ["auth", "auth"], "nodes", 0, "tags"), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put(GOOD_ROWS, ["auth", "Zed"], "nodes", 0, "tags"), + }, { label: "row with wrong-typed coverage", doc: put(GOOD_ROWS, false, "nodes", 0, "coverage"), @@ -762,69 +1480,2496 @@ const DECODERS: readonly DecoderSpec[] = [ ], }, { - name: "build/check findings", + name: "12.7 findings report", decode: decodeFindingsReport, good: GOOD_FINDINGS, verify: (decoded: ReturnType<typeof decodeFindingsReport>) => { - expect(decoded.findings).toHaveLength(3); + expect(decoded.findings).toHaveLength(6); + // The document members decode literally (form-exact, H-3) … + expect(decoded.findings[0].code).toBe("invalid-structural-id"); + expect(decoded.findings[0].locations).toEqual([ + { file: "specs/A.mdx", range: { start: 40, end: 78 } }, + ]); + expect(decoded.findings[0].path).toBeNull(); + expect(decoded.findings[0].identities).toEqual([]); + // … and the 14.N condition identity is DERIVED through the pinned + // token table (model.ts), never read from the document. expect(decoded.findings[0].condition).toBe("14.2"); - expect(decoded.findings[0].file).toBe("specs/A.mdx"); - expect(decoded.findings[0].location).toEqual({ start: 40, end: 78 }); - expect(decoded.findings[1].rule).toBe("no-derived-to-base"); - expect(decoded.findings[1].edge).toEqual(EDGE_OUT); - expect(decoded.findings[2].cycle).toEqual([ - "specs/A.mdx#a", - "specs/B.mdx#b", - "specs/A.mdx#a", + expect(decoded.findings[1].condition).toBe("14.9"); + expect(decoded.findings[1].locations).toHaveLength(2); + expect(decoded.findings[2].condition).toBe("14.12"); + expect(decoded.findings[2].identities).toEqual([ + "no-derived-to-base", + "specs/A.mdx#login", + "depends", + "specs/B.mdx#account", ]); + expect(decoded.findings[3].path).toBe(".xspec"); + expect(decoded.findings[4].code).toBe("refused-id-collision"); + expect(decoded.findings[4].condition).toBeNull(); // refusal: no 14.N + expect(decoded.findings[5].code).toBeNull(); + expect(decoded.findings[5].condition).toBeNull(); }, - bad: [ - { label: "missing findings list", doc: {} }, - { - label: "finding missing condition", - doc: omit(GOOD_FINDINGS, "findings", 0, "condition"), - }, - { - label: "condition not a 14.<n> identity", - doc: put(GOOD_FINDINGS, "oops", "findings", 0, "condition"), - }, + alsoGood: [ { - label: "condition outside section 14", - doc: put(GOOD_FINDINGS, "15.1", "findings", 0, "condition"), + label: "an empty findings array (a finding-free report)", + doc: { findings: [] }, + verify: (decoded: ReturnType<typeof decodeFindingsReport>): void => { + expect(decoded.findings).toEqual([]); + }, }, { - label: "condition 14.0 (no such condition)", - doc: put(GOOD_FINDINGS, "14.0", "findings", 0, "condition"), + 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" }); + }, }, { - label: "finding missing message", + label: + "the two refusal codes SPEC 14 lists last: refused-invalid-rewrite " + + "(locating the moved construct, identities the concerned files' " + + "paths in byte order) then refused-moved-import (locating the " + + "moved declaration, identities empty) (SPEC 14; T6.5-16, T6.5-17, " + + "T14-7)", + doc: { + findings: [ + { + code: "refused-invalid-rewrite", + message: "the exact edits would leave specs/B.mdx unparseable", + locations: [ + { file: "specs/A.mdx", range: { start: 12, end: 60 } }, + ], + path: null, + identities: ["specs/B.mdx", "specs/new.mdx"], + }, + { + code: "refused-moved-import", + message: "the moved text holds an import declaration", + locations: [ + { file: "specs/A.mdx", range: { start: 20, end: 48 } }, + ], + path: null, + identities: [], + }, + ], + }, + verify: (decoded: ReturnType<typeof decodeFindingsReport>): void => { + expect(decoded.findings.map((finding) => finding.code)).toEqual([ + "refused-invalid-rewrite", + "refused-moved-import", + ]); + // Refusal reasons derive no 14.N condition identity. + expect(decoded.findings.map((finding) => finding.condition)).toEqual([ + null, + null, + ]); + expect(decoded.findings[0]!.identities).toEqual([ + "specs/B.mdx", + "specs/new.mdx", + ]); + expect(decoded.findings[1]!.identities).toEqual([]); + }, + }, + { + label: + "refused-exposed-derived-file after refused-invalid-destination, " + + "each concerning its path with locations [] (T6.5-21's " + + "multi-reason arm; SPEC 14's listed order, T12.7-2)", + doc: { + findings: [ + structuredClone(INVALID_DESTINATION_FINDING), + structuredClone(EXPOSED_DERIVED_FILE_FINDING), + ], + }, + verify: (decoded: ReturnType<typeof decodeFindingsReport>): void => { + expect(decoded.findings.map((finding) => finding.code)).toEqual([ + "refused-invalid-destination", + "refused-exposed-derived-file", + ]); + // Refusal reasons derive no 14.N condition identity. + expect(decoded.findings.map((finding) => finding.condition)).toEqual([ + null, + null, + ]); + expect(decoded.findings.map((finding) => finding.path)).toEqual([ + "specs/a'b.mdx", + "specs/A.md", + ]); + expect(decoded.findings[1]!.locations).toEqual([]); + expect(decoded.findings[1]!.identities).toEqual([]); + }, + }, + ], + 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 retired refused-unresolvable-reference code (a code 14 does " + + "not list never appears in any report: no reason exists for a " + + "rewritten reference failing to resolve, SPEC 6.4, 6.5, 14; T14-7)", + doc: put( + GOOD_FINDINGS, + "refused-unresolvable-reference", + "findings", + 4, + "code", + ), + }, + { + label: + "a write failure inside a findings array (14.24 is a usage error " + + "carried only as the exit-2 error document's code, SPEC 14, 12.7)", + doc: { + findings: [ + { + code: "write-failure", + message: "cannot write .xspec: permission denied", + locations: [], + path: ".xspec", + identities: [], + }, + ], + }, + }, + { + label: + "a read failure inside a findings array (14.25 is a usage error " + + "carried only as the exit-2 error document's code, SPEC 14, 12.7)", + doc: { + findings: [ + { + code: "read-failure", + message: "cannot list specs/sub: permission denied", + locations: [], + path: "specs/sub", + identities: [], + }, + ], + }, + }, + { + 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: "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: + "refused-exposed-derived-file before refused-invalid-destination " + + "(14 lists it after: T6.5-21's multi-reason pair reversed)", + doc: { + findings: [ + structuredClone(EXPOSED_DERIVED_FILE_FINDING), + structuredClone(INVALID_DESTINATION_FINDING), + ], + }, + }, + { + label: + "refused-invalid-rewrite before refused-exposed-derived-file " + + "(14 lists refused-exposed-derived-file before it)", + doc: { + findings: [ + { + code: "refused-invalid-rewrite", + message: "the exact edits would leave specs/B.mdx unparseable", + locations: [ + { file: "specs/A.mdx", range: { start: 12, end: 60 } }, + ], + path: null, + identities: ["specs/B.mdx"], + }, + structuredClone(EXPOSED_DERIVED_FILE_FINDING), + ], + }, + }, + { + 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: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put( + GOOD_VIEW_FULL, + ["v2", "auth"], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put( + GOOD_VIEW_FULL, + ["auth", "auth"], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put( + GOOD_VIEW_FULL, + ["auth", "Zed"], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + 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: + "tags out of byte order (12.7 tag-set form: byte order, never " + + "re-sorted by the harness, H-3)", + doc: put( + GOOD_VIEW_FULL_TEXT, + ["v2", "auth"], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + label: "a repeated tag (12.7 tag-set form: duplicates collapsed)", + doc: put( + GOOD_VIEW_FULL_TEXT, + ["auth", "auth"], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + label: + "tags in case-folded order (12.7: byte order, never case-folded — " + + "0x5a sorts before 0x61)", + doc: put( + GOOD_VIEW_FULL_TEXT, + ["auth", "Zed"], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + 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("."); + }, + }, + { + label: + "a write failure: code write-failure concerning the graph-data " + + "area (SPEC 14.24, 12.7)", + doc: { + error: { + code: "write-failure", + message: "cannot write .xspec: permission denied", + locations: [], + path: ".xspec", + identities: [], + }, + }, + verify: (decoded: ReturnType<typeof decodeErrorDocument>): void => { + expect(decoded.error.code).toBe("write-failure"); + expect(decoded.error.condition).toBe("14.24"); + expect(decoded.error.path).toBe(".xspec"); + }, + }, + { + label: + "a read failure: code read-failure concerning the unlistable " + + "directory (SPEC 14.25, 12.7)", + doc: { + error: { + code: "read-failure", + message: "cannot list specs/sub: permission denied", + locations: [], + path: "specs/sub", + identities: [], + }, + }, + verify: (decoded: ReturnType<typeof decodeErrorDocument>): void => { + expect(decoded.error.code).toBe("read-failure"); + expect(decoded.error.condition).toBe("14.25"); + expect(decoded.error.path).toBe("specs/sub"); + }, + }, + ], + 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, + }); + }, + }, + { + label: + "configured sets in their 12.7 value forms decode literally — a " + + 'tag set in byte order ("Z" before "a": 0x5a < 0x61, inverted by ' + + "case folding) and kind sets in 5.2's order as proper subsets " + + "(SPEC 12.7, 7.4, 7.5, 11.6)", + doc: put( + put( + put( + put( + GOOD_RESOLVED_INVENTORY, + ["Z", "a"], + "configuration", + "coverage", + 0, + "targetTags", + ), + ["depends", "references"], + "configuration", + "coverage", + 0, + "edgeKinds", + ), + { tags: ["a", "b"] }, + "configuration", + "policy", + 0, + "to", + ), + ["depends", "embeds"], + "configuration", + "policy", + 0, + "kinds", + ), + verify: ( + decoded: ReturnType<typeof decodeInventoryResolvedMap>, + ): void => { + const profile = decoded.configuration.coverage[0]!; + expect(profile.targetTags).toEqual(["Z", "a"]); + expect(profile.edgeKinds).toEqual(["depends", "references"]); + const rule = decoded.configuration.policy[0]!; + expect(rule.to).toEqual({ tags: ["a", "b"] }); + expect(rule.kinds).toEqual(["depends", "embeds"]); + }, + }, + ], + 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: "malformed location", + label: "a non-boolean emit", doc: put( - GOOD_FINDINGS, - { start: 78, end: 40 }, - "findings", + 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: + 'edgeKinds out of 5.2\'s order (["references", "depends"]: a kind ' + + "set lists its tokens in the order 5.2 lists them, however " + + "configured; SPEC 12.7, 7.4)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["references", "depends"], + "configuration", + "coverage", + 0, + "edgeKinds", + ), + }, + { + label: + "edgeKinds with a repeated token (a kind set collapses a repeated " + + "element; SPEC 12.7, 7.4)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["depends", "depends", "embeds"], + "configuration", + "coverage", + 0, + "edgeKinds", + ), + }, + { + label: + 'targetTags out of byte order (["z", "a"]: a tag set is in byte ' + + "order; SPEC 12.7, 12.0)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["z", "a"], + "configuration", + "coverage", + 0, + "targetTags", + ), + }, + { + label: + 'targetTags in case-folded rather than byte order (["a", "Z"]: ' + + "0x5a sorts before 0x61; SPEC 12.7, 12.0)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["a", "Z"], + "configuration", + "coverage", + 0, + "targetTags", + ), + }, + { + label: + "targetTags with a repeated tag (a tag set collapses duplicates; " + + "SPEC 12.7, 7.4)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["a", "a"], + "configuration", + "coverage", + 0, + "targetTags", + ), + }, + { + label: 'a tags selector out of byte order (["b", "a"]; SPEC 12.7, 7.5)', + doc: put( + GOOD_RESOLVED_INVENTORY, + { tags: ["b", "a"] }, + "configuration", + "policy", + 0, + "to", + ), + }, + { + label: + 'a tags selector with a repeated tag (["a", "a"]; SPEC 12.7, 7.5)', + doc: put( + GOOD_RESOLVED_INVENTORY, + { tags: ["a", "a"] }, + "configuration", + "policy", + 0, + "to", + ), + }, + { + label: + 'a rule\'s kinds out of 5.2\'s order (["embeds", "depends"]; SPEC ' + + "12.7, 7.5)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["embeds", "depends"], + "configuration", + "policy", + 0, + "kinds", + ), + }, + { + label: + "a group selector without kind (explicit though inferred, SPEC " + + "7.5, 12.7)", + doc: omit( + GOOD_RESOLVED_INVENTORY, + "configuration", + "policy", + 0, + "from", + "kind", + ), + }, + { + label: "a selector of no 7.5 form", + doc: put( + GOOD_RESOLVED_INVENTORY, + { unit: "x" }, + "configuration", + "policy", + 0, + "from", + ), + }, + { + label: "a selector mixing the group and files forms", + doc: put( + GOOD_RESOLVED_INVENTORY, + { group: "aux", kind: "spec", files: "src/**" }, + "configuration", + "policy", 0, - "location", + "from", ), }, { - label: "wrong-typed file (must reject, not default)", - doc: put(GOOD_FINDINGS, 9, "findings", 0, "file"), + label: + "source entries out of byte order (SPEC 11.6: files and paths " + + "in byte order of workspace-relative path)", + doc: put( + GOOD_RESOLVED_INVENTORY, + [ + GOOD_RESOLVED_INVENTORY.sources[1], + GOOD_RESOLVED_INVENTORY.sources[0], + GOOD_RESOLVED_INVENTORY.sources[2], + ], + "sources", + ), + }, + { + label: "duplicate source entries (one entry per discovered file)", + doc: put( + GOOD_RESOLVED_INVENTORY, + [ + GOOD_RESOLVED_INVENTORY.sources[0], + GOOD_RESOLVED_INVENTORY.sources[0], + ], + "sources", + ), + }, + { + label: "a membership without kind", + doc: omit(GOOD_RESOLVED_INVENTORY, "sources", 0, "groups", 0, "kind"), + }, + { + label: + "a valid-UTF-8 source path in the marked byte form (SPEC 12.7 " + + "forbids it)", + doc: put( + GOOD_RESOLVED_INVENTORY, + { bytes: "612e6d64" }, + "sources", + 0, + "path", + ), + }, + { + label: + "a derived entry without markdown (structural absence is the " + + "stated null, never omission, SPEC 11.6, 12.7)", + doc: omit(GOOD_RESOLVED_INVENTORY, "derived", 0, "markdown"), + }, + { + label: + "a derived module as the unavailability marker (the projection " + + "is configuration- and discovery-determined, never " + + "record-supplied, SPEC 11.6)", + doc: put( + GOOD_RESOLVED_INVENTORY, + { unavailable: true }, + "derived", + 0, + "module", + ), + }, + ], + }, + { + // The full inventory document decode (SPEC 11.6, 12.7; T11.6-3's + // frame): the top level carries exactly the ten pinned members, decoded + // through the scoped decoders plus the recorded/graphData/journal/ + // sessions forms — `recorded` a three-state datum in byte order, + // `journal` {"path","occupied"} exactly, `sessions` in byte order of + // file name. + name: "11.6 inventory (document)", + decode: decodeInventoryDocument, + good: GOOD_INVENTORY_DOCUMENT, + verify: (decoded: ReturnType<typeof decodeInventoryDocument>) => { + expect(decoded.root).toBe("."); + expect(decoded.config).toBe("xspec.config.ts"); + expect(decoded.configuration.specs.map((g) => g.name)).toEqual([ + "core", + "aux", + ]); + expect(decoded.findings).toEqual([]); + expect(decoded.recorded).toEqual({ + state: "value", + value: ["specs/core/a.md", "specs/core/a.xspec.ts"], + }); + expect(decoded.graphData).toBe(".xspec"); + expect(decoded.journal).toEqual({ + path: ".xspec/journal", + occupied: false, + }); + expect(decoded.sessions).toEqual([ + ".xspec/reviews/S.json", + ".xspec/reviews/ancien.json", + ]); + }, + alsoGood: [ + { + label: + "recorded unavailable (14.23) beside an occupied journal decodes " + + "as stated — never as an empty record or a defaulted occupancy", + doc: put( + put(GOOD_INVENTORY_DOCUMENT, { unavailable: true }, "recorded"), + true, + "journal", + "occupied", + ), + verify: (decoded: ReturnType<typeof decodeInventoryDocument>): void => { + expect(decoded.recorded).toEqual({ state: "unavailable" }); + expect(decoded.journal.occupied).toBe(true); + }, + }, + { + label: "no sessions is the empty array (SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, [], "sessions"), + verify: (decoded: ReturnType<typeof decodeInventoryDocument>): void => { + expect(decoded.sessions).toEqual([]); + }, + }, + ], + bad: [ + { + label: + "an extra top-level member (the form carries exactly the ten " + + "pinned members, SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, ".xspec", "area"), + }, + { + label: "absent graphData member (null is never omission, SPEC 12.7)", + doc: omit(GOOD_INVENTORY_DOCUMENT, "graphData"), + }, + { + label: "absent journal member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "journal"), + }, + { + label: + 'journal without occupied (the member form is {"path", ' + + '"occupied"} exactly, SPEC 12.7)', + doc: omit(GOOD_INVENTORY_DOCUMENT, "journal", "occupied"), + }, + { + label: "journal with an extra member", + doc: put(GOOD_INVENTORY_DOCUMENT, 3, "journal", "lines"), }, { - label: "edge with unknown kind", - doc: put(GOOD_FINDINGS, "dependz", "findings", 1, "edge", "kind"), + label: "a stringly-typed occupied", + doc: put(GOOD_INVENTORY_DOCUMENT, "false", "journal", "occupied"), + }, + { + label: "absent sessions member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "sessions"), + }, + { + label: "null sessions (an empty list is [], never null, SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, null, "sessions"), + }, + { + label: + "sessions out of byte order of file name (the case-folded order, " + + "SPEC 11.6)", + doc: put( + GOOD_INVENTORY_DOCUMENT, + [".xspec/reviews/ancien.json", ".xspec/reviews/S.json"], + "sessions", + ), }, { - label: "cycle with empty identity", - doc: put(GOOD_FINDINGS, [""], "findings", 2, "cycle"), + label: "absent recorded member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "recorded"), }, ], }, @@ -962,22 +4107,152 @@ const DECODERS: readonly DecoderSpec[] = [ GOOD_IMPACT, "requirements", 1, - "categories", - 0, - "attributedTo", + "categories", + 0, + "attributedTo", + ), + }, + { + label: "code entry missing edge", + doc: omit(GOOD_IMPACT, "code", "direct", 0, "edge"), + }, + { + label: "code entry with empty path", + doc: put(GOOD_IMPACT, [], "code", "direct", 0, "path"), + }, + { + label: "wrong-typed baseline (must reject, not default)", + doc: put(GOOD_IMPACT, 7, "baseline"), + }, + ], + }, + { + name: "performed rename/move document (12.7: {findings, mapping})", + decode: decodePerformedOperationReport, + good: GOOD_APPLIED_MAPPING, + verify: (decoded: ReturnType<typeof decodePerformedOperationReport>) => { + expect(decoded).toEqual({ + findings: [], + mapping: [ + { from: "specs/A.mdx#login", to: "specs/A.mdx#signin" }, + { from: "specs/A.mdx#login.form", to: "specs/A.mdx#signin.form" }, + ], + }); + }, + bad: [ + { + label: "a member beside findings and mapping (form-exact member set)", + doc: put(GOOD_APPLIED_MAPPING, [], "files"), + }, + { + label: "findings absent (never omission)", + doc: omit(GOOD_APPLIED_MAPPING, "findings"), + }, + { + label: "null findings (an empty list is [], never null)", + doc: put(GOOD_APPLIED_MAPPING, null, "findings"), + }, + { + label: "findings not an array", + doc: put(GOOD_APPLIED_MAPPING, {}, "findings"), + }, + { + label: + "non-empty findings (a refused operation reports the " + + "findings-only form, never a mapping beside findings)", + doc: put(GOOD_APPLIED_MAPPING, [GOOD_FINDINGS.findings[0]], "findings"), + }, + { + 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: "mapping entries out of `from`-byte order", + doc: put( + GOOD_APPLIED_MAPPING, + [...structuredClone(GOOD_APPLIED_MAPPING.mapping)].reverse(), + "mapping", + ), + }, + { + label: + "two mapping entries for one identity (one {from, to} per " + + "mapped identity)", + doc: put( + GOOD_APPLIED_MAPPING, + [ + { from: "specs/A.mdx#login", to: "specs/A.mdx#signin" }, + { from: "specs/A.mdx#login", to: "specs/A.mdx#other" }, + ], + "mapping", + ), + }, + { + label: "pair with a member beside from and to", + doc: put(GOOD_APPLIED_MAPPING, "rename", "mapping", 0, "via"), + }, + { + 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: "applied mapping entry point (thin alias of the performed decoder)", + 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: "code entry missing edge", - doc: omit(GOOD_IMPACT, "code", "direct", 0, "edge"), + label: "a member beside findings and mapping is not ignored", + doc: put(GOOD_APPLIED_MAPPING, [], "files"), }, { - label: "code entry with empty path", - doc: put(GOOD_IMPACT, [], "code", "direct", 0, "path"), + label: "non-empty findings", + doc: put(GOOD_APPLIED_MAPPING, [GOOD_FINDINGS.findings[0]], "findings"), }, { - label: "wrong-typed baseline (must reject, not default)", - doc: put(GOOD_IMPACT, 7, "baseline"), + label: "mapping entries out of `from`-byte order", + doc: put( + GOOD_APPLIED_MAPPING, + [...structuredClone(GOOD_APPLIED_MAPPING.mapping)].reverse(), + "mapping", + ), }, ], }, @@ -1131,6 +4406,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") }, @@ -1154,6 +4456,21 @@ const DECODERS: readonly DecoderSpec[] = [ label: "absent context node carrying a source range (contradiction)", doc: put(GOOD_ITEM, { start: 3, end: 9 }, "context", 1, "sourceRange"), }, + { + label: + "present scope node's range carrying an extra member (12.7 value form)", + doc: put( + GOOD_ITEM, + { start: 12, end: 96, unit: "bytes" }, + "scope", + "sourceRange", + ), + }, + { + label: + 'present scope node\'s range as {"from", "to"} (never re-mapped, H-3)', + doc: put(GOOD_ITEM, { from: 12, to: 96 }, "scope", "sourceRange"), + }, { label: "missing context", doc: omit(GOOD_ITEM, "context") }, { label: "missing origin", doc: omit(GOOD_ITEM, "origin") }, { @@ -1164,6 +4481,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 +4584,466 @@ 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); + // 14's listed order, not lexicographic: refused-missing-target-parent + // (7th listed) precedes refused-invalid-destination (8th) although "m" + // sorts after "i"; refused-exposed-derived-file (9th) follows + // refused-invalid-destination and precedes refused-invalid-rewrite (10th) + // although "e" sorts before "i" (T6.5-21's multi-reason order, T12.7-2); + // the two codes 14 lists last follow in its order, refused-invalid-rewrite + // (10th) then refused-moved-import (11th). + const refusalSeventh = findingWith({ + code: "refused-missing-target-parent", + }); + const refusalEighth = findingWith({ code: "refused-invalid-destination" }); + const refusalNinth = findingWith({ code: "refused-exposed-derived-file" }); + const refusalTenth = findingWith({ code: "refused-invalid-rewrite" }); + const refusalEleventh = findingWith({ code: "refused-moved-import" }); + expect(compareFindings(refusalLater, refusalSeventh)).toBeLessThan(0); + expect(compareFindings(refusalSeventh, refusalEighth)).toBeLessThan(0); + expect(compareFindings(refusalEighth, refusalNinth)).toBeLessThan(0); + expect(compareFindings(refusalNinth, refusalTenth)).toBeLessThan(0); + expect(compareFindings(refusalTenth, refusalEleventh)).toBeLessThan(0); + // The reversed pairs around refused-exposed-derived-file rank the other + // way, and it ranks after the last numbered condition (14.25's token — + // never in a findings array, but the comparator's rank is total). + expect(compareFindings(refusalNinth, refusalEighth)).toBeGreaterThan(0); + expect(compareFindings(refusalTenth, refusalNinth)).toBeGreaterThan(0); + expect( + compareFindings(findingWith({ code: "read-failure" }), refusalNinth), + ).toBeLessThan(0); + // Code-less findings sort last, after the last listed refusal reason. + expect( + compareFindings(refusalLater, findingWith({ code: null })), + ).toBeLessThan(0); + expect( + compareFindings(refusalNinth, findingWith({ code: null })), + ).toBeLessThan(0); + expect( + compareFindings(refusalEleventh, findingWith({ code: null })), + ).toBeLessThan(0); + // A code 14 does not list takes no rank: handed to the comparator outside + // decode (which admits only 14's codes), the retired + // refused-unresolvable-reference is a harness defect, thrown — never + // ranked beside a listed code. + expect(() => + compareFindings( + findingWith({ code: "refused-unresolvable-reference" }), + refusalEighth, + ), + ).toThrow(/harness defect/); +}); + +test("S-5: the findings decode admits refused-exposed-derived-file and rejects it out of 14's listed order by the order rule", () => { + // One of 14's codes: admitted alone, a refusal reason deriving no 14.N. + const alone = decodeFindingsReport({ + findings: [structuredClone(EXPOSED_DERIVED_FILE_FINDING)], + }); + expect(alone.findings[0]!.code).toBe("refused-exposed-derived-file"); + expect(alone.findings[0]!.condition).toBeNull(); + expect(alone.findings[0]!.path).toBe("specs/A.md"); + // Each reversed pair around it is rejected by the pinned 12.7 order — + // never as an unknown code, so the rejection is the order's own. + const invalidRewrite = { + code: "refused-invalid-rewrite", + message: "the exact edits would leave specs/B.mdx unparseable", + locations: [{ file: "specs/A.mdx", range: { start: 12, end: 60 } }], + path: null, + identities: ["specs/B.mdx"], + }; + const reversed = [ + { + label: "refused-exposed-derived-file before refused-invalid-destination", + findings: [EXPOSED_DERIVED_FILE_FINDING, INVALID_DESTINATION_FINDING], + }, + { + label: "refused-invalid-rewrite before refused-exposed-derived-file", + findings: [invalidRewrite, EXPOSED_DERIVED_FILE_FINDING], + }, + ]; + for (const { label, findings } of reversed) { + const failure = expectDiagnosed(label, () => + decodeFindingsReport({ findings: structuredClone(findings) }), + ); + expect(failure.message).toContain("findings in the pinned 12.7 order"); + expect(failure.message).not.toContain("a stable code"); + } +}); + +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 adjustable adapters' document entries (T12.7-1): the DECODERS entries +// decoding an unpinned-shape surface — query, review, reports, operations; +// everything that is not a form-exact 12.7 or 11.6 document decoder. Each +// runs the marker walk over the whole raw document first +// (`documentRootSite`, forms.ts), so a near-marker in a member the adapter +// never reads — or passes through whole — still rejects, while an exact +// marker there is a legitimate value and decodes. +const UNPINNED_ADAPTER_NAMES: readonly string[] = [ + "query node/show", + "query node (identity/tags summary)", + "query node (identity/tags/metadataHash summary)", + "query node (text-algebra summary)", + "query nodes (identity/tags summary rows)", + "query nodes (identity-only rows)", + "query nodes/subtree/ancestors", + "query edges", + "query reachable", + "ids", + "ids --tree", + "coverage", + "impact", + "review list", + "review status", + "review show (full item)", + "review next", + "review export", +]; + +test("S-5: every adjustable adapter runs the marker walk over the whole document at its entry (T12.7-1)", () => { + const covered = DECODERS.filter((spec) => + UNPINNED_ADAPTER_NAMES.includes(spec.name), + ); + // Every listed adapter is a DECODERS entry (a renamed entry is caught). + expect(covered.map((spec) => spec.name).sort()).toEqual( + [...UNPINNED_ADAPTER_NAMES].sort(), + ); + for (const spec of covered) { + // A near-marker in a top-level member no adapter reads: only the walk + // can reject it, and its diagnosis names the JSON path. + const nearMarker = expectDiagnosed( + `${spec.name}: near-marker in an unread member`, + () => + spec.decode( + put(spec.good, { unavailable: "soon", note: 1 }, "unreadByAdapter"), + ), + ); + expect(nearMarker.message).toContain("adapter"); + expect(nearMarker.message).toContain("$.unreadByAdapter"); + expectDiagnosed(`${spec.name}: unavailable false in an unread member`, () => + spec.decode(put(spec.good, { unavailable: false }, "unreadByAdapter")), + ); + // Positive control: an exact marker there is a legitimate value — the + // adapter decodes the document as before. + spec.decode(put(spec.good, { unavailable: true }, "unreadByAdapter")); + } + // A member passed through whole (an item's recorded `baseline`, + // product-shaped and opaque, T10.2-2) is walked too: only the walk can + // reject a near-marker inside it. + const opaque = expectDiagnosed( + "review export: near-marker inside the opaque baseline record", + () => + decodeExportReport( + put(GOOD_EXPORT, { unavailable: 1 }, "items", 0, "baseline"), + "walk integration", + ), + ); + expect(opaque.message).toContain("$.items[0].baseline"); + // The two 1.7 walks take captured documents at their entries as well. + expectDiagnosed("bare edge-endpoint walk: near-marker in the document", () => + assertBareEdgeEndpoints( + put(GOOD_EDGES, { unavailable: "x" }, "meta"), + "walk integration", + ), + ); + expectDiagnosed( + "node edge-list walk: near-marker outside the edge lists", + () => + assertNodeEdgeListsBare( + put(GOOD_NODE, { unavailable: "x" }, "meta"), + "walk integration", + ), + ); + assertBareEdgeEndpoints( + put(GOOD_EDGES, { unavailable: true }, "meta"), + "walk integration", + ); + assertNodeEdgeListsBare( + put(GOOD_NODE, { unavailable: true }, "meta"), + "walk integration", + ); +}); + +// --- 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 { @@ -1330,6 +5112,174 @@ test("S-5: conditionMention distinguishes 14.2 from 14.20 in both directions", ( expectDiagnosed("not a condition identity", () => conditionMention("15.1")); }); +// T13.4-10's correction judge (TEST-SPEC T13.4-10, §0 H-3; SPEC 14.10): +// the recorded-file finding concerning `out/specs/A.md` has "the file's +// manual deletion, never a rebuild" as its correction, matched for its +// information, never its wording. Every vector is a complete message. + +/** The obstructing orphan T13.4-10 stages, the path the judge is given. */ +const OBSTRUCTING_ORPHAN = "out/specs/A.md"; + +/** U+2014 EM DASH and U+2019 RIGHT SINGLE QUOTATION MARK, by code point. */ +const EM_DASH = String.fromCodePoint(0x2014); +const APOSTROPHE = String.fromCodePoint(0x2019); + +/** A recorded-file finding's account of the file, before its correction. */ +const STALE_HEAD = + "stale generated output: the recorded derived file out/specs/A.md " + + "remains at a path the current sources and configuration no longer " + + "generate"; + +/** Messages carrying the correction, each with the form that carries it. */ +const MANUAL_DELETION_ACCEPTED: readonly { + readonly message: string; + readonly form: "manual-marker" | "instruction"; +}[] = [ + // The reviewers' three plain imperatives, each without a manual marker. + { + message: `${STALE_HEAD} and obstructs the rebuild's write of out/specs/A.md/specs/A.md; delete it, then rebuild (SPEC 14.10)`, + form: "instruction", + }, + { + message: + "Delete out/specs/A.md: it is a recorded derived file the current sources and configuration no longer generate, and it obstructs the rebuild's write of out/specs/A.md/specs/A.md (SPEC 14.10, 14.22)", + form: "instruction", + }, + { + message: `${STALE_HEAD}; remove it (a rebuild is refused while it obstructs the write of out/specs/A.md/specs/A.md) (SPEC 14.10, 14.22)`, + form: "instruction", + }, + // Further instructions to the reader: other openers and objects. + { + message: `${STALE_HEAD}; the rebuild is refused ${EM_DASH} delete it, then rebuild (SPEC 14.10)`, + form: "instruction", + }, + { + message: `${STALE_HEAD}; please remove out/specs/A.md and rebuild (SPEC 14.10)`, + form: "instruction", + }, + { + message: `${STALE_HEAD}; it obstructs the rebuild, so remove \`out/specs/A.md\` and rebuild (SPEC 14.10)`, + form: "instruction", + }, + { + message: `${STALE_HEAD}; a rebuild does not remove it, so delete it (SPEC 14.10)`, + form: "instruction", + }, + { + message: `${STALE_HEAD}; you must delete it before the rebuild can write out/specs/A.md/specs/A.md (SPEC 14.10)`, + form: "instruction", + }, + { + message: `${STALE_HEAD}; xspec won${APOSTROPHE}t remove it: delete it (SPEC 14.10)`, + form: "instruction", + }, + { + message: `${STALE_HEAD}; first remove the stale orphan, then rebuild (SPEC 14.10)`, + form: "instruction", + }, + { + message: "Remove the obstructing file out/specs/A.md, then rebuild.", + form: "instruction", + }, + { message: `${STALE_HEAD}.\nDelete it.`, form: "instruction" }, + // The manual marker beside a deletion or removal word. + { + message: `${STALE_HEAD}; delete out/specs/A.md manually (SPEC 14.10)`, + form: "manual-marker", + }, + { + message: `${STALE_HEAD}; remove it by hand: a rebuild is refused while it obstructs the write of out/specs/A.md/specs/A.md (SPEC 14.10)`, + form: "manual-marker", + }, + { + message: `${STALE_HEAD}; delete the file yourself, then rebuild (SPEC 14.10)`, + form: "manual-marker", + }, + // A negated or general mention of a build or xspec presents no remover. + { + message: `${STALE_HEAD}; delete it by hand: it is not removed by a rebuild, which is refused while it obstructs the write of out/specs/A.md/specs/A.md (SPEC 14.10)`, + form: "manual-marker", + }, + { + message: `${STALE_HEAD}; delete it manually ${EM_DASH} xspec will not remove it (SPEC 14.10)`, + form: "manual-marker", + }, + { + message: `${STALE_HEAD}; do not run \`xspec build\` to remove it: delete it by hand (SPEC 14.10)`, + form: "manual-marker", + }, + { + message: `${STALE_HEAD}; any build or rebuild is refused while it obstructs the write; delete it by hand.`, + form: "manual-marker", + }, + { + message: + "xspec removes recorded files no longer generated only when a rebuild succeeds, and this one is refused; delete out/specs/A.md by hand.", + form: "manual-marker", + }, +]; + +/** Messages presenting a build or xspec as what removes the file. */ +const MANUAL_DELETION_REBUILD_REMEDIES: readonly string[] = [ + // The built product's message, and the task's other two remedies. + `${STALE_HEAD}; run \`xspec build\` to remove it (SPEC 14.10)`, + "stale generated output: out/specs/A.md is a recorded derived file the current sources and configuration no longer generate; rebuild; xspec will remove out/specs/A.md (SPEC 14.10)", + "stale generated output: the recorded derived file out/specs/A.md is no longer generated; rebuilding removes it (SPEC 14.10)", + // xspec presented as the remover, beside a manual marker or alone. + `${STALE_HEAD}; xspec will remove out/specs/A.md, so you need not delete it by hand (SPEC 14.10)`, + `${STALE_HEAD}; it is removed by xspec, never by hand (SPEC 14.10)`, + `${STALE_HEAD}; xspec will then remove it (SPEC 14.10)`, + `${STALE_HEAD}; let xspec remove it (SPEC 14.10)`, + // A removal instruction whose means is a build. + `${STALE_HEAD}; remove it: run \`xspec build\` (SPEC 14.10)`, + `${STALE_HEAD}; remove it ${EM_DASH} run \`xspec build\` (SPEC 14.10)`, + `${STALE_HEAD}; remove it (by running \`xspec build\`)`, + `${STALE_HEAD}; remove out/specs/A.md by running \`xspec build\` (SPEC 14.10)`, + `${STALE_HEAD}; delete it using \`xspec build\` (SPEC 14.10)`, + `${STALE_HEAD}; remove it by rerunning the build (SPEC 14.10)`, + // A build offered as the alternative to the deletion. + `${STALE_HEAD}; delete it, or run \`xspec build\` (SPEC 14.10)`, + `${STALE_HEAD}; delete it by hand, or rebuild.`, + `${STALE_HEAD}; remove it by hand or by rerunning \`xspec build\` (SPEC 14.10)`, +]; + +/** Messages carrying no instruction to delete the file. */ +const MANUAL_DELETION_ABSENT: readonly string[] = [ + `${STALE_HEAD} (SPEC 14.10)`, + `${STALE_HEAD}; delete out/specs/A.md/specs/A.md, then rebuild (SPEC 14.10)`, + `${STALE_HEAD}; do not delete it (SPEC 14.10)`, +]; + +test("S-5: T13.4-10's correction judge accepts the manual deletion in either form, a manual marker or an instruction to the reader (H-3: information, never wording)", () => { + const mismatches = MANUAL_DELETION_ACCEPTED.flatMap(({ message, form }) => { + const verdict: ManualDeletionVerdict = judgeManualDeletionCorrection( + message, + OBSTRUCTING_ORPHAN, + ); + return verdict.verdict === "manual-deletion" && verdict.form === form + ? [] + : [{ message, expected: form, verdict }]; + }); + expect(mismatches).toEqual([]); +}); + +test("S-5: T13.4-10's correction judge rejects every clause presenting a build or xspec as what removes the file (never a rebuild)", () => { + const mismatches = MANUAL_DELETION_REBUILD_REMEDIES.flatMap((message) => { + const verdict = judgeManualDeletionCorrection(message, OBSTRUCTING_ORPHAN); + return verdict.verdict === "rebuild-remedy" ? [] : [{ message, verdict }]; + }); + expect(mismatches).toEqual([]); +}); + +test("S-5: T13.4-10's correction judge finds the correction absent from a message carrying no instruction to delete the file", () => { + const mismatches = MANUAL_DELETION_ABSENT.flatMap((message) => { + const verdict = judgeManualDeletionCorrection(message, OBSTRUCTING_ORPHAN); + return verdict.verdict === "absent" ? [] : [{ message, verdict }]; + }); + expect(mismatches).toEqual([]); +}); + test("S-5: ignored-reason classifier maps SPEC 8.2 reason spellings in order and rejects the unrecognizable", () => { // SPEC.md 8.2's own phrasings classify, order-preserving (the fixed order // is the tests' value assertion, T8.2-1). @@ -1365,6 +5315,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 +5443,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 +5476,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 +5564,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 +5595,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 00000000..181a1a9e --- /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 a30d435e..59a4f964 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 00000000..3322a96b --- /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 811aac67..29e96089 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 — @@ -19,6 +23,13 @@ // gains one (T3-4); // * in-line tags are transparent annotations (T3-5; SPEC.md 3's own // example); +// * the refined construct forms P-2 composes: JavaScript comments beside +// an import in its ESM block are content and a spelled `;` goes with +// the declaration (T3-7); every comment form of 2.7 — `{}`, +// block-comment sequences, line-comment containers, the run-on +// `{// c}` form, ECMAScript-only whitespace between braces — is +// removed whole (T2.7-4); an embedding with whitespace and comments +// beside its call is replaced whole (T2.3-3); // plus misuse guards: degenerate construct pieces and bad spans throw plain // errors (harness defects), never diagnosed product failures. @@ -104,6 +115,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", () => { @@ -330,6 +398,175 @@ test('S-6 (T3-5): <S id="a">Example:</S><S id="b">1. A</S> strips to Example:1. expectCompiled(pieces, "Example:1. A"); }); +// --- refined construct forms (T3-7, T2.7-4, T2.3-3; the forms P-2 composes) -- + +// Characters ECMAScript counts as whitespace or line terminators between +// braces (SPEC.md 14.20) but SPEC.md 1.4 does not — spelled from code points. +const NBSP = String.fromCodePoint(0x00a0); +const BOM = String.fromCodePoint(0xfeff); +const LS = String.fromCodePoint(0x2028); +const PS = String.fromCodePoint(0x2029); + +test("S-6 (T3-7): an import's removal is its declaration's characters alone — a JavaScript comment beside it in its ESM block is content that stays", () => { + // A line comment after the import: the line keeps ` // note` and its + // terminator, not left whitespace-only. + expectCompiled( + [ + removal('import A from "./A.xspec"'), + content(" // note\n"), + removal('import B from "./B.xspec"'), + content("\n\nBody.\n"), + ], + " // note\n\nBody.\n", + ); + // An own-line `// note` between two imports of one block: the import + // lines drop, the comment line survives with its terminator. + expectCompiled( + [ + removal('import A from "./A.xspec"'), + content("\n// note\n"), + removal('import B from "./B.xspec"'), + content("\n\nBody.\n"), + ], + "// note\n\nBody.\n", + ); + // A block comment preceding an import on the block's second line: the + // line keeps `/* c */ ` and its terminator. + expectCompiled( + [ + removal('import A from "./A.xspec"'), + content("\n/* c */ "), + removal('import B from "./B.xspec"'), + content("\n\nBody.\n"), + ], + "/* c */ \n\nBody.\n", + ); + // A block comment after the import on its line. + expectCompiled( + [removal('import A from "./A.xspec"'), content(" /* c */\n\nBody.\n")], + " /* c */\n\nBody.\n", + ); + // Every form in one block over lone-CR terminators. + expectCompiled( + [ + removal('import A from "./A.xspec";'), + content(" // note\r// note\r/* c */ "), + removal('import B from "./B.xspec"'), + content(" /* c */\r\rBody."), + ], + " // note\r// note\r/* c */ /* c */\r\rBody.", + ); +}); + +test("S-6 (T3-7, T11.4-4): a spelled `;` is among the import declaration's own characters — removed with it, the line dropped as left empty purely by the removal", () => { + expectCompiled( + [removal('import C from "./C.xspec";'), content("\n\nBody.\n")], + "\nBody.\n", + ); + expectCompiled( + [ + removal('import A from "./A.xspec";'), + content("\n"), + removal('import B from "./B.xspec";'), + content("\n\nBody.\n"), + ], + "\nBody.\n", + ); +}); + +test("S-6 (T2.7-4): every comment form of 2.7 is removed whole, opening brace through closing brace — `{}`, block-comment sequences, ECMAScript-only whitespace between braces", () => { + expectCompiled([content("A\n"), removal("{}"), content("\nB\n")], "A\nB\n"); + expectCompiled( + [content("x "), removal("{ /* a */ /* b */ }"), content(" y\n")], + "x y\n", + ); + // U+00A0, U+FEFF, U+2028, U+2029 between the braces: comments likewise; a + // boundary-code-point residue beside one is no 1.4 whitespace (the line + // is kept), a 1.4-whitespace residue is (the line drops). + for (const inner of [NBSP, BOM, LS, PS, ` ${NBSP}${LS} `]) { + expectCompiled( + [content("A\n"), removal(`{${inner}}`), content("\nB\n")], + "A\nB\n", + ); + expectCompiled([removal(`{${inner}}`), content(`${NBSP}\n`)], `${NBSP}\n`); + expectCompiled([removal(`{${inner}}`), content(" \n")], ""); + } +}); + +test("S-6 (T2.7-4): line-comment containers — ended by U+000A or U+000D before the closing brace, and the run-on `{// c}` form — are multi-line constructs removed under the merge-and-drop rule", () => { + for (const terminator of ["\n", "\r\n", "\r"]) { + for (const container of [ + `{// c${terminator}}`, + `{// c}${terminator}}`, + `{// a${terminator}// b${terminator}}`, + `{/* a */ // c${terminator}}`, + `{// c${terminator}/* b */}`, + ]) { + // Own line: the two source lines merge into one, left empty purely + // by the removal, dropped with its terminator. + expectCompiled( + [content("A\n"), removal(container), content("\nB\n")], + "A\nB\n", + ); + // Residues on both source lines join into one kept line; the + // construct's interior terminator is never reintroduced. + expectCompiled( + [content("lead "), removal(container), content(" tail\n")], + "lead tail\n", + ); + // A boundary-code-point residue after the closing brace keeps the + // merged line (1.4); a whitespace residue does not. + expectCompiled([removal(container), content(`${NBSP}\n`)], `${NBSP}\n`); + expectCompiled([removal(container), content(" \n")], ""); + } + } +}); + +test("S-6 (T2.3-3): an embedding is replaced whole, whatever whitespace and comments stand beside the call — comment, whitespace, and interior terminator included", () => { + for (const container of [ + '{ text("a") }', + '{/* n */ text("a")}', + '{text("a") /* n */}', + `{${NBSP}text(M1.a)${LS}}`, + ]) { + expectCompiled( + [content("p "), embedding(container, "X"), content(" q\n")], + "p X q\n", + ); + } + for (const terminator of ["\n", "\r\n", "\r"]) { + for (const container of [ + `{// n${terminator}text("a")}`, + `{// c}${terminator}text("a")}`, + `{text("a") // n${terminator}}`, + ]) { + // In line: the lines the container spans merge, the expansion joining + // the residues. + expectCompiled( + [content("lead "), embedding(container, "X"), content(" tail\n")], + "lead X tail\n", + ); + // Own line: the merged line holds the expansion alone. + expectCompiled( + [content("A\n"), embedding(container, "X\nY"), content("\nB\n")], + "A\nX\nY\nB\n", + ); + // An empty expansion leaves the merged line empty: dropped with the + // terminator after the container, its own having gone with it. + expectCompiled( + [content("A\n"), embedding(container, ""), content("\nB\n")], + "A\nB\n", + ); + } + } + // Under lone-CR terminators the container's own U+000D goes with it + // while the surrounding ones stay. + expectCompiled( + [content("A\r"), embedding('{// n\rtext("a")}', "X"), content("\rB")], + "A\rX\rB", + ); +}); + // --- trivial documents ------------------------------------------------------- test("S-6: empty and construct-only documents compile to empty output", () => { diff --git a/test/self/s6-name-analysis.test.ts b/test/self/s6-name-analysis.test.ts new file mode 100644 index 00000000..8dbe7a3e --- /dev/null +++ b/test/self/s6-name-analysis.test.ts @@ -0,0 +1,491 @@ +// S-6 name-analysis vectors (TEST-SPEC 17 S-6; T6.5-22): the analysis behind +// T6.5-22(a)'s universal assertion (test/helpers/oracles/name-analysis.ts) — +// which names a receiving file declares, in any scope and at value or type +// level, which it references, and which SPEC 6.5 bars there — passes this +// fixed vector suite before any test that adds an import trusts it, P-5's +// draws included. Each vector is a receiving file built from T6.5-22's +// stagings with candidate names and the verdict the analysis must reach on +// each, in both directions, hand-computed; no product is involved. +// Certification reaches the analysis only through the violators that target +// T6.5-22, so each class's verdict is checked here directly: +// * counted — `helper` declared only in a function body and, separately, +// only as a type; `Record` in a type annotation; the undeclared global +// `test` of a call; `Foo` of `<Foo />`; `x` of `x.foo`; `t` of +// `{ text as t }`; +// * not counted — `div` of `<div />`, `foo` of `x.foo`, a label, and the +// `text` that `{ text as t }` spells for the other module; +// * barred in a TSX file, the factory name of each pragma T6.5-22(b) +// stages (`h` of `/** @jsx h */`, `/* @JSX h */`, `// @jsx h`, and an +// in-function `/** @jsx h */`; `preact` of `/** @jsx preact.h */`; +// `Frag` of `/** @jsxFrag Frag */`); +// * barred in every kind of file, though it neither declares nor +// references them, each name T6.5-22's constraint list spells or ranges +// over, and a `__`-prefixed name; +// * `React`, barred in both of T6.5-22(b)'s `.tsx` receivers and in +// neither a spec source nor a `.ts` file; `S`, `Spec`, and `text`, +// barred in a spec source and not in a code source; +// so that each name T6.5-22(b)'s lures target is a vector in every kind of +// file its lures stage. The counted and not-counted classes are read in a +// spec source too where its grammar spells them: a spec source's names come +// from its ESTree, a code source's from TypeScript's tree, two readings each +// held to its own vectors. Every receiver is well-formed under its grammar +// (S-9), as T6.5-22's are; the analysis reads no other file. + +import { describe, expect, test } from "vitest"; +import { deriveMdx } from "../helpers/mdx-derivability.js"; +import { + addedIdentifierBreaches, + analyzeNames, + nameVerdict, + type NameVerdict, + type ReceivingFileKind, +} from "../helpers/oracles/name-analysis.js"; +import { judgeTypeScript } from "../helpers/ts-derivability.js"; + +// The constraint list's names, written out here from ECMAScript 2024 and +// T6.5-22 independently of the analysis's own tables. + +/** ECMAScript 2024's reserved words (12.7.2). */ +// prettier-ignore +const ES2024_RESERVED_WORDS = [ + "await", "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "enum", "export", "extends", "false", + "finally", "for", "function", "if", "import", "in", "instanceof", "new", + "null", "return", "super", "switch", "this", "throw", "true", "try", + "typeof", "var", "void", "while", "with", "yield", +]; + +/** The strict-mode-barred words T6.5-22 lists. */ +// prettier-ignore +const STRICT_MODE_WORDS = [ + "let", "static", "implements", "interface", "package", "private", + "protected", "public", "eval", "arguments", +]; + +/** Clause 19's global object properties: value, function, constructor, and + * other properties — the constructors from `AggregateError` through + * `WeakSet`. */ +// prettier-ignore +const CLAUSE_19_PROPERTIES = [ + "globalThis", "Infinity", "NaN", "undefined", + "eval", "isFinite", "isNaN", "parseFloat", "parseInt", + "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent", + "AggregateError", "Array", "ArrayBuffer", "BigInt", "BigInt64Array", + "BigUint64Array", "Boolean", "DataView", "Date", "Error", "EvalError", + "FinalizationRegistry", "Float32Array", "Float64Array", "Function", + "Int8Array", "Int16Array", "Int32Array", "Map", "Number", "Object", + "Promise", "Proxy", "RangeError", "ReferenceError", "RegExp", "Set", + "SharedArrayBuffer", "String", "Symbol", "SyntaxError", "TypeError", + "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", + "URIError", "WeakMap", "WeakRef", "WeakSet", + "Atomics", "JSON", "Math", "Reflect", +]; + +const CONSTRAINT_LIST_NAMES = [ + ...ES2024_RESERVED_WORDS, + ...STRICT_MODE_WORDS, + "require", + "exports", + ...CLAUSE_19_PROPERTIES, + "escape", + "unescape", + "Iterator", + "AsyncIterator", + "SuppressedError", + "__x", +]; + +/** A candidate's expected verdict: declared, referenced, barred — each + * false unless named. */ +interface Expected { + readonly declared?: true; + readonly referenced?: true; + readonly barred?: true; +} + +interface Receiver { + readonly label: string; + readonly kind: ReceivingFileKind; + /** The receiving file's name, by which its grammar is chosen (14.20). */ + readonly name: string; + readonly text: string; + /** The candidates this receiver's own vectors name. */ + readonly candidates: Readonly<Record<string, Expected>>; +} + +const lines = (...parts: string[]): string => parts.join("\n") + "\n"; + +// The receivers. Each `.tsx` receiver carries JSX where T6.5-22(b)'s does. +const RECEIVERS: readonly Receiver[] = [ + { + label: "the spec source a lure's section move adds an import to", + kind: "spec-source", + name: "specs/host.mdx", + text: lines( + 'import A from "./a.xspec"', + "", + '<S id="host" d={A.a}>', + "Host text, quoting {text(A.a)}.", + "</S>", + ), + candidates: { + A: { declared: true, referenced: true }, + a: {}, + d: {}, + S: { referenced: true, barred: true }, + Spec: { barred: true }, + text: { referenced: true, barred: true }, + React: {}, + }, + }, + { + label: "a spec source spelling each counted and uncounted class", + kind: "spec-source", + name: "specs/classes.mdx", + text: lines( + 'import A from "./a.xspec"', + 'import { text as t } from "./b.xspec"', + "export function g() { const helper = 1; return helper }", + "export function spin() { L: for (;;) break L }", + 'export const r = test("adds", () => x.foo)', + "", + '<S id="host">', + "Host text.", + "</S>", + "", + "<Foo />", + "", + "<div />", + ), + candidates: { + A: { declared: true }, + t: { declared: true }, + text: { barred: true }, + g: { declared: true }, + helper: { declared: true, referenced: true }, + spin: { declared: true }, + L: {}, + r: { declared: true }, + test: { referenced: true }, + x: { referenced: true }, + foo: {}, + S: { referenced: true, barred: true }, + Spec: { barred: true }, + Foo: { referenced: true }, + div: {}, + React: {}, + }, + }, + { + label: "the .ts code source a lure's section move adds an import to", + kind: "typescript", + name: "src/host.ts", + text: lines( + "export function area(width: number, height: number): number {", + " return width * height;", + "}", + ), + candidates: { + area: { declared: true }, + width: { declared: true, referenced: true }, + height: { declared: true, referenced: true }, + S: {}, + Spec: {}, + text: {}, + React: {}, + }, + }, + { + label: "a .ts receiver declaring helper only inside a function", + kind: "typescript", + name: "src/helper-fn.ts", + text: lines("function g() { const helper = 1; return helper }"), + candidates: { helper: { declared: true, referenced: true } }, + }, + { + label: "a .ts receiver declaring helper only as a type", + kind: "typescript", + name: "src/helper-type.ts", + text: lines("type helper = number"), + candidates: { helper: { declared: true } }, + }, + { + label: "a .ts receiver whose only mention of Record is a type annotation", + kind: "typescript", + name: "src/record.ts", + text: lines("let r: Record<string, number> = {}"), + candidates: { Record: { referenced: true }, r: { declared: true } }, + }, + { + label: "a .ts receiver calling an undeclared global test(…)", + kind: "typescript", + name: "src/test-call.ts", + text: lines('test("adds", () => {})'), + candidates: { test: { referenced: true } }, + }, + { + label: "a .ts receiver spelling x.foo, a label, and { text as t }", + kind: "typescript", + name: "src/members.ts", + text: lines( + 'import { text as t } from "../specs/a.xspec";', + "", + "export const y = x.foo;", + "export function spin(): void {", + " L: for (;;) break L;", + "}", + ), + candidates: { + t: { declared: true }, + text: {}, + y: { declared: true }, + x: { referenced: true }, + foo: {}, + spin: { declared: true }, + L: {}, + }, + }, + { + label: "the .tsx receiver of specs/React.mdx holding classic-runtime JSX", + kind: "tsx", + name: "src/view.tsx", + text: lines("export const view = <div />;"), + candidates: { + view: { declared: true }, + div: {}, + React: { barred: true }, + h: {}, + S: {}, + Spec: {}, + text: {}, + }, + }, + { + label: "the .tsx receiver of specs/React.mdx holding no JSX", + kind: "tsx", + name: "src/plain.tsx", + text: lines("export const plain = 1;"), + candidates: { plain: { declared: true }, React: { barred: true } }, + }, + { + label: "a .tsx receiver spelling <Foo /> and <div />", + kind: "tsx", + name: "src/both.tsx", + text: lines("export const both = [<Foo />, <div />];"), + candidates: { + both: { declared: true }, + Foo: { referenced: true }, + div: {}, + React: { barred: true }, + }, + }, + { + label: "a .tsx receiver of specs/h.mdx carrying /** @jsx h */", + kind: "tsx", + name: "src/jsx-doc.tsx", + text: lines("/** @jsx h */", "export const view = <div />;"), + candidates: { h: { barred: true }, React: { barred: true } }, + }, + { + label: "a .tsx receiver of specs/h.mdx carrying /* @JSX h */", + kind: "tsx", + name: "src/jsx-upper.tsx", + text: lines("/* @JSX h */", "export const view = <div />;"), + candidates: { h: { barred: true }, React: { barred: true } }, + }, + { + label: "a .tsx receiver of specs/preact.mdx carrying /** @jsx preact.h */", + kind: "tsx", + name: "src/jsx-preact.tsx", + text: lines("/** @jsx preact.h */", "export const view = <div />;"), + candidates: { preact: { barred: true }, h: {}, React: { barred: true } }, + }, + { + label: + "a .tsx receiver of specs/Frag.mdx carrying /** @jsxFrag Frag */ alone", + kind: "tsx", + name: "src/jsx-frag.tsx", + text: lines("/** @jsxFrag Frag */", "export const view = <></>;"), + candidates: { Frag: { barred: true }, React: { barred: true } }, + }, + { + label: "a .tsx receiver of specs/h.mdx carrying the line comment // @jsx h", + kind: "tsx", + name: "src/jsx-line.tsx", + text: lines("// @jsx h", "export const view = <div />;"), + candidates: { h: { barred: true }, React: { barred: true } }, + }, + { + label: + "a .tsx receiver of specs/h.mdx carrying /** @jsx h */ inside a function body, after the first statement", + kind: "tsx", + name: "src/jsx-inner.tsx", + text: lines( + "export const first = 1;", + "export function render() {", + " /** @jsx h */", + " return <div />;", + "}", + ), + candidates: { + first: { declared: true }, + render: { declared: true }, + h: { barred: true }, + React: { barred: true }, + }, + }, +]; + +function expectedVerdict(expected: Expected): { + readonly declared: boolean; + readonly referenced: boolean; + readonly barred: boolean; +} { + return { + declared: expected.declared === true, + referenced: expected.referenced === true, + barred: expected.barred === true, + }; +} + +function actualVerdict(verdict: NameVerdict): { + readonly declared: boolean; + readonly referenced: boolean; + readonly barred: boolean; +} { + return { + declared: verdict.declared, + referenced: verdict.referenced, + barred: verdict.barred !== undefined, + }; +} + +describe("S-6 name analysis: T6.5-22's receivers", () => { + test.each(RECEIVERS.map((receiver) => [receiver.label, receiver] as const))( + "%s is well-formed under its grammar (14.20, S-9)", + (_label, receiver) => { + if (receiver.kind === "spec-source") { + expect(deriveMdx(receiver.text)).toEqual({ derives: true }); + } else { + expect(judgeTypeScript(receiver.text, receiver.name)).toEqual({ + verdict: "well-formed", + }); + } + }, + ); + + test.each(RECEIVERS.map((receiver) => [receiver.label, receiver] as const))( + "%s: each candidate's verdict", + (_label, receiver) => { + const analysis = analyzeNames(receiver.kind, receiver.text); + const wrong = Object.entries(receiver.candidates).flatMap( + ([name, expected]) => { + const actual = actualVerdict(nameVerdict(analysis, name)); + const want = expectedVerdict(expected); + return JSON.stringify(actual) === JSON.stringify(want) + ? [] + : [{ name, actual, want }]; + }, + ); + expect(wrong).toEqual([]); + }, + ); + + test.each(RECEIVERS.map((receiver) => [receiver.label, receiver] as const))( + "%s: every constraint-list name is barred there, neither declared nor referenced", + (_label, receiver) => { + const analysis = analyzeNames(receiver.kind, receiver.text); + const wrong = CONSTRAINT_LIST_NAMES.filter((name) => { + const verdict = nameVerdict(analysis, name); + return ( + verdict.barred === undefined || verdict.declared || verdict.referenced + ); + }); + expect(wrong).toEqual([]); + }, + ); + + test.each(RECEIVERS.map((receiver) => [receiver.label, receiver] as const))( + "%s: React is barred exactly in a TSX source; S, Spec, and text exactly in a spec source", + (_label, receiver) => { + const analysis = analyzeNames(receiver.kind, receiver.text); + expect(nameVerdict(analysis, "React").barred !== undefined).toBe( + receiver.kind === "tsx", + ); + for (const name of ["S", "Spec", "text"]) { + expect(nameVerdict(analysis, name).barred !== undefined).toBe( + receiver.kind === "spec-source", + ); + } + }, + ); +}); + +describe("S-6 name analysis: T6.5-22(a)'s verdict on added identifiers", () => { + const analysisOf = (name: string) => { + const receiver = RECEIVERS.find((entry) => entry.name === name); + if (receiver === undefined) throw new Error(`no receiver ${name}`); + return analyzeNames(receiver.kind, receiver.text); + }; + + test("fresh, admitted, distinct identifiers breach nothing", () => { + expect( + addedIdentifierBreaches(analysisOf("src/host.ts"), ["A", "B"]), + ).toEqual([]); + expect( + addedIdentifierBreaches(analysisOf("specs/host.mdx"), ["B", "React"]), + ).toEqual([]); + }); + + test("each clause is named for the identifier breaching it", () => { + const clauses = (name: string, added: readonly string[]) => + addedIdentifierBreaches(analysisOf(name), added).map( + ({ identifier, clause }) => [identifier, clause.split(":")[0]], + ); + expect(clauses("src/host.ts", ["let", "__x", "Object"])).toEqual([ + ["let", "barred"], + ["__x", "barred"], + ["Object", "barred"], + ]); + expect(clauses("src/helper-type.ts", ["helper"])).toEqual([ + [ + "helper", + "bound by a declaration of the pre-operation file (in some scope, at value or type level)", + ], + ]); + expect(clauses("src/record.ts", ["Record"])).toEqual([ + [ + "Record", + "equal to a name the pre-operation file references (at value or type level)", + ], + ]); + expect(clauses("src/host.ts", ["A", "A"])).toEqual([ + ["A", "not distinct from another identifier added to the file"], + ]); + expect(clauses("specs/host.mdx", ["text"])).toEqual([ + ["text", "barred"], + [ + "text", + "equal to a name the pre-operation file references (at value or type level)", + ], + ]); + expect(clauses("src/jsx-line.tsx", ["h", "React"])).toEqual([ + ["h", "barred"], + ["React", "barred"], + ]); + }); +}); + +describe("S-6 name analysis: a file outside its grammar is never read", () => { + test("a spec source that does not derive is a harness error", () => { + expect(() => analyzeNames("spec-source", "<S>\n")).toThrow( + /S-9's MDX parse: no tree is read/, + ); + }); + + test("a code source that is not well-formed is a harness error", () => { + expect(() => analyzeNames("typescript", "let = ;\n")).toThrow( + /S-6's name analysis: no names are read/, + ); + }); +}); 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 00000000..dab203bf --- /dev/null +++ b/test/self/s6-section-move-oracle.test.ts @@ -0,0 +1,1643 @@ +// 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, the clean-boundary case and sibling stagings of +// TEST-SPEC T6.2-3, and T6.2-4's final-position shapes, 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). Every vector's composed documents, before and +// after the move, are checked to derive under the harness's own S-9 check +// (`deriveMdx`): the form vectors derive (S-9). +// +// 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, in the +// suite's I3 staging): the moved section's opening tag preceded on its +// origin line by non-whitespace, its closing tag preceded on its line by +// spaces and followed by non-whitespace — the opening line's terminator +// and the closing line's spaces contribute at the origin (lines kept) +// and not at the destination (lines 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-3's three impure stagings — (a) the worked shape exactly as +// spelled, (b) its both-sided U+000B/U+000C spelling, (c) the `body</S>` +// variant with such a remainder — each moved to another file's top +// level and into a flow-position parent: the moved node `changed` by +// its boundary lines, the two parents `changed`, no other node; +// * T6.2-4's final-position cases: a parent's last child moved onto +// itself, in the flow-form pinned shape and in T6.5-13(f)'s top-level +// shape, reproduces the parent's sequence — no node changes, no +// categories; its `changed` twin (T6.5-13(e)'s shape): the coincident +// parent alone `changed`, the moved node and the root keeping their +// content; and a non-final child re-inserted at the end, which 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); +// * 6.2's enumeration beyond the parents and the moved subtree — each +// other node with own-content bytes on a line the deletion joins or +// drops or the insertion splits, `changed` iff the drop rule of 3 +// decides that line differently: T6.2-3's sibling stagings (d) (a +// sibling's whitespace residue left alone on the deletion's merged +// line — kept before, dropped after) and (e) (a sibling's U+000C +// residue left alone on the line the insertion splits), a residue the +// deletion joins to prose (dropped before, kept after), and a +// non-parent ancestor whose whitespace lead rides the merged line; +// * a section tag spanning lines: its internal terminator deleted with +// the construct, the lines joined (SPEC 3); +// plus misuse guards: degenerate constructs and incomplete graphs throw +// plain errors (harness defects), never diagnosed product failures. + +import { expect, test } from "vitest"; +import { deriveMdx } from "../helpers/mdx-derivability.js"; +import { + predictSectionMoveImpact, + sectionMoveSourceText, +} from "../helpers/oracles/section-move.js"; +import type { + SectionMoveDocument, + SectionMoveGraphNode, + SectionMoveOwnToken, + SectionMovePiece, + SectionMovePrediction, +} from "../helpers/oracles/section-move.js"; + +// --- vector-side document builders ------------------------------------------- + +// U+000B and U+000C, built from code points (never escape spellings). +const VT = String.fromCharCode(0x0b); +const FF = String.fromCharCode(0x0c); + +const content = (text: string): SectionMovePiece => ({ kind: "content", text }); + +/** S-9: a vector's composed document derives under the harness's own check. */ +function expectDerives(label: string, text: string): void { + expect( + deriveMdx(text, { allowances: [] }), + `${label} must derive (S-9): ${JSON.stringify(text)}`, + ).toEqual({ derives: true }); +} + +/** 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` (a relocated cause). */ +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 origin = cleanOrigin(); + const target = cleanTarget(); + expectDerives("Origin before", sectionMoveSourceText(origin.pieces)); + expectDerives( + "Origin after", + '<S id="origin">\nOrigin holder text.\n\n</S>\n', + ); + expectDerives("Target before", sectionMoveSourceText(target.pieces)); + expectDerives( + "Target after", + '<S id="tgt">\nTarget parent text.\n<S id="tgt.mv" coverage="none" tags="keep mv">\nMoved root text.\n\n<S id="tgt.mv.kid">\nMoved kid text.\n</S>\n</S>\n</S>\n', + ); + + const prediction = predictSectionMoveImpact({ + origin, + target, + 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> Trailing prose.\n</S>\n — + // SPEC 6.2's worked case as the suite's T6.2-3 fixture stages it + // (`I3_ROOM_SOURCE`): the moved section's opening tag preceded on its + // line by non-whitespace and followed there by nothing, its closing tag + // preceded on its line by two spaces and followed there by non-whitespace + // — both tags in text position, so the shape derives (S-9), which the + // former relative with its closing tag alone on its line did not. + 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(" Trailing prose.\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 opening line's terminator and the closing line's spaces at the origin, not at the destination, and is itself changed", () => { + const origin = impureRoom(); + const target = impureHall(); + expectDerives("Room before", sectionMoveSourceText(origin.pieces)); + expectDerives( + "Room after", + '<S id="op">\nOp holder text.\n\nLead-in prose. Trailing prose.\n</S>\n', + ); + expectDerives("Hall before", sectionMoveSourceText(target.pieces)); + expectDerives( + "Hall after", + '<S id="tp">\nHall parent text.\n<S id="tp.imp" coverage="none" tags="edge imp">\nImpure line one.\nImpure line two.\n </S>\n</S>\n', + ); + + const prediction = predictSectionMoveImpact({ + origin, + target, + 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 both boundary lines are kept (`Lead-in prose. `, ` Trailing + // prose.`), so the opening line's terminator and the closing line's two + // spaces contribute; at the destination each tag is alone on its line and + // both lines drop. + 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 joined line — the lead-in and trailing + // prose with the terminator — after the deletion. + expect(prediction.afterOwnTokens.get(I_OP)).toEqual([ + ["run", "Op holder text.\n\nLead-in prose. Trailing 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) }, + }); +}); + +// ============================================================================= +// A further final-position shape — the former T6.2-4 fixture (blank lines, +// `coverage` and `tags`; a flow-form last child beyond the pinned ones, its +// re-insertion reproducing the parent's sequence all the same) — 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 further final-position shape): a parent's last child moved onto itself reproduces the parent's sequence — no node changed, no categories", () => { + const document = pDoc(); + expectDerives("P before", sectionMoveSourceText(document.pieces)); + expectDerives( + "P after", + '<S id="p">\nParent text.\n\n<S id="p.first">\nFirst child text.\n</S>\n\n<S id="p.final" coverage="none" tags="tail">\nTail child text.\n</S>\n</S>\n', + ); + 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(); + expectDerives("P before", sectionMoveSourceText(document.pieces)); + expectDerives( + "P after", + '<S id="p">\nParent text.\n\n\n<S id="p.last" coverage="none" tags="tail">\nTail child text.\n</S>\n<S id="p.zeta">\nFirst child text.\n</S>\n</S>\n', + ); + 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"]: {}, + }); +}); + +// ============================================================================= +// T6.2-4's pinned shapes — (1) the flow-form last child, (2) T6.5-13(f)'s +// top-level shape — and its `changed` twin (T6.5-13(e)): 6.2's `may` on +// both of its sides +// ============================================================================= + +const G_A = "specs/ga.mdx"; +const G_AP = "specs/ga.mdx#p"; +const G_APM = "specs/ga.mdx#p.m"; +const G_APN = "specs/ga.mdx#p.n"; + +test("S-6 (T6.2-4, pinned shape (1)): a flow-form last child, its tags and its parent's closing tag alone on their lines, moved onto its own final position reproduces the coincident parent's sequence — no node changed, no categories", () => { + // `<S id="p">`, U+000A, `<S id="p.m">`, U+000A, `y`, U+000A, `</S>`, + // U+000A, `</S>`, U+000A under `move specs/ga.mdx#p.m specs/ga.mdx#p.n`: + // the deletion removes exactly the construct's lines (the joined line it + // leaves empty dropping with line 4's terminator), leaving `<S id="p">`, + // U+000A, `</S>`, U+000A, and the insertion before that `</S>`, at a line + // start (no terminator added), restores them — the composed file + // byte-identical but for the `id` attribute. Both of `p`'s runs are empty + // at both sides (its tags' lines and the child's closing tag's line each + // dropped, 3), so its sequence is reproduced: no hash changes, no node + // carries any category. + const document = doc(G_A, [ + sec("p", "", [ + content("\n"), + sec("p.m", "", [content("\ny\n")]), + content("\n"), + ]), + content("\n"), + ]); + expectDerives("ga before", sectionMoveSourceText(document.pieces)); + expectDerives("ga after", '<S id="p">\n<S id="p.n">\ny\n</S>\n</S>\n'); + + const prediction = predictSectionMoveImpact({ + origin: document, + target: document, + movedId: "p.m", + newId: "p.n", + }); + + expect(Object.fromEntries(prediction.identityMap)).toEqual({ + [G_APM]: G_APN, + }); + expect(prediction.beforeOwnTokens.get(G_AP)).toEqual([ + ["run", ""], + ["child", G_APM], + ["run", ""], + ]); + expect(prediction.afterOwnTokens.get(G_AP)).toEqual([ + ["run", ""], + ["child", G_APN], + ["run", ""], + ]); + const rootTokens = [ + ["run", ""], + ["child", G_AP], + ["run", ""], + ]; + expect(prediction.beforeOwnTokens.get(G_A)).toEqual(rootTokens); + expect(prediction.afterOwnTokens.get(G_A)).toEqual(rootTokens); + expect(prediction.beforeOwnTokens.get(G_APM)).toEqual([["run", "y\n"]]); + expect(prediction.afterOwnTokens.get(G_APN)).toEqual([["run", "y\n"]]); + expect(sortedSet(prediction.changed)).toEqual([]); + expect(sortedSet(prediction.added)).toEqual([]); + expect(tableOf(prediction)).toEqual({ + [G_A]: {}, + [G_AP]: {}, + [G_APN]: {}, + }); +}); + +const F_A = "specs/fa.mdx"; +const F_AA = "specs/fa.mdx#a"; +const F_AM = "specs/fa.mdx#m"; +const F_AN = "specs/fa.mdx#n"; + +test("S-6 (T6.2-4, T6.5-13(f)): the file's unterminated last section moved onto its own top-level position reproduces the root's sequence — no node changed, no categories", () => { + // `<S id="a">x</S>`, U+000A, `<S id="m">`, U+000A, `y`, U+000A, `</S>` + // with no final terminator, `move specs/fa.mdx#m specs/fa.mdx#n`: the + // deletion leaves `<S id="a">x</S>`, U+000A — the joined line it leaves + // empty dropping, there being no terminator to drop with it — so the + // insertion at the file's end is at a line start, no terminator added, + // and the result is `<S id="a">x</S>`, U+000A, `<S id="n">`, U+000A, `y`, + // U+000A, `</S>`, U+000A. The root, origin and target parent at once, + // keeps its own content — U+000A at both sides: line 1's terminator after + // `a`'s excised contribution on that kept line, the construct's own lines + // dropped (3) — the re-inserted child entering by its canonical identity + // (5.4): no hash changes, no node carries any category. + const document = doc(F_A, [ + sec("a", "", [content("x")]), + content("\n"), + sec("m", "", [content("\ny\n")]), + ]); + expectDerives("fa before", sectionMoveSourceText(document.pieces)); + expectDerives("fa after", '<S id="a">x</S>\n<S id="n">\ny\n</S>\n'); + + const prediction = predictSectionMoveImpact({ + origin: document, + target: document, + movedId: "m", + newId: "n", + }); + + expect(Object.fromEntries(prediction.identityMap)).toEqual({ [F_AM]: F_AN }); + expect(prediction.beforeOwnTokens.get(F_A)).toEqual([ + ["run", ""], + ["child", F_AA], + ["run", "\n"], + ["child", F_AM], + ["run", ""], + ]); + expect(prediction.afterOwnTokens.get(F_A)).toEqual([ + ["run", ""], + ["child", F_AA], + ["run", "\n"], + ["child", F_AN], + ["run", ""], + ]); + expect(prediction.beforeOwnTokens.get(F_AM)).toEqual([["run", "y\n"]]); + expect(prediction.afterOwnTokens.get(F_AN)).toEqual([["run", "y\n"]]); + expect(sortedSet(prediction.changed)).toEqual([]); + expect(sortedSet(prediction.added)).toEqual([]); + expect(tableOf(prediction)).toEqual({ + [F_A]: {}, + [F_AA]: {}, + [F_AN]: {}, + }); +}); + +const T_A = "specs/ta.mdx"; +const T_AP = "specs/ta.mdx#p"; +const T_APM = "specs/ta.mdx#p.m"; +const T_APN = "specs/ta.mdx#p.n"; + +test("S-6 (T6.2-4 changed twin, T6.5-13(e)): a last child re-inserted at its own position whose composed lines fail to reproduce the coincident parent's sequence — the parent alone is changed; the moved node and the root keep their content", () => { + // `foo <S id="p">`, U+000A, `<S id="p.m">x</S></S> baz`, U+000A under + // `move specs/ta.mdx#p.m specs/ta.mdx#p.n`: the deletion's range ends + // exactly at the insertion point, which line 1's terminator precedes in + // the composed text, so no terminator is added before the moved text and + // the result is `foo <S id="p">`, U+000A, `<S id="p.n">x</S>`, U+000A, + // `</S> baz`, U+000A. `p`'s run after its child was empty (its closing tag + // followed the child's at once) and is now the moved text's terminator, + // U+000A, on a kept line (`x`) — `p` `changed`, its cascade on the root; + // the moved node's `x` rides a kept line at both sides, and so do the + // root's `foo ` and ` baz`, U+000A: neither carries a category. + const document = doc(T_A, [ + content("foo "), + sec("p", "", [content("\n"), sec("p.m", "", [content("x")])]), + content(" baz\n"), + ]); + expectDerives("ta before", sectionMoveSourceText(document.pieces)); + expectDerives("ta after", 'foo <S id="p">\n<S id="p.n">x</S>\n</S> baz\n'); + + const prediction = predictSectionMoveImpact({ + origin: document, + target: document, + movedId: "p.m", + newId: "p.n", + }); + + expect(Object.fromEntries(prediction.identityMap)).toEqual({ + [T_APM]: T_APN, + }); + expect(prediction.beforeOwnTokens.get(T_AP)).toEqual([ + ["run", "\n"], + ["child", T_APM], + ["run", ""], + ]); + expect(prediction.afterOwnTokens.get(T_AP)).toEqual([ + ["run", "\n"], + ["child", T_APN], + ["run", "\n"], + ]); + const rootTokens = [ + ["run", "foo "], + ["child", T_AP], + ["run", " baz\n"], + ]; + expect(prediction.beforeOwnTokens.get(T_A)).toEqual(rootTokens); + expect(prediction.afterOwnTokens.get(T_A)).toEqual(rootTokens); + expect(prediction.beforeOwnTokens.get(T_APM)).toEqual([["run", "x"]]); + expect(prediction.afterOwnTokens.get(T_APN)).toEqual([["run", "x"]]); + expect(sortedSet(prediction.changed)).toEqual([T_AP]); + expect(sortedSet(prediction.added)).toEqual([]); + expect(tableOf(prediction)).toEqual({ + [T_A]: { "descendant-changed": req(T_AP) }, + [T_AP]: { changed: chg([T_AP]) }, + [T_APN]: {}, + }); +}); + +// ============================================================================= +// 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 origin = impureRoom(); + expectDerives("Room before", sectionMoveSourceText(origin.pieces)); + expectDerives( + "New after", + '<S id="imp2" coverage="none" tags="edge imp">\nImpure line one.\nImpure line two.\n </S>\n', + ); + const prediction = predictSectionMoveImpact({ + origin, + 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"), + ]); + expectDerives("O before", sectionMoveSourceText(origin.pieces)); + expectDerives("O after", '<S id="op">\nOp text.\n</S>\n'); + expectDerives("H before", sectionMoveSourceText(target.pieces)); + expectDerives( + "H after", + '<S id="tp">\nHall parent text.\n<S id="tp.solo" />\n</S>\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")]); + expectDerives("O before", sectionMoveSourceText(origin.pieces)); + expectDerives("O after", ""); + expectDerives("H before", sectionMoveSourceText(target.pieces)); + expectDerives( + "H after", + '<S id="tp">\n<S id="tp.m">\nMoved body.\n</S>\n</S>\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")])]); + expectDerives("O before", sectionMoveSourceText(origin.pieces)); + expectDerives("O after", ""); + expectDerives("T before", sectionMoveSourceText(target.pieces)); + expectDerives("T after", '<S id="tp">x</S>\n<S id="z">\nM body.\n</S>\n'); + 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"), + ]); + // `{text(X)}<S id="op.mv">` at a line start derives as a flow expression + // followed by a flow-position tag (the stock grammar admits a tag directly + // after a flow expression on its line), which the closing tag alone on + // its line closes. + expectDerives("E before", sectionMoveSourceText(origin.pieces)); + expectDerives("E after", '<S id="op">\nOp text.\n\n{text(X)}\n</S>\n'); + expectDerives("H2 before", sectionMoveSourceText(target.pieces)); + expectDerives( + "H2 after", + '<S id="tp">\nHall text.\n<S id="tp.mv">\nBody.\n</S>\n</S>\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": {}, + }); +}); + +// ============================================================================= +// 6.2's enumeration beyond the parents and the moved subtree: a sibling +// whose residue the deletion joins to prose (dropped before, kept after) +// ============================================================================= + +const G = "specs/G.mdx"; +const G_P = "specs/G.mdx#p"; +const G_PX = "specs/G.mdx#p.x"; +const G_MV_PRE = "specs/G.mdx#p.mv"; +const H3 = "specs/H3.mdx"; +const H3_TP = "specs/H3.mdx#tp"; +const H3_MV = "specs/H3.mdx#tp.mv"; + +test("S-6 (6.2's enumeration): a sibling whose whitespace residue the deletion joins to prose — its line dropped before, kept after — is changed", () => { + // Origin `<S id="p">`, U+000A, `<S id="p.x"> </S><S id="p.mv">`, U+000C, + // U+000A, `M.`, U+000A, U+000C, `</S>tail`, U+000A, `</S>`, U+000A — the + // moved section in T6.2-3(b)'s both-sided spelling (its tags in text + // position at both sides), the sibling `p.x` sharing its opening line. + // Before, that line is left whitespace-only purely by removals (` ` and + // U+000C are 1.4 whitespace) and drops, so `p.x` contributes nothing; + // the deletion joins the residue `<S id="p.x"> </S>` to `tail`, a kept + // line, so its run ` ` appears — `p.x` is `changed` beside the parents. + // The moved node's lead U+000C rides a kept line at the origin and a + // dropped one at the destination, so it is `changed` too (T6.2-3(b)). + const origin = doc(G, [ + sec("p", "", [ + content("\n"), + sec("p.x", "", [content(" ")]), + sec("p.mv", "", [content(`${FF}\nM.\n${FF}`)]), + content("tail\n"), + ]), + content("\n"), + ]); + const target = doc(H3, [sec("tp", "", [content("\nT.\n")]), content("\n")]); + expectDerives("G before", sectionMoveSourceText(origin.pieces)); + expectDerives("G after", '<S id="p">\n<S id="p.x"> </S>tail\n</S>\n'); + expectDerives("H3 before", sectionMoveSourceText(target.pieces)); + expectDerives( + "H3 after", + `<S id="tp">\nT.\n<S id="tp.mv">${FF}\nM.\n${FF}</S>\n</S>\n`, + ); + + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "p.mv", + newId: "tp.mv", + }); + + expect(prediction.beforeOwnTokens.get(G_PX)).toEqual([["run", ""]]); + expect(prediction.afterOwnTokens.get(G_PX)).toEqual([["run", " "]]); + expect(prediction.beforeOwnTokens.get(G_MV_PRE)).toEqual([ + ["run", `M.\n${FF}`], + ]); + expect(prediction.afterOwnTokens.get(H3_MV)).toEqual([["run", "M.\n"]]); + expect(prediction.afterOwnTokens.get(G_P)).toEqual([ + ["run", ""], + ["child", G_PX], + ["run", "tail\n"], + ]); + + expect(sortedSet(prediction.changed)).toEqual([G_P, G_PX, H3_TP, H3_MV]); + expect(sortedSet(prediction.added)).toEqual([]); + + const changed = [G_P, G_PX, H3_TP, H3_MV]; + expect(tableOf(prediction)).toEqual({ + [G]: { + "descendant-changed": reqWithin([G_P, G_PX, H3_MV], [G_P, G_PX]), + }, + [G_P]: { + changed: chg(changed), + "descendant-changed": reqWithin([G_PX, H3_MV], [G_PX]), + }, + [G_PX]: { changed: chg(changed) }, + [H3]: { "descendant-changed": reqWithin([H3_TP, H3_MV], [H3_TP]) }, + [H3_TP]: { changed: chg(changed), "descendant-changed": opt(H3_MV) }, + [H3_MV]: { changed: chg(changed) }, + }); +}); + +// ============================================================================= +// T6.2-3's sibling stagings (d) and (e), and a non-parent ancestor: 6.2's +// enumeration reaching every node with bytes on a line the edits touch +// ============================================================================= + +const A = "specs/a.mdx"; +const A_P = "specs/a.mdx#p"; +const A_PS = "specs/a.mdx#p.s"; +const A_PM = "specs/a.mdx#p.m"; +const B = "specs/b.mdx"; +const B_K = "specs/b.mdx#k"; +const B_M = "specs/b.mdx#m"; + +test("S-6 (T6.2-3(d)): at the origin, a sibling's whitespace residue left alone on the deletion's merged line — kept before, dropped after — is changed; the moved node keeps its sequence", () => { + // Origin `<S id="p">`, U+000A, `<S id="p.s"> </S><S id="p.m">text</S>`, + // U+000A, `</S>`, U+000A; `move a.mdx#p.m b.mdx#m`, the target + // `<S id="k">z</S>`, U+000A. The second line, a paragraph of two in-line + // siblings, is kept before (`text` remaining once the tags are removed) + // and dropped after (`<S id="p.s"> </S>` alone, whitespace-only purely by + // removals), so `p.s` loses its run ` `; `p.m`'s `text` rides a kept + // line at both sides. + const origin = doc(A, [ + sec("p", "", [ + content("\n"), + sec("p.s", "", [content(" ")]), + sec("p.m", "", [content("text")]), + content("\n"), + ]), + content("\n"), + ]); + const target = doc(B, [sec("k", "", [content("z")]), content("\n")]); + expectDerives("a before", sectionMoveSourceText(origin.pieces)); + expectDerives("a after", '<S id="p">\n<S id="p.s"> </S>\n</S>\n'); + expectDerives("b before", sectionMoveSourceText(target.pieces)); + expectDerives("b after", '<S id="k">z</S>\n<S id="m">text</S>\n'); + + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "p.m", + newId: "m", + }); + + expect(prediction.beforeOwnTokens.get(A_PS)).toEqual([["run", " "]]); + expect(prediction.afterOwnTokens.get(A_PS)).toEqual([["run", ""]]); + expect(prediction.beforeOwnTokens.get(A_PM)).toEqual([["run", "text"]]); + expect(prediction.afterOwnTokens.get(B_M)).toEqual([["run", "text"]]); + expect(prediction.beforeOwnTokens.get(A_P)).toEqual([ + ["run", ""], + ["child", A_PS], + ["run", ""], + ["child", A_PM], + ["run", "\n"], + ]); + expect(prediction.afterOwnTokens.get(A_P)).toEqual([ + ["run", ""], + ["child", A_PS], + ["run", ""], + ]); + expect(prediction.afterOwnTokens.get(B)).toEqual([ + ["run", ""], + ["child", B_K], + ["run", "\n"], + ["child", B_M], + ["run", "\n"], + ]); + + expect(sortedSet(prediction.changed)).toEqual([A_P, A_PS, B]); + const changed = [A_P, A_PS, B]; + expect(tableOf(prediction)).toEqual({ + [A]: { "descendant-changed": req(A_P, A_PS) }, + [A_P]: { changed: chg(changed), "descendant-changed": req(A_PS) }, + [A_PS]: { changed: chg(changed) }, + [B]: { changed: chg(changed) }, + [B_K]: {}, + [B_M]: {}, + }); +}); + +const E_A = "specs/ea.mdx"; +const E_AA = "specs/ea.mdx#a"; +const E_B = "specs/eb.mdx"; +const E_BP = "specs/eb.mdx#p"; +const E_BPS = "specs/eb.mdx#p.s"; +const E_BPN = "specs/eb.mdx#p.n"; + +test("S-6 (T6.2-3(e)): at the destination, a sibling's U+000C residue left alone on the line the insertion splits — kept before, dropped after — is changed; the target root keeps its content", () => { + // Target `foo <S id="p">`, U+000A, `<S id="p.s">`, U+000C, `</S></S> tail`, + // U+000A receiving `<S id="m">text</S>` (alone on its origin line) into + // `p.n`: the insertion point, preceded by `p.s`'s closing tag, is not at + // a line start, so 6.5's added terminator splits the line, leaving + // `<S id="p.s">`, U+000C, `</S>` alone — dropped as whitespace-only under + // 1.4 — while `foo ` and ` tail` ride kept lines at both sides. + const origin = doc(E_A, [ + sec("a", "", [content("x")]), + content("\n"), + sec("m", "", [content("text")]), + content("\n"), + ]); + const target = doc(E_B, [ + content("foo "), + sec("p", "", [content("\n"), sec("p.s", "", [content(FF)])]), + content(" tail\n"), + ]); + expectDerives("ea before", sectionMoveSourceText(origin.pieces)); + expectDerives("ea after", '<S id="a">x</S>\n'); + expectDerives("eb before", sectionMoveSourceText(target.pieces)); + expectDerives( + "eb after", + `foo <S id="p">\n<S id="p.s">${FF}</S>\n<S id="p.n">text</S>\n</S> tail\n`, + ); + + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "m", + newId: "p.n", + }); + + expect(prediction.beforeOwnTokens.get(E_BPS)).toEqual([["run", FF]]); + expect(prediction.afterOwnTokens.get(E_BPS)).toEqual([["run", ""]]); + expect(prediction.afterOwnTokens.get(E_BPN)).toEqual([["run", "text"]]); + expect(prediction.beforeOwnTokens.get(E_B)).toEqual([ + ["run", "foo "], + ["child", E_BP], + ["run", " tail\n"], + ]); + expect(prediction.afterOwnTokens.get(E_B)).toEqual([ + ["run", "foo "], + ["child", E_BP], + ["run", " tail\n"], + ]); + expect(prediction.afterOwnTokens.get(E_BP)).toEqual([ + ["run", "\n"], + ["child", E_BPS], + ["run", ""], + ["child", E_BPN], + ["run", "\n"], + ]); + expect(prediction.afterOwnTokens.get(E_A)).toEqual([ + ["run", ""], + ["child", E_AA], + ["run", "\n"], + ]); + + expect(sortedSet(prediction.changed)).toEqual([E_A, E_BP, E_BPS]); + const changed = [E_A, E_BP, E_BPS]; + expect(tableOf(prediction)).toEqual({ + [E_A]: { changed: chg(changed) }, + [E_AA]: {}, + [E_B]: { "descendant-changed": req(E_BP, E_BPS) }, + [E_BP]: { changed: chg(changed), "descendant-changed": req(E_BPS) }, + [E_BPS]: { changed: chg(changed) }, + [E_BPN]: {}, + }); +}); + +const N_A = "specs/na.mdx"; +const N_G = "specs/na.mdx#g"; +const N_GP = "specs/na.mdx#g.p"; +const N_GPM = "specs/na.mdx#g.p.m"; +const N_B = "specs/nb.mdx"; +const N_BK = "specs/nb.mdx#k"; +const N_BM = "specs/nb.mdx#m"; + +test("S-6 (6.2's enumeration): a non-parent ancestor whose whitespace lead rides the deletion's merged line — kept before, dropped after — is changed", () => { + // Origin `<S id="g">`, U+000A, ` <S id="g.p"><S id="g.p.m">body`, U+000A, + // `lead</S></S>`, U+000A, `</S>`, U+000A: the moved section a multi-line + // in-line section with prose remainder and lead (both text-position), + // the parent `g.p` an in-line element holding nothing else, and the + // grandparent `g` owning the opening line's two-space lead and the + // closing line's terminator — on kept lines before (`body`, `lead`) and + // on the merged line ` <S id="g.p"></S>` after, whitespace-only purely + // by removals and dropped. Moved to `nb.mdx`'s top level, the moved node + // keeps its sequence: `body`, U+000A, `lead` ride kept lines there too. + const origin = doc(N_A, [ + sec("g", "", [ + content("\n "), + sec("g.p", "", [sec("g.p.m", "", [content("body\nlead")])]), + content("\n"), + ]), + content("\n"), + ]); + const target = doc(N_B, [sec("k", "", [content("z")]), content("\n")]); + expectDerives("na before", sectionMoveSourceText(origin.pieces)); + expectDerives("na after", '<S id="g">\n <S id="g.p"></S>\n</S>\n'); + expectDerives("nb before", sectionMoveSourceText(target.pieces)); + expectDerives("nb after", '<S id="k">z</S>\n<S id="m">body\nlead</S>\n'); + + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "g.p.m", + newId: "m", + }); + + expect(prediction.beforeOwnTokens.get(N_G)).toEqual([ + ["run", " "], + ["child", N_GP], + ["run", "\n"], + ]); + expect(prediction.afterOwnTokens.get(N_G)).toEqual([ + ["run", ""], + ["child", N_GP], + ["run", ""], + ]); + expect(prediction.afterOwnTokens.get(N_GP)).toEqual([["run", ""]]); + expect(prediction.beforeOwnTokens.get(N_GPM)).toEqual([ + ["run", "body\nlead"], + ]); + expect(prediction.afterOwnTokens.get(N_BM)).toEqual([["run", "body\nlead"]]); + + expect(sortedSet(prediction.changed)).toEqual([N_G, N_GP, N_B]); + const changed = [N_G, N_GP, N_B]; + expect(tableOf(prediction)).toEqual({ + [N_A]: { "descendant-changed": req(N_G, N_GP) }, + [N_G]: { changed: chg(changed), "descendant-changed": req(N_GP) }, + [N_GP]: { changed: chg(changed) }, + [N_B]: { changed: chg(changed) }, + [N_BK]: {}, + [N_BM]: {}, + }); +}); + +// ============================================================================= +// T6.2-3's three impure stagings, each moved to another file's top level and +// into a flow-position parent (S-6): 6.2's worked straddling-line shape (a) +// exactly as spelled, its both-sided U+000B/U+000C spelling (b), and the +// `body</S>` variant with such a remainder (c) +// ============================================================================= + +const C_A = "specs/ca.mdx"; +const C_AM = "specs/ca.mdx#m"; +const C_B = "specs/cb.mdx"; +const C_BK = "specs/cb.mdx#k"; +const C_BM = "specs/cb.mdx#m"; +const C_BP = "specs/cb.mdx#p"; +const C_BPM = "specs/cb.mdx#p.m"; + +/** + * One impure shape, staged at the origin file's top level as `foo <S id="m">` + * + `body` + `</S>` + `afterClose`, U+000A — the moved text `<S id="m">` + + * `body` + `</S>` landing at the destination's line start followed by U+000A + * (6.5). The runs are hand-derived from SPEC 3's drop rule, as each shape's + * comment spells. + */ +interface ImpureShape { + readonly tag: string; + readonly label: string; + /** The moved section's body: the bytes between its tags. */ + readonly body: string; + /** What follows the closing tag on its origin line. */ + readonly afterClose: string; + /** The origin root's one run after the deletion — also its composed text. */ + readonly originAfter: string; + /** The moved node's run at the origin. */ + readonly beforeRun: string; + /** The moved node's run at the destination. */ + readonly afterRun: string; + /** + * The target parent's run after the moved child: U+000A when the closing + * tag's line is kept at the destination, empty when it drops. + */ + readonly parentTail: string; +} + +const IMPURE_SHAPES: readonly ImpureShape[] = [ + { + // (a) `foo <S id="m">`, U+000A, `body`, U+000A, two spaces, `</S> bar`: + // at the origin both boundary lines are kept (`foo`, `bar`), so the + // opening line's terminator and the closing line's two spaces contribute + // to the node; at the destination each tag is alone on its line, a + // flow-position tag, and both lines — `<S id="…">` and ` </S>` — are + // left empty or whitespace-only purely by removals and drop with their + // terminators (3): the node keeps `body`, U+000A alone. + tag: "(a)", + label: "the worked shape with spaces before its closing tag", + body: "\nbody\n ", + afterClose: " bar", + originAfter: "foo bar\n", + beforeRun: "\nbody\n ", + afterRun: "body\n", + parentTail: "", + }, + ...( + [ + ["U+000B", VT], + ["U+000C", FF], + ] as const + ).map(([name, ws]): ImpureShape => ({ + // (b) `foo <S id="m">`, ws, U+000A, `body`, U+000A, ws, `</S> bar`: + // whitespace under 1.4, so both destination lines are left + // whitespace-only by the removals and drop with their terminators, + // but no whitespace to the MDX grammar, so both tags stay in text + // position there, the section closing within its paragraph (6.2). + tag: "(b)", + label: `the both-sided ${name} spelling`, + body: `${ws}\nbody\n${ws}`, + afterClose: " bar", + originAfter: "foo bar\n", + beforeRun: `${ws}\nbody\n${ws}`, + afterRun: "body\n", + parentTail: "", + })), + ...( + [ + ["U+000B", VT], + ["U+000C", FF], + ] as const + ).map(([name, ws]): ImpureShape => ({ + // (c) `foo <S id="m">`, ws, U+000A, `body</S>`: an in-line section + // closed within its paragraph, whose difference is realized on the + // opening line alone — the destination line `<S id="…">`, ws is + // whitespace-only purely by the removal and drops, the tag staying an + // in-line tag there too; the closing tag's line, `body</S>`, is kept + // at both sides, so its terminator at the destination is the parent's. + tag: "(c)", + label: `the body</S> variant with a ${name} remainder`, + body: `${ws}\nbody`, + afterClose: "", + originAfter: "foo \n", + beforeRun: `${ws}\nbody`, + afterRun: "body", + parentTail: "\n", + })), +]; + +/** + * A destination T6.2-3 stages each shape into, with the hand-derived + * target-side expectations every shape shares: the moved text lands at a + * line start followed by U+000A (6.5), so no terminator enters the parent's + * runs; the parent is `changed` (it gains a child reference), the moved node + * is `changed` by its boundary lines, and the file root above a section + * parent is `descendant-changed` attributed to the parent (5.6), with the + * two-sided tolerance for the relocated moved node. + */ +interface ImpureDestination { + readonly name: string; + readonly newId: string; + readonly parent: string; + readonly moved: string; + readonly target: () => SectionMoveDocument; + /** The composed target text around the moved text (its `id` rewritten). */ + readonly compose: (movedText: string) => string; + /** The parent's own-content sequence after the move. */ + readonly parentAfter: (tail: string) => SectionMoveOwnToken[]; + /** The target file's rows of the category table. */ + readonly rows: ( + changed: readonly string[], + ) => Record<string, Record<string, CategoryRow>>; +} + +const IMPURE_DESTINATIONS: readonly ImpureDestination[] = [ + { + // `<S id="k">z</S>`, U+000A — a file ending with a terminator, so the + // insertion point, its end, is a line start; the target root is the + // parent. Line 1 is kept (`z`), its terminator the root's run after `k`. + name: "another file's top level", + newId: "m", + parent: C_B, + moved: C_BM, + target: () => doc(C_B, [sec("k", "", [content("z")]), content("\n")]), + compose: (movedText) => `<S id="k">z</S>\n${movedText}\n`, + parentAfter: (tail) => [ + ["run", ""], + ["child", C_BK], + ["run", "\n"], + ["child", C_BM], + ["run", tail], + ], + rows: (changed) => ({ + [C_B]: { changed: chg(changed), "descendant-changed": opt(C_BM) }, + [C_BK]: {}, + [C_BM]: { changed: chg(changed) }, + }), + }, + { + // `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A — a flow-position + // parent, its tags alone on their lines, the insertion point before its + // closing tag a line start. `p`'s tag-only lines drop at both sides (3), + // so its run before the moved child is `x`, U+000A and the root's runs + // around it stay empty: the root keeps its content. + name: "a flow-position parent", + newId: "p.m", + parent: C_BP, + moved: C_BPM, + target: () => doc(C_B, [sec("p", "", [content("\nx\n")]), content("\n")]), + compose: (movedText) => `<S id="p">\nx\n${movedText}\n</S>\n`, + parentAfter: (tail) => [ + ["run", "x\n"], + ["child", C_BPM], + ["run", tail], + ], + rows: (changed) => ({ + [C_B]: { "descendant-changed": reqWithin([C_BP, C_BPM], [C_BP]) }, + [C_BP]: { changed: chg(changed), "descendant-changed": opt(C_BPM) }, + [C_BPM]: { changed: chg(changed) }, + }), + }, +]; + +for (const shape of IMPURE_SHAPES) { + for (const dest of IMPURE_DESTINATIONS) { + test(`S-6 (T6.2-3${shape.tag}): ${shape.label}, moved into ${dest.name} — the moved node is changed by its boundary lines, the two parents changed, no other node`, () => { + const origin = doc(C_A, [ + content("foo "), + sec("m", "", [content(shape.body)]), + content(`${shape.afterClose}\n`), + ]); + const target = dest.target(); + const movedText = `<S id="${dest.newId}">${shape.body}</S>`; + expectDerives("ca before", sectionMoveSourceText(origin.pieces)); + expectDerives("ca after", shape.originAfter); + expectDerives("cb before", sectionMoveSourceText(target.pieces)); + expectDerives("cb after", dest.compose(movedText)); + + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "m", + newId: dest.newId, + }); + + expect(prediction.beforeOwnTokens.get(C_AM)).toEqual([ + ["run", shape.beforeRun], + ]); + expect(prediction.afterOwnTokens.get(dest.moved)).toEqual([ + ["run", shape.afterRun], + ]); + expect(prediction.afterOwnTokens.get(C_A)).toEqual([ + ["run", shape.originAfter], + ]); + expect(prediction.afterOwnTokens.get(dest.parent)).toEqual( + dest.parentAfter(shape.parentTail), + ); + + const changed = [C_A, dest.parent, dest.moved]; + expect(sortedSet(prediction.changed)).toEqual([...changed].sort()); + expect(sortedSet(prediction.added)).toEqual([]); + expect(tableOf(prediction)).toEqual({ + [C_A]: { changed: chg(changed), "descendant-changed": opt(dest.moved) }, + ...dest.rows(changed), + }); + }); + } +} + +// ============================================================================= +// Multi-line section tags (SPEC 3): a tag's internal terminators +// ============================================================================= + +test("S-6 (3): a section tag spanning lines — its internal terminator deleted with the construct, the lines joined — is handled like any multi-line removal", () => { + // Origin `<S`, U+000A, ` id="m">`, U+000A, `x`, U+000A, `</S>`, U+000A + // (the own-lines multi-line tag form of the §3 fixtures), moved to a + // created file: the joined tag line is left empty purely by the removal + // at both sides and drops, so the moved node's sequence is preserved; + // the deletion leaves the origin empty (its whole first line dropped). + const O = "specs/O.mdx"; + const N = "specs/N.mdx"; + const origin = doc(O, [ + { + kind: "section", + id: "m", + open: '<S\n id="m">', + close: "</S>", + body: [content("\nx\n")], + depends: [], + }, + content("\n"), + ]); + expectDerives("O before", sectionMoveSourceText(origin.pieces)); + expectDerives("O after", ""); + expectDerives("N after", '<S\n id="m2">\nx\n</S>\n'); + + const prediction = predictSectionMoveImpact({ + origin, + target: { createdPath: N }, + movedId: "m", + newId: "m2", + }); + expect(prediction.beforeOwnTokens.get(`${O}#m`)).toEqual([["run", "x\n"]]); + expect(prediction.afterOwnTokens.get(`${N}#m2`)).toEqual([["run", "x\n"]]); + expect(prediction.afterOwnTokens.get(O)).toEqual([["run", ""]]); + expect(sortedSet(prediction.changed)).toEqual([N, O]); + expect(sortedSet(prediction.added)).toEqual([N]); + const changed = [N, O]; + expect(tableOf(prediction)).toEqual({ + [N]: { changed: chg(changed) }, + [`${N}#m2`]: {}, + [O]: { changed: chg(changed) }, + }); +}); + +// ============================================================================= +// Misuse guards +// ============================================================================= + +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 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/self/s8-answer-scale-capacity.test.ts b/test/self/s8-answer-scale-capacity.test.ts new file mode 100644 index 00000000..afc1e7eb --- /dev/null +++ b/test/self/s8-answer-scale-capacity.test.ts @@ -0,0 +1,1170 @@ +// S-8 Answer-scale capacity self-test (TEST-SPEC 17 S-8; §0 H-11). The +// harness must capture, decode, and evaluate every answer SPEC.md permits a +// conforming product over the inputs the suite stages — nesting depth and +// document size included, expansion blowup included — with every internal +// capacity limit dimensioned to that scale, and an exhausted capture limit a +// loud harness error, never a silent truncation. No CERTIFICATIONS.md fixture +// reaches this class (a harness-side failure against a conforming answer is +// a spurious fail, not a missed deviation), so it is gated here, before any +// product exists (H-8's ordering): +// +// 1. the scale is DERIVED from what the suite stages — the generator +// draws (P-8/P-11's towers and mutation budget, P-2/P-3's expansion +// oracle) and the deterministic fixtures (T1.3-7's chained-id tower, +// the anchor of P-8's floor and the largest document the suite stages) +// — never assumed; `staged-scale.ts` states the derivation (shared with +// S-2, which stages the same maxima through the workspace builder), and +// the fixed CI seed set (E-5) is replayed to confirm the staged draws +// stay inside the generator maxima; +// 2. synthetic conforming-form documents at that scale are built +// iteratively (never by recursion — `JSON.stringify` itself overflows +// at these depths) and driven through every H-3/12.7 decoder and every +// answer-document walk the suite performs, asserting no exception and +// the expected datum counts; +// 3. capture is gated at the same scale through S-3's stand-in mechanism: +// a stand-in command streams the largest synthetic document to stdout +// through the one ProductBinding/run path product invocations use +// (H-2, C-2); the captured bytes must be complete and identical to what +// it emitted, the capture feeds the decoders unchanged, the default +// capture cap must hold at least twice the document, and a cap set just +// below the document must surface as ProductRunOutputOverflowError; +// 4. an exhausted capture limit stays a harness error even where +// termination is the assertion: P-8's and P-11's command runs, their +// capture cap lowered against a flooding stand-in, let the overflow +// propagate out of the property, which reports it as a harness error +// naming the seed — never a falsified property — while the hang-guard +// kill, their termination clause, stays a diagnosed failure (S-3); +// 5. and so it stays wherever a shared helper converts a run's rejection +// into a diagnosed failure — 13.5's runBounded and describeExit, the +// write-refusal staging's awaitHoldFile, runSettled, and +// runHeldWithStaging, P-10's runHeldRead, settleStraddleRead, and +// settleKilled: their capture cap lowered against a flooding +// stand-in, each lets the driver's ProductRunOutputOverflowError +// through unchanged, while the hang-guard kill (for a hold-file wait, +// a premature exit) stays a diagnosed failure. S-3 pins the driver's +// own hold-file wait and `rethrowOutputOverflow`; the conversions +// written inline in registered bodies call it the same way. + +import { Buffer } from "node:buffer"; +import { once } from "node:events"; +import { createReadStream, createWriteStream } from "node:fs"; +import { expect, onTestFinished, test } from "vitest"; +import { + assertBareEdgeEndpoints, + assertNodeEdgeListsBare, + assertUnavailabilityMarkerForms, + decodeAtReport, + decodeEdgesReport, + decodeErrorDocument, + decodeFindingsReport, + decodeIdsReport, + decodeIdsTreeReport, + decodeNodeIdentityRowsReport, + decodeNodeReport, + decodeNodeRowsReport, + decodeNodeSummaryRowsReport, + decodeNodeTextAlgebraSummary, + decodeOccurrencesReport, + decodeReachableReport, + decodeViewReport, + describeJsonValue, +} from "../helpers/adapters/index.js"; +import type { IdsTreeNode, ViewNode } from "../helpers/adapters/index.js"; +import { + HarnessAssertionError, + parseJsonStdout, +} from "../helpers/assertions.js"; +import { + checkProperty, + drawFixedSeedTrials, + PROPERTY_SEED_ENV, + PropertyFalsifiedError, +} from "../helpers/property.js"; +import { + DEFAULT_MAX_OUTPUT_BYTES, + ProductRunOutputOverflowError, + runProduct, + startProduct, +} from "../helpers/subprocess.js"; +import type { + ProductBinding, + RunGuards, + RunningProduct, +} from "../helpers/subprocess.js"; +import { TestWorkspace } from "../helpers/workspace.js"; +import { + canonicalJson as canonicalJson1023, + collectStringLeaves as collectStringLeaves1023, +} from "../suite/registry/section-10.2-10.3.js"; +import { collectStringLeaves as collectStringLeaves104 } from "../suite/registry/section-10.4.js"; +import { + canonicalJson as canonicalJson106, + collectStringLeaves as collectStringLeaves106, +} from "../suite/registry/section-10.6.js"; +import { + canonicalJson as canonicalJson107i, + collectStringLeaves as collectStringLeaves107i, +} from "../suite/registry/section-10.7-i.js"; +import { + canonicalJson as canonicalJson107ii, + collectStringLeaves as collectStringLeaves107ii, +} from "../suite/registry/section-10.7-ii.js"; +import { canonicalizeJson } from "../suite/registry/section-12.0-i.js"; +import { describeExit, runBounded } from "../suite/registry/section-13.5.js"; +import { + runHeldRead, + settleKilled, + settleStraddleRead, +} from "../suite/registry/section-16-p10.js"; +import { + documentCarriesUnavailability, + genAvailabilityTrial, + runAvailabilityCommand, +} from "../suite/registry/section-16-p11.js"; +import { + generatedDoc, + specSubtreeTexts, +} from "../suite/registry/section-16-p2-p3.js"; +import { + genFuzzTrial, + MAX_MUTATIONS_PER_TRIAL, + runFuzzCommand, +} from "../suite/registry/section-16-p8.js"; +import { + awaitHoldFile, + holdPathFor, + runHeldWithStaging, + runSettled, +} from "../suite/registry/write-refusal-staging.js"; +import type { StagingApplier } from "../suite/registry/write-refusal-staging.js"; +import { + DEEPEST_STAGED_TOWER, + DEPTH_FLOOR, + FATTEST_TERMINATOR, + GIANT_NESTING_FLOOR, + LARGEST_BASE_BYTES, + LARGEST_BASE_FILE, + LARGEST_DETERMINISTIC_INPUT_BYTES, + LARGEST_GENERATED_INPUT_BYTES, + LARGEST_STAGED_INPUT_BYTES, + largestDeterministicDocument, + largestGeneratedDocument, + largestStagedDocument, + TOWER_BYTES, +} from "./staged-scale.js"; + +// --------------------------------------------------------------------------- +// 1. The scale the suite stages — derived from the generators, not assumed +// +// Nesting and document size are derived once, in `staged-scale.ts` +// (GIANT_NESTING_FLOOR, DEEPEST_STAGED_TOWER, TOWER_BYTES, the generator +// maximum LARGEST_GENERATED_INPUT_BYTES, the deterministic maximum +// LARGEST_DETERMINISTIC_INPUT_BYTES — T1.3-7's tower at DEPTH_FLOOR — and +// their larger, LARGEST_STAGED_INPUT_BYTES, with their derivation comments) +// — the same constants S-2 stages through the workspace builder; every S-8 +// pin binds to the kind it sizes. A trial applies up to +// MAX_MUTATIONS_PER_TRIAL mutations to the same file, and a shuffle mutation +// relocates one contiguous byte range, so tower + tower + shuffle can drop +// the second tower into the first's innermost level: the deepest section +// chain any P-8/P-11 draw can stage is 2 × 4096 = 8192 (a third tower would +// need a fourth mutation). Every `view` and `ids --tree` answer over such an +// input nests one node per level. +const SYNTHETIC_DEPTH = 2 * DEEPEST_STAGED_TOWER; + +// Expansion blowup. SPEC 3 defines a line terminator as CRLF, a lone LF, or +// a lone CR — nothing else — so after P-8's LF → U+2028 rewrite a tower's +// tags no longer stand on lines of their own: no line is dropped, and every +// level's own text is its two separators. A section's subtree text (1.6) +// re-emits every level beneath it, so a `view --text` answer carries, per +// tower, Σ_k (2·(D − k) separators + the content line) — quadratic in the +// depth: ~100 MB at D = 4096 in the six-character `\u2028` JSON spelling a +// conforming product may choose (12.7 pins no escaping), ~50 MB raw. Two +// towers under one rewrite (tower + tower + rewrite: the whole budget) double +// it, and P-11's answer arms request exactly this (`view --text` over the +// mutated base). That is the largest answer SPEC.md permits over a staged +// input — three orders of magnitude past the input's own size. Embedding +// chains multiply less here: the fuzz base's chain (B.b → A.c → A.a.b) has +// fan-out one, a shuffle can carry a tower into an embedded target once, and +// an embedded subtree is re-emitted once per embedding level, not once per +// nesting level; P-2/P-3's generator has no structural expansion bound (each +// target may embed every earlier target), so its fixed-seed maximum is +// measured below and its randomized mode fails loudly in the oracle +// (`specSubtreeTexts` materializes every expansion before any product runs) +// rather than silently under-capturing. +const BLOWUP_TOWERS = 2; +const BLOWUP_DEPTH = DEEPEST_STAGED_TOWER; +/** U+2028 as a conforming product may spell it inside a JSON string. */ +const SEPARATOR_ESCAPED = "\\u2028"; +const SEPARATOR = "\u2028"; + +test("S-8: the derived scale — deepest chain, largest generated and deterministic staged inputs, blowup input", () => { + expect(DEEPEST_STAGED_TOWER).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + expect(SYNTHETIC_DEPTH).toBe(8192); + // The tower the suite stages, byte for byte (sectionTowerSource is what + // mutateNesting appends): 11 bytes per opener line, the content line, 5 + // bytes per closer line. + expect(TOWER_BYTES).toBe( + DEEPEST_STAGED_TOWER * 11 + 6 + DEEPEST_STAGED_TOWER * 5, + ); + expect(FATTEST_TERMINATOR).toBe(3); + // The derivation's claim: the all-towers mix is the largest generated file. + expect(LARGEST_GENERATED_INPUT_BYTES).toBe( + LARGEST_BASE_BYTES + MAX_MUTATIONS_PER_TRIAL * TOWER_BYTES, + ); + expect(LARGEST_GENERATED_INPUT_BYTES).toBeGreaterThan(190_000); + expect(LARGEST_GENERATED_INPUT_BYTES).toBeLessThan(200_000); + // Attained, not merely bounded: the largest base is an `.mdx` file, so a + // nesting draw over it appends the section tower the mix is sized with, + // and the document S-2 stages is exactly that mix. + expect(LARGEST_BASE_FILE[0].endsWith(".mdx")).toBe(true); + expect(largestGeneratedDocument().length).toBe(LARGEST_GENERATED_INPUT_BYTES); + + // The deterministic maximum: T1.3-7's chained-id tower (section-1.3.ts), + // quadratic in the depth because every id spells its whole ancestor chain + // — per level k an opener `<S id="` (7 bytes) plus a (2k − 1)-byte id plus + // `">\n` (3 bytes), then `deep.\n` (6 bytes), then `</S>\n` × D (5 bytes + // each): 9·D + D·(D + 1) + 6 + 5·D, 4,225,030 bytes at D = 2048. The + // exact size moves only when T1.3-7's DEPTH_FLOOR does — deliberately. + expect(LARGEST_DETERMINISTIC_INPUT_BYTES).toBe( + 9 * DEPTH_FLOOR + DEPTH_FLOOR * (DEPTH_FLOOR + 1) + 6 + 5 * DEPTH_FLOOR, + ); + expect(LARGEST_DETERMINISTIC_INPUT_BYTES).toBe(4_225_030); + expect(largestDeterministicDocument().length).toBe( + LARGEST_DETERMINISTIC_INPUT_BYTES, + ); + // The suite's staged maximum is the larger kind's — today the + // deterministic one, ~21× the generator maximum — and the document staged + // under that name is exactly that large. + expect(LARGEST_STAGED_INPUT_BYTES).toBe( + Math.max(LARGEST_GENERATED_INPUT_BYTES, LARGEST_DETERMINISTIC_INPUT_BYTES), + ); + expect(largestStagedDocument().length).toBe(LARGEST_STAGED_INPUT_BYTES); + // The deterministic anchor sits inside the synthetic answer scale: T1.3-7 + // anchors P-8's floor (TEST-SPEC T1.3-7), and the synthetic `view` and + // `ids --tree` documents below nest at least as deep as it. + expect(DEPTH_FLOOR).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + expect(SYNTHETIC_DEPTH).toBeGreaterThanOrEqual(DEPTH_FLOOR); +}); + +test("S-8: the fixed CI seed set stages within the derived scale (E-5 replay)", () => { + // DEFAULT_RUNS_PER_SEED (25) bounds every property's registered run count, + // and each seed's trials are one sequential PRNG stream, so the draws the + // suite stages under the fixed plan are a prefix of these. + const RUNS = 25; + let largestFile = 0; + let deepestTower = 0; + const trials = [ + ...drawFixedSeedTrials(genFuzzTrial, RUNS), + ...drawFixedSeedTrials(genAvailabilityTrial, RUNS), + ]; + for (const trial of trials) { + for (const [, bytes] of trial.files) { + largestFile = Math.max(largestFile, bytes.length); + } + for (const mutation of trial.mutations) { + const depth = /depth-(\d+) /.exec(mutation); + if (depth !== null) + deepestTower = Math.max(deepestTower, Number(depth[1])); + } + } + expect(largestFile).toBeLessThanOrEqual(LARGEST_GENERATED_INPUT_BYTES); + expect(deepestTower).toBeLessThanOrEqual(DEEPEST_STAGED_TOWER); + // P-8's test-strength floor on staged draws (TEST-SPEC §16 P-8): the fixed + // seed set must itself stage nesting at least 2048 deep. + expect(deepestTower).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + + // P-2/P-3: the largest text datum a `query node` answer carries over the + // fixed-seed documents — the expansion oracle's own materialization. + let largestText = 0; + for (const doc of drawFixedSeedTrials(generatedDoc, RUNS)) { + for (const text of specSubtreeTexts(doc).values()) { + largestText = Math.max(largestText, Buffer.byteLength(text, "utf8")); + } + } + expect(largestText).toBeGreaterThan(0); + expect(largestText).toBeLessThan(LARGEST_GENERATED_INPUT_BYTES); +}); + +// --------------------------------------------------------------------------- +// 2. Synthetic conforming-form documents, built iteratively + +const VIEWED_FILE = "specs/A.mdx"; + +interface TowerText { + /** JSON-escaped own text of level k (1 = the outermost). */ + readonly own: (level: number) => string; + /** JSON-escaped subtree text of level k. */ + readonly subtree: (level: number) => string; +} + +interface ViewDocumentSpec { + readonly towers: number; + readonly depth: number; + /** Node text members (the `--text` form), or null for the bare form. */ + readonly text: TowerText | null; + /** JSON-escaped root own/subtree texts (text form only). */ + readonly rootText: { readonly own: string; readonly subtree: string }; + /** Tower node identities: the marker (duplicate `g`) or a plain string. */ + readonly identity: "marker" | "string"; + /** Element count of each flat per-file member and of the findings. */ + readonly flat: number; +} + +/** + * Append one balanced tower's node chain as JSON text: `depth` nested view + * nodes in the literal 12.7 form, ranges laid out exactly as + * sectionTowerSource's bytes lie from `start` — opener lines of 11 bytes + * (`<S id="g">`, the attribute at +3..+9), the 6-byte content line, closer + * lines of 5 bytes. Returns the byte offset after the tower. + */ +function appendTowerNodes( + out: string[], + spec: ViewDocumentSpec, + start: number, + tower: number, +): number { + const closersStart = start + spec.depth * 11 + 6; + for (let level = 1; level <= spec.depth; level += 1) { + const open = start + (level - 1) * 11; + const close = closersStart + (spec.depth - level) * 5; + const identity = + spec.identity === "marker" + ? '{"unavailable":true}' + : JSON.stringify(`${VIEWED_FILE}#t${String(tower)}.g${String(level)}`); + out.push( + `{"identity":${identity},"range":{"start":${String(open)},"end":${String(close + 4)}},` + + `"opening":{"start":${String(open)},"end":${String(open + 10)}},` + + `"closing":{"start":${String(close)},"end":${String(close + 4)}},` + + `"attributes":[{"name":"id","range":{"start":${String(open + 3)},"end":${String(open + 9)}},"text":"id=\\"g\\""}],` + + `"tags":[],"coverage":null,`, + ); + if (spec.text !== null) { + out.push( + `"ownText":"${spec.text.own(level)}","subtreeText":"${spec.text.subtree(level)}",`, + ); + } + out.push('"children":['); + } + for (let level = 1; level <= spec.depth; level += 1) out.push("]}"); + return closersStart + spec.depth * 5; +} + +/** `count` findings in the pinned order: one 14.1 finding locating every + * bearer, then one 14.3 finding per bearer, locations ascending. */ +function appendFindings(out: string[], count: number): void { + if (count === 0) return; + out.push( + '{"code":"missing-id","message":"a section spells no id","locations":[', + ); + for (let index = 0; index < count; index += 1) { + if (index > 0) out.push(","); + out.push( + `{"file":${JSON.stringify(VIEWED_FILE)},"range":{"start":${String(index * 11)},"end":${String(index * 11 + 10)}}}`, + ); + } + out.push('],"path":null,"identities":[]}'); + for (let index = 0; index < count; index += 1) { + out.push( + `,{"code":"duplicate-id","message":"duplicate id g","locations":[{"file":${JSON.stringify(VIEWED_FILE)},"range":{"start":${String(index * 11)},"end":${String(index * 11 + 10)}}}],"path":null,"identities":[]}`, + ); + } +} + +/** The whole `view` document as JSON text pieces (join to get the text). */ +function buildViewDocument(spec: ViewDocumentSpec): string[] { + const out: string[] = []; + const fileLength = spec.towers * TOWER_BYTES; + out.push('{"findings":['); + appendFindings(out, spec.flat); + out.push(`],"views":[{"file":${JSON.stringify(VIEWED_FILE)},"root":`); + out.push( + `{"identity":${JSON.stringify(VIEWED_FILE)},"range":{"start":0,"end":${String(fileLength)}},` + + `"opening":null,"closing":null,"attributes":[],"tags":null,"coverage":null,`, + ); + if (spec.text !== null) { + out.push( + `"ownText":"${spec.rootText.own}","subtreeText":"${spec.rootText.subtree}",`, + ); + } + out.push('"children":['); + let offset = 0; + for (let tower = 1; tower <= spec.towers; tower += 1) { + if (tower > 1) out.push(","); + offset = appendTowerNodes(out, spec, offset, tower); + } + out.push("]}"); + out.push(',"imports":['); + for (let index = 0; index < spec.flat; index += 1) { + if (index > 0) out.push(","); + out.push( + `{"range":{"start":${String(index * 8)},"end":${String(index * 8 + 7)}},"name":null,"target":{"unavailable":true}}`, + ); + } + out.push('],"occurrences":['); + for (let index = 0; index < spec.flat; index += 1) { + if (index > 0) out.push(","); + out.push( + `{"file":${JSON.stringify(VIEWED_FILE)},"range":{"start":${String(index * 6)},"end":${String(index * 6 + 5)}},` + + `"kind":"embeds","source":{"unavailable":true},"target":"specs/A.mdx#a.b"}`, + ); + } + out.push('],"comments":['); + for (let index = 0; index < spec.flat; index += 1) { + if (index > 0) out.push(","); + out.push(`{"start":${String(index * 4)},"end":${String(index * 4 + 3)}}`); + } + out.push("]}]}"); + return out; +} + +/** An `ids --tree` document nesting one node per level, `depth` deep. */ +function buildIdsTreeDocument(depth: number): string { + const out: string[] = [ + `{"files":[{"file":${JSON.stringify(VIEWED_FILE)},"nodes":[`, + ]; + for (let level = 0; level < depth; level += 1) + out.push('{"id":"g","children":['); + for (let level = 0; level < depth; level += 1) out.push("]}"); + out.push("]}]}"); + return out.join(""); +} + +function countViewNodes(root: ViewNode): { nodes: number; depth: number } { + let nodes = 0; + let depth = 0; + const stack: { readonly node: ViewNode; readonly level: number }[] = [ + { node: root, level: 0 }, + ]; + while (stack.length > 0) { + const { node, level } = stack.pop()!; + nodes += 1; + depth = Math.max(depth, level); + for (const child of node.children) + stack.push({ node: child, level: level + 1 }); + } + return { nodes, depth }; +} + +function countIdsNodes(roots: readonly IdsTreeNode[]): { + nodes: number; + depth: number; +} { + let nodes = 0; + let depth = 0; + const stack: { readonly node: IdsTreeNode; readonly level: number }[] = + roots.map((node) => ({ node, level: 1 })); + while (stack.length > 0) { + const { node, level } = stack.pop()!; + nodes += 1; + depth = Math.max(depth, level); + for (const child of node.children) + stack.push({ node: child, level: level + 1 }); + } + return { nodes, depth }; +} + +function innermost(root: ViewNode): ViewNode { + let node = root; + while (node.children.length > 0) node = node.children[0]!; + return node; +} + +/** An independent count of string leaves (the registry walkers' oracle). */ +function countStringLeaves(value: unknown): number { + let count = 0; + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") count += 1; + else if (Array.isArray(current)) stack.push(...current); + else if (typeof current === "object" && current !== null) { + stack.push(...Object.values(current)); + } + } + return count; +} + +const identities = (count: number, offset = 0): string[] => + Array.from( + { length: count }, + (_, index) => `${VIEWED_FILE}#g${String(index + offset)}`, + ); + +test("S-8: every H-3/12.7 decoder and answer-document walk succeeds at the synthetic depth", () => { + const depth = SYNTHETIC_DEPTH; + const flat = SYNTHETIC_DEPTH; + const bareText = buildViewDocument({ + towers: 1, + depth, + text: null, + rootText: { own: "", subtree: "" }, + identity: "marker", + flat, + }).join(""); + const bare = JSON.parse(bareText) as unknown; + + // The `view` document: the positional tree one node per level, and the + // flat per-file members and findings at the same count. + const view = decodeViewReport(bare, { text: false }, "S-8 deep view"); + expect(view.findings).toHaveLength(flat + 1); + expect(view.views).toHaveLength(1); + const fileView = view.views[0]!; + expect(countViewNodes(fileView.root)).toEqual({ nodes: depth + 1, depth }); + expect(fileView.imports).toHaveLength(flat); + expect(fileView.occurrences).toHaveLength(flat); + expect(fileView.comments).toHaveLength(flat); + expect(innermost(fileView.root).identity).toEqual({ unavailable: true }); + + // The `--text` twin at depth (P-11's arm): both text members per level. + const textDoc = JSON.parse( + buildViewDocument({ + towers: 1, + depth, + text: { own: () => "", subtree: () => "deep.\\n" }, + rootText: { own: "", subtree: "deep.\\n" }, + identity: "marker", + flat: 0, + }).join(""), + ) as unknown; + const textView = decodeViewReport( + textDoc, + { text: true }, + "S-8 deep view --text", + ); + expect(countViewNodes(textView.views[0]!.root)).toEqual({ + nodes: depth + 1, + depth, + }); + expect(innermost(textView.views[0]!.root).subtreeText).toBe("deep.\n"); + + // The whole-document marker walk and P-11's unavailability walk. + assertUnavailabilityMarkerForms(bare, "S-8 deep view"); + expect(documentCarriesUnavailability(bare)).toBe(true); + expect(documentCarriesUnavailability(textDoc)).toBe(true); + + // The diagnosis renderer: bounded text at any depth (its fallback runs + // where V8's serializer overflows). + const description = describeJsonValue(bare); + expect(description.startsWith("object ")).toBe(true); + expect(description.length).toBeLessThan(400); + + // The registry modules' generic JSON walkers: string leaves counted + // against an independent walk, canonical renderings that decode back to + // the same tree, key-order canonicalization that decodes likewise. + const leaves = countStringLeaves(bare); + for (const collect of [ + collectStringLeaves1023, + collectStringLeaves104, + collectStringLeaves106, + collectStringLeaves107i, + collectStringLeaves107ii, + ]) { + expect(collect(bare)).toHaveLength(leaves); + } + for (const render of [ + canonicalJson1023, + canonicalJson106, + canonicalJson107i, + canonicalJson107ii, + ]) { + const rendered = render(bare); + expect(rendered.length).toBeGreaterThan(depth * 100); + const decoded = decodeViewReport(JSON.parse(rendered), { text: false }); + expect(countViewNodes(decoded.views[0]!.root)).toEqual({ + nodes: depth + 1, + depth, + }); + } + const canonicalized = decodeViewReport(canonicalizeJson(bare), { + text: false, + }); + expect(countViewNodes(canonicalized.views[0]!.root)).toEqual({ + nodes: depth + 1, + depth, + }); + + // `ids --tree` one node per level; `ids` and the row reports at the + // section count T1.3-7's `query subtree` returns (root plus every level). + const idsTree = decodeIdsTreeReport( + JSON.parse(buildIdsTreeDocument(depth)), + "S-8 ids --tree", + ); + expect(countIdsNodes(idsTree.files[0]!.nodes)).toEqual({ + nodes: depth, + depth, + }); + const ids = identities(depth + 1); + expect( + decodeIdsReport({ files: [{ file: VIEWED_FILE, ids }] }).files[0]!.ids, + ).toHaveLength(depth + 1); + const rows = { + nodes: [ + { + identity: VIEWED_FILE, + sourceRange: { start: 0, end: TOWER_BYTES }, + tags: [], + }, + ...ids.map((identity, index) => ({ + identity, + sourceRange: { start: index, end: index + 1 }, + tags: ["t1"], + coverage: "none", + })), + ], + }; + expect(decodeNodeRowsReport(rows, "S-8 rows")).toHaveLength(depth + 2); + expect(decodeNodeSummaryRowsReport(rows, "S-8 rows")).toHaveLength(depth + 2); + expect(decodeNodeIdentityRowsReport(rows, "S-8 rows")).toHaveLength( + depth + 2, + ); + + // Edge surfaces at the chain's edge count, each through the bare-endpoint + // walk; a reachability witness the length of the chain. + const edges = ids.slice(0, -1).map((from, index) => ({ + from, + to: ids[index + 1]!, + kind: "contains", + })); + expect(decodeEdgesReport({ edges }, "S-8 edges")).toHaveLength(depth); + assertBareEdgeEndpoints({ edges }, "S-8 edges"); + const node = { + identity: VIEWED_FILE, + sourceRange: { start: 0, end: TOWER_BYTES }, + ownText: "", + subtreeText: "deep.\n", + hashes: { + ownHash: "o", + subtreeHash: "s", + effectiveHash: "e", + metadataHash: "m", + }, + tags: [], + edges: { incoming: edges, outgoing: edges }, + }; + expect(decodeNodeReport(node, "S-8 node").incomingEdges).toHaveLength(depth); + expect( + decodeNodeTextAlgebraSummary(node, "S-8 node").containsTargets, + ).toHaveLength(depth); + assertNodeEdgeListsBare(node, "S-8 node"); + const reachable = { reachable: true, path: ids }; + expect(decodeReachableReport(reachable, "S-8 reachable").path).toHaveLength( + depth + 1, + ); + assertBareEdgeEndpoints(reachable, "S-8 reachable"); + + // The flat 12.7 surfaces at the same count: findings-only, occurrences, + // `at`, and the exit-2 error document. + const findingsOnly = JSON.parse( + `{"findings":[${(() => { + const out: string[] = []; + appendFindings(out, flat); + return out.join(""); + })()}]}`, + ) as unknown; + expect( + decodeFindingsReport(findingsOnly, "S-8 findings").findings, + ).toHaveLength(flat + 1); + expect( + decodeOccurrencesReport( + { findings: [], occurrences: fileView.occurrences }, + "S-8 occurrences", + ).occurrences, + ).toHaveLength(flat); + const at = { + findings: (findingsOnly as { findings: unknown[] }).findings, + resolution: { + section: { + identity: { unavailable: true }, + range: { start: 0, end: 10 }, + }, + occurrence: null, + }, + }; + expect(decodeAtReport(at, "S-8 at").findings).toHaveLength(flat + 1); + const error = decodeErrorDocument( + { error: (findingsOnly as { findings: unknown[] }).findings[0] }, + "S-8 error", + ); + expect(error.error.locations).toHaveLength(flat); +}, 120_000); + +// --------------------------------------------------------------------------- +// 3. The capture gate: the largest synthetic document through the H-2 path + +// S-3's stand-in mechanism: an argv-driven Node script written into a fresh +// TestWorkspace and driven through the same ProductBinding shape product +// invocations use. It streams a staged file to standard output in 64 KiB +// chunks and lets the event loop drain before exiting — never process.exit, +// which could drop a pipe's pending writes: that is the product-side defect +// a truncated capture is indistinguishable from (H-11), and the gate must +// know its stand-in emitted every byte. +const EMIT_SOURCE = `import { createReadStream } from "node:fs"; + +const [file] = process.argv.slice(2); +const source = createReadStream(file, { highWaterMark: 1 << 16 }); +source.on("error", (error) => { + process.stderr.write(String(error)); + process.exitCode = 1; +}); +source.pipe(process.stdout, { end: false }); +`; + +/** Write JSON text pieces to a file under back-pressure; the byte length. */ +async function writePieces( + path: string, + pieces: readonly string[], +): Promise<number> { + const stream = createWriteStream(path); + const failure = new Promise<never>((_, reject) => { + stream.once("error", reject); + }); + let bytes = 0; + for (const piece of pieces) { + bytes += Buffer.byteLength(piece, "utf8"); + if (!stream.write(piece)) { + await Promise.race([once(stream, "drain"), failure]); + } + } + await Promise.race([ + new Promise<void>((resolve) => { + stream.end(resolve); + }), + failure, + ]); + return bytes; +} + +/** Byte identity of the emitted file and the captured bytes, streamed. */ +async function assertCapturedFile( + path: string, + captured: Uint8Array, +): Promise<void> { + let offset = 0; + for await (const chunk of createReadStream(path, { + highWaterMark: 1 << 20, + })) { + const bytes = chunk as Buffer; + if (!bytes.equals(captured.subarray(offset, offset + bytes.length))) { + throw new Error( + `S-8: the captured stdout diverges from the emitted document at byte ${String(offset)} (H-2 capture; H-11)`, + ); + } + offset += bytes.length; + } + expect(offset).toBe(captured.length); +} + +/** + * The blowup tower's text members, JSON-escaped: level k's own text is its + * two U+2028 separators (the innermost: separator, content line, separator), + * its subtree text every level from k inward — built inward-out so each + * level's string is one concatenation, never a recursion. + */ +function blowupTowerText(depth: number): TowerText { + const own = (level: number): string => + level === depth + ? `${SEPARATOR_ESCAPED}deep.${SEPARATOR_ESCAPED}` + : `${SEPARATOR_ESCAPED}${SEPARATOR_ESCAPED}`; + const subtree: string[] = new Array<string>(depth + 1).fill(""); + subtree[depth] = own(depth); + for (let level = depth - 1; level >= 1; level -= 1) { + subtree[level] = own(level) + subtree[level + 1]!; + } + return { own, subtree: (level) => subtree[level]! }; +} + +/** Decoded characters of one tower's subtree text at level k. */ +const decodedSubtreeLength = (depth: number, level: number): number => + 2 * (depth - level) + 7; + +test("S-8: capture gate — the largest synthetic document (`view --text` blowup) captured complete and identical, then decoded", async () => { + const workspace = await TestWorkspace.create({ + files: { "emit.mjs": EMIT_SOURCE }, + }); + onTestFinished(() => workspace.dispose()); + const binding: ProductBinding = { + label: "S-8 stand-in", + command: process.execPath, + prefixArgs: [workspace.path("emit.mjs")], + }; + + const text = blowupTowerText(BLOWUP_DEPTH); + const documentPath = workspace.path("answer.json"); + const documentBytes = await writePieces( + documentPath, + buildViewDocument({ + towers: BLOWUP_TOWERS, + depth: BLOWUP_DEPTH, + text, + rootText: { own: "", subtree: text.subtree(1).repeat(BLOWUP_TOWERS) }, + identity: "string", + flat: 0, + }), + ); + // The document holds every level's re-emitted text: per tower + // Σ_k (12·(D − k) + 17) escaped characters, and it dwarfs the staged + // input it answers (S-8: "past its staged input's own size"). + const perTowerText = + (12 * BLOWUP_DEPTH * (BLOWUP_DEPTH - 1)) / 2 + 17 * BLOWUP_DEPTH; + expect(documentBytes).toBeGreaterThan(BLOWUP_TOWERS * perTowerText); + expect(documentBytes).toBeLessThan( + BLOWUP_TOWERS * perTowerText + 16 * 1024 * 1024, + ); + expect(documentBytes).toBeGreaterThan(500 * LARGEST_GENERATED_INPUT_BYTES); + // …and past the largest document the suite stages at all (today T1.3-7's + // deterministic tower) by a documented factor: that fixture's largest + // answer is ~13 MB of `view` over its ~4.2 MB input (section-1.3.ts's + // T1.3-7 comment), about 3×, so a synthetic scale at least 8× the staged + // maximum keeps every answer the deterministic anchor elicits inside what + // the decoders and the capture are gated at, with margin — ~48× today; + // this pin trips once a grown DEPTH_FLOOR (a quadratic file) brings + // T1.3-7's answers near the synthetic scale. + expect(documentBytes).toBeGreaterThan(8 * LARGEST_STAGED_INPUT_BYTES); + // The capture cap is dimensioned to this scale with headroom (H-11). + expect(DEFAULT_MAX_OUTPUT_BYTES).toBeGreaterThanOrEqual(2 * documentBytes); + + // A cap just below the document surfaces as the typed overflow error — + // loud, never a truncated capture (run first so its buffers are released + // before the complete capture below). + await expect( + runProduct(binding, { + cwd: workspace.root, + argv: ["answer.json"], + timeoutMs: 120_000, + maxOutputBytes: documentBytes - 1, + }), + ).rejects.toBeInstanceOf(ProductRunOutputOverflowError); + + // The complete capture through the H-2 path: complete, byte-identical. + const result = await runProduct(binding, { + cwd: workspace.root, + argv: ["answer.json"], + timeoutMs: 120_000, + }); + expect(result.signal).toBeNull(); + expect(result.exitCode).toBe(0); + expect(result.stderrBytes.length).toBe(0); + expect(result.stdoutBytes.length).toBe(documentBytes); + await assertCapturedFile(documentPath, result.stdoutBytes); + + // Evaluation from the capture itself (capture through evaluation): the + // stdout-to-document step product tests use, the form-exact decode, the + // marker and P-11 walks, and the text datum sizes the blowup implies. + const doc = parseJsonStdout(result, "S-8 capture"); + const view = decodeViewReport(doc, { text: true }, "S-8 blowup view --text"); + const root = view.views[0]!.root; + expect(countViewNodes(root)).toEqual({ + nodes: BLOWUP_TOWERS * BLOWUP_DEPTH + 1, + depth: BLOWUP_DEPTH, + }); + expect(root.children).toHaveLength(BLOWUP_TOWERS); + let reEmitted = 0; + const stack: ViewNode[] = [...root.children]; + while (stack.length > 0) { + const node = stack.pop()!; + expect(typeof node.subtreeText).toBe("string"); + reEmitted += (node.subtreeText as string).length; + stack.push(...node.children); + } + let expectedReEmitted = 0; + for (let level = 1; level <= BLOWUP_DEPTH; level += 1) { + expectedReEmitted += + BLOWUP_TOWERS * decodedSubtreeLength(BLOWUP_DEPTH, level); + } + expect(reEmitted).toBe(expectedReEmitted); + const outer = root.children[0]!; + expect(outer.subtreeText).toBe( + `${SEPARATOR.repeat(2 * BLOWUP_DEPTH - 1)}deep.${SEPARATOR}`, + ); + expect(innermost(outer).ownText).toBe(`${SEPARATOR}deep.${SEPARATOR}`); + assertUnavailabilityMarkerForms(doc, "S-8 blowup"); + expect(documentCarriesUnavailability(doc)).toBe(false); + // P-3's decode of the root's `query node` answer at the blowup scale: the + // captured texts and range, and one `contains` edge per tower. + const summary = decodeNodeTextAlgebraSummary( + { + ownText: root.ownText, + subtreeText: root.subtreeText, + sourceRange: root.range, + edges: { + incoming: [], + outgoing: root.children.map((child) => ({ + from: VIEWED_FILE, + to: child.identity, + kind: "contains", + })), + }, + }, + "S-8 text summary", + ); + expect(summary.subtreeText).toHaveLength( + BLOWUP_TOWERS * decodedSubtreeLength(BLOWUP_DEPTH, 1), + ); + expect(summary.containsTargets).toHaveLength(BLOWUP_TOWERS); +}, 600_000); + +/** + * Stand-in for the two driver kills a fuzz command run can meet (header + * item 4): `flood` writes 4 MiB to stdout and exits — past any small capture + * cap — and `hang` never exits. + */ +const GUARD_STANDIN_SOURCE = `const mode = process.argv[2]; +if (mode === "flood") { + const chunk = "x".repeat(1 << 16); + for (let i = 0; i < 64; i += 1) process.stdout.write(chunk); +} else { + setInterval(() => {}, 60000); +} +`; + +/** The seed the guard vectors run under (one of E-5's fixed seeds). */ +const GUARD_VECTOR_SEED = 314159265; + +/** What a run rejects with; a run that resolves fails the vector. */ +async function rejectionOf(run: Promise<unknown>): Promise<unknown> { + try { + await run; + } catch (error) { + return error; + } + throw new Error("S-8: expected the run to reject"); +} + +test("S-8: an exhausted capture limit in a P-8 or P-11 command run is a harness error naming the seed, never a falsified property; the hang-guard kill stays a diagnosed failure (H-11; S-3)", async () => { + const workspace = await TestWorkspace.create({ + files: { "guards.mjs": GUARD_STANDIN_SOURCE }, + }); + onTestFinished(() => workspace.dispose()); + const binding: ProductBinding = { + label: "S-8 guard stand-in", + command: process.execPath, + prefixArgs: [workspace.path("guards.mjs")], + }; + const runners = [ + ["P-8", runFuzzCommand], + ["P-11", runAvailabilityCommand], + ] as const; + for (const [property, run] of runners) { + // The capture cap lowered below what `flood` emits: the kill propagates + // out of the body, and `checkProperty` reports it as a harness error + // carrying the seed — neither a `PropertyFalsifiedError` nor any other + // `HarnessAssertionError`. + const overflow = await rejectionOf( + checkProperty( + `${property} capture-limit vector`, + () => null, + async () => { + await run(binding, workspace, ["flood"], { maxOutputBytes: 4096 }); + }, + { + runs: 1, + seeds: [GUARD_VECTOR_SEED], + env: {}, + maxShrinkExecutions: 0, + }, + ), + ); + expect(overflow, property).toBeInstanceOf(Error); + expect(overflow, property).not.toBeInstanceOf(HarnessAssertionError); + const error = overflow as Error; + expect(error.message, property).toContain( + `harness error while running trial 1 of 1 with seed ${String(GUARD_VECTOR_SEED)}`, + ); + expect(error.message, property).toContain( + `${PROPERTY_SEED_ENV}=${String(GUARD_VECTOR_SEED)}`, + ); + expect(error.cause, property).toBeInstanceOf(ProductRunOutputOverflowError); + + // The hang guard lowered below `hang`'s lifetime: the kill falsifies the + // property's termination clause, a diagnosed failure naming the seed. + const hang = await rejectionOf( + checkProperty( + `${property} hang-guard vector`, + () => null, + async () => { + await run(binding, workspace, ["hang"], { timeoutMs: 300 }); + }, + { + runs: 1, + seeds: [GUARD_VECTOR_SEED], + env: {}, + maxShrinkExecutions: 0, + }, + ), + ); + expect(hang, property).toBeInstanceOf(PropertyFalsifiedError); + const falsified = hang as PropertyFalsifiedError; + expect(falsified.seed, property).toBe(GUARD_VECTOR_SEED); + expect(falsified.assertionMessage, property).toContain( + "hang guard killed it", + ); + } +}, 60_000); + +/** + * Stand-in for the conversion vector (header item 5). Its mode rides the + * binding's prefix, so the argv a helper appends — a P-10 menu read, a + * `--test-hold <path>` pair — follows it, read only for the hold path: + * `flood` writes 4 MiB to stdout and exits, past any small capture cap; + * `exit` exits 3 at once; `hold-flood` and `hold-hang` create the hold file, + * wait for its deletion, then flood or never exit; anything else never + * exits. + */ +const CONVERSION_STANDIN_SOURCE = `import fs from "node:fs"; +const mode = process.argv[2]; +const flood = () => { + const chunk = "x".repeat(1 << 16); + for (let i = 0; i < 64; i += 1) process.stdout.write(chunk); +}; +const hang = () => setInterval(() => {}, 60000); +if (mode === "flood") { + flood(); +} else if (mode === "exit") { + process.exit(3); +} else if (mode === "hold-flood" || mode === "hold-hang") { + const hold = process.argv[process.argv.indexOf("--test-hold") + 1]; + fs.writeFileSync(hold, ""); + const poll = setInterval(() => { + if (!fs.existsSync(hold)) { + clearInterval(poll); + if (mode === "hold-flood") flood(); + else hang(); + } + }, 10); +} else { + hang(); +} +`; + +test("S-8: an exhausted capture limit propagates as a harness error out of every shared helper that converts a run's rejection into a diagnosed failure — 13.5's, the write-refusal staging's, P-10's — while the hang-guard kill and a premature exit stay diagnosed failures (H-11; S-3)", async () => { + const workspace = await TestWorkspace.create({ + files: { "conversions.mjs": CONVERSION_STANDIN_SOURCE }, + }); + onTestFinished(() => workspace.dispose()); + const standin = (mode: string): ProductBinding => ({ + label: `S-8 conversion stand-in (${mode})`, + command: process.execPath, + prefixArgs: [workspace.path("conversions.mjs"), mode], + }); + const start = async ( + mode: string, + guards: RunGuards = {}, + ): Promise<RunningProduct> => + await startProduct(standin(mode), { + cwd: workspace.root, + ...guards, + }); + // The capture cap lowered below what `flood` emits, and the hang guard + // below `hang`'s lifetime. + const capped: RunGuards = { maxOutputBytes: 4096 }; + const guarded: RunGuards = { timeoutMs: 300 }; + // The held helper's staging is beside the point here: a no-op. + const noStaging: StagingApplier = async (root) => ({ + mode: "write-refusal", + path: root, + restore: async () => {}, + }); + const context = "S-8 conversion vector"; + const helpers: readonly { + readonly helper: string; + readonly overflow: () => Promise<unknown>; + readonly diagnosed: () => Promise<unknown>; + }[] = [ + { + helper: "13.5's runBounded", + overflow: () => + runBounded(standin("flood"), workspace.root, [], context, capped), + diagnosed: () => + runBounded(standin("hang"), workspace.root, [], context, guarded), + }, + { + helper: "the write-refusal staging's awaitHoldFile", + overflow: async () => + await awaitHoldFile( + await start("flood", capped), + holdPathFor(workspace, "never-a.tmp"), + context, + ), + diagnosed: async () => + await awaitHoldFile( + await start("exit"), + holdPathFor(workspace, "never-b.tmp"), + context, + ), + }, + { + helper: "the write-refusal staging's runSettled", + overflow: () => + runSettled(standin("flood"), workspace, [], context, capped), + diagnosed: () => + runSettled(standin("hang"), workspace, [], context, guarded), + }, + { + helper: "the write-refusal staging's runHeldWithStaging", + overflow: () => + runHeldWithStaging( + standin("hold-flood"), + workspace, + [], + "hold-a.tmp", + noStaging, + context, + capped, + ), + // A guard long enough for the hold to appear first; a stand-in slower + // than that dies before it and fails as diagnosed all the same. + diagnosed: () => + runHeldWithStaging( + standin("hold-hang"), + workspace, + [], + "hold-b.tmp", + noStaging, + context, + { timeoutMs: 1500 }, + ), + }, + { + helper: "P-10's runHeldRead", + overflow: () => + runHeldRead(standin("flood"), workspace.root, 0, context, capped), + diagnosed: () => + runHeldRead(standin("hang"), workspace.root, 0, context, guarded), + }, + { + helper: "P-10's settleStraddleRead", + overflow: async () => + await settleStraddleRead( + { running: await start("flood", capped), what: "`flood`" }, + context, + ), + diagnosed: async () => + await settleStraddleRead( + { running: await start("hang", guarded), what: "`hang`" }, + context, + ), + }, + { + helper: "P-10's settleKilled", + overflow: async () => + await settleKilled(await start("flood", capped), context), + diagnosed: async () => + await settleKilled(await start("hang", guarded), context), + }, + ]; + for (const { helper, overflow, diagnosed } of helpers) { + const overflowed = await rejectionOf(overflow()); + expect(overflowed, helper).toBeInstanceOf(ProductRunOutputOverflowError); + expect(overflowed, helper).not.toBeInstanceOf(HarnessAssertionError); + const failed = await rejectionOf(diagnosed()); + expect(failed, helper).toBeInstanceOf(HarnessAssertionError); + expect((failed as Error).message, helper).toContain(context); + } + // 13.5's describeExit folds a settled run into a premature-exit + // diagnosis — except an exhausted capture limit, which propagates. + const described = await rejectionOf( + describeExit(await start("flood", capped)), + ); + expect(described).toBeInstanceOf(ProductRunOutputOverflowError); + expect(await describeExit(await start("exit"))).toContain("exit code 3"); +}, 60_000); diff --git a/test/self/s9-fixture-well-formedness.test.ts b/test/self/s9-fixture-well-formedness.test.ts new file mode 100644 index 00000000..e2e2c1ab --- /dev/null +++ b/test/self/s9-fixture-well-formedness.test.ts @@ -0,0 +1,1841 @@ +// Self-checks for the S-9 derivability check (`test/helpers/mdx-derivability.ts`; +// TEST-SPEC 17 S-9 — certification cannot exercise this class, so what is +// verified here is the check itself against the document's own declarations). +// SPEC 14.20 fixes the grammar: MDX syntax at major version 3, decided by +// derivability alone. Every shape TEST-SPEC works through as well-formed must +// derive — T3-3's multi-line-tag arms and its U+2028 ESM arm, T6.2-3's worked +// and U+000B/U+000C shapes, T6.2-4's pinned shapes, T2.1-6's ESM block inside +// a section, T3-7's comment forms, T14-12's positive arms, the composed +// forms of T6.5-13/T6.5-19, and every composed form T6.5-2 asserts as a +// performed move's result — and every shape the document declares +// unparseable must not: a leading byte-order mark, invalid UTF-8, an unclosed +// tag, a text-position tag closed by a flow tag on a later line (the former +// stagings of T3-1's `gamma` and of T6.2-3's impure origin, spelled exactly; +// their restaged sources are judged verbatim from the registry modules, with +// the two files T6.2-3's move leaves), the empty attribute expressions, the +// spread with extra +// content, an ESM block holding a statement, the one-sided section spellings +// 6.2/6.5 refuse, and T14-12's negative arms (the document's shapes below, +// and the staged sources themselves from section-14-iii.ts, whose pinned +// offsets the stock parser's positions confirm wherever they coincide). +// S-9's allowances — ECMAScript's +// early errors, which the stock parser enforces beyond derivability — apply +// only when named and only to their own early error; none passes an +// MDX-syntax rejection. Every non-ASCII or control character is built from +// its code point. + +import { Buffer } from "node:buffer"; +import * as acorn from "acorn"; +import ts from "typescript-5.9.3"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { + MDX_ALLOWANCES, + deriveMdx, + type MdxAllowance, +} from "../helpers/mdx-derivability.js"; +import { HarnessStagingError } from "../helpers/permissions.js"; +import { TestWorkspace, type WorkspaceDecl } from "../helpers/workspace.js"; +import { REMOVALS_SOURCE, T3_7_SOURCE } from "../suite/registry/section-3.js"; +import { P1_FORM_VECTORS } from "../suite/registry/section-16-p1.js"; +import { + ECMASCRIPT_ONLY_WHITESPACE, + P2_P3_FORM_VECTORS, +} from "../suite/registry/section-16-p2-p3.js"; +import { P4_FORM_VECTORS } from "../suite/registry/section-16-p4.js"; +import { P5_FORM_VECTORS } from "../suite/registry/section-16-p5-p6.js"; +import { P7_FORM_VECTORS } from "../suite/registry/section-16-p7.js"; +import { P9_FORM_VECTORS } from "../suite/registry/section-16-p9.js"; +import { P12_FORM_VECTORS } from "../suite/registry/section-16-p12.js"; +import { P13_FORM_VECTORS } from "../suite/registry/section-16-p13.js"; +import { + I3_HALL_MOVED_SOURCE, + I3_ROOM_MOVED_SOURCE, + I3_ROOM_SOURCE, +} from "../suite/registry/section-6.2.js"; +import { X2_COMPOSED_FORMS } from "../suite/registry/section-6.5.js"; +import { + T14_12_FORM_VECTORS, + T14_12_UNPARSEABLE_VECTORS, +} from "../suite/registry/section-14-iii.js"; +import { + A18_FORM_VECTORS, + A19_FORM_VECTORS, + A19_UNDERIVABLE_VECTORS, + J15_FORM_VECTORS, + M17_FORM_VECTORS, + R16_FORM_VECTORS, + R16_REFUSED_VECTORS, +} from "../suite/registry/section-6.5-iii.js"; + +const LF = String.fromCodePoint(0x000a); +const CR = String.fromCodePoint(0x000d); +const VT = String.fromCodePoint(0x000b); +const FF = String.fromCodePoint(0x000c); +const LS = String.fromCodePoint(0x2028); +const BOM = String.fromCodePoint(0xfeff); +const ASTRAL = String.fromCodePoint(0x1f600); + +/** acorn's identifier tables and whitespace list: runtime exports its + * declarations leave out. */ +const ACORN = acorn as unknown as { + readonly isIdentifierStart: (code: number, astral?: boolean) => boolean; + readonly isIdentifierChar: (code: number, astral?: boolean) => boolean; + readonly nonASCIIwhitespace: RegExp; +}; + +/** Lines joined by U+000A, the last one terminated. */ +const doc = (...lines: readonly string[]): string => lines.join(LF) + LF; + +const GAMMA_OPENING = '<S id="gamma">Gamma keeps this line.'; + +/** + * T3-1's `specs/A.mdx` as formerly staged: the registry staging's head, up to + * `gamma`, verbatim, then the former `gamma` region — a text-position tag + * with same-line content whose closing tag stood alone at a line's start + * after the second fence, a flow tag that interrupts the paragraph and leaves + * the in-line element unclosed (T3-3's staging constraint). The staging now + * closes `gamma` within its paragraph, at the end of the code-span line, the + * fence following at top level. + */ +function formerRemovalsSource(): string { + const at = REMOVALS_SOURCE.indexOf(GAMMA_OPENING); + if (at <= 0) { + throw new Error( + "T3-1's staged specs/A.mdx no longer opens `gamma` as expected", + ); + } + return ( + REMOVALS_SOURCE.slice(0, at) + + doc( + GAMMA_OPENING, + "More gamma prose.", + 'Inline code span: `<S id="x">{text("a")}` stays literal.', + "```md", + '<S id="x">', + 'import X from "./X.xspec"', + '{text("a")}', + "```", + "</S>", + ) + ); +} + +function expectDerives( + source: string | Uint8Array, + allowances?: readonly MdxAllowance[], +): void { + expect( + deriveMdx(source, allowances === undefined ? undefined : { allowances }), + ).toEqual({ derives: true }); +} + +function expectRejects( + source: string | Uint8Array, + allowances?: readonly MdxAllowance[], +): { + reason: string; + position?: { line: number; column: number; offset: number }; +} { + const verdict = deriveMdx( + source, + allowances === undefined ? undefined : { allowances }, + ); + expect(verdict.derives).toBe(false); + if (verdict.derives) throw new Error("unreachable"); + expect(verdict.reason.length).toBeGreaterThan(0); + return verdict; +} + +// --------------------------------------------------------------------------- +// Well-formed shapes the document works through. + +const WELL_FORMED: ReadonlyArray<readonly [name: string, source: string]> = [ + // T3-3's multi-line opening tags (S-9 gates the shapes). + ["T3-3 own-lines drop form", doc("<S", ' id="x"', ">", "body", "</S>")], + [ + "T3-3 in-line kept form", + doc("foo <S", ' id="x"', ' coverage="required"> bar</S>'), + ], + ["T3-3 flow-start kept form", doc("<S", ' id="x"', "> bar</S>")], + // T3-3's ESM arm: two imports on one physical line separated by U+2028. + [ + "T3-3 U+2028-separated imports", + doc( + `import A from "./A.xspec"${LS}import B from "./B.xspec"`, + "", + "{text(A.a)} {text(B.b)}", + ), + ], + // T6.2-3's worked shape and its U+000B/U+000C spellings, at the origin and + // as moved text at a line's start. + ["T6.2-3 (a) worked shape", doc('foo <S id="m">', "body", " </S> bar")], + ["T6.2-3 (a) moved text", doc('<S id="m">', "body", " </S>")], + [ + "T6.2-3 (b) both-sided U+000C", + doc(`foo <S id="m">${FF}`, "body", `${FF}</S> bar`), + ], + [ + "T6.2-3 (b) both-sided U+000B", + doc(`foo <S id="m">${VT}`, "body", `${VT}</S> bar`), + ], + ["T6.2-3 (b) moved text U+000C", doc(`<S id="m">${FF}`, "body", `${FF}</S>`)], + ["T6.2-3 (b) moved text U+000B", doc(`<S id="m">${VT}`, "body", `${VT}</S>`)], + [ + "T6.2-3 (c) body</S> with U+000C remainder", + doc(`foo <S id="m">${FF}`, "body</S>"), + ], + [ + "T6.2-3 (c) body</S> with U+000B remainder", + doc(`foo <S id="m">${VT}`, "body</S>"), + ], + ["T6.2-3 (c) moved text", doc(`<S id="m">${FF}`, "body</S>")], + [ + "T6.2-3 in-line variant on one line", + doc(`foo <S id="m">${VT}body${FF}</S> bar`), + ], + // T6.2-3 (d): two in-line siblings on a paragraph line inside a flow parent, + // and the sibling left alone on its line after the move (a flow element). + [ + "T6.2-3 (d) origin", + doc('<S id="p">', '<S id="p.s"> </S><S id="p.m">text</S>', "</S>"), + ], + ["T6.2-3 (d) after the move", doc('<S id="p">', '<S id="p.s"> </S>', "</S>")], + // T6.2-4's pinned final-position shapes and the `changed` twin. + [ + "T6.2-4 flow-form last child", + doc('<S id="p">', '<S id="p.m">', "y", "</S>", "</S>"), + ], + [ + "T6.2-4 twin (T6.5-13(e)) before", + doc('foo <S id="p">', '<S id="p.m">x</S></S> baz'), + ], + [ + "T6.2-4 twin composed", + doc('foo <S id="p">', '<S id="p.n">x</S>', "</S> baz"), + ], + // T2.1-6: an ESM block inside a section element, blank lines around it. + [ + "T2.1-6 ESM block inside a section", + doc( + '<S id="m">', + "", + 'import X from "./X.xspec"', + "", + "body {text(X.a)}", + "</S>", + ), + ], + // T3-7: JavaScript comments beside imports in one ESM block; a `;`-terminated import. + [ + "T3-7 ESM-block comments", + doc( + 'import A from "./A.xspec" // note', + "// note", + '/* c */ import B from "./B.xspec"', + 'import C from "./C.xspec";', + "", + "{text(A.a)} {text(B.b)} {text(C.c)}", + ), + ], + // T14-12's positive arms under the expression grammar alone. + ["T14-12 comma sequence", doc("{a, b}")], + [ + "T14-12 comma sequence in d", + doc('<S id="x" d={BASE.a, BASE.b}>', "", "body", "", "</S>"), + ], + ["T14-12 await admitted", doc("{await x}")], + ["T14-12 no statement lookahead restriction", doc("{function(){}}")], + [ + "T14-12 parenthesised spread operand", + doc('<S id="x" {...(a, b)}>', "", "body", "", "</S>"), + ], + ["T14-12 export declaration holding JSX", doc("export const x = <b/>")], + // Composed forms T6.5-13 and T6.5-19 name as deriving (the fresh identifier + // spelled `X` here; the moved text T6.5-13(h)'s clean-boundary section). + [ + "T6.5-13 (a) declaration appended after the final terminator", + doc( + '<S id="p">', + "x", + '<S id="p.n">', + "moved {text(X.a)}", + "</S>", + "</S>", + 'import X from "./x.xspec"', + ), + ], + [ + "T6.5-13 (a) sibling: declaration at an empty line's start", + [ + '<S id="p">', + "x", + '<S id="p.n">', + "moved {text(X.a)}", + "</S>", + "</S>", + 'import X from "./x.xspec"', + "", + "trailing", + ].join(LF), + ], + [ + "T6.5-13 (b) self-closing target rewritten", + doc( + '<S id="p">', + '<S id="p.n">', + "moved {text(X.a)}", + "</S>", + "</S>", + 'import X from "./x.xspec"', + ), + ], + [ + "T6.5-13 (d) top-level after an unterminated paragraph line", + doc( + "para", + '<S id="n">', + "moved {text(X.a)}", + "</S>", + 'import X from "./x.xspec"', + ), + ], + [ + "T6.5-19 (a) block inside the section derives (inadmissible, not ill-formed)", + [ + '<S id="p">', + 'import X from "./x.xspec"', + "", + "x", + '<S id="p.n">', + "moved {text(X.a)}", + "</S>", + "</S>", + ].join(LF), + ], + [ + "T6.5-19 (a) result", + doc( + '<S id="p">', + "", + "x", + '<S id="p.n">', + "moved {text(X.a)}", + "</S>", + "</S>", + 'import X from "./x.xspec"', + ), + ], + // Deterministic fixtures judged verbatim from their registry modules: + // T3-1's specs/A.mdx (`gamma` an in-line section closed within its + // paragraph, the second fence at top level), T3-7's specs/main.mdx (four + // ESM blocks carrying JavaScript comments beside their imports and a + // `;`-terminated declaration), and T6.2-3's impure origin in + // 6.2's worked shape, with the two files its move leaves (SPEC 6.5). + ["T3-1 specs/A.mdx as staged", REMOVALS_SOURCE], + ["T3-7 specs/main.mdx as staged", T3_7_SOURCE], + ["T6.2-3 impure origin specs/Room.mdx as staged", I3_ROOM_SOURCE], + ["T6.2-3 impure origin after the move", I3_ROOM_MOVED_SOURCE], + ["T6.2-3 impure destination after the move", I3_HALL_MOVED_SOURCE], + // Boundary forms that derive without any allowance. + ["a leading empty line", doc("", '<S id="m">', "body", "</S>")], + [ + "a CRLF-terminated file", + `<S id="m">${CR}${LF}body${CR}${LF}</S>${CR}${LF}`, + ], + ["a bare empty expression container", doc("{}")], + ["a fragment", doc("<>", "", "x", "", "</>")], +]; + +describe("S-9: the document's well-formed shapes derive", () => { + test.each(WELL_FORMED)("%s", (_name, source) => { + expectDerives(source); + expectDerives(Buffer.from(source, "utf8")); + }); +}); + +// --------------------------------------------------------------------------- +// The generators' fixed form vectors (S-9: "every form P-2, P-3, and P-5's +// generators compose … in the fixed vector set of those forms") — each +// enumerated form as its generator module spells it, judged here before any +// product exists; each draw is judged the same way at property time +// (helpers/property.ts `drawSources`). + +describe("S-9: every form the P-2/P-3 generator composes derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P2_P3_FORM_VECTORS.length).toBeGreaterThan(100); + expect(new Set(P2_P3_FORM_VECTORS.map(([name]) => name)).size).toBe( + P2_P3_FORM_VECTORS.length, + ); + }); + // TEST-SPEC §16 P-2 names the whitespace drawn between a comment's braces: + // U+FEFF, U+2028, U+2029, and every Unicode 15.1 space separator but U+0020 + // (U+00A0, U+1680, U+2000 through U+200A, U+202F, U+205F, and U+3000). + test("the generator draws exactly P-2's brace-side whitespace, and the vectors stage each code point alone between the braces and in every gap of a block-comment sequence", () => { + const named = [ + 0xfeff, 0x2028, 0x2029, 0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, + 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x202f, 0x205f, + 0x3000, + ]; + const byCode = (a: number, b: number): number => a - b; + expect( + ECMASCRIPT_ONLY_WHITESPACE.map((char) => char.codePointAt(0)).sort( + (a, b) => byCode(a ?? -1, b ?? -1), + ), + ).toEqual([...named].sort(byCode)); + for (const code of named) { + const char = String.fromCodePoint(code); + const alone = `{${char}}`; + const sequence = `{${char}/* ab */${char}/* cd */${char}}`; + expect( + P2_P3_FORM_VECTORS.some(([, source]) => source.includes(alone)), + ).toBe(true); + expect( + P2_P3_FORM_VECTORS.some(([, source]) => source.includes(sequence)), + ).toBe(true); + } + }); + test.each(P2_P3_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +describe("S-9: every form the PROP-03 rendering (P-4, P-5, P-6) composes derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P4_FORM_VECTORS.length).toBeGreaterThan(2); + expect(new Set(P4_FORM_VECTORS.map(([name]) => name)).size).toBe( + P4_FORM_VECTORS.length, + ); + }); + test.each(P4_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +describe("S-9: every decorated form the P-5 section-move staging composes derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P5_FORM_VECTORS.length).toBeGreaterThan(40); + expect(new Set(P5_FORM_VECTORS.map(([name]) => name)).size).toBe( + P5_FORM_VECTORS.length, + ); + }); + test.each(P5_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// The generators S-9's letter leaves to the per-draw check alone get the +// same fixed vector set (the §16 preamble: every generated workspace is +// valid by construction, S-9 verifying before any product exists that every +// composed form derives): P-1's accepted and rejected draws alike — every +// alphabet character, the forbidden-name shapes, the `.`-chains, single +// line terminators inside a value, the 2.6 whitespace runs — as segment +// draws and as `tags` values in every admissible quote kind. +describe("S-9: every form the P-1 segment and tags stagings compose derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P1_FORM_VECTORS.length).toBeGreaterThan(400); + expect(new Set(P1_FORM_VECTORS.map(([name]) => name)).size).toBe( + P1_FORM_VECTORS.length, + ); + }); + test.each(P1_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +describe("S-9: every form the P-7 discovery and capture stagings compose derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P7_FORM_VECTORS.length).toBe(4); + expect(new Set(P7_FORM_VECTORS.map(([name]) => name)).size).toBe( + P7_FORM_VECTORS.length, + ); + }); + test.each(P7_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +describe("S-9: every form the P-9 rendering composes, initially and after each edit class, derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P9_FORM_VECTORS.length).toBe(8); + expect(new Set(P9_FORM_VECTORS.map(([name]) => name)).size).toBe( + P9_FORM_VECTORS.length, + ); + }); + test.each(P9_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// P-12's workspaces are valid by construction (TEST-SPEC §16 preamble): no +// draw is declared unparseable, so every vector — the every-form file and +// each form's minimal context, with and without the import, each placed +// where the generator may compose it — must derive. +describe("S-9: every form the P-12 generator composes derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P12_FORM_VECTORS.length).toBe(44); + expect(new Set(P12_FORM_VECTORS.map(([name]) => name)).size).toBe( + P12_FORM_VECTORS.length, + ); + }); + test.each(P12_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +describe("S-9: every form the P-13 rendering composes derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(P13_FORM_VECTORS.length).toBe(3); + expect(new Set(P13_FORM_VECTORS.map(([name]) => name)).size).toBe( + P13_FORM_VECTORS.length, + ); + }); + test.each(P13_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// T6.5-2's byte-exact arms pin each geometry's composed text as a performed +// move's result (TEST-SPEC T6.5-2: "the composed form deriving (S-9)"); an +// expectation the parser rejects would bless a text SPEC 6.5 refuses +// (`refused-invalid-rewrite`, T6.5-16) — as the former mid-line arm did, its +// composed target listed among the unparseable shapes below. +describe("S-9: every composed form T6.5-2 asserts as a move's result derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(X2_COMPOSED_FORMS.length).toBeGreaterThan(8); + expect(new Set(X2_COMPOSED_FORMS.map(([name]) => name)).size).toBe( + X2_COMPOSED_FORMS.length, + ); + }); + test.each(X2_COMPOSED_FORMS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// T6.5-15's stagings and composed expectations: every origin and target as +// staged and as the joint import-removal judgment and the move leave it (the +// kept block headed by a declaration at its first line's start, or emptied). +describe("S-9: every form T6.5-15 stages or asserts as a move's result derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(J15_FORM_VECTORS.length).toBe(20); + expect(new Set(J15_FORM_VECTORS.map(([name]) => name)).size).toBe( + J15_FORM_VECTORS.length, + ); + }); + test.each(J15_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// T6.5-16's stagings, the deriving side of each refused rewrite (the other +// rewritten file as 6.5's edits would leave it), and each control's composed +// expectation: every one must derive, while every would-be text the entry +// refuses — the concerned file as the exact edits would leave it — must not, +// the ground of `refused-invalid-rewrite` (SPEC 6.5, 14.20). +describe("S-9: every form T6.5-16 stages, or asserts as a control's result or a refused rewrite's deriving side, derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(R16_FORM_VECTORS.length).toBe(162); + expect(new Set(R16_FORM_VECTORS.map(([name]) => name)).size).toBe( + R16_FORM_VECTORS.length, + ); + }); + test.each(R16_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// T14-12's positive arms as staged (section-14-iii.ts): every spec source +// derives under exactly the S-9 allowance its early-error form names — +// `duplicate-import-binding`, `undefined-export`, +// `invalid-assignment-target`, `let-as-identifier`, `legacy-octal` — and +// the expression-grammar forms plainly (SPEC 14.20: a finding in a +// well-formed file, never a parse failure), the Unicode-pin arms (ae)–(ag) +// among them: U+2EBF0 in an expression, a JSX element name, and an +// attribute name, judged code point by code point under Unicode 15.1. +describe("S-9: every form T14-12's positive arms stage derives under exactly its named allowances", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(T14_12_FORM_VECTORS.length).toBeGreaterThan(12); + expect(new Set(T14_12_FORM_VECTORS.map(([name]) => name)).size).toBe( + T14_12_FORM_VECTORS.length, + ); + }); + test.each(T14_12_FORM_VECTORS)("%s", (_name, source, allowances) => { + expectDerives(source, allowances.length === 0 ? undefined : allowances); + if (allowances.length > 0) { + // The allowance is load-bearing: without it the form does not derive. + expectRejects(source); + } + }); +}); + +// T14-12's negative arms in a spec source (section-14-iii.ts): each staged +// text is declared unparseable, and where the stock parser's rejection +// position coincides with the offset SPEC 14's rule fixes — every arm but +// the spread's, whose extra content the parser reports past the comma — the +// pinned byte offset is confirmed against it (the parser's position is an +// index into the decoded text, converted to the byte length of the prefix). +describe("S-9: every form T14-12's negative arms stage in a spec source does not derive", () => { + test("the vector set is complete and uniquely named", () => { + expect(T14_12_UNPARSEABLE_VECTORS.length).toBe(6); + expect(new Set(T14_12_UNPARSEABLE_VECTORS.map(([name]) => name)).size).toBe( + T14_12_UNPARSEABLE_VECTORS.length, + ); + expect( + T14_12_UNPARSEABLE_VECTORS.filter(([, , offset]) => offset !== null) + .length, + ).toBe(5); + }); + test.each(T14_12_UNPARSEABLE_VECTORS)("%s", (_name, source, offset) => { + const verdict = expectRejects(source); + expectRejects(Buffer.from(source, "utf8")); + // No allowance makes an MDX-syntax rejection pass. + expectRejects(source, MDX_ALLOWANCES); + if (offset !== null) { + expect(verdict.position).toBeDefined(); + expect( + Buffer.byteLength(source.slice(0, verdict.position?.offset), "utf8"), + ).toBe(offset); + } + }); +}); + +describe("S-9: every would-be text T6.5-16 refuses does not derive", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(R16_REFUSED_VECTORS.length).toBe(38); + expect(new Set(R16_REFUSED_VECTORS.map(([name]) => name)).size).toBe( + R16_REFUSED_VECTORS.length, + ); + }); + test.each(R16_REFUSED_VECTORS)("%s", (_name, source) => { + expectRejects(source); + // No allowance makes an MDX-syntax rejection pass. + expectRejects(source, MDX_ALLOWANCES); + }); +}); + +// T6.5-17 stages T2.1-6's in-section ESM block as the moved text of a +// refused move (each staged file valid and deriving, 14.20) and, for its +// control, composes the origin as the deletion leaves it and the target with +// the moved text and the added declaration after its closing tag: every one +// must derive (SPEC 6.5, 14.20). +describe("S-9: every form T6.5-17 stages, or asserts as its control's result, derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(M17_FORM_VECTORS.length).toBe(18); + expect(new Set(M17_FORM_VECTORS.map(([name]) => name)).size).toBe( + M17_FORM_VECTORS.length, + ); + }); + test.each(M17_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// T6.5-18 (section-6.5-iii.ts): the origin and target as staged and as the +// move leaves them — the origin opening with its kept blank line's +// terminator after the deletion's line drop, the moved text appended to the +// target after its final terminator: every one must derive (SPEC 6.5, 3). +describe("S-9: every form T6.5-18 stages or asserts as the move's result derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(A18_FORM_VECTORS.length).toBe(4); + expect(new Set(A18_FORM_VECTORS.map(([name]) => name)).size).toBe( + A18_FORM_VECTORS.length, + ); + }); + test.each(A18_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// T6.5-19 (section-6.5-iii.ts): each arm's stagings, the receiving file as +// every other edit leaves it, and the entry's named offsets that derive — +// the in-section forms at the interior empty line's start and at the tag +// line's end (deriving, yet excluded by 6.5), the paragraph-text forms, and +// the result at the file's end: every one must derive, the exclusion, not +// derivability, deciding the in-section ones (SPEC 6.5, 14.20). +describe("S-9: every form T6.5-19 stages, composes, or names as deriving derives", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(A19_FORM_VECTORS.length).toBe(18); + expect(new Set(A19_FORM_VECTORS.map(([name]) => name)).size).toBe( + A19_FORM_VECTORS.length, + ); + }); + test.each(A19_FORM_VECTORS)("%s", (_name, source) => { + expectDerives(source); + }); +}); + +// The offsets T6.5-19 names as absorbing the line after them — offset 0, +// the start of the body line, and (a)'s insertion point after the moved +// text — head a block that runs on into a tag or prose line: none derives. +describe("S-9: every absorbing offset T6.5-19 names does not derive", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(A19_UNDERIVABLE_VECTORS.length).toBe(5); + expect(new Set(A19_UNDERIVABLE_VECTORS.map(([name]) => name)).size).toBe( + A19_UNDERIVABLE_VECTORS.length, + ); + }); + test.each(A19_UNDERIVABLE_VECTORS)("%s", (_name, source) => { + expectRejects(source); + // No allowance makes an MDX-syntax rejection pass. + expectRejects(source, MDX_ALLOWANCES); + }); +}); + +// --------------------------------------------------------------------------- +// Declared-unparseable shapes. + +const UNPARSEABLE: ReadonlyArray<readonly [name: string, source: string]> = [ + ["an unclosed tag", doc('<S id="x">')], + ["a mismatched closing tag", doc('<S id="x">', "", "</T>")], + // The former stagings of T3-1's `gamma` and of T6.2-3's impure origin: a + // text-position tag with same-line content whose closing tag stood alone at + // a later line's start — a flow tag that interrupts the paragraph and leaves + // the in-line element unclosed (T3-3's staging constraint). + ["T3-1's former specs/A.mdx", formerRemovalsSource()], + [ + "T6.2-3's former impure origin", + doc( + '<S id="op">', + "Op holder text.", + "", + 'Lead-in prose.<S id="op.imp" coverage="none" tags="edge imp"> ', + "Impure line one.", + "Impure line two.", + "</S>", + "</S>", + ), + ], + [ + "a text-position tag closed on a later line", + doc('foo <S id="x"> bar', "", "</S>"), + ], + // T3-3's staging constraint: an in-line tag cannot end on a bare `>` line. + [ + "T3-3 in-line tag ending on a bare > line", + doc("foo <S", ' id="x"', "> bar</S>"), + ], + // 14.20: the braces of an attribute value admit no empty expression. + ["d={}", doc('<S id="x" d={}>', "", "body", "", "</S>")], + ["d={ /* c */ }", doc('<S id="x" d={ /* c */ }>', "", "body", "", "</S>")], + [ + "a spread with extra content", + doc('<S id="x" {...a, b}>', "", "body", "", "</S>"), + ], + ["a self-closing spread with extra content", doc('<S id="x" {...a, b} />')], + // A JavaScript syntax error inside an ESM block (the block runs to a blank line). + [ + "let x = ; in an ESM block", + doc('import { a } from "./a.mdx";', "let x = ;"), + ], + ["export let x = ;", doc("export let x = ;")], + // The one-sided U+000B/U+000C spellings of the worked shape (T6.2-3, + // T6.5-16): the moved text at a line's start does not derive. + [ + "one-sided U+000C after the opening tag", + doc(`<S id="m">${FF}`, "body", "</S>"), + ], + [ + "one-sided U+000C before the closing tag", + doc('<S id="m">', "body", `${FF}</S>`), + ], + [ + "one-sided U+000B after the opening tag", + doc(`<S id="m">${VT}`, "body", "</S>"), + ], + [ + "one-sided U+000B before the closing tag", + doc('<S id="m">', "body", `${VT}</S>`), + ], + // The `body</S>` variant with an empty, space, or tab remainder: a flow tag + // the closing tag inside the following paragraph cannot close. + ["body</S> with an empty remainder", doc('<S id="m">', "body</S>")], + ["body</S> with a space remainder", doc('<S id="m"> ', "body</S>")], + [ + "body</S> with a tab remainder", + doc(`<S id="m">${String.fromCodePoint(0x0009)}`, "body</S>"), + ], + // T14-12's negative arms in a spec source. + [ + "T14-12 an ESM block holding a statement", + doc('import A from "./A.xspec"', "const x = 1"), + ], + [ + "T14-12 import attributes", + doc('import A from "./A.xspec" with { type: "json" }'), + ], + ["T14-12 d={]}", doc('<S id="x" d={]}>', "", "body", "", "</S>")], + ["T14-12 {text(}", doc("{text(}")], + ["T14-12 an unbalanced brace at the file's end", '{text("a")'], + // T6.5-13 (b): an addition at offset 0 would absorb the tag's line. + [ + "an import line directly followed by a tag line", + doc('import X from "./x.xspec"', '<S id="p" />'), + ], + // T6.5-16 (c): a flow-form section — its tags alone on their lines — + // inside a text-position parent: the opening tag's line interrupts the + // paragraph holding the parent's opening tag, which is then never closed + // (T3-3's staging constraint). T6.5-2's former mid-line arm asserted + // exactly this composed target as a performed move's result. + [ + "a flow-form section inside a text-position parent (T6.5-2's former mid-line arm)", + doc( + '<S id="c">Gamma holder.', + '<S id="c.mv">', + "Moved text.", + "</S>", + "</S>", + ), + ], + // T6.5-16(d)'s setext remainder: the deletion leaves `===` under the + // paragraph holding the parent's text-position opening tag, a setext + // heading ending while the element opened inside it is still open. The + // parser's development build (the `development` export condition Vitest + // resolves) trips its stack-consistency assertion here before the + // element-matching rejection the production build raises; `deriveMdx` + // reports that assertion as the non-derivation it is. + [ + "setext underline under a text-position parent's opening tag", + doc('foo <S id="p">bar', "===", "</S> baz"), + ], +]; + +describe("S-9: declared-unparseable shapes do not derive", () => { + test.each(UNPARSEABLE)("%s", (_name, source) => { + expectRejects(source); + expectRejects(Buffer.from(source, "utf8")); + // No allowance makes an MDX-syntax rejection pass. + expectRejects(source, MDX_ALLOWANCES); + }); + + test("a rejection carries the parser's reason and position", () => { + const verdict = expectRejects(doc('<S id="x">', "", "</T>")); + expect(verdict.reason).toContain("mdast-util-mdx-jsx"); + expect(verdict.position).toEqual({ line: 3, column: 1, offset: 12 }); + }); +}); + +describe("S-9: encoding rules of 14.20 the parser does not apply", () => { + test("a leading byte-order mark is a non-derivation (bytes)", () => { + const verdict = expectRejects( + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(doc("# x"), "utf8"), + ]), + ); + expect(verdict.reason).toContain("byte-order mark"); + expect(verdict.position).toEqual({ line: 1, column: 1, offset: 0 }); + }); + + test("a leading U+FEFF in decoded content is its byte-order mark (string)", () => { + expect(expectRejects(BOM + doc("# x")).reason).toContain("byte-order mark"); + // A U+FEFF elsewhere is content (ECMAScript whitespace between braces). + expectDerives(doc(`# x ${BOM}y`)); + expectDerives(doc(`{${BOM}1}`)); + }); + + test.each([ + ["41 E2 82 41", [0x41, 0xe2, 0x82, 0x41], 1], + ["C0 80 (overlong)", [0xc0, 0x80], 0], + ["ED A0 80 (surrogate)", [0xed, 0xa0, 0x80], 0], + ["41 E2 82 at EOF (truncated)", [0x41, 0xe2, 0x82], 1], + [ + "43 61 66 C3 A9 FF (a valid 5-byte prefix then FF)", + [0x43, 0x61, 0x66, 0xc3, 0xa9, 0xff], + 5, + ], + ["F4 90 80 80 (above U+10FFFF)", [0xf4, 0x90, 0x80, 0x80], 0], + ["a stray continuation byte", [0x41, 0x0a, 0x80], 2], + ])( + "invalid UTF-8 %s is a non-derivation at its byte offset", + (_name, bytes, offset) => { + const verdict = expectRejects(Uint8Array.from(bytes)); + expect(verdict.reason).toContain("UTF-8"); + expect(verdict.position?.offset).toBe(offset); + }, + ); + + test("the decoding failure's line and column count the parser's line endings", () => { + const verdict = expectRejects( + Uint8Array.from([0x41, 0x0d, 0x0a, 0x42, 0x0d, 0xe2, 0x82, 0x41]), + ); + expect(verdict.position).toEqual({ line: 3, column: 1, offset: 5 }); + }); + + test("valid multi-byte UTF-8 derives, bytes and string alike", () => { + const text = doc( + `# ${ASTRAL} ${String.fromCodePoint(0x00e9)}`, + "", + `<S id="m">${String.fromCodePoint(0x2028)}</S>`, + ); + expectDerives(text); + expectDerives(Buffer.from(text, "utf8")); + }); + + test("a string holding a lone surrogate is not encodable as UTF-8", () => { + const verdict = expectRejects(`# ${String.fromCharCode(0xd83d)} x${LF}`); + expect(verdict.reason).toContain("surrogate"); + expect(verdict.position?.offset).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Unicode 15.1 (S-9; SPEC 14.20): the identifier characters — an +// expression's and a JSX name's alike — and the space separators are Unicode +// 15.1's, judged code point by code point, whatever the checking tool's +// tables say. The stock tools judge otherwise: acorn 8.17's identifier tables +// are Unicode 17's, and the MDX tokenizer reads a JSX name one UTF-16 code +// unit at a time by the runtime's tables (acorn-jsx, a JSX name inside an +// expression, one code unit at a time too) — so every vector naming U+1C89 +// or U+2EBF0 in an identifier or a name below fails under the stock +// judgement. Every non-ASCII character is built from its code point. + +const cp = (code: number): string => String.fromCodePoint(code); +/** U+1C89, a Unicode 16 letter: no identifier character under 15.1. */ +const U16_LETTER = cp(0x1c89); +/** U+2EBF0, the first character of CJK Unified Ideographs Extension I, + * which Unicode 15.1 added (T14-12's Unicode pin). */ +const EXT_I = cp(0x2ebf0); +/** U+2EBF1, its neighbour. */ +const EXT_I_NEXT = cp(0x2ebf1); +/** U+1D7CE MATHEMATICAL BOLD DIGIT ZERO (Nd): an astral identifier part + * that begins no identifier. */ +const BOLD_ZERO = cp(0x1d7ce); +/** U+180E, a space separator up to Unicode 6.2, a format character under + * 15.1: neither whitespace nor an identifier character (T2.7-4). */ +const U180E = cp(0x180e); +const BACKSLASH = cp(0x5c); +const NAME = (code: number): string => + `U+${code.toString(16).toUpperCase().padStart(4, "0")}`; + +/** The space separators (general category Zs) Unicode 15.1 places outside + * Latin-1, one T2.7-4 arm each (TEST-SPEC T2.7-4). */ +const SPACE_SEPARATORS_PAST_LATIN_1: readonly number[] = [ + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, + 0x2008, 0x2009, 0x200a, 0x202f, 0x205f, 0x3000, +]; + +/** ECMAScript 2024's WhiteSpace and LineTerminator under Unicode 15.1: TAB, + * VT, FF, ZWNBSP, the space separators (U+0020, U+00A0, and those above), LF, + * CR, LS, PS. */ +const WHITESPACE_151: ReadonlySet<number> = new Set([ + 0x09, + 0x0a, + 0x0b, + 0x0c, + 0x0d, + 0x20, + 0xa0, + 0xfeff, + 0x2028, + 0x2029, + ...SPACE_SEPARATORS_PAST_LATIN_1, +]); + +const UNICODE_151_DERIVES: ReadonlyArray< + readonly [name: string, source: string] +> = [ + [ + "`<a` U+2EBF0 `>x</a` U+2EBF0 `>`: an element name U+2EBF0 continues, both tags", + doc(`<a${EXT_I}>x</a${EXT_I}>`), + ], + [ + '`<a b` U+2EBF0 `="1" />`: an attribute name U+2EBF0 continues', + doc(`<a b${EXT_I}="1" />`), + ], + [ + "T14-12: the container `{` U+2EBF0 `}` alone on its line", + doc(`{${EXT_I}}`), + ], + [ + "T14-12: the element `<a` U+2EBF0 ` />` alone on its line", + doc(`<a${EXT_I} />`), + ], + [ + 'T14-12: the section `<S id="x" a` U+2EBF0 `="v" />` alone on its line', + doc(`<S id="x" a${EXT_I}="v" />`), + ], + ["`<` U+2EBF0 ` />`: U+2EBF0 begins a JSX name", doc(`<${EXT_I} />`)], + [ + "`<a.` U+2EBF0 ` />` and `<a:` U+2EBF0 ` />`: member and local names", + doc(`<a.${EXT_I} />`, "", `<a:${EXT_I} />`), + ], + [ + '`<a ` U+2EBF0 `:b="1" />`: an attribute name\'s prefix', + doc(`<a ${EXT_I}:b="1" />`), + ], + [ + "a text-position element, its attribute name holding U+2EBF0, mid-paragraph", + doc(`x <a b${EXT_I}="1">y</a> z`), + ], + [ + "an element in a block quote, after an astral character on an earlier line", + doc(`> ${EXT_I} quoted`, `> <a${EXT_I} />`), + ], + [ + "an element after an astral character and a CR LF line ending", + `x${EXT_I}${CR}${LF}${CR}${LF}<a${EXT_I} />${CR}${LF}`, + ], + [ + "astral characters inside a tag's attribute values, quoted and braced", + doc(`<a b="${EXT_I}" c={"${EXT_I}"} d={${EXT_I}} {...${EXT_I}} />`), + ], + [ + "a flow element followed on its line by a container holding U+2EBF0", + doc(`<a${EXT_I} />{${EXT_I}}`), + ], + [ + "`{<a` U+2EBF0 ` />}`: a JSX name inside a container (acorn-jsx)", + doc(`{<a${EXT_I} />}`), + ], + [ + "a JSX name holding U+2EBF0 inside an attribute expression (acorn-jsx)", + doc(`<a b={<c${EXT_I} />} />`), + ], + [ + "an ESM import binding U+2EBF0", + doc(`import ${EXT_I} from "./A.xspec"`, "", "Text."), + ], + ["a private name holding U+2EBF0", doc(`{class { #${EXT_I} = 1 }}`)], + ["a regular expression's group name U+2EBF0", doc(`{/(?<${EXT_I}>a)/}`)], + [ + "`<a` U+30FB ` />`: KATAKANA MIDDLE DOT continues a name under 15.1", + doc(`<a${cp(0x30fb)} />`), + ], + [ + "`<a` U+1D7CE ` />`: an astral identifier part continues a name", + doc(`<a${BOLD_ZERO} />`), + ], + [ + "U+1C89 outside every identifier: a string, attribute values, a comment, text", + doc( + `{"${U16_LETTER}"}`, + "", + `<a b="${U16_LETTER}" c={"${U16_LETTER}"} />`, + "", + `{/* ${U16_LETTER} */}`, + "", + `Text ${U16_LETTER} here.`, + ), + ], + ...SPACE_SEPARATORS_PAST_LATIN_1.map((code): readonly [string, string] => [ + `T2.7-4: \`{\` ${NAME(code)} \`}\`, a space separator, is an MDX comment`, + doc(`{${cp(code)}}`), + ]), + ...[0xa0, 0xfeff, 0x2028, 0x2029].map((code): readonly [string, string] => [ + `T2.7-4: \`{\` ${NAME(code)} \`}\` is an MDX comment`, + doc(`{${cp(code)}}`), + ]), + ...[0x1680, 0x3000, 0xfeff].map((code): readonly [string, string] => [ + `\`{1\` ${NAME(code)} \`+ 2}\`: whitespace between an expression's tokens`, + doc(`{1${cp(code)}+ 2}`), + ]), + ...[0x1680, 0x3000, 0xfeff, 0x2028].map((code): readonly [string, string] => [ + `\`<a\` ${NAME(code)} \`b="1" />\`: whitespace inside a tag`, + doc(`<a${cp(code)}b="1" />`), + ]), +]; + +const UNICODE_151_REJECTS: ReadonlyArray< + readonly [name: string, source: string, offset: number] +> = [ + [ + "`{` U+1C89 `}`: a Unicode 16 letter begins no identifier", + doc(`{${U16_LETTER}}`), + 1, + ], + ["`<a` U+1C89 ` />`: nor continues a JSX name", doc(`<a${U16_LETTER} />`), 2], + [ + "T2.7-4: `{` U+180E `}`, a format character under 15.1", + doc(`{${U180E}}`), + 1, + ], + ["T2.7-4: `{` U+0085 `}`", doc(`{${cp(0x85)}}`), 1], + ["T2.7-4: `{` U+200B `}`", doc(`{${cp(0x200b)}}`), 1], + ["`{a` U+1C89 `}`: nor continues an identifier", doc(`{a${U16_LETTER}}`), 2], + [ + "`{a` backslash `u{1C89}}`: nor through an escape sequence", + doc(`{a${BACKSLASH}u{1C89}}`), + 1, + ], + ["`<` U+1C89 ` />`: nor begins a JSX name", doc(`<${U16_LETTER} />`), 1], + [ + '`<a b` U+1C89 `="1" />`: nor continues an attribute name', + doc(`<a b${U16_LETTER}="1" />`), + 4, + ], + [ + "`<a.` U+1C89 ` />`: nor begins a member name", + doc(`<a.${U16_LETTER} />`), + 3, + ], + [ + "`{<a` U+1C89 ` />}`: nor continues a JSX name inside a container", + doc(`{<a${U16_LETTER} />}`), + 3, + ], + [ + "a JSX name holding U+1C89 inside an attribute expression", + doc(`<a b={<c${U16_LETTER} />} />`), + 8, + ], + [ + "an ESM import binding holding U+1C89", + doc(`import a${U16_LETTER} from "./A.xspec"`, "", "Text."), + 8, + ], + ["a private name holding U+1C89", doc(`{class { #a${U16_LETTER} = 1 }}`), 11], + [ + "a regular expression's group name U+1C89", + doc(`{/(?<${U16_LETTER}>a)/}`), + -1, + ], + [ + "`<` U+1D7CE ` />`: an astral identifier part begins no JSX name", + doc(`<${BOLD_ZERO} />`), + 1, + ], + [ + "`<a` U+2EBF0 `>x</a` U+2EBF1 `>`: names compared whole, code point by code point", + doc(`<a${EXT_I}>x</a${EXT_I_NEXT}>`), + -1, + ], + [ + '`<a` U+180E `b="1" />`: U+180E is no whitespace inside a tag', + doc(`<a${U180E}b="1" />`), + 2, + ], + [ + "`{1` U+180E `+ 2}`: nor between an expression's tokens", + doc(`{1${U180E}+ 2}`), + 2, + ], +]; + +describe("S-9: identifier characters and space separators are Unicode 15.1's, code point by code point", () => { + test.each(UNICODE_151_DERIVES)("%s derives", (_name, source) => { + expectDerives(source); + expectDerives(Buffer.from(source, "utf8")); + }); + + test.each(UNICODE_151_REJECTS)( + "%s does not derive", + (_name, source, offset) => { + const verdict = expectRejects(source); + // The offending code point, where the vector pins it (the stock + // parser's position, a UTF-16 index; -1 where the rejection lies + // elsewhere — a pattern's, or a closing tag's). + if (offset >= 0) expect(verdict.position?.offset).toBe(offset); + expectRejects(Buffer.from(source, "utf8")); + // No allowance admits a character 15.1 does not. + expectRejects(source, MDX_ALLOWANCES); + }, + ); + + test("TypeScript 5.9.3's ESNext tables are Unicode 15.1's at the version boundaries", () => { + const ESNEXT = ts.ScriptTarget.ESNext; + // [code point, begins an identifier, continues one] + const boundaries: ReadonlyArray<readonly [number, boolean, boolean]> = [ + [0x2ebf0, true, true], // added in 15.1 (CJK Extension I) + [0x31350, true, true], // added in 15.0 (CJK Extension H) + [0x1c89, false, false], // added in 16.0 + [0x323b0, false, false], // added in 17.0 (CJK Extension J) + [0x30fb, false, true], // joined ID_Continue in 15.1 + [0xff65, false, true], // joined ID_Continue in 15.1 + [0x200c, false, true], // ZWNJ: ID_Continue since 15.1, ES's anyway + [0x200d, false, true], // ZWJ + [0x00b7, false, true], // Other_ID_Continue + [0x2e2f, false, false], // Lm, but Pattern_Syntax + [0x180e, false, false], // Cf + [0x24, true, true], // `$` + [0x5f, true, true], // `_` + [0x30, false, true], // `0` + [0x2d, false, false], // `-` (a JSX name's own addition) + ]; + for (const [code, start, part] of boundaries) { + expect([NAME(code), ts.isIdentifierStart(code, ESNEXT)]).toEqual([ + NAME(code), + start, + ]); + expect([NAME(code), ts.isIdentifierPart(code, ESNEXT)]).toEqual([ + NAME(code), + part, + ]); + } + }); + + test("acorn admits every identifier character Unicode 15.1 admits, where 15.1 admits it", () => { + // The premise of holding acorn's identifier tokens to 15.1 as they + // finish: tables that admit more than 15.1's (Unicode 17's) tokenize + // every text 15.1 admits as 15.1's would. + const ESNEXT = ts.ScriptTarget.ESNext; + const missing: string[] = []; + for (let code = 0; code <= 0x10ffff; code++) { + if (code >= 0xd800 && code <= 0xdfff) continue; + if ( + ts.isIdentifierStart(code, ESNEXT) && + !ACORN.isIdentifierStart(code, true) + ) { + missing.push(`${NAME(code)} (start)`); + } + if ( + ts.isIdentifierPart(code, ESNEXT) && + !ACORN.isIdentifierChar(code, true) + ) { + missing.push(`${NAME(code)} (part)`); + } + } + expect(missing).toEqual([]); + }); + + test("acorn's whitespace is Unicode 15.1's: its fixed non-ASCII list", () => { + // acorn skips U+00A0 and the line separators by name, and any other + // code point from U+1680 up that its `nonASCIIwhitespace` matches. + const differing: string[] = []; + for (let code = 0x80; code <= 0xffff; code++) { + const acornSkips = + code === 0xa0 || + code === 0x2028 || + code === 0x2029 || + (code >= 0x1680 && + ACORN.nonASCIIwhitespace.test(String.fromCharCode(code))); + if (acornSkips !== WHITESPACE_151.has(code)) differing.push(NAME(code)); + } + expect(differing).toEqual([]); + }); + + test("this runtime's `\\s` is Unicode 15.1's (the empty-expression judgement reads it)", () => { + const whitespace = /\s/; + const differing: string[] = []; + for (let code = 0; code <= 0xffff; code++) { + if ( + whitespace.test(String.fromCharCode(code)) !== WHITESPACE_151.has(code) + ) { + differing.push(NAME(code)); + } + } + expect(differing).toEqual([]); + }); + + test("a rejection names the character as 15.1 judges it", () => { + expect(expectRejects(doc(`{${U16_LETTER}}`)).reason).toContain( + "Unicode 15.1", + ); + expect(expectRejects(doc(`<a${U16_LETTER} />`)).reason).toContain( + `(U+1C89, judged by Unicode 15.1)`, + ); + expect(expectRejects(doc(`<a${U180E}b="1" />`)).reason).toContain( + `(U+180E, judged by Unicode 15.1)`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Allowances: ECMAScript's early errors, each applying only when named and +// only to its own early error. + +const ALLOWANCE_FORMS: ReadonlyArray<readonly [MdxAllowance, string, string]> = + [ + [ + "duplicate-import-binding", + "two imports binding one identifier in one ESM block", + doc( + 'import { a } from "./x.xspec"', + 'import { a } from "./y.xspec"', + "", + "{text(a.k)}", + ), + ], + [ + "undefined-export", + "export { nope } after a used import", + doc('import A from "./A.xspec"', "export { nope }", "", "{text(A.a)}"), + ], + ["invalid-assignment-target", "{1 = 2}", doc("{1 = 2}")], + ["let-as-identifier", "{let}", doc("{let}")], + ["legacy-octal", "{010}", doc("{010}")], + ]; + +describe("S-9: the named allowances", () => { + test("the allowance list is exactly S-9's", () => { + expect([...MDX_ALLOWANCES]).toEqual([ + "duplicate-import-binding", + "undefined-export", + "invalid-assignment-target", + "let-as-identifier", + "legacy-octal", + ]); + }); + + test.each(ALLOWANCE_FORMS)( + "%s: %s derives when named and fails when unnamed", + (name, _label, source) => { + expectDerives(source, [name]); + expectDerives(Buffer.from(source, "utf8"), [name]); + expectRejects(source); + expectRejects(source, []); + // Naming every other allowance does not cover it. + expectRejects( + source, + MDX_ALLOWANCES.filter((other) => other !== name), + ); + }, + ); + + test.each(ALLOWANCE_FORMS)( + "%s: a different rejection under the named allowance still fails", + (name) => { + for (const [other, , source] of ALLOWANCE_FORMS) { + if (other !== name) expectRejects(source, [name]); + } + expectRejects(doc("{1e}"), [name]); + expectRejects(doc("{0x}"), [name]); + expectRejects(doc("export let x = ;"), [name]); + expectRejects(doc('<S id="x">'), [name]); + }, + ); + + test("duplicate-import-binding covers the same early error across ESM blocks (14.20 admits both)", () => { + const acrossBlocks = doc( + 'import { a } from "./x.xspec"', + "", + 'import { a } from "./y.xspec"', + "", + "{text(a.k)}", + ); + expectDerives(acrossBlocks, ["duplicate-import-binding"]); + expectRejects(acrossBlocks); + expectRejects(acrossBlocks, ["undefined-export"]); + }); + + test("duplicate-import-binding and undefined-export are ESM-block early errors only", () => { + // The same acorn messages inside an expression container are not imports. + expectRejects(doc("{(() => { let a; let a; })()}"), [ + "duplicate-import-binding", + ]); + }); + + test("legacy-octal is the legacy numeric literal at the rejection offset, not every 'Invalid number'", () => { + expectDerives(doc("{(010)}"), ["legacy-octal"]); + expectDerives(doc("{08}"), ["legacy-octal"]); + expectDerives(doc("export const x = 010"), ["legacy-octal"]); + // The offset the parser reports indexes the decoded text as it counts it. + expectDerives(doc(`{"${ASTRAL}" + 010}`), ["legacy-octal"]); + expectRejects(doc("{1e}"), ["legacy-octal"]); + expectRejects(doc("{0x}"), ["legacy-octal"]); + expectRejects(doc("{0o8}"), ["legacy-octal"]); + }); + + test("invalid-assignment-target applies in an ESM block too", () => { + expectDerives(doc("export const x = (1 = 2)"), [ + "invalid-assignment-target", + ]); + }); + + test("an allowed early error never hides a later rejection in the file", () => { + // The parse continues past a tolerated early error, so an MDX-syntax + // rejection, or an early error under an unnamed allowance, further on + // still surfaces. + const dupThenUnclosed = doc( + 'import { a } from "./x.xspec"', + 'import { a } from "./y.xspec"', + "", + '<S id="x">', + ); + expectRejects(dupThenUnclosed, ["duplicate-import-binding"]); + expect(expectRejects(dupThenUnclosed, MDX_ALLOWANCES).reason).toContain( + "closing tag", + ); + const assignThenEmptyAttribute = doc( + "{1 = 2}", + "", + '<S id="x" d={}>', + "", + "body", + "", + "</S>", + ); + expectRejects(assignThenEmptyAttribute, ["invalid-assignment-target"]); + const dupThenExport = doc( + 'import { a } from "./x.xspec"', + 'import { a } from "./y.xspec"', + "export { nope }", + ); + expectRejects(dupThenExport, ["duplicate-import-binding"]); + expectDerives(dupThenExport, [ + "duplicate-import-binding", + "undefined-export", + ]); + const octalThenLet = doc("{010} {let}"); + expectRejects(octalThenLet, ["legacy-octal"]); + expectDerives(octalThenLet, ["legacy-octal", "let-as-identifier"]); + }); + + test("several allowances may be named together, each for its own form", () => { + const all = doc( + 'import { a } from "./x.xspec"', + 'import { a } from "./y.xspec"', + "export { nope }", + "", + "{1 = 2} {let} {010}", + ); + expectDerives(all, MDX_ALLOWANCES); + expectRejects(all, ["duplicate-import-binding"]); + }); +}); + +// --------------------------------------------------------------------------- +// The builder-side wiring (helpers/workspace.ts): every staged `.mdx` file is +// judged at staging time against the staging's S-9 declaration — well-formed +// by default — and a contradiction is a harness error (`HarnessStagingError`, +// mode `mdx-derivability`), never an assertion failure and never a skip. A +// spec-group file not named `.mdx` (SPEC 7.1, 14.19: an invalid path, yet an +// MDX source 14.20 judges, kept parse-locally, 11.2) is judged the same way +// once its staging declares it an MDX source — `mdx.wellFormed`, another +// `mdx` list, or a `file()` option; an undeclared one is not judged. + +const STAGED_ILL_FORMED = doc('<S id="x">', "", "never closed"); +const STAGED_WELL_FORMED = doc('<S id="x">', "", "closed below", "", "</S>"); +const DUPLICATE_BINDING = doc( + 'import { a } from "./x.xspec"', + 'import { a } from "./y.xspec"', + "", + "# Doc", +); +const BOM_BYTES = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from("# Doc" + LF, "utf8"), +]); +const INVALID_UTF8_BYTES = Buffer.from([0x23, 0x20, 0xff, 0x0a]); + +async function stage(decl: WorkspaceDecl): Promise<TestWorkspace> { + const workspace = await TestWorkspace.create(decl); + onTestFinished(() => workspace.dispose()); + return workspace; +} + +async function staged(workspace: TestWorkspace, rel: string): Promise<string> { + return Buffer.from(await workspace.readBytes(rel)).toString("utf8"); +} + +async function expectStagingError( + action: () => Promise<unknown>, + path: string, + ...fragments: readonly string[] +): Promise<HarnessStagingError> { + let thrown: unknown; + try { + await action(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as HarnessStagingError; + expect(error.name).toBe("HarnessStagingError"); + expect(error.mode).toBe("mdx-derivability"); + expect(error.path).toBe(path); + expect(error.message).toContain(`mdx-derivability staging of ${path}: `); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } + return error; +} + +describe("S-9: the builder judges every staged `.mdx` source against its declaration", () => { + test("the default is well-formed: an ill-formed source throws at staging, naming the path and the parser's reason", async () => { + const error = await expectStagingError( + () => + TestWorkspace.create({ files: { "specs/A.mdx": STAGED_ILL_FORMED } }), + "specs/A.mdx", + "declared well-formed (S-9's default)", + "the stock MDX 3 parser rejects it", + "mdast-util-mdx-jsx", + "`mdx.unparseable`", + ); + expect(error.message).toContain(expectRejects(STAGED_ILL_FORMED).reason); + }); + + test("a well-formed source stages under the default, wherever it lies", async () => { + const workspace = await stage({ + files: { + "specs/A.mdx": STAGED_WELL_FORMED, + "specs/deep/B.mdx": "# B" + LF, + }, + }); + expect(await staged(workspace, "specs/A.mdx")).toBe(STAGED_WELL_FORMED); + expect(workspace.mdxDeclarationOf("specs/A.mdx")).toBe("well-formed"); + expect(workspace.mdxDeclarationOf("specs/deep/B.mdx")).toBe("well-formed"); + }); + + test("`unparseable`: the source must not derive; a deriving one throws", async () => { + const workspace = await stage({ + files: { "specs/A.mdx": STAGED_ILL_FORMED }, + mdx: { unparseable: ["specs/A.mdx"] }, + }); + expect(await staged(workspace, "specs/A.mdx")).toBe(STAGED_ILL_FORMED); + expect(workspace.mdxDeclarationOf("specs/A.mdx")).toBe("unparseable"); + await expectStagingError( + () => + TestWorkspace.create({ + files: { "specs/A.mdx": STAGED_WELL_FORMED }, + mdx: { unparseable: ["specs/A.mdx"] }, + }), + "specs/A.mdx", + "declared unparseable", + "derives", + ); + }); + + test("14.20's encoding rules: a byte-order mark and invalid UTF-8 are unparseable stagings", async () => { + await expectStagingError( + () => TestWorkspace.create({ files: { "specs/A.mdx": BOM_BYTES } }), + "specs/A.mdx", + "byte-order mark", + ); + await expectStagingError( + () => + TestWorkspace.create({ + files: { "specs/A.mdx": BOM + "# Doc" + LF }, + }), + "specs/A.mdx", + "byte-order mark", + ); + await expectStagingError( + () => + TestWorkspace.create({ files: { "specs/A.mdx": INVALID_UTF8_BYTES } }), + "specs/A.mdx", + "not valid UTF-8", + ); + const workspace = await stage({ + files: { "specs/A.mdx": BOM_BYTES, "specs/B.mdx": INVALID_UTF8_BYTES }, + mdx: { unparseable: ["specs/A.mdx", "specs/B.mdx"] }, + }); + expect(Buffer.from(await workspace.readBytes("specs/A.mdx"))).toEqual( + BOM_BYTES, + ); + expect(Buffer.from(await workspace.readBytes("specs/B.mdx"))).toEqual( + INVALID_UTF8_BYTES, + ); + }); + + test("`unchecked` skips the check, whatever the source", async () => { + const workspace = await stage({ + files: { + "specs/A.mdx": STAGED_ILL_FORMED, + "specs/B.mdx": STAGED_WELL_FORMED, + "specs/C.mdx": INVALID_UTF8_BYTES, + }, + mdx: { unchecked: ["specs/A.mdx", "specs/B.mdx", "specs/C.mdx"] }, + }); + expect(await staged(workspace, "specs/A.mdx")).toBe(STAGED_ILL_FORMED); + expect(workspace.mdxDeclarationOf("specs/A.mdx")).toBe("unchecked"); + }); + + test("`allowances`: the source derives under exactly the early errors named for it", async () => { + const workspace = await stage({ + files: { "specs/A.mdx": DUPLICATE_BINDING }, + mdx: { allowances: { "specs/A.mdx": ["duplicate-import-binding"] } }, + }); + expect(workspace.mdxDeclarationOf("specs/A.mdx")).toEqual({ + allowances: ["duplicate-import-binding"], + }); + await expectStagingError( + () => + TestWorkspace.create({ files: { "specs/A.mdx": DUPLICATE_BINDING } }), + "specs/A.mdx", + "declared well-formed (S-9's default)", + "already been declared", + ); + await expectStagingError( + () => + TestWorkspace.create({ + files: { "specs/A.mdx": DUPLICATE_BINDING }, + mdx: { allowances: { "specs/A.mdx": ["legacy-octal"] } }, + }), + "specs/A.mdx", + 'declared well-formed under the allowances ["legacy-octal"]', + "already been declared", + ); + // No allowance passes an MDX-syntax rejection. + await expectStagingError( + () => + TestWorkspace.create({ + files: { "specs/A.mdx": STAGED_ILL_FORMED }, + mdx: { allowances: { "specs/A.mdx": [...MDX_ALLOWANCES] } }, + }), + "specs/A.mdx", + "mdast-util-mdx-jsx", + ); + }); + + test("the workspace declaration governs later `file()` stagings; a call option overrides it for that write", async () => { + const workspace = await stage({ mdx: { unparseable: ["specs/A.mdx"] } }); + await workspace.file("specs/A.mdx", STAGED_ILL_FORMED); + await expectStagingError( + () => workspace.file("specs/A.mdx", STAGED_WELL_FORMED), + "specs/A.mdx", + "declared unparseable", + ); + await workspace.file("specs/A.mdx", STAGED_WELL_FORMED, { + mdx: "well-formed", + }); + expect(await staged(workspace, "specs/A.mdx")).toBe(STAGED_WELL_FORMED); + await expectStagingError( + () => workspace.file("specs/B.mdx", STAGED_ILL_FORMED), + "specs/B.mdx", + "declared well-formed (S-9's default)", + ); + await workspace.file("specs/B.mdx", STAGED_ILL_FORMED, { + mdx: "unparseable", + }); + await workspace.file("specs/B.mdx", STAGED_WELL_FORMED, { + mdx: "unchecked", + }); + await workspace.file("specs/C.mdx", DUPLICATE_BINDING, { + mdx: { allowances: ["duplicate-import-binding"] }, + }); + await expectStagingError( + () => workspace.file("specs/C.mdx", DUPLICATE_BINDING), + "specs/C.mdx", + "already been declared", + ); + }); + + test("a refused staging writes nothing", async () => { + const workspace = await stage({ + files: { "specs/A.mdx": STAGED_WELL_FORMED }, + }); + await expectStagingError( + () => workspace.file("specs/A.mdx", STAGED_ILL_FORMED), + "specs/A.mdx", + ); + expect(await staged(workspace, "specs/A.mdx")).toBe(STAGED_WELL_FORMED); + await expectStagingError( + () => workspace.file("specs/deep/D.mdx", STAGED_ILL_FORMED), + "specs/deep/D.mdx", + ); + expect(await workspace.kind("specs/deep")).toBe("absent"); + }); + + test("an undeclared path not named `.mdx` is not judged; declaration keys are normalized paths; a byte path is keyed by its decoding", async () => { + const workspace = await stage({ + files: { + "notes.md": STAGED_ILL_FORMED, + "specs/A.mdx.txt": STAGED_ILL_FORMED, + "specs/A.MDX": STAGED_ILL_FORMED, + "./specs/B.mdx": STAGED_ILL_FORMED, + }, + mdx: { unparseable: ["specs//B.mdx"] }, + }); + expect(workspace.mdxDeclarationOf("notes.md")).toBeUndefined(); + expect(workspace.mdxDeclarationOf("specs/A.MDX")).toBeUndefined(); + expect(workspace.mdxDeclarationOf("specs/./B.mdx")).toBe("unparseable"); + const bytePath = Buffer.from("specs/E.mdx", "utf8"); + expect(workspace.mdxDeclarationOf(bytePath)).toBe("well-formed"); + await expectStagingError( + () => workspace.file(bytePath, STAGED_ILL_FORMED), + "specs/E.mdx", + ); + await workspace.file(bytePath, STAGED_ILL_FORMED, { mdx: "unparseable" }); + expect(await staged(workspace, "specs/E.mdx")).toBe(STAGED_ILL_FORMED); + }); + + test("a declaration defect is refused at creation", async () => { + await expectStagingError( + () => + TestWorkspace.create({ + mdx: { unparseable: ["specs/A.mdx"], unchecked: ["specs/A.mdx"] }, + }), + "specs/A.mdx", + "more than one", + ); + await expectStagingError( + () => + TestWorkspace.create({ + mdx: { wellFormed: ["specs/A.txt"], unparseable: ["specs/A.txt"] }, + }), + "specs/A.txt", + "more than one of", + "`wellFormed`", + ); + // `wellFormed` declares a path of another name; an `.mdx` path is + // well-formed by default, and naming it there is refused. + await expectStagingError( + () => TestWorkspace.create({ mdx: { wellFormed: ["specs//A.mdx"] } }), + "specs/A.mdx", + "`wellFormed` list names an `.mdx` path", + ); + await expectStagingError( + () => + TestWorkspace.create({ mdx: { allowances: { "specs/A.mdx": [] } } }), + "specs/A.mdx", + "empty allowance list", + ); + await expectStagingError( + () => + TestWorkspace.create({ + mdx: { + allowances: { + "specs/A.mdx": ["no-such-allowance" as MdxAllowance], + }, + }, + }), + "specs/A.mdx", + "unknown allowance", + ); + }); +}); + +// --------------------------------------------------------------------------- +// A spec-group file not named `.mdx` — a match without the extension, an +// invalid path (SPEC 7.1, 14.19) whose content 14.20 still judges and 11.2 +// keeps parse-locally (T7.1-1's `specs/notes.txt`, T11.6-2's and T11.6-4's +// `specs/note.txt`): its name does not reach S-9's default, so its staging +// declares it an MDX source, and the builder then judges it exactly as an +// `.mdx` path — every verdict, every staging. + +describe("S-9: the builder judges a spec-group file not named `.mdx` that its staging declares an MDX source", () => { + const NOTES = "specs/notes.txt"; + + test("declared well-formed (`mdx.wellFormed`, or a `file()` option): an ill-formed source is refused with `mdx-derivability`, naming the path and the parser's reason, and nothing is written", async () => { + const error = await expectStagingError( + () => + TestWorkspace.create({ + files: { [NOTES]: STAGED_ILL_FORMED }, + mdx: { wellFormed: [NOTES] }, + }), + NOTES, + "declared well-formed", + "the stock MDX 3 parser rejects it", + "mdast-util-mdx-jsx", + ); + expect(error.message).toContain(expectRejects(STAGED_ILL_FORMED).reason); + const declared = await stage({ mdx: { wellFormed: [NOTES] } }); + expect(declared.mdxDeclarationOf(NOTES)).toBe("well-formed"); + await expectStagingError( + () => declared.file(NOTES, STAGED_ILL_FORMED), + NOTES, + "declared well-formed", + ); + expect(await declared.kind(NOTES)).toBe("absent"); + const optioned = await stage({}); + expect(optioned.mdxDeclarationOf(NOTES)).toBeUndefined(); + await expectStagingError( + () => optioned.file(NOTES, STAGED_ILL_FORMED, { mdx: "well-formed" }), + NOTES, + "declared well-formed", + ); + expect(await optioned.kind(NOTES)).toBe("absent"); + }); + + test("a well-formed source is staged byte-exact, at creation and by `file()`", async () => { + const expected = Buffer.from(STAGED_WELL_FORMED, "utf8"); + const workspace = await stage({ + files: { [NOTES]: STAGED_WELL_FORMED }, + mdx: { wellFormed: [NOTES] }, + }); + expect(Buffer.from(await workspace.readBytes(NOTES))).toEqual(expected); + await workspace.file("specs/other.md", STAGED_WELL_FORMED, { + mdx: "well-formed", + }); + expect(Buffer.from(await workspace.readBytes("specs/other.md"))).toEqual( + expected, + ); + }); + + test("declared unparseable: a deriving source is refused, nothing written; an ill-formed one stages", async () => { + await expectStagingError( + () => + TestWorkspace.create({ + files: { [NOTES]: STAGED_WELL_FORMED }, + mdx: { unparseable: [NOTES] }, + }), + NOTES, + "declared unparseable", + "derives", + ); + const workspace = await stage({ mdx: { unparseable: [NOTES] } }); + expect(workspace.mdxDeclarationOf(NOTES)).toBe("unparseable"); + await expectStagingError( + () => workspace.file(NOTES, STAGED_WELL_FORMED), + NOTES, + "declared unparseable", + ); + expect(await workspace.kind(NOTES)).toBe("absent"); + await expectStagingError( + () => + workspace.file("specs/x.txt", STAGED_WELL_FORMED, { + mdx: "unparseable", + }), + "specs/x.txt", + "declared unparseable", + ); + expect(await workspace.kind("specs/x.txt")).toBe("absent"); + await workspace.file(NOTES, STAGED_ILL_FORMED); + expect(await staged(workspace, NOTES)).toBe(STAGED_ILL_FORMED); + }); + + test("the other lists and stagings judge such a path as an `.mdx` path: `unchecked`, `allowances`, `perDraw`, `edit()`, and `copyFrom()`", async () => { + const workspace = await stage({ + files: { + "specs/fuzz.txt": STAGED_ILL_FORMED, + "specs/dup.txt": DUPLICATE_BINDING, + "specs/draw.txt": STAGED_WELL_FORMED, + [NOTES]: STAGED_WELL_FORMED, + }, + mdx: { + unchecked: ["specs/fuzz.txt"], + allowances: { "specs/dup.txt": ["duplicate-import-binding"] }, + perDraw: ["specs/draw.txt"], + wellFormed: [NOTES], + }, + }); + expect(workspace.mdxDeclarationOf("specs/fuzz.txt")).toBe("unchecked"); + expect(workspace.mdxDeclarationOf("specs/dup.txt")).toEqual({ + allowances: ["duplicate-import-binding"], + }); + expect(workspace.mdxDeclarationOf("specs/draw.txt")).toBe("per-draw"); + await expectStagingError( + () => + TestWorkspace.create({ + files: { "specs/dup.txt": DUPLICATE_BINDING }, + mdx: { wellFormed: ["specs/dup.txt"] }, + }), + "specs/dup.txt", + "already been declared", + ); + await expectStagingError( + () => + TestWorkspace.create({ + files: { "specs/draw.txt": STAGED_ILL_FORMED }, + mdx: { perDraw: ["specs/draw.txt"] }, + }), + "specs/draw.txt", + "declared well-formed per draw", + ); + // `edit()` judges the rewrite under the path's declaration. + await expectStagingError( + () => workspace.edit(NOTES, "</S>", ""), + NOTES, + "declared well-formed", + ); + expect(await staged(workspace, NOTES)).toBe(STAGED_WELL_FORMED); + const edited = STAGED_WELL_FORMED.replace("closed below", "closed, edited"); + await workspace.edit(NOTES, "closed below", "closed, edited"); + expect(await staged(workspace, NOTES)).toBe(edited); + // `copyFrom()` judges the bytes under this workspace's declaration for + // the destination (undeclared in the source workspace, never judged + // there). + const source = await stage({ + files: { "specs/ill.txt": STAGED_ILL_FORMED }, + }); + await expectStagingError( + () => workspace.copyFrom(source, "specs/ill.txt", NOTES), + NOTES, + "declared well-formed", + ); + expect(await staged(workspace, NOTES)).toBe(edited); + await workspace.copyFrom(source, "specs/ill.txt", "specs/fuzz.txt"); + expect(await staged(workspace, "specs/fuzz.txt")).toBe(STAGED_ILL_FORMED); + }); +}); diff --git a/test/self/s9-staged-sources.test.ts b/test/self/s9-staged-sources.test.ts new file mode 100644 index 00000000..86b61ecd --- /dev/null +++ b/test/self/s9-staged-sources.test.ts @@ -0,0 +1,1427 @@ +// Self-test of the staged-source ledger (test/helpers/staged-mdx.ts; +// TEST-SPEC 17 S-9, H-8). Every MDX source a registered test body stages +// after a product invocation in its workspace — an edit, a replacement, an +// arm's variant — is a deterministic fixture file whose well-formedness the +// document declares, and S-9's check runs for it before any product exists. +// S-7's sweep against the empty stub reaches a test's initial staging and the +// `file()` calls before its first invocation only — the body fails at that +// invocation — so a post-invocation staging used to be judged first at suite +// time, against a real product. The ledger closes that gap: each such source +// is a record created at module load (the very expression the staging used, +// moved, never re-spelled) carrying its bytes and its S-9 declaration; +// `TestWorkspace.file()` stages the record; and this file, which loads the +// whole registry (so the ledger is complete and sealed), judges every record +// against its declaration with the builder's own judge (`judgeMdxDeclaration` +// — the one code path `file()` applies at staging time). A record +// contradicting its declaration fails here, before any product exists, as a +// `HarnessStagingError` (mode `mdx-derivability`) — never a product failure, +// never a skip. +// +// Also verified: the ledger's invariants (sealed once the registry has +// loaded, so a run-time registration throws; non-empty; uniquely named, each +// name led by the ID of a registered test, or by E-6 for the §18 exchange +// fixture's four records — helpers/e6.ts is no registry entry, so this file +// imports it before the manifest seals the ledger; no `unchecked` record — +// that declaration is P-8's mutations' alone), its registration rules on a fresh +// unsealed instance, the builder's record overload (a record stages exactly +// its bytes under its own declaration, which overrides the workspace's for +// the path; an `mdx` option beside it throws with nothing written; a record +// makes its path an MDX source whatever the name, so at a spec-group file's +// path not named `.mdx` it stages, and a contradicting one throws, as at an +// `.mdx` path), the record-accepting initial `files` of a workspace +// declaration (`InitialFileContents` — the form of a later-arm workspace's +// initial MDX sources, which S-7's sweep never reaches: `create()` stages +// a record under the record's declaration, at a key of any name; a record +// beside a workspace-declaration entry naming its path throws; the +// declaration's `perDraw` list judges a draw's initial file as well-formed +// and declares the path `per-draw`; `mdxPathsOf` lists a rendered map's +// plain `.mdx` keys for such a list, records left out), the builder's +// `edit()` (a rewrite of the current bytes, judged under the path's +// declaration), and the judge's own red checks (an ill-formed source +// declared well-formed and a deriving source declared unparseable both +// throw; `unchecked` judges nothing). +// +// The ledger's TypeScript records (helpers/staged-ts.ts; S-9's TypeScript +// clause) are verified the same way: every code source and configuration +// file a body stages after its first product invocation is a `StagedTs` +// record created at module load — its bytes, its declaration (well-formed +// or unparseable, never `unchecked`), and the grammar it is judged under +// (`ts`, or `tsx` for a `.tsx` path; SPEC 14.20) — and this file judges +// every one with the builder's own TypeScript judge (`judgeTsDeclaration`, +// handed a neutral file name of the record's grammar) before any product +// exists. Also verified: the records' invariants once the registry has +// loaded (sealed, non-empty, uniquely named after registered tests or E-6; +// the Windows leg's drive-mismatch arm's configuration and spec source, +// helpers/e6-drive-mismatch.ts — staged at creation outside every +// registered body and S-7's sweep — imported before the seal like E-6's), +// their registration rules on a fresh instance, and the builder's record +// overload — `file()` and `create()`'s initial `files` stage a record's +// bytes under the record's declaration, whatever the path's name or +// workspace declaration (a `ts` option beside it, a workspace `ts` entry +// beside an initial record, a path selecting the other grammar, and an +// MDX source's path — an `.mdx` path, or one the workspace's `mdx` +// declaration or an `mdx` option declares an MDX source — all throw, +// nothing written); a contradicting record throws at staging exactly as +// here. + +import { Buffer } from "node:buffer"; +import { describe, expect, onTestFinished, test, vi } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import type { MdxAllowance } from "../helpers/mdx-derivability.js"; +import { HarnessStagingError } from "../helpers/permissions.js"; +import { + StagedMdx, + isStagedMdxLedgerSealed, + stagedMdx, + stagedMdxLedger, +} from "../helpers/staged-mdx.js"; +import { + StagedTs, + isStagedTsLedgerSealed, + stagedTs, + stagedTsLedger, + tsGrammarFileName, +} from "../helpers/staged-ts.js"; +import { + TestWorkspace, + judgeMdxDeclaration, + judgeTsDeclaration, + mdxPathsOf, +} from "../helpers/workspace.js"; +import type { WorkspaceDecl, WorkspaceMdxDecl } from "../helpers/workspace.js"; +// The E-6 exchange fixture (helpers/e6.ts) is no registry entry, yet stages +// four `.mdx` sources no sweep reaches — three initial files, and one edit +// after its first invocations — as records of its own, which this import +// registers BEFORE the registry manifest below seals the ledger. +import "../helpers/e6.js"; +// Likewise the Windows leg's drive-mismatch arm of T11.6-1 +// (test/windows/e6-drive-mismatch.test.ts): it stages its configuration and +// spec source at creation, outside every registered body and S-7's sweep, +// as records of helpers/e6-drive-mismatch.ts, registered here before the +// seal and judged below. +import { + ANCHOR_CONFIG as DRIVE_MISMATCH_CONFIG, + ANCHOR_SOURCE as DRIVE_MISMATCH_SOURCE, +} from "../helpers/e6-drive-mismatch.js"; +import { productTestSuite } from "../suite/registry/index.js"; + +const LF = String.fromCodePoint(0x000a); + +/** Lines joined by U+000A, the last one terminated. */ +const doc = (...lines: readonly string[]): string => lines.join(LF) + LF; + +const utf8 = (text: string): Uint8Array => Buffer.from(text, "utf8"); + +const bytesOf = (source: string | Uint8Array): Uint8Array => + typeof source === "string" ? utf8(source) : source; + +const text = (data: Uint8Array): string => Buffer.from(data).toString("utf8"); + +/** The complete ledger: every registry module has loaded through the manifest. */ +const LEDGER = stagedMdxLedger(); + +/** TEST-SPEC §18 E-6's ID: the exchange fixture (helpers/e6.ts), no registry entry. */ +const E6_FIXTURE_ID = "E-6"; + +const ILL_FORMED = doc('<S id="x">', "", "never closed"); +const WELL_FORMED = doc('<S id="x">', "", "closed below", "", "</S>"); +const DUPLICATE_BINDING = doc( + 'import { a } from "./x.xspec"', + 'import { a } from "./y.xspec"', + "", + "# Doc", +); + +async function stage(decl: WorkspaceDecl = {}): Promise<TestWorkspace> { + const workspace = await TestWorkspace.create(decl); + onTestFinished(() => workspace.dispose()); + return workspace; +} + +function expectMdxStagingError( + thrown: unknown, + key: string, + ...fragments: readonly string[] +): void { + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as HarnessStagingError; + expect(error.name).toBe("HarnessStagingError"); + expect(error.mode).toBe("mdx-derivability"); + expect(error.path).toBe(key); + expect(error.message).toContain(`mdx-derivability staging of ${key}: `); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } +} + +async function expectStagingRejected( + action: () => Promise<unknown>, + key: string, + ...fragments: readonly string[] +): Promise<void> { + let thrown: unknown; + try { + await action(); + } catch (error) { + thrown = error; + } + expectMdxStagingError(thrown, key, ...fragments); +} + +function expectJudgeRejects( + action: () => void, + key: string, + ...fragments: readonly string[] +): void { + let thrown: unknown; + try { + action(); + } catch (error) { + thrown = error; + } + expectMdxStagingError(thrown, key, ...fragments); +} + +/** A well-formed record of the registry's own, for the builder checks. */ +function someWellFormedRecord(): StagedMdx { + const record = LEDGER.find((entry) => entry.mdx === "well-formed"); + if (record === undefined) { + throw new Error("the ledger holds no well-formed record"); + } + return record; +} + +/** A record of the registry's own declared unparseable, for the builder checks. */ +function someUnparseableRecord(): StagedMdx { + const record = LEDGER.find((entry) => entry.mdx === "unparseable"); + if (record === undefined) { + throw new Error("the ledger holds no unparseable record"); + } + return record; +} + +/** + * `TestWorkspace.create(decl)` must be refused with an S-9 staging error + * naming `key`; a workspace it unexpectedly yields is disposed. + */ +async function createRejected( + decl: WorkspaceDecl, + key: string, + ...fragments: readonly string[] +): Promise<void> { + await expectStagingRejected( + async () => { + const workspace = await TestWorkspace.create(decl); + onTestFinished(() => workspace.dispose()); + }, + key, + ...fragments, + ); +} + +/** + * A fresh, unsealed ledger and the builder bound to it — both modules + * re-evaluated after `vi.resetModules()`, so the fresh builder's `StagedMdx` + * is the fresh ledger's class — for the checks that need a record + * contradicting its declaration: the sealed registry ledger holds none, + * this file having judged every record. The fresh builder's errors are the + * fresh permissions module's, matched by name and mode, not `instanceof`. + */ +async function freshBuilder(): Promise<{ + readonly ledger: typeof import("../helpers/staged-mdx.js"); + readonly builder: typeof import("../helpers/workspace.js"); +}> { + vi.resetModules(); + const ledger = await import("../helpers/staged-mdx.js"); + const builder = await import("../helpers/workspace.js"); + return { ledger, builder }; +} + +function expectFreshStagingError( + thrown: unknown, + key: string, + ...fragments: readonly string[] +): void { + expect(thrown).toBeInstanceOf(Error); + const error = thrown as Error & { mode?: unknown; path?: unknown }; + expect(error.name).toBe("HarnessStagingError"); + expect(error.mode).toBe("mdx-derivability"); + expect(error.path).toBe(key); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } +} + +// --------------------------------------------------------------------------- +// The ledger's invariants, with the whole registry loaded. + +describe("S-9: the staged-source ledger, once the registry has loaded", () => { + test("is sealed, so a record created at run time throws instead of escaping this self-test", () => { + expect(isStagedMdxLedgerSealed()).toBe(true); + expect(() => stagedMdx("T0-0 late record", doc("# Late"))).toThrow( + /sealed/, + ); + expect(() => new StagedMdx("T0-0 late record", doc("# Late"))).toThrow( + /sealed/, + ); + expect(LEDGER.some((record) => record.name === "T0-0 late record")).toBe( + false, + ); + }); + + test("is non-empty, and every record is an immutable, uniquely named `StagedMdx`", () => { + expect(LEDGER.length).toBeGreaterThan(0); + const names = LEDGER.map((record) => record.name); + expect(new Set(names).size).toBe(names.length); + for (const record of LEDGER) { + expect(record).toBeInstanceOf(StagedMdx); + expect(Object.isFrozen(record)).toBe(true); + expect(record.name.trim().length).toBeGreaterThan(0); + expect( + typeof record.source === "string" || + record.source instanceof Uint8Array, + ).toBe(true); + } + }); + + test("names every record after registered tests: `<TEST-ID>[/<TEST-ID>…] <what it stages>` — or after E-6, the §18 exchange fixture's ID (helpers/e6.ts), for its four records", () => { + let e6Records = 0; + for (const record of LEDGER) { + const [lead, ...rest] = record.name.split(" "); + expect(rest.join(" ").trim(), record.name).not.toBe(""); + for (const id of (lead ?? "").split("/")) { + if (id === E6_FIXTURE_ID) { + e6Records += 1; + continue; + } + expect( + productTestSuite.has(id), + `${record.name}: ${id} names no registered test (nor ${E6_FIXTURE_ID}, the exchange fixture of helpers/e6.ts)`, + ).toBe(true); + } + } + // The fixture's records — its three initial `.mdx` sources and its leaf + // edit — are judged here like every other: its module loaded before the + // seal (the import order above). + expect(e6Records).toBe(4); + }); + + test("holds no `unchecked` record (that declaration is P-8's mutations' alone, S-9)", () => { + for (const record of LEDGER) { + expect(record.mdx, record.name).not.toBe("unchecked"); + } + }); +}); + +// --------------------------------------------------------------------------- +// The check S-9 requires before any product exists: every record matches its +// declaration under the builder's own judge. + +describe("S-9: every staged source in the ledger matches its declaration before any product exists", () => { + test.each(LEDGER.map((record) => [record.name, record] as const))( + "%s", + (_name, record) => { + judgeMdxDeclaration(record.name, bytesOf(record.source), record.mdx); + }, + ); +}); + +// --------------------------------------------------------------------------- +// The registration rules, on a fresh, unsealed instance of the module. + +describe("S-9: the ledger's registration rules", () => { + async function freshLedger(): Promise< + typeof import("../helpers/staged-mdx.js") + > { + vi.resetModules(); + return await import("../helpers/staged-mdx.js"); + } + + test("a record registers in order with its declaration, well-formed by default; a duplicate name throws", async () => { + const ledger = await freshLedger(); + expect(ledger.isStagedMdxLedgerSealed()).toBe(false); + expect(ledger.stagedMdxLedger()).toEqual([]); + const a = ledger.stagedMdx("T0-1 a", WELL_FORMED); + const b = ledger.stagedMdx("T0-1 b", utf8(ILL_FORMED), "unparseable"); + const c = ledger.stagedMdx("T0-1 c", DUPLICATE_BINDING, { + allowances: ["duplicate-import-binding"], + }); + expect(a.mdx).toBe("well-formed"); + expect(a.source).toBe(WELL_FORMED); + expect(b.mdx).toBe("unparseable"); + expect(c.mdx).toEqual({ allowances: ["duplicate-import-binding"] }); + expect(ledger.stagedMdxLedger()).toEqual([a, b, c]); + expect(() => ledger.stagedMdx("T0-1 a", WELL_FORMED)).toThrow(/duplicate/); + expect(ledger.stagedMdxLedger()).toHaveLength(3); + }); + + test("an `unchecked` declaration, an empty name, an unknown allowance, an empty allowance list, and non-file contents are refused", async () => { + const ledger = await freshLedger(); + expect(() => ledger.stagedMdx("T0-2 u", WELL_FORMED, "unchecked")).toThrow( + /unchecked/, + ); + expect(() => ledger.stagedMdx(" ", WELL_FORMED)).toThrow(/non-empty name/); + expect(() => + ledger.stagedMdx("T0-2 n", WELL_FORMED, { + allowances: ["nope" as MdxAllowance], + }), + ).toThrow(/invalid declaration/); + expect(() => + ledger.stagedMdx("T0-2 e", WELL_FORMED, { allowances: [] }), + ).toThrow(/invalid declaration/); + expect(() => + ledger.stagedMdx("T0-2 c", { text: WELL_FORMED } as unknown as string), + ).toThrow(/string or byte contents/); + expect(ledger.stagedMdxLedger()).toEqual([]); + }); + + test("sealing freezes the ledger: a later registration throws, and sealing twice throws", async () => { + const ledger = await freshLedger(); + const a = ledger.stagedMdx("T0-3 a", WELL_FORMED); + ledger.sealStagedMdxLedger(); + expect(ledger.isStagedMdxLedgerSealed()).toBe(true); + expect(() => ledger.stagedMdx("T0-3 b", WELL_FORMED)).toThrow(/sealed/); + expect(() => ledger.sealStagedMdxLedger()).toThrow(/sealed twice/); + expect(ledger.stagedMdxLedger()).toEqual([a]); + expect(Object.isFrozen(ledger.stagedMdxLedger())).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// The builder's record overload (helpers/workspace.ts `file()`). + +describe("S-9: the builder stages a record's bytes under the record's declaration", () => { + test("a record stages exactly its bytes", async () => { + const record = someWellFormedRecord(); + const workspace = await stage(); + await workspace.file("specs/record.mdx", record); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes("specs/record.mdx")), + Buffer.from(bytesOf(record.source)), + ), + ).toBe(0); + }); + + test("a record's declaration overrides the workspace declaration for its path, as the `mdx` option does; plain contents there are judged under the workspace's", async () => { + const record = someWellFormedRecord(); + const workspace = await stage({ + mdx: { unparseable: ["specs/record.mdx"] }, + }); + await workspace.file("specs/record.mdx", record); + expect(await workspace.kind("specs/record.mdx")).toBe("file"); + await expectStagingRejected( + () => workspace.file("specs/record.mdx", record.source), + "specs/record.mdx", + "declared unparseable (`mdx.unparseable`) but the source derives", + ); + }); + + test("an `mdx` option beside a record is a contradiction: nothing is written", async () => { + const record = someWellFormedRecord(); + const workspace = await stage(); + await expectStagingRejected( + () => workspace.file("specs/record.mdx", record, { mdx: "well-formed" }), + "specs/record.mdx", + "contradiction", + JSON.stringify(record.name), + ); + expect(await workspace.kind("specs/record.mdx")).toBe("absent"); + }); + + test("a record makes its path an MDX source whatever the name: at a spec-group file's path not named `.mdx` it stages exactly its bytes under its own declaration, and a contradicting record throws there, nothing written", async () => { + const wellFormed = someWellFormedRecord(); + const unparseable = someUnparseableRecord(); + const workspace = await stage(); + await workspace.file("specs/record.txt", wellFormed); + await workspace.file("specs/unparseable.txt", unparseable); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes("specs/record.txt")), + Buffer.from(bytesOf(wellFormed.source)), + ), + ).toBe(0); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes("specs/unparseable.txt")), + Buffer.from(bytesOf(unparseable.source)), + ), + ).toBe(0); + // The record declared the path for its own write alone. + expect(workspace.mdxDeclarationOf("specs/record.txt")).toBeUndefined(); + const { ledger, builder } = await freshBuilder(); + const illFormed = ledger.stagedMdx( + "T0-4 ill-formed at a path not named .mdx", + ILL_FORMED, + ); + const fresh = await builder.TestWorkspace.create(); + onTestFinished(() => fresh.dispose()); + let thrown: unknown; + try { + await fresh.file("specs/notes.txt", illFormed); + } catch (error) { + thrown = error; + } + expectFreshStagingError( + thrown, + "specs/notes.txt", + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + expect(await fresh.kind("specs/notes.txt")).toBe("absent"); + }); + + test("a record is judged at staging time too (the same judge): a fresh ledger's contradicting record throws", async () => { + vi.resetModules(); + const ledger = await import("../helpers/staged-mdx.js"); + // The fresh module instance's class is not the builder's, so the record + // is staged through the judge the builder applies to plain contents + // under the record's own declaration — the code path `file()` takes. + const record = ledger.stagedMdx("T0-4 ill-formed", ILL_FORMED); + expectJudgeRejects( + () => + judgeMdxDeclaration(record.name, bytesOf(record.source), record.mdx), + record.name, + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + }); +}); + +// --------------------------------------------------------------------------- +// The record-accepting initial `files` (`InitialFileContents`): the form of a +// later-arm workspace's initial `.mdx` files — a workspace the body creates +// after its first product invocation, which S-7's sweep never reaches — so +// `create()` stages a record under the record's declaration as `file()` +// does. A refusal is `create()`'s: the workspace is disposed, nothing left. + +describe("S-9: the builder stages an initial `files` record under the record's declaration", () => { + test("a record in `files` stages exactly its bytes, under its own declaration: a well-formed record and an unparseable one alike, whatever the workspace's default for the path", async () => { + const wellFormed = someWellFormedRecord(); + const unparseable = someUnparseableRecord(); + const workspace = await stage({ + files: { + "specs/record.mdx": wellFormed, + "specs/unparseable.mdx": unparseable, + "notes.txt": "plain text\n", + }, + }); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes("specs/record.mdx")), + Buffer.from(bytesOf(wellFormed.source)), + ), + ).toBe(0); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes("specs/unparseable.mdx")), + Buffer.from(bytesOf(unparseable.source)), + ), + ).toBe(0); + expect(text(await workspace.readBytes("notes.txt"))).toBe("plain text\n"); + // The path's workspace declaration is the default (well-formed), under + // which the unparseable record's bytes are refused as plain contents: + // the record's own declaration governed the staging. + expect(workspace.mdxDeclarationOf("specs/unparseable.mdx")).toBe( + "well-formed", + ); + await expectStagingRejected( + () => workspace.file("specs/unparseable.mdx", unparseable.source), + "specs/unparseable.mdx", + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + }); + + test("a record is judged at creation (the same judge): a fresh ledger's contradicting record makes `create()` throw, either way round", async () => { + const { ledger, builder } = await freshBuilder(); + const deriving = ledger.stagedMdx( + "T0-5 deriving, declared unparseable", + WELL_FORMED, + "unparseable", + ); + const illFormed = ledger.stagedMdx( + "T0-5 ill-formed, declared well-formed", + ILL_FORMED, + ); + for (const [record, fragment] of [ + [ + deriving, + "declared unparseable (`mdx.unparseable`) but the source derives", + ], + [ + illFormed, + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ], + ] as const) { + let thrown: unknown; + try { + const workspace = await builder.TestWorkspace.create({ + files: { "specs/record.mdx": record }, + }); + onTestFinished(() => workspace.dispose()); + } catch (error) { + thrown = error; + } + expectFreshStagingError(thrown, "specs/record.mdx", fragment); + } + }); + + test("a record at a key not named `.mdx` makes the key an MDX source: `create()` stages it under the record's declaration, and a contradicting record makes `create()` throw", async () => { + const record = someWellFormedRecord(); + const workspace = await stage({ files: { "specs/record.txt": record } }); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes("specs/record.txt")), + Buffer.from(bytesOf(record.source)), + ), + ).toBe(0); + const { ledger, builder } = await freshBuilder(); + const deriving = ledger.stagedMdx( + "T0-5 deriving at a key not named .mdx, declared unparseable", + WELL_FORMED, + "unparseable", + ); + let thrown: unknown; + try { + const fresh = await builder.TestWorkspace.create({ + files: { "specs/notes.txt": deriving }, + }); + onTestFinished(() => fresh.dispose()); + } catch (error) { + thrown = error; + } + expectFreshStagingError( + thrown, + "specs/notes.txt", + "declared unparseable (`mdx.unparseable`) but the source derives", + ); + }); + + test("a record beside a workspace declaration naming its path is a contradiction, whichever list names it: `create()` throws", async () => { + const record = someWellFormedRecord(); + const P = "specs/record.mdx"; + const declarations: readonly WorkspaceMdxDecl[] = [ + { unparseable: [P] }, + { unchecked: [P] }, + { allowances: { [P]: ["duplicate-import-binding"] } }, + { perDraw: [P] }, + ]; + for (const mdx of declarations) { + await createRejected( + { files: { [P]: record }, mdx }, + P, + "contradiction", + JSON.stringify(record.name), + "drop the declaration entry", + ); + } + }); + + test("`perDraw`: a listed path's initial contents are judged well-formed at creation and the path is declared `per-draw`; an ill-formed entry throws, at a path of any name; a path in two lists throws", async () => { + const draw = "specs/draw.mdx"; + const workspace = await stage({ + files: { [draw]: WELL_FORMED }, + mdx: { perDraw: [draw] }, + }); + expect(workspace.mdxDeclarationOf(draw)).toBe("per-draw"); + expect(text(await workspace.readBytes(draw))).toBe(WELL_FORMED); + await createRejected( + { files: { [draw]: ILL_FORMED }, mdx: { perDraw: [draw] } }, + draw, + "declared well-formed per draw", + "but the stock MDX 3 parser rejects it", + ); + await createRejected( + { mdx: { perDraw: [draw], unchecked: [draw] } }, + draw, + "more than one of", + "`perDraw`", + ); + // A path of another name a `perDraw` list names is an MDX source judged + // per draw, as an `.mdx` path is. + await createRejected( + { + files: { "specs/draw.md": ILL_FORMED }, + mdx: { perDraw: ["specs/draw.md"] }, + }, + "specs/draw.md", + "declared well-formed per draw", + ); + }); + + test("`mdxPathsOf`: the plain `.mdx` keys of an initial `files` map, in map order — a record entry and a non-`.mdx` key left out — so a `perDraw` list derived from a rendered map never names a record's path", async () => { + const record = someWellFormedRecord(); + const files = { + "xspec.config.ts": "export default {}", + "specs/b.mdx": WELL_FORMED, + "specs/a.mdx": WELL_FORMED, + "specs/note.md": "# not judged", + "specs/record.mdx": record, + "specs/bytes.mdx": Buffer.from(WELL_FORMED, "utf8"), + }; + expect(mdxPathsOf(files)).toEqual([ + "specs/b.mdx", + "specs/a.mdx", + "specs/bytes.mdx", + ]); + expect(mdxPathsOf({})).toEqual([]); + expect(mdxPathsOf({ "specs/record.mdx": record })).toEqual([]); + const workspace = await stage({ + files, + mdx: { perDraw: mdxPathsOf(files) }, + }); + for (const rel of ["specs/b.mdx", "specs/a.mdx", "specs/bytes.mdx"]) { + expect(workspace.mdxDeclarationOf(rel)).toBe("per-draw"); + } + expect(await workspace.readBytes("specs/record.mdx")).toEqual( + bytesOf(record.source), + ); + }); +}); + +// --------------------------------------------------------------------------- +// The builder's `edit()`: a rewrite of the current bytes — the form of an +// edit to bytes the product wrote — judged under the path's declaration. + +describe("S-9: the builder's edit() rewrites the current bytes under the path's declaration", () => { + const BASE = doc('<S id="e">', "", "Alpha text. Alpha text.", "", "</S>"); + + test("replaces the first occurrence of `from` and stages the result", async () => { + const workspace = await stage({ files: { "specs/e.mdx": BASE } }); + await workspace.edit("specs/e.mdx", "Alpha text.", "Alpha text, edited."); + expect(text(await workspace.readBytes("specs/e.mdx"))).toBe( + doc('<S id="e">', "", "Alpha text, edited. Alpha text.", "", "</S>"), + ); + }); + + test("a `from` the file does not contain is a harness staging error, the file untouched", async () => { + const workspace = await stage({ files: { "specs/e.mdx": BASE } }); + await expect( + workspace.edit("specs/e.mdx", "Beta text.", "Beta text, edited."), + ).rejects.toThrow(/harness staging: specs\/e\.mdx does not contain/); + expect(text(await workspace.readBytes("specs/e.mdx"))).toBe(BASE); + }); + + test("an edit breaking well-formedness contradicts the path's declaration (well-formed by default): nothing is written", async () => { + const workspace = await stage({ files: { "specs/e.mdx": BASE } }); + await expectStagingRejected( + () => workspace.edit("specs/e.mdx", "</S>", ""), + "specs/e.mdx", + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + expect(text(await workspace.readBytes("specs/e.mdx"))).toBe(BASE); + }); + + test("a path the workspace declares unparseable judges the edit under that declaration", async () => { + const workspace = await stage({ + files: { "specs/u.mdx": ILL_FORMED }, + mdx: { unparseable: ["specs/u.mdx"] }, + }); + await workspace.edit("specs/u.mdx", "never closed", "still never closed"); + await expectStagingRejected( + () => workspace.edit("specs/u.mdx", "still never closed", "closed\n</S>"), + "specs/u.mdx", + "declared unparseable (`mdx.unparseable`) but the source derives", + ); + expect(text(await workspace.readBytes("specs/u.mdx"))).toBe( + doc('<S id="x">', "", "still never closed"), + ); + }); +}); + +// --------------------------------------------------------------------------- +// The judge itself (the one code path the builder and this self-test share). + +describe("S-9: the judge — an ill-formed source declared well-formed and a deriving source declared unparseable both throw", () => { + test("an ill-formed source declared well-formed throws mode `mdx-derivability`, naming the key and the parser's rejection", () => { + expectJudgeRejects( + () => + judgeMdxDeclaration( + "hand-made ill-formed", + utf8(ILL_FORMED), + "well-formed", + ), + "hand-made ill-formed", + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + }); + + test("a deriving source declared unparseable throws", () => { + expectJudgeRejects( + () => + judgeMdxDeclaration( + "hand-made deriving", + utf8(WELL_FORMED), + "unparseable", + ), + "hand-made deriving", + "declared unparseable (`mdx.unparseable`) but the source derives", + ); + }); + + test("a source relying on an early error passes under its named allowance alone", () => { + judgeMdxDeclaration("allowed", utf8(DUPLICATE_BINDING), { + allowances: ["duplicate-import-binding"], + }); + expectJudgeRejects( + () => + judgeMdxDeclaration( + "not allowed", + utf8(DUPLICATE_BINDING), + "well-formed", + ), + "not allowed", + "declared well-formed (S-9's default) but the stock MDX 3 parser rejects it", + ); + expectJudgeRejects( + () => + judgeMdxDeclaration("unknown allowance", utf8(WELL_FORMED), { + allowances: ["nope" as MdxAllowance], + }), + "unknown allowance", + "unknown allowance", + ); + }); + + test("a matching declaration passes, and `unchecked` judges nothing", () => { + judgeMdxDeclaration("well-formed", utf8(WELL_FORMED), "well-formed"); + judgeMdxDeclaration("unparseable", utf8(ILL_FORMED), "unparseable"); + judgeMdxDeclaration("unchecked", utf8(ILL_FORMED), "unchecked"); + }); +}); + +// --------------------------------------------------------------------------- +// The staged-source ledger's TypeScript records (helpers/staged-ts.ts): every +// code source and configuration file a registered body stages after its +// first product invocation — a reconfiguration after a `build`, an arm's +// variant code source, the configuration of a workspace the body creates +// after that invocation — is a record created at module load, judged here +// with the builder's own TypeScript judge (`judgeTsDeclaration`, the one +// code path `checkTs` applies at staging time) under the grammar the record +// names, before any product exists (S-9's TypeScript clause and its timing +// clause, H-8). + +/** The complete TypeScript records: every registry module has loaded. */ +const TS_LEDGER = stagedTsLedger(); + +const TS_WELL_FORMED = "export const a = 1;" + LF; +const TS_ILL_FORMED = "export const = 1;" + LF; +/** TSX-only: a JSX element is a syntax error in plain TypeScript. */ +const TSX_ONLY = "export const e = <a />;" + LF; +/** Accepted read as module code only (a top-level `await` form, S-9). */ +const TS_ONE_WAY = "await /re/;" + LF; + +function expectTsStagingError( + thrown: unknown, + key: string, + ...fragments: readonly string[] +): void { + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as Error & { mode?: unknown; path?: unknown }; + expect(error.name).toBe("HarnessStagingError"); + expect(error.mode).toBe("ts-derivability"); + expect(error.path).toBe(key); + expect(error.message).toContain(`ts-derivability staging of ${key}: `); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } +} + +async function expectTsRejected( + action: () => Promise<unknown>, + key: string, + ...fragments: readonly string[] +): Promise<void> { + let thrown: unknown; + try { + await action(); + } catch (error) { + thrown = error; + } + expectTsStagingError(thrown, key, ...fragments); +} + +/** A well-formed plain-TypeScript record of the registry's own. */ +function someWellFormedTsRecord(): StagedTs { + const record = TS_LEDGER.find( + (entry) => entry.ts === "well-formed" && entry.grammar === "ts", + ); + if (record === undefined) { + throw new Error("the ledger holds no well-formed TypeScript record"); + } + return record; +} + +/** + * A fresh, unsealed TypeScript ledger and the builder bound to it (both + * re-evaluated after `vi.resetModules()`, so the fresh builder's `StagedTs` + * is the fresh ledger's class): for the records the sealed registry ledger + * cannot hold — contradicting ones, TSX ones. Errors are the fresh modules' + * own, matched by name and mode. + */ +async function freshTsBuilder(): Promise<{ + readonly ledger: typeof import("../helpers/staged-ts.js"); + readonly builder: typeof import("../helpers/workspace.js"); +}> { + vi.resetModules(); + const ledger = await import("../helpers/staged-ts.js"); + const builder = await import("../helpers/workspace.js"); + return { ledger, builder }; +} + +describe("S-9: the staged TypeScript records, once the registry has loaded", () => { + test("are sealed, so a record created at run time throws instead of escaping this self-test", () => { + expect(isStagedTsLedgerSealed()).toBe(true); + expect(() => stagedTs("T0-0 late record", TS_WELL_FORMED)).toThrow( + /sealed/, + ); + expect(() => new StagedTs("T0-0 late record", TS_WELL_FORMED)).toThrow( + /sealed/, + ); + expect(TS_LEDGER.some((record) => record.name === "T0-0 late record")).toBe( + false, + ); + }); + + test("are non-empty, and every record is an immutable, uniquely named `StagedTs` declared well-formed or unparseable under a named grammar", () => { + expect(TS_LEDGER.length).toBeGreaterThan(0); + const names = TS_LEDGER.map((record) => record.name); + expect(new Set(names).size).toBe(names.length); + for (const record of TS_LEDGER) { + expect(record).toBeInstanceOf(StagedTs); + expect(Object.isFrozen(record)).toBe(true); + expect(record.name.trim().length).toBeGreaterThan(0); + expect( + typeof record.source === "string" || + record.source instanceof Uint8Array, + ).toBe(true); + expect(["well-formed", "unparseable"], record.name).toContain(record.ts); + expect(["ts", "tsx"], record.name).toContain(record.grammar); + } + }); + + test("names every record after registered tests: `<TEST-ID>[/<TEST-ID>…] <what it stages>` — or after E-6, the §18 exchange fixture's ID (helpers/e6.ts), for its two records", () => { + let e6Records = 0; + for (const record of TS_LEDGER) { + const [lead, ...rest] = record.name.split(" "); + expect(rest.join(" ").trim(), record.name).not.toBe(""); + for (const id of (lead ?? "").split("/")) { + if (id === E6_FIXTURE_ID) { + e6Records += 1; + continue; + } + expect( + productTestSuite.has(id), + `${record.name}: ${id} names no registered test (nor ${E6_FIXTURE_ID}, the exchange fixture of helpers/e6.ts)`, + ).toBe(true); + } + } + // The fixture's configuration and code source, staged at its creation + // outside every registered body and S-7's sweep, are judged here alone. + expect(e6Records).toBe(2); + }); + + test("hold the Windows leg's drive-mismatch configuration, and the MDX ledger its spec source (test/windows/e6-drive-mismatch.test.ts stages both at creation, outside every registered body and S-7's sweep): judged here, before any product exists", () => { + expect(TS_LEDGER).toContain(DRIVE_MISMATCH_CONFIG); + expect(LEDGER).toContain(DRIVE_MISMATCH_SOURCE); + expect(DRIVE_MISMATCH_CONFIG.ts).toBe("well-formed"); + expect(DRIVE_MISMATCH_CONFIG.grammar).toBe("ts"); + expect(DRIVE_MISMATCH_SOURCE.mdx).toBe("well-formed"); + expect(DRIVE_MISMATCH_SOURCE.ts).toBeUndefined(); + judgeTsDeclaration( + DRIVE_MISMATCH_CONFIG.name, + bytesOf(DRIVE_MISMATCH_CONFIG.source), + DRIVE_MISMATCH_CONFIG.ts, + tsGrammarFileName(DRIVE_MISMATCH_CONFIG.grammar), + ); + judgeMdxDeclaration( + DRIVE_MISMATCH_SOURCE.name, + bytesOf(DRIVE_MISMATCH_SOURCE.source), + DRIVE_MISMATCH_SOURCE.mdx, + ); + }); +}); + +/** + * Every TypeScript judgement the ledger declares: each TypeScript record + * under its grammar, and each MDX record of a code-group `.mdx` path (one + * carrying `ts`) as plain TypeScript — an `.mdx` name selects it (14.20). + */ +const TS_JUDGEMENTS: readonly (readonly [ + string, + Uint8Array, + "well-formed" | "unparseable", + string, +])[] = [ + ...TS_LEDGER.map( + (record) => + [ + record.name, + bytesOf(record.source), + record.ts, + tsGrammarFileName(record.grammar), + ] as const, + ), + ...LEDGER.flatMap((record) => + record.ts === undefined + ? [] + : [ + [ + `${record.name} (as TypeScript)`, + bytesOf(record.source), + record.ts, + tsGrammarFileName("ts"), + ] as const, + ], + ), +]; + +describe("S-9: every staged TypeScript source in the ledger matches its declaration before any product exists", () => { + test.each(TS_JUDGEMENTS)("%s", (name, data, declaration, fileName) => { + judgeTsDeclaration(name, data, declaration, fileName); + }); +}); + +describe("S-9: the TypeScript records' registration rules", () => { + async function freshTsLedger(): Promise< + typeof import("../helpers/staged-ts.js") + > { + vi.resetModules(); + return await import("../helpers/staged-ts.js"); + } + + test("a record registers in order with its declaration and grammar, well-formed plain TypeScript by default; a duplicate name throws", async () => { + const ledger = await freshTsLedger(); + expect(ledger.isStagedTsLedgerSealed()).toBe(false); + expect(ledger.stagedTsLedger()).toEqual([]); + const a = ledger.stagedTs("T0-1 a", TS_WELL_FORMED); + const b = ledger.stagedTs("T0-1 b", utf8(TS_ILL_FORMED), "unparseable"); + const c = ledger.stagedTs("T0-1 c", TSX_ONLY, "well-formed", "tsx"); + expect([a.ts, a.grammar]).toEqual(["well-formed", "ts"]); + expect(a.source).toBe(TS_WELL_FORMED); + expect([b.ts, b.grammar]).toEqual(["unparseable", "ts"]); + expect([c.ts, c.grammar]).toEqual(["well-formed", "tsx"]); + expect(ledger.stagedTsLedger()).toEqual([a, b, c]); + expect(() => ledger.stagedTs("T0-1 a", TS_WELL_FORMED)).toThrow( + /duplicate/, + ); + expect(ledger.stagedTsLedger()).toHaveLength(3); + }); + + test("an `unchecked` declaration, an unknown grammar, an empty name, and non-file contents are refused", async () => { + const ledger = await freshTsLedger(); + expect(() => + ledger.stagedTs( + "T0-2 u", + TS_WELL_FORMED, + "unchecked" as unknown as "well-formed", + ), + ).toThrow(/unchecked/); + expect(() => + ledger.stagedTs( + "T0-2 g", + TS_WELL_FORMED, + "well-formed", + "jsx" as unknown as "ts", + ), + ).toThrow(/grammar/); + expect(() => ledger.stagedTs(" ", TS_WELL_FORMED)).toThrow( + /non-empty name/, + ); + expect(() => + ledger.stagedTs("T0-2 c", { text: TS_WELL_FORMED } as unknown as string), + ).toThrow(/string or byte contents/); + expect(ledger.stagedTsLedger()).toEqual([]); + }); + + test("sealing freezes the records: a later registration throws, and sealing twice throws", async () => { + const ledger = await freshTsLedger(); + const a = ledger.stagedTs("T0-3 a", TS_WELL_FORMED); + ledger.sealStagedTsLedger(); + expect(ledger.isStagedTsLedgerSealed()).toBe(true); + expect(() => ledger.stagedTs("T0-3 b", TS_WELL_FORMED)).toThrow(/sealed/); + expect(() => ledger.sealStagedTsLedger()).toThrow(/sealed twice/); + expect(ledger.stagedTsLedger()).toEqual([a]); + expect(Object.isFrozen(ledger.stagedTsLedger())).toBe(true); + }); + + test("the grammar a name selects: `.tsx` alone selects TSX (SPEC 14.20)", async () => { + const ledger = await freshTsLedger(); + expect(ledger.tsGrammarOf("src/a.tsx")).toBe("tsx"); + for (const name of [ + "src/a.ts", + "xspec.config.ts", + "a.d.ts", + "a.jsx", + "specs/a.md", + "a.TSX", + ]) { + expect(ledger.tsGrammarOf(name), name).toBe("ts"); + } + expect(ledger.tsGrammarOf(ledger.tsGrammarFileName("tsx"))).toBe("tsx"); + expect(ledger.tsGrammarOf(ledger.tsGrammarFileName("ts"))).toBe("ts"); + }); +}); + +describe("S-9: the builder stages a TypeScript record's bytes under the record's declaration", () => { + test("a record stages exactly its bytes, at a configuration path and at a code-source path", async () => { + const record = someWellFormedTsRecord(); + const workspace = await stage(); + for (const rel of ["xspec.config.ts", "src/record.ts"]) { + await workspace.file(rel, record); + expect( + Buffer.compare( + Buffer.from(await workspace.readBytes(rel)), + Buffer.from(bytesOf(record.source)), + ), + rel, + ).toBe(0); + } + }); + + test("a record's declaration governs its write, overriding the workspace declaration and the name's default, as the `ts` option does — a code source whose name the default does not reach included", async () => { + const { ledger, builder } = await freshTsBuilder(); + const unparseable = ledger.stagedTs( + "T0-4 ill-formed", + TS_ILL_FORMED, + "unparseable", + ); + const wellFormed = ledger.stagedTs("T0-4 well-formed", TS_WELL_FORMED); + const workspace = await builder.TestWorkspace.create({ + ts: { unchecked: ["src/u.ts"], unparseable: ["src/w.ts"] }, + }); + onTestFinished(() => workspace.dispose()); + await workspace.file("src/a.ts", unparseable); + await workspace.file("src/u.ts", unparseable); + await workspace.file("src/w.ts", wellFormed); + await workspace.file("specs/code.md", wellFormed); + expect(text(await workspace.readBytes("src/a.ts"))).toBe(TS_ILL_FORMED); + expect(text(await workspace.readBytes("specs/code.md"))).toBe( + TS_WELL_FORMED, + ); + // Plain contents there are judged under the path's declaration still. + await expectTsRejected( + () => workspace.file("src/a.ts", TS_ILL_FORMED), + "src/a.ts", + "declared well-formed", + ); + await expectTsRejected( + () => workspace.file("src/w.ts", TS_WELL_FORMED), + "src/w.ts", + "declared unparseable", + ); + }); + + test("a `ts` option beside a record is a contradiction: nothing is written", async () => { + const record = someWellFormedTsRecord(); + const workspace = await stage(); + await expectTsRejected( + () => workspace.file("src/record.ts", record, { ts: "well-formed" }), + "src/record.ts", + "contradiction", + JSON.stringify(record.name), + ); + expect(await workspace.kind("src/record.ts")).toBe("absent"); + }); + + test("a record staged at a path selecting the other grammar is a mistake, either way round: nothing is written", async () => { + const record = someWellFormedTsRecord(); + const workspace = await stage(); + await expectTsRejected( + () => workspace.file("src/record.tsx", record), + "src/record.tsx", + JSON.stringify(record.name), + "grammar", + ); + expect(await workspace.kind("src/record.tsx")).toBe("absent"); + const { ledger, builder } = await freshTsBuilder(); + const tsx = ledger.stagedTs("T0-5 tsx", TSX_ONLY, "well-formed", "tsx"); + const fresh = await builder.TestWorkspace.create(); + onTestFinished(() => fresh.dispose()); + await fresh.file("src/view.tsx", tsx); + expect(text(await fresh.readBytes("src/view.tsx"))).toBe(TSX_ONLY); + await expectTsRejected( + () => fresh.file("src/view.ts", tsx), + "src/view.ts", + '"T0-5 tsx"', + "grammar", + ); + expect(await fresh.kind("src/view.ts")).toBe("absent"); + }); + + test("a TypeScript record at an MDX source's path — an `.mdx` path, or one the workspace's `mdx` declaration or an `mdx` option beside it declares an MDX source — is a mistake: nothing is written", async () => { + const record = someWellFormedTsRecord(); + const workspace = await stage({ + mdx: { wellFormed: ["specs/notes.txt"] }, + }); + await expectTsRejected( + () => workspace.file("docs/code.mdx", record), + "docs/code.mdx", + JSON.stringify(record.name), + "`.mdx` path", + ); + expect(await workspace.kind("docs/code.mdx")).toBe("absent"); + await expectTsRejected( + () => workspace.file("specs/notes.txt", record), + "specs/notes.txt", + JSON.stringify(record.name), + "the workspace's `mdx` declaration names the path, an MDX source", + ); + expect(await workspace.kind("specs/notes.txt")).toBe("absent"); + await expectTsRejected( + () => workspace.file("specs/code.md", record, { mdx: "well-formed" }), + "specs/code.md", + JSON.stringify(record.name), + 'the `mdx` option "well-formed" beside it declares the path an MDX source', + ); + expect(await workspace.kind("specs/code.md")).toBe("absent"); + }); + + test("a record is judged at staging time too (the same judge): a fresh ledger's contradicting records throw, one-way text under either declaration included", async () => { + const { ledger, builder } = await freshTsBuilder(); + const workspace = await builder.TestWorkspace.create(); + onTestFinished(() => workspace.dispose()); + for (const [record, fragment] of [ + [ + ledger.stagedTs("T0-6 ill-formed", TS_ILL_FORMED), + "declared well-formed", + ], + [ + ledger.stagedTs("T0-6 deriving", TS_WELL_FORMED, "unparseable"), + "declared unparseable", + ], + [ + ledger.stagedTs("T0-6 one-way", TS_ONE_WAY), + "accepted read as module code only", + ], + [ + ledger.stagedTs("T0-6 one-way, unparseable", TS_ONE_WAY, "unparseable"), + "accepted read as module code only", + ], + [ + ledger.stagedTs("T0-6 TSX-only as plain", TSX_ONLY), + "declared well-formed", + ], + ] as const) { + await expectTsRejected( + () => workspace.file("src/record.ts", record), + "src/record.ts", + fragment, + ); + expect(await workspace.kind("src/record.ts"), record.name).toBe("absent"); + // The self-test's own judgement of the record: the same verdict. + let thrown: unknown; + try { + builder.judgeTsDeclaration( + record.name, + bytesOf(record.source), + record.ts, + ledger.tsGrammarFileName(record.grammar), + ); + } catch (error) { + thrown = error; + } + expectTsStagingError(thrown, record.name, fragment); + } + }); +}); + +describe("S-9: the builder stages an initial `files` TypeScript record under the record's declaration", () => { + test("a record in `files` stages exactly its bytes under its own declaration, whatever the name's default", async () => { + const { ledger, builder } = await freshTsBuilder(); + const config = ledger.stagedTs("T0-7 configuration", TS_WELL_FORMED); + const unparseable = ledger.stagedTs( + "T0-7 ill-formed", + TS_ILL_FORMED, + "unparseable", + ); + const view = ledger.stagedTs("T0-7 view", TSX_ONLY, "well-formed", "tsx"); + const workspace = await builder.TestWorkspace.create({ + files: { + "xspec.config.ts": config, + "src/bad.ts": unparseable, + "src/view.tsx": view, + "specs/code.md": config, + "notes.txt": "plain text\n", + }, + }); + onTestFinished(() => workspace.dispose()); + expect(text(await workspace.readBytes("xspec.config.ts"))).toBe( + TS_WELL_FORMED, + ); + expect(text(await workspace.readBytes("src/bad.ts"))).toBe(TS_ILL_FORMED); + expect(text(await workspace.readBytes("src/view.tsx"))).toBe(TSX_ONLY); + expect(text(await workspace.readBytes("specs/code.md"))).toBe( + TS_WELL_FORMED, + ); + // The path's own declaration stayed the name's default. + expect(workspace.tsDeclarationOf("src/bad.ts")).toBe("well-formed"); + expect(workspace.tsDeclarationOf("specs/code.md")).toBeUndefined(); + }); + + test("a record contradicting its declaration, at a path of the other grammar, at an MDX source's key (an `.mdx` key, or one the workspace's `mdx` declaration names), or beside a workspace `ts` entry naming its path, makes `create()` throw", async () => { + const { ledger, builder } = await freshTsBuilder(); + const wellFormed = ledger.stagedTs("T0-8 well-formed", TS_WELL_FORMED); + const illFormed = ledger.stagedTs("T0-8 ill-formed", TS_ILL_FORMED); + const cases: readonly (readonly [ + WorkspaceDecl, + string, + readonly string[], + ])[] = [ + [ + { files: { "src/a.ts": illFormed } }, + "src/a.ts", + ["declared well-formed"], + ], + [{ files: { "src/a.tsx": wellFormed } }, "src/a.tsx", ["grammar"]], + [{ files: { "docs/a.mdx": wellFormed } }, "docs/a.mdx", ["`.mdx` path"]], + [ + { + files: { "specs/a.txt": wellFormed }, + mdx: { wellFormed: ["specs/a.txt"] }, + }, + "specs/a.txt", + ["names the path, an MDX source"], + ], + [ + { + files: { "src/a.ts": wellFormed }, + ts: { unparseable: ["src/a.ts"] }, + }, + "src/a.ts", + ["contradiction", "drop the declaration entry"], + ], + [ + { files: { "src/a.ts": wellFormed }, ts: { unchecked: ["src/a.ts"] } }, + "src/a.ts", + ["contradiction"], + ], + [ + { + files: { "specs/a.md": wellFormed }, + ts: { wellFormed: ["specs/a.md"] }, + }, + "specs/a.md", + ["contradiction"], + ], + ]; + for (const [decl, key, fragments] of cases) { + let thrown: unknown; + try { + const workspace = await builder.TestWorkspace.create( + decl as Parameters<typeof builder.TestWorkspace.create>[0], + ); + onTestFinished(() => workspace.dispose()); + } catch (error) { + thrown = error; + } + expectTsStagingError(thrown, key, ...fragments); + } + }); +}); + +// --------------------------------------------------------------------------- +// An `.mdx` path a code group discovers is an MDX source and a code source +// at once (SPEC 7.2; T2.1-2's `docs/EXTRA.mdx`): its MDX record carries the +// path's TypeScript declaration too (`ts`), judged above as plain +// TypeScript, and the builder stages the record under both declarations. + +describe("S-9: an MDX record of a code-group `.mdx` path carries its TypeScript declaration too", () => { + /** Well-formed MDX (an ESM block) and well-formed TypeScript alike. */ + const BOTH = "export {};" + LF; + /** Well-formed MDX, ill-formed TypeScript. */ + const MDX_ONLY = doc('<S id="x">', "", "closed below", "", "</S>"); + + test("registers well-formed or unparseable, or none; any other TypeScript declaration is refused", async () => { + vi.resetModules(); + const ledger = await import("../helpers/staged-mdx.js"); + const a = ledger.stagedMdx("T0-9 a", BOTH, "well-formed", "well-formed"); + const b = ledger.stagedMdx( + "T0-9 b", + MDX_ONLY, + "well-formed", + "unparseable", + ); + const c = ledger.stagedMdx("T0-9 c", BOTH); + expect([a.ts, b.ts, c.ts]).toEqual([ + "well-formed", + "unparseable", + undefined, + ]); + expect(() => + ledger.stagedMdx( + "T0-9 u", + BOTH, + "well-formed", + "unchecked" as unknown as "well-formed", + ), + ).toThrow(/TypeScript declaration/); + expect(ledger.stagedMdxLedger()).toEqual([a, b, c]); + }); + + test("the builder stages it under both declarations, at creation and by `file()`; a contradicting TypeScript declaration throws, nothing written", async () => { + const { ledger, builder } = await freshBuilder(); + const code = ledger.stagedMdx( + "T0-10 code", + BOTH, + "well-formed", + "well-formed", + ); + const wrong = ledger.stagedMdx( + "T0-10 wrong", + MDX_ONLY, + "well-formed", + "well-formed", + ); + const unparseable = ledger.stagedMdx( + "T0-10 unparseable", + MDX_ONLY, + "well-formed", + "unparseable", + ); + const workspace = await builder.TestWorkspace.create({ + files: { "docs/code.mdx": code, "docs/prose.mdx": unparseable }, + }); + onTestFinished(() => workspace.dispose()); + expect(text(await workspace.readBytes("docs/code.mdx"))).toBe(BOTH); + expect(text(await workspace.readBytes("docs/prose.mdx"))).toBe(MDX_ONLY); + await workspace.file("docs/later.mdx", code); + expect(text(await workspace.readBytes("docs/later.mdx"))).toBe(BOTH); + let thrown: unknown; + try { + await workspace.file("docs/wrong.mdx", wrong); + } catch (error) { + thrown = error; + } + expectTsStagingError(thrown, "docs/wrong.mdx", "declared well-formed"); + expect(await workspace.kind("docs/wrong.mdx")).toBe("absent"); + thrown = undefined; + try { + await workspace.file("docs/later.mdx", code, { ts: "well-formed" }); + } catch (error) { + thrown = error; + } + expectTsStagingError( + thrown, + "docs/later.mdx", + "contradiction", + '"T0-10 code"', + ); + thrown = undefined; + try { + const beside = await builder.TestWorkspace.create({ + files: { "docs/code.mdx": code }, + ts: { wellFormed: ["docs/code.mdx"] }, + }); + onTestFinished(() => beside.dispose()); + } catch (error) { + thrown = error; + } + expectTsStagingError( + thrown, + "docs/code.mdx", + "contradiction", + "drop the declaration entry", + ); + }); +}); diff --git a/test/self/s9-typescript-well-formedness.test.ts b/test/self/s9-typescript-well-formedness.test.ts new file mode 100644 index 00000000..f7a4c697 --- /dev/null +++ b/test/self/s9-typescript-well-formedness.test.ts @@ -0,0 +1,752 @@ +// Self-checks for S-9's TypeScript check (`test/helpers/ts-derivability.ts`; +// TEST-SPEC 17 S-9's TypeScript clause — certification cannot exercise this +// class, so what is verified here is the check itself against the document's +// own declarations). SPEC 14.20 fixes the grammar: TypeScript's at release +// 5.9.3, TSX or plain as the file name selects, at the language level +// ESNext, derivability there being that release's acceptance. Every code +// source and configuration file the document declares well-formed is +// accepted both as module code and as script code: T14-12's post-parse arms, +// release-pin arms, language-level arms, and whitespace arm; T4-2's +// relative-name and undeclared module declarations and its other +// module-linking forms (their diagnostics are post-parse); T7-2's +// modifier-bearing configuration imports (the declarative form of 7 decides +// them); plain configuration files; a `.tsx` file holding JSX; and the +// fixed vector set of the forms the §16 generators compose — every +// property's configuration file, P-7's capture sources, P-13's `c0/U.ts` +// and `c1/V.ts`, and the fuzz base code source (each draw's own are judged +// per draw by the property runner, helpers/property.ts `drawSources`). +// Every one it declares unparseable is rejected both ways: `010`, `09`, +// U+1C89 in an identifier, the T7-2 syntax error, and T14-12's staged code +// arms. And the top-level `await` forms 14.20 names, accepted read one way +// only, are a harness error whatever the declaration. Every non-ASCII or +// control character this file's own vectors hold is built from its code +// point. + +import { Buffer } from "node:buffer"; +import { describe, expect, test } from "vitest"; +import { + TS_READINGS, + judgeTypeScript, + readTypeScript, + tsDeclarationProblem, + type TsReading, + type TsVerdict, +} from "../helpers/ts-derivability.js"; +import { judgeTsDeclaration } from "../helpers/workspace.js"; +import { + T14_12_CODE_FORM_VECTORS, + T14_12_UNPARSEABLE_ARMS, +} from "../suite/registry/section-14-iii.js"; +import { P1_TS_FORM_VECTORS } from "../suite/registry/section-16-p1.js"; +import { P10_TS_FORM_VECTORS } from "../suite/registry/section-16-p10.js"; +import { P12_TS_FORM_VECTORS } from "../suite/registry/section-16-p12.js"; +import { P13_TS_FORM_VECTORS } from "../suite/registry/section-16-p13.js"; +import { P2_P3_TS_FORM_VECTORS } from "../suite/registry/section-16-p2-p3.js"; +import { P4_TS_FORM_VECTORS } from "../suite/registry/section-16-p4.js"; +import { P5_P6_TS_FORM_VECTORS } from "../suite/registry/section-16-p5-p6.js"; +import { P7_TS_FORM_VECTORS } from "../suite/registry/section-16-p7.js"; +import { P8_P11_TS_FORM_VECTORS } from "../suite/registry/section-16-p8.js"; +import { P9_TS_FORM_VECTORS } from "../suite/registry/section-16-p9.js"; + +const cp = (code: number): string => String.fromCodePoint(code); + +/** U+2EBF0, CJK Unified Ideographs Extension I's first character (Unicode 15.1). */ +const EXT_I = cp(0x2ebf0); +/** U+1C89, a Unicode 16 letter: no identifier character for TypeScript 5.9.3. */ +const U16_LETTER = cp(0x1c89); +const ZWSP = cp(0x200b); +const NEL = cp(0x0085); +const BOM = cp(0xfeff); +const CR = cp(0x000d); +const LF = cp(0x000a); + +/** Lines joined by U+000A, the last one terminated. */ +const lines = (...parts: readonly string[]): string => parts.join(LF) + LF; + +const CODE_IMPORT = 'import A from "../specs/A.xspec"'; + +/** A configuration over one spec group, its import line and callee given. */ +const configWith = (importLine: string, callee = "defineConfig"): string => + lines( + importLine, + "", + `export default ${callee}({`, + " specs: {", + ' main: ["specs/**/*.mdx"]', + " }", + "})", + ); + +/** `[name, file name, source]`. */ +type Vector = readonly [string, string, string]; + +const WELL_FORMED: readonly Vector[] = [ + // T14-12's post-parse arms (l)–(o): each a rule 14.20 excludes. + [ + "T14-12 (l): a rest parameter that is not last", + "src/app.ts", + lines( + CODE_IMPORT, + "", + "function f(...r: number[], x: number) {", + " A.a", + "}", + ), + ], + [ + "T14-12 (m): a misplaced modifier, `abstract m(): void` in a non-abstract class", + "src/app.ts", + lines( + CODE_IMPORT, + "", + "class C {", + " abstract m(): void", + " run(): void {", + " A.a", + " }", + "}", + ), + ], + [ + "T14-12 (n): a duplicate declaration, `let a; let a;`", + "src/app.ts", + lines( + CODE_IMPORT, + "", + "let a; let a;", + "", + "function f(): void {", + " A.a", + "}", + ), + ], + [ + 'T14-12 (o): a type error, `const n: number = "x"`', + "src/app.ts", + lines( + CODE_IMPORT, + "", + 'const n: number = "x"', + "", + "function f(): void {", + " A.a", + "}", + ), + ], + // T14-12's release pin: forms earlier releases reject. + [ + "T14-12 release pin: `{ using x = f(); }`", + "src/app.ts", + lines( + CODE_IMPORT, + "", + "{ using x = f(); }", + "", + "function k(): void {", + " A.a", + "}", + ), + ], + [ + "T14-12 release pin: `async function g() { await using y = h(); }`", + "src/app.ts", + lines( + CODE_IMPORT, + "", + "async function g() { await using y = h(); }", + "", + "function k(): void {", + " A.a", + "}", + ), + ], + [ + 'T14-12 release pin: `import a from "./a.json" with { type: "json" };`', + "src/app.ts", + lines( + CODE_IMPORT, + 'import a from "./a.json" with { type: "json" };', + "", + "function k(): void {", + " A.a", + "}", + ), + ], + // T14-12's language level: ESNext's identifier tables admit U+2EBF0. + [ + "T14-12 language level: `const` U+2EBF0 `= 1` and the marker `S.` U+2EBF0 inside one unit", + "src/app.ts", + lines( + 'import S from "../specs/S.xspec"', + "", + "function f(): void {", + ` const ${EXT_I} = 1`, + ` S.${EXT_I}`, + "}", + ), + ], + [ + "T14-12 language level: a configuration importing `defineConfig as` U+2EBF0 (T7-2's aliased import)", + "xspec.config.ts", + configWith(`import { defineConfig as ${EXT_I} } from "xspec"`, EXT_I), + ], + // T14-12's whitespace: the release's scanner takes U+200B and U+0085 as whitespace. + [ + "T14-12 whitespace: `const` U+200B `a = 1`, and `const` U+0085 `b = 1` on a later line", + "src/app.ts", + lines( + CODE_IMPORT, + "", + `const${ZWSP}a = 1`, + `const${NEL}b = 1`, + "", + "function f(): void {", + " A.a", + "}", + ), + ], + // T4-2: the relative-name and undeclared module declarations, and the + // other module-linking forms — every file's finding 14.15's, post-parse. + [ + 'T4-2: `declare module "./NAME.xspec" { }` as a module augmentation, its file holding `export {}`', + "src/aug.ts", + lines('declare module "./NAME.xspec" { }', "", "export {}"), + ], + [ + 'T4-2: the undeclared `module "./NAME.xspec" { }`', + "src/c.ts", + lines('module "./NAME.xspec" { }'), + ], + [ + 'T4-2: the non-relative ambient wildcard `declare module "*.xspec" { }`', + "src/c.ts", + lines('declare module "*.xspec" { }'), + ], + [ + 'T4-2: `import X = require("./NAME.xspec")`', + "src/c.ts", + lines('import X = require("./NAME.xspec")'), + ], + [ + "T4-2: import types naming a `.xspec` module", + "src/c.ts", + lines( + 'type T = import("./NAME.xspec").default', + 'let v: typeof import("./NAME.xspec")', + ), + ], + [ + "T4-2: export declarations whose module specifier ends in `.xspec`", + "src/c.ts", + lines( + 'export * from "./NAME.xspec"', + 'export * as NS from "./NAME.xspec"', + 'export { default as N } from "./NAME.xspec"', + 'export type { default as T } from "./NAME.xspec"', + ), + ], + [ + "T4-2: a side-effect-only import, a namespace import, and a named binding other than `text`", + "src/c.ts", + lines( + 'import "./NAME.xspec"', + 'import * as NS from "./NAME.xspec"', + 'import { other } from "./NAME.xspec"', + ), + ], + [ + "T4-2: a static-specifier dynamic `import()`, and one whose specifier is a variable", + "src/c.ts", + lines( + 'const m = import("./NAME.xspec")', + 'const p = "./NAME.xspec"', + "const n = import(p)", + ), + ], + [ + "T4-2: the seven spellings that name no module, beside an import and its marker", + "src/c.ts", + lines( + '/// <reference path="../specs/NAME.xspec.ts" />', + 'import NAME from "../specs/NAME.xspec"', + "", + 'const r1 = require("../specs/NAME.xspec")', + 'const r2 = require("./missing.xspec")', + 'const p = "../specs/NAME.xspec"', + "const d1 = import(`../specs/NAME.xspec`)", + "const d2 = import(`./missing.xspec`)", + "const d3 = import(`../specs/NAME.xspec.ts`)", + "", + "function f(): void {", + " NAME.a", + "}", + ), + ], + // T7-2's modifier-bearing configuration imports: the declarative form of + // 7 refuses each (14.14), never a parse failure. + [ + 'T7-2: `import type { defineConfig } from "xspec"`', + "xspec.config.ts", + configWith('import type { defineConfig } from "xspec"'), + ], + [ + 'T7-2: `import { type defineConfig } from "xspec"`', + "xspec.config.ts", + configWith('import { type defineConfig } from "xspec"'), + ], + [ + 'T7-2: `import defer { defineConfig } from "xspec"`', + "xspec.config.ts", + configWith('import defer { defineConfig } from "xspec"'), + ], + [ + 'T7-2: `import { defineConfig } from "xspec" with { type: "json" }`', + "xspec.config.ts", + configWith('import { defineConfig } from "xspec" with { type: "json" }'), + ], + // Plain configuration files. + [ + "a plain `xspec.config.ts`", + "xspec.config.ts", + configWith('import { defineConfig } from "xspec"'), + ], + [ + "a configuration with a code group, string-literal group names, and comments (T7-2)", + "xspec.config.ts", + lines( + "// before the import", + 'import { defineConfig } from "xspec"', + "", + "export default defineConfig({", + " specs: {", + ' "my-group": ["specs/**/*.mdx"] /* between keys */', + " },", + " code: {", + ' "test-code": [', + " // inside a glob list", + ' "src/**/*.ts"', + " ]", + " }", + "})", + "// after the export", + ), + ], + // TSX, as the name selects. + [ + "a `.tsx` file holding JSX", + "src/view.tsx", + lines( + CODE_IMPORT, + "", + "export function View() {", + " A.a", + ' return <div className="v">{"x"}<>y</></div>', + "}", + ), + ], + // The judge's own boundary: an empty file, CRLF line ends, and top-level + // `await` the two readings take alike (the release parses `await x` as an + // await expression outside an await context too, leaving it to the checker). + ["an empty code source", "src/empty.ts", ""], + [ + "a CRLF-terminated code source", + "src/app.ts", + [CODE_IMPORT, "", "function f(): void {", " A.a", "}", ""].join(CR + LF), + ], + ["top-level `await x;`, accepted both ways", "src/app.ts", lines("await x;")], +]; + +const UNPARSEABLE: readonly Vector[] = [ + [ + "`010` in a `.ts` file — a legacy octal literal (T14-12)", + "src/app.ts", + lines(CODE_IMPORT, "", "const n = 010"), + ], + [ + "`09` in a `.ts` file — a leading-zero decimal (T14-12)", + "src/app.ts", + lines(CODE_IMPORT, "", "const n = 09"), + ], + [ + "`const` U+1C89 `x = 1` — U+1C89 begins no identifier (T14-12)", + "src/app.ts", + lines(`const ${U16_LETTER}x = 1`), + ], + [ + "`const a` U+1C89 `= 1` — nor continues one (T14-12)", + "src/app.ts", + lines(`const a${U16_LETTER} = 1`), + ], + [ + "T7-2: a configuration that is not well-formed TypeScript (unclosed braces)", + "xspec.config.ts", + lines( + 'import { defineConfig } from "xspec"', + "", + "export default defineConfig({", + " specs: {", + ' main: ["specs/**/*.mdx"]', + ), + ], + // The grammar the name selects. + [ + "JSX in a `.ts` file — the name selects plain TypeScript", + "src/view.ts", + lines("const e = <div>x</div>"), + ], + [ + "a type assertion `<T>x` in a `.tsx` file — JSX there, unclosed", + "src/view.tsx", + lines("const v = <T>x"), + ], +]; + +/** `[name, file name, source, the one reading that accepts]`. */ +const ONE_WAY: readonly (readonly [string, string, string, TsReading])[] = [ + [ + "`await /re/;` (14.20: module code only)", + "src/app.ts", + lines("await /re/;"), + "module", + ], + [ + "`let a = await / 2 / 1;` (14.20: script code only)", + "src/app.ts", + lines("let a = await / 2 / 1;"), + "script", + ], + [ + "`await /re/;` beside an import and its marker", + "src/app.ts", + lines( + CODE_IMPORT, + "", + "await /re/;", + "", + "function f(): void {", + " A.a", + "}", + ), + "module", + ], + [ + "`let a = await / 2 / 1;` in a `.tsx` file", + "src/view.tsx", + lines("let a = await / 2 / 1;"), + "script", + ], + [ + "`await /re/;` after a configuration's export", + "xspec.config.ts", + configWith('import { defineConfig } from "xspec"') + lines("await /re/;"), + "module", + ], + [ + "`await /re/;` in a declaration file's name — plain TypeScript all the same", + "src/x.d.ts", + lines("await /re/;"), + "module", + ], +]; + +/** The verdict on `source`, judged as a string and as its UTF-8 bytes alike. */ +function judged(source: string, name: string): TsVerdict { + const verdict = judgeTypeScript(source, name); + expect(judgeTypeScript(Buffer.from(source, "utf8"), name)).toEqual(verdict); + return verdict; +} + +function expectUnparseable( + verdict: TsVerdict, +): Extract<TsVerdict, { verdict: "unparseable" }> { + if (verdict.verdict !== "unparseable") { + throw new Error(`expected unparseable, got ${JSON.stringify(verdict)}`); + } + return verdict; +} + +describe("S-9 (TypeScript): every well-formed code source and configuration file is accepted both ways", () => { + test("the vector set is non-empty and uniquely named", () => { + expect(WELL_FORMED.length).toBeGreaterThan(20); + expect(new Set(WELL_FORMED.map(([name]) => name)).size).toBe( + WELL_FORMED.length, + ); + }); + + test.each(WELL_FORMED)("%s", (_name, file, source) => { + const verdict = judged(source, file); + expect(verdict).toEqual({ verdict: "well-formed" }); + for (const reading of TS_READINGS) { + expect(readTypeScript(source, file, reading)).toEqual([]); + } + expect(tsDeclarationProblem(verdict, "well-formed")).toBeUndefined(); + expect(tsDeclarationProblem(verdict, "unparseable")).toMatch( + /^declared unparseable, but TypeScript 5\.9\.3 accepts it both as module code and as script code/, + ); + }); +}); + +describe("S-9 (TypeScript): every declared-unparseable code source and configuration file is rejected both ways", () => { + test.each(UNPARSEABLE)("%s", (_name, file, source) => { + const verdict = expectUnparseable(judged(source, file)); + for (const reading of TS_READINGS) { + expect(verdict.errors[reading].length).toBeGreaterThan(0); + } + expect(tsDeclarationProblem(verdict, "unparseable")).toBeUndefined(); + expect(tsDeclarationProblem(verdict, "well-formed")).toMatch( + /^declared well-formed, but it is not well-formed TypeScript \(5\.9\.3, SPEC 14\.20\): rejected read as module code/, + ); + }); + + test("each rejection is the release's scanner's, located in both readings", () => { + const at = (source: string) => + expectUnparseable(judged(source, "src/app.ts")).errors; + const octal = at(lines("const n = 010")); + const leading = at(lines("const n = 09")); + const letter = at(lines(`const ${U16_LETTER}x = 1`)); + for (const reading of TS_READINGS) { + // The literal's start: the legacy octal and the leading-zero decimal. + expect(octal[reading][0]).toMatchObject({ + code: 1121, + offset: 10, + line: 1, + column: 11, + }); + expect(leading[reading][0]).toMatchObject({ + code: 1489, + offset: 10, + line: 1, + column: 11, + }); + // U+1C89's first byte, offset 6: "Invalid character." + expect(letter[reading][0]).toMatchObject({ + code: 1127, + offset: 6, + line: 1, + column: 7, + }); + } + }); +}); + +describe("S-9 (TypeScript): T14-12's staged code arms are rejected both ways", () => { + const codeArms = T14_12_UNPARSEABLE_ARMS.filter( + (arm) => arm.kind === "code-source", + ); + + test("the code arms are present", () => { + expect(codeArms.map((arm) => arm.arm)).toEqual( + expect.arrayContaining(["p", "q", "ac"]), + ); + }); + + test("(ac)'s pinned offset is 6, U+1C89's first byte, where the release itself rejects", () => { + const arm = codeArms.find((candidate) => candidate.arm === "ac"); + expect(arm).toBeDefined(); + expect(arm?.offset).toBe(6); + expect(arm?.source.startsWith(`const ${U16_LETTER}x = 1`)).toBe(true); + const verdict = expectUnparseable(judged(arm?.source ?? "", "src/app.ts")); + for (const reading of TS_READINGS) { + // "Invalid character." at the code point: an ASCII prefix, so the + // release's UTF-16 position is the byte offset. + expect(verdict.errors[reading][0]).toMatchObject({ + code: 1127, + offset: 6, + }); + } + }); + + test.each( + codeArms.map( + (arm) => + [`T14-12 (${arm.arm}) ${arm.name}`, arm.file, arm.source] as const, + ), + )("%s", (_name, file, source) => { + const verdict = expectUnparseable(judged(source, file)); + expect(tsDeclarationProblem(verdict, "unparseable")).toBeUndefined(); + }); +}); + +// T14-12's positive code arms as staged (section-14-iii.ts): the post-parse +// arms, the release pin, the language level (the code source and the +// configuration), and the whitespace arm — each accepted both ways. +describe("S-9 (TypeScript): every code source and configuration T14-12's positive arms stage is accepted both ways", () => { + test("the vector set is complete and uniquely named", () => { + expect(T14_12_CODE_FORM_VECTORS.length).toBe(10); + expect(new Set(T14_12_CODE_FORM_VECTORS.map(([name]) => name)).size).toBe( + T14_12_CODE_FORM_VECTORS.length, + ); + }); + + test.each(T14_12_CODE_FORM_VECTORS)("%s", (_name, file, source) => { + expect(judged(source, file)).toEqual({ verdict: "well-formed" }); + for (const reading of TS_READINGS) { + expect(readTypeScript(source, file, reading)).toEqual([]); + } + }); +}); + +// The §16 generators' TypeScript forms (S-9: every code source and +// configuration file the document declares well-formed, generated form +// included, is judged before any product exists; the §16 preamble: every +// generated workspace is valid by construction, no draw declared +// unparseable). The fixed vector set: every property's configuration file +// — P-7's and P-13's composed per draw, the rest fixed records also judged +// by test/self/s9-staged-sources.test.ts — P-7's capture sources, P-13's +// `c0/U.ts` and `c1/V.ts`, and P-8's and P-11's base code source, each +// built from its generator's own templates and constants and judged under +// the grammar its staged path selects, through the per-draw judgement the +// property runner applies to each draw (helpers/property.ts `drawSources`). +const GENERATED_TS_FORM_VECTORS: Readonly< + Record< + string, + ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] + > + > +> = { + "P-1": P1_TS_FORM_VECTORS, + "P-2/P-3": P2_P3_TS_FORM_VECTORS, + "P-4": P4_TS_FORM_VECTORS, + "P-5/P-6": P5_P6_TS_FORM_VECTORS, + "P-7": P7_TS_FORM_VECTORS, + "P-8/P-11": P8_P11_TS_FORM_VECTORS, + "P-9": P9_TS_FORM_VECTORS, + "P-10": P10_TS_FORM_VECTORS, + "P-12": P12_TS_FORM_VECTORS, + "P-13": P13_TS_FORM_VECTORS, +}; + +const GENERATED_TS_VECTORS = Object.entries(GENERATED_TS_FORM_VECTORS).flatMap( + ([property, vectors]) => + vectors.map( + ([name, path, source]) => [`${property} ${name}`, path, source] as const, + ), +); + +describe("S-9 (TypeScript): every configuration file and code source the §16 generators compose is accepted both ways", () => { + test("the vector set covers every property and is uniquely named", () => { + const covered = Object.keys(GENERATED_TS_FORM_VECTORS).flatMap((key) => + key.split("/"), + ); + expect(covered.sort()).toEqual( + Array.from({ length: 13 }, (_, i) => `P-${String(i + 1)}`).sort(), + ); + for (const vectors of Object.values(GENERATED_TS_FORM_VECTORS)) { + expect(vectors.some(([, path]) => path === "xspec.config.ts")).toBe(true); + } + expect(GENERATED_TS_VECTORS.length).toBe(22); + expect(new Set(GENERATED_TS_VECTORS.map(([name]) => name)).size).toBe( + GENERATED_TS_VECTORS.length, + ); + }); + + test("P-7's capture sources, P-13's `c0/U.ts` and `c1/V.ts`, and the fuzz base code source are among them", () => { + const paths = new Set(GENERATED_TS_VECTORS.map(([, path]) => path)); + for (const path of ["c0/U.ts", "c1/V.ts", "src/app.ts"]) { + expect(paths.has(path)).toBe(true); + } + expect( + P7_TS_FORM_VECTORS.filter(([name]) => name.includes("codeSource")).length, + ).toBe(3); + }); + + test.each(GENERATED_TS_VECTORS)("%s", (name, path, source) => { + const verdict = judgeTypeScript(source, path); + expect(verdict).toEqual({ verdict: "well-formed" }); + const bytes = + typeof source === "string" ? Buffer.from(source, "utf8") : source; + expect(judgeTypeScript(bytes, path)).toEqual(verdict); + expect(() => { + judgeTsDeclaration(name, bytes, "per-draw", path); + }).not.toThrow(); + }); +}); + +describe("S-9 (TypeScript): text accepted read one way only is a harness error whatever the declaration", () => { + test.each(ONE_WAY)("%s", (_name, file, source, accepts) => { + const verdict = judged(source, file); + expect(verdict).toMatchObject({ verdict: "one-way", accepts }); + // Each reading on its own: a judge reading one way only cannot tell. + const rejects: TsReading = accepts === "module" ? "script" : "module"; + expect(readTypeScript(source, file, accepts)).toEqual([]); + expect(readTypeScript(source, file, rejects).length).toBeGreaterThan(0); + for (const declared of ["well-formed", "unparseable"] as const) { + expect(tsDeclarationProblem(verdict, declared)).toMatch( + new RegExp( + `^declared ${declared}, but under TypeScript 5\\.9\\.3 it is accepted read as ${accepts} code only, rejected read as ${rejects} code`, + ), + ); + } + }); +}); + +describe("S-9 (TypeScript): the encoding rules of 14.20 the parser does not apply (SPEC 1.6)", () => { + const WELL_FORMED_TEXT = lines(CODE_IMPORT, "", "const a = 1"); + + test("a leading byte-order mark is unparseable, bytes and string alike", () => { + // The release's scanner itself skips a leading U+FEFF as whitespace. + expect( + readTypeScript(BOM + WELL_FORMED_TEXT, "src/app.ts", "module"), + ).toEqual([]); + const fromBytes = expectUnparseable( + judgeTypeScript( + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(WELL_FORMED_TEXT, "utf8"), + ]), + "src/app.ts", + ), + ); + expect(fromBytes.reason).toContain("byte-order mark"); + expect(fromBytes.errors).toEqual({ module: [], script: [] }); + expect( + expectUnparseable( + judgeTypeScript(BOM + WELL_FORMED_TEXT, "xspec.config.ts"), + ).reason, + ).toContain("byte-order mark"); + // A U+FEFF elsewhere is content: whitespace to the release's scanner. + expect(judged(lines(`const a =${BOM}1`), "src/app.ts")).toEqual({ + verdict: "well-formed", + }); + }); + + test.each([ + ["41 E2 82 41", [0x41, 0xe2, 0x82, 0x41], 1], + ["C0 80 (overlong)", [0xc0, 0x80], 0], + ["ED A0 80 (surrogate)", [0xed, 0xa0, 0x80], 0], + ["41 E2 82 at EOF (truncated)", [0x41, 0xe2, 0x82], 1], + [ + "43 61 66 C3 A9 FF (a valid 5-byte prefix then FF)", + [0x43, 0x61, 0x66, 0xc3, 0xa9, 0xff], + 5, + ], + ["F4 90 80 80 (above U+10FFFF)", [0xf4, 0x90, 0x80, 0x80], 0], + ["a stray continuation byte", [0x41, 0x0a, 0x80], 2], + ])( + "invalid UTF-8 %s is unparseable at its byte offset", + (_name, bytes, offset) => { + const verdict = expectUnparseable( + judgeTypeScript(Uint8Array.from(bytes), "src/app.ts"), + ); + expect(verdict.reason).toBe( + `not valid UTF-8: an invalid sequence at byte offset ${offset} (SPEC 1.6)`, + ); + expect(tsDeclarationProblem(verdict, "well-formed")).toContain( + "not valid UTF-8", + ); + }, + ); + + test("a string holding a lone surrogate encodes as no UTF-8", () => { + const verdict = expectUnparseable( + judgeTypeScript( + `const a = "${String.fromCharCode(0xd800)}"`, + "src/app.ts", + ), + ); + expect(verdict.reason).toBe( + "not encodable as UTF-8: a lone surrogate at index 11", + ); + }); +}); diff --git a/test/self/s9-undeclared-staging.test.ts b/test/self/s9-undeclared-staging.test.ts new file mode 100644 index 00000000..e51b2107 --- /dev/null +++ b/test/self/s9-undeclared-staging.test.ts @@ -0,0 +1,1317 @@ +// Self-test of the workspace builder's undeclared-staging guard +// (helpers/workspace.ts `TestWorkspace.file()` and `create()`'s initial +// `files`, helpers/product-invocations.ts; TEST-SPEC 17 S-9's timing +// clause, S-7, §0 H-8). The staged-source ledger +// (helpers/staged-mdx.ts, test/self/s9-staged-sources.test.ts) holds every +// `.mdx` source a body stages after a product invocation, so that S-9's +// check runs for it before any product exists; the guard is what makes an +// omission from the ledger a harness error at the first run that reaches +// the site rather than a gap trusted to enumeration: once a product has been +// invoked — in the workspace (the subprocess driver marks the live workspace +// whose root, or realpath, the invocation's working directory is or lies +// under) or anywhere in the running registered body (the async-local context +// the suite wrapper and the certification runner establish, S-7's actual +// reach) — a plain `.mdx` staging, a `file()` write or an initial `files` +// entry of a workspace created after the invocation, throws +// `HarnessStagingError` of mode `undeclared-staging`, and nothing is +// written. +// +// Verified here: a staging before any invocation is accepted (S-7's sweep +// reaches it); after an invocation a plain staging is refused with the rule +// in the diagnosis, whatever its declaration (well-formed, unparseable, +// allowances), and the file is untouched; the exemptions — a staged-source +// record, `edit()`, `unchecked` (by option or by workspace declaration), +// `per-draw` (still judged well-formed at staging), an undeclared path not +// named `.mdx`; a spec-group file not named `.mdx` that its staging +// declares an MDX source (`mdx.wellFormed`, an `mdx` option) is guarded as +// an `.mdx` path is — by `file()`, and as an initial entry in a body — +// with the same exemptions, a record at the path among them; the +// marks — a working directory inside the root, a symbolic link resolving to +// the root, a background `startProduct`, never a sibling workspace; the +// per-body reach — a fresh later-arm workspace's `file()` is refused inside +// a body that has invoked the product elsewhere, and so is a plain `.mdx` +// initial entry of such a workspace, at creation (the diagnosis naming an +// initial `files` entry and its remedies, whatever the entry's +// declaration; the half-built workspace disposed, no temporary directory +// left behind — observed in a private temp directory), while a +// staged-source record entry (the record-accepting `InitialFileContents`, +// the form such a workspace's initial `.mdx` files take) stages under the +// record's declaration, `unchecked` and `perDraw` entries pass (the latter +// still judged), a non-`.mdx` entry passes, and so does the same creation +// before the body's first invocation; outside a body context only the +// per-workspace mark applies, so a creation — which no invocation has +// touched yet — is never refused; the registry's bookkeeping (`dispose` +// unregisters); that `per-draw` is a `file()` declaration only (the judge +// treats it as well-formed, a ledger record refuses it); and that the +// workspace declaration's `perDraw` list is its initial-file form — the +// listed path's initial contents judged well-formed at creation, a later +// plain `file()` there exempt from the guard and still judged. The guard's +// error is a harness error, never a `HarnessAssertionError`. +// +// The guard's TypeScript arm (S-9's TypeScript clause), verified for a code +// source (`src/app.ts`) and a configuration file (`xspec.config.ts`) alike: +// plain contents at a path the TypeScript check judges are refused after a +// workspace invocation and after a body invocation — by `file()`, and as +// an initial `files` entry at creation (per-body mark only, nothing left +// behind) — whatever their declaration (the default, `unparseable`, a +// `wellFormed` name the default misses), with the TypeScript remedies in +// the diagnosis; exempt are a TypeScript record, `edit()`, `unchecked`, the +// TypeScript `per-draw` declaration (by option, and as `ts.perDraw` at +// creation — still judged well-formed), `copyFrom()` out of an invoked +// workspace, and a name nothing judges; an MDX record carrying no `ts` at a +// code-group `.mdx` path is refused, one carrying its `ts` passes; and the +// TypeScript `per-draw` declaration is the builder's alone (the judge +// treats it as well-formed, a record refuses it, `tsPathsOf` lists a +// rendered map's plain TypeScript-default keys for `ts.perDraw`). + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { expect, onTestFinished, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { HarnessStagingError } from "../helpers/permissions.js"; +import { + noteProductInvocation, + productInvokedInBody, + registerWorkspaceRoot, + runProductTestBody, + unregisterWorkspaceRoot, +} from "../helpers/product-invocations.js"; +import { stagedMdx } from "../helpers/staged-mdx.js"; +import { stagedTs } from "../helpers/staged-ts.js"; +import { runProduct, startProduct } from "../helpers/subprocess.js"; +import type { ProductBinding } from "../helpers/subprocess.js"; +import { + TestWorkspace, + judgeMdxDeclaration, + judgeTsDeclaration, + tsPathsOf, +} from "../helpers/workspace.js"; +import type { WorkspaceDecl } from "../helpers/workspace.js"; + +const onPosix = process.platform !== "win32"; + +const LF = String.fromCodePoint(0x000a); + +/** Lines joined by U+000A, the last one terminated. */ +const doc = (...lines: readonly string[]): string => lines.join(LF) + LF; + +const utf8 = (text: string): Uint8Array => Buffer.from(text, "utf8"); + +const text = (data: Uint8Array): string => Buffer.from(data).toString("utf8"); + +const WELL_FORMED = doc('<S id="x">', "", "closed below", "", "</S>"); +const EDITED = doc('<S id="x">', "", "edited below", "", "</S>"); +const ILL_FORMED = doc('<S id="x">', "", "never closed"); + +const A = "specs/A.mdx"; + +/** + * A stand-in "product": Node itself, exiting 0. What the child does is + * immaterial — the driver's one invocation path (`startProduct`) is what + * marks the workspace and the body. + */ +const STANDIN: ProductBinding = { + label: "self-test stand-in (node, exits 0)", + command: process.execPath, + prefixArgs: ["-e", "process.exit(0)"], +}; + +async function stage(decl: WorkspaceDecl = {}): Promise<TestWorkspace> { + const workspace = await TestWorkspace.create(decl); + onTestFinished(() => workspace.dispose()); + return workspace; +} + +async function invoke(cwd: string): Promise<void> { + const result = await runProduct(STANDIN, { cwd, argv: [] }); + expect(result.exitCode).toBe(0); +} + +function expectUndeclared( + thrown: unknown, + key: string, + ...fragments: readonly string[] +): void { + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect(thrown).not.toBeInstanceOf(HarnessAssertionError); + const error = thrown as HarnessStagingError; + expect(error.name).toBe("HarnessStagingError"); + expect(error.mode).toBe("undeclared-staging"); + expect(error.path).toBe(key); + expect(error.message).toContain(`undeclared-staging staging of ${key}: `); + expect(error.message).toContain("after a product invocation"); + expect(error.message).toContain("staged-source record"); + expect(error.message).toContain("S-7"); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } +} + +async function expectRefused( + action: () => Promise<unknown>, + key: string, + ...fragments: readonly string[] +): Promise<void> { + let thrown: unknown; + try { + await action(); + } catch (error) { + thrown = error; + } + expectUndeclared(thrown, key, ...fragments); +} + +/** What one `TestWorkspace.create()` did, observed in a private temp dir. */ +interface Creation { + /** The workspace, when the creation succeeded (disposed at test end). */ + readonly created: TestWorkspace | undefined; + /** What the creation threw, when it was refused. */ + readonly thrown: unknown; + /** The private temp directory's entries after the creation settled. */ + readonly leftBehind: readonly string[]; +} + +const TEMP_VARIABLES = ["TMPDIR", "TMP", "TEMP"] as const; + +/** + * `TestWorkspace.create(decl)` with the OS temp directory pointed at a + * fresh, private directory for the call (`os.tmpdir()` reads these + * variables at each call), so what the creation leaves behind is exactly + * that directory's listing afterwards — immune to the `xspec-harness-*` + * directories the other self-test files create and remove concurrently in + * the shared temp directory. A success leaves its one workspace directory + * there, which is what makes an empty listing after a refusal meaningful. + */ +async function createInPrivateTemp(decl: WorkspaceDecl): Promise<Creation> { + const holder = await fsp.mkdtemp( + path.join(os.tmpdir(), "xspec-guard-self-test-"), + ); + onTestFinished(() => fsp.rm(holder, { recursive: true, force: true })); + const saved = TEMP_VARIABLES.map((name) => process.env[name]); + let redirected = ""; + let created: TestWorkspace | undefined; + let thrown: unknown; + for (const name of TEMP_VARIABLES) process.env[name] = holder; + try { + redirected = os.tmpdir(); + created = await TestWorkspace.create(decl); + } catch (error) { + thrown = error; + } finally { + TEMP_VARIABLES.forEach((name, index) => { + const value = saved[index]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + }); + } + expect(redirected).toBe(holder); + if (created !== undefined) { + const workspace = created; + onTestFinished(() => workspace.dispose()); + } + return { created, thrown, leftBehind: await fsp.readdir(holder) }; +} + +/** A creation that succeeded, leaving its one workspace directory behind. */ +function expectCreated(creation: Creation): TestWorkspace { + expect(creation.thrown).toBeUndefined(); + expect(creation.created).toBeInstanceOf(TestWorkspace); + expect(creation.leftBehind).toEqual([ + expect.stringMatching(/^xspec-harness-/), + ]); + return creation.created!; +} + +test("before any invocation, a plain `.mdx` staging is accepted — S-7's sweep reaches it", async () => { + const workspace = await stage({ files: { [A]: WELL_FORMED } }); + expect(workspace.productInvoked).toBe(false); + await workspace.file("specs/B.mdx", WELL_FORMED); + await workspace.file(A, EDITED); + expect(text(await workspace.readBytes("specs/B.mdx"))).toBe(WELL_FORMED); + expect(text(await workspace.readBytes(A))).toBe(EDITED); +}); + +test("after an invocation in the workspace, a plain `.mdx` staging is refused with the rule, and nothing is written", async () => { + const workspace = await stage({ files: { [A]: WELL_FORMED } }); + await invoke(workspace.root); + expect(workspace.productInvoked).toBe(true); + await expectRefused( + () => workspace.file(A, EDITED), + A, + "in this workspace", + 'declared "well-formed"', + "stagedMdx(", + "`edit()`", + "`unchecked`", + "`per-draw`", + ); + expect(text(await workspace.readBytes(A))).toBe(WELL_FORMED); + await expectRefused( + () => workspace.file("specs/deep/../B.mdx", utf8(WELL_FORMED)), + "specs/B.mdx", + ); + expect(await workspace.kind("specs/B.mdx")).toBe("absent"); +}); + +test("the refusal covers every plain declaration — unparseable and allowances ride the record too", async () => { + const workspace = await stage({ + files: { [A]: WELL_FORMED }, + mdx: { + unparseable: ["specs/bad.mdx"], + allowances: { "specs/dup.mdx": ["duplicate-import-binding"] }, + }, + }); + await invoke(workspace.root); + await expectRefused( + () => workspace.file("specs/bad.mdx", ILL_FORMED), + "specs/bad.mdx", + 'declared "unparseable"', + ); + await expectRefused( + () => workspace.file("specs/dup.mdx", WELL_FORMED), + "specs/dup.mdx", + '"allowances":["duplicate-import-binding"]', + ); + await expectRefused( + () => workspace.file(A, ILL_FORMED, { mdx: "unparseable" }), + A, + 'declared "unparseable"', + ); + expect(await workspace.kind("specs/bad.mdx")).toBe("absent"); + expect(await workspace.kind("specs/dup.mdx")).toBe("absent"); +}); + +test("the exemptions after an invocation: a staged-source record, `edit()`, `unchecked`, `per-draw`, a non-`.mdx` path", async () => { + const workspace = await stage({ + files: { [A]: WELL_FORMED }, + mdx: { unchecked: ["specs/fuzz.mdx"] }, + }); + await invoke(workspace.root); + + // A record: the S-9 self-test's business, staged under its declaration. + const record = stagedMdx("T0-1 guard: the edited source", EDITED); + await workspace.file(A, record); + expect(text(await workspace.readBytes(A))).toBe(EDITED); + + // `edit()`: a declared staging by construction (bytes the product wrote, + // in a real body) — judged at staging time, never guarded. + await workspace.edit(A, "edited below", "edited again"); + expect(text(await workspace.readBytes(A))).toBe( + EDITED.replace("edited below", "edited again"), + ); + + // `unchecked`, by option and by workspace declaration (P-8's mutations). + await workspace.file(A, ILL_FORMED, { mdx: "unchecked" }); + expect(text(await workspace.readBytes(A))).toBe(ILL_FORMED); + await workspace.file("specs/fuzz.mdx", ILL_FORMED); + expect(text(await workspace.readBytes("specs/fuzz.mdx"))).toBe(ILL_FORMED); + + // `per-draw`: exempt from the guard, still judged well-formed at staging. + await workspace.file(A, WELL_FORMED, { mdx: "per-draw" }); + expect(text(await workspace.readBytes(A))).toBe(WELL_FORMED); + let thrown: unknown; + try { + await workspace.file(A, ILL_FORMED, { mdx: "per-draw" }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect((thrown as HarnessStagingError).mode).toBe("mdx-derivability"); + expect((thrown as HarnessStagingError).message).toContain("per draw"); + expect(text(await workspace.readBytes(A))).toBe(WELL_FORMED); + + // A path S-9 judges neither way (an MDX source or a code source at such a + // name is judged only when declared — `mdx.wellFormed`, `ts.wellFormed` — + // and then guarded; the next test, and the TypeScript arm's below). + await workspace.file("notes.txt", "plain text\n"); + await workspace.file("specs/code.md", "export {};\n"); + expect(text(await workspace.readBytes("notes.txt"))).toBe("plain text\n"); + expect(text(await workspace.readBytes("specs/code.md"))).toBe("export {};\n"); +}); + +test("a spec-group file not named `.mdx` that its staging declares an MDX source joins the guard: after an invocation, plain contents there are refused — by `file()`, and as an initial `files` entry in a body — while a record, `unchecked`, and `per-draw` pass, and an undeclared path stays unjudged", async () => { + const NOTES = "specs/notes.txt"; + const workspace = await stage({ + files: { [A]: WELL_FORMED, [NOTES]: WELL_FORMED }, + mdx: { wellFormed: [NOTES], unchecked: ["specs/fuzz.txt"] }, + }); + // Before any invocation the same plain staging passes, judged. + await workspace.file(NOTES, EDITED); + await invoke(workspace.root); + + // Declared by the workspace's `mdx.wellFormed`: refused, nothing written. + await expectRefused( + () => workspace.file(NOTES, WELL_FORMED), + NOTES, + "in this workspace", + 'declared "well-formed"', + "stagedMdx(", + ); + expect(text(await workspace.readBytes(NOTES))).toBe(EDITED); + // Declared by a `file()` option, whatever the declaration. + await expectRefused( + () => + workspace.file("specs/other.txt", WELL_FORMED, { mdx: "well-formed" }), + "specs/other.txt", + 'declared "well-formed"', + ); + await expectRefused( + () => workspace.file("specs/bad.txt", ILL_FORMED, { mdx: "unparseable" }), + "specs/bad.txt", + 'declared "unparseable"', + ); + expect(await workspace.kind("specs/other.txt")).toBe("absent"); + expect(await workspace.kind("specs/bad.txt")).toBe("absent"); + + // The exemptions: a record (it declares its path an MDX source and + // carries the declaration), `unchecked`, and `per-draw` (still judged). + await workspace.file( + NOTES, + stagedMdx( + "T0-1 guard: the edited source at a path not named .mdx", + WELL_FORMED, + ), + ); + expect(text(await workspace.readBytes(NOTES))).toBe(WELL_FORMED); + await workspace.file("specs/fuzz.txt", ILL_FORMED); + await workspace.file("specs/bad.txt", ILL_FORMED, { mdx: "unchecked" }); + await workspace.file(NOTES, EDITED, { mdx: "per-draw" }); + expect(text(await workspace.readBytes(NOTES))).toBe(EDITED); + let thrown: unknown; + try { + await workspace.file(NOTES, ILL_FORMED, { mdx: "per-draw" }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect((thrown as HarnessStagingError).mode).toBe("mdx-derivability"); + expect(text(await workspace.readBytes(NOTES))).toBe(EDITED); + // Undeclared: neither judged nor guarded. + await workspace.file("specs/plain.txt", ILL_FORMED); + expect(text(await workspace.readBytes("specs/plain.txt"))).toBe(ILL_FORMED); + + // As an initial `files` entry of a workspace created after the running + // body's first invocation: refused at creation, nothing left behind; a + // record entry there passes, and an undeclared entry is unjudged. + await runProductTestBody("T0-16", async () => { + const first = await stage({ files: { [A]: WELL_FORMED } }); + await invoke(first.root); + const refused = await createInPrivateTemp({ + files: { [NOTES]: WELL_FORMED }, + mdx: { wellFormed: [NOTES] }, + }); + expect(refused.created).toBeUndefined(); + expectUndeclared( + refused.thrown, + NOTES, + "an initial `files` entry of a workspace created after a product invocation in the running body of T0-16", + 'declared "well-formed"', + ); + expect(refused.leftBehind).toEqual([]); + const record = expectCreated( + await createInPrivateTemp({ + files: { + [NOTES]: stagedMdx( + "T0-16 guard: a later workspace's initial source at a path not named .mdx", + WELL_FORMED, + ), + "specs/plain.txt": ILL_FORMED, + }, + }), + ); + expect(text(await record.readBytes(NOTES))).toBe(WELL_FORMED); + expect(text(await record.readBytes("specs/plain.txt"))).toBe(ILL_FORMED); + }); +}); + +test("an invocation inside the root marks the workspace; a background start marks it before the child runs; a sibling workspace stays unmarked", async () => { + const first = await stage({ dirs: ["sub/deeper"] }); + const sibling = await stage({ files: { [A]: WELL_FORMED } }); + await invoke(first.path("sub/deeper")); + expect(first.productInvoked).toBe(true); + expect(sibling.productInvoked).toBe(false); + await sibling.file(A, EDITED); + expect(text(await sibling.readBytes(A))).toBe(EDITED); + + const held = await stage(); + const running = await startProduct(STANDIN, { cwd: held.root, argv: [] }); + expect(held.productInvoked).toBe(true); + const result = await running.waitForExit(); + expect(result.exitCode).toBe(0); + await expectRefused(() => held.file(A, WELL_FORMED), A); +}); + +test.runIf(onPosix)( + "an invocation through a symbolic link resolving into the workspace marks it (the realpath is matched, T13.4-6's shape)", + async () => { + const workspace = await stage({ files: { [A]: WELL_FORMED } }); + const link = path.join(workspace.tempRoot, "link-work"); + await fsp.symlink("work", link, "dir"); + await invoke(link); + expect(workspace.productInvoked).toBe(true); + await expectRefused(() => workspace.file(A, EDITED), A); + }, +); + +test("per-body reach: inside a registered body, an invocation anywhere makes a plain staging into a fresh workspace refused — its initial `files` take records and declared draws", async () => { + expect(productInvokedInBody()).toBeUndefined(); + await runProductTestBody("T0-2", async () => { + expect(productInvokedInBody()).toBeUndefined(); + const first = await stage({ files: { [A]: WELL_FORMED } }); + await first.file(A, EDITED); // before the body's first invocation + await invoke(first.root); + expect(productInvokedInBody()).toBe("T0-2"); + + // A plain `.mdx` initial entry here is refused at creation (the next + // test); a non-`.mdx` one is not. + const later = await stage({ files: { "notes.txt": "plain text\n" } }); + expect(later.productInvoked).toBe(false); + expect(text(await later.readBytes("notes.txt"))).toBe("plain text\n"); + await expectRefused( + () => later.file("specs/B.mdx", WELL_FORMED), + "specs/B.mdx", + "in the running body of T0-2", + "in another workspace", + ); + expect(await later.kind("specs/B.mdx")).toBe("absent"); + await later.file( + "specs/B.mdx", + stagedMdx("T0-2 guard: the later-arm source", WELL_FORMED), + ); + expect(text(await later.readBytes("specs/B.mdx"))).toBe(WELL_FORMED); + await later.file("specs/C.mdx", ILL_FORMED, { mdx: "unchecked" }); + await later.file("specs/D.mdx", WELL_FORMED, { mdx: "per-draw" }); + + // A later-arm workspace's initial `.mdx` files as records (the + // record-accepting `files`): `create()` stages each under the record's + // declaration, inside the body that has invoked; a `perDraw` entry is + // judged well-formed and its path takes plain contents past the guard. + const arm = await stage({ + files: { + "specs/E.mdx": stagedMdx( + "T0-2 guard: the later-arm initial source", + WELL_FORMED, + ), + "specs/draw.mdx": WELL_FORMED, + "notes.txt": "plain text\n", + }, + mdx: { perDraw: ["specs/draw.mdx"] }, + }); + expect(arm.productInvoked).toBe(false); + expect(text(await arm.readBytes("specs/E.mdx"))).toBe(WELL_FORMED); + expect(text(await arm.readBytes("specs/draw.mdx"))).toBe(WELL_FORMED); + await arm.file("specs/draw.mdx", EDITED); + expect(text(await arm.readBytes("specs/draw.mdx"))).toBe(EDITED); + await expectRefused( + () => arm.file("specs/E.mdx", EDITED), + "specs/E.mdx", + "in the running body of T0-2", + ); + }); + expect(productInvokedInBody()).toBeUndefined(); + + // Outside a body context only the per-workspace mark applies: a fresh + // workspace after an invocation elsewhere is not flagged. + const elsewhere = await stage(); + await invoke(elsewhere.root); + const fresh = await stage(); + await fresh.file(A, WELL_FORMED); + expect(text(await fresh.readBytes(A))).toBe(WELL_FORMED); +}); + +test("initial `files` join the guard: inside a body that has invoked the product, a plain `.mdx` entry of a fresh workspace is refused at creation with the rule, whatever its declaration, and nothing is left behind", async () => { + const INITIAL = + "an initial `files` entry of a workspace created after a product invocation in the running body of T0-9"; + await runProductTestBody("T0-9", async () => { + // Before the body's first invocation the same creation passes — S-7's + // sweep reaches it (a body's first workspace may stay plain). + const first = expectCreated( + await createInPrivateTemp({ files: { [A]: WELL_FORMED } }), + ); + expect(text(await first.readBytes(A))).toBe(WELL_FORMED); + await invoke(first.root); + expect(productInvokedInBody()).toBe("T0-9"); + + // After it: refused, the site and its remedies named, no temporary + // directory left behind. + const refused = await createInPrivateTemp({ files: { [A]: WELL_FORMED } }); + expect(refused.created).toBeUndefined(); + expectUndeclared( + refused.thrown, + A, + INITIAL, + "in another workspace", + 'staged with plain contents (declared "well-formed")', + "Pass a staged-source record as the entry's value", + "stagedMdx(", + "`mdx.perDraw`", + "`mdx.unchecked`", + ); + expect(refused.leftBehind).toEqual([]); + + // Entries staged before the refused one — a record, a non-`.mdx` file — + // go with the disposed half-built workspace. + const mixed = await createInPrivateTemp({ + files: { + "specs/Z.mdx": stagedMdx( + "T0-9 guard: an initial record staged before the refused entry", + WELL_FORMED, + ), + "notes.txt": "plain text\n", + [A]: WELL_FORMED, + }, + }); + expectUndeclared(mixed.thrown, A, INITIAL); + expect(mixed.leftBehind).toEqual([]); + + // Whatever the plain entry's declaration — unparseable and allowances + // ride a record too — and under its normalized key. + const unparseable = await createInPrivateTemp({ + files: { "specs/bad.mdx": ILL_FORMED }, + mdx: { unparseable: ["specs/bad.mdx"] }, + }); + expectUndeclared( + unparseable.thrown, + "specs/bad.mdx", + INITIAL, + 'declared "unparseable"', + ); + expect(unparseable.leftBehind).toEqual([]); + const allowances = await createInPrivateTemp({ + files: { "specs/dup.mdx": WELL_FORMED }, + mdx: { allowances: { "specs/dup.mdx": ["duplicate-import-binding"] } }, + }); + expectUndeclared( + allowances.thrown, + "specs/dup.mdx", + INITIAL, + '"allowances":["duplicate-import-binding"]', + ); + expect(allowances.leftBehind).toEqual([]); + const unnormalized = await createInPrivateTemp({ + files: { "specs/deep/../B.mdx": WELL_FORMED }, + }); + expectUndeclared(unnormalized.thrown, "specs/B.mdx", INITIAL); + expect(unnormalized.leftBehind).toEqual([]); + + // The exemptions: a record entry, staged under the record's declaration. + const record = expectCreated( + await createInPrivateTemp({ + files: { + [A]: stagedMdx( + "T0-9 guard: a later workspace's initial source", + WELL_FORMED, + ), + }, + }), + ); + expect(text(await record.readBytes(A))).toBe(WELL_FORMED); + + // `unchecked` (P-8's mutations, noise): never judged. + const unchecked = expectCreated( + await createInPrivateTemp({ + files: { [A]: ILL_FORMED }, + mdx: { unchecked: [A] }, + }), + ); + expect(text(await unchecked.readBytes(A))).toBe(ILL_FORMED); + + // `perDraw`: past the guard, still judged well-formed at creation. + const draw = expectCreated( + await createInPrivateTemp({ + files: { [A]: WELL_FORMED }, + mdx: { perDraw: [A] }, + }), + ); + expect(text(await draw.readBytes(A))).toBe(WELL_FORMED); + const illDraw = await createInPrivateTemp({ + files: { [A]: ILL_FORMED }, + mdx: { perDraw: [A] }, + }); + expect(illDraw.thrown).toBeInstanceOf(HarnessStagingError); + expect((illDraw.thrown as HarnessStagingError).mode).toBe( + "mdx-derivability", + ); + expect((illDraw.thrown as HarnessStagingError).message).toContain( + "per draw", + ); + expect(illDraw.leftBehind).toEqual([]); + + // A path S-9 judges neither way always passes (a code source or + // configuration file is guarded too — the TypeScript arm's tests below). + const plain = expectCreated( + await createInPrivateTemp({ + files: { + "notes.txt": "plain text\n", + "specs/code.md": "export {};\n", + }, + }), + ); + expect(text(await plain.readBytes("specs/code.md"))).toBe("export {};\n"); + }); + + // Outside any body context, after an invocation elsewhere: only the + // per-workspace mark applies, and a workspace being created has none. + const elsewhere = await stage(); + await invoke(elsewhere.root); + expect(productInvokedInBody()).toBeUndefined(); + const outside = expectCreated( + await createInPrivateTemp({ files: { [A]: WELL_FORMED } }), + ); + expect(text(await outside.readBytes(A))).toBe(WELL_FORMED); +}); + +test("`runProductTestBody` keeps contexts apart and turns a synchronous throw into a rejection", async () => { + await Promise.all([ + runProductTestBody("T0-3", async () => { + noteProductInvocation("/nowhere/at/all", "/nowhere/at/all"); + expect(productInvokedInBody()).toBe("T0-3"); + }), + runProductTestBody("T0-4", async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(productInvokedInBody()).toBeUndefined(); + }), + ]); + await expect( + runProductTestBody("T0-5", () => { + throw new TypeError("synchronous throw from the body"); + }), + ).rejects.toThrow("synchronous throw from the body"); +}); + +test("the root registry: an invocation at or under a registered root marks that root's live mark; `unregister` retires it", () => { + const root = path.resolve("/xspec-guard-self-test/tmp-a/work"); + const realRoot = path.resolve("/xspec-guard-self-test/real/tmp-a/work"); + const mark = registerWorkspaceRoot(root, realRoot); + expect(mark.invoked).toBe(false); + const parent = path.resolve("/xspec-guard-self-test/tmp-a"); + noteProductInvocation(parent, parent); + expect(mark.invoked).toBe(false); // the parent of a root is outside it + noteProductInvocation(path.join(realRoot, "sub"), path.join(realRoot, "sub")); + expect(mark.invoked).toBe(true); // the realpath key matches too + unregisterWorkspaceRoot(mark); + const again = registerWorkspaceRoot(root, realRoot); + expect(again.invoked).toBe(false); + noteProductInvocation(root, root); + expect(again.invoked).toBe(true); + unregisterWorkspaceRoot(again); + unregisterWorkspaceRoot(again); // idempotent +}); + +test("`dispose()` unregisters the workspace, so a later invocation in its directory marks nothing live", async () => { + const workspace = await TestWorkspace.create(); + const { root } = workspace; + await workspace.dispose(); + await fsp.mkdir(root, { recursive: true }); + onTestFinished(() => workspace.dispose()); + await invoke(root); + expect(workspace.productInvoked).toBe(false); +}); + +test("`perDraw` in the workspace declaration: the listed path's initial contents are judged well-formed at creation, and after an invocation a plain `file()` there passes the guard — still judged", async () => { + const draw = "specs/draw.mdx"; + const workspace = await stage({ + files: { [A]: WELL_FORMED, [draw]: WELL_FORMED }, + mdx: { perDraw: [draw] }, + }); + expect(workspace.mdxDeclarationOf(draw)).toBe("per-draw"); + await invoke(workspace.root); + await workspace.file(draw, EDITED); + expect(text(await workspace.readBytes(draw))).toBe(EDITED); + let thrown: unknown; + try { + await workspace.file(draw, ILL_FORMED); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect((thrown as HarnessStagingError).mode).toBe("mdx-derivability"); + expect((thrown as HarnessStagingError).message).toContain("per draw"); + expect(text(await workspace.readBytes(draw))).toBe(EDITED); + // The unlisted path is guarded as before. + await expectRefused(() => workspace.file(A, EDITED), A, "in this workspace"); + + // An ill-formed `perDraw` initial entry is refused at creation. + thrown = undefined; + try { + const refused = await TestWorkspace.create({ + files: { [draw]: ILL_FORMED }, + mdx: { perDraw: [draw] }, + }); + onTestFinished(() => refused.dispose()); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect((thrown as HarnessStagingError).mode).toBe("mdx-derivability"); + expect((thrown as HarnessStagingError).path).toBe(draw); + expect((thrown as HarnessStagingError).message).toContain("per draw"); +}); + +test("`per-draw` is a `file()` declaration only: the judge treats it as well-formed, and a ledger record refuses it", () => { + judgeMdxDeclaration("draw", utf8(WELL_FORMED), "per-draw"); + let thrown: unknown; + try { + judgeMdxDeclaration("draw", utf8(ILL_FORMED), "per-draw"); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect((thrown as HarnessStagingError).mode).toBe("mdx-derivability"); + expect((thrown as HarnessStagingError).message).toContain( + "declared well-formed per draw", + ); + expect(() => + stagedMdx("T0-6 per-draw record", WELL_FORMED, "per-draw"), + ).toThrow(/per-draw/); +}); + +test("`copyFrom()` carries another workspace's bytes — the product's output there — past the guard, judged at staging; out of a workspace the product never touched, the guard applies as to plain contents", async () => { + const untouched = await stage({ files: { [A]: WELL_FORMED } }); + await runProductTestBody("T0-7", async () => { + const source = await stage({ + files: { [A]: WELL_FORMED, "xspec.config.ts": "export default {};\n" }, + mdx: { unchecked: ["specs/fuzz.mdx"] }, + }); + await invoke(source.root); + const fresh = await stage(); + await fresh.copyFrom(source, A); + await fresh.copyFrom(source, "xspec.config.ts"); + await fresh.copyFrom(source, A, "specs/Copy.mdx"); + expect(text(await fresh.readBytes(A))).toBe(WELL_FORMED); + expect(text(await fresh.readBytes("specs/Copy.mdx"))).toBe(WELL_FORMED); + expect(text(await fresh.readBytes("xspec.config.ts"))).toBe( + "export default {};\n", + ); + + // Judged at staging under the destination's declaration (well-formed). + await source.file("specs/fuzz.mdx", ILL_FORMED); + let thrown: unknown; + try { + await fresh.copyFrom(source, "specs/fuzz.mdx"); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HarnessStagingError); + expect((thrown as HarnessStagingError).mode).toBe("mdx-derivability"); + expect(await fresh.kind("specs/fuzz.mdx")).toBe("absent"); + + // Out of a workspace no product touched: the harness's own bytes, a + // deterministic fixture — refused here, inside a body that has invoked + // the product. + await expectRefused( + () => fresh.copyFrom(untouched, A, "specs/B.mdx"), + "specs/B.mdx", + "in the running body of T0-7", + "`copyFrom()`", + ); + expect(await fresh.kind("specs/B.mdx")).toBe("absent"); + }); + + // Before any invocation (outside a body, nothing invoked in the + // destination) the same copy is a pre-invocation staging S-7 reaches. + const fresh = await stage(); + await fresh.copyFrom(untouched, A); + expect(text(await fresh.readBytes(A))).toBe(WELL_FORMED); +}); + +// --------------------------------------------------------------------------- +// The guard's TypeScript arm (S-9's TypeScript and timing clauses): a code +// source or configuration file — any path S-9's TypeScript check judges — +// staged after a product invocation is held to the TypeScript record form +// exactly as an `.mdx` source is held to the MDX one. + +const TS_SOURCE = "export const a = 1;" + LF; +const TS_EDITED = "export const a = 2;" + LF; +const TS_ILL_FORMED = "export const = 1;" + LF; +const CONFIG_SOURCE = doc( + 'import { defineConfig } from "xspec"', + "", + "export default defineConfig({", + ' specs: { main: ["specs/**/*.mdx"] }', + "})", +); +const CONFIG_EDITED = doc( + 'import { defineConfig } from "xspec"', + "", + "export default defineConfig({", + ' specs: { main: ["docs/**/*.mdx"] }', + "})", +); +/** Well-formed MDX (an ESM block) and well-formed TypeScript alike. */ +const MDX_AND_TS = "export {};" + LF; + +/** One kind of TypeScript staging the arm covers. */ +interface TsStaging { + readonly rel: string; + readonly what: string; + readonly source: string; + readonly edited: string; + /** An `edit()` of `edited`: the spelling replaced, and its replacement. */ + readonly edit: readonly [from: string, to: string]; +} + +/** A code source and the configuration file. */ +const TS_STAGINGS: readonly TsStaging[] = [ + { + rel: "src/app.ts", + what: "code source", + source: TS_SOURCE, + edited: TS_EDITED, + edit: ["a = 2", "a = 3"], + }, + { + rel: "xspec.config.ts", + what: "configuration", + source: CONFIG_SOURCE, + edited: CONFIG_EDITED, + edit: ["docs/", "lib/"], + }, +]; + +/** The TypeScript arm's plain-write diagnosis, with its remedies. */ +const TS_WRITE_REMEDIES = [ + "a code source or configuration file staged with plain contents", + "S-9's TypeScript check", + "Stage it as a TypeScript staged-source record", + "stagedTs(", + "stagedMdx(name, source, mdx, ts)", + "`edit()`", + '`{ ts: "unchecked" }`', + '`{ ts: "per-draw" }`', + "`copyFrom()`", +] as const; + +/** The TypeScript arm's initial-entry diagnosis, with its remedies. */ +const TS_INITIAL_REMEDIES = [ + "a code source or configuration file staged with plain contents", + "S-9's TypeScript check", + "Pass a TypeScript staged-source record as the entry's value", + "stagedTs(", + "stagedMdx(name, source, mdx, ts)", + "`ts.perDraw`", + "`ts.unchecked`", +] as const; + +/** A thrown `HarnessStagingError` of mode `ts-derivability` (the S-9 judge). */ +function expectTsJudged( + thrown: unknown, + key: string, + ...fragments: readonly string[] +): void { + expect(thrown).toBeInstanceOf(HarnessStagingError); + const error = thrown as HarnessStagingError; + expect(error.mode).toBe("ts-derivability"); + expect(error.path).toBe(key); + for (const fragment of fragments) { + expect(error.message).toContain(fragment); + } +} + +async function caught(action: () => Promise<unknown>): Promise<unknown> { + try { + await action(); + } catch (error) { + return error; + } + return undefined; +} + +test("the TypeScript arm: after an invocation in the workspace, a plain code source or configuration file staged by `file()` is refused with the TypeScript rule, whatever its declaration, and nothing is written", async () => { + for (const { rel, source, edited } of TS_STAGINGS) { + const workspace = await stage({ + files: { [rel]: source }, + ts: { unparseable: ["src/bad.ts"], wellFormed: ["specs/code.md"] }, + }); + // Before any invocation S-7's sweep reaches the staging: accepted. + await workspace.file(rel, edited); + await workspace.file(rel, source); + await invoke(workspace.root); + await expectRefused( + () => workspace.file(rel, edited), + rel, + "in this workspace", + 'declared "well-formed"', + ...TS_WRITE_REMEDIES, + ); + expect(text(await workspace.readBytes(rel))).toBe(source); + // As bytes, and under its normalized key. + await expectRefused( + () => workspace.file(`deep/../${rel}`, utf8(edited)), + rel, + "S-9's TypeScript check", + ); + expect(text(await workspace.readBytes(rel))).toBe(source); + // Whatever the declaration: the option's, the workspace's `unparseable` + // entry, a `wellFormed` code source whose name the default misses. + await expectRefused( + () => workspace.file(rel, TS_ILL_FORMED, { ts: "unparseable" }), + rel, + 'declared "unparseable"', + ); + await expectRefused( + () => workspace.file("src/bad.ts", TS_ILL_FORMED), + "src/bad.ts", + 'declared "unparseable"', + ); + await expectRefused( + () => workspace.file("specs/code.md", TS_SOURCE), + "specs/code.md", + 'declared "well-formed"', + ); + expect(text(await workspace.readBytes(rel))).toBe(source); + expect(await workspace.kind("src/bad.ts")).toBe("absent"); + expect(await workspace.kind("specs/code.md")).toBe("absent"); + } +}); + +test("the TypeScript arm's exemptions after an invocation: a TypeScript record, `edit()`, `unchecked`, `per-draw` (still judged well-formed), `copyFrom()` out of an invoked workspace, and a name nothing judges", async () => { + for (const { rel, what, source, edited, edit } of TS_STAGINGS) { + const workspace = await stage({ + files: { [rel]: source }, + ts: { unchecked: ["src/fuzz.ts"] }, + }); + await invoke(workspace.root); + + // A record: the S-9 self-test's business, staged under its declaration. + await workspace.file( + rel, + stagedTs(`T0-12 guard: the edited ${what}`, edited), + ); + expect(text(await workspace.readBytes(rel))).toBe(edited); + + // `edit()`: bytes the product wrote, in a real body — judged at + // staging, never guarded. + const [from, to] = edit; + await workspace.edit(rel, from, to); + expect(text(await workspace.readBytes(rel))).toBe(edited.replace(from, to)); + + // `unchecked`, by option and by workspace declaration (P-8's and P-11's + // mutations, a tampered product-written module): never judged. + await workspace.file(rel, TS_ILL_FORMED, { ts: "unchecked" }); + expect(text(await workspace.readBytes(rel))).toBe(TS_ILL_FORMED); + await workspace.file("src/fuzz.ts", TS_ILL_FORMED); + expect(text(await workspace.readBytes("src/fuzz.ts"))).toBe(TS_ILL_FORMED); + + // `per-draw` (a property draw's composed file): exempt from the guard, + // still judged well-formed at staging. + await workspace.file(rel, source, { ts: "per-draw" }); + expect(text(await workspace.readBytes(rel))).toBe(source); + expectTsJudged( + await caught(() => + workspace.file(rel, TS_ILL_FORMED, { ts: "per-draw" }), + ), + rel, + "declared well-formed per draw", + "fix the generator", + ); + expect(text(await workspace.readBytes(rel))).toBe(source); + + // `copyFrom()` out of a workspace the product was invoked in carries + // the product's output there — judged, never guarded; out of one it + // never touched, the bytes are the harness's own: refused. + const invoked = await stage({ files: { [rel]: edited } }); + await invoke(invoked.root); + await workspace.copyFrom(invoked, rel); + expect(text(await workspace.readBytes(rel))).toBe(edited); + const untouched = await stage({ files: { [rel]: source } }); + await expectRefused( + () => workspace.copyFrom(untouched, rel), + rel, + "in this workspace", + "S-9's TypeScript check", + "`copyFrom()`", + ); + expect(text(await workspace.readBytes(rel))).toBe(edited); + + // Names S-9 judges neither way. + await workspace.file("notes.txt", "plain text" + LF); + await workspace.file("specs/code.md", TS_ILL_FORMED); + expect(text(await workspace.readBytes("specs/code.md"))).toBe( + TS_ILL_FORMED, + ); + } +}); + +test("the TypeScript arm's per-body reach: inside a body that has invoked the product, a fresh workspace's plain code source or configuration is refused — by `file()`, and as an initial `files` entry at creation, nothing left behind — while records, `unchecked`, and per-draw stagings pass, the last still judged", async () => { + const INITIAL = + "an initial `files` entry of a workspace created after a product invocation in the running body of T0-13"; + const plainFiles = Object.fromEntries( + TS_STAGINGS.map(({ rel, source }) => [rel, source]), + ); + await runProductTestBody("T0-13", async () => { + // Before the body's first invocation: S-7's sweep reaches a plain + // creation and a plain `file()` (a first workspace may stay plain). + const first = expectCreated( + await createInPrivateTemp({ files: plainFiles }), + ); + for (const { rel, edited } of TS_STAGINGS) await first.file(rel, edited); + await invoke(first.root); + expect(productInvokedInBody()).toBe("T0-13"); + + for (const { rel, what, source, edited } of TS_STAGINGS) { + // `file()` into a fresh later-arm workspace the product never touched. + const later = await stage(); + expect(later.productInvoked).toBe(false); + await expectRefused( + () => later.file(rel, source), + rel, + "in the running body of T0-13", + "in another workspace", + ...TS_WRITE_REMEDIES, + ); + expect(await later.kind(rel)).toBe("absent"); + await later.file( + rel, + stagedTs(`T0-13 guard: the later-arm ${what}`, source), + ); + expect(text(await later.readBytes(rel))).toBe(source); + await later.file(rel, TS_ILL_FORMED, { ts: "unchecked" }); + await later.file(rel, edited, { ts: "per-draw" }); + expect(text(await later.readBytes(rel))).toBe(edited); + + // An initial entry: refused at creation, whatever its declaration, + // the half-built workspace disposed with what it staged before. + const refused = await createInPrivateTemp({ files: { [rel]: source } }); + expect(refused.created).toBeUndefined(); + expectUndeclared( + refused.thrown, + rel, + INITIAL, + "in another workspace", + '(declared "well-formed")', + ...TS_INITIAL_REMEDIES, + ); + expect(refused.leftBehind).toEqual([]); + const unparseable = await createInPrivateTemp({ + files: { [rel]: TS_ILL_FORMED }, + ts: { unparseable: [rel] }, + }); + expectUndeclared( + unparseable.thrown, + rel, + INITIAL, + 'declared "unparseable"', + ); + expect(unparseable.leftBehind).toEqual([]); + const mixed = await createInPrivateTemp({ + files: { + "specs/Z.mdx": stagedMdx( + `T0-13 guard: an initial record staged before the refused ${what}`, + WELL_FORMED, + ), + "notes.txt": "plain text" + LF, + [rel]: source, + }, + }); + expectUndeclared(mixed.thrown, rel, INITIAL); + expect(mixed.leftBehind).toEqual([]); + + // The exemptions at creation: a record entry, `ts.unchecked`, and + // `ts.perDraw` — judged well-formed at creation, its path then taking + // a plain `file()` past the guard, still judged. + const record = expectCreated( + await createInPrivateTemp({ + files: { + [rel]: stagedTs( + `T0-13 guard: a later workspace's initial ${what}`, + source, + ), + }, + }), + ); + expect(text(await record.readBytes(rel))).toBe(source); + const unchecked = expectCreated( + await createInPrivateTemp({ + files: { [rel]: TS_ILL_FORMED }, + ts: { unchecked: [rel] }, + }), + ); + expect(text(await unchecked.readBytes(rel))).toBe(TS_ILL_FORMED); + const draw = expectCreated( + await createInPrivateTemp({ + files: { [rel]: source }, + ts: { perDraw: [rel] }, + }), + ); + expect(draw.tsDeclarationOf(rel)).toBe("per-draw"); + await draw.file(rel, edited); + expect(text(await draw.readBytes(rel))).toBe(edited); + expectTsJudged( + await caught(() => draw.file(rel, TS_ILL_FORMED)), + rel, + "declared well-formed per draw", + ); + expect(text(await draw.readBytes(rel))).toBe(edited); + const illDraw = await createInPrivateTemp({ + files: { [rel]: TS_ILL_FORMED }, + ts: { perDraw: [rel] }, + }); + expectTsJudged(illDraw.thrown, rel, "declared well-formed per draw"); + expect(illDraw.leftBehind).toEqual([]); + } + + // A code source whose name the default misses, declared `wellFormed`, + // is guarded at creation too. + const declared = await createInPrivateTemp({ + files: { "specs/code.md": TS_SOURCE }, + ts: { wellFormed: ["specs/code.md"] }, + }); + expectUndeclared(declared.thrown, "specs/code.md", INITIAL); + expect(declared.leftBehind).toEqual([]); + }); + + // Outside any body context, after an invocation elsewhere: only the + // per-workspace mark applies, and a workspace being created has none (the + // E-6 fixture's creation, the Windows leg's drive-mismatch arm's). + const elsewhere = await stage(); + await invoke(elsewhere.root); + expect(productInvokedInBody()).toBeUndefined(); + const outside = expectCreated( + await createInPrivateTemp({ files: plainFiles }), + ); + for (const { rel, source } of TS_STAGINGS) { + expect(text(await outside.readBytes(rel))).toBe(source); + } +}); + +test("the TypeScript arm at a code-group `.mdx` path: after an invocation, an MDX record carrying no `ts` where the TypeScript check judges the path is refused — by `file()` and at creation — while one carrying its `ts` passes", async () => { + const CODE_MDX = "docs/impl.mdx"; + const bare = stagedMdx( + "T0-14 guard: a code-group source without its TypeScript declaration", + MDX_AND_TS, + ); + const carrying = stagedMdx( + "T0-14 guard: a code-group source with its TypeScript declaration", + MDX_AND_TS, + "well-formed", + "well-formed", + ); + const RECORD_REMEDIES = [ + "the MDX staged-source record", + "carries no TypeScript declaration", + "S-9's TypeScript check", + "stagedMdx(name, source, mdx, ts)", + "`ts.unchecked`", + "`ts.perDraw`", + ] as const; + + // The workspace's `ts` declaration makes the path judged as TypeScript. + const declared = await stage({ ts: { wellFormed: [CODE_MDX] } }); + await declared.file(CODE_MDX, bare); // before any invocation: reached + await invoke(declared.root); + await expectRefused( + () => declared.file(CODE_MDX, bare), + CODE_MDX, + "in this workspace", + 'declared "well-formed"', + ...RECORD_REMEDIES, + ); + // So does a `ts` option. + const optioned = await stage(); + await invoke(optioned.root); + await expectRefused( + () => optioned.file(CODE_MDX, bare, { ts: "well-formed" }), + CODE_MDX, + "in this workspace", + ...RECORD_REMEDIES, + ); + expect(await optioned.kind(CODE_MDX)).toBe("absent"); + // Exempt: the record carrying its `ts`; an `unchecked` TypeScript + // declaration; a path no code group discovers (MDX alone). + await optioned.file(CODE_MDX, carrying); + expect(text(await optioned.readBytes(CODE_MDX))).toBe(MDX_AND_TS); + await optioned.file(CODE_MDX, bare, { ts: "unchecked" }); + await optioned.file("docs/prose.mdx", bare); + expect(text(await optioned.readBytes("docs/prose.mdx"))).toBe(MDX_AND_TS); + + // As an initial entry of a workspace created after the body's first + // invocation. + await runProductTestBody("T0-14", async () => { + const first = await stage(); + await invoke(first.root); + const refused = await createInPrivateTemp({ + files: { [CODE_MDX]: bare }, + ts: { wellFormed: [CODE_MDX] }, + }); + expectUndeclared( + refused.thrown, + CODE_MDX, + "an initial `files` entry of a workspace created after a product invocation in the running body of T0-14", + ...RECORD_REMEDIES, + ); + expect(refused.leftBehind).toEqual([]); + const passed = expectCreated( + await createInPrivateTemp({ files: { [CODE_MDX]: carrying } }), + ); + expect(text(await passed.readBytes(CODE_MDX))).toBe(MDX_AND_TS); + expectCreated( + await createInPrivateTemp({ + files: { [CODE_MDX]: bare }, + ts: { unchecked: [CODE_MDX] }, + }), + ); + }); +}); + +test("the TypeScript `per-draw` declaration is the builder's alone: the judge treats it as well-formed, a record refuses it, a path takes one TypeScript list, a record beside it contradicts it, and `tsPathsOf` lists a rendered map's plain TypeScript-default keys", async () => { + judgeTsDeclaration("draw", utf8(TS_SOURCE), "per-draw"); + expectTsJudged( + await caught(async () => { + judgeTsDeclaration("draw", utf8(TS_ILL_FORMED), "per-draw"); + }), + "draw", + "declared well-formed per draw", + "fix the generator", + ); + expect(() => + stagedTs( + "T0-15 per-draw record", + TS_SOURCE, + "per-draw" as unknown as "well-formed", + ), + ).toThrow(/per-draw/); + expect(() => + stagedMdx( + "T0-15 per-draw TypeScript declaration", + MDX_AND_TS, + "well-formed", + "per-draw" as unknown as "well-formed", + ), + ).toThrow(/per-draw/); + expectTsJudged( + await caught(() => + TestWorkspace.create({ + ts: { perDraw: ["src/app.ts"], unchecked: ["src/app.ts"] }, + }), + ), + "src/app.ts", + "more than one of", + "`perDraw`", + ); + expectTsJudged( + await caught(() => + TestWorkspace.create({ + files: { + "src/app.ts": stagedTs("T0-15 a record beside a draw", TS_SOURCE), + }, + ts: { perDraw: ["src/app.ts"] }, + }), + ), + "src/app.ts", + "contradiction", + ); + expect( + tsPathsOf({ + "xspec.config.ts": CONFIG_SOURCE, + "specs/a.mdx": WELL_FORMED, + "c0/U.ts": TS_SOURCE, + "src/rec.ts": stagedTs("T0-15 a rendered map's record", TS_SOURCE), + "notes.txt": "plain text" + LF, + "lib/m.mjs": TS_SOURCE, + "specs/code.md": TS_SOURCE, + }), + ).toEqual(["xspec.config.ts", "c0/U.ts", "lib/m.mjs"]); +}); diff --git a/test/self/staged-scale.ts b/test/self/staged-scale.ts new file mode 100644 index 00000000..7f438694 --- /dev/null +++ b/test/self/staged-scale.ts @@ -0,0 +1,178 @@ +// The suite's staged input maxima, derived from what the suite stages +// (TEST-SPEC 17 S-2 and S-8; §0 H-11: every input the suite stages, +// deterministic fixtures and generator draws (16) alike): the deepest +// section tower P-8's giant-nesting draws stage, the largest document any +// generator draw stages, the largest document any deterministic fixture +// stages (T1.3-7's chained-id tower), and the larger of those two — the +// largest document the suite stages. S-2 stages these maxima through the +// workspace builder and reads them back byte-complete (the input side — a +// truncating writer or recursion-limited serializer cannot stage shallower +// or smaller inputs than declared); S-8 sizes every decoder, walk, and +// capture limit against the answers a conforming product may emit over them +// (the answer side). One derivation, so neither gate can drift from what +// the suite actually stages, and a grown bound — a generator's, or T1.3-7's +// DEPTH_FLOOR — moves both. + +import { Buffer } from "node:buffer"; +import { DEPTH_FLOOR, depthTower } from "../suite/registry/section-1.3.js"; +import { + FUZZ_BASE_FILES, + MAX_MUTATIONS_PER_TRIAL, + NESTING_DEPTHS, + sectionTowerSource, + TERMINATOR_SEQUENCES, +} from "../suite/registry/section-16-p8.js"; + +// Nesting. P-8's giant-nesting draws stage balanced towers `<S id="g">` × +// depth (NESTING_DEPTHS; the deepest 4096, the floor 2048 that T1.3-7 +// anchors). A trial applies up to MAX_MUTATIONS_PER_TRIAL mutations to the +// same file, and a shuffle mutation relocates one contiguous byte range, so +// tower + tower + shuffle can drop the second tower into the first's +// innermost level: the deepest section chain any P-8/P-11 draw can stage is +// 2 × 4096 = 8192 (a third tower would need a fourth mutation). Every `view` +// and `ids --tree` answer over such an input nests one node per level. +/** P-8's test-strength floor on staged nesting (TEST-SPEC §16 P-8). */ +export const GIANT_NESTING_FLOOR = 2048; +/** The deepest tower any nesting draw stages. */ +export const DEEPEST_STAGED_TOWER = Math.max(...NESTING_DEPTHS); + +// Document size — generator draws. The largest file any draw stages is the +// largest fuzz base file with every mutation of the budget appending the +// deepest balanced tower. The competing growth is a terminator rewrite +// (every LF → the fattest sequence of TERMINATOR_SEQUENCES, U+2028 at three +// bytes): `towers` towers plus `rewrites` rewrites grow each line feed to at +// most 2^(rewrites − 1) × 3 bytes (LFLF doublings, then the fattest +// sequence), and every such mix is computed below — the all-towers mix +// wins. Splices (≤ 8 bytes), garbage (≤ 64), BOMs (≤ 3), terminator runs +// (≤ 64 × 3), and the refined classes' fixed spellings (fragments, brace +// content, ESM-block forms: ≤ 128 bytes per draw, a seeded anchor +// included) are smaller than any tower; truncate and shuffle never grow a +// file. P-2/P-3 documents (≤ 3 files × ≤ 6 sections of single-line +// constructs) and P-4/P-9's (≤ 3 sections per file, prose runs ≤ 8 +// characters) are far smaller. Deterministic fixtures are sized separately +// below — T1.3-7's document is ~21× this generator maximum. +/** + * The deepest tower the suite stages, byte for byte — what a nesting draw + * over an `.mdx` file appends (`mutateNesting`, section-16-p8.ts). + */ +export const TOWER_SOURCE = sectionTowerSource(DEEPEST_STAGED_TOWER, true); +export const TOWER_BYTES = Buffer.byteLength(TOWER_SOURCE, "utf8"); +export const FATTEST_TERMINATOR = Math.max( + ...TERMINATOR_SEQUENCES.map(([, sequence]) => sequence.length), +); + +export function countLineFeeds(text: string): number { + let count = 0; + for ( + let index = text.indexOf("\n"); + index >= 0; + index = text.indexOf("\n", index + 1) + ) { + count += 1; + } + return count; +} + +/** Every tower/rewrite mix of the mutation budget over one base file. */ +export function stagedSizeCandidates(base: string): number[] { + const bytes = Buffer.byteLength(base, "utf8"); + const feeds = countLineFeeds(base); + const towerFeeds = countLineFeeds(TOWER_SOURCE); + const candidates: number[] = []; + for (let towers = 0; towers <= MAX_MUTATIONS_PER_TRIAL; towers += 1) { + const rewrites = MAX_MUTATIONS_PER_TRIAL - towers; + const bytesPerFeed = + rewrites === 0 ? 1 : 2 ** (rewrites - 1) * FATTEST_TERMINATOR; + candidates.push( + bytes + + towers * TOWER_BYTES + + (feeds + towers * towerFeeds) * (bytesPerFeed - 1), + ); + } + return candidates; +} + +/** The largest fuzz base file (path, text); a tie resolves to the first. */ +export const LARGEST_BASE_FILE: readonly [string, string] = + FUZZ_BASE_FILES.reduce((largest, candidate) => + Buffer.byteLength(candidate[1], "utf8") > + Buffer.byteLength(largest[1], "utf8") + ? candidate + : largest, + ); +export const LARGEST_BASE_BYTES = Buffer.byteLength( + LARGEST_BASE_FILE[1], + "utf8", +); +/** The largest document any generator draw stages, in bytes. */ +export const LARGEST_GENERATED_INPUT_BYTES = Math.max( + ...FUZZ_BASE_FILES.flatMap(([, text]) => stagedSizeCandidates(text)), +); + +/** + * The largest document any generator draw stages, byte for byte: the + * largest base file with the whole mutation budget spent on appended + * deepest towers — exactly what MAX_MUTATIONS_PER_TRIAL nesting draws over + * it (depth DEEPEST_STAGED_TOWER, balanced, appending rather than + * replacing) produce, the tower being the section tower because that base + * is an `.mdx` file (asserted by S-8's derivation test). Built by + * concatenation, never by recursion, and sized at + * LARGEST_GENERATED_INPUT_BYTES. + */ +export function largestGeneratedDocument(): Uint8Array { + const parts = [Buffer.from(LARGEST_BASE_FILE[1], "utf8")]; + const tower = Buffer.from(TOWER_SOURCE, "utf8"); + for (let index = 0; index < MAX_MUTATIONS_PER_TRIAL; index += 1) { + parts.push(tower); + } + return Buffer.concat(parts); +} + +// Document size — deterministic fixtures. T1.3-7 (section-1.3.ts) stages +// P-8's giant-nesting floor deterministically: one valid `specs/A.mdx` +// nesting sections DEPTH_FLOOR deep with chained ids (`a`, `a.b`, `a.b.c`, +// …). Because every id spells its whole ancestor chain, the file is +// quadratic in the depth — per level k an opener `<S id="` (7 bytes) plus a +// (2k − 1)-byte id plus `">\n` (3 bytes), then the content line `deep.\n` +// (6 bytes), then `</S>\n` × D (5 bytes each): 9·D + D·(D + 1) + 6 + 5·D +// bytes, 4,225,030 at D = 2048, pure ASCII (bytes = characters). No other +// deterministic fixture's size grows with a bound of that order: the +// registry's only other large repeat counts are T4.1's 745-code-point +// truncation probes (section-4.1-4.2.ts), a few KiB each. +export { DEPTH_FLOOR, depthTower }; +export type { DepthTower } from "../suite/registry/section-1.3.js"; +/** The largest document any deterministic fixture stages, in bytes. */ +export const LARGEST_DETERMINISTIC_INPUT_BYTES = Buffer.byteLength( + depthTower(DEPTH_FLOOR).source, + "utf8", +); + +/** + * The largest document any deterministic fixture stages, byte for byte — + * exactly the `specs/A.mdx` T1.3-7 declares through the workspace builder, + * built by depthTower's loop (never by recursion) and sized at + * LARGEST_DETERMINISTIC_INPUT_BYTES. + */ +export function largestDeterministicDocument(): Uint8Array { + return Buffer.from(depthTower(DEPTH_FLOOR).source, "utf8"); +} + +// The suite's staged maximum: the larger kind's — today the deterministic +// one — so the name S-2's requirement uses ("the largest document size the +// suite stages") means exactly that. +/** The largest document the suite stages, in bytes. */ +export const LARGEST_STAGED_INPUT_BYTES = Math.max( + LARGEST_GENERATED_INPUT_BYTES, + LARGEST_DETERMINISTIC_INPUT_BYTES, +); + +/** + * The largest document the suite stages, byte for byte — the larger kind's + * document (a tie resolves to the deterministic one), sized at + * LARGEST_STAGED_INPUT_BYTES. + */ +export function largestStagedDocument(): Uint8Array { + return LARGEST_DETERMINISTIC_INPUT_BYTES >= LARGEST_GENERATED_INPUT_BYTES + ? largestDeterministicDocument() + : largestGeneratedDocument(); +} diff --git a/test/suite/declare.ts b/test/suite/declare.ts index 5c7ff719..aaf7cbee 100644 --- a/test/suite/declare.ts +++ b/test/suite/declare.ts @@ -11,6 +11,7 @@ // outright. import { test } from "vitest"; +import { runProductTestBody } from "../helpers/product-invocations.js"; import type { ProductTestEntry } from "../helpers/registry.js"; import { builtProductBinding } from "../helpers/subprocess.js"; import { productTestSuite } from "./registry/index.js"; @@ -18,7 +19,10 @@ import { productTestSuite } from "./registry/index.js"; /** * Declare registered product-facing tests as Vitest tests against the built * product. The entry's own budget is the Vitest timeout — the same budget the - * certification runner uses as its hang watchdog. + * certification runner uses as its hang watchdog. Each body runs inside its + * own invocation context (helpers/product-invocations.ts), as it does under + * the certification runner, so the workspace builder's undeclared-staging + * guard sees the body's first product invocation wherever it happens. */ export function declareProductTests( entries: readonly ProductTestEntry[], @@ -38,7 +42,9 @@ export function declareProductTests( `${entry.id} ${entry.title}`, { timeout: entry.timeoutMs }, async () => { - await entry.run(builtProductBinding()); + await runProductTestBody(entry.id, () => + entry.run(builtProductBinding()), + ); }, ); } diff --git a/test/suite/e6-exchange-writer.test.ts b/test/suite/e6-exchange-writer.test.ts index f5b5ac2a..72fa5902 100644 --- a/test/suite/e6-exchange-writer.test.ts +++ b/test/suite/e6-exchange-writer.test.ts @@ -1,9 +1,13 @@ // 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 -// (helpers/e6.ts), asserting every step's exact exit code, and writes the -// captured outputs (transcript + final workspace tree) into +// (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`, a journaled section-form `move` (the inserted-terminator +// probe: its moved text lands before a target parent's closing tag in an +// existing target file that gains an added import), 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 // variable and uploads the directory as the `e6-linux-outputs` artifact; the // Windows leg (test/windows/e6-byte-identity.test.ts) reruns the identical @@ -29,10 +33,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 25-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 cab40718..9c7447a9 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -13,6 +13,8 @@ // Duplicate IDs across modules fail here at import time. import { ProductTestSuite } from "../../helpers/registry.js"; +import { sealStagedMdxLedger } from "../../helpers/staged-mdx.js"; +import { sealStagedTsLedger } from "../../helpers/staged-ts.js"; import { section11to12Tests } from "./section-1.1-1.2.js"; import { section13Tests } from "./section-1.3.js"; import { section14Tests } from "./section-1.4.js"; @@ -33,12 +35,18 @@ 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 { section65iiTests } from "./section-6.5-ii.js"; +import { section65iiiTests } from "./section-6.5-iii.js"; +import { section65ivTests } from "./section-6.5-iv.js"; +import { section65vTests } from "./section-6.5-v.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,15 +62,25 @@ 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 { section120iiiTests } from "./section-12.0-iii.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"; import { section135Tests } from "./section-13.5.js"; import { section14ValidationTests } from "./section-14.js"; +import { section14iiTests } from "./section-14-ii.js"; +import { section14iiiTests } from "./section-14-iii.js"; import { section15ExampleTests } from "./section-15.js"; import { section16P1Tests } from "./section-16-p1.js"; import { section16P2P3Tests } from "./section-16-p2-p3.js"; @@ -72,6 +90,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 +116,18 @@ export const productTestSuite = new ProductTestSuite([ ...section54Tests, ...section55Tests, ...section56Tests, + ...section57Tests, ...section61Tests, ...section62Tests, ...section63Tests, ...section64Tests, ...section65Tests, + ...section65iiTests, + ...section65iiiTests, + ...section65ivTests, + ...section65vTests, ...section66Tests, + ...section67Tests, ...section7BasicsTests, ...section7DiscoveryTests, ...section71to73Tests, @@ -116,15 +143,25 @@ export const productTestSuite = new ProductTestSuite([ ...section107iTests, ...section107iiTests, ...section11Tests, + ...section112Tests, + ...section113Tests, + ...section114Tests, + ...section115Tests, + ...section116Tests, ...section120iTests, ...section120iiTests, + ...section120iiiTests, ...section121to122Tests, ...section123to125Tests, + ...section126Tests, + ...section127Tests, ...section131to132Tests, ...section133Tests, ...section134Tests, ...section135Tests, ...section14ValidationTests, + ...section14iiTests, + ...section14iiiTests, ...section15ExampleTests, ...section16P1Tests, ...section16P2P3Tests, @@ -134,4 +171,16 @@ export const productTestSuite = new ProductTestSuite([ ...section16P8Tests, ...section16P9Tests, ...section16P10Tests, + ...section16P11Tests, + ...section16P12Tests, + ...section16P13Tests, ]); + +// The staged-source ledger (helpers/staged-mdx.ts, and its TypeScript +// records, helpers/staged-ts.ts) is complete once every registration module +// above has loaded: seal both, so that a record created later — at run time, +// from a test body — throws instead of escaping the S-9 self-test +// (test/self/s9-staged-sources.test.ts judges every record before any +// product exists, H-8). +sealStagedMdxLedger(); +sealStagedTsLedger(); diff --git a/test/suite/registry/section-1.1-1.2.ts b/test/suite/registry/section-1.1-1.2.ts index 116b8204..2d0bc288 100644 --- a/test/suite/registry/section-1.1-1.2.ts +++ b/test/suite/registry/section-1.1-1.2.ts @@ -15,6 +15,7 @@ // specifier `./NAME.xspec` (SPEC 4) resolves by Node's extension lookup. import { + classifyIgnoredReasons, decodeCoverageReport, decodeNodeReport, decodeNodeRowsReport, @@ -27,6 +28,7 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import { assertCompileErrorAt, assertNoCompileErrors, @@ -163,14 +165,17 @@ const MIXED_TAGS_SOURCE = [ // The identical consumer compiles against both workspaces' generated modules: // bare references are dependency markers (SPEC 4.5), so a clean compile of // the full chain set demonstrates both modules expose the same skeleton. -const SKELETON_CONSUMER = [ - 'import SPEC from "./specs/A.xspec";', - "", - "SPEC.login;", - "SPEC.login.validCredentials;", - "SPEC.meta;", - "", -].join("\n"); +const SKELETON_CONSUMER = stagedTs( + "T1.1-2 consumer.ts — the skeleton consumer both tag forms compile against, staged after `build`", + [ + 'import SPEC from "./specs/A.xspec";', + "", + "SPEC.login;", + "SPEC.login.validCredentials;", + "SPEC.meta;", + "", + ].join("\n"), +); const T1_1_2 = defineProductTest({ id: "T1.1-2", @@ -339,21 +344,24 @@ export default defineConfig({ }) `; -const VALID_LEAF_CONSUMER = [ - 'import SPEC from "./specs/A.xspec";', - "", - "SPEC.main;", - "SPEC.todo;", - "SPEC.empty;", - "", -].join("\n"); +const VALID_LEAF_CONSUMER = stagedTs( + "T1.1-3 consumer.ts — the leaf-chain consumer, staged after `build`", + [ + 'import SPEC from "./specs/A.xspec";', + "", + "SPEC.main;", + "SPEC.todo;", + "SPEC.empty;", + "", + ].join("\n"), +); -const CHILD_CHAIN_CONSUMER = [ - 'import SPEC from "./specs/A.xspec";', - "", - "SPEC.todo.child;", - "", -].join("\n"); +const CHILD_CHAIN_CONSUMER = stagedTs( + "T1.1-3 bad-consumer.ts — the child-of-a-leaf chain, staged after `build`", + ['import SPEC from "./specs/A.xspec";', "", "SPEC.todo.child;", ""].join( + "\n", + ), +); const T1_1_3 = defineProductTest({ id: "T1.1-3", @@ -576,12 +584,15 @@ const ROOT_TEXT_SOURCE = [ const ROOT_TEXT_COMPILED = "# Title\n\nIntro prose.\n\nAlpha requirement.\n\nAlpha one.\n\n"; -const ROOT_TEXT_CONSUMER = [ - 'import SPEC, { text } from "./specs/A.xspec";', - "", - "process.stdout.write(text(SPEC));", - "", -].join("\n"); +const ROOT_TEXT_CONSUMER = stagedTs( + "T1.2-2 main.ts — `text` of the root node, staged after `build`", + [ + 'import SPEC, { text } from "./specs/A.xspec";', + "", + "process.stdout.write(text(SPEC));", + "", + ].join("\n"), +); const T1_2_2 = defineProductTest({ id: "T1.2-2", @@ -672,7 +683,7 @@ export default defineConfig({ const T1_2_3 = defineProductTest({ id: "T1.2-3", title: - "roots are never coverage targets: ignored with reason `root node`, absent from required/covered/uncovered, unmatched by `--coverage`, coverage attribute absent (SPEC 1.2, 8, 11)", + "roots are never coverage targets: ignored with the root-node reason (adapter-located, no wording pinned), absent from required/covered/uncovered, unmatched by `--coverage`, coverage attribute absent (SPEC 1.2, 8, 11)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -739,10 +750,18 @@ const T1_2_3 = defineProductTest({ JSON.stringify(profile.ignored.map((entry) => entry.identity)), ); } + // The reason's spelling is output shape (SPEC 8.2 names it in prose and + // pins no wording): classify each reported reason string onto its SPEC + // 8.2 identity through the coverage adapter — the discipline T8.2-1 + // applies — and assert the identities: the root-node reason, alone. assertSameJson( - rootEntry.reasons, - ["root node"], - `${coverageLabel}: the root's exclusion reasons (SPEC 8.2 — only the root-node reason applies here)`, + classifyIgnoredReasons( + rootEntry.reasons, + `${coverageLabel} ignored ${rootEntry.identity}`, + ), + ["root"], + `${coverageLabel}: the root's exclusion reasons — the root-node reason and nothing else ` + + `(SPEC 8.2; adapter-located, H-3)`, ); // `query nodes --coverage …` matches no root (SPEC 11). diff --git a/test/suite/registry/section-1.3.ts b/test/suite/registry/section-1.3.ts index 83b8717f..23336a73 100644 --- a/test/suite/registry/section-1.3.ts +++ b/test/suite/registry/section-1.3.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §1.3 (requirement IDs) — SUITE-02: T1.3-1 … T1.3-6. +// TEST-SPEC §1.3 (requirement IDs) — SUITE-02: T1.3-1 … T1.3-7. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -12,8 +12,12 @@ // 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. T1.3-7 stands outside that +// scope — its command surface is `query subtree` and `view`, and +// CERTIFICATIONS.md places the scale-capacity class outside certification +// by construction. // // Location assertions: fixtures are staged as prefix + offending construct + // suffix, all pure ASCII (string indices are byte offsets), and each negative @@ -23,14 +27,18 @@ // terminator) still passes; every other staged construct lies outside the // widened window, so a finding attributed to the wrong construct fails. -import type { Finding } from "../../helpers/adapters/index.js"; +import type { Finding, ViewNode } from "../../helpers/adapters/index.js"; import { assertReportMentions, decodeNodeRowsReport, + decodeViewReport, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { @@ -44,20 +52,30 @@ import { } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group, nothing -// else — the CONF-VALID scope. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// else — the CONF-VALID scope. A staged-source record (S-9's timing clause): +// the later arms' workspaces stage it after their body's first invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T1.3-2/T1.3-4/T1.3-5/T1.3-6 xspec.config.ts — the specs-only configuration of every arm workspace", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); -/** Stage one single-file workspace and collect its `build --json` findings. */ +/** + * Stage one single-file workspace and collect its `build --json` findings. + * `source` is a staged-source record wherever the calling body has already + * invoked the product (S-9's timing clause: a later workspace's initial file + * is judged by the S-9 self-test before any product exists); a body's first + * workspace may stage plain contents. + */ async function findingsOf( product: ProductBinding, - source: string, + source: string | StagedMdx, context: string, ): Promise<readonly Finding[]> { const workspace = await TestWorkspace.create({ @@ -108,7 +126,8 @@ const VALID_NESTING_SOURCE = [ "", ].join("\n"); -interface StructuralArm { +/** The parts a structural arm's `specs/A.mdx` is composed from. */ +interface StructuralArmParts { /** Which SPEC 1.3 invalid case this is (failure diagnostics). */ readonly name: string; readonly prefix: string; @@ -126,30 +145,54 @@ interface StructuralArm { readonly expectedFormMention?: string; } +interface StructuralArm extends StructuralArmParts { + /** + * The composed source, `prefix + construct + suffix`, as a staged-source + * record: every arm but a body's first runs after that body's first + * product invocation, so the S-9 self-test judges each before any product + * exists (the parts stay for `byteWindow`). + */ + readonly source: StagedMdx; +} + +/** Compose an arm's source from its parts, registering it under `recordName`. */ +function structuralArm( + recordName: string, + parts: StructuralArmParts, +): StructuralArm { + return { + ...parts, + source: stagedMdx( + recordName, + parts.prefix + parts.construct + parts.suffix, + ), + }; +} + const STRUCTURAL_ARMS: readonly StructuralArm[] = [ - { + structuralArm("T1.3-2 arm validCredentials-in-login specs/A.mdx", { name: '`<S id="validCredentials">` nested inside `login`', prefix: '<S id="login">\nLogin behavior.\n\n', construct: '<S id="validCredentials">\nDoes not equal the parent id plus one segment.\n</S>', suffix: "\n</S>\n", expectedFormMention: "login.", - }, - { + }), + structuralArm("T1.3-2 arm login.validCredentials-in-account specs/A.mdx", { name: '`<S id="login.validCredentials">` nested inside `account`', prefix: '<S id="account">\nAccount behavior.\n\n', construct: '<S id="login.validCredentials">\nExtends a different parent id.\n</S>', suffix: "\n</S>\n", expectedFormMention: "account.", - }, - { + }), + structuralArm("T1.3-2 arm top-level-auth.login specs/A.mdx", { name: 'top-level `<S id="auth.login">` with no enclosing `auth`', prefix: "", construct: '<S id="auth.login">\nTop-level, yet the id has two segments.\n</S>', suffix: "\n", - }, + }), ]; /** @@ -162,11 +205,7 @@ async function runStructuralArm( testId: string, ): Promise<void> { const context = `${testId} \`build --json\` over ${arm.name}`; - const findings = await findingsOf( - product, - arm.prefix + arm.construct + arm.suffix, - context, - ); + const findings = await findingsOf(product, arm.source, context); assertConditionCounts(findings, { "14.2": 1 }, context); const finding = findings[0]!; assertFindingLocated( @@ -211,38 +250,44 @@ const T1_3_2 = defineProductTest({ }, }); +// T1.3-3's one arm — its body's first workspace, a record all the same (the +// structural arms convert uniformly). +const SKIPPED_LEVEL_ARM = structuralArm("T1.3-3 arm a.b.c-in-a specs/A.mdx", { + name: "`a` containing `a.b.c` with no `a.b` section", + prefix: '<S id="a">\nAlpha.\n\n', + construct: '<S id="a.b.c">\nSkips the level a.b.\n</S>', + suffix: "\n</S>\n", + // The offending id `a.b.c` itself contains every prefix-shaped substring + // of the expected form (`a.`), so no message content is + // implementation-independently assertable here. +}); + const T1_3_3 = defineProductTest({ id: "T1.3-3", title: "an ID that skips a level (`a` containing `a.b.c` with no `a.b`) fails with 14.2 (SPEC 1.3, 14.2)", run: async (product) => { - await runStructuralArm( - product, - { - name: "`a` containing `a.b.c` with no `a.b` section", - prefix: '<S id="a">\nAlpha.\n\n', - construct: '<S id="a.b.c">\nSkips the level a.b.\n</S>', - suffix: "\n</S>\n", - // The offending id `a.b.c` itself contains every prefix-shaped - // substring of the expected form (`a.`), so no message content is - // implementation-independently assertable here. - }, - "T1.3-3", - ); + await runStructuralArm(product, SKIPPED_LEVEL_ARM, "T1.3-3"); }, }); -const TOP_LEVEL_MULTI_SEGMENT: StructuralArm = { - name: "a top-level section with a multi-segment ID", - prefix: "", - construct: '<S id="alpha.beta">\nTwo segments at top level.\n</S>', - suffix: "\n", - // Checked against the empty prefix (14.2): the expected form — exactly one - // segment — has no implementation-independent substring to require. -}; +const TOP_LEVEL_MULTI_SEGMENT = structuralArm( + "T1.3-4 arm alpha.beta specs/A.mdx", + { + name: "a top-level section with a multi-segment ID", + prefix: "", + construct: '<S id="alpha.beta">\nTwo segments at top level.\n</S>', + suffix: "\n", + // Checked against the empty prefix (14.2): the expected form — exactly + // one segment — has no implementation-independent substring to require. + }, +); -const TOP_LEVEL_ONE_SEGMENT_SOURCE = - '<S id="alpha">\nOne segment at top level.\n</S>\n'; +// Staged after the multi-segment arm's invocation: a record (S-9). +const TOP_LEVEL_ONE_SEGMENT_SOURCE = stagedMdx( + "T1.3-4 one-segment specs/A.mdx", + '<S id="alpha">\nOne segment at top level.\n</S>\n', +); const T1_3_4 = defineProductTest({ id: "T1.3-4", @@ -274,6 +319,17 @@ const DUP_GAP = "\n\n"; const DUP_SECOND = '<S id="dup">\nSecond occurrence.\n</S>'; const DUP_SOURCE = `${DUP_FIRST}${DUP_GAP}${DUP_SECOND}\n`; +// T1.3-5, cross-file arm: staged after the same-file arm's invocation, so +// both sources are records (S-9). +const CROSS_FILE_A = stagedMdx( + "T1.3-5 cross-file specs/A.mdx", + '<S id="dup">\nIn file A.\n</S>\n', +); +const CROSS_FILE_B = stagedMdx( + "T1.3-5 cross-file specs/B.mdx", + '<S id="dup">\nIn file B.\n</S>\n', +); + const T1_3_5 = defineProductTest({ id: "T1.3-5", title: @@ -303,18 +359,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)})`, + ); + } } } @@ -323,8 +381,8 @@ const T1_3_5 = defineProductTest({ const crossFile = await TestWorkspace.create({ files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": '<S id="dup">\nIn file A.\n</S>\n', - "specs/B.mdx": '<S id="dup">\nIn file B.\n</S>\n', + "specs/A.mdx": CROSS_FILE_A, + "specs/B.mdx": CROSS_FILE_B, }, }); try { @@ -375,10 +433,123 @@ 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`; the valueless bearer spells none) and is +// multi-segment against the empty prefix, so a product that fails to mask, +// or silently adopts one of the spelled values as the identity, reports an +// extra 14.2 — and a grandchild whose structural check runs normally +// against its parent's spelled id `a.b`. The valueless arm (`<S id>`, the +// bare name — T2.7-3's form) masks the same children whether a product +// reads it as condition 17 or as an absent `id` (condition 1), so its +// discriminating assertion is the bearer's own code: exactly one 14.17 and +// no 14.1. A valid sibling precedes the bearer so the bearer's construct is +// a proper sub-range of the file and its location assertion has teeth. +interface InvalidIdFormArmParts { + /** 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; +} + +interface InvalidIdFormArm extends InvalidIdFormArmParts { + /** + * The composed file — the sibling, then the bearer construct — as a + * staged-source record: the arms run after T1.3-6's masking invocation, + * so the S-9 self-test judges each before any product exists. + */ + readonly source: StagedMdx; +} + +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>"; + +/** Compose an arm's file from its bearer, registering it under `recordName`. */ +function invalidIdFormArm( + recordName: string, + parts: InvalidIdFormArmParts, +): InvalidIdFormArm { + const bearerConstruct = + parts.bearerOpen + FORM_CHILD_OPEN + FORM_GRANDCHILD + FORM_TAIL; + return { + ...parts, + source: stagedMdx(recordName, `${FORM_SIBLING}${bearerConstruct}\n`), + }; +} + +const INVALID_ID_FORM_ARMS: readonly InvalidIdFormArm[] = [ + invalidIdFormArm("T1.3-6 arm repeated-id specs/A.mdx", { + name: 'a repeated-`id` section (`<S id="one" id="two">`)', + bearerOpen: + '<S id="one" id="two">\nBearer: the id attribute is repeated.\n\n', + }), + invalidIdFormArm("T1.3-6 arm braced-id specs/A.mdx", { + 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', + }), + invalidIdFormArm("T1.3-6 arm valueless-id specs/A.mdx", { + name: "a valueless-`id` section (`<S id>`)", + bearerOpen: + "<S id>\nBearer: the id prop is the bare name, spelling no value at all.\n\n", + }), +]; + +/** + * 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, arm.source, 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`, braced-`id`, or valueless-`id` (`<S id>`) bearer reports 14.17 — never 14.1 — masking the same way (SPEC 1.3, 2.7, 14.1, 14.2, 14.17)", run: async (product) => { const context = "T1.3-6 `build --json` over an id-less section with children"; @@ -422,6 +593,214 @@ 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`, a braced `id={"x"}`, and a + // valueless `<S id>` each report condition 17 — the bare name is never + // `missing-id` — and mask 14.2 for the immediate children the same way. + for (const arm of INVALID_ID_FORM_ARMS) { + await runInvalidIdFormArm(product, arm); + } + }, +}); + +// --------------------------------------------------------------------------- +// T1.3-7 Depth — the deterministic anchor of P-8's giant-nesting floor. +// +// SPEC 1.3 bounds no nesting depth: its structural rule — a child's id is its +// parent's id plus "." plus exactly one segment — holds at every level. One +// valid file nests sections DEPTH_FLOOR levels deep. Because every id spells +// its whole ancestor chain, the file is quadratic in the depth (~4.2 MB at +// 2048 with one-letter segments) and both it and the expected identities are +// built iteratively; the answers (~4.5 MB of `query subtree` rows, ~13 MB of +// `view`) are walked iteratively too — H-11, S-8: never one frame per level. +// P-8's own tower repeats `id="g"` at every level, which 1.3 rejects (14.2) +// from the second level on — right for a robustness draw, wrong for the valid +// workspace T1.3-7 stages, so this fixture chains its ids instead. + +/** + * P-8's giant-nesting floor (TEST-SPEC P-8, 16), staged here deterministically. + * Exported, with `depthTower`, for `test/self/staged-scale.ts`, which derives + * the suite's deterministic staged maximum from `depthTower(DEPTH_FLOOR)` + * (S-2, S-8): a change here moves their exact-size pins — deliberately. + */ +export const DEPTH_FLOOR = 2048; + +/** + * One-letter segments cycling through the alphabet: a level's identity is a + * function of its position, so a level the product drops, duplicates, or + * reorders shifts every deeper identity and the sequence comparison names + * the first shifted position. + */ +const DEPTH_SEGMENTS = "abcdefghijklmnopqrstuvwxyz"; + +export interface DepthTower { + /** The file's bytes. */ + readonly source: string; + /** Each level's `id` value, outermost first. */ + readonly ids: readonly string[]; +} + +/** Build the chain iteratively: level k's id is level k−1's id plus "." plus its own segment. */ +export function depthTower(depth: number): DepthTower { + const ids: string[] = []; + const openers: string[] = []; + let id = ""; + for (let level = 1; level <= depth; level += 1) { + const segment = DEPTH_SEGMENTS[(level - 1) % DEPTH_SEGMENTS.length]!; + id = level === 1 ? segment : `${id}.${segment}`; + ids.push(id); + openers.push(`<S id="${id}">\n`); + } + return { + source: `${openers.join("")}deep.\n${"</S>\n".repeat(depth)}`, + ids, + }; +} + +/** A long identity rendered within bounds for a diagnosis. */ +function abbreviateIdentity(identity: string): string { + const limit = 48; + return identity.length <= limit + ? JSON.stringify(identity) + : `${JSON.stringify(identity.slice(0, limit))}… (${identity.length} characters)`; +} + +/** + * Diagnosed, position-by-position comparison of a reported identity sequence + * against the expected one — the count and every position, so first, last, + * and every sampled identity are covered — without rendering either + * multi-megabyte sequence whole (`assertSameJson` would). + */ +function assertIdentitySequence( + actual: readonly string[], + expected: readonly string[], + context: string, +): void { + const shared = Math.min(actual.length, expected.length); + for (let index = 0; index < shared; index += 1) { + if (actual[index] !== expected[index]) { + fail( + `${context}: the identity at position ${index} differs\n` + + ` actual: ${abbreviateIdentity(actual[index]!)}\n` + + ` expected: ${abbreviateIdentity(expected[index]!)}`, + ); + } + } + if (actual.length !== expected.length) { + const detail = + actual.length > expected.length + ? `the first surplus identity is ${abbreviateIdentity(actual[expected.length]!)}` + : `the first missing identity is ${abbreviateIdentity(expected[actual.length]!)}`; + fail( + `${context}: ${expected.length} identities expected (the root plus ` + + `${expected.length - 1} sections), got ${actual.length}; ${detail}`, + ); + } +} + +/** + * Walk the positional tree iteratively (H-11): the staged file nests exactly + * one section per level, so the tree must be one chain — every node has one + * child until the deepest, which has none — and its preorder identities are + * returned for the sequence comparison. + */ +function chainIdentities(root: ViewNode, context: string): string[] { + const identities: string[] = []; + let node = root; + for (let level = 0; ; level += 1) { + if (typeof node.identity !== "string") { + fail( + `${context}: the node at nesting level ${level} reports its identity as ` + + "unavailable, but the file carries no finding — every identity of a " + + "valid file is defined (SPEC 11.2, 1.5)", + ); + } + identities.push(node.identity); + if (node.children.length === 0) return identities; + if (node.children.length !== 1) { + fail( + `${context}: the node at nesting level ${level} ` + + `(${abbreviateIdentity(node.identity)}) reports ${node.children.length} ` + + "children, but the staged file nests exactly one section per level " + + "(SPEC 11.4: the positional tree is defined by construct nesting alone)", + ); + } + node = node.children[0]!; + } +} + +const T1_3_7 = defineProductTest({ + id: "T1.3-7", + title: + "Depth: a valid 2048-deep section chain builds, and `query subtree` and `view` serve every level", + async run(product) { + const tower = depthTower(DEPTH_FLOOR); + const expectedIdentities = [ + "specs/A.mdx", + ...tower.ids.map((id) => `specs/A.mdx#${id}`), + ]; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": tower.source, + }, + }); + try { + await buildOk( + product, + workspace, + `T1.3-7 \`build\` over one file nesting sections ${DEPTH_FLOOR} levels deep — ` + + "a valid workspace, SPEC 1.3 bounding no depth", + ); + + // `query subtree` on the root: the root plus every section, in document + // order (SPEC 11.1) — the count and each identity by position. + const subtreeLabel = "T1.3-7 `query subtree specs/A.mdx` (the root)"; + const rows = decodeNodeRowsReport( + await runJson( + product, + workspace, + ["query", "subtree", "specs/A.mdx"], + subtreeLabel, + ), + subtreeLabel, + ); + assertIdentitySequence( + rows.map((row) => row.identity), + expectedIdentities, + `${subtreeLabel}: the root plus every section, in document order (SPEC 11.1)`, + ); + + // `view` on the file: the full positional tree — one chain, DEPTH_FLOOR + // levels deep, every identity defined (SPEC 11.4). + const viewLabel = "T1.3-7 `view specs/A.mdx`"; + const report = decodeViewReport( + await runJson(product, workspace, ["view", "specs/A.mdx"], viewLabel), + { text: false }, + viewLabel, + ); + if (report.findings.length !== 0) { + fail( + `${viewLabel}: ${report.findings.length} finding(s) accompany the answer, ` + + `but the workspace is valid — the ${DEPTH_FLOOR}-deep chain satisfies ` + + "1.3 at every level", + ); + } + if (report.views.length !== 1) { + fail( + `${viewLabel}: expected exactly one per-file view (the one requested ` + + `file), got ${report.views.length} (SPEC 11.4)`, + ); + } + assertIdentitySequence( + chainIdentities(report.views[0]!.root, viewLabel), + expectedIdentities, + `${viewLabel}: the full positional tree — the root and one section per ` + + `level, ${DEPTH_FLOOR} deep, in document order (SPEC 11.4)`, + ); + } finally { + await workspace.dispose(); + } }, }); @@ -433,4 +812,5 @@ export const section13Tests: readonly ProductTestEntry[] = [ T1_3_4, T1_3_5, T1_3_6, + T1_3_7, ]; diff --git a/test/suite/registry/section-1.4.ts b/test/suite/registry/section-1.4.ts index 7467925b..04f198b1 100644 --- a/test/suite/registry/section-1.4.ts +++ b/test/suite/registry/section-1.4.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §1.4 (ID segments and tags) — SUITE-03: T1.4-1 … T1.4-4. +// TEST-SPEC §1.4 (ID segments and tags) — SUITE-03: T1.4-1 … T1.4-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), @@ -22,23 +22,58 @@ // nothing beyond the entry's scoped query surface (identity, tags, // metadataHash) is demanded of the fixture product. Certification staging // constraints honored here: -// - T1.4-1 stages none of U+00A0/U+0085/U+2028 (§VIOL-VALID-WIDE expects +// - T1.4-1 stages neither U+00A0 nor U+0085 (§VIOL-VALID-WIDE expects // T1.4-1 to keep passing under that violator); +// - T1.4-2's valid boundaries are U+00A0 and U+0085 alone: it stages +// neither U+2028 nor U+2029, which 1.4's quote-and-escape bullet bars +// (§VIOL-VALID-SEP expects T1.4-2 to keep passing under that violator); // - non-whitespace control characters appear only in T1.4-1's control arms -// and T1.4-4's control tag arms (§VIOL-VALID-CTRL). +// and T1.4-4's control tag arms (§VIOL-VALID-CTRL); +// - U+2028 and U+2029 appear only in T1.4-1's and T1.4-4's one arm each +// (§VIOL-VALID-SEP: each certified test fails on those arms alone). // T1.4-3 is in no certification entry's scope: it exercises the generated -// module under standard TypeScript tooling (HARNESS-05, SPEC 13.1). +// module under standard TypeScript tooling (HARNESS-05, SPEC 13.1). Nor is +// T1.4-5 (CERTIFICATIONS.md's exclusions: its access arms ride the same +// tooling driver; its conversion arms are 6.4/6.5's rewrite byte contracts). // -// Location assertions follow the SUITE-02 discipline: fixtures are staged as -// prefix + offending construct + suffix with exactly known bytes, and each -// negative arm asserts the finding's location falls within the offending -// construct's end-widened byte window (support.ts `byteWindow`). +// Location assertions follow the SUITE-02 discipline, pinned exactly: each +// fixture is assembled from parts with exactly known bytes (`assemble`), and +// every negative arm asserts that its 14.4 finding locates exactly the +// offending attribute's own characters — the `id`/`tags` name through the +// closing quote, the attribute range of SPEC 11.4 that an attribute condition +// locates (SPEC 14; T14-11) — one finding per offending attribute, however +// many segments or tokens of its value violate 1.4. +// +// Verbatim reading (SPEC 2.4): a quoted attribute value is the characters +// between its delimiters exactly as spelled, so the six-character escape +// spelling of `.` (a backslash followed by `u002E`) and the character +// reference `.` are a segment containing `\` or `&` — condition 4, never +// the two-segment ID `a.b` — and the escape spelling of `y` (a backslash then +// `u0079`) a tag containing `\`, never the tag `xy`. Those spellings are built +// here from the backslash's code point, so no tool layer decodes them on the +// way into this file, and the arms staging them assert exactly one 14.4 +// finding: a product interpreting the escape or the reference reads a +// different ID or tag — the two-segment `a.b`, structurally invalid at the +// top level (SPEC 1.3), or the accepted tag `xy` — and fails the arm. -import type { Finding } from "../../helpers/adapters/index.js"; -import { decodeNodeSummary } from "../../helpers/adapters/index.js"; +import { Buffer } from "node:buffer"; +import type { + Finding, + GraphEdge, + SourceRange, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeNodeSummary, +} from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; +import { assertAddedImportInsertion } from "../../helpers/import-insertion.js"; +import { deriveMdx } from "../../helpers/mdx-derivability.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertCompileErrorAt, @@ -48,24 +83,30 @@ import { import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, - assertFindingLocated, + assertEdgeSetEqual, assertSameJson, buildFindings, buildOk, - byteWindow, + expectExit, + expectFindingFreeReport, runJson, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group — the -// CONF-VALID scope. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// CONF-VALID scope, and T1.4-5's conversion arms'. A staged-source record +// (S-9's timing clause): the later arms' workspaces stage it after their +// body's first invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T1.4-1/T1.4-4/T1.4-5 xspec.config.ts — the specs-only configuration of every arm workspace", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // --- character classes under test (SPEC 1.4, exact) ------------------------- @@ -102,26 +143,157 @@ const FORBIDDEN_NAMES: readonly string[] = [ ]; /** - * The boundary code points SPEC 1.4 excludes from both character classes: - * U+00A0 (no-break space), U+0085 (next line), U+2028 (line separator). + * The valid boundary code points (T1.4-2): U+00A0 (no-break space) and + * U+0085 (next line), which SPEC 1.4 excludes from both character classes + * and no rule of 1.4 bars. U+2028 and U+2029 belong to neither class either, + * yet 1.4's quote-and-escape bullet bars both from every segment and tag: + * they are T1.4-1's and T1.4-4's invalid arms, never valid ones here. */ const BOUNDARY_CODE_POINTS: readonly (readonly [number, string])[] = [ [0x00a0, "no-break space"], [0x0085, "next line"], +]; + +/** The backslash, built from its code point (see the module header). */ +const BACKSLASH = String.fromCodePoint(0x5c); + +/** An attribute value's delimiter — either quote kind (SPEC 2.7). */ +type QuoteKind = '"' | "'"; + +/** + * The quote, escape, and character-reference characters SPEC 1.4 forbids in + * segments and tags — `"` `'` `\` `&` — each staged in the quote kind that + * keeps the value spellable: a `"` inside a single-quoted value, the rest + * double-quoted. + */ +interface ForbiddenCharacterClass { + readonly name: string; + readonly character: string; + readonly quote: QuoteKind; +} + +const QUOTE_ESCAPE_REFERENCE_CHARACTERS: readonly ForbiddenCharacterClass[] = [ + { + name: 'the double quote `"` (single-quoted value)', + character: '"', + quote: "'", + }, + { name: "the single quote `'`", character: "'", quote: '"' }, + { + name: "the escape character (backslash)", + character: BACKSLASH, + quote: '"', + }, + { name: "the character-reference character `&`", character: "&", quote: '"' }, +]; + +/** + * U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR): in neither 1.4 + * class (T1.4-2), yet barred from every segment and tag by 1.4's + * quote-and-escape bullet — one invalid arm each in T1.4-1 and in T1.4-4, + * each staged as the literal, validly encoded character (never an escape + * spelling, which 2.4 reads verbatim as a value containing `\`). + */ +const LINE_SEPARATOR_CHARACTERS: readonly (readonly [number, string])[] = [ [0x2028, "line separator"], + [0x2029, "paragraph separator"], ]; +/** U+FFFD (REPLACEMENT CHARACTER), which no argument value carries (SPEC 1.4, 12.0). */ +const REPLACEMENT_CHARACTER = 0xfffd; + +/** + * Verbatim spellings (SPEC 2.4; module header): the six-character Unicode + * escape and the decimal character reference an interpreting reader would + * turn into `.`, and the escape it would turn into `y`. Read verbatim, each + * is a value containing `\` or `&` — condition 4. + */ +const ESCAPE_SPELLED_DOT = `${BACKSLASH}u002E`; +const REFERENCE_SPELLED_DOT = "."; +const ESCAPE_SPELLED_Y = `${BACKSLASH}u0079`; + // --- shared staging ---------------------------------------------------------- -// Shared fixture template: a valid sibling first, so each offending construct -// is a proper sub-range of the file and the location assertions have teeth. -// Arms differ from one another only in the one segment or tag under test. +// Shared fixture template: a valid sibling first, so each offending attribute +// is a proper sub-range of the file and the location assertions have teeth — +// the sibling's own `id` attribute is a different range, so a finding +// attributed to the wrong attribute fails. Arms differ from one another only +// in the one segment or tag under test. const SIBLING = '<S id="ok">\nA valid sibling section.\n</S>\n\n'; -/** Stage one single-file workspace and collect its `build --json` findings. */ +/** A fixture assembled from parts, with the pinned attributes' byte ranges. */ +interface Assembled { + readonly source: string; + /** The pinned attributes' ranges in source order (SPEC 1.7 byte offsets). */ + readonly pinned: readonly SourceRange[]; +} + +/** + * Assemble a fixture from string parts; a `{ pin }` part is an attribute + * whose own characters — name through closing quote — are pinned as the + * range a 14.4 finding on it must locate (SPEC 14, 11.4). Each offset is the + * UTF-8 byte length of the text before the pinned part (SPEC 1.7). + */ +function assemble( + parts: readonly (string | { readonly pin: string })[], +): Assembled { + let source = ""; + const pinned: SourceRange[] = []; + for (const part of parts) { + if (typeof part === "string") { + source += part; + continue; + } + const start = Buffer.byteLength(source, "utf8"); + pinned.push({ start, end: start + Buffer.byteLength(part.pin, "utf8") }); + source += part.pin; + } + return { source, pinned }; +} + +/** + * An assembled fixture registered as a staged-source record (S-9's timing + * clause): every fixture this module stages after a body's first product + * invocation — the matrix arms, the nested and lone empty segments — is + * staged from `record`, which the S-9 self-test judged before any product + * existed; `source` and `pinned` stay for the offset assertions. + */ +interface StagedAssembled extends Assembled { + readonly record: StagedMdx; +} + +/** Register an assembled fixture's source under `recordName`. */ +function staged(recordName: string, assembled: Assembled): StagedAssembled { + return { ...assembled, record: stagedMdx(recordName, assembled.source) }; +} + +/** One section after the sibling whose `id` attribute is under test. */ +function segmentStaging(segment: string, quote: QuoteKind = '"'): Assembled { + return assemble([ + `${SIBLING}<S `, + { pin: `id=${quote}${segment}${quote}` }, + ">\nSection with the segment under test.\n</S>\n", + ]); +} + +/** One section after the sibling whose `tags` attribute is under test. */ +function tagStaging(tags: string, quote: QuoteKind = '"'): Assembled { + return assemble([ + `${SIBLING}<S id="sec" `, + { pin: `tags=${quote}${tags}${quote}` }, + ">\nTagged section.\n</S>\n", + ]); +} + +/** + * Stage one single-file workspace and collect its `build --json` findings. + * `source` is a staged-source record wherever the calling body has already + * invoked the product (S-9's timing clause); a body's first workspace may + * stage plain contents. + */ async function findingsOf( product: ProductBinding, - source: string, + source: string | StagedMdx, context: string, ): Promise<readonly Finding[]> { const workspace = await TestWorkspace.create({ @@ -134,40 +306,80 @@ async function findingsOf( } } +/** + * Assert `build --json` reported exactly one 14.4 finding per pinned + * attribute and nothing else, each finding locating exactly one range — its + * attribute's own characters in `specs/A.mdx`, name through closing quote, + * the attribute range of SPEC 11.4 (SPEC 14: file, location, condition + * identity; T14-11). The ranges are compared as a multiset: which finding + * comes first is 12.7's ordering, not this test's concern. + */ +function assertOnly144AtAttributes( + findings: readonly Finding[], + attributes: readonly SourceRange[], + context: string, +): void { + assertConditionCounts(findings, { "14.4": attributes.length }, context); + const got = findings.map((finding) => { + if (finding.locations.length !== 1) { + fail( + `${context}: a 14.4 finding locates exactly one construct — the offending ` + + "attribute (SPEC 14: one finding per offending `id`/`tags` attribute); got " + + `${String(finding.locations.length)} locations (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + const location = finding.locations[0]!; + if (location.file !== "specs/A.mdx") { + fail( + `${context}: the 14.4 finding must locate in the workspace-relative source ` + + `file (SPEC 14, 1.5, 12.7); expected "specs/A.mdx", got ` + + `${JSON.stringify(location.file)} (message: ${JSON.stringify(finding.message)})`, + ); + } + return { start: location.range.start, end: location.range.end }; + }); + const byStart = (a: SourceRange, b: SourceRange): number => a.start - b.start; + assertSameJson( + [...got].sort(byStart), + [...attributes].sort(byStart), + `${context}: the 14.4 finding(s) locate exactly the offending attribute's own ` + + "characters — name through closing quote, the attribute range of SPEC 11.4 — " + + "as zero-based byte offsets, end-exclusive (SPEC 14, 1.7, 12.7)", + ); +} + /** * Run one negative arm over the shared template: `build --json` reports - * exactly one finding, condition 14.4, located within the offending - * construct's byte window (SPEC 14: file, location, condition identity). + * exactly one finding, condition 14.4, located exactly at the one pinned + * attribute. */ async function expectSingle144( product: ProductBinding, - construct: string, + fixture: StagedAssembled, context: string, ): Promise<void> { - const findings = await findingsOf( - product, - `${SIBLING}${construct}\n`, - context, - ); - assertConditionCounts(findings, { "14.4": 1 }, context); - assertFindingLocated( - findings[0]!, - { file: "specs/A.mdx", window: byteWindow(SIBLING, construct) }, - `${context}: the 14.4 finding`, - ); + const findings = await findingsOf(product, fixture.record, context); + assertOnly144AtAttributes(findings, fixture.pinned, context); } // --- T1.4-1 ------------------------------------------------------------------ // The segment-validity matrix. Every representative is staged as its raw // character between two ordinary letters (or as the whole segment, for the -// forbidden names). U+00A0, U+0085, and U+2028 belong to neither 1.4 class -// and are deliberately absent from this test — they are T1.4-2's (and -// §VIOL-VALID-WIDE's) subject. +// forbidden names) — the quote, escape, and character-reference characters, +// U+2028 and U+2029 (one arm each: 1.4's quote-and-escape bullet bars both, +// though neither is whitespace or a control character under 1.4, T1.4-2), +// and U+FFFD included (SPEC 1.4) — plus the two verbatim spellings of SPEC +// 2.4 (module header). U+00A0 and U+0085 belong to neither 1.4 class and no +// rule of 1.4 bars them: they are deliberately absent from this test — they +// are T1.4-2's (and §VIOL-VALID-WIDE's) subject. interface SegmentArm { /** Which SPEC 1.4 rule this segment violates (failure diagnostics). */ readonly name: string; readonly segment: string; + /** The `id` value's delimiter — single quotes only where the segment holds `"`. */ + readonly quote?: QuoteKind; } const INVALID_SEGMENT_ARMS: readonly SegmentArm[] = [ @@ -184,35 +396,74 @@ const INVALID_SEGMENT_ARMS: readonly SegmentArm[] = [ name: `forbidden name "${name}" as a segment`, segment: name, })), + ...QUOTE_ESCAPE_REFERENCE_CHARACTERS.map(({ name, character, quote }) => ({ + name: `${name} in a segment`, + segment: `a${character}b`, + quote, + })), + ...LINE_SEPARATOR_CHARACTERS.map(([codePoint, label]) => ({ + name: `${codePointName(codePoint)} (${label}) in a segment`, + segment: between(codePoint), + })), + { + name: "U+FFFD (REPLACEMENT CHARACTER) in a segment", + segment: between(REPLACEMENT_CHARACTER), + }, + { + name: + "the verbatim escape spelling of `.` (a backslash then `u002E`) in a segment — " + + "a segment containing the escape character, never the two-segment ID `a.b` " + + "(SPEC 2.4)", + segment: `a${ESCAPE_SPELLED_DOT}b`, + }, + { + name: + "the verbatim character reference `.` in a segment — a segment containing " + + "`&`, never the two-segment ID `a.b` (SPEC 2.4)", + segment: `a${REFERENCE_SPELLED_DOT}b`, + }, ]; -function segmentConstruct(segment: string): string { - return `<S id="${segment}">\nSection with the segment under test.\n</S>`; -} +// Every matrix arm's fixture, registered at module load in arm order — the +// template call the body used to make per arm, evaluated once here: the +// arms run after T1.4-1's template-control invocation, so each is a record +// the S-9 self-test judges before any product exists. +const INVALID_SEGMENT_FIXTURES = INVALID_SEGMENT_ARMS.map((arm) => ({ + arm, + fixture: staged( + `T1.4-1 arm ${arm.name} specs/A.mdx`, + segmentStaging(arm.segment, arm.quote), + ), +})); // Empty segment, nested spelling: `a..b` is reachable only via the chain // `a` → `a.` → `a..b`, where every level adds exactly one segment — 1.3 is // satisfied and the empty segment (1.4 → 14.4) is the only condition staged. -// Both `a.` and `a..b` contain an empty segment; whether a product reports -// the violation once or per offending ID is not fixed by SPEC 14, so one or -// two findings are accepted — each must be 14.4 and located within the outer -// offending construct (which contains the inner one). -const EMPTY_NESTED_PREFIX = `${SIBLING}<S id="a">\nAlpha.\n\n`; -const EMPTY_NESTED_CONSTRUCT = [ - '<S id="a.">', - "Introduces the empty segment.", - "", - '<S id="a..b">', - "Nested under the empty segment.", - "</S>", - "</S>", -].join("\n"); -const EMPTY_NESTED_SOURCE = `${EMPTY_NESTED_PREFIX}${EMPTY_NESTED_CONSTRUCT}\n</S>\n`; +// Both `a.` and `a..b` contain an empty segment, so both `id` attributes +// offend: one 14.4 finding per offending attribute, each located at its own +// attribute — the descendant spelling the malformed ancestor segment as its +// own prefix reports in its own attribute too (SPEC 14; T14-11). +const EMPTY_NESTED = staged( + "T1.4-1 nested empty segment specs/A.mdx", + assemble([ + `${SIBLING}<S id="a">\nAlpha.\n\n<S `, + { pin: 'id="a."' }, + ">\nIntroduces the empty segment.\n\n<S ", + { pin: 'id="a..b"' }, + ">\nNested under the empty segment.\n</S>\n</S>\n</S>\n", + ]), +); + +// The lone empty `id=""`, staged after the same invocation: a record too. +const LONE_EMPTY_SEGMENT = staged( + 'T1.4-1 lone empty id="" specs/A.mdx', + segmentStaging(""), +); const T1_4_1 = defineProductTest({ id: "T1.4-1", title: - 'segment validity matrix: empty segments (`a..b` via nesting and a lone `id=""`), `#`, each whitespace character, each control-class representative, and each forbidden name fail with 14.4 (SPEC 1.4, 14.4)', + 'segment validity matrix: empty segments (`a..b` via nesting and a lone `id=""`), `#`, each whitespace character, each control-class representative, each forbidden name, the quote, escape, and character-reference characters (`"` single-quoted, `\'`, backslash, `&`), U+2028 and U+2029 (one arm each, between two letters), and U+FFFD fail with 14.4, one finding per offending `id` attribute located exactly at it; the verbatim escape and character-reference spellings of `.` are segments containing the backslash and `&` — condition 4, never the two-segment ID `a.b` (SPEC 1.4, 2.4, 14, 14.4)', run: async (product) => { // Template control: the base workspace differs from every negative arm // only in the one segment, so each arm's 14.4 is attributable to the @@ -220,7 +471,7 @@ const T1_4_1 = defineProductTest({ const control = await TestWorkspace.create({ files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": `${SIBLING}${segmentConstruct("okseg")}\n`, + "specs/A.mdx": segmentStaging("okseg").source, }, }); try { @@ -233,53 +484,27 @@ const T1_4_1 = defineProductTest({ await control.dispose(); } - // Empty segment via nesting (`a..b`). + // Empty segment via nesting (`a..b`): two offending `id` attributes. const nestedContext = "T1.4-1 `build --json` over the nested empty segment (`a` -> `a.` -> `a..b`)"; - const nested = await TestWorkspace.create({ - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": EMPTY_NESTED_SOURCE, - }, - }); - try { - const findings = await buildFindings(product, nested, nestedContext); - const conditions = findings.map((finding) => finding.condition); - if ( - findings.length < 1 || - findings.length > 2 || - conditions.some((condition) => condition !== "14.4") - ) { - fail( - `${nestedContext}: expected the empty segment to report condition 14.4 — one ` + - `finding, or one per offending ID (\`a.\` and \`a..b\` both contain it) — and ` + - `nothing else (the nesting keeps 1.3 satisfied); got ${JSON.stringify(conditions)}`, - ); - } - const window = byteWindow(EMPTY_NESTED_PREFIX, EMPTY_NESTED_CONSTRUCT); - for (const finding of findings) { - assertFindingLocated( - finding, - { file: "specs/A.mdx", window }, - `${nestedContext}: a 14.4 finding`, - ); - } - } finally { - await nested.dispose(); - } + assertOnly144AtAttributes( + await findingsOf(product, EMPTY_NESTED.record, nestedContext), + EMPTY_NESTED.pinned, + nestedContext, + ); // Empty segment as a lone empty id: one empty segment, so 1.3's // exactly-one-segment top-level rule holds and 14.4 alone reports. await expectSingle144( product, - '<S id="">\nLone empty id.\n</S>', + LONE_EMPTY_SEGMENT, 'T1.4-1 `build --json` over a lone empty `id=""`', ); - for (const arm of INVALID_SEGMENT_ARMS) { + for (const { arm, fixture } of INVALID_SEGMENT_FIXTURES) { await expectSingle144( product, - segmentConstruct(arm.segment), + fixture, `T1.4-1 \`build --json\` with ${arm.name}`, ); } @@ -298,10 +523,15 @@ const BOUNDARY_SEGMENTS_SOURCE = BOUNDARY_CODE_POINTS.map( `<S id="${between(codePoint)}">\nSegment containing the ${label} character.\n</S>\n`, ).join("\n"); +/** The staged boundary code points as `U+XXXX` names (diagnostics). */ +const BOUNDARY_NAMES = BOUNDARY_CODE_POINTS.map(([codePoint]) => + codePointName(codePoint), +).join(" and "); + const T1_4_2 = defineProductTest({ id: "T1.4-2", title: - "segments containing U+00A0, U+0085, and U+2028 are valid — SPEC 1.4 excludes them from both character classes: builds succeed and the nodes are queryable by identity (SPEC 1.4)", + "segments containing U+00A0 and U+0085 are valid — SPEC 1.4 excludes them from both character classes and no rule of 1.4 bars them: builds succeed and the nodes are queryable by identity (U+2028 and U+2029, barred by 1.4's quote-and-escape bullet, are T1.4-1's and T1.4-4's invalid arms) (SPEC 1.4)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -313,7 +543,7 @@ const T1_4_2 = defineProductTest({ await buildOk( product, workspace, - "T1.4-2 `build` over segments containing U+00A0, U+0085, and U+2028", + `T1.4-2 \`build\` over segments containing ${BOUNDARY_NAMES}`, ); for (const id of BOUNDARY_SEGMENT_IDS) { const identity = `specs/A.mdx#${id}`; @@ -349,19 +579,17 @@ const T1_4_2 = defineProductTest({ // would compile, but the dot consumer would carry no error at `login`). const DASH_SEGMENT_SOURCE = '<S id="login-v2">\nDashed segment.\n</S>\n'; -const BRACKET_CONSUMER = [ - 'import SPEC from "./specs/A.xspec";', - "", - 'SPEC["login-v2"];', - "", -].join("\n"); +const BRACKET_CONSUMER = stagedTs( + "T1.4-3 consumer.ts — bracket access to `login-v2`, staged after `build`", + ['import SPEC from "./specs/A.xspec";', "", 'SPEC["login-v2"];', ""].join( + "\n", + ), +); -const DOT_CONSUMER = [ - 'import SPEC from "./specs/A.xspec";', - "", - "SPEC.login-v2;", - "", -].join("\n"); +const DOT_CONSUMER = stagedTs( + "T1.4-3 dot-consumer.ts — dot access to `login-v2`, staged after `build`", + ['import SPEC from "./specs/A.xspec";', "", "SPEC.login-v2;", ""].join("\n"), +); const T1_4_3 = defineProductTest({ id: "T1.4-3", @@ -411,19 +639,27 @@ const T1_4_3 = defineProductTest({ // --- T1.4-4 ------------------------------------------------------------------ -// Tags follow the segment rules except `.` is allowed. The boundary code -// points of T1.4-2 apply to tags too — and since none of the three is 1.4 -// whitespace, 2.6 splitting must not split on them: each staged value is -// exactly one tag, asserted exactly (a product splitting on U+00A0 would -// report two tags; §VIOL-VALID-WIDE rejects the value outright at `build`). +// Tags follow the segment rules except `.` is allowed. The valid boundary +// code points of T1.4-2, U+00A0 and U+0085, apply to tags too — and since +// neither is 1.4 whitespace, 2.6 splitting must not split on them: each +// staged value is exactly one tag, asserted exactly (a product splitting on +// U+00A0 would report two tags; §VIOL-VALID-WIDE rejects the value outright +// at `build`). // The empty and whitespace rules of 1.4 admit no invalid-tag fixture: `tags` // splits on runs of 1.4 whitespace with leading/trailing whitespace ignored // (2.6), so no tag token can be empty or contain whitespace — whitespace-only // values behave as omitted (T2.6-2), and the whitespace control characters -// U+0009–U+000D are split away as separators. +// U+0009–U+000D are split away as separators. The quote, escape, and +// character-reference characters, U+2028 and U+2029 (one arm each, 1.4's +// quote-and-escape bullet; neither is 1.4 whitespace, so 2.6 splitting keeps +// the staged value one tag and 14.4 alone rejects it), and U+FFFD are +// invalid in a tag as in a segment, and the escape spelling of `y` is read +// verbatim (module header). interface TagArm { readonly name: string; readonly tag: string; + /** The `tags` value's delimiter — single quotes only where the tag holds `"`. */ + readonly quote?: QuoteKind; } const VALID_TAG_ARMS: readonly TagArm[] = [ @@ -441,22 +677,54 @@ const INVALID_TAG_ARMS: readonly TagArm[] = [ name: `a tag containing the non-whitespace control character ${codePointName(codePoint)}`, tag: between(codePoint), })), + ...QUOTE_ESCAPE_REFERENCE_CHARACTERS.map(({ name, character, quote }) => ({ + name: `a tag containing ${name}`, + tag: `x${character}y`, + quote, + })), + ...LINE_SEPARATOR_CHARACTERS.map(([codePoint, label]) => ({ + name: `a tag containing ${codePointName(codePoint)} (${label})`, + tag: `x${String.fromCodePoint(codePoint)}y`, + })), + { + name: "a tag containing U+FFFD (REPLACEMENT CHARACTER)", + tag: `x${String.fromCodePoint(REPLACEMENT_CHARACTER)}y`, + }, + { + name: + "the verbatim escape spelling of `y` (`x`, a backslash, then `u0079`) — a tag " + + "containing the escape character, never the tag `xy` (SPEC 2.4)", + tag: `x${ESCAPE_SPELLED_Y}`, + }, ]; -function taggedConstruct(tags: string): string { - return `<S id="sec" tags="${tags}">\nTagged section.\n</S>`; -} +// The tag arms' fixtures, registered at module load in arm order (the +// template calls the body used to make): the first valid arm's workspace is +// T1.4-4's first and the rest follow its invocations — the table converts +// uniformly — and every invalid arm runs after them (S-9's timing clause). +const VALID_TAG_FIXTURES = VALID_TAG_ARMS.map((arm) => ({ + arm, + fixture: staged(`T1.4-4 arm ${arm.name} specs/A.mdx`, tagStaging(arm.tag)), +})); + +const INVALID_TAG_FIXTURES = INVALID_TAG_ARMS.map((arm) => ({ + arm, + fixture: staged( + `T1.4-4 arm ${arm.name} specs/A.mdx`, + tagStaging(arm.tag, arm.quote), + ), +})); const T1_4_4 = defineProductTest({ id: "T1.4-4", title: - "tags: `.` is valid; `#`, a forbidden name, and non-whitespace control characters fail with 14.4; the T1.4-2 boundary code points are valid in tags and never split (SPEC 1.4, 2.6, 14.4)", + "tags: `.` is valid; `#`, a forbidden name, non-whitespace control characters, the quote, escape, and character-reference characters (`\"` single-quoted, `'`, backslash, `&`), U+2028 and U+2029 (one arm each), and U+FFFD fail with 14.4, one finding per offending `tags` attribute located exactly at it; the verbatim escape spelling of `y` is a tag containing the backslash — condition 4, never the tag `xy`; the T1.4-2 boundary code points U+00A0 and U+0085 are valid in tags and never split (SPEC 1.4, 2.4, 2.6, 14, 14.4)", run: async (product) => { - for (const arm of VALID_TAG_ARMS) { + for (const { arm, fixture } of VALID_TAG_FIXTURES) { const workspace = await TestWorkspace.create({ files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": `${SIBLING}${taggedConstruct(arm.tag)}\n`, + "specs/A.mdx": fixture.record, }, }); try { @@ -481,20 +749,513 @@ const T1_4_4 = defineProductTest({ await workspace.dispose(); } } - for (const arm of INVALID_TAG_ARMS) { + for (const { arm, fixture } of INVALID_TAG_FIXTURES) { await expectSingle144( product, - taggedConstruct(arm.tag), + fixture, `T1.4-4 \`build --json\` with ${arm.name}`, ); } }, }); +// --- T1.4-5 ------------------------------------------------------------------ + +// Identifier by characters (TEST-SPEC T1.4-5; SPEC 1.4, 2.4, 4.1, 6.4, 6.5, +// 14.20). 1.4 makes "valid TypeScript identifier" a test of characters alone +// at the release and language level 14.20 fixes — TypeScript 5.9.3 at +// ESNext: the first character one that release admits to begin an +// identifier, each other one it admits to continue one. A reserved word is +// therefore one (2.4: a non-computed access's name is any identifier name +// the file's grammar admits there), and so is a non-ASCII letter; U+1C89 +// (CYRILLIC CAPITAL LETTER TJE, a Unicode 16 letter) is not, though a +// runtime whose Unicode tables postdate 15.1 admits it to begin and to +// continue one; U+2EBF0 (a CJK ideograph of Unicode 15.1) is, at ESNext, +// though not at ES5. Three arms, each its own workspace: +// - (a) Access: dot access naming the reserved words `delete` and `default` +// and the letter U+00E9 builds and checks clean from a spec source and a +// code source alike, the complete `depends`, `embeds`, and `references` +// edge set reported; and a consumer compiling the three dot spellings and +// the quoted `SPEC["<U+1C89>x"]` type-checks under the tooling driver +// (helpers/tooling.ts) at TypeScript 5.9.3 — whose program parses the +// generated module too, so a product judging by its runtime's tables +// whether a property name needs quoting, and leaving `<U+1C89>x` unquoted +// where 5.9.3 cannot parse it, fails the compile (13.1). +// - (b) Conversion: section `m`, its `d` array holding the local references +// `"delete"`, `"<U+00E9>"`, and `"n.2fa"` to origin nodes outside the moved +// subtree, moves out of `specs/a.mdx` into the existing `specs/t.mdx`, +// which lacks `a.mdx`'s module, so the references convert to imported form +// through the target's added declaration in 6.4's fallback spellings: dot +// access for the identifier-valid segments, the reserved word and the +// non-ASCII letter included, and double-quoted computed access for `2fa`, +// whose first character can only continue an identifier — the array's +// brackets, commas, and spaces unchanged. The target's bytes are asserted +// under T6.5-8's discipline (helpers/import-insertion.ts): composed from +// the rules of 6.4/6.5 and 3 up to the fresh identifier, read off the +// rewritten array, and the choice among the line-start admissible offsets. +// - (c) Release and language level: the same move over the local +// references `"<U+1C89>x"` and `"<U+2EBF0>"` reads `<O>["<U+1C89>x"]` and +// `<O>.<U+2EBF0>` — a product classing characters by its runtime's tables +// writes `<O>.<U+1C89>x`, and one judging at ES5 `<O>["<U+2EBF0>"]`. +// The target holds an ESM block of its own, an import of `specs/k.mdx`'s +// module that its section references, so a line-start admissible offset +// stands beside it as at the file's end (the import-free target is +// T6.5-10's). The rewritten target must derive (S-9), and `check`, then +// `build`, are clean. The characters are built from their code points (the +// module header). +const E_ACUTE = String.fromCodePoint(0x00e9); +const TJE_X = `${String.fromCodePoint(0x1c89)}x`; +const IDEOGRAPH = String.fromCodePoint(0x2ebf0); + +// Arm (a)'s configuration: one spec group and one code group (SPEC 7). The +// body's first workspace stages it before any invocation; a record all the +// same, as every T1.4-5 staging is. +const T1_4_5_ACCESS_CONFIG = stagedTs( + "T1.4-5 xspec.config.ts — one spec group and one code group, arm (a)'s", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); + +const T1_4_5_B_SOURCE = stagedMdx( + "T1.4-5 (a) specs/B.mdx — top-level sections delete, default, U+00E9, and U+1C89 then x", + [ + '<S id="delete">', + "Delete text.", + "</S>", + "", + '<S id="default">', + "Default text.", + "</S>", + "", + `<S id="${E_ACUTE}">`, + "Acute text.", + "</S>", + "", + `<S id="${TJE_X}">`, + "Tje text.", + "</S>", + "", + ].join("\n"), +); + +const T1_4_5_A_SOURCE = stagedMdx( + "T1.4-5 (a) specs/A.mdx — importing B.mdx's module as B: d={B.delete}, {text(B.default)}, and d={B.<U+00E9>}", + [ + 'import B from "./B.xspec"', + "", + '<S id="uses" d={B.delete}>', + "{text(B.default)}", + "</S>", + "", + `<S id="acute" d={B.${E_ACUTE}}>`, + "Acute dependent.", + "</S>", + "", + ].join("\n"), +); + +// The markers stand at module scope, so their edges are attributed to the +// file itself (SPEC 4.5, 4.6); the call records an `embeds` edge (4.3). +const T1_4_5_CODE_SOURCE = stagedTs( + "T1.4-5 (a) src/c.ts — importing B.mdx's module as SPEC, { text }: the markers SPEC.delete and SPEC.<U+00E9>, the call text(SPEC.default)", + [ + 'import SPEC, { text } from "../specs/B.xspec";', + "", + "SPEC.delete;", + `SPEC.${E_ACUTE};`, + "text(SPEC.default);", + "", + ].join("\n"), +); + +/** Arm (a)'s complete dependency-edge set (SPEC 2.2, 2.3, 4.3, 4.5, 5.2). */ +const T1_4_5_ACCESS_EDGES: readonly GraphEdge[] = [ + { from: "specs/A.mdx#uses", to: "specs/B.mdx#delete", kind: "depends" }, + { + from: "specs/A.mdx#acute", + to: `specs/B.mdx#${E_ACUTE}`, + kind: "depends", + }, + { from: "specs/A.mdx#uses", to: "specs/B.mdx#default", kind: "embeds" }, + { from: "src/c.ts", to: "specs/B.mdx#default", kind: "embeds" }, + { from: "src/c.ts", to: "specs/B.mdx#delete", kind: "references" }, + { from: "src/c.ts", to: `specs/B.mdx#${E_ACUTE}`, kind: "references" }, +]; + +// The consumer, staged after `build`: the three dot spellings and the +// quoted one, compiled against the generated module at TypeScript 5.9.3. +const T1_4_5_CONSUMER = stagedTs( + 'T1.4-5 (a) consumer.ts — SPEC.delete, SPEC.default, SPEC.<U+00E9>, and SPEC["<U+1C89>x"], staged after `build`', + [ + 'import SPEC from "./specs/B.xspec";', + "", + "SPEC.delete;", + "SPEC.default;", + `SPEC.${E_ACUTE};`, + `SPEC["${TJE_X}"];`, + "", + ].join("\n"), +); + +// Arms (b) and (c)'s target side: `specs/k.mdx`, whose module the target +// imports, and the target `specs/t.mdx`, holding no import of `a.mdx`'s +// module. +const T1_4_5_K_SOURCE = stagedMdx( + "T1.4-5 (b)/(c) specs/k.mdx — the module the target imports", + ['<S id="k">', "K text.", "</S>", ""].join("\n"), +); + +const T1_4_5_TARGET_LINES: readonly string[] = [ + 'import K from "./k.xspec"', + "", + '<S id="tgt" d={K.k}>', + "Target text.", + "</S>", +]; + +const T1_4_5_TARGET_SOURCE = stagedMdx( + "T1.4-5 (b)/(c) specs/t.mdx — the existing target: an import of k.mdx's module, none of a.mdx's", + [...T1_4_5_TARGET_LINES, ""].join("\n"), +); + +/** + * The target's expected post-move bytes WITHOUT the added import (SPEC 6.5, + * 6.4, 3): `m` is top-level, so its text — its `d` array converted, its ID + * unchanged — is inserted at the end of the file followed by U+000A, the + * existing final line terminated (no preceding U+000A), every other byte + * kept. + */ +function t145TargetBase(movedOpeningTag: string): string { + return [ + ...T1_4_5_TARGET_LINES, + movedOpeningTag, + "Moved text.", + "</S>", + "", + ].join("\n"); +} + +const T1_4_5_CONVERSION_ORIGIN = stagedMdx( + 'T1.4-5 (b) specs/a.mdx — section m\'s d array of the local references "delete", "<U+00E9>", and "n.2fa"', + [ + '<S id="delete">', + "Delete text.", + "</S>", + "", + `<S id="${E_ACUTE}">`, + "Acute text.", + "</S>", + "", + '<S id="n">', + "N text.", + "", + '<S id="n.2fa">', + "Two-factor text.", + "</S>", + "</S>", + "", + `<S id="m" d={["delete", "${E_ACUTE}", "n.2fa"]}>`, + "Moved text.", + "</S>", + "", + ].join("\n"), +); + +const T1_4_5_RELEASE_ORIGIN = stagedMdx( + 'T1.4-5 (c) specs/a.mdx — section m\'s d array of the local references "<U+1C89>x" and "<U+2EBF0>"', + [ + `<S id="${TJE_X}">`, + "Tje text.", + "</S>", + "", + `<S id="${IDEOGRAPH}">`, + "Ideograph text.", + "</S>", + "", + `<S id="m" d={["${TJE_X}", "${IDEOGRAPH}"]}>`, + "Moved text.", + "</S>", + "", + ].join("\n"), +); + +/** One conversion arm: (b) or (c). */ +interface T145ConversionArm { + readonly label: string; + readonly origin: StagedMdx; + /** The moved section's expected `d` attribute, rooted at `root`. */ + readonly attribute: (root: string) => string; + /** That attribute in ASCII, for diagnoses. */ + readonly attributeForm: string; + /** Why each entry reads as it does (6.4's fallback spellings, 1.4). */ + readonly why: string; +} + +const T1_4_5_CONVERSION_ARMS: readonly T145ConversionArm[] = [ + { + label: "(b) conversion", + origin: T1_4_5_CONVERSION_ORIGIN, + attribute: (root) => + `d={[${root}.delete, ${root}.${E_ACUTE}, ${root}.n["2fa"]]}`, + attributeForm: 'd={[<O>.delete, <O>.<U+00E9>, <O>.n["2fa"]]}', + why: + "dot access for the identifier-valid segments `delete` (a reserved " + + "word) and U+00E9 (a non-ASCII letter), and `n`, double-quoted " + + "computed access for `2fa`, whose first character can only continue " + + "an identifier", + }, + { + label: "(c) release and language level", + origin: T1_4_5_RELEASE_ORIGIN, + attribute: (root) => `d={[${root}["${TJE_X}"], ${root}.${IDEOGRAPH}]}`, + attributeForm: 'd={[<O>["<U+1C89>x"], <O>.<U+2EBF0>]}', + why: + "double-quoted computed access for U+1C89 then `x` — TypeScript " + + "5.9.3 admits U+1C89 neither to begin nor to continue an identifier, " + + "whatever a runtime's later Unicode tables admit — and dot access for " + + "U+2EBF0, a Unicode 15.1 ideograph that release admits at ESNext, " + + "though not at ES5", + }, +]; + +/** Names the added import may not bind in the target (SPEC 2.1, 14.15). */ +const T1_4_5_FORBIDDEN_ROOTS: readonly { name: string; why: string }[] = [ + { + name: "K", + why: "the identifier the target's retained import of k.mdx's module binds", + }, + ...["S", "Spec", "text"].map((name) => ({ + name, + why: "a compiler-provided name no import in an xspec source file may bind", + })), +]; + +/** + * The fresh identifier the moved `d` array is rooted at — the value-unpinned + * binding of the added declaration (SPEC 6.5), read off its first entry — + * after asserting the moved section's opening tag is exactly the expected + * one: every entry in 6.4's fallback spelling, rooted at that one binding, + * the array's brackets, commas, and spaces unchanged. Diagnosed (H-8) when + * the target holds no such tag line, or more than one. + */ +function t145MovedArrayRoot( + text: string, + arm: T145ConversionArm, + context: string, +): string { + const tagLines = text + .split("\n") + .filter((line) => line.startsWith('<S id="m" ')); + const expectation = + `the moved section's opening tag \`<S id="m" ${arm.attributeForm}>\` — ` + + `its local references converted to imported form through one binding ` + + `<O> of a.mdx's module in 6.4's fallback spellings: ${arm.why}; the ` + + `array's brackets, commas, and spaces unchanged (SPEC 1.4, 6.4, 6.5, ` + + `14.20)`; + const [tagLine] = tagLines; + if (tagLines.length !== 1 || tagLine === undefined) { + fail( + `${context}: specs/t.mdx must hold exactly one line opening with ` + + `\`<S id="m" \`, ${expectation}; found ${String(tagLines.length)} ` + + `in ${JSON.stringify(text)}`, + ); + } + const root = /^<S id="m" d=\{\[([A-Za-z_$][A-Za-z0-9_$]*)[.[]/.exec( + tagLine, + )?.[1]; + if (root === undefined || tagLine !== `<S id="m" ${arm.attribute(root)}>`) { + fail( + `${context}: specs/t.mdx must hold ${expectation}; the line reads ` + + `${JSON.stringify(tagLine)}`, + ); + } + return root; +} + +/** + * Stage one conversion arm, move `m` into the target, and assert the + * target's bytes under T6.5-8's discipline, its derivability (S-9), and a + * clean `check`, then `build`. + */ +async function runT145ConversionArm( + product: ProductBinding, + arm: T145ConversionArm, +): Promise<void> { + const context = `T1.4-5 ${arm.label}`; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/a.mdx": arm.origin, + "specs/t.mdx": T1_4_5_TARGET_SOURCE, + "specs/k.mdx": T1_4_5_K_SOURCE, + }, + }); + try { + // Premise: the staging is valid (every reference resolves), so a later + // failure is the move's, not the staging's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging, every reference resolving`, + ); + await expectExit( + product, + workspace, + ["move", "specs/a.mdx#m", "specs/t.mdx#m"], + 0, + `${context} \`move specs/a.mdx#m specs/t.mdx#m\` into a target ` + + `lacking a.mdx's module (SPEC 6.5)`, + ); + const kind = await workspace.kind("specs/t.mdx"); + if (kind !== "file") { + fail(`${context}: expected a plain file at specs/t.mdx; found ${kind}`); + } + const actual = await workspace.readBytes("specs/t.mdx"); + const text = new TextDecoder("utf-8", { fatal: false }).decode(actual); + const root = t145MovedArrayRoot(text, arm, context); + for (const forbidden of T1_4_5_FORBIDDEN_ROOTS) { + if (root === forbidden.name) { + fail( + `${context}: the added import binds \`${forbidden.name}\`, ` + + `${forbidden.why} — an added import binds fresh identifiers ` + + `colliding with no binding already in the file (SPEC 6.5, ` + + `2.1, 14.15)`, + ); + } + } + // T6.5-8's discipline: composed up to the two unknowns — the fresh + // identifier (now known) and the insertion offset (isolated by the + // helper, which accepts a line-start reading alone; the target holds + // one). + assertAddedImportInsertion( + { + rel: "specs/t.mdx", + base: Buffer.from( + t145TargetBase(`<S id="m" ${arm.attribute(root)}>`), + "utf8", + ), + actual, + importerDir: "specs", + expectedModule: "specs/a.xspec", + identifier: root, + }, + `${context}: specs/t.mdx after the move is its composed post-move ` + + `bytes — the moved text appended at the end of the file, its \`d\` ` + + `array converted (${arm.attributeForm}), every other byte kept — ` + + `with exactly one import of a.mdx's module added as a line of its ` + + `own, byte-exactly 6.5's spelling followed by U+000A at a ` + + `line-start offset, binding the identifier the array is rooted at ` + + `(SPEC 6.5, 6.4, 2.1, 3; T6.5-8's discipline)`, + ); + const verdict = deriveMdx(actual); + if (!verdict.derives) { + fail( + `${context}: specs/t.mdx after the move is not well-formed under ` + + `the stock MDX 3 grammar, its identifier characters Unicode ` + + `15.1's (S-9; SPEC 14.20): ${verdict.reason} — the file reads ` + + JSON.stringify(text), + ); + } + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} \`check --json\` immediately after the move — every ` + + `converted reference resolves through the added binding, and no ` + + `staleness remains (SPEC 6.5, 12.2)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context} \`build --json\` after the move (SPEC 6.5, 12.1)`, + ); + } finally { + await workspace.dispose(); + } +} + +const T1_4_5 = defineProductTest({ + id: "T1.4-5", + title: + "identifier by characters at TypeScript 5.9.3 and ESNext: dot access to the reserved words `delete` and `default` and to U+00E9 builds and checks clean from a spec source and a code source, each `depends`, `embeds`, and `references` edge reported, and a consumer compiling them and `SPEC[\"<U+1C89>x\"]` against the generated module type-checks at 5.9.3; a section move into a target lacking the origin's module converts a `d` array's local references to imported form in exactly 6.4's fallback spellings — dot access for `delete`, U+00E9, and U+2EBF0, double-quoted computed access for `2fa` and for U+1C89 then `x` — under T6.5-8's discipline, the target deriving and `build` and `check` clean (SPEC 1.4, 2.4, 4.1, 6.4, 6.5, 14.20)", + run: async (product) => { + // (a) Access. + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": T1_4_5_ACCESS_CONFIG, + "specs/B.mdx": T1_4_5_B_SOURCE, + "specs/A.mdx": T1_4_5_A_SOURCE, + "src/c.ts": T1_4_5_CODE_SOURCE, + }, + }); + try { + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + "T1.4-5 (a) `build --json` over dot access naming the reserved " + + "words `delete` and `default` and the letter U+00E9, from a spec " + + "source and a code source (SPEC 1.4, 2.4, 14.20)", + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + "T1.4-5 (a) `check --json` after `build` (SPEC 1.4, 2.4, 12.2)", + ); + const label = + "T1.4-5 (a) `query edges --kinds depends,embeds,references`"; + assertEdgeSetEqual( + decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--kinds", "depends,embeds,references"], + label, + ), + label, + ), + T1_4_5_ACCESS_EDGES, + `${label}: the complete dependency-edge set — each dot spelling's ` + + "edge to its node: the spec source's two `depends` edges and its " + + "`embeds` edge, the code source's two `references` edges and its " + + "`embeds` edge, attributed to the file itself (SPEC 1.4, 2.4, " + + "4.3, 4.5, 4.6, 5.2)", + ); + await workspace.file("consumer.ts", T1_4_5_CONSUMER); + const consumer = await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: ["consumer.ts"], + }); + assertNoCompileErrors( + consumer, + 'T1.4-5 (a) consumer compiling `SPEC.delete`, `SPEC.default`, `SPEC.<U+00E9>`, and `SPEC["<U+1C89>x"]` against the generated module at TypeScript 5.9.3 — every identifier-valid segment a dot-accessible property, and the generated module parsing at that release, so `<U+1C89>x`, no identifier there, stands quoted (SPEC 1.4, 2.4, 4.1, 13.1, 14.20)', + ); + } finally { + await workspace.dispose(); + } + // (b) Conversion, then (c) the release and language level. + for (const arm of T1_4_5_CONVERSION_ARMS) { + await runT145ConversionArm(product, arm); + } + }, +}); + /** TEST-SPEC §1.4, in canonical ID order (SUITE-03). */ export const section14Tests: readonly ProductTestEntry[] = [ T1_4_1, T1_4_2, T1_4_3, T1_4_4, + T1_4_5, ]; diff --git a/test/suite/registry/section-1.5.ts b/test/suite/registry/section-1.5.ts index 3efb6aba..fa32b005 100644 --- a/test/suite/registry/section-1.5.ts +++ b/test/suite/registry/section-1.5.ts @@ -18,7 +18,14 @@ // - T1.5-2's non-UTF-8 arm is staged on the Linux leg per its TEST-SPEC text // ("where file names are byte strings"): other platforms' filesystems // refuse such names outright, so the arm runs exactly where the fixture is -// realizable. The `#` arms run everywhere. +// realizable; its finding's concerned path is the marked byte form (SPEC +// 12.0, 12.7). The `#` arms and the U+FFFD-pathed arm run everywhere: the +// latter stages specs/A<U+FFFD>.mdx (the spelling shared with T11.5-3 +// through support.ts; valid UTF-8, so a name every platform holds) and +// pins its finding's concerned path as the plain string form, never the +// byte form (SPEC 12.0: a U+FFFD path has a plain string form). That the +// file is reachable by glob through `view` and nameable by no argument is +// T11.5-3's business (T11.2-3's discipline). // - T1.5-3 exercises the `path#id` vs bare `path` addressing duality across // `query node`, `show`, and `rename`/`move` arguments; sections and roots // carry distinct byte-anchored texts so the two address forms are @@ -28,6 +35,7 @@ import { Buffer } from "node:buffer"; import type { Finding } from "../../helpers/adapters/index.js"; import { assertReportMentions, + classifyIgnoredReasons, decodeCoverageReport, decodeIdsReport, decodeImpactReport, @@ -42,6 +50,8 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { runProduct } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; @@ -52,19 +62,25 @@ import { buildFindings, buildOk, expectExit, + REPLACEMENT_CHARACTER_SPEC_PATH, runJson, sortedIdentities, } from "./support.js"; -// Minimal declarative configuration (SPEC 7): one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): one spec group. A +// staged-source record (S-9's timing clause): T1.5-2's non-UTF-8 and U+FFFD +// arms stage it after the body's first invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T1.5-2 xspec.config.ts — the specs-only configuration of the non-UTF-8 and U+FFFD arms", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // --------------------------------------------------------------------------- // T1.5-1 @@ -392,10 +408,16 @@ const T1_5_1 = defineProductTest({ `${coverageLabel}: the ignored roots are identified by bare workspace-relative path (SPEC 1.5)`, ); for (const ignored of profile.ignored) { + // Reason spellings are output shape (SPEC 8.2 pins no wording): + // classified onto their SPEC 8.2 identities through the coverage + // adapter, as T8.2-1 and T1.2-3 do (H-3). assertSameJson( - ignored.reasons, - ["root node"], - `${coverageLabel}: ${ignored.identity} exclusion reasons (targets: "all" — only the root-node reason applies, SPEC 8.2)`, + classifyIgnoredReasons( + ignored.reasons, + `${coverageLabel} ignored ${ignored.identity}`, + ), + ["root"], + `${coverageLabel}: ${ignored.identity} exclusion reasons (targets: "all" — the root-node reason and nothing else, SPEC 8.2)`, ); } const coverageHumanLabel = "T1.5-1 `coverage` (human report)"; @@ -509,8 +531,18 @@ const T1_5_1 = defineProductTest({ // T1.5-2 // --------------------------------------------------------------------------- -// One spec group plus one code group, for the code-group arm (SPEC 7.2). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// The code arm's code source at `src/a#b.ts`, staged after the spec arm's +// invocation: a staged-source record (S-9's timing clause). +const CODE_ARM_SOURCE = stagedTs( + "T1.5-2 code arm src/a#b.ts", + "export const ok = 1;\n", +); + +// One spec group plus one code group, for the code-group arm (SPEC 7.2), +// staged after the spec arm's invocation: a staged-source record (S-9). +const SPEC_AND_CODE_CONFIG = stagedTs( + "T1.5-2 code arm xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -520,11 +552,22 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); // Valid content everywhere: the invalid-path condition (14.19) must be the // only condition present, so the exact-count assertion has teeth. -const VALID_SECTION_SOURCE = '<S id="ok">\nValid content.\n</S>\n'; +// The valid section source, one staged-source record staged at every arm's +// spec path: the spec arm's `specs/a#b.mdx` (the body's first workspace), +// the code arm's `specs/OK.mdx` and the U+FFFD arm's `specs/A<U+FFFD>.mdx` +// (initial files of workspaces created after the spec arm's invocation), +// and, on the Linux leg, the byte-path `file()` staging (a byte path cannot +// key a `files` entry) — judged before any product exists (S-9, +// test/self/s9-staged-sources.test.ts). +const VALID_SECTION_SOURCE = stagedMdx( + "T1.5-2 the valid section source at every arm's spec path", + '<S id="ok">\nValid content.\n</S>\n', +); // `specs/b<0xFF>.mdx`: 0xFF can occur in no valid UTF-8 sequence, so the // workspace-relative path is not valid UTF-8. The glob rules of SPEC 7 match @@ -536,6 +579,31 @@ const NON_UTF8_SPEC_PATH = Buffer.concat([ Buffer.from(".mdx", "utf8"), ]); +// The marked byte form of that path — the concerned path a 14.19 finding +// presents for a non-UTF-8 path (SPEC 12.0, 12.7) — composed from the SAME +// bytes that stage the file, never measured from product output. +const NON_UTF8_MARKED_PATH = { + bytes: NON_UTF8_SPEC_PATH.toString("hex"), +} as const; + +/** + * The asserted projection of a 14.19 finding (the T11.2-3 discipline): the + * stable code, the empty locations of a path-level condition, and the + * concerned path in the form 12.0 fixes for it (SPEC 14, 12.7). Message and + * identities stay unpinned. + */ +function projectPathFinding(finding: Finding): { + readonly code: string | null; + readonly locations: readonly unknown[]; + readonly path: unknown; +} { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + }; +} + /** * A 14.19 finding must identify the offending source by its exact * workspace-relative path (SPEC 14, 1.5). The condition is about the path @@ -546,11 +614,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)})`, ); } } @@ -558,7 +629,7 @@ function assertFindingFile( const T1_5_2 = defineProductTest({ id: "T1.5-2", title: - "a discovered source path containing `#` fails with 14.19 (spec-group and code-group arms); a non-UTF-8 discovered path fails with 14.19, staged on the Linux leg (SPEC 1.5, 7, 14.19)", + "a discovered source path containing `#` fails with 14.19 (spec-group and code-group arms); a non-UTF-8 discovered path fails with 14.19 with its concerned path in the marked byte form, staged on the Linux leg; a discovered path containing U+FFFD (specs/A<U+FFFD>.mdx) fails with 14.19 on both legs — stable code `invalid-source-path`, locations [], the concerned path presented as the plain string, never the byte form (SPEC 1.5, 7, 12.0, 12.7, 14.19)", run: async (product) => { // Spec-group arm: a discovered `.mdx` whose path contains `#`. const specArm = await TestWorkspace.create({ @@ -584,7 +655,7 @@ const T1_5_2 = defineProductTest({ files: { "xspec.config.ts": SPEC_AND_CODE_CONFIG, "specs/OK.mdx": VALID_SECTION_SOURCE, - "src/a#b.ts": "export const ok = 1;\n", + "src/a#b.ts": CODE_ARM_SOURCE, }, }); try { @@ -598,10 +669,11 @@ const T1_5_2 = defineProductTest({ } // Non-UTF-8 arm, staged on the Linux leg per T1.5-2's own text: Linux - // file names are byte strings, so the fixture stages verbatim; other - // platforms' filesystems cannot hold the path at all. The finding's file - // rendering is not asserted — the path has no UTF-8 spelling, and how a - // report spells an unspellable path is not fixed by SPEC.md. + // file names are byte strings, so the fixture stages verbatim (the + // builder's byte-path `file`, S-2's round-trip); other platforms' + // filesystems cannot hold the path at all. The finding's concerned path + // is the marked byte form — the path's exact bytes, the one presentation + // 12.0 admits for a non-UTF-8 path (SPEC 12.0, 12.7). if (process.platform === "linux") { const nonUtf8Arm = await TestWorkspace.create({ files: { "xspec.config.ts": SPECS_ONLY_CONFIG }, @@ -612,10 +684,56 @@ const T1_5_2 = defineProductTest({ "T1.5-2 `build --json` with a discovered spec source whose path is not valid UTF-8 (Linux leg)"; const findings = await buildFindings(product, nonUtf8Arm, context); assertConditionCounts(findings, { "14.19": 1 }, context); + assertSameJson( + projectPathFinding(findings[0]!), + { + code: "invalid-source-path", + locations: [], + path: NON_UTF8_MARKED_PATH, + }, + `${context} — 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)`, + ); } finally { await nonUtf8Arm.dispose(); } } + + // U+FFFD arm, both legs: the path is valid UTF-8 (U+FFFD encodes as + // EF BF BD), so every platform holds the name and the byte-wise glob + // `specs/**/*.mdx` discovers it (SPEC 7); a path containing U+FFFD is an + // invalid source path (14.19) with a plain string form — the finding's + // concerned path is exactly that string, never the marked byte form, + // which 12.0 reserves for a non-UTF-8 path (SPEC 12.0, 12.7). A product + // discovering the file as valid builds finding-free and fails the count; + // one presenting the path in the byte form fails the projection. + const replacementArm = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [REPLACEMENT_CHARACTER_SPEC_PATH]: VALID_SECTION_SOURCE, + }, + }); + try { + const context = + "T1.5-2 `build --json` with a discovered spec source whose path contains U+FFFD (specs/A<U+FFFD>.mdx, both legs)"; + const findings = await buildFindings(product, replacementArm, context); + assertConditionCounts(findings, { "14.19": 1 }, context); + assertSameJson( + projectPathFinding(findings[0]!), + { + code: "invalid-source-path", + locations: [], + path: REPLACEMENT_CHARACTER_SPEC_PATH, + }, + `${context} — the condition-19 finding carries the stable code, no ` + + `in-source locations, and the U+FFFD concerned path as a plain ` + + `string — never the marked byte form, which 12.0 reserves for a ` + + `non-UTF-8 path (SPEC 14, 12.0, 12.7)`, + ); + } finally { + await replacementArm.dispose(); + } }, }); diff --git a/test/suite/registry/section-1.6-1.7.ts b/test/suite/registry/section-1.6-1.7.ts index 2affc8a2..c8337398 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 { @@ -26,9 +39,12 @@ import { assertExitCode, assertFileBytes, fail, + HarnessAssertionError, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertNoCompileErrors, @@ -39,6 +55,7 @@ import { import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertSameJson, buildFindings, buildOk, @@ -49,18 +66,26 @@ import { // Minimal declarative configuration (SPEC 7): one spec group. The spec-group // globs match only `.mdx` files, so no glob matches a Markdown emit // destination (`specs/*.md`, SPEC 7.3) — the discovered set is identical -// under every `markdown` variant, as T1.6-1's parity arm requires. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// under every `markdown` variant, as T1.6-1's parity arm requires. This +// configuration and the two `markdown` variants below are staged-source +// records (S-9's timing clause): the parity arm stages each into its second +// workspace after the body's first invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T1.6-1 xspec.config.ts — `markdown` absent (the parity arm's first variant)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // As above with `markdown: { emit: false }` (SPEC 7.3). -const EMIT_FALSE_CONFIG = `import { defineConfig } from "xspec" +const EMIT_FALSE_CONFIG = stagedTs( + "T1.6-1 xspec.config.ts — `markdown: { emit: false }`", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -68,11 +93,14 @@ export default defineConfig({ }, markdown: { emit: false } }) -`; +`, +); // As above with emission enabled (default destination: next to each source // file, `specs/A.mdx` → `specs/A.md`; SPEC 7.3, 13.2). -const EMIT_TRUE_CONFIG = `import { defineConfig } from "xspec" +const EMIT_TRUE_CONFIG = stagedTs( + "T1.6-1 xspec.config.ts — `markdown: { emit: true }`", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -80,7 +108,8 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); /** * `query node <identity>` decoded through the H-3 adapter, with the resolved @@ -167,16 +196,22 @@ const EMBEDDING_SECTION = [ "</S>", ].join("\n"); -const EXTENDED_SOURCE = [ - "Root prose.", - "", - INTERLEAVED_BODY, - "", - EMBEDDING_SECTION, - "", - "Root outro.", - "", -].join("\n"); +// The extended workspace is created after the base arm's invocations, so +// its source is a staged-source record, judged by the S-9 self-test before +// any product exists (S-9's timing clause). +const EXTENDED_SOURCE = stagedMdx( + "T1.6-1 extended specs/A.mdx", + [ + "Root prose.", + "", + INTERLEAVED_BODY, + "", + EMBEDDING_SECTION, + "", + "Root outro.", + "", + ].join("\n"), +); // Expected values, derived by hand from SPEC 3: every tag-only line is // emptied purely by removals and dropped with its terminator; all other lines @@ -486,12 +521,15 @@ const REVIEW_SESSION = "expansion"; // `text(node)` at runtime (SPEC 4.3): the compiled consumer prints the // expanded subtree text — requirement text reaches it only through `text`. -const EXPANSION_CONSUMER = [ - 'import SPEC, { text } from "./specs/A.xspec";', - "", - "process.stdout.write(text(SPEC.summary));", - "", -].join("\n"); +const EXPANSION_CONSUMER = stagedTs( + "T1.6-3 main.ts — `text` of the expanded summary, staged after `build`", + [ + 'import SPEC, { text } from "./specs/A.xspec";', + "", + "process.stdout.write(text(SPEC.summary));", + "", + ].join("\n"), +); const T1_6_3 = defineProductTest({ id: "T1.6-3", @@ -689,12 +727,13 @@ const EMBED_TARGET_BEFORE = [ "", ].join("\n"); -const EMBED_TARGET_AFTER = [ - '<S id="target">', - "Edited target text.", - "</S>", - "", -].join("\n"); +// Staged into specs/BASE.mdx after the pre-edit queries — a staged-source +// record, judged before any product exists (S-9, +// test/self/s9-staged-sources.test.ts). +const EMBED_TARGET_AFTER = stagedMdx( + "T1.6-4 specs/BASE.mdx with the embedded target's text edited", + ['<S id="target">', "Edited target text.", "</S>", ""].join("\n"), +); const EMBEDDER_SOURCE = [ 'import BASE from "./BASE.xspec"', @@ -927,8 +966,12 @@ const T1_6_4 = defineProductTest({ // T1.6-5 // --------------------------------------------------------------------------- -// One spec group plus one code group, for the code-source arms (SPEC 7.2). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// One spec group plus one code group, for the code-source arms (SPEC 7.2) — +// T1.6-5's code arm and T1.7-1's endpoints workspace, each created after +// its body's first invocation: a staged-source record (S-9's timing clause). +const SPEC_AND_CODE_CONFIG = stagedTs( + "T1.6-5/T1.7-1 xspec.config.ts — one spec group and one code group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -938,10 +981,13 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); // 0xFF can occur in no valid UTF-8 sequence; everything else in the file is -// valid, so 14.20 is the file's only condition. +// valid, so 14.20 is the file's only condition. The prefix is the file's +// longest well-formed UTF-8 prefix, so its byte length is the finding's offset +// (SPEC 14: the first byte of the first ill-formed sequence; T14-11). function withInvalidUtf8Byte(prefix: string, suffix: string): Uint8Array { return Buffer.concat([ Buffer.from(prefix, "utf8"), @@ -950,25 +996,76 @@ function withInvalidUtf8Byte(prefix: string, suffix: string): Uint8Array { ]); } +// The invalid-UTF-8 fixtures, one spec source and one code source, with the +// offset each finding must carry precomputed from the staged bytes — never +// from product output. Each prefix holds a multibyte character (é: one code +// point, two bytes), so a product counting code points, or reporting the +// byte at which its decoder notices the failure, misses the pinned offset. +const BAD_UTF8_SPEC_PREFIX = '<S id="a">\nBad café '; +const BAD_UTF8_SPEC_SOURCE = withInvalidUtf8Byte( + BAD_UTF8_SPEC_PREFIX, + "byte.\n</S>\n", +); +const BAD_UTF8_SPEC_OFFSET = Buffer.byteLength(BAD_UTF8_SPEC_PREFIX, "utf8"); +const BAD_UTF8_CODE_PREFIX = "export const a = 1; // café "; +const BAD_UTF8_CODE_SOURCE = withInvalidUtf8Byte( + BAD_UTF8_CODE_PREFIX, + "byte\n", +); +const BAD_UTF8_CODE_OFFSET = Buffer.byteLength(BAD_UTF8_CODE_PREFIX, "utf8"); + +// A byte-order mark's finding locates offset 0 (SPEC 14; T14-11), whatever +// the mark's own byte length (three bytes in UTF-8). +const BOM_OFFSET = 0; + // A UTF-8 byte-order mark followed by otherwise-valid content: the fixture // isolates the BOM rule from the invalid-UTF-8 rule (SPEC 1.6: a source that // begins with a BOM is unparseable, 14.20). U+FEFF encodes to EF BB BF; the // workspace builder writes string contents with BOMs kept (S-2). const BOM = "\u{FEFF}"; -const VALID_SECTION_SOURCE = '<S id="ok">\nValid content.\n</S>\n'; +// The code arm's two code sources, 14.20's declared-unparseable encodings: +// that workspace is created after the spec arm's invocation, so each is a +// staged-source record carrying its declaration (S-9's timing clause). +const BAD_UTF8_CODE_RECORD = stagedTs( + "T1.6-5 code arm src/bad-utf8.ts", + BAD_UTF8_CODE_SOURCE, + "unparseable", +); +const BOM_CODE_RECORD = stagedTs( + "T1.6-5 code arm src/bom.ts", + BOM + "export const b = 2;\n", + "unparseable", +); + +// The code arm's spec source: that workspace is created after the spec +// arm's invocation, so a staged-source record (S-9's timing clause). +const VALID_SECTION_SOURCE = stagedMdx( + "T1.6-5 code arm specs/OK.mdx", + '<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 — it carries condition 14.20, and it locates exactly + * one zero-length range in the file at `offset`: the byte length of the + * longest well-formed UTF-8 prefix for an encoding failure, 0 for a + * byte-order mark (SPEC 14's offset rule for an unparseable source; T14-11). + * A range at the byte where a decoder notices the failure, a code-point + * count, a non-empty range, or a finding carrying the file as a path instead + * of locating in it each fails here. */ function assertUnparseableFinding( findings: readonly Finding[], file: string, + offset: number, 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,56 +1073,76 @@ function assertUnparseableFinding( JSON.stringify( findings.map((finding) => ({ condition: finding.condition, - file: finding.file ?? null, + locations: finding.locations, + path: finding.path, })), ), ); } - if (matching[0]!.condition !== "14.20") { + const finding = matching[0]!; + if (finding.condition !== "14.20") { fail( `${context}: the finding for ${JSON.stringify(file)} must carry condition 14.20 ` + `(unparseable source: invalid UTF-8 or leading BOM, SPEC 1.6); got ` + - `${JSON.stringify(matching[0]!.condition)} (message: ${JSON.stringify(matching[0]!.message)})`, + `${JSON.stringify(finding.condition)} (message: ${JSON.stringify(finding.message)})`, ); } + assertSameJson( + finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + [{ file, range: { start: offset, end: offset } }], + `${context}: the 14.20 finding for ${JSON.stringify(file)} locates exactly ` + + `one zero-length range at byte offset ${String(offset)} — the byte ` + + `length of the longest well-formed UTF-8 prefix for an encoding failure ` + + `(the first byte of the first ill-formed sequence), 0 for a byte-order ` + + `mark (SPEC 14, 1.7; T14-11), \`{"start", "end"}\` as zero-based byte ` + + `offsets, end-exclusive (message: ${JSON.stringify(finding.message)})`, + ); } const T1_6_5 = defineProductTest({ id: "T1.6-5", title: - "a spec or code source that is invalid UTF-8, or begins with a BOM, fails `build` with 14.20 (SPEC 1.6, 14.20)", + "a spec or code source that is invalid UTF-8, or begins with a BOM, fails `build` with 14.20 at the failure's offset (SPEC 1.6, 14.20, 14)", run: async (product) => { - // Spec-source arms: one invalid-UTF-8 file, one BOM file. + // Spec-source arms: one invalid-UTF-8 file, one BOM file, each finding at + // the offset SPEC 14 fixes, precomputed from the staged bytes (T14-11). const specArm = await TestWorkspace.create({ files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/bad-utf8.mdx": withInvalidUtf8Byte( - '<S id="a">\nBad ', - " byte.\n</S>\n", - ), + "specs/bad-utf8.mdx": BAD_UTF8_SPEC_SOURCE, "specs/bom.mdx": BOM + '<S id="b">\nBom content.\n</S>\n', }, + // S-9: both spec sources are forms 14.20 declares unparseable. + mdx: { unparseable: ["specs/bad-utf8.mdx", "specs/bom.mdx"] }, }); try { const context = "T1.6-5 `build --json` over the two unparseable spec sources"; const findings = await buildFindings(product, specArm, context); assertConditionCounts(findings, { "14.20": 2 }, context); - assertUnparseableFinding(findings, "specs/bad-utf8.mdx", context); - assertUnparseableFinding(findings, "specs/bom.mdx", context); + assertUnparseableFinding( + findings, + "specs/bad-utf8.mdx", + BAD_UTF8_SPEC_OFFSET, + context, + ); + assertUnparseableFinding(findings, "specs/bom.mdx", BOM_OFFSET, context); } finally { await specArm.dispose(); } // Code-source arms: 14.20 covers discovered code sources too (SPEC 1.6 // names spec and code sources alike); the spec source beside them is - // valid, so the two conditions are the code files'. + // valid, so the two conditions are the code files', each at its offset. const codeArm = await TestWorkspace.create({ files: { "xspec.config.ts": SPEC_AND_CODE_CONFIG, "specs/OK.mdx": VALID_SECTION_SOURCE, - "src/bad-utf8.ts": withInvalidUtf8Byte("export const a = 1; // ", "\n"), - "src/bom.ts": BOM + "export const b = 2;\n", + "src/bad-utf8.ts": BAD_UTF8_CODE_RECORD, + "src/bom.ts": BOM_CODE_RECORD, }, }); try { @@ -1033,8 +1150,13 @@ const T1_6_5 = defineProductTest({ "T1.6-5 `build --json` over the two unparseable code sources"; const findings = await buildFindings(product, codeArm, context); assertConditionCounts(findings, { "14.20": 2 }, context); - assertUnparseableFinding(findings, "src/bad-utf8.ts", context); - assertUnparseableFinding(findings, "src/bom.ts", context); + assertUnparseableFinding( + findings, + "src/bad-utf8.ts", + BAD_UTF8_CODE_OFFSET, + context, + ); + assertUnparseableFinding(findings, "src/bom.ts", BOM_OFFSET, context); } finally { await codeArm.dispose(); } @@ -1086,10 +1208,63 @@ 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. +// The endpoints workspace is created after the range workspace's +// invocations, so its spec source is a staged-source record (S-9). +const ENDPOINT_SPEC_SOURCE = stagedMdx( + "T1.7-1 endpoints specs/E.mdx", + [ + '<S id="alpha" d={"omega"}>', + "Alpha text.", + "</S>", + "", + '<S id="omega">', + "Omega text.", + "</S>", + "", + ].join("\n"), +); + +const ENDPOINT_CODE_SOURCE = stagedTs( + "T1.7-1 endpoints src/app.ts", + [ + '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 +1330,1058 @@ 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 one +// sanctioned TypeScript reference (`deco.ts` two, one per decorated unit; +// `using.ts` two, one per `using` and `await using` unit of T4.6-1) — a +// dependency marker (4.5) or a `text(...)` call (4.3) — inside one +// named-code-unit shape of SPEC 4.6 (TEST-SPEC T1.7-2's further forms +// included: the constructor, a decorated class and member, the export +// exclusion, the `default` unit's spellings, a legacy `module`, and a +// declaration file), 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"; +const OCC_A_ID = "specs/R.mdx#a"; +const OCC_B_ID = "specs/R.mdx#b"; + +// The referenced spec source: `req` and `alt`, so the marker target and the +// `text(...)` target are distinct nodes, plus `a` and `b`, the targets the +// `using` and `await using` arms spell exactly as TEST-SPEC T1.7-2 does +// (`SPEC.a`, `SPEC.b`). +const OCC_TARGET_SOURCE = [ + '<S id="req">', + "Req text.", + "</S>", + "", + '<S id="alt">', + "Alt text.", + "</S>", + "", + '<S id="a">', + "A text.", + "</S>", + "", + '<S id="b">', + "B 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 +// `}` — the declaration spells no terminator, so the range ends at that +// brace (SPEC 1.7, 4.6; contrast `arrow.ts`). +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/arrow.ts — a default export of an ANONYMOUS arrow function spelled +// with a statement terminator: unit `default` spans the WHOLE export +// declaration, `export` through the `;` the declaration spells (SPEC 1.7: +// "`export default () => {};` spans through its `;`"). +const OCC_ARROW_HEAD = + '// prélude 🦄 arrow\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_ARROW_DECL_PRE = "export default () => {\n "; +const OCC_ARROW_DECL = OCC_ARROW_DECL_PRE + OCC_MARKER + ";\n};"; +const OCC_ARROW_SOURCE = OCC_ARROW_HEAD + OCC_ARROW_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/ctor.ts — a constructor: a class member unit named `constructor` +// (`Ctor.constructor`, SPEC 4.6) whose range spans the constructor member +// itself, `constructor` through its closing `}` — not the class. +const OCC_CTOR_HEAD = + '// prélude 🦄 ctor\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_CTOR_CLASS_PRE = "class Ctor {\n "; +const OCC_CTOR_MEMBER_PRE = "constructor() {\n "; +const OCC_CTOR_MEMBER = OCC_CTOR_MEMBER_PRE + OCC_MARKER + ";\n }"; +const OCC_CTOR_SOURCE = + OCC_CTOR_HEAD + OCC_CTOR_CLASS_PRE + OCC_CTOR_MEMBER + "\n}\n"; + +// The decorator every decorated staging applies: an ambient declaration, +// which binds no unit of its own (SPEC 4.6) and sources no occurrence. +const OCC_DEC_DECL = "declare function dec(...args: unknown[]): void;\n\n"; + +// src/decdef.ts — `@dec export default class {}`: the anonymous exported +// construct derives unit `default`, whose range is the whole export +// declaration INCLUDING the decorator list preceding its `export` — from +// the `@` through the closing `}` (SPEC 1.7). The class's range is pinned +// through the `text(...)` embed in its non-unit property initializer, +// attributed to the bare class (SPEC 4.6), as `cls.ts` does. +const OCC_DECDEF_HEAD = + '// prélude 🦄 decdef\nimport SPEC, { text } from "../specs/R.xspec";\n\n' + + OCC_DEC_DECL; +const OCC_DECDEF_DECL_PRE = "@dec export default class {\n greeting = "; +const OCC_DECDEF_DECL = OCC_DECDEF_DECL_PRE + OCC_CLS_CALL + ";\n}"; +const OCC_DECDEF_SOURCE = OCC_DECDEF_HEAD + OCC_DECDEF_DECL + "\n"; + +// src/decexp.ts — `@dec export class DecExp {}`: the decorator list leads, +// so nothing is excluded — the unit spans whole from its `@`, the `export` +// inside (SPEC 1.7). +const OCC_DECEXP_HEAD = + '// prélude 🦄 decexp\nimport SPEC, { text } from "../specs/R.xspec";\n\n' + + OCC_DEC_DECL; +const OCC_DECEXP_DECL_PRE = "@dec export class DecExp {\n greeting = "; +const OCC_DECEXP_DECL = OCC_DECEXP_DECL_PRE + OCC_CLS_CALL + ";\n}"; +const OCC_DECEXP_SOURCE = OCC_DECEXP_HEAD + OCC_DECEXP_DECL + "\n"; + +// src/deco.ts — a decorated class holding a decorated member, each under +// TWO decorators: the class unit `Deco` spans from the class's FIRST `@` +// through its closing `}`, and the member unit `Deco.m` from the member's +// first `@` through the method's closing `}` (SPEC 1.7: a decorator list +// is part of the class declaration or member it decorates, whose own +// characters begin at its first decorator). The class's range is pinned +// through the `text(...)` embed in its non-unit property initializer +// (attributed to the bare class, SPEC 4.6), the member's through the +// marker in its body. +const OCC_DECO_HEAD = + '// prélude 🦄 deco\nimport SPEC, { text } from "../specs/R.xspec";\n\n' + + OCC_DEC_DECL; +const OCC_DECO_CLASS_PRE = "@dec @dec class Deco {\n greeting = "; +const OCC_DECO_CLASS_MID = ";\n "; +const OCC_DECO_MEMBER_PRE = "@dec\n @dec\n m() {\n "; +const OCC_DECO_MEMBER = OCC_DECO_MEMBER_PRE + OCC_MARKER + ";\n }"; +const OCC_DECO_CLASS = + OCC_DECO_CLASS_PRE + + OCC_CLS_CALL + + OCC_DECO_CLASS_MID + + OCC_DECO_MEMBER + + "\n}"; +const OCC_DECO_SOURCE = OCC_DECO_HEAD + OCC_DECO_CLASS + "\n"; + +// src/expdec.ts — `export @dec class ExpDec {}`: only what LEADS is +// excluded, so the unit spans `@dec class ExpDec {…}` — the `export ` +// prefix out, the decorator in (SPEC 1.7). +const OCC_EXPDEC_HEAD = + '// prélude 🦄 expdec\nimport SPEC, { text } from "../specs/R.xspec";\n\n' + + OCC_DEC_DECL; +const OCC_EXPDEC_EXPORT_PRE = "export "; +const OCC_EXPDEC_CONSTRUCT_PRE = "@dec class ExpDec {\n greeting = "; +const OCC_EXPDEC_CONSTRUCT = OCC_EXPDEC_CONSTRUCT_PRE + OCC_CLS_CALL + ";\n}"; +const OCC_EXPDEC_SOURCE = + OCC_EXPDEC_HEAD + OCC_EXPDEC_EXPORT_PRE + OCC_EXPDEC_CONSTRUCT + "\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/mod.ts — a legacy-keyword dotted namespace (`module Legacy.Inner`): +// the same declaration as `namespace Legacy.Inner` (SPEC 4.6), so its +// nested units share the SINGLE declaration's range, pinned as `ns.ts` +// pins `Outer.Inner` — through the reachable unit `Legacy.Inner`, from +// `module` through the closing `}` (SPEC 1.7). +const OCC_MOD_HEAD = + '// prélude 🦄 mod\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_MOD_DECL_PRE = "module Legacy.Inner {\n "; +const OCC_MOD_DECL = OCC_MOD_DECL_PRE + OCC_MARKER + ";\n}"; +const OCC_MOD_SOURCE = OCC_MOD_HEAD + OCC_MOD_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/spaced.ts — `export function spaced() {}` (three spaces): the +// leading `export` and whatever separates it from the construct's first +// token are excluded, so the unit spans from `function` (SPEC 1.7). +const OCC_SPACED_HEAD = + '// prélude 🦄 spaced\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_SPACED_EXPORT_PRE = "export "; +const OCC_SPACED_CONSTRUCT_PRE = "function spaced() {\n "; +const OCC_SPACED_CONSTRUCT = OCC_SPACED_CONSTRUCT_PRE + OCC_MARKER + ";\n}"; +const OCC_SPACED_SOURCE = + OCC_SPACED_HEAD + OCC_SPACED_EXPORT_PRE + OCC_SPACED_CONSTRUCT + "\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"; + +// src/types.d.ts — a declaration file, ambient by kind (SPEC 4.6: `.d.` in +// its last path segment), matched by the code group's `src/**/*.ts` glob and +// well-formed (14.20: TypeScript's ambient-context checks are post-parse): +// its body-bearing `function f` binds no unit (T4.6-3), so the marker +// inside it attributes to the whole file, and the source is the whole-file +// location — identity the path alone, range the entire file (SPEC 1.7). +const OCC_DTS_HEAD = + '// prélude 🦄 decl\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_DTS_FN_PRE = "function f(): void {\n "; +const OCC_DTS_SOURCE = OCC_DTS_HEAD + OCC_DTS_FN_PRE + OCC_MARKER + ";\n}\n"; + +// src/using.ts — the `using` and `await using` units of T4.6-1, spelled as +// TEST-SPEC T1.7-2 spells them: SPEC 4.6 counts both as variable +// declarations, so `using f = () => { SPEC.a }` at top level derives unit +// `f`, and `await using h = () => { SPEC.b }` inside the async function `g` +// derives `g.h`. Each unit spans its own name through its initializer — +// `f = () => { SPEC.a }` and `h = () => { SPEC.b }` — neither from `using` +// nor from `await` (SPEC 1.7), and no terminator is spelled, so each range +// ends at the arrow body's closing brace. The file is accepted by TypeScript +// 5.9.3 both as module code and as script code (14.20), as the workspace +// builder's staging-time judge confirms (S-9). +const OCC_USING_HEAD = + '// prélude 🦄 using\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_USING_KEYWORD = "using "; +const OCC_USING_UNIT_PRE = "f = () => { "; +const OCC_USING_MARKER = "SPEC.a"; +const OCC_USING_UNIT = OCC_USING_UNIT_PRE + OCC_USING_MARKER + " }"; +const OCC_AWAIT_USING_PRE = "\n\nasync function g() {\n await using "; +const OCC_AWAIT_USING_UNIT_PRE = "h = () => { "; +const OCC_AWAIT_USING_MARKER = "SPEC.b"; +const OCC_AWAIT_USING_UNIT = + OCC_AWAIT_USING_UNIT_PRE + OCC_AWAIT_USING_MARKER + " }"; +const OCC_USING_BEFORE_AWAIT = + OCC_USING_HEAD + OCC_USING_KEYWORD + OCC_USING_UNIT + OCC_AWAIT_USING_PRE; +const OCC_USING_SOURCE = + OCC_USING_BEFORE_AWAIT + OCC_AWAIT_USING_UNIT + "\n}\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 < arrow < cls < ctor < decdef < decexp +// < deco < expdec < fn < mod < multi < named < ns < pair < spaced < top < +// types.d.ts < using — then by range start, which places `deco.ts`'s +// property embed before the marker in its decorated member, and `using.ts`'s +// `f` before `g.h`): the spec source stages no `d` prop, no MDX embedding, +// and no import, and import declarations record no occurrence (5.7), so the +// twenty-one staged references are the workspace's only occurrences. +const OCC_EXPECTED: readonly OccurrenceArm[] = [ + { + what: + "anonymous default export spelling no terminator — unit `default` " + + "carries the WHOLE export declaration's range, ending at the closing " + + "brace (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: + "anonymous default arrow function spelled with a terminator — unit " + + "`default` spans the WHOLE export declaration through its `;` (SPEC " + + "1.7)", + fileSource: OCC_ARROW_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_ARROW_DECL, + record: { + file: "src/arrow.ts", + range: rangeAfter(OCC_ARROW_HEAD + OCC_ARROW_DECL_PRE, OCC_MARKER), + kind: "references", + source: { + identity: "src/arrow.ts#default", + range: rangeAfter(OCC_ARROW_HEAD, OCC_ARROW_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: + "constructor — the member unit `Ctor.constructor` (SPEC 4.6) spans " + + "the constructor member, `constructor` through its closing `}`, not " + + "the class (SPEC 1.7)", + fileSource: OCC_CTOR_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_CTOR_MEMBER, + record: { + file: "src/ctor.ts", + range: rangeAfter( + OCC_CTOR_HEAD + OCC_CTOR_CLASS_PRE + OCC_CTOR_MEMBER_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/ctor.ts#Ctor.constructor", + range: rangeAfter(OCC_CTOR_HEAD + OCC_CTOR_CLASS_PRE, OCC_CTOR_MEMBER), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "`@dec export default class {}` — unit `default` spans the whole " + + "export declaration from its `@`, the decorator list preceding " + + "`export` included (SPEC 1.7)", + fileSource: OCC_DECDEF_SOURCE, + occurrenceSpan: OCC_CLS_CALL, + unitSpan: OCC_DECDEF_DECL, + record: { + file: "src/decdef.ts", + range: rangeAfter(OCC_DECDEF_HEAD + OCC_DECDEF_DECL_PRE, OCC_CLS_CALL), + kind: "embeds", + source: { + identity: "src/decdef.ts#default", + range: rangeAfter(OCC_DECDEF_HEAD, OCC_DECDEF_DECL), + }, + target: OCC_ALT_ID, + }, + }, + { + what: + "`@dec export class DecExp {}` — spans whole from its `@`, the " + + "`export` inside (SPEC 1.7)", + fileSource: OCC_DECEXP_SOURCE, + occurrenceSpan: OCC_CLS_CALL, + unitSpan: OCC_DECEXP_DECL, + record: { + file: "src/decexp.ts", + range: rangeAfter(OCC_DECEXP_HEAD + OCC_DECEXP_DECL_PRE, OCC_CLS_CALL), + kind: "embeds", + source: { + identity: "src/decexp.ts#DecExp", + range: rangeAfter(OCC_DECEXP_HEAD, OCC_DECEXP_DECL), + }, + target: OCC_ALT_ID, + }, + }, + { + what: + "decorated class under two decorators — unit `Deco` spans from the " + + "class's FIRST `@` through its closing `}` (SPEC 1.7, 4.6)", + fileSource: OCC_DECO_SOURCE, + occurrenceSpan: OCC_CLS_CALL, + unitSpan: OCC_DECO_CLASS, + record: { + file: "src/deco.ts", + range: rangeAfter(OCC_DECO_HEAD + OCC_DECO_CLASS_PRE, OCC_CLS_CALL), + kind: "embeds", + source: { + identity: "src/deco.ts#Deco", + range: rangeAfter(OCC_DECO_HEAD, OCC_DECO_CLASS), + }, + target: OCC_ALT_ID, + }, + }, + { + what: + "decorated member under two decorators — unit `Deco.m` spans from " + + "the member's FIRST `@` through the method's closing `}` (SPEC 1.7, " + + "4.6)", + fileSource: OCC_DECO_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_DECO_MEMBER, + record: { + file: "src/deco.ts", + range: rangeAfter( + OCC_DECO_HEAD + + OCC_DECO_CLASS_PRE + + OCC_CLS_CALL + + OCC_DECO_CLASS_MID + + OCC_DECO_MEMBER_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/deco.ts#Deco.m", + range: rangeAfter( + OCC_DECO_HEAD + + OCC_DECO_CLASS_PRE + + OCC_CLS_CALL + + OCC_DECO_CLASS_MID, + OCC_DECO_MEMBER, + ), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "`export @dec class ExpDec {}` — only what leads is excluded: the " + + "unit spans `@dec class ExpDec {…}`, the `export ` prefix out and " + + "the decorator in (SPEC 1.7)", + fileSource: OCC_EXPDEC_SOURCE, + occurrenceSpan: OCC_CLS_CALL, + unitSpan: OCC_EXPDEC_CONSTRUCT, + record: { + file: "src/expdec.ts", + range: rangeAfter( + OCC_EXPDEC_HEAD + OCC_EXPDEC_EXPORT_PRE + OCC_EXPDEC_CONSTRUCT_PRE, + OCC_CLS_CALL, + ), + kind: "embeds", + source: { + identity: "src/expdec.ts#ExpDec", + range: rangeAfter( + OCC_EXPDEC_HEAD + OCC_EXPDEC_EXPORT_PRE, + OCC_EXPDEC_CONSTRUCT, + ), + }, + 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: + "legacy `module Legacy.Inner` — the same declaration as a dotted " + + "`namespace`: its nested units share the SINGLE declaration's range, " + + "pinned through the reachable unit `Legacy.Inner` (SPEC 1.7, 4.6)", + fileSource: OCC_MOD_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_MOD_DECL, + record: { + file: "src/mod.ts", + range: rangeAfter(OCC_MOD_HEAD + OCC_MOD_DECL_PRE, OCC_MARKER), + kind: "references", + source: { + identity: "src/mod.ts#Legacy.Inner", + range: rangeAfter(OCC_MOD_HEAD, OCC_MOD_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: + "`export function spaced() {}` (several spaces) — the leading " + + "`export` and whatever separates it from the construct are excluded: " + + "the unit spans from `function` (SPEC 1.7)", + fileSource: OCC_SPACED_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_SPACED_CONSTRUCT, + record: { + file: "src/spaced.ts", + range: rangeAfter( + OCC_SPACED_HEAD + OCC_SPACED_EXPORT_PRE + OCC_SPACED_CONSTRUCT_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/spaced.ts#spaced", + range: rangeAfter( + OCC_SPACED_HEAD + OCC_SPACED_EXPORT_PRE, + OCC_SPACED_CONSTRUCT, + ), + }, + 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, + }, + }, + { + what: + "marker inside a body-bearing function of a declaration file " + + "(`src/types.d.ts`, ambient by kind) — `f` is no unit (SPEC 4.6; " + + "T4.6-3), so the source is the whole-file location: identity the " + + "path alone, range the entire file (SPEC 1.7)", + fileSource: OCC_DTS_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_DTS_SOURCE, + record: { + file: "src/types.d.ts", + range: rangeAfter(OCC_DTS_HEAD + OCC_DTS_FN_PRE, OCC_MARKER), + kind: "references", + source: { + identity: "src/types.d.ts", + range: { start: 0, end: utf8Length(OCC_DTS_SOURCE) }, + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "`using f = () => { SPEC.a }` at top level — a variable declaration " + + "(SPEC 4.6), so unit `f` spans its own name through its initializer, " + + "`f = () => { SPEC.a }`, never from `using` (SPEC 1.7)", + fileSource: OCC_USING_SOURCE, + occurrenceSpan: OCC_USING_MARKER, + unitSpan: OCC_USING_UNIT, + record: { + file: "src/using.ts", + range: rangeAfter( + OCC_USING_HEAD + OCC_USING_KEYWORD + OCC_USING_UNIT_PRE, + OCC_USING_MARKER, + ), + kind: "references", + source: { + identity: "src/using.ts#f", + range: rangeAfter(OCC_USING_HEAD + OCC_USING_KEYWORD, OCC_USING_UNIT), + }, + target: OCC_A_ID, + }, + }, + { + what: + "`await using h = () => { SPEC.b }` inside the async function `g` — a " + + "variable declaration (SPEC 4.6), so unit `g.h` spans its own name " + + "through its initializer, `h = () => { SPEC.b }`, neither from " + + "`await` nor from `using` (SPEC 1.7)", + fileSource: OCC_USING_SOURCE, + occurrenceSpan: OCC_AWAIT_USING_MARKER, + unitSpan: OCC_AWAIT_USING_UNIT, + record: { + file: "src/using.ts", + range: rangeAfter( + OCC_USING_BEFORE_AWAIT + OCC_AWAIT_USING_UNIT_PRE, + OCC_AWAIT_USING_MARKER, + ), + kind: "references", + source: { + identity: "src/using.ts#g.h", + range: rangeAfter(OCC_USING_BEFORE_AWAIT, OCC_AWAIT_USING_UNIT), + }, + target: OCC_B_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, and for the `using` and `await using` units (`using f = () => { SPEC.a }` spans `f = () => { SPEC.a }`, `await using h = () => { SPEC.b }` in an async `g` spans `h = () => { SPEC.b }`, neither from `using` nor from `await`); 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; the second occurrence's construct for `path#unit@2`; and the further forms — the constructor member, a decorated class and member from their first `@`, the export exclusion (`export @dec class`, `@dec export class`, `export function`), the `default` unit through a spelled `;` or from a leading `@`, a legacy `module A.B`, and a declaration file's whole-file range (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/arrow.ts": OCC_ARROW_SOURCE, + "src/cls.ts": OCC_CLS_SOURCE, + "src/ctor.ts": OCC_CTOR_SOURCE, + "src/decdef.ts": OCC_DECDEF_SOURCE, + "src/decexp.ts": OCC_DECEXP_SOURCE, + "src/deco.ts": OCC_DECO_SOURCE, + "src/expdec.ts": OCC_EXPDEC_SOURCE, + "src/fn.ts": OCC_FN_SOURCE, + "src/mod.ts": OCC_MOD_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/spaced.ts": OCC_SPACED_SOURCE, + "src/top.ts": OCC_TOP_SOURCE, + "src/types.d.ts": OCC_DTS_SOURCE, + "src/using.ts": OCC_USING_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. Every + // record is compared before the verdict, so one run diagnoses each + // deviating form at once rather than only the first in file order. + const mismatches: string[] = []; + OCC_EXPECTED.forEach((arm, index) => { + try { + 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)`, + ); + } catch (error) { + if (!(error instanceof HarnessAssertionError)) throw error; + mismatches.push(error.message); + } + }); + if (mismatches.length > 0) { + fail( + `${context}: ${String(mismatches.length)} of ` + + `${String(OCC_EXPECTED.length)} occurrence records differ from ` + + `the pinned ones:\n` + + mismatches.join("\n"), + ); + } + } finally { + await workspace.dispose(); + } }, }); @@ -1166,4 +2393,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 c583c390..bcea4c28 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-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), @@ -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,24 +56,68 @@ // 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. +// +// T10.1-6 (session-directory and area occupancy; `create`'s ordering): +// - Non-directory occupants are staged at `.xspec/reviews` and at `.xspec` +// as a plain file and, separately, as a symbolic link to a real directory +// holding product-written content (a valid session `s.json`; a journal, +// graph data, and a valid session) — the staging the product must not +// list, read, or write through (SPEC 10.1, 13.4). The link's target lives +// inside the workspace root, outside the area, so one whole-root snapshot +// compare covers the occupant, the link, and its target at once (H-4: +// "byte-unchanged", "byte-identical", "nothing written through it"). +// Symbolic links are never followed by the snapshot (helpers/snapshot.ts), +// so a product writing through the link changes the target's entries. +// - "graph data has been refreshed" is observed the way SPEC.md defines it +// (13.3, 14.10): `check` afterwards reports no graph-data unit form — +// graph-data content is opaque (H-4), so its bytes are never compared. +// The per-file staleness `check` reports is the edited source's: every +// 14.10 finding concerns a derived path of `specs/A.mdx` — `specs/A.xspec.` +// plus a suffix, the module and its companions (SPEC 13.1) — and the +// module itself is among them, since it embeds the edited text (SPEC 4.2). +// - `review status s` against the occupied session directory is an unknown +// session — a plain usage error, so the exit-2 error document's `code` and +// `path` are `null` (SPEC 12.0, 12.7). +// - The concerned path of the code-less existing-name refusal and of the +// condition-21 finding reported in its place is pinned nowhere (SPEC 10.7, +// 14.21), so only their identity, count, and empty locations are asserted. +// +// Sources staged after a body's first product invocation — the stale arm's +// and stale twins' edited `specs/A.mdx` (T10.1-1, T10.1-6) and T10.1-5's +// invalidating `specs/B.mdx` — are staged-source records +// (helpers/staged-mdx.ts, S-9: judged before any product exists); the other +// stagings here are session files and occupants, which S-9 does not judge, +// and initial workspace files. +import * as fsp from "node:fs/promises"; +import type { + Finding, + SessionStatusRow, +} from "../../helpers/adapters/index.js"; import { + GRAPH_DATA_AREA_PATH, assertReportMentions, decodeFindingsReport, + decodeIdsReport, + decodeInventoryDocument, decodeSessionListReport, decodeSessionStatusReport, + decodeViewReport, stageBlockedByAbsentItem, stageBlockedByCycle, stageDeleteItemField, stageDuplicateItemEntry, stageGarbleCreationParameters, + stageGarbleDecompositions, stageUnknownItemStatus, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -75,31 +128,53 @@ import { diffSnapshots, snapshotDirectory, } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { + assertConditionCounts, + assertFindingConcernsPath, + assertFindingLocated, assertSameJson, buildOk, + expectErrorDocument, expectExit, + expectFindingFreeReport, runCli, + runFindingsReport, runJson, } from "./support.js"; -// Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): exactly one spec group. Every +// `CORE_FILES` workspace stages it, and T10.1-1's determinism twins, T10.1-4's +// per-state workspaces, and T10.1-6's occupancy twins follow their bodies' +// first invocations, so it is one TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), well-formed, +// staged wherever this configuration is (T10.1-5's workspace too). +const SPECS_ONLY_CONFIG = stagedTs( + "T10.1-1/T10.1-4/T10.1-6 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // The same spec group plus one coverage profile (SPEC 7.4) for the // coverage-session arm of T10.1-4: `main`'s one leaf has no incoming // dependency edge, so the profile leaves it uncovered and `create --coverage` -// derives at least one item while recording the profile definition. -const COVERAGE_CONFIG = `import { defineConfig } from "xspec" +// derives at least one item while recording the profile definition. That +// workspace follows the body's first invocations, so the configuration is a +// TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript and +// timing clauses), well-formed. +const COVERAGE_CONFIG = stagedTs( + "T10.1-4 xspec.config.ts — one spec group and the coverage profile p", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -114,7 +189,8 @@ export default defineConfig({ } ] }) -`; +`, +); const A_MDX = [ '<S id="a">', @@ -126,18 +202,36 @@ const A_MDX = [ "", ].join("\n"); -// The staleness edit for T10.1-1: same structure, different leaf text — the -// graph data written by the earlier `build` no longer matches the sources. -const A_MDX_EDITED = A_MDX.replace("Kid text.", "Kid text, edited."); +// The staleness edit for T10.1-1's stale arm and T10.1-6's stale twins: same +// structure, different leaf text — the graph data written by the earlier +// `build` no longer matches the sources. Every staging of it follows the +// body's `build`, so it is one staged-source record shared by both tests +// (helpers/staged-mdx.ts; S-9's before-any-product clause) — the same +// expression, moved into the record. +const A_MDX_EDITED = stagedMdx( + "T10.1-1/T10.1-6 specs/A.mdx with a.k's text edited (the staleness edit)", + A_MDX.replace("Kid text.", "Kid text, edited."), +); -const CORE_FILES: Readonly<Record<string, string>> = { +// The initial `specs/A.mdx` of every `CORE_FILES` / `COVERAGE_FILES` +// workspace and of T10.1-5's: one staged-source record made from `A_MDX` +// (the string stays for the staleness edit above), named with every +// staging test — T10.1-1's determinism twins, T10.1-4's per-state +// workspaces, and T10.1-6's occupancy twins follow their bodies' first +// invocations (helpers/staged-mdx.ts; S-9's before-any-product clause). +const A_MDX_STAGED = stagedMdx( + "T10.1-1/T10.1-2/T10.1-3/T10.1-4/T10.1-5/T10.1-6 specs/A.mdx", + A_MDX, +); + +const CORE_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": A_MDX, + "specs/A.mdx": A_MDX_STAGED, }; -const COVERAGE_FILES: Readonly<Record<string, string>> = { +const COVERAGE_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": COVERAGE_CONFIG, - "specs/A.mdx": A_MDX, + "specs/A.mdx": A_MDX_STAGED, }; const REVIEWS_DIR = ".xspec/reviews"; @@ -149,7 +243,7 @@ function sessionRel(name: string): string { /** Stage a fresh workspace, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ files }); @@ -412,9 +506,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 +564,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 +664,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 +784,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 +853,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 +899,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 +1041,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 +1080,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 +1254,974 @@ 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"); +// The invalidating edit is staged after the body's `build` and `review +// create`, so it is a staged-source record (helpers/staged-mdx.ts; S-9's +// before-any-product clause): well-formed MDX — the file derives; only 14.1 +// fails it. +const T10_1_5_B_INVALID = stagedMdx( + "T10.1-5 specs/B.mdx with the section's id removed (derives; fails 14.1)", + ["<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_STAGED, + "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)", + ); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// T10.1-6 — session-directory and area occupancy; `create`'s ordering +// --------------------------------------------------------------------------- + +// The non-directory occupant staged at `.xspec/reviews` and at `.xspec` +// (SPEC 14.22's plain-file kind; content arbitrary — the occupant is never +// read). +const T10_1_6_OCCUPANT = "not a directory\n"; + +// The derived paths of `specs/A.mdx`: its module and companions are +// `specs/A.xspec.` plus a suffix (SPEC 13.1); the module itself embeds the +// node text (SPEC 4.2), so a text edit makes it stale for certain. +const T10_1_6_A_DERIVED_PREFIX = "specs/A.xspec."; +const T10_1_6_A_MODULE = "specs/A.xspec.ts"; + +/** The product-written session every staging starts from (`s`). */ +const T10_1_6_SESSION = "s"; +/** The name `create` is refused for on every occupied session directory. */ +const T10_1_6_NEW_SESSION = "n"; + +const T10_1_6_CREATE_S: readonly string[] = [ + "review", + "create", + "--strategy", + "audit", + "--name", + T10_1_6_SESSION, +]; + +/** How a path comes to hold a non-directory (SPEC 13.4). */ +type T1016Occupant = + | { readonly kind: "plain-file" } + | { + readonly kind: "symlink"; + /** Where the relocated product-written directory goes (root-relative). */ + readonly targetRel: string; + /** The link's stored target, spelled relative to the link's directory. */ + readonly linkTarget: string; + }; + +const T10_1_6_PLAIN_FILE: T1016Occupant = { kind: "plain-file" }; +// `.xspec/reviews` → `../elsewhere-reviews`: a real directory inside the +// workspace root, outside the area, holding the product-written `s.json`. +const T10_1_6_REVIEWS_LINK: T1016Occupant = { + kind: "symlink", + targetRel: "elsewhere-reviews", + linkTarget: "../elsewhere-reviews", +}; +// `.xspec` → `elsewhere-area`: the relocated area — journal, graph data, and +// the valid session — beside the link. +const T10_1_6_AREA_LINK: T1016Occupant = { + kind: "symlink", + targetRel: "elsewhere-area", + linkTarget: "elsewhere-area", +}; + +/** + * Stage a non-directory occupant at `rel` (SPEC 13.4): `plain-file` + * replaces whatever the path holds with a plain file; `symlink` relocates + * the directory the path holds to `targetRel` and leaves a symbolic link + * spelled `linkTarget` in its place, so the link's target holds exactly what + * the product wrote there. The staging is verified on the harness's own + * process before any product runs: a wrong occupant kind is machinery + * misuse, thrown as a plain `Error` — never a diagnosed failure (H-11). + */ +async function stageNonDirectoryOccupant( + workspace: TestWorkspace, + rel: string, + occupant: T1016Occupant, +): Promise<void> { + if (occupant.kind === "plain-file") { + await fsp.rm(workspace.path(rel), { recursive: true, force: true }); + await workspace.file(rel, T10_1_6_OCCUPANT); + } else { + const held = await workspace.kind(rel); + if (held !== "dir") { + throw new Error( + `T10.1-6 staging: ${rel} must hold the product-written directory ` + + `to relocate to ${occupant.targetRel}; found ${held}`, + ); + } + await fsp.rename(workspace.path(rel), workspace.path(occupant.targetRel)); + await workspace.symlink(rel, occupant.linkTarget, "dir"); + } + const expected = occupant.kind === "plain-file" ? "file" : "symlink"; + const staged = await workspace.kind(rel); + if (staged !== expected) { + throw new Error( + `T10.1-6 staging: expected ${rel} to hold a ${expected} once staged; ` + + `found ${staged} (a harness staging error, not a product observation)`, + ); + } +} + +/** + * Exactly one finding, of the given counting identity — a condition token of + * 14, or `(code-less)` for a refusal carrying no stable code (10.7) — with + * no in-source location (locations [], SPEC 12.7) and, where SPEC pins one, + * the concerned path. + */ +function assertExactlyOneFinding( + findings: readonly Finding[], + identity: string, + concernedPath: string | null, + context: string, +): Finding { + assertConditionCounts(findings, { [identity]: 1 }, context); + const finding = findings[0]!; + if (concernedPath !== null) { + assertFindingConcernsPath(finding, concernedPath, context); + } + assertSameJson( + finding.locations, + [], + `${context}: the finding has no in-source location — locations [] ` + + `(SPEC 12.7)`, + ); + return finding; +} + +/** + * `check`'s report once a refused `create` has refreshed graph data on the + * edited-source twin (SPEC 13.5, 13.3): every condition-10 finding is per + * file, concerning a derived path of `specs/A.mdx` — `specs/A.xspec.` plus + * a suffix (SPEC 13.1) — with the module itself among them (it embeds the + * edited text, SPEC 4.2), and none is the graph-data unit form, whose + * concerned path would be `.xspec` (SPEC 14.10). Beside the staleness, + * exactly `beside` (counted by identity) and nothing else. + */ +function assertPerFileStalenessOfA( + findings: readonly Finding[], + beside: Readonly<Record<string, number>>, + context: string, +): void { + const stale = findings.filter((finding) => finding.condition === "14.10"); + assertConditionCounts( + findings.filter((finding) => finding.condition !== "14.10"), + beside, + `${context} — beside the per-file staleness, exactly the expected ` + + `findings and nothing else (SPEC 14, 12.2)`, + ); + if (stale.length === 0) { + fail( + `${context}: the edited source's generated module no longer matches ` + + `what the current sources generate — its documentation comment ` + + `embeds the edited text (SPEC 4.2) — so \`check\` reports per-file ` + + `staleness (SPEC 14.10, 12.2); got no condition-10 finding`, + ); + } + for (const finding of stale) { + if ( + typeof finding.path !== "string" || + !finding.path.startsWith(T10_1_6_A_DERIVED_PREFIX) + ) { + fail( + `${context}: graph data was refreshed before the refusal (SPEC ` + + `13.5, 13.3), so no graph-data unit form is reported and every ` + + `condition-10 finding is per file, concerning a derived path of ` + + `specs/A.mdx — ${T10_1_6_A_DERIVED_PREFIX}* (SPEC 13.1, 14.10); ` + + `got a condition-10 finding concerning ` + + `${JSON.stringify(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + assertSameJson( + finding.locations, + [], + `${context}: a per-file staleness finding names its path and has no ` + + `in-source location — locations [] (SPEC 14.10, 12.7)`, + ); + } + if (!stale.some((finding) => finding.path === T10_1_6_A_MODULE)) { + fail( + `${context}: the module ${T10_1_6_A_MODULE} itself is stale — it ` + + `embeds the edited text (SPEC 4.2, 13.1) — so a condition-10 ` + + `finding concerns it (SPEC 14.10); got ` + + JSON.stringify(stale.map((finding) => finding.path)), + ); + } +} + +/** + * The session directory holds sessions only while a directory occupies its + * path (SPEC 10.1, 13.4): on a freshly built valid workspace whose + * `.xspec/reviews` holds a non-directory, no command lists through the + * occupant — `review list` reports no sessions, `review status s` is an + * unknown session, `inventory` reports `sessions` [] — `check` is clean and + * the gate carries nothing (14.22 is `create`'s finding there, never + * `check`'s or the gate's), `ids` answers, and `create` refuses its own + * obstructed write path with one condition-22 finding concerning + * `.xspec/reviews`. One whole-root compare around the sweep (the workspace + * is fresh, so no 13.3 refresh legitimately intervenes): nothing written — + * the occupant byte-unchanged, the link and its target byte-identical. + */ +async function assertSessionDirectoryOccupied( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<void> { + await assertLeavesUnchanged( + workspace.root, + async () => { + const listContext = + `${label} \`review list --json\` — no command lists through the ` + + `occupant: no sessions, exit 0 (SPEC 10.1, 13.4, 10.7)`; + const list = decodeSessionListReport( + await runJson( + product, + workspace, + ["review", "list", "--json"], + listContext, + ), + listContext, + ); + assertSameJson(list.sessions, [], `${listContext}: sessions`); + + const statusContext = + `${label} \`review status s --json\` — \`s\` names no session ` + + `through the occupant: exit 2, unknown session (SPEC 10.1, 12.0)`; + const status = await expectExit( + product, + workspace, + ["review", "status", T10_1_6_SESSION, "--json"], + 2, + statusContext, + ); + const error = expectErrorDocument(status, statusContext); + assertSameJson( + { code: error.code, path: error.path }, + { code: null, path: null }, + `${statusContext} — a plain usage error's document carries code ` + + `and path null (SPEC 12.7)`, + ); + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${label} \`check --json\` — the occupied session directory is ` + + `\`create\`'s condition-22 finding, never \`check\`'s or the ` + + `gate's, and it holds no session to find corrupt (SPEC 14.22, ` + + `12.2, 10.1)`, + ); + + const inventoryContext = + `${label} \`inventory --json\` — sessions [] while the session ` + + `directory holds no directory, and no finding (SPEC 11.6, 13.4)`; + const inventory = decodeInventoryDocument( + await runJson( + product, + workspace, + ["inventory", "--json"], + inventoryContext, + ), + inventoryContext, + ); + assertSameJson(inventory.findings, [], `${inventoryContext}: findings`); + assertSameJson(inventory.sessions, [], `${inventoryContext}: sessions`); + + const idsContext = + `${label} \`ids --json\` — the gate carries no finding, so \`ids\` ` + + `answers, exit 0 (SPEC 13.3, 14.22)`; + decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsContext), + idsContext, + ); + + const createContext = + `${label} \`review create --strategy audit --name n --json\` — the ` + + `occupant obstructs the session write: exit 1, exactly one ` + + `condition-22 finding concerning .xspec/reviews, locations [] ` + + `(SPEC 10.1, 14.22)`; + assertExactlyOneFinding( + await runFindingsReport( + product, + workspace, + [ + "review", + "create", + "--strategy", + "audit", + "--name", + T10_1_6_NEW_SESSION, + "--json", + ], + 1, + createContext, + ), + "14.22", + REVIEWS_DIR, + createContext, + ); + }, + `${label}: nothing is written — the occupant byte-unchanged, the link ` + + `and its target byte-identical, nothing written through it, and no ` + + `session file anywhere (SPEC 10.1, 13.4, 14.22)`, + ); +} + +/** The one refusal finding a stale twin's `create` reports. */ +interface ExpectedRefusal { + /** A condition token of 14, or `(code-less)` for the 10.7 refusal. */ + readonly identity: string; + /** The concerned path where SPEC pins one, else null (left unasserted). */ + readonly concernedPath: string | null; +} + +/** + * `create`'s ordering on a stale twin (SPEC 13.5, 14.22): a section's text + * is edited after `build`, then `create --name <name>` is refused — the one + * finding of `expected` (condition 22, the code-less existing-name refusal, + * or condition 21 in its place), exit 1. The refusal follows the gate and + * refresh of 13.3, so graph data has been refreshed: the compare around + * `create` confines every change to `.xspec/` outside `.xspec/reviews/` + * (the refresh's opaque writes, H-4 — nothing outside the area, the + * occupant and every session file byte-unchanged, no session file + * written), and `check` afterwards reports the edited source's per-file + * staleness beside exactly `besideStaleness` and no graph-data unit form — + * where a product examining the session directory before refreshing leaves + * graph data stale (the unit form then reported). + */ +async function assertCreateFollowsRefresh( + product: ProductBinding, + workspace: TestWorkspace, + name: string, + expected: ExpectedRefusal, + besideStaleness: Readonly<Record<string, number>>, + label: string, +): Promise<void> { + await workspace.file("specs/A.mdx", A_MDX_EDITED); + const argv = [ + "review", + "create", + "--strategy", + "audit", + "--name", + name, + "--json", + ]; + const createContext = + `${label} \`${argv.join(" ")}\` on the stale twin — exit 1 with the ` + + `one refusal finding, judged after the gate and refresh (SPEC 13.5, ` + + `14.22, 10.7, 14.21)`; + const before = await snapshotDirectory(workspace.root); + const findings = await runFindingsReport( + product, + workspace, + argv, + 1, + createContext, + ); + const after = await snapshotDirectory(workspace.root); + assertExactlyOneFinding( + findings, + expected.identity, + expected.concernedPath, + createContext, + ); + for (const change of diffSnapshots(before, after)) { + const underArea = + change.key === GRAPH_DATA_AREA_PATH || + change.key.startsWith(`${GRAPH_DATA_AREA_PATH}/`); + const underReviews = + change.key === REVIEWS_DIR || change.key.startsWith(`${REVIEWS_DIR}/`); + if (!underArea || underReviews) { + fail( + `${label}: the refused \`create\` on a stale twin writes nothing ` + + `but the 13.3 refresh, confined to .xspec/ outside ` + + `.xspec/reviews/ — no session file written or changed, the ` + + `occupant byte-unchanged, nothing outside the area touched ` + + `(SPEC 13.5, 13.3, 14.22); found ${change.change} ` + + `${change.path}: ${change.detail}`, + ); + } + } + const checkContext = `${label} \`check --json\` after the refused \`create\``; + assertPerFileStalenessOfA( + await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — the edited source's generated module is stale, ` + + `so \`check\` exits 1 (SPEC 12.2, 14.10)`, + ), + besideStaleness, + checkContext, + ); +} + +/** + * The graph-data area's own path occupied by a non-directory (SPEC 13.4, + * 14.22, 14.23): `inventory` meets condition 23 in its record-supplied + * datum — `recorded` explicitly unavailable, `journal.occupied` false and + * `sessions` [] (nothing is read below the occupant), that one finding + * concerning `.xspec`, exit 1 (11.6); `build`, `ids`, and `review list` + * each refuse on `build`'s obstructed graph-data write path — exactly one + * condition-22 finding concerning `.xspec`, the gate's report for the reads + * (13.3), exit 1; `check` reports that finding beside condition 10 in the + * unreadable-record unit form and nothing else (14.10, 14.23: reported + * whatever the workspace's validity); and `view` of a clean file answers + * finding-free (11.2, T11.2-6). One whole-root compare around the sweep: + * nothing written, the link's target byte-identical. + */ +async function assertAreaOccupied( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<void> { + await assertLeavesUnchanged( + workspace.root, + async () => { + const inventoryContext = + `${label} \`inventory --json\` — the area's own path holds no ` + + `directory: recorded unavailable with the one condition-23 ` + + `finding, exit 1 (SPEC 11.6, 14.23, 13.4)`; + const inventoryRun = await expectExit( + product, + workspace, + ["inventory", "--json"], + 1, + inventoryContext, + ); + const inventory = decodeInventoryDocument( + parseJsonStdout(inventoryRun, inventoryContext), + inventoryContext, + ); + assertExactlyOneFinding( + inventory.findings, + "14.23", + GRAPH_DATA_AREA_PATH, + `${inventoryContext} — that finding alone, concerning the area`, + ); + assertSameJson( + inventory.recorded, + { state: "unavailable" }, + `${inventoryContext} — the record-supplied datum is reported ` + + `explicitly unavailable, never read as an empty record (SPEC ` + + `14.23, 11.6)`, + ); + assertSameJson( + inventory.journal.occupied, + false, + `${inventoryContext} — the journal is unoccupied to the inventory ` + + `below an area path holding no directory (SPEC 11.6, 13.4)`, + ); + assertSameJson( + inventory.sessions, + [], + `${inventoryContext} — no session while the area's own path holds ` + + `no directory (SPEC 11.6, 10.1)`, + ); + + for (const argv of [["build"], ["ids"], ["review", "list"]] as const) { + const context = + `${label} \`${argv.join(" ")} --json\` — a directory component ` + + `of graph data's write path is occupied by a non-directory: ` + + `exactly one condition-22 finding concerning .xspec, exit 1, ` + + `nothing written (SPEC 14.22, 13.3, 13.4)`; + assertExactlyOneFinding( + await runFindingsReport( + product, + workspace, + [...argv, "--json"], + 1, + context, + ), + "14.22", + GRAPH_DATA_AREA_PATH, + context, + ); + } + + const checkContext = + `${label} \`check --json\` — the obstructed graph-data write path ` + + `beside condition 10 in the unreadable-record unit form, and ` + + `nothing else (SPEC 14.22, 14.10, 14.23, 12.2)`; + const checkFindings = await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + checkContext, + ); + assertConditionCounts( + checkFindings, + { "14.22": 1, "14.10": 1 }, + checkContext, + ); + for (const finding of checkFindings) { + assertFindingConcernsPath( + finding, + GRAPH_DATA_AREA_PATH, + finding.condition === "14.22" + ? `${checkContext}: the offending component's ` + + `workspace-relative path (SPEC 14.22, 13.4)` + : `${checkContext}: the unit form's concerned path is the ` + + `graph-data area, no path inside it named (SPEC 14.10, ` + + `14.23, 11.6)`, + ); + assertSameJson( + finding.locations, + [], + `${checkContext}: a path-concerned condition is unlocated — ` + + `locations [] (SPEC 12.7)`, + ); + if (finding.condition === "14.10" && !/build/i.test(finding.message)) { + fail( + `${checkContext}: the unit form instructs rebuilding (SPEC ` + + `14.10) — any message naming \`build\` qualifies (H-3); got ` + + JSON.stringify(finding.message), + ); + } + } + + const viewContext = + `${label} \`view specs/A.mdx --json\` — \`view\` answers from the ` + + `current sources: the clean file finding-free, exit 0 (SPEC 11.2, ` + + `T11.2-6)`; + const view = decodeViewReport( + await runJson( + product, + workspace, + ["view", "specs/A.mdx", "--json"], + viewContext, + ), + { text: false }, + viewContext, + ); + assertSameJson(view.findings, [], `${viewContext}: findings`); + }, + `${label}: nothing is written — the occupant byte-unchanged, the link ` + + `and its target byte-identical, nothing written through it (SPEC ` + + `14.22, 13.4)`, + ); +} + +const T10_1_6 = defineProductTest({ + id: "T10.1-6", + title: + "session-directory and area occupancy; `create`'s ordering: `.xspec/reviews` occupied by a plain file, or by a symbolic link to a directory holding a valid session, holds no sessions (`list` none, `status s` exit 2, `check` clean, `inventory` sessions [], `ids` answers) and `create` is refused with one condition-22 finding concerning `.xspec/reviews`, nothing written; on stale twins the refused `create` — condition 22, the code-less existing-name refusal, or condition 21 in its place — follows the refresh (`check` then reports the edited source's per-file staleness and no unit form; no session written or changed); `.xspec` occupied by a plain file, or by a symbolic link to a directory holding a journal and valid sessions: `inventory` recorded unavailable with the one condition-23 finding, `build`/`ids`/`review list` one condition-22 finding concerning `.xspec`, `check` that finding beside the unreadable-record unit form, `view` finding-free (10.1, 10.7, 11.6, 13.3–13.5, 14.10, 14.21–14.23)", + timeoutMs: 360_000, + run: async (product) => { + // --- Session-directory occupancy on a freshly built valid workspace -- + // Plain file: no session has been created, so `.xspec/reviews` is + // absent after `build` (SPEC 10.1) and the plain file takes its path. + await withWorkspace(CORE_FILES, async (workspace) => { + const label = "T10.1-6 [.xspec/reviews: plain file]"; + await buildOk(product, workspace, `${label} \`build\``); + await stageNonDirectoryOccupant( + workspace, + REVIEWS_DIR, + T10_1_6_PLAIN_FILE, + ); + await assertSessionDirectoryOccupied(product, workspace, label); + }); + // Symbolic link: the product writes `s` first; its session directory + // is then relocated outside the area and linked from its path, so the + // link's target holds the valid product-written `s.json`. + await withWorkspace(CORE_FILES, async (workspace) => { + const label = + "T10.1-6 [.xspec/reviews: symbolic link to a directory holding s.json]"; + await buildOk(product, workspace, `${label} \`build\``); + await expectExit( + product, + workspace, + T10_1_6_CREATE_S, + 0, + `${label} \`review create --strategy audit --name s\` — the valid ` + + `session the link's target then holds (SPEC 10.1)`, + ); + await stageNonDirectoryOccupant( + workspace, + REVIEWS_DIR, + T10_1_6_REVIEWS_LINK, + ); + await assertSessionDirectoryOccupied(product, workspace, label); + }); + + // --- Ordering: `create` examines the session directory only past the + // gate and refresh of 13.3 (SPEC 13.5) — three stale twins. + await withWorkspace(CORE_FILES, async (workspace) => { + const label = "T10.1-6 [stale twin, .xspec/reviews: plain file]"; + await buildOk(product, workspace, `${label} \`build\``); + await stageNonDirectoryOccupant( + workspace, + REVIEWS_DIR, + T10_1_6_PLAIN_FILE, + ); + await assertCreateFollowsRefresh( + product, + workspace, + T10_1_6_NEW_SESSION, + { identity: "14.22", concernedPath: REVIEWS_DIR }, + {}, + label, + ); + }); + await withWorkspace(CORE_FILES, async (workspace) => { + const label = "T10.1-6 [stale twin holding a valid s]"; + await buildOk(product, workspace, `${label} \`build\``); + await expectExit( + product, + workspace, + T10_1_6_CREATE_S, + 0, + `${label} \`review create --strategy audit --name s\` (SPEC 10.1)`, + ); + await assertCreateFollowsRefresh( + product, + workspace, + T10_1_6_SESSION, + { identity: "(code-less)", concernedPath: null }, + {}, + label, + ); + }); + await withWorkspace(CORE_FILES, async (workspace) => { + const label = "T10.1-6 [stale twin holding a corrupt s]"; + await buildOk(product, workspace, `${label} \`build\``); + await expectExit( + product, + workspace, + T10_1_6_CREATE_S, + 0, + `${label} \`review create --strategy audit --name s\` (SPEC 10.1)`, + ); + // Shape-independent corruption (module header): unparseable bytes + // over the product-written session (SPEC 14.21). + await workspace.file(sessionRel(T10_1_6_SESSION), NON_SESSION_GARBAGE); + await assertCreateFollowsRefresh( + product, + workspace, + T10_1_6_SESSION, + { identity: "14.21", concernedPath: null }, + { "14.21": 1 }, + label, + ); + }); + + // --- The graph-data area's own path (SPEC 13.4, 14.22, 14.23) -------- + // Built first: the derived files exist and match, so `check` meets no + // per-file staleness beside the two findings the staging pins. + await withWorkspace(CORE_FILES, async (workspace) => { + const label = "T10.1-6 [.xspec: plain file]"; + await buildOk(product, workspace, `${label} \`build\``); + await stageNonDirectoryOccupant( + workspace, + GRAPH_DATA_AREA_PATH, + T10_1_6_PLAIN_FILE, + ); + await assertAreaOccupied(product, workspace, label); + }); + // Symbolic link: a journaled rename brings the journal into existence + // (SPEC 6.1) and regenerates; `create` writes the valid session; the + // area is then relocated and linked from its path. + await withWorkspace(CORE_FILES, async (workspace) => { + const label = + "T10.1-6 [.xspec: symbolic link to a directory holding a journal and s.json]"; + await buildOk(product, workspace, `${label} \`build\``); + await expectExit( + product, + workspace, + ["rename", "specs/A.mdx", "a.k", "a.k2"], + 0, + `${label} \`rename specs/A.mdx a.k a.k2\` — the journal the link's ` + + `target then holds (SPEC 6.1, 6.4)`, + ); + await expectExit( + product, + workspace, + T10_1_6_CREATE_S, + 0, + `${label} \`review create --strategy audit --name s\` — the valid ` + + `session the link's target then holds (SPEC 10.1)`, + ); + await stageNonDirectoryOccupant( + workspace, + GRAPH_DATA_AREA_PATH, + T10_1_6_AREA_LINK, + ); + await assertAreaOccupied(product, workspace, label); + }); + }, +}); + /** 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, + T10_1_6, ]; diff --git a/test/suite/registry/section-10.2-10.3.ts b/test/suite/registry/section-10.2-10.3.ts index a150cb9c..c963a024 100644 --- a/test/suite/registry/section-10.2-10.3.ts +++ b/test/suite/registry/section-10.2-10.3.ts @@ -48,6 +48,13 @@ // - Fixture hash captures run right after an explicit `build` at each staged // moment, so no read relies on the 13.3 refresh path except where the // TEST-SPEC text stages it (T10.2-3 reads directly after the deletion). +// +// Sources staged after a body's first product invocation — every edit of a +// built workspace's spec source in T10.2-2, T10.2-4, T10.3-1, and T10.3-2 — +// are staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists), the same template calls moved to module level; T10.2-1's +// edit precedes its first `build` (staged between `gitCommitAll` and the +// build), so S-7's sweep reaches it against the stub and it stays plain. import * as fsp from "node:fs/promises"; import type { @@ -66,28 +73,49 @@ 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; +import type { InitialFileContents } from "../../helpers/workspace.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" +// T10.2-2's audit arm stages it in a workspace created after the body's +// first invocations, so it is one TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), well-formed, +// staged wherever this configuration is. +const SPECS_ONLY_CONFIG = stagedTs( + "T10.2-2 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus a direct coverage profile over it (SPEC 7.4) — the // coverage-session fixtures: a leaf with no incoming dependency edge is // uncovered and yields an `uncovered-requirement` item (SPEC 10.7). -const COVERAGE_CONFIG = `import { defineConfig } from "xspec" +// T10.2-2's coverage arm stages it in a workspace created after the body's +// first invocations, so it is a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), well-formed. +const COVERAGE_CONFIG = stagedTs( + "T10.2-2 xspec.config.ts — one spec group and the coverage profile p", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -102,7 +130,8 @@ export default defineConfig({ } ] }) -`; +`, +); // Spec group, code group (SPEC 7.2), and the coverage profile together — the // T10.2-1 fixture needs a `code-impact` item (a code location, SPEC 9.2) and @@ -129,8 +158,8 @@ export default defineConfig({ /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -243,20 +272,44 @@ function requireItem( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; +export function canonicalJson(value: unknown): string { + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -276,14 +329,25 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; @@ -813,6 +877,37 @@ function t2CovSpec(text: string): string { return ['<S id="u">', text, "</S>", ""].join("\n"); } +// T10.2-2's edits of built workspaces — the `--base` arm's v1 and v2 kid +// texts, the audit arm's e1, the coverage arm's e1 leaf — are staged-source +// records (helpers/staged-mdx.ts; S-9's before-any-product clause), the same +// template calls moved to module level; so are the audit and coverage arms' +// initial files (e0), staged after the `--base` arm's invocations. The +// `--base` arm's own v0 entry, the body's first workspace, stays plain. +const T10_2_2_KID_V1 = stagedMdx( + "T10.2-2 specs/A.mdx with the kid text at v1 (--base arm)", + t2Spec("Kid text v1."), +); +const T10_2_2_KID_V2 = stagedMdx( + "T10.2-2 specs/A.mdx with the kid text at v2 (--base arm)", + t2Spec("Kid text v2."), +); +const T10_2_2_KID_E0 = stagedMdx( + "T10.2-2 specs/A.mdx with the kid text at e0 (audit arm)", + t2Spec("Kid text e0."), +); +const T10_2_2_KID_E1 = stagedMdx( + "T10.2-2 specs/A.mdx with the kid text at e1 (audit arm)", + t2Spec("Kid text e1."), +); +const T10_2_2_UNCOVERED_E0 = stagedMdx( + "T10.2-2 specs/U.mdx with the uncovered leaf at e0 (coverage arm)", + t2CovSpec("Uncovered leaf e0."), +); +const T10_2_2_UNCOVERED_E1 = stagedMdx( + "T10.2-2 specs/U.mdx with the uncovered leaf at e1 (coverage arm)", + t2CovSpec("Uncovered leaf e1."), +); + /** * Premises and expectations shared by the three T10.2-2 arms: `entry` is the * moment whose values `baseline` must hold; `others` are the later moments @@ -869,7 +964,7 @@ const T10_2_2 = defineProductTest({ "T10.2-2 --base arm, baseline moment (v0)", ); - await workspace.file(T2_SPEC, t2Spec("Kid text v1.")); + await workspace.file(T2_SPEC, T10_2_2_KID_V1); await buildOk(product, workspace, "T10.2-2 --base arm `build` at v1"); const atCreate = await queryNode( product, @@ -901,7 +996,7 @@ const T10_2_2 = defineProductTest({ ); // Further edit after the item entered the session. - await workspace.file(T2_SPEC, t2Spec("Kid text v2.")); + await workspace.file(T2_SPEC, T10_2_2_KID_V2); await buildOk(product, workspace, "T10.2-2 --base arm `build` at v2"); const afterEdit = await queryNode( product, @@ -960,7 +1055,7 @@ const T10_2_2 = defineProductTest({ // --- audit arm: values of the current graph at item entry --- await withWorkspace( SPECS_ONLY_CONFIG, - { [T2_SPEC]: t2Spec("Kid text e0.") }, + { [T2_SPEC]: T10_2_2_KID_E0 }, async (workspace) => { await buildOk(product, workspace, "T10.2-2 audit arm `build` at e0"); const atEntry = await queryNode( @@ -990,7 +1085,7 @@ const T10_2_2 = defineProductTest({ "T10.2-2 audit arm, first read", ); - await workspace.file(T2_SPEC, t2Spec("Kid text e1.")); + await workspace.file(T2_SPEC, T10_2_2_KID_E1); await buildOk(product, workspace, "T10.2-2 audit arm `build` at e1"); const afterEdit = await queryNode( product, @@ -1033,7 +1128,7 @@ const T10_2_2 = defineProductTest({ // --- coverage arm: values of the current graph at item entry --- await withWorkspace( COVERAGE_CONFIG, - { [T2_COV_SPEC]: t2CovSpec("Uncovered leaf e0.") }, + { [T2_COV_SPEC]: T10_2_2_UNCOVERED_E0 }, async (workspace) => { await buildOk(product, workspace, "T10.2-2 coverage arm `build` at e0"); const atEntry = await queryNode( @@ -1063,7 +1158,7 @@ const T10_2_2 = defineProductTest({ "T10.2-2 coverage arm, first read", ); - await workspace.file(T2_COV_SPEC, t2CovSpec("Uncovered leaf e1.")); + await workspace.file(T2_COV_SPEC, T10_2_2_UNCOVERED_E1); await buildOk(product, workspace, "T10.2-2 coverage arm `build` at e1"); const afterEdit = await queryNode( product, @@ -1296,6 +1391,23 @@ function t4SpecWithoutK(controlText: string): string { ].join("\n"); } +// T10.2-4's edits of the built workspace — both leaves at v2, then v3, then +// the a.k section deleted — are staged-source records +// (helpers/staged-mdx.ts; S-9's before-any-product clause), the same template +// calls moved to module level. +const T10_2_4_V2 = stagedMdx( + "T10.2-4 specs/A.mdx with both leaves at v2", + t4Spec("Resolved kid v2.", "Control kid v2."), +); +const T10_2_4_V3 = stagedMdx( + "T10.2-4 specs/A.mdx with both leaves at v3", + t4Spec("Resolved kid v3.", "Control kid v3."), +); +const T10_2_4_WITHOUT_K_V3 = stagedMdx( + "T10.2-4 specs/A.mdx with a.k deleted and the control kid at v3", + t4SpecWithoutK("Control kid v3."), +); + const T10_2_4 = defineProductTest({ id: "T10.2-4", title: @@ -1331,10 +1443,7 @@ const T10_2_4 = defineProductTest({ ).id; // Edit both leaves: live relevant hashes become R2 / Rn2. - await workspace.file( - T4_SPEC, - t4Spec("Resolved kid v2.", "Control kid v2."), - ); + await workspace.file(T4_SPEC, T10_2_4_V2); await buildOk(product, workspace, "T10.2-4 `build` at v2"); const r2 = await queryNode(product, workspace, T4_AK, "T10.2-4 R2"); const rn2 = await queryNode(product, workspace, T4_AN, "T10.2-4 Rn2"); @@ -1351,10 +1460,7 @@ const T10_2_4 = defineProductTest({ ); // Further edit: live values become R3 / Rn3. - await workspace.file( - T4_SPEC, - t4Spec("Resolved kid v3.", "Control kid v3."), - ); + await workspace.file(T4_SPEC, T10_2_4_V3); await buildOk(product, workspace, "T10.2-4 `build` at v3"); const r3 = await queryNode(product, workspace, T4_AK, "T10.2-4 R3"); const rn3 = await queryNode(product, workspace, T4_AN, "T10.2-4 Rn3"); @@ -1539,7 +1645,7 @@ const T10_2_4 = defineProductTest({ const recordedAtResolve = canonicalJson(resolvedRead.current); // Delete the scope node (the a.k section construct). - await workspace.file(T4_SPEC, t4SpecWithoutK("Control kid v3.")); + await workspace.file(T4_SPEC, T10_2_4_WITHOUT_K_V3); await buildOk(product, workspace, "T10.2-4 `build` after the deletion"); const afterLoss = await showItem( product, @@ -1608,10 +1714,18 @@ function t5Spec(xText: string): string { ].join("\n"); } +// T10.3-1's edit of the built workspace (x's text at v2) — a staged-source +// record (helpers/staged-mdx.ts; S-9's before-any-product clause), the same +// template call moved to module level. +const T10_3_1_X_V2 = stagedMdx( + "T10.3-1 specs/A.mdx with x's text at v2", + t5Spec("Ex text v2."), +); + 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 +1766,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 +1790,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`, @@ -1796,7 +1912,7 @@ const T10_3_1 = defineProductTest({ // Invalidated items need review: edit x — its item (and the root's, // whose scope includes x) become invalidated; the root re-blocks // (SPEC 10.3), so `next` returns x's item. - await workspace.file(T5_SPEC, t5Spec("Ex text v2.")); + await workspace.file(T5_SPEC, T10_3_1_X_V2); await buildOk(product, workspace, "T10.3-1 `build` after the x edit"); const invalidated = await sessionStatus( product, @@ -1856,6 +1972,14 @@ function t6Spec(kidText: string): string { ].join("\n"); } +// T10.3-2's edit of the built workspace (the kid text at v2) — a +// staged-source record (helpers/staged-mdx.ts; S-9's before-any-product +// clause), the same template call moved to module level. +const T10_3_2_KID_V2 = stagedMdx( + "T10.3-2 specs/A.mdx with the kid text at v2", + t6Spec("Kid text v2."), +); + const T10_3_2 = defineProductTest({ id: "T10.3-2", title: @@ -1934,7 +2058,7 @@ const T10_3_2 = defineProductTest({ ); // Invalidate the blocker: the dependent's blocked state flips back. - await workspace.file(T6_SPEC, t6Spec("Kid text v2.")); + await workspace.file(T6_SPEC, T10_3_2_KID_V2); await buildOk(product, workspace, "T10.3-2 `build` after the kid edit"); await expectBlockedStates( { diff --git a/test/suite/registry/section-10.4.ts b/test/suite/registry/section-10.4.ts index 4c774b19..7796a388 100644 --- a/test/suite/registry/section-10.4.ts +++ b/test/suite/registry/section-10.4.ts @@ -43,6 +43,33 @@ // other tests (T6.1-1, T13.4-5, T12.0-11). // - Fixture edits are followed by an explicit `build` before any read, so no // read relies on the 13.3 refresh path (that path is T13.3-*'s business). +// +// Sources staged after a body's first product invocation — T10.4-1's +// sensitivity edits (each scenario's former `write()` closure over mutable +// version variables enumerated into its successive states, in staging order, +// every state carrying the earlier edits forward), T10.4-2's presence flips, +// T10.4-3's context-set edit, T10.4-4's post-rename deletion, T10.4-5's +// staleness edit — are staged-source records (helpers/staged-mdx.ts, S-9: +// judged before any product exists), the same template calls moved to module +// level. A staging that precedes a body's first invocation stays plain (S-7's +// sweep reaches it against the stub): T10.4-1's subtree-coherence pre-`create` +// edit and T10.4-3's a.k edit, each between `gitCommitAll` and the first +// `build`, and each body's first workspace's initial files. The initial +// `.mdx` files of every later workspace — T10.4-1's five later scenarios, +// T10.4-2's context and origin arms, T10.4-4's move and reintroduction arms +// — are records too, passed as the `files` entries (S-9's before-any-product +// clause covers a workspace created after the body's first invocation). +// T10.4-4's reintroduction appends to bytes the rename wrote — no harness +// constant equals them — so it is an anchored `workspace.edit()` (the tail's +// uniqueness and end position diagnosed first, SPEC 6.4). +// +// The configurations and the code source of those later workspaces are +// TypeScript staged-source records (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), every one well-formed and staged wherever it is +// used: `SPECS_ONLY_CONFIG` (T10.4-1's, T10.4-2's, and T10.4-4's later +// workspaces), `SPECS_CODE_CONFIG` and `CI_CODE_SOURCE` (T10.4-1's +// code-impact scenario), and `COVERAGE_CONFIG` (T10.4-1's +// uncovered-requirement scenario). import { Buffer } from "node:buffer"; import type { @@ -69,23 +96,31 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +const SPECS_ONLY_CONFIG = stagedTs( + "T10.4-1/T10.4-2/T10.4-4 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // Spec group plus a code group (SPEC 7.2) — the `code-impact` scenario needs // an impacted code location (SPEC 9.2, 10.5). -const SPECS_CODE_CONFIG = `import { defineConfig } from "xspec" +const SPECS_CODE_CONFIG = stagedTs( + "T10.4-1 code-impact xspec.config.ts — one spec group and the code group app", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -95,12 +130,15 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); // Spec group plus a direct coverage profile over it (SPEC 7.4) — the // `uncovered-requirement` scenario: an uncovered required leaf yields an // `uncovered-requirement` item in a coverage session (SPEC 10.7). -const COVERAGE_CONFIG = `import { defineConfig } from "xspec" +const COVERAGE_CONFIG = stagedTs( + "T10.4-1 uncovered-requirement xspec.config.ts — one spec group and the coverage profile p", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -115,12 +153,13 @@ export default defineConfig({ } ] }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -287,14 +326,25 @@ async function expectItemStatus( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; @@ -424,6 +474,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 +563,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, @@ -552,6 +634,51 @@ function scSpec( ].join("\n"); } +// The scenario's successive states of specs/S.mdx, in staging order — the +// former `write()` closure over mutable version variables enumerated, every +// state carrying the earlier edits forward. The pre-`create` edit (p's own +// text at v1, staged between `gitCommitAll` and the body's first `build`) +// precedes every product invocation and stays plain contents; the four arms' +// states follow it and are staged-source records (helpers/staged-mdx.ts, +// S-9: judged before any product exists), indexed by arm. +const T10_4_1_SC_PRE_CREATE = scSpec( + "Parent own v1.", + "", + "Child text v0.", + "", + "Outside v0.", +); +const T10_4_1_SC_STATES = [ + stagedMdx( + "T10.4-1 subtree-coherence specs/S.mdx after arm 1's edit: the child text at v1", + scSpec("Parent own v1.", "", "Child text v1.", "", "Outside v0."), + ), + stagedMdx( + "T10.4-1 subtree-coherence specs/S.mdx after arm 2's edit: p tagged pt", + scSpec("Parent own v1.", ' tags="pt"', "Child text v1.", "", "Outside v0."), + ), + stagedMdx( + "T10.4-1 subtree-coherence specs/S.mdx after arm 3's edit: p.c tagged ct", + scSpec( + "Parent own v1.", + ' tags="pt"', + "Child text v1.", + ' tags="ct"', + "Outside v0.", + ), + ), + stagedMdx( + "T10.4-1 subtree-coherence specs/S.mdx after arm 4's edit: the outside text at v1 (control)", + scSpec( + "Parent own v1.", + ' tags="pt"', + "Child text v1.", + ' tags="ct"', + "Outside v1.", + ), + ), +] as const; + // parent-consistency (--base; SPEC 10.4: ownHash and metadataHash of the // scope node; subtreeHash of each context node). A deep leaf edit under // a > a.k > a.k.d makes a's item's context node a.k, with the changed branch @@ -588,6 +715,43 @@ function pcSpec( ].join("\n"); } +// The scenario's initial specs/P.mdx: its workspace is created after the +// body's first `build` (the subtree-coherence scenario's), so the initial +// file is a staged-source record too (S-9's before-any-product clause), the +// same template call moved to module level. +const T10_4_1_PC_INITIAL = stagedMdx( + "T10.4-1 parent-consistency specs/P.mdx at the baseline (the scenario's initial source)", + pcSpec("Alpha own v0.", "", "Deep leaf v0.", "Other v0."), +); + +// The scenario's successive states of specs/P.mdx, in staging order (the +// former `write()` closure enumerated, each state carrying the earlier edits +// forward) — every one, the pre-`create` edit included, follows the body's +// first `build` (the subtree-coherence scenario's), so all are records: +// index 0 the pre-`create` edit, then one per arm. +const T10_4_1_PC_STATES = [ + stagedMdx( + "T10.4-1 parent-consistency specs/P.mdx after the pre-create edit: the deep leaf at v1", + pcSpec("Alpha own v0.", "", "Deep leaf v1.", "Other v0."), + ), + stagedMdx( + "T10.4-1 parent-consistency specs/P.mdx after arm 1's edit: the deep leaf at v2", + pcSpec("Alpha own v0.", "", "Deep leaf v2.", "Other v0."), + ), + stagedMdx( + "T10.4-1 parent-consistency specs/P.mdx after arm 2's edit: a tagged at", + pcSpec("Alpha own v0.", ' tags="at"', "Deep leaf v2.", "Other v0."), + ), + stagedMdx( + "T10.4-1 parent-consistency specs/P.mdx after arm 3's edit: a's own text at v1", + pcSpec("Alpha own v1.", ' tags="at"', "Deep leaf v2.", "Other v0."), + ), + stagedMdx( + "T10.4-1 parent-consistency specs/P.mdx after arm 4's edit: the sibling text at v1 (control)", + pcSpec("Alpha own v1.", ' tags="at"', "Deep leaf v2.", "Other v1."), + ), +] as const; + // dependency-consistency (--base; SPEC 10.4: ownHash and metadataHash of the // scope node; subtreeHash of each upstream target in context). dep depends on // t (whose staged own-text edit generates the item); t.c is the deep-edit @@ -624,6 +788,76 @@ function dcSpec( ].join("\n"); } +// The scenario's initial specs/D.mdx — a workspace created after the body's +// first `build`, so a record too (the same template call moved here). +const T10_4_1_DC_INITIAL = stagedMdx( + "T10.4-1 dependency-consistency specs/D.mdx at the baseline (the scenario's initial source)", + dcSpec( + "Dep own v0.", + "", + "Target own v0.", + "Target child v0.", + "Unrelated v0.", + ), +); + +// The scenario's successive states of specs/D.mdx, in staging order (the +// former `write()` closure enumerated, each state carrying the earlier edits +// forward), all after the body's first `build`: index 0 the pre-`create` +// edit, then one per arm. +const T10_4_1_DC_STATES = [ + stagedMdx( + "T10.4-1 dependency-consistency specs/D.mdx after the pre-create edit: the target's own text at v1", + dcSpec( + "Dep own v0.", + "", + "Target own v1.", + "Target child v0.", + "Unrelated v0.", + ), + ), + stagedMdx( + "T10.4-1 dependency-consistency specs/D.mdx after arm 1's edit: dep's own text at v1", + dcSpec( + "Dep own v1.", + "", + "Target own v1.", + "Target child v0.", + "Unrelated v0.", + ), + ), + stagedMdx( + "T10.4-1 dependency-consistency specs/D.mdx after arm 2's edit: dep tagged dt", + dcSpec( + "Dep own v1.", + ' tags="dt"', + "Target own v1.", + "Target child v0.", + "Unrelated v0.", + ), + ), + stagedMdx( + "T10.4-1 dependency-consistency specs/D.mdx after arm 3's edit: the target's child text at v1", + dcSpec( + "Dep own v1.", + ' tags="dt"', + "Target own v1.", + "Target child v1.", + "Unrelated v0.", + ), + ), + stagedMdx( + "T10.4-1 dependency-consistency specs/D.mdx after arm 4's edit: the unrelated text at v1 (control)", + dcSpec( + "Dep own v1.", + ' tags="dt"', + "Target own v1.", + "Target child v1.", + "Unrelated v1.", + ), + ), +] as const; + // metadata-consistency (--base; SPEC 10.4: metadataHash of the scope node // only). m's staged tag change generates the item; the control is a text // edit of m itself — subtreeHash moves, metadataHash does not. @@ -634,6 +868,31 @@ function mcSpec(mTags: string, mText: string): string { return [`<S id="m" tags="${mTags}">`, mText, "</S>", ""].join("\n"); } +// The scenario's initial specs/M.mdx — a workspace created after the body's +// first `build`, so a record too (the same template call moved here). +const T10_4_1_MC_INITIAL = stagedMdx( + "T10.4-1 metadata-consistency specs/M.mdx at the baseline (the scenario's initial source)", + mcSpec("m0", "Em text v0."), +); + +// The scenario's successive states of specs/M.mdx, in staging order (the +// former `write()` closure enumerated), all after the body's first `build`: +// index 0 the pre-`create` edit, then one per arm. +const T10_4_1_MC_STATES = [ + stagedMdx( + "T10.4-1 metadata-consistency specs/M.mdx after the pre-create edit: m tagged m1", + mcSpec("m1", "Em text v0."), + ), + stagedMdx( + "T10.4-1 metadata-consistency specs/M.mdx after arm 1's edit: m tagged m2", + mcSpec("m2", "Em text v0."), + ), + stagedMdx( + "T10.4-1 metadata-consistency specs/M.mdx after arm 2's edit: m's text at v1 (control)", + mcSpec("m2", "Em text v1."), + ), +] as const; + // code-impact (--base; SPEC 10.4: subtreeHash and effectiveHash of each node // targeted by the scoped location's impact edges). src/ref.ts references t; // t depends on up (the effectiveHash-only upstream handle); w is the control @@ -662,13 +921,44 @@ function ciSpec(tText: string, upText: string, wText: string): string { } // A whole-file code location (SPEC 4.6): the bare-reference marker sits at -// the top level, so the `references` edge runs from `src/ref.ts` itself. -const CI_CODE_SOURCE = [ - 'import C from "../specs/C.xspec";', - "", - "C.t;", - "", -].join("\n"); +// the top level, so the `references` edge runs from `src/ref.ts` itself. The +// scenario's workspace follows the body's first `build`, so the code source +// is a TypeScript staged-source record (module header), well-formed. +const CI_CODE_SOURCE = stagedTs( + "T10.4-1 code-impact src/ref.ts (a whole-file code location referencing t)", + ['import C from "../specs/C.xspec";', "", "C.t;", ""].join("\n"), +); + +// The scenario's initial specs/C.mdx — a workspace created after the body's +// first `build`, so a record too (the same template call moved here); the +// code source beside it is a TypeScript record (`CI_CODE_SOURCE`). +const T10_4_1_CI_INITIAL = stagedMdx( + "T10.4-1 code-impact specs/C.mdx at the baseline (the scenario's initial source)", + ciSpec("Target v0.", "Upstream v0.", "Watcher v0."), +); + +// The scenario's successive states of specs/C.mdx, in staging order (the +// former `write()` closure enumerated, each state carrying the earlier edits +// forward), all after the body's first `build`: index 0 the pre-`create` +// edit, then one per arm. +const T10_4_1_CI_STATES = [ + stagedMdx( + "T10.4-1 code-impact specs/C.mdx after the pre-create edit: the target text at v1", + ciSpec("Target v1.", "Upstream v0.", "Watcher v0."), + ), + stagedMdx( + "T10.4-1 code-impact specs/C.mdx after arm 1's edit: the target text at v2", + ciSpec("Target v2.", "Upstream v0.", "Watcher v0."), + ), + stagedMdx( + "T10.4-1 code-impact specs/C.mdx after arm 2's edit: the upstream text at v1", + ciSpec("Target v2.", "Upstream v1.", "Watcher v0."), + ), + stagedMdx( + "T10.4-1 code-impact specs/C.mdx after arm 3's edit: the watcher text at v1 (control)", + ciSpec("Target v2.", "Upstream v1.", "Watcher v1."), + ), +] as const; // uncovered-requirement (coverage session; SPEC 10.4: subtreeHash and // metadataHash of the scope node). Uncovered leaves u (under test) and e @@ -690,6 +980,32 @@ function urSpec(uAttrs: string, uText: string, eText: string): string { ].join("\n"); } +// The scenario's initial specs/U.mdx — a workspace created after the body's +// first `build`, so a record too (the same template call moved here). +const T10_4_1_UR_INITIAL = stagedMdx( + "T10.4-1 uncovered-requirement specs/U.mdx at the baseline (the scenario's initial source)", + urSpec("", "You leaf v0.", "Elsewhere v0."), +); + +// The scenario's successive states of specs/U.mdx, in staging order (the +// former `write()` closure enumerated, each state carrying the earlier edits +// forward), all after the body's first `build` — one per arm (a coverage +// session stages no pre-`create` edit). +const T10_4_1_UR_STATES = [ + stagedMdx( + "T10.4-1 uncovered-requirement specs/U.mdx after arm 1's edit: u's text at v1", + urSpec("", "You leaf v1.", "Elsewhere v0."), + ), + stagedMdx( + "T10.4-1 uncovered-requirement specs/U.mdx after arm 2's edit: u tagged ut", + urSpec(' tags="ut"', "You leaf v1.", "Elsewhere v0."), + ), + stagedMdx( + "T10.4-1 uncovered-requirement specs/U.mdx after arm 3's edit: the elsewhere text at v1 (control)", + urSpec(' tags="ut"', "You leaf v1.", "Elsewhere v1."), + ), +] as const; + const T10_4_1 = defineProductTest({ id: "T10.4-1", title: @@ -710,22 +1026,12 @@ const T10_4_1 = defineProductTest({ }, async (workspace) => { const prefix = "T10.4-1 subtree-coherence"; - let pOwn = "Parent own v0."; - let pAttrs = ""; - let cText = "Child text v0."; - let cAttrs = ""; - let oText = "Outside v0."; - const write = async (): Promise<void> => { - await workspace.file( - SC_FILE, - scSpec(pOwn, pAttrs, cText, cAttrs, oText), - ); - }; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - pOwn = "Parent own v1."; // p `changed` relative to the baseline - await write(); + // p `changed` relative to the baseline — staged before the body's + // first product invocation, so plain contents (S-7 reaches it). + await workspace.file(SC_FILE, T10_4_1_SC_PRE_CREATE); await buildOk(product, workspace, `${prefix} \`build\` after the edit`); await expectExit( product, @@ -762,8 +1068,7 @@ const T10_4_1 = defineProductTest({ label: "text edit inside the scope subtree (a scope node's subtreeHash)", apply: async () => { - cText = "Child text v1."; - await write(); + await workspace.file(SC_FILE, T10_4_1_SC_STATES[0]); }, invalidates: true, changed: [ @@ -778,8 +1083,7 @@ const T10_4_1 = defineProductTest({ { label: "metadata-only edit on the scope root (its metadataHash)", apply: async () => { - pAttrs = ' tags="pt"'; - await write(); + await workspace.file(SC_FILE, T10_4_1_SC_STATES[1]); }, invalidates: true, changed: [{ node: SC_P, hash: "metadataHash" }], @@ -795,8 +1099,7 @@ const T10_4_1 = defineProductTest({ "no subtreeHash and MUST still invalidate (the relevant " + "metadataHash is each scope node's)", apply: async () => { - cAttrs = ' tags="ct"'; - await write(); + await workspace.file(SC_FILE, T10_4_1_SC_STATES[2]); }, invalidates: true, changed: [{ node: SC_PC, hash: "metadataHash" }], @@ -809,8 +1112,7 @@ const T10_4_1 = defineProductTest({ { label: "control: an edit outside the scope subtree", apply: async () => { - oText = "Outside v1."; - await write(); + await workspace.file(SC_FILE, T10_4_1_SC_STATES[3]); }, invalidates: false, changed: [{ node: SC_O, hash: "subtreeHash" }], @@ -831,22 +1133,15 @@ const T10_4_1 = defineProductTest({ await withWorkspace( SPECS_ONLY_CONFIG, { - [PC_FILE]: pcSpec("Alpha own v0.", "", "Deep leaf v0.", "Other v0."), + [PC_FILE]: T10_4_1_PC_INITIAL, }, async (workspace) => { const prefix = "T10.4-1 parent-consistency"; - let aOwn = "Alpha own v0."; - let aAttrs = ""; - let dText = "Deep leaf v0."; - let oText = "Other v0."; - const write = async (): Promise<void> => { - await workspace.file(PC_FILE, pcSpec(aOwn, aAttrs, dText, oText)); - }; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - dText = "Deep leaf v1."; // a.k.d `changed` - await write(); + // a.k.d `changed` + await workspace.file(PC_FILE, T10_4_1_PC_STATES[0]); await buildOk(product, workspace, `${prefix} \`build\` after the edit`); await expectExit( product, @@ -897,8 +1192,7 @@ const T10_4_1 = defineProductTest({ label: "deep text edit under the context child (a context node's subtreeHash)", apply: async () => { - dText = "Deep leaf v2."; - await write(); + await workspace.file(PC_FILE, T10_4_1_PC_STATES[1]); }, invalidates: true, changed: [{ node: PC_AK, hash: "subtreeHash" }], @@ -914,8 +1208,7 @@ const T10_4_1 = defineProductTest({ { label: "metadata edit of the scope node (its metadataHash)", apply: async () => { - aAttrs = ' tags="at"'; - await write(); + await workspace.file(PC_FILE, T10_4_1_PC_STATES[2]); }, invalidates: true, changed: [{ node: PC_A, hash: "metadataHash" }], @@ -927,8 +1220,7 @@ const T10_4_1 = defineProductTest({ { label: "own-text edit of the scope node (its ownHash)", apply: async () => { - aOwn = "Alpha own v1."; - await write(); + await workspace.file(PC_FILE, T10_4_1_PC_STATES[3]); }, invalidates: true, changed: [{ node: PC_A, hash: "ownHash" }], @@ -940,8 +1232,7 @@ const T10_4_1 = defineProductTest({ { label: "control: an edit in a sibling subtree of the scope node", apply: async () => { - oText = "Other v1."; - await write(); + await workspace.file(PC_FILE, T10_4_1_PC_STATES[4]); }, invalidates: false, changed: [{ node: PC_O, hash: "subtreeHash" }], @@ -961,32 +1252,15 @@ const T10_4_1 = defineProductTest({ await withWorkspace( SPECS_ONLY_CONFIG, { - [DC_FILE]: dcSpec( - "Dep own v0.", - "", - "Target own v0.", - "Target child v0.", - "Unrelated v0.", - ), + [DC_FILE]: T10_4_1_DC_INITIAL, }, async (workspace) => { const prefix = "T10.4-1 dependency-consistency"; - let depOwn = "Dep own v0."; - let depAttrs = ""; - let tOwn = "Target own v0."; - let tcText = "Target child v0."; - let uText = "Unrelated v0."; - const write = async (): Promise<void> => { - await workspace.file( - DC_FILE, - dcSpec(depOwn, depAttrs, tOwn, tcText, uText), - ); - }; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - tOwn = "Target own v1."; // t `changed`: dep's target effectiveHash moves - await write(); + // t `changed`: dep's target effectiveHash moves + await workspace.file(DC_FILE, T10_4_1_DC_STATES[0]); await buildOk(product, workspace, `${prefix} \`build\` after the edit`); await expectExit( product, @@ -1031,8 +1305,7 @@ const T10_4_1 = defineProductTest({ { label: "own-text edit of the scope node (its ownHash)", apply: async () => { - depOwn = "Dep own v1."; - await write(); + await workspace.file(DC_FILE, T10_4_1_DC_STATES[1]); }, invalidates: true, changed: [{ node: DC_DEP, hash: "ownHash" }], @@ -1044,8 +1317,7 @@ const T10_4_1 = defineProductTest({ { label: "metadata edit of the scope node (its metadataHash)", apply: async () => { - depAttrs = ' tags="dt"'; - await write(); + await workspace.file(DC_FILE, T10_4_1_DC_STATES[2]); }, invalidates: true, changed: [{ node: DC_DEP, hash: "metadataHash" }], @@ -1058,8 +1330,7 @@ const T10_4_1 = defineProductTest({ label: "text edit under the upstream target in context (target subtreeHash)", apply: async () => { - tcText = "Target child v1."; - await write(); + await workspace.file(DC_FILE, T10_4_1_DC_STATES[3]); }, invalidates: true, changed: [{ node: DC_T, hash: "subtreeHash" }], @@ -1071,8 +1342,7 @@ const T10_4_1 = defineProductTest({ { label: "control: an edit to an unrelated node", apply: async () => { - uText = "Unrelated v1."; - await write(); + await workspace.file(DC_FILE, T10_4_1_DC_STATES[4]); }, invalidates: false, changed: [{ node: DC_U, hash: "subtreeHash" }], @@ -1091,19 +1361,14 @@ const T10_4_1 = defineProductTest({ // --- metadata-consistency ---------------------------------------------- await withWorkspace( SPECS_ONLY_CONFIG, - { [MC_FILE]: mcSpec("m0", "Em text v0.") }, + { [MC_FILE]: T10_4_1_MC_INITIAL }, async (workspace) => { const prefix = "T10.4-1 metadata-consistency"; - let mTags = "m0"; - let mText = "Em text v0."; - const write = async (): Promise<void> => { - await workspace.file(MC_FILE, mcSpec(mTags, mText)); - }; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - mTags = "m1"; // m `metadata-changed` - await write(); + // m `metadata-changed` + await workspace.file(MC_FILE, T10_4_1_MC_STATES[0]); await buildOk( product, workspace, @@ -1147,8 +1412,7 @@ const T10_4_1 = defineProductTest({ { label: "metadata edit of the scope node (metadataHash only)", apply: async () => { - mTags = "m2"; - await write(); + await workspace.file(MC_FILE, T10_4_1_MC_STATES[1]); }, invalidates: true, changed: [{ node: MC_M, hash: "metadataHash" }], @@ -1159,8 +1423,7 @@ const T10_4_1 = defineProductTest({ "control: a text edit of the scope node does not invalidate " + "(only the metadataHash is relevant to this kind)", apply: async () => { - mText = "Em text v1."; - await write(); + await workspace.file(MC_FILE, T10_4_1_MC_STATES[2]); }, invalidates: false, changed: [{ node: MC_M, hash: "subtreeHash" }], @@ -1176,22 +1439,16 @@ const T10_4_1 = defineProductTest({ await withWorkspace( SPECS_CODE_CONFIG, { - [CI_FILE]: ciSpec("Target v0.", "Upstream v0.", "Watcher v0."), + [CI_FILE]: T10_4_1_CI_INITIAL, [CI_CODE]: CI_CODE_SOURCE, }, async (workspace) => { const prefix = "T10.4-1 code-impact"; - let tText = "Target v0."; - let upText = "Upstream v0."; - let wText = "Watcher v0."; - const write = async (): Promise<void> => { - await workspace.file(CI_FILE, ciSpec(tText, upText, wText)); - }; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - tText = "Target v1."; // t `changed`: src/ref.ts directly impacted - await write(); + // t `changed`: src/ref.ts directly impacted + await workspace.file(CI_FILE, T10_4_1_CI_STATES[0]); await buildOk(product, workspace, `${prefix} \`build\` after the edit`); await expectExit( product, @@ -1227,8 +1484,7 @@ const T10_4_1 = defineProductTest({ { label: "text edit of an impact-edge target (target subtreeHash)", apply: async () => { - tText = "Target v2."; - await write(); + await workspace.file(CI_FILE, T10_4_1_CI_STATES[1]); }, invalidates: true, changed: [{ node: CI_T, hash: "subtreeHash" }], @@ -1239,8 +1495,7 @@ const T10_4_1 = defineProductTest({ "upstream edit changing only the target's effectiveHash " + "(its subtreeHash stays put)", apply: async () => { - upText = "Upstream v1."; - await write(); + await workspace.file(CI_FILE, T10_4_1_CI_STATES[2]); }, invalidates: true, changed: [{ node: CI_T, hash: "effectiveHash" }], @@ -1251,8 +1506,7 @@ const T10_4_1 = defineProductTest({ "control: an edit to a node that is no impact-edge target " + "and upstream of none", apply: async () => { - wText = "Watcher v1."; - await write(); + await workspace.file(CI_FILE, T10_4_1_CI_STATES[3]); }, invalidates: false, changed: [{ node: CI_W, hash: "subtreeHash" }], @@ -1270,15 +1524,9 @@ const T10_4_1 = defineProductTest({ // --- uncovered-requirement ---------------------------------------------- await withWorkspace( COVERAGE_CONFIG, - { [UR_FILE]: urSpec("", "You leaf v0.", "Elsewhere v0.") }, + { [UR_FILE]: T10_4_1_UR_INITIAL }, async (workspace) => { const prefix = "T10.4-1 uncovered-requirement"; - let uAttrs = ""; - let uText = "You leaf v0."; - let eText = "Elsewhere v0."; - const write = async (): Promise<void> => { - await workspace.file(UR_FILE, urSpec(uAttrs, uText, eText)); - }; await buildOk(product, workspace, `${prefix} \`build\``); await expectExit( @@ -1323,8 +1571,7 @@ const T10_4_1 = defineProductTest({ { label: "text edit in the scope node's subtree (its subtreeHash)", apply: async () => { - uText = "You leaf v1."; - await write(); + await workspace.file(UR_FILE, T10_4_1_UR_STATES[0]); }, invalidates: true, changed: [{ node: UR_U, hash: "subtreeHash" }], @@ -1333,8 +1580,7 @@ const T10_4_1 = defineProductTest({ { label: "metadata edit of the scope node (its metadataHash)", apply: async () => { - uAttrs = ' tags="ut"'; - await write(); + await workspace.file(UR_FILE, T10_4_1_UR_STATES[1]); }, invalidates: true, changed: [{ node: UR_U, hash: "metadataHash" }], @@ -1343,8 +1589,7 @@ const T10_4_1 = defineProductTest({ { label: "control: an edit elsewhere", apply: async () => { - eText = "Elsewhere v1."; - await write(); + await workspace.file(UR_FILE, T10_4_1_UR_STATES[2]); }, invalidates: false, changed: [{ node: UR_E, hash: "subtreeHash" }], @@ -1375,11 +1620,187 @@ 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"; + +// The origin arm's initial X: a workspace created after the body's first +// `build`, so a record too, wrapped in place. +const T2O_X_SOURCE = stagedMdx( + "T10.4-2 specs/X.mdx with x depending on T.t (the origin arm's initial 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"); +} + +// T10.4-2's presence flips — every one staged after the body's first +// `build` — are staged-source records (helpers/staged-mdx.ts, S-9: judged +// before any product exists), the same template calls moved to module level; +// the context and origin arms' initial files (below) are records too, the +// first arm's X — the body's first workspace — a plain `files` entry. +const T10_4_2_X_DELETED = stagedMdx( + "T10.4-2 specs/X.mdx with the scope node x deleted (y at v0)", + t2Spec(false, "Wye text v0."), +); +const T10_4_2_X_ABSENT_Y_EDITED = stagedMdx( + "T10.4-2 specs/X.mdx with x still absent across the unrelated y edit (y at v1)", + t2Spec(false, "Wye text v1."), +); +const T10_4_2_X_RESTORED = stagedMdx( + "T10.4-2 specs/X.mdx with x restored (y at v1)", + t2Spec(true, "Wye text v1."), +); +const T10_4_2_C_REFERENCE_REMOVED = stagedMdx( + "T10.4-2 specs/C.mdx with dd's d reference removed and tt deleted (context arm)", + t2cSpec("", false), +); +const T10_4_2_C_T_REAUTHORED = stagedMdx( + "T10.4-2 specs/C.mdx with tt re-authored, dd still bearing no d reference (context arm)", + t2cSpec("", true), +); +const T10_4_2_O_D_LIST_EDITED = stagedMdx( + "T10.4-2 specs/O.mdx with the d-list edit on d (origin arm)", + t2oDSpec(' d={"e"}'), +); +const T10_4_2_T_REFERENCE_REMOVED = stagedMdx( + "T10.4-2 specs/T.mdx with t's reference to D removed (origin arm)", + t2oTSpec(false), +); +const T10_4_2_O_D_DELETED = stagedMdx( + "T10.4-2 specs/O.mdx with d's section deleted (origin arm)", + t2oDSpec(null), +); +const T10_4_2_C_INITIAL = stagedMdx( + "T10.4-2 specs/C.mdx with dd referencing tt (the context arm's initial source)", + t2cSpec(' d={"tt"}', true), +); +const T10_4_2_T_INITIAL = stagedMdx( + "T10.4-2 specs/T.mdx with t referencing O.d (the origin arm's initial source)", + t2oTSpec(true), +); +const T10_4_2_O_INITIAL = stagedMdx( + "T10.4-2 specs/O.mdx with d beside e (the origin arm's initial source)", + t2oDSpec(""), +); + +/** 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, @@ -1424,7 +1845,7 @@ const T10_4_2 = defineProductTest({ ); // Deleting the scope node after resolve invalidates. - await workspace.file(T2_FILE, t2Spec(false, "Wye text v0.")); + await workspace.file(T2_FILE, T10_4_2_X_DELETED); await buildOk(product, workspace, "T10.4-2 `build` after deleting x"); await expectItemStatus( product, @@ -1475,7 +1896,7 @@ const T10_4_2 = defineProductTest({ // Remaining absent does not invalidate — even across an unrelated // edit that moves the graph. - await workspace.file(T2_FILE, t2Spec(false, "Wye text v1.")); + await workspace.file(T2_FILE, T10_4_2_X_ABSENT_Y_EDITED); await buildOk(product, workspace, "T10.4-2 `build` after the y edit"); await expectItemStatus( product, @@ -1490,7 +1911,7 @@ const T10_4_2 = defineProductTest({ // Restoring the node invalidates the resolution recorded against // absence. - await workspace.file(T2_FILE, t2Spec(true, "Wye text v1.")); + await workspace.file(T2_FILE, T10_4_2_X_RESTORED); await buildOk(product, workspace, "T10.4-2 `build` after restoring x"); await expectItemStatus( product, @@ -1521,6 +1942,372 @@ const T10_4_2 = defineProductTest({ } }, ); + + // --- context arm: a context node's absent-to-present flip -------------- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [T2C_FILE]: T10_4_2_C_INITIAL }, + 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, T10_4_2_C_REFERENCE_REMOVED); + 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, T10_4_2_C_T_REAUTHORED); + 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]: T10_4_2_T_INITIAL, + [T2O_D_FILE]: T10_4_2_O_INITIAL, + }, + 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, T10_4_2_O_D_LIST_EDITED); + 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, T10_4_2_T_REFERENCE_REMOVED); + await workspace.file(T2O_D_FILE, T10_4_2_O_D_DELETED); + 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`, + ); + }, + ); }, }); @@ -1550,6 +2337,15 @@ function t3Spec(kText: string, sText: string): string { ].join("\n"); } +// T10.4-3's context-set change — the a.s edit, staged after the body's first +// `build` — is a staged-source record (helpers/staged-mdx.ts, S-9: judged +// before any product exists); the a.k edit precedes that `build` (staged +// between `gitCommitAll` and it) and stays plain. +const T10_4_3_AS_EDITED = stagedMdx( + "T10.4-3 specs/A.mdx with the sibling branch a.s at v1 (the context-set change)", + t3Spec("Kay text v1.", "Ess text v1."), +); + const T10_4_3 = defineProductTest({ id: "T10.4-3", title: @@ -1634,7 +2430,7 @@ const T10_4_3 = defineProductTest({ ); // The context-set change: a new changed branch under a. - await workspace.file(T3_FILE, t3Spec("Kay text v1.", "Ess text v1.")); + await workspace.file(T3_FILE, T10_4_3_AS_EDITED); await buildOk(product, workspace, "T10.4-3 `build` after the a.s edit"); const after = await captureHashes( product, @@ -1723,17 +2519,22 @@ const T4R_SOURCE = [ "", ].join("\n"); -// The manual deletion of pp.c after the rename (SPEC 6.6: a plain edit). -const T4R_WITHOUT_CHILD = [ - '<S id="pp">', - "Parent own text.", - "</S>", - "", - '<S id="q">', - "Cue text.", - "</S>", - "", -].join("\n"); +// The manual deletion of pp.c after the rename (SPEC 6.6: a plain edit) — a +// staged-source record (helpers/staged-mdx.ts, S-9: judged before any +// product exists), staged after the arm's `build` and `rename`. +const T4R_WITHOUT_CHILD = stagedMdx( + "T10.4-4 specs/R.mdx with pp.c deleted after the rename (rename arm)", + [ + '<S id="pp">', + "Parent own text.", + "</S>", + "", + '<S id="q">', + "Cue text.", + "</S>", + "", + ].join("\n"), +); // Part 2 (file-move order flip): specs/b.mdx before specs/d.mdx in byte // order; moving d.mdx to a.mdx flips which file sorts first. @@ -1747,27 +2548,37 @@ const T4M_W = "specs/d.mdx#w"; const T4M_AN = "specs/a.mdx#n"; const T4M_AW = "specs/a.mdx#w"; -const T4M_B_SOURCE = [ - '<S id="m">', - "Emm text.", - "</S>", - "", - '<S id="r">', - "Arr text.", - "</S>", - "", -].join("\n"); - -const T4M_D_SOURCE = [ - '<S id="n">', - "Enn text.", - "</S>", - "", - '<S id="w">', - "Dub text.", - "</S>", - "", -].join("\n"); +// The move and reintroduction arms follow the rename arm's invocations, so +// their initial sources are staged-source records (S-9's before-any-product +// clause), wrapped in place; the rename arm's `T4R_SOURCE`, the body's first +// workspace, stays plain. +const T4M_B_SOURCE = stagedMdx( + "T10.4-4 move arm specs/b.mdx", + [ + '<S id="m">', + "Emm text.", + "</S>", + "", + '<S id="r">', + "Arr text.", + "</S>", + "", + ].join("\n"), +); + +const T4M_D_SOURCE = stagedMdx( + "T10.4-4 move arm specs/d.mdx", + [ + '<S id="n">', + "Enn text.", + "</S>", + "", + '<S id="w">', + "Dub text.", + "</S>", + "", + ].join("\n"), +); // Part 3 (reintroduction): top-level leaves a and s; rename a -> b, then a // new section reintroduces the identity `a` (a fresh canonical chain, 5.4). @@ -1777,16 +2588,19 @@ const T4I_A = "specs/E.mdx#a"; const T4I_B = "specs/E.mdx#b"; const T4I_S = "specs/E.mdx#s"; -const T4I_SOURCE = [ - '<S id="a">', - "Aye original text.", - "</S>", - "", - '<S id="s">', - "Ess text.", - "</S>", - "", -].join("\n"); +const T4I_SOURCE = stagedMdx( + "T10.4-4 reintroduction arm specs/E.mdx", + [ + '<S id="a">', + "Aye original text.", + "</S>", + "", + '<S id="s">', + "Ess text.", + "</S>", + "", + ].join("\n"), +); const T4I_NEW_SECTION = [ "", @@ -1796,6 +2610,38 @@ const T4I_NEW_SECTION = [ "", ].join("\n"); +// The rename-rewritten file's tail — s's closing run, which the rename's +// minimal in-place edits leave as the file's end (SPEC 6.4) — the anchor the +// reintroduction's append extends through `workspace.edit()`: the appended +// bytes follow the product's, which no harness constant equals, so the +// staging is judged at staging time, not a staged-source record. +const T4I_TAIL = ['<S id="s">', "Ess text.", "</S>", ""].join("\n"); + +/** + * Diagnose that `search` occurs exactly once in the product-rewritten + * `content` before `edit()` stages the append at it: SPEC 6.4 fixes the + * rewritten form (minimal in-place edits), so a missing or ambiguous anchor + * is a diagnosed assertion failure about the product's rewriting, never + * `edit()`'s plain refusal (H-8). + */ +function anchorOnce(content: string, search: string, context: string): void { + const first = content.indexOf(search); + if (first === -1) { + fail( + `${context}: expected the rewritten source to contain ` + + `${JSON.stringify(search)} exactly once (SPEC 6.4 fixes the rewritten ` + + `form), but it does not appear; source: ${JSON.stringify(content)}`, + ); + } + if (content.includes(search, first + search.length)) { + fail( + `${context}: the anchor ${JSON.stringify(search)} appears more than once, ` + + `so the manual edit cannot be staged unambiguously; source: ` + + JSON.stringify(content), + ); + } +} + const T10_4_4 = defineProductTest({ id: "T10.4-4", title: @@ -2254,17 +3100,27 @@ const T10_4_4 = defineProductTest({ `${prefix} \`rename specs/E.mdx a b\``, ); - // Author a new top-level leaf section `a` — appended to whatever the + // Author a new top-level leaf section `a` — appended to what the // rename left on disk, so the reintroduced identity starts a new // canonical chain after the journal entry that vacated it (SPEC 5.4). - const renamed = await workspace.readBytes(T4I_FILE); - await workspace.file( - T4I_FILE, - Buffer.concat([ - Buffer.from(renamed), - Buffer.from(T4I_NEW_SECTION, "utf8"), - ]), - ); + // The bytes are the product's, so the append is an `edit()` extending + // the file's tail — s's closing run, which the rename's minimal + // in-place edits leave as the file's end (SPEC 6.4) — its uniqueness + // and end position diagnosed first (H-8). + const renamed = Buffer.from( + await workspace.readBytes(T4I_FILE), + ).toString("utf8"); + const appendContext = `${prefix} appending the new section a after the rename-rewritten s`; + anchorOnce(renamed, T4I_TAIL, appendContext); + if (!renamed.endsWith(T4I_TAIL)) { + fail( + `${appendContext}: expected the rewritten source to end with ` + + `${JSON.stringify(T4I_TAIL)} (SPEC 6.4: minimal in-place edits ` + + `leave the file's tail in place); source: ` + + JSON.stringify(renamed), + ); + } + await workspace.edit(T4I_FILE, T4I_TAIL, T4I_TAIL + T4I_NEW_SECTION); await buildOk( product, workspace, @@ -2422,6 +3278,14 @@ function t5Spec(xText: string): string { ].join("\n"); } +// T10.4-5's staleness edit — staged after the body's first `build` — is a +// staged-source record (helpers/staged-mdx.ts, S-9: judged before any +// product exists). +const T10_4_5_X_EDITED = stagedMdx( + "T10.4-5 specs/W.mdx with the resolved scope node x at v1 (the staleness edit)", + t5Spec("Ex text v1."), +); + const T10_4_5 = defineProductTest({ id: "T10.4-5", title: @@ -2464,7 +3328,7 @@ const T10_4_5 = defineProductTest({ // The staleness: edit the resolved scope node and rebuild, so every // subsequent read computes and reports invalidation (SPEC 10.4). - await workspace.file(T5_FILE, t5Spec("Ex text v1.")); + await workspace.file(T5_FILE, T10_4_5_X_EDITED); await buildOk(product, workspace, "T10.4-5 `build` after the x edit"); /** Run one read; assert the session file's bytes did not move. */ diff --git a/test/suite/registry/section-10.5.ts b/test/suite/registry/section-10.5.ts index 1591a183..47a6e9b3 100644 --- a/test/suite/registry/section-10.5.ts +++ b/test/suite/registry/section-10.5.ts @@ -47,6 +47,24 @@ // so no read relies on the 13.3 refresh path (T13.3-*'s business); hash // premises via `query node` bracket the edits that must isolate a single // sensitivity (SPEC 5.5), as in §10.4. +// - Staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists): every `.mdx` edit a body stages after its first product +// invocation — T10.5-1's extended- and chain-fixture edits, T10.5-4's +// deletion of v.e and v.f, T10.5-5's p.b edit, r.c revert, and both +// decomposition edits, T10.5-6's par.s edit — is the same template call +// moved to module level as a record, as is the initial `.mdx` file of +// every workspace created after the body's first `build` (T10.5-1's +// extended and chain fixtures, T10.5-5's decomposition sub-fixture). A +// staging that precedes a body's first `build` (each fixture's first edit, +// between `gitCommitAll` and that `build`) stays plain, as do each body's +// first workspace's initial `files` entries (S-7's sweep reaches them +// against the stub). +// - The configuration of every workspace created after a body's first +// `build` (T10.5-1's extended and chain fixtures, T10.5-5's decomposition +// sub-fixture) is `SPECS_ONLY_CONFIG`, a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), +// well-formed, staged wherever that configuration is; `SPECS_CODE_CONFIG` +// serves only bodies' first workspaces and stays plain. import type { ExportReport, @@ -59,6 +77,7 @@ import type { } from "../../helpers/adapters/index.js"; import { decodeExportReport, + decodeImpactReport, decodeItemReport, decodeNextReport, decodeNodeReport, @@ -67,19 +86,25 @@ import { import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +const SPECS_ONLY_CONFIG = stagedTs( + "T10.5-1/T10.5-5 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // Spec group plus a code group (SPEC 7.2) — fixtures deriving `code-impact` // items need an impacted code location (SPEC 9.2, 10.5). @@ -97,8 +122,8 @@ export default defineConfig({ /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -352,7 +377,9 @@ async function captureHashes( // --------------------------------------------------------------------------- // T10.5-1 — generation: SPEC 15's worked change, plus the skipping rule, -// subtree scope, and the shared-ancestor union +// subtree scope, the shared-ancestor union, and the two-levels-deep chain +// (a > a.b > a.b.c with only a.b.c changed: a's parent-consistency context +// is exactly {a.b} — the branch head — never a.b.c; a.b's is {a.b.c}) // --------------------------------------------------------------------------- // The SPEC 15 example workspace, verbatim. @@ -443,10 +470,63 @@ function x1Spec( ].join("\n"); } +// Chain fixture (SPEC 10.5 rule 2, the two-levels-deep change): a > a.b > +// a.b.c with only a.b.c changed. Each changed branch enters an ancestor's +// parent-consistency item as one context node — the ancestor's child on +// that branch — so a's context is exactly {a.b}, never the changed node +// a.b.c two levels beneath it, while a.b's context is {a.b.c}; the origin of +// both is the branch's changed node. T10.4-1's deep-edit sensitivity +// presupposes this context node (TEST-SPEC T10.5-1). +const Y_FILE = "specs/Y.mdx"; +const Y_A = "specs/Y.mdx#a"; +const Y_AB = "specs/Y.mdx#a.b"; +const Y_ABC = "specs/Y.mdx#a.b.c"; + +function ySpec(cText: string): string { + return [ + '<S id="a">', + "Aye own text.", + "", + '<S id="a.b">', + "Abe own text.", + "", + '<S id="a.b.c">', + cText, + "</S>", + "</S>", + "</S>", + "", + ].join("\n"); +} + +// T10.5-1's extended- and chain-fixture edits follow the worked change's +// `build` (the body's first product invocation), so they are staged-source +// records (helpers/staged-mdx.ts, S-9: judged before any product exists) — +// the same template calls moved to module level; the worked change's own +// edit precedes that `build` and stays plain, as does its initial file; the +// extended and chain fixtures' initial files, staged after that `build`, +// are records too (below). +const T10_5_1_X1_EDITED = stagedMdx( + "T10.5-1 specs/X.mdx with p's own text and the three leaves at v1 (extended fixture)", + x1Spec("Pee own v1.", "Cee text v1.", "Kay text v1.", "Ess text v1."), +); +const T10_5_1_Y_EDITED = stagedMdx( + "T10.5-1 specs/Y.mdx with a.b.c at v1 (chain fixture)", + ySpec("Cee text v1."), +); +const T10_5_1_X1_INITIAL = stagedMdx( + "T10.5-1 specs/X.mdx with p's own text and the three leaves at v0 (the extended fixture's initial source)", + x1Spec("Pee own v0.", "Cee text v0.", "Kay text v0.", "Ess text v0."), +); +const T10_5_1_Y_INITIAL = stagedMdx( + "T10.5-1 specs/Y.mdx with a.b.c at v0 (the chain fixture's initial source)", + ySpec("Cee text v0."), +); + const T10_5_1 = defineProductTest({ id: "T10.5-1", title: - "path-blocks generation: SPEC 15's worked change (a leaf text edit to print.hello) yields exactly the four listed items — print.hello's subtree-coherence item (context: its ancestor chain; origin: itself), print's parent-consistency item blocked by it (context/origin: print.hello), derived.hello's dependency-consistency item (context/origin: print.hello), and a code-impact item for src/hello.ts#hello (context: derived.hello, the impact-edge target that makes it impacted; origin: print.hello) — and the extended fixture pins the skipping rule (a changed node with a changed ancestor generates no own item; the ancestor's single subtree-coherence item carries both changed nodes as origin and its scope covers the node plus all descendants, its scope text the scope root's subtree text) and the shared-ancestor union (two changed leaves under one unchanged ancestor yield one parent-consistency item whose context and origin are the union of changed branches) (SPEC 5.6, 9.2, 10.5, 15)", + "path-blocks generation: SPEC 15's worked change (a leaf text edit to print.hello) yields exactly the four listed items — print.hello's subtree-coherence item (context: its ancestor chain; origin: itself), print's parent-consistency item blocked by it (context/origin: print.hello), derived.hello's dependency-consistency item (context/origin: print.hello), and a code-impact item for src/hello.ts#hello (context: derived.hello, the impact-edge target that makes it impacted; origin: print.hello) — and the extended fixture pins the skipping rule (a changed node with a changed ancestor generates no own item; the ancestor's single subtree-coherence item carries both changed nodes as origin and its scope covers the node plus all descendants, its scope text the scope root's subtree text) and the shared-ancestor union (two changed leaves under one unchanged ancestor yield one parent-consistency item whose context and origin are the union of changed branches) and the two-levels-deep chain (a > a.b > a.b.c with only a.b.c changed: a's parent-consistency item's context is exactly {a.b} — the branch head, a's child on the changed branch, each changed branch entering as one context node — never the changed node a.b.c, while a.b's item's context is exactly {a.b.c}, the identities asserted, both items' origin the changed node) (SPEC 5.6, 9.2, 10.5, 15)", timeoutMs: 300_000, run: async (product) => { // --- SPEC 15's worked change --------------------------------------------- @@ -589,21 +669,13 @@ const T10_5_1 = defineProductTest({ await withWorkspace( SPECS_ONLY_CONFIG, { - [X1_FILE]: x1Spec( - "Pee own v0.", - "Cee text v0.", - "Kay text v0.", - "Ess text v0.", - ), + [X1_FILE]: T10_5_1_X1_INITIAL, }, async (workspace) => { const prefix = "T10.5-1 extended fixture"; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - await workspace.file( - X1_FILE, - x1Spec("Pee own v1.", "Cee text v1.", "Kay text v1.", "Ess text v1."), - ); + await workspace.file(X1_FILE, T10_5_1_X1_EDITED); await buildOk( product, workspace, @@ -712,6 +784,125 @@ const T10_5_1 = defineProductTest({ ); }, ); + + // --- chain fixture: a change two levels beneath an ancestor ----------- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [Y_FILE]: T10_5_1_Y_INITIAL }, + async (workspace) => { + const prefix = "T10.5-1 chain fixture"; + await workspace.gitInit(); + const base = await workspace.gitCommitAll("baseline"); + await workspace.file(Y_FILE, T10_5_1_Y_EDITED); + await buildOk(product, workspace, `${prefix} \`build\` after the edit`); + await createBaseSession(product, workspace, base, "s", prefix); + + // Only a.b.c is changed (SPEC 5.6: a leaf text edit changes the leaf + // alone; its ancestors are descendant-changed), so it gets the one + // subtree-coherence item and each non-root ancestor on its path — + // a.b and a — one parent-consistency item; the file root gets none. + const status = await sessionStatus(product, workspace, "s", prefix); + assertSameJson( + kindScopeSet(status), + [ + `parent-consistency ${Y_A}`, + `parent-consistency ${Y_AB}`, + `subtree-coherence ${Y_ABC}`, + ].sort(), + `${prefix}: only a.b.c is changed, so it gets the one ` + + `subtree-coherence item and each non-root ancestor on the path ` + + `to the root (a.b, a) one parent-consistency item — the changed ` + + `node and the file root get none (SPEC 5.6, 10.5)`, + ); + + const exported = await exportSession(product, workspace, "s", prefix); + const scABC = requireItem( + exported.items, + "subtree-coherence", + Y_ABC, + prefix, + ); + const pcAB = requireItem( + exported.items, + "parent-consistency", + Y_AB, + prefix, + ); + const pcA = requireItem( + exported.items, + "parent-consistency", + Y_A, + prefix, + ); + + // subtree-coherence: context is a.b.c's ancestor chain (a.b, a, and + // the file root); origin the changed node (SPEC 10.5 rule 1). + assertSameJson( + identitySet(scABC.context), + [Y_FILE, Y_A, Y_AB].sort(), + `${prefix}: a.b.c's subtree-coherence item's context is its ` + + `ancestor chain (SPEC 10.5)`, + ); + assertSameJson( + identitySet(scABC.origin), + [Y_ABC], + `${prefix}: a.b.c's subtree-coherence item's origin is the changed ` + + `node in scope (SPEC 10.5)`, + ); + + // a.b: its child on the changed branch is the changed node itself. + assertSameJson( + identitySet(pcAB.context), + [Y_ABC], + `${prefix}: a.b's parent-consistency item's context is exactly ` + + `{a.b.c} — a.b's child on the changed branch, here the changed ` + + `node itself (SPEC 10.5)`, + ); + assertSameJson( + identitySet(pcAB.origin), + [Y_ABC], + `${prefix}: a.b's parent-consistency item's origin is the changed ` + + `branch's changed node (SPEC 10.5)`, + ); + + // a: the changed branch enters as ONE context node — a's child on + // that branch, the branch head a.b — never the changed node a.b.c + // two levels beneath (SPEC 10.5 rule 2; a product listing the + // changed nodes, or the whole branch, as context fails here). + assertSameJson( + identitySet(pcA.context), + [Y_AB], + `${prefix}: a's parent-consistency item's context is exactly ` + + `{a.b} — the branch head, a's child on the changed branch — ` + + `never the changed node a.b.c two levels beneath it (SPEC 10.5: ` + + `each changed branch enters as one context node)`, + ); + assertSameJson( + identitySet(pcA.origin), + [Y_ABC], + `${prefix}: a's parent-consistency item's origin is the changed ` + + `branch's changed node, a.b.c (SPEC 10.5)`, + ); + + // The blocking chain over the same three items (SPEC 10.5 rule 2; + // T10.5-2 pins the general chains): a.b's item is blocked by its + // child's subtree-coherence item, a's by its child's + // parent-consistency item. + assertBlockedBy(scABC, [], `${prefix} a.b.c subtree-coherence item`); + assertBlockedBy( + pcAB, + [scABC.id], + `${prefix} a.b's parent-consistency item — the changed branch's ` + + `changed node is a.b's child, so its subtree-coherence item blocks`, + ); + assertBlockedBy( + pcA, + [pcAB.id], + `${prefix} a's parent-consistency item — the change lies deeper ` + + `than a's child a.b, so a.b's parent-consistency item blocks`, + ); + }, + ); }, }); @@ -901,7 +1092,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 +1109,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 +1145,10 @@ const N_BASELINE = [ "Dep two text.", "</S>", "", + '<S id="dep3">', + "Dep three text.", + "</S>", + "", '<S id="h">', "Aitch own text.", "", @@ -988,6 +1192,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 +1233,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 +1250,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 +1310,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 +1334,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)), ); } @@ -1391,6 +1655,15 @@ const O_DELETED_ORDER: readonly string[] = [ `code-impact ${O_TWO}`, ]; +// The deletion of v.e and v.f (a manual edit, SPEC 6.6) follows the body's +// first `build`, so it is a staged-source record (helpers/staged-mdx.ts, +// S-9: judged before any product exists); the two pre-`build` edits stay +// plain. +const T10_5_4_A_WITHOUT_VEF = stagedMdx( + "T10.5-4 specs/A.mdx with the v.e and v.f sections deleted", + oASpec(false), +); + const T10_5_4 = defineProductTest({ id: "T10.5-4", title: @@ -1442,7 +1715,7 @@ const T10_5_4 = defineProductTest({ // Delete the v.e and v.f sections (a manual edit, SPEC 6.6) — their // items' scope nodes become absent; membership is untouched (no // re-derivation happens without an updated resolve). - await workspace.file(O_A_FILE, oASpec(false)); + await workspace.file(O_A_FILE, T10_5_4_A_WITHOUT_VEF); await buildOk( product, workspace, @@ -1624,6 +1897,34 @@ function vSpec(gaOwn: string, withZ: boolean): string { ].join("\n"); } +// T10.5-5's later edits — sub-fixture A's p.b edit and r.c revert, and both +// of sub-fixture B's — follow the body's first `build`, so they are +// staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists), the same template calls moved to module level; +// sub-fixture A's first edit precedes that `build` and stays plain, as does +// its initial file; sub-fixture B's initial file, staged after it, is a +// record too (below). +const T10_5_5_W_PB_EDITED = stagedMdx( + "T10.5-5 specs/W.mdx with p.b at v1 beside the p.a and r.c edits (re-derivation)", + wSpec("Paa text v1.", "Pab text v1.", "Arc text v1."), +); +const T10_5_5_W_RC_REVERTED = stagedMdx( + "T10.5-5 specs/W.mdx with r.c reverted to its baseline text (re-derivation)", + wSpec("Paa text v1.", "Pab text v1.", "Arc text v0."), +); +const T10_5_5_V_GA_EDITED = stagedMdx( + "T10.5-5 specs/V.mdx with g.a's own text at v1 (decomposition)", + vSpec("Gaa own v1.", false), +); +const T10_5_5_V_WITH_Z = stagedMdx( + "T10.5-5 specs/V.mdx with g.a.z authored after the splits (decomposition)", + vSpec("Gaa own v1.", true), +); +const T10_5_5_V_INITIAL = stagedMdx( + "T10.5-5 specs/V.mdx with g.a's own text at v0 (the decomposition sub-fixture's initial source)", + vSpec("Gaa own v0.", false), +); + const T10_5_5 = defineProductTest({ id: "T10.5-5", title: @@ -1694,10 +1995,7 @@ const T10_5_5 = defineProductTest({ probes, `${prefix} pre-edit capture`, ); - await workspace.file( - W_FILE, - wSpec("Paa text v1.", "Pab text v1.", "Arc text v1."), - ); + await workspace.file(W_FILE, T10_5_5_W_PB_EDITED); await buildOk( product, workspace, @@ -1888,10 +2186,7 @@ const T10_5_5 = defineProductTest({ ); // Revert r.c to its baseline text: r's family stops generating. - await workspace.file( - W_FILE, - wSpec("Paa text v1.", "Pab text v1.", "Arc text v0."), - ); + await workspace.file(W_FILE, T10_5_5_W_RC_REVERTED); await buildOk( product, workspace, @@ -1960,12 +2255,12 @@ const T10_5_5 = defineProductTest({ // --- sub-fixture B: split decompositions govern re-derivation ------------ await withWorkspace( SPECS_ONLY_CONFIG, - { [V_FILE]: vSpec("Gaa own v0.", false) }, + { [V_FILE]: T10_5_5_V_INITIAL }, async (workspace) => { const prefix = "T10.5-5 decomposition"; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); - await workspace.file(V_FILE, vSpec("Gaa own v1.", false)); + await workspace.file(V_FILE, T10_5_5_V_GA_EDITED); await buildOk(product, workspace, `${prefix} \`build\` after the edit`); await createBaseSession(product, workspace, base, "s", prefix); @@ -2056,7 +2351,7 @@ const T10_5_5 = defineProductTest({ // edit): it enters only through the decomposition applied at // re-derivation — g.a.z itself is a changed node with the changed // ancestor g.a, so rule 1 skips it. - await workspace.file(V_FILE, vSpec("Gaa own v1.", true)); + await workspace.file(V_FILE, T10_5_5_V_WITH_Z); await buildOk( product, workspace, @@ -2180,6 +2475,14 @@ function c6Spec(kText: string, sText: string): string { ].join("\n"); } +// The par.s edit follows the body's first `build`, so it is a staged-source +// record (helpers/staged-mdx.ts, S-9: judged before any product exists); the +// par.k edit precedes it and stays plain. +const T10_5_6_C_PAR_S_EDITED = stagedMdx( + "T10.5-6 specs/C.mdx with par.k and par.s both at v1 (the post-decoy edit)", + c6Spec("Kay text v1.", "Ess text v1."), +); + const T10_5_6 = defineProductTest({ id: "T10.5-6", title: @@ -2246,7 +2549,7 @@ const T10_5_6 = defineProductTest({ // runs the generators against the recorded commit (c1), under which // par.k and par.s are both changed. Against the decoy `mark` or // HEAD (both c2), par.k's committed edit is invisible. - await workspace.file(C6_FILE, c6Spec("Kay text v1.", "Ess text v1.")); + await workspace.file(C6_FILE, T10_5_6_C_PAR_S_EDITED); await buildOk( product, workspace, diff --git a/test/suite/registry/section-10.6.ts b/test/suite/registry/section-10.6.ts index 102cd44b..605e42f0 100644 --- a/test/suite/registry/section-10.6.ts +++ b/test/suite/registry/section-10.6.ts @@ -48,6 +48,19 @@ // entry-time relevant-hash values (captured through `query node` at the // recorded moment) appearing among the record's string leaves — the same // operationalizations as §10.2/§10.4. +// - Staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists): every `.mdx` edit a body stages after its first product +// invocation — T10.6-2's deletion of f and e, T10.6-3's authoring and +// editing of p.b — is the same template call moved to module level as a +// record, as is the initial file of T10.6-2's split sub-fixture (a +// workspace created after the body's first `build`: the f-and-e deletion's +// record, whose bytes it spells); each body's first workspace's initial +// `files` entries stay plain (S-7's sweep reaches them against the stub). +// - Every workspace stages `SPECS_ONLY_CONFIG`, and T10.6-2's split +// sub-fixture is created after the body's first `build`, so the +// configuration is a TypeScript staged-source record (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), well-formed, staged in every +// workspace. import type { ExportReport, @@ -67,24 +80,30 @@ import { import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. Audit // fixtures need no code group — audit derives `subtree-coherence` items only. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +const SPECS_ONLY_CONFIG = stagedTs( + "T10.6-2 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -296,20 +315,44 @@ function assertBlockedBy( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; +export function canonicalJson(value: unknown): string { + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -329,14 +372,25 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; @@ -673,20 +727,19 @@ const F_A = "specs/F.mdx#a"; const F_AB = "specs/F.mdx#a.b"; const F_S = "specs/F.mdx#s"; -const F_SOURCE = [ - '<S id="a">', - "Aye own text.", - "", - '<S id="a.b">', - "Abe text.", - "</S>", - "</S>", - "", - '<S id="s">', - "Ess text.", - "</S>", - "", -].join("\n"); +// The deletion of f and e (a manual edit, SPEC 6.6) follows the body's first +// `build`, so it is a staged-source record (helpers/staged-mdx.ts, S-9: +// judged before any product exists); the first sub-fixture's initial files +// stay plain `files` entries. The split sub-fixture's initial specs/F.mdx — +// its workspace follows the first sub-fixture's invocations, so a record too +// — spells exactly B.mdx's post-deletion bytes (a > a.b, then s), so it is +// the SAME record staged at both sites (one record per byte sequence), not +// a second spelling. +const T10_6_2_B_WITHOUT_FE = stagedMdx( + "T10.6-2 specs/B.mdx with the f and e sections deleted (the split sub-fixture's initial specs/F.mdx: the same bytes)", + b2Spec(false), +); +const F_SOURCE = T10_6_2_B_WITHOUT_FE; const T10_6_2 = defineProductTest({ id: "T10.6-2", @@ -781,7 +834,7 @@ const T10_6_2 = defineProductTest({ // Delete the f and e sections (a manual edit, SPEC 6.6): membership // and blockedBy are untouched (no re-derivation without an updated // resolve), only the order and presence change. - await workspace.file(B2_FILE, b2Spec(false)); + await workspace.file(B2_FILE, T10_6_2_B_WITHOUT_FE); await buildOk( product, workspace, @@ -991,6 +1044,19 @@ function rSpec(pbText: string | undefined): string { ].join("\n"); } +// Both p.b stagings — authoring the section, then editing it — follow the +// body's first `build`, so they are staged-source records +// (helpers/staged-mdx.ts, S-9: judged before any product exists); the +// initial file (no p.b) stays a plain `files` entry. +const T10_6_3_R_PB_V0 = stagedMdx( + "T10.6-3 specs/R.mdx with the new section p.b authored at v0", + rSpec("Pab text v0."), +); +const T10_6_3_R_PB_V1 = stagedMdx( + "T10.6-3 specs/R.mdx with p.b edited to v1", + rSpec("Pab text v1."), +); + const T10_6_3 = defineProductTest({ id: "T10.6-3", title: @@ -1029,7 +1095,7 @@ const T10_6_3 = defineProductTest({ // records subtreeHash and metadataHash of each scope node; p.b is a // leaf, so its own two values are the record) — the graph does not // change between this build and the re-derivation below. - await workspace.file(R_FILE, rSpec("Pab text v0.")); + await workspace.file(R_FILE, T10_6_3_R_PB_V0); await buildOk( product, workspace, @@ -1228,7 +1294,7 @@ const T10_6_3 = defineProductTest({ // rewritten — still reports the record written at its creation // (SPEC 10.2: reads report current as recorded; 10.4: invalidation // applies to resolved items alone). - await workspace.file(R_FILE, rSpec("Pab text v1.")); + await workspace.file(R_FILE, T10_6_3_R_PB_V1); await buildOk( product, workspace, diff --git a/test/suite/registry/section-10.7-i.ts b/test/suite/registry/section-10.7-i.ts index 153f0df3..752216a0 100644 --- a/test/suite/registry/section-10.7-i.ts +++ b/test/suite/registry/section-10.7-i.ts @@ -56,15 +56,43 @@ // - Corrupt-session staging (T10.7-5) uses the shape-independent state // (unparseable bytes written over a session file the product itself // wrote), per the T10.1-4 staging conventions — no session file is ever -// fabricated from an assumed layout. +// fabricated from an assumed layout. T10.7-1's existing-corrupt-session +// arms stage T10.1-4's states the same way: unparseable bytes, a +// directory, and a symbolic link (to a valid session the product wrote) +// directly; an invariant violation (an unknown item status) through the +// H-3 adapter over the product-written file. `create` naming the corrupt +// session then reports condition 21 in the code-less refusal's place — +// exactly one finding, exit 1, nothing modified (SPEC 10.1, 10.7, 14.21). +// The concerned path of the code-less existing-name refusal and of the +// condition-21 finding is pinned nowhere (SPEC 10.7, 14.21), so identity, +// count, and empty locations are asserted (T10.1-6's reading). // - Workspaces are git-less wherever no baseline is involved (T10.7-2, // T10.7-4, T10.7-6): coverage and audit sessions require no git. // - Every fixture edit is followed by an explicit `build` before any read, // so no read relies on the 13.3 refresh path (T13.3-*'s business). +// - Staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists): every `.mdx` edit a body stages after its first product +// invocation — T10.7-2's B.mdx and extra/N.mdx additions (N.mdx is the +// same call in both arms: one record), T10.7-3's par edit, T10.7-4's two +// deletions, T10.7-5's w edit, T10.7-6's p.a edit — is the same template +// call moved to module level as a record, as are the initial `.mdx` files +// of the workspaces created after a body's first invocation — T10.7-1's +// corrupt-session workspaces (`W1_SOURCE`, the one record serving the +// body's first workspace too) and T10.7-2's audit arm (leaf a, the +// coverage arm's same call: one record staged in both); every other +// initial `files` entry, a body's first workspace's, stays plain; +// T10.7-1's and T10.7-5's corrupt-session bytes stage no `.mdx` path. +// - TypeScript staged-source records (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), every one well-formed: the configurations of those +// later workspaces — `COVERAGE_CONFIG` (T10.7-1's corrupt-session +// workspaces) and `SPECS_ONLY_CONFIG` (T10.7-2's audit arm), each staged +// wherever that configuration is — and T10.7-2's two post-create +// configuration edits (`C2_CONFIG_EDITED`, `A2_CONFIG_EDITED`). import * as fsp from "node:fs/promises"; import type { ExportReport, + Finding, ItemKind, ItemStatus, NodeReport, @@ -80,40 +108,51 @@ import { decodeNodeReport, decodeSessionListReport, decodeSessionStatusReport, + stageUnknownItemStatus, } from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { + assertConditionCounts, assertSameJson, buildOk, + expectErrorDocument, expectExit, runCli, + runFindingsReport, runJson, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +const SPECS_ONLY_CONFIG = stagedTs( + "T10.7-2 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus a direct coverage profile over it (SPEC 7.4): with the // group serving as its own boundary, a leaf is covered exactly when some // non-root node has a single dependency edge to it (SPEC 8). -const COVERAGE_CONFIG = `import { defineConfig } from "xspec" +const COVERAGE_CONFIG = stagedTs( + "T10.7-1 xspec.config.ts — one spec group and the coverage profile p", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -128,12 +167,13 @@ export default defineConfig({ } ] }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -395,20 +435,44 @@ function assertStoredCounts( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; +export function canonicalJson(value: unknown): string { + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -428,14 +492,25 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; @@ -443,7 +518,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 +534,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)`, ); } @@ -469,12 +546,173 @@ async function expectCreateUsageError( // --------------------------------------------------------------------------- const W1_FILE = "specs/W.mdx"; -const W1_SOURCE = ['<S id="w">', "Dub text.", "</S>", ""].join("\n"); +// Staged by the body's first workspace and, after its invocations, by each +// corrupt-session state's workspace: one staged-source record (S-9's +// before-any-product clause), wrapped in place. +const W1_SOURCE = stagedMdx( + "T10.7-1 specs/W.mdx (the flag-exclusivity workspace's initial source and each corrupt-session state's)", + ['<S id="w">', "Dub text.", "</S>", ""].join("\n"), +); + +/** The session every refusal arm of T10.7-1 names, and its file (10.1). */ +const W1_SESSION = "s"; +const W1_SESSION_REL = `.xspec/reviews/${W1_SESSION}.json`; + +/** + * The staged occupant of the session path must be what the arm claims — a + * harness staging check (a plain error, never a diagnosed failure, H-8). + */ +async function requireStagedKind( + workspace: TestWorkspace, + expected: "file" | "dir" | "symlink", + context: string, +): Promise<void> { + const staged = await workspace.kind(W1_SESSION_REL); + if (staged !== expected) { + throw new Error( + `${context} staging: expected ${W1_SESSION_REL} to hold a ` + + `${expected} once staged; found ${staged} (a harness staging ` + + `error, not a product observation)`, + ); + } +} + +/** + * `review create --strategy audit --name s --json` refused (SPEC 10.7): + * exit 1 with the findings-only report holding exactly one finding of the + * given counting identity — `(code-less)` for the existing-name refusal, + * which 14 assigns no stable code, or `14.21` where the existing session is + * corrupt and its corruption stands in the refusal's place, no code-less + * refusal beside it — with no in-source location (locations [], SPEC 12.7), + * and nothing modified anywhere under the root (the whole-root compare; the + * snapshot never follows links, so a link occupant and its target are both + * covered). The finding's concerned path is pinned nowhere (SPEC 10.7, + * 14.21; module header), so it is left unasserted (H-4). + */ +async function expectCreateRefused( + product: ProductBinding, + workspace: TestWorkspace, + identity: "(code-less)" | "14.21", + context: string, +): Promise<Finding> { + const argv = [ + "review", + "create", + "--strategy", + "audit", + "--name", + W1_SESSION, + "--json", + ]; + const label = `${context} \`${argv.join(" ")}\``; + const why = + identity === "(code-less)" + ? "the code-less existing-name refusal of 10.7 — one finding, " + + "carrying no stable code (SPEC 14)" + : "the existing session is corrupt, so its corruption is reported " + + "in the refusal's place — one finding, condition 21 " + + "`corrupt-session`, no code-less refusal beside it (SPEC 10.7, " + + "14.21)"; + let refusal: Finding | undefined; + await assertLeavesUnchanged( + workspace.root, + async () => { + const findings = await runFindingsReport( + product, + workspace, + argv, + 1, + `${label} — a refused review operation exits 1 with the ` + + `findings-only report as the entire stdout (SPEC 10.7, 12.0, 12.7)`, + ); + assertConditionCounts( + findings, + { [identity]: 1 }, + `${label} — exactly one finding: ${why}`, + ); + refusal = findings[0]; + assertSameJson( + refusal.locations, + [], + `${label}: the finding has no in-source location — locations [] ` + + `(SPEC 12.7)`, + ); + }, + `${label} — the refused create modifies nothing: no session written, ` + + `the existing session's file, occupant, and link target ` + + `byte-unchanged (SPEC 10.1, 10.7)`, + ); + if (refusal === undefined) { + throw new Error(`${label}: the compare-around body did not run`); + } + return refusal; +} + +/** + * One corrupt state of the existing session `s`, staged T10.1-4's way on a + * built workspace: the shape-independent states directly, the invariant + * violation through the H-3 adapter over a session file the product wrote. + */ +interface CorruptCreateArm { + readonly state: string; + readonly stage: ( + product: ProductBinding, + workspace: TestWorkspace, + context: string, + ) => Promise<void>; +} + +const CORRUPT_CREATE_ARMS: readonly CorruptCreateArm[] = [ + { + // Cannot be parsed (SPEC 10.1, 14.21): garbage bytes at the session + // path — shape-independent, so no product-written session is needed. + state: "unparseable JSON (garbage bytes)", + stage: async (_product, workspace, context) => { + await workspace.file( + W1_SESSION_REL, + "this is deliberately not a JSON document ][}{\n", + ); + await requireStagedKind(workspace, "file", context); + }, + }, + { + // Violates a session invariant (SPEC 10.1: statuses drawn from 10.3) + // while staying one well-formed JSON document — the state a `create` + // that merely checks the name's existence, without reading the session, + // cannot tell from a valid one. + state: "unknown item status (H-3 adapter over the product-written file)", + stage: async (product, workspace, context) => { + await createAuditSession(product, workspace, W1_SESSION, context); + await stageUnknownItemStatus(workspace.path(W1_SESSION_REL)); + await requireStagedKind(workspace, "file", context); + }, + }, + { + // Not a plain file (SPEC 13.4): a directory at the session path. + state: "session path occupied by a directory", + stage: async (_product, workspace, context) => { + await workspace.dir(W1_SESSION_REL); + await requireStagedKind(workspace, "dir", context); + }, + }, + { + // Not a plain file (SPEC 13.4): a symbolic link at the session path, + // targeting the valid session `real` the product wrote beside it — a + // product reading through the link sees a healthy existing session and + // answers the code-less refusal instead of condition 21. + state: "session path occupied by a symbolic link to a valid session", + stage: async (product, workspace, context) => { + await createAuditSession(product, workspace, "real", context); + await workspace.symlink(W1_SESSION_REL, "real.json"); + await requireStagedKind(workspace, "symlink", context); + }, + }, +]; const T10_7_1 = defineProductTest({ id: "T10.7-1", title: - "`review create` flag exclusivity: exactly one of `--base`, `--strategy audit`, `--coverage` is required — supplying none, any two, all three, or `--strategy` with any other value (`path-blocks`, `coverage`, garbage) is a usage error, exit 2, as is a missing `--name`; `--coverage` naming no configured profile is exit 2 (an unknown profile named in arguments) with nothing created — no session file exists and `list` reports no such session; `create` with an existing session's exact name is refused, exit 1 (SPEC 10.1, 10.7, 12.0)", + "`review create` flag exclusivity: exactly one of `--base`, `--strategy audit`, `--coverage` is required — supplying none, any two, all three, or `--strategy` with any other value (`path-blocks`, `coverage`, garbage) is a usage error, exit 2, as is a missing `--name`; `--coverage` naming no configured profile is exit 2 (an unknown profile named in arguments) with nothing created — no session file exists and `list` reports no such session; `create` with an existing session's exact name is refused: exit 1 with exactly one code-less finding, nothing modified; naming an existing corrupt session — unparseable bytes, an invariant violation staged through the H-3 adapter over the product-written file, a directory or a symbolic link at the session path (T10.1-4's stagings) — reports the corruption in the refusal's place: exactly one finding, condition 21 `corrupt-session`, no code-less refusal beside it, exit 1, nothing modified (SPEC 10.1, 10.7, 12.0, 13.4, 14.21)", timeoutMs: 240_000, run: async (product) => { await withWorkspace( @@ -620,21 +858,37 @@ const T10_7_1 = defineProductTest({ `${prefix} \`review create --strategy audit\` — missing --name`, ); - // An existing name is refused: exit 1, a refused review operation - // (SPEC 10.7, 12.0; the ASCII-case-fold variant is T10.1-2's - // business — this arm stages the exact name). - await createAuditSession(product, workspace, "s", prefix); - await expectExit( + // An existing name is refused: exit 1, a refused review operation — + // exactly one code-less finding, nothing modified (SPEC 10.7, 12.0; + // the ASCII-case-fold variant is T10.1-2's business — this arm + // stages the exact name). + await createAuditSession(product, workspace, W1_SESSION, prefix); + await expectCreateRefused( product, workspace, - ["review", "create", "--strategy", "audit", "--name", "s"], - 1, - `${prefix} \`review create --strategy audit --name s\` again — ` + - `\`create\` with the name of an existing session is refused ` + - `(exit 1, SPEC 10.7, 12.0)`, + "(code-less)", + `${prefix} [existing valid session]`, ); }, ); + + // Naming an existing corrupt session (SPEC 10.7, 14.21): the corruption + // stands in the refusal's place. One fresh workspace per state, so no + // other session's state can enter the report: each is built, the + // session `s` staged corrupt T10.1-4's way, then `create --name s` + // driven against it. + for (const arm of CORRUPT_CREATE_ARMS) { + await withWorkspace( + COVERAGE_CONFIG, + { [W1_FILE]: W1_SOURCE }, + async (workspace) => { + const context = `T10.7-1 [existing corrupt session: ${arm.state}]`; + await buildOk(product, workspace, `${context} \`build\``); + await arm.stage(product, workspace, context); + await expectCreateRefused(product, workspace, "14.21", context); + }, + ); + } }, }); @@ -659,7 +913,9 @@ const C2_B = "specs/B.mdx"; const C2_B_NODE = "specs/B.mdx#b"; const C2_N = "extra/N.mdx"; -const C2_CONFIG_EDITED = `import { defineConfig } from "xspec" +const C2_CONFIG_EDITED = stagedTs( + "T10.7-2 coverage arm xspec.config.ts after create (profile renamed to q, main's globs edited)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -674,7 +930,8 @@ export default defineConfig({ } ] }) -`; +`, +); function leafSpec(id: string, text: string): string { return [`<S id="${id}">`, text, "</S>", ""].join("\n"); @@ -686,14 +943,37 @@ function leafSpec(id: string, text: string): string { // creation parameters, so its generators run against the whole current // workspace under the current configuration and the extra items enter on // re-derivation. -const A2_CONFIG_EDITED = `import { defineConfig } from "xspec" +const A2_CONFIG_EDITED = stagedTs( + "T10.7-2 audit arm xspec.config.ts after create (main gains extra/**/*.mdx)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx", "extra/**/*.mdx"] } }) -`; +`, +); + +// Leaf a is the same call in the coverage arm's workspace (the body's first) +// and the audit arm's (created after the coverage arm's invocations): one +// staged-source record (S-9's before-any-product clause), staged at both. +const T10_7_2_A_LEAF = stagedMdx( + "T10.7-2 specs/A.mdx with leaf a (the coverage arm's initial source and the audit arm's)", + leafSpec("a", "Aye text."), +); + +// Both arms' post-create additions follow the arm's first `build`, so they +// are staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists); extra/N.mdx is the same call in both arms — one record. +const T10_7_2_B_LEAF = stagedMdx( + "T10.7-2 specs/B.mdx with leaf b (added by the coverage arm's post-create configuration edit)", + leafSpec("b", "Bee text."), +); +const T10_7_2_N_LEAF = stagedMdx( + "T10.7-2 extra/N.mdx with leaf n (added after create in the coverage and audit arms)", + leafSpec("n", "Enn text."), +); const T10_7_2 = defineProductTest({ id: "T10.7-2", @@ -705,7 +985,7 @@ const T10_7_2 = defineProductTest({ await withWorkspace( COVERAGE_CONFIG, { - [C2_A]: leafSpec("a", "Aye text."), + [C2_A]: T10_7_2_A_LEAF, [C2_G]: leafSpec("g", "Gee text."), }, async (workspace) => { @@ -759,8 +1039,8 @@ const T10_7_2 = defineProductTest({ // Post-create configuration edit: profile renamed p → q, main's // globs edited; B.mdx and extra/N.mdx added. await workspace.file("xspec.config.ts", C2_CONFIG_EDITED); - await workspace.file(C2_B, leafSpec("b", "Bee text.")); - await workspace.file(C2_N, leafSpec("n", "Enn text.")); + await workspace.file(C2_B, T10_7_2_B_LEAF); + await workspace.file(C2_N, T10_7_2_N_LEAF); await buildOk( product, workspace, @@ -908,7 +1188,7 @@ const T10_7_2 = defineProductTest({ // --- audit arm: no creation parameters are recorded --------------------- await withWorkspace( SPECS_ONLY_CONFIG, - { [C2_A]: leafSpec("a", "Aye text.") }, + { [C2_A]: T10_7_2_A_LEAF }, async (workspace) => { const prefix = "T10.7-2 audit arm"; await buildOk(product, workspace, `${prefix} \`build\``); @@ -936,7 +1216,7 @@ const T10_7_2 = defineProductTest({ // parameters (SPEC 10.7), so nothing shields it from the current // configuration: its generators run against the current workspace. await workspace.file("xspec.config.ts", A2_CONFIG_EDITED); - await workspace.file(C2_N, leafSpec("n", "Enn text.")); + await workspace.file(C2_N, T10_7_2_N_LEAF); await buildOk( product, workspace, @@ -1009,6 +1289,14 @@ function c3Spec(parText: string): string { ].join("\n"); } +// The healthy session's edit follows the body's first `build`, so it is a +// staged-source record (helpers/staged-mdx.ts, S-9: judged before any +// product exists). +const T10_7_3_C_PAR_EDITED = stagedMdx( + "T10.7-3 specs/C.mdx with par's own text at v1 (the healthy session's edit against the real baseline)", + c3Spec("Par own text v1."), +); + /** Remove the workspace's `.git` directory entirely (staging, T10.7-3). */ async function removeGitDir( workspace: TestWorkspace, @@ -1043,9 +1331,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"], [ @@ -1075,7 +1364,7 @@ const T10_7_3 = defineProductTest({ // scope root has a child, so `resolve` and `split` would both be // legal if the baseline stayed reconstructable: the destroyed // baseline is each later command's only failure cause. - await workspace.file(C3_FILE, c3Spec("Par own text v1.")); + await workspace.file(C3_FILE, T10_7_3_C_PAR_EDITED); await buildOk( product, workspace, @@ -1151,10 +1440,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)`, @@ -1246,6 +1535,17 @@ function c4KSpec(withU2: boolean, withU1: boolean): string { ].join("\n"); } +// Both deletions follow the body's first `build`, so they are staged-source +// records (helpers/staged-mdx.ts, S-9: judged before any product exists). +const T10_7_4_K_WITHOUT_U2 = stagedMdx( + "T10.7-4 specs/K.mdx without par.u2's section (par.u1 kept)", + c4KSpec(false, true), +); +const T10_7_4_K_WITHOUT_U2_U1 = stagedMdx( + "T10.7-4 specs/K.mdx without par.u2's and par.u1's sections", + c4KSpec(false, false), +); + const C4_Z_SOURCE = ['<S id="z">', "Zee text.", "</S>", ""].join("\n"); const T10_7_4 = defineProductTest({ @@ -1362,7 +1662,7 @@ const T10_7_4 = defineProductTest({ // same file's present-scope items — before Z.mdx's item, so it // stays within its file group rather than dropping to the end // (SPEC 10.5's ordering rule applied to the coverage order, 10.7). - await workspace.file(C4_K, c4KSpec(false, true)); + await workspace.file(C4_K, T10_7_4_K_WITHOUT_U2); await buildOk( product, workspace, @@ -1420,7 +1720,7 @@ const T10_7_4 = defineProductTest({ // tiebreak needs two absent items with equal identity strings, // which only arise via journaled-rename reintroduction — T10.4-4's // staging — so it is not staged here. - await workspace.file(C4_K, c4KSpec(false, false)); + await workspace.file(C4_K, T10_7_4_K_WITHOUT_U2_U1); await buildOk( product, workspace, @@ -1470,6 +1770,14 @@ function c5Spec(text: string): string { return ['<S id="w">', text, "</S>", ""].join("\n"); } +// The edit of w after a2's resolution follows the body's first `build`, so it +// is a staged-source record (helpers/staged-mdx.ts, S-9: judged before any +// product exists); the corrupt session's bytes stage no `.mdx` path. +const T10_7_5_W_EDITED = stagedMdx( + "T10.7-5 specs/W.mdx with w's text at v1 (the edit after a2's resolution)", + c5Spec("Dub text v1."), +); + const T10_7_5 = defineProductTest({ id: "T10.7-5", title: @@ -1508,7 +1816,7 @@ const T10_7_5 = defineProductTest({ "no-change", `${prefix} \`resolve a2 <w's item> --status no-change\``, ); - await workspace.file(C5_FILE, c5Spec("Dub text v1.")); + await workspace.file(C5_FILE, T10_7_5_W_EDITED); await buildOk( product, workspace, @@ -1699,6 +2007,13 @@ function fullRowSequence(report: SessionStatusReport): readonly string[] { ); } +// Stage D's edit follows the body's first `build`, so it is a staged-source +// record (helpers/staged-mdx.ts, S-9: judged before any product exists). +const T10_7_6_T_PA_EDITED = stagedMdx( + "T10.7-6 specs/T.mdx with p.a's text at v1 (stage D's edit under two resolved scopes)", + c6Spec("Paa text v1."), +); + const T10_7_6 = defineProductTest({ id: "T10.7-6", title: @@ -1855,7 +2170,7 @@ const T10_7_6 = defineProductTest({ const paBefore = await queryNode(product, workspace, C6_PA, prefix); const pBefore = await queryNode(product, workspace, C6_P, prefix); const pbBefore = await queryNode(product, workspace, C6_PB, prefix); - await workspace.file(C6_FILE, c6Spec("Paa text v1.")); + await workspace.file(C6_FILE, T10_7_6_T_PA_EDITED); await buildOk( product, workspace, diff --git a/test/suite/registry/section-10.7-ii.ts b/test/suite/registry/section-10.7-ii.ts index f088c3f4..15a59685 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). @@ -63,7 +68,29 @@ // - Workspaces are git-less wherever no baseline is involved; every fixture // edit is followed by an explicit `build` before any read, so no read // relies on the 13.3 refresh path (T13.3-*'s business). - +// - Staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists): every `.mdx` edit a body stages after its first product +// invocation — T10.7-7's payload-arm edit, T10.7-8's x.k edit, T10.7-9's +// g.a.z authoring and both audit-arm authorings, T10.7-10's p.a edit, +// T10.7-11's edge swap, T10.7-12's A.mdx v1/v2 and B.mdx v1/v2 edits — is +// the same template call moved to module level as a record, as is the +// initial `.mdx` file of every workspace created after the body's first +// invocation (T10.7-7's payload arm, T10.7-9's audit arm, T10.7-12's +// provenance and coverage sub-fixtures; T10.7-7's empty-session arm +// stages none). T10.7-9's path-blocks v1 edit precedes the body's first +// `build` (git staging invokes no product) and stays plain, as do each +// body's first workspace's initial `files` entries. +// - TypeScript staged-source records (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), every one well-formed: the configurations of the +// workspaces created after a body's first invocation — `SPECS_ONLY_CONFIG` +// (T10.7-7's empty-session arm, T10.7-9's audit arm, T10.7-12's provenance +// sub-fixture), `SPECS_CODE_CONFIG` (T10.7-7's payload arm), and +// `COVERAGE_ALL_CONFIG` (T10.7-12's coverage sub-fixture), each staged +// wherever that configuration is — and the code sources staged after one: +// T10.7-7's `src/next.ts` (`N7_CODE_SOURCE`) and T10.7-12's `src/ref.ts` +// and `src/del.ts` (`M12_CODE_SOURCE`, `M12_CODE_DEL_SOURCE`). + +import * as fsp from "node:fs/promises"; import type { ExportReport, ItemKind, @@ -84,26 +111,34 @@ 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; +import type { InitialFileContents } from "../../helpers/workspace.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" +const SPECS_ONLY_CONFIG = stagedTs( + "T10.7-7/T10.7-9/T10.7-12 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus a direct coverage profile over it (SPEC 7.4): with the // group serving as its own boundary, a leaf is covered exactly when some @@ -129,7 +164,9 @@ export default defineConfig({ // branch node can be required and uncovered — making an // `uncovered-requirement` scope whose subtree text differs from its own text // (the T10.7-12 discriminator). -const COVERAGE_ALL_CONFIG = `import { defineConfig } from "xspec" +const COVERAGE_ALL_CONFIG = stagedTs( + "T10.7-12 xspec.config.ts — one spec group and the coverage profile p over every node (targets all)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -145,11 +182,14 @@ export default defineConfig({ } ] }) -`; +`, +); // Spec group plus a code group (SPEC 7.2) — the T10.7-12 matrix fixture needs // a `code-impact` item (an impacted code location, SPEC 9.2, 10.5). -const SPECS_CODE_CONFIG = `import { defineConfig } from "xspec" +const SPECS_CODE_CONFIG = stagedTs( + "T10.7-7 xspec.config.ts — one spec group and the code group app", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -159,12 +199,13 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -451,20 +492,44 @@ function assertBlockedBy( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; +export function canonicalJson(value: unknown): string { + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -484,14 +549,25 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; @@ -586,6 +662,7 @@ function payloadProjection(item: ReviewItem): unknown { node: entry.node, before: entry.before, after: entry.after, + sourceRange: entry.sourceRange, })), }; } @@ -595,8 +672,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 +712,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 +753,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 +851,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 +977,50 @@ function n7PbSpec(kidText: string): string { ].join("\n"); } +// The payload arm's initial source and reviewed edit follow the body's first +// `build` (the order arm's), so both are staged-source records +// (helpers/staged-mdx.ts, S-9: judged before any product exists); the code +// source beside them is a TypeScript record (`N7_CODE_SOURCE`). +const T10_7_7_A2_KID_V0 = stagedMdx( + "T10.7-7 specs/A2.mdx with a.k's text at v0 (the payload arm's initial source)", + n7PbSpec("Kid line v0."), +); +const T10_7_7_A2_KID_V1 = stagedMdx( + "T10.7-7 specs/A2.mdx with a.k's text at v1 (the payload arm's reviewed edit)", + n7PbSpec("Kid line v1."), +); + +// 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}"; +// The payload arm's workspace follows the order arm's invocations and the +// code source is staged after its first `build`, so it is a TypeScript +// staged-source record (module header), well-formed. +const N7_CODE_SOURCE = stagedTs( + "T10.7-7 src/next.ts (the payload arm's impacted code location nextUnit)", + `${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 +1062,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,9 +1193,15 @@ 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, - { [N7_PB_FILE]: n7PbSpec("Kid line v0.") }, + SPECS_CODE_CONFIG, + { [N7_PB_FILE]: T10_7_7_A2_KID_V0 }, async (workspace) => { const prefix = "T10.7-7 payload arm"; await workspace.gitInit(); @@ -1072,7 +1214,11 @@ const T10_7_7 = defineProductTest({ `${prefix} baseline capture`, ); - await workspace.file(N7_PB_FILE, n7PbSpec("Kid line v1.")); + // 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, T10_7_7_A2_KID_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 +1272,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 +1310,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 +1320,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 +1355,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`, + ); }, ); }, @@ -1227,6 +1545,13 @@ function e8Spec(kidText: string): string { ].join("\n"); } +// The x.k edit follows the body's first `build`, so it is a staged-source +// record (helpers/staged-mdx.ts, S-9: judged before any product exists). +const T10_7_8_X_KAY_V1 = stagedMdx( + "T10.7-8 specs/X.mdx with x.k's text at v1 (the edit invalidating its stored no-change)", + e8Spec("Kay line v1."), +); + const T10_7_8 = defineProductTest({ id: "T10.7-8", title: @@ -1400,10 +1725,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)`, ); } @@ -1411,7 +1736,7 @@ const T10_7_8 = defineProductTest({ // x.k so its stored no-change goes stale — the exported item reads // invalidated and re-blocks its dependents, without rewriting the // stored status (list-side counting is T10.7-5's business). - await workspace.file(E8_FILE, e8Spec("Kay line v1.")); + await workspace.file(E8_FILE, T10_7_8_X_KAY_V1); await buildOk( product, workspace, @@ -1500,6 +1825,15 @@ function s9Spec(gaOwn: string, withZ: boolean): string { ].join("\n"); } +// The path-blocks arm's authoring of g.a.z follows the body's first `build`, +// so it is a staged-source record (helpers/staged-mdx.ts, S-9: judged before +// any product exists); the arm's v1 edit precedes that `build` and stays +// plain (S-7's sweep reaches it against the stub). +const T10_7_9_G_WITH_Z = stagedMdx( + "T10.7-9 specs/G.mdx with g.a.z authored (g.a's own text at v1)", + s9Spec("Gaa own v1.", true), +); + // Audit arm: h with child h.a; h.b and h.c are authored later. const S9_H_FILE = "specs/H.mdx"; const S9_H = "specs/H.mdx#h"; @@ -1524,6 +1858,22 @@ function s9HSpec(withB: boolean, withC: boolean): string { ].join("\n"); } +// The audit arm's initial source and two authorings follow the body's first +// `build`, so they are staged-source records (helpers/staged-mdx.ts, S-9: +// judged before any product exists). +const T10_7_9_H_INITIAL = stagedMdx( + "T10.7-9 specs/H.mdx with h.a alone under h (the audit arm's initial source)", + s9HSpec(false, false), +); +const T10_7_9_H_WITH_B = stagedMdx( + "T10.7-9 specs/H.mdx with h.b authored after create", + s9HSpec(true, false), +); +const T10_7_9_H_WITH_B_C = stagedMdx( + "T10.7-9 specs/H.mdx with h.b and h.c authored", + s9HSpec(true, true), +); + const T10_7_9 = defineProductTest({ id: "T10.7-9", title: @@ -1538,6 +1888,9 @@ const T10_7_9 = defineProductTest({ const prefix = "T10.7-9 path-blocks arm"; await workspace.gitInit(); const base = await workspace.gitCommitAll("baseline"); + // This edit precedes the body's first product invocation (git + // staging invokes none), so S-7's sweep reaches it against the + // stub: plain contents, no ledger record (helpers/staged-mdx.ts). await workspace.file(S9_FILE, s9Spec("Gaa own v1.", false)); await buildOk(product, workspace, `${prefix} \`build\` after the edit`); await createBaseSession(product, workspace, base, "s", prefix); @@ -1732,7 +2085,7 @@ const T10_7_9 = defineProductTest({ // `updated` — z's item enters through the decomposition (one item // per current child subtree) with a fresh id; no subtree-coherence // item for g.a is re-added. - await workspace.file(S9_FILE, s9Spec("Gaa own v1.", true)); + await workspace.file(S9_FILE, T10_7_9_G_WITH_Z); await buildOk( product, workspace, @@ -1815,7 +2168,7 @@ const T10_7_9 = defineProductTest({ // --- audit arm ------------------------------------------------------------ await withWorkspace( SPECS_ONLY_CONFIG, - { [S9_H_FILE]: s9HSpec(false, false) }, + { [S9_H_FILE]: T10_7_9_H_INITIAL }, async (workspace) => { const prefix = "T10.7-9 audit arm"; await buildOk(product, workspace, `${prefix} \`build\``); @@ -1864,7 +2217,7 @@ const T10_7_9 = defineProductTest({ // it and h's item's blockedBy still names only h.a's item — the // discriminating stale blocker set the split's inheritance rule is // asserted against. - await workspace.file(S9_H_FILE, s9HSpec(true, false)); + await workspace.file(S9_H_FILE, T10_7_9_H_WITH_B); await buildOk( product, workspace, @@ -1996,7 +2349,7 @@ const T10_7_9 = defineProductTest({ // h is never re-added; h.c's item enters with a fresh id; blockedBy // is recomputed per the audit rule — h.b's inherited blocker is // dropped — with decomposed references replaced by the decomposition. - await workspace.file(S9_H_FILE, s9HSpec(true, true)); + await workspace.file(S9_H_FILE, T10_7_9_H_WITH_B_C); await buildOk( product, workspace, @@ -2091,12 +2444,19 @@ function r10Spec(paText: string): string { ].join("\n"); } +// The p.a edit follows the body's first `build`, so it is a staged-source +// record (helpers/staged-mdx.ts, S-9: judged before any product exists). +const T10_7_10_R_PA_V1 = stagedMdx( + "T10.7-10 specs/R.mdx with p.a's text at v1 (the edit invalidating its resolution)", + r10Spec("Paa line v1."), +); + 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 +2506,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 +2527,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)`, ); } @@ -2256,7 +2617,7 @@ const T10_7_10 = defineProductTest({ // Invalidate the resolution: edit p.a, with hash premises, then // re-resolve the invalidated item — it works like any resolve and // records the new current state (v1), clearing the invalidation. - await workspace.file(R10_FILE, r10Spec("Paa line v1.")); + await workspace.file(R10_FILE, T10_7_10_R_PA_V1); await buildOk(product, workspace, `${prefix} \`build\` at v1`); const v1 = await queryNode( product, @@ -2348,6 +2709,13 @@ function c11DSpec(target: "k1" | "k2"): string { ].join("\n"); } +// The swap follows the body's first `build`, so it is a staged-source record +// (helpers/staged-mdx.ts, S-9: judged before any product exists). +const T10_7_11_D_TO_K2 = stagedMdx( + "T10.7-11 specs/D.mdx with src's covering d edge moved from k1 to k2", + c11DSpec("k2"), +); + const C11_K_SOURCE = [ '<S id="k1">', "Kay-one line.", @@ -2414,7 +2782,7 @@ const T10_7_11 = defineProductTest({ // uncovered, k2 covered. k2's relevant hashes (its own subtree and // metadata hashes, SPEC 10.4) are untouched by an incoming-edge // change, so its resolution stands. - await workspace.file(C11_D, c11DSpec("k2")); + await workspace.file(C11_D, T10_7_11_D_TO_K2); await buildOk(product, workspace, `${prefix} \`build\` after the swap`); const k2Stable = requireRow( await sessionStatus(product, workspace, "c", prefix), @@ -2533,8 +2901,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 +2919,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 +3018,53 @@ 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 matrix arm's v1 and v2 edits follow the body's first `build`, so they +// are staged-source records (helpers/staged-mdx.ts, S-9: judged before any +// product exists); the code sources staged beside v1 are TypeScript records +// (`M12_CODE_SOURCE`, `M12_CODE_DEL_SOURCE`). +const T10_7_12_A_V1 = stagedMdx( + "T10.7-12 specs/A.mdx at v1 (the matrix arm's reviewed differences before create)", + m12Spec(M12_V1), +); +const T10_7_12_A_V2 = stagedMdx( + "T10.7-12 specs/A.mdx at v2 (par.n re-edited and gone deleted after create)", + m12Spec(M12_V2), +); + +// 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}"; +// Staged beside v1, after the body's first `build`: a TypeScript +// staged-source record (module header), well-formed. +const M12_CODE_SOURCE = stagedTs( + "T10.7-12 src/ref.ts (the matrix arm's present code location refUnit)", + `${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). Staged beside v1, after the body's +// first `build`: a TypeScript staged-source record (module header), +// well-formed. +const M12_CODE_DEL_SOURCE = stagedTs( + "T10.7-12 src/del.ts (the matrix arm's deleted whole-file code location)", + '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 @@ -2689,6 +3103,22 @@ function b12Spec( ].join("\n"); } +// The provenance arm's initial source and its v1 and v2 edits follow the +// body's first `build` (the matrix arm's), so they are staged-source records +// (helpers/staged-mdx.ts, S-9: judged before any product exists). +const T10_7_12_B_T0 = stagedMdx( + "T10.7-12 specs/B.mdx with px.x at T0, yq.y and dm's d reference present (the provenance sub-fixture's initial source)", + b12Spec("Ex line T0.", true, true), +); +const T10_7_12_B_X_T1 = stagedMdx( + "T10.7-12 specs/B.mdx with px.x at T1, yq.y and dm's d reference removed", + b12Spec("Ex line T1.", false, false), +); +const T10_7_12_B_WITHOUT_X = stagedMdx( + "T10.7-12 specs/B.mdx without px.x (deleted after create)", + b12Spec(null, false, false), +); + // Sub-fixture C: the uncovered-requirement payload. `targets: "all"` makes // the branch node `top` required, so an uncovered scope exists whose subtree // text differs from its own text; cov's d edge covers top.in. @@ -2697,25 +3127,31 @@ const U12_ROOT = "specs/U.mdx"; const U12_TOP = "specs/U.mdx#top"; const U12_COV = "specs/U.mdx#cov"; -const U12_SOURCE = [ - '<S id="top">', - "Top own line.", - "", - '<S id="top.in">', - "Inner line.", - "</S>", - "</S>", - "", - '<S id="cov" d={"top.in"}>', - "Cov line.", - "</S>", - "", -].join("\n"); +// The coverage sub-fixture follows the matrix and provenance sub-fixtures' +// invocations, so its initial source is a staged-source record (S-9's +// before-any-product clause), wrapped in place. +const U12_SOURCE = stagedMdx( + "T10.7-12 specs/U.mdx (the coverage sub-fixture's initial source)", + [ + '<S id="top">', + "Top own line.", + "", + '<S id="top.in">', + "Inner line.", + "</S>", + "</S>", + "", + '<S id="cov" d={"top.in"}>', + "Cov line.", + "</S>", + "", + ].join("\n"), +); 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 ------- @@ -2743,8 +3179,9 @@ const T10_7_12 = defineProductTest({ const wt0 = await capture(M12_WT, "v0"); // 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_FILE, T10_7_12_A_V1); + 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 +3194,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. - await workspace.file(M12_FILE, m12Spec(M12_V2)); + // 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, T10_7_12_A_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 +3266,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 +3580,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 +3596,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 +3654,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. @@ -3239,7 +3736,7 @@ const T10_7_12 = defineProductTest({ // --- sub-fixture B: absent-node provenance across re-derivation ---------- await withWorkspace( SPECS_ONLY_CONFIG, - { [B12_FILE]: b12Spec("Ex line T0.", true, true) }, + { [B12_FILE]: T10_7_12_B_T0 }, async (workspace) => { const prefix = "T10.7-12 provenance"; await workspace.gitInit(); @@ -3260,7 +3757,7 @@ const T10_7_12 = defineProductTest({ // v1: px.x edited (T0→T1); yq.y deleted with dm's d reference to it // removed in the same write (so no unresolved reference exists). - await workspace.file(B12_FILE, b12Spec("Ex line T1.", false, false)); + await workspace.file(B12_FILE, T10_7_12_B_X_T1); await buildOk(product, workspace, `${prefix} \`build\` at v1`); const x1 = await queryNode( product, @@ -3307,7 +3804,7 @@ const T10_7_12 = defineProductTest({ ).id; // v2: delete px.x after create. - await workspace.file(B12_FILE, b12Spec(null, false, false)); + await workspace.file(B12_FILE, T10_7_12_B_WITHOUT_X); await buildOk(product, workspace, `${prefix} \`build\` at v2`); const assertProvenance = async (stage: string): Promise<void> => { diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts new file mode 100644 index 00000000..09ef8b45 --- /dev/null +++ b/test/suite/registry/section-11.2.ts @@ -0,0 +1,4700 @@ +// 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.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). +// A TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), staged wherever it is used and named for every body +// staging it after a product invocation: T11.2-4's later stagings and +// T11.2-5's second workspace here, T11.3-1's, T11.3-2's, and T11.3-3's later +// workspaces, T11.4-3's shared workspace and T11.4-5's and T11.4-6's later +// ones, and P-12's later trials (registry/section-16-p12.ts). +export const SPECS_ONLY_CONFIG = stagedTs( + "T11.2-4/T11.2-5/T11.3-1/T11.3-2/T11.3-3/T11.4-3/T11.4-5/T11.4-6/P-12 xspec.config.ts — one spec group", + `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; +// Restaged after a body's first product invocation (T11.2-5's cycle-pair +// workspace, T11.2-6's later fixtures), so S-7's sweep never reaches those +// stagings against the stub: a staged-source record (helpers/staged-mdx.ts; +// S-9's before-any-product clause) made from the string the pins use, and +// staged at every site the bytes appear (T11.2-1's too). +const C_STAGED = stagedMdx( + "T11.2-1/T11.2-5/T11.2-6 specs/C.mdx (the finding-free file; T11.2-6's outDir fixture stages it at specs/A.mdx too)", + 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: [], + }, + ], +}; + +/** + * The finding-free C-shaped document's tree projection at `file`: a node + * identity is formed over the file's path (SPEC 1.5), so the same bytes + * staged at another path project the same ranges and attribute entries + * under that path's identities (T11.2-6 stages them at `specs/A.mdx`). + */ +function cShapedTreeAt(file: string): TreeExpectation { + return { + identity: file, + range: C_ROOT_RANGE, + attributes: [], + children: [ + { + identity: `${file}#c`, + range: C_SECTION_RANGE, + attributes: [C_ID], + children: [], + }, + ], + }; +} + +const C_TREE: TreeExpectation = cShapedTreeAt(C_FILE); + +// 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_STAGED, + }, + // S-9: B is the staged parse failure (14.20). + mdx: { unparseable: [B_FILE] }, + }); + 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; +// T11.3-1 restages the file after its first product invocation (S-9's +// before-any-product clause): a record made from the string the pins use. +export const OK_STAGED = stagedMdx( + "T11.2-3/T11.3-1 specs/OK.mdx (the valid-path contrast)", + 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_STAGED, + [HP_FILE]: HP_SOURCE, + [CS_FILE]: CS_SOURCE, + }, + }); + try { + // This staging precedes the body's first product invocation (the gate + // `build` below), so S-7's sweep reaches it against the stub: plain + // contents, no ledger record (helpers/staged-mdx.ts). + 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). +// - The enclosure arm stages the entry's construct in two spellings, one +// workspace each — the entry's own one-line spelling (a paragraph holding +// text-position tags under the stock grammar) and the flow-tag spelling +// with `<div>` and `</div>` each alone on its line — since 11.2's rule +// ("an element by its own tags") is spelling-blind while the two derive +// as different MDX node kinds; each pins the 14.16 finding's range +// EXACTLY (`<div>`'s first byte through `</div>`'s last byte, the line +// terminator after `</div>` excluded — the one range the entry fixes), +// the text values as hand-derived literals, and the occurrence's `source` +// as the root's identity with its whole-file range (SPEC 1.5, 1.7). + +/** 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; +// T11.3-1 restages the file after its first product invocation (S-9's +// before-any-product clause): a record made from the string the pins use. +export const R_STAGED = stagedMdx( + "T11.2-4/T11.3-1 specs/R.mdx (the resolution matrix)", + 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; +// The chain workspace follows the resolution matrix's invocations (S-9's +// before-any-product clause): records made from the strings the pins use. +const CH_A_STAGED = stagedMdx( + "T11.2-4 specs/CH-A.mdx (the embedding chain)", + CH_A_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_STAGED = stagedMdx( + "T11.2-4 specs/CH-B.mdx (the embedding chain)", + CH_B_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_STAGED = stagedMdx( + "T11.2-4 specs/CH-C.mdx (the embedding chain)", + CH_C_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; +// A later workspace of the body (S-9's before-any-product clause). +const CY_STAGED = stagedMdx( + "T11.2-4 specs/CY.mdx (the self-embedding cycle)", + 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; +// A later workspace of the body (S-9's before-any-product clause). +const IMP_STAGED = stagedMdx( + "T11.2-4 specs/IMP.mdx (the importer of GONE.mdx)", + 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_STAGED = stagedMdx( + "T11.2-4 specs/GONE.mdx (the imported file, deleted mid-arm)", + GONE_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 }, +]; + +// --- staging 5: enclosure — a stray element enclosing a section and an +// embedding at the root level --------------------------------------------------- +// +// SPEC 11.2 (removal classification by form): a stray element (14.16) is +// content, preserved by its own tags, and the sections and embeddings it +// encloses are classified by their own forms — the enclosed section a node +// of the positional tree parented to the innermost enclosing SECTION +// construct, the root when none encloses it (SPEC 11.4, T11.4-1), and the +// enclosed embedding a resolving spelling of the root's own contribution: +// its occurrence records the root as its source graph node (5.7). The same +// construct is staged in two spellings, one workspace each — (a) the +// entry's own one-line spelling `<div><S id="x">t</S>{text("y")}</div>` +// (under the stock grammar a paragraph holding text-position tags: a flow +// JSX attempt fails at the `t` after `<S id="x">`), and (b) the flow-tag +// spelling with `<div>` and `</div>` each alone on its line — both deriving +// (S-9's default declaration; the builder judges them at staging). `y` +// precedes the element, so the expansion reads a closed same-file target. +// +// Text derivation (SPEC 3, 1.6), shared by both spellings: lines 1 and 3 +// hold nothing but y's removed tags and drop with their terminators; line 2 +// (`Y text.` LF) is kept and is y's whole contribution; `<div>` and `</div>` +// match no removal rule's form and stay as content; x's tags are removed +// and its `t` stays in place; `{text("y")}` is replaced by y's subtree text +// `Y text.` LF — an expansion belonging to the root's own run. The root's +// subtree text is the whole output; its own text is that output with y's +// and x's contributions excised. + +const ENCL_FILE = "specs/ENCL.mdx"; +const ENCL_Y_TEXT = "Y text.\n"; +const ENCL_X_TEXT = "t"; +const ENCL_Y_CONSTRUCT = '<S id="y">\n' + ENCL_Y_TEXT + "</S>"; +const ENCL_X_CONSTRUCT = '<S id="x">' + ENCL_X_TEXT + "</S>"; +const ENCL_EMBED_TEXT = '{text("y")}'; + +/** One enclosure spelling: its staged bytes, composed ranges, and pins. */ +interface EnclosureStaging { + readonly label: string; + readonly source: string; + /** The staged bytes as a ledger record (S-9's before-any-product clause). */ + readonly staged: StagedMdx; + /** `<div>`'s first byte through `</div>`'s last byte (the 14.16 range). */ + readonly divRange: SourceRange; + readonly divText: string; + readonly yRange: SourceRange; + readonly xRange: SourceRange; + readonly embedRange: SourceRange; + readonly rootRange: SourceRange; + readonly textTree: TextTreeExpectation; + readonly occurrences: readonly OccurrenceRecord[]; +} + +/** + * Compose one spelling: `afterOpen` follows `<div>` and `beforeClose` + * precedes `</div>` ("" for the one-line spelling, LF for the flow-tag + * spelling). Every range is composed from the same parts that stage the + * file; the expected root text values are pinned literals, hand-derived + * per spelling (below) — never computed from the staging. + */ +function stageEnclosure( + label: string, + afterOpen: string, + beforeClose: string, + rootOwn: string, + rootSubtree: string, +): EnclosureStaging { + const f = new ByteFixture(); + const yRange = f.add(ENCL_Y_CONSTRUCT); + f.add("\n"); + const divStart = f.pos; + f.add("<div>" + afterOpen); + const xRange = f.add(ENCL_X_CONSTRUCT); + const embedRange = f.add(ENCL_EMBED_TEXT); + f.add(beforeClose + "</div>"); + const divRange: SourceRange = { start: divStart, end: f.pos }; + f.add("\n"); + const rootRange: SourceRange = { start: 0, end: f.pos }; + return { + label, + source: f.source, + staged: stagedMdx(`T11.2-4 specs/ENCL.mdx (${label})`, f.source), + divRange, + divText: + "<div>" + + afterOpen + + ENCL_X_CONSTRUCT + + ENCL_EMBED_TEXT + + beforeClose + + "</div>", + yRange, + xRange, + embedRange, + rootRange, + textTree: { + identity: ENCL_FILE, + range: rootRange, + ownText: rootOwn, + subtreeText: rootSubtree, + children: [ + { + identity: `${ENCL_FILE}#y`, + range: yRange, + ownText: ENCL_Y_TEXT, + subtreeText: ENCL_Y_TEXT, + children: [], + }, + { + identity: `${ENCL_FILE}#x`, + range: xRange, + ownText: ENCL_X_TEXT, + subtreeText: ENCL_X_TEXT, + children: [], + }, + ], + }, + occurrences: [ + { + file: ENCL_FILE, + range: embedRange, + kind: "embeds", + // The root as the source graph node: its identity the path alone + // (SPEC 1.5) together with its own range, the entire file (1.7). + source: { identity: ENCL_FILE, range: rootRange }, + target: `${ENCL_FILE}#y`, + }, + ], + }; +} + +// (a) the entry's spelling, one line at the root level: +// root own `<div>` `Y text.` LF `</div>` LF +// root subtree `Y text.` LF `<div>t` `Y text.` LF `</div>` LF +const ENCL_A = stageEnclosure( + "the entry's one-line spelling", + "", + "", + "<div>Y text.\n</div>\n", + "Y text.\n<div>tY text.\n</div>\n", +); +// (b) the flow-tag spelling, `<div>` and `</div>` each alone on its line: +// root own `<div>` LF `Y text.` LF LF `</div>` LF +// root subtree `Y text.` LF `<div>` LF `t` `Y text.` LF LF `</div>` LF +const ENCL_B = stageEnclosure( + "the flow-tag spelling", + "\n", + "\n", + "<div>\nY text.\n\n</div>\n", + "Y text.\n<div>\ntY text.\n\n</div>\n", +); +const ENCL_STAGINGS: readonly EnclosureStaging[] = [ENCL_A, ENCL_B]; + +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; enclosure: `<div><S id=\"x\">t</S>{text(\"y\")}</div>` staged in flow position at the root level (the entry's one-line spelling and the flow-tag spelling, one workspace each), `y` a section of the same file → exactly one 14.16 finding located `<div>` through `</div>`, node `x` in the view's tree a child of the root (never of the element, T11.4-1), the embedding's occurrence recorded with the root as its `source`, and under `view --text` the root's own text carrying `<div>` and `</div>` with `x`'s whole contribution excised and the embedding expanded to `y`'s subtree text, its subtree text carrying `t` in place with `x`'s tags removed — each byte-asserted via bare `view --text` and bare `occurrences` (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", + ); + for (const staging of ENCL_STAGINGS) { + const what = `the enclosure staging (${staging.label})`; + sliceCheck( + staging.source, + staging.divRange, + staging.divText, + `${what}: the stray element, <div> through </div>`, + ); + sliceCheck( + staging.source, + staging.yRange, + ENCL_Y_CONSTRUCT, + `${what}: y`, + ); + sliceCheck( + staging.source, + staging.xRange, + ENCL_X_CONSTRUCT, + `${what}: x`, + ); + sliceCheck( + staging.source, + staging.embedRange, + ENCL_EMBED_TEXT, + `${what}: the enclosed embedding container`, + ); + sliceCheck( + staging.source, + staging.rootRange, + staging.source, + `${what}: the root's whole-file range`, + ); + } + + // 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_STAGED, + }, + }); + 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_STAGED, + [CH_B_FILE]: CH_B_STAGED, + [CH_C_FILE]: CH_C_STAGED, + }, + }); + 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_STAGED, + }, + }); + 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_STAGED, + [GONE_FILE]: GONE_STAGED, + }, + }); + 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(); + } + } + // --- Staging 5: enclosure — a stray element enclosing a section and an + // embedding at the root level, one workspace per spelling (SPEC 11.2: + // the element is preserved by its own tags, the section and the + // embedding it encloses classified by their own forms). + for (const staging of ENCL_STAGINGS) { + // Shared: exactly the one staged condition, located by the element's + // own tags (the identical finding must accompany both surfaces). + const assertEnclosureFindings = ( + findings: readonly Finding[], + context: string, + ): void => { + assertConditionCounts( + findings, + { "14.16": 1 }, + `${context} — the stray element is the workspace's ONLY ` + + `condition: exactly one 14.16 and no phantom condition — the ` + + `enclosed section and embedding are valid by their own forms ` + + `(SPEC 11.2, 2.7, 14)`, + ); + const finding = findingByCondition(findings, "14.16", context); + assertLocatedFinding( + finding, + [{ file: ENCL_FILE, window: staging.divRange }], + `${context} — the stray element is located by its finding, one ` + + `location (SPEC 11.2, 14)`, + ); + assertSameJson( + finding.locations, + [{ file: ENCL_FILE, range: staging.divRange }], + `${context} — the element is located by its own tags: EXACTLY ` + + `<div>'s first byte through </div>'s last byte, the line ` + + `terminator after </div> excluded (SPEC 11.2, 14)`, + ); + }; + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [ENCL_FILE]: staging.staged, + }, + }); + try { + const viewContext = `T11.2-4 bare \`view --text\` (enclosure: ${staging.label})`; + const viewResult = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${viewContext} — the stray element's 14.16 finding accompanies, ` + + `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 (SPEC 11)`, + ), + { text: true }, + viewContext, + ); + assertEnclosureFindings(viewReport.findings, viewContext); + assertSameJson( + viewReport.views.map((view) => view.file), + [ENCL_FILE], + `${viewContext} — one per-file view: the enclosure file (SPEC 11.4)`, + ); + const enclView = viewReport.views[0]!; + assertSameJson( + projectTextNode(enclView.root), + staging.textTree, + `${viewContext} — the positional tree and the text values: the ` + + `root's children in document order y then x — x a child of the ` + + `ROOT, the innermost enclosing section construct, never of the ` + + `element, which gets no view node (SPEC 11.4, T11.4-1) — each ` + + `with its construct range; the root's own text carrying <div> ` + + `and </div> as content with x's whole contribution excised and ` + + `the embedding expanded to y's subtree text, its subtree text ` + + `carrying x's t in place with x's tags removed; y's and x's ` + + `values byte-exact (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + enclView.occurrences, + staging.occurrences, + `${viewContext} — the enclosed embedding's occurrence: the one ` + + `record, brace through brace, kind embeds, its source the ROOT ` + + `— identity the path alone with the whole-file range — and ` + + `target y (SPEC 5.7, 1.5, 1.7, 11.2)`, + ); + assertSameJson( + [enclView.imports, enclView.comments], + [[], []], + `${viewContext} — no imports or comments staged: empty arrays, ` + + `never null (SPEC 12.7)`, + ); + + const occContext = `T11.2-4 bare \`occurrences\` (enclosure: ${staging.label})`; + const occResult = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${occContext} — the same 14.16 finding accompanies the ` + + `enumeration, 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, + ); + assertEnclosureFindings(occReport.findings, occContext); + assertSameJson( + occReport.occurrences, + staging.occurrences, + `${occContext} — the workspace's COMPLETE enumeration: exactly ` + + `the enclosed embedding's record with the root as its source ` + + `(SPEC 5.7, 11.2, 11.3)`, + ); + } 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; +// The cycle-pair workspace follows the body's first invocations (S-9's +// before-any-product clause): records made from the strings the pins use. +const D_STAGED = stagedMdx("T11.2-5 specs/D.mdx (the cycle pair)", 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_STAGED = stagedMdx("T11.2-5 specs/E.mdx (the cycle pair)", 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_STAGED, + [WRONG_KIND_CODE_FILE]: WRONG_KIND_CODE_SOURCE, + }, + mdx: { unparseable: [B_FILE] }, + }); + 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_STAGED, + [D_FILE]: D_STAGED, + [E_FILE]: E_STAGED, + }, + }); + 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 three 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 no in-source location and a concerned path that +// names a domain file's own finding for condition 19 alone (SPEC 11.2: an +// obstructing component that is itself a discovered file attaches +// nothing) — 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 +// too ({14.13: 1}: the workspace fails `build`'s validations — journal +// errors alike, SPEC 13.3 — so 14.10's mismatch forms, per file and graph +// data, are undetectable and go unreported, SPEC 14.10, while the two +// forms reported whatever the validity have nothing here: the record the +// passing build wrote stays readable, no 14.23 state, and every recorded +// derived path is still generated, no recorded-file form; the exact pin +// discriminates a product reporting phantom staleness beside the journal +// error — the unit form of a hash comparison the journal's canonical +// identities, SPEC 5.4, leave unverifiable, or the generated files as +// stale against a regeneration the failing sources leave undefined) — +// 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.22: 1} concerning +// `mdout`: the swap deleted the emitted Markdown, but a workspace whose +// `build` is refused fails `build`'s validations — source validation +// errors, journal errors, and refused writes alike (SPEC 13.3) — and on +// such a workspace 14.10's mismatch forms, per file and graph data, are +// undetectable and go unreported (SPEC 14.10, 14), the missing +// `mdout/specs/C.md` included; the two forms reported whatever the +// validity have nothing here — the record is readable (no 14.23 state; +// contrast T10.1-6's `.xspec`-obstructed arm, where `check` reports the +// unreadable-record unit form beside the refusal) and every recorded +// derived path is still generated (no recorded-file form). The exact pin +// discriminates a product reporting the deleted emission as per-file +// staleness beside the refusal — the deviation the earlier +// {14.10: 1, 14.22: 1} pin demanded. 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). +// +// Fixture 3 (the obstructing component is itself a discovered file — 14.22 +// under 11.2's concerned-path clause): `markdown.outDir` names the +// discovered spec source `specs/A.mdx`, a plain file holding the +// finding-free C-shaped bytes, so every emit destination +// (`specs/A.mdx/specs/A.md`, and `specs/A.mdx/specs/C.md` for the second +// source staged beside it) lies below that file — the one offending +// component whatever write paths it refuses (SPEC 14.22: one finding per +// distinct offending component). The source stays discovered: 13.4 +// excludes the files AT the emit destinations alone, and 7.3 admits the +// spelling, the occupant judged only where the write is made (13.4). The +// workspace is never built: the refusal precedes every write of the +// command's own (SPEC 14.22, 12.1), so the whole-root compares are at +// their sharpest — a product generating the modules or graph data before +// refusing the emission, or refreshing graph data on the failing side at +// `view`, is caught byte-wise where a pre-built ground would hide an +// identical rewrite. `build --json` reports exactly {14.22: 1} concerning +// `specs/A.mdx`, exit 1, nothing written. `check --json` reports exactly +// that finding, {14.22: 1}: the workspace fails `build`'s validations (a +// refused write, SPEC 13.3), so the unemittable destinations and the +// never-generated modules — per-file mismatch — and the absent graph data +// — the unit mismatch form — are undetectable and go unreported (SPEC +// 14.10), and no record exists to be unreadable (14.23) or to hold a +// recorded path the configuration no longer generates (the two forms +// reported whatever the validity); exit 1, nothing written. Then `view +// specs/A.mdx`: complete and finding-free, +// exit 0 — the concerned path IS the requested file, and still the finding +// is no domain file's, a concerned path naming a domain file's own finding +// for condition 19 alone (SPEC 11.2): the discriminating arm against a +// product attaching findings by concerned path. +// +// 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). +// +// Fixtures 2 and 3 are created after fixture 1's invocations, so their +// configurations are TypeScript staged-source records (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), both well-formed; fixture 1's is +// SPECS_ONLY_CONFIG, a record itself. + +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 = stagedTs( + "T11.2-6 obstructed-write-path fixture xspec.config.ts — Markdown emission under mdout", + `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"; + +// Fixture 3: the emit directory is the discovered spec source itself — a +// well-formed outDir spelling (SPEC 7.3: non-empty segments, none `.` or +// `..`), its occupant judged only where the write is made (13.4, 14.22). +const T11_2_6_SOURCE_OUTDIR_CONFIG = stagedTs( + "T11.2-6 discovered-component fixture xspec.config.ts — markdown.outDir naming the discovered spec source", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true, outDir: "${A_FILE}" } +}) +`, +); + +/** + * The T11.2-6 never-attach arm: `view` naming the finding-free file + * (C-shaped bytes at `file`, projecting `tree`) answers complete and + * finding-free at exit 0 — whatever journal or write-path state the + * workspace holds, the obstructing component being the viewed file itself + * included (SPEC 11.2) — modifying nothing. + */ +async function assertViewFindingFree( + product: ProductBinding, + workspace: TestWorkspace, + file: string, + tree: TreeExpectation, + context: string, +): Promise<void> { + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", 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; a concerned path names a domain file's ` + + `own finding for condition 19 alone, never for a write-path ` + + `component, discovered file or not), 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), + [file], + `${context} — exactly the requested file's view (SPEC 11.4)`, + ); + const fileView = report.views[0]!; + assertSameJson( + projectNode(fileView.root), + tree, + `${context} — ${file}'s complete view: the answer is served ` + + `whole, from the current sources (SPEC 11.2, 11.4)`, + ); + assertSameJson( + [fileView.imports, fileView.occurrences, fileView.comments], + [[], [], []], + `${context} — ${file} 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), separately with the `markdown.outDir` directory replaced by a plain file (14.22, the obstructed emit write path's one offending component), and separately, on a never-built workspace, with `markdown.outDir` naming the discovered finding-free spec source `specs/A.mdx` itself (14.22 whose one offending component is a discovered file, every emit destination lying below that plain file), `view` of the finding-free file — in the third fixture `specs/A.mdx` itself, a concerned path naming a domain file's own finding for condition 19 alone (11.2) — 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` (in every fixture exactly the gate condition — a workspace failing `build`'s validations, a journal error and a refused write alike (13.3), leaves 14.10's mismatch forms, the deleted emission and the never-generated derived files included, unreported, and neither whatever-validity form is staged: the record, where a build wrote one, stays readable and every recorded derived path still generated (14.10) — concerning the journal path, and the offending component, `mdout` and `specs/A.mdx`), 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, 13.4, 7.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_STAGED, + }, + }); + 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): exactly the gate + // condition. The workspace fails `build`'s validations (journal + // errors alike, SPEC 13.3), so 14.10's mismatch forms — per file + // and graph data — are undetectable and go unreported (SPEC 14.10), + // and the two forms reported whatever the validity have nothing + // here: the record the passing build wrote stays readable (no 14.23 + // state) and every recorded derived path is still generated (no + // recorded-file form). No phantom staleness is accepted beside the + // journal error (the module comment). + 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; + assertConditionCounts( + findings, + { "14.13": 1 }, + `${checkContext} — exactly the journal error: the workspace ` + + `fails \`build\`'s validations, so 14.10's mismatch forms ` + + `go unreported, and neither whatever-validity form is ` + + `staged — the record readable, every recorded path still ` + + `generated (SPEC 12.2, 14.13, 13.3, 14.10)`, + ); + assertFindingConcernsPath( + findings[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 assertViewFindingFree( + product, + workspace, + C_FILE, + C_TREE, + `${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_STAGED, + }, + }); + 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 — a per-file mismatch `check` leaves + // unreported on a workspace failing `build`'s validations, refused + // writes included (SPEC 14.10, 13.3), and invisible to `build`, + // which refuses at the obstruction (SPEC 13.4, 14.22). + 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`: exactly the obstruction. The swap's + // deleted emission is a per-file mismatch, and a workspace whose + // `build` is refused fails `build`'s validations (SPEC 13.3), where + // 14.10's mismatch forms are undetectable and go unreported (SPEC + // 14.10); the record stays readable and every recorded derived + // path still generated, so neither whatever-validity form is + // staged (module header). + 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.22": 1 }, + `${checkContext} — exactly the obstructed component: a ` + + `refused write fails \`build\`'s validations, so the ` + + `deleted ${T11_2_6_EMITTED} is a per-file mismatch 14.10 ` + + `leaves unreported there, and the readable record with ` + + `every recorded path still generated stages neither form ` + + `reported whatever the validity (SPEC 14.22, 13.3, 14.10, ` + + `12.2, 14)`, + ); + assertFindingConcernsPath( + findings[0]!, + T11_2_6_OUTDIR, + `${checkContext} — the refused write's concerned path (SPEC ` + + `14.22, 13.4)`, + ); + }, + `${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 assertViewFindingFree( + product, + workspace, + C_FILE, + C_TREE, + `${context} \`view ${C_FILE}\``, + ); + } finally { + await workspace.dispose(); + } + } + + // --- Fixture 3: the obstructing component is a discovered file (14.22) -- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": T11_2_6_SOURCE_OUTDIR_CONFIG, + [A_FILE]: C_STAGED, + [C_FILE]: C_STAGED, + }, + }); + try { + const context = "T11.2-6 (obstructing component a discovered file)"; + + // Never built: the refusal precedes every write of the command's + // own (SPEC 14.22, 12.1), so `build` writes no module, companion, + // Markdown, or graph data here — the whole-root compare sees any. + const buildContext = `${context} \`build --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + `${buildContext} — every emit destination lies below the ` + + `plain file ${A_FILE}, a workspace-relative directory ` + + `component occupied by a non-directory: the write is ` + + `refused and reported; the spelling is a well-formed ` + + `outDir, no configuration error (SPEC 14.22, 13.4, 7.3)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + { "14.22": 1 }, + `${buildContext} — one finding per distinct offending ` + + `component whatever write paths it refuses: both refused ` + + `destinations share the component ${A_FILE}, and the ` + + `valid sources stage nothing else (SPEC 14.22, 12.1)`, + ); + assertFindingConcernsPath( + findings[0]!, + A_FILE, + `${buildContext} — the concerned path is the offending ` + + `component's workspace-relative path: the discovered ` + + `source itself (SPEC 14.22, 13.4)`, + ); + }, + `${buildContext} — the write is refused before any write of the ` + + `command's own: no module, companion, Markdown, or graph data ` + + `written, the source byte-unchanged (SPEC 14.22, 12.1)`, + ); + + // `check` judges exactly `build`'s write paths (SPEC 14.22, 12.2): + // exactly the obstruction. The workspace fails `build`'s + // validations (a refused write, SPEC 13.3), so the unemittable + // destinations, the never-generated modules, and the absent graph + // data are 14.10's mismatch forms, undetectable and unreported + // there (SPEC 14.10), and no record exists for its two + // whatever-validity forms (module header); writing nothing. + const checkContext = `${context} \`check --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` judges \`build\`'s write paths ` + + `and reports the obstruction without writing (SPEC 14.22, ` + + `12.2)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, checkContext), + checkContext, + ).findings; + assertConditionCounts( + findings, + { "14.22": 1 }, + `${checkContext} — exactly the one offending component: on ` + + `this never-built workspace whose \`build\` is refused, ` + + `14.10's mismatch forms go unreported and no record exists ` + + `for its whatever-validity forms (SPEC 14.22, 13.3, 14.10, ` + + `12.2)`, + ); + assertFindingConcernsPath( + findings[0]!, + A_FILE, + `${checkContext} — the refused write's concerned path is the ` + + `discovered source occupying the component (SPEC 14.22, ` + + `13.4)`, + ); + }, + `${checkContext} — \`check\` writes nothing (SPEC 12.2, 13.3)`, + ); + + // ...never this answer: `view` of the discovered file that IS the + // obstructing component. Its concerned path names the requested + // file, and still the finding is no domain file's — a concerned + // path names a domain file's own finding for condition 19 alone + // (SPEC 11.2) — so the answer is complete and finding-free at exit + // 0, served from the current sources, nothing written. + await assertViewFindingFree( + product, + workspace, + A_FILE, + cShapedTreeAt(A_FILE), + `${context} \`view ${A_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 00000000..19deaf08 --- /dev/null +++ b/test/suite/registry/section-11.3.ts @@ -0,0 +1,2159 @@ +// 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 — +// TEST-SPEC's classes plus the four quote, escape, and character-reference +// characters in a segment and U+FFFD in the path or the id part — are +// syntax-class usage errors (SPEC 12.0): each runs through the shared +// `expectSyntaxClassUsageError` (registry/support.ts) on this same failing +// workspace (the plain usage error's document, the argument check preceding +// answering whatever findings the workspace carries) and again on two twins +// holding the discovered specs/OK.mdx under an invalid and under no +// configuration, byte-identically — reported without loading configuration, +// so before every identity reading, which discovery would have to precede +// (T12.0-10's discipline); 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.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_STAGED, + NO_OCC_EXPECTED_CONDITIONS, + NO_OCC_MAIN_STAGED, + NO_OCC_SPARE_FILE, + NO_OCC_SPARE_STAGED, + NO_OCC_UNITS, + ORD_ALPHA_FILE, + ORD_ALPHA_STAGED, + ORD_APP_FILE, + ORD_APP_SOURCE, + ORD_EXPECTED, + ORD_ZED_FILE, + ORD_ZED_STAGED, + SPAN_ARMS, + SPAN_APP_SOURCE, + SPAN_BASE_STAGED, + SPAN_MAIN_STAGED, + SPEC_AND_CODE_CONFIG, + T5_7_1_APP_SOURCE, + T5_7_1_BASE_SOURCE, + T5_7_1_MAIN_SOURCE, + T5_7_1_UNITS, +} from "./section-5.7.js"; +import { + CS_EXPECTED_OCCURRENCES, + CS_FILE, + CS_SOURCE, + OK_FILE, + OK_STAGED, + R_CONDITION_COUNTS, + R_EXPECTED_OCCURRENCES, + R_FILE, + R_SOURCE, + R_STAGED, + SPEC_AND_CODE_CONFIG as AVAILABILITY_SPEC_AND_CODE_CONFIG, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; +import type { ConfigurationStateTwins } from "./support.js"; +import { + BESIDE_ROOT_FILE_PATTERN_DECOY, + INSIDE_NO_MATCH_FILE_PATTERNS, + OUTSIDE_ROOT_FILE_PATTERNS, + REPLACEMENT_CHARACTER, + assertConditionCounts, + assertFindingLocated, + assertSameJson, + buildFindings, + buildOk, + expectExit, + expectFilePatternUsageError, + expectSyntaxClassUsageError, + runJson, + stageBesideRoot, + stageConfigurationStateTwins, +} 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)`, + ); + } + } +} + +// T11.3-1's later workspaces — every one but the first is created after the +// body's first product invocation — stage their code sources and +// configurations as TypeScript staged-source records (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), all well-formed. The constants the +// owning modules export stay plain there (their own bodies stage them +// before any invocation), so each is wrapped here, the same expression +// moved: section-5.7.ts's three fixtures' `src/app.ts`, and section-11.2.ts's +// code-group configuration and invalid-path code source. section-5.7.ts's +// SPEC_AND_CODE_CONFIG and section-11.2.ts's SPECS_ONLY_CONFIG are records +// themselves, staged as imported (T11.3-2's and T11.3-3's later workspaces +// stage the latter too). +const T11_3_1_SPAN_APP_SOURCE = stagedTs( + "T11.3-1 T5.7-2 fixture src/app.ts (section-5.7.ts's SPAN_APP_SOURCE)", + SPAN_APP_SOURCE, +); +const T11_3_1_ORD_APP_SOURCE = stagedTs( + "T11.3-1 T5.7-3 fixture src/app.ts (section-5.7.ts's ORD_APP_SOURCE)", + ORD_APP_SOURCE, +); +const T11_3_1_NO_OCC_APP_SOURCE = stagedTs( + "T11.3-1 T5.7-4 fixture src/app.ts (section-5.7.ts's NO_OCC_APP_SOURCE)", + NO_OCC_APP_SOURCE, +); +const T11_3_1_CS_CONFIG = stagedTs( + "T11.3-1 invalid-path code-source workspace xspec.config.ts — one spec group and one code group (section-11.2.ts's SPEC_AND_CODE_CONFIG)", + AVAILABILITY_SPEC_AND_CODE_CONFIG, +); +const T11_3_1_CS_SOURCE = stagedTs( + "T11.3-1 invalid-path code-source workspace src/co#de.ts (section-11.2.ts's CS_SOURCE)", + CS_SOURCE, +); + +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_STAGED, + "specs/MAIN.mdx": SPAN_MAIN_STAGED, + "src/app.ts": T11_3_1_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_STAGED, + [ORD_ALPHA_FILE]: ORD_ALPHA_STAGED, + [ORD_APP_FILE]: T11_3_1_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_STAGED, + [NO_OCC_SPARE_FILE]: NO_OCC_SPARE_STAGED, + [MAIN_FILE]: NO_OCC_MAIN_STAGED, + [APP_FILE]: T11_3_1_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": T11_3_1_CS_CONFIG, + [OK_FILE]: OK_STAGED, + [CS_FILE]: T11_3_1_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_STAGED, + }, + }); + 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"; + +// Workspace 2's initial files, created after the body's first product +// invocation (workspace 1's runs), so S-7's sweep never reaches them +// against the stub: staged-source records (helpers/staged-mdx.ts; S-9's +// before-any-product clause), the same expressions moved into them. +const CONJ_T_SOURCE = stagedMdx( + "T11.3-2 specs/T.mdx (the conjunction workspace)", + [ + '<S id="x">', + "X text.", + "</S>", + "", + '<S id="y">', + "Y text.", + "</S>", + "", + ].join("\n"), +); + +const CONJ_P_SOURCE = stagedMdx( + "T11.3-2 specs/P.mdx (the conjunction workspace)", + [ + 'import T from "./T.xspec"', + "", + '<S id="p" d={[T.x, T.y]}>', + "P text.", + "</S>", + "", + ].join("\n"), +); + +const CONJ_Q_SOURCE = stagedMdx( + "T11.3-2 specs/Q.mdx (the conjunction workspace)", + [ + '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 inside pattern spelled with a `.` or empty segment (`./specs/*.mdx`, `specs//*.mdx`) admits the empty set the same way, its normalized form matching the staged spec files; an outside-root pattern by spelling alone (`../x/*.mdx`, `../x`, `a/../../x`, `/specs/*.mdx` — SPEC 7's depth rule) exits 2 as an invalid flag value with the single 12.7 error document, code and path null, a matching file beside the root notwithstanding, 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, + }, + // S-9: the undiscovered decoy is deliberately unparseable (14.20). + mdx: { unparseable: [FILTER_TRAP_FILE] }, + }); + // The file the ascending outside-root spellings name when resolved, + // beside the root (T7-4's discipline: exit 2 never from a side reason). + await stageBesideRoot(workspace, BESIDE_ROOT_FILE_PATTERN_DECOY); + 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. + // Then the inside-root spellings with a `.` or an empty segment + // (SPEC 7, 12.0): admitted, matching nothing — a discovered + // path carries no such segment — while their normalized form + // `specs/*.mdx` matches apple and beta, the files a normalizing + // product then consults (exit 1, their findings accompanying). + const emptySetGlobs: readonly (readonly [string, string])[] = [ + [ + "docs/*.mdx", + "matching the on-disk but UNDISCOVERED docs/note.mdx", + ], + ["nosuch/**/*.mdx", "matching nothing at all"], + ...INSIDE_NO_MATCH_FILE_PATTERNS.map( + ({ spelling, why }): readonly [string, string] => [ + spelling, + `inside the root by spelling, ${why}`, + ], + ), + ]; + for (const [glob, what] of emptySetGlobs) { + 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 by spelling alone (SPEC 7's depth + // rule, as 11.1) is an invalid flag value, exit 2 (SPEC 11.3, + // 12.0): the argument check precedes answering (11.2), whatever + // findings the named files carry — asserted on this failing + // workspace, the matching file beside the root notwithstanding, + // through the shared plain-usage-error protocol (single 12.7 + // error document, code and path null, message on stderr). + for (const { spelling, why } of OUTSIDE_ROOT_FILE_PATTERNS) { + await expectFilePatternUsageError( + product, + workspace, + ["occurrences", "--file", spelling], + `T11.3-2 \`occurrences --file ${JSON.stringify(spelling)}\` ` + + `(${why}) 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; the quote, escape, and + * character-reference characters one arm each of the four; U+FFFD in the + * path part — bare, and beside a well-formed id — and in the id part), 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 — inside its real id `ok` + * for the character arms — so a product that resolves first and errs only + * on unknown names answers (empty or otherwise) and fails the exit + * assertion. The characters are built from their code points or the shared + * `REPLACEMENT_CHARACTER`, so no tool layer decodes a spelling on the way + * into this file. + */ +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}#o${String.fromCodePoint(0x22)}k`, + 'a segment containing the quote character `"`', + ], + [ + `${TO_OK_FILE}#o${String.fromCodePoint(0x27)}k`, + "a segment containing the quote character `'`", + ], + [ + `${TO_OK_FILE}#o${String.fromCodePoint(0x5c)}k`, + "a segment containing the escape character `\\` (the `a.mdx#x\\y` class)", + ], + [ + `${TO_OK_FILE}#o&k`, + "a segment containing the character-reference character `&` (the " + + "`a.mdx#x&y` class)", + ], + [`${TO_OK_FILE}#`, "a trailing empty id part (the `a.mdx#` class)"], + [ + `specs/O${REPLACEMENT_CHARACTER}K.mdx#ok`, + "U+FFFD in the path part beside a well-formed id (12.0's argument-value " + + "rule: no argument value carries it)", + ], + [ + `specs/O${REPLACEMENT_CHARACTER}K.mdx`, + "U+FFFD in a bare path — the whole spelling is the path part", + ], + [ + `${TO_OK_FILE}#o${REPLACEMENT_CHARACTER}k`, + "U+FFFD in the id part (12.0's argument-value rule)", + ], +]; + +// 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"; + +// Workspace 2's initial files, created after the body's first product +// invocation (workspace 1's runs), so S-7's sweep never reaches them +// against the stub: staged-source records (helpers/staged-mdx.ts; S-9's +// before-any-product clause), the same expressions moved into them. +const SEL_BASE_SOURCE = stagedMdx( + "T11.3-3 specs/BASE.mdx (the selection workspace)", + [ + '<S id="top">', + "Top text.", + "", + '<S id="top.sub">', + "Sub text.", + "</S>", + "</S>", + "", + ].join("\n"), +); + +const SEL_USE_SOURCE = stagedMdx( + "T11.3-3 specs/USE.mdx (the selection workspace)", + [ + '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 segment containing `\"`, `'`, `\\`, or `&` (one arm each), a trailing empty id part, and U+FFFD in the path part (bare, and beside a well-formed id) or in the id part — each exit 2 with the single 12.7 error document (the plain usage error: `code` and `path` null), the argument check preceding answering whatever findings the workspace carries and preceding every identity reading — reported without loading configuration, byte-identically with the configuration file invalid or missing (T12.0-10's discipline), nothing modified; 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, + }, + // S-9: the masked file is the staged parse failure (14.20). + mdx: { unparseable: [TO_MASKED_FILE] }, + }); + let twins: ConfigurationStateTwins | undefined; + try { + // The configuration-state twins of the malformed sweep: the + // discovered OK.mdx alone, under an invalid and under no + // configuration (SPEC 12.0; T12.0-10's discipline). + twins = await stageConfigurationStateTwins({ + [TO_OK_FILE]: TO_OK_SOURCE, + }); + const stagedTwins = twins; + 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 (SPEC 11.3, 1.4, 12.0): each a + // malformed value of the syntax class — exit 2 with the plain + // usage error's document (the surface is JSON-only, so JSON + // output is in effect; `code` and `path` null), the argument + // check preceding answering whatever findings the workspace + // carries — reported without loading configuration: + // identically, byte for byte, on the twins holding the same + // discovered file under an invalid and under no configuration + // (T12.0-10's discipline), so the check precedes every identity + // reading, which discovery would have to precede. + for (const [spelling, what] of TO_MALFORMED) { + await expectSyntaxClassUsageError( + product, + workspace, + stagedTwins, + ["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 twins?.dispose(); + 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"); + +// Staged between T11.3-4's arms — after arm 1's invocation, so S-7's sweep +// never reaches it against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause), the same +// expression moved into the record. +const EMPTY_HOLDER_SOURCE = stagedMdx( + "T11.3-4 specs/holder.mdx holding the workspace's one resolving occurrence of tgt (staged between the arms)", + [ + '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 00000000..3650df02 --- /dev/null +++ b/test/suite/registry/section-11.4.ts @@ -0,0 +1,4385 @@ +// 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). Two +// workspaces, three files, three invocations: +// +// - specs/attrs.mdx, staged via the running-offset builder: a +// five-attribute section tag `<S id="dup" id="dup" note="mystery" +// {...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">`, then the tag-set-form bearers `<S id="bset" +// tags="b a a">` and `<S id="zset" tags="z A">`, each reporting its +// interpreted tags in the 12.7 value form — exactly `["a", "b"]` (byte +// order, duplicates collapsed) and `["A", "z"]` (bytes, never +// case-folded) — compared literally on the `view` node, never re-sorted +// by the harness: a product echoing the spelled order or the duplicate +// fails the form-exact decode (H-3, T12.7-1); the form's `query node` +// and `show --json` carriage is T2.6-1's and T12.4-1's, not driven here +// (CERTIFICATIONS.md §CONF-AVAIL: T11.4-3 drives `view` alone)); 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). +// - The third invocation is a bare `view` over T2.7-3's shared fixture +// (`VALUELESS_TAGS_FIXTURE`, imported from section-2.7: the exact +// specs/A.mdx bytes the build arm stages — `<S id="ok">` then +// `<S id="x" tags>` — with the declared offsets that arm slice-checks +// before staging), alone in its own workspace, so build and view share +// one fixture (TEST-SPEC T11.4-3). The matrix file's valueless `tags` +// rides a tag whose identity the repeated `id` has already withdrawn, +// so it cannot ask what this arm asks: the bearer's identity is +// well-formed and unique, hence defined (SPEC 11.2: exactly one quoted +// static `id` spells; tags/coverage invalidity never undefines identity) +// — asserted the plain `specs/A.mdx#x`, never the marker — while the +// bare-name prop alone leaves its interpreted tags unavailable beside +// the absent-prop default coverage `"required"`, BOTH attribute entries +// listed in tag order (the bare name's text the name alone), and the one +// 14.17 the build reports located within the opening tag's window (the +// same `FindingSourceExpectation` T2.7-3 holds the build's finding to), +// nothing else (no 14.1: the identity is spelled), exit 1. A product +// withdrawing identity on the valueless prop alone, reading the bare +// name as an absent prop (the plain default `[]` where 11.2 leaves the +// value unavailable), or dropping the valueless entry from the listing +// fails here; the sibling `ok` keeps every datum plain as the control. +// +// T11.4-4 — imports (SPEC 11.4, 11.2, 2.1). One workspace — a spec group +// over `specs/` and one code group over `docs/` — four files, three `view`s +// (the bare whole-domain form and two operand forms), the imports member +// asserted as ONE exact list: +// +// - specs/imports.mdx opens with the ten-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; (7) +// `../docs/impl.xspec`, of valid form, designating docs/impl.mdx — an +// `.mdx` file matched only by the code group, a discovered code source +// and no spec source (SPEC 7.2, 2.1); (8) `./B.xspec`, designating the +// discovered spec source specs/B.mdx, which begins with a byte-order mark +// — unparseable (SPEC 1.6, 14.20) and masked, yet discovery, not +// parseability, defines designation; (9) the non-canonical +// `./sub/../BASE.xspec` (no `sub/` on disk) — lexical resolution +// designates specs/BASE.mdx (SPEC 2.1; T2.1-2); (10) the +// semicolon-terminated `import AGAIN from "./BASE.xspec";` — the `;` ends +// ECMAScript's ImportDeclaration (SPEC 14.20), so the range ends after it +// (T3-7's removal arm). After the block, prose holding the embedding +// `{text(B.x)}` into the masked target. +// - Every declaration, valid and invalid, is listed with its range (SPEC +// 11.4): the exact ten-entry compare fails a product that omits invalid +// declarations from the listing, misplaces a byte, or ends the +// semicolon-terminated declaration's range before its `;`. +// - The binding-name datum is the DEFAULT binding's identifier: plain +// 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 or the target's parseability: 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), the masked target's import carries the plain +// "specs/B.mdx" with no 14.15, and the non-canonical spelling reports the +// designated "specs/BASE.mdx", never the spelling; while `./typo.xspec` +// (discovery defines none), the bare specifier (form defines none), and +// the code-source target (no spec source) each carry +// `{"unavailable": true}` literally — never `null` (the decode rejects a +// `null` target outright). +// - Findings: exactly six 14.15 — one per invalid declaration (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 position among them pins which finding is +// which); one 14.6 for the embedding into the masked target, located +// exactly by its full braced container (no spelling resolves into a +// masked file, SPEC 11.2 — the occurrence list stays `[]`); and, exactly +// when B is requested, B's 14.20 at its one zero-length range, offset 0 +// (SPEC 14): the bare view and `view specs/B.mdx` carry it — B +// contributing no view, the latter's views `[]` — while `view +// specs/imports.mdx` does not (a masked file is never consulted by an +// expansion, so its parse-failure finding accompanies only when it is +// itself requested, SPEC 11.4), its imports member the same list. 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 viewed files' root-only +// trees byte-asserted, the roots' stated-null tags/coverage riding the +// decode. docs/impl.mdx's content is read by no invocation (§CONF-AVAIL). +// +// 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: workspaces of `.mdx` spec sources at valid-UTF-8 +// `#`-free paths, imports as the fixtures stage them — T11.4-4's masked +// target a byte-order-mark file (its 14.20 the zero-length range at offset +// 0) and its code group the one the scope admits, its glob matching one +// `.mdx` file no spec glob matches, whose content no invocation reads; +// every command driven is drawn from the enumerated surface — T11.4-1's +// bare whole-domain `view`, T11.4-4's bare `view` plus two `<file>`-operand +// `view`s, T11.4-3's two bare `view`s plus one `<file>`-operand `view`, +// never `occurrences` or `at` — with NO gate-reference `build` (each +// answer's own findings member is the staging integrity) and NO snapshot +// compare (graph-data and refresh behavior are expressly out of CONF-AVAIL +// 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, 14.6, and 14.20 at +// offset 0). 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 — the shared fixture's bare-name `tags` +// among them — read `null` where the test asserts the marker literally (a +// `null` identity fails the form-exact decode outright; `null` +// tags/coverage fail the tree compare against the expected marker); +// under VIOL-AVAIL-OMIT every stated-`null` member its answers carry (each +// root's `tags`/`coverage`, every finding's `null` path, the spread entry's +// `null` name) is absent and the decode rejects the omission, the exit-0 +// 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 three unresolved import targets (`./typo.xspec`, the bare specifier, +// the specifier designating the discovered code source) 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 (its masked-target +// and non-canonical-specifier arms carry defined targets and are unmoved); +// under VIOL-AVAIL-OMIT every stated-`null` member its answers carry (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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + expectAvailabilityUsageError, + SPEC_AND_CODE_CONFIG, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; +import { + assertValuelessTagsFixture, + VALUELESS_TAGS_FIXTURE, + VALUELESS_TAGS_STAGED, +} from "./section-2.7.js"; +import { + BESIDE_ROOT_FILE_PATTERN_DECOY, + INSIDE_NO_MATCH_FILE_PATTERNS, + OUTSIDE_ROOT_FILE_PATTERNS, + assertConditionCounts, + assertFindingLocated, + assertSameJson, + buildFindings, + buildOk, + expectExit, + expectFilePatternUsageError, + runJson, + stageBesideRoot, +} 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, as does an inside pattern spelled with a `.` or empty segment (`./specs/*.mdx`, `specs//*.mdx`) whose normalized form would match the staged spec files; an outside-root glob by spelling alone (`../x/*.mdx`, `../x`, `a/../../x`, `/specs/*.mdx` — the depth rule of SPEC 7) exits 2 as an invalid flag value with the single 12.7 error document, code and path null, a matching file beside the root notwithstanding; 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, + }, + // S-9: the undiscovered decoy is deliberately unparseable (14.20). + mdx: { unparseable: [OV_DECOY_FILE] }, + }); + // The file the ascending outside-root spellings name when resolved, + // beside the root (T7-4's discipline: exit 2 never from a side reason). + await stageBesideRoot(workspace, BESIDE_ROOT_FILE_PATTERN_DECOY); + 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. + // The inside-root spellings with a `.` or an empty segment (SPEC + // 7, 12.0) join the table: admitted, matching nothing — a + // discovered path carries no such segment — while their + // normalized form `specs/*.mdx` matches dup.mdx and bad.mdx, the + // files a normalizing product then serves (bad.mdx's 14.3 + // accompanying, exit 1). + const emptySetGlobs: readonly (readonly [string, string])[] = [ + [ + "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", + ], + ...INSIDE_NO_MATCH_FILE_PATTERNS.map( + ({ spelling, why }): readonly [string, string] => [ + spelling, + `inside the root by spelling, ${why}`, + ], + ), + ]; + for (const [glob, what] of emptySetGlobs) { + 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)`, + ); + } + + // --- An outside-root glob by spelling alone (SPEC 7's depth + // rule, as 11.3 and 11.1) is an invalid flag value, exit 2 (SPEC + // 11.4, 12.0): the argument check precedes answering (11.2), + // whatever findings the workspace carries — the matching file + // beside the root notwithstanding — through the shared + // plain-usage-error protocol (single 12.7 error document, code + // and path null, message on stderr). + for (const { spelling, why } of OUTSIDE_ROOT_FILE_PATTERNS) { + await expectFilePatternUsageError( + product, + workspace, + ["view", "--file", spelling], + `T11.4-2 \`view --file ${JSON.stringify(spelling)}\` (${why}) ` + + `on the failing workspace`, + ); + } + + // --- 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\n"); +// The tag-set-form bearers (SPEC 12.7; TEST-SPEC T11.4-3): `tags="b a a"` +// reports exactly ["a", "b"] — byte order, duplicates collapsed — and +// `tags="z A"` exactly ["A", "z"] — bytes (0x41 < 0x7a), never case-folded. +const CN_BSET_START = CN.pos; +CN.add("<S "); +const CN_BSET_ID = CN.attr("id", 'id="bset"'); +CN.add(" "); +const CN_BSET_TAGS = CN.attr("tags", 'tags="b a a"'); +CN.add(">\nBset text.\n</S>"); +const CN_BSET_RANGE: SourceRange = { start: CN_BSET_START, end: CN.pos }; +CN.add("\n\n"); +const CN_ZSET_START = CN.pos; +CN.add("<S "); +const CN_ZSET_ID = CN.attr("id", 'id="zset"'); +CN.add(" "); +const CN_ZSET_TAGS = CN.attr("tags", 'tags="z A"'); +CN.add(">\nZset text.\n</S>"); +const CN_ZSET_RANGE: SourceRange = { start: CN_ZSET_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: [], + }, + { + // `tags="b a a"`: the interpreted tags in the 12.7 tag-set form — + // byte order, duplicates collapsed — compared literally, never + // re-sorted by the harness (H-3); coverage the absent-prop default. + identity: `${CLEAN_FILE}#bset`, + range: CN_BSET_RANGE, + attributes: [CN_BSET_ID, CN_BSET_TAGS], + tags: ["a", "b"], + coverage: "required", + children: [], + }, + { + // `tags="z A"`: byte order is never case-folded (0x41 < 0x7a). + identity: `${CLEAN_FILE}#zset`, + range: CN_ZSET_RANGE, + attributes: [CN_ZSET_ID, CN_ZSET_TAGS], + tags: ["A", "z"], + coverage: "required", + children: [], + }, + ], +}; + +// T2.7-3's shared fixture (module header): the exact specs/A.mdx bytes the +// build arm stages, with the offsets that arm verifies — build and view +// share one fixture (TEST-SPEC T11.4-3). +const SHARED = VALUELESS_TAGS_FIXTURE; +const SHARED_ROOT_RANGE: SourceRange = { + start: 0, + end: Buffer.byteLength(SHARED.source, "utf8"), +}; + +const SHARED_TREE: AttributeDataShape = { + identity: SHARED.file, + range: SHARED_ROOT_RANGE, + attributes: [], + tags: null, + coverage: null, + children: [ + { + // The valid sibling: every datum plain — its identity defined, its + // absent props the defaults (SPEC 11.2) — the control beside the one + // defect. + identity: `${SHARED.file}#${SHARED.sibling.id}`, + range: SHARED.sibling.sectionRange, + attributes: SHARED.sibling.attributes, + tags: [], + coverage: "required", + children: [], + }, + { + // The bearer: exactly one quoted static `id`, well-formed and unique, + // spells and defines its identity whatever invalid-form prop stands + // beside it (SPEC 11.2) — the plain value, never the marker; BOTH + // attribute entries in tag order, the bare name's text the name alone + // (SPEC 11.4); interpreted tags unavailable (a valueless prop is no + // quoted static string, SPEC 2.7), coverage the absent-prop default. + identity: `${SHARED.file}#${SHARED.id}`, + range: SHARED.sectionRange, + attributes: SHARED.attributes, + tags: UNAVAILABLE, + coverage: "required", + children: [], + }, + ], +}; + +const T11_4_3 = defineProductTest({ + id: "T11.4-3", + title: + 'raw attribute spellings as parsed, one entry per spelled attribute in tag order on the five-attribute tag `<S id="dup" id="dup" note="mystery" {...extras} tags>` — a repeated `id` (BOTH entries), an unknown prop, a spread attribute (its `name` structurally absent — the stated `null` — its source text the whole braced construct), a valueless bare-name `tags` — each entry\'s name, range, and source text byte-asserted against precomputed offsets behind a multi-byte prefix; inclusion is by form: every invalid form stays a listed entry, its invalidity a located finding beside the view, never a view omission — exactly five 14.17 (those four plus a braced `coverage={"none"}` on a second section), each located in the matrix file; per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2, every state carried once (identity unavailable on the repeated-`id` bearer; tags unavailable on the valueless `tags` beside its absent-prop default coverage "required"; coverage unavailable on the braced value beside its defined identity and default empty tags; all three plain in the sibling file); a root\'s `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: the finding-free specs/clean.mdx named as a `<file>` operand exits 0 with them `null`, the bare whole-domain view exiting 1 for the matrix file\'s findings and markers; T2.7-3\'s shared `<S id="x" tags>` fixture (specs/A.mdx, the exact bytes its build arm stages) viewed bare in its own workspace: the bearer\'s identity the plain `specs/A.mdx#x` — exactly one quoted static `id`, well-formed and unique, keeps its defined identity whatever invalid-form prop stands beside it — its `id` and bare-name `tags` entries listed in tag order at the fixture\'s declared offsets, its interpreted tags unavailable beside the absent-prop default coverage "required", the one 14.17 the build reports located within the opening tag\'s window and nothing else (never 14.1), exit 1, the valid sibling every datum plain; tag-set form (12.7): the finding-free file\'s bearers `tags="b a a"` and `tags="z A"` report `tags` exactly `["a", "b"]` and `["A", "z"]` on the `view` node — byte order, duplicates collapsed, never case-folded — compared literally through the form-exact decode, a product echoing the spelled order or the duplicate failing (T12.7-1\'s discipline; the form\'s `query node`/`show --json` carriage is T2.6-1\'s and T12.4-1\'s) (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"], + [CN_BSET_ID, "the bset id"], + [CN_BSET_TAGS, "the bset tags"], + [CN_ZSET_ID, "the zset id"], + [CN_ZSET_TAGS, "the zset tags"], + ] 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, + CN_BSET_RANGE, + '<S id="bset" tags="b a a">\nBset text.\n</S>', + "the bset construct", + ); + sliceCheck( + CLEAN_SOURCE, + CN_ZSET_RANGE, + '<S id="zset" tags="z A">\nZset text.\n</S>', + "the zset construct", + ); + sliceCheck(CLEAN_SOURCE, CLEAN_ROOT_RANGE, CLEAN_SOURCE, "the clean file"); + // The shared fixture's declared offsets against its own bytes: the + // fixture's own check (section-2.7), run here too since this test may + // run alone, plus the root range this module derives. + assertValuelessTagsFixture(); + sliceCheck( + SHARED.source, + SHARED_ROOT_RANGE, + SHARED.source, + "the shared file", + ); + + const workspace = await TestWorkspace.create({ + files: { + "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; the tag-set-form bearers report their interpreted ` + + `tags in the 12.7 value form, compared literally — tags="b a a" ` + + `exactly ["a", "b"] (byte order, duplicates collapsed) and ` + + `tags="z A" exactly ["A", "z"] (bytes, never case-folded) — a ` + + `product echoing the spelled order or the duplicate fails; 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, and the tag-set-form bearers' ` + + `["a", "b"] and ["A", "z"] literal (SPEC 11.4, 12.7)`, + ); + } finally { + await workspace.dispose(); + } + + // --- Invocation 3: T2.7-3's shared fixture, viewed bare in its own + // workspace (module header) — the identity question the matrix file + // cannot ask: a well-formed, unique `id` beside a valueless `tags`. + // Created after this body's first invocation, so its initial file is + // the staged-source record T2.7-3's arm stages (the same bytes as + // `SHARED.source`; S-9's before-any-product clause). + const sharedWorkspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [SHARED.file]: VALUELESS_TAGS_STAGED, + }, + }); + try { + const sharedContext = + 'T11.4-3 bare `view` over T2.7-3\'s shared `<S id="x" tags>` fixture'; + const sharedResult = await expectExit( + product, + sharedWorkspace, + ["view"], + 1, + `${sharedContext} — the answer carries the bare-name prop's 14.17 ` + + `and its explicitly-unavailable tags datum, so the invocation ` + + `exits 1 with the full document still emitted (SPEC 11.2, 11.4)`, + ); + const sharedReport = decodeViewReport( + parseJsonStdout( + sharedResult, + `${sharedContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: false }, + sharedContext, + ); + assertConditionCounts( + sharedReport.findings, + { "14.17": 1 }, + `${sharedContext}: exactly the one 14.17 T2.7-3's build arm ` + + `asserts on the same bytes accompanies the view — the valueless ` + + `tags (SPEC 2.7, 14) — and nothing else: no 14.1 (the bearer ` + + `spells an identity: exactly one quoted static id, SPEC 11.2), ` + + `no 14.2/14.3 (ok and x are unique and conformant)`, + ); + assertFindingLocated( + sharedReport.findings[0]!, + SHARED.finding, + `${sharedContext} — the 14.17 locates in ${SHARED.file} within ` + + `the bearer's opening tag, the window T2.7-3 holds the build's ` + + `finding to: the condition beside the view is the condition the ` + + `build reports (SPEC 11.2, 14)`, + ); + assertSameJson( + sharedReport.views.map((view) => view.file), + [SHARED.file], + `${sharedContext}: the one discovered spec source is viewed (SPEC 11.4)`, + ); + assertSameJson( + projectAttributeData(sharedReport.views[0]!.root), + SHARED_TREE, + `${sharedContext} — ${SHARED.file}: the bearer's identity is the ` + + `plain "${SHARED.file}#${SHARED.id}" — exactly one quoted static ` + + `id, well-formed and unique, spells and defines it whatever ` + + `invalid-form prop stands beside it (SPEC 11.2: tags/coverage ` + + `invalidity never undefines identity; a product withdrawing ` + + `identity on the valueless prop alone fails) — its attributes ` + + `the id entry then the bare-name tags entry in tag order, each ` + + `byte-exact at the fixture's declared offsets, the bare name's ` + + `text the name alone (SPEC 11.4: inclusion is by form), its ` + + `interpreted tags explicitly unavailable (a valueless prop is ` + + `no quoted static string, SPEC 2.7 — a product reading the bare ` + + `name as an absent prop reports the plain default [] and fails) ` + + `beside the absent-prop default coverage "required"; the sibling ` + + `ok carries every datum plain, and the root's tags/coverage are ` + + `the stated null, never the marker (SPEC 12.7)`, + ); + const sharedView = sharedReport.views[0]!; + assertSameJson( + [sharedView.imports, sharedView.occurrences, sharedView.comments], + [[], [], []], + `${sharedContext}: no import, reference spelling, or MDX comment ` + + `is staged — empty lists are [], never null (SPEC 11.4, 12.7)`, + ); + } finally { + await sharedWorkspace.dispose(); + } + }, +}); + +// --- T11.4-4 — imports ---------------------------------------------------------- +// +// The declaration matrix (module header): ten 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; docs/impl.mdx is matched only by the code group `docs` — a +// discovered code source and no spec source (SPEC 7.2), so a specifier +// designating it names no spec source (SPEC 2.1); specs/B.mdx begins with a +// byte-order mark — a discovered spec source, unparseable (SPEC 1.6, 14.20) +// and masked (SPEC 11.2, 11.4). After the ESM block, one embedding into the +// masked target — resolving into nothing (no spelling resolves into a masked +// file, SPEC 11.2), so it records no occurrence and is the importing file's +// own 14.6 (SPEC 11.4). + +const IMPORTS_FILE = "specs/imports.mdx"; + +// SPECS_ONLY_CONFIG's spec group plus one code group whose glob matches +// `.mdx` files under `docs/` (SPEC 7.2) — the one code group §CONF-AVAIL +// admits, as this test's wrong-kind-target arm stages it: its one match is +// no spec source, so no view domain holds it and no invocation of this test +// reads its content. +const IMPORTS_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + docs: ["docs/**/*.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"), +}; + +// The masked target: a byte-order mark (U+FEFF, encoded EF BB BF — the +// workspace builder writes string contents with BOMs kept, S-2; the code +// point is spelled numerically so that no tool layer decodes an escape on +// its way into the file) before content that would otherwise spell the +// section `x` — so a product that strips the mark and parses the rest views +// B, resolves the embedding `B.x`, and fails the masking arms. +const MASKED_TARGET_FILE = "specs/B.mdx"; +const MASKED_TARGET_SOURCE = + String.fromCodePoint(0xfeff) + '<S id="x">\nMasqué.\n</S>\n'; +const MASKED_TARGET_FINDING_LOCATION = { + file: MASKED_TARGET_FILE, + range: { start: 0, end: 0 }, +} as const; + +// The wrong-kind target: an `.mdx` file matched only by the code group. Its +// content is valid TypeScript — a code source's grammar (SPEC 14.20) — and +// is read by no invocation of this test (§CONF-AVAIL's staging constraint). +const CODE_TARGET_FILE = "docs/impl.mdx"; +const CODE_TARGET_SOURCE = "export const impl = 1;\n"; + +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"); +// A `.xspec` specifier of valid form designating the discovered code source +// (lexically: specs/ → ../docs/impl.xspec → docs/impl.mdx): no spec source, +// so discovery defines no target (SPEC 2.1, 14.15). +const IMP_CODE_TEXT = 'import CODE from "../docs/impl.xspec"'; +const IMP_CODE = IMP.add(IMP_CODE_TEXT); +IMP.add("\n"); +// The masked target: discovery, not parseability, defines designation — the +// import is valid and its target the plain "specs/B.mdx" (SPEC 2.1, 11.4). +const IMP_MASKED_TEXT = 'import B from "./B.xspec"'; +const IMP_MASKED = IMP.add(IMP_MASKED_TEXT); +IMP.add("\n"); +// A non-canonical spelling (no `sub/` on disk): lexical resolution designates +// specs/BASE.mdx, the reported target the designated file, never the +// spelling (SPEC 2.1; T2.1-2). +const IMP_NONCANON_TEXT = 'import NONCANON from "./sub/../BASE.xspec"'; +const IMP_NONCANON = IMP.add(IMP_NONCANON_TEXT); +IMP.add("\n"); +// Semicolon-terminated: the `;` ends ECMAScript's ImportDeclaration, so it is +// among the declaration's own characters and the range ends after it (SPEC +// 14.20, 11.4; T3-7's removal arm). A second default import of BASE under +// another name is valid (SPEC 2.1). +const IMP_SEMI_TEXT = 'import AGAIN from "./BASE.xspec";'; +const IMP_SEMI = IMP.add(IMP_SEMI_TEXT); +IMP.add("\n\nProse après les imports — l'embedding "); +const IMP_EMBED_TEXT = "{text(B.x)}"; +const IMP_EMBED = IMP.add(IMP_EMBED_TEXT); +IMP.add(" vise la cible masquée.\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 discovered spec source the specifier + * designates under 2.1, parseable or not, 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 }, + { range: IMP_CODE, name: "CODE", target: UNAVAILABLE }, + { range: IMP_MASKED, name: "B", target: MASKED_TARGET_FILE }, + { range: IMP_NONCANON, name: "NONCANON", target: IMPORT_TARGET_FILE }, + { range: IMP_SEMI, name: "AGAIN", target: IMPORT_TARGET_FILE }, +]; + +/** + * The six invalid declarations in document order — also the order of the + * answer's 14.15 findings: they share one code, and equal codes order by + * locations (SPEC 12.7), so position among them pins which finding is which. + * Each 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 }, + { + what: "the `../docs/impl.xspec` specifier designating the discovered code source", + range: IMP_CODE, + }, +]; + +// Root-only expected trees (neither viewed 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: [], +}; + +/** + * The findings the importing file itself contributes to any answer whose + * domain holds it (SPEC 11.2): exactly one 14.6 — the embedding into the + * masked target, located by its full braced container (SPEC 14) — and one + * 14.15 per invalid declaration, in declaration order, each within its own + * declaration's byte window. The caller has already pinned the condition + * counts, so the filters below select complete sets. + */ +function assertImportsFileFindings( + findings: readonly Finding[], + context: string, +): void { + const embeddingFindings = findings.filter( + (finding) => finding.condition === "14.6", + ); + assertSameJson( + embeddingFindings.map((finding) => finding.locations), + [[{ file: IMPORTS_FILE, range: IMP_EMBED }]], + `${context} — the embedding into the masked target records no ` + + `occurrence (no spelling resolves into a masked file, SPEC 11.2) and ` + + `is the importing file's own 14.6, located exactly by its full ` + + `braced container (SPEC 11.4, 14; a product stripping the mark and ` + + `resolving B.x reports no such finding)`, + ); + const importFindings = findings.filter( + (finding) => finding.condition === "14.15", + ); + importFindings.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 the findings arrive in declaration order; ` + + `SPEC 14, 12.7)`, + ); + }); +} + +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`, the bare specifier `BASE.xspec`, a `.xspec` specifier designating the discovered code source docs/impl.mdx (an `.mdx` file matched only by a code group), a valid import of the byte-order-mark-masked spec source specs/B.mdx, the non-canonical `./sub/../BASE.xspec`, and a semicolon-terminated declaration whose range ends after its `;` — the binding-name datum the DEFAULT binding\'s identifier: plain 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), the masked target the plain "specs/B.mdx" (discovery, not parseability, defines designation — the import valid, no 14.15) and the non-canonical spelling the designated "specs/BASE.mdx", while `./typo.xspec` (discovery defines none), the bare specifier (form defines none — a suffix-keyed resolver notwithstanding), and the code-source target (no spec source) are each `{"unavailable": true}` literally, never `null`; each invalidity a located 14.15 finding beside the view — exactly six, one per invalid declaration, each within its own declaration\'s end-widened byte window — the embedding `{text(B.x)}` into the masked target recording no occurrence and reported as the importing file\'s own 14.6 at its full braced container; the masked file contributing no view, its 14.20 — one zero-length range at offset 0 — accompanying the bare whole-domain `view` and `view specs/B.mdx` (views `[]`) and NOT `view specs/imports.mdx`, whose imports member is the same list; and any finding or explicitly-unavailable datum means exit 1 with the full answer still emitted (SPEC 11.4, 11.2, 2.1, 1.6, 1.7, 7.2, 12.7, 14; T2.1-2, T3-7; 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"], + [IMP_CODE, IMP_CODE_TEXT, "the code-source-target import"], + [IMP_MASKED, IMP_MASKED_TEXT, "the masked-target import"], + [IMP_NONCANON, IMP_NONCANON_TEXT, "the non-canonical-specifier import"], + [IMP_SEMI, IMP_SEMI_TEXT, "the semicolon-terminated import"], + [IMP_EMBED, IMP_EMBED_TEXT, "the embedding into the masked target"], + ] as const) { + sliceCheck(IMPORTS_SOURCE, range, span, what); + } + sliceCheck( + IMPORTS_SOURCE, + IMPORTS_ROOT_RANGE, + IMPORTS_SOURCE, + "the imports file", + ); + if ( + !Buffer.from(MASKED_TARGET_SOURCE, "utf8") + .subarray(0, 3) + .equals(Buffer.from([0xef, 0xbb, 0xbf])) + ) { + fail( + "§11.4 fixture self-check — the masked target must begin with the " + + "UTF-8 byte-order mark EF BB BF (a harness-side staging error, " + + "not a product failure)", + ); + } + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": IMPORTS_CONFIG, + [IMPORT_TARGET_FILE]: IMPORT_TARGET_SOURCE, + [MASKED_TARGET_FILE]: MASKED_TARGET_SOURCE, + [CODE_TARGET_FILE]: CODE_TARGET_SOURCE, + [IMPORTS_FILE]: IMPORTS_SOURCE, + }, + // S-9: the masked target begins with a byte-order mark (14.20); the + // code-source target, a name the default does not reach, is + // well-formed TypeScript. + mdx: { unparseable: [MASKED_TARGET_FILE] }, + ts: { wellFormed: [CODE_TARGET_FILE] }, + }); + try { + // Invocation 1 (CONF-AVAIL's enumerated surface: no gate-reference + // `build`, no snapshot compare): the bare whole-domain `view` — every + // discovered spec source requested, the masked B among them. The + // answer carries the six 14.15 findings, the 14.6, B's 14.20, and + // three explicitly-unavailable targets, so exit 1 with the full + // document still emitted (SPEC 11.2). + const context = "T11.4-4 bare `view` (whole domain: B, BASE, imports)"; + const result = await expectExit( + product, + workspace, + ["view"], + 1, + `${context} — the answer carries the six staged 14.15 findings, ` + + `the embedding's 14.6, the masked target's 14.20, and three ` + + `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 the staged conditions, nothing else — the valid default + // imports (BÄSE, B, NONCANON, AGAIN) are finding-free (an unused + // binding is valid; several imports may bind one module under + // different names, SPEC 2.1), no binding collision is staged (nine + // distinct identifiers), and no viewed file spells a section. + assertConditionCounts( + report.findings, + { "14.6": 1, "14.15": 6, "14.20": 1 }, + `${context}: exactly six 14.15 accompany — the side-effect-only, ` + + `named-only, and namespace-only binding forms, the undiscovered ` + + `./typo.xspec target, the bare specifier, and the specifier ` + + `designating the discovered code source (SPEC 2.1, 14) — beside ` + + `the embedding's 14.6 and the requested masked file's 14.20 ` + + `(SPEC 11.2, 11.4), and nothing else: the four valid default ` + + `imports contribute none — the masked target's import included ` + + `(discovery, not parseability, defines designation) — and no ` + + `other condition is staged`, + ); + assertImportsFileFindings(report.findings, context); + assertSameJson( + report.findings + .filter((finding) => finding.condition === "14.20") + .map((finding) => finding.locations), + [[MASKED_TARGET_FINDING_LOCATION]], + `${context} — the masked target's 14.20 carries one zero-length ` + + `range at offset 0 in specs/B.mdx, the byte-order mark's offset ` + + `(SPEC 14, 1.6)`, + ); + + // The parseable requested files are viewed, in byte order of + // workspace-relative path ("specs/BASE.mdx" < "specs/imports.mdx"); + // the unparseable requested B contributes no view (SPEC 11.4). + assertSameJson( + report.views.map((view) => view.file), + [IMPORT_TARGET_FILE, IMPORTS_FILE], + `${context}: the two parseable discovered spec sources are viewed, ` + + `in byte order of workspace-relative path, and the masked ` + + `specs/B.mdx contributes no view — its parse-failure finding ` + + `reports it (SPEC 11.4, 11.2, 12.7; a product stripping the ` + + `byte-order mark views it and fails)`, + ); + const targetView = report.views[0]!; + const importsView = report.views[1]!; + + // The subject compare: the imports member is exactly the ten-entry + // list — every declaration, valid and invalid, with its byte-exact + // range (the semicolon-terminated one's ending after its `;`), 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, 2.1, 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 — the ` + + `semicolon-terminated declaration's ending after its \`;\` (SPEC ` + + `14.20; T3-7); name the default binding's identifier or the ` + + `stated null for the side-effect-only, named-only, and ` + + `namespace-only forms — never the marker, never part/ns; target ` + + `the designated discovered spec source wherever specifier form ` + + `and discovery define one — binding validity notwithstanding, ` + + `parseability notwithstanding (the masked "specs/B.mdx"), the ` + + `non-canonical spelling reporting the designated ` + + `"specs/BASE.mdx" — and the literal unavailability marker for ` + + `./typo.xspec, the bare specifier, and the specifier designating ` + + `the discovered code source, never null (SPEC 11.4, 11.2, 2.1, ` + + `12.7)`, + ); + + // The rest of each per-file view: root-only trees byte-asserted; the + // embedding records no occurrence (SPEC 11.2), no MDX comment is + // staged, and the target holds no import — 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}: the embedding into the masked ` + + `target records no occurrence and no MDX comment is staged — ` + + `empty lists are [], never null (SPEC 11.2, 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)`, + ); + + // Invocation 2: `view specs/imports.mdx` — the masked target is not + // requested, and without `--text` nothing consults further (SPEC + // 11.4), so its 14.20 accompanies NOTHING: a masked file's + // parse-failure finding accompanies the answer only when it is itself + // requested. The imports member is the same list — the masked + // target's plain path is the datum whether or not B is requested. + const operandContext = + "T11.4-4 `view specs/imports.mdx` (the masked target not requested)"; + const operandResult = await expectExit( + product, + workspace, + ["view", IMPORTS_FILE], + 1, + `${operandContext} — the importing file's own findings and its ` + + `explicitly-unavailable targets accompany, so exit 1 with the ` + + `full document still emitted (SPEC 11.2)`, + ); + const operandReport = decodeViewReport( + parseJsonStdout( + operandResult, + `${operandContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: false }, + operandContext, + ); + assertConditionCounts( + operandReport.findings, + { "14.6": 1, "14.15": 6 }, + `${operandContext}: exactly the importing file's own findings — ` + + `six 14.15 and the embedding's 14.6 — and NO 14.20: the masked ` + + `specs/B.mdx is not requested, and a masked file is never ` + + `consulted by an expansion, so its parse-failure finding ` + + `accompanies only when it is itself requested (SPEC 11.4, 11.2; ` + + `a product consulting import targets, or reporting the whole ` + + `workspace's findings, carries it and fails)`, + ); + assertImportsFileFindings(operandReport.findings, operandContext); + assertSameJson( + operandReport.views.map((view) => view.file), + [IMPORTS_FILE], + `${operandContext}: the one requested file is viewed (SPEC 11.4)`, + ); + assertSameJson( + operandReport.views[0]!.imports, + EXPECTED_IMPORT_ENTRIES, + `${operandContext} — ${IMPORTS_FILE}: the imports member is the ` + + `same ten-entry list as under the whole-domain view — the ` + + `masked target's plain "specs/B.mdx" among them whether or not ` + + `B is requested (SPEC 11.4, 2.1, 12.7)`, + ); + + // Invocation 3: `view specs/B.mdx` — the masked target requested + // alone: a discovered spec source, so the operand is in the domain, + // and an unparseable requested file contributes no view, its + // parse-failure finding reporting it (SPEC 11.4, 11.2). + const maskedContext = + "T11.4-4 `view specs/B.mdx` (the masked target requested alone)"; + const maskedResult = await expectExit( + product, + workspace, + ["view", MASKED_TARGET_FILE], + 1, + `${maskedContext} — specs/B.mdx is a discovered spec source, so ` + + `the operand is no usage error; its 14.20 accompanies, so exit 1 ` + + `with the full document still emitted (SPEC 11.4, 11.2)`, + ); + const maskedReport = decodeViewReport( + parseJsonStdout( + maskedResult, + `${maskedContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: false }, + maskedContext, + ); + assertSameJson( + maskedReport.findings.map((finding) => [ + finding.condition, + finding.locations, + ]), + [["14.20", [MASKED_TARGET_FINDING_LOCATION]]], + `${maskedContext}: exactly one finding — the masked file's 14.20 ` + + `at its one zero-length range, offset 0 — and nothing of the ` + + `importing file, which lies outside this answer's domain (SPEC ` + + `11.2, 11.4, 14)`, + ); + assertSameJson( + maskedReport.views, + [], + `${maskedContext}: an unparseable requested file contributes no ` + + `view — views is [] (SPEC 11.4, 12.7; a product stripping the ` + + `byte-order mark and viewing the section it hides fails)`, + ); + } 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; +// T11.4-5's cycle, masked, and invalid-path workspaces are created after the +// body's first product invocation (the chain workspace's runs), so S-7's +// sweep never reaches their initial files against the stub: staged-source +// records (helpers/staged-mdx.ts; S-9's before-any-product clause), each +// made from the string the slice checks read, or wrapped in place. +const CYE_STAGED = stagedMdx( + "T11.4-5 cycle workspace specs/entry.mdx", + 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; +const CYL_STAGED = stagedMdx( + "T11.4-5 cycle workspace specs/loop.mdx", + 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_STAGED = stagedMdx( + "T11.4-5 masked workspace specs/main.mdx", + 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). +// The requested file is the staged parse failure (14.20): the record +// carries the `unparseable` declaration the workspace declaration held. +const MK_GONE_SOURCE = stagedMdx( + "T11.4-5 masked workspace specs/gone.mdx", + '<S id="g">\nNever closed.\n', + "unparseable", +); + +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_STAGED = stagedMdx( + "T11.4-5 invalid-path workspace specs/vi#ew.mdx", + IP_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_STAGED, + [CYL_FILE]: CYL_STAGED, + }, + }); + 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_STAGED, + [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_STAGED, + }, + }); + 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"; +// T11.4-6's imperfect workspace is created after the body's first product +// invocation (the emission workspace's runs), so S-7's sweep never reaches +// its initial files against the stub: staged-source records +// (helpers/staged-mdx.ts; S-9's before-any-product clause) — the target +// wrapped in place, the importer made from the string the slice checks +// read. +const BCT_SOURCE = stagedMdx( + "T11.4-6 imperfect workspace specs/tgt.mdx", + '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_STAGED = stagedMdx( + "T11.4-6 imperfect workspace specs/imp.mdx", + 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_STAGED, + [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 00000000..a3ff278c --- /dev/null +++ b/test/suite/registry/section-11.5.ts @@ -0,0 +1,1843 @@ +// 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 { + 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { + ArgvValue, + ProductBinding, + RunResult, +} 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, + expectErrorDocument, + expectExit, + REPLACEMENT_CHARACTER_SPEC_PATH, + 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, + }, + // S-9: the undiscovered decoy is deliberately unparseable (14.20). + mdx: { unparseable: [OS_DECOY_FILE] }, + }); + 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 or contains U+FFFD 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), five 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 a malformed value (the raw bytes are not valid UTF-8, +// the lossy decode contains U+FFFD: 12.0's argument-value rule) or an +// unknown file (the two renderings name undiscovered paths), exit 2 with +// the single 12.7 error document either way — T11.5-3 pins the exit and +// the document for this file, not which of the two errors — 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). +// - specs/A<U+FFFD>.mdx — U+FFFD-pathed (14.19), staged on EVERY platform +// (REPLACEMENT_CHARACTER_SPEC_PATH, the spelling shared with T1.5-2 +// through support.ts: valid UTF-8, EF BF BD, a name any filesystem +// holds). Its condition-19 finding presents the concerned path as a plain +// string — never the marked byte form, which 12.0 reserves for a +// non-UTF-8 path. `at specs/A<U+FFFD>.mdx 0` is a malformed value — +// 12.0's argument-value rule: a value containing U+FFFD is a usage error +// of the syntax class, judged before every per-operand check — exit 2 +// with the single 12.7 error document, never an answer (a product naming +// the discovered file answers exit 1 with a resolution and fails the exit +// assertion) and never the unknown-file error: a syntax-class error is +// reported without loading configuration, while a configuration error +// precedes every check consulting configuration or discovery — the +// unknown-file check among them — so the same invocation on a +// configuration-less workspace holding the same file must still answer +// the plain usage error (`code` and `path` null; the T12.0-10 +// discipline) — a product judging the file as unknown answers 14.14 +// there instead — and the two error documents are byte-identical (the +// document depends on the arguments alone; H-4). The glob-reached view +// (`view --file specs/A*.mdx`, admitting no other staged file) answers +// exit 1 with exactly the file's condition-19 finding (stable code, no +// locations, the plain-string concerned path) and its one view — `file` +// the plain string, the full positional tree byte-exact, every node +// identity, root included, explicitly unavailable (SPEC 11.2, 11.4). +// +// The gate `build --json` doubles as staging integrity (exactly casse's +// 14.20 plus A<U+FFFD>'s 14.19 and — 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 }; + +// --- specs/A<U+FFFD>.mdx — U+FFFD-pathed spec source (14.19, every leg) ------ +// The path is valid UTF-8 (U+FFFD encodes as EF BF BD), so every platform +// holds the name and the byte-wise glob rules of SPEC 7 discover it; a path +// containing U+FFFD is an invalid source path with a plain string form +// (SPEC 14.19, 12.0). The spelling is shared with T1.5-2 (support.ts). +const RC_PATH = REPLACEMENT_CHARACTER_SPEC_PATH; +const RC = new ByteFixture(); +RC.add("Préambule — U+FFFD dans le chemin.\n\n"); +const RC_SEC_START = RC.pos; +RC.add('<S id="seule">\nTexte positionné aussi.\n</S>'); +const RC_SEC_RANGE: SourceRange = { start: RC_SEC_START, end: RC.pos }; +RC.add("\n"); +const RC_SOURCE = RC.source; +// T11.5-3's configuration-less twin is created after the body's first +// product invocation (the operand workspace's runs), so S-7's sweep never +// reaches its initial file against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause) made from the +// string the slice check reads, staged in both workspaces. +const RC_STAGED = stagedMdx( + "T11.5-3 specs/A<U+FFFD>.mdx (the U+FFFD-pathed source; the operand workspace and its configuration-less twin)", + RC_SOURCE, +); +const RC_ROOT_RANGE: SourceRange = { start: 0, end: RC.pos }; + +/** + * Representative `at` spellings for the non-UTF-8-pathed source (SPEC 12.0: + * argument values are valid UTF-8 without U+FFFD, so NO value names it — + * each is a malformed value or an unknown file, exit 2, whatever the + * spelling's provenance; T11.5-3 pins the exit and the error document for + * this file, not which of the two). + */ +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 — a malformed value: " + + "argument values are valid 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) — a " + + "malformed value under 12.0's argument-value rule, naming nothing", + }, + { + 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 a condition-19 finding (the T11.2-3 + * discipline): stable code token, the empty locations of a path-level + * condition, the concerned path in the form 12.0 fixes for it — the marked + * byte form for a non-UTF-8 path, the plain string for a U+FFFD path + * (SPEC 14, 12.0, 12.7). Message and identities stay unpinned. + */ +function projectPathFinding(finding: Finding): { + readonly code: string | null; + readonly locations: readonly unknown[]; + readonly path: unknown; +} { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + }; +} + +type PathFindingProjection = ReturnType<typeof projectPathFinding>; + +/** + * The projections in one canonical order — SPEC 14 fixes no order among + * unlocated findings, so both sides of a compare pass through this. + */ +function inCanonicalOrder( + projections: readonly PathFindingProjection[], +): PathFindingProjection[] { + return [...projections].sort((a, b) => { + const [x, y] = [JSON.stringify(a), JSON.stringify(b)]; + return x < y ? -1 : x > y ? 1 : 0; + }); +} + +/** + * Run one `at` invocation expected to fail as a plain usage error: exit 2 + * exactly, stdout the single 12.7 error document (the surface is JSON-only, + * SPEC 11), its finding carrying `code` and `path` null — the plain usage + * error, never a configuration error's stable code and concerned path + * (SPEC 12.7, 14) — and the usage message on stderr (12.0). Returns the run + * for the byte compare across workspaces (the T12.0-10 discipline). + */ +async function expectPlainUsageError( + product: ProductBinding, + workspace: Workspace, + argv: readonly string[], + context: string, +): Promise<RunResult> { + const result = await expectExit(product, workspace, argv, 2, context); + const error = expectErrorDocument(result, context); + if (error.code !== null || error.path !== null) { + fail( + `${context}: the reported error must be the plain usage error — ` + + `\`code\` and \`path\` null (SPEC 12.7, 14) — never a ` + + `configuration error; got code ${JSON.stringify(error.code)}, path ` + + `${JSON.stringify(error.path)} (message: ` + + `${JSON.stringify(error.message)})`, + ); + } + if (result.stderrBytes.length === 0) { + fail( + `${context}: usage error messages are standard-error content ` + + `(SPEC 12.0), but stderr is empty`, + ); + } + return result; +} + +/** 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 — a malformed value or 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; the U+FFFD-pathed specs/A<U+FFFD>.mdx, staged on either leg, bears its condition-19 finding with the concerned path as a plain string (never the byte form); `at specs/A<U+FFFD>.mdx 0` is a malformed value — exit 2 with the plain usage error document (`code` and `path` null), never an answer and never the unknown-file error: reported identically, byte for byte, on a configuration-less workspace holding the same file, where an unknown-file check would be preceded by the configuration error — and `view --file specs/A*.mdx` serves it exit 1 with exactly its finding (plain-string concerned path) and its positional tree byte-exact with every identity explicitly unavailable; no invocation of the sweep modifies anything (SPEC 11.5, 11.2, 5.7, 1.7, 12.0, 12.7, 14.19; T11.2-3, T11.2-5, T12.0-10)", + 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", + ); + sliceCheck( + RC_SOURCE, + RC_SEC_RANGE, + '<S id="seule">\nTexte positionné aussi.\n</S>', + "the U+FFFD-pathed 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, + [RC_PATH]: RC_STAGED, + }, + // S-9: casse.mdx is the staged parse failure (14.20). + mdx: { unparseable: [OC_CASSE_FILE] }, + }); + try { + // This staging precedes the body's first product invocation (the gate + // `build` below), so S-7's sweep reaches it against the stub: plain + // contents, no ledger record (helpers/staged-mdx.ts). + 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, one 14.19 for the U+FFFD-pathed " + + "specs/A<U+FFFD>.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": 2 } + : { "14.20": 1, "14.19": 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)`, + ); + // Each condition-19 finding: the stable code, no in-source + // locations, and its concerned path in the form 12.0 fixes — the + // plain string for the U+FFFD path (never the byte form), the + // marked byte form for the non-UTF-8 path. SPEC 14 fixes no order + // among unlocated findings, so both sides are compared in one + // canonical order. + assertSameJson( + inCanonicalOrder( + gateFindings + .filter((finding) => finding.condition === "14.19") + .map(projectPathFinding), + ), + inCanonicalOrder([ + { code: "invalid-source-path", locations: [], path: RC_PATH }, + ...(NU3_STAGED + ? [ + { + code: "invalid-source-path", + locations: [], + path: NU3_MARKED_PATH, + }, + ] + : []), + ]), + `${gateContext} — each condition-19 finding carries the stable ` + + `code, no in-source locations, and its concerned path in the ` + + `form 12.0 fixes: the plain string for the U+FFFD path, never ` + + `the byte form; the marked byte form for the non-UTF-8 path ` + + `(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(projectPathFinding), + [ + { + 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)`, + ); + } + + // --- The U+FFFD-pathed source (SPEC 12.0, 11.5; either leg): + // `at specs/A<U+FFFD>.mdx 0` is a malformed value — a usage error + // of the syntax class, judged before every per-operand check — + // exit 2 with the plain usage error document, never an answer. + const rcAtArgv: readonly string[] = ["at", RC_PATH, "0"]; + const rcAtContext = + "T11.5-3 `at specs/A<U+FFFD>.mdx 0` (the U+FFFD-pathed source " + + "named by its own spelling — a malformed value under 12.0's " + + "argument-value rule, the file nameable by no argument value)"; + const rcAtOnWorkspace = await expectPlainUsageError( + product, + workspace, + rcAtArgv, + `${rcAtContext} — exit 2 with the single 12.7 error document, ` + + `never an answer: a product naming the discovered file would ` + + `answer a resolution (SPEC 12.0, 11.5)`, + ); + // Never the unknown-file error: a syntax-class error is reported + // without loading configuration, while the unknown-file check + // consults discovery and so is preceded by the configuration + // error (14.14) — on a configuration-less workspace holding the + // same file, the same invocation must still answer the plain + // usage error, byte-identically (SPEC 12.0; the T12.0-10 + // discipline, H-4). + const configless = await TestWorkspace.create({ + files: { [RC_PATH]: RC_STAGED }, + }); + try { + const rcAtConfigless = await assertLeavesUnchanged( + configless.root, + () => + expectPlainUsageError( + product, + configless, + rcAtArgv, + `${rcAtContext} on a configuration-less workspace holding ` + + `the same file — the malformed value is a syntax-class ` + + `error, reported without loading configuration; an ` + + `unknown-file check would be preceded by the ` + + `configuration error, code \`configuration-error\` ` + + `(SPEC 12.0, 14.14)`, + ), + `${rcAtContext} on a configuration-less workspace — a ` + + `syntax-alone usage error modifies nothing (SPEC 12.0)`, + ); + assertBytesEqual( + rcAtOnWorkspace.stdoutBytes, + rcAtConfigless.stdoutBytes, + `${rcAtContext}: reported identically with and without a ` + + `configuration — the error document depends on the ` + + `invocation's arguments alone, never on configuration ` + + `state or discovery (SPEC 12.0; H-4's product-to-itself ` + + `compare)`, + ); + } finally { + await configless.dispose(); + } + + // The glob-reached view is the one route to its positions: + // `view --file specs/A*.mdx` admits exactly the U+FFFD-pathed + // file (every other staged name starts in lowercase). + const rcViewContext = + "T11.5-3 `view --file specs/A*.mdx` (the glob-reached view: " + + "the one route to the U+FFFD-pathed file's positions)"; + const rcViewResult = await runCli(product, workspace, [ + "view", + "--file", + "specs/A*.mdx", + ]); + assertExitCode( + rcViewResult, + 1, + `${rcViewContext} — 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 rcViewReport = decodeViewReport( + parseJsonStdout( + rcViewResult, + `${rcViewContext} — a single JSON document is the only ` + + `output form (SPEC 11)`, + ), + { text: false }, + rcViewContext, + ); + assertSameJson( + rcViewReport.findings.map(projectPathFinding), + [{ code: "invalid-source-path", locations: [], path: RC_PATH }], + `${rcViewContext} — exactly the admitted file's condition-19 ` + + `finding accompanies: stable code, no in-source locations, ` + + `the concerned path as a plain string, never the byte form ` + + `(SPEC 11.2, 14, 12.0, 12.7)`, + ); + assertSameJson( + rcViewReport.views.map((view) => view.file), + [RC_PATH], + `${rcViewContext} — the glob admits exactly the U+FFFD-pathed ` + + `file, its \`file\` member the plain string form (SPEC 7, ` + + `11.4, 12.0, 12.7)`, + ); + assertSameJson( + projectResolution(rcViewReport.views[0]!.root), + { + identity: UNAVAILABLE, + range: RC_ROOT_RANGE, + children: [ + { identity: UNAVAILABLE, range: RC_SEC_RANGE, children: [] }, + ], + }, + `${rcViewContext} — 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 00000000..3dbdc39d --- /dev/null +++ b/test/suite/registry/section-11.6.ts @@ -0,0 +1,2806 @@ +// 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); +// - physical anchoring, Linux leg (SPEC 11.6): the working directory and +// the workspace root enter the spelling as physical directory paths, +// every symbolic link among their components resolved. With the root `R` +// holding `a/b`, the product is driven from a working directory reached +// through the symbolic link `R/L` → `R/a/b` — the link path given as the +// child's working directory and as `PWD`, exactly as a shell that `cd`ed +// through the link presents the invocation (the T13.4-6 staging; the +// kernel resolves the working directory physically while `PWD` still +// names the link, so a product anchoring on the logical spelling would +// report the link's lexical relation) — and reports `root` `../..` and +// `config` `../../xspec.config.ts`: the physical relation between the +// working directory and the root, never the link's lexical `..`. A link +// above both (`elsewhere/link` → `R`, beside the root in the fixture's +// temporary directory; the working directory `elsewhere/link/a`) reports +// `..` and `../xspec.config.ts`, unchanged — a link above both changes +// nothing, and the configuration file enters as its own name under the +// root so spelled. Staged with an ordinary directory symlink on every +// platform (never a skip); the expected spellings are fixed by the +// physical relation alone, so no realpath arithmetic is involved. +// +// 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. Five 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), whereas an `.mdx` source +// whose path is invalid keeps both — per-source derived paths follow the +// `NAME.mdx` name shape alone (13.1), and the inventory answers whatever +// the sources' validity (11.6): `specs/a'b.mdx` (7.1's bar, T7.1-1) → +// module `specs/a'b.xspec.ts` and Markdown `specs/a'b.md`; on the Linux +// leg, the non-UTF-8-named `specs/b<0xFF>.mdx` (T1.5-2's name) → its +// entry's `source`, `module`, and `markdown` each in the marked byte form +// (12.0, 12.7, T12.7-1), composed from the staged source bytes with the +// final `.mdx` replaced by `.xspec.ts` or `.md` — conditional staging +// inside the shared body, never a skip (H-9), as T12.7-1's byte-form arm +// stages. Each invalid-path entry is diagnosed on its own before the +// whole-map compare, so a product deriving paths for valid sources alone +// fails there by name — and the answer stays complete, finding-free, +// exit 0: the 14.19 findings are reported where their condition assigns +// them, 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); +// - configured sets: a profile's `targetTags: ["z", "a", "a"]` and +// `edgeKinds: ["references", "depends"]`, a rule's `kinds: ["embeds", +// "depends"]`, and a `tags` selector `["b", "a", "b"]` — each valid, read +// as a set (7.4, 7.5) — reported in their 12.7 value forms, form-exact: +// `targetTags` `["a", "z"]` and the selector's `tags` `["a", "b"]` (byte +// order, the repeated element collapsed), `edgeKinds` `["depends", +// "references"]` and `kinds` `["depends", "embeds"]` (5.2's order, +// however configured) — the inventory arms T7.4-1 and T7.5-1 cite. +// +// Every list is asserted in its pinned order, the set-valued members +// included: a tag set in byte order with duplicates collapsed and a kind +// set in 5.2's order are 12.7 value forms, decoder-enforced +// (`decodeInventoryResolvedMap`, H-3) and compared literally here — never +// sorted or otherwise normalized harness-side — while sources/derived +// arrive in byte order of workspace-relative path and 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, + InventoryDerivedEntry, + InventoryDocument, + InventoryJournalStatus, + InventoryResolvedMap, + PathValue, +} from "../../helpers/adapters/index.js"; +import { + GRAPH_DATA_AREA_PATH, + corruptGraphDataShapeBlind, + decodeInventoryAnchoring, + decodeInventoryDocument, + decodeInventoryFindings, + decodeInventoryResolvedMap, + pathValueBytes, + 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { + ProductBinding, + RunOptions, + 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"] + } +}) +`; + +// Staged by T11.6-1's first workspace and by T11.6-4's invalid-configuration +// workspace — the latter after that body's first product invocation, so +// S-7's sweep never reaches it against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause), one for both. +const ANCHOR_SOURCE = stagedMdx( + "T11.6-1/T11.6-4 specs/a.mdx (the anchor source; T11.6-4's invalid-configuration workspace)", + '<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. + * `env`, when given, is merged last into the child's environment: the + * physical-anchoring arms set `PWD` to the link path a shell would present. + */ +async function expectAnchoredInventory( + product: ProductBinding, + cwd: string, + argv: readonly string[], + expected: AnchoringExpectation, + context: string, + env?: RunOptions["env"], +): Promise<RunResult> { + const result = await runProduct(product, { + cwd, + argv, + ...(env === undefined ? {} : { env }), + }); + 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; physical anchoring, Linux leg: from a working directory reached through the symbolic link `R/L` → `R/a/b` inside the root (the link path as working directory and `PWD`) `root` is `../..` and `config` `../../xspec.config.ts` — the physical relation, never the link's lexical `..` — and from `elsewhere/link/a` under a link above both (`elsewhere/link` → `R`) `..` and `../xspec.config.ts`, unchanged, the configuration file entering as its own name under the root so spelled; 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(); + } + + // --- physical anchoring, Linux leg (SPEC 11.6): the working directory + // and the workspace root enter the spelling as physical directory + // paths, every symbolic link among their components resolved. Each + // link path is given as the child's working directory AND as `PWD` — + // exactly as a shell that `cd`ed through the link presents the + // invocation (the T13.4-6 staging): the kernel resolves the working + // directory physically while `PWD` still names the link, so a product + // anchoring on the logical spelling reports the link's lexical + // relation, which 11.6 forbids. The expected spellings are fixed by + // the physical relation alone (whatever links the temporary prefix + // holds), so no realpath arithmetic is involved. Staged with an + // ordinary directory symlink on every platform — never a skip. + + // (a) a link inside the root: `R/L` → `R/a/b` (the nested directory + // staged above). From `R/L` the physical working directory is + // `R/a/b`, two levels below the root: `../..`, never the link's + // lexical `..`. + const insideLink = workspace.path("L"); + await workspace.symlink("L", "a/b", "dir"); + await expectAnchoredInventory( + product, + insideLink, + ["inventory"], + { root: "../..", config: "../../xspec.config.ts" }, + "T11.6-1 — `inventory` from the working directory `R/L`, a symbolic " + + "link inside the root resolving to `R/a/b` (the link path as " + + "working directory and `PWD`): the anchoring spells the physical " + + "relation between the working directory and the root — `../..` " + + "and `../../xspec.config.ts` — never the link's lexical `..` " + + "(SPEC 11.6)", + { PWD: insideLink }, + ); + + // (b) a link above both: `elsewhere/link` → `R` (beside the root, in + // the fixture's own temporary directory), the working directory + // `elsewhere/link/a`. Physically `R/a` under `R`: `..`, unchanged — a + // link above both changes nothing — and the configuration file + // enters as its own name under the root so spelled. + const elsewhere = path.join(workspace.tempRoot, "elsewhere"); + await fsp.mkdir(elsewhere); + const aboveLink = path.join(elsewhere, "link"); + await fsp.symlink(path.join("..", rootBase), aboveLink, "dir"); + const aboveCwd = path.join(aboveLink, "a"); + await expectAnchoredInventory( + product, + aboveCwd, + ["inventory"], + { root: "..", config: "../xspec.config.ts" }, + "T11.6-1 — `inventory` from `elsewhere/link/a`, where " + + "`elsewhere/link` is a symbolic link above both the working " + + "directory and the root, resolving to the root (the link path as " + + "working directory and `PWD`): a link above both changes nothing " + + "— `..` — and the configuration file enters as its own name " + + "under the root so spelled, `../xspec.config.ts` (SPEC 11.6)", + { PWD: aboveCwd }, + ); + } 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" } + } + ] +}) +`; + +// T11.6-2's later workspaces (every one but `defaults`) are created after +// its first invocations, so their configurations below are TypeScript +// staged-source records (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), all well-formed. + +/** + * 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 = stagedTs( + "T11.6-2 emit workspace xspec.config.ts — emission next to source, the extension-free glob specs/*", + `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 = stagedTs( + "T11.6-2 outDir workspace xspec.config.ts — emission under mdout", + `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 = stagedTs( + "T11.6-2 disabled workspace xspec.config.ts — emit false with outDir docsout", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: false, outDir: "docsout" } +}) +`, +); + +/** + * Configured sets (SPEC 7.4, 7.5, 12.7; the inventory arms of T7.4-1 and + * T7.5-1): a profile's `targetTags` and `edgeKinds`, a rule's `kinds`, and + * a `tags` selector each spelled with a repeated element or out of order — + * valid, read as sets — so the view must report the 12.7 value forms, never + * the spellings: tag sets in byte order with duplicates collapsed, kind sets + * in 5.2's order. + */ +const RESOLVED_SETS_CONFIG = stagedTs( + "T11.6-2 sets workspace xspec.config.ts — configured sets spelled with repeats and out of order", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"], + aux: ["aux/**/*.mdx"] + }, + coverage: [ + { + name: "ensemble", + target: "main", + targetTags: ["z", "a", "a"], + boundary: "aux", + mode: "direct", + edgeKinds: ["references", "depends"] + } + ], + policy: [ + { + name: "etiquettes", + type: "forbidden", + from: { tags: ["b", "a", "b"] }, + to: { group: "aux" }, + kinds: ["embeds", "depends"] + } + ] +}) +`, +); + +/** + * The three dependency edge kinds in the order 5.2 lists them — the 12.7 + * value form of a defaulted `edgeKinds`/`kinds` (SPEC 7.4/7.5: both default + * to all three; 12.7: a kind set is in 5.2's order, however configured). + */ +const ALL_EDGE_KINDS: readonly DependencyEdgeKind[] = [ + "depends", + "embeds", + "references", +]; + +/** + * 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; +} + +// T11.6-2's emit, outDir, disabled, and sets workspaces are created after +// the body's first product invocation (the defaults workspace's runs), so +// S-7's sweep never reaches their initial sources against the stub: +// staged-source records (helpers/staged-mdx.ts; S-9's before-any-product +// clause), the same literals moved into them. +const T11_6_2_EMIT_A = stagedMdx( + "T11.6-2 emit workspace specs/a.mdx", + '<S id="seule">\nÉmise.\n</S>\n', +); + +// The emit workspace's spec-group file without the `.mdx` extension (the +// extension-free glob `specs/*` matches it; 14.19, SPEC 7.1): an MDX source +// all the same, its content judged by 14.20 whatever its name (11.2). The +// inventory parses no sources (11.6), so no assertion turns on its content; +// it is declared well-formed all the same — one line of prose, which +// derives — rather than left undeclared: a record at a path not named +// `.mdx` declares that path a well-formed MDX source for its own write, so +// the ledger self-test judges it before any product exists (S-9). +const T11_6_2_EMIT_NOTE = stagedMdx( + "T11.6-2 emit workspace specs/note.txt (a spec-group file without .mdx)", + "pas une source xspec\n", +); + +// The emit workspace's invalid-path `.mdx` sources (SPEC 13.1: per-source +// derived paths follow the `NAME.mdx` name shape alone; 11.6: the inventory +// answers whatever the sources' validity). `specs/a'b.mdx` — `'` is barred +// from a spec-group file's path (7.1, T7.1-1), so the file is invalid +// (14.19) — keeps its module path and, with emission next to sources, its +// Markdown destination. +const T11_6_2_QUOTE_SOURCE = "specs/a'b.mdx"; +const T11_6_2_EMIT_QUOTE = stagedMdx( + "T11.6-2 emit workspace specs/a'b.mdx (an invalid-path .mdx source: 7.1's bar)", + '<S id="apostrophe">\nInvalide par son nom.\n</S>\n', +); + +/** + * Whether non-UTF-8 file names are stageable: the Linux leg, where file + * names are byte strings (TEST-SPEC T1.5-2, T11.6-2) — conditional staging + * inside the shared body, never a skip (H-9), as T12.7-1's byte-form arm. + */ +const NON_UTF8_STAGED = process.platform === "linux"; + +// (Linux leg) The non-UTF-8-named `.mdx` source — T1.5-2's +// `specs/b<0xFF>.mdx` (0xFF occurs in no valid UTF-8 sequence; SPEC 7's +// byte-wise glob rules still discover it). Its entry's `source`, `module`, +// and `markdown` are each the marked byte form (12.0, 12.7), composed from +// the SAME bytes that stage the file — the module path and the Markdown +// destination are the source's bytes with the final `.mdx` replaced by +// `.xspec.ts` and `.md` (13.1, 13.2, 7.3) — never measured from product +// output. +const T11_6_2_NU_SOURCE_BYTES = Buffer.concat([ + Buffer.from("specs/b", "utf8"), + Buffer.from([0xff]), + Buffer.from(".mdx", "utf8"), +]); + +/** `source`'s bytes with the final `.mdx` replaced by `suffix` (SPEC 13.1). */ +function replaceFinalMdx(source: Buffer, suffix: string): Buffer { + const mdx = Buffer.from(".mdx", "utf8"); + if (!source.subarray(source.length - mdx.length).equals(mdx)) { + throw new Error( + `T11.6-2 fixture: the byte path ${source.toString("hex")} does not ` + + "end in .mdx", + ); + } + return Buffer.concat([ + source.subarray(0, source.length - mdx.length), + Buffer.from(suffix, "utf8"), + ]); +} + +const T11_6_2_EMIT_NU = stagedMdx( + "T11.6-2 emit workspace specs/b<0xFF>.mdx (Linux leg: a non-UTF-8-named .mdx source)", + '<S id="octet">\nNom hors UTF-8.\n</S>\n', +); + +/** `specs/a'b.mdx`'s expected derived-map entry (SPEC 13.1, 7.3). */ +const T11_6_2_QUOTE_ENTRY: InventoryDerivedEntry = { + source: T11_6_2_QUOTE_SOURCE, + module: "specs/a'b.xspec.ts", + markdown: "specs/a'b.md", +}; + +/** + * (Linux leg) `specs/b<0xFF>.mdx`'s expected derived-map entry: `source`, + * `module`, and `markdown` each the marked byte form (SPEC 12.0, 12.7). + */ +const T11_6_2_NU_ENTRY: InventoryDerivedEntry = { + source: { bytes: T11_6_2_NU_SOURCE_BYTES.toString("hex") }, + module: { + bytes: replaceFinalMdx(T11_6_2_NU_SOURCE_BYTES, ".xspec.ts").toString( + "hex", + ), + }, + markdown: { + bytes: replaceFinalMdx(T11_6_2_NU_SOURCE_BYTES, ".md").toString("hex"), + }, +}; + +/** + * The emit workspace's invalid-path `.mdx` entries, each asserted on its own + * before the whole-map compare — the Linux-leg one exactly where staged. + */ +const T11_6_2_INVALID_PATH_ENTRIES: readonly { + readonly what: string; + readonly entry: InventoryDerivedEntry; +}[] = [ + { + what: "`specs/a'b.mdx` (`'` barred by 7.1, T7.1-1)", + entry: T11_6_2_QUOTE_ENTRY, + }, + ...(NON_UTF8_STAGED + ? [ + { + what: "the non-UTF-8-named `specs/b<0xFF>.mdx` (Linux leg, T1.5-2)", + entry: T11_6_2_NU_ENTRY, + }, + ] + : []), +]; + +const T11_6_2_OUTDIR_G = stagedMdx( + "T11.6-2 outDir workspace specs/g.mdx", + '<S id="haut">\nRacine.\n</S>\n', +); +const T11_6_2_OUTDIR_H = stagedMdx( + "T11.6-2 outDir workspace specs/sub/h.mdx", + '<S id="bas">\nNichée.\n</S>\n', +); +const T11_6_2_DISABLED_SEUL = stagedMdx( + "T11.6-2 disabled workspace specs/seul.mdx", + '<S id="seul">\nInerte.\n</S>\n', +); +const T11_6_2_SETS_M = stagedMdx( + "T11.6-2 sets workspace specs/m.mdx", + '<S id="m" tags="a">\nPrincipal.\n</S>\n', +); +const T11_6_2_SETS_X = stagedMdx( + "T11.6-2 sets workspace aux/x.mdx", + '<S id="x">\nAnnexe.\n</S>\n', +); + +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, whereas an `.mdx` source whose path is invalid keeps both — `specs/a\'b.mdx` (7.1\'s bar) → module `specs/a\'b.xspec.ts` and, emitting next to sources, Markdown `specs/a\'b.md`, and on the Linux leg a non-UTF-8-named `.mdx` source → its `source`, `module`, and `markdown` each in the marked byte form, the final `.mdx` replaced by `.xspec.ts` and `.md` — per-source derived paths following the `NAME.mdx` name shape alone; 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; configured sets — a profile\'s `targetTags: ["z", "a", "a"]` and `edgeKinds: ["references", "depends"]`, a rule\'s `kinds: ["embeds", "depends"]`, and a `tags` selector `["b", "a", "b"]`, each valid and read as a set — reported in their 12.7 value forms, form-exact and compared literally: ["a", "z"] and ["a", "b"] in byte order with the repeated element collapsed, ["depends", "references"] and ["depends", "embeds"] in 5.2\'s order however configured (SPEC 11.6, 12.7, 7.3, 7.4, 7.5, 7.1, 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, + }, + ], + 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, + }, + ], + }, + // 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( + 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( + 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": T11_6_2_EMIT_A, + // An `.mdx` source whose path is invalid (`'`, 14.19, SPEC 7.1): + // discovered, and its derived paths follow the `NAME.mdx` name + // shape alone (13.1). + [T11_6_2_QUOTE_SOURCE]: T11_6_2_EMIT_QUOTE, + // 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). Its record + // declares it a well-formed MDX source (S-9). + "specs/note.txt": T11_6_2_EMIT_NOTE, + }, + }); + try { + // (Linux leg) The non-UTF-8-named `.mdx` source, staged through the + // builder's byte-path `file` (T1.5-2's staging). + if (NON_UTF8_STAGED) { + await emit.file(T11_6_2_NU_SOURCE_BYTES, T11_6_2_EMIT_NU); + } + const map = await expectResolvedInventory( + product, + emit.root, + ["inventory"], + "T11.6-2 — `inventory` with emission enabled (default destinations), " + + "invalid-path `.mdx` sources and a non-`.mdx` spec-group file " + + "staged beside the valid source (SPEC 11.6, 7.3, 13.1)", + ); + // Each invalid-path `.mdx` source keeps both derived paths, diagnosed + // on its own: a product deriving paths for valid sources alone (or + // for UTF-8 paths alone) fails here by name, before the whole-map + // compare (SPEC 13.1, 11.6, 7.3; 12.0, 12.7 for the byte form). + for (const { what, entry } of T11_6_2_INVALID_PATH_ENTRIES) { + const sourceBytes = pathValueBytes(entry.source); + const got = map.derived.find((candidate) => + pathValueBytes(candidate.source).equals(sourceBytes), + ); + if (got === undefined) { + fail( + `T11.6-2 — ${what}: the derived map carries one entry per ` + + `discovered spec source, this invalid-path one included (SPEC ` + + `11.6, 7.1: the file is discovered whatever its validity); ` + + `got entries for ` + + `${map.derived.map((e) => renderPathValue(e.source)).join(", ")}`, + ); + } + assertSameJson( + got, + entry, + `T11.6-2 — ${what}: an \`.mdx\` source whose path is invalid ` + + `keeps its generated-module path and, with emission next to ` + + `sources, its Markdown destination — per-source derived paths ` + + `follow the \`NAME.mdx\` name shape alone, the final \`.mdx\` ` + + `replaced by \`.xspec.ts\` and \`.md\`, a non-UTF-8 path in ` + + `the marked byte form (SPEC 13.1, 11.6, 7.3, 12.0, 12.7)`, + ); + } + assertSameJson( + 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: [], + }, + // Byte order of workspace-relative path (11.6): `specs/a'b.mdx` + // (0x27 after `specs/a`) before `specs/a.mdx` (0x2E), then the + // Linux leg's `specs/b<0xFF>.mdx`, then `specs/note.txt`. + sources: [ + // Every discovered source, valid or not, with its membership + // (11.6 "every discovered source file"). + { + path: T11_6_2_QUOTE_ENTRY.source, + groups: [{ name: "main", kind: "spec" }], + }, + { + path: "specs/a.mdx", + groups: [{ name: "main", kind: "spec" }], + }, + ...(NON_UTF8_STAGED + ? [ + { + path: T11_6_2_NU_ENTRY.source, + groups: [{ name: "main", kind: "spec" as const }], + }, + ] + : []), + // 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: [ + // An `.mdx` source whose path is invalid keeps both derived + // paths: the `NAME.mdx` name shape alone defines them (13.1). + T11_6_2_QUOTE_ENTRY, + // 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", + }, + // (Linux leg) The non-UTF-8-named `.mdx` source: all three + // members in the marked byte form (12.0, 12.7). + ...(NON_UTF8_STAGED ? [T11_6_2_NU_ENTRY] : []), + // 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, an `.mdx` source whose path is " + + "invalid keeping both (the non-UTF-8 one in the marked byte " + + "form, Linux leg); the non-`.mdx` spec-group file listed in " + + "`sources` with `module` and `markdown` null (SPEC 11.6, 7.3, " + + "13.1, 12.0, 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": T11_6_2_OUTDIR_G, + "specs/sub/h.mdx": T11_6_2_OUTDIR_H, + }, + }); + 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( + 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": T11_6_2_DISABLED_SEUL, + }, + }); + 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( + 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(); + } + + // --- configured sets in their value forms ----------------------------- + const sets = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: RESOLVED_SETS_CONFIG, + "specs/m.mdx": T11_6_2_SETS_M, + "aux/x.mdx": T11_6_2_SETS_X, + }, + }); + try { + const map = await expectResolvedInventory( + product, + sets.root, + ["inventory"], + "T11.6-2 — `inventory` with the configured sets spelled repeated " + + "and out of order (SPEC 7.4, 7.5, 12.7, 11.6)", + ); + assertSameJson( + map, + { + configuration: { + specs: [ + { name: "main", globs: ["specs/**/*.mdx"] }, + { name: "aux", globs: ["aux/**/*.mdx"] }, + ], + code: [], + markdown: { emit: false, outDir: null }, + coverage: [ + { + name: "ensemble", + target: "main", + // `targetTags: ["z", "a", "a"]` read as a set (7.4) and + // reported in its 12.7 value form: byte order, the repeated + // element collapsed. + targetTags: ["a", "z"], + targets: "leaves", + boundary: "aux", + boundaryKind: "spec", + mode: "direct", + // `edgeKinds: ["references", "depends"]` → 5.2's order, + // however configured (12.7). + edgeKinds: ["depends", "references"], + }, + ], + policy: [ + { + name: "etiquettes", + type: "forbidden", + // The `tags` selector `["b", "a", "b"]` → byte order, + // duplicates collapsed (7.5, 12.7). + from: { tags: ["a", "b"] }, + to: { group: "aux", kind: "spec" }, + // `kinds: ["embeds", "depends"]` → 5.2's order (12.7). + kinds: ["depends", "embeds"], + }, + ], + }, + sources: [ + { path: "aux/x.mdx", groups: [{ name: "aux", kind: "spec" }] }, + { path: "specs/m.mdx", groups: [{ name: "main", kind: "spec" }] }, + ], + derived: [ + { source: "aux/x.mdx", module: "aux/x.xspec.ts", markdown: null }, + { + source: "specs/m.mdx", + module: "specs/m.xspec.ts", + markdown: null, + }, + ], + } satisfies InventoryResolvedMap, + "T11.6-2 — configured sets in their value forms, form-exact and " + + 'compared literally: `targetTags` ["a", "z"] and the selector\'s ' + + '`tags` ["a", "b"] in byte order with the repeated element ' + + 'collapsed, `edgeKinds` ["depends", "references"] and `kinds` ' + + '["depends", "embeds"] in 5.2\'s order however configured — ' + + "never the configured spellings (SPEC 7.4, 7.5, 12.7, 11.6)", + ); + } finally { + await sets.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 } +}) +`; + +// The lag arm's rewrite, staged by `file()` after the build: a TypeScript +// staged-source record (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), well-formed. +const DURABLES_NOEMIT_CONFIG = stagedTs( + "T11.6-3 xspec.config.ts — the emission-off twin (the lag arm's rewrite)", + `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"; + +// The sessions workspace is created after the record workspace's +// invocations: its configuration is a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), well-formed. +const SESSIONS_CONFIG = stagedTs( + "T11.6-3 sessions workspace xspec.config.ts — one spec group", + `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)`, + ); + } + } +} + +// T11.6-3's sessions workspace is created after the body's first product +// invocation (the record workspace's runs), so S-7's sweep never reaches +// its initial source against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause), the literal +// moved into it. +const T11_6_3_SESSIONS_SEUL = stagedMdx( + "T11.6-3 sessions workspace specs/seul.mdx", + '<S id="seul">\nSeul.\n</S>\n', +); + +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": T11_6_3_SESSIONS_SEUL, + }, + }); + 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, +}; + +// Arms B and C stage their workspaces after arm A's invocations, so both +// configurations are TypeScript staged-source records (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses). + +/** + * Not well-formed TypeScript: the invalid-configuration staging (14.14) — + * an `unparseable` record (14.20), which carries the declaration. + */ +const IMPERFECT_BROKEN_CONFIG = stagedTs( + "T11.6-4 arm B xspec.config.ts — not well-formed TypeScript (the invalid configuration)", + "ceci n'est pas du TypeScript ((( donc pas une configuration\n", + "unparseable", +); + +/** Arm C's valid workspace: one source, emission on (a rich record). */ +const RECORD_EMIT_CONFIG = stagedTs( + "T11.6-4 arm C xspec.config.ts — one source, emission on (the corrupt-record workspace)", + `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, + }; +} + +// T11.6-4's corrupt-record workspace (arm C) is created after the body's +// first product invocation (arm A's runs), so S-7's sweep never reaches +// its initial source against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause), the literal +// moved into it; arm B's `specs/a.mdx` is the anchor record above. +const T11_6_4_RECORD_SEUL = stagedMdx( + "T11.6-4 corrupt-record workspace specs/seul.mdx", + '<S id="seul">\nContenu stable.\n</S>\n', +); + +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", + }, + // S-9: casse.mdx is the imperfect workspace's parse failure (14.20). + // note.txt, the spec-group file without `.mdx` (14.19), is an MDX + // source all the same, its content judged by 14.20 whatever its name: + // declared well-formed — the premise's one finding per staged + // construct allows it no 14.20 — and judged at creation, the body's + // first staging, before any product invocation. + mdx: { + unparseable: ["specs/casse.mdx"], + wellFormed: ["specs/note.txt"], + }, + }); + 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, + }, + // S-9: the broken configuration is not well-formed TypeScript (14.20); + // its record carries the `unparseable` declaration. + }); + 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": T11_6_4_RECORD_SEUL, + }, + }); + 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 cf3d234d..07e1d079 100644 --- a/test/suite/registry/section-11.ts +++ b/test/suite/registry/section-11.ts @@ -19,7 +19,12 @@ // `--coverage` matches no root; `--group` accepts only a spec group's name — // a code group's name is an invalid flag value (12.0, the wrong-kind group // reference of 14.14); `--file` uses the glob rules of 7, the outside-root -// rule included. `nodes`, `subtree`, and `ancestors` share one row contract: +// rule included; `--tag` accepts any well-formed tag (1.4) — acceptance is +// syntactic, as on `occurrences --to` (11.3): a spelling no tag can have is +// a malformed value, a usage error of the syntax class reported without +// loading configuration (12.0), while a well-formed tag no node carries +// matches nothing, exit 0. `nodes`, `subtree`, and `ancestors` share one row +// contract: // identity, source range, tags, coverage attribute (absent for roots). // `subtree` returns the queried node plus all descendants in document order; // `ancestors` returns the proper ancestors nearest-first ending at the file @@ -28,7 +33,13 @@ // three dependency kinds, never `contains`) and, when one does, one shortest // witness path with the 12.0 byte-least tie-break; `reachable --kinds` // rejects `contains` while `edges --kinds` filters over all four kinds and -// defaults to no filter. All results use stable, deterministic ordering. +// defaults to no filter. A list-valued flag (`--kinds`) takes one +// comma-separated value read as a set drawn from its command's vocabulary +// (11.1): an element that is empty — a leading, trailing, or doubled comma +// — or outside the vocabulary is an invalid flag value, a usage error of the +// syntax class reported without loading configuration (12.0), and a +// repeated element collapses, `depends,depends` answering byte-identically +// to `depends`. All results use stable, deterministic ordering. // // Conservative operationalizations (noted per H-4/H-3): // - Both-forms comparison (the §11 preamble): each subcommand's primary arm @@ -73,8 +84,8 @@ import { decodeReachableReport, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -85,16 +96,30 @@ import { } from "../../helpers/determinism.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { runProduct } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { + BESIDE_ROOT_FILE_PATTERN_DECOY, + INSIDE_NO_MATCH_FILE_PATTERNS, + OUTSIDE_ROOT_FILE_PATTERNS, + REPLACEMENT_CHARACTER, assertEdgeSetEqual, assertSameJson, buildOk, + expectErrorDocument, expectExit, + expectFilePatternUsageError, + expectSyntaxClassUsageError, + insideNoMatchFilePatterns, runJson, sortedIdentities, + stageBesideRoot, + stageConfigurationStateTwins, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. @@ -141,7 +166,7 @@ export default defineConfig({ /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( config: string, - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -185,10 +210,6 @@ function wholeFileRange(source: string): SourceRange { return { start: 0, end: utf8Length(source) }; } -function sortedTags(tags: readonly string[]): string[] { - return [...tags].sort(); -} - function edgeSortKey(edge: GraphEdge): string { return `${edge.kind}\u0000${edge.from}\u0000${edge.to}`; } @@ -210,19 +231,23 @@ function normalizedNodeReport(report: NodeReport): unknown { ownText: report.ownText, subtreeText: report.subtreeText, hashes: report.hashes, - tags: sortedTags(report.tags), + tags: report.tags, coverage: report.coverage, incomingEdges: sortedEdges(report.incomingEdges), outgoingEdges: sortedEdges(report.outgoingEdges), }; } -/** One row with tag order normalized (row order handled by the caller). */ +/** + * One row projected for comparison (row order handled by the caller); its + * tags are compared literally — the decoder already enforces 12.7's tag-set + * form, strictly ascending by UTF-8 bytes. + */ function normalizedRow(row: NodeRow): unknown { return { identity: row.identity, sourceRange: row.sourceRange, - tags: sortedTags(row.tags), + tags: row.tags, coverage: row.coverage, }; } @@ -284,9 +309,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 +328,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)`, ); } @@ -335,9 +361,10 @@ function assertRowFields( `offsets, start-inclusive and end-exclusive (SPEC 1.7, 11)`, ); assertSameJson( - sortedTags(row.tags), - sortedTags(want.tags), - `${context}: tags of ${row.identity} (SPEC 2.6, 11)`, + row.tags, + want.tags, + `${context}: tags of ${row.identity} — the 12.7 tag-set form, ` + + `strictly ascending by UTF-8 bytes (SPEC 2.6, 11, 12.7)`, ); if (row.coverage !== want.coverage) { fail( @@ -485,7 +512,7 @@ const T11_1 = defineProductTest({ `contribution with the embedding fully expanded (SPEC 1.6, 3)`, ); assertSameJson( - sortedTags(alpha.tags), + alpha.tags, ["core", "deep"], `${alphaContext}: tags (SPEC 2.6, 11)`, ); @@ -641,6 +668,12 @@ const T11_2_A_SOURCE = `${T11_2_A1}\n\n${T11_2_A2}\n\n${T11_2_A3}\n`; const T11_2_B1 = '<S id="b1" tags="red" coverage="none">\nB one.\n</S>'; const T11_2_B2 = '<S id="b2">\nB two.\n</S>'; const T11_2_B_SOURCE = `${T11_2_B1}\n\n${T11_2_B2}\n`; +// Staged by T11-2's workspace and by its configuration-state twins — the +// twins after the body's first invocation, so S-7's sweep never reaches +// them against the stub: staged-source records (helpers/staged-mdx.ts; +// S-9's before-any-product clause) made from the strings the ranges use. +const T11_2_A_STAGED = stagedMdx("T11-2 specs/alpha/A.mdx", T11_2_A_SOURCE); +const T11_2_B_STAGED = stagedMdx("T11-2 specs/beta/B.mdx", T11_2_B_SOURCE); // Every requirement node in the workspace with its full row contract — // identity, exact source range, tags, coverage attribute (absent for the two @@ -704,18 +737,67 @@ function expectedRows( }); } +// T11-2's file set; the configuration stands apart, since the `--tag` +// sweep's configuration-state twins stage these same files under an invalid +// and under no configuration. The twins are created after the `build`, so +// every file of the set is a staged-source record: the `.mdx` sources MDX +// records, `src/app.ts` a TypeScript record (helpers/staged-ts.ts; S-9's +// TypeScript and timing clauses). +const T11_2_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/alpha/A.mdx": T11_2_A_STAGED, + "specs/beta/B.mdx": T11_2_B_STAGED, + "src/app.ts": stagedTs( + "T11-2 src/app.ts — an empty module (the workspace's and its configuration-state twins')", + "export {};\n", + ), +}; + +// `--tag` acceptance is syntactic (SPEC 11.1, 1.4, 12.0): the spellings no +// tag can have, one arm per TEST-SPEC class, each exit 2. Where the form +// allows, the defect stands between two ordinary letters, so the character +// alone is what a product accepts or rejects. The control characters are +// built from their code points — U+0000 can be no argument value at all (no +// argument vector carries a NUL), so the class is represented by its two +// remaining boundaries, U+001F and U+007F — and U+FFFD is the shared +// `REPLACEMENT_CHARACTER`, so no tool layer decodes a spelling on the way +// into this file. +const T11_2_MALFORMED_TAGS: ReadonlyArray<{ + readonly spelling: string; + readonly what: string; +}> = [ + { spelling: "", what: "the empty string (a segment MUST be non-empty)" }, + { spelling: "a b", what: "a whitespace-bearing spelling (U+0020 inside)" }, + { spelling: "#", what: "`#` (no segment contains one)" }, + { spelling: "then", what: "a forbidden name" }, + { + spelling: `a${String.fromCodePoint(0x1f)}b`, + what: "a control character (U+001F)", + }, + { + spelling: `a${String.fromCodePoint(0x7f)}b`, + what: "a control character (U+007F)", + }, + { spelling: 'a"b', what: 'the quote character `"`' }, + { spelling: "a'b", what: "the quote character `'`" }, + { + spelling: "a\\b", + what: "the escape character `\\` (the `a\\b` spelling of T12.0-10)", + }, + { spelling: "a&b", what: "the character-reference character `&`" }, + { + spelling: `a${REPLACEMENT_CHARACTER}b`, + what: "U+FFFD (12.0's argument-value rule: no argument value carries it)", + }, +]; + const T11_2 = defineProductTest({ id: "T11-2", title: - "`query nodes` rows are requirement nodes carrying identity, source range, tags, and coverage attribute (absent for roots); `--group`, `--file <glob>`, `--tag`, and `--coverage` combine conjunctively; `--coverage` matches no root; a `--file` pattern resolving outside the workspace root and a `--group` naming a code group are invalid flag values, exit 2 (SPEC 11, 7, 12.0, 14.14)", + "`query nodes` rows are requirement nodes carrying identity, source range, tags, and coverage attribute (absent for roots); `--group`, `--file <glob>`, `--tag`, and `--coverage` combine conjunctively; `--coverage` matches no root; a `--file` pattern outside the workspace root by spelling alone (`../x/*.mdx`, `../x`, `a/../../x`, `/specs/*.mdx`) is an invalid flag value — exit 2 with the plain usage error's document, code and path null, a matching file beside the root notwithstanding — while an inside pattern spelled with a `.` or empty segment (`./specs/*.mdx`, `specs//*.mdx`, and their twins over `specs/alpha`) is admitted and matches nothing, exit 0 with no rows; a `--group` naming a code group is an invalid flag value, exit 2; `--tag` acceptance is syntactic, as on `occurrences --to`: a well-formed tag no node carries (`green`; `a.b`, since a tag may contain `.`) matches nothing — exit 0, an empty row set — while a spelling no tag can have, one arm each — the empty string, `a b`, `#`, `then`, a control character (U+001F and U+007F, the class's argument-bearable boundaries), `\"`, `'`, `\\`, `&`, and U+FFFD — is a malformed value of the syntax class: exit 2 with the plain usage error's document (`code` and `path` null), reported without loading configuration — byte-identical with the configuration file invalid or missing (T12.0-10's discipline) — nothing modified (SPEC 11, 11.1, 1.4, 7, 12.0, 12.7, 14.14)", run: async (product) => { await withWorkspace( TWO_SPEC_GROUP_CONFIG, - { - "specs/alpha/A.mdx": T11_2_A_SOURCE, - "specs/beta/B.mdx": T11_2_B_SOURCE, - "src/app.ts": "export {};\n", - }, + T11_2_FILES, async (workspace) => { await buildOk(product, workspace, "T11-2 `build`"); @@ -822,17 +904,52 @@ const T11_2 = defineProductTest({ assertRowSet(rows, expectedRows(T11_2_ROWS, arm.ids), context); } - // Invalid flag values (SPEC 11, 12.0): a `--file` pattern resolving - // outside the workspace root (the outside-root rule of 7, exit 2 - // like its configuration-time counterpart 14.14), and a `--group` - // naming a code group (the wrong-kind group reference of 14.14). - await expectUsageError( - product, - workspace, - ["query", "nodes", "--file", "../*.mdx"], - "a `--file` pattern resolving outside the workspace root", - "T11-2 `query nodes --file ../*.mdx`", - ); + // Invalid flag values (SPEC 11, 12.0): a `--file` pattern outside + // the workspace root by its spelling alone — decided as 7 decides + // a configured glob (T7-4), exit 2 like its configuration-time + // counterpart 14.14 — with the file the ascending spellings name + // when resolved staged beside the root, so exit 2 never comes from + // a side reason; and a `--group` naming a code group (the + // wrong-kind group reference of 14.14). + await stageBesideRoot(workspace, BESIDE_ROOT_FILE_PATTERN_DECOY); + for (const { spelling, why } of OUTSIDE_ROOT_FILE_PATTERNS) { + await expectFilePatternUsageError( + product, + workspace, + ["query", "nodes", "--file", spelling, "--json"], + `T11-2 \`query nodes --file ${JSON.stringify(spelling)}\` (${why})`, + ); + } + + // An inside pattern spelled with a `.` or an empty segment is + // admitted and matches nothing — a discovered path carries no such + // segment (SPEC 7, 12.0): exit 0 with no rows. TEST-SPEC's pinned + // `specs` spellings run beside their twins over `specs/alpha`, + // whose normalized form (`specs/alpha/*.mdx`) matches A.mdx — the + // arm a normalizing product fails. + for (const { spelling, why } of [ + ...INSIDE_NO_MATCH_FILE_PATTERNS, + ...insideNoMatchFilePatterns("specs/alpha"), + ]) { + const context = `T11-2 \`query nodes --file ${JSON.stringify(spelling)}\` (${why})`; + const rows = decodeNodeRowsReport( + await runJson( + product, + workspace, + ["query", "nodes", "--file", spelling, "--json"], + `${context} — an inside pattern matching nothing is admitted: ` + + `exit 0 (SPEC 11.1, 7)`, + ), + context, + ); + assertSameJson( + rows, + [], + `${context}: no rows — the pattern matches no discovered file, ` + + `whose path carries no \`.\` or empty segment (SPEC 7, 12.0); ` + + `an empty row set is [], never null (12.7)`, + ); + } await expectUsageError( product, workspace, @@ -840,6 +957,58 @@ const T11_2 = defineProductTest({ "a `--group` value naming a code group (a wrong-kind group reference, 14.14)", "T11-2 `query nodes --group app`", ); + + // `--tag` acceptance is syntactic (SPEC 11.1, 1.4, 12.0; as on + // `occurrences --to`, 11.3). A well-formed tag no node carries + // matches nothing — exit 0, an empty row set — `a.b` beside `green` + // since a tag MAY contain `.` (1.4): a product applying the segment + // rule to tags refuses it and fails the exit assertion. + for (const tag of ["green", "a.b"]) { + const context = `T11-2 \`query nodes --tag ${tag}\` — a well-formed tag no node carries`; + const rows = decodeNodeRowsReport( + await runJson( + product, + workspace, + ["query", "nodes", "--tag", tag, "--json"], + `${context} matches nothing: exit 0, never a usage error (SPEC 11.1, 1.4)`, + ), + context, + ); + assertSameJson( + rows, + [], + `${context}: an empty row set — [], never null (SPEC 11.1, 12.7)`, + ); + } + + // Each spelling no tag can have is a malformed value of the syntax + // class: exit 2 with the plain usage error's document, reported + // without loading configuration — identically, byte for byte, on + // the twins holding the same files under an invalid and under no + // configuration (T12.0-10's discipline; a product loading + // configuration before judging the tag answers 14.14 there) — the + // whole sweep modifying nothing on the built workspace. + const twins = await stageConfigurationStateTwins(T11_2_FILES); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const { spelling, what } of T11_2_MALFORMED_TAGS) { + await expectSyntaxClassUsageError( + product, + workspace, + twins, + ["query", "nodes", "--tag", spelling, "--json"], + `T11-2 \`query nodes --tag ${JSON.stringify(spelling)}\` (${what})`, + ); + } + }, + "T11-2 malformed `--tag` sweep — a syntax-class usage error " + + "modifies nothing (SPEC 12.0)", + ); + } finally { + await twins.dispose(); + } }, ); }, @@ -1022,24 +1191,37 @@ const T11_3 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T11-4 — `query edges`: --from/--to over both node kinds, --kinds, exit 2 +// T11-4 — `query edges`: --from/--to over both node kinds, --kinds as a set // --------------------------------------------------------------------------- const T11_4_HUB = '<S id="hub" d={"leaf"}>\nHub: {text("leaf")}\n</S>'; const T11_4_LEAF = '<S id="leaf">\nLeaf text.\n</S>'; -const T11_4_SOURCE = `${T11_4_HUB}\n\n${T11_4_LEAF}\n`; -const T11_4_APP = [ - 'import SPEC, { text } from "../specs/E.xspec";', - "", - "export function embedder(): string {", - " return text(SPEC.leaf);", - "}", - "", - "export function referrer(): void {", - " SPEC.hub;", - "}", - "", -].join("\n"); +// Staged by T11-4's workspace and by its configuration-state twins — the +// twins after the body's first invocation, so S-7's sweep never reaches +// them against the stub: a staged-source record (helpers/staged-mdx.ts; +// S-9's before-any-product clause), the same expression moved into it. +const T11_4_SOURCE = stagedMdx( + "T11-4 specs/E.mdx", + `${T11_4_HUB}\n\n${T11_4_LEAF}\n`, +); +// T11-4's code source: its configuration-state twins stage it after the +// `build`, so it is a TypeScript staged-source record (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), staged in all three workspaces. +const T11_4_APP = stagedTs( + "T11-4 src/app.ts — the embedder and referrer units (the workspace's and its configuration-state twins')", + [ + 'import SPEC, { text } from "../specs/E.xspec";', + "", + "export function embedder(): string {", + " return text(SPEC.leaf);", + "}", + "", + "export function referrer(): void {", + " SPEC.hub;", + "}", + "", + ].join("\n"), +); const T11_4_FILE = "specs/E.mdx"; const T11_4_HUB_ID = "specs/E.mdx#hub"; @@ -1047,6 +1229,37 @@ const T11_4_LEAF_ID = "specs/E.mdx#leaf"; const T11_4_EMBEDDER = "src/app.ts#embedder"; const T11_4_REFERRER = "src/app.ts#referrer"; +const T11_4_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/E.mdx": T11_4_SOURCE, + "src/app.ts": T11_4_APP, +}; + +// `--kinds` is read as a set drawn from `edges`'s vocabulary — the four +// kinds of 5.2 (SPEC 11.1): an element that is empty — a leading, trailing, +// or doubled comma — or outside the vocabulary is an invalid flag value, a +// usage error the arguments alone determine (12.0). One arm per defect; +// where the form allows, the defect stands beside a valid element, so the +// element alone is what a product accepts or rejects. +const T11_4_MALFORMED_KINDS: ReadonlyArray<{ + readonly spelling: string; + readonly what: string; +}> = [ + { spelling: "nonsense", what: "an element outside the vocabulary" }, + { + spelling: "depends,nonsense", + what: "an element outside the vocabulary beside a valid one", + }, + { spelling: "depends,", what: "a trailing comma — an empty element" }, + { spelling: ",depends", what: "a leading comma — an empty element" }, + { + spelling: "depends,,embeds", + what: "a doubled comma — an empty element between two valid ones", + }, +]; + +// The two invocation forms of a JSON-only surface (SPEC 11, 12.0). +const T11_4_FORMS: readonly (readonly string[])[] = [["--json"], []]; + // The workspace's complete edge set — all four kinds present. const T11_4_ALL_EDGES: readonly GraphEdge[] = [ { from: T11_4_FILE, to: T11_4_HUB_ID, kind: "contains" }, @@ -1066,11 +1279,11 @@ function edgesWhere( const T11_4 = defineProductTest({ id: "T11-4", title: - "`query edges`: `--from`/`--to` accept requirement nodes and code locations; `--kinds` filters over all four kinds via one comma-separated value and defaults to no filter, `contains` edges included; an unknown kind value is an invalid flag value, exit 2 (SPEC 11, 5.2, 12.0)", + "`query edges`: `--from`/`--to` accept requirement nodes and code locations; `--kinds` filters over all four kinds via one comma-separated value read as a set and defaults to no filter, `contains` edges included; a repeated element collapses — `depends,depends` answers byte-identically to `depends` — while an element outside the vocabulary or empty (`depends,`, `,depends`, `depends,,embeds`) is an invalid flag value, a syntax-class usage error at exit 2 reported without loading configuration (SPEC 11, 11.1, 5.2, 12.0, 12.7)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, - { "specs/E.mdx": T11_4_SOURCE, "src/app.ts": T11_4_APP }, + T11_4_FILES, async (workspace) => { await buildOk(product, workspace, "T11-4 `build`"); @@ -1146,13 +1359,77 @@ const T11_4 = defineProductTest({ assertEdgeSetEqual(edges, arm.expected, context); } - await expectUsageError( - product, - workspace, - ["query", "edges", "--kinds", "nonsense"], - "an unknown edge-kind value", - "T11-4 `query edges --kinds nonsense`", - ); + // A repeated element collapses (SPEC 11.1): `--kinds depends,depends` + // is read as the set {depends}, so on each form its answer is + // byte-identical to `--kinds depends`'s (T12.0-14) — the single + // spelling pinned first as the `depends` edges alone. + const singleArgv = ["query", "edges", "--kinds", "depends"]; + const repeatedArgv = ["query", "edges", "--kinds", "depends,depends"]; + for (const form of T11_4_FORMS) { + const formLabel = form.length === 0 ? "without --json" : "--json"; + const singleContext = `T11-4 \`${singleArgv.join(" ")}\` (${formLabel})`; + const single = await expectExit( + product, + workspace, + [...singleArgv, ...form], + 0, + singleContext, + ); + assertEdgeSetEqual( + decodeEdgesReport( + parseJsonStdout(single, singleContext), + singleContext, + ), + edgesWhere((edge) => edge.kind === "depends"), + `${singleContext}: the \`depends\` edges alone (SPEC 11.1)`, + ); + const repeatedContext = `T11-4 \`${repeatedArgv.join(" ")}\` (${formLabel})`; + const repeated = await expectExit( + product, + workspace, + [...repeatedArgv, ...form], + 0, + `${repeatedContext} — a repeated element collapses to the set ` + + `{depends}: exit 0, never an invalid flag value (SPEC 11.1)`, + ); + assertBytesEqual( + repeated.stdoutBytes, + single.stdoutBytes, + `${repeatedContext}: the answer is byte-identical to ` + + `\`--kinds depends\`'s — a repeated element collapses (SPEC ` + + `11.1, 12.0; T12.0-14)`, + ); + } + + // Each malformed `--kinds` value is a usage error of the syntax + // class: exit 2 with the plain usage error's document, reported + // without loading configuration — identically, byte for byte, on + // the twins holding the same files under an invalid and under no + // configuration (T12.0-10's discipline; a product loading + // configuration before judging the value answers 14.14 there) — + // the whole sweep modifying nothing on the built workspace. + const twins = await stageConfigurationStateTwins(T11_4_FILES); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const { spelling, what } of T11_4_MALFORMED_KINDS) { + await expectSyntaxClassUsageError( + product, + workspace, + twins, + ["query", "edges", "--kinds", spelling, "--json"], + `T11-4 \`query edges --kinds ${spelling}\` (${what}: an ` + + `invalid flag value, SPEC 11.1)`, + ); + } + }, + "T11-4 malformed `--kinds` sweep — a syntax-class usage error " + + "modifies nothing (SPEC 12.0)", + ); + } finally { + await twins.dispose(); + } }, ); }, @@ -1430,7 +1707,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>'; @@ -1438,7 +1716,11 @@ const T11_6_S_SOURCE = `${T11_6_S1}\n`; // One code file staging all three code-location identity forms (SPEC 4.6): // a top-level marker attributed to the whole file (bare path), a getter // (the first `Box.v` occurrence), and a setter (the second — `Box.v@2`, -// the spec's own getter/setter duplicate-chain example). +// the spec's own getter/setter duplicate-chain example). Class `C` stages +// the two unit names 1.4 forbids as ID segments: a constructor holding a +// marker (`C.constructor`) and a method named `then` holding a marker and a +// `text(...)` call (`C.then`) — each a named unit judged over the file's +// named units, never by 1.4's segment rules (SPEC 4.6, 12.0). const T11_6_CODE = [ 'import SPEC, { text } from "../specs/S.xspec";', "", @@ -1453,6 +1735,16 @@ const T11_6_CODE = [ " }", "}", "", + "export class C {", + " constructor() {", + " SPEC.s1;", + " }", + " then(): void {", + " SPEC.s1;", + " text(SPEC.s1);", + " }", + "}", + "", ].join("\n"); const T11_6_ROOT = "specs/S.mdx"; @@ -1461,7 +1753,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`, `query subtree`, `query ancestors`, and `show` given a code-group `path` or `path#unit` — each exit 2, modifying nothing; an unspelled unit name on a discovered code source is unknown in every graph-node argument position (`edges --from`/`--to`, `reachable --from`/`--to`), exit 2, as are an out-of-range disambiguator (`@2` on a once-occurring chain) and `@1` at every occurrence count — no occurrence bears `@1`, staged at one and at two occurrences; unit names 1.4 forbids as ID segments are named units all the same — `path#C.constructor`, a class `C` whose constructor holds a marker, and `path#C.then`, a method named `then` — each answering `query edges --from` with that unit's edges, exit 0: a code unit is judged over the file's named units, never by 1.4's segment rules (SPEC 11, 1.4, 1.5, 4.6, 12.0, 12.4)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -1545,6 +1837,40 @@ const T11_6 = defineProductTest({ "`path#unit@N` addresses the N-th occurrence of a duplicate " + "unit chain — the setter of the getter/setter pair (SPEC 4.6)", }, + // Unit names 1.4 forbids as ID segments (SPEC 4.6; 12.0: a code + // unit is judged over the file's named units, never by 1.4's + // segment rules): a product pre-validating every `<graph-node>` + // spelling with 1.4's forbidden-name rule — as `occurrences --to` + // applies it to requirement identities (T11.3-3) — exits 2 here + // and fails the exit-0 answer. + { + from: "src/code.ts#C.constructor", + expected: [ + { + from: "src/code.ts#C.constructor", + to: T11_6_S1_ID, + kind: "references", + }, + ], + what: + "a constructor is a unit named `constructor` — a name 1.4 " + + "forbids as an ID segment — answering with its edges, exit 0 " + + "(SPEC 4.6, 12.0)", + }, + { + from: "src/code.ts#C.then", + expected: [ + { + from: "src/code.ts#C.then", + to: T11_6_S1_ID, + kind: "references", + }, + { from: "src/code.ts#C.then", to: T11_6_S1_ID, kind: "embeds" }, + ], + what: + "a method named `then` — likewise a name 1.4 forbids as an ID " + + "segment — answering with its edges, exit 0 (SPEC 4.6, 12.0)", + }, ]; for (const arm of codeArms) { const context = `T11-6 \`query edges --from ${arm.from}\` — ${arm.what}`; @@ -1577,6 +1903,135 @@ 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`, `query + // subtree`, and `query ancestors` each take a `<node>` — a + // requirement-node identity, `path#id` or a bare spec-group `path` + // (11.1) — and so does `show` (12.4), so a code-group `path` or + // `path#unit` — a code source named where a requirement-node identity + // is required — is a usage error for each command, in each form. A + // read command never writes and the argument check is judged before + // anything else (12.0), so each arm runs under the modifies-nothing + // compare: the whole workspace root, derived files included (13), is + // byte-identical around the invocation. + const wrongKindCommands: readonly (readonly string[])[] = [ + ["query", "node"], + ["query", "subtree"], + ["query", "ancestors"], + ["show"], + ]; + for (const command of wrongKindCommands) { + for (const operand of ["src/code.ts", "src/code.ts#Box.v"]) { + const argv = [...command, operand]; + const context = `T11-6 \`${argv.join(" ")}\``; + await assertLeavesUnchanged( + workspace.root, + () => + expectUsageError( + product, + workspace, + argv, + "a code source named where a requirement-node identity is " + + "required (wrong-kind operand)", + context, + ), + context, + ); + } + } + + // Unknown code units (SPEC 12.0, 4.6): the check is judged + // 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 3c909d34..a3cb37ed 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,12 +57,33 @@ // - 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`, // where a glob matching nothing is a valid empty restriction that exit -// codes cannot discriminate. +// codes cannot discriminate. Its malformed-value table (U+FFFD in every +// argument position; the non-UTF-8 bytes on the Linux leg) runs through +// the shared syntax-class discipline (`expectSyntaxClassUsageError`, +// T12.0-10: the plain usage error, byte-identical with the configuration +// invalid or missing) ahead of the `show`/`view` normalization negatives, +// so a product normalizing `./` or `//` spellings fails at those after +// the value-level arms have run. +// The positive side of the backslash (Linux leg, gated inside the body +// beside the non-UTF-8 arm, so the Windows leg reruns the entry and +// skips no arm, E-6) closes the body over two workspaces of its own, +// created after the first invocation, so every file they stage is a +// staged-source record (S-9): a code side — `occurrences --file` over +// `src/a`, backslash, `b.ts` beside `src/ab.ts`, after a `src/*.ts` +// control showing both discovered, each marking its node — and a spec +// side — `view` and `at … 0` over the invalid-path `specs/a`, +// backslash, `b.mdx`, pinned as T12.0-13 pins its `#` path (exactly +// the condition-19 finding; identities unavailable, ranges on view). // - T12.0-6 stages the two-casing tree (`specs/A.mdx` beside `specs/a.mdx`) // on Linux only — such trees exist only on case-sensitive filesystems (the // suite leg is Linux); the single-casing probe, tag, ID, and session-name @@ -57,14 +93,24 @@ import { Buffer } from "node:buffer"; import { assertReportMentions, + decodeAtReport, decodeExportReport, decodeFindingsReport, decodeIdsReport, decodeNodeReport, decodeNodeRowsReport, + decodeOccurrencesReport, decodeReachableReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import type { + Finding, + OccurrenceRecord, + SourceRange, + ViewNode, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertExitCode, assertStdoutEmpty, fail, @@ -75,30 +121,47 @@ import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { releaseHoldFile, + rethrowOutputOverflow, runProduct, startProduct, } from "../../helpers/subprocess.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { + REPLACEMENT_CHARACTER, + REPLACEMENT_CHARACTER_SPEC_PATH, assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, + expectPlainUsageError, + expectSyntaxClassUsageError, runCli, runJson, sortedIdentities, + stageConfigurationStateTwins, } from "./support.js"; -// Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): exactly one spec group. A +// TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), staged wherever it is used: T12.0-2's, T12.0-3's, +// and T12.0-6's later workspaces stage it after a product invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T12.0-2/T12.0-3/T12.0-6 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); /** Stage a fresh workspace, run `body`, dispose (H-1). */ async function withWorkspace<T>( @@ -187,6 +250,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 +310,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 +337,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 +365,7 @@ const SWEEP_STEPS: readonly SweepStep[] = [ what: "review export", argv: () => ["review", "export", SWEEP_SESSION], harvest: harvestItemIds, + jsonOnly: true, }, { what: "review show", @@ -333,10 +426,63 @@ 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). + */ +export function canonicalizeJson(value: unknown): unknown { + // H-11: an explicit stack, never native recursion per nesting level. + type Container = unknown[] | Record<string, unknown>; + const isContainer = (candidate: unknown): candidate is Container => + candidate !== null && typeof candidate === "object"; + if (!isContainer(value)) return value; + const root: Container = Array.isArray(value) ? [] : {}; + const pending: { readonly source: Container; readonly copy: Container }[] = [ + { source: value, copy: root }, + ]; + // A leaf is placed as is; a container is placed as a fresh empty copy — + // attached in its parent's sorted member order, filled when its own frame + // is popped. + const placed = (member: unknown): unknown => { + if (!isContainer(member)) return member; + const copy: Container = Array.isArray(member) ? [] : {}; + pending.push({ source: member, copy }); + return copy; + }; + while (pending.length > 0) { + const frame = pending.pop(); + if (frame === undefined) break; + const { source, copy } = frame; + if (Array.isArray(source)) { + const target = copy as unknown[]; + for (let index = 0; index < source.length; index += 1) { + target[index] = placed(source[index]); + } + } else { + const target = copy as Record<string, unknown>; + for (const key of Object.keys(source).sort()) { + target[key] = placed(source[key]); + } + } + } + return root; +} + /** * 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 +510,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 +557,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(); } @@ -393,17 +581,27 @@ const T12_0_1 = defineProductTest({ // One unresolved same-file `d` reference (SPEC 14.5): the finding family is // arbitrary — the arms assert streams, not the error catalog. -const STREAMS_INVALID_SOURCE = [ - '<S id="a" d={"missing"}>', - "Alpha text.", - "</S>", - "", -].join("\n"); -const STREAMS_VALID_SOURCE = ['<S id="a">', "Alpha text.", "</S>", ""].join( - "\n", +// Staged-source records (helpers/staged-mdx.ts; S-9's before-any-product +// clause): the findings source of T12.0-2's first workspace, which +// T12.0-9's findings arm stages after its first invocation (by import), +// and the minimal valid source the later workspaces of this section's +// three modules stage (and section-13.4.ts's T13.4-8, by import) — one +// record each, named with every staging test. +export const STREAMS_INVALID_SOURCE = stagedMdx( + "T12.0-2/T12.0-9 specs/A.mdx with an unresolved d reference (T12.0-2's findings workspace; T12.0-9's findings arm)", + ['<S id="a" d={"missing"}>', "Alpha text.", "</S>", ""].join("\n"), +); +export const STREAMS_VALID_SOURCE = stagedMdx( + "T12.0-2/T12.0-3/T12.0-9/T12.0-10/T12.0-14/T13.4-8 specs/A.mdx (the minimal section a: T12.0-2's usage-error and configuration-error arms, T12.0-3's relative-resolution workspace, T12.0-9's corrupt-session and configuration-error arms, T12.0-10's past-the-gate workspace, T12.0-14's grammar workspace; T13.4-8's relocated file, its file-form move and emission arms)", + ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), ); -// An unknown top-level key is a configuration error (SPEC 7, 14.14). -const STREAMS_BAD_CONFIG = `import { defineConfig } from "xspec" +// An unknown top-level key is a configuration error (SPEC 7, 14.14). T12.0-2 +// stages it after its first workspace's invocations: a TypeScript +// staged-source record, well-formed TypeScript (14.20) though an invalid +// configuration. +const STREAMS_BAD_CONFIG = stagedTs( + "T12.0-2 configuration-error workspace xspec.config.ts — an unknown top-level key", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -411,15 +609,18 @@ export default defineConfig({ }, bogus: true }) -`; +`, +); 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 +662,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 +713,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 +727,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 +753,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 +789,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); }, ); @@ -565,16 +806,26 @@ const T12_0_2 = defineProductTest({ // A second, self-contained configuration whose directory (alt/) is its own // workspace root (SPEC 7: configured globs resolve relative to the -// configuration file's directory). -const ALT_CONFIG = `import { defineConfig } from "xspec" +// configuration file's directory). T12.0-3's relative-resolution workspace +// follows its sweep story, so this is a TypeScript staged-source record, as +// SPECS_ONLY_CONFIG beside it is. +const ALT_CONFIG = stagedTs( + "T12.0-3 alt/xspec.config.ts — the alternate root's configuration (the spec group alt)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { alt: ["aspecs/**/*.mdx"] } }) -`; -const ALT_SOURCE = ['<S id="b">', "Bee text.", "</S>", ""].join("\n"); +`, +); +// T12.0-3's relative-resolution workspace follows its sweep story: the +// alternate root's source is a staged-source record. +const ALT_SOURCE = stagedMdx( + "T12.0-3 alt/aspecs/B.mdx (the alternate root's source)", + ['<S id="b">', "Bee text.", "</S>", ""].join("\n"), +); const T12_0_3 = defineProductTest({ id: "T12.0-3", @@ -717,10 +968,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 +1006,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 +1041,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. @@ -811,20 +1070,25 @@ const T12_0_4 = defineProductTest({ // T12.0-5 — argument addressing // --------------------------------------------------------------------------- -const ADDRESSING_SOURCE = [ - '<S id="alpha" d={"omega"}>', - "Alpha intro.", - "", - '<S id="alpha.kid">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="omega">', - "Omega text.", - "</S>", - "", -].join("\n"); +// The addressing source: the body's first workspace and, after its +// invocations, the configuration-state twins — a staged-source record. +const ADDRESSING_SOURCE = stagedMdx( + "T12.0-5 specs/A.mdx (the addressing workspace and its configuration-state twins)", + [ + '<S id="alpha" d={"omega"}>', + "Alpha intro.", + "", + '<S id="alpha.kid">', + "Kid text.", + "</S>", + "</S>", + "", + '<S id="omega">', + "Omega text.", + "</S>", + "", + ].join("\n"), +); // "specs/" + 0xFF + "A.mdx": 0xFF never occurs in valid UTF-8, so the // argument value is not valid UTF-8 (SPEC 12.0) — stageable on Linux, where @@ -835,10 +1099,522 @@ const NON_UTF8_NODE_ARG = Uint8Array.from([ ...Buffer.from("A.mdx", "utf8"), ]); +// The native-separator and normalization negatives (SPEC 12.0: arguments +// naming files are read as spelled and compared byte-wise against +// workspace-relative paths, which no normalization touches): each spelling +// names no discovered file — discovered paths carry no `\`, `.` segment, or +// empty segment (SPEC 7) — so each is an unknown-file usage error on `show` +// and `view` alike. The `\` arm discriminates on the Windows leg (E-6), +// where `\` is the native separator; the other two on either leg. +const UNNORMALIZED_SPELLINGS: readonly (readonly [string, string])[] = [ + ["specs\\A.mdx", "the native separator `\\`"], + ["./specs/A.mdx", "a `.` segment"], + ["specs//A.mdx", "an empty segment"], +]; + +// U+FFFD in every argument position (SPEC 12.0, the value-level rule: an +// argument value containing U+FFFD is a malformed value, a usage error of +// the syntax class judged before every per-flag and per-operand check). +// Every other value of each invocation is well-formed and, where a later +// check would consult it, names something that exists (the `<new-id>` arm +// renames an existing ID; the `--test-hold` arm is an otherwise-performable +// rename), so the malformed value is each invocation's only defect and the +// arm is sharp against a product judging positions in another order. The +// character is built from its code point (REPLACEMENT_CHARACTER) so no +// tool layer can normalize the spelling away. +const T12_0_5_MALFORMED_VALUES: readonly (readonly [ + readonly string[], + string, +])[] = [ + [["show", `${SWEEP_ALPHA}${REPLACEMENT_CHARACTER}`, "--json"], "a `<node>`"], + [["view", REPLACEMENT_CHARACTER_SPEC_PATH, "--json"], "a `<file>` operand"], + [ + ["ids", "--file", REPLACEMENT_CHARACTER_SPEC_PATH, "--json"], + "a `--file` glob", + ], + [ + ["query", "nodes", "--tag", `green${REPLACEMENT_CHARACTER}`, "--json"], + "a `--tag`", + ], + [ + ["occurrences", "--to", `${SWEEP_ALPHA}${REPLACEMENT_CHARACTER}`, "--json"], + "a `--to`", + ], + [ + ["rename", SWEEP_FILE, "alpha", `alpha${REPLACEMENT_CHARACTER}`, "--json"], + "a `<new-id>` (never `refused-invalid-id`: the value never reaches the " + + "1.4 check, SPEC 6.4)", + ], + [ + ["review", "status", `s${REPLACEMENT_CHARACTER}`, "--json"], + "a session name", + ], + [ + [ + "review", + "resolve", + "s", + "r1", + "--status", + "updated", + "--note", + `note${REPLACEMENT_CHARACTER}`, + "--json", + ], + "a `--note` text", + ], + [ + ["impact", "--base", `main${REPLACEMENT_CHARACTER}`, "--json"], + "a `--base` ref", + ], + [ + ["ids", "--config", `xspec${REPLACEMENT_CHARACTER}.config.ts`, "--json"], + "a `--config` path", + ], + [ + [ + "rename", + SWEEP_FILE, + "alpha", + "alpha2", + "--test-hold", + `hold${REPLACEMENT_CHARACTER}.tmp`, + "--json", + ], + "a `--test-hold` path (no hold file appears: the value is judged before " + + "acquisition, SPEC 12.0, 13.5)", + ], +]; + +// The positive side of the backslash (T12.0-5; SPEC 12.0, 7, 7.1). SPEC +// 12.0: the backslash is an ordinary byte, no separator, so an argument +// spelled with it names or matches only a discovered file of that very path +// (1.5); SPEC 7: a glob supports exactly `*`, `?`, and `**`, every other +// byte a literal, so the backslash in a `--file` pattern is never an escape. +// Linux leg only, where a file name can hold the byte: E-6 reruns the whole +// entry on Windows "less its Linux-leg arms" (no Windows filesystem admits +// the staged names), so the body gates these arms itself, beside the +// non-UTF-8 arm, and the Windows leg skips no arm. The backslash is built +// from its code point (U+005C), never spelled as an escape in this source; +// the record names spell it in words. +const BACKSLASH = String.fromCharCode(0x5c); + +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + +// The spec side: a discovered spec-group file `specs/a`, backslash, `b.mdx` +// — an invalid source path (condition 19: 7.1 bars the backslash from a +// spec-group file's path, 14.19) whose content is condition-free (one +// well-formed, unique id), so the path is the file's one defect. Prose +// precedes the section, so offset 0 lies in no section and `at … 0` +// resolves to the root construct. Every expected range is composed from +// the same parts the staged file is. +const BACKSLASH_SPEC_FILE = `specs/a${BACKSLASH}b.mdx`; +const BACKSLASH_SPEC_PROSE = "Backslash-path prose.\n\n"; +const BACKSLASH_SPEC_SECTION = [ + '<S id="pb">', + "Backslash-path text.", + "</S>", +].join("\n"); +const BACKSLASH_SPEC_TEXT = `${BACKSLASH_SPEC_PROSE}${BACKSLASH_SPEC_SECTION}\n`; +const BACKSLASH_SPEC_ROOT_RANGE: SourceRange = { + start: 0, + end: Buffer.byteLength(BACKSLASH_SPEC_TEXT, "utf8"), +}; +const BACKSLASH_SPEC_SECTION_RANGE: SourceRange = { + start: Buffer.byteLength(BACKSLASH_SPEC_PROSE, "utf8"), + end: Buffer.byteLength( + `${BACKSLASH_SPEC_PROSE}${BACKSLASH_SPEC_SECTION}`, + "utf8", + ), +}; +// Staged in a workspace the body creates after its first product +// invocation: a staged-source record (S-9), beside SPECS_ONLY_CONFIG's. +const BACKSLASH_SPEC_SOURCE = stagedMdx( + "T12.0-5 specs/a, a backslash, b.mdx (the positive side of the " + + "backslash, spec side: a discovered spec-group file at an invalid " + + "source path)", + BACKSLASH_SPEC_TEXT, +); + +/** + * The asserted projection of a finding (SPEC 14, 12.7): its stable code, + * its in-source locations, and its concerned path — message and identities + * stay unpinned (informational). + */ +function projectPathFinding(finding: Finding): { + readonly code: string | null; + readonly locations: readonly unknown[]; + readonly path: unknown; +} { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + }; +} + +/** The condition-19 finding concerning the backslash-named spec file. */ +const BACKSLASH_SPEC_FINDING = { + code: "invalid-source-path", + locations: [], + path: BACKSLASH_SPEC_FILE, +} as const; + +/** A view tree's identities and construct ranges, children in order. */ +interface IdentityTree { + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly children: readonly IdentityTree[]; +} + +function projectIdentityTree(node: ViewNode): IdentityTree { + return { + identity: node.identity, + range: node.range, + children: node.children.map(projectIdentityTree), + }; +} + +/** + * T12.0-5's spec-side positive arm (Linux leg): `view` and `at … 0` given + * the backslash-named discovered file name that very file — membership + * holds, never the unknown-file exit 2 of a product reading the backslash + * as a separator — its structure on view with every identity explicitly + * unavailable, and exactly its condition-19 finding accompanying: exit 1 + * (SPEC 12.0, 11.4, 11.5, 11.2, 7.1, 14.19; T11.2-3, and T12.0-13 for `#`). + */ +async function expectBackslashSpecArms(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [BACKSLASH_SPEC_FILE]: BACKSLASH_SPEC_SOURCE, + }, + }, + async (workspace) => { + const spelled = JSON.stringify(BACKSLASH_SPEC_FILE); + const viewContext = + `T12.0-5 \`view ${spelled}\` (the positive side of the backslash, ` + + `Linux leg; the operand JSON-spelled)`; + const viewResult = await runCli(product, workspace, [ + "view", + BACKSLASH_SPEC_FILE, + ]); + assertExitCode( + viewResult, + 1, + `${viewContext} — the operand names the discovered file of that ` + + `very path (the backslash an ordinary byte, no separator), so ` + + `membership holds — never the unknown-file exit 2 — and the ` + + `answer carries the file's condition-19 finding: exit 1 (SPEC ` + + `12.0, 11.4, 7.1, 14.19)`, + ); + const viewReport = decodeViewReport( + parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + assertSameJson( + viewReport.findings.map(projectPathFinding), + [BACKSLASH_SPEC_FINDING], + `${viewContext} — the consulted domain is the requested file ` + + `alone, so exactly its condition-19 finding accompanies: the ` + + `stable code "invalid-source-path", no in-source locations, the ` + + `file as its concerned path (SPEC 7.1, 14.19, 11.2, 11.4, 12.7)`, + ); + assertSameJson( + viewReport.views.map((view) => view.file), + [BACKSLASH_SPEC_FILE], + `${viewContext} — exactly one per-file view, for the requested ` + + `path presented as spelled (SPEC 11.4, 12.0, 1.5)`, + ); + assertSameJson( + projectIdentityTree(viewReport.views[0]!.root), + { + identity: UNAVAILABLE, + range: BACKSLASH_SPEC_ROOT_RANGE, + children: [ + { + identity: UNAVAILABLE, + range: BACKSLASH_SPEC_SECTION_RANGE, + children: [], + }, + ], + }, + `${viewContext} — the invalid-path file keeps its positional tree ` + + `and construct ranges on view while every node identity, root ` + + `included, is explicitly unavailable (SPEC 11.2, 1.5)`, + ); + + const atContext = + `T12.0-5 \`at ${spelled} 0\` (the positive side of the ` + + `backslash, Linux leg; the operand JSON-spelled)`; + const atResult = await runCli(product, workspace, [ + "at", + BACKSLASH_SPEC_FILE, + "0", + ]); + assertExitCode( + atResult, + 1, + `${atContext} — the \`<file>\` operand names the discovered file ` + + `exactly as a view operand does — never the unknown-file exit 2 ` + + `— and the answer carries the file's condition-19 finding and an ` + + `unavailable identity: exit 1 (SPEC 12.0, 11.5, 7.1, 14.19)`, + ); + const atReport = decodeAtReport( + parseJsonStdout( + atResult, + `${atContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + atContext, + ); + assertSameJson( + atReport.findings.map(projectPathFinding), + [BACKSLASH_SPEC_FINDING], + `${atContext} — the consulted domain is the named file alone: ` + + `exactly its condition-19 finding (SPEC 7.1, 14.19, 11.2, 11.5)`, + ); + assertSameJson( + atReport.resolution, + { + section: { + identity: UNAVAILABLE, + range: BACKSLASH_SPEC_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)`, + ); + }, + ); +} + +// The code side: two valid code sources — `src/a`, backslash, `b.ts` (a +// code source's path may hold the byte: 14.19 bars it from spec-group +// paths alone) and its sibling `src/ab.ts` — each marking its own node of +// `specs/A.mdx` from its own function unit, so every occurrence record +// individuates its file. Under the pattern `src/a`, backslash, `*.ts` the +// backslash is a literal byte (SPEC 7), matching the first file alone; a +// product reading it as an escape of `*` matches neither file (the +// configured-glob twin is T7-4's literal-backslash arm), and one reading the +// path operand's backslash as an escape or a separator lists the sibling's +// occurrence or none. All four files are staged in a workspace the body +// creates after its first product invocation: staged-source records (S-9). +const BACKSLASH_CODE_FILE = `src/a${BACKSLASH}b.ts`; +const BACKSLASH_CODE_SIBLING = "src/ab.ts"; +const BACKSLASH_CODE_PATTERN = `src/a${BACKSLASH}*.ts`; +const BACKSLASH_CODE_SPEC_FILE = "specs/A.mdx"; +const BACKSLASH_CODE_CONFIG = stagedTs( + "T12.0-5 xspec.config.ts (the positive side of the backslash, code " + + "side: one spec group and one code group)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); +const BACKSLASH_CODE_SPEC = stagedMdx( + "T12.0-5 specs/A.mdx (the positive side of the backslash, code side: " + + "the two marked nodes)", + [ + '<S id="alpha">', + "Alpha text.", + "</S>", + "", + '<S id="omega">', + "Omega text.", + "</S>", + "", + ].join("\n"), +); +const BACKSLASH_CODE_SOURCES: Readonly<Record<string, StagedTs>> = { + [BACKSLASH_CODE_FILE]: stagedTs( + "T12.0-5 src/a, a backslash, b.ts (the positive side of the " + + "backslash, code side: the backslash-named code source, marking " + + "alpha)", + [ + 'import SPEC from "../specs/A.xspec";', + "", + "export function inBackslash(): void {", + " SPEC.alpha;", + "}", + "", + ].join("\n"), + ), + [BACKSLASH_CODE_SIBLING]: stagedTs( + "T12.0-5 src/ab.ts (the positive side of the backslash, code side: " + + "the sibling, marking omega)", + [ + 'import SPEC from "../specs/A.xspec";', + "", + "export function inSibling(): void {", + " SPEC.omega;", + "}", + "", + ].join("\n"), + ), +}; + +/** An occurrence record's individuating tuple (SPEC 12.7, 5.7). */ +interface OccurrenceTuple { + readonly file: unknown; + readonly kind: string; + readonly source: unknown; + readonly target: string; +} + +function projectOccurrence(record: OccurrenceRecord): OccurrenceTuple { + return { + file: record.file, + kind: record.kind, + source: + "identity" in record.source ? record.source.identity : record.source, + target: record.target, + }; +} + +/** The first file's one occurrence: its marker of `alpha`. */ +const BACKSLASH_CODE_OCCURRENCE: OccurrenceTuple = { + file: BACKSLASH_CODE_FILE, + kind: "references", + source: `${BACKSLASH_CODE_FILE}#inBackslash`, + target: `${BACKSLASH_CODE_SPEC_FILE}#alpha`, +}; + +/** The sibling's one occurrence: its marker of `omega`. */ +const BACKSLASH_SIBLING_OCCURRENCE: OccurrenceTuple = { + file: BACKSLASH_CODE_SIBLING, + kind: "references", + source: `${BACKSLASH_CODE_SIBLING}#inSibling`, + target: `${BACKSLASH_CODE_SPEC_FILE}#omega`, +}; + +/** Tuples as a bytewise-sorted multiset (membership, not 5.7's order). */ +function sortedTuples(tuples: readonly OccurrenceTuple[]): string[] { + return tuples.map((tuple) => JSON.stringify(tuple)).sort(); +} + +/** + * `occurrences --file <pattern>` over the valid code-side workspace: exit + * 0, a finding-free answer (the admitted files are valid sources), and the + * records' individuating tuples (SPEC 11.3, 12.7). + */ +async function backslashOccurrences( + product: ProductBinding, + workspace: TestWorkspace, + pattern: string, + context: string, +): Promise<OccurrenceTuple[]> { + const result = await runCli(product, workspace, [ + "occurrences", + "--file", + pattern, + ]); + assertExitCode( + result, + 0, + `${context} — the workspace is valid and the admitted files are ` + + `valid sources: a finding-free answer, exit 0 (SPEC 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + context, + ); + assertSameJson( + report.findings.map(projectPathFinding), + [], + `${context} — no finding accompanies: both code sources are valid ` + + `(a code source's path may hold the backslash, SPEC 14.19, 7.2)`, + ); + return report.occurrences.map(projectOccurrence); +} + +/** + * T12.0-5's code-side positive arm (Linux leg): `occurrences --file` given + * the first file's path, and given the pattern `src/a`, backslash, `*.ts`, + * lists the first file's occurrence alone (SPEC 12.0, 7, 11.3) — after the + * control `src/*.ts` shows both files discovered, each marking its node, + * so "alone" is attributable to the spelling. + */ +async function expectBackslashCodeArms(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": BACKSLASH_CODE_CONFIG, + [BACKSLASH_CODE_SPEC_FILE]: BACKSLASH_CODE_SPEC, + ...BACKSLASH_CODE_SOURCES, + }, + }, + async (workspace) => { + const controlContext = + "T12.0-5 `occurrences --file src/*.ts` (the positive side of the " + + "backslash, Linux leg: the staging control)"; + assertSameJson( + sortedTuples( + await backslashOccurrences( + product, + workspace, + "src/*.ts", + controlContext, + ), + ), + sortedTuples([BACKSLASH_CODE_OCCURRENCE, BACKSLASH_SIBLING_OCCURRENCE]), + `${controlContext} — both code sources are discovered, each ` + + `marking its own node, so the arms below discriminate by ` + + `spelling alone (records compared as a sorted multiset; SPEC ` + + `11.3, 4.5, 4.6)`, + ); + const arms: readonly (readonly [string, string])[] = [ + [ + BACKSLASH_CODE_FILE, + "the first file's path — the backslash an ordinary byte, no " + + "separator, and no escape of the `b` after it (SPEC 12.0, 7)", + ], + [ + BACKSLASH_CODE_PATTERN, + "the pattern src/a, a backslash, *.ts — the backslash a literal " + + "byte of the pattern, never an escape of `*`: a product " + + "reading it as one matches neither file (SPEC 7, 12.0)", + ], + ]; + for (const [pattern, what] of arms) { + const context = + `T12.0-5 \`occurrences --file ${JSON.stringify(pattern)}\` (the ` + + `positive side of the backslash, Linux leg; the pattern ` + + `JSON-spelled)`; + assertSameJson( + await backslashOccurrences(product, workspace, pattern, context), + [BACKSLASH_CODE_OCCURRENCE], + `${context} — ${what}: the first file's occurrence alone`, + ); + } + }, + ); +} + const T12_0_5 = defineProductTest({ id: "T12.0-5", title: - "argument addressing: `<node>`, `<graph-node>`, and `<file>` arguments and `--file` globs are workspace-relative with `/` separators, independent of the working directory (representative commands run from a subdirectory), while `--test-hold <path>` resolves against the working directory; an argument spelled with `\\` names no workspace file — paths compare byte-wise — and is an unknown-file usage error, exit 2 (discriminating on the Windows leg, E-6); a non-UTF-8 argument value (raw bytes in the OS argument vector, Linux leg) is a usage error, exit 2 (SPEC 12.0, 13.5, 1.5)", + "argument addressing: `<node>`, `<graph-node>`, and `<file>` arguments and `--file` globs are workspace-relative with `/` separators, independent of the working directory (representative commands run from a subdirectory), while `--test-hold <path>` resolves against the working directory; native-separator and normalization negatives — an argument spelled with `\\` (`specs\\A.mdx`), with a `.` segment (`./specs/A.mdx`), or with an empty segment (`specs//A.mdx`) is read as spelled and compared byte-wise, so it names no workspace file: an unknown-file usage error, exit 2, on `show` and `view` as representatives (the `\\` arm discriminating on the Windows leg, E-6); the positive side of the backslash (Linux leg, gated inside the body, E-6) — with a discovered spec-group file `specs/a`, backslash, `b.mdx` staged (an invalid source path, condition 19), `view` and `at … 0` name that very file: membership holds, every node identity unavailable, exit 1 with exactly its condition-19 finding, never the unknown-file exit 2; with valid code sources `src/a`, backslash, `b.ts` and `src/ab.ts` each marking a node (both discovered, the `src/*.ts` control), `occurrences --file` given the first file's path or the pattern `src/a`, backslash, `*.ts` lists the first file's occurrence alone — the backslash an ordinary byte, never a separator or an escape; malformed values — an argument value that is not valid UTF-8 (raw bytes in the OS argument vector, Linux leg) and an argument value containing U+FFFD in every position (a `<node>`, a `<file>` operand, a `--file` glob, a `--tag`, a `--to`, a `<new-id>` — never `refused-invalid-id` — a session name, a `--note` text, a `--base` ref, a `--config` path, and a `--test-hold` path) — are usage errors of the syntax class: exit 2 with the plain usage error's document (`code` null), reported without loading configuration — byte-identical with the configuration file invalid or missing (T12.0-10's discipline) — and modifying nothing (SPEC 12.0, 12.7, 13.5, 6.4, 1.5, 7, 7.1, 11.3, 11.4, 11.5, 14.19)", run: async (product) => { await withWorkspace( { @@ -924,47 +1700,92 @@ const T12_0_5 = defineProductTest({ `listing is restricted to exactly specs/A.mdx (SPEC 12.0, 12.3)`, ); - // Native-separator negative: `\` is an ordinary byte in a path - // argument, so `specs\A.mdx` names no workspace file. - const backslashContext = String.raw`T12.0-5 \`show specs\A.mdx --json\``; - const backslash = await runCli(product, workspace, [ - "show", - "specs\\A.mdx", - "--json", - ]); - assertExitCode( - backslash, - 2, - `${backslashContext} — paths compare byte-wise, so an argument ` + - `spelled with \\ names no workspace file: an unknown-file usage ` + - `error (SPEC 12.0; discriminating on the Windows leg, E-6)`, - ); - assertStdoutEmpty( - backslash, - `${backslashContext} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, - ); - - // Non-UTF-8 argument value — Linux leg only: argv is a byte channel - // there; other platforms cannot carry the argument at all. - if (process.platform === "linux") { - const nonUtf8Context = - "T12.0-5 `show <specs/\\xffA.mdx bytes> --json` (Linux leg)"; - const nonUtf8 = await runProduct(product, { - cwd: workspace.root, - argv: ["show", NON_UTF8_NODE_ARG, "--json"], - }); - assertExitCode( - nonUtf8, - 2, - `${nonUtf8Context} — argument values are interpreted as UTF-8; ` + - `a value that is not valid UTF-8 is a usage error (SPEC 12.0)`, + // Malformed values (SPEC 12.0, the value-level rule): an argument + // value containing U+FFFD — stageable on both legs — is a malformed + // value in every position, a usage error of the syntax class judged + // before every per-flag and per-operand check: exit 2 with the plain + // usage error's document (`code` null, 12.7), reported without + // loading configuration — byte-identical with the workspace's + // configuration file invalid or missing (T12.0-10's discipline, + // through the shared helper) — never the check the position would + // otherwise reach (the `<new-id>` arm: never `refused-invalid-id`, + // 6.4; the `--test-hold` arm: no hold file, no acquisition, 13.5). + // The whole table runs inside one modifies-nothing compare over the + // built workspace; the twins hold the same source under the two + // other configuration states. + const twins = await stageConfigurationStateTwins({ + [SWEEP_FILE]: ADDRESSING_SOURCE, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, position] of T12_0_5_MALFORMED_VALUES) { + await expectSyntaxClassUsageError( + product, + workspace, + twins, + argv, + `T12.0-5 \`${argv.join(" ")}\` — U+FFFD in ${position}`, + ); + } + // Non-UTF-8 argument value — Linux leg only: argv is a byte + // channel there (the driver's POSIX trampoline); other + // platforms cannot carry the argument at all. + if (process.platform === "linux") { + await expectSyntaxClassUsageError( + product, + workspace, + twins, + ["show", NON_UTF8_NODE_ARG, "--json"], + "T12.0-5 `show <specs/\\xffA.mdx bytes> --json` (Linux " + + "leg) — an argument value that is not valid UTF-8 is a " + + "malformed value, the same syntax-class usage error", + ); + } + }, + "T12.0-5: a malformed argument value modifies nothing — the " + + "usage error precedes every per-flag and per-operand check " + + "(SPEC 12.0)", ); - assertStdoutEmpty( - nonUtf8, - `${nonUtf8Context} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + } finally { + await twins.dispose(); + } + + // Native-separator and normalization negatives (SPEC 12.0: read as + // spelled, compared byte-wise, no normalization) on `show` and + // `view` as representatives. Controls first: the exactly-spelled + // path resolves on both commands, so each negative is attributable + // to its spelling alone. + for (const command of ["show", "view"] as const) { + const controlContext = `T12.0-5 \`${command} ${SWEEP_FILE} --json\` (control)`; + parseJsonStdout( + await expectExit( + product, + workspace, + [command, SWEEP_FILE, "--json"], + 0, + `${controlContext} — the exactly-spelled path names the ` + + `discovered file, so the negatives below are attributable ` + + `to their spellings alone (SPEC 12.0)`, + ), + controlContext, ); + for (const [spelling, defect] of UNNORMALIZED_SPELLINGS) { + await expectPlainUsageError( + product, + workspace, + [command, spelling, "--json"], + `T12.0-5 \`${command} ${spelling} --json\` — an argument ` + + `spelled with ${defect} is read as spelled and compared ` + + `byte-wise, so it names no workspace file (discovered ` + + `paths carry none, SPEC 7): an unknown-file usage error, ` + + `exit 2, never a normalized match (SPEC 12.0` + + (spelling.includes("\\") + ? "; discriminating on the Windows leg, E-6)" + : ")"), + ); + } } // --test-hold resolves against the working directory (13.5); the @@ -988,6 +1809,7 @@ const T12_0_5 = defineProductTest({ try { await running.waitForFile(holdAbs); } catch (error) { + rethrowOutputOverflow(error); fail( `${holdContext}: --test-hold <path> is a filesystem path ` + `resolved against the working directory, so the hold file ` + @@ -1017,6 +1839,16 @@ const T12_0_5 = defineProductTest({ } }, ); + + // The positive side of the backslash (SPEC 12.0, 7, 7.1) — Linux leg + // only, where a file name can hold the byte: gated here inside the + // shared body, as the non-UTF-8 arm is, so the Windows leg (E-6: "less + // its Linux-leg arms") reruns the whole entry and skips no arm. Each + // side stages its own workspace. + if (process.platform === "linux") { + await expectBackslashCodeArms(product); + await expectBackslashSpecArms(product); + } }, }); @@ -1032,24 +1864,27 @@ const CASE_FILE = "specs/T.mdx"; // them. Byte-wise comparison makes them two distinct tags (SPEC 12.0). const NFC_TAG = "caf\u00e9"; const NFD_TAG = "cafe\u0301"; -const CASE_SOURCE = [ - '<S id="case" tags="foo">', - "Lower case node.", - "</S>", - "", - '<S id="Case" tags="Foo">', - "Upper case node.", - "</S>", - "", - `<S id="nfc" tags="${NFC_TAG}">`, - "NFC-tagged node.", - "</S>", - "", - `<S id="nfd" tags="${NFD_TAG}">`, - "NFD-tagged node.", - "</S>", - "", -].join("\n"); +const CASE_SOURCE = stagedMdx( + "T12.0-6 casing workspace specs/T.mdx", + [ + '<S id="case" tags="foo">', + "Lower case node.", + "</S>", + "", + '<S id="Case" tags="Foo">', + "Upper case node.", + "</S>", + "", + `<S id="nfc" tags="${NFC_TAG}">`, + "NFC-tagged node.", + "</S>", + "", + `<S id="nfd" tags="${NFD_TAG}">`, + "NFD-tagged node.", + "</S>", + "", + ].join("\n"), +); /** * T12.0-6's single-casing path probe as one shared code path: called by the @@ -1097,15 +1932,27 @@ 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)`, ); }, ); } +// The two-casing workspace's sources (T12.0-6's third workspace, after the +// probe's and the casing workspace's invocations): staged-source records, +// the literals moved to module level. +const T12_0_6_UPPER_CASING = stagedMdx( + "T12.0-6 two-casing workspace specs/A.mdx", + ['<S id="upper">', "Upper file text.", "</S>", ""].join("\n"), +); +const T12_0_6_LOWER_CASING = stagedMdx( + "T12.0-6 two-casing workspace specs/a.mdx", + ['<S id="lower">', "Lower file text.", "</S>", ""].join("\n"), +); + const T12_0_6 = defineProductTest({ id: "T12.0-6", title: @@ -1239,10 +2086,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)`, ); } }, @@ -1256,18 +2103,8 @@ const T12_0_6 = defineProductTest({ { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": [ - '<S id="upper">', - "Upper file text.", - "</S>", - "", - ].join("\n"), - "specs/a.mdx": [ - '<S id="lower">', - "Lower file text.", - "</S>", - "", - ].join("\n"), + "specs/A.mdx": T12_0_6_UPPER_CASING, + "specs/a.mdx": T12_0_6_LOWER_CASING, }, }, async (workspace) => { @@ -1315,10 +2152,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 aabfd785..6e6cc881 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,26 +128,43 @@ 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { pathExists, releaseHoldFile, + rethrowOutputOverflow, runProduct, startProduct, } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; +import { + STREAMS_INVALID_SOURCE, + STREAMS_VALID_SOURCE, +} from "./section-12.0-i.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, + expectPlainUsageError, + REPLACEMENT_CHARACTER_SPEC_PATH, runCli, runJson, } from "./support.js"; @@ -239,6 +303,10 @@ async function makeStoryWorkspace(): Promise<{ try { await workspace.gitInit(); const baseRef = await workspace.gitCommitAll("story baseline"); + // Every caller (T12.0-7, T12.0-9) stages this edit before its first + // product invocation (git staging invokes none), so S-7's sweep reaches + // it against the stub: plain contents, no ledger record + // (helpers/staged-mdx.ts). await workspace.file(STORY_FILE_A, storyASource("Omega text v2.")); return { workspace, baseRef }; } catch (error) { @@ -601,7 +669,13 @@ const TIE_REACHABLE_SOURCE = [ // Coverage fixture: boundary group `bnd` (only `b`), target group `tgt`; // transitive mode; two equal-length covering paths to `zz` via `ma`/`mb`. -const TIE_COVERAGE_CONFIG = `import { defineConfig } from "xspec" +// T12.0-8's coverage and impact arms follow its reachable arm's +// invocations, so their configurations and code source are TypeScript +// staged-source records (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), all well-formed. +const TIE_COVERAGE_CONFIG = stagedTs( + "T12.0-8 coverage arm xspec.config.ts — the spec groups bnd and tgt and the transitive profile prof", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -617,29 +691,39 @@ export default defineConfig({ } ] }) -`; -const TIE_BOUNDARY_SOURCE = [ - 'import T from "../tgt/T.xspec"', - "", - '<S id="b" d={[T.ma, T.mb]}>', - "Boundary text.", - "</S>", - "", -].join("\n"); -const TIE_TARGET_SOURCE = [ - '<S id="ma" d={"zz"}>', - "Middle a text.", - "</S>", - "", - '<S id="mb" d={"zz"}>', - "Middle b text.", - "</S>", - "", - '<S id="zz">', - "End target text.", - "</S>", - "", -].join("\n"); +`, +); +// The coverage arm follows the reachable arm's `build`: its two sources are +// staged-source records (helpers/staged-mdx.ts; S-9's before-any-product +// clause), wrapped in place. +const TIE_BOUNDARY_SOURCE = stagedMdx( + "T12.0-8 coverage arm specs/bnd/B.mdx", + [ + 'import T from "../tgt/T.xspec"', + "", + '<S id="b" d={[T.ma, T.mb]}>', + "Boundary text.", + "</S>", + "", + ].join("\n"), +); +const TIE_TARGET_SOURCE = stagedMdx( + "T12.0-8 coverage arm specs/tgt/T.mdx", + [ + '<S id="ma" d={"zz"}>', + "Middle a text.", + "</S>", + "", + '<S id="mb" d={"zz"}>', + "Middle b text.", + "</S>", + "", + '<S id="zz">', + "End target text.", + "</S>", + "", + ].join("\n"), +); // Impact fixture: `src/app.ts` references `n`; `n` depends on `ca` and `cb`, // both edited since the baseline — two equal-length witness paths from `n`. @@ -659,13 +743,37 @@ const tieImpactSpecSource = (caText: string, cbText: string): string => "</S>", "", ].join("\n"); +// The impact arm's initial state (both texts at v1), staged after T12.0-8's +// first product invocation: a staged-source record, the same template call +// moved to module level. +const T12_0_8_M_V1 = stagedMdx( + "T12.0-8 specs/M.mdx with ca and cb at v1 (the impact arm's initial source)", + tieImpactSpecSource("Changed a v1.", "Changed b v1."), +); +// The impact arm's doubly-edited state, staged after T12.0-8's first product +// invocation (the reachable arm's `build`) into a later-arm workspace S-7's +// sweep never reaches: a staged-source record (helpers/staged-mdx.ts; S-9's +// before-any-product clause), the same template call moved to module level. +const T12_0_8_M_V2 = stagedMdx( + "T12.0-8 specs/M.mdx with ca and cb both edited (the impact arm's doubly-edited state)", + tieImpactSpecSource("Changed a v2.", "Changed b v2."), +); const TIE_IMPACT_APP = "src/app.ts"; -const TIE_IMPACT_APP_SOURCE = [ - 'import M from "../specs/M.xspec";', - "", - "M.n;", - "", -].join("\n"); +const TIE_IMPACT_APP_SOURCE = stagedTs( + "T12.0-8 impact arm src/app.ts — the reference M.n", + ['import M from "../specs/M.xspec";', "", "M.n;", ""].join("\n"), +); +// The imported configurations stay plain for their owners' bodies and for +// this module's first workspaces; the later workspaces stage records of this +// module's own wrapping them, the same expressions moved here. +const T12_0_8_9_SPEC_AND_CODE_CONFIG = stagedTs( + "T12.0-8/T12.0-9 xspec.config.ts — one spec group and one code group (section-9.ts's SPEC_AND_CODE_CONFIG)", + SPEC_AND_CODE_CONFIG, +); +const T12_0_9_10_SPECS_ONLY_CONFIG = stagedTs( + "T12.0-9/T12.0-10 xspec.config.ts — one spec group (section-5.6.ts's SPECS_ONLY_CONFIG)", + SPECS_ONLY_CONFIG, +); const T12_0_8 = defineProductTest({ id: "T12.0-8", @@ -767,21 +875,15 @@ const T12_0_8 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": SPEC_AND_CODE_CONFIG, - [TIE_IMPACT_SPEC]: tieImpactSpecSource( - "Changed a v1.", - "Changed b v1.", - ), + "xspec.config.ts": T12_0_8_9_SPEC_AND_CODE_CONFIG, + [TIE_IMPACT_SPEC]: T12_0_8_M_V1, [TIE_IMPACT_APP]: TIE_IMPACT_APP_SOURCE, }, }, async (workspace) => { await workspace.gitInit(); const base = await workspace.gitCommitAll("tie-break baseline"); - await workspace.file( - TIE_IMPACT_SPEC, - tieImpactSpecSource("Changed a v2.", "Changed b v2."), - ); + await workspace.file(TIE_IMPACT_SPEC, T12_0_8_M_V2); await buildOk( product, workspace, @@ -825,6 +927,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 +944,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 +953,61 @@ 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)`, + ); + } } } +// Staged-source records for the workspaces T12.0-9 creates after its story +// workspace's invocations (helpers/staged-mdx.ts; S-9's before-any-product +// clause): the findings arm's id-less section, and the minimal `alpha` +// section the wrong-kind and exclusion arms stage — the same bytes +// T12.0-10's precedence pair and syntax workspace stage, so one record +// named with both tests; the findings arm's `specs/A.mdx` and the +// corrupt-session and configuration-error arms' are §12.0-i's records. +const T12_0_9_U_SOURCE = stagedMdx( + "T12.0-9 findings arm specs/U.mdx (a section spelling no identity)", + ["<S>", "Section spelling no identity.", "</S>", ""].join("\n"), +); +const ALPHA_SECTION_STAGED = stagedMdx( + "T12.0-9/T12.0-10 specs/A.mdx (the minimal section alpha: T12.0-9's wrong-kind and exclusion arms; T12.0-10's precedence pair and syntax workspace)", + ['<S id="alpha">', "Alpha text.", "</S>", ""].join("\n"), +); + +// T12.0-9's later arms (and T12.0-10's invalid-configuration workspace) +// stage these after a product invocation: TypeScript staged-source records +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), the inline +// literals moved here. The unknown-key configuration is well-formed +// TypeScript (14.20), an invalid configuration (SPEC 7, 14.14). +const T12_0_9_KIND_APP_SOURCE = stagedTs( + "T12.0-9 wrong-kind arm src/app.ts — valid, reference-free TypeScript", + "export function noop(): void {}\n", +); +const T12_0_9_10_UNKNOWN_KEY_CONFIG = stagedTs( + "T12.0-9/T12.0-10 xspec.config.ts — an unknown top-level key (T12.0-9's configuration-error arm, T12.0-10's invalid-configuration workspace)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`, +); + 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 +1225,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 +1279,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 +1369,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"], @@ -1179,8 +1394,8 @@ const T12_0_9 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), + "xspec.config.ts": T12_0_9_10_SPECS_ONLY_CONFIG, + "specs/A.mdx": STREAMS_VALID_SOURCE, }, }, async (corruptWorkspace) => { @@ -1226,23 +1441,25 @@ 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: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": [ - '<S id="a" d={"missing"}>', - "Alpha text.", - "</S>", - "", - ].join("\n"), + "xspec.config.ts": T12_0_9_10_SPECS_ONLY_CONFIG, + "specs/A.mdx": STREAMS_INVALID_SOURCE, + // A parseable section spelling no identity: its 14.1 finding and + // its explicitly-unavailable identity ride the answers below. + "specs/U.mdx": T12_0_9_U_SOURCE, }, }, 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,24 +1468,78 @@ 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, + }, ]); }, ); - // --- Configuration errors (exit 2, SPEC 14.14). + // --- 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": `import { defineConfig } from "xspec" + "xspec.config.ts": T12_0_8_9_SPEC_AND_CODE_CONFIG, + "specs/A.mdx": ALPHA_SECTION_STAGED, + // Valid, reference-free TypeScript: discovered through the code + // group's glob, bearing no requirement nodes (SPEC 7.2). + "src/app.ts": T12_0_9_KIND_APP_SOURCE, + }, + }, + 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, + }, + ]); + }, + ); -export default defineConfig({ - specs: { - main: ["specs/**/*.mdx"] - }, - bogus: true -}) -`, - "specs/A.mdx": ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), + // --- Configuration errors (exit 2, SPEC 14.14). + await withWorkspace( + { + files: { + "xspec.config.ts": T12_0_9_10_UNKNOWN_KEY_CONFIG, + "specs/A.mdx": STREAMS_VALID_SOURCE, }, }, async (configWorkspace) => { @@ -1288,10 +1559,8 @@ export default defineConfig({ await withWorkspace( { files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": ['<S id="alpha">', "Alpha text.", "</S>", ""].join( - "\n", - ), + "xspec.config.ts": T12_0_9_10_SPECS_ONLY_CONFIG, + "specs/A.mdx": ALPHA_SECTION_STAGED, }, }, async (holdWorkspace) => { @@ -1314,6 +1583,7 @@ export default defineConfig({ try { await running.waitForFile(holdPath); } catch (error) { + rethrowOutputOverflow(error); fail( `${holdContext}: the mutating command creates the hold file ` + `immediately after acquiring workspace exclusivity ` + @@ -1349,6 +1619,685 @@ 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, InitialFileContents>> = { + "xspec.config.ts": PRECEDENCE_CONFIG, + [PREC_SPEC_FILE]: ALPHA_SECTION_STAGED, + [PREC_CODE_FILE]: "export function known(): void {}\n", +}; + +const PRECEDENCE_FAILING_FILES: Readonly<Record<string, InitialFileContents>> = + { + ...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"; + +// The within-class-2 rows' staging: the session name `review create --name n` +// spells and the `<file>` the lone-operand `at` row names, each staged beside +// the invalid configuration so the syntax-alone check is seen to precede what +// the same command would consult next (SPEC 12.0, 10.7, 11.5). +const SYNTAX_SESSION_NAME = "n"; +const SYNTAX_SESSION_REL = `.xspec/reviews/${SYNTAX_SESSION_NAME}.json`; +const SYNTAX_AT_FILE = "specs/A.mdx"; + +/** One syntax-class row: an error the invocation's arguments alone determine. */ +interface SyntaxClassRow { + /** Which member of the syntax class the row exercises (SPEC 12.0). */ + readonly what: string; + /** + * The invocation. `--json` rides along wherever the surface takes it, so + * the error document is the entire stdout; the JSON-only surfaces of 11.3 + * and 11.4 (`occurrences`, `view`) emit it with or without the flag. + */ + readonly argv: readonly string[]; +} + +// One row per member of the syntax class as SPEC 12.0 enumerates it — every +// error the arguments alone determine: an unknown command, subcommand, or +// flag, a repeated flag, a missing required flag or argument, a surplus +// operand, a malformed value, and every invalid flag value or operand +// spelling that a fixed vocabulary, a spelling rule, or a co-occurrence rule +// decides. Each is reported without loading configuration, so a product +// that loads it first answers 14.14 on the invalid-configuration workspace +// and fails the plain-error pin; the rows naming `specs/A.mdx` or the +// session `n` name what the same command would consult next, staged beside +// the invalid configuration (above) so a product consulting it before the +// syntax check is observed. The escape character, U+2028, and U+2029 are +// built from their code points (no tool layer decodes a `\\` or a +// line-terminator spelling on the way in). +const T12_0_10_SYNTAX_ROWS: readonly SyntaxClassRow[] = [ + { + what: "an unknown command", + argv: ["definitely-not-a-command", "--json"], + }, + { + what: "an unknown subcommand (`query bogus`, T12.0-14)", + argv: ["query", "bogus", "--json"], + }, + { + what: "an unknown flag (`--bogus`)", + argv: ["ids", "--bogus", "--json"], + }, + { + what: + "the `--name=value` token, which spells no flag (an unknown flag, " + + "T12.0-14)", + argv: ["review", "create", "--strategy", "audit", "--name=n", "--json"], + }, + { + what: "a repeated flag", + argv: ["ids", "--json", "--json"], + }, + { + what: + "a missing required flag (`review create --name n` with none of " + + "`--base`, `--strategy audit`, or `--coverage`, SPEC 10.7)", + argv: ["review", "create", "--name", SYNTAX_SESSION_NAME, "--json"], + }, + { + what: + "a missing required argument (`at <file>` alone, its `<offset>` " + + "absent, SPEC 11.5)", + argv: ["at", SYNTAX_AT_FILE, "--json"], + }, + { + what: + "a value-taking flag as the last token (`--file` lacking its value, " + + "T12.0-14)", + argv: ["ids", "--json", "--file"], + }, + { + what: "a surplus operand (`ids extra`)", + argv: ["ids", "extra", "--json"], + }, + { + what: "a fourth `rename` operand (SPEC 6.4's three-operand synopsis)", + argv: ["rename", SYNTAX_AT_FILE, "alpha", "beta", "gamma", "--json"], + }, + { + what: "the malformed multi-`#` value (T12.0-13's spelling)", + argv: ["show", "a#b#c", "--json"], + }, + { + what: "a U+FFFD-bearing value (T12.0-5; SPEC 12.0's argument-value rule)", + argv: ["show", REPLACEMENT_CHARACTER_SPEC_PATH, "--json"], + }, + { + what: "`--status bogus`, outside `resolve`'s fixed vocabulary (SPEC 10.7)", + argv: [ + "review", + "resolve", + SYNTAX_SESSION_NAME, + "item", + "--status", + "bogus", + "--json", + ], + }, + { + what: "`--strategy bogus`, outside `create`'s fixed vocabulary (SPEC 10.7)", + argv: [ + "review", + "create", + "--strategy", + "bogus", + "--name", + SYNTAX_SESSION_NAME, + "--json", + ], + }, + { + what: "`query nodes --coverage bogus`, outside `required|none` (SPEC 11.1)", + argv: ["query", "nodes", "--coverage", "bogus", "--json"], + }, + { + what: "a `--kinds` element outside its vocabulary (SPEC 11.1)", + argv: ["query", "edges", "--kinds", "bogus", "--json"], + }, + { + what: "an empty `--kinds` element (`depends,`, a trailing comma, SPEC 11.1)", + argv: ["query", "edges", "--kinds", "depends,", "--json"], + }, + { + what: + "`review create` with two of its exactly-one-of flags (`--strategy " + + "audit` beside `--base`, SPEC 10.7)", + argv: [ + "review", + "create", + "--strategy", + "audit", + "--base", + "HEAD", + "--name", + SYNTAX_SESSION_NAME, + "--json", + ], + }, + { + what: "`--test-hold` beside `--preview` (excluded under `--preview`, SPEC 6.6)", + argv: [ + "rename", + SYNTAX_AT_FILE, + "alpha", + "beta", + "--preview", + "--test-hold", + "hold", + "--json", + ], + }, + { + what: "a `<file>` operand beside `--file` on `view` (SPEC 11.4)", + argv: ["view", SYNTAX_AT_FILE, "--file", "specs/*.mdx"], + }, + { + what: "a session name outside the form of 10.1 (`.x`, a leading `.`)", + argv: ["review", "create", "--strategy", "audit", "--name", ".x", "--json"], + }, + { + what: "an `<offset>` spelled `+7` — a sign is no decimal digit (SPEC 11.5)", + argv: ["at", SYNTAX_AT_FILE, "+7", "--json"], + }, + { + what: + "a `--to` malformed as an identity (a whitespace-bearing id segment, " + + "SPEC 11.3, 1.4)", + argv: ["occurrences", "--to", `${SYNTAX_AT_FILE}#a b`], + }, + { + what: + "a `--tag` malformed as a tag (`a\\b`, the escape character, SPEC " + + "11.1, 1.4)", + argv: [ + "query", + "nodes", + "--tag", + `a${String.fromCodePoint(0x5c)}b`, + "--json", + ], + }, + { + what: + "a `--tag` spelled with U+2028 (LINE SEPARATOR, between two letters " + + "as T1.4-1 spells it) — a well-formed argument value (SPEC 12.0) " + + "that no tag can be, malformed under 1.4's quote-and-escape bullet " + + "(SPEC 11.1, 1.4)", + argv: [ + "query", + "nodes", + "--tag", + `a${String.fromCodePoint(0x2028)}b`, + "--json", + ], + }, + { + what: + "a `--to` whose id segment carries U+2029 (PARAGRAPH SEPARATOR, " + + "between two letters as T1.4-1 spells it) — a well-formed argument " + + "value (SPEC 12.0) malformed as an identity, its segment barred by " + + "1.4's quote-and-escape bullet (SPEC 11.3, 1.4)", + argv: [ + "occurrences", + "--to", + `${SYNTAX_AT_FILE}#a${String.fromCodePoint(0x2029)}b`, + ], + }, + { + what: + "a `--file` pattern outside the workspace root (`../x`, decided by " + + "its spelling alone, SPEC 7, 11.1)", + argv: ["ids", "--file", "../x", "--json"], + }, +]; + +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, one arm per member of the syntax class (every error the arguments alone determine): an unknown command, subcommand (`query bogus`), or flag (`--bogus`; the `--name=value` token, T12.0-14); a repeated flag; a missing required flag (`review create --name n` with none of `--base`, `--strategy audit`, or `--coverage`) or argument (`at <file>` alone; a value-taking flag as the last token); a surplus operand (`ids extra`; a fourth `rename` operand); a malformed value (`show a#b#c`, T12.0-13; a U+FFFD-bearing value, T12.0-5); `--status bogus`; `--strategy bogus`; `query nodes --coverage bogus`; a `--kinds` element outside its vocabulary or empty (`depends,`); `review create` with two of its exactly-one-of flags; `--test-hold` beside `--preview`; a `<file>` operand beside `--file` on `view`; a session name outside the form of 10.1 (`.x`); an `<offset>` spelled `+7`; a `--to` malformed as an identity and a `--tag` malformed as a tag (`a\\b`; and, one arm each, a `--tag` spelled with U+2028 and a `--to` whose id segment carries U+2029, each malformed under 1.4's quote-and-escape bullet); and a `--file` pattern outside the workspace root (`../x`, by spelling alone) — each reported without loading configuration and modifying nothing: byte-identical error documents with the configuration file invalid or missing, each the plain usage error (`code` and `path` null, no locations), the syntax-alone check preceding what the command would consult next (a session already named `n` and the named `<file>`, staged beside the invalid configuration, go unconsulted) — while a configuration error precedes every check that consults configuration or discovery: `coverage <unknown-profile>` and `query nodes --group <code-group>` with invalid configuration each report 14.14 (`configuration-error`), not the unknown profile or the wrong-kind group (SPEC 12.0, 13.3, 11.1, 11.2, 4.6, 10.1, 10.7, 11.3, 1.4, 11.4, 11.5, 6.4, 6.6, 7, 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, + mdx: { unparseable: [PREC_BROKEN_FILE] }, + }, + 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": T12_0_9_10_SPECS_ONLY_CONFIG, + "specs/A.mdx": STREAMS_VALID_SOURCE, + }, + }, + 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": T12_0_9_10_UNKNOWN_KEY_CONFIG, + // What the missing-flag and missing-argument rows would consult + // next, staged so a product judging it before the syntax check is + // observed: a session already named `n` — SPEC 10.7 refuses + // `review create` with an existing session's name, exit 1; a + // conforming invocation never reads it, so any bytes serve — and + // the `at` row's `<file>`, present here and absent from the + // missing-configuration workspace, so a product judging the file + // before the argument count answers the two states differently + // and fails the byte-identical compare below. + [SYNTAX_SESSION_REL]: "xspec-harness pre-existing session\n", + [SYNTAX_AT_FILE]: ALPHA_SECTION_STAGED, + }, + }, + async (invalidConfig) => { + await withWorkspace({}, async (missingConfig) => { + for (const row of T12_0_10_SYNTAX_ROWS) { + const command = row.argv.join(" "); + // An error the arguments alone determine is reported before + // anything else, so the invocation modifies nothing — the + // mutating `review create`, `review resolve`, and `rename` + // rows included, whose session directory already holds `n` + // and whose named file is present (whole-root compare, + // `.xspec/` included; a `--test-hold` file created beside + // `--preview` would appear at the root). + const onInvalid = await assertLeavesUnchanged( + invalidConfig.root, + () => + expectPlainUsageError( + product, + invalidConfig, + row.argv, + `T12.0-10 \`${command}\` with the configuration file ` + + `invalid — ${row.what} is determined by the ` + + `invocation's arguments alone and reported without ` + + `loading configuration: the plain usage error, ` + + `\`code\` and \`path\` null, never 14.14 (SPEC 12.0, ` + + `12.7)`, + ), + `T12.0-10 \`${command}\` with the configuration file invalid ` + + `— a syntax-class usage error modifies nothing (SPEC 12.0)`, + ); + const onMissing = await assertLeavesUnchanged( + missingConfig.root, + () => + expectPlainUsageError( + product, + missingConfig, + row.argv, + `T12.0-10 \`${command}\` with the configuration file ` + + `missing — ${row.what} is reported without loading ` + + `configuration: the plain usage error, \`code\` and ` + + `\`path\` null, never 14.14 (SPEC 12.0, 12.7)`, + ), + `T12.0-10 \`${command}\` with the configuration file missing ` + + `— a syntax-class usage error modifies nothing (SPEC 12.0)`, + ); + assertBytesEqual( + onInvalid.stdoutBytes, + onMissing.stdoutBytes, + `T12.0-10 \`${command}\`: reported identically with the ` + + `workspace's configuration file invalid or missing — the ` + + `error document depends on the invocation's arguments ` + + `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)", + ); + // Likewise the wrong-kind group check of 11.1: whether `app` + // names a code group, a spec group, or nothing is read from the + // configuration, so the configuration error precedes it. + await expectConfigurationError( + product, + invalidConfig, + ["query", "nodes", "--group", "app"], + "T12.0-10 `query nodes --group app` with invalid " + + "configuration — the group check of 11.1 consults the " + + "configuration, so the configuration error precedes it: " + + "14.14 is reported, not the wrong-kind or unknown group " + + "(SPEC 12.0, 11.1, 14.14)", + ); + }); + }, + ); + }, +}); + // --------------------------------------------------------------------------- // T12.0-11 — git is read-only // --------------------------------------------------------------------------- @@ -1446,6 +2395,9 @@ const T12_0_11 = defineProductTest({ // Edit omega after the commit, so the baseline session derives one // unblocked path-blocks item (omega's subtree-coherence item; omega // has no non-root ancestor, SPEC 10.5) for `next` and `resolve`. + // The edit precedes the body's first product invocation (the `build` + // below), so S-7's sweep reaches it against the stub: plain + // contents, no ledger record (helpers/staged-mdx.ts). await workspace.file(GITRO_FILE, gitroSource("Omega text v2.")); await buildOk(product, workspace, "T12.0-11 `build` (fresh fixture)"); @@ -1621,6 +2573,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 +2679,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 +2739,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.0-iii.ts b/test/suite/registry/section-12.0-iii.ts new file mode 100644 index 00000000..814752d0 --- /dev/null +++ b/test/suite/registry/section-12.0-iii.ts @@ -0,0 +1,717 @@ +// TEST-SPEC §12.0 III (global command conventions, third part) — SUITE-42 +// continued: T12.0-14 (invocation grammar). +// +// 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 12.0's invocation grammar: arguments are tokens; a token beginning +// with `--` is a flag token spelled exactly `--` plus the flag's name, and no +// other token is (no single-dash short forms); a value-taking flag takes the +// whole next token whatever it looks like and lacks its value when none +// follows; `--name=value` spells no flag; `--` ends flag reading and is +// dropped; flag tokens stand anywhere; the remaining tokens must match the +// synopsis exactly (no command word, an unknown command or subcommand, a +// missing operand, a surplus operand — never accepted and ignored); a flag +// may be given at most once; a flag's arity is fixed by its name across +// commands and known before the command word; list-valued flags take one +// comma-separated value (11.1). +// +// Conservative operationalizations (noted per H-3/H-4): +// - "Behaves as `ids`" and "byte-identical documents" are H-4's +// product-to-itself compare: the same exit code and byte-identical stdout +// for the two spellings on one fixture. +// - "JSON out of effect, stdout not a JSON document" (`ids --file --json`) +// is asserted as: exit 0, stdout that does not parse as a JSON document +// (an empty human listing included — the human form is unpinned, H-3), +// and byte-identical to the human listing of a glob matching nothing — +// with the premise that a matching glob restricts the listing (12.3), so +// the pair is attributable to the glob `--json` being in effect. +// - "Missing configuration, stdout empty (JSON out of effect, the diagnostic +// on stderr)" (`ids --config --json`): exit 2, empty stdout, non-empty +// stderr — wording unpinned (H-3). Its pair, `ids --json --config`, +// answers the plain usage error document on stdout: `--config` lacking its +// value with `--json` read as a flag. +// - "Nothing done" / "no session created": the compare-around-command +// protocol over the whole root, `.xspec/` included, on a workspace built +// beforehand — so a lenient parser that ignored the surplus token would +// have a rename to journal or a session to write, and is observed. +// - "`xspec --config cfg/xspec.config.ts build` loads that configuration": +// staged in a workspace whose root holds no configuration (the premise: +// `build` alone is a configuration error, 14.14), so exit 0 with derived +// output written under `cfg/` alone is attributable to the leading +// `--config` being read as the global flag with its value. +// - `review create --strategy audit --name -a`: the session file's presence +// at `.xspec/reviews/-a.json` (10.1) plus the session answering `next`; +// `resolve … --note -x` is observed through `review show … --json`'s +// `note` member (10.2, 10.7). +// - The `--kinds` arms run on `query edges` (JSON-only, 11), so the plain +// usage error's document is on stdout with no `--json` given; the collapse +// pair (`depends,depends` against `depends`) is the byte-identical compare +// T11-4 pins in full. + +import { + decodeItemReport, + decodeNextReport, +} from "../../helpers/adapters/index.js"; +import { + assertBytesEqual, + assertStdoutEmpty, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { + assertLeavesUnchanged, + diffSnapshots, + snapshotDirectory, +} from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; +import { STREAMS_VALID_SOURCE } from "./section-12.0-i.js"; +import { SPECS_ONLY_CONFIG } from "./section-5.6.js"; +import { + buildOk, + expectConfigurationError, + expectExit, + expectPlainUsageError, + runCli, + runJson, +} from "./support.js"; + +/** 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(); + } +} + +// --------------------------------------------------------------------------- +// T12.0-14 — invocation grammar +// --------------------------------------------------------------------------- + +/** + * The grammar fixture's one spec source: a root node holding `a` — the + * minimal section a, the staged-source record section-12.0-i.ts registers + * (the same bytes, spelled once; helpers/staged-mdx.ts). + */ +const GRAMMAR_SPEC_FILE = "specs/A.mdx"; +const GRAMMAR_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [GRAMMAR_SPEC_FILE]: STREAMS_VALID_SOURCE, +}; +/** A `--file` glob inside the root (7) that matches no discovered file. */ +const GRAMMAR_NO_MATCH_GLOB = "no-such-dir/*.mdx"; +/** The `--config` arm's configuration, whose directory is the root (7). */ +const GRAMMAR_CONFIG_DIR = "cfg"; +const GRAMMAR_CONFIG_PATH = `${GRAMMAR_CONFIG_DIR}/xspec.config.ts`; +/** + * The `--config`-first arm's source, staged in T12.0-14's second workspace + * (after the grammar workspace's invocations): a staged-source record + * (helpers/staged-mdx.ts; S-9's before-any-product clause). + */ +const T12_0_14_CFG_B_SOURCE = stagedMdx( + "T12.0-14 cfg/specs/B.mdx (the --config-first arm's source)", + '<S id="b">\nBeta text.\n</S>\n', +); +/** + * The `--config`-first arm's configuration, staged in the same second + * workspace: a TypeScript staged-source record (helpers/staged-ts.ts; S-9's + * TypeScript and timing clauses) wrapping section-5.6.ts's SPECS_ONLY_CONFIG, + * the same expression moved here (the grammar workspace, the body's first, + * stages the constant plain). + */ +const T12_0_14_CFG_CONFIG = stagedTs( + "T12.0-14 cfg/xspec.config.ts — one spec group, the --config-first arm's configuration (section-5.6.ts's SPECS_ONLY_CONFIG)", + SPECS_ONLY_CONFIG, +); +const GRAMMAR_CONFIG_FILES: Readonly<Record<string, InitialFileContents>> = { + [GRAMMAR_CONFIG_PATH]: T12_0_14_CFG_CONFIG, + [`${GRAMMAR_CONFIG_DIR}/specs/B.mdx`]: T12_0_14_CFG_B_SOURCE, +}; +/** The session `--name -a` creates and the note `--note -x` stores. */ +const GRAMMAR_SESSION_NAME = "-a"; +const GRAMMAR_SESSION_REL = `.xspec/reviews/${GRAMMAR_SESSION_NAME}.json`; +const GRAMMAR_NOTE = "-x"; + +/** + * One invocation expected to fail as a usage error with JSON output out of + * effect: exit 2 exactly (H-5), standard output empty (SPEC 12.0: when JSON + * output is not in effect, an exit-2 error leaves standard output empty), + * and the diagnostic on standard error (wording unpinned, H-3). The empty + * stdout is the discriminating half of each pair: a parser reading a + * `--json` token as the flag where 12.0 reads it as a value or an operand + * emits the 12.7 error document there instead. + */ +async function expectSilentUsageError( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<RunResult> { + const result = await expectExit(product, workspace, argv, 2, context); + assertStdoutEmpty( + result, + `${context}: with JSON output not in effect, an exit-2 error leaves ` + + `standard output empty (SPEC 12.0)`, + ); + 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; +} + +/** + * Assert that a run's standard output is not a JSON document: JSON output + * is out of effect (SPEC 12.0), so the listing is in its human form — + * whatever that form is (H-3), an empty stdout included, it is never a + * single JSON document. + */ +function assertNotJsonDocument(result: RunResult, context: string): void { + let parsed = false; + try { + JSON.parse(result.stdout); + parsed = true; + } catch { + // Not a JSON document — the expected outcome. + } + if (parsed) { + fail( + `${context}: JSON output is out of effect — the \`--json\` token was ` + + `taken whole as the value of the flag before it, never read as the ` + + `flag (SPEC 12.0) — so standard output must not be a JSON document; ` + + `got one: ${JSON.stringify(result.stdout.slice(0, 200))}`, + ); + } +} + +const T12_0_14 = defineProductTest({ + id: "T12.0-14", + title: + "invocation grammar (12.0's token rules, each arm discriminating a lenient parser): the remaining tokens match the synopsis exactly — `ids extra` and `rename specs/A.mdx a b c` (a fourth operand) exit 2 with nothing done (whole root byte-unchanged, journal and sources included), `xspec` alone and `query bogus` exit 2; `--name=value` spells no flag (`review create --strategy audit --name=n` exits 2 as an unknown flag, no session created); flag tokens stand anywhere (`--json ids` and `ids --json` emit byte-identical documents; `--config cfg/xspec.config.ts build` loads that configuration, exit 0 with derived output under `cfg/` alone where `build` alone is a configuration error); a value-taking flag takes the whole next token (`ids --file --json` runs with the glob `--json` — exit 0, an empty listing byte-identical to a no-match glob's, stdout not a JSON document; `ids --config --json` names the path `--json` — exit 2, missing configuration, stdout empty, the diagnostic on stderr — where `ids --json --config` exits 2 with the error document on stdout; `ids --file` as the last token exits 2); no single-dash short forms (`review create --strategy audit --name -a` creates `.xspec/reviews/-a.json`, `resolve … --note -x` stores the note `-x`, `ids -j` exits 2 as a surplus operand with stdout empty); `--` ends flag reading and is dropped (`ids --` byte-identical to `ids`; `ids -- --json` exits 2 with stdout empty); arity is fixed by name across commands (`build --file --json` exits 2 having consumed `--json`, stdout empty; `build --bogus --json` and `build --json --file` exit 2 with the error document; each modifying nothing); a repeated `--json` exits 2 with the error document as its entire stdout; list-valued flags: `--kinds depends,`, `,depends`, and `depends,,embeds` each exit 2 with the error document while `depends,depends` answers byte-identically to `depends` (SPEC 12.0, 12.3, 12.1, 10.1, 10.7, 11.1, 7, 12.7)", + timeoutMs: 180_000, + run: async (product) => { + await withWorkspace({ files: GRAMMAR_FILES }, async (workspace) => { + // Premise: the fixture builds, so a lenient rename or session write + // below would have derived state to change and a journal to append. + await buildOk(product, workspace, "T12.0-14 premise `build`"); + + // Reference answers on this fixture: the human listing and the JSON + // document of `ids` (SPEC 12.3, 12.0). + const idsHuman = await expectExit( + product, + workspace, + ["ids"], + 0, + "T12.0-14 reference `ids` — the listing in its human form, exit 0 " + + "(SPEC 12.3, 12.0)", + ); + const idsJsonContext = "T12.0-14 reference `ids --json`"; + const idsJson = await expectExit( + product, + workspace, + ["ids", "--json"], + 0, + `${idsJsonContext} — the listing as a JSON document, exit 0 (SPEC ` + + `12.3, 12.0)`, + ); + parseJsonStdout( + idsJson, + `${idsJsonContext} — with \`--json\` read as a flag, the single ` + + `JSON document is the entire standard output (SPEC 12.0)`, + ); + + // --- Flag tokens stand anywhere: before the command word too. + const jsonFirst = await expectExit( + product, + workspace, + ["--json", "ids"], + 0, + "T12.0-14 `--json ids` — flag tokens may stand anywhere among the " + + "arguments, before the command word included: the invocation is " + + "`ids` with JSON output in effect, exit 0 (SPEC 12.0)", + ); + assertBytesEqual( + jsonFirst.stdoutBytes, + idsJson.stdoutBytes, + "T12.0-14 `--json ids` against `ids --json`: once the flags are " + + "removed the remaining tokens are the same command, so the two " + + "spellings emit byte-identical documents (SPEC 12.0; H-4's " + + "product-to-itself compare)", + ); + + // --- `--` ends flag reading and is dropped. + const dashDash = await expectExit( + product, + workspace, + ["ids", "--"], + 0, + "T12.0-14 `ids --` — the token `--` ends flag reading and is " + + "dropped, so the invocation is `ids`: exit 0, never a surplus " + + "operand or an unknown flag (SPEC 12.0)", + ); + assertBytesEqual( + dashDash.stdoutBytes, + idsHuman.stdoutBytes, + "T12.0-14 `ids --` against `ids`: `--` is dropped, so the two " + + "behave identically — byte-identical standard output (SPEC 12.0; " + + "H-4's product-to-itself compare)", + ); + await expectSilentUsageError( + product, + workspace, + ["ids", "--", "--json"], + "T12.0-14 `ids -- --json` — every token after `--` is a non-flag " + + "token, `--`-prefixed spellings included, so `--json` is a " + + "surplus operand: exit 2 with JSON output out of effect — " + + "standard output empty, the usage message on standard error " + + "(SPEC 12.0)", + ); + + // --- No single-dash short forms: `-j` is an operand, never a flag. + await expectSilentUsageError( + product, + workspace, + ["ids", "-j"], + "T12.0-14 `ids -j` — a token beginning with a single `-` is an " + + "operand or a value, never a flag: `-j` is a surplus operand to " + + "`ids`, exit 2 with standard output empty (no `--json` flag is " + + "in effect), never accepted as a short form of `--json` (SPEC " + + "12.0)", + ); + + // --- The remaining tokens MUST match the synopsis exactly. + await expectSilentUsageError( + product, + workspace, + [], + "T12.0-14 `xspec` alone — no command word is a usage error of the " + + "syntax class: exit 2, standard output empty (SPEC 12.0)", + ); + await expectPlainUsageError( + product, + workspace, + ["query", "bogus", "--json"], + "T12.0-14 `query bogus --json` — an unknown subcommand is a usage " + + "error: exit 2 with the plain usage error's document (SPEC 12.0, " + + "12.7)", + ); + await assertLeavesUnchanged( + workspace.root, + () => + expectSilentUsageError( + product, + workspace, + ["ids", "extra"], + "T12.0-14 `ids extra` — more operands than the synopsis admits " + + "is a usage error: exit 2, standard output empty (SPEC 12.0, " + + "12.3)", + ), + "T12.0-14 `ids extra` — a surplus token is never accepted and " + + "ignored: nothing is done (SPEC 12.0)", + ); + await assertLeavesUnchanged( + workspace.root, + () => + expectSilentUsageError( + product, + workspace, + ["rename", GRAMMAR_SPEC_FILE, "a", "b", "c"], + "T12.0-14 `rename specs/A.mdx a b c` — a fourth operand to " + + "`rename` (a three-operand synopsis, SPEC 6.4) is a usage " + + "error: exit 2, standard output empty (SPEC 12.0)", + ), + "T12.0-14 `rename specs/A.mdx a b c` — a surplus token is never " + + "accepted and ignored, so no rename occurs: the journal, the " + + "sources, and every derived file stay byte-unchanged (SPEC 12.0)", + ); + + // --- `--name=value` is no flag spelling: an unknown flag. + const nameEqualsContext = + "T12.0-14 `review create --strategy audit --name=n --json`"; + await assertLeavesUnchanged( + workspace.root, + () => + expectPlainUsageError( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name=n", "--json"], + `${nameEqualsContext} — \`--name=value\` is not a spelling of ` + + `any flag: the token is an unknown flag, a usage error of ` + + `the syntax class — exit 2 with the plain usage error's ` + + `document (SPEC 12.0, 12.7)`, + ), + `${nameEqualsContext} — an unknown flag modifies nothing: no ` + + `session is created (SPEC 12.0, 10.7)`, + ); + if ((await workspace.kind(".xspec/reviews/n.json")) !== "absent") { + fail( + `${nameEqualsContext}: no session is created — \`--name=n\` ` + + `names no session, so .xspec/reviews/n.json must not exist ` + + `(SPEC 12.0, 10.1)`, + ); + } + + // --- A value-taking flag takes the whole next token, whatever it + // looks like — a value beginning with `--` included. + // Premise: a `--file` glob restricts the listing (SPEC 12.3), so the + // no-match listing differs from the full one and the pair below is + // attributable to the glob `--json` being in effect. + const noMatch = await expectExit( + product, + workspace, + ["ids", "--file", GRAMMAR_NO_MATCH_GLOB], + 0, + `T12.0-14 premise \`ids --file ${GRAMMAR_NO_MATCH_GLOB}\` — a glob ` + + `inside the root matching no discovered file restricts the ` + + `listing to nothing: an empty listing, exit 0 (SPEC 12.3, 7)`, + ); + if ( + noMatch.stdoutBytes.length === idsHuman.stdoutBytes.length && + noMatch.stdoutBytes.every( + (byte, index) => byte === idsHuman.stdoutBytes[index], + ) + ) { + fail( + `T12.0-14 premise \`ids --file ${GRAMMAR_NO_MATCH_GLOB}\`: the ` + + `listing restricted to no file carries no file, so its bytes ` + + `must differ from the unrestricted listing's — \`--file\` ` + + `restricts the listing to the files the glob matches (SPEC ` + + `12.3); got the unrestricted listing`, + ); + } + const fileJsonContext = "T12.0-14 `ids --file --json`"; + const fileJson = await expectExit( + product, + workspace, + ["ids", "--file", "--json"], + 0, + `${fileJsonContext} — \`--file\` takes the whole next token as its ` + + `value whatever it looks like, so the invocation runs with the ` + + `glob \`--json\`, which matches nothing: an empty listing, exit ` + + `0 — never \`--file\` lacking its value (SPEC 12.0, 12.3)`, + ); + assertNotJsonDocument(fileJson, fileJsonContext); + assertBytesEqual( + fileJson.stdoutBytes, + noMatch.stdoutBytes, + `${fileJsonContext} against \`ids --file ${GRAMMAR_NO_MATCH_GLOB}\`: ` + + `both are the human-form empty listing of a glob matching nothing ` + + `— byte-identical standard output, JSON output out of effect in ` + + `each (SPEC 12.0, 12.3; H-4's product-to-itself compare)`, + ); + const configJson = await expectSilentUsageError( + product, + workspace, + ["ids", "--config", "--json"], + "T12.0-14 `ids --config --json` — `--config` takes the whole next " + + "token, so the configuration path is `--json`, which nothing " + + "occupies: missing configuration, exit 2, with JSON output out " + + "of effect — standard output empty, the diagnostic on standard " + + "error (SPEC 12.0, 7, 14.14)", + ); + if (!/config/i.test(configJson.stderr)) { + fail( + `T12.0-14 \`ids --config --json\`: the diagnostic on standard ` + + `error identifies the configuration as the failing subject — ` + + `the path \`--json\` names no configuration file (SPEC 14.14, ` + + `7); any phrasing naming the configuration qualifies (H-3); got ` + + `${JSON.stringify(configJson.stderr)}`, + ); + } + await expectPlainUsageError( + product, + workspace, + ["ids", "--json", "--config"], + "T12.0-14 `ids --json --config` — `--config` as the last token " + + "lacks its value, a usage error, with `--json` read as a flag: " + + "exit 2 with the plain usage error's document as the entire " + + "standard output — the pair with `ids --config --json` " + + "discriminating a parser that recognizes `--json` in a value " + + "position (SPEC 12.0, 12.7)", + ); + await expectSilentUsageError( + product, + workspace, + ["ids", "--file"], + "T12.0-14 `ids --file` — a value-taking flag as the last token " + + "lacks its value: a usage error, exit 2, standard output empty " + + "(SPEC 12.0)", + ); + + // --- A flag's arity is fixed by its name across commands, known + // before the command word; a `--` token naming no flag of any + // command takes no value. Each is a syntax-class error, so `build` + // modifies nothing (whole-root compares). + await assertLeavesUnchanged( + workspace.root, + () => + expectSilentUsageError( + product, + workspace, + ["build", "--file", "--json"], + "T12.0-14 `build --file --json` — `--file` takes a value on " + + "every command, so it consumes `--json` and is then an " + + "unknown flag to `build`: exit 2 with JSON output out of " + + "effect — standard output empty (SPEC 12.0, 12.1)", + ), + "T12.0-14 `build --file --json` — a syntax-class usage error " + + "modifies nothing (SPEC 12.0)", + ); + await assertLeavesUnchanged( + workspace.root, + () => + expectPlainUsageError( + product, + workspace, + ["build", "--bogus", "--json"], + "T12.0-14 `build --bogus --json` — a `--` token naming no flag " + + "of any command takes no value, so `--json` stays a flag: " + + "exit 2 with the plain usage error's document on standard " + + "output (SPEC 12.0, 12.7)", + ), + "T12.0-14 `build --bogus --json` — a syntax-class usage error " + + "modifies nothing (SPEC 12.0)", + ); + await assertLeavesUnchanged( + workspace.root, + () => + expectPlainUsageError( + product, + workspace, + ["build", "--json", "--file"], + "T12.0-14 `build --json --file` — `--json` a flag, `--file` " + + "unknown to `build` and lacking its value: exit 2 with the " + + "plain usage error's document on standard output (SPEC " + + "12.0, 12.1, 12.7)", + ), + "T12.0-14 `build --json --file` — a syntax-class usage error " + + "modifies nothing (SPEC 12.0)", + ); + + // --- A repeated `--json` is a usage error that still puts JSON output + // in effect. + await expectPlainUsageError( + product, + workspace, + ["ids", "--json", "--json"], + "T12.0-14 `ids --json --json` — a flag may be given at most once: " + + "the repetition is a usage error that still puts JSON output in " + + "effect, so the plain usage error's document is the entire " + + "standard output, exit 2 (SPEC 12.0, 12.7)", + ); + + // --- List-valued flags take one comma-separated value (SPEC 11.1): + // an empty element is a usage error; a repeated element collapses. + // `query` is JSON-only (11), so the error document is on stdout with + // no `--json` given. + for (const value of ["depends,", ",depends", "depends,,embeds"]) { + await expectPlainUsageError( + product, + workspace, + ["query", "edges", "--kinds", value], + `T12.0-14 \`query edges --kinds ${value}\` — the comma-separated ` + + `value carries an empty element (a trailing, leading, or ` + + `doubled comma): an invalid flag value of the syntax class, ` + + `exit 2 with the plain usage error's document (SPEC 12.0, ` + + `11.1, 12.7)`, + ); + } + const single = await expectExit( + product, + workspace, + ["query", "edges", "--kinds", "depends"], + 0, + "T12.0-14 `query edges --kinds depends` — one element inside the " + + "vocabulary, exit 0 (SPEC 11.1)", + ); + const repeated = await expectExit( + product, + workspace, + ["query", "edges", "--kinds", "depends,depends"], + 0, + "T12.0-14 `query edges --kinds depends,depends` — a repeated " + + "element collapses to the set {depends}: exit 0, never an " + + "invalid flag value (SPEC 11.1, 12.0)", + ); + assertBytesEqual( + repeated.stdoutBytes, + single.stdoutBytes, + "T12.0-14 `query edges --kinds depends,depends` against `--kinds " + + "depends`: the collapsed set answers byte-identically (SPEC 11.1, " + + "12.0; T11-4 pins the answer; H-4's product-to-itself compare)", + ); + + // --- Values beginning with `-`: `--name -a` names the session `-a` + // (a valid name, SPEC 10.1) and `--note -x` stores the note `-x`. + const createContext = + `T12.0-14 \`review create --strategy audit --name ` + + `${GRAMMAR_SESSION_NAME}\``; + await expectExit( + product, + workspace, + [ + "review", + "create", + "--strategy", + "audit", + "--name", + GRAMMAR_SESSION_NAME, + ], + 0, + `${createContext} — \`--name\` takes the whole next token, so ` + + `\`-a\` is the session name: one or more characters from A–Z, ` + + `a–z, 0–9, \`.\`, \`_\`, and \`-\`, not beginning with \`.\` — ` + + `a valid name, the session created, exit 0 (SPEC 12.0, 10.1, ` + + `10.7)`, + ); + if ((await workspace.kind(GRAMMAR_SESSION_REL)) !== "file") { + fail( + `${createContext}: the session is stored at ${GRAMMAR_SESSION_REL} ` + + `as a plain file (SPEC 10.1); got ` + + `${await workspace.kind(GRAMMAR_SESSION_REL)}`, + ); + } + const nextContext = `T12.0-14 \`review next ${GRAMMAR_SESSION_NAME} --json\``; + const next = decodeNextReport( + await runJson( + product, + workspace, + ["review", "next", GRAMMAR_SESSION_NAME, "--json"], + `${nextContext} — the audit session over one requirement node ` + + `holds unblocked items, the first of which \`next\` returns ` + + `(SPEC 10.6, 10.7)`, + ), + nextContext, + ); + if (next.fullyResolved || next.item === undefined) { + fail( + `${nextContext}: an audit session creates one subtree-coherence ` + + `item per requirement node, root nodes included, the leaf's ` + + `unblocked (SPEC 10.6), so \`next\` returns an item needing ` + + `review; got fully resolved`, + ); + } + const itemId = next.item.id; + const resolveContext = + `T12.0-14 \`review resolve ${GRAMMAR_SESSION_NAME} ${itemId} ` + + `--status updated --note ${GRAMMAR_NOTE}\``; + await expectExit( + product, + workspace, + [ + "review", + "resolve", + GRAMMAR_SESSION_NAME, + itemId, + "--status", + "updated", + "--note", + GRAMMAR_NOTE, + ], + 0, + `${resolveContext} — \`--note\` takes the whole next token, so ` + + `\`-x\` is the note, never a flag: the unblocked item resolves, ` + + `exit 0 (SPEC 12.0, 10.7)`, + ); + const showContext = `T12.0-14 \`review show ${GRAMMAR_SESSION_NAME} ${itemId} --json\``; + const item = decodeItemReport( + await runJson( + product, + workspace, + ["review", "show", GRAMMAR_SESSION_NAME, itemId, "--json"], + showContext, + ), + showContext, + ); + if (item.note !== GRAMMAR_NOTE) { + fail( + `${showContext}: \`resolve … --note -x\` stores the note \`-x\` ` + + `— the whole next token, a value beginning with \`-\` included ` + + `(SPEC 12.0, 10.2, 10.7); got note ${JSON.stringify(item.note)}`, + ); + } + if (item.status !== "updated") { + fail( + `${showContext}: the resolved item carries the status \`updated\` ` + + `(SPEC 10.3, 10.7); got ${JSON.stringify(item.status)}`, + ); + } + }); + + // --- `--config <path>` before the command word loads that + // configuration: the root holds none (the premise below), so an exit-0 + // `build` writing derived output under `cfg/` alone is attributable to + // the leading flag and its value (SPEC 12.0, 7). + await withWorkspace({ files: GRAMMAR_CONFIG_FILES }, async (workspace) => { + await expectConfigurationError( + product, + workspace, + ["build"], + "T12.0-14 premise `build` with no configuration at or above the " + + "working directory — the upward search fails: a configuration " + + "error, exit 2 (SPEC 7, 14.14), so the arm's exit 0 is " + + "attributable to `--config` alone", + ); + const configFirstContext = `T12.0-14 \`--config ${GRAMMAR_CONFIG_PATH} build\``; + const before = await snapshotDirectory(workspace.root); + await expectExit( + product, + workspace, + ["--config", GRAMMAR_CONFIG_PATH, "build"], + 0, + `${configFirstContext} — the global \`--config\` flag and its value ` + + `stand before the command word: the named configuration is ` + + `loaded, its directory the workspace root, and the build ` + + `succeeds, exit 0 (SPEC 12.0, 7)`, + ); + const after = await snapshotDirectory(workspace.root); + const changes = diffSnapshots(before, after); + if (changes.length === 0) { + fail( + `${configFirstContext}: a successful \`build\` over the named ` + + `configuration's root writes derived output there — generated ` + + `modules beside the sources and graph data under \`.xspec/\` ` + + `(SPEC 12.1, 13.1, 13.3) — but nothing under the workspace ` + + `changed`, + ); + } + const outside = changes.filter( + (change) => + change.key !== GRAMMAR_CONFIG_DIR && + !change.key.startsWith(`${GRAMMAR_CONFIG_DIR}/`), + ); + if (outside.length > 0) { + fail( + `${configFirstContext}: all configured paths resolve relative to ` + + `the configuration file's directory, which is the workspace ` + + `root (SPEC 7), so every write lands under ` + + `${GRAMMAR_CONFIG_DIR}/; got changes outside it: ` + + outside + .slice(0, 10) + .map((change) => `${change.change} ${change.path}`) + .join(", "), + ); + } + }); + }, +}); + +export const section120iiiTests: readonly ProductTestEntry[] = [T12_0_14]; diff --git a/test/suite/registry/section-12.1-12.2.ts b/test/suite/registry/section-12.1-12.2.ts index ba3b0f34..4fb8c537 100644 --- a/test/suite/registry/section-12.1-12.2.ts +++ b/test/suite/registry/section-12.1-12.2.ts @@ -1,5 +1,5 @@ // TEST-SPEC §12.1 (`xspec build`) and §12.2 (`xspec check`) — SUITE-43: -// T12.1-1, T12.1-3, T12.1-4, T12.2-1, T12.2-2, T12.2-3. +// T12.1-1, T12.1-3, T12.1-4, T12.2-1, T12.2-2, T12.2-3, T12.2-4. // // T12.1-2 (no policy) is a pure cross-reference in TEST-SPEC — its whole text // is "T7.5-6." — so no separate body is registered here: its content runs as @@ -48,25 +48,57 @@ // `rename`/`move` (6.4/6.5) and durable files only by their owning // commands (13.4) leave no path a failed `build` or any `check` may // legitimately change. -// - T12.2-2 runs one workspace per finding family. Families staged as -// invalid sources or corrupted durable state cannot fix whether a product -// additionally reports 14.10 staleness: whether prior derived state is -// detectably stale when "what the current sources generate" is undefined -// (invalid sources, unreplayable journal) is not settled by SPEC 13.3/14 — -// a regeneration-comparing product reports nothing (masked, 14), a -// hash-comparing product reports staleness. Family assertions therefore -// count the non-14.10 findings exactly (the family condition may never be -// missing, and no phantom non-staleness condition is accepted) and set -// 14.10 findings aside. The staleness family itself asserts the reverse: -// every finding is 14.10, names its file, and instructs rebuilding. +// - T12.2-2 runs one workspace per finding family, and every family's +// `check` findings are pinned exact — the staged conditions and nothing +// beside them, 14.10 included. SPEC 14.10 settles staleness on a failing +// workspace: where the workspace fails `build`'s validations (invalid +// sources, a journal error — SPEC 13.3) the content the current sources +// generate is undefined, so the mismatch forms, per file and graph data, +// are undetectable and go unreported, while the unreadable-record form +// (14.23) and the recorded-file form (a recorded derived path no longer +// generated) are reported whatever the validity. Judged per staging, +// neither whatever-validity form is staged: the pre-built families edited +// to invalid sources (family 1) or given a garbage journal line (family 7) +// leave the record readable and every recorded path still generated (the +// same sources and configuration); the never-built families (5, 6) hold +// no record at all (a failing `build` writes nothing, 12.1); and the +// freshly built passing families (8, 9) have nothing stale. So a product +// reporting prior derived state as stale beside the family condition — a +// hash-comparing product's unit form, a regeneration-comparing product's +// per-file form — fails, as does one omitting the family condition. 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 @@ -81,23 +113,72 @@ // 1–7 negative tests and T14-1's completeness matrix. The separately // listed families (references, cycles, journal, policy, sessions) get // their own workspaces below. +// - T12.2-4 (b) reads one condition-10 finding per orphaned path, concerning +// it, and no other: dropping a source from the groups orphans the paths +// the record holds for it (13.3) — 13.1 leaves the companion set to the +// product, and 14.10 reports one finding per such path — so the expected +// set comes from the record, as TEST-SPEC defines it: the dropped +// source's module (whose path 13.1 fixes, and which the record must +// name), each companion `inventory`'s `recorded` set lists for it after +// the build (T11.6-3; attributable through 13.1's naming), and its +// Markdown where emitted — none here, the fixture's configuration having +// no `markdown` key (7.3). Read on the freshly built workspace, before +// the configuration change, never from a listing of the written files: a +// product whose written files and record disagree is judged by its +// record. Each expected path is pinned a plain file after the build — the +// recorded file remaining at the path, the occupant the recorded-file +// form concerns (14.10, 13.4). A product without companions reports +// exactly one finding. Each arm's `check` and failing `build` run +// inside whole-root compares (T12.2-3, T12.1-4), every arm's "no +// condition 12" and "no condition 10" are the exact condition multiset +// beside the validation finding (located within its section's opening +// tag), and the premise `check` on each freshly built workspace pins the +// violation as detectable, so its absence on the failing staging is +// 14.12's confinement rather than the fixture's silence. 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { assertGraphDataPresent, deleteGraphData } from "./section-13.3.js"; import { assertConditionCounts, + assertFindingConcernsPath, + assertFindingLocated, + assertSameJson, buildFindings, buildOk, + byteWindow, expectConfigurationError, expectExit, readGeneratedModule, + recordedCompanionPaths, runJson, } from "./support.js"; @@ -118,9 +199,44 @@ export default defineConfig({ `; } +// `markdownConfig`'s two configurations, each staged after a body's first +// product invocation — emission on by T12.2-2's staleness, graph-data +// unit-form, and unreadable-record families' workspaces; emission off by +// T12.1-3's arm-3 rewrite and T12.2-2's staleness arm-4 rewrite and its +// cycles, journal, and sessions families' workspaces — so S-7's sweep never +// reaches those stagings against the stub: TypeScript staged-source records +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), staged at +// every site, the first workspaces' too. +const MARKDOWN_EMIT_CONFIG = stagedTs( + "T12.2-2 xspec.config.ts — one spec group, Markdown emission on (markdownConfig(true): the staleness, graph-data unit-form, and unreadable-record families' workspaces)", + markdownConfig(true), +); +const MARKDOWN_NO_EMIT_CONFIG = stagedTs( + "T12.1-3/T12.2-2 xspec.config.ts — one spec group, Markdown emission off (markdownConfig(false): T12.1-3's arm-3 rewrite; T12.2-2's staleness arm-4 rewrite and its cycles, journal, and sessions families' workspaces)", + markdownConfig(false), +); + +// The valid single-section source `a1`, byte-identical wherever it is +// staged: the initial specs/A.mdx of T12.1-1's and T12.2-1's workspace, +// T12.1-3's, T12.1-4's, T12.2-3's, and every T12.2-2 family workspace +// holding a1; T12.1-3's manual-rename copy at specs/C.mdx; and the source +// T12.1-4, T12.2-2's graph-data mismatch arm, and T12.2-3 stage back over +// an edit after a build. The later T12.2-2 families' workspaces and the +// staged-back and copied files follow their body's first product +// invocation, so S-7's sweep never reaches them against the stub: ONE +// staged-source record (helpers/staged-mdx.ts; S-9's before-any-product +// clause) staged at every site — the record rather than a plain spelling +// of its bytes in the first workspaces too. Exported: T14-4's and T14-6's +// stale workspace (section-14.ts's STALE_DECL) stages the same bytes as +// specs/a.mdx. +export const VALID_A1_SOURCE = stagedMdx( + "T12.1-1/T12.1-3/T12.1-4/T12.2-1/T12.2-2/T12.2-3/T14-4/T14-6 specs/A.mdx (the valid a1 source: every workspace's initial specs/A.mdx holding a1; T12.1-3's manual-rename copy at specs/C.mdx; staged back after a build by T12.1-4, T12.2-2, and T12.2-3; T14-4's and T14-6's stale workspace specs/a.mdx)", + ['<S id="a1">', "Alpha behavior.", "</S>", ""].join("\n"), +); + /** Stage a fresh workspace with the given files, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ files }); @@ -153,12 +269,12 @@ async function checkFindings( } /** - * The T12.2-2 family assertion: `check` exits 1 and the non-14.10 findings - * are exactly the staged family conditions. 14.10 staleness findings are set - * aside — whether a product reports prior derived state as stale when the - * staged corruption makes current generation uncomputable is not settled by - * SPEC (see the module header) — while the family condition may never be - * missing and no phantom condition is accepted. + * The T12.2-2 family assertion: `check` exits 1 and its findings are exactly + * the staged family conditions — 14.10 counted with the rest: on a failing + * workspace the mismatch forms go unreported, and no family stages a + * whatever-validity form (SPEC 14.10; the module header judges each + * staging) — so the family condition may never be missing and no phantom + * condition, staleness included, is accepted. */ async function checkFamilyFindings( product: ProductBinding, @@ -167,13 +283,12 @@ async function checkFamilyFindings( context: string, ): Promise<readonly Finding[]> { const findings = await checkFindings(product, workspace, context); - const nonStale = findings.filter((finding) => finding.condition !== "14.10"); assertConditionCounts( - nonStale, + findings, expected, - `${context} — the staged family conditions, counted over the non-14.10 ` + - `findings (14.10 staleness against the staged corruption is neither ` + - `required nor forbidden; see the module header)`, + `${context} — exactly the staged family conditions: 14.10's mismatch ` + + `forms go unreported on a failing workspace, and no whatever-validity ` + + `form is staged (SPEC 14.10; see the module header)`, ); return findings; } @@ -201,11 +316,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 +340,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 +358,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, @@ -303,10 +476,11 @@ async function expectNoModuleOrCompanions( // --------------------------------------------------------------------------- // Two spec files whose validity requires dependency resolution (B imports A -// and depends on its node), Markdown emission enabled. -const PRODUCTS_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": markdownConfig(true), - "specs/A.mdx": ['<S id="a1">', "Alpha behavior.", "</S>", ""].join("\n"), +// and depends on its node), Markdown emission enabled; specs/A.mdx is the +// valid a1 record. +const PRODUCTS_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, "specs/B.mdx": [ 'import A from "./A.xspec"', "", @@ -414,10 +588,15 @@ const T12_1_1 = defineProductTest({ // Two independent spec files (no cross-references), so removal and manual // renaming keep the workspace valid (SPEC 6.6: manual restructuring is a -// deletion plus an addition). -const REGEN_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": markdownConfig(true), - "specs/A.mdx": ['<S id="a1">', "Alpha behavior.", "</S>", ""].join("\n"), +// deletion plus an addition). Arm 2's manual rename copies A's source to +// specs/C.mdx after the arm-1 `build` — a staging after a product +// invocation, so the ledger record (S-9, helpers/staged-mdx.ts) that is +// also the initial specs/A.mdx: the fixture's own bytes, which `build` +// leaves untouched (sources are product-written only by `rename`/`move`, +// SPEC 12.1, 6.4, 6.5 — pinned as the arm's staging premise). +const REGEN_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, "specs/B.mdx": ['<S id="b1">', "Beta behavior.", "</S>", ""].join("\n"), }; @@ -462,9 +641,18 @@ const T12_1_3 = defineProductTest({ await expectFile(workspace, "specs/A.md", `${arm1} (A survives)`); // Arm 2 — manually renaming a source (6.6: deletion plus addition): - // A's old derived paths disappear, C's appear. - const sourceBytes = await workspace.readBytes("specs/A.mdx"); - await workspace.file("specs/C.mdx", sourceBytes); + // A's old derived paths disappear, C's appear. The copy is the + // fixture's own bytes (the ledger record), so the staging premise + // pins first that `build` left the source untouched. + assertBytesEqual( + await workspace.readBytes("specs/A.mdx"), + VALID_A1_SOURCE.source, + "T12.1-3 arm 2 staging premise — `build` writes derived files and " + + "graph data only, never a source (SPEC 12.1; sources are " + + "product-written only by `rename`/`move`, 6.4, 6.5), so " + + "specs/A.mdx still holds the fixture's bytes for the manual rename", + ); + await workspace.file("specs/C.mdx", VALID_A1_SOURCE); await fsp.rm(workspace.path("specs/A.mdx")); await buildOk( product, @@ -483,7 +671,7 @@ const T12_1_3 = defineProductTest({ // Arm 3 — disabling emission: the emitted Markdown disappears, the // module stays (SPEC 7.3: with emit false, no path is a Markdown emit // destination). - await workspace.file("xspec.config.ts", markdownConfig(false)); + await workspace.file("xspec.config.ts", MARKDOWN_NO_EMIT_CONFIG); await buildOk( product, workspace, @@ -503,29 +691,30 @@ const T12_1_3 = defineProductTest({ // T12.1-4 — failed build modifies nothing // --------------------------------------------------------------------------- -const FAILED_BUILD_VALID_SOURCE = [ - '<S id="a1">', - "Alpha behavior.", - "</S>", - "", -].join("\n"); - // The valid source with a nested section lacking `id` — condition 14.1, the -// staged validation error. -const FAILED_BUILD_INVALID_SOURCE = [ - '<S id="a1">', - "Alpha behavior.", - "", - "<S>", - "Nested section without an id.", - "</S>", - "</S>", - "", -].join("\n"); +// staged validation error, staged over the built valid source (a ledger +// record, S-9). +const FAILED_BUILD_INVALID_SOURCE = stagedMdx( + "T12.1-4/T12.2-2 specs/A.mdx with a nested section lacking id (14.1), staged over the built valid source", + [ + '<S id="a1">', + "Alpha behavior.", + "", + "<S>", + "Nested section without an id.", + "</S>", + "</S>", + "", + ].join("\n"), +); // The valid configuration plus one unknown top-level key — a configuration -// error (SPEC 7, 14.14). -const FAILED_BUILD_BOGUS_CONFIG = `import { defineConfig } from "xspec" +// error (SPEC 7, 14.14), staged over the built workspace: a TypeScript +// staged-source record (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), well-formed. +const FAILED_BUILD_BOGUS_CONFIG = stagedTs( + "T12.1-4 xspec.config.ts — an unknown top-level key bogus (arm 2's configuration error, staged over the built workspace)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -534,7 +723,8 @@ export default defineConfig({ markdown: { emit: true }, bogus: true }) -`; +`, +); const T12_1_4 = defineProductTest({ id: "T12.1-4", @@ -543,8 +733,8 @@ const T12_1_4 = defineProductTest({ run: async (product) => { await withWorkspace( { - "xspec.config.ts": markdownConfig(true), - "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, }, async (workspace) => { // Prior derived state. @@ -573,7 +763,7 @@ const T12_1_4 = defineProductTest({ // Arm 2 — configuration error: exit 2, nothing modified. The source // is restored first so the staged configuration defect is the // workspace's only defect. - await workspace.file("specs/A.mdx", FAILED_BUILD_VALID_SOURCE); + await workspace.file("specs/A.mdx", VALID_A1_SOURCE); await workspace.file("xspec.config.ts", FAILED_BUILD_BOGUS_CONFIG); await assertLeavesUnchanged( workspace.root, @@ -637,8 +827,14 @@ const T12_2_1 = defineProductTest({ // non-static `d` value), plus one code file staging 14.7 (an unresolved // TypeScript marker). Every reference targets a distinct missing name, so no // condition masks another (SPEC 14: each present condition is reported). -const REFERENCES_FAMILY_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// The family's workspace follows family 1's invocations, so its +// configuration and code source are TypeScript staged-source records +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), wrapped in +// place. +const REFERENCES_FAMILY_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T12.2-2 references family xspec.config.ts — one spec group and one code group (src/**/*.ts)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -649,46 +845,84 @@ export default defineConfig({ } }) `, - "specs/A.mdx": [ - '<S id="a1" d={"nope"}>', - "Unknown dependency target.", - "</S>", - "", - '<S id="a2">', - "Unknown text target below.", - "", - '{text("nada")}', - "</S>", - "", - '<S id="a3" d={42}>', - "Non-static dependency value.", - "</S>", - "", - ].join("\n"), - "src/app.ts": [ - 'import A from "../specs/A.xspec";', - "", - "function marker(): void {", - " A.missing;", - "}", - "", - ].join("\n"), + ), + "specs/A.mdx": stagedMdx( + "T12.2-2 references family specs/A.mdx (an unknown d target, an unknown text target, and a non-static d value)", + [ + '<S id="a1" d={"nope"}>', + "Unknown dependency target.", + "</S>", + "", + '<S id="a2">', + "Unknown text target below.", + "", + '{text("nada")}', + "</S>", + "", + '<S id="a3" d={42}>', + "Non-static dependency value.", + "</S>", + "", + ].join("\n"), + ), + "src/app.ts": stagedTs( + "T12.2-2 references family src/app.ts (an unresolved TypeScript marker, A.missing)", + [ + 'import A from "../specs/A.xspec";', + "", + "function marker(): void {", + " A.missing;", + "}", + "", + ].join("\n"), + ), }; // Family: cycles. A self-`depends` is a dependency cycle of length one // (SPEC 5.3) needing no import — so no spec import cycle is co-staged and // the exact condition count holds. -const CYCLE_FAMILY_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": markdownConfig(false), - "specs/A.mdx": ['<S id="s" d={"s"}>', "Depends on itself.", "</S>", ""].join( - "\n", +const CYCLE_FAMILY_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": MARKDOWN_NO_EMIT_CONFIG, + "specs/A.mdx": stagedMdx( + "T12.2-2 cycles family specs/A.mdx (a self-depends cycle of length one)", + ['<S id="s" d={"s"}>', "Depends on itself.", "</S>", ""].join("\n"), ), }; +// The violating dependence h1 -> lo/L.mdx#l1 under the forbidden rule +// `no-hi-to-lo`, byte-identical in T12.2-2's policy family, every T12.2-4 +// arm's fixture, and T14-4's and T14-6's policy workspace (section-14.ts, +// by import), all staged after their body's first product invocation +// (T12.2-4's arms (b)–(d)): ONE staged-source record (S-9). +export const POLICY_HI_SOURCE = stagedMdx( + "T12.2-2/T12.2-4/T14-4/T14-6 hi/H.mdx (h1 depending on lo/L.mdx#l1 under the no-hi-to-lo rule: T12.2-2's policy family; every T12.2-4 arm; T14-4's and T14-6's policy workspace)", + [ + 'import L from "../lo/L.xspec"', + "", + '<S id="h1" d={L.l1}>', + "Violating dependence.", + "</S>", + "", + ].join("\n"), +); + +// The violated target l1, byte-identical in T12.2-2's policy family and +// T14-4's and T14-6's policy workspace (section-14.ts, by import): ONE +// staged-source record (S-9). +export const POLICY_LO_SOURCE = stagedMdx( + "T12.2-2/T14-4/T14-6 lo/L.mdx (the violated target l1: T12.2-2's policy family; T14-4's and T14-6's policy workspace)", + ['<S id="l1">', "Low one.", "</S>", ""].join("\n"), +); + // Family: policy (14.12, check-only). One forbidden rule, one violating -// edge; build-side silence is T7.5-6's subject (T12.1-2). -const POLICY_FAMILY_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// edge; build-side silence is T7.5-6's subject (T12.1-2). The family's +// workspace follows family 1's invocations, so its configuration is a +// TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), wrapped in place. +const POLICY_FAMILY_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T12.2-2 policy family xspec.config.ts — hi and lo groups under the forbidden rule no-hi-to-lo", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -705,15 +939,9 @@ export default defineConfig({ ] }) `, - "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"), + ), + "hi/H.mdx": POLICY_HI_SOURCE, + "lo/L.mdx": POLICY_LO_SOURCE, }; // TEST-SPEC-sanctioned malformed journal line (the T6.1-3 shape). @@ -722,11 +950,32 @@ const GARBAGE_JOURNAL_LINE = const CORRUPT_SESSION_PATH = ".xspec/reviews/bad.json"; +// The family stagings made after each family workspace's `build` — the +// build-validations family's second edit and the staleness and graph-data +// families' source edits: ledger records (S-9, helpers/staged-mdx.ts). +const T12_2_2_B_INVALID_SEGMENT = stagedMdx( + "T12.2-2 specs/B.mdx with an invalid ID segment (14.4), staged over the built valid source (build-validations family)", + ['<S id="bad name">', "Invalid segment.", "</S>", ""].join("\n"), +); +const T12_2_2_A_EDITED = stagedMdx( + "T12.2-2 specs/A.mdx with a1's text edited without rebuilding (staleness family, the edited-source arm)", + ['<S id="a1">', "Alpha behavior, edited.", "</S>", ""].join("\n"), +); +const T12_2_2_A_MISMATCH_EDIT = stagedMdx( + "T12.2-2 specs/A.mdx with a1's text edited for the graph-data mismatch arm (refresh-then-revert)", + [ + '<S id="a1">', + "Alpha behavior, edited for the mismatch arm.", + "</S>", + "", + ].join("\n"), +); + 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 @@ -734,8 +983,8 @@ const T12_2_2 = defineProductTest({ // or graph data would find nothing and exit 0. await withWorkspace( { - "xspec.config.ts": markdownConfig(true), - "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, "specs/B.mdx": ['<S id="b1">', "Beta behavior.", "</S>", ""].join("\n"), }, async (workspace) => { @@ -746,10 +995,7 @@ const T12_2_2 = defineProductTest({ "state from a prior valid build persists (SPEC 12.1)", ); await workspace.file("specs/A.mdx", FAILED_BUILD_INVALID_SOURCE); - await workspace.file( - "specs/B.mdx", - ['<S id="bad name">', "Invalid segment.", "</S>", ""].join("\n"), - ); + await workspace.file("specs/B.mdx", T12_2_2_B_INVALID_SEGMENT); await checkFamilyFindings( product, workspace, @@ -762,11 +1008,13 @@ 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), - "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, }, async (workspace) => { const moduleRel = "specs/A.xspec.ts"; @@ -783,7 +1031,15 @@ const T12_2_2 = defineProductTest({ moduleRel, "T12.2-2 (staleness) after the initial build (SPEC 13.1)", ); - await workspace.file(moduleRel, `${original}// tampered\n`); + // The tampered module is an edit of product-written bytes — a + // derived file, no code source, whose well-formedness the document + // does not declare — so it is staged `unchecked` (S-9): never a + // staged-source record (no harness constant equals those bytes), and + // never judged, which would turn a product's malformed module into a + // harness error (H-8). + await workspace.file(moduleRel, `${original}// tampered\n`, { + ts: "unchecked", + }); assertSingleStaleFile( await checkFindings( product, @@ -816,6 +1072,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 @@ -825,10 +1145,7 @@ const T12_2_2 = defineProductTest({ workspace, "T12.2-2 (staleness) rebuild between arms (SPEC 12.1)", ); - await workspace.file( - "specs/A.mdx", - ['<S id="a1">', "Alpha behavior, edited.", "</S>", ""].join("\n"), - ); + await workspace.file("specs/A.mdx", T12_2_2_A_EDITED); const arm3Findings = await checkFindings( product, workspace, @@ -850,7 +1167,7 @@ const T12_2_2 = defineProductTest({ "T12.2-2 (staleness) rebuild between arms — regenerates from the " + "edited, still-valid source (SPEC 12.1)", ); - await workspace.file("xspec.config.ts", markdownConfig(false)); + await workspace.file("xspec.config.ts", MARKDOWN_NO_EMIT_CONFIG); const arm4Findings = await checkFindings( product, workspace, @@ -866,7 +1183,198 @@ 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": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_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", T12_2_2_A_MISMATCH_EDIT); + 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", VALID_A1_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": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_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 +1387,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,11 +1399,11 @@ 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), - "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + "xspec.config.ts": MARKDOWN_NO_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, }, async (workspace) => { await buildOk( @@ -916,7 +1424,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,12 +1442,12 @@ 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( { - "xspec.config.ts": markdownConfig(false), - "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + "xspec.config.ts": MARKDOWN_NO_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, }, async (workspace) => { await buildOk( @@ -965,15 +1473,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,56 +1490,203 @@ 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. + +// 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), +// staged after the state's rebuild — a ledger record (S-9). +const T12_2_3_A_EDITED = stagedMdx( + "T12.2-3 specs/A.mdx with a1's text edited without rebuilding (the mismatch staging and the edited-source state)", + [ + '<S id="a1">', + "Alpha behavior, edited without rebuilding.", + "</S>", + "", + ].join("\n"), +); + 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( { - "xspec.config.ts": markdownConfig(true), - "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/A.mdx": VALID_A1_SOURCE, }, async (workspace) => { + // 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", T12_2_3_A_EDITED); + 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", VALID_A1_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", T12_2_3_A_EDITED); + 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)", ); }, ); @@ -1039,6 +1694,406 @@ const T12_2_3 = defineProductTest({ }); /** TEST-SPEC §12.1–12.2 T12.1-1…T12.2-3, in canonical ID order (SUITE-43). */ +// --------------------------------------------------------------------------- +// T12.2-4 — staleness and policy on a failing workspace +// --------------------------------------------------------------------------- + +// One fixture for the four stagings (SPEC 7.5, 14.12): the forbidden rule +// `no-hi-to-lo` with the violating edge hi/H.mdx#h1 --depends--> lo/L.mdx#l1 +// (T7.5-2's shape), a third group `extra` whose one source extra/E.mdx is the +// source arm (b) drops from the groups, and lo/L.mdx — the "other file" of +// every arm — whose second section takes the validation error: an unresolved +// `d` reference (14.5). The file is exact parts, so the finding's window is +// the offending opening tag's own bytes (`byteWindow`). +const T12_2_4_L_PATH = "lo/L.mdx"; +const T12_2_4_L_HEAD = '<S id="l1">\nLow one.\n</S>\n\n'; +const T12_2_4_L_BROKEN_TAG = '<S id="l2" d={"nope"}>'; +const T12_2_4_L_TAIL = "\nLow two.\n</S>\n"; +// Both lo/L.mdx states are staged after the arm's `t1224Prepare` build — +// ledger records (S-9, helpers/staged-mdx.ts); the valid state is also the +// fixture's initial entry (every arm's workspace, arms (b)–(d) after the +// body's first product invocation). +const T12_2_4_L_VALID = stagedMdx( + "T12.2-4 lo/L.mdx, the valid source (every arm's initial lo/L.mdx; arm (d)'s repair)", + `${T12_2_4_L_HEAD}<S id="l2">${T12_2_4_L_TAIL}`, +); +const T12_2_4_L_INVALID = stagedMdx( + "T12.2-4 lo/L.mdx with l2's `d` reference unresolved (14.5) — every arm's failing staging", + `${T12_2_4_L_HEAD}${T12_2_4_L_BROKEN_TAG}${T12_2_4_L_TAIL}`, +); +const T12_2_4_BROKEN_WINDOW = byteWindow(T12_2_4_L_HEAD, T12_2_4_L_BROKEN_TAG); + +/** + * The fixture's configuration with the `extra` group present — staged by + * arms (b)–(d)'s workspaces after the body's first product invocation: a + * TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript + * and timing clauses), staged at every site. + */ +const T12_2_4_CONFIG = stagedTs( + "T12.2-4 xspec.config.ts — hi, lo, and extra groups under the forbidden rule no-hi-to-lo (every arm's workspace)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + hi: ["hi/**/*.mdx"], + lo: ["lo/**/*.mdx"], + extra: ["extra/**/*.mdx"] + }, + policy: [ + { + name: "no-hi-to-lo", + type: "forbidden", + from: { group: "hi" }, + to: { group: "lo" } + } + ] +}) +`, +); + +/** + * The same configuration with the `extra` group dropped (arm (b)), staged + * over the built workspace: a TypeScript staged-source record (S-9). + */ +const T12_2_4_CONFIG_WITHOUT_EXTRA = stagedTs( + "T12.2-4 (b) xspec.config.ts — the extra group dropped without a rebuild (the orphaning rewrite)", + `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" } + } + ] +}) +`, +); + +const T12_2_4_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": T12_2_4_CONFIG, + "hi/H.mdx": POLICY_HI_SOURCE, + [T12_2_4_L_PATH]: T12_2_4_L_VALID, + "extra/E.mdx": stagedMdx( + "T12.2-4 extra/E.mdx (the source arm (b) drops from the groups)", + ['<S id="e">', "Extra.", "</S>", ""].join("\n"), + ), +}; + +/** The violating edge's 14.12 identities, in 14.12's order (SPEC 12.7). */ +const T12_2_4_VIOLATION_IDENTITIES: readonly string[] = [ + "no-hi-to-lo", + "hi/H.mdx#h1", + "depends", + "lo/L.mdx#l1", +]; + +/** The 14.10 findings an arm expects beside the validation finding. */ +type T1224StaleExpectation = + | { readonly form: "none" } + | { readonly form: "recorded-file"; readonly paths: readonly string[] } + | { readonly form: "unit" }; + +/** + * Exactly one 14.12 finding carrying the fixture's violating edge: the + * violated rule's name and the edge's source identity, kind token, and + * target identity in order, no in-source location, no concerned path + * (SPEC 7.5, 14.12, 12.7). + */ +function assertOnlyTheViolation( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts(findings, { "14.12": 1 }, context); + const finding = findings[0]!; + assertSameJson( + finding.identities, + T12_2_4_VIOLATION_IDENTITIES, + `${context}: the violated rule's name and the edge's source identity, ` + + `kind token, and target identity, in order (SPEC 14.12, 12.7)`, + ); + assertSameJson( + finding.locations, + [], + `${context}: the offending entity is a graph edge, not a spelling — ` + + `locations [] (SPEC 14.12, 12.7)`, + ); + if (finding.path !== null) { + fail( + `${context}: a policy finding concerns no path — path null ` + + `(SPEC 14.12, 12.7); got ${JSON.stringify(finding.path)}`, + ); + } +} + +/** + * Build the fixture and pin the premise every arm's confinement claim rests + * on: on the freshly built valid workspace `check` exits 1 with exactly the + * staged violation — so its absence on the failing staging is 14.12's + * confinement, never the fixture's silence (SPEC 12.1, 7.5, 14.12). + */ +async function t1224Prepare( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + await buildOk( + product, + workspace, + `${context} initial \`build\` — the sources are valid and build does ` + + `not evaluate policy (SPEC 12.1, 7.5)`, + ); + const premise = `${context} premise \`check --json\` on the built valid workspace`; + assertOnlyTheViolation( + await checkFindings(product, workspace, premise), + `${premise} — the staged violation is the only finding (SPEC 7.5, 14.12)`, + ); +} + +/** + * The paths dropping a spec source from the groups orphans, as TEST-SPEC + * T12.2-4(b) defines them — from the record, never from a listing of the + * written files: on the freshly built workspace, before the configuration + * change, `inventory`'s `recorded` set is read (SPEC 11.6, T11.6-3), and + * the set is the source's module (whose path 13.1 fixes; the record must + * name it), each companion the record lists for the source through 13.1's + * naming (`recordedCompanionPaths`: none for a product writing no + * companions), and its Markdown where emitted — none here, the fixture's + * configuration having no `markdown` key (7.3, 13.2). Each path is pinned a + * plain file after the build: the recorded derived file remaining at the + * path, the occupant 14.10's recorded-file form concerns — a recorded path + * holding no plain file is a record naming no derived file the build + * generated (13.3, 13.4, 13.1). Returned as workspace-relative paths, + * sorted (the fixture's paths are ASCII, so in byte order). + */ +async function t1224RecordedOrphans( + product: ProductBinding, + workspace: TestWorkspace, + sourcePath: string, + context: string, +): Promise<readonly string[]> { + const inventoryContext = `${context} — \`inventory\` after the initial build`; + const companions = recordedCompanionPaths( + decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], inventoryContext), + inventoryContext, + ), + sourcePath, + inventoryContext, + ); + const moduleRel = `${sourcePath.slice(0, -".mdx".length)}.xspec.ts`; + const paths = [moduleRel, ...companions].sort(); + for (const rel of paths) { + const kind = await workspace.kind(rel); + if (kind !== "file") { + fail( + `${inventoryContext}: the record lists ${JSON.stringify(rel)} ` + + `among ${sourcePath}'s derived paths, but the path holds ` + + `${kind} after the build — the record names the derived files ` + + `the build generated, every file xspec writes a plain file ` + + `(SPEC 13.3, 13.4, 13.1)`, + ); + } + } + return paths; +} + +/** + * The recorded-file form on a failing workspace: one 14.10 finding per + * orphaned recorded path, concerning it, each unlocated (a path-concerned + * condition, 12.7) and instructing rebuilding — and no other 14.10, the + * mismatch forms being undetectable there (SPEC 14.10). + */ +function assertStaleFindingsConcernExactly( + stale: readonly Finding[], + paths: readonly string[], + context: string, +): void { + assertAllStale(stale, context); + assertSameJson( + stale.map((finding) => finding.path ?? "(null)").sort(), + [...paths].sort(), + `${context}: the 14.10 findings' concerned paths are exactly the ` + + `orphaned recorded paths, one finding per path (SPEC 14.10, 12.7)`, + ); + for (const finding of stale) { + assertSameJson( + finding.locations, + [], + `${context}: a recorded derived file remaining at a path no longer ` + + `generated is a path-concerned finding with no in-source location ` + + `(SPEC 14.10, 12.7); path ${JSON.stringify(finding.path)}`, + ); + } +} + +/** + * One arm's failing staging: the validation error introduced in lo/L.mdx, + * then `check --json` — exit 1, modifying nothing (`check` never refreshes + * and never repairs or replaces what it reports, 13.3) — pinned to exactly + * the validation finding beside the expected 14.10 findings and never a + * 14.12 (on a failing workspace no violation is detectable, 14.12, 7.5), + * the 14.5 located within its section's opening tag; then `build --json` on + * the same staging: exit 1 with the validation finding alone (14.10 and + * 14.12 are check-only), modifying nothing (12.1; T12.1-4). + */ +async function t1224FailingStaging( + product: ProductBinding, + workspace: TestWorkspace, + stale: T1224StaleExpectation, + context: string, +): Promise<void> { + await workspace.file(T12_2_4_L_PATH, T12_2_4_L_INVALID); + const expected: Record<string, number> = { "14.5": 1 }; + if (stale.form === "recorded-file") expected["14.10"] = stale.paths.length; + else if (stale.form === "unit") expected["14.10"] = 1; + + const checkContext = `${context} \`check --json\` on the failing staging`; + const findings = await assertLeavesUnchanged( + workspace.root, + () => checkFindings(product, workspace, checkContext), + `${context}: \`check\` reports and modifies nothing — it never ` + + `refreshes, repairs, or replaces what it reports (SPEC 13.3, 12.2)`, + ); + assertConditionCounts( + findings, + expected, + `${checkContext} — exactly the validation finding` + + (stale.form === "none" + ? ` and no condition 10` + : ` beside the expected condition-10 findings`) + + `, never a condition 12: the mismatch forms of 14.10 are undetectable ` + + `on a workspace failing build's validations, and no policy violation ` + + `is detectable there (SPEC 14.10, 14.12, 7.5)`, + ); + const validation = findings.find((finding) => finding.condition === "14.5")!; + assertFindingLocated( + validation, + { file: T12_2_4_L_PATH, window: T12_2_4_BROKEN_WINDOW }, + `${checkContext} — the unresolved \`d\` reference located within its ` + + `section's opening tag (SPEC 14.5, 14)`, + ); + const staleFindings = findings.filter( + (finding) => finding.condition === "14.10", + ); + if (stale.form === "recorded-file") { + assertStaleFindingsConcernExactly( + staleFindings, + stale.paths, + `${checkContext} — the recorded-file form is reported whatever the ` + + `sources' validity: it compares the record against the set of ` + + `generated paths, a set discovery and configuration define on any ` + + `workspace (SPEC 14.10, 13.1, 7.3, 11.6)`, + ); + } else if (stale.form === "unit") { + assertSingleUnitFormFinding( + staleFindings, + `${checkContext} — the unreadable-record unit form is reported ` + + `whatever the sources' validity, alone among the 14.10 forms: ` + + `never the mismatch form beside it (SPEC 14.10, 14.23)`, + ); + } + + const buildContext = `${context} \`build --json\` on the failing staging`; + await assertLeavesUnchanged( + workspace.root, + async () => { + assertConditionCounts( + await buildFindings(product, workspace, buildContext), + { "14.5": 1 }, + `${buildContext} — build reports the validation finding alone: ` + + `staleness and policy are check-only (SPEC 12.1, 14.10, 14.12)`, + ); + }, + `${context}: a build failing with validation errors modifies nothing — ` + + `every derived file and all graph data byte-identical (SPEC 12.1; ` + + `T12.1-4)`, + ); +} + +const T12_2_4 = defineProductTest({ + id: "T12.2-4", + title: + "14.10 and 14.12 confine themselves on a workspace failing `build`'s validations: from a freshly built valid workspace with a forbidden rule and an edge violating it (the premise `check` reporting exactly that violation), four stagings each given one validation error (an unresolved `d` reference in another file) and run through `check` — (a) a generated module hand-edited: the validation finding and no condition 10, the per-file mismatch form undetectable there; (b) recorded derived paths orphaned by dropping their source from the configuration's groups without a rebuild: the validation finding beside one condition-10 finding per orphaned path, concerning it — the dropped source's module, each companion `inventory`'s `recorded` set lists for it after the build (T11.6-3), and its Markdown where emitted (none: the fixture emits no Markdown) — and no other condition-10 finding, the recorded-file form comparing the record against the set of generated paths, a set defined on any workspace; (c) the record corrupted shape-blind: the unreadable-record unit form beside the validation finding, no mismatch form; (d) the violating edge alone beside the validation error: no condition 12, the violation reported once the error is repaired — exit 1 throughout, `check` modifying nothing, `build` on each failing staging reporting the validation finding alone and modifying nothing (SPEC 12.2, 14.10, 14.12, 7.5, 13.3, 12.1)", + run: async (product) => { + // (a) A generated module hand-edited — the module of a file other than + // the one taking the validation error. + await withWorkspace(T12_2_4_FILES, async (workspace) => { + const context = "T12.2-4 (a, generated module hand-edited)"; + await t1224Prepare(product, workspace, context); + const moduleRel = "hi/H.xspec.ts"; + const original = await readGeneratedModule( + workspace, + moduleRel, + `${context} after the initial build (SPEC 13.1)`, + ); + // An edit of product-written bytes, staged `unchecked` (S-9; as + // T12.2-2's staleness arm 1 stages its tampered module). + await workspace.file(moduleRel, `${original}// tampered\n`, { + ts: "unchecked", + }); + await t1224FailingStaging(product, workspace, { form: "none" }, context); + }); + + // (b) Recorded derived paths orphaned: the `extra` group dropped from + // the configuration without a rebuild, the paths the record holds for + // its source (read from `inventory` after the build) remaining at paths + // no longer generated. + await withWorkspace(T12_2_4_FILES, async (workspace) => { + const context = "T12.2-4 (b, recorded derived paths orphaned)"; + await t1224Prepare(product, workspace, context); + const orphaned = await t1224RecordedOrphans( + product, + workspace, + "extra/E.mdx", + `${context} — the dropped source's recorded derived paths`, + ); + await workspace.file("xspec.config.ts", T12_2_4_CONFIG_WITHOUT_EXTRA); + await t1224FailingStaging( + product, + workspace, + { form: "recorded-file", paths: orphaned }, + context, + ); + }); + + // (c) The record 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). + await withWorkspace(T12_2_4_FILES, async (workspace) => { + const context = "T12.2-4 (c, record corrupted shape-blind)"; + await t1224Prepare(product, workspace, context); + await corruptGraphDataShapeBlind(workspace.root, `${context} staging`); + await t1224FailingStaging(product, workspace, { form: "unit" }, context); + }); + + // (d) The violating edge alone beside the validation error, then the + // error repaired: the violation reported again (T7.5-2). + await withWorkspace(T12_2_4_FILES, async (workspace) => { + const context = "T12.2-4 (d, the violating edge alone)"; + await t1224Prepare(product, workspace, context); + await t1224FailingStaging(product, workspace, { form: "none" }, context); + await workspace.file(T12_2_4_L_PATH, T12_2_4_L_VALID); + const repaired = `${context} \`check --json\` once the validation error is repaired`; + assertOnlyTheViolation( + await assertLeavesUnchanged( + workspace.root, + () => checkFindings(product, workspace, repaired), + `${repaired}: \`check\` modifies nothing (SPEC 13.3)`, + ), + `${repaired} — the violation is reported once the workspace passes ` + + `build's validations again (SPEC 7.5, 14.12; T7.5-2)`, + ); + }); + }, +}); + export const section121to122Tests: readonly ProductTestEntry[] = [ T12_1_1, T12_1_3, @@ -1046,4 +2101,5 @@ export const section121to122Tests: readonly ProductTestEntry[] = [ T12_2_1, T12_2_2, T12_2_3, + T12_2_4, ]; diff --git a/test/suite/registry/section-12.3-12.5.ts b/test/suite/registry/section-12.3-12.5.ts index 3ae9f993..38888b90 100644 --- a/test/suite/registry/section-12.3-12.5.ts +++ b/test/suite/registry/section-12.3-12.5.ts @@ -10,8 +10,10 @@ // order of workspace-relative path, IDs within a file in document order — // with `--json` per 12.0. `--tree` renders each file's IDs as a tree // following section nesting, in the same file and document order. `--file -// <glob>` restricts to files the glob matches (the rules of 7; a pattern -// resolving outside the workspace root is an invalid flag value, exit 2). +// <glob>` restricts to files the glob matches (the rules of 7: a pattern +// outside the workspace root by its spelling alone — 7's depth rule — is an +// invalid flag value, exit 2, while an inside pattern spelled with a `.` or +// empty segment is admitted and matches nothing, an empty listing at exit 0). // `--unreferenced` restricts to requirement nodes with no incoming dependency // edges from specs or code (`contains` does not count); unreferenced is not // uncovered. When the listing is restricted and a listed node's parent is not @@ -21,9 +23,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 @@ -47,8 +49,9 @@ // asserted — the ordered content lives in the JSON assertions. // - T12.4-1's primary assertion is the adapter comparison: `show --json` and // `query node --json` for the same node are both decoded by the one node -// adapter and compared field by field (orders SPEC fixes nothing about — -// tag order, edge-list order — normalized first). Symmetric omission is +// adapter and compared field by field (the edge-list order, which SPEC +// fixes nothing about, normalized first; tags are compared literally, the +// decoder enforcing 12.7's tag-set form). Symmetric omission is // caught by pinning the discriminating fields on the `show` side directly: // tags, `coverage="none"`, and the root arm's absent coverage attribute. // The human form is asserted for distinctive information presence, @@ -61,7 +64,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,27 +83,41 @@ 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { + BESIDE_ROOT_FILE_PATTERN_DECOY, + INSIDE_NO_MATCH_FILE_PATTERNS, + OUTSIDE_ROOT_FILE_PATTERNS, assertSameJson, buildOk, + expectErrorDocument, expectExit, + expectFilePatternUsageError, + insideNoMatchFilePatterns, runJson, sortedIdentities, + stageBesideRoot, } from "./support.js"; // --------------------------------------------------------------------------- @@ -112,8 +135,14 @@ export default defineConfig({ `; // One spec group plus one code group (SPEC 7.2), so code-side `references` -// edges (4.5) enter the graph. -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// edges (4.5) enter the graph. T12.3-1's restricted-tree workspace follows +// the ordering workspace's invocations, so S-7's sweep never reaches it +// against the stub: a TypeScript staged-source record (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), staged at every site — T12.4-1's +// first workspace too. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T12.3-1 xspec.config.ts — one spec group and one code group (the restricted-tree workspace; T12.4-1's workspace too)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -123,12 +152,13 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: InitialFileContents, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -142,9 +172,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 +191,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)`, ); } @@ -282,10 +313,6 @@ async function expectHumanListing( ); } -function sortedTags(tags: readonly string[]): string[] { - return [...tags].sort(); -} - function edgeSortKey(edge: GraphEdge): string { return `${edge.kind}\u0000${edge.from}\u0000${edge.to}`; } @@ -307,7 +334,7 @@ function normalizedNodeReport(report: NodeReport): unknown { ownText: report.ownText, subtreeText: report.subtreeText, hashes: report.hashes, - tags: sortedTags(report.tags), + tags: report.tags, coverage: report.coverage, incomingEdges: sortedEdges(report.incomingEdges), outgoingEdges: sortedEdges(report.outgoingEdges), @@ -380,47 +407,58 @@ const T12_3_1_TREE: readonly TreeEntry[] = [ // Restricted-tree workspace: `grand.par` and `solo` are referenced from code // (4.5 markers), so `--unreferenced` lists `grand`, `grand.par.leaf`, and // `solo.kid` — a listed node under an unlisted parent with a listed -// grandparent, and one with no listed ancestor at all. -const T12_3_1_T = [ - '<S id="grand">', - "Grand line.", - "", - '<S id="grand.par">', - "Par line.", - "", - '<S id="grand.par.leaf">', - "Leaf line.", - "</S>", - "</S>", - "</S>", - "", - '<S id="solo">', - "Solo line.", - "", - '<S id="solo.kid">', - "Kid line.", - "</S>", - "</S>", - "", -].join("\n"); - -const T12_3_1_APP = [ - 'import SPEC from "../specs/T.xspec";', - "", - "export function touchPar(): void {", - " SPEC.grand.par;", - "}", - "", - "export function touchSolo(): void {", - " SPEC.solo;", - "}", - "", -].join("\n"); +// grandparent, and one with no listed ancestor at all. The workspace +// follows the ordering workspace's invocations, so S-7's sweep never +// reaches it against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause). +const T12_3_1_T = stagedMdx( + "T12.3-1 restricted-tree workspace specs/T.mdx (grand holding grand.par holding grand.par.leaf; solo holding solo.kid)", + [ + '<S id="grand">', + "Grand line.", + "", + '<S id="grand.par">', + "Par line.", + "", + '<S id="grand.par.leaf">', + "Leaf line.", + "</S>", + "</S>", + "</S>", + "", + '<S id="solo">', + "Solo line.", + "", + '<S id="solo.kid">', + "Kid line.", + "</S>", + "</S>", + "", + ].join("\n"), +); + +// The restricted-tree workspace's code markers, staged after the ordering +// workspace's invocations: a TypeScript staged-source record (S-9). +const T12_3_1_APP = stagedTs( + "T12.3-1 restricted-tree workspace src/app.ts (code markers referencing grand.par and solo)", + [ + 'import SPEC from "../specs/T.xspec";', + "", + "export function touchPar(): void {", + " SPEC.grand.par;", + "}", + "", + "export function touchSolo(): void {", + " SPEC.solo;", + "}", + "", + ].join("\n"), +); const T12_3_1 = defineProductTest({ id: "T12.3-1", title: - "`ids` groups requirement IDs by file — files in byte order of workspace-relative path (differing from configuration order), IDs within a file in document order (differing from their byte order); `--tree` renders per-file nesting in the same orders; `--file <glob>` restricts by the rules of SPEC 7 with an outside-root pattern an invalid flag value (exit 2); a restricted `--tree` (`--unreferenced`) nests a listed node under its nearest listed ancestor or at its file's top level, containing exactly the listed IDs; the human form carries the same information as `--json` (SPEC 12.3, 7, 12.0)", + "`ids` groups requirement IDs by file — files in byte order of workspace-relative path (differing from configuration order), IDs within a file in document order (differing from their byte order); `--tree` renders per-file nesting in the same orders; `--file <glob>` restricts by the rules of SPEC 7 — an outside-root pattern by spelling alone (`../x/*.mdx`, `../x`, `a/../../x`, `/specs/*.mdx`) is an invalid flag value, exit 2 with the plain usage error's document, code and path null, a matching file beside the root notwithstanding, while an inside pattern spelled with a `.` or empty segment (`./specs/*.mdx`, `specs//*.mdx`, and their twins over `apecs`) is admitted and matches nothing, an empty listing at exit 0; a restricted `--tree` (`--unreferenced`) nests a listed node under its nearest listed ancestor or at its file's top level, containing exactly the listed IDs; the human form carries the same information as `--json` (SPEC 12.3, 7, 12.0)", run: async (product) => { await withWorkspace( T12_3_1_ORDERING_CONFIG, @@ -507,16 +545,54 @@ const T12_3_1 = defineProductTest({ "crosses `/`, SPEC 7)", ); - // A `--file` pattern resolving outside the workspace root is an - // invalid flag value — exit 2, like its configuration-time - // counterpart (SPEC 12.3, 7, 14.14, 12.0). - await expectUsageError( - product, - workspace, - ["ids", "--file", "../*.mdx"], - "a `--file` pattern resolving outside the workspace root", - "T12.3-1 `ids --file ../*.mdx`", - ); + // A `--file` pattern outside the workspace root by its spelling + // alone — decided as 7 decides a configured glob (T7-4) — is an + // invalid flag value: exit 2 with the plain usage error's document, + // like its configuration-time counterpart (SPEC 12.3, 7, 14.14, + // 12.0), the file the ascending spellings name when resolved staged + // beside the root so exit 2 never comes from a side reason. + await stageBesideRoot(workspace, BESIDE_ROOT_FILE_PATTERN_DECOY); + for (const { spelling, why } of OUTSIDE_ROOT_FILE_PATTERNS) { + await expectFilePatternUsageError( + product, + workspace, + ["ids", "--file", spelling, "--json"], + `T12.3-1 \`ids --file ${JSON.stringify(spelling)} --json\` (${why})`, + ); + } + + // An inside pattern spelled with a `.` or an empty segment is + // admitted and matches nothing — a discovered path carries no such + // segment (SPEC 7, 12.0) — so the restricted listing holds no file + // at all: `files` exactly [] (the module header's ID-less-entry + // caveat concerns matched files; here none is matched). TEST-SPEC's + // pinned `specs` spellings run beside their twins over `apecs`, + // whose normalized form (`apecs/*.mdx`) matches M.mdx — the arm a + // normalizing product fails. + for (const { spelling, why } of [ + ...INSIDE_NO_MATCH_FILE_PATTERNS, + ...insideNoMatchFilePatterns("apecs"), + ]) { + const context = `T12.3-1 \`ids --file ${JSON.stringify(spelling)} --json\` (${why})`; + const report = decodeIdsReport( + await runJson( + product, + workspace, + ["ids", "--file", spelling, "--json"], + `${context} — an inside pattern matching nothing is admitted: ` + + `an empty listing, exit 0 (SPEC 12.3, 7)`, + ), + context, + ); + assertSameJson( + report.files, + [], + `${context}: the listing restricted to the files the pattern ` + + `matches holds no file — a discovered path carries no \`.\` ` + + `or empty segment (SPEC 12.3, 7, 12.0); an empty list is [], ` + + `never null (12.7)`, + ); + } }, ); @@ -841,7 +917,7 @@ const T12_4_1 = defineProductTest({ // the adapter compare alone would accept a symmetric omission. const star = await decodeBoth(T12_4_1_STAR, "path#id arm"); assertSameJson( - sortedTags(star.show.tags), + star.show.tags, ["amber", "vital"], `T12.4-1: \`show ${T12_4_1_STAR}\` reports the staged tags — a ` + `show omitting tags fails (SPEC 12.4, 2.6)`, @@ -907,7 +983,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 +1000,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 +1094,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 00000000..7489f632 --- /dev/null +++ b/test/suite/registry/section-12.6.ts @@ -0,0 +1,442 @@ +// 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 { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import { SECTION_A_SOURCE } from "./section-7-basics.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. T12.6-2 stages it in a workspace created after +// its first product invocation (context 3), so S-7's sweep never reaches it +// against the stub: a TypeScript staged-source record (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), well-formed. +const INVALID_CONFIG = stagedTs( + "T12.6-2 xspec.config.ts — an unknown top-level key (the invalid configuration, context 3)", + `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). T12.6-2 +// stages it in a workspace created after its first product invocation: a +// TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), declared unparseable (14.20) — the record carries the +// declaration. +const MALFORMED_CONFIG_TARGET = stagedTs( + "T12.6-2 malformed-config.ts — not well-formed TypeScript (the malformed --config target)", + "this is ( not TypeScript {{{\n", + "unparseable", +); + +// A minimal single-section source: one node `a` under the file root. T12.6-2 +// stages it again after its first product invocation (context 3), so S-7's +// sweep never reaches that workspace against the stub; its bytes are +// section-7-basics.ts's minimal section a — that staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause), reused by import +// rather than spelled again, every site here staging it. +const VALID_SOURCE = SECTION_A_SOURCE; + +/** 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( + { + // S-9: the malformed configuration is not well-formed TypeScript + // (14.20); its record carries the `unparseable` declaration. + 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 00000000..34c06d9f --- /dev/null +++ b/test/suite/registry/section-12.7.ts @@ -0,0 +1,3431 @@ +// 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 walk equally runs at +// every adjustable adapter's document entry (`documentRootSite`, +// forms.ts), the exclusivity clause being universal like the value forms: +// arm F's captured unpinned-shape documents pass through it too. +// - Arm F asserts 12.7's range form where SPEC leaves the document shape +// unpinned (H-3): the adjustable adapters' range decode is the literal form +// decode itself (`decodeSourceRange` delegates to `decodeRangeForm` — +// exactly {"start", "end"}, non-negative integers; S-5 feeds it +// `[start, end]`, `{"from", "to"}`, and an extra member), so a decoded +// range IS a form-exact one, never re-mapped, and each is then asserted +// byte-exact against the staged construct (SPEC 1.7, 4.6) — every present +// node of the review payload included, since 10.7 gives every present +// scope, context, and origin node its source range. +// - 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, T4.4-1's former +// operationalization (it now pins the literal root identity): 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 two +// multi-reason refusals (T14-7) whose listed order inverts the +// token-alphabetical order — the section move's pair (`refused-cycle` < +// `refused-id-collision` alphabetically, yet collision ranks 3rd and cycle +// 5th in 14's listing) and T6.5-21's two-reason file move +// (`refused-exposed-derived-file` < `refused-invalid-destination` +// alphabetically, yet the invalid destination ranks 8th and the exposure +// 9th, between it and `refused-invalid-rewrite`) — 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. +// - The second multi-reason refusal is the one the entry names: T6.5-21's +// two-reason file move, staged identically from section-6.5-iv.ts's +// exported table through that module's own staging code +// (`runD21RefusedStaging` over `D21_A_STAGING` with `D21_TWO_REASON_MOVE` +// alone — (a)'s premise `build` re-pinning `specs/A.md` the plain file it +// wrote): `move specs/A.mdx specs/a'b.mdx` reports +// `refused-invalid-destination` (the barred `'`, concerning the +// destination as spelled), then `refused-exposed-derived-file` (the +// vacated emit destination `specs/A.md`, which the second spec glob +// `specs/*.md` would discover). The code and concerned-path sequence is +// pinned exactly (order, count, and completeness); the move's full +// contract — the modifies-nothing compare, `locations` `[]`, the +// exposure's `identities` `[]` — is T6.5-21's own. The arm runs last in +// the body, so a product predating the reason (performing the move) still +// meets every other arm first. +// - 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. +// - The clean-workspace pin — a successful `build --json` and `check --json` +// each emitting exactly `{"findings": []}` as the entire stdout — is +// asserted on the document-forms workspace right after its premise +// `build`, through `expectFindingFreeReport` (support.ts): exit 0, the +// single JSON document the entire stdout (H-5), decoded form-exact as the +// findings-only report (the one member `findings`, nothing beside it) and +// its array asserted empty. "Exactly" is the form: SPEC 12.0/12.7 pin no +// byte layout for the serialization (12.0's byte-determinism is a +// per-input property, not a byte form), so the bytes are not compared +// (H-3) — the pin exercised on the report form itself, beside the +// JSON-only surfaces (TEST-SPEC T12.7-2; T12.1-1 and T12.2-1 keep their +// plain exit assertions). +// +// 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 in TEST-SPEC's own staging +// (T11.6-1's form) — from the sibling working directory `work/`, +// `--config ../cfg/xspec.config.ts` naming no file and, separately, a +// file that is not well-formed TypeScript, each reporting +// `../cfg/xspec.config.ts` (SPEC 14: "the path `--config` names — it is +// that file"; with `--config` given, a missing file is missing +// configuration, 14.14, never a plain usage error — reported never as `.` +// and never as `../xspec.config.ts`, the invalid root file the upward +// search from `work/` would find, failing a product that falls back to +// the search when the named file is absent), each failing `build` +// snapshot-compared over the whole root (SPEC 12.1: a build failing at +// configuration load modifies nothing); from the root, an argument +// spelled with a leading `./` segment reporting the canonical +// `cfg/broken.config.ts` (11.6: no `.` segments — failing a +// verbatim-echoing product); 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, + NodeRow, + OccurrenceRecord, + PathValue, + ReviewItem, + SourceRange, + ViewNode, + ViewReport, +} from "../../helpers/adapters/index.js"; +import { + assertUnavailabilityMarkerForms, + decodeAtReport, + decodeExportReport, + decodeFindingsReport, + decodeInventoryResolvedMap, + decodeNodeReport, + decodeNodeRowsReport, + decodeOccurrencesReport, + decodePerformedOperationReport, + decodeVersionDocument, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { + stageReadRefusalOfDirectory, + stageWriteRefusalUnder, +} from "../../helpers/permissions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.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 { + D21_A_STAGING, + D21_TWO_REASON_MOVE, + runD21RefusedStaging, +} from "./section-6.5-iv.js"; +import { + assertConditionCounts, + assertFindingMentionsLocation, + assertSameJson, + buildFindings, + buildOk, + expectErrorDocument, + expectExit, + expectFindingFreeReport, + 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"; + +/** + * Whether T12.7-3's Linux-leg arms run (the NU3_STAGED pattern of + * section-11.5): a working directory that is a symbolic link (11.6's + * physical resolution) and the environment refusals of 14.24/14.25, staged by + * permission removal alone through `test/helpers/permissions.ts` (E-1 — + * each staging verifies itself on the harness's own process and reports an + * ineffective one, a privileged runner, as a harness error, never a pass or + * a skip; H-9, H-11). On any other platform the `--config` arms alone run. + */ +const LINUX_LEG_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. The +// arms after each body's first stage it in workspaces created after that +// body's first product invocation — T12.7-1's review-refusal and byte-form +// paths arms, T12.7-2's refusal-ordering and document-forms arms, T12.7-3's +// usage-error and environment-refusal arms — so S-7's sweep never reaches +// those stagings against the stub: a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), staged at +// every site, the first arms' too. +const SPECS_ONLY_CONFIG = stagedTs( + "T12.7-1/T12.7-2/T12.7-3 xspec.config.ts — one spec group (every arm staging it after its body's first invocation)", + `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) +// --------------------------------------------------------------------------- + +// The arm follows T12.7-1's first product invocation, so its configuration +// is a TypeScript staged-source record (helpers/staged-ts.ts; S-9's +// TypeScript and timing clauses). +const POLICY_CONFIG = stagedTs( + "T12.7-1 policy-finding arm xspec.config.ts — one spec group under the forbidden rule no-self-deps", + `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. The arm follows T12.7-1's first +// product invocation (the located-findings arm's `build`), and T12.7-2's +// identities-ordering arm stages the same bytes after its own first, so +// S-7's sweep reaches neither against the stub: ONE staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause) for both. +const POLICY_SOURCE = stagedMdx( + "T12.7-1/T12.7-2 specs/P.mdx (p depending locally on a: T12.7-1's policy-finding arm; T12.7-2's identities-ordering arm)", + `<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 (T4.4-1's former 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"; + +// The arm follows T12.7-1's first product invocation, so its configuration +// and code source are TypeScript staged-source records +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), the code +// source's inline expression hoisted into a module-level record below. +const CROSS_CONFIG = stagedTs( + "T12.7-1 cross-module arm xspec.config.ts — one spec group and one code group (src/**/*.ts)", + `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);"; +const T12_7_1_CROSS_APP = stagedTs( + "T12.7-1 cross-module arm src/app.ts (HOMEMOD's node passed to FOREIGNMOD's text export)", + CROSS_IMPORT_PREFIX + CROSS_STATEMENT + "\n", +); + +// The arm follows T12.7-1's first product invocation, so S-7's sweep never +// reaches its workspace against the stub: its two spec sources are +// staged-source records (helpers/staged-mdx.ts; S-9's before-any-product +// clause), the literals moved into them. +const T12_7_1_HOMEMOD = stagedMdx( + "T12.7-1 cross-module arm specs/HOMEMOD.mdx (the calling code's own module)", + '<S id="first">\nHome behavior.\n</S>\n', +); +const T12_7_1_FOREIGNMOD = stagedMdx( + "T12.7-1 cross-module arm specs/FOREIGNMOD.mdx (the foreign module whose text export is called)", + '<S id="second">\nForeign behavior.\n</S>\n', +); + +async function runCrossModuleArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": CROSS_CONFIG, + "specs/HOMEMOD.mdx": T12_7_1_HOMEMOD, + "specs/FOREIGNMOD.mdx": T12_7_1_FOREIGNMOD, + "src/app.ts": T12_7_1_CROSS_APP, + }, + }, + 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 +// --------------------------------------------------------------------------- + +// The arm follows T12.7-1's first product invocation: its source is a +// staged-source record (S-9), the literal moved into it. +const T12_7_1_REVIEWED = stagedMdx( + "T12.7-1 review-refusal arm specs/R.mdx (the one reviewed leaf)", + '<S id="r">\nReviewed leaf.\n</S>\n', +); + +async function runReviewRefusalArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/R.mdx": T12_7_1_REVIEWED, + }, + }, + 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"; +// The condition-free `ok` source: an initial file of this arm's workspace, +// created after T12.7-1's first product invocation, and — byte-identical — +// of T12.7-2's condition-ordering workspace: ONE staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause). +const OK_SOURCE = stagedMdx( + "T12.7-1/T12.7-2 specs/OK.mdx (the condition-free ok section: T12.7-1's byte-form paths arm; T12.7-2's condition-ordering workspace)", + '<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 byte-form paths arm stages both files after T12.7-1's first product +// invocation (the located-findings arm's `build`), so S-7's sweep never +// reaches them against the stub: staged-source records +// (helpers/staged-mdx.ts; S-9's before-any-product clause) over the same +// sources. +const T12_7_1_IN = stagedMdx( + "T12.7-1 specs/d<0xFF>/In.mdx (the byte-form paths arm's importer, embedding OK.ok and holding an id-less section)", + IN_SOURCE, +); +const T12_7_1_TGT = stagedMdx( + "T12.7-1 specs/d<0xFF>/Tgt.mdx (the byte-form paths arm's import target)", + TGT_SOURCE, +); + +// 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, T12_7_1_IN); + await workspace.file(TGT_PATH_BYTES, T12_7_1_TGT); + + // --- `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(); + } +} + +// --------------------------------------------------------------------------- +// Arm F — the unpinned surfaces' ranges (11.1, 12.4, 10.7) through the H-3 +// decode: exactly {"start", "end"}, byte-exact against the staged constructs +// --------------------------------------------------------------------------- +// +// 12.7's value forms bind every JSON output (H-3), pinned document form or +// not: on the shape-unpinned surfaces — `query node`, the `query nodes`/ +// `subtree`/`ancestors` rows (11.1), `show --json` (12.4), and a review +// payload's node states (10.7) — a range reaches the assertion through the +// adjustable adapters' decode, whose range decode is the literal 12.7 form +// (`decodeSourceRange` = `decodeRangeForm`: exactly the two members, +// non-negative integers; a range carried as `[start, end]`, `{"from", "to"}`, +// or with an extra member fails there and is never re-mapped — S-5). This +// arm drives every listed surface over one fixture whose construct byte +// offsets are composed from the same parts that stage the files (the arm-A +// discipline), so each decoded range is additionally asserted byte-exact +// (SPEC 1.7: a non-root requirement node's range spans its section +// construct, a root's the entire file, a named code unit's the construct +// binding its name, 4.6). +// +// The review half stages SPEC 10.5's smallest change under a code +// reference: `top > top.leaf`, only the leaf's text edited between the +// baseline commit and `review create --base`, and `src/ref.ts#unit` +// referencing the leaf — a subtree-coherence item scoped at the leaf (its +// context the ancestor chain: the file root and `top`), a parent-consistency +// item at `top` (context: the leaf), and a code-impact item at the unit +// (context: the leaf; SPEC 10.5, 9.2), the leaf every item's origin — so the +// payload carries a present requirement-node scope, a present code-location +// scope, and present context and origin nodes, every one of which enters +// with its source range (SPEC 10.7, 1.7). Every range the payload carries is +// asserted: a present payload node without a range, or with a range other +// than its construct's, fails. +// +// The arm follows T12.7-1's first product invocation, so its configuration +// and code source are TypeScript staged-source records +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses). + +const UR_CONFIG = stagedTs( + "T12.7-1 unpinned-surface ranges arm xspec.config.ts — one spec group and one code group (src/**/*.ts)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); + +const UR_FILE = "specs/A.mdx"; +const UR_ROOT_ID = UR_FILE; +const UR_TOP_ID = `${UR_FILE}#top`; +const UR_LEAF_ID = `${UR_FILE}#top.leaf`; + +// The current (post-edit) spec source, composed so the top section's range +// spans its opening tag through its closing tag with the leaf nested inside. +const UR = new ByteFixture(); +UR.add("Überschrift — multi-byte prefix.\n\n"); +const UR_TOP_OPEN = '<S id="top">\nTop own text.\n\n'; +const UR_LEAF_TEXT = '<S id="top.leaf">\nLeaf text, edited.\n</S>'; +const UR_TOP_CLOSE = "\n</S>"; +const UR_TOP_TEXT = `${UR_TOP_OPEN}${UR_LEAF_TEXT}${UR_TOP_CLOSE}`; +const UR_TOP_START = UR.add(UR_TOP_OPEN).start; +const UR_LEAF_RANGE = UR.add(UR_LEAF_TEXT); +const UR_TOP_RANGE: SourceRange = { + start: UR_TOP_START, + end: UR.add(UR_TOP_CLOSE).end, +}; +UR.add("\n"); +const UR_SOURCE = UR.source; +// A root node's range is the entire file (SPEC 1.7). +const UR_ROOT_RANGE: SourceRange = { + start: 0, + end: Buffer.byteLength(UR_SOURCE, "utf8"), +}; +// The baseline: the same layout, the leaf's text alone differing (SPEC 5.6: +// the leaf is `changed`, `top` and the root descendant-changed). +const UR_BASELINE_SOURCE = UR_SOURCE.replace( + "Leaf text, edited.", + "Leaf text.", +); +// The current source is staged over the committed baseline after T12.7-1's +// first product invocation (the located-findings arm's `build`), so S-7's +// sweep never reaches it against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause) over the same +// source. +const T12_7_1_UR_EDITED = stagedMdx( + "T12.7-1 specs/A.mdx with top.leaf's text edited (the unpinned-surface ranges arm's current source over the baseline)", + UR_SOURCE, +); +// The baseline is the arm's initial specs/A.mdx, in a workspace created +// after T12.7-1's first product invocation: a staged-source record too, +// made from the string the fixture self-check compares. +const T12_7_1_UR_BASELINE = stagedMdx( + "T12.7-1 specs/A.mdx at the baseline (the unpinned-surface ranges arm's initial source, committed before the leaf edit)", + UR_BASELINE_SOURCE, +); + +const UR_CODE_FILE = "src/ref.ts"; +const UR_UNIT_ID = `${UR_CODE_FILE}#unit`; +const UR_CODE = new ByteFixture(); +UR_CODE.add( + 'import A from "../specs/A.xspec";\n\n// Präzise Bytes vor der Einheit (multi-byte prefix).\n\n', +); +// The named unit's range is the construct binding its name — the function +// declaration's own bytes, keyword through closing brace (SPEC 1.7, 4.6). +const UR_UNIT_TEXT = "function unit() {\n A.top.leaf;\n}"; +const UR_UNIT_RANGE = UR_CODE.add(UR_UNIT_TEXT); +UR_CODE.add("\n"); +const UR_CODE_SOURCE = UR_CODE.source; +// The code source staged as the arm's initial src/ref.ts: a TypeScript +// staged-source record made from the string the fixture self-check slices. +const T12_7_1_UR_CODE = stagedTs( + "T12.7-1 unpinned-surface ranges arm src/ref.ts (the named unit referencing top.leaf)", + UR_CODE_SOURCE, +); + +/** Every node this fixture stages, with its construct's byte range. */ +const UR_RANGES: ReadonlyMap<string, SourceRange> = new Map([ + [UR_ROOT_ID, UR_ROOT_RANGE], + [UR_TOP_ID, UR_TOP_RANGE], + [UR_LEAF_ID, UR_LEAF_RANGE], + [UR_UNIT_ID, UR_UNIT_RANGE], +]); + +/** identity → range over the given identities, keys in byte order. */ +function urExpectedRanges(ids: readonly string[]): Record<string, SourceRange> { + const out: Record<string, SourceRange> = {}; + for (const id of [...ids].sort()) { + const range = UR_RANGES.get(id); + if (range === undefined) { + throw new Error( + `section-12.7 fixture self-check: no staged range for ${id}`, + ); + } + out[id] = range; + } + return out; +} + +/** + * Rows projected to identity → range, keys in byte order (T11-2/3 pin the + * row order; this arm asserts membership and each row's range). + */ +function urRowRanges( + rows: readonly NodeRow[], + context: string, +): Record<string, SourceRange> { + const out: Record<string, SourceRange> = {}; + const sorted = [...rows].sort((a, b) => + a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0, + ); + for (const row of sorted) { + if (Object.hasOwn(out, row.identity)) { + fail( + `${context}: each node is reported once; ${row.identity} appears ` + + `more than once among the rows (SPEC 11.1)`, + ); + } + out[row.identity] = row.sourceRange; + } + return out; +} + +/** The unique item of a kind and scope node (SPEC 10.1, 10.5). */ +function urRequireItem( + items: readonly ReviewItem[], + kind: ReviewItem["kind"], + scope: string, + context: string, +): ReviewItem { + const matches = items.filter( + (item) => item.kind === kind && item.scope.node === scope, + ); + if (matches.length !== 1) { + fail( + `${context}: expected exactly one ${kind} item scoped at ${scope} ` + + `(SPEC 10.5: the leaf edit yields the leaf's subtree-coherence item, ` + + `top's parent-consistency item, and the referencing unit's ` + + `code-impact item); found ${String(matches.length)} among ` + + JSON.stringify(items.map((item) => `${item.kind} ${item.scope.node}`)), + ); + } + return matches[0]!; +} + +function urRequireContext( + item: ReviewItem, + node: string, + context: string, +): void { + if (!item.context.some((state) => state.node === node)) { + fail( + `${context}: the item's context must carry ${node} (SPEC 10.5) — the ` + + `present context node whose range this arm asserts; got ` + + JSON.stringify(item.context.map((state) => state.node)), + ); + } +} + +/** + * Every present node of a payload — scope, context, and origin (an origin + * entry's presence is its after side's, SPEC 10.7) — enters with its + * construct's range, decoded as exactly {"start", "end"} (SPEC 10.7, 1.7, + * 12.7); an absent node carries none (the decode forbids one there). + */ +function urAssertPayloadRanges(item: ReviewItem, context: string): void { + const states = [ + { + what: "scope", + node: item.scope.node, + present: item.scope.present, + range: item.scope.sourceRange, + }, + ...item.context.map((state, index) => ({ + what: `context[${String(index)}]`, + node: state.node, + present: state.present, + range: state.sourceRange, + })), + ...item.origin.map((entry, index) => ({ + what: `origin[${String(index)}]`, + node: entry.node, + present: entry.after.present, + range: entry.sourceRange, + })), + ]; + for (const state of states) { + if (!state.present) continue; + const expected = UR_RANGES.get(state.node); + if (expected === undefined) { + fail( + `${context}: ${state.what} presents ${state.node}, a node this ` + + `fixture never staged`, + ); + } + if (state.range === undefined) { + fail( + `${context}: ${state.what} (${state.node}) is present but carries ` + + `no source range — every present scope, context, and origin node, ` + + `requirement node and code location alike, enters the payload ` + + `with its source range (SPEC 10.7, 1.7)`, + ); + } + assertSameJson( + state.range, + expected, + `${context}: ${state.what} (${state.node})'s range — decoded as ` + + `exactly {"start", "end"} (12.7's universal value form, H-3) and ` + + `byte-exact: the construct's own bytes (SPEC 1.7, 4.6, 10.7)`, + ); + } +} + +async function runUnpinnedRangesArm(product: ProductBinding): Promise<void> { + sliceCheck(UR_SOURCE, UR_LEAF_RANGE, UR_LEAF_TEXT, "the leaf construct"); + sliceCheck(UR_SOURCE, UR_TOP_RANGE, UR_TOP_TEXT, "the top construct"); + sliceCheck(UR_CODE_SOURCE, UR_UNIT_RANGE, UR_UNIT_TEXT, "the named unit"); + if (UR_BASELINE_SOURCE === UR_SOURCE) { + throw new Error( + "section-12.7 fixture self-check: the baseline must differ from the " + + "current source in the leaf's text", + ); + } + + await withWorkspace( + { + files: { + "xspec.config.ts": UR_CONFIG, + [UR_FILE]: T12_7_1_UR_BASELINE, + [UR_CODE_FILE]: T12_7_1_UR_CODE, + }, + }, + async (workspace) => { + const prefix = "T12.7-1 (unpinned-surface ranges)"; + await workspace.gitInit(); + const base = await workspace.gitCommitAll("baseline"); + await workspace.file(UR_FILE, T12_7_1_UR_EDITED); + await buildOk( + product, + workspace, + `${prefix} \`build\` after the leaf edit`, + ); + + // `query node` (11.1) and `show --json` (12.4): the node's own range. + for (const argv of [ + ["query", "node", UR_LEAF_ID, "--json"], + ["show", UR_LEAF_ID, "--json"], + ]) { + const context = `${prefix} \`${argv.join(" ")}\``; + const report = decodeNodeReport( + await runJson(product, workspace, argv, context), + context, + ); + if (report.identity !== UR_LEAF_ID) { + fail( + `${context}: the report must present the queried node ` + + `${UR_LEAF_ID}; got ${JSON.stringify(report.identity)}`, + ); + } + assertSameJson( + report.sourceRange, + UR_LEAF_RANGE, + `${context} — the node's source range decodes as exactly ` + + `{"start", "end"} (12.7's universal value form, through the H-3 ` + + `decode, never re-mapped) and is byte-exact: the section ` + + `construct from its opening tag through its closing tag ` + + `(SPEC 1.7, 11.1, 12.4)`, + ); + } + + // The row surfaces (11.1): every row's range, membership per T11-2/3. + const rowArms: readonly { + readonly argv: readonly string[]; + readonly ids: readonly string[]; + readonly what: string; + }[] = [ + { + argv: ["query", "nodes", "--json"], + ids: [UR_ROOT_ID, UR_TOP_ID, UR_LEAF_ID], + what: "every node of the workspace", + }, + { + argv: ["query", "subtree", UR_TOP_ID, "--json"], + ids: [UR_TOP_ID, UR_LEAF_ID], + what: "top and its descendant", + }, + { + argv: ["query", "ancestors", UR_LEAF_ID, "--json"], + ids: [UR_TOP_ID, UR_ROOT_ID], + what: "the leaf's ancestors, top and the file root", + }, + ]; + for (const arm of rowArms) { + const context = `${prefix} \`${arm.argv.join(" ")}\``; + const rows = decodeNodeRowsReport( + await runJson(product, workspace, arm.argv, context), + context, + ); + assertSameJson( + urRowRanges(rows, context), + urExpectedRanges(arm.ids), + `${context} — ${arm.what}, each row's range decoded as exactly ` + + `{"start", "end"} (12.7, H-3) and byte-exact: a non-root ` + + `node's section construct, the root's entire file (SPEC 1.7, 11.1)`, + ); + } + + // The review payload (10.7): a present requirement-node scope, a + // present code-location scope, and present context and origin nodes. + await expectExit( + product, + workspace, + ["review", "create", "--base", base, "--name", "s"], + 0, + `${prefix} \`review create --base <baseline> --name s\` (SPEC 10.7)`, + ); + const exportContext = `${prefix} \`review export s --json\``; + const exported = decodeExportReport( + await runJson( + product, + workspace, + ["review", "export", "s", "--json"], + exportContext, + ), + exportContext, + ); + const leafItem = urRequireItem( + exported.items, + "subtree-coherence", + UR_LEAF_ID, + exportContext, + ); + const topItem = urRequireItem( + exported.items, + "parent-consistency", + UR_TOP_ID, + exportContext, + ); + const unitItem = urRequireItem( + exported.items, + "code-impact", + UR_UNIT_ID, + exportContext, + ); + // The present context nodes the range assertion then covers. + urRequireContext( + leafItem, + UR_ROOT_ID, + `${exportContext} subtree-coherence`, + ); + urRequireContext( + leafItem, + UR_TOP_ID, + `${exportContext} subtree-coherence`, + ); + urRequireContext( + topItem, + UR_LEAF_ID, + `${exportContext} parent-consistency`, + ); + urRequireContext(unitItem, UR_LEAF_ID, `${exportContext} code-impact`); + for (const item of exported.items) { + if (!item.origin.some((entry) => entry.node === UR_LEAF_ID)) { + fail( + `${exportContext}: the ${item.kind} item at ${item.scope.node} ` + + `must carry the changed leaf ${UR_LEAF_ID} among its origin ` + + `entries (SPEC 10.5) — the present origin node whose range ` + + `this arm asserts; got ` + + JSON.stringify(item.origin.map((entry) => entry.node)), + ); + } + urAssertPayloadRanges( + item, + `${exportContext} ${item.kind} item at ${item.scope.node}`, + ); + } + }, + ); +} + +// --------------------------------------------------------------------------- +// 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"; +// The byte-form paths arm's `ok` source (T12.7-1), byte for byte: that one +// record rather than a second spelling of its bytes. +const ORD_OK_SOURCE = OK_SOURCE; + +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 { + // This staging precedes T12.7-2's first product invocation (this arm's + // `build` below is the body's first), so S-7's sweep reaches it against + // the stub: plain contents, no ledger record (helpers/staged-mdx.ts). + 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 (5th) — +// 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; +// The arm follows T12.7-2's first product invocation (the +// condition-ordering arm's `build`): its source is a staged-source record +// (S-9) made from the string the slice checks read. +const T12_7_2_MR = stagedMdx( + "T12.7-2 refusal-ordering arm specs/MR.mdx (keep holding keep.sub, and mv depending on keep)", + 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]: T12_7_2_MR, + }, + }, + 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 (5th 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 B2 — T6.5-21's two-reason file move: refused-invalid-destination +// before refused-exposed-derived-file +// --------------------------------------------------------------------------- +// +// The multi-reason refusal the entry names (module header note): T6.5-21(a)'s +// staging, staged identically through section-6.5-iv.ts's own staging code, +// its two-reason move alone. 14 lists `refused-invalid-destination` 8th and +// `refused-exposed-derived-file` 9th — between it and +// `refused-invalid-rewrite` — the inverse of their alphabetical order, so a +// token-sorting product fails. The body runs this arm last. + +/** + * T12.7-2's pin of the two-reason move's report: the reasons in 14's listed + * order, each concerning its path (`move specs/A.mdx specs/a'b.mdx` in + * T6.5-21(a)'s staging — the destination as spelled, then the origin's emit + * destination `specs/A.md`). + */ +const TWO_REASON_FINDINGS = [ + { code: "refused-invalid-destination", path: "specs/a'b.mdx" }, + { code: "refused-exposed-derived-file", path: "specs/A.md" }, +] as const; + +async function runTwoReasonFileMoveArm(product: ProductBinding): Promise<void> { + await runD21RefusedStaging( + product, + { ...D21_A_STAGING, moves: [D21_TWO_REASON_MOVE] }, + `T12.7-2 (refusal ordering) T6.5-21's two-reason file move in ` + + `T6.5-21 ${D21_A_STAGING.key}`, + async (workspace, move, context) => { + const result = await expectExit( + product, + workspace, + [...move.argv, "--json"], + 1, + `${context} — the file move is refused for two reasons at once: the ` + + `destination holds the barred \`'\` (SPEC 7.1), and the relocation ` + + `leaves specs/A.md, holding the Markdown the premise build wrote, ` + + `no emit destination while the spec glob specs/*.md would discover ` + + `it (SPEC 13.4, 7) — 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, + })), + TWO_REASON_FINDINGS, + `${context} — the two-reason refusal report: one finding per ` + + `applicable reason and no reason beside them (SPEC 14), in 14's ` + + `LISTED order — refused-invalid-destination (8th listed), ` + + `concerning the destination as spelled, before ` + + `refused-exposed-derived-file (9th, listed between it and ` + + `refused-invalid-rewrite), concerning the origin's emit ` + + `destination — the inverse of their alphabetical order (SPEC 14, ` + + `12.7)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// 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. The arm +// follows T12.7-2's first product invocation, so its configuration is a +// TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses). + +const IDS_CONFIG = stagedTs( + "T12.7-2 identities-ordering arm xspec.config.ts — two forbidden rules rb and ra with identical selectors", + `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"; +// T12.7-1's policy-finding source, byte for byte: that one record. +const IDS_SOURCE = POLICY_SOURCE; + +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 successful `build --json` +// and a finding-free `check --json` on the clean, freshly built workspace +// (each exactly `{"findings": []}` as the entire stdout — the findings-only +// form with the empty array, its one member and nothing beside it; 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, +}; + +// The document-forms arm follows T12.7-2's first product invocation: its +// two spec sources are staged-source records (S-9) — F's made from the +// string the slice checks read, W's literal wrapped in place. +const T12_7_2_DF_F = stagedMdx( + "T12.7-2 document-forms arm specs/F.mdx (f tagged alpha beta with coverage none, importing W, and f.leaf embedding W.w)", + DF_F_SOURCE, +); +const DF_W_SOURCE = stagedMdx( + "T12.7-2 document-forms arm specs/W.mdx (the embedded w)", + '<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]: T12_7_2_DF_F, + [DF_W_FILE]: DF_W_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.7-2 (document forms) `build` — the staged workspace is valid", + ); + + // --- A successful `build --json` on the clean, freshly built + // workspace: exactly `{"findings": []}` as the entire stdout — the pin + // exercised on the report form itself (SPEC 12.1, 12.7). + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + "T12.7-2 (document forms) `build --json` on the clean, freshly " + + "built workspace (SPEC 12.1: a successful build; 12.7: the " + + "findings-only form with the empty array)", + ); + + // --- A finding-free `check --json` on the same freshly built + // workspace: exactly `{"findings": []}` as the entire stdout. + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + "T12.7-2 (document forms) `check --json` on the clean, freshly " + + "built workspace (SPEC 12.2: no finding; 12.7: the findings-only " + + "form with the empty array)", + ); + + // --- `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, + ); + + // --- A performed `rename --json` (last: it rewrites the workspace): + // exactly `{"findings", "mapping"}` — `findings` `[]`, a successful + // operation carrying none, and `mapping` in the preview's form, one + // `{"from", "to"}` per mapped identity ordered by `from` bytes (SPEC + // 6.4, 12.7; T6.4-1). Renaming `f` maps `f` and its descendant + // `f.leaf` by prefix replacement and nothing else — F references W's + // `w`, never the reverse — so the mapping is the renamed node then its + // descendant, `#f` a proper prefix of `#f.leaf` (SPEC 6.4). + const renameContext = + "T12.7-2 (document forms) `rename specs/F.mdx f g --json`"; + const performed = decodePerformedOperationReport( + await runJson( + product, + workspace, + ["rename", DF_F_FILE, "f", "g", "--json"], + `${renameContext} — the valid workspace's rename proceeds, ` + + `exit 0 (SPEC 6.4, 12.0)`, + ), + renameContext, + ); + assertSameJson( + performed, + { + findings: [], + mapping: [ + { from: `${DF_F_FILE}#f`, to: `${DF_F_FILE}#g` }, + { from: `${DF_F_FILE}#f.leaf`, to: `${DF_F_FILE}#g.leaf` }, + ], + }, + `${renameContext} — the performed-operation document: findings [] ` + + `and the applied mapping, the renamed node then its descendant in ` + + `\`from\`-byte order, no member beside the two (SPEC 6.4, 12.7)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// 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: the +// initial specs/A.mdx of the config-paths, search-failure, single-finding, +// usage, and environment-refusal arms' workspaces — all but the first +// created after the body's first product invocation, so S-7's sweep never +// reaches them against the stub: a staged-source record +// (helpers/staged-mdx.ts; S-9's before-any-product clause), staged at +// every site. +const ERR_SOURCE = stagedMdx( + "T12.7-3 specs/A.mdx (the minimal valid source: the config-paths, search-failure, single-finding, usage, and environment-refusal arms' workspaces)", + '<S id="a">\nAlpha.\n</S>\n', +); + +// The 14.24 arm's stale edit, staged after T12.7-3's first product invocation +// (the config-paths arm's), so S-7's sweep never reaches it against the stub: +// a staged-source record (helpers/staged-mdx.ts; S-9's before-any-product +// clause), the literal moved into the record. +const T12_7_3_A_EDITED = stagedMdx( + "T12.7-3 specs/A.mdx edited after the staging build (the stale workspace for the 14.24 write refusal)", + '<S id="a">\nAlpha, edited.\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. Staged by the single-finding arm, after T12.7-3's first product + * invocation: a TypeScript staged-source record (helpers/staged-ts.ts; S-9's + * TypeScript and timing clauses), well-formed. + */ +const ERR_MULTI_DEFECT_CONFIG = stagedTs( + "T12.7-3 xspec.config.ts — three independent 14.14 defects in one well-formed file (the single-finding arm)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + definitelyUnknownKey: true, + specs: { + main: ["specs/**/*.mdx"], + outside: ["../escapee/**/*.mdx"] + }, + markdown: { emit: true, definitelyUnknownField: false } +}) +`, +); + +/** + * Not well-formed TypeScript — the "malformed" `--config` target of + * T12.7-3's sibling-directory staging (SPEC 14 condition 14: "a + * configuration file that is not well-formed TypeScript"), one defect by + * the T7-2 attribution discipline: the file is not a program at all, so + * the refusal is attributable to nothing but its form. Staged after T12.7-3's + * first product invocation — by the sibling-directory arm's `file()` at + * `cfg/xspec.config.ts` and as the symlink-working-directory arm's + * `a/xspec.config.ts` — so S-7's sweep never reaches it against the stub: a + * TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript + * and timing clauses), declared unparseable (14.20); the record carries the + * declaration at both sites. + */ +const ERR_MALFORMED_CONFIG = stagedTs( + "T12.7-3 cfg/xspec.config.ts and a/xspec.config.ts — not well-formed TypeScript (the malformed configuration)", + "this is not TypeScript ((( and so not a configuration\n", + "unparseable", +); + +/** + * T12.7-3's sibling-directory staging (T11.6-1's form): the invocation + * working directory `work/` and the configuration directory `cfg/` are + * siblings under the workspace root, so `--config ../cfg/xspec.config.ts` + * names the file by one ascent segment then two descent segments, joined + * with `/` — already the canonical anchoring spelling of SPEC 11.6, which + * the error document must therefore report as spelled, never `.`. + */ +const ERR_SIBLING_CWD = "work"; +const ERR_SIBLING_CONFIG_FILE = "cfg/xspec.config.ts"; +const ERR_SIBLING_CONFIG_ARG = "../cfg/xspec.config.ts"; +// The same file named non-canonically — a `.` segment and an empty segment +// (the doubled `/`): the spelling a product must echo byte-for-byte while +// nothing occupies the path (SPEC 14), and must reduce to +// ERR_SIBLING_CONFIG_ARG (11.6's canonical anchoring spelling) once the +// malformed file exists. +const ERR_SIBLING_CONFIG_ARG_NONCANONICAL = "./../cfg//xspec.config.ts"; +// A path under cfg/ that nothing ever occupies, named absolutely. +const ERR_ABSENT_CONFIG_FILE = "cfg/absent.config.ts"; + +/** + * 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)`, + ); +} + +/** + * Assert an environment refusal's error document (SPEC 14.24, 14.25, 12.0, + * 12.7; T14-9 and T14-10 pin the per-command contracts and states): exit 2 + * exactly — a usage error, never a finding and never an internal error — + * stderr diagnostics present, and stdout exactly the single 12.7 error + * document whose one finding carries the stable code `expectedCode`, + * locations [] (write and read conditions carry no in-source location, + * SPEC 14), and the concerned path exactly `expectedPath`. + */ +function assertEnvironmentRefusalDocument( + result: RunResult, + expectedCode: "write-failure" | "read-failure", + expectedPath: string, + context: string, +): void { + const condition = expectedCode === "write-failure" ? "14.24" : "14.25"; + const access = expectedCode === "write-failure" ? "write" : "read"; + assertExitCode( + result, + 2, + `${context} — a ${access} the environment refuses is a usage error: ` + + `the command stops at it and exits 2, never a finding, never an ` + + `internal error (SPEC ${condition}, 12.0)`, + ); + assertStderrDiagnostic(result, context); + const finding = expectErrorDocument(result, context); + assertSameJson( + projectFindingForm(finding), + { code: expectedCode, path: expectedPath, locations: [] }, + `${context} — the error document's one finding: the stable code ` + + `${JSON.stringify(expectedCode)} (SPEC ${condition}, 14), locations ` + + `[] (an unlocated condition, SPEC 14), and the concerned path ` + + `${JSON.stringify(expectedPath)} (SPEC ${condition}, 12.7; message: ` + + `${JSON.stringify(finding.message)})`, + ); +} + +/** + * 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; a `--config` path nothing occupies echoed exactly as + * given (SPEC 14, 12.0). + */ +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", ERR_SIBLING_CWD], + }, + 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)", + ); + // TEST-SPEC's own staging (T11.6-1's form): from the sibling working + // directory work/, `--config ../cfg/xspec.config.ts` — first naming + // no file (cfg/ exists; that file does not), then a file that is not + // well-formed TypeScript. Each is a configuration error (SPEC 14.14: + // missing or invalid configuration — with --config given, a missing + // file is missing configuration, never a plain usage error) whose + // concerned path is the named file in the canonical anchoring + // spelling relative to the invocation working directory: one ascent + // segment then the descent, joined with `/` — `../cfg/xspec.config.ts` + // (SPEC 14: "the path --config names — it is that file"), never "." + // (reserved for a failed upward search with no --config), and never + // `../xspec.config.ts`, the invalid root file the upward search from + // work/ would find — failing a product that falls back to the search + // when the named file is absent. A build failing at configuration + // load modifies nothing (SPEC 12.1): the whole root is + // snapshot-compared around each invocation. + const siblingCwd = workspace.path(ERR_SIBLING_CWD); + const siblingArgv = [ + "build", + "--json", + "--config", + ERR_SIBLING_CONFIG_ARG, + ]; + await assertLeavesUnchanged( + workspace.root, + () => + expectAnchoredConfigurationError( + product, + siblingCwd, + siblingArgv, + ERR_SIBLING_CONFIG_ARG, + "T12.7-3 `build --json --config ../cfg/xspec.config.ts` from " + + "the sibling directory work/ (the named file nonexistent)", + ), + "T12.7-3 sibling-directory `--config` naming a nonexistent file: " + + "a build failing at configuration load modifies nothing (SPEC " + + "12.1)", + ); + // Naming a path nothing occupies, the argument value is echoed EXACTLY + // as given (SPEC 14: the one concerned path no physical resolution can + // spell; 12.0): the non-canonical spelling `./../cfg//xspec.config.ts` + // — a `.` segment and a doubled `/` — byte-for-byte, never reduced to + // `../cfg/xspec.config.ts`; and an absolute path as given, 12.0's sole + // absolute-form echo beside 11.6's drive case — failing a product that + // canonicalizes a nonexistent argument, and one that reports the + // invalid root file the upward search would find. + const absentAbsoluteArg = workspace.path(ERR_ABSENT_CONFIG_FILE); + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectAnchoredConfigurationError( + product, + siblingCwd, + [ + "build", + "--json", + "--config", + ERR_SIBLING_CONFIG_ARG_NONCANONICAL, + ], + ERR_SIBLING_CONFIG_ARG_NONCANONICAL, + "T12.7-3 `build --json --config ./../cfg//xspec.config.ts` from " + + "the sibling directory work/ (the named path unoccupied: the " + + "argument value echoed byte-for-byte, SPEC 14)", + ); + await expectAnchoredConfigurationError( + product, + siblingCwd, + ["build", "--json", "--config", absentAbsoluteArg], + absentAbsoluteArg, + `T12.7-3 \`build --json --config ${absentAbsoluteArg}\` from ` + + "the sibling directory work/ (an absolute path nothing " + + "occupies: echoed as given, SPEC 14, 12.0)", + ); + }, + "T12.7-3 sibling-directory `--config` naming unoccupied paths " + + "non-canonically and absolutely: a build failing at " + + "configuration load modifies nothing (SPEC 12.1)", + ); + // S-9: the malformed configuration is not well-formed TypeScript + // (14.20); its record carries the `unparseable` declaration. + await workspace.file(ERR_SIBLING_CONFIG_FILE, ERR_MALFORMED_CONFIG); + await assertLeavesUnchanged( + workspace.root, + () => + expectAnchoredConfigurationError( + product, + siblingCwd, + siblingArgv, + ERR_SIBLING_CONFIG_ARG, + "T12.7-3 `build --json --config ../cfg/xspec.config.ts` from " + + "the sibling directory work/ (the named file not well-formed " + + "TypeScript)", + ), + "T12.7-3 sibling-directory `--config` naming a malformed file: a " + + "build failing at configuration load modifies nothing (SPEC 12.1)", + ); + // The malformed file now existing, the same non-canonical spelling and + // the absolute path name IT — a path its occupant makes resolvable — + // so each reports 11.6's canonical anchoring spelling relative to the + // invocation working directory, `../cfg/xspec.config.ts` (SPEC 14: + // the path --config names, whatever occupies it, in the anchoring + // form), never the argument as given: existence, not spelling, decides + // the form. + const existingAbsoluteArg = workspace.path(ERR_SIBLING_CONFIG_FILE); + await expectAnchoredConfigurationError( + product, + siblingCwd, + ["build", "--json", "--config", ERR_SIBLING_CONFIG_ARG_NONCANONICAL], + ERR_SIBLING_CONFIG_ARG, + "T12.7-3 `build --json --config ./../cfg//xspec.config.ts` from " + + "the sibling directory work/ (the named file existing and " + + "malformed: the canonical anchoring spelling, never the argument " + + "as given)", + ); + await expectAnchoredConfigurationError( + product, + siblingCwd, + ["build", "--json", "--config", existingAbsoluteArg], + ERR_SIBLING_CONFIG_ARG, + `T12.7-3 \`build --json --config ${existingAbsoluteArg}\` from ` + + "the sibling directory work/ (the named file existing and " + + "malformed: the canonical anchoring spelling relative to the " + + "working directory, never the absolute argument as given)", + ); + // 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); on the shape-unpinned surfaces — `query node`, " + + "the `query nodes`/`subtree`/`ancestors` rows (11.1), `show --json` " + + "(12.4), and a review payload's present scope node (a requirement " + + "node's and a `code-impact` location's), context, and origin nodes " + + "(10.7) — every range decodes through the H-3 adapters as exactly " + + '{"start", "end"}, never re-mapped, and byte-exact against the staged ' + + "constructs (1.7, 4.6) (SPEC 12.7, 12.0, 14, 11.2-11.6, 11.1, 12.4, " + + "10.7, 1.7)", + run: async (product) => { + await runLocatedFindingsArm(product); + await runPolicyFindingArm(product); + await runCrossModuleArm(product); + await runReviewRefusalArm(product); + await runUnpinnedRangesArm(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 — and so does T6.5-21's two-reason file move " + + "(move specs/A.mdx specs/a'b.mdx in T6.5-21(a)'s staging): " + + "refused-invalid-destination before refused-exposed-derived-file, " + + "which 14 lists between it and refused-invalid-rewrite, again 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 — on a clean, freshly built workspace a " + + "successful build --json and check --json each emit exactly " + + '{"findings": []} as the entire stdout, the one member and nothing ' + + 'beside it), 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, 6.5, 13.3, " + + "11.3-11.5, 12.6, 7.3, 12.1, 12.2)", + run: async (product) => { + await runConditionOrderingArm(product); + await runRefusalOrderingArm(product); + await runIdentitiesOrderingArm(product); + await runDocumentFormsArm(product); + await runTwoReasonFileMoveArm(product); + }, +}); + +/** + * Arm (Linux leg): 11.6's physical resolution of the invocation working + * directory. From `R/L`, a symbolic link to `R/a/b`, `--config + * ./../xspec.config.ts` names `R/a/xspec.config.ts` — the argument resolved + * against the physical working directory (SPEC 12.0, 7) — a malformed + * existing file, so the concerned path is its canonical anchoring spelling + * over the PHYSICAL relation between the working directory and the file: + * `../xspec.config.ts` (SPEC 11.6: the working directory enters the spelling + * with every symbolic link among its components resolved). Never + * `../a/xspec.config.ts`, the relation spelled from the link's lexical + * position, and never the argument as given, `./../xspec.config.ts` — the + * form reserved for a path nothing occupies (SPEC 14), which a product + * resolving the argument lexically from `R/L` (finding nothing at + * `R/xspec.config.ts`) would report. + */ +async function runErrorSymlinkWorkingDirectoryArm( + product: ProductBinding, +): Promise<void> { + await withWorkspace( + { + // S-9: the malformed configuration is not well-formed TypeScript + // (14.20); its record carries the `unparseable` declaration. + files: { "a/xspec.config.ts": ERR_MALFORMED_CONFIG }, + dirs: ["a/b"], + symlinks: { L: "a/b" }, + }, + async (workspace) => { + await expectAnchoredConfigurationError( + product, + workspace.path("L"), + ["build", "--json", "--config", "./../xspec.config.ts"], + "../xspec.config.ts", + "T12.7-3 `build --json --config ./../xspec.config.ts` from the " + + "working directory R/L, a symbolic link to R/a/b (the malformed " + + "configuration at R/a/xspec.config.ts: the physical relation " + + "between the working directory and the file, SPEC 11.6)", + ); + }, + ); +} + +// A second valid source, under the directory staged unlistable — an +// initial file of a workspace created after T12.7-3's first product +// invocation: a staged-source record (S-9). +const ERR_SUB_SOURCE = stagedMdx( + "T12.7-3 specs/sub/S.mdx (the valid source under the directory staged unlistable, 14.25)", + '<S id="s">\nSigma.\n</S>\n', +); + +/** + * Arms (Linux leg): the error documents of the two environment refusals, + * each staged by permission removal alone (E-1) through the shared + * permission-staging helpers — T14-6's stagings, the documents here asserted + * form-exact on code AND concerned path (T14-9 and T14-10 pin the + * per-command contracts and the states left): + * - 14.24: a stale workspace whose graph-data area `.xspec` is unwritable — + * `build --json` regenerates the derived files under the writable `specs/` + * and is refused at its graph-data write, whatever its write order — the + * error document `{"code": "write-failure", "path": ".xspec"}`: a write of + * graph data concerns the graph-data area, no path inside it named (SPEC + * 14.24, 11.6); + * - 14.25: a directory discovery lists, `specs/sub`, staged unlistable with + * search kept — `build --json` stops at the refused listing — the error + * document `{"code": "read-failure", "path": "specs/sub"}`: the concerned + * path is the object's workspace-relative path (SPEC 14.25). + */ +async function runErrorEnvironmentRefusalArms( + product: ProductBinding, +): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": ERR_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.7-3 (14.24) staging `build` (SPEC 12.1)", + ); + await workspace.file("specs/A.mdx", T12_7_3_A_EDITED); + const staging = await stageWriteRefusalUnder(workspace.path(".xspec")); + try { + const context = + "T12.7-3 `build --json` with the graph-data area .xspec " + + "unwritable on the stale workspace"; + const result = await runProduct(product, { + cwd: workspace.root, + argv: ["build", "--json"], + }); + assertEnvironmentRefusalDocument( + result, + "write-failure", + ".xspec", + context, + ); + } finally { + await staging.restore(); + } + }, + ); + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": ERR_SOURCE, + "specs/sub/S.mdx": ERR_SUB_SOURCE, + }, + }, + async (workspace) => { + const staging = await stageReadRefusalOfDirectory( + workspace.path("specs/sub"), + ); + try { + const context = "T12.7-3 `build --json` with specs/sub unlistable"; + const result = await runProduct(product, { + cwd: workspace.root, + argv: ["build", "--json"], + }); + assertEnvironmentRefusalDocument( + result, + "read-failure", + "specs/sub", + context, + ); + } finally { + await staging.restore(); + } + }, + ); +} + +// --------------------------------------------------------------------------- +// 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 — from the " + + "sibling working directory work/, --config ../cfg/xspec.config.ts " + + "reports ../cfg/xspec.config.ts, the named file nonexistent and, " + + 'separately, not well-formed TypeScript, never ".", each failing build ' + + "modifying nothing; from the root a ./-spelled argument reports " + + 'without the "." segment; "." 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; a --config path nothing occupies is reported as " + + "the argument value exactly as given — ./../cfg//xspec.config.ts " + + "byte-for-byte and an absolute path — while the same spellings naming " + + "the malformed existing file report the canonical " + + "../cfg/xspec.config.ts; (Linux leg) from a working directory R/L that " + + "is a symbolic link to R/a/b, --config ./../xspec.config.ts naming the " + + "malformed R/a/xspec.config.ts reports ../xspec.config.ts, the physical " + + "relation, never ../a/xspec.config.ts and never the spelling as given; " + + "(Linux leg, permission-staged) a write the environment refuses — " + + ".xspec unwritable on a stale workspace — is " + + '{"code": "write-failure", "path": ".xspec"} and a read it refuses — ' + + 'specs/sub unlistable — is {"code": "read-failure", "path": ' + + '"specs/sub"}, each locations [] with diagnostics on stderr (SPEC 12.0, ' + + "12.7, 14, 11.6, 7, 12.1, 14.24, 14.25)", + run: async (product) => { + await runErrorConfigPathsArm(product); + await runErrorSearchFailureArm(product); + await runErrorSingleFindingArm(product); + await runErrorUsageArm(product); + if (LINUX_LEG_STAGED) { + await runErrorSymlinkWorkingDirectoryArm(product); + await runErrorEnvironmentRefusalArms(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.1-13.2.ts b/test/suite/registry/section-13.1-13.2.ts index 2933b63e..951a10d5 100644 --- a/test/suite/registry/section-13.1-13.2.ts +++ b/test/suite/registry/section-13.1-13.2.ts @@ -67,6 +67,8 @@ import { displaySnapshotPath, snapshotDirectory, } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import { assertCompileErrorAt, assertNoCompileErrors, @@ -78,6 +80,7 @@ import type { FileOffset, SourceDefinitionTarget, } from "../../helpers/tooling.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { buildOk } from "./support.js"; @@ -95,7 +98,7 @@ export default defineConfig({ /** Stage a fresh workspace with the given files, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ files }); @@ -537,30 +540,48 @@ export default defineConfig({ `; } +// Arm (b)'s configuration: its workspace follows arm (a)'s invocation, so +// S-7's sweep never reaches it against the stub — a TypeScript staged-source +// record (helpers/staged-ts.ts; S-9's TypeScript and timing clauses). +const T13_2_1_OUTDIR_CONFIG = stagedTs( + "T13.2-1 (b) xspec.config.ts — Markdown emission under outDir docs", + emissionConfig('{ emit: true, outDir: "docs" }'), +); + // A section-3-representative source — an import (removed, line dropped), a // tag with props (removed), an own-line MDX comment (dropped with its line), // a mid-line `text(...)` embedding (replaced with the target's subtree text), // author whitespace preserved — plus a subdirectory target source, so the -// outDir arm observes preserved workspace-relative paths on both files. -const EMISSION_SOURCES: Readonly<Record<string, string>> = { - "specs/A.mdx": [ - 'import LIB from "./sub/LIB.xspec"', - "", - "# Guide", - "", - '<S id="alpha" tags="quote">', - "Alpha keeps spacing.", - "{/* dropped comment line */}", - "Quoting: {text(LIB.util)} inline.", - "</S>", - "", - "Tail prose.", - "", - ].join("\n"), +// outDir arm observes preserved workspace-relative paths on both files. The +// outDir arm's workspace is created after the default arm's invocations, so +// both `.mdx` entries are staged-source records (S-9's before-any-product +// clause; helpers/staged-mdx.ts), the default arm's workspace staging them +// too. +const EMISSION_SOURCES: Readonly<Record<string, InitialFileContents>> = { + "specs/A.mdx": stagedMdx( + "T13.2-1 specs/A.mdx (the section-3-representative source, staged by both placement arms)", + [ + 'import LIB from "./sub/LIB.xspec"', + "", + "# Guide", + "", + '<S id="alpha" tags="quote">', + "Alpha keeps spacing.", + "{/* dropped comment line */}", + "Quoting: {text(LIB.util)} inline.", + "</S>", + "", + "Tail prose.", + "", + ].join("\n"), + ), // A single unterminated line: util's subtree text carries no trailing // terminator (SPEC 3: the final line MAY have no terminator), so the // mid-line embedding above stays a single line. - "specs/sub/LIB.mdx": '<S id="util">Util behavior text.</S>', + "specs/sub/LIB.mdx": stagedMdx( + "T13.2-1 specs/sub/LIB.mdx (the unterminated embedded target, staged by both placement arms)", + '<S id="util">Util behavior text.</S>', + ), }; // Hand-derived per SPEC 3: the import line and the tag-only, comment-only @@ -620,7 +641,7 @@ const T13_2_1 = defineProductTest({ // markdown.outDir — redirected, not duplicated). await withWorkspace( { - "xspec.config.ts": emissionConfig('{ emit: true, outDir: "docs" }'), + "xspec.config.ts": T13_2_1_OUTDIR_CONFIG, ...EMISSION_SOURCES, }, async (workspace) => { diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index 1e681201..6018e27b 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -14,16 +14,35 @@ // - "The graph data", operationally (T13.3-2, binding here and for T13.4-3): // every path under `.xspec/` except the durable `.xspec/journal` and // `.xspec/reviews/` (SPEC 13.4). Tests only ever remove or compare it whole. -// - "Rewritten as `build` would write it" is asserted as byte equality -// against graph data an actual `build` of the identical workspace state -// wrote (12.0/13.3 byte determinism makes that reference exact). The -// reference build runs *after* all durable staging (session creation and -// resolves), so the compare never conflates refresh output with any -// build-input difference. In the source-edit arm the staged edit keeps the -// generated file set identical (content-only edits to one existing source), -// so "recorded derived-file paths are left unchanged" cannot make a -// conforming refresh's bytes differ from the reference build's; the -// record-survival clause itself is asserted behaviorally in the +// - "Rewritten as `build` would write it" is asserted, in the source-edit +// arm, as byte equality against graph data an actual `build` of the +// identical workspace state wrote (12.0/13.3 byte determinism makes that +// reference exact). The reference build runs *after* all durable staging +// (session creation and resolves), so the compare never conflates refresh +// output with any build-input difference, and the staged edit keeps the +// generated file set identical (content-only edits to one existing +// source), so "recorded derived-file paths are left unchanged" cannot make +// a conforming refresh's bytes differ from the reference build's. The +// deletion arm admits no such reference: the operational deletion removes +// the record with the graph data, and an absent record stays absent (SPEC +// 13.3) — a conforming refresh writes graph data beside no record where +// every `build` writes one, and the record's location inside the area is +// unenumerated (11.6) — so no byte compare against a build's graph data +// can hold there (a compare against the post-build state passes only a +// product whose refresh writes a fresh record). That arm asserts instead +// that every refreshing read rewrites the same graph-data bytes for the +// same workspace state (12.0), that `inventory` afterwards reports +// `recorded` exactly `[]` (11.6: the empty record, never a fresh one — +// the same `inventory` on the deleted state before any read is the +// premise that the deletion emptied the record), and that `check` is +// clean: 14.10's unit form is the product's own comparison of the graph +// data against the current sources and configuration with the recorded +// paths excluded, so a clean `check` with every derived file still +// matching is "as `build` would write it" as far as H-4 lets the harness +// see, and the pair discriminates a refresh that writes a fresh record +// from a `check` that reads the absent record as a mismatch (TEST-SPEC +// T13.3-2). The record-survival clause itself is asserted behaviorally +// in the // set-changing sub-arm (delete a source; the refresh leaves the stale // module in place, `check` reports 14.10 against the recorded orphan, and // the next `build` removes it — removal is possible only if the refresh @@ -50,23 +69,93 @@ // 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): each staging holds an `audit` session created +// while the workspace was valid — no baseline to resolve, so the gate +// alone stands between `review status` and an answer. 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); that staging drives the +// five reads `ids`, `show`, `coverage`, `review status`, and `query` only +// — `impact` is absent from it by necessity, not oversight (TEST-SPEC +// T13.3-3): `impact` always takes `--base` (SPEC 9), baseline resolution +// precedes the gate (12.0), and no journal-error staging reaches the gate +// at `impact` — a garbage line appended after the baseline commit meets +// 6.3's suffix replay as an unresolvable mapping (exit 2, the usage error +// T6.3-4 pins), and a garbage line already committed at the baseline ref +// makes a baseline that cannot be validated as a workspace (exit 2 again, +// 6.3); neither verdict is asserted here. 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); this +// staging alone also drives `impact --base`, against a commit taken +// before the obstruction was staged — the baseline's sources and +// configuration validate and the journal is absent on both sides (an +// empty journal is a prefix of every journal, 6.3) — so its baseline +// resolves and 14.22 is the operative gate finding (12.0). 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, @@ -80,6 +169,8 @@ import { } from "../../helpers/determinism.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { DirectorySnapshot, SnapshotEntry, @@ -90,21 +181,31 @@ import { snapshotDirectory, } from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertFindingConcernsPath, assertFindingLocated, assertSameJson, buildOk, expectExit, + expectFindingFreeReport, runCli, runJson, } from "./support.js"; // One spec group plus one coverage profile (SPEC 7, 7.4): `coverage` and // `review create --coverage` need a configured profile, and `targets: "all"` -// keeps the required set at every non-root node (SPEC 8.1). -const GRAPH_CONFIG = `import { defineConfig } from "xspec" +// keeps the required set at every non-root node (SPEC 8.1). T13.3-2's +// record-discipline workspace, T13.3-3's garbage-journal workspace, and +// T13.3-4's two-directory workspaces stage it after their body's first +// product invocation, so S-7's sweep never reaches those stagings against +// the stub: a TypeScript staged-source record (helpers/staged-ts.ts; S-9's +// TypeScript and timing clauses), staged at every site. +const GRAPH_CONFIG = stagedTs( + "T13.3-2/T13.3-3/T13.3-4 xspec.config.ts — one spec group and the coverage profile p (targets all)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -120,11 +221,12 @@ export default defineConfig({ } ] }) -`; +`, +); /** Stage a fresh workspace with the given files, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ files }); @@ -139,19 +241,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 +280,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 { @@ -199,11 +297,38 @@ function assertGraphDataPresent( } } +/** + * Assert the inventory's record-supplied datum is the plain empty record: + * `recorded` exactly `[]` — a value, never the unavailability marker, never + * null (SPEC 11.6: the record is empty wherever none exists, after recorded + * state was removed without a rebuild included; 14.23: a record's absence + * meets no condition; 12.7: the empty list is `[]`). + */ +function assertEmptyRecord( + recorded: ReturnType<typeof decodeInventoryRecordedDatum>, + label: string, + why: string, +): void { + if (recorded.state !== "value") { + fail( + `${label}: ${why} — expected the plain empty record, \`recorded\` ` + + `exactly [] (SPEC 11.6, 14.23, 12.7); got state ` + + JSON.stringify(recorded.state), + ); + } + assertSameJson( + recorded.value, + [], + `${label}: ${why} — \`recorded\` must be exactly [] (SPEC 11.6, 12.7)`, + ); +} + /** * 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,28 +490,102 @@ 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 // --------------------------------------------------------------------------- -const T13_3_1_A = [ - '<S id="alpha" d={["beta"]}>', - "Alpha depends on beta.", - "</S>", - "", - '<S id="beta">', - "Beta text.", - "</S>", - "", -].join("\n"); +// alpha depending on beta, then beta: T13.3-1's source — and byte for byte +// the source T13.3-2's record-discipline workspace and T13.3-3's whole-gate +// workspaces stage after their bodies' first invocations, so it is one +// staged-source record staged at every site (S-9's before-any-product +// clause; helpers/staged-mdx.ts), aliased below where each arm names it. +const ALPHA_ON_BETA_SOURCE = stagedMdx( + "T13.3-1/T13.3-2/T13.3-3 specs/A.mdx (alpha depending on beta, then beta: T13.3-1's workspace, T13.3-2's record-discipline workspace, T13.3-3's whole-gate workspaces)", + [ + '<S id="alpha" d={["beta"]}>', + "Alpha depends on beta.", + "</S>", + "", + '<S id="beta">', + "Beta text.", + "</S>", + "", + ].join("\n"), +); 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 }, + { "xspec.config.ts": GRAPH_CONFIG, "specs/A.mdx": ALPHA_ON_BETA_SOURCE }, async (workspace) => { const A_ROOT = "specs/A.mdx"; const ALPHA = "specs/A.mdx#alpha"; @@ -590,6 +789,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 +876,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"); +// Arm B's edit is staged after arm A's product invocations, so it is a ledger +// record (S-9's before-any-product clause; helpers/staged-mdx.ts) — the same +// constant (which still spells the expected snapshot bytes in the body). +const T13_3_2_A_EDITED = stagedMdx( + "T13.3-2 source-edit arm: specs/A.mdx revised, with a section carrying a same-file d reference added", + T13_3_2_A_V1, +); 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). +// Its workspace is created after the source-edit arms' invocations: the +// staged-source record holding these bytes (T13.3-1's source). +const T13_3_2_RECORD_A = ALPHA_ON_BETA_SOURCE; + 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); an absent record stays absent — after a refreshing read on the deletion arm `inventory` reports `recorded` [] and, every derived file still matching, `check` is clean; 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, 11.6, 12.1, 14.10, 14.23)", run: async (product) => { await withWorkspace( { @@ -690,15 +975,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,14 +1112,115 @@ 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 - // deleted whole; the command answers from current sources and must - // leave the workspace byte-identical to the fixed point W0: graph - // data rewritten exactly as `build` wrote it (12.0/13.3 - // determinism), durables untouched, no TypeScript or Markdown - // generated or removed. + // deleted whole — the record with it (SPEC 13.3: the recorded + // derived-file paths are part of graph data, and the operational + // deletion removes every non-durable path under .xspec/) — and the + // command answers from current sources. Outside the graph data the + // workspace must stay byte-identical to the fixed point W0 (no + // TypeScript or Markdown generated or removed, durables untouched); + // the graph data is rewritten — present again, every read writing + // the same bytes (12.0) — but never byte-compared against W0's: an + // absent record stays absent (13.3), so a conforming refresh writes + // graph data beside no record where W0 holds `build`'s (module + // header). "As `build` would write it" is the product's own + // comparison instead: `inventory` reports `recorded` exactly [] + // and `check` is clean afterwards (14.10, 11.6; TEST-SPEC T13.3-2). + // + // Premise, on the deleted state before any read: the deletion + // emptied the record — `inventory` reports `recorded` [] (11.6: the + // record is empty after recorded state was removed without a + // rebuild) — and `inventory` itself neither refreshes nor writes + // (compare-around; a refreshing inventory would leave the reads + // below nothing to refresh, so it fails loud here rather than + // hollowing out the arm, H-11). + await deleteGraphData( + workspace, + "T13.3-2 (deleted graph data) premise", + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + const label = + "T13.3-2 (deleted graph data) premise `inventory` on the " + + "deleted state"; + assertEmptyRecord( + decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], label), + label, + ), + label, + "the operational deletion removes the record with the graph " + + "data, and the inventory reads an absent record as the " + + "empty record (SPEC 13.3, 11.6, 14.23)", + ); + }, + "T13.3-2 (deleted graph data) premise: `inventory` neither " + + "refreshes nor writes (SPEC 11.6)", + ); + const expectedOutsideGraphA = filteredEntries( + w0.entries, + (key) => !isGraphDataKey(key), + ); + let refreshedGraphA: Map<string, SnapshotEntry> | undefined; for (const probe of armAProbes) { await deleteGraphData( workspace, @@ -848,13 +1229,70 @@ const T13_3_2 = defineProductTest({ await probe.run(); const after = await snapshotDirectory(workspace.root); assertSnapshotsEqual( - w0, - after, - `T13.3-2 (deleted graph data) after ${probe.label}: the ` + - `workspace must be byte-identical to the post-build state — ` + - `graph data rewritten exactly as \`build\` would write it, ` + - `no TypeScript or Markdown generated or removed, journal and ` + - `session files untouched (SPEC 13.3, 13.4)`, + asSnapshot(workspace.root, expectedOutsideGraphA), + asSnapshot( + workspace.root, + filteredEntries(after.entries, (key) => !isGraphDataKey(key)), + ), + `T13.3-2 (deleted graph data) after ${probe.label}: outside ` + + `the graph data the workspace must be byte-identical to the ` + + `post-build state — no TypeScript or Markdown generated or ` + + `removed, journal and session files untouched (SPEC 13.3, ` + + `13.4)`, + ); + const graphNow = graphDataEntries(after); + if (refreshedGraphA === undefined) { + assertGraphDataPresent( + after, + `T13.3-2 (deleted graph data) after ${probe.label} — the ` + + `read must have rewritten the deleted graph data`, + ); + refreshedGraphA = graphNow; + } else { + assertSnapshotsEqual( + asSnapshot(workspace.root, refreshedGraphA), + asSnapshot(workspace.root, graphNow), + `T13.3-2 (deleted graph data) after ${probe.label}: every ` + + `refreshing read rewrites the same graph-data bytes for ` + + `the same workspace state (SPEC 13.3, 12.0)`, + ); + } + + // An absent record stays absent (SPEC 13.3): the refresh wrote + // graph data beside the absent record and never a record, so + // `inventory` reports `recorded` exactly [] (11.6) — and, every + // derived file still matching and the graph data now present and + // matching, `check` is clean: a lagging record alone is never + // staleness (14.10). Neither surface refreshes or writes (11.6, + // 12.2, 13.3), so both run inside one compare-around. + await assertLeavesUnchanged( + workspace.root, + async () => { + const invLabel = `T13.3-2 (deleted graph data) \`inventory\` after ${probe.label}`; + assertEmptyRecord( + decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], invLabel), + invLabel, + ), + invLabel, + "an absent record stays absent: the refresh writes graph " + + "data beside the absent record and never a record (SPEC " + + "13.3, 11.6)", + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `T13.3-2 (deleted graph data) \`check --json\` after ` + + `${probe.label} — every derived file still matches and ` + + `the refreshed graph data matches the current sources ` + + `and configuration; a lagging (here absent) record alone ` + + `is never staleness (SPEC 14.10, 13.3)`, + ); + }, + `T13.3-2 (deleted graph data) after ${probe.label}: ` + + `\`inventory\` and \`check\` neither refresh nor write (SPEC ` + + `11.6, 12.2, 13.3)`, ); } @@ -864,7 +1302,7 @@ const T13_3_2 = defineProductTest({ // data is restored, so every command individually faces // stale-but-present graph data and must answer from the edited // sources. - await workspace.file("specs/A.mdx", T13_3_2_A_V1); + await workspace.file("specs/A.mdx", T13_3_2_A_EDITED); const expectedNonGraph = filteredEntries( w0.entries, (key) => !isGraphDataKey(key), @@ -874,7 +1312,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 +1383,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 +1483,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,105 +1766,724 @@ const T13_3_2 = defineProductTest({ } }, ); - }, -}); - -/** - * Assert a `check` findings report consists solely of 14.10 staleness - * findings against one source's generated module and companions: every - * finding carries condition 14.10 and a file under `<prefix>`, and the - * module `<module>` itself is among the named files (SPEC 14.10, 13.1). - */ -function assertStaleModuleFindings( - findings: readonly Finding[], - prefix: string, - module: string, - context: string, -): void { - if (findings.length === 0) { - fail( - `${context}: expected at least one 14.10 staleness finding (SPEC ` + - `14.10); got none`, - ); - } - for (const finding of findings) { - if (finding.condition !== "14.10") { - fail( - `${context}: every finding here must be condition 14.10 — the only ` + - `staged condition is the stale/orphaned generated output (SPEC ` + - `14.10); got ${JSON.stringify(finding.condition)} (message: ` + - `${JSON.stringify(finding.message)})`, - ); - } - if (finding.file === undefined || !finding.file.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)} ` + - `(message: ${JSON.stringify(finding.message)})`, - ); - } - } - if (!findings.some((finding) => finding.file === 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)), - ); - } -} - -// --------------------------------------------------------------------------- -// T13.3-3 — failed refresh -// --------------------------------------------------------------------------- - -const T13_3_3_A = [ - '<S id="alpha">', - "Alpha intro.", - '<S id="alpha.one">', - "Alpha-one text.", - "</S>", - "</S>", - "", - '<S id="gamma">', - "Gamma text.", - "</S>", - "", -].join("\n"); -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"); -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)", - run: async (product) => { + // --- 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_3_A, - "specs/B.mdx": T13_3_3_B_VALID, - }, + { "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 ALPHA_ONE = "specs/A.mdx#alpha.one"; - const GAMMA = "specs/A.mdx#gamma"; + const BETA = "specs/A.mdx#beta"; - // --- Staging while the sources are valid: a resolvable commit, a - // build, and a session holding a resolved leaf so that `alpha`'s - // subtree-coherence item is unblocked (its scope root has a child, - // so `split` would apply) and `gamma`'s leaf item is unblocked and - // unresolved (so `resolve` would apply). + // 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("valid baseline"); - await buildOk(product, workspace, "T13.3-3 staging `build`"); + 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-3 staging `review create --strategy audit --name s`", + "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)}`, + ); + } + }, + ); + }, +}); + +/** + * Assert a `check` findings report consists solely of 14.10 staleness + * findings against one source's generated module and companions: every + * finding carries condition 14.10 and a file under `<prefix>`, and the + * module `<module>` itself is among the named files (SPEC 14.10, 13.1). + */ +function assertStaleModuleFindings( + findings: readonly Finding[], + prefix: string, + module: string, + context: string, +): void { + if (findings.length === 0) { + fail( + `${context}: expected at least one 14.10 staleness finding (SPEC ` + + `14.10); got none`, + ); + } + for (const finding of findings) { + if (finding.condition !== "14.10") { + fail( + `${context}: every finding here must be condition 14.10 — the only ` + + `staged condition is the stale/orphaned generated output (SPEC ` + + `14.10); got ${JSON.stringify(finding.condition)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if (typeof finding.path !== "string" || !finding.path.startsWith(prefix)) { + fail( + `${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.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.path)), + ); + } +} + +// --------------------------------------------------------------------------- +// T13.3-3 — failed refresh +// --------------------------------------------------------------------------- + +const T13_3_3_A = [ + '<S id="alpha">', + "Alpha intro.", + '<S id="alpha.one">', + "Alpha-one text.", + "</S>", + "</S>", + "", + '<S id="gamma">', + "Gamma text.", + "</S>", + "", +].join("\n"); +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"); +// The invalidating edit is staged after the body's `build`, so it is a ledger +// record (S-9's before-any-product clause; helpers/staged-mdx.ts) — the same +// constant, well-formed MDX (the missing id is validation condition 14.1). +const T13_3_3_B_ID_LESS = stagedMdx( + "T13.3-3 specs/B.mdx with its section's id removed (14.1)", + T13_3_3_B_INVALID, +); + +// --- 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. +// Both whole-gate workspaces are created after the body's first invocations, +// so their `.mdx` sources are staged-source records (S-9's before-any-product +// clause; helpers/staged-mdx.ts): A is the record holding these bytes +// (T13.3-1's source). +const T13_3_3_GATE_A = ALPHA_ON_BETA_SOURCE; +// An unreferenced section for the legitimate journaled rename (line 1). +const T13_3_3_GATE_T = stagedMdx( + "T13.3-3 specs/T.mdx (the garbage-journal workspace's unreferenced tmp section)", + ['<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). +// The workspace follows the body's first product invocation: a TypeScript +// staged-source record (S-9). +const T13_3_3_OUTDIR_CONFIG = stagedTs( + "T13.3-3 obstructed-write-path workspace xspec.config.ts — emission under outDir mdout, the coverage profile p", + `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 gated reads' whole-gate invocations (SPEC 13.3; `review` represented + * by `status`, the read subcommand — the mutating subcommands are the + * invalid-sources workspace's subject): the five reads taking no baseline, + * plus `impact --base <impactBase>` when a baseline is supplied — the + * obstructed-write staging alone, against a commit taken before the + * obstruction was staged; the journal-error staging supplies none, `impact` + * being absent from it by necessity (TEST-SPEC T13.3-3; module header). + */ +function gatedReadInvocations( + alpha: string, + impactBase?: string, +): readonly { readonly argv: readonly string[]; readonly what: string }[] { + const impact = + impactBase === undefined + ? [] + : [ + { + argv: ["impact", "--base", impactBase, "--json"], + what: "`impact --base <ref> --json`", + }, + ]; + return [ + { argv: ["ids", "--json"], what: "`ids --json`" }, + { argv: ["show", alpha, "--json"], what: `\`show ${alpha} --json\`` }, + { argv: ["coverage", "--json"], what: "`coverage --json`" }, + ...impact, + { + 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; the gate is over every finding a `build` would report: with a garbage journal line staged, ids, show, coverage, review status, and query each report exactly the journal error (14.13) naming the line — `impact` absent from that staging by necessity, a garbage line meeting baseline resolution first (exit 2, T6.3-4) — and with an obstructed write path staged, the same five and `impact --base` against a commit taken before the obstruction (its baseline resolving) each report exactly the refused write (14.22) naming 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( + { + "xspec.config.ts": GRAPH_CONFIG, + "specs/A.mdx": T13_3_3_A, + "specs/B.mdx": T13_3_3_B_VALID, + }, + async (workspace) => { + const ALPHA = "specs/A.mdx#alpha"; + const ALPHA_ONE = "specs/A.mdx#alpha.one"; + const GAMMA = "specs/A.mdx#gamma"; + + // --- Staging while the sources are valid: a resolvable commit, a + // build, and a session holding a resolved leaf so that `alpha`'s + // subtree-coherence item is unblocked (its scope root has a child, + // so `split` would apply) and `gamma`'s leaf item is unblocked and + // unresolved (so `resolve` would apply). + await workspace.gitInit(); + const base = await workspace.gitCommitAll("valid baseline"); + await buildOk(product, workspace, "T13.3-3 staging `build`"); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + "T13.3-3 staging `review create --strategy audit --name s`", ); const initial = await sessionStatus( product, @@ -1399,7 +2535,7 @@ const T13_3_3 = defineProductTest({ // exactly one condition (14.1, missing id). The baseline commit // predates it, so baseline resolution succeeds and the refresh // failure is the operative error (SPEC 12.0, 6.3). - await workspace.file("specs/B.mdx", T13_3_3_B_INVALID); + await workspace.file("specs/B.mdx", T13_3_3_B_ID_LESS); /** * Run one probe: exit 1, stdout is the findings report carrying @@ -1488,7 +2624,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 +2635,7 @@ const T13_3_3 = defineProductTest({ JSON.stringify( findings.map((finding) => ({ condition: finding.condition, - file: finding.file, + locations: finding.locations, })), ), ); @@ -1544,6 +2682,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 is explained in the module header. The five reads + // taking no baseline are driven — `impact` is absent from this staging + // by necessity (a garbage line meets baseline resolution first, exit 2, + // T6.3-4; module header) — so no git repository is staged: no baseline + // to resolve, the gate alone standing between each invocation and an + // answer. 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 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"), + }; + + for (const probe of gatedReadInvocations(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). This staging alone drives + // `impact --base` beside the five other reads, against the commit taken + // before the obstruction was staged (TEST-SPEC T13.3-3). + 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(); + // The commit taken before the obstruction is staged — `impact + // --base`'s baseline: pristine valid sources and configuration at + // the ref, the journal absent on both sides (an empty journal is a + // prefix of every journal), so the baseline resolves and 14.22 is + // the operative gate finding (SPEC 6.3, 12.0). + const base = await workspace.gitCommitAll( + "baseline (valid sources, before the obstruction)", + ); + 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(ALPHA, base)) { + 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, + ); + }, + ); }, }); @@ -1554,28 +2905,32 @@ const T13_3_3 = defineProductTest({ // A workspace exercising the enumerated graph-data content (SPEC 13.3): // nested sections, a dependency edge, tags, a coverage attribute, a // subdirectory source, and a configured coverage profile. No git: graph data -// derives from sources and configuration alone. -const T13_3_4_FILES: Readonly<Record<string, string>> = { +// derives from sources and configuration alone. The two-directory form's +// workspaces are created after the same-workspace form's invocations, so +// the `.mdx` entries are staged-source records (S-9's before-any-product +// clause; helpers/staged-mdx.ts), the first workspace staging them too. +const T13_3_4_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": GRAPH_CONFIG, - "specs/A.mdx": [ - '<S id="alpha" d={["beta"]} tags="core deep">', - "Alpha depends on beta.", - '<S id="alpha.one" coverage="none">', - "Alpha-one text.", - "</S>", - "</S>", - "", - '<S id="beta">', - "Beta text.", - "</S>", - "", - ].join("\n"), - "specs/sub/C.mdx": [ - '<S id="gamma" tags="edge">', - "Gamma text.", - "</S>", - "", - ].join("\n"), + "specs/A.mdx": stagedMdx( + "T13.3-4 specs/A.mdx (the determinism workspace: nested sections, a d edge, tags, a coverage attribute)", + [ + '<S id="alpha" d={["beta"]} tags="core deep">', + "Alpha depends on beta.", + '<S id="alpha.one" coverage="none">', + "Alpha-one text.", + "</S>", + "</S>", + "", + '<S id="beta">', + "Beta text.", + "</S>", + "", + ].join("\n"), + ), + "specs/sub/C.mdx": stagedMdx( + "T13.3-4 specs/sub/C.mdx (the determinism workspace's subdirectory source)", + ['<S id="gamma" tags="edge">', "Gamma text.", "</S>", ""].join("\n"), + ), }; const T13_3_4 = defineProductTest({ diff --git a/test/suite/registry/section-13.4.ts b/test/suite/registry/section-13.4.ts index 69e79e1a..bf05c708 100644 --- a/test/suite/registry/section-13.4.ts +++ b/test/suite/registry/section-13.4.ts @@ -1,7 +1,10 @@ // 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-9 (derived paths above +// sources and other derived paths), T13.4-10 (rebuild-obstructing orphans), +// T13.4-11 (removing recorded paths no longer generated). // 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 @@ -32,6 +35,45 @@ // output is a function of sources, configuration, and the journal alone // (13.4), so conforming builds of both workspaces yield byte-identical // trees. +// - T13.4-4's link arm and T13.4-11(c) stage their links through one +// function, `stageLinkToOutsideFile`: a symbolic link at a derived file's +// own path resolving to a plain file outside the workspace root (in the +// workspace's temporary directory, beside the root), the target's bytes +// captured before the product runs and compared after. CERTIFICATIONS.md +// certifies that staging through T13.4-11(c) (VIOL-ORPHAN-LINKTARGET), and +// T13.4-4's link arm rides the certification insofar as it shares the +// staging (its Exclusions entry "T13.4-4's link arm"). Outside the root, +// the targets take no part in T13.4-4's whole-workspace compare. +// - T13.4-4's directory arms stage a directory at each of the two derived +// paths SPEC pins for `specs/A.mdx` — the module (13.1) and the Markdown +// emitted next to it (13.2, 7.3) — before any build, in two workspaces: +// empty, and each holding `notes.txt`, which no group matches. Neither +// path is a directory component of a discovered source's or another +// derived path, so 14.22 does not reach it (T13.4-9 stages the paths it +// does). "Replaced by the derived file" is asserted as the plain-file +// kind and the whole-workspace compare against the pristine reference +// (above), so nothing of a directory survives; `check` clean is the +// finding-free report. +// - T13.4-3's two halves — the record missing (deleted per T13.3-2's +// operational definition) and unreadable (corrupted shape-blind through +// the H-3 record-staging adapter, T6.6-6's staging, before the +// configuration change) — walk one procedure, so their assertions mirror +// each other exactly. "`build` replaces the record" (13.3, 14.23) is +// asserted at the record-consulting surfaces — `check` exits 0 (the record +// readable and current: no condition-23 unit form, no graph-data +// staleness; the orphan, recorded nowhere, names no stale file) and +// `inventory`'s `recorded` datum is the plain current generation, naming +// A's module and no B path (13.3: the paths most recently generated) — +// and, whole and opaque (H-4), by the cross-half byte compare of the graph +// data the narrowed `build` wrote: derived output is a function of +// sources, configuration, and the journal alone (13.4), identical across +// the halves, so the garbage-overwritten and the deleted record are +// replaced with the same bytes — a product leaving garbage beside a fresh +// record fails it. The unreadable half's staging premise is read through +// `inventory` inside a whole-root compare (exit 1 with `recorded` +// explicitly unavailable — T11.6-4's pin; 11.6: neither refreshing nor +// writing), so the configuration change is known to find the record +// unreadable and untouched. // - T13.4-5 stays inside CERTIFICATIONS.md §CONF-CORE's scope (the test is // in-scope there): one spec group of importless, tagless `.mdx` sources; // no `code`, `markdown`, `coverage`, or `policy` keys; no git; mutating @@ -47,10 +89,35 @@ // workspace root (no outside-root confound, 14.14). With one source file, // exactly one write path (`out/specs/A.md`) traverses the link, so `build // --json` must report exactly one 14.22 and nothing else (the sources are -// valid, and build cannot observe 14.10, 12.1). `check` must report the -// 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. +// valid, and build cannot observe 14.10, 12.1). `check` must report +// exactly the same 14.22 without writing: a workspace whose `build` is +// refused fails `build`'s validations (SPEC 13.3), so 14.10's mismatch +// forms — the never-generated derived files, the absent graph data — are +// undetectable and go unreported (SPEC 14.10), and no record exists for +// its two whatever-validity forms (an unreadable record, 14.23; a +// recorded path no longer generated); 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), and so is the `check` +// side's (as above: 14.10's mismatch forms go unreported on a workspace +// failing `build`'s validations, SPEC 14.10, 13.3). 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 +135,116 @@ // 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-*). +// - T13.4-9 (the relation between derived paths, SPEC 13.4 and 14.22's +// second sentence): each staging is one fresh workspace on which +// `build --json`, `check --json`, and the gated read `ids --json` +// (T13.3-3) run in turn, each inside a whole-root compare (the +// compare-around machinery CERTIFICATIONS.md's VIOL-CORE-CHATTYREADS note +// names this test under) — `build` writing and removing nothing, `check` +// reporting without writing, the gated read modifying nothing — and each +// exiting 1 with the form-exact 12.7 findings-only report ("answering +// nothing" for `ids`) holding exactly one finding: condition 22 +// concerning the offending derived path, `locations` `[]`, nothing beside +// it. The set is exact on all three: each workspace otherwise passes +// `build`'s validations, the gate judges exactly `build`'s findings +// (13.3), and `check`, on a workspace failing `build`'s validations, +// leaves 14.10's mismatch forms unreported while neither whatever-validity +// form is staged — no record exists before any build, and (f)'s names +// `a.mdx`'s derived paths, every one still generated (SPEC 14.10). +// TEST-SPEC states each staging's emission settings and code groups and +// nothing more: the one spec group globs `specs/**/*.mdx` — `**/*.mdx` in +// (b) and (f), whose sources lie at the workspace root — (c) and (e)'s +// spec-source leg configure nothing else, and (e)'s code-source leg is +// configured as (d) is (emission next to sources, a code group globbing +// `specs/**/*.ts`). (e)'s companion paths are read per leg under that +// leg's configuration — "the staging's configuration" — through +// support.ts `readRecordedCompanionPaths` (a scratch twin holding +// `specs/A.mdx`'s bytes at that path alone, built, its `inventory` +// `recorded` set read), one staging per companion path in each leg. +// "Staged before any build" is verified before each of those stagings' +// first invocation — the offending path a directory ((a), (c), (d), (e)) +// or nothing ((b)), a miss being a harness error — and (f)'s premise is +// re-pinned after its `build` of `a.mdx` alone: `out/a.md` the plain file +// that build emitted, a product writing none there failing diagnosed. +// - T13.4-10 (rebuild-obstructing orphans, SPEC 13.4, 14.22, and 14.10's +// correction): two arms, each in a fresh workspace — the recorded orphan, +// and the unrecorded twin, its graph data deleted before the +// reconfiguration (T13.3-2's operational definition, section-13.3.ts +// `deleteGraphData`). Each builds under one spec group globbing +// `specs/*.mdx` and `markdown: { emit: true, outDir: "out" }` — the +// staging's emission settings and nothing more — premising +// `out/specs/A.md` the plain file that build emitted, then reconfigures +// `outDir` to "out/specs/A.md". The refused `build --json` and the +// following `check --json` each run inside a whole-root compare (the +// compare-around machinery CERTIFICATIONS.md's VIOL-CORE-CHATTYREADS note +// names this test under): `build` modifies nothing, and `check`, which +// never refreshes (13.3), writes nothing either, so the orphan stays, +// byte-identical, until it is deleted by hand. The finding sets are +// exact: `build`'s one condition 22 concerning `out/specs/A.md`; +// `check`'s that finding beside one condition 10 concerning the same +// path (the recorded arm) or alone (the twin) — a condition-10 finding +// concerning a path no longer generated is the recorded-file form, and a +// mismatch form would be a further finding. The recorded-file finding's +// correction, "its manual deletion" (14.10), never a rebuild, is asserted +// by H-3's robust matching over its `message` — required information, +// never wording — judged by the pure `judgeManualDeletionCorrection` of +// the human-report adapter (`test/helpers/adapters/human.ts`, driven by +// S-5 vectors). The manual deletion is present in either form: a +// deletion or removal word beside its manual character ("manual", "by +// hand", or "yourself"), or an instruction to the reader to delete or +// remove the file — a clause opening with "delete" or "remove" at the +// message's start or after a clause boundary (`;`, `:`, `.`, `,`, an em +// dash, "then", "and", "please", among others) and naming +// `out/specs/A.md`, "it", or "the file" — so "delete it, then rebuild" +// passes as "delete it manually" does. No clause may present a build or +// xspec itself as what removes the file, unless it negates that removal: +// a build "to remove" it, a build or xspec that "removes" or "will +// remove" it, a removal "by" a build or xspec, a removal whose means is a +// build ("remove it: run `xspec build`"), or a build offered as the +// alternative ("or run `xspec build`"). So the generic correction +// instructing rebuilding fails, while a message naming the refused +// rebuild beside the manual deletion passes. After the manual +// deletion, `build` exits 0, `out/specs/A.md/specs/A.md` is a plain file, +// and `check` is clean, in both arms. +// - T13.4-11 stays inside CERTIFICATIONS.md §CONF-ORPHAN's scope (the test +// is in-scope there): one spec group of trivial single-section `.mdx` +// sources whose glob matches `.mdx` names alone; `markdown` emitting next +// to sources (under `outDir: "out"` in (d) and (e)), then reconfigured — +// emission disabled by `markdown` absent in (a) and (c) and by +// `emit: false` in (b) and (f), both spellings 7.3 admits, or `outDir` +// changed to `"md"`; (b)'s code group `specs/*.md` alone; commands +// `build` and `check --json` alone. "No condition-10 finding concerning +// P" is asserted over the 12.7 `path` member of the `stale-output` +// findings, the first `check`'s other findings left unasserted — the +// graph-data unit form unpinned (13.3, 14.10), and in (d) and (e) the +// per-file form of the fresh emit destination `md/specs/A.md` — so that +// `check` may exit 0 or 1, consistently with its findings (12.0). Every +// "byte-identical" compares against a capture taken once the staging is +// complete, before the first `check`. (e)'s inside staging puts the +// link's target directory at `foreign/` in the root, under no group's +// globs; its outside staging, beside the root in the workspace's +// temporary directory; both links store a relative target. The +// order-independence arm's "exactly the regenerated one" is H-6's +// two-directory compare of the whole workspace against a twin holding +// the same sources and configuration, freshly built — never `inventory` +// (§CONF-ORPHAN's staging constraint). import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; @@ -80,8 +257,12 @@ import type { import { assertJsonKeysByteSorted, assertReportMentions, + corruptGraphDataShapeBlind, decodeFindingsReport, + decodeInventoryRecordedDatum, decodeSessionStatusReport, + judgeManualDeletionCorrection, + renderPathValue, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, @@ -96,32 +277,56 @@ import type { SnapshotEntry, } from "../../helpers/snapshot.js"; import { + assertDirectoriesEqual, assertLeavesUnchanged, assertSnapshotsEqual, snapshotDirectory, } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; -import { runProduct } from "../../helpers/subprocess.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { STREAMS_VALID_SOURCE } from "./section-12.0-i.js"; +import { deleteGraphData } from "./section-13.3.js"; +import { CORE_A_STAGED } from "./section-13.5.js"; import { assertConditionCounts, + assertFindingConcernsPath, buildOk, expectExit, + expectFindingFreeReport, + readRecordedCompanionPaths, runCli, + runFindingsReport, runJson, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group, no -// other keys — the CONF-CORE workspace shape (CERTIFICATIONS.md). -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// other keys — the CONF-CORE workspace shape (CERTIFICATIONS.md). T13.4-3's +// second half and T13.4-6's journal-occupant, session-occupant, and +// linked-working-directory arms stage it in workspaces created after their +// body's first product invocation, so S-7's sweep never reaches those +// stagings against the stub: a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), staged at +// every site. +const SPECS_ONLY_CONFIG = stagedTs( + "T13.4-3/T13.4-6 xspec.config.ts — one spec group (T13.4-3's second half; T13.4-6's journal-occupant, session-occupant, and linked-working-directory arms)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus Markdown emission next to each source (SPEC 7.3), so // all four derived-file classes exist: module, companions, emitted Markdown, @@ -139,19 +344,11 @@ export default defineConfig({ // Importless, tagless `.mdx` sources (the CONF-CORE shape; fine everywhere // else too): `a` carries a child so `rename` exercises descendant rewriting; // `g` is a second top-level leaf whose audit item is unblocked (SPEC 10.6). -const A_MDX = [ - '<S id="a">', - "Alpha text.", - '<S id="a.k">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="g">', - "Gamma text.", - "</S>", - "", -].join("\n"); +// Byte for byte section-13.5.ts's CONF-CORE-shaped source, and staged here in +// workspaces created after a body's first invocation too (T13.4-3's second +// half, T13.4-6's later arms): that module's staged-source record (S-9's +// before-any-product clause; helpers/staged-mdx.ts), staged at every site. +const A_MDX = CORE_A_STAGED; const A_ROOT = "specs/A.mdx"; const JOURNAL_REL = ".xspec/journal"; @@ -266,6 +463,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, @@ -579,16 +789,21 @@ const T13_4_2 = defineProductTest({ "truncate", async (key) => { const bytes = bytesOf(key); + // S-9: a derived file is no source — no discovery reaches it + // (13.4) — and these are edits of product-written bytes, so + // the TypeScript well-formedness of a truncated module or + // companion is undeclared. await workspace.file( key, bytes.subarray(0, Math.floor(bytes.length / 2)), + { ts: "unchecked" }, ); }, ], [ "garbage-overwrite", async (key) => { - await workspace.file(key, GARBAGE_BYTES); + await workspace.file(key, GARBAGE_BYTES, { ts: "unchecked" }); }, ], ]; @@ -624,53 +839,259 @@ const T13_4_2 = defineProductTest({ // The narrowed configuration: B.mdx no longer belongs to any group, so B's // derived files are no longer generated (a literal path is a valid glob, -// SPEC 7). -const A_ONLY_CONFIG = `import { defineConfig } from "xspec" +// SPEC 7). Staged by `file()` after each half's initial build: a TypeScript +// staged-source record (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses). +const A_ONLY_CONFIG = stagedTs( + "T13.4-3 xspec.config.ts — narrowed to specs/A.mdx (each half's configuration change after its build)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/A.mdx"] } }) -`; +`, +); -const T13_4_3 = defineProductTest({ - id: "T13.4-3", - title: - "a derived file orphaned while the recorded derived-file paths were missing is outside xspec's knowledge: builds under the narrowed configuration leave it alone byte-exactly, and after manual deletion it stays gone (SPEC 13.4, 13.3, 12.1)", - run: async (product) => { - await withWorkspace( - { - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": A_MDX, - "specs/B.mdx": ['<S id="b">', "Beta text.", "</S>", ""].join("\n"), - }, +const A_MODULE_REL = "specs/A.xspec.ts"; +const B_DERIVED_PREFIX = "specs/B.xspec."; + +/** A snapshot's graph-data entries (T13.3-2's operational path set). */ +function graphDataEntries( + snapshot: DirectorySnapshot, +): Map<string, SnapshotEntry> { + return filteredEntries(snapshot.entries, (key) => isGraphDataKey(key)); +} + +/** + * One half of the orphan knowledge boundary (SPEC 13.4: a derived file + * orphaned while the record was itself missing or unreadable, 14.23): how + * the product-written record is put out of xspec's knowledge after the + * initial `build` and before the configuration change. + */ +interface OrphanBoundaryHalf { + readonly label: string; + readonly disturbRecord: ( + workspace: TestWorkspace, + tag: string, + ) => Promise<void>; +} + +/** + * The record is replaced by the successful `build` (SPEC 13.3, 14.23): + * asserted at the record-consulting surfaces — `check` exits 0 (the record + * readable and current: no condition-23 or graph-data staleness, and the + * orphan, unrecorded, names no stale file) and `inventory`'s `recorded` + * datum is the plain current generation, naming A's module and no B path + * (13.3: the paths of the derived files most recently generated) — both + * reads modifying nothing (13.3, 11.6). + */ +async function assertRecordReplaced( + product: ProductBinding, + workspace: TestWorkspace, + tag: string, +): Promise<void> { + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectExit( + product, + workspace, + ["check"], + 0, + `${tag} \`check\` after the narrowed build — clean: the successful ` + + `build replaced the record, so it is readable and current (no ` + + `condition-23 unit form, no graph-data staleness), and the ` + + `orphan, recorded nowhere, is outside \`check\`'s knowledge too ` + + `— no stale-file finding names it (SPEC 13.3, 13.4, 14.10, ` + + `14.23, 12.2)`, + ); + const context = `${tag} \`inventory\` after the narrowed build`; + const recorded = decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], context), + context, + ); + if (recorded.state !== "value") { + fail( + `${context}: the successful \`build\` replaces the record, so ` + + `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(recorded.state)}`, + ); + } + // ASCII paths are plain strings in every 12.7 path value (the decoder + // rejects a valid-UTF-8 path in byte form), so the string compares + // are exact. + const paths = recorded.value; + if (!paths.some((p) => typeof p === "string" && p === A_MODULE_REL)) { + fail( + `${context}: the replaced record is the current generation — it ` + + `names A's generated module ${JSON.stringify(A_MODULE_REL)} ` + + `(SPEC 13.3, 13.1, 11.6); got ${JSON.stringify(paths)}`, + ); + } + const strays = paths.filter( + (p) => typeof p === "string" && p.startsWith(B_DERIVED_PREFIX), + ); + if (strays.length > 0) { + fail( + `${context}: the replaced record holds the paths of the derived ` + + `files most recently generated — B.mdx is no longer a ` + + `configured source, so no B-derived path is recorded (the ` + + `orphans are outside xspec's knowledge, SPEC 13.3, 13.4); got ` + + JSON.stringify(strays), + ); + } + }, + `${tag}: \`check\` and \`inventory\` after the narrowed build modify ` + + `nothing — \`check\` never refreshes and \`inventory\` never writes ` + + `(SPEC 13.3, 11.6)`, + ); +} + +/** + * Walk one half of the boundary: build so B's derived files exist and are + * recorded; disturb the record; narrow the configuration so B's files are no + * longer generated; `build` — the record is replaced (`assertRecordReplaced`) + * and B's orphaned files, outside xspec's knowledge, survive byte-exactly; a + * subsequent `build` leaves them alone; deleted manually, they stay gone. + * Returns the graph data as the narrowed `build` wrote it, for the + * cross-half byte compare. + */ +async function walkOrphanBoundary( + product: ProductBinding, + half: OrphanBoundaryHalf, +): Promise<DirectorySnapshot> { + const tag = `T13.4-3 (${half.label})`; + return await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": A_MDX, + // The second half's workspace follows the first half's invocations: + // the staged-source record of these bytes (`B_MDX`, below). + "specs/B.mdx": B_MDX, }, - async (workspace) => { - // Build so B's derived files exist and are recorded (SPEC 13.3). - await buildOk(product, workspace, "T13.4-3 initial `build`"); - const s1 = await snapshotDirectory(workspace.root); - const bDerived = [ - ...filteredEntries(s1.entries, (key, entry) => { - return entry.kind === "file" && key.startsWith("specs/B.xspec."); - }).keys(), - ].sort(); - if (!bDerived.includes("specs/B.xspec.ts")) { + }, + async (workspace) => { + // Build so B's derived files exist and are recorded (SPEC 13.3). + await buildOk(product, workspace, `${tag} initial \`build\``); + const s1 = await snapshotDirectory(workspace.root); + const bDerived = [ + ...filteredEntries(s1.entries, (key, entry) => { + return entry.kind === "file" && key.startsWith(B_DERIVED_PREFIX); + }).keys(), + ].sort(); + if (!bDerived.includes("specs/B.xspec.ts")) { + fail( + `${tag}: staging premise — after \`build\`, B.mdx's generated ` + + `module specs/B.xspec.ts exists as a plain file (SPEC 13.1); ` + + `found B-derived files: ${JSON.stringify(bDerived)}`, + ); + } + + // Put the record out of xspec's knowledge — missing or unreadable — + // before the configuration change (SPEC 13.4, 14.23). + await half.disturbRecord(workspace, tag); + + // Narrow the configuration so B's files are no longer generated, then + // build: B's former derived files are orphaned, but the record that + // would identify them was missing or unreadable — they are outside + // xspec's knowledge and must not be removed; the build replaces the + // record. + await workspace.file("xspec.config.ts", A_ONLY_CONFIG); + await buildOk( + product, + workspace, + `${tag} \`build\` under the narrowed configuration (SPEC 12.1)`, + ); + const s2 = await snapshotDirectory(workspace.root); + for (const key of bDerived) { + const before = s1.entries.get(key); + const after = s2.entries.get(key); + if (before === undefined || before.kind !== "file") { + throw new Error(`${tag} internal error: no file entry for ${key}`); + } + if (after === undefined || after.kind !== "file") { fail( - "T13.4-3: staging premise — after `build`, B.mdx's generated " + - "module specs/B.xspec.ts exists as a plain file (SPEC 13.1); " + - `found B-derived files: ${JSON.stringify(bDerived)}`, + `${tag}: the orphaned derived file ${key} must survive the ` + + `build — it was orphaned while the recorded derived-file ` + + `paths were ${half.label === "missing record" ? "missing" : "unreadable"}, ` + + `so it is outside xspec's knowledge and is not removed ` + + `(SPEC 13.4, 13.3, 14.23); found ` + + `${after === undefined ? "absent" : after.kind}`, ); } + assertBytesEqual( + after.bytes, + before.bytes, + `${tag}: the orphaned derived file ${key} after the build — ` + + `left alone byte-exactly (SPEC 13.4)`, + ); + } + if (s2.entries.get(A_MODULE_REL)?.kind !== "file") { + fail( + `${tag}: ${A_MODULE_REL} must exist after the build — A.mdx is ` + + `still a configured source (SPEC 13.1, 12.1)`, + ); + } + await assertRecordReplaced(product, workspace, tag); + + // A subsequent build leaves the stray files (and everything else at + // the fixed point) alone. + await buildOk(product, workspace, `${tag} subsequent \`build\``); + const s3 = await snapshotDirectory(workspace.root); + assertSnapshotsEqual( + s2, + s3, + `${tag}: the workspace after a subsequent build vs before it — ` + + `subsequent builds leave the stray files alone (SPEC 13.4, 12.0)`, + ); + + // The orphans may be deleted manually; builds do not resurrect them + // (they are not generated by the current configuration and not + // recorded). + for (const key of bDerived) { + await fsp.rm(workspace.path(key), { force: true }); + } + await buildOk( + product, + workspace, + `${tag} \`build\` after the manual deletion (SPEC 12.1)`, + ); + const s4 = await snapshotDirectory(workspace.root); + const expected = filteredEntries(s3.entries, (key) => { + return !bDerived.includes(key); + }); + assertSnapshotsEqual( + asSnapshot(workspace.root, expected), + s4, + `${tag}: the workspace after deleting the strays and rebuilding — ` + + `the manually deleted orphans stay gone and nothing else changes ` + + `(SPEC 13.4, 12.0)`, + ); + return asSnapshot(workspace.root, graphDataEntries(s2)); + }, + ); +} - // Destroy the graph data — and with it the recorded derived-file - // paths (T13.3-2's operational definition: everything under .xspec/ - // except the durable journal and reviews/; neither exists here). +const T13_4_3 = defineProductTest({ + id: "T13.4-3", + title: + "a derived file orphaned while the recorded derived-file paths were missing (deleted) or unreadable (corrupted shape-blind before the configuration change) is outside xspec's knowledge: `build` under the narrowed configuration replaces the record — `check` clean, `recorded` the current generation, the graph data byte-identical across the two halves — and leaves the orphan alone byte-exactly, subsequent builds likewise, and after manual deletion it stays gone (SPEC 13.4, 13.3, 14.23, 12.1)", + run: async (product) => { + // The missing half: destroy the graph data — and with it the recorded + // derived-file paths (T13.3-2's operational definition: everything + // under .xspec/ except the durable journal and reviews/; neither + // exists here). + const missing = await walkOrphanBoundary(product, { + label: "missing record", + disturbRecord: async (workspace, tag) => { const xspecKind = await workspace.kind(".xspec"); if (xspecKind !== "dir") { fail( - "T13.4-3: staging premise — after `build`, the .xspec/ " + + `${tag}: staging premise — after \`build\`, the .xspec/ ` + `directory exists (SPEC 13.3); found ${xspecKind}`, ); } @@ -681,81 +1102,73 @@ const T13_4_3 = defineProductTest({ force: true, }); } + }, + }); - // Narrow the configuration so B's files are no longer generated, - // then build: B's former derived files are orphaned, but the record - // that would identify them is gone — they are outside xspec's - // knowledge and must not be removed. - await workspace.file("xspec.config.ts", A_ONLY_CONFIG); - await buildOk( - product, - workspace, - "T13.4-3 `build` under the narrowed configuration (SPEC 12.1)", - ); - const s2 = await snapshotDirectory(workspace.root); - for (const key of bDerived) { - const before = s1.entries.get(key); - const after = s2.entries.get(key); - if (before === undefined || before.kind !== "file") { - throw new Error(`T13.4-3 internal error: no file entry for ${key}`); - } - if (after === undefined || after.kind !== "file") { - fail( - `T13.4-3: the orphaned derived file ${key} must survive the ` + - `build — it was orphaned while the recorded derived-file ` + - `paths were missing, so it is outside xspec's knowledge and ` + - `is not removed (SPEC 13.4, 13.3); found ` + - `${after === undefined ? "absent" : after.kind}`, + // The unreadable half: corrupt the product-written record shape-blind + // (T6.6-6's staging; H-3 record-staging adapter — garbage over + // T13.3-2's operational path set, files present but readable as no + // record, SPEC 14.23). Premise, read through the record-consulting + // surface inside a whole-root compare: `inventory` exits 1 with + // `recorded` explicitly unavailable (T11.6-4's pin) and leaves the + // state intact (11.6: it neither refreshes nor writes), so the + // configuration change finds the record unreadable. + const unreadable = await walkOrphanBoundary(product, { + label: "unreadable record", + disturbRecord: async (workspace, tag) => { + await corruptGraphDataShapeBlind(workspace.root, `${tag} staging`); + const context = + `${tag} premise \`inventory\` with the record corrupted ` + + `shape-blind before the configuration change`; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["inventory"], + 1, + `${context} — an answer carrying the condition-23 finding ` + + `exits 1, emitted in full (SPEC 14.23, 12.0)`, ); - } - assertBytesEqual( - after.bytes, - before.bytes, - `T13.4-3: the orphaned derived file ${key} after the build — ` + - `left alone byte-exactly (SPEC 13.4)`, - ); - } - if (s2.entries.get("specs/A.xspec.ts")?.kind !== "file") { - fail( - "T13.4-3: specs/A.xspec.ts must exist after the build — A.mdx " + - "is still a configured source (SPEC 13.1, 12.1)", - ); - } - - // A subsequent build leaves the stray files (and everything else at - // the fixed point) alone. - await buildOk(product, workspace, "T13.4-3 subsequent `build`"); - const s3 = await snapshotDirectory(workspace.root); - assertSnapshotsEqual( - s2, - s3, - "T13.4-3: the workspace after a subsequent build vs before it — " + - "subsequent builds leave the stray files alone (SPEC 13.4, 12.0)", - ); - - // The orphans may be deleted manually; builds do not resurrect them - // (they are not generated by the current configuration and not - // recorded). - for (const key of bDerived) { - await fsp.rm(workspace.path(key), { force: true }); - } - await buildOk( - product, - workspace, - "T13.4-3 `build` after the manual deletion (SPEC 12.1)", - ); - const s4 = await snapshotDirectory(workspace.root); - const expected = filteredEntries(s3.entries, (key) => { - return !bDerived.includes(key); - }); - assertSnapshotsEqual( - asSnapshot(workspace.root, expected), - s4, - "T13.4-3: the workspace after deleting the strays and rebuilding " + - "— the manually deleted orphans stay gone and nothing else " + - "changes (SPEC 13.4, 12.0)", + const recorded = decodeInventoryRecordedDatum( + parseJsonStdout( + result, + `${context} — inventory is JSON-only: a single JSON ` + + `document as the entire stdout (SPEC 11, 12.0)`, + ), + context, + ); + if (recorded.state !== "unavailable") { + fail( + `${context}: staging premise — recorded state that exists ` + + `but cannot be read as a record reports \`recorded\` ` + + `explicitly unavailable (SPEC 14.23, 11.6, 12.7); got ` + + `state ${JSON.stringify(recorded.state)}`, + ); + } + }, + `${context}: modifies nothing — the corrupt state is neither ` + + `read, repaired, nor replaced by \`inventory\` (SPEC 11.6, 13.3)`, ); }, + }); + + // Cross-half compare, whole and opaque (H-4 self-comparison): derived + // output is a function of sources, configuration, and the journal alone + // (SPEC 13.4), identical across the halves — so the graph data the + // narrowed `build` wrote over the garbage is byte-identical to what it + // wrote over nothing: the record replaced with exactly what `build` + // writes, no garbage left beside it (13.3, 12.0). + assertSnapshotsEqual( + missing, + unreadable, + "T13.4-3: the graph data the narrowed `build` wrote over the " + + "unreadable record vs over the missing one — byte-identical: " + + "derived files are reproducible from sources, configuration, and " + + "the journal alone, identical across the halves, so a successful " + + "build replaces the unreadable record with exactly what it writes " + + "(SPEC 13.4, 13.3, 14.23, 12.0; H-4 self-comparison)", ); }, }); @@ -764,29 +1177,225 @@ const T13_4_3 = defineProductTest({ // T13.4-4 — derived paths belong to xspec // --------------------------------------------------------------------------- -const TARGET_REL = "target.txt"; -const TARGET_BYTES = - "harness-owned link target: nothing may ever be written through the link\n"; - // The common staging of the dirty workspace and its pristine reference // (module-header rationale: derived output is a function of sources, -// configuration, and the journal alone, SPEC 13.4). -const T13_4_4_COMMON: Readonly<Record<string, string>> = { +// configuration, and the journal alone, SPEC 13.4). The link arm's targets +// lie outside the workspace root (`stageLinkToOutsideFile`, below), so they +// take no part in the whole-workspace compare. +const T13_4_4_COMMON: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": MARKDOWN_CONFIG, "specs/A.mdx": A_MDX, - [TARGET_REL]: TARGET_BYTES, }; -/** `"../" × depth` up from a `/`-separated key's directory to the root. */ -function relativeTargetFrom(key: string, targetName: string): string { - const depth = key.split("/").length - 1; - return "../".repeat(depth) + targetName; +// Where the shared link staging puts its targets: a directory beside the +// workspace root, in the workspace's own temporary directory (`tempRoot`, +// whose `work/` is the root), disposed with it. +const OUTSIDE_LINK_TARGETS_DIR = "outside-link-targets"; + +/** + * A symbolic link the harness staged at a derived file's own path, resolving + * to a plain file outside the workspace root, with the target's bytes as + * captured before any product invocation over the staging. Exported with the + * staging for T6.5-21(d) (section-6.5-iv.ts). + */ +export interface OutsideFileLink { + /** The link's workspace-relative path: a derived file's own path. */ + readonly linkRel: string; + /** The target's absolute path, beside the workspace root. */ + readonly targetAbs: string; + /** The target's bytes, captured once the staging was complete. */ + readonly targetBefore: Uint8Array; +} + +/** + * The one link staging T13.4-4's link arm and T13.4-11(c) share: the + * occupant at a derived file's own path (the file a build put there) is + * replaced by a symbolic link resolving to a fresh plain file outside the + * workspace root, and the target's bytes are captured before the product + * runs over the staging; `assertOutsideLinkTargetUnchanged` compares them + * afterward. CERTIFICATIONS.md certifies this staging through T13.4-11(c) + * (VIOL-ORPHAN-LINKTARGET), and T13.4-4's link arm rides that certification + * insofar as it shares this staging (the Exclusions entry "T13.4-4's link + * arm" and the violator's note) — so both stage through this one function, + * and so does T6.5-21(d)'s link occupant (section-6.5-iv.ts), which rides + * the certification likewise, insofar as it shares this staging and its + * after-compare. The staging verifies itself — the path holds a symbolic + * link resolving to the target — and a staging that misses is an internal + * harness error, never a product verdict. The link stores a relative + * target; `targetName` names the target file, unique within the workspace. + */ +export async function stageLinkToOutsideFile( + workspace: TestWorkspace, + linkRel: string, + targetName: string, +): Promise<OutsideFileLink> { + const targetAbs = path.join( + workspace.tempRoot, + OUTSIDE_LINK_TARGETS_DIR, + targetName, + ); + await fsp.mkdir(path.dirname(targetAbs), { recursive: true }); + await fsp.writeFile( + targetAbs, + `harness-owned link target outside the workspace root, linked from ` + + `${linkRel}: never written through, never removed\n`, + { flag: "wx" }, + ); + const linkAbs = workspace.path(linkRel); + await fsp.rm(linkAbs, { force: true }); + await workspace.symlink( + linkRel, + path.relative(path.dirname(linkAbs), targetAbs), + "file", + ); + if ((await workspace.kind(linkRel)) !== "symlink") { + throw new Error( + `internal error: failed to stage a symbolic link at ${linkRel}`, + ); + } + const [resolved, expected] = await Promise.all([ + fsp.realpath(linkAbs), + fsp.realpath(targetAbs), + ]); + if (resolved !== expected) { + throw new Error( + `internal error: the symbolic link staged at ${linkRel} resolves to ` + + `${resolved}, not to its target ${expected}`, + ); + } + return { linkRel, targetAbs, targetBefore: await fsp.readFile(targetAbs) }; +} + +/** + * The shared link staging's after-compare: the target outside the workspace + * root is still a plain file holding exactly the bytes captured before the + * product ran — nothing written through the link, and the target never + * removed in the link's place (SPEC 13.4: writes never traverse symbolic + * links; a removal removes a symbolic link itself, never its target). + */ +export async function assertOutsideLinkTargetUnchanged( + link: OutsideFileLink, + context: string, +): Promise<void> { + let kind: "file" | "dir" | "symlink" | "other" | "absent"; + try { + const stats = await fsp.lstat(link.targetAbs); + kind = stats.isSymbolicLink() + ? "symlink" + : stats.isFile() + ? "file" + : stats.isDirectory() + ? "dir" + : "other"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + kind = "absent"; + } + if (kind !== "file") { + fail( + `${context}: the target of the symbolic link staged at ` + + `${link.linkRel} — a plain file outside the workspace root, at ` + + `${link.targetAbs} — must still be that plain file, byte-identical ` + + `(SPEC 13.4: a symbolic link itself, never its target; nothing is ` + + `ever written through a link); found ${kind}`, + ); + } + assertBytesEqual( + await fsp.readFile(link.targetAbs), + link.targetBefore, + `${context}: the target of the symbolic link staged at ` + + `${link.linkRel}, outside the workspace root, byte-identical (SPEC ` + + `13.4: nothing is ever written through a link, and a removal removes ` + + `the link itself, never its target)`, + ); +} + +// The directory-occupant stagings (T13.4-4's arm 3): a directory at each of +// the two derived paths SPEC pins for `specs/A.mdx` — the generated module's +// (13.1: `NAME.xspec.ts` in the source's directory) and the emitted +// Markdown's (13.2, 7.3: next to the source under MARKDOWN_CONFIG) — +// holding nothing xspec discovers or generates: empty in one workspace, and +// in a second each holding `notes.txt`, a file no group matches (the one +// spec group globs `.mdx` names, no code group is configured, and the name +// holds no `.xspec.`, so no exclusion is involved either, 13.4). Neither +// path is a directory component of a discovered source's path or of another +// derived path, so 14.22's refusal does not reach it (T13.4-9 stages the +// paths it does reach): writing the derived file replaces the directory +// (13.4). Both workspaces are staged before the body's first product +// invocation, so their plain configuration is no undeclared staging (S-9's +// timing clause). +const T13_4_4_DIRECTORY_PATHS = ["specs/A.xspec.ts", "specs/A.md"] as const; + +/** One directory-occupant staging of T13.4-4's arm 3. */ +interface DirectoryOccupantStaging { + /** The staging's tag in assertion contexts. */ + readonly tag: string; + /** The common staging plus the directories at the derived paths. */ + readonly decl: WorkspaceDecl; + /** The one file each directory holds, by name; none when empty. */ + readonly held: string | undefined; +} + +const T13_4_4_HELD_NAME = "notes.txt"; + +const T13_4_4_DIRECTORY_STAGINGS: readonly DirectoryOccupantStaging[] = [ + { + tag: "empty directories", + decl: { files: T13_4_4_COMMON, dirs: T13_4_4_DIRECTORY_PATHS }, + held: undefined, + }, + { + tag: "directories each holding a file no group matches", + decl: { + files: { + ...T13_4_4_COMMON, + ...Object.fromEntries( + T13_4_4_DIRECTORY_PATHS.map((dir) => [ + `${dir}/${T13_4_4_HELD_NAME}`, + `user notes in the directory at ${dir}: no group matches them\n`, + ]), + ), + }, + }, + held: T13_4_4_HELD_NAME, + }, +]; + +/** + * A directory-occupant staging verifies itself before any product + * invocation: each derived path holds a real directory holding exactly the + * staged plain file, or nothing. A staging that misses is an internal + * harness error, never a product verdict. + */ +async function verifyDirectoryOccupants( + workspace: TestWorkspace, + staging: DirectoryOccupantStaging, +): Promise<void> { + const expected = staging.held === undefined ? [] : [staging.held]; + for (const dir of T13_4_4_DIRECTORY_PATHS) { + const names = + (await workspace.kind(dir)) === "dir" + ? await workspace.readdirNames(dir) + : undefined; + const heldKinds = await Promise.all( + expected.map((name) => workspace.kind(`${dir}/${name}`)), + ); + if ( + names === undefined || + names.join("/") !== expected.join("/") || + heldKinds.some((kind) => kind !== "file") + ) { + throw new Error( + `internal error: failed to stage ${staging.tag} at ${dir}`, + ); + } + } } const T13_4_4 = defineProductTest({ id: "T13.4-4", title: - "a user-created file at a derived path is replaced by `build`, and 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 the build, link gone, plain file present, no error (SPEC 13.4, 12.1)", + "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 the build, link gone, plain file present, no error; and a directory at a derived path holding nothing xspec discovers or generates — empty, and separately holding a file no group matches — is replaced by the derived file: `build` exits 0, a plain file there, `check` clean (SPEC 13.4, 12.1; 14.22's refusal reaching only a directory component of a discovered source's or another derived path)", run: async (product) => { const reference = await TestWorkspace.create({ files: T13_4_4_COMMON }); const dirty = await TestWorkspace.create({ @@ -796,8 +1405,24 @@ const T13_4_4 = defineProductTest({ "specs/A.xspec.ts": "user content at the generated module's path\n", "specs/A.md": "user content at the emitted Markdown's path\n", }, + // S-9: the noise at the generated module's path is no code source — + // a derived path no discovery reaches (13.4), its well-formedness + // undeclared. + ts: { unchecked: ["specs/A.xspec.ts"] }, }); + const directoryArms: { + readonly staging: DirectoryOccupantStaging; + readonly workspace: TestWorkspace; + }[] = []; try { + // Arm 3's workspaces, staged and verified before the body's first + // product invocation (S-9's timing clause). + for (const staging of T13_4_4_DIRECTORY_STAGINGS) { + const workspace = await TestWorkspace.create(staging.decl); + directoryArms.push({ staging, workspace }); + await verifyDirectoryOccupants(workspace, staging); + } + // The pristine reference build fixes the expected byte tree. await buildOk(product, reference, "T13.4-4 reference `build`"); const sr = await snapshotDirectory(reference.root); @@ -861,18 +1486,21 @@ const T13_4_4 = defineProductTest({ ); // Arm 2 — symbolic links at derived files' own paths: one per derived - // class. Each link resolves to the harness's target file; a product - // writing through a link modifies the target, a product refusing - // errors out, and a conforming product replaces the link itself. + // class, each through the shared link staging (T13.4-11(c)'s), so + // each resolves to its own plain file outside the workspace root; a + // product writing through a link modifies its target, a product + // refusing errors out, and a conforming product replaces the link + // itself. const linkKeys = ["specs/A.xspec.ts", "specs/A.md", graphKey] as const; - for (const key of linkKeys) { - await fsp.rm(dirty.path(key), { force: true }); - await dirty.symlink(key, relativeTargetFrom(key, TARGET_REL)); - if ((await dirty.kind(key)) !== "symlink") { - throw new Error( - `T13.4-4 internal error: failed to stage a symlink at ${key}`, - ); - } + const links: OutsideFileLink[] = []; + for (const [index, key] of linkKeys.entries()) { + links.push( + await stageLinkToOutsideFile( + dirty, + key, + `T13.4-4-target-${String(index)}.txt`, + ), + ); } await buildOk( product, @@ -890,23 +1518,74 @@ const T13_4_4 = defineProductTest({ ); } } - assertBytesEqual( - await dirty.readBytes(TARGET_REL), - TARGET_BYTES, - "T13.4-4 (symlink occupants): the link target after `build` — " + - "nothing is ever written through the link (SPEC 13.4)", - ); + for (const link of links) { + await assertOutsideLinkTargetUnchanged( + link, + "T13.4-4 (symlink occupants): the link target after `build` — " + + "nothing is ever written through the link (SPEC 13.4)", + ); + } const sw2 = await snapshotDirectory(dirty.root); assertSnapshotsEqual( sr, sw2, "T13.4-4 (symlink occupants): the workspace after `build` vs the " + "pristine reference — every derived path holds its generated " + - "plain file and the target is untouched (SPEC 13.4, 12.0)", + "plain file (SPEC 13.4, 12.0)", ); + + // Arm 3 — a directory at each derived path SPEC pins, holding nothing + // xspec discovers or generates: empty, and separately each holding a + // file no group matches. The first `build` replaces each directory + // with the derived file and exits 0 (13.4: writing a derived file + // replaces whatever exists at its path; 14.22's refusal reaches only + // a path that is a directory component of a discovered source's path + // or of another derived path, T13.4-9); a plain file stands there, + // the workspace equals the pristine reference — nothing of the + // directory left — and `check` is clean. + for (const { staging, workspace } of directoryArms) { + const tag = `T13.4-4 (${staging.tag})`; + await buildOk( + product, + workspace, + `${tag}: \`build\` over directories at the derived paths ` + + `${T13_4_4_DIRECTORY_PATHS.join(" and ")}, holding nothing ` + + `xspec discovers or generates — each replaced by the derived ` + + `file, not refused (SPEC 13.4; 14.22 reaches only a directory ` + + `component of a discovered source's or another derived path)`, + ); + for (const key of T13_4_4_DIRECTORY_PATHS) { + const kind = await workspace.kind(key); + if (kind !== "file") { + fail( + `${tag}: after \`build\`, ${key} must be a plain file — the ` + + `derived file replaces the directory staged there (SPEC ` + + `13.4: writing a derived file replaces whatever exists at ` + + `its path); found ${kind}`, + ); + } + } + assertSnapshotsEqual( + sr, + await snapshotDirectory(workspace.root), + `${tag}: the workspace after \`build\` vs the pristine reference ` + + `— each directory replaced by the derived file, nothing of it ` + + `left (SPEC 13.4, 12.0)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${tag}: \`check --json\` after the build — clean (SPEC 13.4, ` + + `14.10)`, + ); + } } finally { await reference.dispose(); await dirty.dispose(); + for (const { workspace } of directoryArms) { + await workspace.dispose(); + } } }, }); @@ -1126,7 +1805,13 @@ const T13_4_5 = defineProductTest({ // Markdown redirected into `out`, which the fixture stages as a symbolic // link to a real directory inside the workspace (SPEC 7.3; module header). -const OUT_CONFIG = `import { defineConfig } from "xspec" +// The arms after the first stage it in workspaces created after the body's +// first product invocation: a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), staged at +// every site. +const OUT_CONFIG = stagedTs( + "T13.4-6 xspec.config.ts — Markdown emission under outDir out (the occupant and cardinality arms)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1134,7 +1819,26 @@ export default defineConfig({ }, markdown: { emit: true, outDir: "out" } }) -`; +`, +); + +// 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). Every staging +// of it — the cardinality arms, T13.4-3's orphan-boundary halves, T13.4-8's +// emission arm, T13.4-11's order-independence arm and its twin — is in a +// workspace created after its body's first invocation (T13.4-3's first half +// aside), or added after an invocation in its own, so it is one +// staged-source record (S-9's before-any-product clause; +// helpers/staged-mdx.ts). +const B_MDX = stagedMdx( + "T13.4-3/T13.4-6/T13.4-8/T13.4-11 the minimal section b (specs/B.mdx, T13.4-11's order-independence arm and its twin included; T13.4-6's specs/two/B.mdx; T13.4-8's specs/sub/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 @@ -1154,10 +1858,108 @@ 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. The set is exact on both sides: `build` cannot + * observe 14.10 (SPEC 12.1), and `check`, on a workspace whose `build` is + * refused — one failing `build`'s validations (SPEC 13.3) — leaves 14.10's + * mismatch forms unreported and has no record for its whatever-validity + * forms (SPEC 14.10; module header); `command` picks the failure wording. + */ +function assertObstructionFindings( + findings: readonly Finding[], + components: readonly string[], + command: "build" | "check", + 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; + fail( + `${context}: beside the staged condition-22 finding(s), ` + + (command === "check" + ? `nothing else is reportable: the refused write fails ` + + `\`build\`'s validations, so 14.10's mismatch forms — the ` + + `never-generated derived files, the absent graph data — go ` + + `unreported, and no record exists for its whatever-validity ` + + `forms (SPEC 14.10, 13.3, 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, + 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 +1971,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 +2344,1609 @@ 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). The arm's workspace +// follows the body's first product invocation: a TypeScript staged-source +// record (helpers/staged-ts.ts; S-9's TypeScript and timing clauses). +const FRESH_GROUP_CONFIG = stagedTs( + "T13.4-8 section-form move arm xspec.config.ts — spec groups specs/** and fresh/**, Markdown emission on", + `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). The arm's workspace follows the body's first +// product invocation: a TypeScript staged-source record (S-9). +const NESTED_OUT_CONFIG = stagedTs( + "T13.4-8 emission arm xspec.config.ts — Markdown emission under the nonexistent nested outDir out/md", + `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). Byte for byte section-12.0-i.ts's minimal section a, +// and the emission arm's workspace staging it follows the file-form move's +// invocations: that staged-source record (S-9's before-any-product clause; +// helpers/staged-mdx.ts), its bytes compared through `.source`. +const RELOCATED_MDX = STREAMS_VALID_SOURCE; + +// 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. Its +// workspace follows the file-form move's invocations: a staged-source record. +const MOVED_CONSTRUCT = ['<S id="mv">', "Moved text.", "</S>"].join("\n"); +const SECTION_ORIGIN_MDX = stagedMdx( + "T13.4-8 specs/S.mdx (the section-form move's origin: stay, then the moved mv)", + ['<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.source, + "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)", + ); + } + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// T13.4-9 — derived paths above sources and other derived paths +// --------------------------------------------------------------------------- + +// T13.4-9's configurations (module header): one spec group, then a code +// group and Markdown emission where the staging states them. Every +// workspace but the body's first is created after a product invocation — +// the companion legs' after their scratch twins' builds as well — so each +// configuration is a TypeScript staged-source record (helpers/staged-ts.ts; +// S-9's TypeScript and timing clauses), staged at every site. +const RELATION_EMIT_CONFIG = stagedTs( + "T13.4-9 xspec.config.ts — specs/**/*.mdx, Markdown emitted next to sources ((a))", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`, +); +const RELATION_ROOT_OUT_CONFIG = stagedTs( + 'T13.4-9 xspec.config.ts — **/*.mdx, Markdown emitted under outDir "out" ((b) and (f), the sources at the workspace root)', + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["**/*.mdx"] + }, + markdown: { emit: true, outDir: "out" } +}) +`, +); +const RELATION_SPECS_CONFIG = stagedTs( + "T13.4-9 xspec.config.ts — specs/**/*.mdx alone, no Markdown emission ((c), (e)'s spec-source leg, and that leg's companion twin)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); +const RELATION_CODE_CONFIG = stagedTs( + "T13.4-9 xspec.config.ts — specs/**/*.mdx, a code group globbing specs/**/*.ts, Markdown emitted next to sources ((d), (e)'s code-source leg, and that leg's companion twin)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["specs/**/*.ts"] + }, + markdown: { emit: true } +}) +`, +); + +// T13.4-9's sources: minimal single-section spec sources — the relation +// reads paths alone, so their content is immaterial — and the code source +// TEST-SPEC states, `export const v = 1` (followed by U+000A, as +// T13.4-11(b)'s and T6.5-20's are), each the only file beneath the +// offending path where (d) and (e) stage it. +const RELATION_UPPER_MDX = stagedMdx( + "T13.4-9 the upper source — (a)'s and (d)'s specs/a.mdx, (b)'s and (f)'s a.mdx, (c)'s and (e)'s specs/A.mdx (and the companion twins')", + ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), +); +const RELATION_BENEATH_MDX = stagedMdx( + "T13.4-9 the source beneath the offending path — (a)'s specs/a.md/b.mdx, (b)'s and (f)'s a.md/b.mdx, (c)'s specs/A.xspec.ts/B.mdx, (e)'s specs/A.xspec.<suffix>/B.mdx", + ['<S id="b">', "Beta text.", "</S>", ""].join("\n"), +); +const RELATION_CODE_SOURCE = stagedTs( + "T13.4-9 the code source beneath the offending path, the only file there — (d)'s specs/a.md/x.ts, (e)'s specs/A.xspec.<suffix>/c.ts — export const v = 1", + "export const v = 1\n", +); + +/** One T13.4-9 staging (module header). */ +interface RelationStaging { + /** The staging's tag in assertion contexts. */ + readonly tag: string; + readonly config: StagedTs; + /** The initial files beside the configuration. */ + readonly files: Readonly<Record<string, InitialFileContents>>; + /** + * What occupies the offending path once the staging is complete: for the + * five stagings made before any build, the directory over what lies + * beneath it, or nothing ((b): no build has emitted anything); for (f), + * the plain file its premise `build` of `files` emitted. + */ + readonly occupant: "dir" | "absent" | "built-file"; + /** (f) alone: the sources staged after the premise `build`. */ + readonly added: Readonly<Record<string, StagedMdx>>; + /** The offending derived path: the one finding's concerned path. */ + readonly offending: string; +} + +/** (a) through (d), each staged before any build. */ +const RELATION_STAGINGS_A_TO_D: readonly RelationStaging[] = [ + { + tag: "(a) emission next to sources, specs/a.mdx beside specs/a.md/b.mdx — a.mdx's emit path specs/a.md a directory component of a discovered source's path (and of b.mdx's derived paths)", + config: RELATION_EMIT_CONFIG, + files: { + "specs/a.mdx": RELATION_UPPER_MDX, + "specs/a.md/b.mdx": RELATION_BENEATH_MDX, + }, + occupant: "dir", + added: {}, + offending: "specs/a.md", + }, + { + tag: '(b) emission under outDir "out", a.mdx and a.md/b.mdx at the workspace root — the emit path out/a.md a directory component of the emit path out/a.md/b.md', + config: RELATION_ROOT_OUT_CONFIG, + files: { "a.mdx": RELATION_UPPER_MDX, "a.md/b.mdx": RELATION_BENEATH_MDX }, + occupant: "absent", + added: {}, + offending: "out/a.md", + }, + { + tag: "(c) specs/A.mdx beside a discovered specs/A.xspec.ts/B.mdx — the module path specs/A.xspec.ts a directory component of a source's path", + config: RELATION_SPECS_CONFIG, + files: { + "specs/A.mdx": RELATION_UPPER_MDX, + "specs/A.xspec.ts/B.mdx": RELATION_BENEATH_MDX, + }, + occupant: "dir", + added: {}, + offending: "specs/A.xspec.ts", + }, + { + tag: "(d) emission next to sources, specs/a.mdx beside a discovered code source specs/a.md/x.ts, the only file beneath — the emit path specs/a.md a directory component of that source's path and of no derived path", + config: RELATION_CODE_CONFIG, + files: { + "specs/a.mdx": RELATION_UPPER_MDX, + "specs/a.md/x.ts": RELATION_CODE_SOURCE, + }, + occupant: "dir", + added: {}, + offending: "specs/a.md", + }, +]; + +/** + * (e)'s stagings, the companion leg, each staged before any build: per + * leg, the companion paths a build of `specs/A.mdx` records under that + * leg's configuration (`readRecordedCompanionPaths`: a scratch twin holding + * `specs/A.mdx`'s bytes at that path alone, built, its `inventory` + * `recorded` set read; none for a product writing no companions), one + * staging per companion path — beside a discovered `<companion>/B.mdx`, as + * (c) stages the module path, and beside a discovered code source + * `<companion>/c.ts`, the only file beneath, as (d) stages the emit path. + */ +async function relationCompanionStagings( + product: ProductBinding, +): Promise<readonly RelationStaging[]> { + const sourceLeg = await readRecordedCompanionPaths( + product, + RELATION_SPECS_CONFIG, + "specs/A.mdx", + RELATION_UPPER_MDX, + "T13.4-9 (e)'s companion paths of specs/A.mdx under (c)'s configuration", + ); + const codeLeg = await readRecordedCompanionPaths( + product, + RELATION_CODE_CONFIG, + "specs/A.mdx", + RELATION_UPPER_MDX, + "T13.4-9 (e)'s companion paths of specs/A.mdx under (d)'s configuration", + ); + return [ + ...sourceLeg.map((companion): RelationStaging => ({ + tag: `(e) specs/A.mdx beside a discovered ${companion}/B.mdx — the companion path ${companion} a directory component of a source's path and of B.mdx's derived paths`, + config: RELATION_SPECS_CONFIG, + files: { + "specs/A.mdx": RELATION_UPPER_MDX, + [`${companion}/B.mdx`]: RELATION_BENEATH_MDX, + }, + occupant: "dir", + added: {}, + offending: companion, + })), + ...codeLeg.map((companion): RelationStaging => ({ + tag: `(e) specs/A.mdx beside a discovered code source ${companion}/c.ts, the only file beneath — the companion path ${companion} a directory component of that source's path`, + config: RELATION_CODE_CONFIG, + files: { + "specs/A.mdx": RELATION_UPPER_MDX, + [`${companion}/c.ts`]: RELATION_CODE_SOURCE, + }, + occupant: "dir", + added: {}, + offending: companion, + })), + ]; +} + +/** + * (f): (b)'s staging after a `build` of `a.mdx` alone, `a.md/b.mdx` added + * after it — `out/a.md`, the plain file that build emitted, occupies a + * directory component of the write path `out/a.md/b.md` (T13.4-6's + * relation) while being the derived path the relation between derived + * paths names: one offending component under both (14.22). + */ +const RELATION_STAGING_F: RelationStaging = { + tag: "(f) (b)'s staging after a build of a.mdx alone, a.md/b.mdx added after it — out/a.md the plain file that build emitted, offending under both relations at one component", + config: RELATION_ROOT_OUT_CONFIG, + files: { "a.mdx": RELATION_UPPER_MDX }, + occupant: "built-file", + added: { "a.md/b.mdx": RELATION_BENEATH_MDX }, + offending: "out/a.md", +}; + +/** The commands T13.4-9 drives on every staging, in order. */ +const RELATION_COMMANDS = ["build", "check", "ids"] as const; +type RelationCommand = (typeof RELATION_COMMANDS)[number]; + +/** Each command's part in the contract, for the exit-code failure. */ +const RELATION_ROLE: Readonly<Record<RelationCommand, string>> = { + build: "`build` refuses the write and reports it before making any write", + check: "`check` reports it without writing", + ids: "the gated read reports it and answers nothing (T13.3-3)", +}; + +/** Why nothing beside the one finding is reportable, per command. */ +const RELATION_EXACTNESS: Readonly<Record<RelationCommand, string>> = { + build: + "the workspace otherwise passes `build`'s validations, and `build` " + + "cannot observe 14.10 (SPEC 12.1)", + check: + "on a workspace failing `build`'s validations 14.10's mismatch forms " + + "go unreported, and neither whatever-validity form is staged — no " + + "record exists before any build, and (f)'s names `a.mdx`'s derived " + + "paths, every one still generated (SPEC 14.10, 13.3)", + ids: + "the gate is over exactly the findings a `build` would report (SPEC " + + "13.3)", +}; + +/** What the whole-root compare around each command pins. */ +const RELATION_UNCHANGED: Readonly<Record<RelationCommand, string>> = { + build: + "`build` refuses before any write — no module, companion, Markdown, " + + "or graph data written or removed, the directory and everything " + + "beneath it untouched (SPEC 14.22, 13.4, 12.1)", + check: "`check` reports without writing (SPEC 14.22, 12.2)", + ids: "the gated read modifies nothing (SPEC 13.3)", +}; + +/** + * T13.4-9's contract for one command on a completed staging (module + * header): inside a whole-root compare, exit 1 and the form-exact 12.7 + * findings-only report holding exactly one finding — condition 22 + * concerning the offending derived path, `locations` `[]` — and nothing + * beside it (SPEC 14.22, 13.4, 13.3, 12.7). + */ +async function expectRelationReport( + product: ProductBinding, + workspace: TestWorkspace, + command: RelationCommand, + offending: 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} — a derived path above a source or another derived ` + + `path is a condition-22 finding: ${RELATION_ROLE[command]}, exit ` + + `1, never a success or a crash (SPEC 14.22, 13.4, 13.3, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + `${context} — the form-exact 12.7 findings-only report, answering ` + + `nothing (SPEC 12.7, 13.3, H-3)`, + ).findings; + assertConditionCounts( + findings, + { "14.22": 1 }, + `${context} — exactly one condition-22 finding and nothing beside ` + + `it: one finding per distinct offending path, whatever write ` + + `paths it refuses and whichever relations it meets (SPEC 14.22); ` + + RELATION_EXACTNESS[command], + ); + const finding = findings[0]!; + assertFindingConcernsPath( + finding, + offending, + `${context} — the finding concerns the offending derived path ` + + `(SPEC 14.22, 13.4)`, + ); + if (finding.locations.length !== 0) { + fail( + `${context} — the condition-22 finding concerns a path, so its ` + + `\`locations\` is [] (SPEC 14.22, 12.7); got ` + + JSON.stringify( + finding.locations.map((location) => ({ + file: renderPathValue(location.file), + range: location.range, + })), + ), + ); + } + }, + `${context} — ${RELATION_UNCHANGED[command]}`, + ); +} + +/** + * Stage one T13.4-9 staging in a fresh workspace (H-1) and drive `build`, + * `check`, and `ids` on it. A staging made before any build is verified + * before the first invocation — the offending path holding a directory or + * nothing, a miss being a harness error; (f)'s premise is re-pinned after + * its `build` — `out/a.md` the plain file that build emitted, a product + * writing none there failing diagnosed — before `added` is staged. + */ +async function runRelationStaging( + product: ProductBinding, + staging: RelationStaging, +): Promise<void> { + const context = `T13.4-9 ${staging.tag}`; + await withWorkspace( + { files: { "xspec.config.ts": staging.config, ...staging.files } }, + async (workspace) => { + if (staging.occupant === "built-file") { + await buildOk( + product, + workspace, + `${context}: the premise \`build\`, before ` + + `${Object.keys(staging.added).join(", ")} is added — the ` + + `staged workspace passes \`build\`'s validations, exit 0 (SPEC ` + + `12.1)`, + ); + const kind = await workspace.kind(staging.offending); + if (kind !== "file") { + fail( + `${context}: staging premise — after the premise \`build\`, ` + + `${staging.offending} is the plain file that build emitted ` + + `(SPEC 7.3, 13.2, 13.4); found ${kind}`, + ); + } + for (const [rel, source] of Object.entries(staging.added)) { + await workspace.file(rel, source); + } + } else { + const kind = await workspace.kind(staging.offending); + if (kind !== staging.occupant) { + throw new Error( + `internal error: ${context} — staged before any build, ` + + `${staging.offending} must hold ` + + `${staging.occupant === "dir" ? "a directory" : "nothing"}; ` + + `found ${kind}`, + ); + } + } + for (const command of RELATION_COMMANDS) { + await expectRelationReport( + product, + workspace, + command, + staging.offending, + context, + ); + } + }, + ); +} + +const T13_4_9 = defineProductTest({ + id: "T13.4-9", + title: + "derived paths above sources and other derived paths: a module, companion, or emitted Markdown path that is a directory component of a discovered source's path or of another such path xspec writes, occupied or not, is refused before any write — six stagings, each otherwise passing `build`'s validations, the first five staged before any build: (a) emission next to sources, `specs/a.mdx` beside `specs/a.md/b.mdx`; (b) under `outDir: \"out\"`, `a.mdx` and `a.md/b.mdx` at the workspace root, `out/a.md` above `out/a.md/b.md`; (c) `specs/A.mdx` beside `specs/A.xspec.ts/B.mdx`; (d) `specs/a.mdx` beside a code source `specs/a.md/x.ts`, the only file beneath; (e) per companion path `specs/A.xspec.<suffix>` a build of `specs/A.mdx` records (read from a scratch twin's `inventory`), `specs/A.mdx` beside `<companion>/B.mdx` and, separately, beside a code source `<companion>/c.ts`; (f) (b)'s staging after a `build` of `a.mdx` alone, `out/a.md` then the plain file it emitted — in each, `build` exits 1 with exactly one condition-22 finding concerning the offending derived path (`specs/a.md`, `out/a.md`, `specs/A.xspec.ts`, `specs/a.md`, the companion path, `out/a.md`), `locations` `[]`, writing and removing nothing; `check` reports the same finding without writing; `ids` reports it, exit 1, answering nothing (SPEC 13.4, 14.22, 13.3)", + run: async (product) => { + for (const staging of RELATION_STAGINGS_A_TO_D) { + await runRelationStaging(product, staging); + } + for (const staging of await relationCompanionStagings(product)) { + await runRelationStaging(product, staging); + } + await runRelationStaging(product, RELATION_STAGING_F); + }, +}); + +// --------------------------------------------------------------------------- +// T13.4-10 — rebuild-obstructing orphans +// --------------------------------------------------------------------------- + +// T13.4-10's configurations (module header): one spec group globbing +// `specs/*.mdx`, Markdown emitted under `outDir: "out"`, then `outDir` +// reconfigured to "out/specs/A.md". Each arm's reconfiguration is staged +// after its initial `build`, and the unrecorded twin's workspace is created +// after the body's first product invocation, so the configurations and the +// source are staged-source records (helpers/staged-ts.ts, +// helpers/staged-mdx.ts; S-9's TypeScript and timing clauses), staged at +// every site. +const OBSTRUCTION_OUT_CONFIG = stagedTs( + 'T13.4-10 xspec.config.ts — specs/*.mdx, Markdown emitted under outDir "out" (the initial build of the recorded arm and of the unrecorded twin)', + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + markdown: { emit: true, outDir: "out" } +}) +`, +); +const OBSTRUCTION_RECONFIGURED_CONFIG = stagedTs( + 'T13.4-10 xspec.config.ts — specs/*.mdx, outDir reconfigured to "out/specs/A.md" (both arms, staged after the initial build)', + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + markdown: { emit: true, outDir: "out/specs/A.md" } +}) +`, +); +const OBSTRUCTION_A_MDX = stagedMdx( + "T13.4-10 specs/A.mdx (the trivial single-section a: the recorded arm and the unrecorded twin)", + ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), +); + +/** The orphan: the initial build's emit path, no longer generated. */ +const OBSTRUCTION_ORPHAN = "out/specs/A.md"; +/** The reconfigured emit path, below the orphan (SPEC 7.3, 13.2). */ +const OBSTRUCTION_EMIT = "out/specs/A.md/specs/A.md"; + +/** + * The recorded-file finding concerning the obstructing orphan instructs its + * manual deletion, never a rebuild: the rebuild that removes a recorded + * file no longer generated is the very write the orphan obstructs, refused + * (SPEC 14.10, 13.4, 14.22; H-3's robust matching, judged by the pure + * `judgeManualDeletionCorrection` of the human-report adapter, whose S-5 + * vectors drive it apart from any product). + */ +function assertManualDeletionCorrection( + finding: Finding, + context: string, +): void { + const judged = judgeManualDeletionCorrection( + finding.message, + OBSTRUCTION_ORPHAN, + ); + if (judged.verdict === "rebuild-remedy") { + fail( + `${context} — the recorded-file finding concerning ` + + `${OBSTRUCTION_ORPHAN} instructs its manual deletion, never a ` + + `rebuild: the rebuild is refused while the orphan obstructs its ` + + `write, so it removes nothing (SPEC 14.10, 13.4, 14.22; H-3's ` + + `robust matching: no clause presenting a build as what removes the ` + + `file, ${judged.pattern} matched ${JSON.stringify(judged.clause)}); ` + + `got ${JSON.stringify(finding.message)}`, + ); + } + if (judged.verdict === "absent") { + fail( + `${context} — the recorded-file finding concerning ` + + `${OBSTRUCTION_ORPHAN} instructs the file's manual deletion, a ` + + `rebuild being refused while it obstructs the write (SPEC 14.10, ` + + `13.4, 14.22): required information missing from the human report ` + + `(H-3: information presence, never exact wording) — neither a ` + + `deletion or removal word beside its manual character nor an ` + + `instruction to the reader to delete or remove the file; got ` + + `${JSON.stringify(finding.message)}`, + ); + } +} + +/** One T13.4-10 arm: the recorded orphan, or its unrecorded twin. */ +interface ObstructionArm { + /** Diagnostic tag. */ + readonly tag: string; + /** + * Whether the record lists the orphan: graph data as the initial build + * wrote it, or deleted before the reconfiguration (T13.3-2's operational + * definition) — the orphan then outside xspec's knowledge (T13.4-3). + */ + readonly recorded: boolean; +} + +const OBSTRUCTION_ARMS: readonly ObstructionArm[] = [ + { tag: "T13.4-10 (the recorded orphan)", recorded: true }, + { + tag: "T13.4-10 (the unrecorded twin: graph data deleted before the reconfiguration)", + recorded: false, + }, +]; + +/** + * Walk one arm in a fresh workspace (H-1): build under `outDir: "out"` + * (`out/specs/A.md` premised the plain file that build emitted), delete the + * graph data in the twin, reconfigure `outDir`, then `build` and `check` — + * each inside a whole-root compare — delete the orphan by hand, `build`, + * and `check` again (module header). + */ +async function walkObstructionArm( + product: ProductBinding, + arm: ObstructionArm, +): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": OBSTRUCTION_OUT_CONFIG, + "specs/A.mdx": OBSTRUCTION_A_MDX, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${arm.tag} initial \`build\` under outDir "out" (SPEC 12.1)`, + ); + await assertKindIs( + workspace, + OBSTRUCTION_ORPHAN, + "file", + `${arm.tag}: staging premise — the initial build emits ` + + `specs/A.mdx's Markdown at ${OBSTRUCTION_ORPHAN} as a plain file, ` + + `recording it (SPEC 13.2, 7.3, 13.3)`, + ); + if (!arm.recorded) { + await deleteGraphData( + workspace, + `${arm.tag}: deleting the graph data before the reconfiguration ` + + `(T13.3-2's operational definition)`, + ); + } + await workspace.file("xspec.config.ts", OBSTRUCTION_RECONFIGURED_CONFIG); + + const buildContext = `${arm.tag} \`build --json\` after the reconfiguration`; + await assertLeavesUnchanged( + workspace.root, + async () => { + const findings = await runFindingsReport( + product, + workspace, + ["build", "--json"], + 1, + `${buildContext} — the orphan ${OBSTRUCTION_ORPHAN} occupies a ` + + `directory component of the emit path ${OBSTRUCTION_EMIT}, ` + + `obstructing that write: the rebuild is refused, exit 1 (SPEC ` + + `13.4, 14.22, 12.1, 12.0)`, + ); + assertConditionCounts( + findings, + { "14.22": 1 }, + `${buildContext} — exactly one condition-22 finding and nothing ` + + `beside it: the workspace otherwise passes \`build\`'s ` + + `validations, and \`build\` cannot observe 14.10 (SPEC 14.22, ` + + `12.1)`, + ); + assertFindingConcernsPath( + findings[0]!, + OBSTRUCTION_ORPHAN, + `${buildContext} — the condition-22 finding concerns the ` + + `obstructing orphan (SPEC 14.22, 13.4)`, + ); + }, + `${buildContext} — the rebuild is refused before any write or ` + + `removal: the orphan, every other derived file, and graph data ` + + `byte-identical (SPEC 13.4, 14.22, 12.1)`, + ); + + const checkContext = `${arm.tag} \`check --json\` after the refused rebuild`; + await assertLeavesUnchanged( + workspace.root, + async () => { + const findings = await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs \`build\`'s validations, ` + + `the refused write among them, and exits 1 on any finding ` + + `(SPEC 12.2, 14.22, 12.0)`, + ); + assertConditionCounts( + findings, + arm.recorded ? { "14.22": 1, "14.10": 1 } : { "14.22": 1 }, + arm.recorded + ? `${checkContext} — the condition-22 finding and, beside it, ` + + `exactly one condition-10 finding, and nothing else: the ` + + `recorded-file form compares the record against the ` + + `generated paths on any workspace, while the mismatch ` + + `forms are undetectable on a workspace failing \`build\`'s ` + + `validations (SPEC 14.10, 14.22, 12.2)` + : `${checkContext} — the condition-22 finding alone: no ` + + `record lists ${OBSTRUCTION_ORPHAN}, so no recorded-file ` + + `finding, and the missing graph data is a mismatch form, ` + + `undetectable on a workspace failing \`build\`'s ` + + `validations (SPEC 14.10, 14.22, 13.4)`, + ); + assertFindingConcernsPath( + findings.find((finding) => finding.condition === "14.22")!, + OBSTRUCTION_ORPHAN, + `${checkContext} — the condition-22 finding concerns the ` + + `obstructing orphan (SPEC 14.22, 13.4)`, + ); + if (arm.recorded) { + const stale = findings.find( + (finding) => finding.condition === "14.10", + )!; + assertFindingConcernsPath( + stale, + OBSTRUCTION_ORPHAN, + `${checkContext} — the condition-10 finding is the ` + + `recorded-file form concerning ${OBSTRUCTION_ORPHAN}, a ` + + `recorded derived file remaining at a path no longer ` + + `generated, never a mismatch form (SPEC 14.10, 12.7)`, + ); + assertManualDeletionCorrection(stale, checkContext); + } + }, + `${checkContext} — \`check\` reports without writing, so the ` + + `orphan stays until it is deleted manually (SPEC 13.3, 13.4, 12.2)`, + ); + + await fsp.rm(workspace.path(OBSTRUCTION_ORPHAN)); + await buildOk( + product, + workspace, + `${arm.tag} \`build\` after ${OBSTRUCTION_ORPHAN} is deleted by hand ` + + `— nothing obstructs the write, exit 0 (SPEC 13.4, 12.1)`, + ); + await assertKindIs( + workspace, + OBSTRUCTION_EMIT, + "file", + `${arm.tag}: after the manual deletion, \`build\` writes ` + + `specs/A.mdx's Markdown at the reconfigured emit path ` + + `${OBSTRUCTION_EMIT} as a plain file (SPEC 13.2, 7.3, 13.4)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${arm.tag} \`check --json\` after the manual deletion and the ` + + `\`build\` — clean (SPEC 13.4, 14.10)`, + ); + }, + ); +} + +const T13_4_10 = defineProductTest({ + id: "T13.4-10", + title: + 'rebuild-obstructing orphans: an orphan, recorded or not, occupying a workspace-relative directory component of a path the rebuild writes obstructs that write — `build` with `markdown: { emit: true, outDir: "out" }` emits `specs/A.mdx`\'s Markdown at `out/specs/A.md`, then `outDir` is reconfigured to `"out/specs/A.md"`: `build` exits 1 with exactly one condition-22 finding concerning `out/specs/A.md`, modifying nothing; `check` exits 1 reporting that finding and exactly one condition-10 finding in the recorded-file form concerning `out/specs/A.md` whose correction is the file\'s manual deletion, never a rebuild, and no mismatch form; once the file is deleted by hand, `build` exits 0 writing `out/specs/A.md/specs/A.md` and `check` is clean; the unrecorded twin — graph data deleted before the reconfiguration — obstructs identically, and its `check` reports the condition-22 finding alone (SPEC 13.4, 14.22, 14.10, 12.1, 12.2, 13.5)', + run: async (product) => { + for (const arm of OBSTRUCTION_ARMS) { + await walkObstructionArm(product, arm); + } + }, +}); + +// --------------------------------------------------------------------------- +// T13.4-11 — removing recorded paths no longer generated +// --------------------------------------------------------------------------- + +// CERTIFICATIONS.md §CONF-ORPHAN's staging constraints: one spec group whose +// glob matches `.mdx` names alone, so no glob reaches a derived path while +// it is one and 13.4's source exclusion stays dormant; `markdown` emitting +// next to sources (under `outDir: "out"` in (d) and (e)), then reconfigured +// — emission disabled under either spelling 7.3 admits, or `outDir` +// changed; the code group arriving only in (b), as emission is disabled. +// +// Every configuration below is staged after the body's first product +// invocation — the later arms' initial ones in workspaces created after it, +// each arm's changed one by `file()` after its build, the +// order-independence arm's in both its workspaces — so S-7's sweep never +// reaches those stagings against the stub (T13.4-11 failing diagnosed +// against a product, its later arms are first reached in certification): +// TypeScript staged-source records (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), staged at every site, `OrphanArm` typing its +// configuration fields `StagedTs`. +const ORPHAN_EMIT_CONFIG = stagedTs( + "T13.4-11 xspec.config.ts — specs/*.mdx, Markdown emission next to sources (arms (a), (b), (c), and (f) build under it)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + markdown: { emit: true } +}) +`, +); + +// Emission disabled by `markdown` absent (SPEC 7.3): arms (a) and (c). +const ORPHAN_NO_MARKDOWN_CONFIG = stagedTs( + "T13.4-11 (a)/(c) xspec.config.ts — emission disabled by markdown absent", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + } +}) +`, +); + +// Emission disabled by `emit: false` (SPEC 7.3): arm (f). +const ORPHAN_EMIT_FALSE_CONFIG = stagedTs( + "T13.4-11 (f) xspec.config.ts — emission disabled by emit false", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + markdown: { emit: false } +}) +`, +); + +// Arm (b): emission disabled as a code group globbing `specs/*.md` is added +// (SPEC 7.2), so the recorded `specs/A.md` is a discovered code source once +// it is no emit destination (7.3, 13.4). +const ORPHAN_CODE_GROUP_CONFIG = stagedTs( + "T13.4-11 (b) xspec.config.ts — emission disabled as a code group globbing specs/*.md is added", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + code: { + app: ["specs/*.md"] + }, + markdown: { emit: false } +}) +`, +); + +// Arms (d) and (e): built under `outDir: "out"`, recording `out/specs/A.md`, +// then `outDir` changed to `"md"`. +const ORPHAN_OUT_CONFIG = stagedTs( + "T13.4-11 (d)/(e) xspec.config.ts — Markdown emission under outDir out (the initial build)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + markdown: { emit: true, outDir: "out" } +}) +`, +); +const ORPHAN_MD_CONFIG = stagedTs( + "T13.4-11 (d)/(e) xspec.config.ts — outDir changed to md", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + markdown: { emit: true, outDir: "md" } +}) +`, +); + +// The order-independence arm: `specs/**/*.mdx` reaches the nested source +// `specs/B.md/C.mdx` — still `.mdx` names alone. +const ORPHAN_NESTED_CONFIG = stagedTs( + "T13.4-11 order-independence arm xspec.config.ts — specs/**/*.mdx, Markdown emission next to sources (the arm's workspace and its twin)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`, +); + +// Trivial single-section sources (CERTIFICATIONS.md §CONF-ORPHAN: no +// imports, embeddings, comments, or props beyond `id`). Every arm after the +// first stages them in a workspace created after the body's first product +// invocation: staged-source records (S-9's before-any-product clause; +// helpers/staged-mdx.ts). The order-independence arm's `specs/B.mdx` is +// B_MDX, above. +const ORPHAN_A_MDX = stagedMdx( + "T13.4-11 specs/A.mdx (the trivial single-section a: arms (a) to (f))", + ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), +); +const ORPHAN_C_MDX = stagedMdx( + "T13.4-11 specs/B.md/C.mdx (the trivial single-section c: the order-independence arm's first build)", + ['<S id="c">', "Gamma text.", "</S>", ""].join("\n"), +); + +// Arm (b)'s well-formed TypeScript, overwriting the emitted `specs/A.md` after +// the body's first product invocation: a TypeScript staged-source record +// (S-9) — a discovered code source whose name the default does not reach +// (the code group globs `specs/*.md`), declared well-formed by the record, +// which makes the path judged. +const ORPHAN_CODE_SOURCE = stagedTs( + "T13.4-11 (b) specs/A.md — a discovered code source (export const n = 1) over the emitted Markdown", + "export const n = 1\n", +); +// Arm (a)'s file inside the directory replacing `specs/A.md` — no glob +// matches it. +const ORPHAN_DIR_FILE_REL = "specs/A.md/kept.txt"; +const ORPHAN_DIR_FILE_BYTES = "a file inside a directory at a recorded path\n"; +// Arm (e)'s foreign plain file `A.md` in the directory the link targets. +const ORPHAN_FOREIGN_BYTES = + "a foreign plain file no build wrote: nothing reads or removes it\n"; + +/** + * `check --json` where T13.4-11 leaves the accompanying findings unasserted + * — the graph-data unit form, unpinned (13.3, 14.10), and in (d) and (e) + * the per-file form of the fresh emit destination `md/specs/A.md`: exit 0 + * with the finding-free report or exit 1 with at least one finding (SPEC + * 12.2, 12.0: `check` exits 1 on any finding), the report decoded + * form-exact either way (H-3). Returns the findings. + */ +async function orphanCheckFindings( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<readonly Finding[]> { + const result = await runCli(product, workspace, ["check", "--json"]); + if ( + result.signal !== null || + (result.exitCode !== 0 && result.exitCode !== 1) + ) { + fail( + `${context}: \`check\` on a workspace passing \`build\`'s validations ` + + `exits 0 when clean and 1 on any finding — never another code ` + + `(SPEC 12.2, 12.0); got ${summarizeResult(result)}`, + ); + } + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + context, + ).findings; + if ((result.exitCode === 0) !== (findings.length === 0)) { + fail( + `${context}: \`check\` exits 1 exactly when it reports a finding ` + + `(SPEC 12.2, 12.0); got exit ${String(result.exitCode)} with ` + + `${String(findings.length)} finding(s)`, + ); + } + return findings; +} + +/** The condition-10 findings concerning one workspace-relative path. */ +function staleFindingsConcerning( + findings: readonly Finding[], + rel: string, +): Finding[] { + return findings.filter((finding) => { + return finding.condition === "14.10" && finding.path === rel; + }); +} + +/** + * One of T13.4-11's arms (a)–(f): the build it starts from, the recorded + * path its change leaves no longer generated, the change itself, and what + * the first `check` reports concerning that path. + */ +interface OrphanArm { + /** Diagnostic tag, e.g. "T13.4-11 (a) a directory". */ + readonly tag: string; + /** The configuration of the initial build (a staged-source record). */ + readonly builtConfig: StagedTs; + /** The recorded derived path the change leaves no longer generated. */ + readonly recordedRel: string; + /** The configuration the change installs (a staged-source record). */ + readonly changedConfig: StagedTs; + /** + * Stage the change's occupant — before the configuration change is + * written, before the first `check` — capturing what the arm compares, + * and return the judgment of the occupant after `build`. + */ + readonly stage: ( + workspace: TestWorkspace, + ) => Promise<(context: string) => Promise<void>>; + /** The first `check`: the recorded-file finding (c), or no finding. */ + readonly firstCheck: "recorded-file-finding" | "no-finding"; + /** What the first `check`'s expectation rests on (diagnostics). */ + readonly why: string; +} + +/** + * Walk one arm: build (the recorded path premised a plain file — emitted + * Markdown, SPEC 13.2, 7.3), stage the change, then `check`, `build`, and + * `check` again — the first `check` judged concerning the recorded path, + * `build` exiting 0 (so no 14.22, exit 1, and no 14.24, exit 2: SPEC 12.0), + * the occupant judged after it, and the last `check` clean. + */ +async function walkOrphanArm( + product: ProductBinding, + arm: OrphanArm, +): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": arm.builtConfig, + "specs/A.mdx": ORPHAN_A_MDX, + }, + }, + async (workspace) => { + await buildOk(product, workspace, `${arm.tag} initial \`build\``); + await assertKindIs( + workspace, + arm.recordedRel, + "file", + `${arm.tag}: staging premise — the initial build emits specs/A.mdx's ` + + `Markdown at ${arm.recordedRel} as a plain file, recording it ` + + `(SPEC 13.2, 7.3, 13.3)`, + ); + const judgeAfterBuild = await arm.stage(workspace); + await workspace.file("xspec.config.ts", arm.changedConfig); + + const firstContext = `${arm.tag} first \`check --json\``; + if (arm.firstCheck === "recorded-file-finding") { + const findings = await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + `${firstContext} — ${arm.why}`, + ); + const concerning = staleFindingsConcerning(findings, arm.recordedRel); + if (concerning.length !== 1) { + fail( + `${firstContext}: exactly one condition-10 finding in the ` + + `recorded-file form concerning ${arm.recordedRel} — ${arm.why} ` + + `(SPEC 14.10: one finding per such path, its derived path the ` + + `finding's path, 12.7); got ${String(concerning.length)} among ` + + JSON.stringify( + findings.map((finding) => ({ + code: finding.code, + path: finding.path, + })), + ), + ); + } + } else { + const findings = await orphanCheckFindings( + product, + workspace, + firstContext, + ); + const concerning = staleFindingsConcerning(findings, arm.recordedRel); + if (concerning.length > 0) { + fail( + `${firstContext}: no condition-10 finding concerns ` + + `${arm.recordedRel} — ${arm.why} (SPEC 13.4, 14.10: the ` + + `recorded-file form reports exactly the occupants the removal ` + + `would remove); got ` + + JSON.stringify(concerning.map((finding) => finding.message)), + ); + } + } + + await buildOk( + product, + workspace, + `${arm.tag} \`build\` — the removal of the recorded path no longer ` + + `generated succeeds, no 14.22 (exit 1) and no 14.24 (exit 2) ` + + `(SPEC 13.4, 12.1, 12.0)`, + ); + await judgeAfterBuild(`${arm.tag} after \`build\``); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${arm.tag} last \`check --json\` — clean: the rebuilt record no ` + + `longer lists the path (SPEC 13.3, 13.4, 14.10)`, + ); + }, + ); +} + +/** + * A directory's byte state after the product ran: still a real directory, + * entry for entry byte-identical to the snapshot taken once the staging was + * complete (H-4) — checked as a directory first, so a removed or replaced + * directory is a diagnosed failure rather than a snapshot error. + */ +async function assertDirectoryUnchanged( + before: DirectorySnapshot, + what: string, + context: string, +): Promise<void> { + let kind: "dir" | "absent" | "other"; + try { + const stats = await fsp.lstat(before.root); + kind = stats.isDirectory() ? "dir" : "other"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + kind = "absent"; + } + if (kind !== "dir") { + fail( + `${context}: ${what} must still be a directory, byte-identical with ` + + `its content (SPEC 13.4); found ${kind === "other" ? "a non-directory" : "nothing"} at ${before.root}`, + ); + } + assertSnapshotsEqual( + before, + await snapshotDirectory(before.root), + `${context}: ${what} and its content byte-identical (SPEC 13.4)`, + ); +} + +/** Arm (a): a directory holding a file at the recorded path. */ +const ORPHAN_ARM_DIRECTORY: OrphanArm = { + tag: "T13.4-11 (a) a directory", + builtConfig: ORPHAN_EMIT_CONFIG, + recordedRel: "specs/A.md", + changedConfig: ORPHAN_NO_MARKDOWN_CONFIG, + stage: async (workspace) => { + await fsp.rm(workspace.path("specs/A.md")); + await workspace.file(ORPHAN_DIR_FILE_REL, ORPHAN_DIR_FILE_BYTES); + const before = await snapshotDirectory(workspace.path("specs/A.md")); + return async (context) => { + await assertDirectoryUnchanged( + before, + "the directory at the recorded specs/A.md — a path holding a " + + "directory is left as it is, the removal making no write", + context, + ); + }; + }, + firstCheck: "no-finding", + why: + "the recorded path holds a directory, which the removal leaves as it " + + "is (whether the graph-data unit form accompanies it is unasserted)", +}; + +/** Arm (b): a discovered code source at the recorded path. */ +const ORPHAN_ARM_SOURCE: OrphanArm = { + tag: "T13.4-11 (b) a discovered source", + builtConfig: ORPHAN_EMIT_CONFIG, + recordedRel: "specs/A.md", + changedConfig: ORPHAN_CODE_GROUP_CONFIG, + stage: async (workspace) => { + // S-9: a discovered code source whose name the default does not reach + // (the code group globs `specs/*.md`), declared well-formed by its + // record. + await workspace.file("specs/A.md", ORPHAN_CODE_SOURCE); + return async (context) => { + assertBytesEqual( + await readFileDiagnosed( + workspace, + "specs/A.md", + `${context}: the discovered code source at the recorded ` + + `specs/A.md is left in place — a source is never derived`, + ), + ORPHAN_CODE_SOURCE.source, + `${context}: the discovered code source at the recorded specs/A.md ` + + `byte-identical — a source is never derived (SPEC 13.4)`, + ); + }; + }, + firstCheck: "no-finding", + why: + "once no emit destination, the recorded path is a discovered code " + + "source, which the removal leaves in place (SPEC 7.2, 7.3)", +}; + +/** Arm (c): a symbolic link to a file outside the workspace. */ +const ORPHAN_ARM_LINK: OrphanArm = { + tag: "T13.4-11 (c) a symbolic link", + builtConfig: ORPHAN_EMIT_CONFIG, + recordedRel: "specs/A.md", + changedConfig: ORPHAN_NO_MARKDOWN_CONFIG, + stage: async (workspace) => { + const link = await stageLinkToOutsideFile( + workspace, + "specs/A.md", + "T13.4-11-c-target.md", + ); + return async (context) => { + await assertKindIs( + workspace, + "specs/A.md", + "absent", + `${context}: the recorded specs/A.md held a symbolic link, which ` + + `the removal removes as the link itself, never its target, and ` + + `with emission disabled nothing is written there (SPEC 13.4, 7.3)`, + ); + await assertOutsideLinkTargetUnchanged(link, context); + }; + }, + firstCheck: "recorded-file-finding", + why: + "a symbolic link at the recorded path is an occupant the removal " + + "removes, judged as itself", +}; + +/** Arm (d): the recorded path below a plain-file component. */ +const ORPHAN_ARM_PLAIN_COMPONENT: OrphanArm = { + tag: "T13.4-11 (d) nothing to remove", + builtConfig: ORPHAN_OUT_CONFIG, + recordedRel: "out/specs/A.md", + changedConfig: ORPHAN_MD_CONFIG, + stage: async (workspace) => { + await fsp.rm(workspace.path("out/specs"), { recursive: true }); + await workspace.file("out/specs", OCCUPANT); + return async (context) => { + assertBytesEqual( + await readFileDiagnosed( + workspace, + "out/specs", + `${context}: the plain file at out/specs, above the recorded ` + + `out/specs/A.md, is left as it is`, + ), + OCCUPANT, + `${context}: out/specs byte-identical — the recorded path below it ` + + `holds nothing, and its removal makes no write (SPEC 13.4)`, + ); + }; + }, + firstCheck: "no-finding", + why: + "the recorded path lies below a non-directory component and holds " + + "nothing — nothing is read there", +}; + +/** + * Arm (e): the recorded path below a symbolic link to a real directory + * holding a foreign plain file `A.md` — staged inside the workspace, under + * no group's globs, and outside the workspace root. + */ +function orphanArmLinkComponent(where: "inside" | "outside"): OrphanArm { + return { + tag: `T13.4-11 (e) nothing to remove below a symbolic link (its target ${where} the workspace root)`, + builtConfig: ORPHAN_OUT_CONFIG, + recordedRel: "out/specs/A.md", + changedConfig: ORPHAN_MD_CONFIG, + stage: async (workspace) => { + await fsp.rm(workspace.path("out/specs"), { recursive: true }); + const targetAbs = + where === "inside" + ? workspace.path("foreign") + : path.join(workspace.tempRoot, "foreign"); + await fsp.mkdir(targetAbs, { recursive: true }); + await fsp.writeFile(path.join(targetAbs, "A.md"), ORPHAN_FOREIGN_BYTES); + const linkAbs = workspace.path("out/specs"); + const linkTarget = path.relative(path.dirname(linkAbs), targetAbs); + await workspace.symlink("out/specs", linkTarget, "dir"); + if ((await workspace.kind("out/specs")) !== "symlink") { + throw new Error( + "internal error: failed to stage a symbolic link at out/specs", + ); + } + const [resolved, expected] = await Promise.all([ + fsp.realpath(linkAbs), + fsp.realpath(targetAbs), + ]); + if (resolved !== expected) { + throw new Error( + `internal error: the symbolic link staged at out/specs resolves ` + + `to ${resolved}, not to its target directory ${expected}`, + ); + } + const before = await snapshotDirectory(targetAbs); + return async (context) => { + const kind = await workspace.kind("out/specs"); + if (kind !== "symlink") { + fail( + `${context}: the symbolic link at out/specs — a directory ` + + `component of the recorded out/specs/A.md — is left as it is ` + + `(SPEC 13.4); found ${kind}`, + ); + } + const stored = await workspace.linkTarget("out/specs"); + if (stored !== linkTarget) { + fail( + `${context}: the symbolic link at out/specs byte-identical — ` + + `its stored target ${JSON.stringify(linkTarget)} (SPEC 13.4); ` + + `got ${JSON.stringify(stored)}`, + ); + } + await assertDirectoryUnchanged( + before, + `the link's target directory (${where} the workspace root), ` + + `holding the foreign A.md — nothing below a symbolic-link ` + + `component is read or removed, whatever the link targets`, + context, + ); + }; + }, + firstCheck: "no-finding", + why: + "the recorded path lies below a component a symbolic link occupies, " + + "where nothing is read whatever the link targets", + }; +} + +/** Arm (f): no occupant at the recorded path. */ +const ORPHAN_ARM_NO_OCCUPANT: OrphanArm = { + tag: "T13.4-11 (f) no occupant", + builtConfig: ORPHAN_EMIT_CONFIG, + recordedRel: "specs/A.md", + changedConfig: ORPHAN_EMIT_FALSE_CONFIG, + stage: async (workspace) => { + await fsp.rm(workspace.path("specs/A.md")); + return async (context) => { + await assertKindIs( + workspace, + "specs/A.md", + "absent", + `${context}: the recorded specs/A.md held nothing, and with ` + + `emission disabled nothing is written there (SPEC 13.4, 7.3)`, + ); + }; + }, + firstCheck: "no-finding", + why: + "the recorded path holds nothing (whether the graph-data unit form " + + "accompanies it is unasserted)", +}; + +/** + * The order-independence arm (SPEC 13.4: a completed regeneration's outcome + * does not depend on the order of its writes and removals): `build` with + * `specs/B.md/C.mdx`, recording `specs/B.md/C.md` and `C.mdx`'s module and + * companions; then `C.mdx` deleted and `specs/B.mdx` added, whose emit path + * `specs/B.md` is the directory holding those recorded orphans. `build` + * exits 0, and the workspace is exactly the regenerated one — its files + * compared with a twin holding the same sources and configuration, freshly + * built (H-6's two-directory protocol; CERTIFICATIONS.md §CONF-ORPHAN's + * staging constraint: never `inventory`) — `check` clean. + */ +async function walkOrphanOrderIndependence( + product: ProductBinding, +): Promise<void> { + const tag = "T13.4-11 (order independence)"; + await withWorkspace( + { + files: { + "xspec.config.ts": ORPHAN_NESTED_CONFIG, + "specs/B.md/C.mdx": ORPHAN_C_MDX, + }, + }, + async (workspace) => { + await buildOk(product, workspace, `${tag} initial \`build\``); + for (const rel of ["specs/B.md/C.md", "specs/B.md/C.xspec.ts"]) { + await assertKindIs( + workspace, + rel, + "file", + `${tag}: staging premise — the initial build writes C.mdx's ` + + `emitted Markdown and module under specs/B.md/, recording them ` + + `(SPEC 13.1, 13.2, 13.3)`, + ); + } + await fsp.rm(workspace.path("specs/B.md/C.mdx")); + await workspace.file("specs/B.mdx", B_MDX); + await buildOk( + product, + workspace, + `${tag} \`build\` — the write replacing the directory specs/B.md, ` + + `each recorded orphan's removal finding nothing below the replaced ` + + `path or removing its file first alike; no 14.24, and no 14.22 on ` + + `a removal (SPEC 13.4, 12.1)`, + ); + await assertKindIs( + workspace, + "specs/B.md", + "file", + `${tag}: specs/B.md is a plain file holding B.mdx's Markdown and ` + + `nothing under it (SPEC 13.4, 13.2)`, + ); + await withWorkspace( + { + files: { + "xspec.config.ts": ORPHAN_NESTED_CONFIG, + "specs/B.mdx": B_MDX, + }, + }, + async (twin) => { + await buildOk(product, twin, `${tag} the twin's \`build\``); + await assertDirectoriesEqual( + workspace.root, + twin.root, + `${tag}: the workspace after \`build\` vs a twin holding the ` + + `same sources and configuration, freshly built — exactly the ` + + `regenerated one (SPEC 13.4, 12.1, 12.0; H-6)`, + ); + }, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${tag} \`check --json\` after the build — clean (SPEC 13.4, 14.10)`, + ); + }, + ); +} + +const T13_4_11 = defineProductTest({ + id: "T13.4-11", + title: + 'removing recorded paths no longer generated: 13.4 removes a recorded derived path\'s occupant only where it is neither a directory nor a discovered source — a symbolic link as the link itself — leaving a directory, a discovered source, or nothing as it is, making no write, and 14.10\'s recorded-file form reports exactly what that removal would remove; each arm builds with emission next to sources (under `outDir: "out"` in (d) and (e)), stages its change, then runs `check`, `build`, `check`: (a) a directory holding a file, emission disabled — no condition-10 finding concerning `specs/A.md`, the directory byte-identical; (b) a discovered code source `export const n = 1` under a code group globbing `specs/*.md`, emission disabled — no finding, the file byte-identical; (c) a symbolic link to a file outside the workspace, emission disabled — the recorded-file finding, `build` removing the link itself, its target byte-identical; (d) `out/specs` a plain file and `outDir` changed to `"md"` — no finding, `build` exits 0, `out/specs` byte-identical; (e) `out/specs` a symbolic link to a directory holding a foreign `A.md`, inside the workspace and outside its root — no finding, `build` exits 0, link, directory, and `A.md` byte-identical; (f) `specs/A.md` deleted, emission disabled — no finding, `build` exits 0, nothing there; every last `check` clean; and order independence — `specs/B.mdx` added as the orphaned `specs/B.md/C.mdx` is deleted, its emit path the directory holding the recorded orphans: `build` exits 0, the workspace equal to a freshly built twin, `check` clean (SPEC 13.4, 14.10, 12.1, 12.2)', + run: async (product) => { + await walkOrphanArm(product, ORPHAN_ARM_DIRECTORY); + await walkOrphanArm(product, ORPHAN_ARM_SOURCE); + await walkOrphanArm(product, ORPHAN_ARM_LINK); + await walkOrphanArm(product, ORPHAN_ARM_PLAIN_COMPONENT); + await walkOrphanArm(product, orphanArmLinkComponent("inside")); + await walkOrphanArm(product, orphanArmLinkComponent("outside")); + await walkOrphanArm(product, ORPHAN_ARM_NO_OCCUPANT); + await walkOrphanOrderIndependence(product); + }, +}); + /** TEST-SPEC §13.4, in canonical ID order (SUITE-47). */ export const section134Tests: readonly ProductTestEntry[] = [ T13_4_1, @@ -1496,4 +3955,8 @@ export const section134Tests: readonly ProductTestEntry[] = [ T13_4_4, T13_4_5, T13_4_6, + T13_4_8, + T13_4_9, + T13_4_10, + T13_4_11, ]; diff --git a/test/suite/registry/section-13.5.ts b/test/suite/registry/section-13.5.ts index dcf174d8..31305f60 100644 --- a/test/suite/registry/section-13.5.ts +++ b/test/suite/registry/section-13.5.ts @@ -1,11 +1,19 @@ // 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 -// arms, and the non-mutating unknown-flag arm), T13.5-2 (mutual exclusion), +// basics: five held mutating-command arms each compared byte-identically +// against its no-seam twin (seam neutrality), the stale-workspace arm — the +// hold precedes the 13.3 refresh, graph data byte-identical while held — the +// occupied-hold-path exit-2 arms, the non-mutating unknown-flag arm, and +// `build --test-hold --json` consuming `--json` as the hold path — the flag +// value-taking by name on every command, JSON out of effect, stdout empty), +// T13.5-2 (mutual exclusion), // T13.5-3 (exclusivity ends with the process), T13.5-4 (readers during // mutation + build/query storm), T13.5-5 (atomic visibility via a polling -// reader), T13.5-6 (workspace isolation), T13.5-7 (interrupted mutation: -// held-point kill and a post-release kill-timing spread with the disjunctive -// `check` assertion). +// reader), T13.5-6 (workspace isolation), T13.5-7 (interrupted or +// write-refused mutation: the pinned write order — the refusal arms (a)–(f) +// composed from write-refusal-staging.ts, and the kill arm), T13.5-8 (acquisition before every later check: the +// exclusion-first refusals on a failing workspace, the hold seam engaging on +// invocations the 12.0 argument checks, baseline resolution, or the 13.3 +// gate refuse, and the non-mutating preview boundary). // // All mutual-exclusion choreography goes through the `--test-hold <path>` // seam (SPEC 13.5) via the subprocess driver's background-start, hold-file, @@ -14,18 +22,42 @@ // snapshots never see them; `--test-hold` resolves against the working // directory (SPEC 12.0; T12.0-5), so the absolute path is exact. // -// CERTIFICATIONS.md staging constraints (binding; T13.5-1…T13.5-5 are -// §CONF-CORE in-scope): +// CERTIFICATIONS.md staging constraints (binding; T13.5-1…T13.5-5 and +// T13.5-8 are §CONF-CORE in-scope): // - Every mutating command these tests drive is `rename`, file-form `move` // (never the section form), or a mutating `review` subcommand with // `create` under `--strategy audit` (§CONF-CORE), and the in-scope // 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-1's stale-workspace arm is the only in-scope mutating command +// started on stale graph data (§CONF-CORE's freshness constraint, which +// §VIOL-CORE-EARLYREFRESH's passing side leans on): it stages its own +// workspaces, and every other mutating command these tests start — +// T13.5-1's basic arms, T13.5-2's held and excluded commands, T13.5-3's +// killed and subsequent commands, T13.5-4's held mutator — starts on a +// freshly built workspace with no refresh pending (T10.1-1). // - T13.5-2's excluded commands carry no `--test-hold` (§VIOL-CORE-NOLOCK), // and its modifies-nothing compare brackets each excluded command alone, // with the baseline snapshot taken while command 1 is already held // (§VIOL-CORE-EARLYWRITE). +// - T13.5-8's failing workspace is a second spec source beginning with a +// byte-order mark (14.20 at offset 0), added after the valid `build` and +// the audit session's creation, masked and never an operand; the `build` +// establishing it runs outside every bracket and before anything is held +// (§VIOL-CORE-CHATTYREADS); its excluded commands carry no `--test-hold` +// (§VIOL-CORE-NOLOCK) and its modifies-nothing compares bracket each +// excluded or refused invocation alone (§VIOL-CORE-EARLYWRITE); its +// refused valid-workspace commands start on a freshly built workspace +// lying in no repository, where every `--base` ref is unresolvable +// (§CONF-CORE); and its waits for a refused invocation's hold file fail +// loud, never proceeding on the command's exit or on a timeout +// (§VIOL-CORE-LATELOCK). // - T13.5-3's subsequent mutating command succeeds whether or not the killed // operation's writes landed — `rename specs/A.mdx g g2`, independent of // the killed `a`→`a2` and never a retry of it (§VIOL-CORE-EARLYWRITE). @@ -46,6 +78,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 @@ -69,64 +109,104 @@ // concurrent-vs-serial compare of a six-command script (per-step exit // codes and stdout bytes, and the final workspace trees, H-6 // two-directory style). -// - T13.5-7's kill-timing spread is a fixed delay list — kill scheduling is -// choreography, never an assertion input (H-10); the operative assertion -// is delay-independent and disjunctive exactly as specified: after a -// post-release kill, `check` exits 0 or 1 (never a signal death, never -// another code — the configuration is intact, so the exit-2 class is not -// stageable), and after a held-point kill it exits 0 (the hold precedes -// all modification). +// - T13.5-7's refusal arms stage each 14.24 refusal by permission removal +// while the command is held at the seam (after acquisition, before any +// modification — seam neutrality) and read every rewritten-byte +// expectation from a twin on which the same operation ran unrefused +// (H-6); the states are asserted entry by entry against 13.5's pinned +// per-command write order (write-refusal-staging.ts). Its kill-timing +// spread is a fixed delay list — kill scheduling is choreography, never +// an assertion input (H-10); the operative assertion is delay-independent +// and disjunctive exactly as 13.5 admits: after a post-release kill, +// `check` exits 0 or 1 (never a signal death, never another code — the +// configuration is intact, so the exit-2 class is not stageable) with +// condition 5–7 findings alone or condition 10 findings alone, and after +// a held-point kill it exits 0 (the hold precedes all modification). import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import type { + Finding, SessionStatusReport, SessionStatusRow, } from "../../helpers/adapters/index.js"; -import { decodeSessionStatusReport } from "../../helpers/adapters/index.js"; +import { + decodeFindingsReport, + decodeSessionStatusReport, +} from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, describeByteDifference, fail, HarnessAssertionError, + parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import { assertDirectoriesEqual, assertLeavesUnchanged, assertSnapshotsEqual, + diffSnapshots, snapshotDirectory, } from "../../helpers/snapshot.js"; import type { ProductBinding, + RunGuards, RunningProduct, RunResult, } from "../../helpers/subprocess.js"; import { pathExists, releaseHoldFile, + rethrowOutputOverflow, runProduct, startProduct, summarizeResult, } from "../../helpers/subprocess.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { buildOk, expectExit, runCli, runJson } from "./support.js"; +import { assertOutsideAnyRepository } from "./section-6.3.js"; +import { + assertConditionCounts, + assertFindingLocated, + buildFindings, + buildOk, + expectExit, + runCli, + runJson, +} from "./support.js"; +import { + awaitHoldFile, + holdPathFor, + renameTwin, + runKillArm, + runWriteRefusalArms, + WRITE_REFUSALS_STAGED, +} from "./write-refusal-staging.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group, no -// other keys — the CONF-CORE workspace shape (CERTIFICATIONS.md). -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// other keys — the CONF-CORE workspace shape (CERTIFICATIONS.md). A +// staged-source record: T13.5-1, T13.5-4, T13.5-6, and T13.5-8 stage it in +// workspaces created after a product invocation (CORE_DECL, ISO_TWO_DECL), +// as T6.6-3's runs-while-held arm does (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.6-3/T13.5-1/T13.5-4/T13.5-6/T13.5-8 xspec.config.ts — exactly one spec group, the CONF-CORE workspace shape (CORE_DECL's and ISO_TWO_DECL's)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // Importless, tagless `.mdx` source (the CONF-CORE shape): `a` carries a // child so `rename` rewrites a descendant and `review split` has a child @@ -145,9 +225,25 @@ const A_MDX = [ "</S>", "", ].join("\n"); +// The source is staged in workspaces created after a body's first product +// invocation (T13.5-1's stale arm, T13.5-4's storm pair, T13.5-6's serial and +// concurrent pairs, T13.5-8's valid-workspace arms, T6.6-3's scheduling +// workspace) and, byte for byte, by section-13.4.ts's tests (its later +// workspaces included), so it is one staged-source record made from the +// string (S-9's before-any-product clause; helpers/staged-mdx.ts) — the +// string stays for the staleness edit below — staged wherever the bytes are. +export const CORE_A_STAGED = stagedMdx( + "T6.6-3/T13.4-1/T13.4-2/T13.4-3/T13.4-4/T13.4-5/T13.4-6/T13.5-1/T13.5-2/T13.5-3/T13.5-4/T13.5-6/T13.5-8 specs/A.mdx (the CONF-CORE-shaped source: a holding a.k, then g; T13.4-6's specs/one/A.mdx too)", + A_MDX, +); -const CORE_DECL: WorkspaceDecl = { - files: { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/A.mdx": A_MDX }, +/** + * 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": CORE_A_STAGED }, }; const REVIEWS_REL = ".xspec/reviews"; @@ -170,36 +266,11 @@ 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. - */ -function holdPathFor(workspace: TestWorkspace, name: string): string { - return path.join(workspace.tempRoot, name); -} - -/** - * Await the hold file's appearance, converting the driver's diagnosed - * rejection (the process exited first, or the wait timed out) into a - * diagnosed assertion failure (H-8). - */ -async function awaitHoldFile( - running: RunningProduct, - absPath: string, - context: string, -): Promise<void> { - try { - await running.waitForFile(absPath); - } catch (error) { - fail( - `${context}: the mutating command must create the hold file at ` + - `${absPath} immediately after acquiring workspace exclusivity and ` + - `before modifying anything (SPEC 13.5) — ` + - `${error instanceof Error ? error.message : String(error)}`, - ); - } -} +// The hold-path and hold-await helpers live in write-refusal-staging.ts (the +// seam-held staging choreography shared with T14-9); re-exported here for +// T6.6-3, which shares this module's drive-during-hold choreography +// (CERTIFICATIONS.md). +export { awaitHoldFile, holdPathFor }; /** The hold file must be an empty plain file ("creates an empty file"). */ async function assertEmptyHoldFile( @@ -229,11 +300,16 @@ async function assertEmptyHoldFile( } } -/** One-line outcome of a settled run, for premature-exit diagnoses. */ -async function describeExit(running: RunningProduct): Promise<string> { +/** + * One-line outcome of a settled run, for premature-exit diagnoses. An + * exhausted capture limit is never folded into one: it propagates as the + * harness error it is (H-11). + */ +export async function describeExit(running: RunningProduct): Promise<string> { try { return summarizeResult(await running.waitForExit()); } catch (error) { + rethrowOutputOverflow(error); return error instanceof Error ? error.message : String(error); } } @@ -242,18 +318,26 @@ async function describeExit(running: RunningProduct): Promise<string> { * Run a command to completion under a bound, converting a rejection (a * product that blocks or hangs instead of exiting, killed at the bound) into * a diagnosed assertion failure (H-8). The bound is a hang guard, never an - * assertion input (H-10). + * assertion input (H-10). An exhausted capture limit is never converted: it + * propagates as the harness error it is (H-11). `guards` lower the bound or + * the capture limit for S-8's vector alone. */ -async function runBounded( +export async function runBounded( product: ProductBinding, cwd: string, argv: readonly string[], context: string, - timeoutMs = 15_000, + guards: RunGuards = {}, ): Promise<RunResult> { try { - return await runProduct(product, { cwd, argv, timeoutMs }); + return await runProduct(product, { + cwd, + argv, + timeoutMs: guards.timeoutMs ?? 15_000, + maxOutputBytes: guards.maxOutputBytes, + }); } catch (error) { + rethrowOutputOverflow(error); return fail( `${context}: the command must terminate on its own rather than block ` + `or hang (SPEC 13.5, 12.0; H-8: hangs become diagnosed failures) — ` + @@ -308,27 +392,114 @@ function requireRowByScope( // T13.5-1 — hold seam basics // --------------------------------------------------------------------------- -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)", - 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})`; +// The staleness edit for T13.5-1's stale-workspace arm, as T10.1-1 stages +// it: same structure and identities, different leaf text — the graph data the +// earlier `build` wrote no longer matches the sources (SPEC 13.3), while the +// workspace stays valid. +const A_MDX_EDITED = A_MDX.replace("Kid text.", "Kid text, edited."); +// The staleness edit is staged after each of the three workspaces' `build`, +// so it is a ledger record (S-9's before-any-product clause; +// helpers/staged-mdx.ts) — the same constant, one record for all three. +const T13_5_1_A_EDITED = stagedMdx( + "T13.5-1 stale arm: specs/A.mdx with Kid text edited", + A_MDX_EDITED, +); + +/** + * Snapshot scope "graph data alone": everything under `.xspec/` except the + * durable files there — the journal (`.xspec/journal`, SPEC 6.1) and the + * review sessions (`.xspec/reviews/`, SPEC 10.1) — with everything outside + * `.xspec/` pruned. SPEC 13.3 leaves graph data's layout unenumerated, so + * the scope is the graph-data area (11.6) minus its durable occupants; in a + * harness-staged workspace nothing foreign lives there. + */ +function excludeAllButGraphData(relPathBytes: Uint8Array): boolean { + const rel = Buffer.from(relPathBytes).toString("latin1"); + if (rel === ".xspec") return false; + if (!rel.startsWith(".xspec/")) return true; + return ( + rel === ".xspec/journal" || + rel === ".xspec/reviews" || + rel.startsWith(".xspec/reviews/") + ); +} + +/** + * T13.5-1's stale-workspace arm (SPEC 13.5: the hold precedes every + * modification, the 13.3 refresh included). Three identically staged + * CONF-CORE-shaped workspaces: `build`, then one section's own text edited so + * the graph data is stale while the workspace stays valid (as T10.1-1 stages + * it). The held workspace runs `review create --strategy audit --test-hold`: + * while held, every workspace file — `.xspec/graph.json` included, the one + * file a pre-hold refresh would change — is byte-identical to its + * pre-invocation state; after release the command exits 0 and the session + * file exists; the final tree equals the no-seam twin's (seam neutrality on + * this arm too — both refresh, to the same final state), and the graph data + * equals what `build` writes on the build twin (13.3: the refresh writes + * exactly what `build` would write, the recorded derived-file paths — which + * this edit leaves unchanged — excepted): product-to-itself compares under + * H-4, well-defined across directories per H-6. This is the only in-scope + * mutating command the 13.5 tests start on stale graph data + * (CERTIFICATIONS.md §CONF-CORE's freshness constraint, which + * §VIOL-CORE-EARLYREFRESH's passing side leans on); every other arm stages + * itself freshly built. + */ +async function staleWorkspaceArm(product: ProductBinding): Promise<void> { + const create = ["review", "create", "--strategy", "audit", "--name", "n"]; + const context = + "T13.5-1 (stale workspace, held `review create --strategy audit --name n`)"; + await withWorkspace(CORE_DECL, async (workspace) => { + await withWorkspace(CORE_DECL, async (twinNoSeam) => { + await withWorkspace(CORE_DECL, async (twinBuild) => { + // Identical staging on all three: `build`, then the staleness edit. + await buildOk(product, workspace, "T13.5-1 stale arm staging `build`"); + await buildOk( + product, + twinNoSeam, + "T13.5-1 stale arm no-seam twin staging `build`", + ); + await buildOk( + product, + twinBuild, + "T13.5-1 stale arm build twin staging `build`", + ); + for (const staged of [workspace, twinNoSeam, twinBuild]) { + await staged.file("specs/A.mdx", T13_5_1_A_EDITED); + } + + // What `build` writes on the edited sources (the build twin), and the + // staging premise: it differs from the held workspace's current graph + // data, so a refresh is pending there (SPEC 13.3: graph data carries + // the sources' hashes and source ranges, so an edited text cannot + // leave it current) — otherwise the arm could discriminate nothing. + await buildOk( + product, + twinBuild, + "T13.5-1 stale arm build twin `build` on the edited sources", + ); + const staleGraphData = await snapshotDirectory(workspace.root, { + exclude: excludeAllButGraphData, + }); + const builtGraphData = await snapshotDirectory(twinBuild.root, { + exclude: excludeAllButGraphData, + }); + if (diffSnapshots(staleGraphData, builtGraphData).length === 0) { + fail( + `${context}: staging premise — after the edit the workspace's ` + + `graph data must be stale, i.e. differ from what \`build\` ` + + `writes on an identically edited twin (SPEC 13.3: graph data ` + + `carries the sources' hashes and source ranges), but the two ` + + `are byte-identical, so no refresh is pending and the arm ` + + `cannot discriminate (H-8)`, + ); + } + + // Pre-invocation state: every workspace file, `.xspec/` included. const before = await snapshotDirectory(workspace.root); + const hold = holdPathFor(workspace, "hold-stale.tmp"); const running = await startProduct(product, { cwd: workspace.root, - argv: [...argv, "--test-hold", hold], + argv: [...create, "--test-hold", hold], }); try { await awaitHoldFile(running, hold, context); @@ -338,14 +509,17 @@ const T13_5_1 = defineProductTest({ 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)`, + `started — graph data (.xspec/graph.json, the one file the ` + + `pending 13.3 refresh changes) and every other workspace ` + + `file: the hold is created after acquiring exclusivity and ` + + `before every modification, the refresh included, so a ` + + `product that refreshes before acquiring exclusivity fails ` + + `here (SPEC 13.5, 13.3)`, ); if (running.hasExited()) { fail( - `${context}: the command must proceed only once the hold file ` + - `is deleted, but it exited while the hold file still ` + + `${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)}`, ); } @@ -354,6 +528,7 @@ const T13_5_1 = defineProductTest({ try { result = await running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); return fail( `${context}: once the hold file is deleted the command must ` + `proceed and complete normally (SPEC 13.5) — ` + @@ -366,97 +541,277 @@ const T13_5_1 = defineProductTest({ `${context}: completes normally once the hold file is deleted ` + `(SPEC 13.5)`, ); - await onCompleted(); } finally { running.kill(); await releaseHoldFile(hold); } - }; + const kind = await workspace.kind(sessionRel("n")); + if (kind !== "file") { + fail( + `${context}: after completing normally, the session file exists ` + + `as a plain file at ${sessionRel("n")} (SPEC 10.1); found ${kind}`, + ); + } - // 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}`, + // Seam neutrality on this arm: the no-seam twin runs the same + // operation on the same stale state without `--test-hold`, and the + // final trees are compared whole — sources, journal, sessions, + // derived files, and graph data (both refresh, to the same state). + await expectExit( + product, + twinNoSeam, + create, + 0, + "T13.5-1 (stale workspace, twin) `review create --strategy audit " + + "--name n` run without --test-hold on the identical stale twin " + + "workspace (SPEC 13.5)", + ); + await assertDirectoriesEqual( + workspace.root, + twinNoSeam.root, + `${context} vs its no-seam twin: the final workspace state of the ` + + `held-then-released run — sources, journal, sessions, derived ` + + `files, and graph data — is byte-identical to the same operation ` + + `run without --test-hold on an identical twin workspace (SPEC ` + + `13.5 seam neutrality; a product-to-itself comparison under H-4, ` + + `well-defined across directories per H-6)`, + ); + + // The refresh (SPEC 13.3, T10.1-1): after release the graph data is + // refreshed — byte-identical to what `build` writes on the build + // twin, the edit leaving the recorded derived-file paths unchanged. + await assertDirectoriesEqual( + workspace.root, + twinBuild.root, + `${context} vs its build twin, graph data alone (everything ` + + `under .xspec/ but the journal and .xspec/reviews/): after ` + + `release the 13.3 refresh has run, writing exactly what ` + + `\`build\` writes on an identically edited twin — the recorded ` + + `derived-file paths, which the edit leaves unchanged, excepted ` + + `(SPEC 13.3, 13.5; T10.1-1; a product-to-itself comparison ` + + `under H-4/H-6)`, + { exclude: excludeAllButGraphData }, + ); + }); + }); + }); +} + +const T13_5_1 = defineProductTest({ + id: "T13.5-1", + title: + "each mutating command (`rename`, file-form `move`, `review create/resolve/split`) with `--test-hold` creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, and completes normally, the held-then-released run's final workspace state — sources, journal, sessions, derived files, and graph data — byte-identical to the same operation run without `--test-hold` on an identical twin workspace (seam neutrality: the seam changes no other behavior; H-4/H-6); on a workspace whose graph data is stale (a section's text edited after `build`, the workspace still valid) `review create --strategy audit --test-hold` leaves graph data and every other workspace file byte-identical while held — the hold precedes the 13.3 refresh too — and after release creates the session and refreshes the graph data to what `build` writes on an identical twin; anything at the hold path — file, directory, or symlink — fails the command exit 2 without modifying anything; `build` and `query` given `--test-hold` fail exit 2 as an unknown flag, and `build --test-hold --json` — the flag value-taking by name on every command — consumes `--json` as the hold path and leaves JSON out of effect: exit 2, stdout empty, no hold file (SPEC 13.5, 13.3, 12.0)", + run: async (product) => { + // Every arm below stages itself on a freshly built workspace with no + // refresh pending (CERTIFICATIONS.md §CONF-CORE's freshness constraint); + // the stale-workspace arm at the end stages its own. + await withWorkspace(CORE_DECL, async (workspace) => { + // Seam neutrality (SPEC 13.5: the seam changes no other behavior): an + // identical twin workspace is driven through the exact same command + // 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 { + 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) { + rethrowOutputOverflow(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); } - }, - ); - // 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"), + // 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, + `T13.5-1 (twin ${what}) run without --test-hold on the ` + + `identical twin workspace (SPEC 13.5)`, ); - 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)', - ); - } - }, - ); + 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 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 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)', + ); + } + }, + ); - // 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)", - ); + // 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 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(), - ); + // 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 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 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(), + ["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 @@ -603,7 +958,64 @@ const T13_5_1 = defineProductTest({ `${context}: the usage error modifies nothing (SPEC 12.0)`, ); } + + // `--test-hold` is value-taking by name on every command (SPEC 12.0: a + // flag's arity is fixed by its name, known before the command word is + // identified, and a value-taking flag takes the whole next token + // whatever it looks like), so `build --test-hold --json` consumes + // `--json` as the hold path — a filesystem path resolved against the + // working directory, the workspace root here — and leaves JSON out of + // effect: the unknown flag's usage error exits 2 with empty stdout. A + // product reading `--json` as the JSON flag answers the error document + // on stdout and fails here; one honoring the flag would create + // `./--json` and wait, failing the bounded run or the absence check. + { + const context = + "T13.5-1 (`build --test-hold --json`: `--json` consumed as the " + + "hold path)"; + const consumed = path.join(workspace.root, "--json"); + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runBounded( + product, + workspace.root, + ["build", "--test-hold", "--json"], + context, + ); + assertExitCode( + result, + 2, + `${context}: --test-hold on \`build\` is an unknown flag — a ` + + `usage error (SPEC 13.5, 12.0)`, + ); + if (result.stdoutBytes.length !== 0) { + fail( + `${context}: \`--json\` is the value of \`--test-hold\`, not ` + + `the JSON flag (SPEC 12.0: a value-taking flag takes the ` + + `whole next token, a \`--\`-prefixed one included), so ` + + `JSON is out of effect and stdout is empty — got ` + + `${String(result.stdoutBytes.length)} bytes; ` + + summarizeResult(result), + ); + } + if (await pathExists(consumed)) { + fail( + `${context}: no hold file may be created at the consumed ` + + `path \`--json\` (resolved against the working directory) ` + + `— the flag is refused, not honored (SPEC 13.5, 12.0)`, + ); + } + }, + `${context}: the usage error modifies nothing (SPEC 12.0)`, + ); + } }); + + // Stale-workspace arm (SPEC 13.5: the hold precedes every modification, + // the 13.3 refresh included) — on its own workspaces, the only in-scope + // mutating command started on stale graph data. + await staleWorkspaceArm(product); }, }); @@ -713,6 +1125,7 @@ const T13_5_2 = defineProductTest({ try { result1 = await running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); return fail( `${context1}: command 1 must complete normally once the hold ` + `file is deleted (SPEC 13.5) — ` + @@ -889,6 +1302,7 @@ const T13_5_4 = defineProductTest({ try { result = await running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); return fail( `${contextHeld}: the held rename must complete normally once ` + `the hold file is deleted (SPEC 13.5) — ` + @@ -925,6 +1339,13 @@ const T13_5_4 = defineProductTest({ const settled = await Promise.allSettled( started.map((running) => running.waitForExit()), ); + // An exhausted capture limit anywhere in the storm is the harness's + // own failure (H-11), reported ahead of any diagnosed one. + for (const outcome of settled) { + if (outcome.status === "rejected") { + rethrowOutputOverflow(outcome.reason); + } + } settled.forEach((outcome, index) => { if (outcome.status === "rejected") { const reason = outcome.reason as unknown; @@ -987,6 +1408,21 @@ function pollSource(text: string): string { return ['<S id="p">', text, "</S>", ""].join("\n"); } +// The alternating states are staged after the first `build`, so they are +// ledger records (S-9's before-any-product clause; helpers/staged-mdx.ts): +// the loop's two versions, enumerated — state two, then state one — and +// picked by the same alternation. State one is the workspace's initial +// source too, which stages the record rather than a second spelling of its +// bytes. +const T13_5_5_POLL_STATE_TWO = stagedMdx( + "T13.5-5 specs/P.mdx in state two", + pollSource(POLL_TEXT_TWO), +); +const T13_5_5_POLL_STATE_ONE = stagedMdx( + "T13.5-5 specs/P.mdx in state one (the initial source; the alternation's state one)", + pollSource(POLL_TEXT_ONE), +); + const T13_5_5 = defineProductTest({ id: "T13.5-5", title: @@ -996,7 +1432,7 @@ const T13_5_5 = defineProductTest({ { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - [POLL_FILE]: pollSource(POLL_TEXT_ONE), + [POLL_FILE]: T13_5_5_POLL_STATE_ONE, }, }, async (workspace) => { @@ -1063,7 +1499,7 @@ const T13_5_5 = defineProductTest({ const stateTwo = i % 2 === 0; await workspace.file( POLL_FILE, - pollSource(stateTwo ? POLL_TEXT_TWO : POLL_TEXT_ONE), + stateTwo ? T13_5_5_POLL_STATE_TWO : T13_5_5_POLL_STATE_ONE, ); await buildAndRecord( `T13.5-5 \`build\` #${String(i + 2)} (state ${stateTwo ? "two" : "one"})`, @@ -1149,20 +1585,26 @@ const T13_5_5 = defineProductTest({ // --------------------------------------------------------------------------- // The second workspace differs from the first in file name, IDs, and texts, -// so cross-workspace interference cannot cancel out. -const ISO_TWO_MDX = [ - '<S id="b">', - "Bravo isolated text.", - '<S id="b.k">', - "Bravo kid text.", - "</S>", - "</S>", - "", - '<S id="h">', - "Hotel isolated text.", - "</S>", - "", -].join("\n"); +// so cross-workspace interference cannot cancel out. The serial and +// concurrent pairs are created after the held-overlap probe's invocations, +// so the source is a staged-source record (S-9's before-any-product clause; +// helpers/staged-mdx.ts), the probe's workspace 2 staging it too. +const ISO_TWO_MDX = stagedMdx( + "T13.5-6 workspace 2 specs/B.mdx (b holding b.k, then h: the held-overlap probe's, the serial run's, and the concurrent run's)", + [ + '<S id="b">', + "Bravo isolated text.", + '<S id="b.k">', + "Bravo kid text.", + "</S>", + "</S>", + "", + '<S id="h">', + "Hotel isolated text.", + "</S>", + "", + ].join("\n"), +); const ISO_TWO_DECL: WorkspaceDecl = { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/B.mdx": ISO_TWO_MDX }, @@ -1265,6 +1707,7 @@ const T13_5_6 = defineProductTest({ try { result1 = await running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); return fail( `${contextHeld}: workspace 1's rename must complete normally ` + `once the hold file is deleted (SPEC 13.5) — ` + @@ -1313,6 +1756,13 @@ const T13_5_6 = defineProductTest({ "T13.5-6 concurrent workspace 2", ), ]); + // An exhausted capture limit in either script is the harness's own + // failure (H-11), reported ahead of any diagnosed one. + for (const outcome of settled) { + if (outcome.status === "rejected") { + rethrowOutputOverflow(outcome.reason); + } + } for (const outcome of settled) { if (outcome.status === "rejected") { const reason = outcome.reason as unknown; @@ -1382,156 +1832,482 @@ const T13_5_6 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T13.5-7 — interrupted mutation +// T13.5-7 — interrupted or write-refused mutation: the pinned write order // --------------------------------------------------------------------------- -// A multi-file rename fixture (outside CONF-CORE's in-scope set, so imports -// and references are fine here): renaming `a` rewrites A.mdx (its own and -// descendant IDs), B.mdx (a `d` chain reference and a `text(...)` target), -// and C.mdx (a `d` chain reference), then regenerates modules, emitted -// Markdown, and graph data and appends the journal — a wide write set for -// the kill spread (SPEC 6.4, 13.5). -const MULTI_CONFIG = `import { defineConfig } from "xspec" - -export default defineConfig({ - specs: { - main: ["specs/**/*.mdx"] +// The refusal arms (a)–(f), their fixtures, stagings, twins, and the +// pinned-state laws live in write-refusal-staging.ts (shared with T14-9); +// this entry composes them: the rename twin once (arms (a), (b), and the +// kill arm read their expectations from it), the refusal arms on the Linux +// leg (E-1: permission stagings are Linux-only; elsewhere they are not +// staged — the NU3_STAGED pattern of section-11.5 — and the Windows subset +// selects T13.5-7 nowhere), then the platform-safe kill arm. +const T13_5_7 = defineProductTest({ + id: "T13.5-7", + title: + "interrupted or write-refused mutation: a write the environment refuses stops the command at that write — exit 2 with the error document (`write-failure`, the concerned path) — leaving every earlier write of 13.5's pinned per-command order complete and no later one attempted, each state read from a twin on which the same operation ran unrefused: (a) source edits first in preview `files` order, (b) the journal append as the commit point, (c) the identity effect complete once appended, (d) a relocation's two writes, (e) `review` mutators writing the session file once, last, after the refresh, (f) `build` and a refresh each file complete with the order unpinned, and a refreshing read refused at its graph-data write; the kill arm: `check` never crashes and reports exactly a state 13.5 admits (SPEC 13.5, 14.24, 12.0, 12.7, 6.4, 6.7, 13.3)", + // A hang guard only (H-10): some twenty workspaces, each built, renamed, + // checked, and driven — generous under a saturated box. + timeoutMs: 600_000, + run: async (product) => { + const rename = await renameTwin(product); + if (WRITE_REFUSALS_STAGED) { + await runWriteRefusalArms(product, rename); + } + await runKillArm(product, rename); }, - markdown: { emit: true } -}) -`; +}); -const MULTI_A = [ - '<S id="a">', - "Alpha root text.", - '<S id="a.k1">', - "Kid one text.", - "</S>", - '<S id="a.k2">', - "Kid two text.", - "</S>", - "</S>", - "", -].join("\n"); +/** TEST-SPEC §13.5, in canonical ID order (SUITE-48). */ +// --------------------------------------------------------------------------- +// T13.5-8 — acquisition before every later check +// --------------------------------------------------------------------------- -const MULTI_B = [ - 'import A from "./A.xspec"', - "", - '<S id="b" d={A.a.k1}>', - "Beta text embeds: {text(A.a.k2)}", - "</S>", - "", -].join("\n"); +// The one condition CONF-CORE's failing workspace stages (CERTIFICATIONS.md +// §CONF-CORE's staging constraint on T13.5-8): a second spec source beginning +// with a UTF-8 byte-order mark — unparseable (SPEC 1.6, 14.20), its finding +// the zero-length range at offset 0 (SPEC 14), the file masked and never an +// operand of any arm's command — added after the workspace was built valid +// and after the session the held `review resolve` names was created under +// `--strategy audit`. U+FEFF encodes to EF BB BF; the workspace builder +// writes string contents with BOMs kept (S-2). The code point is spelled +// numerically so the source carries no escape sequence to misread. +const BOM_FILE = "specs/B.mdx"; +const BOM_MDX = + String.fromCodePoint(0xfeff) + '<S id="b">\nBom content.\n</S>\n'; +// Staged after the session was created, so a ledger record (S-9's +// before-any-product clause; helpers/staged-mdx.ts) — the same constant under +// the call's former `unparseable` declaration (14.20). +const T13_5_8_BOM = stagedMdx( + "T13.5-8 specs/B.mdx beginning with a byte-order mark", + BOM_MDX, + "unparseable", +); -const MULTI_C = [ - 'import A from "./A.xspec"', - "", - '<S id="c" d={A.a}>', - "Ceta text.", - "</S>", - "", -].join("\n"); +/** + * The failing workspace's findings as `build` and the gate of 13.3 report + * them: exactly one finding, condition 20 (unparseable source), located in + * the BOM-led file — the one condition the staging presents (§CONF-CORE). + * The finding's identity and file carry the arm; the range SPEC 14 pins for + * a byte-order mark (zero-length, at offset 0) is 14.20's own subject + * (T1.6-5, T14), not this test's, so it is not re-asserted here. + */ +function assertGateFindings( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts( + findings, + { "14.20": 1 }, + `${context}: exactly the one condition the failing workspace stages — ` + + `the BOM-led source's unparseability (SPEC 14.20, 13.3)`, + ); + assertFindingLocated( + findings[0]!, + { file: BOM_FILE }, + `${context}: the condition-20 finding locates the BOM-led file ` + + `${BOM_FILE} — the masked source, never an operand (SPEC 14, 14.20)`, + ); +} -const MULTI_DECL: WorkspaceDecl = { - files: { - "xspec.config.ts": MULTI_CONFIG, - "specs/A.mdx": MULTI_A, - "specs/B.mdx": MULTI_B, - "specs/C.mdx": MULTI_C, - }, -}; +/** + * Drive one invocation a later check refuses or the gate turns back, under + * `--test-hold` with no other holder (SPEC 13.5: exclusivity is acquired + * before the argument checks of 12.0, baseline resolution (6.3), and the + * gate of 13.3, so the seam engages on such an invocation too): the hold + * file is created first — the wait fails loud, a diagnosed product failure, + * when the command exits before creating it, as a product acquiring late + * does, reporting the refusal at once with no hold file (§CONF-CORE's + * justification; H-8, H-9: never a pass on the command's exit or on a + * timeout) — the workspace is byte-identical while held, the command is + * still running after the while-held snapshot, and only once the harness + * deletes the hold file does it exit, with `expectedExit` and nothing + * modified. Returns the run for the caller's own report assertions. + */ +async function refusedSeamArm( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + holdName: string, + expectedExit: number, + context: string, + ordering: string, +): Promise<RunResult> { + const hold = holdPathFor(workspace, holdName); + 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}: ${ordering}, so the hold file is created before the ` + + `refusal is judged — a product judging it first reports it without ` + + `ever creating the hold file`, + ); + await assertEmptyHoldFile(hold, context); + const whileHeld = await snapshotDirectory(workspace.root); + assertSnapshotsEqual( + before, + whileHeld, + `${context}: the workspace while held vs before the command started — ` + + `byte-identical: the hold precedes every later check and every ` + + `modification, and a refused invocation modifies nothing (SPEC 13.5)`, + ); + if (running.hasExited()) { + fail( + `${context}: the command must exit only after the hold file is ` + + `deleted — its refusal follows the hold — 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) { + rethrowOutputOverflow(error); + return fail( + `${context}: once the hold file is deleted the command must proceed ` + + `to its own refusal and exit (SPEC 13.5) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + assertExitCode( + result, + expectedExit, + `${context}: after the hold's deletion the command exits with its ` + + `own outcome — ${ordering} (SPEC 13.5, 12.0)`, + ); + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `${context}: the refused invocation modifies nothing — before, while ` + + `held, and after (SPEC 13.5, 12.0)`, + ); + return result; + } finally { + running.kill(); + await releaseHoldFile(hold); + } +} -// Post-release kill delays in milliseconds — scheduling choreography only, -// never an assertion input (H-10): the operative assertion is -// delay-independent. -const KILL_DELAYS_MS: readonly number[] = [0, 2, 5, 10, 20, 40, 80, 160]; +/** + * The failing-workspace arms — exclusion first, then the gate's seam + * ordering — on one workspace built valid, its audit session created, and + * only then failed by the BOM-led second source (§CONF-CORE): the gate + * writes nothing on it (SPEC 13.3), so every command here starts where no + * refresh is pending (§VIOL-CORE-EARLYREFRESH's passing side). The `build` + * that establishes the failing workspace runs outside every bracket and + * before anything is held (§VIOL-CORE-CHATTYREADS's passing side). + */ +async function failingWorkspaceArms(product: ProductBinding): Promise<void> { + await withWorkspace(CORE_DECL, async (workspace) => { + await buildOk(product, workspace, "T13.5-8 staging `build`"); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + "T13.5-8 staging `review create --strategy audit --name s`", + ); + const status = await sessionStatus( + product, + workspace, + "s", + "T13.5-8 staging", + ); + const gItem = requireRowByScope( + status, + "specs/A.mdx#g", + "T13.5-8 staging (leaf item)", + ); -const T13_5_7 = defineProductTest({ - id: "T13.5-7", - title: - "a mutating command killed mid-operation can leave the workspace inconsistent and `check` reports such states rather than passing silently: a kill at the held point demonstrably leaves the workspace consistent (`check` passes), and across a spread of post-release kill timings on a multi-file `rename`, `check` never crashes and either passes on a consistent state or reports findings (SPEC 13.5, 14)", - run: async (product) => { - const probeKill = async (delayMs: number | null): Promise<void> => { - const label = - delayMs === null ? "held point" : `${String(delayMs)} ms after release`; - await withWorkspace(MULTI_DECL, async (workspace) => { - await buildOk( - product, - workspace, - `T13.5-7 (${label}) staging \`build\``, + // The workspace now fails `build`'s validations: the staging premise, + // established through `build --json` itself — exit 1, exactly the + // condition-20 finding at the BOM file's offset 0 — before any command + // is held and outside every bracket. + await workspace.file(BOM_FILE, T13_5_8_BOM); + const premise = + "T13.5-8 staging premise `build --json` on the failing workspace " + + `(${BOM_FILE} begins with a byte-order mark)`; + assertGateFindings( + await buildFindings(product, workspace, premise), + premise, + ); + + // Exclusion first (SPEC 13.5: the refusal precedes the gate's findings + // and the precondition's): command 1 is `review resolve` under + // `--test-hold` — acquisition precedes the gate of 13.3, so it holds + // rather than exiting 1 at the gate — and each other mutating command, + // valid in its own right and carrying no `--test-hold` + // (§VIOL-CORE-NOLOCK's staging constraint), is refused exit 2 while it + // is held, never the gate's or the precondition's exit 1, modifying + // nothing — the compare bracketing each excluded command alone with its + // baseline taken while command 1 is already held (§CONF-CORE, + // §VIOL-CORE-EARLYWRITE; T13.5-2's compare). + const hold = holdPathFor(workspace, "hold-resolve.tmp"); + const context1 = + "T13.5-8 command 1 `review resolve s <leaf item> --status skipped " + + "--test-hold <path>` on the failing workspace"; + const running = await startProduct(product, { + cwd: workspace.root, + argv: [ + "review", + "resolve", + "s", + gItem.id, + "--status", + "skipped", + "--test-hold", + hold, + ], + }); + try { + await awaitHoldFile( + running, + hold, + `${context1}: exclusivity is acquired before the gate of 13.3, so ` + + `the hold file is created before the gate turns the command back ` + + `— a product running the gate first exits 1 there without ever ` + + `creating it`, + ); + await assertEmptyHoldFile(hold, context1); + const heldBaseline = await snapshotDirectory(workspace.root); + const excluded: readonly (readonly [ + readonly string[], + string, + string, + ])[] = [ + [ + ["review", "create", "--strategy", "audit", "--name", "n"], + "`review create --strategy audit --name n`", + "the gate of 13.3", + ], + [ + ["review", "resolve", "s", gItem.id, "--status", "skipped"], + "`review resolve s <leaf item> --status skipped`", + "the gate of 13.3", + ], + [ + ["rename", "specs/A.mdx", "a", "b"], + "`rename specs/A.mdx a b`", + "rename's valid-workspace precondition (6.4)", + ], + ]; + for (const [argv, what, later] of excluded) { + const context = `T13.5-8 excluded ${what} while command 1 is held on the failing workspace`; + const result = await runBounded(product, workspace.root, argv, context); + assertExitCode( + result, + 2, + `${context}: the mutual-exclusion refusal precedes ${later} — a ` + + `usage error, exit 2, never the exit 1 of a product that runs ` + + `${later} before acquiring exclusivity (SPEC 13.5, 13.3, 12.0)`, ); - await expectExit( - product, - workspace, - ["check"], - 0, - `T13.5-7 (${label}) staging \`check\` — the staged workspace is ` + - `consistent before the kill (SPEC 12.2)`, + if (running.hasExited()) { + fail( + `${context}: command 1 must still be held when the excluded ` + + `command exits — the refusal is prompt, not a wait for ` + + `command 1 (SPEC 13.5) — ${await describeExit(running)}`, + ); + } + assertSnapshotsEqual( + heldBaseline, + await snapshotDirectory(workspace.root), + `${context}: modifies nothing — journal, sessions, and sources ` + + `byte-identical (SPEC 13.5; T13.5-2's compare)`, ); + } - const hold = holdPathFor(workspace, "hold-kill.tmp"); - const context = `T13.5-7 (${label}) \`rename specs/A.mdx a a2 --test-hold <path>\``; - const running = await startProduct(product, { - cwd: workspace.root, - argv: ["rename", "specs/A.mdx", "a", "a2", "--test-hold", hold], - }); - try { - await awaitHoldFile(running, hold, context); - if (delayMs === null) { - // Held-point kill: the hold file is never deleted. - running.kill("SIGKILL"); - } else { - await releaseHoldFile(hold); - if (delayMs > 0) await sleep(delayMs); - running.kill("SIGKILL"); - } - // The run settles for kills and for completions that beat the - // kill alike; the death's shape is not asserted (kills after the - // hold's release land nondeterministically). - await running.waitForExit(); - } finally { - running.kill(); - await releaseHoldFile(hold); - } + await releaseHoldFile(hold); + let result1: RunResult; + try { + result1 = await running.waitForExit(); + } catch (error) { + rethrowOutputOverflow(error); + return fail( + `${context1}: once the hold file is deleted command 1 must ` + + `proceed to the gate and exit (SPEC 13.5, 13.3) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + assertExitCode( + result1, + 1, + `${context1}: after the hold's deletion the gate of 13.3 turns the ` + + `command back with the failing workspace's findings, exit 1 (SPEC ` + + `13.3, 13.5)`, + ); + assertSnapshotsEqual( + heldBaseline, + await snapshotDirectory(workspace.root), + `${context1}: the gate writes nothing — the session file unchanged, ` + + `no refresh on a failing workspace (SPEC 13.3)`, + ); + } finally { + running.kill(); + await releaseHoldFile(hold); + } + + // Seam ordering at the gate (SPEC 13.3: for a mutating `review` + // subcommand, exclusivity acquisition precedes the gate's report; 13.5): + // with no other holder, `review create --strategy audit --name n` on the + // failing workspace creates the hold file first, holds the workspace + // byte-identical, and only after the hold's deletion exits 1 with the + // condition-20 finding, creating no session. + const gateContext = + "T13.5-8 (seam ordering at the gate: `review create --strategy audit " + + "--name n --json --test-hold <path>` on the failing workspace)"; + const gateResult = await refusedSeamArm( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "n", "--json"], + "hold-gate.tmp", + 1, + gateContext, + "exclusivity is acquired before the gate of 13.3", + ); + assertGateFindings( + decodeFindingsReport( + parseJsonStdout( + gateResult, + `${gateContext} — the gate's report is the findings report of ` + + `12.7 as the entire stdout (SPEC 13.3, 12.0, H-5)`, + ), + gateContext, + ).findings, + gateContext, + ); + }); +} + +/** + * The valid-workspace arms: the seam ordering of the 12.0 argument checks + * and of baseline resolution (6.3), then the non-mutating boundary of 6.6 — + * each refused invocation started on a freshly built workspace with no + * refresh pending (§CONF-CORE's freshness constraint) that lies in no + * repository (§CONF-CORE's staging constraint on the baseline arm: every + * ref is unresolvable there, whatever its spelling). + */ +async function validWorkspaceArms(product: ProductBinding): Promise<void> { + await withWorkspace(CORE_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T13.5-8 staging `build` (valid workspace)", + ); + await assertOutsideAnyRepository( + workspace.root, + "T13.5-8 (seam ordering: `review create --base <unresolvable-ref>`)", + ); + + // A nonexistent old ID: the argument checks of 12.0 refuse it (6.4), + // after acquisition and the hold. + await refusedSeamArm( + product, + workspace, + ["rename", "specs/A.mdx", "nope", "x"], + "hold-nope.tmp", + 2, + "T13.5-8 (seam ordering: `rename specs/A.mdx nope x --test-hold " + + "<path>`, a nonexistent old ID)", + "exclusivity is acquired before the argument checks of 12.0 — a " + + "nonexistent old ID's usage error (6.4)", + ); + + // An unresolvable baseline: in no repository, no ref can be read — a + // usage error (6.3, 12.0), judged after acquisition and the hold. + await refusedSeamArm( + product, + workspace, + ["review", "create", "--base", "no-such-ref", "--name", "n"], + "hold-base.tmp", + 2, + "T13.5-8 (seam ordering: `review create --base no-such-ref --name n " + + "--test-hold <path>`, the workspace in no repository)", + "exclusivity is acquired before baseline resolution — a baseline " + + "that cannot be read is a usage error (6.3)", + ); - const checkContext = `T13.5-7 (${label}) \`check\` after the kill`; + // The non-mutating boundary (SPEC 6.6: a preview acquires no + // exclusivity and does not take the acquisition-tied seam): the refused + // preview exits 2 at once, and `--test-hold` beside `--preview` is + // itself a usage error creating no hold file (T6.6-3). + const previewContext = + "T13.5-8 (non-mutating boundary: `rename specs/A.mdx nope x --preview`)"; + await assertLeavesUnchanged( + workspace.root, + async () => { const result = await runBounded( product, workspace.root, - ["check"], - checkContext, + ["rename", "specs/A.mdx", "nope", "x", "--preview"], + previewContext, ); - if (delayMs === null) { - assertExitCode( - result, - 0, - `${checkContext}: a kill at the held point demonstrably leaves ` + - `the workspace consistent — the hold precedes all ` + - `modification (SPEC 13.5), so \`check\` passes`, - ); - } else if ( - result.signal !== null || - (result.exitCode !== 0 && result.exitCode !== 1) - ) { + assertExitCode( + result, + 2, + `${previewContext}: the refused preview — a nonexistent old ID's ` + + `usage error — exits 2 at once, a preview acquiring no ` + + `exclusivity and taking no seam (SPEC 6.6, 6.4, 12.0)`, + ); + }, + `${previewContext}: a preview modifies nothing (SPEC 6.6)`, + ); + const previewHold = holdPathFor(workspace, "hold-preview.tmp"); + const combinedContext = + "T13.5-8 (non-mutating boundary: `rename specs/A.mdx nope x --preview " + + "--test-hold <path>`)"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runBounded( + product, + workspace.root, + [ + "rename", + "specs/A.mdx", + "nope", + "x", + "--preview", + "--test-hold", + previewHold, + ], + combinedContext, + ); + assertExitCode( + result, + 2, + `${combinedContext}: --test-hold beside --preview is a usage ` + + `error — a preview does not take the acquisition-tied seam ` + + `(SPEC 6.6, 12.0; T6.6-3)`, + ); + if (await pathExists(previewHold)) { fail( - `${checkContext}: \`check\` never crashes and either passes on ` + - `a consistent state (exit 0) or reports findings (exit 1) — ` + - `the workspace's configuration is intact, so no other ` + - `outcome is stageable (SPEC 13.5, 14, 12.0); got ` + - summarizeResult(result), + `${combinedContext}: no hold file may be created — a preview ` + + `acquires nothing, and the flag is refused, not honored ` + + `(SPEC 6.6, 13.5)`, ); } - }); - }; + }, + `${combinedContext}: the usage error modifies nothing (SPEC 6.6, 12.0)`, + ); + }); +} - await probeKill(null); - for (const delayMs of KILL_DELAYS_MS) { - await probeKill(delayMs); - } +const T13_5_8 = defineProductTest({ + id: "T13.5-8", + title: + "acquisition precedes every later check: while `review resolve --test-hold` is held on a workspace failing `build`'s validations (a second spec source beginning with a byte-order mark, 14.20), `review create --strategy audit --name n`, `review resolve s <item> --status skipped`, and `rename specs/A.mdx a b` each fail promptly with the exclusion usage error, exit 2 — never the gate's or the precondition's exit 1 — modifying nothing; under `--test-hold` with no other holder, `rename specs/A.mdx nope x` (a nonexistent old ID, 12.0), `review create --base <unresolvable-ref> --name n` (6.3, the workspace in no repository), and, on the failing workspace, `review create --strategy audit --name n` (13.3's gate) each create the hold file first — the wait failing loud when the command exits without creating it — the workspace byte-identical while held, and exit 2, 2, and 1 with their own usage error or the condition-20 finding only after the hold's deletion, nothing modified; the non-mutating boundary: `rename specs/A.mdx nope x --preview` exits 2 at once acquiring nothing, and `--test-hold` beside `--preview` is exit 2 creating no hold file (SPEC 13.5, 13.3, 6.3, 6.4, 6.6, 12.0, 14)", + run: async (product) => { + await failingWorkspaceArms(product); + await validWorkspaceArms(product); }, }); -/** TEST-SPEC §13.5, in canonical ID order (SUITE-48). */ export const section135Tests: readonly ProductTestEntry[] = [ T13_5_1, T13_5_2, @@ -1540,4 +2316,5 @@ export const section135Tests: readonly ProductTestEntry[] = [ T13_5_5, T13_5_6, T13_5_7, + T13_5_8, ]; diff --git a/test/suite/registry/section-14-ii.ts b/test/suite/registry/section-14-ii.ts new file mode 100644 index 00000000..44706ef7 --- /dev/null +++ b/test/suite/registry/section-14-ii.ts @@ -0,0 +1,2528 @@ +// TEST-SPEC §14 II (the environment refusals: the reporting contract of a +// refused write, and the outcomes of a refused read) — SUITE-49: T14-9, +// T14-10. +// +// T14-9 (write failures, 14.24) is the reporting-contract test of a write the +// environment refuses: a usage error — exit 2, the 12.7 error document as +// stdout (`code` `"write-failure"`, `path` the concerned path), the +// diagnostic on stderr, never a finding — from every command that makes the +// write and from none that writes nothing, met only at the write it refuses +// (12.0: after every check and validation). The per-command states a refused +// write leaves are T13.5-7's subject (section-13.5.ts); the stagings, +// fixtures, twins, and seam choreography are shared through +// write-refusal-staging.ts, and every arm here is contract and recovery: +// stage, refuse, decode, then — the permissions restored — `build` exits 0 +// and `check` is clean (12.1, 13.4). +// +// Staging discipline (E-1, Linux leg): permission removal alone, applied at +// the seam of 13.5 for a mutating command (`runHeldWithStaging`: after +// acquisition, before any modification) and before the invocation for +// `build` and the reads (`runStaged`), each staging verified on the +// harness's own process first — an ineffective one, a privileged runner, is +// a `HarnessStagingError` (H-11), never a pass or a skip (H-9). Elsewhere +// the arms are not staged (the NU3_STAGED pattern of section-11.5, here +// `WRITE_REFUSALS_STAGED`), and the Windows subset (E-6) selects T14-9 +// nowhere. +// +// Conservative operationalizations (H-3): +// - Arm (a)'s rename is `b.k` → `b.k2` in `specs/b/B.mdx` — the child +// identity, referenced nowhere — so the refused source rewrite is the +// operation's first write: the state 13.5 pins is "nothing written", and +// the recovery clause ("after every arm ... `build` exits 0") holds on it. +// The staging is (a)'s (`specs/b` unwritable) and the concerned path the +// same `specs/b/B.mdx`; T13.5-7 (a) drives the multi-file rename whose +// partly applied rewrite leaves the workspace failing validation. +// - "stderr the diagnostic": wording is free, so the assertion is presence — +// a non-empty stderr beside the error document on stdout (12.0: usage +// error messages and diagnostics are standard-error content). +// - The reporter sweep pins every refreshing read's concerned path to the +// graph-data area `.xspec` exactly: a refresh writes graph data alone +// (13.3: no TypeScript or Markdown is generated or removed), and the +// mutating `review create` refreshes before its session write (13.5), so +// on the stale workspace with `.xspec` unwritable the area is the first +// refused write of each — never a path inside it (14.24, 11.6). +// - The refused writes of the precedence arms and the state assertions +// beyond the contract (nothing written where the refused write is the +// operation's first; the journal or the session file byte-unchanged) are +// whole-entry byte compares against the workspace's own pre-invocation +// snapshot, never harness-composed content (H-6). +// +// T14-10 (read failures, 14.25): the object read decides the outcome, one +// arm per row of 14.25, each refusal staged by permission removal alone +// (E-1, Linux leg; `stageReadRefusalOfFile`: mode 0o200, the write +// permission kept so a regeneration replacing or rewriting the object is +// never itself refused; `stageReadRefusalOfDirectory`: mode 0o100, search +// kept so entries stay reachable by name; nonexistence never staged as a +// refusal), each staging verified on the harness's own process first. +// Snapshots for the "nothing modified" laws are taken outside the staging +// windows (a staged object cannot be snapshotted), before staging and after +// restoring. +// +// Conservative operationalizations (H-3): +// - Arm (a)'s fixture: `specs/A.mdx` → `specs/B.mdx` → `specs/C.mdx` ← +// `specs/D.mdx`, plus the code source `src/app.ts` marking A's section. +// A premise asserts that B's `d` reference and the marker each record an +// occurrence when readable, so "no record for its spellings" under the +// refusal is the masking of 14.25, not an absence; D's record stays as +// the per-file answer the surface keeps. +// - "`ids` exits 1 answering nothing" (b) and "`review status` reports +// exactly one finding" (c) are the findings-only document `{"findings": +// […]}` (12.7: a report whose defined content is findings alone), decoded +// form-exact. +// - Arm (f)'s "every path under `.xspec/` other than the journal and the +// session directory staged unreadable" is every plain file in T13.3-2's +// operational path set (`isGraphDataKey`), recursively, at mode 0o200; +// directories keep their listing and write permission so the +// regeneration `ids` owes (13.3) is never itself refused, whatever the +// product's layout. A staged file the regeneration has since removed is +// not restored (nothing to reinstate). The preview is the file-form +// `move specs/c/C.mdx specs/c/D.mdx` — same directory, C imported nowhere +// — a plan valid on the readable record. +// - Arm (g)'s "a malformed value" is `at specs/a/A.mdx zz` (an offset +// spelled as anything but decimal digits, 11.5 — a syntax-class member, +// 12.0); "nothing modified" is the whole-tree snapshot around the sweep. +// - Unstageable, recorded here as T14-10 records them (and as T6.5-6 +// records its unstageable clauses): a refused read of a path occupant's +// kind — whether a product learns a kind by a separate examination the +// environment can refuse or from a listing it already made is its own +// (7, 13.4) — and a refused read of a directory above the workspace root, +// which the working directory lies beneath and cannot be entered without +// traversing. Neither is asserted. + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import type { + Finding, + OccurrencesReport, +} from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeFindingsReport, + decodeIdsReport, + decodeInventoryDocument, + decodeOccurrencesReport, + decodePreviewReport, + decodeSessionListReport, + decodeViewReport, + isGraphDataKey, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import type { PermissionStaging } from "../../helpers/permissions.js"; +import { + HarnessStagingError, + stageReadRefusalOfDirectory, + stageReadRefusalOfFile, + stageWriteRefusal, +} from "../../helpers/permissions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { DirectorySnapshot } from "../../helpers/snapshot.js"; +import { assertSnapshotsEqual } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { pathExists } from "../../helpers/subprocess.js"; +import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertFindingConcernsPath, + assertFindingLocated, + assertSameJson, + buildOk, + expectConfigurationError, + expectErrorDocument, + expectExit, + runJson, +} from "./support.js"; +import type { RefusalFixture } from "./write-refusal-staging.js"; +import { + GRAPH_DATA_AREA, + JOURNAL_PATH, + MOVE_ARGV, + MOVE_DESTINATION, + MOVE_DESTINATION_DIR, + MOVE_FIXTURE, + MOVE_MARKDOWN_DIR, + MOVE_MARKDOWN_WRITES, + MOVE_ORIGIN, + RENAME_A_PATH, + RENAME_B_DIR, + RENAME_B_MODULE, + RENAME_B_PATH, + RENAME_C_PATH, + RENAME_FIXTURE, + REVIEWS_DIR, + WRITE_REFUSALS_STAGED, + assertStalenessAlone, + expectWriteFailure, + isDerivedFile, + prepareRefusalWorkspace, + refusalAt, + refusalUnder, + requireItemByScope, + runHeldWithStaging, + runSettled, + runStaged, + sessionStatus, + snapshotWorkspace, +} from "./write-refusal-staging.js"; + +// --------------------------------------------------------------------------- +// Shared constants and helpers +// --------------------------------------------------------------------------- + +/** The arms' full rename, `b` → `b2` in `specs/b/B.mdx` (SPEC 6.4). */ +const RENAME_B_TO_B2: readonly string[] = [ + "rename", + RENAME_B_PATH, + "b", + "b2", + "--json", +]; +/** Arm (a)'s rename: the child identity `b.k`, referenced nowhere. */ +const RENAME_CHILD: readonly string[] = [ + "rename", + RENAME_B_PATH, + "b.k", + "b.k2", + "--json", +]; +const MOVE_JSON: readonly string[] = [...MOVE_ARGV, "--json"]; + +// Text-only staleness edits of built sources: the workspace stays valid, its +// graph data and the edited source's derived files stale (SPEC 13.3). +const B_EDIT_FROM = "Beta text."; +const B_EDIT_TO = "Beta text, edited."; +const A_EDIT_FROM = "Alpha text."; +const A_EDIT_TO = "Alpha text, edited."; +/** A's `d` reference into B after the fixture's prior rename (`b0` → `b`). */ +const A_REFERENCE = "d={B.b}"; +/** The same reference respelled to resolve nowhere (SPEC 14.5). */ +const A_UNRESOLVED = "d={B.nope}"; + +const SESSION = "s"; +const NEW_SESSION = "n"; +const SESSION_FILE = `${REVIEWS_DIR}/${SESSION}.json`; +/** An unblocked leaf item's scope (SPEC 10.6) — not the edited source's. */ +const RESOLVE_SCOPE = "specs/c/C.mdx#c"; +/** A requirement node of the built rename fixture, `show`'s operand. */ +const SHOW_IDENTITY = "specs/b/B.mdx#b"; + +/** + * A refused write's 14.24 contract with its diagnostic: exit 2, the error + * document as stdout (`write-failure`, a concerned path — `expectWriteFailure`), + * and a non-empty stderr (12.0: the diagnostic is standard-error content). + */ +function expectRefusal( + result: RunResult, + concerned: readonly string[], + context: string, +): Finding { + const finding = expectWriteFailure(result, concerned, context); + if (result.stderr.trim().length === 0) { + fail( + `${context} — the diagnostic accompanies the error document on ` + + `standard error (SPEC 14.24, 12.0: usage-error messages are ` + + `standard-error content); got an empty stderr beside ` + + `${JSON.stringify(finding.message)}`, + ); + } + return finding; +} + +/** + * Recovery (SPEC 12.1, 13.4): with the permissions restored — every staging + * is restored the moment its command exits — the next `build` exits 0 and + * `check` is clean. + */ +async function assertRecovers( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + await buildOk( + product, + workspace, + `${context}: recovery — the permissions restored, the next \`build\` ` + + `exits 0 (SPEC 12.1, 13.4)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: recovery — after the next \`build\`, \`check\` is clean ` + + `(SPEC 12.2, 13.4)`, + ); +} + +/** The bytes of the snapshot's plain file at `rel`. */ +function fileBytes( + snapshot: DirectorySnapshot, + rel: string, + context: string, +): Uint8Array { + const entry = snapshot.entries.get(rel); + if (entry === undefined || entry.kind !== "file") { + return fail( + `${context}: ${rel} must be a plain file (SPEC 13.4); found ` + + (entry === undefined ? "absent" : entry.kind), + ); + } + return entry.bytes; +} + +/** One plain file byte-unchanged between two snapshots. */ +function assertFileUnchanged( + before: DirectorySnapshot, + after: DirectorySnapshot, + rel: string, + context: string, +): void { + const prior = fileBytes(before, rel, context); + const current = fileBytes(after, rel, context); + if (Buffer.compare(Buffer.from(prior), Buffer.from(current)) !== 0) { + fail(`${context}: ${rel} must be byte-unchanged; found it rewritten`); + } +} + +// --------------------------------------------------------------------------- +// The concerned paths, one arm each (T13.5-7's stagings) +// --------------------------------------------------------------------------- + +/** + * (a) A rewritten source: `rename specs/b/B.mdx b.k b.k2` with `specs/b` + * staged unwritable at the seam — the error document concerns + * `specs/b/B.mdx`; the refused rewrite being the operation's first write + * (the child identity is referenced nowhere), nothing is written: no + * journal entry, nothing regenerated (13.5). + */ +async function rewrittenSourceArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 (a) `rename specs/b/B.mdx b.k b.k2 --json` with specs/b unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const result = await runHeldWithStaging( + product, + workspace, + RENAME_CHILD, + "hold-t14-9-a.tmp", + refusalUnder(RENAME_B_DIR), + context, + ); + expectRefusal( + result, + [RENAME_B_PATH], + `${context} — the rewritten source is the concerned path (SPEC 14.24, 6.4)`, + ); + assertSnapshotsEqual( + prepared.before, + await snapshotWorkspace(workspace.root), + `${context}: nothing written — the child identity \`b.k\` is ` + + `referenced nowhere, so B's refused rewrite is the operation's ` + + `first write and the command stops there: no journal entry (the ` + + `append follows every source edit), nothing regenerated (SPEC ` + + `13.5, 14.24)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (b) The journal: the full rename with `.xspec/journal` staged unwritable + * (`.xspec` read-only, its occupant unwritable) — the error document + * concerns `.xspec/journal`, the journal byte-unchanged (no entry); restored, + * the consistently rewritten sources build clean (T13.5-7 (b)). + */ +async function journalArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 (b) `rename specs/b/B.mdx b b2 --json` with .xspec/journal unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const result = await runHeldWithStaging( + product, + workspace, + RENAME_B_TO_B2, + "hold-t14-9-b.tmp", + refusalAt(JOURNAL_PATH), + context, + ); + expectRefusal( + result, + [JOURNAL_PATH], + `${context} — the journal is the concerned path (SPEC 14.24, 6.1)`, + ); + assertFileUnchanged( + prepared.before, + await snapshotWorkspace(workspace.root), + JOURNAL_PATH, + `${context}: the refused append leaves no entry (SPEC 13.5, 6.1)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (c) An emitted Markdown file's creation or removal: the file-form move + * with `out/specs` staged unwritable — the error document concerns one of + * the two Markdown writes the finishing regeneration owes there, + * `out/specs/sub/B.md`'s creation (the directory it needs is part of that + * write, 13.4) or `out/specs/A.md`'s removal, the order among a + * regeneration's derived-file writes being unpinned (13.5). + */ +async function markdownArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 (c) `move specs/A.mdx specs/sub/B.mdx --json` with out/specs unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + MOVE_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const result = await runHeldWithStaging( + product, + workspace, + MOVE_JSON, + "hold-t14-9-c.tmp", + refusalUnder(MOVE_MARKDOWN_DIR), + context, + ); + expectRefusal( + result, + MOVE_MARKDOWN_WRITES, + `${context} — an emitted Markdown file's creation or removal under ` + + `${MOVE_MARKDOWN_DIR} is the concerned path (SPEC 14.24, 13.2, 13.5)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (d) A relocation's second write, the origin's removal: the file-form move + * with `specs/sub` present and writable and `specs` staged unwritable — the + * destination produced, the origin's removal refused, the error document + * concerning `specs/A.mdx`, the origin's own path (14.24: each of a + * relocation's two writes concerns its own path). + */ +async function originRemovalArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 (d) `move specs/A.mdx specs/sub/B.mdx --json` with specs unwritable, specs/sub present and writable"; + const prepared = await prepareRefusalWorkspace( + product, + MOVE_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await workspace.dir(MOVE_DESTINATION_DIR); + const result = await runHeldWithStaging( + product, + workspace, + MOVE_JSON, + "hold-t14-9-d.tmp", + refusalAt(MOVE_ORIGIN), + context, + ); + expectRefusal( + result, + [MOVE_ORIGIN], + `${context} — the origin's removal, the relocation's second write, ` + + `concerns the origin's own path (SPEC 14.24, 13.5)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (d) A relocation's first write, the destination's production: the same + * move with `specs/sub` the directory staged unwritable instead — the error + * document concerns `specs/sub/B.mdx`, the origin still present and nothing + * relocated: the relocation's entry precedes `src/` in `files` order (6.6, + * 12.7), so its refused first write leaves the whole workspace + * byte-unchanged (13.5). + */ +async function destinationProductionArm( + product: ProductBinding, +): Promise<void> { + const context = + "T14-9 (d) `move specs/A.mdx specs/sub/B.mdx --json` with specs/sub unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + MOVE_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await workspace.dir(MOVE_DESTINATION_DIR); + const staged = await snapshotWorkspace(workspace.root); + const result = await runHeldWithStaging( + product, + workspace, + MOVE_JSON, + "hold-t14-9-d2.tmp", + refusalUnder(MOVE_DESTINATION_DIR), + context, + ); + expectRefusal( + result, + [MOVE_DESTINATION], + `${context} — the destination's production, the relocation's first ` + + `write, concerns the destination's own path (SPEC 14.24, 13.5)`, + ); + assertSnapshotsEqual( + staged, + await snapshotWorkspace(workspace.root), + `${context}: the origin still present, nothing relocated — the ` + + `relocation's entry precedes src/ in files order (SPEC 6.6, 12.7), ` + + `so its refused first write leaves every source, the journal, ` + + `derived files, and graph data byte-unchanged (SPEC 13.5, 14.24)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (e) A session file: on a stale, valid workspace holding an audit session, + * `review resolve s <item> --status no-change` with `.xspec/reviews` and the + * session file staged unwritable at the seam (`.xspec` itself writable, so + * the refresh preceding the session write succeeds, 13.5) — the error + * document concerns `.xspec/reviews/s.json`, the session file + * byte-unchanged. + */ +async function sessionFileArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 (e) `review resolve s <item> --status no-change --json` with .xspec/reviews and the session file unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", SESSION], + 0, + `${context} staging \`review create --strategy audit --name s\` (SPEC 10.7)`, + ); + // The item lookup precedes the staleness edit: `review status` is itself + // a refreshing read (SPEC 13.3), so it runs while nothing is stale. + const item = requireItemByScope( + await sessionStatus(product, workspace, SESSION, `${context} staging`), + RESOLVE_SCOPE, + `${context} staging`, + ); + await workspace.edit(RENAME_A_PATH, A_EDIT_FROM, A_EDIT_TO); + const staged = await snapshotWorkspace(workspace.root); + const result = await runHeldWithStaging( + product, + workspace, + [ + "review", + "resolve", + SESSION, + item.id, + "--status", + "no-change", + "--json", + ], + "hold-t14-9-e.tmp", + refusalAt(SESSION_FILE), + context, + ); + expectRefusal( + result, + [SESSION_FILE], + `${context} — the session file is the concerned path (SPEC 14.24, 10.7)`, + ); + assertFileUnchanged( + staged, + await snapshotWorkspace(workspace.root), + SESSION_FILE, + `${context}: the session write, the mutator's last write, refused ` + + `(SPEC 13.5, 14.24)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (f) A generated module or companion: `build` on the B-edited workspace + * with `specs/b` staged unwritable before the invocation (`build` takes no + * hold) — the error document concerns a derived path under `specs/b/`, the + * first `specs/b/` write in the product's order: the set a regeneration + * writes there is the set the prior build left (12.1 rewrites every derived + * file; the edit changes content, not the set). + */ +async function derivedPathArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 (f) `build --json` with specs/b unwritable on the B-edited workspace"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const derivedUnderB = [...prepared.before.entries.keys()].filter( + (rel) => + isDerivedFile(rel, prepared.before.entries.get(rel)) && + rel.startsWith(`${RENAME_B_DIR}/`), + ); + if (derivedUnderB.length === 0) { + fail( + `${context}: the built workspace holds B's generated module and ` + + `companions beside its source under ${RENAME_B_DIR}/ (SPEC 13.1, ` + + `13.2); found none among ` + + JSON.stringify([...prepared.before.entries.keys()]), + ); + } + await workspace.edit(RENAME_B_PATH, B_EDIT_FROM, B_EDIT_TO); + const result = await runStaged( + product, + workspace, + ["build", "--json"], + refusalUnder(RENAME_B_DIR), + context, + ); + expectRefusal( + result, + derivedUnderB, + `${context} — a generated module or companion under ${RENAME_B_DIR}/, ` + + `the first ${RENAME_B_DIR}/ write in the product's order, is the ` + + `concerned path (SPEC 14.24, 12.1, 13.1)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// The reporter set, the never-reporters, and precedence (SPEC 14.24, 12.0) +// --------------------------------------------------------------------------- + +// The precedence fixture: the rename fixture plus a code group — `app`, one +// code source importing A's generated module — so that `query nodes --group +// app` names a code group, an invalid flag value (SPEC 11.1: `--group` +// accepts only a configured spec group's name). The prior journaled rename +// is the rename fixture's; the code source references nothing it rewrites. +// T14-9 prepares it after its first product invocation, so its +// configuration and code source are TypeScript staged-source records +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses). +const CODE_GROUP = "app"; +const PRECEDENCE_CONFIG = stagedTs( + "T14-9 the precedence fixture xspec.config.ts (one spec group, the code group app, Markdown emission next to sources)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + ${CODE_GROUP}: ["src/**/*.ts"] + }, + markdown: { emit: true } +}) +`, +); +const PRECEDENCE_APP = stagedTs( + "T14-9 the precedence fixture src/app.ts (importing specs/a/A.mdx's module)", + ['import A from "../specs/a/A.xspec";', "", "A.a;", ""].join("\n"), +); +const PRECEDENCE_FIXTURE: RefusalFixture = { + decl: { + files: { + ...RENAME_FIXTURE.decl.files, + "xspec.config.ts": PRECEDENCE_CONFIG, + "src/app.ts": PRECEDENCE_APP, + }, + }, + priorRename: RENAME_FIXTURE.priorRename, +}; + +/** An exit-2 error the checks of 12.0 report with no stable code. */ +function expectPlainUsageError( + result: RunResult, + why: string, + context: string, +): void { + assertExitCode( + result, + 2, + `${context} — ${why} is a usage error, exit 2, reported before any ` + + `write: a write failure is met only at the write it refuses, after ` + + `every check and validation, so a product attempting the refresh ` + + `first fails here (SPEC 12.0, 14.24)`, + ); + const finding = expectErrorDocument(result, context); + if (finding.code !== null) { + fail( + `${context} — ${why} carries no stable code: \`code\` is null where ` + + `14 assigns none, never \`write-failure\` (SPEC 14, 12.7); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } +} + +/** + * Graph data, the reporter set, the never-reporters, and the check-first + * precedence, all on one state: the built precedence fixture holding an + * audit session and a git baseline, B text-edited (stale, valid), `.xspec` + * staged unwritable before each invocation (the reads take no hold). Every + * refreshing read of 13.3 and the mutating `review create` exit 2 with the + * error document concerning `.xspec` (14.24: a graph-data write concerns + * the area); `check` exits 1 with the staleness alone, `inventory` and + * `version` exit 0, a `rename --preview` exits 0 writing nothing; the + * invalid-flag-value and unknown-node usage errors precede the refresh + * (`code` null); nothing in the workspace changes around the sweep. + */ +async function reportersArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 reporters: the stale, session-bearing workspace with .xspec unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + PRECEDENCE_FIXTURE, + context, + ); + const { workspace, baseline } = prepared; + try { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", SESSION], + 0, + `${context} staging \`review create --strategy audit --name s\` (SPEC 10.7)`, + ); + await workspace.edit(RENAME_B_PATH, B_EDIT_FROM, B_EDIT_TO); + const staged = await snapshotWorkspace(workspace.root); + const staging = await refusalUnder(GRAPH_DATA_AREA)(workspace.root); + try { + const reporters: readonly (readonly string[])[] = [ + ["ids", "--json"], + ["show", SHOW_IDENTITY, "--json"], + ["coverage", "--json"], + ["impact", "--base", baseline, "--json"], + ["review", "status", SESSION, "--json"], + ["query", "nodes"], + ["occurrences"], + ["view", RENAME_A_PATH], + ["at", RENAME_A_PATH, "0"], + [ + "review", + "create", + "--strategy", + "audit", + "--name", + NEW_SESSION, + "--json", + ], + ]; + for (const argv of reporters) { + const label = `${context} \`${argv.join(" ")}\``; + expectRefusal( + await runSettled(product, workspace, argv, label), + [GRAPH_DATA_AREA], + `${label} — a refreshing read of 13.3, or a review mutator ` + + `refreshing before its session write (13.5), whose graph-data ` + + `write the environment refuses reports the write failure ` + + `concerning the graph-data area, never a path inside it, and ` + + `answers nothing (SPEC 14.24, 11.6, 13.3)`, + ); + } + + // Never-reporters on the same state (14.24: never `check` or any other + // command that writes nothing; 6.6: a preview writes nothing). + const checkLabel = `${context} \`check --json\``; + const checkResult = await runSettled( + product, + workspace, + ["check", "--json"], + checkLabel, + ); + assertExitCode( + checkResult, + 1, + `${checkLabel} — \`check\` writes nothing, so it is never a 14.24 ` + + `reporter: on the same state it exits 1 with the staleness ` + + `(SPEC 14.24, 12.2)`, + ); + const checkFindings = decodeFindingsReport( + parseJsonStdout(checkResult, checkLabel), + checkLabel, + ).findings; + if (checkFindings.length === 0) { + fail( + `${checkLabel}: exit 1 carries the staleness findings (SPEC 12.2, 14.10)`, + ); + } + for (const finding of checkFindings) { + if (finding.condition !== "14.10") { + fail( + `${checkLabel}: \`check\` reports the staleness alone — ` + + `condition 10, never a write failure (SPEC 14.24, 14.10); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + await runJson( + product, + workspace, + ["inventory"], + `${context} \`inventory\` — \`inventory\` parses no sources and ` + + `writes nothing: exit 0 with its answer on the stale workspace ` + + `whose graph-data area is unwritable (SPEC 14.24, 11.6)`, + ); + await runJson( + product, + workspace, + ["version"], + `${context} \`version\` — \`version\` writes nothing and loads no ` + + `configuration: exit 0 (SPEC 14.24, 12.6)`, + ); + const previewArgv = [ + "rename", + RENAME_B_PATH, + "b", + "b2", + "--preview", + "--json", + ]; + const previewLabel = `${context} \`${previewArgv.join(" ")}\``; + const preview = await runSettled( + product, + workspace, + previewArgv, + previewLabel, + ); + assertExitCode( + preview, + 0, + `${previewLabel} — a preview writes nothing — no sources, no ` + + `journal, no derived files, no graph data — so it exits 0 on the ` + + `valid, stale workspace, refreshing nothing (SPEC 6.6, 14.24)`, + ); + + // Precedence: the checks of 12.0 precede the write (and the refresh). + expectPlainUsageError( + await runSettled( + product, + workspace, + ["query", "nodes", "--group", CODE_GROUP], + `${context} \`query nodes --group ${CODE_GROUP}\``, + ), + `an invalid flag value — a code group's name under \`--group\` (SPEC 11.1)`, + `${context} \`query nodes --group ${CODE_GROUP}\``, + ); + const missing = `${RENAME_A_PATH}#missing`; + expectPlainUsageError( + await runSettled( + product, + workspace, + ["query", "node", missing], + `${context} \`query node ${missing}\``, + ), + "an unknown node identity named in an argument (SPEC 12.0)", + `${context} \`query node ${missing}\``, + ); + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + staged, + await snapshotWorkspace(workspace.root), + `${context}: nothing in the workspace changes around the sweep — a ` + + `refresh writes graph data alone (refused), a preview writes ` + + `nothing, and the usage errors precede every write (SPEC 13.3, ` + + `6.6, 12.0)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * Precedence, the failing twin: an identically prepared workspace whose + * sources also fail validation (A's `d` reference respelled to resolve + * nowhere, 14.5) beside the staleness edit — with `.xspec` staged + * unwritable, `ids` exits 1 with the findings: nothing is written on a + * failing workspace (13.3), so no write is refused, and a product + * attempting the refresh first fails here. + */ +async function failingTwinArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 precedence: `ids --json` on the failing, stale twin with .xspec unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + PRECEDENCE_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await workspace.edit(RENAME_B_PATH, B_EDIT_FROM, B_EDIT_TO); + await workspace.edit(RENAME_A_PATH, A_REFERENCE, A_UNRESOLVED); + const staged = await snapshotWorkspace(workspace.root); + const result = await runStaged( + product, + workspace, + ["ids", "--json"], + refusalUnder(GRAPH_DATA_AREA), + context, + ); + assertExitCode( + result, + 1, + `${context} — the gate of 13.3 turns the read back with the ` + + `workspace's findings, exit 1: nothing is written on a failing ` + + `workspace, so no write is refused (SPEC 13.3, 14.24, 12.0)`, + ); + assertConditionCounts( + decodeFindingsReport(parseJsonStdout(result, context), context).findings, + { "14.5": 1 }, + `${context} — exactly the staged condition, A's unresolved \`d\` ` + + `reference (SPEC 14.5, 13.3)`, + ); + assertSnapshotsEqual( + staged, + await snapshotWorkspace(workspace.root), + `${context}: nothing written on the failing workspace (SPEC 13.3)`, + ); + // Recovery: the harness's own invalidity reverted, then `build`/`check`. + await workspace.edit(RENAME_A_PATH, A_UNRESOLVED, A_REFERENCE); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * Precedence, a validation refusal under (a)'s staging: an + * identity-unchanged rename (T6.4-3) with `specs/b` staged unwritable at the + * seam exits 1 with its refusal reported alone — exactly one finding, + * `refused-identity-unchanged` — and attempts no write: the workspace + * byte-unchanged (6.4, 13.5). + */ +async function validationRefusalArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 precedence: `rename specs/b/B.mdx b b --json` refused by validation with specs/b unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const result = await runHeldWithStaging( + product, + workspace, + ["rename", RENAME_B_PATH, "b", "b", "--json"], + "hold-t14-9-refused.tmp", + refusalUnder(RENAME_B_DIR), + context, + ); + assertExitCode( + result, + 1, + `${context} — an identity-unchanged rename is refused by validation: ` + + `exit 1 with its refusal, before any write (SPEC 6.4, 12.0, 14.24)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + context, + ).findings; + const codes = findings.map((finding) => finding.code); + if (codes.length !== 1 || codes[0] !== "refused-identity-unchanged") { + fail( + `${context} — the refusal is reported alone: exactly one finding, ` + + `its stable code \`refused-identity-unchanged\`, never a write ` + + `failure beside it (SPEC 6.4, 14); got ${JSON.stringify(codes)}`, + ); + } + assertSnapshotsEqual( + prepared.before, + await snapshotWorkspace(workspace.root), + `${context}: no write attempted — the refused rename modifies ` + + `nothing (SPEC 6.4, 13.5)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +/** + * Precedence, the hold file: `--test-hold` naming a path in a read-only + * directory (staged under T14-9's path-form discipline, beside the + * workspace) — the hold file cannot be created, 13.5's usage error: exit 2, + * the error document with `code` null, never this condition; nothing + * modified. + */ +async function holdFileArm(product: ProductBinding): Promise<void> { + const context = + "T14-9 precedence: `rename specs/b/B.mdx b b2 --test-hold <read-only directory>/hold.tmp --json`"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const holdDir = path.join(workspace.tempRoot, "t14-9-read-only"); + await fsp.mkdir(holdDir); + const hold = path.join(holdDir, "hold.tmp"); + const staging = await stageWriteRefusal(hold); + try { + expectPlainUsageError( + await runSettled( + product, + workspace, + [...RENAME_B_TO_B2, "--test-hold", hold], + context, + ), + "a hold file that cannot be created — the usage error of 13.5, " + + "never a write of 14.24 (SPEC 13.5, 14.24)", + context, + ); + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + prepared.before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified — the hold file is created before any ` + + `modification, and its failure stops the command (SPEC 13.5)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// T14-9 — write failures (14.24) +// --------------------------------------------------------------------------- + +const T14_9 = defineProductTest({ + id: "T14-9", + title: + "write failures (14.24): a write the environment refuses is a usage error — exit 2, the error document (`code` `write-failure`, `path` the concerned path) on stdout, the diagnostic on stderr, never a finding — from every command making the write and met only at the write it refuses; one arm per concerned path through T13.5-7's stagings: a derived path under specs/b/ (`build`), an emitted Markdown file under out/specs (`move`), the rewritten source specs/b/B.mdx and the journal (`rename`), the session file (`review resolve`), a relocation's origin removal and destination production (`move`), and the graph-data area `.xspec` from every refreshing read of 13.3 and `review create` on a stale workspace — while `check` exits 1 with the staleness, `inventory` and `version` exit 0, and a `--preview` exits 0 writing nothing; precedence: the invalid-flag-value and unknown-node usage errors (`code` null), the failing twin's findings (`ids` exit 1), a validation refusal under (a)'s staging (exit 1, no write), and a hold file in a read-only directory (13.5's usage error, `code` null); after every arm, permissions restored, `build` exits 0 and `check` is clean (SPEC 14.24, 12.0, 12.7, 13.3, 13.5)", + // A hang guard only (H-10): eleven workspaces, each built, renamed, + // checked, committed, and driven — generous under a saturated box. + timeoutMs: 600_000, + run: async (product) => { + // E-1: permission stagings belong to the Linux leg; elsewhere the arms + // are not staged (the NU3_STAGED pattern of section-11.5) and no other + // leg selects T14-9 (E-6). + if (!WRITE_REFUSALS_STAGED) return; + await rewrittenSourceArm(product); + await journalArm(product); + await markdownArm(product); + await originRemovalArm(product); + await destinationProductionArm(product); + await sessionFileArm(product); + await derivedPathArm(product); + await reportersArm(product); + await failingTwinArm(product); + await validationRefusalArm(product); + await holdFileArm(product); + }, +}); + +// --------------------------------------------------------------------------- +// T14-10 — read failures (14.25): fixtures and shared helpers +// --------------------------------------------------------------------------- + +/** E-1: the read-refusal stagings belong to the same Linux leg (T14-9's gate). */ +const READ_REFUSALS_STAGED = WRITE_REFUSALS_STAGED; + +/** 14.25's stable code, carried only by the exit-2 error document (SPEC 14). */ +const READ_FAILURE_CODE = "read-failure"; +/** The configuration path in the anchoring form, from the root (SPEC 11.6). */ +const CONFIG_PATH = "xspec.config.ts"; +/** Arm (f)'s file-form move of C within its own directory (SPEC 6.5, 6.6). */ +const MOVE_C_DESTINATION = "specs/c/D.mdx"; + +// Arm (a)'s fixture: `specs/A.mdx` references `specs/B.mdx` (a `d` +// reference — the one 14.5 the masking leaves), B references `specs/C.mdx` +// (B's own spelling, the occurrence the masking hides), `specs/D.mdx` +// references C too (the record that stays on view), and the code source +// `src/app.ts` marks A's section (the marker's occurrence, hidden when the +// code source is the refused one). No cycle: A → B → C ← D. +const SOURCE_A_PATH = "specs/A.mdx"; +const SOURCE_B_PATH = "specs/B.mdx"; +const SOURCE_C_PATH = "specs/C.mdx"; +const SOURCE_D_PATH = "specs/D.mdx"; +const SOURCE_CODE_PATH = "src/app.ts"; +const SOURCE_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + ${CODE_GROUP}: ["src/**/*.ts"] + } +}) +`; +const SOURCE_DECL: WorkspaceDecl = { + files: { + [CONFIG_PATH]: SOURCE_CONFIG, + [SOURCE_A_PATH]: [ + 'import B from "./B.xspec"', + "", + '<S id="a" d={B.b}>', + "Alpha text.", + "</S>", + "", + ].join("\n"), + [SOURCE_B_PATH]: [ + 'import C from "./C.xspec"', + "", + '<S id="b" d={C.c}>', + "Beta text.", + "</S>", + "", + ].join("\n"), + [SOURCE_C_PATH]: ['<S id="c">', "Ceta text.", "</S>", ""].join("\n"), + [SOURCE_D_PATH]: [ + 'import C from "./C.xspec"', + "", + '<S id="d" d={C.c}>', + "Delta text.", + "</S>", + "", + ].join("\n"), + [SOURCE_CODE_PATH]: [ + 'import A from "../specs/A.xspec";', + "", + "A.a;", + "", + ].join("\n"), + }, +}; + +// Arm (g)'s fixture: the rename fixture plus a source under `specs/sub`, a +// directory the discovery of SPEC 7 lists under the glob `specs/**/*.mdx`. +// Arm (g) follows T14-10's earlier arms' invocations, so the source is a +// staged-source record (S-9, test/self/s9-staged-sources.test.ts), staged +// by the prepared workspace and the invalid-configuration one alike. +const SUB_DIR = "specs/sub"; +const SUB_PATH = `${SUB_DIR}/S.mdx`; +const LISTING_FIXTURE: RefusalFixture = { + decl: { + files: { + ...RENAME_FIXTURE.decl.files, + [SUB_PATH]: stagedMdx( + "T14-10 specs/sub/S.mdx (arm (g)'s listing fixture: the source under the unlistable directory)", + ['<S id="s">', "Sub text.", "</S>", ""].join("\n"), + ), + }, + }, + priorRename: RENAME_FIXTURE.priorRename, +}; +/** + * The rename fixture's configuration with an unknown top-level key (14.14): + * a staged-source record (S-9), since arm (g)'s invalid-configuration + * workspace follows the body's first product invocation. + */ +const INVALID_CONFIG = stagedTs( + "T14-10 (g) xspec.config.ts (the rename fixture's configuration with an unknown top-level key, 14.14)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`, +); + +/** A freshly built, valid workspace and its pre-staging snapshot. */ +interface BuiltWorkspace { + readonly workspace: TestWorkspace; + readonly before: DirectorySnapshot; +} + +/** Stage a declared workspace, `build` it, and see `check` clean (SPEC 12.1, 12.2). */ +async function prepareBuiltWorkspace( + product: ProductBinding, + decl: WorkspaceDecl, + context: string, +): Promise<BuiltWorkspace> { + const workspace = await TestWorkspace.create(decl); + try { + await buildOk( + product, + workspace, + `${context} staging \`build\` (SPEC 12.1)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} staging \`check\` — a freshly built, valid workspace (SPEC 12.2)`, + ); + return { workspace, before: await snapshotWorkspace(workspace.root) }; + } catch (error) { + await workspace.dispose(); + throw error; + } +} + +/** + * A refused read's 14.25 contract: exit 2; the error document as the entire + * stdout, its finding's stable code `read-failure` and `path` the object's + * workspace-relative path; the diagnostic on stderr (12.0). + */ +function expectReadFailure( + result: RunResult, + concerned: string, + context: string, +): Finding { + assertExitCode( + result, + 2, + `${context} — a read the environment refuses is a usage error: the ` + + `command stops at that read, attempting nothing further, and exits ` + + `2 — never a finding, never an internal error (SPEC 14.25, 12.0)`, + ); + const finding = expectErrorDocument(result, context); + if (finding.code !== READ_FAILURE_CODE) { + fail( + `${context} — the error document's finding carries the stable code ` + + `${JSON.stringify(READ_FAILURE_CODE)} (SPEC 14.25, 14, 12.7); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if (finding.path !== concerned) { + fail( + `${context} — the concerned path is the refused object's ` + + `workspace-relative path ${JSON.stringify(concerned)} (SPEC 14.25, ` + + `12.7); got ${JSON.stringify(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if (result.stderr.trim().length === 0) { + fail( + `${context} — the diagnostic accompanies the error document on ` + + `standard error (SPEC 14.25, 12.0: usage-error messages are ` + + `standard-error content); got an empty stderr beside ` + + `${JSON.stringify(finding.message)}`, + ); + } + return finding; +} + +/** An exit-2 error of the syntax class: `code` null, no configuration loaded. */ +function expectSyntaxClassError( + result: RunResult, + why: string, + context: string, +): void { + assertExitCode( + result, + 2, + `${context} — ${why} is a syntax-class usage error, exit 2, reported ` + + `without loading configuration and so before the discovery read the ` + + `environment refuses (SPEC 12.0, 14.25)`, + ); + const finding = expectErrorDocument(result, context); + if (finding.code !== null) { + fail( + `${context} — ${why} carries no stable code: \`code\` is null where ` + + `14 assigns none, never \`read-failure\` (SPEC 14, 12.7, 12.0); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } +} + +/** + * A findings-report surface under a staging: the exact exit code (a hang + * becomes a diagnosed failure), the findings-only document `{"findings": + * […]}` as the entire stdout, decoded form-exact (SPEC 12.7). + */ +async function stagedFindings( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + exitCode: number, + context: string, +): Promise<readonly Finding[]> { + const result = await runSettled(product, workspace, argv, context); + assertExitCode(result, exitCode, context); + return decodeFindingsReport(parseJsonStdout(result, context), context) + .findings; +} + +/** A staged run expected to exit `exitCode`, its stdout parsed as one document. */ +async function stagedDocument( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + exitCode: number, + context: string, +): Promise<unknown> { + const result = await runSettled(product, workspace, argv, context); + assertExitCode(result, exitCode, context); + return parseJsonStdout(result, context); +} + +/** + * The one condition-20 finding for the refused source `file`: its one + * location the zero-length range at offset 0 (SPEC 14: an unparseable + * source carries one zero-length range at the failure's offset — for a + * refused read, 0; 14.25). + */ +function requireRefusedSourceFinding( + findings: readonly Finding[], + file: string, + context: string, +): Finding { + const matching = findings.filter((finding) => finding.condition === "14.20"); + const finding = matching[0]; + if (matching.length !== 1 || finding === undefined) { + return fail( + `${context}: exactly one condition-20 finding — the refused source, ` + + `masked exactly as an unparseable one (SPEC 14.25, 14.20); got ` + + `${String(matching.length)}: ` + + JSON.stringify( + matching.map(({ code, message }) => ({ code, message })), + ), + ); + } + assertSameJson( + finding.locations, + [{ file, range: { start: 0, end: 0 } }], + `${context}: the refused read's one location is the zero-length range ` + + `at offset 0 in ${file} — content that cannot be read parses as ` + + `nothing (SPEC 14, 14.20, 14.25, 12.7)`, + ); + return finding; +} + +/** Nothing inside the refused source reports: no other finding locates in it. */ +function assertMaskedFile( + findings: readonly Finding[], + file: string, + context: string, +): void { + for (const finding of findings) { + if (finding.condition === "14.20") continue; + if (finding.locations.some((location) => location.file === file)) { + fail( + `${context}: nothing inside the refused source reports — its ` + + `content parses as nothing, so the file is masked exactly as an ` + + `unparseable one (SPEC 14.20, 14.25, 11.2); got ` + + `${JSON.stringify(finding.code)} located in ${file} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } +} + +/** The occurrence records a report lists for `file`. */ +function recordsIn(report: OccurrencesReport, file: string): number { + return report.occurrences.filter((record) => record.file === file).length; +} + +/** + * Every plain file under `rel` that is graph data (T13.3-2's operational + * path set: under `.xspec/`, outside the durable journal and the session + * directory), recursively, as workspace-relative paths. + */ +async function collectGraphDataFiles( + rootAbs: string, + rel: string, +): Promise<string[]> { + const collected: string[] = []; + const entries = await fsp.readdir(path.join(rootAbs, rel), { + withFileTypes: true, + }); + for (const entry of entries) { + const key = `${rel}/${entry.name}`; + if (!isGraphDataKey(key)) continue; + if (entry.isDirectory()) { + collected.push(...(await collectGraphDataFiles(rootAbs, key))); + } else if (entry.isFile()) { + collected.push(key); + } + } + return collected; +} + +/** + * Restore each staging whose object still exists — a regeneration may have + * replaced or removed a staged file, and a replaced file's mode is its own + * (nothing to reinstate on an absent path). Reverse order of staging. + */ +async function restoreSurviving( + stagings: readonly PermissionStaging[], +): Promise<void> { + for (let i = stagings.length - 1; i >= 0; i--) { + const staging = stagings[i]!; + if (await pathExists(staging.path)) await staging.restore(); + } +} + +/** + * Arm (f)'s staging: every plain file under `.xspec/` other than the journal + * and the session directory unreadable (mode 0o200, each verified). Files + * only — directories keep their listing and write permission, so the + * regeneration a refreshing read owes (13.3) is never itself refused, + * whatever the product's layout. At least one file must exist: the staging + * applies to record files the product itself wrote (H-3), and staging + * nothing would be no staging (H-11). + */ +async function stageGraphDataUnreadable( + workspace: TestWorkspace, + context: string, +): Promise<PermissionStaging[]> { + const files = ( + await collectGraphDataFiles(workspace.root, GRAPH_DATA_AREA) + ).sort(); + if (files.length === 0) { + throw new HarnessStagingError( + "read-refusal-of-file", + workspace.path(GRAPH_DATA_AREA), + `${context}: no graph-data file found under ${GRAPH_DATA_AREA}/ ` + + `outside the journal and the session directory — the staging ` + + `applies to record files the product itself wrote after a ` + + `successful build (SPEC 13.3, 12.1)`, + ); + } + const stagings: PermissionStaging[] = []; + try { + for (const rel of files) { + stagings.push(await stageReadRefusalOfFile(workspace.path(rel))); + } + } catch (error) { + await restoreSurviving(stagings).catch(() => undefined); + throw error; + } + return stagings; +} + +// --------------------------------------------------------------------------- +// (a) A discovered source's content — condition 20 (SPEC 14.25, 14.20, 11.2) +// --------------------------------------------------------------------------- + +/** + * The spec source `specs/B.mdx` staged unreadable: `build` and `check` + * report the one condition-20 finding at offset 0 beside A's unresolved + * reference (14.5) and nothing from inside B; `view` serves A's view, B + * contributing none; `occurrences` lists no record for B's spelling while + * D's stays; `at` on B reports the resolution explicitly unavailable at 0, + * 7, and 999999 — never the out-of-range usage error. Nothing is modified; + * restored, the workspace builds clean. + */ +async function specSourceSubArm( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + const before = await snapshotWorkspace(workspace.root); + const staging = await stageReadRefusalOfFile(workspace.path(SOURCE_B_PATH)); + try { + for (const argv of [ + ["build", "--json"], + ["check", "--json"], + ] as const) { + const label = `${context} \`${argv.join(" ")}\` with ${SOURCE_B_PATH} unreadable`; + const findings = await stagedFindings( + product, + workspace, + argv, + 1, + `${label} — a discovered source whose content the environment ` + + `refuses is condition 20, a finding: exit 1 (SPEC 14.25, 14.20, ` + + `12.0)`, + ); + assertConditionCounts( + findings, + { "14.20": 1, "14.5": 1 }, + `${label} — the refused source is one condition-20 finding and A's ` + + `reference into it reports as unresolved, nothing else (SPEC ` + + `14.25, 14.20, 14.5)`, + ); + requireRefusedSourceFinding(findings, SOURCE_B_PATH, label); + const unresolved = findings.find( + (finding) => finding.condition === "14.5", + )!; + assertFindingLocated( + unresolved, + { file: SOURCE_A_PATH }, + `${label} — the unresolved reference locates in ${SOURCE_A_PATH}, ` + + `the referencing file (SPEC 14, 14.5)`, + ); + assertMaskedFile(findings, SOURCE_B_PATH, label); + } + + const viewArgv = ["view", SOURCE_A_PATH, SOURCE_B_PATH]; + const viewLabel = `${context} \`${viewArgv.join(" ")}\` with ${SOURCE_B_PATH} unreadable`; + const view = decodeViewReport( + await stagedDocument( + product, + workspace, + viewArgv, + 1, + `${viewLabel} — an answer carrying a finding exits 1 with the full ` + + `answer document still emitted (SPEC 11.2, 12.0)`, + ), + { text: false }, + viewLabel, + ); + assertSameJson( + view.views.map((fileView) => fileView.file), + [SOURCE_A_PATH], + `${viewLabel} — the surface still answers per file: A's view is ` + + `served and the refused B contributes none (SPEC 11.2, 11.4, 14.25)`, + ); + assertConditionCounts( + view.findings, + { "14.20": 1, "14.5": 1 }, + `${viewLabel} — the findings of every domain file accompany the ` + + `answer: B's condition-20 finding and A's unresolved reference ` + + `(SPEC 11.2, 14.25)`, + ); + requireRefusedSourceFinding(view.findings, SOURCE_B_PATH, viewLabel); + + const occLabel = `${context} \`occurrences\` with ${SOURCE_B_PATH} unreadable`; + const occurrences = decodeOccurrencesReport( + await stagedDocument( + product, + workspace, + ["occurrences"], + 1, + `${occLabel} — the domain's findings accompany the answer, exit 1 ` + + `(SPEC 11.3, 11.2)`, + ), + occLabel, + ); + if (recordsIn(occurrences, SOURCE_B_PATH) !== 0) { + fail( + `${occLabel} — no record for B's spellings: a spelling inside the ` + + `refused source is hidden with the rest of it, pointed to only ` + + `by the condition-20 finding (SPEC 11.2, 5.7, 14.25); got ` + + `${String(recordsIn(occurrences, SOURCE_B_PATH))} record(s)`, + ); + } + if (recordsIn(occurrences, SOURCE_D_PATH) === 0) { + fail( + `${occLabel} — the surface still answers per file: D's resolving ` + + `reference keeps its record while B is masked (SPEC 11.2, 11.3)`, + ); + } + assertConditionCounts( + occurrences.findings, + { "14.20": 1, "14.5": 1 }, + `${occLabel} — the entire discovered set's findings accompany the ` + + `answer (SPEC 11.3, 11.2)`, + ); + + for (const offset of ["0", "7", "999999"]) { + const atArgv = ["at", SOURCE_B_PATH, offset]; + const atLabel = `${context} \`${atArgv.join(" ")}\` with ${SOURCE_B_PATH} unreadable`; + const report = decodeAtReport( + await stagedDocument( + product, + workspace, + atArgv, + 1, + `${atLabel} — the resolution is reported explicitly unavailable ` + + `beside the condition-20 finding, exit 1 — never the ` + + `out-of-range usage error: the offset bound is judged only ` + + `where the content was read (SPEC 11.5, 14.25)`, + ), + atLabel, + ); + assertSameJson( + report.resolution, + { unavailable: true }, + `${atLabel} — the resolution is exactly the unavailability marker: ` + + `never null, never a fabricated root resolution (SPEC 11.5, 11.2, ` + + `12.7)`, + ); + assertConditionCounts( + report.findings, + { "14.20": 1 }, + `${atLabel} — the consulted domain is the named file alone, its ` + + `condition-20 finding accompanying (SPEC 11.5, 11.2)`, + ); + requireRefusedSourceFinding(report.findings, SOURCE_B_PATH, atLabel); + } + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified — a failing build and check, and the ` + + `surfaces of 11.2 on a failing workspace, write nothing (SPEC 12.1, ` + + `13.3, 11.2)`, + ); + await assertRecovers(product, workspace, context); +} + +/** + * Separately, the code source `src/app.ts` staged unreadable: `build` and + * `check` report its one condition-20 finding at offset 0 alone (no spec + * source references a code source), and `occurrences` lists no record for + * its marker while D's record stays. Nothing is modified; restored, the + * workspace builds clean. + */ +async function codeSourceSubArm( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + const before = await snapshotWorkspace(workspace.root); + const staging = await stageReadRefusalOfFile( + workspace.path(SOURCE_CODE_PATH), + ); + try { + for (const argv of [ + ["build", "--json"], + ["check", "--json"], + ] as const) { + const label = `${context} \`${argv.join(" ")}\` with ${SOURCE_CODE_PATH} unreadable`; + const findings = await stagedFindings( + product, + workspace, + argv, + 1, + `${label} — a discovered code source whose content the environment ` + + `refuses is condition 20, a finding: exit 1 (SPEC 14.25, 14.20, ` + + `12.0)`, + ); + assertConditionCounts( + findings, + { "14.20": 1 }, + `${label} — the refused code source is one condition-20 finding ` + + `and nothing else: nothing inside it reports, and no spec source ` + + `references a code source (SPEC 14.25, 14.20)`, + ); + requireRefusedSourceFinding(findings, SOURCE_CODE_PATH, label); + } + const occLabel = `${context} \`occurrences\` with ${SOURCE_CODE_PATH} unreadable`; + const occurrences = decodeOccurrencesReport( + await stagedDocument( + product, + workspace, + ["occurrences"], + 1, + `${occLabel} — the entire discovered set is the domain, the code ` + + `source's condition-20 finding accompanying: exit 1 (SPEC 11.3, ` + + `11.2)`, + ), + occLabel, + ); + if (recordsIn(occurrences, SOURCE_CODE_PATH) !== 0) { + fail( + `${occLabel} — no record for the refused code source's marker: a ` + + `spelling inside a masked file is hidden with the rest of it ` + + `(SPEC 11.2, 5.7, 14.25); got ` + + `${String(recordsIn(occurrences, SOURCE_CODE_PATH))} record(s)`, + ); + } + if (recordsIn(occurrences, SOURCE_D_PATH) === 0) { + fail( + `${occLabel} — the surface still answers per file: D's resolving ` + + `reference keeps its record (SPEC 11.2, 11.3)`, + ); + } + assertConditionCounts( + occurrences.findings, + { "14.20": 1 }, + `${occLabel} — the domain's findings are the code source's ` + + `condition-20 finding alone (SPEC 11.3, 11.2)`, + ); + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified on the failing workspace (SPEC 12.1, 13.3, 11.2)`, + ); + await assertRecovers(product, workspace, context); +} + +/** + * (a) A discovered source's content: the fixture built and clean, the + * premise that B's `d` reference and the code source's marker each record an + * occurrence when readable (so "no record" below is a masking, not an + * absence), then the spec source and the code source each refused in turn. + */ +async function sourceContentArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (a) a discovered source's content"; + const { workspace } = await prepareBuiltWorkspace( + product, + SOURCE_DECL, + context, + ); + try { + const premiseLabel = `${context} premise \`occurrences\` on the readable workspace`; + const premise = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], premiseLabel), + premiseLabel, + ); + for (const file of [SOURCE_B_PATH, SOURCE_CODE_PATH, SOURCE_D_PATH]) { + if (recordsIn(premise, file) === 0) { + fail( + `${premiseLabel}: ${file}'s resolving reference records an ` + + `occurrence — a \`d\` reference or a dependency marker whose ` + + `target resolves (SPEC 5.7, 4.5) — so that its absence under ` + + `the refusal below is the masking of 14.25, not an absence`, + ); + } + } + await specSourceSubArm(product, workspace, `${context}, the spec source`); + await codeSourceSubArm(product, workspace, `${context}, the code source`); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (b) The journal's content — condition 13 (SPEC 14.25, 14.13, 13.3, 6.4) +// --------------------------------------------------------------------------- + +/** + * `.xspec/journal` staged unreadable on the journal-bearing rename fixture: + * `build`, `check`, the gated `ids` (answering nothing), and the `rename` + * (refused) each report the one condition-13 finding concerning the journal + * and exit 1; `inventory` reports `journal.occupied` true, finding-free, + * exit 0 — the kind read, permitted, is the only read it makes there. + * Nothing is modified; restored, the workspace builds clean. + */ +async function journalContentArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (b) the journal's content"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const staging = await stageReadRefusalOfFile(workspace.path(JOURNAL_PATH)); + try { + const reporters: readonly (readonly string[])[] = [ + ["build", "--json"], + ["check", "--json"], + ["ids", "--json"], + RENAME_B_TO_B2, + ]; + for (const argv of reporters) { + const label = `${context} \`${argv.join(" ")}\` with ${JOURNAL_PATH} unreadable`; + const findings = await stagedFindings( + product, + workspace, + argv, + 1, + `${label} — a journal the environment refuses to read is ` + + `condition 13, a finding the workspace fails on: \`build\` and ` + + `\`check\` report it, a gated read answers nothing but the ` + + `findings, and a \`rename\` is refused — exit 1 with the ` + + `findings-only document (SPEC 14.25, 14.13, 13.3, 6.4, 12.7)`, + ); + assertConditionCounts( + findings, + { "14.13": 1 }, + `${label} — the journal error alone (SPEC 14.13, 14.25)`, + ); + assertFindingConcernsPath( + findings[0]!, + JOURNAL_PATH, + `${label} — the journal is the concerned path (SPEC 14, 14.13)`, + ); + } + const invLabel = `${context} \`inventory\` with ${JOURNAL_PATH} unreadable`; + const inventory = decodeInventoryDocument( + await stagedDocument( + product, + workspace, + ["inventory"], + 0, + `${invLabel} — the inventory reads the journal path's kind alone, ` + + `permitted, never its content: a complete, finding-free ` + + `answer, exit 0 (SPEC 11.6, 14.25)`, + ), + invLabel, + ); + assertSameJson( + inventory.findings, + [], + `${invLabel} — finding-free: the inventory reads no journal ` + + `content, so it meets no condition-13 (SPEC 11.6, 14.25)`, + ); + if (inventory.journal.occupied !== true) { + fail( + `${invLabel} — \`journal.occupied\` is true: occupancy by ` + + `presence alone, the content unread (SPEC 11.6); got ` + + `${String(inventory.journal.occupied)}`, + ); + } + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + prepared.before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified — a failing build, a gated read, a ` + + `refused rename, and the inventory write nothing (SPEC 12.1, 13.3, ` + + `6.4, 11.6)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (c) A session file's content — condition 21 (SPEC 14.25, 14.21, 10.1, 10.7) +// --------------------------------------------------------------------------- + +/** + * The audit session `s` created, its file staged unreadable: `check` and + * `review status s` each report exactly one condition-21 finding concerning + * the session file, exit 1, nothing modified; `review list` reports the + * session corrupt by name, exit 1; `inventory` lists the session, finding- + * free, exit 0 (selected by name alone, content unread). + */ +async function sessionContentArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (c) a session file's content"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", SESSION], + 0, + `${context} staging \`review create --strategy audit --name s\` (SPEC 10.7)`, + ); + const before = await snapshotWorkspace(workspace.root); + const staging = await stageReadRefusalOfFile(workspace.path(SESSION_FILE)); + try { + for (const argv of [ + ["check", "--json"], + ["review", "status", SESSION, "--json"], + ] as const) { + const label = `${context} \`${argv.join(" ")}\` with ${SESSION_FILE} unreadable`; + const findings = await stagedFindings( + product, + workspace, + argv, + 1, + `${label} — a session file the environment refuses to read is ` + + `corrupt, condition 21: reported by \`check\` and by the ` + + `\`review\` subcommand naming it, exit 1, the findings-only ` + + `document (SPEC 14.25, 14.21, 10.1, 12.7)`, + ); + assertConditionCounts( + findings, + { "14.21": 1 }, + `${label} — exactly one finding, \`corrupt-session\` (SPEC 14.21, 10.1)`, + ); + assertFindingConcernsPath( + findings[0]!, + SESSION_FILE, + `${label} — the session file is the concerned path (SPEC 14, 14.21)`, + ); + } + const listLabel = `${context} \`review list --json\` with ${SESSION_FILE} unreadable`; + const list = decodeSessionListReport( + await stagedDocument( + product, + workspace, + ["review", "list", "--json"], + 1, + `${listLabel} — \`list\` exits 1 when any session is corrupt ` + + `(SPEC 10.7, 14.21)`, + ), + listLabel, + ); + assertSameJson( + list.sessions, + [{ name: SESSION, corrupt: true }], + `${listLabel} — the session reported corrupt by name, in place of ` + + `its fields (SPEC 10.7, 14.21)`, + ); + const invLabel = `${context} \`inventory\` with ${SESSION_FILE} unreadable`; + const inventory = decodeInventoryDocument( + await stagedDocument( + product, + workspace, + ["inventory"], + 0, + `${invLabel} — the inventory lists sessions by name alone, ` + + `reading no session content: finding-free, exit 0 (SPEC 11.6, ` + + `14.25)`, + ), + invLabel, + ); + assertSameJson( + inventory.findings, + [], + `${invLabel} — finding-free (SPEC 11.6)`, + ); + assertSameJson( + inventory.sessions, + [SESSION_FILE], + `${invLabel} — the session listed by its file path (SPEC 11.6)`, + ); + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified — a corrupt session is reported, never ` + + `repaired or replaced, and the reads write nothing (SPEC 14.21, ` + + `10.1, 11.6)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (d) The configuration file's content — condition 14 (SPEC 14.25, 14.14, 7) +// --------------------------------------------------------------------------- + +/** + * `xspec.config.ts` staged unreadable: `build`, `ids`, `inventory`, and + * `view` (representatives of every command but `version`) exit 2 with the + * error document — `code` `configuration-error`, `path` the configuration + * path in the anchoring form — while `version` answers, exit 0. Nothing is + * modified; restored, the workspace builds clean. + */ +async function configurationContentArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (d) the configuration file's content"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const staging = await stageReadRefusalOfFile(workspace.path(CONFIG_PATH)); + try { + const loaders: readonly (readonly string[])[] = [ + ["build"], + ["ids"], + ["inventory"], + ["view", RENAME_A_PATH], + ]; + for (const argv of loaders) { + const label = `${context} \`${argv.join(" ")} --json\` with ${CONFIG_PATH} unreadable`; + const result = await expectConfigurationError( + product, + workspace, + argv, + `${label} — a configuration file the environment refuses to read ` + + `is invalid configuration, condition 14, from every command ` + + `that loads it (SPEC 14.25, 14.14, 7)`, + ); + const finding = expectErrorDocument(result, label); + if (finding.path !== CONFIG_PATH) { + fail( + `${label} — the concerned path is the configuration path in ` + + `the anchoring form, ${JSON.stringify(CONFIG_PATH)} from the ` + + `root (SPEC 14, 11.6); got ${JSON.stringify(finding.path)} ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } + } + const versionLabel = `${context} \`version\` with ${CONFIG_PATH} unreadable`; + await stagedDocument( + product, + workspace, + ["version"], + 0, + `${versionLabel} — \`version\` loads no configuration, so the ` + + `refused read never occurs: exit 0 with its answer (SPEC 12.6, ` + + `14.14, 14.25)`, + ); + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + prepared.before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified — a configuration error precedes every ` + + `write (SPEC 14.14, 12.0)`, + ); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (e) A derived file's content — condition 10 (SPEC 14.25, 14.10, 12.1, 13.4) +// --------------------------------------------------------------------------- + +/** + * B's generated module staged unreadable: `check` reports exactly one + * condition-10 finding, the per-file form concerning that path (the graph + * data matches, so no unit form), exit 1; `build` exits 0 — it reads no + * derived file, and its write replaces the occupant; afterwards `check` is + * clean. + */ +async function derivedContentArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (e) a derived file's content"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const kind = await workspace.kind(RENAME_B_MODULE); + if (kind !== "file") { + fail( + `${context}: the built workspace holds B's generated module as a ` + + `plain file at ${RENAME_B_MODULE} (SPEC 13.1, 13.4); found ${kind}`, + ); + } + const staging = await stageReadRefusalOfFile( + workspace.path(RENAME_B_MODULE), + ); + try { + const checkLabel = `${context} \`check --json\` with ${RENAME_B_MODULE} unreadable`; + const findings = await stagedFindings( + product, + workspace, + ["check", "--json"], + 1, + `${checkLabel} — a derived file whose content the environment ` + + `refuses to deliver is stale: condition 10, exit 1 (SPEC 14.25, ` + + `14.10, 12.2)`, + ); + assertStalenessAlone( + findings, + { perFile: [RENAME_B_MODULE], unit: false }, + `${checkLabel} — exactly one condition-10 finding, the per-file ` + + `form concerning the unreadable module; the graph data matches, ` + + `so no unit form (SPEC 14.10, 14.25)`, + ); + const buildLabel = `${context} \`build\` with ${RENAME_B_MODULE} unreadable`; + assertExitCode( + await runSettled(product, workspace, ["build"], buildLabel), + 0, + `${buildLabel} — \`build\` reads no derived file: its write ` + + `replaces the occupant, so the refused content read never occurs ` + + `and the build exits 0 (SPEC 14.25, 12.1, 13.4)`, + ); + } finally { + await staging.restore(); + } + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: after the build, \`check\` is clean — the module ` + + `regenerated (SPEC 12.2, 13.4)`, + ); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (f) Graph data — the state of condition 23 (SPEC 14.25, 14.23, 14.10, 13.3) +// --------------------------------------------------------------------------- + +/** + * Every graph-data file staged unreadable: `inventory` reports `recorded` + * unavailable with the condition-23 finding concerning `.xspec`, exit 1; a + * `move --preview` reports its `delta` unavailable likewise; `check` + * reports one condition-10 finding in the unit form alone; `ids` exits 0 + * with its answer, regenerating the data rather than failing; `build` exits + * 0, `inventory` then reporting `recorded` in full — the same paths as + * before the staging. + */ +async function graphDataArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (f) graph data"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const intactLabel = `${context} premise \`inventory\` on the readable record`; + const intact = decodeInventoryDocument( + await runJson(product, workspace, ["inventory"], intactLabel), + intactLabel, + ); + if (intact.recorded.state !== "value") { + fail( + `${intactLabel}: on the freshly built workspace the record is ` + + `readable and \`recorded\` lists the recorded derived paths ` + + `(SPEC 11.6, 13.3); got state ${intact.recorded.state}`, + ); + } + const stagings = await stageGraphDataUnreadable(workspace, context); + try { + const invLabel = `${context} \`inventory\` with every graph-data file unreadable`; + const inventory = decodeInventoryDocument( + await stagedDocument( + product, + workspace, + ["inventory"], + 1, + `${invLabel} — a refused read of graph data is the state of ` + + `condition 23 to a surface consulting the record: the finding ` + + `accompanies the answer, exit 1 (SPEC 14.25, 14.23, 11.6)`, + ), + invLabel, + ); + assertConditionCounts( + inventory.findings, + { "14.23": 1 }, + `${invLabel} — exactly the one condition-23 finding (SPEC 14.23, 11.6)`, + ); + assertFindingConcernsPath( + inventory.findings[0]!, + GRAPH_DATA_AREA, + `${invLabel} — the concerned path is the graph-data area (SPEC 14.23, 11.6)`, + ); + assertSameJson( + inventory.findings[0]!.locations, + [], + `${invLabel} — no path inside the area is named (SPEC 14.23, 13.3, 12.7)`, + ); + assertSameJson( + inventory.recorded, + { state: "unavailable" }, + `${invLabel} — \`recorded\` is explicitly unavailable, never ` + + `fabricated and never read as an empty record (SPEC 14.23, 11.6, ` + + `12.7)`, + ); + + const previewArgv = [ + "move", + RENAME_C_PATH, + MOVE_C_DESTINATION, + "--preview", + "--json", + ]; + const previewLabel = `${context} \`${previewArgv.join(" ")}\` with every graph-data file unreadable`; + const preview = decodePreviewReport( + await stagedDocument( + product, + workspace, + previewArgv, + 1, + `${previewLabel} — a preview consulting the record reports its ` + + `delta unavailable beside the condition-23 finding, exit 1, ` + + `the full preview still emitted (SPEC 14.23, 6.6, 12.0)`, + ), + previewLabel, + ); + assertConditionCounts( + preview.findings, + { "14.23": 1 }, + `${previewLabel} — exactly the one condition-23 finding (SPEC 14.23, 6.6)`, + ); + assertFindingConcernsPath( + preview.findings[0]!, + GRAPH_DATA_AREA, + `${previewLabel} — the concerned path is the graph-data area (SPEC 14.23, 11.6)`, + ); + if ( + preview.mapping === null || + preview.files === null || + preview.delta === null + ) { + fail( + `${previewLabel} — the plan is reported: \`mapping\`, \`files\`, ` + + `and \`delta\` are null exactly on refusal, and this move is ` + + `not refused (SPEC 6.6, 12.7)`, + ); + } + if (!("unavailable" in preview.delta)) { + fail( + `${previewLabel} — the record-supplied datum, the delta, is ` + + `reported explicitly unavailable as one datum, never read as ` + + `an empty record (SPEC 14.23, 6.6, 12.7); got ` + + `${JSON.stringify(preview.delta)}`, + ); + } + + const checkLabel = `${context} \`check --json\` with every graph-data file unreadable`; + assertStalenessAlone( + await stagedFindings( + product, + workspace, + ["check", "--json"], + 1, + `${checkLabel} — unreadable recorded state is staleness to ` + + `\`check\`: exit 1 (SPEC 14.25, 14.10)`, + ), + { perFile: [], unit: true }, + `${checkLabel} — one condition-10 finding in the unit form alone: ` + + `the unreadable-record form, never the mismatch form beside it, ` + + `and no per-file form — every derived file is intact (SPEC ` + + `14.10, 14.25)`, + ); + + const idsLabel = `${context} \`ids --json\` with every graph-data file unreadable`; + decodeIdsReport( + await stagedDocument( + product, + workspace, + ["ids", "--json"], + 0, + `${idsLabel} — to a refreshing read, graph data it cannot read ` + + `is graph data that does not match: it regenerates the data ` + + `and answers, exit 0, never a failure (SPEC 14.25, 13.3)`, + ), + idsLabel, + ); + + const buildLabel = `${context} \`build\` after the refreshing read`; + assertExitCode( + await runSettled(product, workspace, ["build"], buildLabel), + 0, + `${buildLabel} — \`build\` replaces the record, unreadable state ` + + `included: exit 0 (SPEC 12.1, 13.4, 14.23)`, + ); + const afterLabel = `${context} \`inventory\` after the build`; + const after = decodeInventoryDocument( + await stagedDocument( + product, + workspace, + ["inventory"], + 0, + `${afterLabel} — the rebuilt record is readable: a complete, ` + + `finding-free answer, exit 0 (SPEC 12.1, 11.6)`, + ), + afterLabel, + ); + assertSameJson( + after.findings, + [], + `${afterLabel} — finding-free (SPEC 11.6)`, + ); + assertSameJson( + after.recorded, + intact.recorded, + `${afterLabel} — \`recorded\` in full: the rebuilt record lists ` + + `the same derived paths as the intact one — the same sources and ` + + `configuration generate the same set (SPEC 12.1, 11.6, 13.3)`, + ); + } finally { + await restoreSurviving(stagings); + } + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: recovery — after the rebuild, \`check\` is clean (SPEC 12.2)`, + ); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (g) A directory discovery lists — a usage error (SPEC 14.25, 7, 12.0) +// --------------------------------------------------------------------------- + +/** + * `specs/sub` staged unlistable under the glob `specs/**\/*.mdx`: every + * command that loads the configuration — `build`, `check`, `ids`, `view`, + * `inventory`, `review list`, and a `rename` — exits 2 with the error + * document (`code` `read-failure`, `path` `specs/sub`), nothing modified. + * Precedence, at the read in read order: with A also failing validation, + * `build` still exits 2 with the read failure; `coverage <unknown-profile>` + * reports the read failure; the syntax class — a surplus operand, a + * `--file` pattern outside the root, a malformed offset — is reported + * without loading configuration (`code` null); and with the configuration + * file itself invalid the configuration error precedes the read. + */ +async function discoveryListingArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (g) a directory discovery lists"; + const prepared = await prepareRefusalWorkspace( + product, + LISTING_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + const staging = await stageReadRefusalOfDirectory(workspace.path(SUB_DIR)); + try { + const loaders: readonly (readonly string[])[] = [ + ["build", "--json"], + ["check", "--json"], + ["ids", "--json"], + ["view", RENAME_A_PATH], + ["inventory"], + ["review", "list", "--json"], + RENAME_B_TO_B2, + ]; + for (const argv of loaders) { + const label = `${context} \`${argv.join(" ")}\` with ${SUB_DIR} unlistable`; + expectReadFailure( + await runSettled(product, workspace, argv, label), + SUB_DIR, + `${label} — a directory the discovery of 7 lists, refused: every ` + + `command that loads the configuration stops at the read (SPEC ` + + `14.25, 7, 12.0)`, + ); + } + const coverageArgv = ["coverage", "no-such-profile", "--json"]; + const coverageLabel = `${context} \`${coverageArgv.join(" ")}\` with ${SUB_DIR} unlistable`; + expectReadFailure( + await runSettled(product, workspace, coverageArgv, coverageLabel), + SUB_DIR, + `${coverageLabel} — discovery precedes every error consulting the ` + + `configuration: the unknown profile is judged after the reads ` + + `its load makes, so the read failure is reported (SPEC 12.0, ` + + `14.25, 7.4)`, + ); + const syntaxClass: readonly (readonly [readonly string[], string])[] = [ + [["ids", "extra", "--json"], "a surplus operand"], + [ + ["ids", "--file", "../x", "--json"], + "a `--file` pattern outside the workspace root, decided by its spelling alone", + ], + [ + ["at", RENAME_A_PATH, "zz"], + "an offset spelled as anything but decimal digits — a malformed value", + ], + ]; + for (const [argv, why] of syntaxClass) { + const label = `${context} \`${argv.join(" ")}\` with ${SUB_DIR} unlistable`; + expectSyntaxClassError( + await runSettled(product, workspace, argv, label), + why, + label, + ); + } + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + prepared.before, + await snapshotWorkspace(workspace.root), + `${context}: nothing modified — every command stops at the refused ` + + `read, attempting nothing further, the rename included (SPEC ` + + `14.25, 13.5)`, + ); + + // The failing twin on the same workspace: A's reference respelled to + // resolve nowhere (14.5) — the read failure is still what `build` + // reports, never the findings. + await workspace.edit(RENAME_A_PATH, A_REFERENCE, A_UNRESOLVED); + const twinStaging = await stageReadRefusalOfDirectory( + workspace.path(SUB_DIR), + ); + try { + const label = `${context} \`build --json\` with ${SUB_DIR} unlistable and ${RENAME_A_PATH} failing validation`; + expectReadFailure( + await runSettled(product, workspace, ["build", "--json"], label), + SUB_DIR, + `${label} — a read failure is met at the read, in read order: the ` + + `discovery of 7 precedes every validation consulting it, so the ` + + `findings are never reported (SPEC 12.0, 14.25)`, + ); + } finally { + await twinStaging.restore(); + } + await workspace.edit(RENAME_A_PATH, A_UNRESOLVED, A_REFERENCE); + await assertRecovers(product, workspace, context); + } finally { + await workspace.dispose(); + } + + // With the configuration file itself invalid, the configuration error + // precedes the read: the configuration is read before discovery (14.14). + const invalid = await TestWorkspace.create({ + files: { ...LISTING_FIXTURE.decl.files, [CONFIG_PATH]: INVALID_CONFIG }, + }); + try { + const staging = await stageReadRefusalOfDirectory(invalid.path(SUB_DIR)); + try { + const label = `${context} \`build --json\` with the configuration invalid and ${SUB_DIR} unlistable`; + const finding = expectErrorDocument( + await expectConfigurationError( + product, + invalid, + ["build"], + `${label} — a configuration error precedes every other error of ` + + `exit class 2, the read failure included: the configuration is ` + + `read before the discovery it defines (SPEC 14.14, 12.0, 14.25)`, + ), + label, + ); + assertFindingConcernsPath( + finding, + CONFIG_PATH, + `${label} — the configuration path is the concerned path (SPEC 14, 14.14)`, + ); + } finally { + await staging.restore(); + } + } finally { + await invalid.dispose(); + } +} + +// --------------------------------------------------------------------------- +// (h) The session directory's listing — a usage error (SPEC 14.25, 10.1) +// --------------------------------------------------------------------------- + +/** + * `.xspec/reviews` staged unlistable on a valid workspace holding a session: + * `review list`, `inventory`, and `check` each exit 2 with the error + * document concerning `.xspec/reviews`, while `build` (reading no session) + * and `ids` exit 0. `review status <name>`, which may find its session by + * name without listing, is asserted nowhere. + */ +async function sessionDirectoryArm(product: ProductBinding): Promise<void> { + const context = "T14-10 (h) the session directory's listing"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", SESSION], + 0, + `${context} staging \`review create --strategy audit --name s\` (SPEC 10.7)`, + ); + const staging = await stageReadRefusalOfDirectory( + workspace.path(REVIEWS_DIR), + ); + try { + const listers: readonly (readonly string[])[] = [ + ["review", "list", "--json"], + ["inventory"], + ["check", "--json"], + ]; + for (const argv of listers) { + const label = `${context} \`${argv.join(" ")}\` with ${REVIEWS_DIR} unlistable`; + expectReadFailure( + await runSettled(product, workspace, argv, label), + REVIEWS_DIR, + `${label} — the session directory's listing, refused, is a usage ` + + `error from every command making it (SPEC 14.25, 10.1, 11.6, ` + + `14.21)`, + ); + } + const buildLabel = `${context} \`build\` with ${REVIEWS_DIR} unlistable`; + assertExitCode( + await runSettled(product, workspace, ["build"], buildLabel), + 0, + `${buildLabel} — \`build\` reads no session, so the refused listing ` + + `never occurs: exit 0 (SPEC 14.21, 12.1, 14.25)`, + ); + const idsLabel = `${context} \`ids --json\` with ${REVIEWS_DIR} unlistable`; + decodeIdsReport( + await stagedDocument( + product, + workspace, + ["ids", "--json"], + 0, + `${idsLabel} — a refreshing read lists no session: exit 0 with ` + + `its answer (SPEC 13.3, 14.25)`, + ), + idsLabel, + ); + } finally { + await staging.restore(); + } + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: recovery — the listing permitted again, \`check\` is ` + + `clean and the session intact (SPEC 12.2, 14.21)`, + ); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// T14-10 — read failures (14.25) +// --------------------------------------------------------------------------- + +const T14_10 = defineProductTest({ + id: "T14-10", + title: + "read failures (14.25): the object read decides the outcome, one arm per row, each refusal staged by permission removal alone (a file's content: mode 0o200, its write permission kept; a directory's listing: mode 0o100, search kept; nonexistence never a refusal) — (a) a discovered source's content is condition 20 at `build` and `check`, exit 1, its one location the zero-length range at offset 0, the file masked exactly as an unparseable one (A's reference reports 14.5, nothing inside reports) while `view` serves the other requested file, `occurrences` lists no record for its spellings, and `at` at 0, 7, and 999999 reports the resolution explicitly unavailable, never the out-of-range usage error — a spec source and, separately, a code source; (b) the journal's content is condition 13 concerning .xspec/journal from `build`, `check`, `ids` (answering nothing), and a refused `rename`, while `inventory` reports `journal.occupied` true, finding-free; (c) a session file's content is condition 21 from `check`, `review status` (one finding, nothing modified), and `review list` (the session reported corrupt), `inventory` listing the session; (d) the configuration file's content is condition 14 from `build`, `ids`, `inventory`, and `view` (`code` configuration-error, `path` xspec.config.ts), `version` exiting 0; (e) a generated module's content is one per-file condition-10 finding to `check` while `build` exits 0; (f) every graph-data file unreadable is the state of condition 23 — `inventory` and a `move --preview` report their record-supplied datum unavailable beside the finding, `check` one unit-form condition-10 finding alone, `ids` regenerates and answers (exit 0), and after `build` the inventory reports `recorded` in full; (g) a directory discovery lists, unlistable: `build`, `check`, `ids`, `view`, `inventory`, `review list`, and a `rename` each exit 2 with the error document (`code` read-failure, `path` specs/sub), nothing modified, the read failure preceding a failing source's findings and an unknown profile, the syntax class (`code` null) and an invalid configuration preceding it; (h) the session directory unlistable: `review list`, `inventory`, and `check` exit 2 concerning .xspec/reviews while `build` and `ids` exit 0; two clauses — a refused read of a path occupant's kind, and of a directory above the workspace root — admit no product-independent staging and are recorded so (SPEC 14.25, 14, 11.2, 11.5, 11.6, 13.3, 10.7, 12.0, 12.7)", + // A hang guard only (H-10): nine workspaces, each built, checked, and + // driven through a handful of invocations — generous under a saturated box. + timeoutMs: 600_000, + run: async (product) => { + // E-1: permission stagings belong to the Linux leg; elsewhere the arms + // are not staged (the NU3_STAGED pattern of section-11.5) and no other + // leg selects T14-10 (E-6). + if (!READ_REFUSALS_STAGED) return; + await sourceContentArm(product); + await journalContentArm(product); + await sessionContentArm(product); + await configurationContentArm(product); + await derivedContentArm(product); + await graphDataArm(product); + await discoveryListingArm(product); + await sessionDirectoryArm(product); + }, +}); + +/** TEST-SPEC §14 II — T14-9 and T14-10, in canonical ID order (SUITE-49). */ +export const section14iiTests: readonly ProductTestEntry[] = [T14_9, T14_10]; diff --git a/test/suite/registry/section-14-iii.ts b/test/suite/registry/section-14-iii.ts new file mode 100644 index 00000000..0eb0308c --- /dev/null +++ b/test/suite/registry/section-14-iii.ts @@ -0,0 +1,1840 @@ +// TEST-SPEC §14 III — SUITE-49 (continued): T14-12, the well-formedness +// contract. SPEC 14.20 decides well-formedness by derivability alone under +// the input languages' grammars — every rule beyond derivability excluded, +// whether the language's own text calls its violation a syntax error or its +// tools report it after parsing. This module holds T14-12 whole: the +// positive arms — each file well-formed, proceeding to its ordinary outcome, +// never 14.20 — and the negative arms — each file unparseable, 14.20 at the +// one zero-length offset the rule of 14 fixes, masking everything inside the +// file, reported by `build` and `check` — and exports the 14.16 and 14.20 +// stagings for T14-4's reporter matrix (section-14.ts sweeps them over the +// surfaces of 11.2). T14-11's per-condition ranges live in section-14.ts, +// the environment refusals in section-14-ii.ts; a third module keeps both +// files' edits bounded (the section-6.5-i/-ii/-iii precedent). +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace per arm (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). +// +// Arms (TEST-SPEC T14-12, positive half): +// +// In a spec source, ECMAScript's early errors — each a finding in a +// well-formed file (SPEC 14.20: static-semantic early errors take no part in +// derivability), staged under S-9's named allowance for exactly the early +// error the form relies on (helpers/mdx-derivability.ts): +// (a) two imports binding one identifier within one ESM block — 14.15, the +// colliding pair, never 14.20 (T2.1-3's one-block arm; its tolerance — +// one finding for the collision, or one per import — is kept here); +// allowance `duplicate-import-binding`; +// (b) `export { nope }`, `nope` a binding no declaration introduces, on the +// line after a valid, used import in one ESM block: exactly one +// condition-16 finding located at the export statement whole (SPEC 14: +// "an export statement whole"), exit 1, no 14.20 and no 14.15 (the +// statement holds no declaration, which 2.1's collision clause needs); +// the import beside it proceeds normally — listed under `view`'s +// `imports` with its resolved target, the statement getting no view +// entry (11.4: the invalid constructs of 14.16 get no view entry), and +// the `{text(BASE.a)}` embedding rooted at the import recorded by +// `occurrences --file` (5.7, 11.3), the finding accompanying each +// answer, exit 1 (11.2); allowance `undefined-export`; +// (c) `{1 = 2}` — an assignment to a non-simple target — 14.16, brace +// through brace; allowance `invalid-assignment-target`; +// (d) `{let}` — a strict-mode restriction — 14.16; allowance +// `let-as-identifier`; +// (e) `{010}` — a legacy octal literal — 14.16; allowance `legacy-octal`. +// And the expression grammar (well-formed plainly — the default declaration): +// (f) `{a, b}` — a comma sequence is one expression — 14.16; +// (g) `d={BASE.a, BASE.b}` — one expression, 14.8 located whole (T14-11's +// arm (q) pins the same range; here the twin proves the file is no +// parse failure beside it); +// (h) `{await x}` — `await` admitted — 14.16; +// (i) `{function(){}}` — no statement lookahead restriction — 14.16; +// (j) a spread attribute `{...(a, b)}` — 14.17 at the whole braced +// construct (T2.7-3's grammar pair, deriving side); +// (k) `export const x = <b/>` — JSX in an ESM block's declaration — 14.16, +// the statement whole. +// In a code-group file, TypeScript's post-parse checks — each leaving the +// file well-formed (SPEC 14.20: well-formed TypeScript is the parser's +// acceptance, the post-parse grammar checks, name binding, and type checking +// excluded): `build` and `check` exit 0 on the otherwise valid workspace, a +// marker inside one of the file's units attributed to it (4.5, 4.6) and its +// `references` edge recorded: +// (l) a rest parameter that is not last, `function f(...r: number[], x: +// number) {}` — the marker inside `f`; +// (m) a misplaced modifier, `abstract m(): void` in a non-abstract class — +// the marker inside the class's method `run` (an abstract member is no +// unit, 4.6, so `C.run` is the attributed unit); +// (n) a duplicate declaration, `let a; let a;` — the marker inside a +// sibling function `f`; +// (o) a type error, `const n: number = "x"` — the marker inside `f`. +// The release pin (SPEC 14.20: TypeScript's grammar at release 5.9.3, +// language level ESNext, every text that release accepts both as module +// code and as script code well-formed) — each a `.ts` code source holding a +// form the releases before it reject, beside a marker inside the sibling +// unit `k`: `build` and `check` exit 0, the marker's `references` edge +// recorded: +// (x) `{ using x = f(); }`; +// (y) `async function g() { await using y = h(); }`; +// (z) `import a from "./a.json" with { type: "json" };` (the spec-source +// twin of the import-attributes form stays 14.20: arm (t)). +// The language level (14.20: ESNext, the level deciding which characters an +// identifier admits, 1.4) — U+2EBF0, a Unicode 15.1 letter that release +// admits at ESNext but rejects at ES3 and ES5: +// (aa) a `.ts` code source holding, inside the unit `f`, `const` U+2EBF0 +// `= 1` and the marker `S.`U+2EBF0, `S` the default binding of +// `specs/S.mdx`, which holds a section U+2EBF0 (a valid segment, 1.4): +// `build` and `check` exit 0, the marker's `references` edge to that +// section recorded; +// (ab) a configuration importing `defineConfig as` U+2EBF0 and exporting +// `export default` U+2EBF0 `({…})` over an otherwise valid argument +// (T7-2's aliased import): `build` exits 0 — the configuration loads +// — and `ids --json` lists the spec group's one file, the +// configuration in effect (T7-2's own confirmation). +// The release's other side: (ac), `const` U+1C89 `x = 1`, a negative arm +// (below). +// The code source's whitespace — that release's scanner's, not ECMAScript's: +// (ad) `const`, U+200B, `a = 1` and, on the next line, `const`, U+0085, +// `b = 1`, each away from any reference spelling, beside a marker +// inside the unit `f`: `build` and `check` exit 0, its edge recorded. +// The Unicode pin (14.20: ECMAScript 2024 takes its identifier characters, +// JSX names' included, from Unicode 15.1) — each a spec source deriving +// (S-9), its construct alone on its line after the valid section, never +// 14.20: +// (ae) `{` U+2EBF0 `}` — 14.16 at the container; +// (af) `<a` U+2EBF0 ` />` — 14.16 at the element's own tag (its +// self-closing tag's own characters, SPEC 14); +// (ag) `<S id="x" a` U+2EBF0 `="v" />` — 14.17 at that attribute, an +// unknown prop (the attribute's own characters, SPEC 14). +// +// Negative arms (TEST-SPEC T14-12, negative half) — 14.20, the one +// zero-length range at the offset the rule of 14 fixes, precomputed from the +// staged bytes (`assemble` with an empty pin), each arm masking everything +// inside its file (T14-3) and reported by `build` and `check`, the surfaces +// of 11.2 being T14-4's rows over the same stagings: +// (p) `010` and (q) `09` in a `.ts` file — text ECMAScript derives but +// TypeScript's scanner rejects — each at the literal's second digit +// (the prefix through its `0` begins a well-formed file); +// (ac) `const` U+1C89 `x = 1` in a `.ts` file — at offset 6, U+1C89's +// first byte (the prefix `const ` begins a well-formed file, and none +// under that release begins `const ` then U+1C89, which 5.9.3 admits +// neither to begin nor to continue an identifier at any level — a +// runtime's tables postdating Unicode 15.1 admit it); +// (r) a spread attribute `{...a, b}` — at its comma (T2.7-3's grammar +// pair, failing side); +// (s) an ESM block holding a statement — an import line followed on the +// next line, no blank line between, by `const x = 1` — at the start of +// the `const` line (the block derives import and export declarations +// only); +// (t) an import spelled with import attributes, `with { type: "json" }` — +// at the offset of `with` (syntax the edition lacks); +// (u) `d={]}` — at the `]`; +// (v) `{text(}` — at its `}`; +// (w) an unbalanced brace, `{text("a")` as the file's last bytes — at the +// file's byte length (the whole file a prefix of a well-formed one). +// +// Conservative operationalizations (H-3): +// - Every spec-source container arm stands in flow position at the top +// level, after a valid section (T14-11's preamble), so the container's +// 14.16 is the file's one finding; `a`, `b`, and `x` inside the containers +// of (f), (h) are free identifiers — an invalid container is no reference +// spelling, so nothing resolves or fails to (SPEC 2.7, 14.16), and the +// pinned expectation is exactly one finding. +// - (b)'s three surfaces compare the findings by condition and locations +// (the message is free text) and the `imports`, `comments`, and +// `occurrences` members whole, projected into explicit key order. +// - The code arms assert the workspace-wide `references` edge set is exactly +// the one marker's edge (T4.5-1's pattern), so the offending construct +// neither hides the unit nor adds one. +// - The staged texts are exported as `T14_12_FORM_VECTORS` with their +// allowances for the S-9 self-test, which judges every staging without +// the product — the S-7 sweep reaches only a body's first staging — and +// the negative arms' spec sources as `T14_12_UNPARSEABLE_VECTORS`, each +// staged as a record declared unparseable and judged non-deriving by the +// same self-test, which also confirms the pinned offset against the stock +// parser's rejection position wherever the two coincide (every arm but +// the spread's: the stock parser reports a spread's extra content at the +// content, past the comma the rule of 14 fixes — the rule, not the tool, +// fixes the offset). The Unicode-pin arms (ae)–(ag) are among the form +// vectors: S-9's check judges their identifier characters, a JSX name's +// included, code point by code point under Unicode 15.1, so each derives +// whatever a tokenizer judging a JSX name one UTF-16 code unit at a time +// reports. The positive arms' code sources and (ab)'s configuration are +// exported as `T14_12_CODE_FORM_VECTORS` for S-9's TypeScript check, which +// judges each accepted by TypeScript 5.9.3 both as module code and as +// script code; the negative code arms reach it through +// `T14_12_UNPARSEABLE_ARMS`. +// - Every workspace after the body's first — (b)'s onward — stages its +// `.mdx` sources as staged-source records (helpers/staged-mdx.ts; S-9's +// timing clause), and its configuration and code sources as TypeScript +// records (helpers/staged-ts.ts) — the three configurations, each code +// arm's `src/app.ts` — registered at load and judged by the ledger +// self-test before any product exists, each carrying its arm's +// declaration: an early-error form its allowance, a negative arm's +// failing file — a spec source or a code source — `unparseable`. T14-4 +// and T14-6 sweep the 14.16 and 14.20 arms' +// workspaces (`T14_12_REPORTER_STAGINGS`) and T14-11 re-stages the +// negative arms' sources, each after its own first invocation, so a +// record's name carries every test that stages it. +// - Every negative spec-source staging opens with a would-be invalid segment +// (`id="bad name"`, 14.4) before its failing construct — for the ESM-block +// arms after the block, (s) also spelling an import designating no +// discovered spec source (14.15) before the failure — and every code-source +// staging a would-be unresolved marker (`A.missing`, 14.7) — before the +// literal in (p) and (q), after the failing line in (ac), whose pinned +// offset 6 fixes the file's opening bytes: the pinned multiset is exactly +// one 14.20, so a product +// reporting the masked condition, or reporting it instead of the parse +// failure, fails. `check --json` is pinned exactly on the never-built +// workspace: it holds no record, so 14.10 has nothing to report beside +// the parse failure (SPEC 14.10, 12.2). +// - The 14.16 and 14.20 stagings are exported as `T14_12_REPORTER_STAGINGS` +// for T14-4, which sweeps them for reporter membership over `build`, +// `check`, and the surfaces whose domain holds the staged file (SPEC +// 11.2); the offsets stay this module's subject. + +import { Buffer } from "node:buffer"; +import type { + Finding, + GraphEdge, + OccurrenceRecord, + ViewImportEntry, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeIdsReport, + decodeOccurrencesReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import type { MdxAllowance } from "../../helpers/mdx-derivability.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertEdgeSetEqual, + assertSameJson, + buildFindings, + buildOk, + expectExit, + expectFindingFreeReport, + runFindingsReport, + runJson, +} from "./support.js"; + +// --------------------------------------------------------------------------- +// Shared staging +// --------------------------------------------------------------------------- + +// The exotic characters the release-pin, language-level, whitespace, and +// Unicode-pin arms stage, each built from its code point (never spelled as +// an escape or a literal in this file). + +/** U+2EBF0, CJK Unified Ideographs Extension I's first character — a letter Unicode 15.1 added, astral. */ +const EXT_I = String.fromCodePoint(0x2ebf0); +/** U+1C89, CYRILLIC CAPITAL LETTER TJE — a Unicode 16 letter, no identifier character under TypeScript 5.9.3. */ +const TJE = String.fromCodePoint(0x1c89); +/** U+200B ZERO WIDTH SPACE: whitespace to TypeScript 5.9.3's scanner, none to ECMAScript's grammar. */ +const ZWSP = String.fromCodePoint(0x200b); +/** U+0085 NEXT LINE: whitespace to TypeScript 5.9.3's scanner, none to ECMAScript's grammar. */ +const NEL = String.fromCodePoint(0x0085); + +// A staged-source record (S-9): every arm's workspace but the body's first +// follows its first product invocation, and T14-4's and T14-6's sweeps stage +// the 14.16 and spec-source 14.20 arms' workspaces after theirs. +const SPECS_ONLY_CONFIG = stagedTs( + "T14-4/T14-6/T14-12 xspec.config.ts (one spec group, specs/**/*.mdx)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); + +// 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, 4.5). A staged-source record (S-9): the code arms' workspaces +// follow the body's first invocation, and T14-4's and T14-6's sweeps stage +// the code-source 14.20 arms' workspaces after theirs. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T14-4/T14-6/T14-12 xspec.config.ts (one spec group and the code group app, src/**/*.ts)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); + +/** 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(); + } +} + +/** A byte range in SPEC 1.7's form: zero-based, start-inclusive, end-exclusive. */ +interface ByteRange { + readonly start: number; + readonly end: number; +} + +interface PinnedPart { + readonly pin: string; +} + +/** Mark a fixture part as pinned (T14-11's fixture notation). */ +function pin(text: string): PinnedPart { + return { pin: text }; +} + +/** A fixture text with the byte range of each pinned part, in part order. */ +interface AssembledFixture { + readonly text: string; + readonly ranges: readonly ByteRange[]; +} + +/** + * Concatenate the parts; each pinned part's range is [bytes before it, bytes + * through it) over the assembled UTF-8 text (SPEC 1.7). + */ +function assemble(parts: readonly (string | PinnedPart)[]): AssembledFixture { + let text = ""; + const ranges: ByteRange[] = []; + for (const part of parts) { + if (typeof part === "string") { + text += part; + continue; + } + const start = Buffer.byteLength(text, "utf8"); + text += part.pin; + ranges.push({ start, end: Buffer.byteLength(text, "utf8") }); + } + return { text, ranges }; +} + +/** The pinned range of `fixture` at `index` — a harness bug when absent. */ +function pinned(fixture: AssembledFixture, index: number): ByteRange { + const range = fixture.ranges[index]; + if (range === undefined) { + throw new Error( + `T14-12 fixture pins no part #${String(index)} (harness bug in section-14-iii.ts)`, + ); + } + return range; +} + +/** One pinned location: a file and its exact `{start, end}` (SPEC 12.7). */ +interface ExactLocation { + readonly file: string; + readonly range: ByteRange; +} + +/** A finding whose condition and complete location list are pinned. */ +interface ExactFindingExpectation { + readonly condition: string; + readonly locations: readonly ExactLocation[]; +} + +/** A finding projected to what the arms pin: condition and exact locations. */ +function projectFinding(finding: Finding): ExactFindingExpectation { + return { + condition: finding.condition ?? finding.code ?? "(code-less)", + locations: finding.locations.map((location) => ({ + file: + typeof location.file === "string" + ? location.file + : `<bytes ${location.file.bytes}>`, + range: { start: location.range.start, end: location.range.end }, + })), + }; +} + +const byJson = (a: unknown, b: unknown): number => { + const left = JSON.stringify(a); + const right = JSON.stringify(b); + return left < right ? -1 : left > right ? 1 : 0; +}; + +/** + * Assert the findings are exactly the pinned ones — one finding per pinned + * expectation, each condition's location lists byte-exact and complete — + * and never 14.20 (the arm's whole point: a finding in a well-formed file). + */ +function assertExactFindings( + findings: readonly Finding[], + expected: readonly ExactFindingExpectation[], + context: string, +): void { + const parseFailure = findings.find( + (finding) => finding.condition === "14.20", + ); + if (parseFailure !== undefined) { + fail( + `${context}: the file is well-formed — derivability alone decides ` + + `well-formedness, and the staged form fails only a rule beyond it ` + + `(SPEC 14.20) — so no 14.20 is reported; got an unparseable-source ` + + `finding at ${JSON.stringify(projectFinding(parseFailure).locations)} ` + + `(message: ${JSON.stringify(parseFailure.message)})`, + ); + } + const counts: Record<string, number> = {}; + for (const expectation of expected) { + counts[expectation.condition] = (counts[expectation.condition] ?? 0) + 1; + } + assertConditionCounts( + findings, + counts, + `${context} — exactly the stated findings, one per offending construct ` + + `and none beside, the file proceeding to its ordinary outcome (SPEC ` + + `14, 14.20)`, + ); + for (const finding of findings) { + if (finding.path !== null) { + fail( + `${context}: a finding locating in source concerns no path — \`path\` ` + + `is null for located conditions (SPEC 12.7, 14); got ` + + `${JSON.stringify(finding.path)} on the condition-` + + `${String(finding.condition)} finding (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + assertSameJson( + findings.map(projectFinding).sort(byJson), + [...expected].sort(byJson), + `${context}: each finding locates exactly the pinned byte range(s) — ` + + `the range SPEC 14 fixes for its condition, \`{"start", "end"}\` as ` + + `zero-based byte offsets, end-exclusive (SPEC 14, 1.7, 12.7)`, + ); +} + +// --------------------------------------------------------------------------- +// Spec-source arms: one form per workspace, `build --json` findings pinned +// --------------------------------------------------------------------------- + +/** A valid section every spec fixture opens with (a multibyte prefix, `é`). */ +const PREAMBLE = '<S id="ok">\nTarget: café.\n</S>\n\n'; + +/** The spec source the `BASE`-rooted arms import: `a` and `b` resolve. */ +const BASE_FILE = "specs/BASE.mdx"; +const BASE_MDX = '<S id="a">\nA.\n</S>\n\n<S id="b">\nB.\n</S>\n'; +const BASE_IMPORT = 'import BASE from "./BASE.xspec"'; + +/** + * The `BASE` module as a staged-source record (S-9), staged beside (b)'s, + * (g)'s, and (t)'s files: each of those workspaces follows the body's first + * product invocation, and T14-4's and T14-6's sweeps (over (b) and (t)) and + * T14-11's re-staging (of (t)) stage it after their own. + */ +const BASE_STAGED = stagedMdx( + "T14-4/T14-6/T14-11/T14-12 the BASE module (the (b), (g), and (t) arms) specs/BASE.mdx", + BASE_MDX, +); + +/** The file every spec-source arm stages its form in. */ +const ARM_FILE = "specs/A.mdx"; + +/** One positive spec-source arm's row: a form, its allowance, its pinned findings. */ +interface SpecFormRow { + /** The arm's letter (diagnostics). */ + readonly arm: string; + /** The form under test and the rule it relies on (diagnostics). */ + readonly name: string; + readonly fixture: AssembledFixture; + /** Further staged sources (the `BASE` module's record), beside the arm's file. */ + readonly extraFiles?: Readonly<Record<string, InitialFileContents>>; + /** S-9: the early error the form relies on; absent, it derives plainly. */ + readonly allowances?: readonly MdxAllowance[]; + /** The condition each pinned part reports, in pin order. */ + readonly conditions: readonly string[]; +} + +/** + * One positive spec-source arm: its row, and its file as a staged-source + * record under the row's allowances (S-9) — every arm's workspace follows + * the body's first product invocation. + */ +interface SpecFormArm extends SpecFormRow { + /** `fixture.text` as its record. */ + readonly source: StagedMdx; +} + +/** A flow-position container at the top level, after the valid section. */ +function containerArm( + arm: string, + name: string, + container: string, + allowances?: readonly MdxAllowance[], +): SpecFormRow { + return { + arm, + name, + fixture: assemble([PREAMBLE, pin(container), "\n"]), + ...(allowances === undefined ? {} : { allowances }), + conditions: ["14.16"], + }; +} + +const SPEC_FORM_ROWS: readonly SpecFormRow[] = [ + containerArm( + "c", + "`{1 = 2}` — an assignment to a target that is not simple, an early error (14.16, never 14.20)", + "{1 = 2}", + ["invalid-assignment-target"], + ), + containerArm( + "d", + "`{let}` — `let` as an identifier reference, a strict-mode restriction (14.16, never 14.20)", + "{let}", + ["let-as-identifier"], + ), + containerArm( + "e", + "`{010}` — a legacy octal literal, a strict-mode restriction (14.16, never 14.20)", + "{010}", + ["legacy-octal"], + ), + containerArm( + "f", + "`{a, b}` — a comma sequence is one expression (14.16, never 14.20)", + "{a, b}", + ), + { + arm: "g", + name: "`d={BASE.a, BASE.b}` — a comma sequence is one expression, 14.8 located whole (never 14.20)", + fixture: assemble([ + BASE_IMPORT, + "\n\n", + PREAMBLE, + '<S id="q" d={', + pin("BASE.a, BASE.b"), + "}>\nA comma sequence.\n</S>\n", + ]), + extraFiles: { [BASE_FILE]: BASE_STAGED }, + conditions: ["14.8"], + }, + containerArm( + "h", + "`{await x}` — `await` is admitted by the expression grammar (14.16, never 14.20)", + "{await x}", + ), + containerArm( + "i", + "`{function(){}}` — no statement's lookahead restriction applies (14.16, never 14.20)", + "{function(){}}", + ), + { + arm: "j", + name: "a spread attribute `{...(a, b)}` — `...` followed by exactly one assignment expression (14.17 at the whole braced construct, never 14.20)", + fixture: assemble([ + PREAMBLE, + '<S id="s" ', + pin("{...(a, b)}"), + ">\nA spread attribute.\n</S>\n", + ]), + conditions: ["14.17"], + }, + { + arm: "k", + name: "`export const x = <b/>` — JSX inside an ESM block's declaration is an export statement (14.16 at the statement whole, never 14.20)", + fixture: assemble([PREAMBLE, pin("export const x = <b/>"), "\n"]), + conditions: ["14.16"], + }, + // The Unicode pin: U+2EBF0, a Unicode 15.1 letter, as an expression's + // identifier and inside a JSX element's and an attribute's name. + containerArm( + "ae", + "`{` U+2EBF0 `}` — the one-character identifier U+2EBF0, a letter Unicode 15.1 added, alone in an expression container (14.16 at the container, never 14.20)", + `{${EXT_I}}`, + ), + { + arm: "af", + name: "`<a` U+2EBF0 ` />` — a JSX element name continued by U+2EBF0, a Unicode 15.1 letter judged as one code point (14.16 at the element's own tag, never 14.20)", + fixture: assemble([PREAMBLE, pin(`<a${EXT_I} />`), "\n"]), + conditions: ["14.16"], + }, + { + arm: "ag", + name: '`<S id="x" a` U+2EBF0 `="v" />` — an attribute name continued by U+2EBF0, a Unicode 15.1 letter judged as one code point (14.17 at that attribute, an unknown prop; never 14.20)', + fixture: assemble([PREAMBLE, '<S id="x" ', pin(`a${EXT_I}="v"`), " />\n"]), + conditions: ["14.17"], + }, +]; + +/** + * Whether T14-4's reporter matrix sweeps the arm — a 14.16 construct alone + * (TEST-SPEC T14-4: "the 14.16 and 14.20 arms of … T14-12"; T14-6's + * stable-code sweep with it, `T14_12_REPORTER_STAGINGS`). + */ +function isSweptSpecForm(arm: SpecFormRow): boolean { + return arm.conditions.length === 1 && arm.conditions[0] === "14.16"; +} + +/** + * The arms with their records, computed once at module load, each named + * with every test staging it: T14-12, and T14-4 and T14-6 for a swept arm. + */ +const SPEC_FORM_ARMS: readonly SpecFormArm[] = SPEC_FORM_ROWS.map( + (row): SpecFormArm => ({ + ...row, + source: stagedMdx( + `${isSweptSpecForm(row) ? "T14-4/T14-6/T14-12" : "T14-12"} (${row.arm}) ${row.name} ${ARM_FILE}`, + row.fixture.text, + row.allowances === undefined + ? "well-formed" + : { allowances: row.allowances }, + ), + }), +); + +/** The workspace one spec-form arm stages: its record carries its allowance (S-9). */ +function specFormDecl(arm: SpecFormArm): WorkspaceDecl { + return { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + ...(arm.extraFiles ?? {}), + [ARM_FILE]: arm.source, + }, + }; +} + +/** One spec-form arm: `build --json` exits 1 with exactly the pinned findings. */ +async function runSpecFormArm( + product: ProductBinding, + arm: SpecFormArm, +): Promise<void> { + const context = `T14-12 (${arm.arm}) ${arm.name}`; + const expected = arm.conditions.map((condition, index) => ({ + condition, + locations: [{ file: ARM_FILE, range: pinned(arm.fixture, index) }], + })); + await withWorkspace(specFormDecl(arm), async (workspace) => { + const findings = await buildFindings( + product, + workspace, + `${context} — \`build --json\` exits 1 with the findings report: the ` + + `file is well-formed and proceeds to its ordinary outcome, a ` + + `finding (SPEC 14.20, 12.0, 12.7)`, + ); + assertExactFindings(findings, expected, context); + }); +} + +// (a) Two imports binding one identifier within one ESM block (consecutive +// lines, no blank line between): a duplicate lexically declared name is an +// early error, excluded from derivability, so the file is well-formed and +// the collision is 14.15 — T2.1-3's one-block arm, the one a product handing +// the block to a parser enforcing early errors fails. T2.1-3's tolerance is +// kept: one finding for the collision, or one per import, every one 14.15 +// and located within one of the two declarations. +const DUP_BINDING_FIRST = 'import BASE from "./B1.xspec"'; +const DUP_BINDING_SECOND = 'import BASE from "./B2.xspec"'; +const DUP_BINDING_FIXTURE = assemble([ + pin(DUP_BINDING_FIRST), + "\n", + pin(DUP_BINDING_SECOND), + "\n\n", + '<S id="x">\nBody.\n</S>\n', +]); +const DUP_BINDING_FILES: Readonly<Record<string, string>> = { + "specs/B1.mdx": '<S id="b1">\nFirst module.\n</S>\n', + "specs/B2.mdx": '<S id="b2">\nSecond module.\n</S>\n', +}; + +async function runDuplicateBindingArm(product: ProductBinding): Promise<void> { + const context = + "T14-12 (a) two imports binding one identifier within one ESM block — " + + "a duplicate lexically declared name is an early error, 14.15 in a " + + "well-formed file, never 14.20"; + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + ...DUP_BINDING_FILES, + [ARM_FILE]: DUP_BINDING_FIXTURE.text, + }, + mdx: { allowances: { [ARM_FILE]: ["duplicate-import-binding"] } }, + }, + async (workspace) => { + const findings = await buildFindings( + product, + workspace, + `${context} — \`build --json\` exits 1 with the findings report ` + + `(SPEC 12.0, 12.7)`, + ); + const conditions = findings.map((finding) => finding.condition); + if ( + findings.length < 1 || + findings.length > 2 || + conditions.some((condition) => condition !== "14.15") + ) { + fail( + `${context}: expected the colliding pair to report condition 14.15 ` + + `— one finding for the collision, or one per import — and ` + + `never 14.20 (SPEC 2.1, 14.20; T2.1-3); got ` + + `${JSON.stringify(findings.map(projectFinding))}`, + ); + } + const windows = [ + pinned(DUP_BINDING_FIXTURE, 0), + pinned(DUP_BINDING_FIXTURE, 1), + ]; + for (const finding of findings) { + if (finding.locations.length === 0) { + fail( + `${context}: the 14.15 finding must locate the colliding ` + + `declaration(s) (SPEC 14); got no location (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + for (const location of finding.locations) { + const inside = windows.some( + (window) => + location.range.start >= window.start && + location.range.end <= window.end, + ); + if (location.file !== ARM_FILE || !inside) { + fail( + `${context}: every 14.15 location lies within one of the two ` + + `import declarations of ${ARM_FILE} (SPEC 14: an ` + + `import-binding collision locates every colliding ` + + `declaration by its own characters); got ` + + `${JSON.stringify(projectFinding(finding).locations)}`, + ); + } + } + } + }, + ); +} + +// --------------------------------------------------------------------------- +// (b) `export { nope }` — an export naming no declaration, on the line after +// a valid, used import in one ESM block +// --------------------------------------------------------------------------- + +// The file: the import, the export statement on the next line (one ESM +// block), a blank line, and a section embedding `BASE.a` — the import used. +// Pinned: the import declaration (the `view` import range, 11.4), the export +// statement whole (the 14.16 range, SPEC 14), the embedding's full braced +// container (the occurrence span, 5.7); the section's construct range (the +// occurrence's source node range, 5.7, 1.7) is computed beside them. +const NOPE_EXPORT = "export { nope }"; +const NOPE_SECTION_OPEN = '<S id="x">\n'; +const NOPE_EMBEDDING = "{text(BASE.a)}"; +const NOPE_SECTION_CLOSE = "\n</S>"; +const NOPE_FIXTURE = assemble([ + pin(BASE_IMPORT), + "\n", + pin(NOPE_EXPORT), + "\n\n", + NOPE_SECTION_OPEN, + pin(NOPE_EMBEDDING), + NOPE_SECTION_CLOSE, + "\n", +]); +const NOPE_SECTION_RANGE: ByteRange = (() => { + const start = pinned(NOPE_FIXTURE, 1).end + Buffer.byteLength("\n\n", "utf8"); + return { + start, + end: + start + + Buffer.byteLength( + NOPE_SECTION_OPEN + NOPE_EMBEDDING + NOPE_SECTION_CLOSE, + "utf8", + ), + }; +})(); + +/** + * (b)'s file as a staged-source record under its allowance (S-9): the + * workspace follows (a)'s invocations, and T14-4's and T14-6's sweeps stage + * it after theirs. + */ +const NOPE_STAGED = stagedMdx( + "T14-4/T14-6/T14-12 (b) `export { nope }` after a valid, used import specs/A.mdx", + NOPE_FIXTURE.text, + { allowances: ["undefined-export"] }, +); + +/** The (b) workspace: the `BASE` module beside the arm's file, both records (S-9). */ +const NOPE_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [BASE_FILE]: BASE_STAGED, + [ARM_FILE]: NOPE_STAGED, + }, +}; + +/** The one finding: 14.16 at the export statement whole (SPEC 14). */ +const NOPE_EXPECTED_FINDINGS: readonly ExactFindingExpectation[] = [ + { + condition: "14.16", + locations: [{ file: ARM_FILE, range: pinned(NOPE_FIXTURE, 1) }], + }, +]; + +/** An occurrence record projected into explicit key order (SPEC 5.7, 12.7). */ +interface ProjectedRecord { + readonly file: string; + readonly range: ByteRange; + readonly kind: string; + readonly source: + | { readonly identity: string; readonly range: ByteRange } + | { readonly unavailable: true }; + readonly target: string; +} + +function projectPath(value: string | { readonly bytes: string }): string { + return typeof value === "string" ? value : `<bytes ${value.bytes}>`; +} + +function projectRecord(record: OccurrenceRecord): ProjectedRecord { + return { + file: projectPath(record.file), + range: { start: record.range.start, end: record.range.end }, + kind: record.kind, + source: + "unavailable" in record.source + ? { unavailable: true } + : { + identity: record.source.identity, + range: { + start: record.source.range.start, + end: record.source.range.end, + }, + }, + target: record.target, + }; +} + +/** An import entry projected into explicit key order (SPEC 11.4, 12.7). */ +interface ProjectedImport { + readonly range: ByteRange; + readonly name: string | null; + readonly target: string | { readonly unavailable: true }; +} + +function projectImport(entry: ViewImportEntry): ProjectedImport { + return { + range: { start: entry.range.start, end: entry.range.end }, + name: entry.name, + target: + typeof entry.target === "object" && "unavailable" in entry.target + ? { unavailable: true } + : projectPath(entry.target), + }; +} + +/** The embedding rooted at the import: one `embeds` occurrence from `x` to `BASE#a`. */ +const NOPE_EXPECTED_RECORD: ProjectedRecord = { + file: ARM_FILE, + range: pinned(NOPE_FIXTURE, 2), + kind: "embeds", + source: { identity: `${ARM_FILE}#x`, range: NOPE_SECTION_RANGE }, + target: `${BASE_FILE}#a`, +}; + +/** The import beside the statement, listed with its resolved target. */ +const NOPE_EXPECTED_IMPORT: ProjectedImport = { + range: pinned(NOPE_FIXTURE, 0), + name: "BASE", + target: BASE_FILE, +}; + +/** The findings of an answer, projected and sorted, equal the pinned set. */ +function assertAnswerFindings( + findings: readonly Finding[], + context: string, +): void { + assertSameJson( + findings.map(projectFinding).sort(byJson), + [...NOPE_EXPECTED_FINDINGS].sort(byJson), + `${context}: the domain file's one finding — 14.16 at the export ` + + `statement whole — accompanies the answer, never 14.20, never 14.15 ` + + `(SPEC 11.2, 14, 14.20)`, + ); +} + +async function runExportNopeArm(product: ProductBinding): Promise<void> { + const context = + "T14-12 (b) `export { nope }` after a valid, used import in one ESM " + + "block — an export naming no declaration is an early error, 14.16 in a " + + "well-formed file, never 14.20 and never 14.15"; + await withWorkspace(NOPE_DECL, async (workspace) => { + // `build --json`: exactly one condition-16 finding located at the + // export statement whole (SPEC 14: "an export statement whole"), exit + // 1; no 14.20 (the file is well-formed) and no 14.15 (the statement + // holds no declaration, which 2.1's collision clause needs). + assertExactFindings( + await buildFindings( + product, + workspace, + `${context} — \`build --json\` exits 1 with the findings report ` + + `(SPEC 12.0, 12.7)`, + ), + NOPE_EXPECTED_FINDINGS, + `${context} — \`build --json\``, + ); + + // `view`: the import proceeds normally — listed under `imports` with + // its binding and resolved target; the statement gets no view entry + // (11.4: the invalid constructs of 14.16 get no view entry) — so + // `imports` holds exactly the one declaration and `comments` nothing; + // the embedding's occurrence is the file's one record; the finding + // accompanies the answer, exit 1 (11.2). + const viewContext = `${context} — \`view ${ARM_FILE}\``; + const view = decodeViewReport( + parseJsonStdout( + await expectExit( + product, + workspace, + ["view", ARM_FILE], + 1, + `${viewContext}: an answer carrying a finding exits 1 with the ` + + `full answer document still emitted (SPEC 11.2)`, + ), + viewContext, + ), + { text: false }, + viewContext, + ); + assertAnswerFindings(view.findings, viewContext); + assertSameJson( + view.views.map((entry) => projectPath(entry.file)), + [ARM_FILE], + `${viewContext}: the requested, parseable file is viewed (SPEC 11.4)`, + ); + const fileView = view.views[0]!; + assertSameJson( + fileView.imports.map(projectImport), + [NOPE_EXPECTED_IMPORT], + `${viewContext}: \`imports\` lists exactly the import declaration — ` + + `its source range, its default binding \`BASE\`, and its resolved ` + + `target ${BASE_FILE} — the export statement getting no entry ` + + `(SPEC 11.4, 14.16)`, + ); + assertSameJson( + fileView.comments, + [], + `${viewContext}: no MDX comment is staged, and an export statement ` + + `is no comment — \`comments\` is [] (SPEC 11.4, 12.7)`, + ); + assertSameJson( + fileView.occurrences.map(projectRecord), + [NOPE_EXPECTED_RECORD], + `${viewContext}: the \`{text(BASE.a)}\` embedding rooted at the ` + + `import records its occurrence — the full braced container, kind ` + + `embeds, source \`${ARM_FILE}#x\` with the section's construct ` + + `range, target \`${BASE_FILE}#a\` (SPEC 11.4, 5.7)`, + ); + + // `occurrences --file`: the embedding is recorded, the finding + // accompanying the answer, exit 1 (SPEC 11.3, 11.2). + const occurrencesContext = `${context} — \`occurrences --file ${ARM_FILE}\``; + const report = decodeOccurrencesReport( + parseJsonStdout( + await expectExit( + product, + workspace, + ["occurrences", "--file", ARM_FILE], + 1, + `${occurrencesContext}: an answer carrying a finding exits 1 ` + + `with the full answer document still emitted (SPEC 11.2)`, + ), + occurrencesContext, + ), + occurrencesContext, + ); + assertAnswerFindings(report.findings, occurrencesContext); + assertSameJson( + report.occurrences.map(projectRecord), + [NOPE_EXPECTED_RECORD], + `${occurrencesContext}: the embedding rooted at the import is the ` + + `domain's one occurrence record — the full braced container, kind ` + + `embeds, source \`${ARM_FILE}#x\`, target \`${BASE_FILE}#a\` ` + + `(SPEC 11.3, 5.7)`, + ); + }); +} + +// --------------------------------------------------------------------------- +// Code-group arms: TypeScript's post-parse checks leave the file well-formed +// --------------------------------------------------------------------------- + +/** The spec source the code arms import: `a` resolves. */ +const A_MDX = '<S id="a">\nAlpha behavior.\n</S>\n'; + +/** + * `A_MDX` as a staged-source record (S-9): every code arm's workspace but + * (aa)'s — (l)–(o)'s, (x)–(z)'s, and (ad)'s, the negative (p)'s, (q)'s, + * and (ac)'s — and the configuration arm (ab)'s follow the body's first + * product invocation, and T14-4's and T14-6's sweeps and T14-11's + * re-staging stage (p)'s, (q)'s, and (ac)'s after theirs. + */ +const A_STAGED = stagedMdx( + "T14-4/T14-6/T14-11/T14-12 the code arms' spec source specs/A.mdx", + A_MDX, +); + +const CODE_IMPORT = 'import A from "../specs/A.xspec"'; +const CODE_FILE = "src/app.ts"; + +/** The spec source a code arm's marker designates a node of. */ +interface CodeFormTarget { + /** The spec source's workspace-relative path. */ + readonly file: string; + /** Its staged-source record (S-9). */ + readonly source: StagedMdx; + /** The designated node's identity in that file. */ + readonly id: string; +} + +/** The default target: `specs/A.mdx`'s section `a`, the marker `A.a`. */ +const A_TARGET: CodeFormTarget = { + file: "specs/A.mdx", + source: A_STAGED, + id: "a", +}; + +/** One code arm's row: `src/app.ts`'s lines and the unit the marker lies in. */ +interface CodeFormRow { + readonly arm: string; + readonly name: string; + /** The file's lines, each LF-terminated when laid out. */ + readonly lines: readonly string[]; + /** The named unit (SPEC 4.6) enclosing the marker. */ + readonly unit: string; + /** The node the marker designates; absent, `A_TARGET` (the marker `A.a`). */ + readonly target?: CodeFormTarget; +} + +/** + * The language-level arm's spec source: one top-level section whose + * segment is U+2EBF0 (a valid segment, SPEC 1.4) — a staged-source record + * (S-9), the arm's workspace following the body's first invocation. + */ +const S_MDX = `<S id="${EXT_I}">\nIdeograph behavior.\n</S>\n`; +const S_STAGED = stagedMdx( + "T14-12 (aa) the language-level arm's spec source, one section U+2EBF0 specs/S.mdx", + S_MDX, +); + +const CODE_FORM_ROWS: readonly CodeFormRow[] = [ + { + arm: "l", + name: "a rest parameter that is not last, `function f(...r: number[], x: number) {}` — a post-parse grammar check (well-formed TypeScript)", + lines: [ + CODE_IMPORT, + "", + "function f(...r: number[], x: number) {", + " A.a", + "}", + ], + unit: "f", + }, + { + arm: "m", + name: "a misplaced modifier, `abstract m(): void` in a non-abstract class — a post-parse grammar check (well-formed TypeScript)", + lines: [ + CODE_IMPORT, + "", + "class C {", + " abstract m(): void", + " run(): void {", + " A.a", + " }", + "}", + ], + unit: "C.run", + }, + { + arm: "n", + name: "a duplicate declaration, `let a; let a;` — name binding (well-formed TypeScript)", + lines: [ + CODE_IMPORT, + "", + "let a; let a;", + "", + "function f(): void {", + " A.a", + "}", + ], + unit: "f", + }, + { + arm: "o", + name: 'a type error, `const n: number = "x"` — type checking (well-formed TypeScript)', + lines: [ + CODE_IMPORT, + "", + 'const n: number = "x"', + "", + "function f(): void {", + " A.a", + "}", + ], + unit: "f", + }, + // The release pin: forms the releases predating their admission reject, + // each beside a marker inside the sibling unit `k`. + { + arm: "x", + name: "the release pin: `{ using x = f(); }` — a `using` declaration in a block (well-formed TypeScript at 5.9.3)", + lines: [ + CODE_IMPORT, + "", + "{ using x = f(); }", + "", + "function k(): void {", + " A.a", + "}", + ], + unit: "k", + }, + { + arm: "y", + name: "the release pin: `async function g() { await using y = h(); }` — an `await using` declaration (well-formed TypeScript at 5.9.3)", + lines: [ + CODE_IMPORT, + "", + "async function g() { await using y = h(); }", + "", + "function k(): void {", + " A.a", + "}", + ], + unit: "k", + }, + { + arm: "z", + name: 'the release pin: `import a from "./a.json" with { type: "json" };` — an import spelled with import attributes in a code source (well-formed TypeScript at 5.9.3)', + lines: [ + CODE_IMPORT, + 'import a from "./a.json" with { type: "json" };', + "", + "function k(): void {", + " A.a", + "}", + ], + unit: "k", + }, + // The language level: U+2EBF0 begins an identifier at ESNext, not at ES3 + // or ES5 — the local `const` and the marker's segment alike. + { + arm: "aa", + name: "the language level: `const` U+2EBF0 `= 1` and the marker `S.` U+2EBF0 inside one unit — an identifier character TypeScript 5.9.3 admits at ESNext alone (well-formed TypeScript)", + lines: [ + 'import S from "../specs/S.xspec"', + "", + "function f(): void {", + ` const ${EXT_I} = 1`, + ` S.${EXT_I}`, + "}", + ], + unit: "f", + target: { file: "specs/S.mdx", source: S_STAGED, id: EXT_I }, + }, + // The code source's whitespace: that release's scanner's, not ECMAScript's. + { + arm: "ad", + name: "the whitespace: `const` U+200B `a = 1` and, on the next line, `const` U+0085 `b = 1` — both code points whitespace to that release's scanner, neither to ECMAScript's grammar (well-formed TypeScript)", + lines: [ + CODE_IMPORT, + "", + `const${ZWSP}a = 1`, + `const${NEL}b = 1`, + "", + "function f(): void {", + " A.a", + "}", + ], + unit: "f", + }, +]; + +/** + * One code arm: its row, and its file laid out as a staged-source record + * (S-9) — every code arm's workspace follows the body's first product + * invocation. + */ +interface CodeFormArm extends CodeFormRow { + /** `lines` laid out, each LF-terminated. */ + readonly text: string; + /** `text` as its record. */ + readonly source: StagedTs; +} + +const CODE_FORM_ARMS: readonly CodeFormArm[] = CODE_FORM_ROWS.map( + (row): CodeFormArm => { + const text = row.lines.map((line) => line + "\n").join(""); + return { + ...row, + text, + source: stagedTs(`T14-12 (${row.arm}) ${row.name} ${CODE_FILE}`, text), + }; + }, +); + +/** + * One code arm: the file is well-formed, so `build` and `check` exit 0 on + * the otherwise valid workspace and the marker inside the unit records its + * `references` edge, attributed to that unit (SPEC 14.20, 4.5, 4.6). + */ +async function runCodeFormArm( + product: ProductBinding, + arm: CodeFormArm, +): Promise<void> { + const context = `T14-12 (${arm.arm}) ${arm.name}`; + const target = arm.target ?? A_TARGET; + await withWorkspace( + { + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [target.file]: target.source, + [CODE_FILE]: arm.source, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} — \`build\` exits 0: the file is well-formed, TypeScript's ` + + `post-parse checks excluded from derivability, and the workspace ` + + `is otherwise valid (SPEC 14.20, 12.1)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} — \`check --json\` on the freshly built workspace ` + + `(SPEC 14.20, 12.2)`, + ); + const label = `${context} — \`query edges --kinds references\``; + const edges: readonly GraphEdge[] = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--kinds", "references"], + label, + ), + label, + ); + assertEdgeSetEqual( + edges, + [ + { + from: `${CODE_FILE}#${arm.unit}`, + to: `${target.file}#${target.id}`, + kind: "references", + }, + ], + `${context}: the marker inside the unit is attributed to it and its ` + + `\`references\` edge recorded — the workspace's whole set (SPEC ` + + `4.5, 4.6, 14.20)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// (ab) The language level in the configuration file +// --------------------------------------------------------------------------- + +/** + * (ab)'s configuration: T7-2's aliased `defineConfig` import, the alias the + * one-character identifier U+2EBF0 — well-formed under TypeScript 5.9.3 at + * ESNext, the grammar 14.20 judges a configuration file by, and rejected at + * ES3 and ES5 — over an otherwise valid argument (one spec group). A + * TypeScript record (S-9): the arm's workspace follows the body's first + * product invocation. + */ +const ALIASED_IDEOGRAPH_CONFIG_TEXT = `import { defineConfig as ${EXT_I} } from "xspec" + +export default ${EXT_I}({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; +const ALIASED_IDEOGRAPH_CONFIG = stagedTs( + "T14-12 (ab) xspec.config.ts (defineConfig imported as U+2EBF0, one spec group)", + ALIASED_IDEOGRAPH_CONFIG_TEXT, +); + +/** + * (ab): the configuration loads without error — `build` exits 0 and + * `check` is clean on the otherwise valid workspace, where a product + * parsing the configuration file at ES3 or ES5 reports 14.20 or 14.14 — and + * is in effect: `ids --json` lists exactly the spec group's one file and + * its one identity (T7-2's aliased arm's own confirmation; SPEC 7, 14.20). + */ +async function runAliasedConfigArm(product: ProductBinding): Promise<void> { + const context = + "T14-12 (ab) the language level: a configuration importing " + + "`defineConfig as` U+2EBF0 and exporting `export default` U+2EBF0 " + + "`({…})` — an identifier character TypeScript 5.9.3 admits at ESNext " + + "alone (T7-2's aliased import)"; + await withWorkspace( + { + files: { + "xspec.config.ts": ALIASED_IDEOGRAPH_CONFIG, + "specs/A.mdx": A_STAGED, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} — \`build\` exits 0: the configuration is well-formed ` + + `TypeScript and loads without error, the workspace otherwise ` + + `valid (SPEC 14.20, 7, 12.1)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} — \`check --json\` on the freshly built workspace ` + + `(SPEC 14.20, 12.2)`, + ); + const label = `${context} — \`ids --json\``; + const report = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], label), + label, + ); + assertSameJson( + report.files, + [{ file: "specs/A.mdx", ids: ["a"] }], + `${label}: the aliased configuration took effect — its spec group ` + + `drives discovery (SPEC 7)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// Negative arms: 14.20, the one zero-length range at the offset the rule of +// 14 fixes; masking; reported by `build` and `check` +// --------------------------------------------------------------------------- + +/** + * A would-be invalid segment (14.4) every negative spec-source staging + * spells before its failing construct (or, for the ESM-block arms, after + * the block): masked by the parse failure, so a product reporting it beside + * — or instead of — the one 14.20 fails (SPEC 14.20: a file that fails to + * parse is masked whole, T14-3). The multibyte `é` before every pinned + * offset puts a character-indexed or line/column report off by one from + * the pinned byte offset (T14-11's discipline). + */ +const MASKED_PREAMBLE = + '<S id="bad name">\nA would-be invalid segment (14.4), masked: café.\n</S>\n\n'; + +/** A would-be unresolved marker (14.7) the code-source stagings hold, masked. */ +const MASKED_MARKER = "A.missing"; + +/** One negative arm: a staging whose named file is unparseable at `offset`. */ +export interface UnparseableArm { + /** The arm's letter (diagnostics). */ + readonly arm: string; + /** The form under test (diagnostics). */ + readonly name: string; + /** Where the failing file lies: a spec source (`.mdx`) or a code source (`.ts`). */ + readonly kind: "spec-source" | "code-source"; + /** The unparseable file's workspace-relative path. */ + readonly file: string; + /** + * Every staged file, the configuration included: each a staged-source + * record — the `.mdx` sources MDX records, the configuration and a code + * source TypeScript records, the arm's failing file declared unparseable + * (S-9) — registered at load, since every negative arm's workspace + * follows the body's first product invocation and T14-4, T14-6, and + * T14-11 stage the same records after theirs. + */ + readonly files: Readonly<Record<string, InitialFileContents>>; + /** The staged unparseable text (the S-9 vectors; Task 46's reuse). */ + readonly source: string; + /** The failure's byte offset: SPEC 14's zero-length range `{offset, offset}`. */ + readonly offset: number; + /** The condition the staged would-be construct would report, masked (diagnostics). */ + readonly masked: string; + /** Why the rule of 14 fixes that offset (diagnostics). */ + readonly rule: string; + /** + * Whether the stock MDX 3 parser's rejection position (`deriveMdx`, S-9) + * coincides with the rule's offset — confirmed by the S-9 self-test; the + * spread arm's does not (the stock parser reports the extra content past + * the comma), the rule alone fixing its offset. + */ + readonly parserAgrees: boolean; +} + +/** A spec-source negative arm: `parts` with one empty pin at the failure's offset. */ +function specUnparseableArm( + arm: string, + name: string, + parts: readonly (string | PinnedPart)[], + rule: string, + options: { + readonly extraFiles?: Readonly<Record<string, InitialFileContents>>; + readonly masked?: string; + readonly parserAgrees?: boolean; + } = {}, +): UnparseableArm { + const fixture = assemble(parts); + return { + arm, + name, + kind: "spec-source", + file: ARM_FILE, + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + ...(options.extraFiles ?? {}), + [ARM_FILE]: stagedMdx( + `T14-4/T14-6/T14-11/T14-12 (${arm}) ${name} ${ARM_FILE}`, + fixture.text, + "unparseable", + ), + }, + source: fixture.text, + offset: pinned(fixture, 0).start, + masked: options.masked ?? 'the invalid segment of `id="bad name"` (14.4)', + rule, + parserAgrees: options.parserAgrees ?? true, + }; +} + +/** A code-source negative arm: `src/app.ts` beside the valid `specs/A.mdx`. */ +function codeUnparseableArm( + arm: string, + name: string, + parts: readonly (string | PinnedPart)[], + rule: string, +): UnparseableArm { + const fixture = assemble(parts); + return { + arm, + name, + kind: "code-source", + file: CODE_FILE, + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/A.mdx": A_STAGED, + [CODE_FILE]: stagedTs( + `T14-4/T14-6/T14-11/T14-12 (${arm}) ${name} ${CODE_FILE}`, + fixture.text, + "unparseable", + ), + }, + source: fixture.text, + offset: pinned(fixture, 0).start, + masked: `the unresolved marker \`${MASKED_MARKER}\` (14.7)`, + rule, + // `parserAgrees` confirms the stock MDX parser's position, which a code + // source never meets; S-9's TypeScript check judges the file unparseable + // by its record's declaration — at load in the ledger self-test, and + // again at staging. + parserAgrees: false, + }; +} + +const LEADING_ZERO_RULE = + "the literal's second digit: the prefix through its `0` begins a " + + "well-formed file, while TypeScript's scanner rejects a legacy octal " + + "literal and a leading-zero decimal — text ECMAScript derives — so no " + + "well-formed TypeScript file begins with the prefix through the second " + + "digit (SPEC 14.20: well-formed TypeScript is the parser's acceptance)"; + +/** Every negative arm, in the order of the TEST-SPEC T14-12 text. */ +export const T14_12_UNPARSEABLE_ARMS: readonly UnparseableArm[] = [ + codeUnparseableArm( + "p", + "`010` in a `.ts` file — a legacy octal literal, rejected by TypeScript's scanner (14.20 at the literal's second digit)", + [ + CODE_IMPORT, + "\n\n", + MASKED_MARKER, + "\n\n", + "const n = 0", + pin(""), + "10\n", + ], + LEADING_ZERO_RULE, + ), + codeUnparseableArm( + "q", + "`09` in a `.ts` file — a leading-zero decimal, rejected by TypeScript's scanner (14.20 at the literal's second digit)", + [CODE_IMPORT, "\n\n", MASKED_MARKER, "\n\n", "const n = 0", pin(""), "9\n"], + LEADING_ZERO_RULE, + ), + codeUnparseableArm( + "ac", + "`const` U+1C89 `x = 1` in a `.ts` file — U+1C89, a Unicode 16 letter, which TypeScript 5.9.3 admits neither to begin nor to continue an identifier (14.20 at offset 6, its first byte)", + [ + "const ", + pin(""), + `${TJE}x = 1`, + "\n\n", + CODE_IMPORT, + "\n\n", + MASKED_MARKER, + "\n", + ], + "U+1C89's first byte, offset 6: the prefix `const ` begins a " + + "well-formed file, while TypeScript 5.9.3 admits U+1C89 neither to " + + "begin nor to continue an identifier at any language level — " + + "whatever a runtime's Unicode tables postdating 15.1 admit — so no " + + "well-formed file begins with `const ` then U+1C89 (SPEC 14.20: " + + "TypeScript's grammar at release 5.9.3)", + ), + specUnparseableArm( + "r", + "a spread attribute `{...a, b}` — `...` followed by more than one assignment expression (14.20 at its comma; T2.7-3's grammar pair, failing side)", + [ + MASKED_PREAMBLE, + '<S id="s" {...a', + pin(""), + ", b}>\nA spread with extra content.\n</S>\n", + ], + "the offset of the comma: the prefix through `{...a` begins a " + + "well-formed file (`{...a}`), while a spread attribute's braces hold " + + "`...` followed by exactly one assignment expression beside whitespace " + + "and comments alone, so no well-formed file begins with `{...a,` " + + "(SPEC 14.20, 2.7)", + { parserAgrees: false }, + ), + specUnparseableArm( + "s", + "an ESM block holding a statement — an import line followed on the next line, no blank line between, by `const x = 1` (14.20 at the start of the `const` line)", + [ + 'import BAD from "./missing.xspec"', + "\n", + pin(""), + "const x = 1", + "\n\n", + MASKED_PREAMBLE, + ], + "the start of the `const` line: the prefix through the import line's " + + "terminator begins a well-formed file, while the block runs to a blank " + + "line and derives import and export declarations only, so no " + + "well-formed file continues it with `c` (SPEC 14.20)", + { + masked: + "the import designating no discovered spec source (14.15, before " + + 'the failure) and the invalid segment of `id="bad name"` (14.4, ' + + "after it)", + }, + ), + specUnparseableArm( + "t", + 'an import spelled with import attributes, `import BASE from "./BASE.xspec" with { type: "json" }` (14.20 at the offset of `with`)', + [ + 'import BASE from "./BASE.xspec" ', + pin(""), + 'with { type: "json" }', + "\n\n", + MASKED_PREAMBLE, + ], + "the offset of `with`: the prefix through the declaration and its " + + "trailing space begins a well-formed file, while import attributes " + + "are syntax the edition lacks (ECMAScript 2024) and, no line " + + "terminator preceding, nothing else may follow the declaration on " + + "its line (SPEC 14.20)", + { extraFiles: { [BASE_FILE]: BASE_STAGED } }, + ), + specUnparseableArm( + "u", + "`d={]}` — a JavaScript syntax error inside an attribute value's braces (14.20 at the `]`)", + [ + MASKED_PREAMBLE, + '<S id="s" d={', + pin(""), + "]}>\nA syntax error inside braces.\n</S>\n", + ], + "the offset of the `]`: the prefix through `d={` begins a well-formed " + + "file, while no expression begins with `]` (SPEC 14.20)", + ), + specUnparseableArm( + "v", + "`{text(}` — a JavaScript syntax error inside an expression container (14.20 at its `}`)", + [MASKED_PREAMBLE, "{text(", pin(""), "}\n"], + "the offset of the `}`: the prefix through `{text(` begins a " + + 'well-formed file (`{text("a")}`), while no argument list continues ' + + "with `}` (SPEC 14.20)", + ), + specUnparseableArm( + "w", + "an unbalanced brace — `{text(\"a\")` as the file's last bytes (14.20 at the file's byte length)", + [MASKED_PREAMBLE, '{text("a")', pin("")], + "the file's byte length: the whole file is a prefix of a well-formed " + + "one, its bytes ending before the container's closing brace (SPEC " + + "14.20, 14: the offset is never past the file's length)", + ), +]; + +/** + * The workspace one negative arm stages: every file a staged-source record + * carrying its own S-9 declaration — the failing file, a spec source or a + * code source by the arm's kind, declared unparseable — so the workspace + * declares nothing beside them. + */ +function unparseableDecl(arm: UnparseableArm): WorkspaceDecl { + return { files: arm.files }; +} + +/** + * Exactly one finding — 14.20, `path` null, its one location the + * zero-length range at the arm's offset in the arm's file — and nothing + * beside it: the staged would-be condition inside the file is masked. + */ +function assertUnparseableExactly( + findings: readonly Finding[], + arm: UnparseableArm, + context: string, +): void { + assertConditionCounts( + findings, + { "14.20": 1 }, + `${context} — exactly one finding, condition 14.20: the file is not ` + + `well-formed, and an unparseable file masks every condition inside ` + + `it, so ${arm.masked} is never reported (SPEC 14.20, 14; T14-3)`, + ); + const finding = findings[0]!; + if (finding.path !== null) { + fail( + `${context}: an unparseable source locates in the file — \`path\` is ` + + `null (SPEC 12.7, 14); got ${JSON.stringify(finding.path)}`, + ); + } + assertSameJson( + projectFinding(finding), + { + condition: "14.20", + locations: [ + { file: arm.file, range: { start: arm.offset, end: arm.offset } }, + ], + }, + `${context}: the one zero-length range at the failure's offset — ` + + `${arm.rule} — never a non-empty range, a character index, or a ` + + `line/column pair (SPEC 14, 14.20, 1.7)`, + ); +} + +/** + * One negative arm: `build --json` and `check --json` each exit 1 with + * exactly the pinned 14.20 finding — the never-built workspace holds no + * record, so 14.10 has nothing to report beside it (SPEC 14.10, 12.2); the + * surfaces of 11.2 are T14-4's rows over the same staging. + */ +async function runUnparseableArm( + product: ProductBinding, + arm: UnparseableArm, +): Promise<void> { + const context = `T14-12 (${arm.arm}) ${arm.name}`; + await withWorkspace(unparseableDecl(arm), async (workspace) => { + for (const [what, argv] of [ + ["`build --json`", ["build", "--json"]], + ["`check --json`", ["check", "--json"]], + ] as const) { + assertUnparseableExactly( + await runFindingsReport( + product, + workspace, + argv, + 1, + `${context} — ${what} exits 1 with the findings report: an ` + + `unparseable source is a finding (SPEC 14.20, 12.0, 12.7)`, + ), + arm, + `${context} — ${what}`, + ); + } + }); +} + +// --------------------------------------------------------------------------- +// T14-12 +// --------------------------------------------------------------------------- + +const T14_12 = defineProductTest({ + id: "T14-12", + title: + "well-formedness is decided by derivability alone (SPEC 14.20) — positive arms, each file well-formed and proceeding to its ordinary outcome, never 14.20: in a spec source, ECMAScript's early errors — two imports binding one identifier in one ESM block (14.15), `export { nope }` after a valid, used import (exactly one 14.16 at the statement whole, no 14.15; the import listed by `view` with its resolved target, the statement getting no view entry, the `{text(BASE.a)}` embedding recorded by `occurrences --file`, the finding accompanying each answer, exit 1), `{1 = 2}`, `{let}`, `{010}` (14.16 each) — and the expression grammar — `{a, b}` (14.16), `d={BASE.a, BASE.b}` (14.8 located whole), `{await x}`, `{function(){}}` (14.16 each), `{...(a, b)}` (14.17 at the whole braced construct), `export const x = <b/>` (14.16 at the statement whole) — and the Unicode pin, U+2EBF0 judged as one Unicode 15.1 code point — `{` U+2EBF0 `}` (14.16 at the container), the element `<a` U+2EBF0 ` />` (14.16 at its own tag), and a section attribute named `a` then U+2EBF0 (14.17 at that attribute); in a code-group file, TypeScript's post-parse checks — a rest parameter that is not last, a misplaced `abstract` modifier, a duplicate `let` declaration, a type error — each leaving the file well-formed: `build` and `check` exit 0, a marker inside one of the file's units attributed to it and its `references` edge recorded; the release pin — `{ using x = f(); }`, `async function g() { await using y = h(); }`, and an import spelled with import attributes in a code source — the language level — `const` U+2EBF0 `= 1` beside the marker `S.` U+2EBF0 inside one unit, its edge to the section U+2EBF0 recorded, and a configuration importing `defineConfig as` U+2EBF0 (`build` exit 0, `check` clean, the configuration in effect) — and the code source's whitespace — `const` U+200B `a = 1` and `const` U+0085 `b = 1` — each well-formed under TypeScript 5.9.3 at ESNext: `build` and `check` exit 0, the marker's `references` edge recorded; negative arms, each file unparseable — 14.20, the one zero-length range at the offset the rule of 14 fixes, reported by `build` and by `check` and masking every condition inside the file: `010` and `09` in a `.ts` file at the literal's second digit, `const` U+1C89 `x = 1` in a `.ts` file at offset 6 (U+1C89's first byte), a spread attribute `{...a, b}` at its comma, an ESM block holding a `const` statement at the start of its line, import attributes at `with`, `d={]}` at the `]`, `{text(}` at its `}`, and an unbalanced `{text(\"a\")` ending the file at the file's byte length (the surfaces of 11.2 over the same stagings: T14-4's rows) (SPEC 14.20, 14, 1.4, 1.7, 2.1, 2.7, 4.5, 4.6, 5.7, 7, 11.2, 11.3, 11.4)", + run: async (product) => { + await runDuplicateBindingArm(product); + await runExportNopeArm(product); + for (const arm of SPEC_FORM_ARMS) { + await runSpecFormArm(product, arm); + } + for (const arm of CODE_FORM_ARMS) { + await runCodeFormArm(product, arm); + } + await runAliasedConfigArm(product); + for (const arm of T14_12_UNPARSEABLE_ARMS) { + await runUnparseableArm(product, arm); + } + }, +}); + +/** + * Every MDX source T14-12's positive arms stage, with the S-9 allowances + * the staging names (empty: derives plainly) — for the S-9 self-test, which + * judges each without the product (the S-7 sweep reaches only a body's + * first staging). `[name, source, allowances]`, uniquely named. + */ +export const T14_12_FORM_VECTORS: readonly (readonly [ + string, + string, + readonly MdxAllowance[], +])[] = [ + ["T14-12 the BASE module", BASE_MDX, []], + [ + "T14-12 (a) two imports binding one identifier in one ESM block", + DUP_BINDING_FIXTURE.text, + ["duplicate-import-binding"], + ], + ...Object.entries(DUP_BINDING_FILES).map( + ([file, source]): readonly [string, string, readonly MdxAllowance[]] => [ + `T14-12 (a) the imported module ${file}`, + source, + [], + ], + ), + [ + "T14-12 (b) `export { nope }` after a valid, used import", + NOPE_FIXTURE.text, + ["undefined-export"], + ], + ...SPEC_FORM_ARMS.map( + (arm): readonly [string, string, readonly MdxAllowance[]] => [ + `T14-12 (${arm.arm}) ${arm.name}`, + arm.fixture.text, + arm.allowances ?? [], + ], + ), + ["T14-12 the code arms' spec source", A_MDX, []], + [ + "T14-12 (aa) the language-level arm's spec source, one section U+2EBF0", + S_MDX, + [], + ], +]; + +/** + * Every code source and configuration file T14-12's positive arms stage — + * the post-parse arms (l)–(o), the release pin (x)–(z), the language level + * (aa) and (ab)'s configuration, the whitespace arm (ad) — each declared + * well-formed (S-9), for the S-9 TypeScript self-test, which judges each + * accepted by TypeScript 5.9.3 both as module code and as script code + * without the product. `[name, file name, source]`, uniquely named. + */ +export const T14_12_CODE_FORM_VECTORS: readonly (readonly [ + string, + string, + string, +])[] = [ + ...CODE_FORM_ARMS.map((arm): readonly [string, string, string] => [ + `T14-12 (${arm.arm}) ${arm.name}`, + CODE_FILE, + arm.text, + ]), + [ + "T14-12 (ab) a configuration importing `defineConfig as` U+2EBF0", + "xspec.config.ts", + ALIASED_IDEOGRAPH_CONFIG_TEXT, + ], +]; + +/** + * Every MDX source T14-12's negative arms stage — each declared unparseable + * (S-9) — with the pinned byte offset where the stock parser's rejection + * position coincides with the rule's (`null` where it does not: the spread + * arm), for the S-9 self-test's confirmation. `[name, source, offset]`, + * uniquely named. + */ +export const T14_12_UNPARSEABLE_VECTORS: readonly (readonly [ + string, + string, + number | null, +])[] = T14_12_UNPARSEABLE_ARMS.filter((arm) => arm.kind === "spec-source").map( + (arm): readonly [string, string, number | null] => [ + `T14-12 (${arm.arm}) ${arm.name}`, + arm.source, + arm.parserAgrees ? arm.offset : null, + ], +); + +/** + * One T14-12 staging for T14-4's reporter matrix: its condition, label, + * workspace, and the surfaces whose domain can hold its staged file (SPEC + * 11.2) — all three for a spec source, `occurrences` alone for a code + * source. + */ +export interface ReporterStaging { + readonly condition: "14.16" | "14.20"; + readonly label: string; + readonly decl: WorkspaceDecl; + readonly answers: + | { readonly kind: "spec-source"; readonly file: string } + | { readonly kind: "code-source" }; +} + +/** + * The 14.16 and 14.20 arms of T14-12 (TEST-SPEC T14-4: "among the matrix's + * stagings … the 14.16 and 14.20 arms of … T14-12 (all three surfaces for + * their spec-source stagings)"): (b)'s `export { nope }`, the condition-16 + * containers, export statement, and element of (c)–(f), (h), (i), (k), + * (ae), and (af), and every negative arm, (ac) included — the same + * stagings the arms above drive, so T14-4 sweeps exactly what T14-12 pins. + */ +export const T14_12_REPORTER_STAGINGS: readonly ReporterStaging[] = [ + { + condition: "14.16", + label: + "T14-12 (b) `export { nope }` after a valid, used import — an early error, 14.16 in a well-formed file", + decl: NOPE_DECL, + answers: { kind: "spec-source", file: ARM_FILE }, + }, + ...SPEC_FORM_ARMS.filter(isSweptSpecForm).map((arm): ReporterStaging => ({ + condition: "14.16", + label: `T14-12 (${arm.arm}) ${arm.name}`, + decl: specFormDecl(arm), + answers: { kind: "spec-source", file: ARM_FILE }, + })), + ...T14_12_UNPARSEABLE_ARMS.map((arm): ReporterStaging => ({ + condition: "14.20", + label: `T14-12 (${arm.arm}) ${arm.name}`, + decl: unparseableDecl(arm), + answers: + arm.kind === "spec-source" + ? { kind: "spec-source", file: arm.file } + : { kind: "code-source" }, + })), +]; + +export const section14iiiTests: readonly ProductTestEntry[] = [T14_12]; diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index bc4979a6..a808be44 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -1,13 +1,26 @@ // TEST-SPEC §14 (validation errors: the reporting contract) — SUITE-49: -// T14-1 … T14-5. +// T14-1 … T14-8 and T14-11 (T14-9 and T14-10, the Linux-leg 14.24/14.25 +// tests, live in section-14-ii.ts). // // 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), +// conditions 14.5/14.6/14.7 plus the consumer-side type error, and the +// escape-spelled forms read verbatim (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) — and the per-condition range contract: each located condition's +// range byte-exact per SPEC 14's rule, against offsets precomputed from the +// staged bytes (T14-11). // // 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), @@ -31,12 +44,19 @@ // fixes presence, not the point — where a parser gives up inside an // unparseable file is parser-specific — so the arms assert the finding // names the file and carries a location, never a window. -// - check-side condition counts set 14.10 staleness findings aside (the -// T12.2-2/T13.4-6 rationale: whether prior or missing derived state is -// detectably stale when the staged defect makes current generation -// uncomputable is not settled by SPEC 13.3/14); the staged conditions are -// counted exactly over the non-14.10 findings. `build`-side counts are -// exact — `build` cannot observe 14.10 (SPEC 14.10, 12.1). +// - check-side condition counts are exact, 14.10 included, like the +// `build`-side counts (`build` cannot observe 14.10, SPEC 14.10, 12.1): +// on a workspace failing `build`'s validations `check` reports 14.10 in +// its two whatever-validity forms alone — the unreadable-record unit +// form (14.23) and the recorded-file form — the mismatch forms, per file +// and graph data, being undetectable and unreported there (SPEC 14.10, +// 13.3). Judged per staging, neither whatever-validity form exists: +// T14-1's, T14-3's, and the sweep's never-built workspaces hold no +// record (a failing `build` writes nothing, 12.1), and the pre-built +// stagings — the sweep's journal entry, T14-4's failing-workspace 14.21 +// row — leave the record readable with every recorded path still +// generated. So a product reporting phantom staleness beside the staged +// conditions fails. // - T14-4's 14.14 row: the 14.14 entry routes it through every command "as a // usage error (12.0), not a finding", so its both-reporters assertion is // the exit-2 contract (empty stdout under `--json`, stderr naming the @@ -51,39 +71,348 @@ // - 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). 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-4 also sweeps T14-12's 14.16 and 14.20 stagings (section-14-iii.ts's +// exported `T14_12_REPORTER_STAGINGS`: `export { nope }` and the +// early-error containers under their S-9 allowances, the six unparseable +// spec sources' records declared unparseable, the two unparseable `.ts` +// sources) as further sweep entries — reporter membership by exact +// counts, the pinned offsets being T14-12's own subject; the `.ts` +// entries are code-source rows (`occurrences` alone). +// - 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), save the link-and-target compare of +// T6.5-4's symbolic-link arms, T14-7's own clause (the re-descent arms, +// below). "Locating every colliding bearer" +// is asserted every-participant strict (support.ts +// assertFindingLocatesExactly, honoring a case's declared complete +// bearer set, `locatedAtEach`): T6.4-3's exported two-bearer +// prefix-replacement arm — rename `a`→`b` over `a`/`a.c` beside +// `b`/`b.c` — is one `refused-id-collision` finding whose location set +// is exactly `b` then `b.c` (12.7's within-finding order, the enclosing +// bearer's location start-bounded before its child's construct, path +// null), a product locating the first alone failing; T14-7's own +// collision arms declare their one remaining bearer the same way — the +// section move's occupant `keep.mv`, the control rename's `a.sib` — +// exactly one location, none beside. The home tables' cycle locations +// stay SOME-quantified (support.ts assertFindingMentionsLocation): "the +// would-be cycle's full path" is asserted as the dependency cycle's +// participating `d` spelling (T6.5-4's 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); T14-7's own spec +// import cycle — `B` imports `A`, and `user` in `A` references the +// moved `x` in local form, its rewrite adding `B`'s import to `A` — +// declares the complete participant set every-participant strict +// (`locatedAtEach`): `B`'s existing import declaration by its own +// characters and that local reference's spelling (5.7), never a range +// for the import that does not yet exist. The remaining +// every-participant cardinality contract is T14-8's subject. +// SPEC 14 lists exactly eleven refusal reasons — the two it lists +// last, refused-invalid-rewrite and refused-moved-import, are +// T6.5-16's and T6.5-17's subjects, and refused-exposed-derived-file, +// listed just before them, is T6.5-21's — and no +// unresolvable-reference reason exists +// (its retired code is unknown to the form-exact decode, S-5), so no +// arm stages one. The +// exact self-move's modifies-nothing and journal discipline are staged +// at its home (T6.5-6); T14-7 asserts the identity-unchanged rename and +// the exact self-move of either form for their `identities` — the +// unchanged identity as the sole element, the bare `<new-file>` for the +// file form (its entry) — every identity-pinned reason's `identities` +// being asserted exact (support.ts assertRefusalIdentities). +// 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 +// destination spellings `./a.mdx` for the origin `a.mdx`, `specs//b.mdx`, +// and `specs/../specs/b.mdx`, in the file form and as a section form's +// target path — each naming an occupied path were it normalized, refused +// refused-invalid-destination alone concerning the path as spelled, +// occupancy being judged in discovered-path form alone (14, 6.5, 12.0); +// the spec import cycle's complete located participant set (above); 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. No +// report carries a code outside 14's list: the form-exact decode admits +// only 14's codes (forms.ts, S-5), so an unlisted code fails as an H-3 +// form failure before any count, and the exact multiset excludes every +// listed code beside the staged reasons — asserted on every arm. +// The refined arms (TEST-SPEC T14-7's closing clauses): refused-invalid- +// rewrite and refused-moved-import are re-asserted over T6.5-16's and +// T6.5-17's exported arm tables (the home-tables note above T14-7's +// registration) — staged under the home configuration, the entry's exact +// ranges as the complete bearer set (path null with it), `identities` +// the entry's, every beside reason's concern derived from the operands; +// identities over invalid paths — `specs/new.txt#x y` and +// `specs/new.txt#p` beside refused-invalid-destination, each a plain +// string over the path as spelled defining no node (`query node` on it +// exit 2, 12.0's unknown identity); and the spec import cycle's sibling +// arm — the chain `d={C.foo}` carried into `B` while `C` imports `B` — +// locating `C`'s existing import and the chain's spelling in `A`, the +// located set the spellings rooted at the added binding whether or not +// their characters change (14, 6.5, 5.7, 1.5, 12.0). +// The re-descent arms (TEST-SPEC T14-7's refused-invalid-destination and +// refused-exposed-derived-file clauses): T6.5-4's symbolic-link arms — a +// link to a directory at a component of the destination path and of a +// created target file's path (the shared table's inside-root entries, +// `MOVE_LINK_INSIDE_CASES`, and the exported outside-root staging) and +// of the `outDir` emit destination (the derived-path arm's exported link +// sibling) — each refused-invalid-destination alone, never 14.22, inside +// a compare of the link and its target (`assertLinkAndTargetUnchanged`: +// the root narrowed to the link entry and an inside target's tree, an +// outside target compared on its own) — the one modifies-nothing compare +// T14-7 owns, the link and its target byte-identical after each refusal +// being T14-7's own clause; T6.5-4's barred path characters through +// `MOVE_REFUSAL_CASES`; T6.5-20's derived-path relations and +// module-linking designation over its exported table +// (`d20RefusedStagings`, `runD20RefusedStaging`), each move exactly one +// refused-invalid-destination finding concerning the destination as +// spelled; and refused-exposed-derived-file over T6.5-21's +// (`D21_REFUSED_STAGINGS`, `runD21RefusedStaging`) — `path` the +// origin's emit destination, `identities` `[]`, the two-reason move's +// refused-invalid-destination beside it. Every expectation stating a +// `path` also asserts `locations` `[]`: a reason concerning a path +// carries it as the finding's `path` with `locations` `[]` (14, 12.7). +// - 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 cycle mentions-location — its collision arms are +// every-participant strict, above): 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. +// - T14-11 pins ranges byte-EXACT, no end-widening: every offset is the +// UTF-8 byte length of the staged text before the pinned construct +// (`assemble`/`pin`), fixtures carrying a multibyte character before it so +// a character-indexed or line/column report fails; per arm the condition +// multiset is exact and each condition's complete location lists are +// compared as a multiset (order among same-condition findings is 12.7's +// ordering contract, T12.7-*'s subject). TS declaration forms are staged +// without `;` so "own characters" is unambiguous, while the dynamic +// `import()`, the marker, the `text(...)` call, and the optional-chain +// statement carry a `;` the range must exclude. The 14.20 arms pin the +// offsets SPEC 14 fixes — 0 for a byte-order mark and a refused read, the +// first undecodable byte, the longest well-formed-prefix length — where +// T14-3/T14-5 assert presence alone; the refused-read arm is a +// permission staging (E-1), Linux leg, run last. `d={}` and +// `d={ /* c */ }` are condition 20 (SPEC 2.7, 14.20: an attribute value +// admits no empty expression), never 14.8 — the one zero-length range at +// the offset of the closing brace, the prefix before `}` beginning some +// well-formed file — and are staged `mdx.unparseable` (the stock grammar +// rejects both, `unexpected-empty-expression`, its position the byte +// after the opening brace: the rule's offset for `d={}` alone). The +// refined `d`-value ranges (p)–(t) follow the occurrence-span rule of +// SPEC 14 (5.7) over a module imported as `BASE`: `(BASE.a)` with its +// parentheses, a comma sequence whole, `BASE.missing` alone past a block +// comment and past U+00A0, U+FEFF, U+1680, and U+3000, a spread entry +// with its `...`, and the elisions of one array literal as one finding +// at the whole literal. The colliding-declaration forms (u) stage +// T4.5-8's shared table (`T4_5_8_FURTHER_LOCATED_FORMS`, section-4.5.ts), +// one code file per form beside its import; the encoding forms (v) stage +// SPEC 14's four ill-formed byte sequences as exact bytes, a spec and a +// code source each, pinning the first ill-formed byte's offset +// zero-length — never the byte at which a decoder notices, never a +// one-byte range. The module-linking forms (x) stage the 14.15 forms +// beyond (j)'s, one code file each: `export import X = require(…)` from +// `import`, two import types from `import` through the closing +// parenthesis of the argument list (a `typeof` before and a qualifier or +// type arguments after excluded), and a string-named module declaration +// whole and, past a leading `export`, from `declare`. import { Buffer } from "node:buffer"; +import * as path from "node:path"; import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; import { + CONDITION_CODE_TOKENS, assertReportMentions, + corruptGraphDataShapeBlind, + decodeAtReport, decodeEdgesReport, decodeFindingsReport, + decodeInventoryFindings, + decodeOccurrencesReport, + decodePreviewReport, + decodeViewFilesReport, + renderPathValue, } from "../../helpers/adapters/index.js"; import { assertExitCode, fail, parseJsonStdout, } from "../../helpers/assertions.js"; +import { + stageReadRefusalOfDirectory, + stageReadRefusalOfFile, + stageWriteRefusalUnder, +} from "../../helpers/permissions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { StagedMdx, stagedMdx } from "../../helpers/staged-mdx.js"; +import { StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertCompileErrorAt, + assertNoCompileErrors, ConsumerProject, } from "../../helpers/tooling.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + 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 { SameScopeDeclarationArm } from "./section-4.5.js"; +import { T4_5_8_FURTHER_LOCATED_FORMS } from "./section-4.5.js"; +import { T2_3_3_UNPARSEABLE_STAGING } from "./section-2.2-2.3.js"; +import { T2_4_2_UNPARSEABLE_STAGINGS } from "./section-2.4.js"; +import { + T2_7_3_SPREAD_UNPARSEABLE_STAGING, + T2_7_4_UNPARSEABLE_STAGINGS, +} from "./section-2.7.js"; +import type { UnparseableArm } from "./section-14-iii.js"; +import { + T14_12_REPORTER_STAGINGS, + T14_12_UNPARSEABLE_ARMS, +} from "./section-14-iii.js"; +import type { R16Location, R16RefusedArm } from "./section-6.5-iii.js"; +import { + A13_FOURTH_STAGED, + M17_REFUSED_ARMS, + R16_CONFIG, + R16_REFUSED_ARMS, +} from "./section-6.5-iii.js"; +import type { RefusalExpectation } from "./section-6.5.js"; +import { + MOVE_DERIVED_LINK_CASE, + MOVE_DERIVED_LINK_COMPONENT, + MOVE_DERIVED_LINK_FILES, + MOVE_DERIVED_PATH_CASE, + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + MOVE_LINK_COMPONENT, + MOVE_LINK_INSIDE_CASES, + MOVE_LINK_OUTSIDE_CASES, + MOVE_LINK_OUTSIDE_FILES, + MOVE_REFUSAL_CASES, + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, + stageMoveDerivedLinkComponent, + stageMoveLinkOutsideComponent, + stageMoveRefusalOccupants, + V4_SOLO_SOURCE, +} from "./section-6.5.js"; +import { + D21_REFUSED_STAGINGS, + d20RefusedStagings, + runD20RefusedStaging, + runD21RefusedStaging, +} from "./section-6.5-iv.js"; +import { + POLICY_HI_SOURCE, + POLICY_LO_SOURCE, + VALID_A1_SOURCE, +} from "./section-12.1-12.2.js"; +import { SELF_DEPENDS_STAGED } from "./section-5.1-5.3.js"; +import type { + BearerLocationExpectation, + UnparseableStaging, +} from "./support.js"; import { assertConditionCounts, assertEdgeSetEqual, + assertFindingConcernsPath, assertFindingLocated, + assertFindingLocatesExactly, + assertFindingMentionsLocation, + assertRefusalIdentities, assertSameJson, buildFindings, buildOk, byteWindow, expectConfigurationError, + expectErrorDocument, expectExit, + findingsInSourceOrder, runCli, runJson, } from "./support.js"; @@ -92,20 +421,31 @@ import { // Shared fixture material and helpers // --------------------------------------------------------------------------- -// Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): exactly one spec group. A +// staged-source record (S-9; helpers/staged-ts.ts): most workspaces staging +// it follow their body's first product invocation, so it is judged before +// any product exists; the other stagings pass the record too. +const SPECS_ONLY_CONFIG = stagedTs( + "T14-4/T14-6/T14-7/T14-8/T14-11 xspec.config.ts (one spec group, specs/**/*.mdx)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // 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, 4.5). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// usage (4, 4.5). A staged-source record (S-9), as SPECS_ONLY_CONFIG is: +// T14-4's and T14-6's code-source sweep entries and T14-11's code arms stage +// it after their bodies' first invocations; T14-1's, T14-2's, and T14-3's +// first workspaces pass the record too. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T14-4/T14-6/T14-11 xspec.config.ts (one spec group and the code group app, src/**/*.ts)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -115,7 +455,27 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); + +// One spec group plus an unknown top-level key (SPEC 7, 14.14) — the +// canonical valid configuration with exactly one defect, so the error is +// attributable to it (the T7-2 attribution discipline): T14-3's +// configuration-error arm and the 14.14 staging T14-4's and T14-6's arms +// share (BOGUS_KEY_DECL), each workspace following its body's first +// invocation — a staged-source record (S-9). +const BOGUS_KEY_CONFIG = stagedTs( + "T14-3/T14-4/T14-6 xspec.config.ts (one spec group and the unknown top-level key bogus, 14.14)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`, +); /** One spec group over `specs/`, Markdown emission on or off (SPEC 7, 7.3). */ function markdownConfig(emit: boolean): string { @@ -130,6 +490,16 @@ export default defineConfig({ `; } +/** + * `markdownConfig(true)` as a staged-source record (S-9): the stale + * workspace's configuration (STALE_DECL), which T14-6 stages after its + * body's first invocation (T14-4's first workspace passes it too). + */ +const MARKDOWN_EMIT_CONFIG = stagedTs( + "T14-6 xspec.config.ts (one spec group, Markdown emission on: the stale workspace, 14.10)", + markdownConfig(true), +); + /** Stage a fresh workspace with the given entries, run `body`, dispose (H-1). */ async function withWorkspace<T>( decl: WorkspaceDecl, @@ -165,11 +535,21 @@ async function checkFindings( } /** - * The check-side tolerance of the module header: 14.10 staleness findings - * against staged corruption are set aside, everything else is counted. + * 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). */ -function nonStale(findings: readonly Finding[]): readonly Finding[] { - return findings.filter((finding) => finding.condition !== "14.10"); +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); } /** @@ -252,7 +632,7 @@ const T14_1_FOUR_CONSTRUCT = "<div>Not a section.</div>"; const T14_1_FIVE_PREFIX = 'import OK from "../specs/ok.xspec";\n\n'; const T14_1_FIVE_CONSTRUCT = "OK.absent;"; -const T14_1_FILES: Readonly<Record<string, string>> = { +const T14_1_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": SPEC_AND_CODE_CONFIG, "specs/ok.mdx": '<S id="present">\nA resolvable target.\n</S>\n', "specs/one.mdx": `${T14_1_ONE_PREFIX}${T14_1_ONE_CONSTRUCT}\n`, @@ -350,10 +730,10 @@ const T14_1 = defineProductTest({ buildContext, ); const checkContext = - "T14-1 `check --json` over the same workspace (the non-14.10 " + - "findings; see the module header)"; + "T14-1 `check --json` over the same workspace (counted exactly, " + + "14.10 included: never built, so no record; see the module header)"; assertCompleteReport( - nonStale(await checkFindings(product, workspace, checkContext)), + await checkFindings(product, workspace, checkContext), checkContext, ); }); @@ -366,10 +746,23 @@ const T14_1 = defineProductTest({ // Initial, fully valid staging: `build` succeeds, so a prior valid // generation of specs/base.xspec.ts exists — the state the type-error facet -// is asserted in (a later failing `build` modifies nothing, SPEC 12.1). -const T14_2_INITIAL_FILES: Readonly<Record<string, string>> = { +// is asserted in (a later failing `build` modifies nothing, SPEC 12.1). The +// base module holds `login` beside `b1`: the node the escape-spelled marker +// below would name if its spelling were interpreted (SPEC 2.4), and the +// property TypeScript reads that marker as (4.5) — so the prior valid +// generation exports it and the marker is no type error. +const T14_2_INITIAL_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": SPEC_AND_CODE_CONFIG, - "specs/base.mdx": '<S id="b1">\nBase behavior.\n</S>\n', + "specs/base.mdx": [ + '<S id="b1">', + "Base behavior.", + "</S>", + "", + '<S id="login">', + "The node an interpreted escape spelling would name.", + "</S>", + "", + ].join("\n"), "specs/ref.mdx": [ 'import BASE from "./base.xspec"', "", @@ -395,15 +788,68 @@ const T14_2_REF_MID = "\n\n"; const T14_2_REF_TEXT_CONSTRUCT = '<S id="r2">\nUnknown text target:\n\n{text("notext")}\n</S>'; +// The escape spelling: the ten characters `lo\u0067in` as they stand in the +// source (the doubled backslash keeps the `\` in the harness's own string). +// Read verbatim (SPEC 2.4), the literal's value contains `\` and names no +// identity (1.4); interpreted, it would spell `login`. +const T14_2_ESCAPED_LOGIN = "lo\\u0067in"; + +// The escape-spelled local `d` reference after the node `login` its +// interpreted value would name (T2.4-5's premise): a product resolving the +// interpreted spelling reports nothing for it and fails the 14.5 count. +const T14_2_REF_LOGIN_CONSTRUCT = + '<S id="login">\nThe local node an interpreted escape spelling would name.\n</S>'; +const T14_2_REF_ESCAPED_CONSTRUCT = + `<S id="r3" d={"${T14_2_ESCAPED_LOGIN}"}>\n` + + "Escape-spelled local dependency.\n</S>"; + const T14_2_APP_PREFIX = 'import BASE, { text } from "../specs/base.xspec";\n\n'; const T14_2_APP_MARKER = "BASE.nomark;"; const T14_2_APP_CALL = "text(BASE.nocall);"; +// A second consumer file: the escape-free control `BASE.login` (resolving, +// no finding) followed by the escape-spelled marker `BASE.lo\u0067in` — a +// chain segment carrying a Unicode escape spells a name containing `\`, +// which no segment contains (SPEC 2.4), so the marker resolves nowhere +// (14.7). TypeScript reads the escaped identifier as `login`, which the +// generated module exports, so the file type-checks clean: 14.7's type-error +// clause holds only for a spelling free of escape sequences. +const T14_2_ESCAPED_PREFIX = + 'import BASE from "../specs/base.xspec";\n\nBASE.login;\n'; +const T14_2_ESCAPED_MARKER = `BASE.${T14_2_ESCAPED_LOGIN};`; + +// The six unresolved reference forms, staged into specs/ref.mdx after the +// initial build — a staged-source record: judged before any product exists +// (S-9, test/self/s9-staged-sources.test.ts). +const T14_2_REF_BROKEN = stagedMdx( + "T14-2 specs/ref.mdx with every unresolved reference form", + T14_2_REF_PREFIX + + T14_2_REF_D_CONSTRUCT + + T14_2_REF_MID + + T14_2_REF_TEXT_CONSTRUCT + + T14_2_REF_MID + + T14_2_REF_LOGIN_CONSTRUCT + + T14_2_REF_MID + + T14_2_REF_ESCAPED_CONSTRUCT + + "\n", +); + +// The two consumer files staged after the initial build — staged-source +// records (S-9; helpers/staged-ts.ts), judged before any product exists. +const T14_2_APP_BROKEN = stagedTs( + "T14-2 src/app.ts with the unresolved marker and text call", + `${T14_2_APP_PREFIX}${T14_2_APP_MARKER}\n${T14_2_APP_CALL}\n`, +); +const T14_2_ESCAPED_BROKEN = stagedTs( + "T14-2 src/escaped.ts with the escape-free control and the escape-spelled marker", + `${T14_2_ESCAPED_PREFIX}${T14_2_ESCAPED_MARKER}\n`, +); + const T14_2 = defineProductTest({ id: "T14-2", title: - "a `d` reference, a `text(...)` target, and a TypeScript marker and `text` call that do not resolve are 14.5, 14.6, and 14.7 respectively, each locating its reference; the TypeScript case is also a type error against the generated module, asserted while a prior valid generation exists (SPEC 14.5, 14.6, 14.7, 4.1)", + 'a `d` reference, a `text(...)` target, and a TypeScript marker and `text` call that do not resolve are 14.5, 14.6, and 14.7 respectively, each locating its reference; the TypeScript case is also a type error against the generated module, asserted while a prior valid generation exists; escape-spelled forms are unresolved likewise, read verbatim — `d={"lo\\u0067in"}` is 14.5 and the marker `BASE.lo\\u0067in` is 14.7 beside the node `login` their interpreted spellings would name, the marker no type error (SPEC 14.5, 14.6, 14.7, 4.1, 2.4)', run: async (product) => { await withWorkspace({ files: T14_2_INITIAL_FILES }, async (workspace) => { // Prior valid generation: the modules specs/base.xspec.ts and @@ -415,33 +861,29 @@ const T14_2 = defineProductTest({ "exist for the type-error facet, SPEC 12.1, 13.1)", ); - // Break every reference form: external `d`, local `text(...)`, and - // the two TypeScript forms (marker; `text` call). - await workspace.file( - "specs/ref.mdx", - T14_2_REF_PREFIX + - T14_2_REF_D_CONSTRUCT + - T14_2_REF_MID + - T14_2_REF_TEXT_CONSTRUCT + - "\n", - ); - await workspace.file( - "src/app.ts", - `${T14_2_APP_PREFIX}${T14_2_APP_MARKER}\n${T14_2_APP_CALL}\n`, - ); + // Break every reference form: external `d`, local `text(...)`, the + // escape-spelled local `d`, and the TypeScript forms (marker; `text` + // call; escape-spelled marker). + await workspace.file("specs/ref.mdx", T14_2_REF_BROKEN); + await workspace.file("src/app.ts", T14_2_APP_BROKEN); + await workspace.file("src/escaped.ts", T14_2_ESCAPED_BROKEN); - const context = - "T14-2 `build --json` over the four unresolved references"; + const context = "T14-2 `build --json` over the six unresolved references"; const findings = await buildFindings(product, workspace, context); assertConditionCounts( findings, - { "14.5": 1, "14.6": 1, "14.7": 2 }, - `${context} — an unresolved \`d\` reference is 14.5, an unresolved ` + - `\`text(...)\` target is 14.6, and each unresolved TypeScript ` + - `reference (marker; \`text\` call) is 14.7 (SPEC 14.5–14.7)`, + { "14.5": 2, "14.6": 1, "14.7": 3 }, + `${context} — an unresolved \`d\` reference is 14.5 (the external ` + + `\`BASE.nodep\` and the escape-spelled local \`"lo\\u0067in"\`, ` + + `read verbatim, SPEC 2.4), an unresolved \`text(...)\` target is ` + + `14.6, and each unresolved TypeScript reference (marker; \`text\` ` + + `call; the escape-spelled marker \`BASE.lo\\u0067in\`) is 14.7 ` + + `(SPEC 14.5–14.7)`, ); + const [dependencyFinding, escapedDependencyFinding] = + findingsInSourceOrder(findings, "14.5"); assertFindingLocated( - findingOf(findings, "14.5", context), + dependencyFinding!, { file: "specs/ref.mdx", window: byteWindow(T14_2_REF_PREFIX, T14_2_REF_D_CONSTRUCT), @@ -459,12 +901,28 @@ const T14_2 = defineProductTest({ }, `${context}: the 14.6 finding`, ); - const typescriptFindings = findings - .filter((finding) => finding.condition === "14.7") - .slice() - .sort((a, b) => (a.location?.start ?? -1) - (b.location?.start ?? -1)); assertFindingLocated( - typescriptFindings[0]!, + escapedDependencyFinding!, + { + file: "specs/ref.mdx", + window: byteWindow( + T14_2_REF_PREFIX + + T14_2_REF_D_CONSTRUCT + + T14_2_REF_MID + + T14_2_REF_TEXT_CONSTRUCT + + T14_2_REF_MID + + T14_2_REF_LOGIN_CONSTRUCT + + T14_2_REF_MID, + T14_2_REF_ESCAPED_CONSTRUCT, + ), + }, + `${context}: the escape-spelled \`d\` reference's 14.5 finding — ` + + `\`"lo\\u0067in"\` is read verbatim, never as \`login\` (SPEC 2.4)`, + ); + const [markerFinding, callFinding, escapedMarkerFinding] = + findingsInSourceOrder(findings, "14.7"); + assertFindingLocated( + markerFinding!, { file: "src/app.ts", window: byteWindow(T14_2_APP_PREFIX, T14_2_APP_MARKER), @@ -472,7 +930,7 @@ const T14_2 = defineProductTest({ `${context}: the marker's 14.7 finding`, ); assertFindingLocated( - typescriptFindings[1]!, + callFinding!, { file: "src/app.ts", window: byteWindow( @@ -482,6 +940,16 @@ const T14_2 = defineProductTest({ }, `${context}: the \`text\` call's 14.7 finding`, ); + assertFindingLocated( + escapedMarkerFinding!, + { + file: "src/escaped.ts", + window: byteWindow(T14_2_ESCAPED_PREFIX, T14_2_ESCAPED_MARKER), + }, + `${context}: the escape-spelled marker's 14.7 finding — ` + + `\`BASE.lo\\u0067in\` spells a segment containing \`\\\` and ` + + `resolves nowhere (SPEC 2.4), never the node \`login\``, + ); // The type-error facet: the failed build modified nothing (SPEC // 12.1), so the prior valid generation persists — against it, each @@ -510,6 +978,24 @@ const T14_2 = defineProductTest({ "T14-2 the unresolved `text` argument must be a TypeScript type " + "error against the generated module (SPEC 14.7, 4.1)", ); + + // The escape-spelled marker is no type error: 14.7's type-error + // clause holds only for a spelling free of escape sequences (SPEC + // 14.7, 2.4) — TypeScript reads `BASE.lo\u0067in` as `BASE.login`, + // which the prior valid generation exports, so the consumer file + // holding it (and the escape-free control) compiles clean. + const escapedProject = await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: ["src/escaped.ts"], + }); + assertNoCompileErrors( + escapedProject, + "T14-2 the escape-spelled marker `BASE.lo\\u0067in` must be no " + + "type error against the generated module — TypeScript reads the " + + "escaped identifier as `login`, which the prior valid generation " + + "exports; 14.7's type-error clause holds only for a spelling free " + + "of escape sequences (SPEC 14.7, 2.4)", + ); }); }, }); @@ -547,6 +1033,12 @@ const T14_3_BROKEN_TS = [ ].join("\n"); const T14_3_FILES: WorkspaceDecl = { + // S-9: the sources 14.20 declares unparseable — the three MDX sources, and + // the TypeScript one (a TSX-only construct in a `.ts` file). + mdx: { + unparseable: ["specs/brokenmdx.mdx", "specs/badutf8.mdx", "specs/bom.mdx"], + }, + ts: { unparseable: ["src/brokents.ts"] }, files: { "xspec.config.ts": SPEC_AND_CODE_CONFIG, "specs/brokenmdx.mdx": T14_3_BROKEN_MDX, @@ -616,7 +1108,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 +1134,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"), @@ -663,19 +1160,21 @@ function assertMaskingReport( } // The configuration-error arm: an unknown top-level key (SPEC 7, 14.14) -// beside sources that are themselves invalid. -const T14_3_CONFIG_ARM_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" - -export default defineConfig({ - specs: { - main: ["specs/**/*.mdx"] - }, - bogus: true -}) -`, - "specs/invalid.mdx": "<S>\nMissing id (14.1), never analyzed.\n</S>\n", - "specs/broken.mdx": '<S id="x">\nUnclosed element (14.20), never analyzed.\n', +// beside sources that are themselves invalid. The arm's workspace follows +// the body's first invocations, so its configuration and sources are +// staged-source records (S-9, test/self/s9-staged-sources.test.ts), the +// malformed source declared unparseable. +const T14_3_CONFIG_ARM_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": BOGUS_KEY_CONFIG, + "specs/invalid.mdx": stagedMdx( + "T14-3 configuration-error arm specs/invalid.mdx (a missing id, never analyzed)", + "<S>\nMissing id (14.1), never analyzed.\n</S>\n", + ), + "specs/broken.mdx": stagedMdx( + "T14-3 configuration-error arm specs/broken.mdx (an unclosed element, never analyzed)", + '<S id="x">\nUnclosed element (14.20), never analyzed.\n', + "unparseable", + ), }; const T14_3 = defineProductTest({ @@ -693,10 +1192,10 @@ const T14_3 = defineProductTest({ buildContext, ); const checkContext = - "T14-3 `check --json` over the same workspace (the non-14.10 " + - "findings; see the module header)"; + "T14-3 `check --json` over the same workspace (counted exactly, " + + "14.10 included: never built, so no record; see the module header)"; assertMaskingReport( - nonStale(await checkFindings(product, workspace, checkContext)), + await checkFindings(product, workspace, checkContext), checkContext, ); }); @@ -748,119 +1247,200 @@ 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. */ -function specArm(condition: string, label: string, source: string): SweepEntry { +/** + * Shorthand: a specs-only workspace whose one source stages the condition — + * a staged-source record carrying its own S-9 declaration, beside the + * configuration's record (every sweep workspace but T14-6's first follows + * its body's first invocation; the table is converted uniformly). + */ +function specArm( + condition: string, + label: string, + source: StagedMdx, +): SweepEntry { return { condition, label, decl: { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/a.mdx": source }, }, + answers: { kind: "spec-source", file: "specs/a.mdx" }, }; } -/** Shorthand: a valid spec plus one code file staging the condition. */ -function codeArm(condition: string, label: string, source: string): SweepEntry { +// The code arms' valid spec source (a staged-source record, S-9). +const CODE_ARM_SPEC_SOURCE = stagedMdx( + "T14-4/T14-6 specs/s.mdx (the code arms' valid spec source n1: the sweep's 14.7 and 14.18 entries)", + '<S id="n1">\nCode-referenced behavior.\n</S>\n', +); + +/** + * Shorthand: a valid spec plus one code file staging the condition — a + * staged-source record (S-9; helpers/staged-ts.ts), as the spec arms' are. + */ +function codeArm( + condition: string, + label: string, + source: StagedTs, +): SweepEntry { return { condition, label, decl: { files: { "xspec.config.ts": SPEC_AND_CODE_CONFIG, - "specs/s.mdx": '<S id="n1">\nCode-referenced behavior.\n</S>\n', + "specs/s.mdx": CODE_ARM_SPEC_SOURCE, "src/app.ts": source, }, }, + answers: { kind: "code-source" }, }; } +// The sources the sweep shares with the dedicated arms below, staged-source +// records (S-9): the minimal valid source a1 — the journal-error and +// symbolic-link entries' spec source and the ground of VALID_SPECS_DECL, +// BOGUS_KEY_DECL, and READ_REFUSAL_DECL — and specs/a.mdx without an id, +// the missing-ID entry's source, which T14-4's 14.21 arm also re-stages +// on its just-rebuilt workspace (the failing side). +const VALID_A1_BEHAVIOR = stagedMdx( + "T14-4/T14-6 specs/a.mdx (the minimal valid source a1: the sweep's journal-error and symbolic-link entries; the 14.21, 14.23, and 14.14 arms' workspaces; T14-6's 14.25 and code-null arms)", + '<S id="a1">\nValid behavior.\n</S>\n', +); +const ID_LESS_A_SOURCE = stagedMdx( + "T14-4/T14-6 specs/a.mdx without an id (the sweep's missing-ID entry, 14.1; re-staged after the build by T14-4's 14.21 arm, its failing workspace)", + "<S>\nNo id.\n</S>\n", +); + const GARBAGE_JOURNAL_LINE = "?? harness-injected garbage: not a journal entry ??\n"; const SWEEP_ENTRIES: readonly SweepEntry[] = [ - specArm("14.1", "missing ID", "<S>\nNo id.\n</S>\n"), + specArm("14.1", "missing ID", ID_LESS_A_SOURCE), specArm( "14.2", "invalid structural ID", - [ - '<S id="p">', - "Parent.", - "", - '<S id="q.r">', - "A child whose ID does not extend the parent's.", - "</S>", - "</S>", - "", - ].join("\n"), - ), - { - ...specArm( - "14.3", - "duplicate ID within a file", + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.2, invalid structural ID)", [ - '<S id="dup">', - "First occurrence.", - "</S>", + '<S id="p">', + "Parent.", "", - '<S id="dup">', - "Second occurrence.", + '<S id="q.r">', + "A child whose ID does not extend the parent's.", + "</S>", "</S>", "", ].join("\n"), ), + ), + { + ...specArm( + "14.3", + "duplicate ID within a file", + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.3, duplicate ID within a file)", + [ + '<S id="dup">', + "First occurrence.", + "</S>", + "", + '<S id="dup">', + "Second occurrence.", + "</S>", + "", + ].join("\n"), + ), + ), perOccurrenceTolerated: true, }, specArm( "14.4", "invalid segment", - '<S id="bad name">\nInvalid segment.\n</S>\n', + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.4, invalid segment)", + '<S id="bad name">\nInvalid segment.\n</S>\n', + ), ), specArm( "14.5", "unknown dependency", - '<S id="a" d={"nope"}>\nUnknown dependency target.\n</S>\n', + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.5, unknown dependency)", + '<S id="a" d={"nope"}>\nUnknown dependency target.\n</S>\n', + ), ), specArm( "14.6", "unknown text target", - '<S id="a">\nBody:\n\n{text("nada")}\n</S>\n', + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.6, unknown text target)", + '<S id="a">\nBody:\n\n{text("nada")}\n</S>\n', + ), ), codeArm( "14.7", "unknown TypeScript reference", - ['import SPEC from "../specs/s.xspec";', "", "SPEC.missing;", ""].join( - "\n", + stagedTs( + "T14-4/T14-6 sweep src/app.ts (14.7, unknown TypeScript reference)", + ['import SPEC from "../specs/s.xspec";', "", "SPEC.missing;", ""].join( + "\n", + ), ), ), specArm( "14.8", "invalid argument", - '<S id="a" d={42}>\nNon-static dependency value.\n</S>\n', - ), - specArm( - "14.9", - "dependency cycle", - '<S id="s" d={"s"}>\nDepends on itself.\n</S>\n', + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.8, invalid argument)", + '<S id="a" d={42}>\nNon-static dependency value.\n</S>\n', + ), ), + specArm("14.9", "dependency cycle", SELF_DEPENDS_STAGED), { condition: "14.11", label: "cross-module text call", decl: { files: { "xspec.config.ts": SPEC_AND_CODE_CONFIG, - "specs/alpha.mdx": '<S id="first">\nAlpha behavior.\n</S>\n', - "specs/bravo.mdx": '<S id="second">\nBravo behavior.\n</S>\n', - "src/app.ts": [ - 'import ALPHA from "../specs/alpha.xspec";', - 'import { text as textB } from "../specs/bravo.xspec";', - "", - "textB(ALPHA.first);", - "", - ].join("\n"), + "specs/alpha.mdx": stagedMdx( + "T14-4/T14-6 sweep specs/alpha.mdx (14.11, cross-module text call)", + '<S id="first">\nAlpha behavior.\n</S>\n', + ), + "specs/bravo.mdx": stagedMdx( + "T14-4/T14-6 sweep specs/bravo.mdx (14.11, cross-module text call)", + '<S id="second">\nBravo behavior.\n</S>\n', + ), + "src/app.ts": stagedTs( + "T14-4/T14-6 sweep src/app.ts (14.11, cross-module text call)", + [ + 'import ALPHA from "../specs/alpha.xspec";', + 'import { text as textB } from "../specs/bravo.xspec";', + "", + "textB(ALPHA.first);", + "", + ].join("\n"), + ), }, }, + answers: { kind: "code-source" }, }, { condition: "14.13", @@ -868,49 +1448,63 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ decl: { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', + "specs/a.mdx": VALID_A1_BEHAVIOR, }, }, prepare: async (product, workspace) => { 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", "invalid import", - [ - 'import X from "./missing.xspec"', - "", - '<S id="a">', - "The import designates no discovered spec source.", - "</S>", - "", - ].join("\n"), + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.15, invalid import)", + [ + 'import X from "./missing.xspec"', + "", + '<S id="a">', + "The import designates no discovered spec source.", + "</S>", + "", + ].join("\n"), + ), ), specArm( "14.16", "invalid construct", - '<S id="a">\nBody.\n</S>\n\n<div>Not a section.</div>\n', + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.16, invalid construct)", + '<S id="a">\nBody.\n</S>\n\n<div>Not a section.</div>\n', + ), ), specArm( "14.17", "invalid prop", - '<S id="a" bogus="1">\nUnknown prop.\n</S>\n', + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.17, invalid prop)", + '<S id="a" bogus="1">\nUnknown prop.\n</S>\n', + ), ), codeArm( "14.18", "unsupported node usage", - [ - 'import SPEC from "../specs/s.xspec";', - "", - "const alias = SPEC.n1;", - "", - ].join("\n"), + stagedTs( + "T14-4/T14-6 sweep src/app.ts (14.18, unsupported node usage)", + [ + 'import SPEC from "../specs/s.xspec";', + "", + "const alias = SPEC.n1;", + "", + ].join("\n"), + ), ), { condition: "14.19", @@ -918,17 +1512,36 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ decl: { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/a#b.mdx": '<S id="a">\nValid content, invalid path.\n</S>\n', + "specs/a#b.mdx": stagedMdx( + "T14-4/T14-6 sweep specs/a#b.mdx (14.19, invalid source path)", + '<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'), + // S-9: the one entry source the document declares unparseable. + specArm( + "14.20", + "unparseable source", + stagedMdx( + "T14-4/T14-6 sweep specs/a.mdx (14.20, unparseable source)", + '<S id="x">\nUnclosed element.\n', + "unparseable", + ), + ), { condition: "14.22", label: "symbolic link in a write path", decl: { files: { - "xspec.config.ts": `import { defineConfig } from "xspec" + "xspec.config.ts": stagedTs( + "T14-4/T14-6 sweep xspec.config.ts (14.22, Markdown emission into outDir out, a symbolic link)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -937,15 +1550,151 @@ export default defineConfig({ markdown: { emit: true, outDir: "out" } }) `, - "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', + ), + "specs/a.mdx": VALID_A1_BEHAVIOR, }, dirs: ["real-out"], symlinks: { out: "real-out" }, }, + answers: { kind: "no-domain-file", file: "specs/a.mdx" }, }, + // The 14.16 and 14.20 arms of T14-12 (TEST-SPEC T14-4: "among the + // matrix's stagings … all three surfaces for their spec-source + // stagings"), staged by their home module — the early-error forms under + // their S-9 allowances, the unparseable spec sources declared so — and + // swept here for reporter membership: `build`, `check`, and the surfaces + // whose domain holds the staged file (`occurrences` alone for the `.ts` + // arms, 14.20 in a code source). + ...T14_12_REPORTER_STAGINGS.map((staging): SweepEntry => ({ + condition: staging.condition, + label: staging.label, + decl: staging.decl, + answers: staging.answers, + })), ]; -/** One command's sweep assertion (build exact; check over non-14.10). */ +// --------------------------------------------------------------------------- +// 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. Every workspace of the decls below +// but T14-4's first follows its body's first invocation, so each `.mdx` +// entry is a staged-source record (S-9) — here T12.2-2's own valid a1 +// source, byte-identical, by import — and so is each configuration (here +// `markdownConfig(true)`'s record). +const STALE_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": MARKDOWN_EMIT_CONFIG, + "specs/a.mdx": VALID_A1_SOURCE, + }, +}; +// The post-build edit of specs/a.mdx is a staged-source record (S-9: judged +// before any product exists by test/self/s9-staged-sources.test.ts, since +// S-7's sweep never reaches a staging that follows a product invocation). +const STALE_EDIT = stagedMdx( + "T14-4/T14-6 specs/a.mdx edited after the build (the stale workspace, 14.10)", + '<S id="a1">\nAlpha behavior, edited.\n</S>\n', +); + +// 14.12 (T7.5-2's fixture): one forbidden rule, one violating dependence — +// its two sources T12.2-2's policy family's records, byte-identical, by +// import. +const POLICY_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": stagedTs( + "T14-4/T14-6 xspec.config.ts (the policy workspace: groups hi and lo, the forbidden rule no-hi-to-lo, 14.12)", + `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": POLICY_HI_SOURCE, + "lo/L.mdx": POLICY_LO_SOURCE, + }, +}; + +// 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": VALID_A1_BEHAVIOR, + }, +}; + +// 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 — the +// configuration BOGUS_KEY_CONFIG, the record T14-3's arm stages too. +const BOGUS_KEY_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": BOGUS_KEY_CONFIG, + "specs/a.mdx": VALID_A1_BEHAVIOR, + }, +}; + +/** + * 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` and `check` alike exact). */ function assertSweepFindings( findings: readonly Finding[], entry: SweepEntry, @@ -973,213 +1722,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)", + // 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 (POLICY_DECL). + await withWorkspace(POLICY_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.12) `build` over the policy-violating workspace — " + + "policy violations are `check` findings, and `build` succeeds " + + "and regenerates regardless (SPEC 14.12, 12.1, 7.5)", + ); + assertConditionCounts( + await checkFindings(product, workspace, "T14-4 (14.12) `check --json`"), + { "14.12": 1 }, + "T14-4 (14.12) `check` reports the one violating edge — the " + + "freshly built workspace stages nothing else (SPEC 14.12, 12.2)", + ); + }); + + // --- 14.21: reported by `check`, by `review` subcommands naming the + // session, and by `review list` — not by `build` (VALID_SPECS_DECL plus + // the garbage session file). + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.21) staging `build` (SPEC 12.1)", + ); + await workspace.file(GARBAGE_SESSION_PATH, GARBAGE_SESSION_CONTENT); + await expectExit( + product, + workspace, + ["build"], + 0, + "T14-4 (14.21) `build` beside the corrupt session — `build` does " + + "not read sessions, so 14.21 is not its finding (SPEC 14.21)", + ); + assertConditionCounts( + await checkFindings(product, workspace, "T14-4 (14.21) `check --json`"), + { "14.21": 1 }, + "T14-4 (14.21) `check` reports the one corrupt session — the " + + "just-rebuilt workspace stages nothing else (SPEC 14.21, 12.2)", + ); + for (const argv of [ + ["review", "status", "bad"], + ["review", "list"], + ] as const) { + const context = `T14-4 (14.21) \`${argv.join(" ")}\``; + const result = await runCli(product, workspace, argv); + assertExitCode( + result, + 1, + `${context} — a review subcommand naming a corrupt session, and ` + + `\`review list\` reporting one, exit 1 (SPEC 14.21, 10.1, ` + + `10.7, 12.0)`, ); - await workspace.file( - "specs/a.mdx", - '<S id="a1">\nAlpha behavior, edited.\n</S>\n', + 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)`, ); - 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( + } + + // 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", ID_LESS_A_SOURCE); + assertConditionCounts( + await buildFindings( 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( + "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( + await checkFindings( 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)", + "T14-4 (14.21, failing workspace) `check --json`", + ), + { "14.1": 1, "14.21": 1 }, + "T14-4 (14.21, failing workspace) `check` reports 14.21 beside " + + "the failing workspace's other findings — the validation error " + + "and the corrupt session together, counted exactly: 14.10's " + + "mismatch forms go unreported on the failing workspace, whose " + + "record stays readable with every recorded path still generated " + + "(SPEC 14.21, 12.2, 14.10; module header)", + ); + for (const argv of [ + ["review", "status", "bad", "--json"], + ["review", "list", "--json"], + ] as const) { + const context = `T14-4 (14.21, failing workspace) \`${argv.join(" ")}\``; + const result = await expectExit( + product, + workspace, + argv, + 1, + `${context} — on a workspace failing \`build\`'s validations a ` + + `gated read reports the gate's findings and exits 1 without ` + + `answering (SPEC 13.3, 12.0)`, ); - }, - ); + assertConditionCounts( + decodeFindingsReport(parseJsonStdout(result, context), context) + .findings, + { "14.1": 1 }, + `${context} — exactly the gate's findings: no session file is ` + + `read on a failing workspace, so no condition-21 finding is ` + + `reported beside them — on this workspace 14.21 is \`check\`'s ` + + `alone (SPEC 14.21, 13.3, 10.1; depth: T10.1-5)`, + ); + } + }); - // --- 14.12: check-only. A policy-violating workspace `build`s - // successfully; `check` reports the violation. - await withWorkspace( - { - files: { - "xspec.config.ts": `import { defineConfig } from "xspec" + // --- 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)"); -export default defineConfig({ - specs: { - hi: ["hi/**/*.mdx"], - lo: ["lo/**/*.mdx"] - }, - policy: [ - { - name: "no-hi-to-lo", - type: "forbidden", - from: { group: "hi" }, - to: { group: "lo" } - } - ] -}) -`, - "hi/H.mdx": [ - 'import L from "../lo/L.xspec"', - "", - '<S id="h1" d={L.l1}>', - "Violating dependence.", - "</S>", - "", - ].join("\n"), - "lo/L.mdx": ['<S id="l1">', "Low one.", "</S>", ""].join("\n"), - }, - }, - async (workspace) => { - await buildOk( - product, - workspace, - "T14-4 (14.12) `build` over the policy-violating workspace — " + - "policy violations are `check` findings, and `build` succeeds " + - "and regenerates regardless (SPEC 14.12, 12.1, 7.5)", - ); - assertConditionCounts( - await checkFindings( + const inventoryContext = "T14-4 (14.23) `inventory`"; + assertConditionCounts( + decodeInventoryFindings( + await runJsonExpecting( product, workspace, - "T14-4 (14.12) `check --json`", + ["inventory"], + 1, + `${inventoryContext} — the condition-23 finding accompanies ` + + `the answer and the invocation exits 1 (SPEC 14.23, 11.6)`, ), - { "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)", - ); - }, - ); + 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)`, + ); - // --- 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( - 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( + const previewContext = + "T14-4 (14.23) `rename specs/a.mdx a1 a2 --preview --json`"; + assertConditionCounts( + decodePreviewReport( + await runJsonExpecting( 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, + ["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); @@ -1193,12 +2045,96 @@ export default defineConfig({ ); const checkContext = `T14-4 (${entry.label}) \`check --json\``; assertSweepFindings( - nonStale(await checkFindings(product, workspace, checkContext)), + await checkFindings(product, workspace, checkContext), entry, `${checkContext} — condition ${entry.condition} is a \`check\` ` + - `finding, counted exactly over the non-14.10 findings (see the ` + - `module header; SPEC 14, 12.2)`, + `finding, counted exactly, 14.10 included (see the module ` + + `header; SPEC 14, 12.2, 14.10)`, ); + + 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)`, + ); + } }); } }, @@ -1224,16 +2160,21 @@ const T14_5_UNIT_SOURCE = [ "", ].join("\n"); -const T14_5_SPEC_SOURCE = [ - '<S id="t1">', - "Marker target.", - "</S>", - "", - '<S id="t2">', - "Embedded target.", - "</S>", - "", -].join("\n"); +// Both arms' spec source: the `.mts` arm follows the `.tsx` arm's +// invocations, so it is a staged-source record (S-9), staged by both. +const T14_5_SPEC_SOURCE = stagedMdx( + "T14-5 specs/U.mdx (the .tsx and .mts arms' spec source: the marker target t1 and the embedded target t2)", + [ + '<S id="t1">', + "Marker target.", + "</S>", + "", + '<S id="t2">', + "Embedded target.", + "</S>", + "", + ].join("\n"), +); function codeGroupConfig(glob: string): string { return `import { defineConfig } from "xspec" @@ -1249,6 +2190,22 @@ export default defineConfig({ `; } +// The `.mts` arm's configuration and code file follow the `.tsx` arm's +// invocations, so they are staged-source records (S-9; helpers/staged-ts.ts): +// the configuration `codeGroupConfig("src/**/*.mts")`'s record, and the +// shared unit's bytes declared unparseable under the plain-TypeScript +// grammar the `.mts` name selects (14.20). The `.tsx` arm, the body's first +// workspace, stages the plain constants. +const T14_5_MTS_CONFIG = stagedTs( + "T14-5 xspec.config.ts (one spec group and the code group app over src/**/*.mts)", + codeGroupConfig("src/**/*.mts"), +); +const T14_5_MTS_SOURCE = stagedTs( + "T14-5 src/view.mts (the TSX-only unit under a plain-TypeScript name)", + T14_5_UNIT_SOURCE, + "unparseable", +); + const T14_5 = defineProductTest({ id: "T14-5", title: @@ -1324,9 +2281,12 @@ const T14_5 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": codeGroupConfig("src/**/*.mts"), + "xspec.config.ts": T14_5_MTS_CONFIG, "specs/U.mdx": T14_5_SPEC_SOURCE, - "src/view.mts": T14_5_UNIT_SOURCE, + // S-9: any name but `.tsx` selects plain TypeScript, so the + // TSX-only construct is unparseable here (14.20) — the record's + // declaration. + "src/view.mts": T14_5_MTS_SOURCE, }, }, async (workspace) => { @@ -1350,11 +2310,3602 @@ 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)})`, + ); + } +} + +// The environment refusals of 14.24/14.25 are staged by permission removal +// (E-1), the Linux leg's discipline: elsewhere the arms are not staged (the +// NU3_STAGED pattern of section-11.5); the Windows subset (E-6) selects +// T14-6 nowhere. +const ENVIRONMENT_REFUSALS_STAGED = process.platform === "linux"; + +// 14.25 (T14-10 (g)'s fixture): a directory the discovery of SPEC 7 lists, +// under the glob `specs/**/*.mdx`, holding a valid source — staged unlistable +// with search permission kept (its entry reachable by name; nonexistence is +// never staged as a refusal, 14.25). +const UNLISTABLE_DIR = "specs/sub"; +const READ_REFUSAL_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/a.mdx": VALID_A1_BEHAVIOR, + [`${UNLISTABLE_DIR}/b.mdx`]: stagedMdx( + "T14-6 specs/sub/b.mdx (the 14.25 arm's valid source under the unlistable directory)", + '<S id="b1">\nValid behavior.\n</S>\n', + ), + }, +}; + +const T14_6 = defineProductTest({ + id: "T14-6", + title: + "stable codes: for each of the 25 conditions, staged via its primary test's fixture and read from its stated reporter: conditions 1–23 carry the exact token 14 lists (`missing-id` … `unreadable-record`) as the `code` of their finding in the JSON report form, and conditions 24 and 25 — usage errors, never findings — carry `write-failure` and `read-failure` as the `code` of the exit-2 error document, appearing in no findings array (environment refusals staged by permission removal, Linux leg) — 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, + ); + }); + + // --- 14.24 `write-failure` and 14.25 `read-failure`: usage errors, + // never findings (SPEC 14.24, 14.25, 12.0) — each carries its stable + // code as the exit-2 error document's `code`, in no findings array + // (12.7). Both are environment refusals staged by permission removal + // alone (E-1, Linux leg): the harness verifies each staging on itself + // before the product runs and reports an ineffective one — a privileged + // runner — as a harness error, never a pass or a skip (H-9, H-11). + if (ENVIRONMENT_REFUSALS_STAGED) { + // 14.24, T14-9 (f)'s staging: a stale workspace whose graph-data area + // `.xspec` is unwritable — `build` (14.24's first-listed reporter) + // regenerates the derived files under the writable `specs/` and is + // refused at its graph-data write, whatever its write order: the + // command stops at the refused write and exits 2 (12.0: met only at + // the write it refuses, after every check and validation — the stale + // workspace passes them all). + await withWorkspace(STALE_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-6 (14.24) staging `build` (SPEC 12.1)", + ); + await workspace.file("specs/a.mdx", STALE_EDIT); + const staging = await stageWriteRefusalUnder( + path.join(workspace.root, ".xspec"), + ); + try { + const context = + "T14-6 (14.24) `build --json` with `.xspec` unwritable on the " + + "stale workspace"; + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 2, + `${context} — a write the environment refuses is a usage ` + + `error, exit 2, never a finding (SPEC 14.24, 12.0)`, + ); + assertExactCodeToken( + [expectErrorDocument(result, context)], + 24, + `${context} — the error document's finding`, + ); + } finally { + await staging.restore(); + } + }); + + // 14.25, T14-10 (g)'s staging: a directory discovery lists, staged + // unlistable — every command that loads the configuration exits 2 + // with the error document at the read (14.25: reported by every + // command making the read); `build` is the representative. + await withWorkspace(READ_REFUSAL_DECL, async (workspace) => { + const staging = await stageReadRefusalOfDirectory( + path.join(workspace.root, UNLISTABLE_DIR), + ); + try { + const context = + "T14-6 (14.25) `build --json` with `specs/sub` unlistable"; + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 2, + `${context} — a read the environment refuses is a usage ` + + `error, exit 2, never a finding (SPEC 14.25, 12.0)`, + ); + assertExactCodeToken( + [expectErrorDocument(result, context)], + 25, + `${context} — the error document's finding`, + ); + } finally { + await staging.restore(); + } + }); + } + + // --- `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) + * or, where the case declares its complete bearer set (`locatedAtEach`), + * exactly that set — every colliding bearer, none beside (SPEC 14); a + * reason concerning a path (`path` stated) carries it as the finding's + * `path` with `locations` `[]` (SPEC 14, 12.7). The modifies-nothing + * compares are the home tests' subject (T6.4-3, T6.5-4); the symbolic-link + * arms' link-and-target compare wraps this contract + * (`assertLinkAndTargetUnchanged`). 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). No report carries a code outside 14's list: the + * form-exact decode admits only 14's codes (forms.ts KNOWN_CODE_TOKENS — + * the 25 condition tokens and the eleven refusal reasons), so an unlisted + * code, the retired `refused-unresolvable-reference` included, fails as an + * H-3 form failure before any count, and the exact multiset excludes every + * listed code beside the expected ones. + */ +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.locatedAtEach !== undefined) { + assertFindingLocatesExactly( + finding, + expectation.locatedAtEach, + `${context}: the ${expectation.finding} finding's complete ` + + `located-bearer set — every colliding bearer, none beside`, + ); + } + assertRefusalIdentities( + finding, + expectation.finding, + expectation.identities, + `${context}: the ${expectation.finding} finding's concerned identity`, + ); + if (expectation.path !== undefined) { + assertFindingConcernsPath( + finding, + expectation.path, + `${context}: the ${expectation.finding} finding's concerned path`, + ); + if (finding.locations.length !== 0) { + fail( + `${context}: the ${expectation.finding} finding concerns a path, ` + + `so it carries that path as its \`path\` with \`locations\` [] ` + + `(SPEC 14, 12.7); got ` + + JSON.stringify( + finding.locations.map((location) => ({ + file: renderPathValue(location.file), + range: location.range, + })), + ), + ); + } + } + } +} + +/** + * The link and its target byte-identical around one refusal (TEST-SPEC + * T14-7, over T6.5-4's symbolic-link arms): the workspace root narrowed to + * the link itself — an entry recording its kind and verbatim target bytes, + * never followed (snapshot.ts) — and, where the link resolves inside the + * root, every entry of its target directory, the ancestors of both kept as + * bare directory entries and every other entry pruned; a target outside the + * root is compared on its own around that narrowed compare, since no view + * of the root can see a write landing there (SPEC 6.5, 13.4). + */ +async function assertLinkAndTargetUnchanged( + workspace: TestWorkspace, + link: string, + action: () => Promise<void>, + context: string, +): Promise<void> { + const kind = await workspace.kind(link); + if (kind !== "symlink") { + fail( + `${context}: staging premise — ${link} is the symbolic link T6.5-4's ` + + `link-component staging put there, untouched since by every ` + + `refused operation and by the premise \`build\`, which neither ` + + `traverses nor replaces it (SPEC 6.5: a refused operation modifies ` + + `nothing; 7, 13.4); found ${kind}`, + ); + } + const linkPath = workspace.path(link); + const target = path.resolve( + path.dirname(linkPath), + await workspace.linkTarget(link), + ); + const fromRoot = path.relative(workspace.root, target); + const insideTarget = + fromRoot !== "" && !fromRoot.startsWith("..") && !path.isAbsolute(fromRoot) + ? fromRoot.split(path.sep).join("/") + : null; + const kept = (rel: string): boolean => + rel === link || + link.startsWith(`${rel}/`) || + (insideTarget !== null && + (rel === insideTarget || + insideTarget.startsWith(`${rel}/`) || + rel.startsWith(`${insideTarget}/`))); + const narrowed = (): Promise<void> => + assertLeavesUnchanged( + workspace.root, + action, + `${context}: the symbolic link ${link} and its target stay ` + + `byte-identical — nothing written through or over the link (SPEC ` + + `6.5, 13.4; TEST-SPEC T14-7)`, + { exclude: (bytes) => !kept(Buffer.from(bytes).toString("utf8")) }, + ); + if (insideTarget !== null) { + await narrowed(); + return; + } + await assertLeavesUnchanged( + target, + narrowed, + `${context}: the target directory of the symbolic link ${link}, ` + + `outside the workspace root, stays byte-identical — nothing written ` + + `through the link (SPEC 6.5, 13.4; TEST-SPEC T14-7)`, + ); +} + +// 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, InitialFileContents>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/Src.mdx": V4_SOLO_SOURCE, + [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 staged source (S-9: every T14-7 workspace after the first follows the +// body's first invocations — a staged-source record, made from the string +// the windows below are computed from). +const T14_7_MULTI_STAGED = stagedMdx( + "T14-7 every-applicable-reason arm specs/M.mdx (keep holding the occupant keep.mv; mv depending on keep)", + T14_7_MULTI_SOURCE, +); + +// The remaining colliding bearer's whole construct — the collision's +// complete bearer set (SPEC 14: every colliding bearer; the occupant is the +// one ID remaining after the removal that the new ID collides with, so the +// finding locates it exactly, none beside — the moved section bears `mv`, +// not `keep.mv`) — 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_RENAME_STAGED = stagedMdx( + "T14-7 invalid-workspace arm specs/R.mdx (a holding a.mid and a.sib)", + T14_7_RENAME_SOURCE, +); +const T14_7_BAD_FILE = "specs/Bad.mdx"; +const T14_7_BAD_VALID = stagedMdx( + "T14-7 invalid-workspace arm specs/Bad.mdx (the valid twin: bad, before its unresolved dependency target is staged)", + '<S id="bad">\nBad-file text, valid for the control arm.\n</S>\n', +); +// The invalid twin is staged after the control arm's invocations — a +// staged-source record (S-9, test/self/s9-staged-sources.test.ts), as are +// the arm's initial sources (the workspace follows the body's first +// invocations). +const T14_7_BAD_INVALID = stagedMdx( + "T14-7 specs/Bad.mdx with an unresolved dependency target (the invalid-workspace arm)", + '<S id="bad" d={"nope"}>\nUnresolved dependency target.\n</S>\n', +); + +// The destination-spelling staging (T14-7's own; SPEC 14, 6.5, 12.0): +// occupancy is judged at a path in discovered-path form alone, so a +// destination spelled with a `.`, `..`, or empty segment — `./a.mdx` for the +// origin `a.mdx` (the self-move by spelling), `specs//b.mdx`, and +// `specs/../specs/b.mdx`, each naming an occupied path were it normalized — +// is refused-invalid-destination alone, concerning the path as spelled: +// never refused-destination-exists, never refused-identity-unchanged (the +// exact one-entry multiset excludes both), and never performed (exit 1). The +// root-level origin `a.mdx` needs a root-level glob (SPEC 7.1: any glob list, +// every match `.mdx`). The section form's target path so spelled is refused +// alike; its `<new-id>` `z` collides with nothing anywhere, so no second +// reason could apply even to a product normalizing the spelling. The arm's +// workspace follows the body's first invocations, so its configuration is +// a staged-source record (S-9; helpers/staged-ts.ts). +const T14_7_SPELLING_CONFIG = stagedTs( + "T14-7 destination-spelling arm xspec.config.ts (the root-level glob *.mdx beside specs/**/*.mdx)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["*.mdx", "specs/**/*.mdx"] + } +}) +`, +); +const T14_7_SPELLING_ORIGIN = "a.mdx"; +const T14_7_SPELLING_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": T14_7_SPELLING_CONFIG, + [T14_7_SPELLING_ORIGIN]: stagedMdx( + "T14-7 destination-spelling arm a.mdx (the root-level origin x)", + '<S id="x">\nX text.\n</S>\n', + ), + "specs/b.mdx": A13_FOURTH_STAGED, +}; +const T14_7_SPELLED_DESTINATIONS: readonly string[] = [ + "./a.mdx", + "specs//b.mdx", + "specs/../specs/b.mdx", +]; + +// The spec-import-cycle location staging (T14-7's own; SPEC 14, 6.5, 5.7): +// `B` imports `A` (the declaration `b`'s external reference to `keep` uses), +// and `user`, a section of `A` outside the moved subtree, references `x` in +// local form. Moving `x` into `B` rewrites that reference to `B`'s external +// form, which needs a binding of `B`'s module `A` lacks — the rewrite adds +// `B`'s import to `A`, closing the cycle A → B → A. The refusal locates the +// would-be cycle's full path in pre-operation coordinates: `B`'s existing +// import declaration by its own characters, and the import the move would +// add — existing in no pre-operation source — by the one reference spelling +// whose rewrite requires it (its occurrence span, the reference's own +// expression, 5.7), never a range for the import that does not yet exist. +// Bearers in 12.7's within-finding order: `specs/A.mdx` precedes +// `specs/B.mdx` by path bytes. No dependency cycle arises beside it (`user` +// → `x`, `b` → `keep`), so the finding is the report's only one. +const T14_7_CYCLE_A = "specs/A.mdx"; +const T14_7_CYCLE_B = "specs/B.mdx"; +const T14_7_CYCLE_A_SOURCE = [ + '<S id="keep">', + "Keep text.", + "</S>", + "", + '<S id="x">', + "X text.", + "</S>", + "", + '<S id="user" d={"x"}>', + "User text.", + "</S>", + "", +].join("\n"); +const T14_7_IMPORT_DECLARATION = 'import A from "./A.xspec"'; +const T14_7_CYCLE_B_SOURCE = stagedMdx( + "T14-7 import-cycle arm specs/B.mdx (b importing A for keep)", + [ + T14_7_IMPORT_DECLARATION, + "", + '<S id="b" d={A.keep}>', + "B text.", + "</S>", + "", + ].join("\n"), +); +const T14_7_CYCLE_A_STAGED = stagedMdx( + "T14-7 import-cycle arm specs/A.mdx (keep, x, and user referencing x in local form)", + T14_7_CYCLE_A_SOURCE, +); +const T14_7_LOCAL_REFERENCE = 'd={"x"}'; +const T14_7_LOCAL_REFERENCE_WINDOW = byteWindow( + T14_7_CYCLE_A_SOURCE.slice( + 0, + T14_7_CYCLE_A_SOURCE.indexOf(T14_7_LOCAL_REFERENCE), + ), + T14_7_LOCAL_REFERENCE, +); +const T14_7_IMPORT_DECLARATION_WINDOW = byteWindow( + "", + T14_7_IMPORT_DECLARATION, +); + +// refused-invalid-rewrite and refused-moved-import, via the home tables +// (TEST-SPEC T14-7: T6.5-16, T6.5-17). T14-7 re-asserts the reporting +// contract alone over T6.5-16's and T6.5-17's exported refused arms, each +// staged under the home module's configuration: exit 1, the form-exact +// report, the exact code multiset — the arm's own reason plus every reason +// the entry pins beside it, one finding each — and the arm's finding +// located exactly, every pinned location within its own 1.7 range and none +// beside, in 12.7's within-finding order: the moved section's construct in +// the origin file and, for an addition no offset admits, every reference +// spelling rooted at its binding (refused-invalid-rewrite, `identities` the +// concerned files' paths in byte order); each moved import declaration by +// the import range of 11.4 (refused-moved-import, `identities` empty) — +// `path` null for both, a refusal locating in source concerning no path +// (SPEC 14, 12.7). The S-9 probes of the would-be texts and the +// modifies-nothing compares stay the home tests' subject. +// +// T6.5-16's one arm reported beside `refused-id-collision` is left to its +// home: the entry pins that collision's bearers nowhere (T6.5-16 asserts +// the beside reason's code alone), while T14-7 asserts the collision's +// located bearers and identities over T6.4-3's, T6.5-4's, and its own +// stagings. Every other beside reason's concern follows from the operands +// alone (`besideExpectation`). +const T14_7_INVALID_REWRITE_ARMS: readonly R16RefusedArm[] = + R16_REFUSED_ARMS.filter( + (arm) => !(arm.beside ?? []).includes("refused-id-collision"), + ); + +/** + * A home arm's pinned locations as T14-7's complete bearer set: each 1.7 + * range its own window — one location within it, none beside, index-wise in + * 12.7's order (support.ts assertFindingLocatesExactly, which asserts `path` + * null with it). + */ +function pinnedBearers( + locations: readonly R16Location[], +): BearerLocationExpectation[] { + return locations.map(({ file, start, end }) => ({ + file, + window: { start, end }, + })); +} + +/** + * The expectation of a reason a home arm pins beside its own (SPEC 14: every + * applicable reason reports together, one finding per reason), its concern + * read off the operands as 14 spells it: `refused-invalid-id` concerns the + * new identity — the `<target-file>#<new-id>` operand verbatim; + * `refused-missing-target-parent` the target-parent identity — `<new-id>` + * minus its final segment over the target file; `refused-invalid-destination` + * the destination path, asserted where the entry pins it (`besidePath`). + */ +function besideExpectation( + code: string, + argv: readonly string[], + besidePath: Readonly<Record<string, string>> | undefined, +): RefusalExpectation { + const destination = argv[2] ?? ""; + const hash = destination.indexOf("#"); + const targetFile = destination.slice(0, hash); + const newId = destination.slice(hash + 1); + switch (code) { + case "refused-invalid-id": + return { finding: code, identities: [destination] }; + case "refused-missing-target-parent": + return { + finding: code, + identities: [`${targetFile}#${newId.slice(0, newId.lastIndexOf("."))}`], + }; + case "refused-invalid-destination": { + const path = besidePath?.[code]; + return path === undefined ? { finding: code } : { finding: code, path }; + } + default: + throw new Error( + `harness defect: T14-7 states no expectation for the beside reason ` + + `${JSON.stringify(code)} of a home arm (SPEC 14)`, + ); + } +} + +/** refused-invalid-rewrite over T6.5-16's exported refused arms (the note above). */ +async function runT147InvalidRewriteArms( + product: ProductBinding, +): Promise<void> { + for (const arm of T14_7_INVALID_REWRITE_ARMS) { + const context = `T14-7 refused-invalid-rewrite (T6.5-16 ${arm.key})`; + await withWorkspace( + { files: { "xspec.config.ts": R16_CONFIG, ...arm.files } }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context}: staging \`build\` — the pre-move workspace valid, ` + + `every staged file well-formed (the T6.5-16 protocol)`, + ); + await assertRefusalReport( + product, + workspace, + arm.argv, + [ + { + finding: "refused-invalid-rewrite", + locatedAtEach: pinnedBearers(arm.locations), + identities: arm.identities, + }, + ...(arm.beside ?? []).map((code) => + besideExpectation(code, arm.argv, arm.besidePath), + ), + ], + `${context} — ${arm.summary}`, + ); + }, + ); + } +} + +/** refused-moved-import over T6.5-17's exported refused arms (the note above). */ +async function runT147MovedImportArms(product: ProductBinding): Promise<void> { + for (const arm of M17_REFUSED_ARMS) { + const context = `T14-7 refused-moved-import (T6.5-17 ${arm.key})`; + await withWorkspace( + { files: { "xspec.config.ts": R16_CONFIG, ...arm.files } }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context}: staging \`build\` — the pre-move workspace valid, ` + + `the section's ESM block deriving inside it (the T6.5-17 protocol)`, + ); + await assertRefusalReport( + product, + workspace, + arm.argv, + [ + { + finding: "refused-moved-import", + locatedAtEach: pinnedBearers(arm.locations), + identities: [], + }, + ...(arm.beside ?? []).map((code) => + besideExpectation(code, arm.argv, undefined), + ), + ], + `${context} — ${arm.summary}`, + ); + }, + ); + } +} + +// The invalid-path identity staging (T14-7's own; SPEC 14, 1.5, 12.0, +// 12.7): `specs/new.txt` — an absent path lacking `.mdx`, no valid +// destination (6.5, 14.19) — as a section move's target file. A refusal's +// identities are spellings in 1.5's form over the would-be operation, +// carried whatever the path's validity and defining no node: `move +// specs/A.mdx#x 'specs/new.txt#x y'` reports refused-invalid-destination +// (`path` the destination as spelled) beside refused-invalid-id with +// `identities` exactly `["specs/new.txt#x y"]`, the invalid ID verbatim; +// `move specs/A.mdx#x specs/new.txt#p.y` reports refused-invalid-destination +// beside refused-missing-target-parent with `["specs/new.txt#p"]`, the +// absent file bearing no `p`. Each identity is a plain string over the path +// as spelled (12.7), defining no node: `query node` on it is 12.0's +// unknown-identity usage error, exit 2 — `specs/new.txt` a path in no +// configured group (T11-6). No further reason applies — `x y` and `p.y` +// collide with nothing in an absent file, no cycle arises, the would-be +// text is judged only under an intrinsically valid `<new-id>` with an +// insertion point (6.5), and the origin's deletion leaves `keep` well-formed +// — so each report is exactly its two findings. +const T14_7_INVALID_PATH_ORIGIN = "specs/A.mdx"; +const T14_7_INVALID_PATH = "specs/new.txt"; +const T14_7_INVALID_PATH_FILES: Readonly<Record<string, InitialFileContents>> = + { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_7_INVALID_PATH_ORIGIN]: stagedMdx( + "T14-7 invalid-path arm specs/A.mdx (keep and x)", + '<S id="keep">\nKeep text.\n</S>\n\n<S id="x">\nX text.\n</S>\n', + ), + }; + +/** One identity over the invalid path: the `<new-id>` moved to, the reason concerning the identity, and the identity itself. */ +interface InvalidPathArm { + readonly newId: string; + readonly identity: string; + readonly beside: RefusalExpectation; + readonly reason: string; +} + +const T14_7_INVALID_PATH_ARMS: readonly InvalidPathArm[] = [ + { + newId: "x y", + identity: `${T14_7_INVALID_PATH}#x y`, + beside: { + finding: "refused-invalid-id", + identities: [`${T14_7_INVALID_PATH}#x y`], + }, + reason: + "an invalid ID over the absent, extension-less path — " + + "refused-invalid-id concerning `specs/new.txt#x y`, the invalid ID " + + "spelled verbatim, beside refused-invalid-destination", + }, + { + newId: "p.y", + identity: `${T14_7_INVALID_PATH}#p`, + beside: { + finding: "refused-missing-target-parent", + identities: [`${T14_7_INVALID_PATH}#p`], + }, + reason: + "a missing target parent over the absent, extension-less path — " + + "refused-missing-target-parent concerning `specs/new.txt#p`, the " + + "absent file bearing no `p`, beside refused-invalid-destination", + }, +]; + +/** Identities over invalid paths (the staging note above). */ +async function runT147InvalidPathArms(product: ProductBinding): Promise<void> { + await withWorkspace( + { files: T14_7_INVALID_PATH_FILES }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 invalid-path staging `build` over the valid workspace " + + "(`specs/new.txt` absent)", + ); + for (const arm of T14_7_INVALID_PATH_ARMS) { + const destination = `${T14_7_INVALID_PATH}#${arm.newId}`; + await assertRefusalReport( + product, + workspace, + ["move", `${T14_7_INVALID_PATH_ORIGIN}#x`, destination], + [ + arm.beside, + { + finding: "refused-invalid-destination", + path: T14_7_INVALID_PATH, + }, + ], + `T14-7 move (identities over an invalid path, \`${destination}\`: ` + + `${arm.reason}; the identity a spelling in 1.5's form over the ` + + `would-be operation, carried whatever its path's validity)`, + ); + const result = await expectExit( + product, + workspace, + ["query", "node", arm.identity, "--json"], + 2, + `T14-7 \`query node ${arm.identity}\` — the refusal's identity is a ` + + `plain string over the path as spelled, defining no node: 12.0's ` + + `unknown-identity usage error, exit 2 — \`${T14_7_INVALID_PATH}\` ` + + `a path in no configured group (SPEC 14, 1.5, 12.0, 12.7; T11-6)`, + ); + expectErrorDocument( + result, + `T14-7 \`query node ${arm.identity} --json\` — under --json, the ` + + `exit-2 error document is the entire stdout (SPEC 12.0, 12.7, H-5)`, + ); + } + }, + ); +} + +// The sibling spec-import-cycle staging (T14-7's own; SPEC 14, 6.5, 5.7): +// `A` imports a third module `specs/C.mdx` as `C`, the moved section `x` +// carries `d={C.foo}`, and `C` imports `B` (its `foo` referencing `B.bar`). +// Moving `x` into `B` roots that chain at a binding of `C`'s module `B` +// lacks — the rewrite adds `C`'s import to `B`, closing the cycle B → C → +// B. The finding locates `C`'s existing import of `B` by its own characters +// and the chain's spelling in `A` (pre-operation coordinates, inside the +// moved text): the located set is the spellings rooted at the added +// binding, independent of whether each appears as a `reference-rewrite` — +// a product choosing `C` as the fresh identifier would rewrite nothing +// there, and the spelling is located all the same. 12.7's within-finding +// order: `specs/A.mdx` before `specs/C.mdx` by path bytes. Nothing else +// applies: `x` collides with nothing in `B`, no dependency cycle arises +// (`x` → `foo` → `bar`), and the origin's deletion — `C`'s import, its +// only use moved away, removed with its emptied block — leaves `A` +// well-formed, so the finding is the report's only one. +const T14_7_SIBLING_A = "specs/A.mdx"; +const T14_7_SIBLING_B = "specs/B.mdx"; +const T14_7_SIBLING_C = "specs/C.mdx"; +const T14_7_SIBLING_CHAIN = "d={C.foo}"; +const T14_7_SIBLING_A_SOURCE = [ + 'import C from "./C.xspec"', + "", + '<S id="keep">', + "Keep text.", + "</S>", + "", + `<S id="x" ${T14_7_SIBLING_CHAIN}>`, + "X text.", + "</S>", + "", +].join("\n"); +const T14_7_SIBLING_B_SOURCE = stagedMdx( + "T14-7 sibling import-cycle arm specs/B.mdx (bar)", + ['<S id="bar">', "Bar text.", "</S>", ""].join("\n"), +); +const T14_7_SIBLING_C_IMPORT = 'import B from "./B.xspec"'; +const T14_7_SIBLING_C_SOURCE = stagedMdx( + "T14-7 sibling import-cycle arm specs/C.mdx (foo importing B for bar)", + [ + T14_7_SIBLING_C_IMPORT, + "", + '<S id="foo" d={B.bar}>', + "Foo text.", + "</S>", + "", + ].join("\n"), +); +const T14_7_SIBLING_A_STAGED = stagedMdx( + "T14-7 sibling import-cycle arm specs/A.mdx (x carrying d={C.foo} beside keep)", + T14_7_SIBLING_A_SOURCE, +); +const T14_7_SIBLING_CHAIN_WINDOW = byteWindow( + T14_7_SIBLING_A_SOURCE.slice( + 0, + T14_7_SIBLING_A_SOURCE.indexOf(T14_7_SIBLING_CHAIN), + ), + T14_7_SIBLING_CHAIN, +); +const T14_7_SIBLING_C_IMPORT_WINDOW = byteWindow("", T14_7_SIBLING_C_IMPORT); + +/** The spec import cycle's sibling arm (the staging note above). */ +async function runT147SiblingCycleArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_7_SIBLING_A]: T14_7_SIBLING_A_STAGED, + [T14_7_SIBLING_B]: T14_7_SIBLING_B_SOURCE, + [T14_7_SIBLING_C]: T14_7_SIBLING_C_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 sibling import-cycle staging `build` over the valid " + + "workspace (`A` imports `C`, `C` imports `B`, `x` references " + + "`C.foo`)", + ); + await assertRefusalReport( + product, + workspace, + ["move", `${T14_7_SIBLING_A}#x`, `${T14_7_SIBLING_B}#x`], + { + finding: "refused-cycle", + locatedAtEach: [ + { file: T14_7_SIBLING_A, window: T14_7_SIBLING_CHAIN_WINDOW }, + { file: T14_7_SIBLING_C, window: T14_7_SIBLING_C_IMPORT_WINDOW }, + ], + }, + "T14-7 move (spec import cycle, the sibling arm: carrying `x` with " + + "`d={C.foo}` from A into B, where A imports C and C imports B — " + + "the chain rooted at a binding of C's module B lacks, the added " + + "import closing B → C → B; the finding locates C's existing " + + "import of B and the chain's spelling in A, exactly those two, " + + "though a product choosing `C` as the fresh identifier would " + + "rewrite nothing there)", + ); + }, + ); +} + +// T6.5-4's symbolic-link arms beyond the shared table's inside-root entries +// (TEST-SPEC T14-7's refused-invalid-destination clause: a link to a +// directory at a component of the destination path, of a created target +// file's path, and of the `outDir` emit destination, the link and its +// target byte-identical after each refusal): the outside-root staging — +// `specs/sub` a link to an empty directory beside the workspace root, both +// forms — and the derived-path arm's link sibling — `mdout/new`, the emit +// destination's directory component, a link to `linked/` — each staged +// through T6.5-4's exported fixture and protocol (the link staged before +// the premise `build`, which passes), each refused-invalid-destination +// alone concerning the destination path, never 14.22, inside the +// link-and-target compare. The shared table's inside-root entries +// (`MOVE_LINK_INSIDE_CASES`) get the same compare in T14-7's table loop. + +/** The outside-root link staging and the derived-path link sibling (the note above). */ +async function runT147LinkArms(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": MOVE_REFUSAL_CONFIG, + ...MOVE_LINK_OUTSIDE_FILES, + }, + }, + async (workspace) => { + await stageMoveLinkOutsideComponent(workspace); + await buildOk( + product, + workspace, + "T14-7 outside-root link staging `build` (the T6.5-4 protocol) — " + + "the link specs/sub is never discovered nor traversed and lies " + + "under no current source's write path, so each refusal below is " + + "the move's own", + ); + for (const { argv, expected, reason } of MOVE_LINK_OUTSIDE_CASES) { + const context = `T14-7 move (${reason})`; + await assertLinkAndTargetUnchanged( + workspace, + MOVE_LINK_COMPONENT, + () => + assertRefusalReport(product, workspace, argv, expected, context), + context, + ); + } + }, + ); + await withWorkspace( + { + files: { + "xspec.config.ts": MOVE_DERIVED_PATH_CONFIG, + ...MOVE_DERIVED_LINK_FILES, + }, + }, + async (workspace) => { + await stageMoveDerivedLinkComponent(workspace); + await buildOk( + product, + workspace, + "T14-7 derived-path link sibling `build` (the T6.5-4 protocol) — " + + "the link mdout/new lies under no current source's write path, " + + "so the refusal below is the move's own", + ); + const context = `T14-7 move (${MOVE_DERIVED_LINK_CASE.reason})`; + await assertLinkAndTargetUnchanged( + workspace, + MOVE_DERIVED_LINK_COMPONENT, + () => + assertRefusalReport( + product, + workspace, + MOVE_DERIVED_LINK_CASE.argv, + MOVE_DERIVED_LINK_CASE.expected, + context, + ), + context, + ); + }, + ); +} + +// refused-invalid-destination over T6.5-20's derived-path relations and +// module-linking designation (TEST-SPEC T14-7: "and so do T6.5-4's barred +// path characters and T6.5-20's derived-path relations and module-linking +// designation" — the barred characters reach T14-7 through +// MOVE_REFUSAL_CASES), staged through T6.5-20's exported table and staging +// code (`d20RefusedStagings`, `runD20RefusedStaging`: the companion legs +// read as the home test reads them, each staging before any build or after +// its premise `build` as the home test stages it) — each refused move +// exactly one refused-invalid-destination finding concerning the +// destination as spelled (the section form's target file), `locations` +// `[]`, nothing beside, never 14.22. The modifies-nothing compares stay the +// home test's subject. + +/** T6.5-20's refused stagings under T14-7's reporting contract (the note above). */ +async function runT147DerivedPathRelationArms( + product: ProductBinding, +): Promise<void> { + for (const staging of await d20RefusedStagings(product, "T14-7 (T6.5-20)")) { + await runD20RefusedStaging( + product, + staging, + `T14-7 move (T6.5-20 ${staging.key})`, + (workspace, move, context) => + assertRefusalReport( + product, + workspace, + move.argv, + { finding: "refused-invalid-destination", path: move.path }, + `${context} — refused-invalid-destination alone, concerning the ` + + `destination as spelled, never 14.22`, + ), + ); + } +} + +// refused-exposed-derived-file over T6.5-21's refused stagings (TEST-SPEC +// T14-7: `path` the origin's emit destination, `locations` `[]`, +// `identities` `[]`), staged through T6.5-21's exported table and staging +// code (`D21_REFUSED_STAGINGS`, `runD21RefusedStaging`): (a) after its +// premise `build` — the file-form move, then the two-reason move reporting +// refused-invalid-destination (T6.5-4's barred `'`, `path` the destination +// as spelled) beside it — and (b) before any build, each move's findings +// exactly the table's, `identities` stated exactly where 14 pins them. + +/** T6.5-21's refused stagings under T14-7's reporting contract (the note above). */ +async function runT147ExposedDerivedFileArms( + product: ProductBinding, +): Promise<void> { + for (const staging of D21_REFUSED_STAGINGS) { + await runD21RefusedStaging( + product, + staging, + `T14-7 move (T6.5-21 ${staging.key})`, + (workspace, move, context) => + assertRefusalReport( + product, + workspace, + move.argv, + move.findings.map((expected): RefusalExpectation => + expected.identities === undefined + ? { finding: expected.code, path: expected.path } + : { + finding: expected.code, + path: expected.path, + identities: expected.identities, + }, + ), + context, + ), + ); + } +} + +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 — its identities exactly the one 1.5 identity over the destination file, the invalid ID spelled verbatim, no prefix-produced identity beside it (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 and by the exact self-move of either form, no collision or occupied-destination reason beside it, its identities the unchanged identity as the sole element — the bare `<new-file>` root identity for the file form; refused-id-collision locating every colliding bearer — the location set exactly the colliding bearers, two in T6.4-3's prefix-replacement arm, `b` and `b.c`, a product locating the first alone failing — its identities exactly the located bearers' identities in location order; refused-structural-parent and refused-missing-target-parent concerning the violated and the target-parent identity, each the sole identities element; refused-cycle locating the would-be cycle's full path in pre-operation coordinates — a dependency cycle's participating `d` spelling; a spec import cycle's existing import declaration by its own characters and, for the import the move would add, the local reference spelling whose rewrite requires it, exactly those two, never a range for the import that does not yet exist; refused-destination-exists concerning the occupied path, the section form's non-spec-source occupant included — occupancy judged in discovered-path form alone: a destination spelled `./a.mdx` for the origin `a.mdx`, `specs//b.mdx`, or `specs/../specs/b.mdx`, each naming an occupied path were it normalized, is refused-invalid-destination alone concerning the path as spelled, never reported occupied, and a section-form target path so spelled likewise; 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, and in T6.5-4's symbolic-link arms a link to a directory at a component of the destination path, of a created target file's path, and of the `outDir` emit destination, the link and its target byte-identical after each refusal — and so do T6.5-4's barred path characters and T6.5-20's derived-path relations and module-linking designation, over T6.5-20's exported refused stagings; refused-exposed-derived-file over T6.5-21's exported refused stagings — `path` the origin's emit destination, `locations` `[]`, `identities` `[]`, the two-reason move's refused-invalid-destination beside it; every reason concerning a path carrying it as the finding's `path` with `locations` `[]`; the eleven reasons 14 lists (refused-exposed-derived-file, refused-invalid-rewrite, and refused-moved-import, T6.5-21's, T6.5-16's, and T6.5-17's subjects, included) are the whole refusal vocabulary — a code 14 does not list never appears in any report, the form-exact decode admitting only 14's codes (no unresolvable-reference reason exists); 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; refused-invalid-rewrite and refused-moved-import re-asserted over T6.5-16's and T6.5-17's exported arms — the former locating the moved construct and, for an addition no offset admits, the spellings rooted at its binding, its identities the concerned files' paths in byte order; the latter locating each moved declaration by the import range of 11.4, its identities empty — `path` null for both, every reason the entry pins beside reported together; identities over invalid paths: `move specs/A.mdx#x 'specs/new.txt#x y'` reports refused-invalid-destination (`path` `specs/new.txt`) beside refused-invalid-id with identities exactly `[\"specs/new.txt#x y\"]`, and `move specs/A.mdx#x specs/new.txt#p.y` refused-invalid-destination beside refused-missing-target-parent with `[\"specs/new.txt#p\"]` — each a plain string over the path as spelled, defining no node: `query node` on it is a usage error, exit 2; and the spec import cycle's sibling arm — `A` imports a third module `C`, the moved text carries `d={C.foo}`, `C` imports `B` — locating `C`'s existing import of `B` and the chain's spelling in `A`, the located set the spellings rooted at the added binding, independent of whether each appears as a reference-rewrite (SPEC 14, 6.4, 6.5, 4, 5.3, 5.7, 1.5, 7, 11.4, 12.0, 12.7, 13.4)", + 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 two + // collisions — the single-bearer arm locating the remaining bearer; + // the two-bearer prefix-replacement arm, one refused-id-collision + // finding whose location set is exactly `b` then `b.c`, the case's + // declared complete bearer set (SPEC 14: every colliding bearer — a + // product locating the first alone fails) — 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, T6.5-4's barred path characters and its + // inside-root symbolic-link arms among them — each link arm inside the + // link-and-target compare (TEST-SPEC T14-7: the link and its target + // byte-identical after each refusal; 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 kase of MOVE_REFUSAL_CASES) { + const context = `T14-7 move (${kase.reason})`; + const report = (): Promise<void> => + assertRefusalReport( + product, + workspace, + kase.argv, + kase.expected, + context, + ); + if (MOVE_LINK_INSIDE_CASES.includes(kase)) { + await assertLinkAndTargetUnchanged( + workspace, + MOVE_LINK_COMPONENT, + report, + context, + ); + } else { + await report(); + } + } + }, + ); + + // --- 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 over T6.5-4's remaining symbolic-link + // arms (the link-arm note): the outside-root staging and the + // derived-path arm's link sibling at the `outDir` emit destination's + // component — never 14.22, the link and its target byte-identical after + // each refusal (SPEC 6.5, 7, 13.4, 14). + await runT147LinkArms(product); + + // --- 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_STAGED, + }, + }, + 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, + }, + locatedAtEach: [ + { file: T14_7_MULTI_FILE, window: T14_7_OCCUPANT_WINDOW }, + ], + identities: [`${T14_7_MULTI_FILE}#keep.mv`], + }, + { + 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)", + ); + }, + ); + + // --- refused-invalid-destination alone for a destination spelled with a + // `.`, `..`, or empty segment (the spelling fixture note): each spelling + // names an occupied path were it normalized — the origin itself for + // `./a.mdx`, the discovered `specs/b.mdx` for the other two — yet + // occupancy is judged in discovered-path form alone, so the exact + // one-entry multiset holds no refused-destination-exists and no + // refused-identity-unchanged beside it, the concerned path the + // destination as spelled; the section form's target path so spelled + // likewise (SPEC 14, 6.5, 12.0). + await withWorkspace({ files: T14_7_SPELLING_FILES }, async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 destination-spelling staging `build` (a root-level origin " + + "under a root-level glob beside `specs/b.mdx`)", + ); + for (const spelled of T14_7_SPELLED_DESTINATIONS) { + await assertRefusalReport( + product, + workspace, + ["move", T14_7_SPELLING_ORIGIN, spelled], + { finding: "refused-invalid-destination", path: spelled }, + `T14-7 move (file form; destination spelled \`${spelled}\` — no ` + + "discovered path carries a `.`, `..`, or empty segment: " + + "refused-invalid-destination alone, concerning the path as " + + "spelled, never reported occupied)", + ); + await assertRefusalReport( + product, + workspace, + ["move", `${T14_7_SPELLING_ORIGIN}#x`, `${spelled}#z`], + { finding: "refused-invalid-destination", path: spelled }, + `T14-7 move (section form; target path spelled \`${spelled}\` — ` + + "refused-invalid-destination alone, concerning the target file " + + "path as spelled, never reported occupied)", + ); + } + }); + + // --- refused-cycle locating a would-be spec import cycle's full path + // (the import-cycle fixture note): the existing import declaration by + // its own characters and, for the import the move would add, the local + // reference spelling whose rewrite requires it — exactly those two + // participants, in 12.7's within-finding order, never a range for the + // import that does not yet exist, path null (SPEC 14, 6.5, 5.7, 12.7). + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_7_CYCLE_A]: T14_7_CYCLE_A_STAGED, + [T14_7_CYCLE_B]: T14_7_CYCLE_B_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 import-cycle staging `build` over the valid workspace " + + "(`B` imports `A`; `user` references `x` in local form)", + ); + await assertRefusalReport( + product, + workspace, + ["move", `${T14_7_CYCLE_A}#x`, `${T14_7_CYCLE_B}#x`], + { + finding: "refused-cycle", + locatedAtEach: [ + { file: T14_7_CYCLE_A, window: T14_7_LOCAL_REFERENCE_WINDOW }, + { file: T14_7_CYCLE_B, window: T14_7_IMPORT_DECLARATION_WINDOW }, + ], + }, + "T14-7 move (spec import cycle: carrying `x` from A into B, where " + + "B imports A and `user` references `x` locally — the rewrite " + + "adds B's import to A, closing the cycle; the finding locates " + + "B's existing import declaration and that local reference's " + + "spelling, exactly those two, never the import that does not " + + "yet exist)", + ); + }, + ); + + // --- refused-cycle over the sibling spec-import-cycle arm (the sibling + // staging note): the chain `C.foo` carried into `B`, `C` importing `B` + // — the finding locates `C`'s existing import and the chain's spelling + // in `A`, the spellings rooted at the added binding whether or not any + // character of theirs changes (SPEC 14, 6.5, 5.7). + await runT147SiblingCycleArm(product); + + // --- refused-invalid-destination over T6.5-20's derived-path relations + // and module-linking designation (the T6.5-20 note): every refused move + // of its exported table exactly one finding concerning the destination + // as spelled, `locations` `[]`, never 14.22 (SPEC 6.5, 4, 14). + await runT147DerivedPathRelationArms(product); + + // --- refused-exposed-derived-file over T6.5-21's refused stagings (the + // T6.5-21 note): `path` the origin's emit destination, `locations` `[]`, + // `identities` `[]` — the two-reason move's refused-invalid-destination + // reported beside it (SPEC 6.5, 13.4, 14, 12.7). + await runT147ExposedDerivedFileArms(product); + + // --- refused-invalid-rewrite and refused-moved-import, re-asserted over + // T6.5-16's and T6.5-17's exported arms (the home-tables note): the + // moved construct and the spellings rooted at an addition no offset + // admits, `identities` the concerned files' paths in byte order; each + // moved declaration by the import range of 11.4, `identities` empty — + // `path` null for both, every reason pinned beside reported together + // (SPEC 14, 6.5, 11.4, 12.7). + await runT147InvalidRewriteArms(product); + await runT147MovedImportArms(product); + + // --- Identities over invalid paths (the invalid-path staging note): + // `specs/new.txt#x y` and `specs/new.txt#p`, each carried as a plain + // string over the path as spelled beside refused-invalid-destination, + // defining no node — `query node` on it exit 2 (SPEC 14, 1.5, 12.0, + // 12.7). + await runT147InvalidPathArms(product); + + // --- 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_STAGED, + [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 }, + locatedAtEach: [ + { file: T14_7_RENAME_FILE, window: T14_7_SIB_WINDOW }, + ], + identities: [`${T14_7_RENAME_FILE}#a.sib`], + }, + "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)", + ); + // The exact self-move of either form reports + // refused-identity-unchanged alone — no collision reason beside it + // (SPEC 6.4: the after-removal check collides with nothing) and no + // refused-destination-exists for the file form (SPEC 14: the origin + // path itself is the one occupant that reason never reports) — its + // `identities` the unchanged identity in 1.5's form over the + // destination: `<target-file>#<id>` for the section form, the bare + // `<new-file>`, its root identity, for the file form (SPEC 14). + await assertRefusalReport( + product, + workspace, + ["move", `${T14_7_RENAME_FILE}#a.mid`, `${T14_7_RENAME_FILE}#a.mid`], + { + finding: "refused-identity-unchanged", + identities: [`${T14_7_RENAME_FILE}#a.mid`], + }, + "T14-7 move (the exact section-form self-move — " + + "refused-identity-unchanged alone, its identities the unchanged " + + "`<target-file>#<id>` identity)", + ); + await assertRefusalReport( + product, + workspace, + ["move", T14_7_RENAME_FILE, T14_7_RENAME_FILE], + { + finding: "refused-identity-unchanged", + identities: [T14_7_RENAME_FILE], + }, + "T14-7 move (the exact file-form self-move — " + + "refused-identity-unchanged alone, never " + + "refused-destination-exists beside it, its identities the bare " + + "`<new-file>` root identity)", + ); + 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"', +]; +// S-9: two imports binding one identifier — an early error 14.20 admits, +// the named allowance; the workspace follows the body's first invocation, +// so its sources are staged-source records. +const T14_8_COL_SOURCE = stagedMdx( + "T14-8 import-binding collision arm specs/Col.mdx (two imports binding A)", + [ + ...T14_8_COL_IMPORTS, + "", + '<S id="col">', + "Collision-file body text.", + "</S>", + "", + ].join("\n"), + { allowances: ["duplicate-import-binding"] }, +); +const T14_8_ONE_SOURCE = stagedMdx( + "T14-8 import-binding collision arm specs/One.mdx (the first import's target)", + '<S id="one">\nTarget one text.\n</S>\n', +); +const T14_8_TWO_SOURCE = stagedMdx( + "T14-8 import-binding collision arm specs/Two.mdx (the second import's target)", + '<S id="two">\nTarget two text.\n</S>\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, InitialFileContents>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_CYC_A_FILE]: stagedMdx( + "T14-8 cross-file dependency cycle arm specs/CycA.mdx (a depending on B.b)", + `${T14_8_CYC_A_IMPORT}\n\n${T14_8_CYC_A_ELEMENT}\n`, + ), + [T14_8_CYC_B_FILE]: stagedMdx( + "T14-8 cross-file dependency cycle arm specs/CycB.mdx (b depending on A.a)", + `${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, InitialFileContents>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_IMP_A_FILE]: stagedMdx( + "T14-8 pure import cycle arm specs/ImpA.mdx (importing ImpB, the binding unused)", + `${T14_8_IMP_A_IMPORT}\n\n<S id="ia">\nImport-cycle A text, binding unused.\n</S>\n`, + ), + [T14_8_IMP_B_FILE]: stagedMdx( + "T14-8 pure import cycle arm specs/ImpB.mdx (importing ImpA, the binding unused)", + `${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 = stagedMdx( + "T14-8 no-occurrence embedding arm specs/Emb.mdx (an unresolved local text(...) between prose)", + `${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. The arm's workspace follows the body's first +// invocations, so its configuration is a staged-source record (S-9; +// helpers/staged-ts.ts). +const T14_8_POLICY_CONFIG = stagedTs( + "T14-8 policy arm xspec.config.ts (the forbidden rule no-spec-deps over the group main)", + `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 = stagedMdx( + "T14-8 policy arm specs/Pol.mdx (p depending on a under the no-spec-deps rule)", + [ + '<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": T14_8_ONE_SOURCE, + "specs/Two.mdx": T14_8_TWO_SOURCE, + }, + }, + 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)`, + ); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// T14-11 — per-condition ranges, byte-exact +// --------------------------------------------------------------------------- + +// Every fixture below is assembled from exactly known parts (`assemble`), and +// every pinned offset is the UTF-8 byte length of the text before the pinned +// part — never a string index: the shared preamble and the 14.20 fixtures put +// a multibyte character (`é`) before the pinned construct, so a product +// reporting character indices or line/column pairs fails. Ranges are asserted +// byte-EXACT — no end-widening: the module-header window convention is set +// aside here, since SPEC 14 fixes each condition's range. + +/** A part of a fixture whose exact byte range the arm pins (`pin`). */ +interface PinnedPart { + readonly pin: string; +} + +/** Mark a fixture part as pinned; `pin("")` pins a zero-length range. */ +function pin(text: string): PinnedPart { + return { pin: text }; +} + +/** A fixture text with the byte range of each pinned part, in part order. */ +interface AssembledFixture { + readonly text: string; + readonly ranges: readonly { readonly start: number; readonly end: number }[]; +} + +/** + * Concatenate the parts; each pinned part's range is [bytes before it, bytes + * through it) over the assembled UTF-8 text (SPEC 1.7: zero-based byte + * offsets, start-inclusive, end-exclusive). + */ +function assemble(parts: readonly (string | PinnedPart)[]): AssembledFixture { + let text = ""; + const ranges: { start: number; end: number }[] = []; + for (const part of parts) { + if (typeof part === "string") { + text += part; + continue; + } + const start = Buffer.byteLength(text, "utf8"); + text += part.pin; + ranges.push({ start, end: Buffer.byteLength(text, "utf8") }); + } + return { text, ranges }; +} + +/** One pinned location: a file and its exact `{start, end}` (SPEC 12.7). */ +interface ExactLocation { + readonly file: string; + readonly range: { readonly start: number; readonly end: number }; +} + +/** The pinned ranges of `fixture` (by pin index) as locations in `file`. */ +function located( + file: string, + fixture: AssembledFixture, + ...indices: readonly number[] +): readonly ExactLocation[] { + return indices.map((index) => { + const range = fixture.ranges[index]; + if (range === undefined) { + throw new Error( + `T14-11 fixture ${file} pins no part #${String(index)} (harness bug)`, + ); + } + return { file, range }; + }); +} + +/** A finding whose condition and complete location list are pinned. */ +interface ExactFindingExpectation { + readonly condition: string; + readonly locations: readonly ExactLocation[]; +} + +/** One range-rule arm: a workspace whose `build --json` findings are pinned. */ +interface RangeRuleCase { + /** The arm's letter (diagnostics). */ + readonly arm: string; + /** The SPEC 14 range rule under test (diagnostics). */ + readonly rule: string; + /** The configuration, a staged-source record (S-9) as `files`' entries are. */ + readonly config: StagedTs; + /** + * The sources beside the configuration, every one a staged-source record + * carrying its own S-9 declaration — an `.mdx` entry an MDX record, a code + * source a TypeScript record, 14.20's declared forms declared unparseable: + * every arm's workspace but (a)'s follows the body's first invocation, + * (a)'s converted uniformly, the (w) arms' records registered by their + * home modules. + */ + readonly files: Readonly<Record<string, InitialFileContents>>; + /** Every staged finding, with its complete location list. */ + readonly expected: readonly ExactFindingExpectation[]; +} + +/** A valid section every MDX fixture opens with — `ok` is a resolvable target. */ +const T14_11_PREAMBLE = '<S id="ok">\nTarget: café.\n</S>\n\n'; + +/** + * The spec source the code-file arms import: `a`, `a.b`, and `b` resolve — + * a staged-source record (S-9), also the refined `d`-value arms' imported + * `specs/BASE.mdx` and arm (o)'s readable source. + */ +const T14_11_A_MDX = stagedMdx( + "T14-11 specs/A.mdx (the imported module: a, a.b, and b; arms (d), (j), (l), (u), and (o), and arms (p)-(t)'s specs/BASE.mdx)", + '<S id="a">\nA.\n<S id="a.b">\nA.b.\n</S>\n</S>\n\n<S id="b">\nB.\n</S>\n', +); + +// (a) 14.5 — an unresolved entry of a `d` array literal: the entry's own +// expression, its quotes included, no bracket, comma, or whitespace. +const T14_11_D_ENTRY = assemble([ + T14_11_PREAMBLE, + '<S id="a" d={["ok", ', + pin('"absent"'), + "]}>\nUnknown second entry.\n</S>\n", +]); + +// (b) 14.8 — a non-array braced `d` value: the expression the braces enclose, +// the braces excluded. +const T14_11_D_IDENT = assemble([ + T14_11_PREAMBLE, + '<S id="a" d={', + pin("foo"), + "}>\nA dynamic d value.\n</S>\n", +]); + +// (c) 14.20 — braces enclosing no expression: an attribute value admits no +// empty expression (SPEC 2.7), so `d={}` and `d={ /* c */ }` are not +// well-formed MDX — condition 20, never 14.8 — the one zero-length range at +// the offset of the closing brace (the prefix before `}` begins some +// well-formed file: `d={1}` continues either). +const T14_11_D_EMPTY = assemble([ + T14_11_PREAMBLE, + '<S id="a" d={', + pin(""), + "}>\nAn empty d value.\n</S>\n", +]); +const T14_11_D_COMMENT_ONLY = assemble([ + T14_11_PREAMBLE, + '<S id="a" d={ /* c */ ', + pin(""), + "}>\nA comment-only d value.\n</S>\n", +]); + +// (d) 14.8 — a non-static bare reference in expression-statement position: +// the statement's expression, exclusive of the `;`. +const T14_11_OPTIONAL_CHAIN = assemble([ + 'import SPEC from "../specs/A.xspec"\n\n', + pin("SPEC?.a"), + ";\n", +]); + +// (e) 14.2 — each bearer's `id` attribute: a two-segment top-level ID and a +// level-skipping child, one finding each. +const T14_11_STRUCTURAL = assemble([ + T14_11_PREAMBLE, + "<S ", + pin('id="top.two"'), + '>\nTwo segments at top level.\n</S>\n\n<S id="p">\n<S ', + pin('id="p.q.r"'), + ">\nSkips a level.\n</S>\n</S>\n", +]); + +// (f) 14.3 — one finding locating each bearer's `id` attribute. +const T14_11_DUPLICATE = assemble([ + T14_11_PREAMBLE, + "<S ", + pin('id="dup"'), + ">\nFirst bearer.\n</S>\n\n<S ", + pin('id="dup"'), + ">\nSecond bearer.\n</S>\n", +]); + +// (g) 14.4 — one finding per violating attribute, each at the attribute: the +// parent's `id`, the child's own `id` (spelling the malformed segment as its +// prefix), and a `tags` attribute. +const T14_11_SEGMENT_TAG = assemble([ + T14_11_PREAMBLE, + "<S ", + pin('id="a b"'), + ">\n<S ", + pin('id="a b.c"'), + '>\nChild of a malformed segment.\n</S>\n</S>\n\n<S id="t" ', + pin('tags="bad#tag"'), + ">\nA malformed tag.\n</S>\n", +]); + +// (h) 14.17 per form — a repeated prop locating every attribute spelling the +// name, an unknown prop, a spread attribute (its whole braced construct), and +// an invalid `coverage` value, each at the attribute's own characters. +const T14_11_INVALID_PROP = assemble([ + T14_11_PREAMBLE, + "<S ", + pin('id="rep"'), + " ", + pin('id="rep2"'), + '>\nRepeated id.\n</S>\n\n<S id="unk" ', + pin('wibble="x"'), + '>\nUnknown prop.\n</S>\n\n<S id="spr" ', + pin("{...extra}"), + '>\nSpread attribute.\n</S>\n\n<S id="cov" ', + pin('coverage="maybe"'), + ">\nInvalid coverage value.\n</S>\n", +]); + +// (i) 14.1 — the section's opening tag, its own characters alone. +const T14_11_MISSING_ID = assemble([ + T14_11_PREAMBLE, + pin('<S coverage="none">'), + "\nMissing id.\n</S>\n", +]); + +// (j) 14.15 per form: an import declaration (the second of an MDX file's two, +// alone), an export declaration, and `import X = require(…)` by their own +// characters — each staged without `;`, so "own characters" is unambiguous — +// a dynamic `import()` by its call expression (its `;` excluded), and the +// colliding non-import declaration by the construct binding the name — the +// variable declarator `SPEC = 1`, the `const` statement excluded — beside the +// import declaration it collides with (T4.5-8). The chains rooted at the +// collided identifier are unresolved (14.7, SPEC 2.4): the marker by its bare +// chain exclusive of the `;`, the `text(...)` call callee through closing +// parenthesis (5.7) — `a` and `b` exist, so a product ignoring the collision +// resolves them and reports nothing. +const T14_11_IMPORT_MDX = assemble([ + 'import A from "./A.xspec"\n', + pin('import NOPE from "./Missing.xspec"'), + '\n\n<S id="b" d={A.a}>\nUses A.\n</S>\n', +]); +const T14_11_EXPORT_TS = assemble([ + "const before = 1\n\n", + pin('export * from "../specs/A.xspec"'), + "\n", +]); +const T14_11_REQUIRE_TS = assemble([ + "const before = 1\n\n", + pin('import X = require("../specs/A.xspec")'), + "\n", +]); +const T14_11_DYNAMIC_TS = assemble([ + "const before = 1\n\n", + pin('import("../specs/A.xspec")'), + ";\n", +]); +const T14_11_COLLISION_TS = assemble([ + pin('import SPEC, { text } from "../specs/A.xspec"'), + "\n\nconst ", + pin("SPEC = 1"), + "\n\n", + pin("SPEC.a"), + ";\n", + pin("text(SPEC.b)"), + ";\n", +]); + +// (k) 14.16 per form: an element from the first character of its opening tag +// through the last of its closing tag, a self-closing element its own tag, a +// fragment from `<>` through `</>` (an invalid element, SPEC 2.7; T2.7-1), +// an expression container brace through brace, an export statement whole. +const T14_11_CONSTRUCTS = assemble([ + T14_11_PREAMBLE, + pin("<div>Not a section.</div>"), + "\n\n", + pin("<br />"), + "\n\n", + pin("<>Fragment.</>"), + "\n\n", + pin("{1 + 1}"), + "\n\n", + pin("export const x = 1"), + "\n", +]); + +// (l) 14.18: a node binding's identifier extended by the longest static chain +// it roots at the offending use (`a.b` exists, so nothing else reports); a +// `text` binding passed to another function, its identifier alone. +const T14_11_USAGE_TS = assemble([ + 'import SPEC, { text } from "../specs/A.xspec"\n\n' + + "declare function f(x: unknown): void\n\nconst n = ", + pin("SPEC.a.b"), + "\n\nf(", + pin("text"), + ")\n", +]); + +// (m) 14.20's one zero-length range at the failure's offset: 0 for a +// byte-order mark; for an encoding failure the offset of the first byte at +// which UTF-8 decoding fails — a valid 5-byte prefix (`Café`: four characters, +// five bytes) then 0xFF; for a syntax failure the byte length of the longest +// whole-character prefix with which some well-formed file begins — an MDX file +// ending inside an unclosed section (the whole file such a prefix: its byte +// length, past the multibyte `é`) and the TypeScript file `let x = ;` (the +// prefix `let x = `: 8) — never past the file's length. +const T14_11_BOM_MDX = assemble([ + pin(""), + `${BOM}<S id="bom">\nA byte-order mark.\n</S>\n`, +]); +const T14_11_ENCODING_PREFIX = "Café"; +const T14_11_ENCODING_MDX = withInvalidUtf8Byte( + T14_11_ENCODING_PREFIX, + '\n<S id="enc">\nBody.\n</S>\n', +); +const T14_11_ENCODING_OFFSET = Buffer.byteLength( + T14_11_ENCODING_PREFIX, + "utf8", +); +const T14_11_UNCLOSED_MDX = assemble([ + '<S id="open">\nEnds inside an unclosed section: café.\n', + pin(""), +]); +const T14_11_SYNTAX_TS = assemble(["let x = ", pin(""), ";\n"]); + +// (p)–(t) The refined `d`-value ranges (the occurrence-span rule of SPEC 14, +// 5.7) over a module imported as `BASE` — `T14_11_A_MDX`, whose `a` and `b` +// resolve and whose `missing` does not. Each fixture opens with the import +// and the preamble, so the multibyte `é` precedes every pinned construct. +const T14_11_BASE = "specs/BASE.mdx"; +const T14_11_BASE_IMPORT = 'import BASE from "./BASE.xspec"\n\n'; +const NBSP = String.fromCodePoint(0xa0); // U+00A0 — ECMAScript whitespace +const ZWNBSP = String.fromCodePoint(0xfeff); // U+FEFF — inside the file, so no byte-order mark + +// (p) 14.8 — `d={(BASE.a)}`: the expression the braces enclose, first token +// through last, so the parentheses are included (parentheses join no static +// chain, SPEC 2.4). +const T14_11_D_PAREN = assemble([ + T14_11_BASE_IMPORT, + T14_11_PREAMBLE, + '<S id="p" d={', + pin("(BASE.a)"), + "}>\nA parenthesized chain.\n</S>\n", +]); + +// (q) 14.8 — `d={BASE.a, BASE.b}`: a comma sequence is one expression +// (SPEC 14.20), located whole — never two references. +const T14_11_D_COMMA = assemble([ + T14_11_BASE_IMPORT, + T14_11_PREAMBLE, + '<S id="q" d={', + pin("BASE.a, BASE.b"), + "}>\nA comma sequence.\n</S>\n", +]); + +// (r) 14.5 — the unresolved expression alone: the braces, and the whitespace +// and comment between them and the expression, excluded — a block comment +// with ASCII whitespace, U+00A0 on each side, U+FEFF before it, and U+1680 +// and U+3000 each on each side (ECMAScript whitespace, SPEC 1.4: its space +// separators Unicode 15.1's, 14.20; T5.7-2), the last four shifting the +// pinned start by their own byte lengths (2, 3, 3, and 3), so a product +// bounding the expression by ASCII or Latin-1 whitespace alone, or +// counting characters, fails the arm. +const OGHAM_SPACE = String.fromCodePoint(0x1680); // U+1680 — a Unicode 15.1 space separator (Zs) +const IDEOGRAPHIC_SPACE = String.fromCodePoint(0x3000); // U+3000 — a Unicode 15.1 space separator (Zs) +const T14_11_D_TRIVIA = assemble([ + T14_11_BASE_IMPORT, + T14_11_PREAMBLE, + '<S id="m1" d={ /* c */ ', + pin("BASE.missing"), + ' }>\nA block comment before the reference.\n</S>\n\n<S id="m2" d={' + NBSP, + pin("BASE.missing"), + NBSP + '}>\nU+00A0 on each side.\n</S>\n\n<S id="m3" d={' + ZWNBSP, + pin("BASE.missing"), + '}>\nU+FEFF before the reference.\n</S>\n\n<S id="m4" d={' + OGHAM_SPACE, + pin("BASE.missing"), + OGHAM_SPACE + + '}>\nU+1680 on each side.\n</S>\n\n<S id="m5" d={' + + IDEOGRAPHIC_SPACE, + pin("BASE.missing"), + IDEOGRAPHIC_SPACE + "}>\nU+3000 on each side.\n</S>\n", +]); + +// (s) 14.8 — a spread entry `d={[...BASE.a]}`: `...BASE.a`, the `...` +// included, no bracket. +const T14_11_D_SPREAD = assemble([ + T14_11_BASE_IMPORT, + T14_11_PREAMBLE, + '<S id="s" d={[', + pin("...BASE.a"), + "]}>\nA spread entry.\n</S>\n", +]); + +// (t) 14.8 — the elisions of one array literal as one finding at the whole +// literal, brackets included, however many holes; two literals, two findings +// (the resolving entries beside the holes report nothing). +const T14_11_D_ELISIONS = assemble([ + T14_11_BASE_IMPORT, + T14_11_PREAMBLE, + '<S id="e1" d={', + pin("[BASE.a, , , BASE.b]"), + '}>\nTwo holes.\n</S>\n\n<S id="e2" d={', + pin("[, BASE.b]"), + "}>\nOne hole.\n</S>\n", +]); + +// (u) 14.15 — a colliding non-import declaration by the construct binding +// the name, per form, as 1.7 reads one (T4.5-8's shared stagings, +// `T4_5_8_FURTHER_LOCATED_FORMS`): `let SPEC;` at `SPEC` alone (a declarator +// without initializer is its name), `const { SPEC } = o` at `{ SPEC } = o` +// (the binding pattern through its initializer, the `const` statement +// excluded), `@dec class SPEC {}` from its `@` (a decorator list is part of +// the class it decorates), and `export class SPEC {}` from `class` (the +// leading `export` excluded) — each in its own code file beside the import +// declaration it collides with, located by its own characters (staged +// without `;`, as in (j)), the chains the collided identifier roots +// unresolved (14.7, SPEC 2.4): the marker by its bare chain exclusive of +// the `;`, the `text(...)` call callee through closing parenthesis (5.7) — +// `a` and `b` exist, so a product ignoring the collision resolves them and +// reports nothing. A form's supporting declaration (`declare const o`, +// `declare function dec`) precedes its colliding line and binds no `SPEC`. +function collisionFixture(form: SameScopeDeclarationArm): AssembledFixture { + const at = form.line.indexOf(form.construct); + if (at === -1) { + throw new Error( + `T14-11 (u) fixture broke: the located construct must occur within ` + + `the declaration line (${form.name}) — fix ` + + `T4_5_8_FURTHER_LOCATED_FORMS in section-4.5.ts (harness bug)`, + ); + } + return assemble([ + pin('import SPEC, { text } from "../specs/A.xspec"'), + "\n\n", + form.line.slice(0, at), + pin(form.construct), + form.line.slice(at + form.construct.length), + "\n\n", + pin("SPEC.a"), + ";\n", + pin("text(SPEC.b)"), + ";\n", + ]); +} + +/** + * The (u) forms as the code files of one workspace, `src/collide-<key>.ts`, + * each staged as a staged-source record (S-9; arm (u) follows the body's + * first invocation). + */ +const T14_11_COLLISION_FILES = Object.entries(T4_5_8_FURTHER_LOCATED_FORMS).map( + ([key, form]) => { + const file = `src/collide-${key}.ts`; + const fixture = collisionFixture(form); + return { + file, + fixture, + source: stagedTs( + `T14-11 (u) ${file} (the ${key} form beside its import)`, + fixture.text, + ), + }; + }, +); + +// (v) 14.20's encoding offset — the byte length of the file's longest +// well-formed UTF-8 prefix: the offset of the first byte of the first +// ill-formed sequence, whether it is malformed or truncated by the file's +// end, never a later byte at which a decoder notices it, and — like every +// 14.20 range — zero-length. The five sequences SPEC 14 names, staged as +// exact bytes in a spec source and in a code source alike (SPEC 1.6: a +// source of either kind that is not valid UTF-8 is unparseable): a valid +// 5-byte prefix then `FF` — `Café` (`43 61 66 C3 A9`), then a byte no UTF-8 +// sequence holds; arm (m) stages the same form as `specs/enc.mdx` — locates +// 5; `41 E2 82 41` — a three-byte sequence cut short, the second `41` where +// a decoder notices — 1; `C0 80` — an overlong encoding of U+0000 — 0; +// `ED A0 80` — the surrogate code point U+D800 — 0; `41 E2 82` truncated by +// the end of the file — 1. A decoder accepting overlong or surrogate +// encodings decodes such a file and reports nothing; one reporting where it +// resynchronizes locates a later byte. The whole file is masked (14.20), so +// no other finding stands beside. The pins are the document's own numbers: +// computing them with a decoder would import the judgement under test. +interface EncodingForm { + /** The file basename (`specs/<name>.mdx` and `src/<name>.ts`). */ + readonly name: string; + /** The file's exact bytes. */ + readonly bytes: readonly number[]; + /** The byte length of the longest well-formed UTF-8 prefix (SPEC 14). */ + readonly offset: number; +} +const T14_11_ENCODING_FORMS: readonly EncodingForm[] = [ + { name: "prefix", bytes: [0x43, 0x61, 0x66, 0xc3, 0xa9, 0xff], offset: 5 }, + { name: "cut", bytes: [0x41, 0xe2, 0x82, 0x41], offset: 1 }, + { name: "overlong", bytes: [0xc0, 0x80], offset: 0 }, + { name: "surrogate", bytes: [0xed, 0xa0, 0x80], offset: 0 }, + { name: "eof", bytes: [0x41, 0xe2, 0x82], offset: 1 }, +]; + +/** + * Each encoding form as a spec source and as a code source, its pin located: + * each a staged-source record declared unparseable (S-9; arm (v) follows the + * body's first invocation) — the spec source an MDX record, the code source + * a TypeScript record. + */ +const T14_11_ENCODING_FILES = T14_11_ENCODING_FORMS.flatMap((form) => + [`specs/${form.name}.mdx`, `src/${form.name}.ts`].map((file) => ({ + file, + contents: file.endsWith(".mdx") + ? stagedMdx( + `T14-11 (v) ${file} (the ${form.name} encoding form)`, + Uint8Array.from(form.bytes), + "unparseable", + ) + : stagedTs( + `T14-11 (v) ${file} (the ${form.name} encoding form)`, + Uint8Array.from(form.bytes), + "unparseable", + ), + location: { file, range: { start: form.offset, end: form.offset } }, + })), +); + +// (w) The syntax-failure offsets other tests pin, re-asserted the same way +// (TEST-SPEC T14-11's closing clause: "the syntax-failure offsets of T2.3-3, +// T2.4-2, T2.7-3, T2.7-4, and T14-12 asserted the same way"). Each home +// module exports its staging — the very bytes its own arm drives, never +// re-spelled here (`UnparseableStaging`, support.ts) — and T14-11 runs +// `build --json` over it as one more range-rule arm: exactly one finding, +// 14.20, `path` null, its one location the zero-length range at the offset +// SPEC 14's rule fixes — the byte length of the longest whole-character +// prefix with which some well-formed file begins, never a line/column pair +// and never past the file's length. The stagings: T2.3-3's +// `{text("a") text("b")}` at the second `text`; T2.4-2's TypeScript-only +// forms in a spec source — `d={BASE.auth!}` at its closing brace, +// `{text(BASE.auth!)}` at its closing parenthesis, and the `as` forms at the +// offset of `as`; T2.7-3's spread attribute `{...a, b}` at its comma; +// T2.7-4's comment-grammar failures — U+0085 and U+200B between braces at +// the code point, `{// c` U+2028/U+2029 `}` U+000A `}` at the first `}`, +// and `{// c}` with no later `}` at the file's byte length; and T14-12's +// negative arms — `010` and `09` in a `.ts` file at the second digit, the +// spread's comma, an ESM block's statement at the `const` line's start, +// import attributes at `with`, `d={]}` at the `]`, `{text(}` at its `}`, +// and an unbalanced `{text("a")` at the file's byte length. Every spec +// source is the staged-source record its home module registers — the +// failing one declared unparseable (S-9) — staged here under that very +// declaration, as in its home arm (the (w) workspaces follow this body's +// earlier invocations); a code-source staging's failing file is likewise +// the TypeScript record its home module registers, declared unparseable +// (`reassertedCase` confirms each staging's failing file is a record so +// declared, at load); the configuration is this module's record for the +// staging's kind (the home modules stage the same text). + +/** T14-12's arm as an `UnparseableStaging`: its sources beside the configuration. */ +function t1412Staging(arm: UnparseableArm): UnparseableStaging { + return { + name: `(${arm.arm}) ${arm.name} (T14-12)`, + kind: arm.kind, + file: arm.file, + files: Object.fromEntries( + Object.entries(arm.files).filter(([file]) => file !== "xspec.config.ts"), + ), + offset: arm.offset, + }; +} + +/** Every re-asserted staging, in the order of T14-11's closing clause. */ +const T14_11_REASSERTED_STAGINGS: readonly UnparseableStaging[] = [ + T2_3_3_UNPARSEABLE_STAGING, + ...T2_4_2_UNPARSEABLE_STAGINGS, + T2_7_3_SPREAD_UNPARSEABLE_STAGING, + ...T2_7_4_UNPARSEABLE_STAGINGS, + ...T14_12_UNPARSEABLE_ARMS.map(t1412Staging), +]; + +/** One re-asserted staging as a range-rule arm: `{14.20: 1}` at its offset. */ +function reassertedCase( + index: number, + staging: UnparseableStaging, +): RangeRuleCase { + const { kind, file, files, offset } = staging; + // S-9: the failing file is a staged-source record declared unparseable — + // an MDX record for a spec-source staging, a TypeScript record for a + // code-source one — so the workspace declares nothing beside it. + const failing = files[file]; + const declared = + kind === "code-source" + ? failing instanceof StagedTs && failing.ts === "unparseable" + : failing instanceof StagedMdx && failing.mdx === "unparseable"; + if (!declared) { + throw new Error( + `T14-11 (w): the re-asserted staging ${JSON.stringify(staging.name)} ` + + `stages its failing ${kind} ${file} as something other than a ` + + `staged-source record declared unparseable (S-9)`, + ); + } + return { + arm: `w.${String(index)}`, + rule: `14.20 — a syntax-failure offset another test pins, re-asserted: ${staging.name}`, + config: kind === "code-source" ? SPEC_AND_CODE_CONFIG : SPECS_ONLY_CONFIG, + files, + expected: [ + { + condition: "14.20", + locations: [{ file, range: { start: offset, end: offset } }], + }, + ], + }; +} + +const T14_11_REASSERTED_CASES: readonly RangeRuleCase[] = + T14_11_REASSERTED_STAGINGS.map((staging, index) => + reassertedCase(index + 1, staging), + ); + +// (x) 14.15 per form, the module-linking forms beyond (j)'s (SPEC 4, 14): +// `export import X = require(…)` from `import` — the leading `export` and +// the space separating it excluded, as 1.7 excludes one; an import type +// from `import` through the closing parenthesis of its argument list, in +// two type positions — `typeof import(…).default`, the `typeof` before it +// and the `.default` qualifier after it excluded, and `import(…).T<number>`, +// the `.T` qualifier and the type arguments excluded; and a string-named +// module declaration by its own characters — `declare module "…" { }` +// whole, `declare` included, and `export declare module "…" { }` from +// `declare`, the leading `export` excluded (1.7). Each form stands alone in +// its own code file, staged without `;` as in (j), after a declaration +// holding the multibyte `é`, so a product counting characters misplaces +// every range. Every specifier designates the discovered spec source +// `specs/A.mdx`, so the form alone is the defect (an import declaration is +// the only form through which a TypeScript file consumes a spec module, +// SPEC 4). TypeScript's complaints about these files — a relative ambient +// module name, an `export` modifier on an ambient module declaration — are +// post-parse checks, so each file is well-formed (14.20), its one finding +// 14.15's. +const T14_11_LINKING_LEAD = 'const before = "café"\n\n'; +const T14_11_LINKING_FORMS: readonly { + readonly file: string; + readonly form: string; + readonly fixture: AssembledFixture; +}[] = [ + { + file: "src/export-require.ts", + form: "export import X = require of the spec module, located from import", + fixture: assemble([ + T14_11_LINKING_LEAD + "export ", + pin('import X = require("../specs/A.xspec")'), + "\n", + ]), + }, + { + file: "src/typeof-import.ts", + form: "the import type typeof import(…).default, located import through the argument list", + fixture: assemble([ + T14_11_LINKING_LEAD + "type D = typeof ", + pin('import("../specs/A.xspec")'), + ".default\n", + ]), + }, + { + file: "src/qualified-import.ts", + form: "the import type import(…).T<number>, located import through the argument list", + fixture: assemble([ + T14_11_LINKING_LEAD + "type G = ", + pin('import("../specs/A.xspec")'), + ".T<number>\n", + ]), + }, + { + file: "src/module.ts", + form: "declare module of the spec module, located whole", + fixture: assemble([ + T14_11_LINKING_LEAD, + pin('declare module "../specs/A.xspec" { }'), + "\n", + ]), + }, + { + file: "src/export-module.ts", + form: "export declare module of the spec module, located from declare", + fixture: assemble([ + T14_11_LINKING_LEAD + "export ", + pin('declare module "../specs/A.xspec" { }'), + "\n", + ]), + }, +]; + +/** + * The (x) forms as the code files of one workspace, each a staged-source + * record (S-9; arm (x) follows the body's first invocation). + */ +const T14_11_LINKING_FILES = T14_11_LINKING_FORMS.map( + ({ file, form, fixture }) => ({ + file, + fixture, + source: stagedTs(`T14-11 (x) ${file} (${form})`, fixture.text), + }), +); + +const T14_11_SPEC = "specs/A.mdx"; +const T14_11_CODE = "src/app.ts"; + +const T14_11_CASES: readonly RangeRuleCase[] = [ + { + arm: "a", + rule: "14.5 — an unresolved `d` array entry: the entry's own expression alone", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (a) specs/A.mdx (an unresolved d array entry)", + T14_11_D_ENTRY.text, + ), + }, + expected: [ + { condition: "14.5", locations: located(T14_11_SPEC, T14_11_D_ENTRY, 0) }, + ], + }, + { + arm: "b", + rule: "14.8 — `d={foo}`: the expression the braces enclose, braces excluded", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (b) specs/A.mdx (d={foo})", + T14_11_D_IDENT.text, + ), + }, + expected: [ + { condition: "14.8", locations: located(T14_11_SPEC, T14_11_D_IDENT, 0) }, + ], + }, + { + arm: "c", + rule: "14.20 — `d={}` and `d={ /* c */ }`: not well-formed MDX, the zero-length range at the closing brace, never 14.8", + config: SPECS_ONLY_CONFIG, + // S-9: an attribute value admits no empty expression (SPEC 2.7, 14.20); + // the stock grammar rejects both (`unexpected-empty-expression`) — the + // records are declared unparseable. + files: { + "specs/empty.mdx": stagedMdx( + "T14-11 (c) specs/empty.mdx (d={})", + T14_11_D_EMPTY.text, + "unparseable", + ), + "specs/comment-only.mdx": stagedMdx( + "T14-11 (c) specs/comment-only.mdx (d={ /* c */ })", + T14_11_D_COMMENT_ONLY.text, + "unparseable", + ), + }, + expected: [ + { + condition: "14.20", + locations: located("specs/empty.mdx", T14_11_D_EMPTY, 0), + }, + { + condition: "14.20", + locations: located("specs/comment-only.mdx", T14_11_D_COMMENT_ONLY, 0), + }, + ], + }, + { + arm: "d", + rule: "14.8 — `SPEC?.a;`: the statement's expression, exclusive of the `;`", + config: SPEC_AND_CODE_CONFIG, + files: { + [T14_11_SPEC]: T14_11_A_MDX, + [T14_11_CODE]: stagedTs( + "T14-11 (d) src/app.ts (SPEC?.a — a non-static bare reference)", + T14_11_OPTIONAL_CHAIN.text, + ), + }, + expected: [ + { + condition: "14.8", + locations: located(T14_11_CODE, T14_11_OPTIONAL_CHAIN, 0), + }, + ], + }, + { + arm: "e", + rule: "14.2 — each bearer's `id` attribute, one finding per bearer", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (e) specs/A.mdx (a two-segment top-level ID and a level-skipping child)", + T14_11_STRUCTURAL.text, + ), + }, + expected: [ + { + condition: "14.2", + locations: located(T14_11_SPEC, T14_11_STRUCTURAL, 0), + }, + { + condition: "14.2", + locations: located(T14_11_SPEC, T14_11_STRUCTURAL, 1), + }, + ], + }, + { + arm: "f", + rule: "14.3 — one finding locating each bearer's `id` attribute", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (f) specs/A.mdx (two bearers of dup)", + T14_11_DUPLICATE.text, + ), + }, + expected: [ + { + condition: "14.3", + locations: located(T14_11_SPEC, T14_11_DUPLICATE, 0, 1), + }, + ], + }, + { + arm: "g", + rule: "14.4 — one finding per violating `id` or `tags` attribute, at the attribute", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (g) specs/A.mdx (malformed id segments and a malformed tag)", + T14_11_SEGMENT_TAG.text, + ), + }, + expected: [0, 1, 2].map((index) => ({ + condition: "14.4", + locations: located(T14_11_SPEC, T14_11_SEGMENT_TAG, index), + })), + }, + { + arm: "h", + rule: "14.17 per form — a repeated prop at every attribute spelling the name; an unknown prop, a spread attribute, and an invalid `coverage` value at the attribute", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (h) specs/A.mdx (a repeated, an unknown, a spread, and an invalid coverage prop)", + T14_11_INVALID_PROP.text, + ), + }, + expected: [ + { + condition: "14.17", + locations: located(T14_11_SPEC, T14_11_INVALID_PROP, 0, 1), + }, + ...[2, 3, 4].map((index) => ({ + condition: "14.17", + locations: located(T14_11_SPEC, T14_11_INVALID_PROP, index), + })), + ], + }, + { + arm: "i", + rule: "14.1 — the section's opening tag", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (i) specs/A.mdx (a section without an id)", + T14_11_MISSING_ID.text, + ), + }, + expected: [ + { + condition: "14.1", + locations: located(T14_11_SPEC, T14_11_MISSING_ID, 0), + }, + ], + }, + { + arm: "j", + rule: "14.15 per form — declarations by their own characters, a dynamic `import()` by its call expression, a colliding declarator beside its import (14.7 for the chains it roots)", + config: SPEC_AND_CODE_CONFIG, + files: { + [T14_11_SPEC]: T14_11_A_MDX, + "specs/B.mdx": stagedMdx( + "T14-11 (j) specs/B.mdx (an import of a missing module beside A's)", + T14_11_IMPORT_MDX.text, + ), + "src/exp.ts": stagedTs( + "T14-11 (j) src/exp.ts (export * from the spec module)", + T14_11_EXPORT_TS.text, + ), + "src/req.ts": stagedTs( + "T14-11 (j) src/req.ts (import X = require of the spec module)", + T14_11_REQUIRE_TS.text, + ), + "src/dyn.ts": stagedTs( + "T14-11 (j) src/dyn.ts (a dynamic import() of the spec module)", + T14_11_DYNAMIC_TS.text, + ), + "src/collide.ts": stagedTs( + "T14-11 (j) src/collide.ts (a colliding const declarator beside its import)", + T14_11_COLLISION_TS.text, + ), + }, + expected: [ + { + condition: "14.15", + locations: located("specs/B.mdx", T14_11_IMPORT_MDX, 0), + }, + { + condition: "14.15", + locations: located("src/exp.ts", T14_11_EXPORT_TS, 0), + }, + { + condition: "14.15", + locations: located("src/req.ts", T14_11_REQUIRE_TS, 0), + }, + { + condition: "14.15", + locations: located("src/dyn.ts", T14_11_DYNAMIC_TS, 0), + }, + { + condition: "14.15", + locations: located("src/collide.ts", T14_11_COLLISION_TS, 0, 1), + }, + { + condition: "14.7", + locations: located("src/collide.ts", T14_11_COLLISION_TS, 2), + }, + { + condition: "14.7", + locations: located("src/collide.ts", T14_11_COLLISION_TS, 3), + }, + ], + }, + { + arm: "k", + rule: "14.16 per form — an element through its closing tag, a self-closing tag, a fragment `<>` through `</>`, an expression container brace through brace, an export statement whole", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_SPEC]: stagedMdx( + "T14-11 (k) specs/A.mdx (the five invalid construct forms)", + T14_11_CONSTRUCTS.text, + ), + }, + expected: [0, 1, 2, 3, 4].map((index) => ({ + condition: "14.16", + locations: located(T14_11_SPEC, T14_11_CONSTRUCTS, index), + })), + }, + { + arm: "l", + rule: "14.18 — the binding's identifier extended by the longest static chain it roots; a `text` binding alone", + config: SPEC_AND_CODE_CONFIG, + files: { + [T14_11_SPEC]: T14_11_A_MDX, + [T14_11_CODE]: stagedTs( + "T14-11 (l) src/app.ts (the node binding's chain SPEC.a.b and a text binding passed on)", + T14_11_USAGE_TS.text, + ), + }, + expected: [0, 1].map((index) => ({ + condition: "14.18", + locations: located(T14_11_CODE, T14_11_USAGE_TS, index), + })), + }, + { + arm: "m", + rule: "14.20 — one zero-length range at the failure's offset: a byte-order mark, an encoding failure, an MDX and a TypeScript syntax failure", + config: SPEC_AND_CODE_CONFIG, + // S-9: the four sources are 14.20's declared-unparseable forms — the + // three MDX sources' records and the TypeScript one's declared so. + files: { + "specs/bom.mdx": stagedMdx( + "T14-11 (m) specs/bom.mdx (a byte-order mark)", + T14_11_BOM_MDX.text, + "unparseable", + ), + "specs/enc.mdx": stagedMdx( + "T14-11 (m) specs/enc.mdx (an invalid byte after a valid 5-byte prefix)", + T14_11_ENCODING_MDX, + "unparseable", + ), + "specs/open.mdx": stagedMdx( + "T14-11 (m) specs/open.mdx (ends inside an unclosed section)", + T14_11_UNCLOSED_MDX.text, + "unparseable", + ), + "src/bad.ts": stagedTs( + "T14-11 (m) src/bad.ts (let x = ; — a TypeScript syntax failure)", + T14_11_SYNTAX_TS.text, + "unparseable", + ), + }, + expected: [ + { + condition: "14.20", + locations: located("specs/bom.mdx", T14_11_BOM_MDX, 0), + }, + { + condition: "14.20", + locations: [ + { + file: "specs/enc.mdx", + range: { + start: T14_11_ENCODING_OFFSET, + end: T14_11_ENCODING_OFFSET, + }, + }, + ], + }, + { + condition: "14.20", + locations: located("specs/open.mdx", T14_11_UNCLOSED_MDX, 0), + }, + { + condition: "14.20", + locations: located("src/bad.ts", T14_11_SYNTAX_TS, 0), + }, + ], + }, + { + arm: "p", + rule: "14.8 — `d={(BASE.a)}`: the enclosed expression first token through last, its parentheses included", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_BASE]: T14_11_A_MDX, + [T14_11_SPEC]: stagedMdx( + "T14-11 (p) specs/A.mdx (d={(BASE.a)})", + T14_11_D_PAREN.text, + ), + }, + expected: [ + { condition: "14.8", locations: located(T14_11_SPEC, T14_11_D_PAREN, 0) }, + ], + }, + { + arm: "q", + rule: "14.8 — `d={BASE.a, BASE.b}`: the whole comma sequence, one expression", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_BASE]: T14_11_A_MDX, + [T14_11_SPEC]: stagedMdx( + "T14-11 (q) specs/A.mdx (d={BASE.a, BASE.b})", + T14_11_D_COMMA.text, + ), + }, + expected: [ + { condition: "14.8", locations: located(T14_11_SPEC, T14_11_D_COMMA, 0) }, + ], + }, + { + arm: "r", + rule: "14.5 — `BASE.missing` alone: the braces and the whitespace and comment between them and the expression excluded, U+00A0, U+FEFF, U+1680, and U+3000 spelled there likewise", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_BASE]: T14_11_A_MDX, + [T14_11_SPEC]: stagedMdx( + "T14-11 (r) specs/A.mdx (BASE.missing past a block comment, U+00A0, U+FEFF, U+1680, and U+3000)", + T14_11_D_TRIVIA.text, + ), + }, + expected: [0, 1, 2, 3, 4].map((index) => ({ + condition: "14.5", + locations: located(T14_11_SPEC, T14_11_D_TRIVIA, index), + })), + }, + { + arm: "s", + rule: "14.8 — a spread entry `d={[...BASE.a]}`: `...BASE.a`, the `...` included", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_BASE]: T14_11_A_MDX, + [T14_11_SPEC]: stagedMdx( + "T14-11 (s) specs/A.mdx (d={[...BASE.a]})", + T14_11_D_SPREAD.text, + ), + }, + expected: [ + { + condition: "14.8", + locations: located(T14_11_SPEC, T14_11_D_SPREAD, 0), + }, + ], + }, + { + arm: "t", + rule: "14.8 — elisions: one finding per array literal at the whole literal, brackets included, however many holes", + config: SPECS_ONLY_CONFIG, + files: { + [T14_11_BASE]: T14_11_A_MDX, + [T14_11_SPEC]: stagedMdx( + "T14-11 (t) specs/A.mdx (two array literals with elisions)", + T14_11_D_ELISIONS.text, + ), + }, + expected: [0, 1].map((index) => ({ + condition: "14.8", + locations: located(T14_11_SPEC, T14_11_D_ELISIONS, index), + })), + }, + { + arm: "u", + rule: "14.15 — a colliding non-import declaration by the construct binding the name, per form: `let SPEC;` at `SPEC`, `const { SPEC } = o` at `{ SPEC } = o`, `@dec class SPEC {}` from `@`, `export class SPEC {}` from `class`, each beside its import (14.7 for the chains it roots)", + config: SPEC_AND_CODE_CONFIG, + files: { + [T14_11_SPEC]: T14_11_A_MDX, + ...Object.fromEntries( + T14_11_COLLISION_FILES.map((entry) => [entry.file, entry.source]), + ), + }, + expected: T14_11_COLLISION_FILES.flatMap((entry) => [ + { + condition: "14.15", + locations: located(entry.file, entry.fixture, 0, 1), + }, + { condition: "14.7", locations: located(entry.file, entry.fixture, 2) }, + { condition: "14.7", locations: located(entry.file, entry.fixture, 3) }, + ]), + }, + { + arm: "v", + rule: "14.20 — an encoding failure's zero-length range at the first byte of the first ill-formed sequence: a valid 5-byte prefix then `FF` → 5, `41 E2 82 41` → 1, `C0 80` → 0, `ED A0 80` → 0, `41 E2 82` at the file's end → 1, a spec and a code source alike", + config: SPEC_AND_CODE_CONFIG, + // S-9: every source here is invalid UTF-8, 14.20's declared form — each + // spec source's and each code source's record declared unparseable + // (T14_11_ENCODING_FILES). + files: Object.fromEntries( + T14_11_ENCODING_FILES.map((entry) => [entry.file, entry.contents]), + ), + expected: T14_11_ENCODING_FILES.map((entry) => ({ + condition: "14.20", + locations: [entry.location], + })), + }, + ...T14_11_REASSERTED_CASES, + { + arm: "x", + rule: "14.15 per form — `export import X = require(…)` from `import`, its leading `export` excluded; an import type `import` through the closing parenthesis of its argument list, a `typeof` before it and a qualifier or type arguments after it excluded; a string-named module declaration by its own characters, `declare` included, a leading `export` excluded", + config: SPEC_AND_CODE_CONFIG, + files: { + [T14_11_SPEC]: T14_11_A_MDX, + ...Object.fromEntries( + T14_11_LINKING_FILES.map((entry) => [entry.file, entry.source]), + ), + }, + expected: T14_11_LINKING_FILES.map((entry) => ({ + condition: "14.15", + locations: located(entry.file, entry.fixture, 0), + })), + }, +]; + +/** + * The T14-11 contract over one arm's findings: the exact condition multiset + * (one finding per offending construct — 14.4's and 14.17's per-attribute + * cardinality, 14.3's one finding); every finding concerning no path (12.7: + * located conditions carry `path` null); and, per condition, the complete + * location lists compared as a multiset — each list in 12.7's within-finding + * order, each range `{"start", "end"}` exactly the pinned bytes (1.7). Order + * among same-condition findings is 12.7's ordering contract, not this + * test's, so the lists are sorted before comparison. + */ +function assertExactRanges( + findings: readonly Finding[], + expected: readonly ExactFindingExpectation[], + context: string, +): void { + const counts: Record<string, number> = {}; + for (const expectation of expected) { + counts[expectation.condition] = (counts[expectation.condition] ?? 0) + 1; + } + assertConditionCounts( + findings, + counts, + `${context} — the staged conditions, one finding per offending ` + + `construct and none beside (SPEC 14)`, + ); + for (const finding of findings) { + if (finding.path !== null) { + fail( + `${context}: a finding locating in source concerns no path — \`path\` ` + + `is null for located conditions (SPEC 12.7, 14); got ` + + `${JSON.stringify(finding.path)} on the condition-` + + `${String(finding.condition)} finding (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + const byJson = (a: unknown, b: unknown): number => { + const left = JSON.stringify(a); + const right = JSON.stringify(b); + return left < right ? -1 : left > right ? 1 : 0; + }; + for (const condition of new Set(expected.map((e) => e.condition))) { + const want = expected + .filter((expectation) => expectation.condition === condition) + .map((expectation) => expectation.locations) + .sort(byJson); + const got = findings + .filter((finding) => finding.condition === condition) + .map((finding) => + finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + ) + .sort(byJson); + assertSameJson( + got, + want, + `${context}: the condition-${condition} finding(s) locate exactly the ` + + `pinned byte ranges — every offending construct, each by the range ` + + `SPEC 14 fixes for the condition, \`{"start", "end"}\` as zero-based ` + + `byte offsets, end-exclusive (SPEC 14, 1.7, 12.7)`, + ); + } +} + +/** One range-rule arm: `build --json` over its workspace, findings pinned. */ +async function runRangeRuleArm( + product: ProductBinding, + kase: RangeRuleCase, +): Promise<void> { + const context = `T14-11 (${kase.arm}) ${kase.rule}`; + await withWorkspace( + { files: { "xspec.config.ts": kase.config, ...kase.files } }, + async (workspace) => { + const findings = await buildFindings( + product, + workspace, + `${context} — \`build --json\` exits 1 with the findings report ` + + `(SPEC 12.0, 12.7)`, + ); + assertExactRanges(findings, kase.expected, context); + }, + ); +} + +// (n) Per-spelling resolution inside a repeated `d` (SPEC 11.2): the 14.17 +// repetition locates both `d` attributes; the resolving entry records one +// occurrence spanning its own expression; the unresolved entry is one 14.5 +// finding at its expression. +const T14_11_REPEATED_D = assemble([ + T14_11_PREAMBLE, + '<S id="r" ', + pin('d={"ok"}'), + " ", + pin('d={"absent"}'), + ">\nRepeated d.\n</S>\n", +]); + +/** + * The expression a `d={…}` attribute's braces enclose: after the three ASCII + * bytes `d={`, before the one-byte closing brace (SPEC 14, 5.7). + */ +function enclosedExpression(attribute: { + readonly start: number; + readonly end: number; +}): { start: number; end: number } { + return { start: attribute.start + 3, end: attribute.end - 1 }; +} + +// Arm (n)'s source, staged after the table's arms' invocations (a +// staged-source record, S-9). +const T14_11_REPEATED_D_STAGED = stagedMdx( + "T14-11 (n) specs/A.mdx (a repeated d, one entry resolving)", + T14_11_REPEATED_D.text, +); + +async function runRepeatedDependencyArm( + product: ProductBinding, +): Promise<void> { + const context = + "T14-11 (n) per-spelling resolution inside a repeated `d` (SPEC 11.2)"; + const file = T14_11_SPEC; + const attributes = located(file, T14_11_REPEATED_D, 0, 1); + const resolving = enclosedExpression(attributes[0]!.range); + const unresolved = enclosedExpression(attributes[1]!.range); + const expected: readonly ExactFindingExpectation[] = [ + { condition: "14.17", locations: attributes }, + { condition: "14.5", locations: [{ file, range: unresolved }] }, + ]; + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [file]: T14_11_REPEATED_D_STAGED, + }, + }, + async (workspace) => { + const buildContext = `${context} — \`build --json\``; + assertExactRanges( + await buildFindings(product, workspace, buildContext), + expected, + buildContext, + ); + const occurrencesContext = `${context} — \`occurrences\``; + const report = decodeOccurrencesReport( + await runJsonExpecting( + product, + workspace, + ["occurrences"], + 1, + `${occurrencesContext} exits 1: the answer carries the domain ` + + `file's findings (SPEC 11.2, 12.0)`, + ), + occurrencesContext, + ); + assertExactRanges(report.findings, expected, occurrencesContext); + assertSameJson( + report.occurrences.map(({ file: recorded, range, kind, target }) => ({ + file: recorded, + range: { start: range.start, end: range.end }, + kind, + target, + })), + [{ file, range: resolving, kind: "depends", target: `${file}#ok` }], + `${occurrencesContext}: exactly one occurrence — the resolving ` + + `entry's, spanning its own expression, kind depends, target ` + + `${file}#ok — the unresolved entry recording none (SPEC 11.2, 5.7)`, + ); + }, + ); +} + +// (o) 14.20 for a refused read (SPEC 14.25): the zero-length range at offset +// 0. A permission-based staging (E-1): Linux leg only, run last (the +// NU3_STAGED pattern of section-11.5.ts; the runner must be unprivileged). +const T14_11_REFUSAL_STAGED = process.platform === "linux"; + +// Arm (o)'s refused file (a staged-source record, S-9: the arm follows the +// body's earlier invocations). +const T14_11_REFUSED_SOURCE = stagedMdx( + "T14-11 (o) specs/R.mdx (the source staged unreadable)", + '<S id="r">\nRefused content.\n</S>\n', +); + +async function runRefusedReadArm(product: ProductBinding): Promise<void> { + const context = + "T14-11 (o) 14.20 for a refused read: the zero-length range at offset 0 " + + "(SPEC 14.25; Linux leg, E-1)"; + const file = "specs/R.mdx"; + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_11_SPEC]: T14_11_A_MDX, + [file]: T14_11_REFUSED_SOURCE, + }, + }, + async (workspace) => { + const staging = await stageReadRefusalOfFile(workspace.path(file)); + try { + const findings = await buildFindings( + product, + workspace, + `${context} — \`build --json\``, + ); + assertExactRanges( + findings, + [ + { + condition: "14.20", + locations: [{ file, range: { start: 0, end: 0 } }], + }, + ], + context, + ); + } finally { + await staging.restore(); + } + }, + ); +} + +const T14_11 = defineProductTest({ + id: "T14-11", + title: + "per-condition ranges: byte-precise fixtures against precomputed offsets, one arm per range rule of SPEC 14 beyond T14-8's — `d` value expressions (an array entry alone, `d={foo}`'s enclosed expression, `(BASE.a)` with its parentheses, a comma sequence whole, `BASE.missing` alone past a block comment and past U+00A0/U+FEFF/U+1680/U+3000, a spread entry with its `...`, the elisions of one array literal as one finding at the whole literal — two literals, two findings), `d={}` and `d={ /* c */ }` as 14.20 at the closing brace (never 14.8), a non-static bare reference exclusive of its `;`, the attribute conditions 14.2/14.3/14.4/14.17 at the attribute's own characters (one finding per violating attribute; a repeated prop locating every spelling), 14.1's opening tag, 14.15's declaration forms (`export import X = require(…)` from `import`, its `export` excluded), import types (`import` through the closing parenthesis of the argument list — `typeof import(…).default` and `import(…).T<number>`, the `typeof`, qualifier, and type arguments excluded), string-named module declarations (`declare module` whole, `export declare module` from `declare`), and colliding declarations (the declarator `SPEC = 1`, `let SPEC;` at `SPEC`, `const { SPEC } = o` at `{ SPEC } = o`, `@dec class SPEC {}` from `@`, `export class SPEC {}` from `class`), 14.16's construct forms (a fragment `<>` through `</>` included), 14.18's chain-extended binding, 14.20's zero-length offsets (a byte-order mark; an encoding failure at the first byte of the first ill-formed sequence — a valid 5-byte prefix then `FF` → 5, `41 E2 82 41` and `41 E2 82` at the file's end → 1, `C0 80` and `ED A0 80` → 0 — in a spec and a code source alike; syntax — its own two forms and, re-asserted the same way, the offsets T2.3-3, T2.4-2, T2.7-3, T2.7-4, and T14-12 pin; and — Linux leg — a refused read), and a repeated `d`'s per-spelling resolution — every range exact, never a line/column pair (SPEC 14, 1.4, 1.6, 1.7, 2.4, 4, 5.7, 11.2, 11.4, 12.7)", + run: async (product) => { + for (const kase of T14_11_CASES) { + await runRangeRuleArm(product, kase); + } + await runRepeatedDependencyArm(product); + if (T14_11_REFUSAL_STAGED) { + await runRefusedReadArm(product); + } + }, +}); + +/** TEST-SPEC §14 T14-1…T14-8 and T14-11, 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, + T14_11, ]; diff --git a/test/suite/registry/section-15.ts b/test/suite/registry/section-15.ts index ca9f17a8..fdbb9909 100644 --- a/test/suite/registry/section-15.ts +++ b/test/suite/registry/section-15.ts @@ -67,6 +67,7 @@ import { import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertRequirementCategories, impactAgainst } from "./section-5.6.js"; import { assertImpactedCode, readSourceText } from "./section-9.js"; @@ -119,6 +120,19 @@ const specSource = (helloText: string): string => "", ].join("\n"); +// The edit of print.hello's text and its later restoration are staged after +// the walkthrough's `build`, so they are ledger records (S-9's +// before-any-product clause; helpers/staged-mdx.ts) — the same template +// calls, moved to module level. +const T15_1_HELLO_EDITED = stagedMdx( + "T15-1 specs/SPEC.mdx with print.hello's text edited", + specSource("Print hello, edited."), +); +const T15_1_HELLO_RESTORED = stagedMdx( + "T15-1 specs/SPEC.mdx restored to its original text", + specSource("Print hello."), +); + const DERIVED_SOURCE = [ 'import SPEC from "./SPEC.xspec"', "", @@ -299,7 +313,7 @@ const T15_1 = defineProductTest({ ); // --- Editing print.hello's text: the listed categories (SPEC 15) ----- - await workspace.file(SPEC_FILE, specSource("Print hello, edited.")); + await workspace.file(SPEC_FILE, T15_1_HELLO_EDITED); await buildOk( product, workspace, @@ -455,7 +469,7 @@ const T15_1 = defineProductTest({ // --- The rename taken instead: journal mapping, no-change impact ----- // "Instead": the edit is reverted to its exact baseline bytes, so the // rename operates on the workspace as committed at `base`. - await workspace.file(SPEC_FILE, specSource("Print hello.")); + await workspace.file(SPEC_FILE, T15_1_HELLO_RESTORED); await buildOk( product, workspace, diff --git a/test/suite/registry/section-16-p1.ts b/test/suite/registry/section-16-p1.ts index 109e1752..ab939f45 100644 --- a/test/suite/registry/section-16-p1.ts +++ b/test/suite/registry/section-16-p1.ts @@ -2,84 +2,180 @@ // // One registered product-facing property test (C-2 "one code path"): seeded, // reproducible generators (helpers/property.ts, H-10; fixed seed set in CI, -// E-5) produce segment candidates and `tags`-prop values over a code-point +// E-5) produce segment draws and `tags`-prop values over a code-point // alphabet weighted toward the SPEC 1.4 boundary classes P-1 names — the -// whitespace and control classes of 1.4, the excluded boundary code points -// U+00A0/U+0085/U+2028, `.` and `#`, the forbidden names, and the glob -// metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which -// are ordinary valid segment characters. Each trial stages its value in a +// whitespace and control classes of 1.4, U+00A0/U+0085 (valid, in neither +// class), U+2028/U+2029 (in neither class, yet invalid under 1.4's +// quote-and-escape bullet), `.` and `#`, the quote, escape, and +// character-reference characters `"` `'` `\` `&` and U+FFFD (each invalid, +// 1.4), the forbidden names, and the glob metacharacters of common dialects +// (`[` `]` `{` `}` `!` `+` `(` `)`), which are ordinary valid segment +// characters. Each trial stages its draw in a // fresh workspace (H-1), drives `build` strictly as a subprocess (H-2/H-5), // and asserts acceptance iff the harness-side oracle — an independent -// restatement of SPEC 1.4 (exact character classes) and 2.6 (tag splitting) -// — accepts: +// restatement of SPEC 1.4 (exact character classes), 1.3 (`.` is the ID +// separator) and 2.6 (tag splitting) — accepts: // -// * segment: accepted (`build` exit 0) iff the value satisfies 1.4 as one -// segment. Rejections are exit 1 with a findings report whose conditions -// are exactly the staged ones: 14.4 alone for a dot-free segment; for a -// dot-containing value (structurally more than one segment at top level, -// 1.3) 14.2 and/or 14.4 — sub-segment analysis of an invalid ID is not -// pinned by SPEC 14, the accept/reject boundary is. +// * segment: the property judges the staged spelling's resulting split. A +// draw containing `.` can be spelled as no single segment (1.4: `.` is +// the ID separator), so it stages as that many segments — its bearer +// nested beneath the ancestor chain the split's prefixes spell, one +// section per level, each ancestor's `id` the draw's prefix up to that +// dot — and a `.`-free draw stages as one top-level segment. The +// structural rule (1.3) therefore holds by construction whenever the +// segments are valid (structural-rule outcomes are T1.3-2..4's, never +// this oracle's), and `build` must accept (exit 0) iff every resulting +// segment satisfies 1.4 — the empty segments a leading, trailing, or +// doubled dot yields included as invalid. Rejections are exit 1 with a +// findings report whose conditions are exactly the staged ones: 14.4 +// alone for a dot-free draw (one top-level single-segment ID); for a +// dot-containing draw 14.4 and/or 14.2 — every ID from the offending +// level down carries the invalid segment, and whether a product reads an +// ill-formed level (`a.` beneath `a`) as a 1.4 violation alone or also +// as a structural one is sub-segment analysis SPEC 14 does not pin; the +// accept/reject boundary is. // * tags: accepted iff every token of the 2.6 split (runs of 1.4 // whitespace, leading/trailing ignored) satisfies 1.4 with `.` allowed — // whitespace never reaches tag validation, and zero tokens are accepted // as an omitted prop (T2.6-2). Rejections report 14.4 only. // -// CONF-VALID in-scope; certified by §VIOL-VALID-CTRL and §VIOL-VALID-WIDE -// (CERTIFICATIONS.md). Fixtures stay within the CONF-VALID scope: one -// configured spec group of `.mdx` sources whose sections carry `id`/`tags` -// props only, and the command surface is `build` with 14.1–14.4 reporting. -// The generator's reachability of the certifying classes is deterministic -// under the fixed seed set (E-5): the committed seeds stage, many times over, -// (a) values whose only 1.4 violation is a non-whitespace control character — -// accepted by VIOL-VALID-CTRL where 1.4 rejects them — and (b) 1.4-valid -// values containing U+00A0/U+0085/U+2028 — rejected by VIOL-VALID-WIDE where -// 1.4 accepts them. CERT-09/CERT-10 verify both against the real fixtures. +// CONF-VALID in-scope; certified by §VIOL-VALID-CTRL, §VIOL-VALID-WIDE, and +// §VIOL-VALID-SEP (CERTIFICATIONS.md). Fixtures stay within the CONF-VALID +// scope: one configured spec group of `.mdx` sources whose sections carry +// `id`/`tags` props only — values in either quote kind, bearers nested as +// deeply as the `.`-bearing draws stage them — and the command surface is +// `build` with 14.1–14.4 reporting. The generator's reachability of the +// certifying classes is deterministic under the fixed seed set (E-5): the +// committed seeds stage, many times over, (a) draws whose only 1.4 violation is +// a non-whitespace control character — accepted by VIOL-VALID-CTRL where 1.4 +// rejects them — (b) 1.4-valid draws containing U+00A0 or U+0085 — +// VIOL-VALID-WIDE's valid boundaries, U+00A0 and U+0085 alone, rejected by it +// where 1.4 accepts them — and (c) draws whose only 1.4 violation is U+2028 or +// U+2029 — accepted by VIOL-VALID-SEP where 1.4 rejects them — each of the two +// code points in segment draws and in tag draws alike (AGENTS.md records the +// counts). The certification runner verifies each against the real fixtures. +// The same seeds stage the shapes the staging discipline below introduces — +// `.`-bearing draws accepted nested and rejected at an ancestor or at the +// bearer, single-quoted spellings — through the `dottedChain` shape and the +// quote characters' alphabet weights. // // Byte-exact staging per the SUITE-03 discipline (HARNESS-01): every // character under test — raw control bytes included — is written into the // fixture's source bytes exactly as generated (UTF-8 encoded, no BOM, no -// newline translation), inside a double-quoted attribute value, so validity -// (14.4) — never source encoding (14.20) — is the condition at stake. In this +// newline translation), inside a quoted attribute value, so validity (14.4) +// — never source encoding (14.20) — is the condition at stake. In this // module's own source the characters are constructed from hex code points // via `cp(0x…)` (visible, tool-safe, immune to editor/formatter // normalization); the builder encodes the resulting strings to the identical // raw bytes. // -// Two staging guards keep the generated values inside that model, and are -// deliberate alphabet/shape choices, not oracle behavior: -// * The alphabet omits `"` (the staging delimiter), `&` (MDX decodes -// character references in attribute values), and a few other -// MDX-structural ASCII characters (`'`, `<`, `>`, backslash): all are -// ordinary valid segment characters but none is a P-1 boundary class, -// and staging them would exercise MDX attribute lexing, not 1.4 -// validity. -// * Generated values never stage a blank line inside the opening tag (a +// Staging discipline (SPEC 2.7, 2.4: an `id` or `tags` value is a plain +// single- or double-quoted static string read verbatim, no escape or +// character-reference form being interpreted): each draw is spelled in the +// quote kind its content admits — single quotes for a draw containing `"`, +// double quotes for one containing `'`; either kind is admissible for a draw +// containing neither, and this module spells those double. Since 1.4 makes +// every draw containing `"` or `'` invalid (the quote, escape, and +// character-reference characters), such a draw is staged in the other quote +// kind and predicted rejected (14.4), never set aside. A draw containing +// both quote characters admits no static-string spelling, so it is never +// staged: the staged file could only be unparseable (14.20) or prop-invalid +// (14.17) — a harness artifact of exactly the class H-11 forbids reporting +// as a product failure — and it is invalid under the oracle too, so its +// exclusion loses no prediction. The generators redraw such a draw +// (`spellable` below), keeping each property's trial count. That the product +// accepts both quote kinds alike is T2.7-3's deterministic question, not +// this generator's. +// +// Two further staging guards keep the generated values inside that model, +// and are deliberate alphabet/shape choices, not oracle behavior: +// * The alphabet omits the MDX-structural ASCII characters `<` and `>`: +// ordinary valid segment characters that are no P-1 boundary class, and +// staging them would exercise MDX attribute lexing, not 1.4 validity. +// The quote, escape, and character-reference characters `"` `'` `\` `&` +// and U+FFFD — invalid boundary classes of 1.4 — are all in the +// alphabet: the two quote characters under the staging discipline +// above; `\` and `&` as raw characters inside the quoted value, where +// 2.4 reads them verbatim — no escape sequence or character reference +// is interpreted — so the oracle predicts 14.4 on the character itself, +// and a product interpreting an escape or reference form (reading the +// six-character escape of `.` or the reference `.` as `.`) answers +// for a value it was never given; the verbatim spellings themselves are +// T1.4-1's and T1.4-4's deterministic arms. U+FFFD is staged as the +// literal, validly encoded code point (its UTF-8 bytes EF BF BD), so +// validity (14.4) — never source encoding (14.20) — is at stake. +// * Generated values never stage a blank line inside an opening tag (a // line-terminator sequence enclosing only spaces/tabs): MDX flow tags do // not admit blank lines, so such staging would test parseability (14.20) // instead. Single line terminators — the 1.4 whitespace class members // P-1 names — are staged freely, exactly as SUITE-03's matrix stages -// them one at a time. +// them one at a time. A prefix of a hazard-free draw is hazard-free (its +// terminator pairs are a subset), so the ancestor `id`s a `.`-bearing +// draw stages are covered by the repair of the whole draw. +// +// S-9 (TEST-SPEC 17; the §16 preamble: each draw is checked before the +// product is driven on it): accepted and rejected draws alike derive under +// the stock MDX 3 grammar by construction — every draw sits inside a quoted +// attribute value of a flow tag, holding no quote of its own kind and no +// blank line (above) — so each draw's staged source is judged by the +// harness's derivability check before `build` sees it +// (helpers/property.ts `drawSources`, fed from the same pure staging +// functions the bodies stage from), and the fixed vector set +// `P1_FORM_VECTORS` below — every alphabet character alone and inside a +// value, the forbidden-name shapes, the ancestor chains a `.`-bearing draw +// spells (empty segments included), single line terminators and repaired +// blank-line hazards inside a value, and the 2.6 whitespace runs — each +// staged as a segment draw and as a `tags` value in every admissible quote +// kind, is judged before any product exists +// (test/self/s9-fixture-well-formedness.test.ts). import type { Finding } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; -import type { Choices, Gen } from "../../helpers/property.js"; +import type { Choices, DrawSource, 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 { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { buildFindings, buildOk } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group — the -// CONF-VALID scope, byte-identical to SUITE-03's staging. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// CONF-VALID scope, byte-identical to SUITE-03's staging. A TypeScript +// staged-source record (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), well-formed: every trial stages it afresh, from the second trial +// on after the body's first product invocation — an initial file S-7's +// sweep never reaches, so the ledger self-test judges it before any product +// exists. +const SPECS_ONLY_CONFIG = stagedTs( + "P-1 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the property's one configuration file, the record above — judged + * as a record by test/self/s9-staged-sources.test.ts too, and here beside + * every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); P-1 composes no code + * source. + */ +export const P1_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-1 configuration (SPECS_ONLY_CONFIG)", + "xspec.config.ts", + SPECS_ONLY_CONFIG.source, + ], +]; /** The character with the given code point (hex-spelled, tool-safe). */ function cp(codePoint: number): string { @@ -92,14 +188,23 @@ const VT = cp(0x000b); const FF = cp(0x000c); const CR = cp(0x000d); const SPACE = cp(0x0020); - -// --- the SPEC 1.4 / 2.6 oracle ---------------------------------------------- +const DOUBLE_QUOTE = cp(0x0022); +const SINGLE_QUOTE = cp(0x0027); +const AMPERSAND = cp(0x0026); +const BACKSLASH = cp(0x005c); +const DOT = cp(0x002e); +const REPLACEMENT_CHARACTER = cp(0xfffd); + +// --- the SPEC 1.3 / 1.4 / 2.6 oracle ------------------------------------------- // // An independent restatement of the spec text, judging the exact value the // trial stages. SPEC 1.4: whitespace means exactly U+0009 U+000A U+000B // U+000C U+000D U+0020, control characters means exactly U+0000–U+001F and // U+007F, and no other code point (U+00A0, U+0085, U+2028 included) belongs -// to either class. +// to either class. U+2028 and U+2029, in neither class, are barred by 1.4's +// quote-and-escape bullet beside `"` `'` `\` `&`, and split no tag (2.6 +// splits on the whitespace class alone). SPEC 1.3: `.` separates an ID's +// segments. const FORBIDDEN_NAMES: readonly string[] = [ "$", @@ -117,12 +222,33 @@ function isSpecControl(codePoint: number): boolean { return codePoint <= 0x001f || codePoint === 0x007f; } +/** + * The quote, escape, and character-reference characters SPEC 1.4 excludes + * from segments and tags — `"`, `'`, `\`, `&` — so that every segment and + * tag is spelled verbatim in every form (2.4, 2.7, 6.4). + */ +const QUOTE_ESCAPE_REFERENCE_CODE_POINTS: ReadonlySet<number> = new Set([ + 0x0022, 0x0027, 0x005c, 0x0026, +]); + +/** + * U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR), which the same + * bullet of SPEC 1.4 bars from segments and tags. Neither belongs to the + * whitespace or the control class, so neither splits a tag (`splitTags` + * below): a tag holding one is one token, predicted rejected (14.4). + */ +const LINE_SEPARATOR_NAMES: ReadonlyMap<number, string> = new Map([ + [0x2028, "LINE SEPARATOR"], + [0x2029, "PARAGRAPH SEPARATOR"], +]); + type Verdict = { readonly valid: true } | { readonly valid: false; readonly reason: string }; /** * SPEC 1.4 validity of one segment or tag value. The two roles differ in - * exactly one rule: a tag MAY contain `.`. + * exactly one rule: a tag MAY contain `.` (a segment never does — the split + * below leaves none in a segment, so that rule is the tag role's contrast). */ function valueVerdict(value: string, role: "segment" | "tag"): Verdict { if (value.length === 0) { @@ -154,10 +280,69 @@ function valueVerdict(value: string, role: "segment" | "tag"): Verdict { reason: `the ${role} contains the control character ${codePointName(codePoint)} (1.4)`, }; } + if (QUOTE_ESCAPE_REFERENCE_CODE_POINTS.has(codePoint)) { + return { + valid: false, + reason: `the ${role} contains the quote, escape, or character-reference character ${codePointName(codePoint)} (1.4)`, + }; + } + const separatorName = LINE_SEPARATOR_NAMES.get(codePoint); + if (separatorName !== undefined) { + return { + valid: false, + reason: `the ${role} contains ${codePointName(codePoint)} (${separatorName}), barred by the quote-and-escape bullet (1.4)`, + }; + } + if (codePoint === 0xfffd) { + return { + valid: false, + reason: `the ${role} contains U+FFFD, the replacement character (1.4)`, + }; + } } return { valid: true }; } +/** + * The segments a draw spells (SPEC 1.3: `.` is the ID separator, so a draw + * containing `.` can be spelled as no single segment and stages as that many + * — TEST-SPEC P-1). The empty draw splits to one empty segment; a leading, + * trailing, or doubled dot yields an empty segment; each is a 1.4 violation. + */ +function splitSegments(draw: string): readonly string[] { + return draw.split(DOT); +} + +type SegmentsVerdict = + | { readonly accepted: true; readonly segments: readonly string[] } + | { + readonly accepted: false; + readonly segments: readonly string[]; + readonly reason: string; + }; + +/** + * Acceptance of a whole segment draw: `build` accepts the staging iff every + * segment of the split satisfies 1.4 (the structural rule holds by + * construction — see the module header). + */ +function segmentsVerdict(draw: string): SegmentsVerdict { + const segments = splitSegments(draw); + for (let index = 0; index < segments.length; index += 1) { + const verdict = valueVerdict(segments[index]!, "segment"); + if (!verdict.valid) { + return { + accepted: false, + segments, + reason: + `segment ${String(index + 1)} of ${String(segments.length)}, ` + + `${renderCodePoints(segments[index]!)}, is invalid: ${verdict.reason}`, + }; + } + } + return { accepted: true, segments }; +} + /** * The 2.6 splitting model: tags are split on runs of 1.4 whitespace, and * leading and trailing whitespace is ignored — so no token is ever empty or @@ -216,7 +401,8 @@ function codePointName(codePoint: number): string { /** * Counterexample/context rendering: the JSON escape plus the exact code * points, so control and boundary characters are unambiguous in failure - * messages (JSON.stringify escapes controls but not U+00A0/U+0085/U+2028). + * messages (JSON.stringify escapes controls but not U+00A0, U+0085, U+2028, + * or U+2029). */ function renderCodePoints(value: string): string { const points = [...value] @@ -225,6 +411,53 @@ function renderCodePoints(value: string): string { return `${JSON.stringify(value)} <${points}>`; } +// --- the quote discipline (SPEC 2.7; module header) ---------------------------- + +function containsBothQuoteKinds(value: string): boolean { + return value.includes(DOUBLE_QUOTE) && value.includes(SINGLE_QUOTE); +} + +/** + * The quote kind a draw's content admits: single quotes for a draw + * containing `"`, double quotes otherwise — so double quotes for one + * containing `'`, and for one containing neither (either kind would do). + * Never asked of a draw containing both: `spellable` keeps those out. + */ +function quoteKindFor(value: string): string { + return value.includes(DOUBLE_QUOTE) ? SINGLE_QUOTE : DOUBLE_QUOTE; +} + +/** + * Redraws `spellable` spends before repairing a draw instead. Trials are + * bounded (H-10), and at the alphabet's quote weights a draw containing both + * quote characters is rare enough that the bound is never reached in + * practice. + */ +const MAX_SPELLING_REDRAWS = 32; + +/** + * Redraw while the draw contains both quote characters — such a draw admits + * no static-string spelling and is never staged (module header) — so each + * property keeps its trial count and every staged draw is spellable. The + * redraws are further draws on the same tape, so replay and shrinking + * reproduce them exactly (a shrink candidate exhausted mid-redraw is an + * unsatisfiable tape the shrinker discards). Past the redraw bound the last + * draw is repaired by dropping its `'` characters — a spellable draw staged + * rather than a trial failed as a harness defect. + */ +function spellable(draw: Gen<string>): Gen<string> { + return (choices) => { + let value = draw(choices); + for (let redraws = 0; containsBothQuoteKinds(value); redraws += 1) { + if (redraws === MAX_SPELLING_REDRAWS) { + return value.split(SINGLE_QUOTE).join(""); + } + value = draw(choices); + } + return value; + }; +} + // --- generators ---------------------------------------------------------------- // // Weighted code-point alphabet. Order is simplest-first: weightedPick shrinks @@ -253,24 +486,47 @@ const ALPHABET: ReadonlyArray<readonly [number, string]> = [ [2, "+"], [2, "("], [2, ")"], - // The boundary code points SPEC 1.4 excludes from both classes — valid - // (T1.4-2 anchors; §VIOL-VALID-WIDE's flip class): no-break space, next - // line, line separator. + // 1.4's quote-and-escape bullet — the quote, escape, and + // character-reference characters, U+2028, and U+2029 — and U+FFFD: + // invalid boundary classes (SPEC 1.4). Each quote character is spellable + // only inside the other quote kind (SPEC 2.7; the staging discipline in + // the module header chooses the kind per draw, predicts rejection, and + // never stages a draw holding both). `\` and `&` are staged raw inside + // the quoted value, which SPEC 2.4 reads verbatim (no escape sequence or + // character reference interpreted), so a draw holding one is predicted + // rejected on the character itself. U+2028 (line separator) and U+2029 + // (paragraph separator) are in neither the whitespace nor the control + // class, so neither splits a tag (2.6): a draw holding one is predicted + // rejected on the code point itself. Each is weighted above its group so + // that the fixed seeds reach it, in segment draws and in tag draws alike, + // in draws holding no other violation — §VIOL-VALID-SEP's flip class, + // which a draw must otherwise dodge every invalid class to enter + // (AGENTS.md records the counts). They and U+FFFD are staged as the + // literal, validly encoded code points, so 14.4 — never 14.20 — is at + // stake. + [3, DOUBLE_QUOTE], + [3, SINGLE_QUOTE], + [3, BACKSLASH], + [3, AMPERSAND], + [10, cp(0x2028)], + [10, cp(0x2029)], + [3, REPLACEMENT_CHARACTER], + // The valid boundary code points SPEC 1.4 excludes from both classes and + // bars by no rule, U+00A0 and U+0085 alone (T1.4-2 anchors; + // §VIOL-VALID-WIDE's flip class): no-break space, next line. [5, cp(0x00a0)], [5, cp(0x0085)], - [5, cp(0x2028)], - // Breadth beyond the named set: further code points a Unicode-whitespace - // (JS regex `\s`-style) classifier would misclassify (en quad, paragraph - // separator), plus non-ASCII and non-BMP valid characters. All valid per - // 1.4 ("no other code point belongs to either class"). + // Breadth beyond the named set: a further code point a Unicode-whitespace + // (JS regex `\s`-style) classifier would misclassify (en quad), plus + // non-ASCII and non-BMP valid characters. All valid per 1.4 ("no other + // code point belongs to either class", and no rule bars them). [1, cp(0x2000)], - [1, cp(0x2029)], [1, cp(0x00e9)], [1, cp(0x4e2d)], [1, cp(0x1f600)], - // "." (invalid in a segment, valid in a tag, structural in ids) and "#" - // (invalid everywhere). - [4, "."], + // "." (the ID separator — a segment draw containing it stages as several + // segments, a tag may contain it) and "#" (invalid everywhere). + [4, DOT], [4, "#"], // The 1.4 whitespace class, exactly — invalid in segments; the separators // 2.6 splits tags on. @@ -297,12 +553,14 @@ const WHITESPACE_CHARACTERS: readonly string[] = [SPACE, TAB, LF, VT, FF, CR]; /** * Drop every line terminator (CRLF, lone LF, lone CR) that would close a - * blank line — a line containing only spaces/tabs — inside the staged - * opening tag; see the module header. Deterministic and pure, so tape replay - * and shrinking reproduce the repaired value exactly. The template lines - * around the attribute value always carry non-blank content (`<S id="`, - * `">`), so only terminator sequences inside the value can form a blank - * line. + * blank line — a line containing only spaces/tabs — inside a staged opening + * tag; see the module header. Deterministic and pure, so tape replay and + * shrinking reproduce the repaired value exactly. The template lines around + * an attribute value always carry non-blank content (`<S id=` plus the + * opening quote, the closing quote plus `>`), so only terminator sequences + * inside the value can form a blank line — and a prefix of a repaired value + * (an ancestor `id` of a `.`-bearing draw) carries a subset of its + * terminator pairs, so it is repaired too. */ function withoutBlankLineHazards(value: string): string { let out = ""; @@ -349,25 +607,64 @@ const affixedForbiddenName: Gen<string> = (choices) => { * rule is an exact-string match, so the flip is valid. `$` has no letter and * stays forbidden; the oracle decides either way. */ -const caseFlippedForbiddenName: Gen<string> = (choices) => { - const name = choices.pick(FORBIDDEN_NAMES); +const caseFlippedForbiddenName: Gen<string> = (choices) => + upcaseFirstLetter(choices.pick(FORBIDDEN_NAMES)); + +/** The flip itself: the first a–z letter upcased; a name without one unchanged. */ +function upcaseFirstLetter(name: string): string { const index = [...name].findIndex((ch) => ch >= "a" && ch <= "z"); if (index < 0) return name; return ( name.slice(0, index) + name[index]!.toUpperCase() + name.slice(index + 1) ); -}; +} -/** Segment candidates: random code points, plus forbidden-name shapes. */ -const segmentCandidate: Gen<string> = (choices) => { +/** + * The alphabet's 1.4-valid segment characters at their alphabet weights — + * the ordinary, glob, boundary, and breadth entries — selected + * through the oracle so the two never disagree. + */ +const VALID_SEGMENT_ALPHABET: ReadonlyArray<readonly [number, string]> = + ALPHABET.filter(([, character]) => valueVerdict(character, "segment").valid); + +/** A 1–4 character piece drawn from the valid segment characters. */ +const validLeaningPiece: Gen<string> = (choices) => + listOf((c: Choices) => c.weightedPick(VALID_SEGMENT_ALPHABET), { + min: 1, + max: 4, + })(choices).join(""); + +/** + * A `.`-joined chain of 2–4 pieces — each valid-leaning, or (one time in + * four in random mode) an arbitrary segment draw — so the fixed seeds stage, + * many times over, chains whose every level is valid (accepted, the bearer + * nested 2–4 deep) and chains with an invalid level at an ancestor or at the + * bearer (rejected). A random draw's own `.`s reach those shapes only + * rarely: every level of an accepted chain must avoid every invalid class. + */ +const dottedChain: Gen<string> = (choices) => + listOf( + (c: Choices) => + c.boolean(0.25) ? randomSegmentCharacters(c) : validLeaningPiece(c), + { min: 2, max: 4 }, + )(choices).join(DOT); + +/** + * Segment draws: random code points, `.`-joined chains, and forbidden-name + * shapes — an affix of `.` puts a forbidden name at one level of a + * two-segment chain. Blank-line hazards repaired, then held spellable (a + * draw with both quote kinds is redrawn). + */ +const segmentCandidate: Gen<string> = spellable((choices) => { const shape = choices.weightedPick<Gen<string>>([ [8, randomSegmentCharacters], + [4, dottedChain], [2, forbiddenName], [1, affixedForbiddenName], [1, caseFlippedForbiddenName], ]); return withoutBlankLineHazards(shape(choices)); -}; +}); /** A run of 1–3 whitespace separators (2.6 splits on runs). */ const whitespaceRun: Gen<string> = (choices) => @@ -389,8 +686,9 @@ const tagToken: Gen<string> = (choices) => { * the staged value covers empty and whitespace-only values (zero tokens), * leading/trailing whitespace, multi-character separator runs, and adjacent * token pieces merging — the oracle judges the final staged value only. + * Blank-line hazards repaired, then held spellable, as for segments. */ -const tagsValueCandidate: Gen<string> = (choices) => { +const tagsValueCandidate: Gen<string> = spellable((choices) => { const pieces = listOf( (c: Choices) => c.weightedPick<Gen<string>>([ @@ -400,7 +698,7 @@ const tagsValueCandidate: Gen<string> = (choices) => { { max: 6 }, )(choices); return withoutBlankLineHazards(pieces.join("")); -}; +}); // --- per-trial staging and acceptance assertions ------------------------------- @@ -422,7 +720,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)} ` + @@ -439,6 +737,11 @@ async function inStagedWorkspace( ): Promise<void> { const workspace = await TestWorkspace.create({ files: { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/A.mdx": source }, + // S-9: the source is the draw's, judged by the property runner before + // the body saw it (`drawSources` on the registrations below) — declared + // per draw, as every initial `.mdx` file a trial stages after the body's + // first product invocation must be (helpers/workspace.ts). + mdx: { perDraw: ["specs/A.mdx"] }, }); try { await body(workspace); @@ -447,34 +750,80 @@ async function inStagedWorkspace( } } -/** The P-1 segment property body: accepted by `build` iff 1.4-valid. */ +/** + * The source staging a segment draw: the split's prefixes spell the ancestor + * chain (`a`, `a.b`, `a.b.c` for the draw `a.b.c`), one section per level + * with its prefix as `id`, the bearer — the draw itself — innermost with the + * trial's prose; a `.`-free draw is one top-level section. Every level's + * `id` is a prefix of the draw, so it holds at most the quote kinds the draw + * holds and the draw's quote kind spells every level. + */ +function segmentSource(segments: readonly string[], quote: string): string { + const ids = segments.map((_, index) => + segments.slice(0, index + 1).join(DOT), + ); + const opening = ids.map((id) => `<S id=${quote}${id}${quote}>${LF}`).join(""); + const closing = `</S>${LF}`.repeat(ids.length); + return `${opening}Section under test.${LF}${closing}`; +} + +/** The source staging a `tags` value: one section `sec` carrying it. */ +function tagsSource(value: string, quote: string): string { + return ( + `<S id="sec" tags=${quote}${value}${quote}>${LF}` + + `Tagged section under test.${LF}</S>${LF}` + ); +} + +// S-9's per-draw check (helpers/property.ts `drawSources`): the one source +// each property stages, composed by the same pure functions the bodies +// stage from (module header). + +function stagedSegmentSources(draw: string): DrawSource[] { + return [ + ["specs/A.mdx", segmentSource(splitSegments(draw), quoteKindFor(draw))], + ]; +} + +function stagedTagsSources(value: string): DrawSource[] { + return [["specs/A.mdx", tagsSource(value, quoteKindFor(value))]]; +} + +/** The P-1 segment property body: accepted by `build` iff every segment is 1.4-valid. */ async function assertSegmentAcceptance( product: ProductBinding, - segment: string, + draw: string, ): Promise<void> { - const verdict = valueVerdict(segment, "segment"); - const source = `<S id="${segment}">${LF}Section under test.${LF}</S>${LF}`; + const verdict = segmentsVerdict(draw); + const quote = quoteKindFor(draw); + const source = segmentSource(verdict.segments, quote); + const staging = + verdict.segments.length === 1 + ? "staged as one top-level segment" + : `staged as ${String(verdict.segments.length)} segments, the bearer ` + + `nested beneath the ancestor chain its prefixes spell`; await inStagedWorkspace(source, async (workspace) => { - if (verdict.valid) { + if (verdict.accepted) { await buildOk( product, workspace, - `P-1: segment ${renderCodePoints(segment)} satisfies SPEC 1.4, so ` + - `\`build\` must accept the workspace`, + `P-1: draw ${renderCodePoints(draw)} (${staging}) — every resulting ` + + `segment satisfies SPEC 1.4, so \`build\` must accept the workspace`, ); return; } const context = - `P-1: segment ${renderCodePoints(segment)} violates SPEC 1.4 ` + - `(${verdict.reason}), so \`build --json\` must reject the workspace`; + `P-1: draw ${renderCodePoints(draw)} (${staging}) violates SPEC 1.4 — ` + + `${verdict.reason} — so \`build --json\` must reject the workspace`; const findings = await buildFindings(product, workspace, context); - // A dot-free candidate stages exactly one top-level single-segment ID, so - // 14.4 is the only present condition; a dot-containing candidate is - // structurally more than one segment at top level (1.3), so 14.2 and/or - // 14.4 report (sub-segment analysis of an invalid ID is not pinned). + // A dot-free draw stages exactly one top-level single-segment ID, so + // 14.4 is the only present condition; a dot-containing draw stages a + // chain whose every ID from the offending level down carries the invalid + // segment, and 14.2 and/or 14.4 report (whether an ill-formed level is + // also read structurally is sub-segment analysis SPEC 14 does not pin). assertRejectionFindings( findings, - segment.includes(".") ? ["14.2", "14.4"] : ["14.4"], + verdict.segments.length === 1 ? ["14.4"] : ["14.2", "14.4"], context, ); }); @@ -486,7 +835,8 @@ async function assertTagsAcceptance( value: string, ): Promise<void> { const verdict = tagsVerdict(value); - const source = `<S id="sec" tags="${value}">${LF}Tagged section under test.${LF}</S>${LF}`; + const quote = quoteKindFor(value); + const source = tagsSource(value, quote); await inStagedWorkspace(source, async (workspace) => { if (verdict.accepted) { await buildOk( @@ -508,15 +858,133 @@ async function assertTagsAcceptance( }); } +// --- S-9's fixed form-vector set (module header) ------------------------------- + +/** The line terminators a value may hold singly (the no-blank-line rule). */ +const FORM_TERMINATORS: ReadonlyArray< + readonly [name: string, terminator: string] +> = [ + ["lone LF", LF], + ["lone CR", CR], + ["CRLF", CR + LF], +]; + +/** + * The values the generators' shapes compose, by family: every alphabet + * character alone and inside a value, the forbidden-name shapes (exact, at + * either level of a chain, case-flipped), `.`-joined chains and the empty + * segments a leading, trailing, or doubled dot yields, single line + * terminators inside a value with the repaired blank-line hazards, and the + * 2.6 whitespace separators, runs, and zero-token values. + */ +const P1_FORM_VALUES: ReadonlyArray<readonly [family: string, value: string]> = + [ + ...ALPHABET.flatMap(([, character]): (readonly [string, string])[] => [ + ["alphabet character alone", character], + ["alphabet character inside a value", `a${character}b`], + ]), + ...FORBIDDEN_NAMES.flatMap((name): (readonly [string, string])[] => [ + ["forbidden name", name], + ["forbidden name at the first level of a chain", `${name}${DOT}a`], + ["forbidden name at the last level of a chain", `a${DOT}${name}`], + ...(upcaseFirstLetter(name) === name + ? [] + : [["case-flipped forbidden name", upcaseFirstLetter(name)] as const]), + ]), + ["chain of two valid pieces", `ab${DOT}c1`], + ["chain of four valid pieces", `a${DOT}b-${DOT}_9${DOT}zA`], + ["leading dot (an empty first segment)", `${DOT}a`], + ["trailing dot (an empty last segment)", `a${DOT}`], + ["doubled dot (an empty middle segment)", `a${DOT}${DOT}b`], + ...FORM_TERMINATORS.flatMap( + ([name, terminator]): (readonly [string, string])[] => [ + [`${name} between characters`, `a${terminator}b`], + [`${name} leading`, `${terminator}a`], + [`${name} trailing`, `a${terminator}`], + [`${name} before an indented continuation`, `a${terminator} ${TAB}b`], + [ + `${name} doubled, repaired`, + withoutBlankLineHazards(`a${terminator}${terminator}b`), + ], + [ + `${name} around a spaces-only line, repaired`, + withoutBlankLineHazards(`a${terminator} ${terminator}b`), + ], + [ + `${name} alone, doubled and repaired`, + withoutBlankLineHazards(`${terminator}${terminator}`), + ], + ], + ), + ["mixed terminators between characters", `a${LF}b${CR}c${CR}${LF}d`], + ["lone LF then lone CR, repaired", withoutBlankLineHazards(`a${LF}${CR}b`)], + ...WHITESPACE_CHARACTERS.flatMap((ws): (readonly [string, string])[] => { + const name = codePointName(ws.codePointAt(0)!); + return [ + [`${name} as a separator`, `a${ws}b`], + [`${name} leading and trailing`, `${ws}a${ws}`], + [`${name} alone (zero tokens)`, ws], + ]; + }), + ["a whitespace run as a separator", `a${SPACE}${TAB}${VT}${FF}b`], + ["a whitespace run alone (zero tokens)", `${SPACE}${SPACE}${TAB}`], + ["the empty value (zero tokens)", ""], + ]; + +/** + * The quote kinds a value admits (module header): the other kind for a + * value holding one quote character, either kind for one holding neither — + * `quoteKindFor`'s choice first. + */ +function admissibleQuoteKinds(value: string): readonly string[] { + if (containsBothQuoteKinds(value)) return []; + const chosen = quoteKindFor(value); + if (value.includes(DOUBLE_QUOTE) || value.includes(SINGLE_QUOTE)) { + return [chosen]; + } + return [chosen, chosen === DOUBLE_QUOTE ? SINGLE_QUOTE : DOUBLE_QUOTE]; +} + +function quoteKindName(quote: string): string { + return quote === DOUBLE_QUOTE ? "double" : "single"; +} + +/** + * The fixed form-vector set of the P-1 generators (S-9): each value above + * staged as a segment draw (its `.`-split spelling the ancestor chain) and + * as a `tags` value, in every admissible quote kind — name and source. + */ +export const P1_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = P1_FORM_VALUES.flatMap(([family, value]) => + admissibleQuoteKinds(value).flatMap( + (quote): (readonly [string, string])[] => [ + [ + `segment draw, ${family}: ${renderCodePoints(value)}, ` + + `${quoteKindName(quote)} quotes`, + segmentSource(splitSegments(value), quote), + ], + [ + `tags value, ${family}: ${renderCodePoints(value)}, ` + + `${quoteKindName(quote)} quotes`, + tagsSource(value, quote), + ], + ], + ), +); + // --- the registered property test ---------------------------------------------- const P_1 = defineProductTest({ id: "P-1", title: - "property: a generated segment is accepted by `build` iff it satisfies SPEC 1.4; " + - "a generated `tags` value is accepted iff every 2.6-split token satisfies 1.4 " + - "with `.` allowed, zero tokens behaving as an omitted prop (SPEC 1.4, 2.6; " + - "TEST-SPEC §16 P-1)", + "property: a generated segment draw, staged as the segments its `.`s split it " + + "into (the bearer nested beneath the ancestor chain the split's prefixes " + + "spell), is accepted by `build` iff every resulting segment satisfies SPEC " + + "1.4; a generated `tags` value is accepted iff every 2.6-split token " + + "satisfies 1.4 with `.` allowed, zero tokens behaving as an omitted prop; " + + "each draw spelled in the quote kind its content admits, one holding both " + + "never staged (SPEC 1.3, 1.4, 2.6, 2.7; TEST-SPEC §16 P-1)", // Wall-clock hang guard only (H-10): two properties, three fixed seeds each // (E-5), one workspace and one build subprocess per trial, plus the shrink // budget on falsification. @@ -525,10 +993,10 @@ const P_1 = defineProductTest({ await checkProperty( "P-1 segment validity", segmentCandidate, - async (segment) => { - await assertSegmentAcceptance(product, segment); + async (draw) => { + await assertSegmentAcceptance(product, draw); }, - { render: renderCodePoints }, + { render: renderCodePoints, drawSources: stagedSegmentSources }, ); await checkProperty( "P-1 tag validity", @@ -536,7 +1004,7 @@ const P_1 = defineProductTest({ async (value) => { await assertTagsAcceptance(product, value); }, - { render: renderCodePoints }, + { render: renderCodePoints, drawSources: stagedTagsSources }, ); }, }); diff --git a/test/suite/registry/section-16-p10.ts b/test/suite/registry/section-16-p10.ts index 4eb1649c..655901fd 100644 --- a/test/suite/registry/section-16-p10.ts +++ b/test/suite/registry/section-16-p10.ts @@ -139,48 +139,85 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, + RunGuards, RunningProduct, RunResult, } from "../../helpers/subprocess.js"; import { releaseHoldFile, + rethrowOutputOverflow, runProduct, startProduct, summarizeResult, } from "../../helpers/subprocess.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { buildOk, expectExit, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. Audit // sessions need no code group and no git (SPEC 10.6). -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// A TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), well-formed: every trial stages it afresh, from the +// second trial on after the body's first product invocation — an initial +// file S-7's sweep never reaches, so the ledger self-test judges it before +// any product exists. +const SPECS_ONLY_CONFIG = stagedTs( + "P-10 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the property's one configuration file, the record above — judged + * as a record by test/self/s9-staged-sources.test.ts too, and here beside + * every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); P-10 composes no code + * source. + */ +export const P10_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-10 configuration (SPECS_ONLY_CONFIG)", + "xspec.config.ts", + SPECS_ONLY_CONFIG.source, + ], +]; // The fixed initial spec file (module header): importless and tagless, one // top-level section with a child plus a second top-level leaf — the audit // session holds four items (file root, `a`, `a.k`, `g`; SPEC 10.6) with a // non-trivial `blockedBy` chain, and both `a` and `g` are rename targets. +// A staged-source record (helpers/staged-mdx.ts): every trial stages it +// afresh, from the second trial on after the body's first product invocation +// — an initial file S-7's sweep never reaches, so the ledger self-test judges +// it before any product exists (S-9). const INITIAL_SOURCE_REL = "specs/A.mdx"; const INITIAL_TOP_IDS = ["a", "g"] as const; -const A_MDX = [ - '<S id="a">', - "Alpha text.", - '<S id="a.k">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="g">', - "Gamma text.", - "</S>", - "", -].join("\n"); +const A_MDX = stagedMdx( + "P-10 specs/A.mdx", + [ + '<S id="a">', + "Alpha text.", + '<S id="a.k">', + "Kid text.", + "</S>", + "</S>", + "", + '<S id="g">', + "Gamma text.", + "</S>", + "", + ].join("\n"), +); const SESSION_NAME = "s"; const JOURNAL_REL = ".xspec/journal"; @@ -623,19 +660,25 @@ function assertSameState( /** * Run one menu read to completion under the driver's hang guard, converting - * a rejection (hang, runaway output) into a diagnosed failure (H-8). + * a rejection (a hang, killed at the guard) into a diagnosed failure (H-8). + * An exhausted capture limit is never converted: it propagates out of the + * property body as the harness error it is, and `checkProperty` reports it + * with the seed (H-11). `guards` lower the hang guard or the capture limit + * for S-8's vector alone. */ -async function runHeldRead( +export async function runHeldRead( product: ProductBinding, cwd: string, menuIndex: number, context: string, + guards: RunGuards = {}, ): Promise<void> { const read = READ_MENU[menuIndex]; let result: RunResult; try { - result = await runProduct(product, { cwd, argv: read.argv }); + result = await runProduct(product, { cwd, argv: read.argv, ...guards }); } catch (error) { + rethrowOutputOverflow(error); return fail( `${context} ${read.what}: a read command must run and terminate while ` + `a mutating command is held (SPEC 13.5; H-8) — ` + @@ -651,13 +694,17 @@ async function runHeldRead( ); } -interface StraddleRead { +export interface StraddleRead { readonly running: RunningProduct; readonly what: string; } -/** Await a straddle read: terminated, no signal death, exit 0 or 1. */ -async function settleStraddleRead( +/** + * Await a straddle read: terminated, no signal death, exit 0 or 1. A + * rejected run fails diagnosed (H-8), except an exhausted capture limit, + * which propagates as the harness error it is (H-11). + */ +export async function settleStraddleRead( read: StraddleRead, context: string, ): Promise<void> { @@ -665,6 +712,7 @@ async function settleStraddleRead( try { result = await read.running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); return fail( `${context} straddling ${read.what}: a read command running across a ` + `mutating command's commit window must terminate (SPEC 13.5; H-8: ` + @@ -883,6 +931,7 @@ async function runEpisode( try { await running.waitForFile(hold); } catch (error) { + rethrowOutputOverflow(error); fail( `${context}: ${HOLD_APPEAR_CONTEXT} — ` + `${error instanceof Error ? error.message : String(error)}`, @@ -897,9 +946,10 @@ async function runEpisode( if (running.hasExited()) { const outcome = await running .waitForExit() - .then(summarizeResult, (error: unknown) => - error instanceof Error ? error.message : String(error), - ); + .then(summarizeResult, (error: unknown) => { + rethrowOutputOverflow(error); + return error instanceof Error ? error.message : String(error); + }); fail( `${context}: the mutating command must proceed only once the hold ` + `file is deleted, but it exited while still held (SPEC 13.5) — ` + @@ -936,6 +986,7 @@ async function runEpisode( try { result = await running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); fail( `${context}: once the hold file is deleted the mutating command ` + `must proceed and complete (SPEC 13.5; H-8) — ` + @@ -967,14 +1018,19 @@ async function runEpisode( } } -/** Settle a killed mutator; the death's shape is not asserted. */ -async function settleKilled( +/** + * Settle a killed mutator; the death's shape is not asserted. A rejected run + * fails diagnosed (H-8), except an exhausted capture limit, which propagates + * as the harness error it is (H-11). + */ +export async function settleKilled( running: RunningProduct, context: string, ): Promise<void> { try { await running.waitForExit(); } catch (error) { + rethrowOutputOverflow(error); fail( `${context}: the killed mutating command must settle (H-8) — ` + `${error instanceof Error ? error.message : String(error)}`, diff --git a/test/suite/registry/section-16-p11.ts b/test/suite/registry/section-16-p11.ts new file mode 100644 index 00000000..5a98f349 --- /dev/null +++ b/test/suite/registry/section-16-p11.ts @@ -0,0 +1,712 @@ +// 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 is converted into a *diagnosed assertion +// failure* (H-8; S-3: hangs are reported as failures), because +// termination is this property's assertion, not merely harness hygiene; +// the timeout is dimensioned to the staged answer scale (H-11; +// `FUZZ_COMMAND_TIMEOUT_MS` below), and a killed invocation is reported +// unshrunk, since every shrink candidate re-observing a kill would cost +// the full guard. A run killed by the driver's output-capture cap is +// never converted: an exhausted capture limit is a loud harness error, +// which `checkProperty` reports with the seed — never a falsified +// property, since a truncated capture is indistinguishable from a +// partial document (H-11; S-8 dimensions the cap to the staged answer +// scale and pins this division); +// * 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 { + ProductRunTimeoutError, + runProduct, +} from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { FuzzRunGuards } from "./section-16-p8.js"; +import { + drawFuzzMutation, + FUZZ_BASE_FILES, + FUZZ_BASE_RECORDS, + MAX_MUTATIONS_PER_TRIAL, +} 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, MAX_MUTATIONS_PER_TRIAL - 1); + 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) + * — dimensioned to the staged answer scale (H-11), not to parse time. The + * largest answer SPEC.md permits over a P-11 draw is `view --text` over + * `specs/A.mdx` carrying two appended depth-4096 section towers under P-8's + * LF → U+2028 rewrite (the whole mutation budget), whose quadratic text + * expansion S-8 sizes the capture to (~204 MB in the `\u2028` spelling). + * Measured at 20ee9fd through `runProduct` against the built product on a + * 4-core machine: that invocation emits 58.6 MB and terminates in 17.0 s + * (bare `view --text` over the same workspace 16.5 s; the same input with + * LF → U+0020, 9.3 s; the other surfaces at that scale — `view`, + * `occurrences`, `at` — 1.0–1.5 s; any arm over an unmutated-scale draw + * ~0.3 s). 120 s is 7× that maximum: the ≥ 4× margin a conforming product + * is owed over its measured answer time, plus headroom for the up-to-3.5× + * larger `\u2028` spelling and slower CI runners. So a conforming product + * is never killed while still emitting its answer (H-11: an exhausted + * harness limit is a harness defect, never a diagnosed product failure), + * and a genuinely hanging one costs one guard per diagnosis, reported + * unshrunk (runAvailabilityCommand). Re-measure with a temporary self-test + * staging `FUZZ_BASE_FILES` with `sectionTowerSource(4096, true)` appended + * twice to `specs/A.mdx` and every LF rewritten to U+2028 (see AGENTS.md). + */ +const FUZZ_COMMAND_TIMEOUT_MS = 120_000; + +/** + * Run one availability invocation, converting the hang-guard kill — exactly + * that — into a diagnosed assertion failure: P-11's first clause is that + * every invocation terminates (S-3: hangs are reported as failures). An + * exhausted capture limit (`ProductRunOutputOverflowError`) is never + * converted: it propagates out of the property body as a harness error that + * `checkProperty` reports with the seed — never a falsified property (H-11) + * — and so does anything else the driver throws (H-8). `guards` is P-8's + * `FuzzRunGuards`, for S-8's self-test alone; the registered body passes + * none. + */ +export async function runAvailabilityCommand( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + guards: FuzzRunGuards = {}, +): Promise<RunResult> { + try { + return await runProduct(product, { + cwd: workspace.root, + argv, + timeoutMs: guards.timeoutMs ?? FUZZ_COMMAND_TIMEOUT_MS, + maxOutputBytes: guards.maxOutputBytes, + }); + } catch (error) { + // The hang-guard kill is reported unshrunk (`shrinkable: false`): a + // shrink candidate can re-observe it only by waiting out the guard + // again — one full guard per candidate — so shrinking's execution + // budget would stop bounding the body's wall clock (the entry's + // `timeoutMs` below). The drawn trial, at most three mutations and four + // invocations, is the reported counterexample, and its seed replays it + // (H-10). + if (error instanceof ProductRunTimeoutError) { + fail( + `P-11: every invocation of the availability surfaces must terminate ` + + `on fuzzed sources (TEST-SPEC §16 P-11; SPEC 11.2, 12.0), but the ` + + `invocation was still running when the harness's hang guard killed ` + + `it — ${error.message}`, + { shrinkable: false }, + ); + } + 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. Exported + * for S-8, which drives this walk at the suite's staged answer scale. + */ +export function documentCarriesUnavailability(value: unknown): boolean { + // H-11: an explicit stack, never native recursion per nesting level — the + // fuzzed `view` answers carry the depth-2048 and depth-4096 section towers + // the suite stages (P-8, P-11), past V8's frame budget; no depth cap. + const pending: unknown[] = [value]; + while (pending.length > 0) { + const current = pending.pop(); + if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + pending.push(current[index]); + } + continue; + } + if (typeof current !== "object" || current === null) continue; + const obj = current as Record<string, unknown>; + if (Object.hasOwn(obj, "unavailable")) return true; + const members = Object.values(obj); + for (let index = members.length - 1; index >= 0; index -= 1) { + pending.push(members[index]); + } + } + return false; +} + +/** 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 `.mdx` paths whose staged bytes differ from the base file's. */ +function mutatedMdxPaths(trial: AvailabilityTrial): string[] { + return mutatedPaths(trial).filter((path) => path.endsWith(".mdx")); +} + +/** The code-source paths whose staged bytes differ from the base file's. */ +function mutatedCodePaths(trial: AvailabilityTrial): string[] { + return mutatedPaths(trial).filter((path) => !path.endsWith(".mdx")); +} + +/** Every path whose staged bytes differ from the base file's. */ +function mutatedPaths(trial: AvailabilityTrial): string[] { + const base = new Map( + FUZZ_BASE_FILES.map(([path, text]) => [path, Buffer.from(text, "utf8")]), + ); + return trial.files + .filter(([path, bytes]) => !(base.get(path)?.equals(bytes) ?? false)) + .map(([path]) => path); +} + +/** The P-11 property body for one trial (see the module header). */ +async function runAvailabilityTrial( + product: ProductBinding, + trial: AvailabilityTrial, +): Promise<void> { + const mutated = mutatedPaths(trial); + const workspace = await TestWorkspace.create({ + // S-9: a mutated file is a fuzz staging whose well-formedness the + // document does not declare — a document's derivability and a code + // source's TypeScript well-formedness alike — staged plain and declared + // `unchecked`; an unmutated base file (the configuration always among + // them) is the harness's constant, staged as P-8's record + // (`FUZZ_BASE_RECORDS`) — judged before any product exists — since every + // trial after the first stages it after the body's first product + // invocation. + files: Object.fromEntries( + trial.files.map(([path, bytes]) => [ + path, + mutated.includes(path) ? bytes : (FUZZ_BASE_RECORDS.get(path) ?? bytes), + ]), + ), + mdx: { unchecked: mutatedMdxPaths(trial) }, + ts: { unchecked: mutatedCodePaths(trial) }, + }); + 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 on the body only (H-10), sized to the diagnosis + // path, never an assertion input. The sweep is three fixed seeds (E-5) × + // 12 trials × 2–4 invocations, ≤ 144, each bounded by + // FUZZ_COMMAND_TIMEOUT_MS and with no staging build. A conforming product + // answers within the measured scale that guard is derived from — ≤ 17 s + // for a `view --text` arm at the staged maximum, ≤ 1.5 s for any other arm + // at tower scale (the pinned seeds draw 18 `view --text` answer arms, 2 of + // them over towers, and 5 tower trials in all; dry run at 20ee9fd) — so a + // sweep whose every text arm reached the maximum runs ≈ 8.5 min; a + // slow-but-terminating product then fails as one hang-guard kill on top, + // unshrunk (≈ 10.5 min in all), and 20 min doubles that for CI runners: + // the failure is the guard's diagnosis of one invocation, never this body + // timeout. Shrinking any other failure class costs answer time, not guard + // time (≤ 100 executions at ≤ 17 s per maximal-scale text arm). The + // adversarial bound — every invocation just under the guard — is ≈ 4.8 h, + // past the 45-minute CI job ceiling governing the whole suite; no body + // budget can cover it. + timeoutMs: 1_200_000, + run: async (product) => { + await checkProperty( + "P-11 availability robustness", + 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 00000000..5af43134 --- /dev/null +++ b/test/suite/registry/section-16-p12.ts @@ -0,0 +1,846 @@ +// 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, valid by +// construction — 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, and resolving `d` references and +// `{text(...)}` embeddings — 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"). +// Every staged file must carry its entry — asserted before the +// occurrence comparison: each is a parseable discovered spec source +// (valid by construction, and judged derivable by S-9 before the body +// runs), a bare `view` covers every discovered spec source, and the +// tree exists for every parseable file (SPEC 11.4, 11.2) — so a missing +// entry is a product failure, never a file to skip: a product hiding a +// file from `view`, `occurrences`, and `at` alike would otherwise pass +// the sweep vacuously over it (H-8). The masked case — an unparseable +// requested file contributing no view, every offset's resolution +// explicitly unavailable — is T11.5-3's deterministic arm, outside +// P-12's valid draws. +// * **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: valid by construction (TEST-SPEC §16 preamble — P-12 is not +// among the properties staging invalid or imperfect input by design, P-1's +// invalid draws, P-8, and P-11, so its oracles are evaluated over documents +// that build and a generator artifact never surfaces as a product failure). +// The configuration is constant and valid (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: argument checks +// alone exit 2; P-12's entry pins no exit beyond that). References follow +// P-4's discipline (section-16-p4.ts): each targets the file's constant +// anchor `t` — its first top-level section, holding prose alone — or, +// through the drawn import `M0` of the first file (files after the first +// only; the first draws none, so no import cycle arises, SPEC 2.1), that +// file's anchor `M0.t`, or `s1` — the first section a file emits, always +// top-level — at sites after `s1`'s closing tag alone (the root's later +// content or a later top-level subtree). So every reference resolves +// (SPEC 2.2–2.4); none names the site's own section or an ancestor (SPEC +// 5.3: "a section MUST NOT depend on or embed its own ancestor"); every +// depends/embeds edge points into a subtree whose own references reach +// only the anchors, which reference nothing, so the combined +// contains/depends/embeds graph is acyclic (5.3); and section IDs come +// from a fresh per-file counter beside `t` (`s1`, `s1.s2`, …: unique and +// structurally valid, 1.3, 1.4). Imperfect input is anchored elsewhere: an +// unparseable requested file's explicitly unavailable resolution by +// T11.5-3, explicitly unavailable identity data by T11.2-*, and the +// imperfect-input classes by P-11. +// +// Rendering discipline (every composed file derives, S-9): 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). `P12_FORM_VECTORS` below spells every composed form, each +// where the generator may compose it, for the S-9 self-test +// (test/self/s9-fixture-well-formedness.test.ts), and every draw's sources +// are judged before the product sees them (`drawSources`, +// helpers/property.ts). +// +// 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 measured: 20 files (1-file workspaces ×2, +// 2-file ×3, 3-file ×4), 8 drawn imports and one `M0.t` reference (a +// `d={M0.t}`), 4 embeddings (`'t'` ×3, `"t"` ×1, each with a tail) and 6 +// single `d` props (`d={"t"}` ×5, `d={M0.t}` ×1) — 10 occurrences, spread +// over two files in three trials and two to a file in two files — one +// depth-2 section, multi-byte prose in 14 of the 20 files, and 1339 `at` +// invocations in all. No `"s1"` reference and no `d` array occur there, +// nor in the first 25 trials per seed: both arise only at a site after +// `s1` closes (about 2% of files over a 1000-trial sample on other seeds), +// and the S-9 vectors spell both. Every file of both seed sets derives, +// and every trial of both — as every one of the sample's 38 trials +// spelling `"s1"`, and each form vector — builds under the built product +// with exit 0 and no finding (an implementation-time cross-check, never a +// committed check against the product). 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, DrawSource, 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, mdxPathsOf } 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"; + +// --------------------------------------------------------------------------- +// Generation: file pool, content pools, per-file builder. + +/** 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), every one + * resolving (module header, "Input space"): `"t"` — the file's constant + * anchor section below — and `'t'`, a spelling variant of the same target; + * `"s1"` once the file's `s1` has closed (`s1` is the first section a file + * emits, always top-level, so at every later site it exists and is neither + * the site's own section nor an ancestor, SPEC 5.3); `M0.t` (external) where + * the import was drawn. Simplest first (pick shrinks toward the first + * entry). + */ +function embedArgumentMenu( + hasImport: boolean, + s1Closed: boolean, +): readonly string[] { + const menu = ['"t"', "'t'"]; + if (s1Closed) menu.push('"s1"'); + if (hasImport) menu.push("M0.t"); + return menu; +} + +/** + * Opening-tag `d` prop spellings (SPEC 2.2), `""` = prop omitted, under the + * embedding menu's targets and conditions: every entry resolves, and the + * array's entries record occurrences separately (5.7). + */ +function dPropMenu( + hasImport: boolean, + s1Closed: boolean, +): ReadonlyArray<readonly [number, string]> { + const entries: (readonly [number, string])[] = [ + [5, ""], + [2, ' d={"t"}'], + ]; + if (s1Closed) entries.push([1, ' d={["t", "s1"]}']); + if (hasImport) entries.push([1, " d={M0.t}"]); + return entries; +} + +/** One generated workspace (module header). */ +export interface P12Trial { + /** Staged content per workspace-relative path, in FILE_POOL order. */ + readonly files: ReadonlyArray<readonly [string, string]>; +} + +// --- the line templates (the generator and the S-9 vector set below) ------ + +/** The import's ESM block: the declaration, then the mandatory blank line (the ESM block must end, FP-094). */ +const IMPORT_BLOCK_LINES: readonly string[] = [IMPORT_LINE, ""]; + +/** The constant anchor section `t` opening every file. */ +function anchorSectionLines(prose: string): string[] { + return ['<S id="t">', prose, "</S>"]; +} + +/** A prose line, optionally carrying an embedding and, after it, a tail. */ +function proseLine( + prose: string, + embedArgument: string | null, + tail: string | null, +): string { + if (embedArgument === null) return prose; + return `${prose}{text(${embedArgument})}${tail ?? ""}`; +} + +/** A section's opening tag: the dotted id, then the `d` prop spelling. */ +function openingTag(dotted: string, dProp: string): string { + return `<S id="${dotted}"${dProp}>`; +} + +/** An own-line MDX comment. */ +function commentLine(interior: string): string { + return `{/* ${interior} */}`; +} + +/** + * One file's lines (joined by single newlines; module header discipline). + * The constant anchor section `t` opens every file, so the reference + * spellings above always have a target, in-file and cross-file; `s1Closed` + * turns true right after the closing tag of `s1` — the first section + * `emitSection` names, always top-level — so neither `s1`'s opening tag nor + * anything inside it spells `"s1"` (module header, "Input space"). + */ +function genFileLines(choices: Choices, hasImport: boolean): string[] { + const lines: string[] = []; + if (hasImport) lines.push(...IMPORT_BLOCK_LINES); + lines.push(...anchorSectionLines(choices.pick(PROSE_POOL))); + + let seg = 1; + let s1Closed = false; + const nextSeg = (): string => { + const name = `s${String(seg)}`; + seg += 1; + return name; + }; + const emitProse = (): void => { + const prose = choices.pick(PROSE_POOL); + const embedArgument = choices.boolean(0.4) + ? choices.pick(embedArgumentMenu(hasImport, s1Closed)) + : null; + const tail = + embedArgument !== null && choices.boolean(0.5) + ? choices.pick(TAIL_POOL) + : null; + lines.push(proseLine(prose, embedArgument, tail)); + }; + const emitSection = (parentDotted: string, depth: number): void => { + const segName = nextSeg(); + const dotted = parentDotted === "" ? segName : `${parentDotted}.${segName}`; + lines.push( + openingTag(dotted, choices.weightedPick(dPropMenu(hasImport, s1Closed))), + ); + 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(commentLine(choices.pick(COMMENT_POOL))); + } else emitSection(dotted, depth + 1); + } + lines.push("</S>"); + if (dotted === "s1") s1Closed = true; + }; + + 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(commentLine(choices.pick(COMMENT_POOL))); + } else emitSection("", 0); + } + return lines; +} + +// --- S-9's fixed form-vector set (TEST-SPEC 17 S-9; the §16 preamble) ------ + +/** A file's staged text: its lines joined by single newlines, terminated. */ +function p12FileText(lines: readonly string[]): string { + return `${lines.join("\n")}\n`; +} + +/** + * One file holding every form the generator composes, with or without the + * import (the `M0` spellings join the menus with it), each where the + * generator may compose it — so the file is valid as the generator would + * compose it (module header, "Input space"): the anchor, every prose line, + * every embedding argument of the opening menu plain and with every tail, + * every comment, a blank line, and one top-level section per `d` prop + * spelling, each nested to the depth cap with every inner shape. The first + * of them is `s1`, spelling the omitted prop (every menu's first entry) and + * drawing its subtree's spellings from the opening menus alone (`s1` is not + * yet closed there); the embedding arguments the menus add once `s1` has + * closed follow its closing tag, plain and with every tail, and the later + * sections draw from the full menus. + */ +function p12FormFileLines(hasImport: boolean): string[] { + const lines: string[] = hasImport ? [...IMPORT_BLOCK_LINES] : []; + lines.push(...anchorSectionLines(PROSE_POOL[0])); + for (const prose of PROSE_POOL) lines.push(proseLine(prose, null, null)); + const pushEmbeddings = (embedArguments: readonly string[]): void => { + for (const argument of embedArguments) { + lines.push(proseLine(PROSE_POOL[1], argument, null)); + for (const tail of TAIL_POOL) { + lines.push(proseLine(PROSE_POOL[2], argument, tail)); + } + } + }; + const openingArguments = embedArgumentMenu(hasImport, false); + pushEmbeddings(openingArguments); + for (const interior of COMMENT_POOL) lines.push(commentLine(interior)); + lines.push(""); + const dPropSpellings = (s1Closed: boolean): string[] => + dPropMenu(hasImport, s1Closed).map(([, spelling]) => spelling); + let seg = 1; + let s1Closed = false; + const nextSeg = (): string => { + const name = `s${String(seg)}`; + seg += 1; + return name; + }; + dPropSpellings(true).forEach((dProp, index) => { + const embedArguments = embedArgumentMenu(hasImport, s1Closed); + const dProps = dPropSpellings(s1Closed); + const top = nextSeg(); + lines.push(openingTag(top, dProp)); + lines.push( + proseLine( + PROSE_POOL[3], + embedArguments[index % embedArguments.length]!, + TAIL_POOL[index % TAIL_POOL.length]!, + ), + ); + lines.push(""); + lines.push(commentLine(COMMENT_POOL[index % COMMENT_POOL.length]!)); + const child = `${top}.${nextSeg()}`; + lines.push(openingTag(child, dProps[(index + 1) % dProps.length]!)); + const grandchild = `${child}.${nextSeg()}`; + lines.push(openingTag(grandchild, "")); + lines.push(proseLine(PROSE_POOL[0], null, null)); + lines.push("</S>", "</S>", "</S>"); + if (top === "s1") { + s1Closed = true; + pushEmbeddings( + embedArgumentMenu(hasImport, true).filter( + (argument) => !openingArguments.includes(argument), + ), + ); + } + }); + return lines; +} + +/** + * Each form alone after the anchor — a minimal context — named. A form the + * menus offer only once `s1` has closed (a spelling of `s1`) follows a + * closed top-level `s1`, as the generator composes it: the embedding on the + * root's next line, the `d` prop on the next top-level section, `s2`. + */ +function p12MinimalContexts( + hasImport: boolean, +): (readonly [name: string, source: string])[] { + const label = hasImport ? "with the import" : "without the import"; + const context = ( + name: string, + afterS1: boolean, + ...lines: string[] + ): readonly [string, string] => [ + `${name}, alone after the anchor${afterS1 ? " and a closed s1" : ""} ${label}`, + p12FileText([ + ...(hasImport ? IMPORT_BLOCK_LINES : []), + ...anchorSectionLines(PROSE_POOL[0]), + ...(afterS1 ? [openingTag("s1", ""), PROSE_POOL[0], "</S>"] : []), + ...lines, + ]), + ]; + const openingArguments = embedArgumentMenu(hasImport, false); + const openingDProps = dPropMenu(hasImport, false).map( + ([, spelling]) => spelling, + ); + return [ + ...PROSE_POOL.map((prose) => + context(`prose ${JSON.stringify(prose)}`, false, prose), + ), + ...embedArgumentMenu(hasImport, true).flatMap((argument) => { + const afterS1 = !openingArguments.includes(argument); + return [ + context( + `embedding of ${argument}`, + afterS1, + proseLine(PROSE_POOL[0], argument, null), + ), + ...TAIL_POOL.map((tail) => + context( + `embedding of ${argument} with the tail ${JSON.stringify(tail)}`, + afterS1, + proseLine(PROSE_POOL[0], argument, tail), + ), + ), + ]; + }), + ...COMMENT_POOL.map((interior) => + context( + `comment ${JSON.stringify(interior)}`, + false, + commentLine(interior), + ), + ), + ...dPropMenu(hasImport, true).map(([, dProp]) => { + const afterS1 = !openingDProps.includes(dProp); + return context( + dProp === "" ? "a section without a d prop" : `a section with${dProp}`, + afterS1, + openingTag(afterS1 ? "s2" : "s1", dProp), + PROSE_POOL[0], + "</S>", + ); + }), + context("a blank line", false, ""), + ]; +} + +/** + * The fixed form-vector set of the P-12 generator (S-9): every form in one + * file and each alone in a minimal context, with and without the import — + * each vector file valid as the generator would compose it (every reference + * it spells resolves, none to its own section or an ancestor; IDs unique). + */ +export const P12_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [false, true].flatMap((hasImport): (readonly [string, string])[] => { + const label = hasImport ? "with the import" : "without the import"; + return [ + [`every form ${label}`, p12FileText(p12FormFileLines(hasImport))], + ...p12MinimalContexts(hasImport), + ]; +}); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the property's one configuration file, section-11.2.ts's + * record — judged as a record by test/self/s9-staged-sources.test.ts too, + * and here beside every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); P-12 composes no code + * source. + */ +export const P12_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-12 configuration (section-11.2.ts's SPECS_ONLY_CONFIG)", + "xspec.config.ts", + SPECS_ONLY_CONFIG.source, + ], +]; + +/** 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`, + ]); + } + return { files }; +}; + +/** Counterexample rendering: the staged sources, in full. */ +export function renderP12Trial(trial: P12Trial): string { + return JSON.stringify({ 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; +} + +/** + * S-9's per-draw check (helpers/property.ts `drawSources`): every composed + * file, each of which must derive — the workspaces are valid by + * construction (module header, "Input space"; TEST-SPEC §16 preamble). + */ +function stagedP12Sources(trial: P12Trial): DrawSource[] { + return trial.files.map(([path, contents]): DrawSource => [path, contents]); +} + +/** The P-12 property body for one generated trial (module header). */ +async function runP12Trial( + product: ProductBinding, + trial: P12Trial, +): Promise<void> { + const files = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + ...Object.fromEntries(trial.files), + }; + const workspace = await TestWorkspace.create({ + files, + // S-9: every composed file is the draw's, judged by the property runner + // before the body saw it (`stagedP12Sources` above) and declared + // `perDraw`, as every initial `.mdx` file a trial stages after the + // body's first product invocation must be (helpers/workspace.ts) — + // judged again at creation: it must derive. + mdx: { perDraw: mdxPathsOf(files) }, + }); + try { + // --- the derivability ground: one bare `view` over the whole domain ---- + const viewContext = "P-12 `xspec view`"; + 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); + } + // Every staged file carries its view entry, in `trial.files` order: each + // is a parseable discovered spec source (valid by construction, judged + // derivable by S-9 before this body ran), a bare `view` covers every + // discovered spec source, and the tree exists for every parseable file + // (SPEC 11.4, 11.2). Required before the occurrence comparison, whose + // totality clause would otherwise run over a partial view set, and + // before the at sweep, which would otherwise pass vacuously over a file + // the product hides from all three surfaces (H-8). + const stagedViews = trial.files.map( + ([path, content]): readonly [string, string, FileView] => { + const entry = viewByPath.get(path); + if (entry === undefined) { + fail( + `${viewContext}: the answer carries no view for ` + + `${JSON.stringify(path)} — a staged spec source, parseable ` + + `(valid by construction and judged derivable by S-9 before ` + + `the product ran) and discovered under the configuration's ` + + `\`specs/**/*.mdx\`: a bare \`view\` covers every discovered ` + + `spec source, only an unparseable file contributing none, and ` + + `the tree exists for every parseable file (SPEC 11.4, 11.2), ` + + `so at ≡ view holds for every file (TEST-SPEC §16 P-12)`, + ); + } + return [path, content, entry]; + }, + ); + + // --- occurrence order: enumeration ≡ view-collected, sorted (5.7) ------ + const occContext = "P-12 `xspec occurrences`"; + 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, entry] of stagedViews) { + const byteLength = Buffer.byteLength(content, "utf8"); + const data: ResolutionData = { + root: entry.root, + occurrences: entry.occurrences, + }; + for (let offset = 0; offset <= byteLength; offset += 1) { + const context = `P-12 \`at ${path} ${String(offset)}\``; + const report = decodeAtReport( + parseJsonStdout( + await runAnswer( + product, + workspace, + ["at", path, String(offset)], + context, + ), + context, + ), + context, + ); + assertSameJson( + report.resolution, + resolveAtFromView(data, offset), + `${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, valid by construction " + + "(nested sections, imports, comments, resolving d references and " + + "{text(...)} embeddings behind multi-byte prose), 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 (every staged file — a parseable discovered spec source — " + + "carrying its view entry), 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, + drawSources: stagedP12Sources, + }, + ); + }, +}); + +/** 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 00000000..3169c5b5 --- /dev/null +++ b/test/suite/registry/section-16-p13.ts @@ -0,0 +1,1232 @@ +// 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, DrawSource, 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, + mdxPathsOf, + tsPathsOf, +} 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)`, + ); +} + +// --------------------------------------------------------------------------- +// S-9's fixed form-vector set (TEST-SPEC 17 S-9; the §16 preamble): the +// rendering's forms over a fixed trial model — three spec files (one- and +// two-import ESM blocks), every top-level, child, and grandchild segment, +// every tag set and coverage spelling, `d` as a single local target, a +// single external root, and mixed local/external-section/external-root +// arrays, embeddings of local, external-section, and external-root targets +// in sections (singly and twice) and at the root, and one code file — +// judged as `renderP13Files` stages them (its `.mdx` entries; the code and +// configuration files are not MDX). + +function formSection( + path: string, + id: string, + shape: Partial<Omit<P13Section, "identity" | "id">>, +): P13Section { + return { + identity: `${path}#${id}`, + id, + tags: shape.tags ?? [], + coverage: shape.coverage ?? null, + dRefs: shape.dRefs ?? [], + embeds: shape.embeds ?? [], + children: shape.children ?? [], + }; +} + +const [FORM_A, FORM_B, FORM_C] = SPEC_PATHS; + +const P13_FORM_TRIAL: P13Trial = { + specFiles: [ + { + index: 0, + path: FORM_A, + rootEmbeds: [`${FORM_A}#k`, `${FORM_A}#t`], + sections: [ + formSection(FORM_A, "k", { + dRefs: [`${FORM_A}#k.m`, `${FORM_A}#k.b`], + embeds: [`${FORM_A}#k.m`], + children: [ + formSection(FORM_A, "k.m", { + tags: TAG_SETS[1]!, + coverage: "none", + dRefs: [`${FORM_A}#k.m.x`], + embeds: [`${FORM_A}#k.m.x`], + children: [ + formSection(FORM_A, "k.m.x", { + tags: TAG_SETS[2]!, + coverage: "required", + }), + ], + }), + formSection(FORM_A, "k.b", { tags: TAG_SETS[3]! }), + ], + }), + formSection(FORM_A, "d", { + coverage: "none", + dRefs: [`${FORM_A}#k`], + embeds: [`${FORM_A}#k.b`, `${FORM_A}#k`], + }), + formSection(FORM_A, "t", { + tags: TAG_SETS[1]!, + coverage: "required", + dRefs: [`${FORM_A}#k`, `${FORM_A}#d`], + }), + ], + }, + { + index: 1, + path: FORM_B, + rootEmbeds: [FORM_A], + sections: [ + formSection(FORM_B, "k", { + tags: TAG_SETS[2]!, + dRefs: [FORM_A], + embeds: [`${FORM_A}#k.m`], + children: [ + formSection(FORM_B, "k.m", { + dRefs: [`${FORM_B}#k.m.x`, `${FORM_A}#t`], + children: [formSection(FORM_B, "k.m.x", {})], + }), + ], + }), + ], + }, + { + index: 2, + path: FORM_C, + rootEmbeds: [], + sections: [ + formSection(FORM_C, "k", { + tags: TAG_SETS[1]!, + coverage: "none", + dRefs: [`${FORM_B}#k`, FORM_A, FORM_B], + embeds: [FORM_B], + }), + formSection(FORM_C, "d", { + dRefs: [`${FORM_C}#k`], + embeds: [`${FORM_C}#k`], + }), + ], + }, + ], + codeFiles: [ + { + index: 0, + path: CODE_PATHS[0], + topLevel: [{ kind: "marker", target: `${FORM_A}#k` }], + units: [ + { + name: UNIT_NAMES[0], + statements: [{ kind: "text", target: `${FORM_B}#k.m` }], + }, + { + name: UNIT_NAMES[1], + statements: [{ kind: "marker", target: FORM_A }], + }, + ], + }, + ], + specGroups: [ + [SPEC_GROUP_NAMES[0], [0]], + [SPEC_GROUP_NAMES[1], [1, 2]], + ], + codeGroups: [[CODE_GROUP_NAMES[0], [0]]], + profiles: [ + { + name: "p1", + target: SPEC_GROUP_NAMES[0], + boundary: CODE_GROUP_NAMES[0], + mode: "transitive", + targets: "all", + targetTags: TARGET_TAG_SETS[2]!, + edgeKinds: KIND_SETS[6]!, + }, + ], +}; + +/** The fixed form-vector set of the P-13 rendering (S-9): name and source. */ +export const P13_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = Object.entries(renderP13Files(P13_FORM_TRIAL)) + .filter(([path]) => path.endsWith(".mdx")) + .map(([path, source]): readonly [string, string] => [ + `rendering forms of ${path}`, + source, + ]); + +// S-9's fixed TypeScript form-vector set: the configuration and code-source +// forms of `renderP13Files` over three fixed trials — the MDX form trial +// above (three imports per code file, one code group, a profile spelling +// every optional member, `c0/U.ts` with a top-level marker and two units); +// both code files `c0/U.ts` and `c1/V.ts` over one spec file (one import +// each), without and with top-level statements, every statement kind on a +// root, a section, and a grandchild, two code groups, and three profiles +// omitting each optional member in turn (`targets` as `"leaves"` and +// `"all"`); and a trial with no code file (the configuration without its +// `code` block). + +const [FORM_A_FILE] = P13_FORM_TRIAL.specFiles; + +const P13_TS_FORM_TRIALS: ReadonlyArray<readonly [label: string, P13Trial]> = [ + ["the MDX form trial", P13_FORM_TRIAL], + [ + "two code files over one spec file", + { + specFiles: [FORM_A_FILE!], + codeFiles: [ + { + index: 0, + path: CODE_PATHS[0], + topLevel: [], + units: [ + { + name: UNIT_NAMES[0], + statements: [ + { kind: "text", target: FORM_A }, + { kind: "marker", target: `${FORM_A}#k.m.x` }, + ], + }, + { + name: UNIT_NAMES[1], + statements: [{ kind: "text", target: `${FORM_A}#k.m` }], + }, + ], + }, + { + index: 1, + path: CODE_PATHS[1], + topLevel: [{ kind: "text", target: `${FORM_A}#k` }], + units: [ + { + name: UNIT_NAMES[0], + statements: [{ kind: "marker", target: FORM_A }], + }, + ], + }, + ], + specGroups: [[SPEC_GROUP_NAMES[0], [0]]], + codeGroups: [ + [CODE_GROUP_NAMES[0], [0, 1]], + [CODE_GROUP_NAMES[1], [1]], + ], + profiles: [ + { + name: "p1", + target: SPEC_GROUP_NAMES[0], + boundary: CODE_GROUP_NAMES[1], + mode: "direct", + targets: null, + targetTags: null, + edgeKinds: null, + }, + { + name: "p2", + target: SPEC_GROUP_NAMES[0], + boundary: CODE_GROUP_NAMES[0], + mode: "transitive", + targets: "leaves", + targetTags: TARGET_TAG_SETS[3]!, + edgeKinds: null, + }, + { + name: "p3", + target: SPEC_GROUP_NAMES[0], + boundary: SPEC_GROUP_NAMES[0], + mode: "direct", + targets: "all", + targetTags: null, + edgeKinds: KIND_SETS[2]!, + }, + ], + }, + ], + [ + "no code file", + { + ...P13_FORM_TRIAL, + codeFiles: [], + codeGroups: [], + profiles: [ + { + name: "p1", + target: SPEC_GROUP_NAMES[1], + boundary: SPEC_GROUP_NAMES[0], + mode: "transitive", + targets: null, + targetTags: null, + edgeKinds: null, + }, + ], + }, + ], +]; + +/** + * The fixed TypeScript form-vector set of the P-13 rendering (TEST-SPEC 17 + * S-9; the §16 preamble): `[name, staged path, source]` for every + * configuration file and code source `renderP13Files` stages over the + * fixed trials above — `xspec.config.ts` three times, `c0/U.ts` twice, and + * `c1/V.ts` once; each path selects its grammar (plain TypeScript). + */ +export const P13_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string] +> = P13_TS_FORM_TRIALS.flatMap(([label, trial]) => + Object.entries(renderP13Files(trial)) + .filter(([path]) => !path.endsWith(".mdx")) + .map(([path, source]): readonly [string, string, string] => [ + `${label}: ${path}`, + path, + source, + ]), +); + +/** + * S-9's per-draw check (helpers/property.ts `drawSources`): the staged files + * — the `.mdx` spec sources judged for derivability, and the configuration + * and the code sources (`c0/U.ts`, `c1/V.ts`, TypeScript-default names) + * judged well-formed TypeScript. + */ +function stagedP13Sources(trial: P13Trial): DrawSource[] { + return Object.entries(renderP13Files(trial)); +} + +/** The P-13 property body for one generated trial (module header). */ +async function runP13Trial( + product: ProductBinding, + trial: P13Trial, +): Promise<void> { + const files = renderP13Files(trial); + // S-9: the draw's sources, judged by the property runner before the body + // saw them (`stagedP13Sources` above) — declared per draw, as every + // initial `.mdx` file a trial stages after the body's first product + // invocation must be (helpers/workspace.ts); the configuration and the + // code sources (`c0/U.ts`, `c1/V.ts`) the draw composed are declared per + // draw too (`ts.perDraw`: judged well-formed at staging, past the + // undeclared-staging guard's TypeScript arm). + const workspace = await TestWorkspace.create({ + files, + mdx: { perDraw: mdxPathsOf(files) }, + ts: { perDraw: tsPathsOf(files) }, + }); + 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, + drawSources: stagedP13Sources, + }, + ); + }, +}); + +/** 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 16ab0e0f..2e184f99 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 @@ -27,7 +30,10 @@ // children yielding N + 1 runs, so |subtree| = |own| + Σ|child subtree| // and a run decomposition exists that splits the reported own text into // exactly N + 1 runs (empty runs counting) around the children's -// reported subtree texts. +// reported subtree texts. A node's children are the product's answer +// too: the targets of its `query node` answer's outgoing `contains` +// edges (SPEC 5.2), put in document order by the source ranges (1.7) +// each child's own `query node` answer reports. // // CONF-MD in-scope (CERTIFICATIONS.md): both properties run against the // CONF-MD conformer, and P-2 is certified by §VIOL-MD-CLASS (the line-drop @@ -38,29 +44,82 @@ // 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 // against exactly the violators. The bodies stay within the CONF-MD command -// surface: `build` plus `query node` decoded through the scoped own/subtree -// text adapter (decodeNodeTextSummary) — nothing beyond own and subtree -// text is demanded of a scoped fixture product. +// surface, on the route its P-3 staging constraint fixes: `build`, plus one +// `query node` per node decoded through the scoped text-algebra adapter +// (decodeNodeTextAlgebraSummary: own and subtree text, source range, and +// the targets of the outgoing `contains` edges — nothing else is demanded +// of a scoped fixture product). P-3 reads each node's own and subtree text +// from its own answer, takes its children from that same answer's outgoing +// `contains` edges ordered by the source ranges the children's own answers +// report, and enumerates a document's nodes from the generated document +// itself — never through `view` (with or without `--text`) or +// `query subtree`. The generator's record of the structure +// (`DocNode.childRefs`) is never consulted, so no harness model stands +// between the product's answers. // // 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. -// * Section tags, imports, and embeddings are single-line and ASCII; the -// exotic bytes live in content, where P-2 aims them. Multi-line comments +// * 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 — and the inline-delimiter punctuation `*`, `_`, `[`, `]`, +// `(`, `)`, whose CommonMark pairs (emphasis, links) can straddle an +// inline section's tag within a paragraph, a shape the MDX grammar +// rejects (S-9: every composed form derives; `P2_P3_FORM_VECTORS` +// below is the fixed vector set, and every draw is checked before the +// product sees it). Everything else (the remaining 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 and imports are single-line and ASCII; the exotic bytes +// live in content, where P-2 aims them. Comments take every form of +// SPEC 2.7 and embeddings every form of 2.3 (`mdxComment`, +// `embeddingContainer` below: `{}`, block-comment sequences, +// line-comment containers ended by a drawn terminator, the run-on +// `{// c}` form, ECMAScript-only whitespace between braces; whitespace +// and comments beside a `text(...)` call — T2.7-4's and T2.3-3's positive +// forms), and an ESM block carries JavaScript comments beside its +// imports and `;`-terminated declarations (T3-7). Multi-line containers // carry 1–2 internal terminators and no internal blank line (MDX -// expressions admit none); the comment alphabet contains no `/`, so a -// premature `*/` cannot form. +// expressions admit none); the comment alphabets contain no `/` or `*`, +// so a premature `*/` cannot form, and a line comment's alphabet has no +// U+2028 or U+2029, at which the lexical grammar would end it while +// 14.20's deletion judgement runs on (T2.7-4's negative arms). +// * A generated line's lead never opens a CommonMark container (a list +// item: `-` or digits with `.`/`)`, then whitespace, after any +// indentation) or an ATX heading (`#`s then whitespace): inside a +// container the continuation line of a multi-line expression is a lazy +// line the grammar rejects, and a heading ends at its line, so a +// multi-line container it hosts never closes (`opensBlockConstruct`, +// applied by `keptProse`). Every other lead — `-a`, `9.a`, `#{`, `---` +// (a thematic break), a setext underline — is harmless. // * Line terminators are drawn per line; a deterministic guard keeps a // lone-CR terminator from being followed by an empty line's LF (the two // bytes would merge into one CRLF terminator and desynchronize the @@ -81,8 +140,11 @@ // the interior compiles compositionally; validated against T3-2's // hand-derived chain). -import { decodeNodeTextSummary } from "../../helpers/adapters/index.js"; -import type { NodeTextSummary } from "../../helpers/adapters/index.js"; +import { decodeNodeTextAlgebraSummary } from "../../helpers/adapters/index.js"; +import type { + NodeTextAlgebraSummary, + SourceRange, +} from "../../helpers/adapters/index.js"; import { assertFileBytes, assertFilesEqual, @@ -90,19 +152,28 @@ import { } from "../../helpers/assertions.js"; import type { MarkdownPiece } from "../../helpers/oracles/markdown.js"; import { compileMarkdown } from "../../helpers/oracles/markdown.js"; -import type { Choices, Gen } from "../../helpers/property.js"; +import type { Choices, DrawSource, 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 { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; -import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import { TestWorkspace, mdxPathsOf } from "../../helpers/workspace.js"; import { buildOk, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): one spec group, emission // enabled with the default destination next to each source (SPEC 7.3, 13.2). // The spec-group glob matches only `.mdx` files, so no glob matches a // Markdown emit destination. -const EMIT_TRUE_CONFIG = `import { defineConfig } from "xspec" +// A TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), well-formed: every trial stages it afresh, from the +// second trial on after the body's first product invocation — an initial +// file S-7's sweep never reaches, so the ledger self-test judges it before +// any product exists. +const EMIT_TRUE_CONFIG = stagedTs( + "P-2/P-3 xspec.config.ts — one spec group, Markdown emission enabled", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -110,7 +181,26 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the one configuration file P-2 and P-3 share, the record above — + * judged as a record by test/self/s9-staged-sources.test.ts too, and here + * beside every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); neither composes a code + * source. + */ +export const P2_P3_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-2/P-3 configuration (EMIT_TRUE_CONFIG)", + "xspec.config.ts", + EMIT_TRUE_CONFIG.source, + ], +]; /** The character with the given code point (hex-spelled, tool-safe). */ function cp(codePoint: number): string { @@ -128,6 +218,15 @@ const CRLF = CR + LF; const NBSP = cp(0x00a0); const NEL = cp(0x0085); const LS = cp(0x2028); +// ECMAScript's remaining line terminator and its zero-width no-break space: +// whitespace between braces and within an ESM block (SPEC 14.20), content +// bytes everywhere else (1.4). +const PS = cp(0x2029); +const FEFF = cp(0xfeff); +// 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 @@ -163,7 +262,11 @@ export type TargetShape = export interface DocNode { /** `path` for a root, `path#dotted.id` for a section (SPEC 1.5). */ readonly ref: string; - /** Direct child sections' refs, in document order (SPEC 1.6). */ + /** + * Direct child sections' refs, in document order (SPEC 1.6) — the + * generator's own record, never consulted by P-3, which takes each node's + * children from the product's `contains` edges (module header). + */ readonly childRefs: readonly string[]; } @@ -283,24 +386,31 @@ const PROSE_ALPHABET: ReadonlyArray<readonly [number, string]> = [ // (JS regex `\s`-style) classifier would misclassify, plus multi-byte and // astral content so byte counting is exercised. [1, cp(0x2000)], - [1, cp(0x2029)], + [1, PS], [1, cp(0x3000)], [2, cp(0x00e9)], [1, cp(0x4e2d)], [1, cp(0x1f600)], // Markdown punctuation — plain content to SPEC 3 (compilation never - // interprets Markdown semantics). + // interprets Markdown semantics) — restricted to characters that open no + // CommonMark inline construct: the emphasis delimiters `*` and `_` and + // the link characters `[`, `]`, `(`, `)` are excluded because their pairs + // can straddle an inline section's tag — within one line or across a + // paragraph's lines — and the MDX grammar rejects a JSX element whose tag + // lies inside an emphasis or link that closes outside it (S-9; found by + // the fixed form vectors below). Block-level punctuation (`#`, `-`, `|`, + // `:`, `!`, `.`) cannot pair across a tag. [2, "."], [2, "-"], - [1, "_"], + [1, ";"], [1, "#"], - [1, "*"], + [1, ","], [1, "|"], [1, ":"], - [1, "["], - [1, "]"], - [1, "("], - [1, ")"], + [1, "%"], + [1, "@"], + [1, "?"], + [1, "$"], [1, "!"], ]; @@ -330,12 +440,49 @@ function run(element: Gen<string>, min: number, max: number): Gen<string> { /** Free prose (may be empty or whitespace-only). */ const prose: Gen<string> = run(proseChar, 0, 10); -/** Prose guaranteed to contain a plain non-whitespace character. */ +/** + * Whether a line beginning with `lead` would open a CommonMark container or + * an ATX heading: after any indentation (MDX disables indented code, so no + * amount of leading whitespace makes a code block), a list marker — `-`, or + * one to nine digits then `.` or `)` — or one to six `#`, followed by + * whitespace or by the end of the lead (whatever follows the lead is judged + * as if it were whitespace, the conservative reading). `+`, `*`, and `>` are + * not in the prose alphabet. A container makes the continuation line of a + * multi-line expression a lazy line, which the expression grammar rejects, + * and a heading ends at its line, so a multi-line container hosted on one + * never closes (S-9; both found by the per-draw check). + */ +function opensBlockConstruct(lead: string): boolean { + const isWhitespace = (char: string | undefined): boolean => + char === undefined || INLINE_WHITESPACE.includes(char as never); + let i = 0; + while (i < lead.length && isWhitespace(lead[i])) i += 1; + let j = i; + if (lead[j] === "-") { + j += 1; + } else if (lead[j] === "#") { + while (j < lead.length && j - i < 6 && lead[j] === "#") j += 1; + } else { + while (j < lead.length && j - i < 9 && lead[j] >= "0" && lead[j] <= "9") { + j += 1; + } + if (j === i || (lead[j] !== "." && lead[j] !== ")")) return false; + j += 1; + } + return isWhitespace(lead[j]); +} + +/** + * Prose guaranteed to contain a plain non-whitespace character — a line's + * lead, so it never opens a container or a heading (`opensBlockConstruct`: + * a letter is prepended when the draw would; no choice is consumed). + */ const keptProse: Gen<string> = (choices) => { const before = run(proseChar, 0, 4)(choices); const anchor = plainChar(choices); const after = run(proseChar, 0, 4)(choices); - return before + anchor + after; + const text = before + anchor + after; + return opensBlockConstruct(text) ? `a${text}` : text; }; /** Plain-only prose (letters/digits/space) with a guaranteed anchor. */ @@ -369,6 +516,235 @@ const terminator: Gen<string> = (choices) => [3, CR], ]); +// --------------------------------------------------------------------------- +// The refined container and ESM-block forms (TEST-SPEC §16 P-2: T2.7-4's +// comment forms, T2.3-3's embedding forms, T3-7's ESM-block comments) + +// Line-comment interiors: the prose alphabet minus ECMAScript's line +// terminators U+2028 and U+2029 — the lexical grammar ends a line comment at +// either while 14.20's deletion judgement runs it through the first U+000A or +// U+000D alone, and the two disagreeing makes the file unparseable (T2.7-4's +// negative arms, never staged here). A block comment may hold them (a +// terminator inside one changes nothing), so block-comment interiors keep +// the whole alphabet (`commentProse`). Neither alphabet spells `/` or `*`. +const LINE_COMMENT_ALPHABET: ReadonlyArray<readonly [number, string]> = + PROSE_ALPHABET.filter(([, char]) => char !== LS && char !== PS); + +const lineCommentProse: Gen<string> = (choices) => + ` ${run((c: Choices) => c.weightedPick(LINE_COMMENT_ALPHABET), 0, 6)(choices)} `; + +/** + * The whitespace P-2 draws between a comment container's braces: ECMAScript's + * whitespace and line terminators that 1.4 excludes (14.20, T2.7-4) — U+FEFF, + * U+2028, U+2029, and every Unicode 15.1 space separator but U+0020: U+00A0, + * U+1680, U+2000 through U+200A, U+202F, U+205F, and U+3000 (TEST-SPEC §16 + * P-2). Code point order, U+00A0 (the shrink target) first. + */ +export const ECMASCRIPT_ONLY_WHITESPACE: readonly string[] = [ + NBSP, + cp(0x1680), + ...Array.from({ length: 0x200a - 0x2000 + 1 }, (_, k) => cp(0x2000 + k)), + LS, + PS, + cp(0x202f), + cp(0x205f), + cp(0x3000), + FEFF, +]; + +const ecmascriptOnlyWhitespaceChar: Gen<string> = (choices) => + choices.pick(ECMASCRIPT_ONLY_WHITESPACE); + +/** + * A gap of a block-comment sequence: a space, nothing, or — a third of the + * time, shared equally — one of the ECMAScript-only whitespace characters. + * One draw whatever the set's size; integer weights keep the space and + * nothing shares exact. + */ +const SEQUENCE_GAPS: ReadonlyArray<readonly [number, string]> = [ + [3 * ECMASCRIPT_ONLY_WHITESPACE.length, SPACE], + [ECMASCRIPT_ONLY_WHITESPACE.length, ""], + ...ECMASCRIPT_ONLY_WHITESPACE.map((char): readonly [number, string] => [ + 2, + char, + ]), +]; + +/** Whitespace beside a `text(...)` call in an embedding container (the gaps + * of a comment's block-comment sequence are `SEQUENCE_GAPS`): 1.4's inline + * whitespace and, less often, ECMAScript's own. */ +const containerWhitespace: Gen<string> = run( + (choices: Choices) => + choices.weightedPick<string>([ + [6, SPACE], + [1, TAB], + [1, NBSP], + [1, LS], + ]), + 1, + 2, +); + +/** + * An MDX comment container in one of 2.7's forms (T2.7-4's positive forms), + * the usual block-comment form dominant — the certified flip classes ride the + * residues beside it, not the form: `{}`; a block-comment sequence with + * whitespace (ECMAScript's included) or nothing between the comments; + * ECMAScript-only whitespace between the braces; and, when `multiLine` + * allows, the line-comment containers — ended by a drawn terminator (LF, + * CRLF, lone CR: 14.20 ends a line comment at U+000A or U+000D alike) + * before the closing brace, the run-on `{// c}` form whose first brace lies + * on the commented-out line and closes nothing, and mixed sequences. A + * multi-line container's terminator is among its own characters: deleted + * with it, the lines it spans merging (SPEC 3), never a line of the + * generator's line model. + */ +function mdxComment(choices: Choices, multiLine = true): string { + type Form = + | "usual" + | "empty" + | "sequence" + | "ecmascriptWhitespace" + | "lineComment" + | "runOn" + | "blockThenLine" + | "lineThenBlock" + | "twoLines"; + const forms: (readonly [number, Form])[] = [ + [12, "usual"], + [2, "empty"], + [2, "sequence"], + [2, "ecmascriptWhitespace"], + ]; + if (multiLine) { + forms.push( + [2, "lineComment"], + [2, "runOn"], + [1, "blockThenLine"], + [1, "lineThenBlock"], + [1, "twoLines"], + ); + } + const block = (): string => `/*${commentProse(choices)}*/`; + const line = (): string => `//${lineCommentProse(choices)}`; + const gap = (): string => choices.weightedPick(SEQUENCE_GAPS); + switch (choices.weightedPick(forms)) { + case "usual": + return `{${block()}}`; + case "empty": + return "{}"; + case "sequence": + return `{${gap()}${block()}${gap()}${block()}${gap()}}`; + case "ecmascriptWhitespace": + return `{${run(ecmascriptOnlyWhitespaceChar, 1, 2)(choices)}}`; + case "lineComment": + return `{${line()}${terminator(choices)}}`; + case "runOn": + return `{${line()}}${terminator(choices)}}`; + case "blockThenLine": + return `{${block()} ${line()}${terminator(choices)}}`; + case "lineThenBlock": + return `{${line()}${terminator(choices)}${block()}}`; + case "twoLines": + return `{${line()}${terminator(choices)}${line()}${terminator(choices)}}`; + } +} + +/** + * An embedding container around `text(<argText>)` in one of 2.3's forms + * (T2.3-3's positive forms), the bare `{text(...)}` dominant: whitespace + * beside the call (ECMAScript's included), a block comment before it, after + * it, or both, and, when `multiLine` allows, a line comment before the call + * ended by a drawn terminator, the run-on `{// c}` form holding the call on + * the next line, and a line comment after the call ended before the closing + * brace. The whole container is the embedding's own characters (SPEC 3, 2.3: + * replaced whole, interior terminator included). + */ +function embeddingContainer( + choices: Choices, + argText: string, + multiLine = true, +): string { + type Form = + | "bare" + | "whitespace" + | "blockBefore" + | "blockAfter" + | "blockBoth" + | "lineBefore" + | "runOn" + | "lineAfter"; + const forms: (readonly [number, Form])[] = [ + [12, "bare"], + [2, "whitespace"], + [2, "blockBefore"], + [2, "blockAfter"], + [1, "blockBoth"], + ]; + if (multiLine) { + forms.push([2, "lineBefore"], [2, "runOn"], [1, "lineAfter"]); + } + const call = `text(${argText})`; + const block = (): string => `/*${commentProse(choices)}*/`; + const line = (): string => `//${lineCommentProse(choices)}`; + switch (choices.weightedPick(forms)) { + case "bare": + return `{${call}}`; + case "whitespace": + return `{${containerWhitespace(choices)}${call}${containerWhitespace(choices)}}`; + case "blockBefore": + return `{${block()} ${call}}`; + case "blockAfter": + return `{${call} ${block()}}`; + case "blockBoth": + return `{ ${block()} ${call} ${block()} }`; + case "lineBefore": + return `{${line()}${terminator(choices)}${call}}`; + case "runOn": + return `{${line()}}${terminator(choices)}${call}}`; + case "lineAfter": + return `{${call} ${line()}${terminator(choices)}}`; + } +} + +// 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 +873,7 @@ function genBlock( | "comment" | "multiComment" | "embedLine" + | "fence" | "section" | "selfClosing" >([ @@ -506,6 +883,7 @@ function genBlock( [3, "comment"], [2, "multiComment"], [3, "embedLine"], + [2, "fence"], [5, "section"], [2, "selfClosing"], ]); @@ -526,6 +904,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) { @@ -534,7 +915,7 @@ function genBlock( } out.push({ kind: "embed", - text: `{text(${ref.argText})}`, + text: embeddingContainer(choices, ref.argText), targetKey: ref.targetKey, }); endLine(choices, ctx, out, false); @@ -571,12 +952,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,24 +973,29 @@ 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"], ]); const pieces: DocEntry[] = [{ kind: "content", text: lead }]; switch (inline) { case "comment": - pieces.push({ kind: "removal", text: `{/*${commentProse(choices)}*/}` }); + pieces.push({ kind: "removal", text: mdxComment(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) { pieces.push({ kind: "embed", - text: `{text(${ref.argText})}`, + text: embeddingContainer(choices, ref.argText), targetKey: ref.targetKey, }); } @@ -649,17 +1036,16 @@ 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, ctx: FileContext, out: DocEntry[], ): void { - const comment: DocEntry = { - kind: "removal", - text: `{/*${commentProse(choices)}*/}`, - }; + const comment: DocEntry = { kind: "removal", text: mdxComment(choices) }; const residue = choices.weightedPick<Gen<string>>([ [4, () => ""], [4, run(boundaryChar, 1, 3)], @@ -681,6 +1067,7 @@ function genCommentLine( )(c), ], [2, run(plainChar, 1, 3)], + [2, codeSpan], ])(choices); const residueFirst = choices.boolean(0.3); if (residue !== "" && residueFirst) { @@ -718,6 +1105,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 @@ -811,12 +1238,44 @@ export const generatedDoc: Gen<GeneratedDoc> = (choices) => { }, }; - for (const { binding, path: p } of imports) { + // The ESM block (SPEC 14.20): one declaration per line, each optionally + // `;`-terminated (the `;` among its own characters, T3-7's terminator + // arm), with JavaScript comments beside the declarations — content under + // SPEC 3, no MDX comment (T3-7): a line or block comment after an import + // on its line, an own-line `// note` between two imports or after the + // last, and a block comment before an import on the block's second line + // onward. Never before the first line: an ESM block begins at an import + // keyword, so a comment there would make the block a paragraph. + const jsLineComment = (): string => `//${lineCommentProse(choices)}`; + const jsBlockComment = (): string => `/*${commentProse(choices)}*/`; + imports.forEach(({ binding, path: p }, importIndex) => { const name = p.slice("specs/".length, -".mdx".length); + if (importIndex > 0 && choices.boolean(0.2)) { + entries.push({ kind: "content", text: jsLineComment() }); + endLine(choices, ctx, entries, false); + } + if (importIndex > 0 && choices.boolean(0.2)) { + entries.push({ kind: "content", text: `${jsBlockComment()} ` }); + } + const semicolon = choices.boolean(0.25) ? ";" : ""; entries.push({ kind: "removal", - text: `import ${binding} from "./${name}.xspec"`, + text: `import ${binding} from "./${name}.xspec"${semicolon}`, }); + const trailing = choices.weightedPick<"none" | "line" | "block">([ + [7, "none"], + [2, "line"], + [1, "block"], + ]); + if (trailing === "line") { + entries.push({ kind: "content", text: ` ${jsLineComment()}` }); + } else if (trailing === "block") { + entries.push({ kind: "content", text: ` ${jsBlockComment()}` }); + } + endLine(choices, ctx, entries, false); + }); + if (imports.length > 0 && choices.boolean(0.15)) { + entries.push({ kind: "content", text: jsLineComment() }); endLine(choices, ctx, entries, false); } // A mandatory blank line after the import block: MDX ESM blocks extend @@ -854,6 +1313,629 @@ export const generatedDoc: Gen<GeneratedDoc> = (choices) => { return { files, targets }; }; +// --------------------------------------------------------------------------- +// Fixed form vectors (TEST-SPEC 17 S-9; 16 preamble) +// +// The enumerated forms this generator can compose — not draws — each spelled +// from the generator's own constants and templates as a complete source, in +// every context the generator can place it (directly after a prose line, +// after a blank line, and as a block section's interior, all in one +// document), so that S-9's self-test +// (test/self/s9-fixture-well-formedness.test.ts) proves before any product +// exists that every form derives under the grammar 14.20 fixes; at property +// time every draw is checked the same way before the product sees it +// (`drawSources` on the registrations below; helpers/property.ts). A form +// added to the generator is added here. + +/** Every character of the prose alphabet, once, in alphabet order. */ +const ALL_PROSE_CHARS = PROSE_ALPHABET.map(([, char]) => char).join(""); +/** Every character of the line-comment alphabet, once, in alphabet order. */ +const ALL_LINE_COMMENT_CHARS = LINE_COMMENT_ALPHABET.map( + ([, char]) => char, +).join(""); +/** The mandatory construct-free plain-prose first line (module header). */ +const FORM_FIRST_LINE = "a0 first"; +const FORM_TRAIL_LINE = "z9 trail"; + +function codePointName(char: string): string { + const codePoint = char.codePointAt(0); + if (codePoint === undefined) throw new Error("empty character"); + return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`; +} + +/** A fresh dotted id allocator under a prefix (`""` at root level). */ +function formIds(prefix: string): () => string { + let counter = 0; + return () => { + const dotted = `${prefix}s${String(counter)}`; + counter += 1; + return dotted; + }; +} + +/** One form: its lines, given an id allocator for the sections it spells. */ +interface FormSpelling { + readonly name: string; + readonly lines: (id: () => string) => readonly string[]; +} + +const FORM_TERMINATORS: ReadonlyArray<readonly [string, string]> = [ + ["LF", LF], + ["CRLF", CRLF], + ["CR", CR], +]; + +/** The comment-line residue classes of genCommentLine. */ +const FORM_RESIDUES: ReadonlyArray<readonly [string, string]> = [ + ["boundary-only", NBSP + NEL + LS], + ["whitespace-only", SPACE + TAB + VT], + ["mixed", SPACE + NBSP + NEL + LS + TAB], + ["plain", "ab9"], + ["code-span", `${BACKTICK}${CONSTRUCT_LIKE_SPAN_INTERIORS[0]}${BACKTICK}`], +]; + +/** Every code span: 1–2 backtick runs around each interior (codeSpan). */ +const FORM_CODE_SPANS: ReadonlyArray<readonly [string, string]> = [ + 1, 2, +].flatMap((length) => + CONSTRUCT_LIKE_SPAN_INTERIORS.map((interior): readonly [string, string] => [ + `code span, ${String(length)}-backtick runs around ${JSON.stringify(interior)}`, + `${BACKTICK.repeat(length)}${interior}${BACKTICK.repeat(length)}`, + ]), +); + +/** The prop combinations of extraProps, in its spelling order (d, coverage, tags). */ +const FORM_PROPS: ReadonlyArray<readonly [string, string]> = [ + ["no props", ""], + ["d single local", ' d={"s0"}'], + ["d single external", " d={M1.s0}"], + ["d pair", ' d={["s0", M1.s0]}'], + ["coverage required", ' coverage="required"'], + ["coverage none", ' coverage="none"'], + ["tags one", ' tags="t1"'], + ["tags two", ' tags="t1 t2"'], + ["tags alpha", ' tags="alpha"'], + ["all props", ' d={["s0", M1.s0]} coverage="required" tags="t1 t2"'], +]; + +const FORM_FENCE_MARKERS = [3, 4].flatMap((length) => [ + BACKTICK.repeat(length), + TILDE.repeat(length), +]); +const FORM_FENCE_INFOS = ["", "ts", "md"] as const; + +/** The block forms of genBlock (and the import lines), one entry each. */ +const BLOCK_FORMS: readonly FormSpelling[] = [ + { name: "blank line", lines: () => [""] }, + ...INLINE_WHITESPACE.map((char): FormSpelling => ({ + name: `whitespace-only line of ${codePointName(char)}`, + lines: () => [char], + })), + { + name: "whitespace-only line, a run of all four whitespace characters", + lines: () => [INLINE_WHITESPACE.join("")], + }, + { + name: "prose line of every alphabet character", + lines: () => [ALL_PROSE_CHARS], + }, + ...PROSE_ALPHABET.map(([, char]): FormSpelling => ({ + name: `prose line of ${codePointName(char)} alone`, + lines: () => [char], + })), + { + name: "comment line, alphabet interior", + lines: () => [`{/* ${ALL_PROSE_CHARS} */}`], + }, + { name: "comment line, empty interior", lines: () => ["{/* */}"] }, + ...FORM_RESIDUES.flatMap(([residueName, residue]): FormSpelling[] => [ + { + name: `comment line, ${residueName} residue before`, + lines: () => [`${residue}{/* ab */}`], + }, + { + name: `comment line, ${residueName} residue after`, + lines: () => [`{/* ab */}${residue}`], + }, + ]), + ...FORM_TERMINATORS.flatMap( + ([terminatorName, terminator]): FormSpelling[] => [ + { + name: `multi-line comment, one internal ${terminatorName}`, + lines: () => [`{/* ab${terminator}cd */}`], + }, + { + name: `multi-line comment, two internal ${terminatorName}`, + lines: () => [`{/* ab${terminator}cd${terminator}ef */}`], + }, + { + name: `multi-line comment with residues, internal ${terminatorName}`, + lines: () => [`a0 lead{/* ab${terminator}cd */}${ALL_PROSE_CHARS}`], + }, + ], + ), + { + name: "multi-line comment, mixed internal terminators", + lines: () => [`{/* ab${LF}cd${CR}ef */}`], + }, + // The refined comment forms of 2.7 (mdxComment), own-line. + { name: "comment line, `{}`", lines: () => ["{}"] }, + { + name: "comment line, block-comment sequence with spaces between", + lines: () => [`{ /* ${ALL_PROSE_CHARS} */ /* ab */ }`], + }, + { + name: "comment line, block-comment sequence with nothing between", + lines: () => ["{/* ab *//* cd */}"], + }, + { + name: "comment line, block-comment sequence with ECMAScript-only whitespace between", + lines: () => [`{${NBSP}/* ab */${LS}/* cd */${FEFF}}`], + }, + // Each brace-side whitespace character P-2 names (TEST-SPEC §16 P-2), alone + // between the braces and in every gap of a block-comment sequence. + ...ECMASCRIPT_ONLY_WHITESPACE.flatMap((char): FormSpelling[] => [ + { + name: `comment line, ${codePointName(char)} between the braces`, + lines: () => [`{${char}}`], + }, + { + name: `comment line, block-comment sequence with ${codePointName(char)} in every gap`, + lines: () => [`{${char}/* ab */${char}/* cd */${char}}`], + }, + ]), + { + name: "comment line, every ECMAScript-only whitespace between the braces", + lines: () => [`{${ECMASCRIPT_ONLY_WHITESPACE.join("")}}`], + }, + ...FORM_TERMINATORS.flatMap( + ([terminatorName, terminator]): FormSpelling[] => [ + { + name: `line-comment container ended by ${terminatorName}, alphabet interior`, + lines: () => [`{// ${ALL_LINE_COMMENT_CHARS}${terminator}}`], + }, + { + name: `line-comment container ended by ${terminatorName}, empty interior`, + lines: () => [`{// ${terminator}}`], + }, + { + name: `run-on line-comment container, ${terminatorName}`, + lines: () => [`{// ${ALL_LINE_COMMENT_CHARS}}${terminator}}`], + }, + { + name: `block comment then line comment, ${terminatorName}`, + lines: () => [`{/* ${ALL_PROSE_CHARS} */ // ab${terminator}}`], + }, + { + name: `line comment then block comment, ${terminatorName}`, + lines: () => [`{// ab${terminator}/* ${ALL_PROSE_CHARS} */}`], + }, + { + name: `two line comments, ${terminatorName}`, + lines: () => [`{// ab${terminator}// cd${terminator}}`], + }, + { + name: `line-comment container with residues, ${terminatorName}`, + lines: () => [`a0 lead{// ab${terminator}}${ALL_PROSE_CHARS}`], + }, + { + name: `run-on container with residues, ${terminatorName}`, + lines: () => [`a0 lead{// ab}${terminator}}${ALL_PROSE_CHARS}`], + }, + ...FORM_RESIDUES.flatMap(([residueName, residue]): FormSpelling[] => [ + { + name: `line-comment container, ${residueName} residue before, ${terminatorName}`, + lines: () => [`${residue}{// ab${terminator}}`], + }, + { + name: `line-comment container, ${residueName} residue after, ${terminatorName}`, + lines: () => [`{// ab${terminator}}${residue}`], + }, + ]), + ], + ), + ...FORM_FENCE_MARKERS.flatMap((marker) => + FORM_FENCE_INFOS.map((info): FormSpelling => ({ + name: `fenced code block ${marker}${info}, full interior`, + lines: () => [ + `${marker}${info}`, + ...CONSTRUCT_LIKE_LINES, + ALL_PROSE_CHARS, + "", + marker, + ], + })), + ), + ...CONSTRUCT_LIKE_LINES.map((line): FormSpelling => ({ + name: `fenced code block holding ${JSON.stringify(line)} alone`, + lines: () => [FORM_FENCE_MARKERS[0], line, FORM_FENCE_MARKERS[0]], + })), + { + name: "fenced code block, empty", + lines: () => [FORM_FENCE_MARKERS[0], FORM_FENCE_MARKERS[0]], + }, + { name: "embedding line, local reference", lines: () => ['{text("s0")}'] }, + { + name: "embedding line, external reference", + lines: () => ["{text(M1.s0)}"], + }, + // The refined embedding forms of 2.3 (embeddingContainer), own-line. + { + name: "embedding line, whitespace beside the call", + lines: () => [`{ ${TAB}text("s0")${NBSP}${LS} }`], + }, + { + name: "embedding line, block comment before the call", + lines: () => [`{/* ${ALL_PROSE_CHARS} */ text(M1.s0)}`], + }, + { + name: "embedding line, block comment after the call", + lines: () => [`{text("s0") /* ${ALL_PROSE_CHARS} */}`], + }, + { + name: "embedding line, block comments on both sides", + lines: () => ["{ /* ab */ text(M1.s0) /* cd */ }"], + }, + ...FORM_TERMINATORS.flatMap( + ([terminatorName, terminator]): FormSpelling[] => [ + { + name: `embedding line, line comment before the call, ${terminatorName}`, + lines: () => [`{// ${ALL_LINE_COMMENT_CHARS}${terminator}text("s0")}`], + }, + { + name: `embedding line, run-on line comment holding the call, ${terminatorName}`, + lines: () => [ + `{// ${ALL_LINE_COMMENT_CHARS}}${terminator}text(M1.s0)}`, + ], + }, + { + name: `embedding line, line comment after the call, ${terminatorName}`, + lines: () => [`{text("s0") // ${ALL_LINE_COMMENT_CHARS}${terminator}}`], + }, + ], + ), + ...TAG_NAMES.flatMap((tag): FormSpelling[] => [ + { + name: `self-closing section line <${tag}>`, + lines: (id) => [`<${tag} id="${id()}" />`], + }, + { + name: `block section <${tag}>`, + lines: (id) => [`<${tag} id="${id()}">`, "interior a", `</${tag}>`], + }, + ]), + ...FORM_PROPS.map(([propsName, props]): FormSpelling => ({ + name: `block section, ${propsName}`, + lines: (id) => [`<S id="${id()}"${props}>`, "interior a", "</S>"], + })), + { name: "block section, empty", lines: (id) => [`<S id="${id()}">`, "</S>"] }, + { + name: "block sections nested three deep", + lines: (id) => { + const outer = id(); + return [ + `<S id="${outer}">`, + `<Spec id="${outer}.s0">`, + `<S id="${outer}.s0.s0">`, + "x", + "</S>", + "</Spec>", + "</S>", + ]; + }, + }, +]; + +/** The inline elements of genProseLine, each spelled once. */ +const INLINE_ELEMENTS: ReadonlyArray< + readonly [string, (id: () => string) => string] +> = [ + ["inline comment", () => "{/* ab */}"], + ["inline embedding, local reference", () => '{text("s0")}'], + ["inline embedding, external reference", () => "{text(M1.s0)}"], + // The refined comment and embedding forms, in text position. + ["inline comment `{}`", () => "{}"], + ["inline block-comment sequence", () => `{ /* ab */${NBSP}/* cd */ }`], + [ + "inline comment of ECMAScript-only whitespace", + () => `{${ECMASCRIPT_ONLY_WHITESPACE.join("")}}`, + ], + ...ECMASCRIPT_ONLY_WHITESPACE.flatMap( + (char): (readonly [string, () => string])[] => [ + [ + `inline comment, ${codePointName(char)} between the braces`, + () => `{${char}}`, + ], + [ + `inline block-comment sequence with ${codePointName(char)} in every gap`, + () => `{${char}/* ab */${char}/* cd */${char}}`, + ], + ], + ), + ["inline embedding, whitespace beside the call", () => `{ text("s0")${LS}}`], + [ + "inline embedding, block comments beside the call", + () => "{/* ab */ text(M1.s0) /* cd */}", + ], + ...FORM_TERMINATORS.flatMap( + ([terminatorName, terminator]): (readonly [string, () => string])[] => [ + [ + `inline line-comment container, ${terminatorName}`, + () => `{// ${ALL_LINE_COMMENT_CHARS}${terminator}}`, + ], + [ + `inline run-on container, ${terminatorName}`, + () => `{// ab}${terminator}}`, + ], + [ + `inline embedding, line comment before the call, ${terminatorName}`, + () => `{// ab${terminator}text("s0")}`, + ], + [ + `inline embedding, run-on line comment holding the call, ${terminatorName}`, + () => `{// ab}${terminator}text(M1.s0)}`, + ], + [ + `inline embedding, line comment after the call, ${terminatorName}`, + () => `{text("s0") // ab${terminator}}`, + ], + ], + ), + ...FORM_CODE_SPANS.map( + ([spanName, span]): readonly [string, () => string] => [ + spanName, + () => span, + ], + ), + ...TAG_NAMES.flatMap( + (tag): (readonly [string, (id: () => string) => string])[] => [ + [ + `inline section <${tag}>, alphabet interior`, + (id) => `<${tag} id="${id()}">${ALL_PROSE_CHARS}</${tag}>`, + ], + ...INLINE_WHITESPACE.map( + (char): readonly [string, (id: () => string) => string] => [ + `inline section <${tag}>, ${codePointName(char)} interior`, + (id) => `<${tag} id="${id()}">${char}</${tag}>`, + ], + ), + [ + `inline section <${tag}>, two-character whitespace interior`, + (id) => `<${tag} id="${id()}">${FF}${VT}</${tag}>`, + ], + [ + `inline section <${tag}>, empty interior`, + (id) => `<${tag} id="${id()}"></${tag}>`, + ], + [ + `inline self-closing section <${tag}>`, + (id) => `<${tag} id="${id()}" />`, + ], + ], + ), +]; + +/** The prose-line forms: a kept lead, an inline element, an optional tail. */ +const PROSE_LINE_FORMS: readonly FormSpelling[] = [ + ...INLINE_ELEMENTS.flatMap(([elementName, element]): FormSpelling[] => [ + { + name: `prose line hosting ${elementName}, no tail`, + lines: (id) => [`a0 ${element(id)}`], + }, + { + name: `prose line hosting ${elementName}, alphabet tail`, + lines: (id) => [`a0 ${element(id)}${ALL_PROSE_CHARS}`], + }, + ]), + // The character adjoining a construct on either side: each alphabet + // character directly before the opening tag and directly after the + // closing tag of an inline section, and around an inline comment. + ...PROSE_ALPHABET.flatMap(([, char]): FormSpelling[] => [ + { + name: `inline section adjoined by ${codePointName(char)} on both sides`, + lines: (id) => [`a${char}<S id="${id()}">x</S>${char}a`], + }, + { + name: `inline comment adjoined by ${codePointName(char)} on both sides`, + lines: () => [`a${char}{/* ab */}${char}a`], + }, + { + name: `run-on container adjoined by ${codePointName(char)} on both sides`, + lines: () => [`a${char}{// ab}${LF}}${char}a`], + }, + ]), + // Leads the container/heading guard leaves reachable (opensBlockConstruct): + // a marker character not followed by whitespace opens nothing. + { + name: "prose line led by U+002D then a letter, hosting a run-on container", + lines: () => [`-a {// ab}${LF}}`], + }, + { + name: "prose line led by a digit and U+002E then a letter, hosting a line-comment container", + lines: () => [`9.a {// ab${LF}}`], + }, + { + name: "prose line led by U+0023 then a letter (no heading), hosting a two-line embedding", + lines: () => [`#a {// ab${LF}text("s0")}`], + }, + { + name: "prose line led by whitespace, U+0023, and a letter, hosting a multi-line comment", + lines: () => [` ${TAB}#a {/* ab${LF}cd */}`], + }, +]; + +/** The import block: one import per earlier file, then the mandatory blank. */ +const FORM_IMPORT_LINES = [ + 'import M1 from "./A.xspec"', + 'import M2 from "./B.xspec"', +] as const; + +/** + * The ESM-block forms of generatedDoc (T3-7): `;`-terminated declarations + * and JavaScript comments beside the imports — each block's lines, the + * mandatory blank line after it excluded. The last entry spells every form + * in one block; the composites below carry it under every terminator. + */ +const FORM_ESM_BLOCKS: ReadonlyArray<readonly [string, readonly string[]]> = [ + ["one import", [FORM_IMPORT_LINES[0]]], + ["two imports", [...FORM_IMPORT_LINES]], + [ + "`;`-terminated imports", + ['import M1 from "./A.xspec";', 'import M2 from "./B.xspec";'], + ], + [ + "a line comment after an import", + [ + `import M1 from "./A.xspec" // ${ALL_LINE_COMMENT_CHARS}`, + FORM_IMPORT_LINES[1], + ], + ], + [ + "a block comment after an import", + [ + `import M1 from "./A.xspec" /* ${ALL_PROSE_CHARS} */`, + FORM_IMPORT_LINES[1], + ], + ], + [ + "an own-line line comment between two imports", + [ + FORM_IMPORT_LINES[0], + `// ${ALL_LINE_COMMENT_CHARS}`, + FORM_IMPORT_LINES[1], + ], + ], + [ + "a block comment before an import on the block's second line", + [ + FORM_IMPORT_LINES[0], + `/* ${ALL_PROSE_CHARS} */ import M2 from "./B.xspec"`, + ], + ], + [ + "an own-line line comment after the last import", + [FORM_IMPORT_LINES[0], `// ${ALL_LINE_COMMENT_CHARS}`], + ], + [ + "a `;`-terminated single import with a line comment after it", + [`import M1 from "./A.xspec"; // ${ALL_LINE_COMMENT_CHARS}`], + ], + [ + "every ESM-block form in one block", + [ + `import M1 from "./A.xspec"; // ${ALL_LINE_COMMENT_CHARS}`, + `// ${ALL_LINE_COMMENT_CHARS}`, + `/* ${ALL_PROSE_CHARS} */ import M2 from "./B.xspec" /* ${ALL_PROSE_CHARS} */`, + `// ${ALL_LINE_COMMENT_CHARS}`, + ], + ], +]; +const FORM_ESM_BLOCK_ALL = FORM_ESM_BLOCKS[FORM_ESM_BLOCKS.length - 1][1]; + +/** + * One form's document: the form after the first prose line, after a blank + * line, and as a block section's interior (with dotted ids beneath it). + */ +function formDocument(form: FormSpelling): string { + const lines = [ + FORM_FIRST_LINE, + ...form.lines(formIds("")), + FORM_TRAIL_LINE, + "", + ...form.lines(formIds("t")), + "", + '<S id="u">', + ...form.lines(formIds("u.")), + "</S>", + FORM_TRAIL_LINE, + ]; + return lines.join(LF) + LF; +} + +/** + * Every form in one document under one terminator drawn for every line + * (the composite the generator's per-line terminator draw can reach; the + * multi-line container forms keep their own internal terminators), the + * all-forms ESM block at its head. `cycle` draws LF, CRLF, CR in turn, + * applying endLine's lone-CR guard. + */ +function compositeDocument( + terminatorOf: ( + index: number, + lineIsEmpty: boolean, + previous: string, + ) => string, + stripFinal: boolean, +): string { + const id = formIds(""); + const lines = [ + ...FORM_ESM_BLOCK_ALL, + "", + FORM_FIRST_LINE, + ...BLOCK_FORMS.flatMap((form) => form.lines(id)), + ...PROSE_LINE_FORMS.flatMap((form) => form.lines(id)), + FORM_TRAIL_LINE, + ]; + let text = ""; + let previous = ""; + lines.forEach((line, index) => { + const terminator = terminatorOf(index, line === "", previous); + text += line + terminator; + previous = terminator; + }); + return stripFinal ? text.slice(0, -previous.length) : text; +} + +const CYCLED_TERMINATORS = [LF, CRLF, CR] as const; + +/** + * The fixed form-vector set of the P-2/P-3 generator (S-9): name and source. + */ +export const P2_P3_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + ...BLOCK_FORMS.map((form): readonly [string, string] => [ + form.name, + formDocument(form), + ]), + ...PROSE_LINE_FORMS.map((form): readonly [string, string] => [ + form.name, + formDocument(form), + ]), + ...FORM_ESM_BLOCKS.flatMap(([blockName, block]) => + FORM_TERMINATORS.map( + ([terminatorName, terminator]): readonly [string, string] => [ + `import block, ${blockName}, then the mandatory blank line, under ${terminatorName}`, + [...block, "", FORM_FIRST_LINE, "{text(M1.s0)}"].join(terminator) + + terminator, + ], + ), + ), + [ + "lone-CR terminator followed by an empty line (endLine's guard: CR, never LF)", + `a${CR}${CR}b${LF}`, + ], + ...FORM_TERMINATORS.flatMap( + ([terminatorName, terminator]): (readonly [string, string])[] => [ + [ + `every form under ${terminatorName} terminators`, + compositeDocument(() => terminator, false), + ], + [ + `every form under ${terminatorName} terminators, final terminator stripped`, + compositeDocument(() => terminator, true), + ], + ], + ), + [ + "every form under cycled terminators (endLine's lone-CR guard applied)", + compositeDocument((index, lineIsEmpty, previous) => { + const drawn = CYCLED_TERMINATORS[index % CYCLED_TERMINATORS.length]; + return lineIsEmpty && previous === CR && drawn === LF ? CR : drawn; + }, false), + ], +]; + // --------------------------------------------------------------------------- // Rendering @@ -871,12 +1953,26 @@ function mdPathOf(sourcePath: string): string { return `${sourcePath.slice(0, -".mdx".length)}.md`; } -function workspaceFiles(doc: GeneratedDoc): Record<string, string> { - const files: Record<string, string> = { "xspec.config.ts": EMIT_TRUE_CONFIG }; +function workspaceFiles( + doc: GeneratedDoc, +): Record<string, InitialFileContents> { + const files: Record<string, InitialFileContents> = { + "xspec.config.ts": EMIT_TRUE_CONFIG, + }; for (const file of doc.files) files[file.path] = sourceOf(file); return files; } +/** + * S-9's per-draw check (helpers/property.ts `drawSources`): every file a + * draw composes — the `.mdx` sources are judged before the product sees + * them. The configuration staged beside them is no draw's: a staged-source + * record, judged by the ledger self-test before any product exists. + */ +function stagedSources(doc: GeneratedDoc): DrawSource[] { + return doc.files.map((file): DrawSource => [file.path, sourceOf(file)]); +} + /** * The logical lines of a generated file that no construct touches, each with * its terminator (the final line possibly without one). Construct-internal @@ -964,9 +2060,14 @@ async function runP2Trial( ): Promise<void> { const expected = specCompiledOutputs(doc); const files = workspaceFiles(doc); - const first = await TestWorkspace.create({ files }); + // S-9: the draw's sources, judged by the property runner before the body + // saw them (`stagedSources` above) — declared per draw, as every initial + // `.mdx` file a trial stages after the body's first product invocation + // must be (helpers/workspace.ts). + const mdx = { perDraw: mdxPathsOf(files) }; + const first = await TestWorkspace.create({ files, mdx }); try { - const second = await TestWorkspace.create({ files }); + const second = await TestWorkspace.create({ files, mdx }); try { await buildOk( product, @@ -1067,41 +2168,70 @@ function excerpt(text: string): string { return rendered.length <= 160 ? rendered : `${rendered.slice(0, 160)}…`; } -/** The SPEC 1.6 algebra for one node, over product-reported values only. */ +/** + * One child of a node as the P-3 algebra reads it: an identity named by the + * node's outgoing `contains` edges, with the source range and subtree text + * the child's own `query node` answer reports. + */ +interface P3Child { + readonly identity: string; + readonly sourceRange: SourceRange; + readonly subtreeText: string; +} + +/** Document order of siblings: by reported source range (SPEC 1.7). */ +function bySourceRange(a: P3Child, b: P3Child): number { + return ( + a.sourceRange.start - b.sourceRange.start || + a.sourceRange.end - b.sourceRange.end + ); +} + +function describeChildren(children: readonly P3Child[]): string { + if (children.length === 0) return "(none)"; + return children + .map( + (child) => + `\n ${child.identity} ` + + `[${String(child.sourceRange.start)}, ${String(child.sourceRange.end)}): ` + + excerpt(child.subtreeText), + ) + .join(""); +} + +/** + * The SPEC 1.6 algebra for one node, over product-reported values only: the + * node's own answer and the children its outgoing `contains` edges name, in + * document order (`children`, already ordered by their reported ranges). + */ function assertTextAlgebra( - node: DocNode, - texts: ReadonlyMap<string, NodeTextSummary>, + identity: string, + self: NodeTextAlgebraSummary, + children: readonly P3Child[], context: string, ): void { - const self = texts.get(node.ref); - if (self === undefined) { - throw new Error(`P-3 harness defect: no queried texts for ${node.ref}`); - } - const children = node.childRefs.map((ref) => { - const child = texts.get(ref); - if (child === undefined) { - throw new Error(`P-3 harness defect: no queried texts for ${ref}`); - } - return child.subtreeText; - }); - const childrenLength = children.reduce((sum, text) => sum + text.length, 0); + const childTexts = children.map((child) => child.subtreeText); + const childrenLength = childTexts.reduce((sum, text) => sum + text.length, 0); if (self.subtreeText.length !== self.ownText.length + childrenLength) { fail( - `${context}: for ${node.ref}, |subtree text| must equal |own text| plus the sum of ` + - `the ${String(children.length)} children's |subtree text| — the children interleave ` + - `with exactly N + 1 own-text runs and nothing else (SPEC 1.6); got ` + + `${context}: for ${identity}, |subtree text| must equal |own text| plus the sum of ` + + `the ${String(children.length)} children's |subtree text| — the children its ` + + `outgoing \`contains\` edges name interleave with exactly N + 1 own-text runs and ` + + `nothing else (SPEC 1.6, 5.2); got ` + `${String(self.subtreeText.length)} vs ${String(self.ownText.length)} + ${String(childrenLength)}\n` + - ` subtree: ${excerpt(self.subtreeText)}\n own: ${excerpt(self.ownText)}`, + ` subtree: ${excerpt(self.subtreeText)}\n own: ${excerpt(self.ownText)}\n` + + ` children (by source range): ${describeChildren(children)}`, ); } - if (!interleavingExists(self.subtreeText, self.ownText, children)) { + if (!interleavingExists(self.subtreeText, self.ownText, childTexts)) { fail( - `${context}: for ${node.ref}, the reported subtree text does not decompose as the ` + - `reported own text's N + 1 runs interleaved with the ${String(children.length)} ` + - `children's reported subtree texts in document order (SPEC 1.6)\n` + + `${context}: for ${identity}, the reported subtree text does not decompose as the ` + + `reported own text's N + 1 runs interleaved with the reported subtree texts of the ` + + `${String(children.length)} children its outgoing \`contains\` edges name, in ` + + `document order by their reported source ranges (SPEC 1.6, 5.2, 1.7)\n` + ` subtree: ${excerpt(self.subtreeText)}\n` + ` own: ${excerpt(self.ownText)}\n` + - ` children: ${children.map(excerpt).join(", ")}`, + ` children (by source range): ${describeChildren(children)}`, ); } } @@ -1110,34 +2240,42 @@ async function runP3Trial( product: ProductBinding, doc: GeneratedDoc, ): Promise<void> { - const workspace = await TestWorkspace.create({ files: workspaceFiles(doc) }); + const files = workspaceFiles(doc); + // S-9: the draw's sources, declared per draw (see runP2Trial). + const workspace = await TestWorkspace.create({ + files, + mdx: { perDraw: mdxPathsOf(files) }, + }); try { await buildOk( product, workspace, "P-3: `build` of the generated workspace with `markdown: { emit: true }`", ); + // One `query node` answer per identity, each node queried once whether + // it is reached by enumeration or named by a parent's `contains` edge + // (a named identity outside the enumeration is queried too, so the + // algebra compares the product's answers to each other alone). + const answers = new Map<string, NodeTextAlgebraSummary>(); + const answerOf = async ( + identity: string, + ): Promise<NodeTextAlgebraSummary> => { + const known = answers.get(identity); + if (known !== undefined) return known; + const label = `P-3 \`query node ${identity}\``; + const answer = decodeNodeTextAlgebraSummary( + await runJson(product, workspace, ["query", "node", identity], label), + label, + ); + answers.set(identity, answer); + return answer; + }; for (const file of doc.files) { - const texts = new Map<string, NodeTextSummary>(); - for (const node of file.nodes) { - const label = `P-3 \`query node ${node.ref}\``; - texts.set( - node.ref, - decodeNodeTextSummary( - await runJson( - product, - workspace, - ["query", "node", node.ref], - label, - ), - label, - ), - ); - } - const root = texts.get(file.path); - if (root === undefined) { - throw new Error(`P-3 harness defect: no root texts for ${file.path}`); - } + // The document's nodes are enumerated from the generated document + // itself (CERTIFICATIONS.md §CONF-MD's P-3 staging constraint admits + // it); every text, range, and child below is the product's answer. + for (const node of file.nodes) await answerOf(node.ref); + const root = await answerOf(file.path); const mdRel = mdPathOf(file.path); await assertFileBytes( workspace.path(mdRel), @@ -1146,7 +2284,18 @@ async function runP3Trial( `output emitted at ${mdRel}, byte for byte (SPEC 1.6, 1.2, 3)`, ); for (const node of file.nodes) { - assertTextAlgebra(node, texts, "P-3"); + const self = await answerOf(node.ref); + const children: P3Child[] = []; + for (const identity of self.containsTargets) { + const child = await answerOf(identity); + children.push({ + identity, + sourceRange: child.sourceRange, + subtreeText: child.subtreeText, + }); + } + children.sort(bySourceRange); + assertTextAlgebra(node.ref, self, children, "P-3"); } } } finally { @@ -1160,11 +2309,15 @@ 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 " + + "with JavaScript comments and `;` terminators beside them in their ESM block, comments " + + "in every form of 2.7 — `{}`, block-comment sequences, line-comment containers, the " + + "run-on `{// c}` form, ECMAScript-only whitespace between braces — embeddings with " + + "whitespace and comments beside the call, 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, @@ -1175,7 +2328,12 @@ const P_2 = defineProductTest({ async (doc) => { await runP2Trial(product, doc); }, - { runs: 12, maxShrinkExecutions: 150, render: renderDoc }, + { + runs: 12, + maxShrinkExecutions: 150, + render: renderDoc, + drawSources: stagedSources, + }, ); }, }); @@ -1184,9 +2342,10 @@ const P_3 = defineProductTest({ id: "P-3", title: "property: for random documents, the root's subtree text equals the compiled Markdown " + - "output, and every node's subtree text equals its own-text runs interleaved with its " + - "children's subtree texts in document order, N children yielding N + 1 runs — asserted " + - "as internal consistency of the product's reported values (SPEC 1.6, 3; TEST-SPEC §16 P-3)", + "output, and every node's subtree text equals its own-text runs interleaved with the " + + "subtree texts of the children its `contains` edges name, in document order, N children " + + "yielding N + 1 runs — asserted as internal consistency of the product's reported values " + + "(SPEC 1.6, 5.2, 3; TEST-SPEC §16 P-3)", // Wall-clock hang guard only (H-10): one build plus one `query node` per // requirement node per trial, three fixed seeds (E-5), plus shrinking. timeoutMs: 300_000, @@ -1197,7 +2356,12 @@ const P_3 = defineProductTest({ async (doc) => { await runP3Trial(product, doc); }, - { runs: 6, maxShrinkExecutions: 100, render: renderDoc }, + { + runs: 6, + maxShrinkExecutions: 100, + render: renderDoc, + drawSources: stagedSources, + }, ); }, }); diff --git a/test/suite/registry/section-16-p4.ts b/test/suite/registry/section-16-p4.ts index d13ce9ea..ef2b4734 100644 --- a/test/suite/registry/section-16-p4.ts +++ b/test/suite/registry/section-16-p4.ts @@ -57,9 +57,13 @@ // dependency or embedding target), structure, metadata, dependency, no-op — // so the fixed seed set (E-5) exercises every law deterministically; the // class mix under the committed seeds was verified by an implementation-time -// dry-run (all six classes and every law-relevant prediction pattern occur, -// and every staged source — before and after each edit — parses under -// remark-mdx). +// dry-run (all six classes and every law-relevant prediction pattern occur). +// That every staged source — before and after each edit — derives (SPEC +// 14.20) is S-9's live check: `P4_FORM_VECTORS` below spells the +// rendering's forms for the S-9 self-test +// (test/self/s9-fixture-well-formedness.test.ts), and every draw's sources, +// the edited files included, are judged before the product sees them +// (`drawSources`, helpers/property.ts). // // P-4 is outside every CERTIFICATIONS.md fixture scope (its preamble: // conformers for P-4/P-5/P-6 would be near-complete second products), so @@ -105,12 +109,13 @@ import { decodeNodeRowsReport, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; -import type { Choices, Gen } from "../../helpers/property.js"; +import type { Choices, DrawSource, 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 { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; -import { TestWorkspace } from "../../helpers/workspace.js"; +import { TestWorkspace, mdxPathsOf } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, @@ -120,14 +125,40 @@ import { // Minimal declarative configuration (SPEC 7): exactly one spec group. No // code groups and no Markdown emission — hashes are the subject. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// A TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), well-formed: every trial stages it afresh, from the +// second trial on after the body's first product invocation — an initial +// file S-7's sweep never reaches, so the ledger self-test judges it before +// any product exists. +const SPECS_ONLY_CONFIG = stagedTs( + "P-4 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the property's one configuration file, the record above — judged + * as a record by test/self/s9-staged-sources.test.ts too, and here beside + * every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); P-4 composes no code + * source. + */ +export const P4_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-4 configuration (SPECS_ONLY_CONFIG)", + "xspec.config.ts", + SPECS_ONLY_CONFIG.source, + ], +]; // --------------------------------------------------------------------------- // Workspace model @@ -217,7 +248,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 +275,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 +302,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, @@ -1884,6 +1930,177 @@ export const genP4Trial: Gen<P4Trial> = (choices) => { return { model, edits }; }; +// --------------------------------------------------------------------------- +// Fixed form vectors (TEST-SPEC 17 S-9; 16 preamble) +// +// Every form renderWorkspace can spell — the import header, the first prose +// line, prose hosting embeddings in every reference spelling (local quote +// flavors; external dot, double- and single-quoted computed access; the bare +// binding for a file root), blank and comment lines, sections with every +// prop form (`tags` omitted, empty, dotted, duplicated; `coverage="none"`; +// `d` omitted, empty, single as `d={ref}` and `d={[ref]}`, a pair, a +// duplicate), nested three deep, and an empty section — in one fixed model, +// plus the added section of an addChild edit; the S-9 self-test proves every +// vector derives before any product exists, and each draw's sources are +// judged the same way before the product sees them (`drawSources` below). +// P-5 and P-6 stage through this rendering too (section-16-p5-p6.ts adds +// its own decorated forms). + +function vectorProse(text: string): ProseItem { + return { kind: "prose", parts: [{ kind: "text", text }] }; +} + +function vectorSection( + seg: string, + items: BodyItem[], + props: Partial< + Pick<SectionItem, "tags" | "coverageNone" | "deps" | "depsSingle"> + > = {}, +): SectionItem { + return { + kind: "section", + seg, + tags: null, + coverageNone: false, + deps: null, + depsSingle: false, + items, + ...props, + }; +} + +const P4_FORM_MODEL: WorkspaceModel = { + files: [ + { + nextSeg: 5, + items: [ + vectorProse("a0 first"), + vectorSection("s0", [vectorProse("k9 body")]), + { kind: "blank" }, + { kind: "comment", words: "note am q" }, + vectorSection("s1", [], { tags: [], coverageNone: true, deps: [] }), + vectorSection( + "s2", + [ + { + kind: "prose", + parts: [ + { kind: "text", text: "a0 lead " }, + { kind: "embed", ref: { file: 0, dotted: "s0", spell: 0 } }, + { kind: "text", text: " b1 mid " }, + { kind: "embed", ref: { file: 0, dotted: "s0", spell: 1 } }, + { kind: "text", text: " c2 tail" }, + ], + }, + vectorSection( + "s0", + [vectorSection("s0", [vectorProse("k9 deep")])], + { + tags: ["t1", "t2", "beta.x", "t1"], + deps: [{ file: 0, dotted: "s0", spell: 1 }], + depsSingle: true, + }, + ), + ], + { deps: [{ file: 0, dotted: "s0", spell: 0 }], depsSingle: false }, + ), + vectorSection("s3", [vectorProse("k9 pair")], { + deps: [ + { file: 0, dotted: "s0", spell: 0 }, + { file: 0, dotted: "s1", spell: 1 }, + ], + }), + vectorSection("s4", [vectorProse("k9 dup")], { + deps: [ + { file: 0, dotted: "s0", spell: 0 }, + { file: 0, dotted: "s0", spell: 1 }, + ], + }), + ], + }, + { + nextSeg: 1, + items: [ + { + kind: "prose", + parts: [ + { kind: "text", text: "b0 first " }, + { kind: "embed", ref: { file: 0, dotted: "", spell: 0 } }, + { kind: "text", text: " root " }, + { kind: "embed", ref: { file: 0, dotted: "s2.s0", spell: 0 } }, + { kind: "text", text: " dot " }, + { kind: "embed", ref: { file: 0, dotted: "s2.s0", spell: 1 } }, + { kind: "text", text: " dq " }, + { kind: "embed", ref: { file: 0, dotted: "s2.s0", spell: 2 } }, + { kind: "text", text: " sq" }, + ], + }, + vectorSection("s0", [vectorProse("k9 ext")], { + deps: [{ file: 0, dotted: "s3", spell: 2 }], + depsSingle: true, + }), + ], + }, + { + nextSeg: 1, + items: [ + vectorProse("c0 first"), + vectorSection("s0", [], { + tags: ["zeta"], + coverageNone: true, + deps: [ + { file: 0, dotted: "", spell: 0 }, + { file: 1, dotted: "s0", spell: 1 }, + ], + }), + ], + }, + ], +}; + +/** The fixed form-vector set of the PROP-03 rendering (S-9): name, source. */ +export const P4_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + ...Object.entries(renderWorkspace(P4_FORM_MODEL)).map( + ([path, source]): readonly [string, string] => [ + `rendering forms of ${path}`, + source, + ], + ), + ...Object.entries( + renderWorkspace( + applyEdit(P4_FORM_MODEL, { + kind: "addChild", + node: "specs/A.mdx", + at: 1, + text: "added body alpha", + }).after, + ), + ) + .filter(([path]) => path === "specs/A.mdx") + .map(([path, source]): readonly [string, string] => [ + `rendering forms of ${path} after an addChild edit`, + source, + ]), +]; + +/** + * S-9's per-draw check (helpers/property.ts `drawSources`): the staged + * workspace and, per edit, the files the edit rewrites — every source the + * trial stages, judged before the product sees it. + */ +function stagedP4Sources(trial: P4Trial): DrawSource[] { + const sources: DrawSource[] = Object.entries(renderWorkspace(trial.model)); + for (const edit of trial.edits) { + const { after, description } = applyEdit(trial.model, edit); + for (const [path, source] of Object.entries(renderWorkspace(after))) { + sources.push([path, source, `after the edit — ${description}`]); + } + } + return sources; +} + /** Counterexample rendering: staged sources plus the edit data. */ function renderTrial(trial: P4Trial): string { return JSON.stringify({ @@ -1990,9 +2207,14 @@ async function runP4Trial( const beforeSem = semanticsOf(trial.model); const beforeIds = [...beforeSem.keys()].sort(); const staged = { "xspec.config.ts": SPECS_ONLY_CONFIG, ...beforeFiles }; - const first = await TestWorkspace.create({ files: staged }); + // S-9: the draw's sources, judged by the property runner before the body + // saw them (`stagedP4Sources` above) — declared per draw, as every initial + // `.mdx` file a trial stages after the body's first product invocation + // must be (helpers/workspace.ts). + const mdx = { perDraw: mdxPathsOf(staged) }; + const first = await TestWorkspace.create({ files: staged, mdx }); try { - const second = await TestWorkspace.create({ files: staged }); + const second = await TestWorkspace.create({ files: staged, mdx }); try { await buildOk( product, @@ -2046,7 +2268,10 @@ async function runP4Trial( ); } for (const path of changedPaths) { - await first.file(path, afterFiles[path]); + // S-9: a draw's source, judged per draw by the property runner + // (stagedP4Sources) — `per-draw` exempts it from the builder's + // undeclared-staging guard. + await first.file(path, afterFiles[path], { mdx: "per-draw" }); } const context = `P-4 after the single edit — ${description} —`; await buildOk( @@ -2074,7 +2299,7 @@ async function runP4Trial( // Restore the pristine workspace: each edit applies independently // to the same before-state ("random single edits", not sequences). for (const path of changedPaths) { - await first.file(path, beforeFiles[path]); + await first.file(path, beforeFiles[path], { mdx: "per-draw" }); } } } finally { @@ -2108,7 +2333,12 @@ const P_4 = defineProductTest({ async (trial) => { await runP4Trial(product, trial); }, - { runs: 6, maxShrinkExecutions: 100, render: renderTrial }, + { + runs: 6, + maxShrinkExecutions: 100, + render: renderTrial, + drawSources: stagedP4Sources, + }, ); }, }); diff --git a/test/suite/registry/section-16-p5-p6.ts b/test/suite/registry/section-16-p5-p6.ts index 3424a551..20da497f 100644 --- a/test/suite/registry/section-16-p5-p6.ts +++ b/test/suite/registry/section-16-p5-p6.ts @@ -9,8 +9,9 @@ // // * P-5 arm 1 — purity sequences. A random workspace, committed as a git // baseline, then 1–3 journaled operations drawn from `rename` (fresh -// final segment, descendants re-prefixed) and file-form `move` (fresh -// `specs/N<k>.mdx` destination), each followed by a commit. After every +// final segment, descendants re-prefixed) and file-form `move` (a fresh +// `specs/<name>.mdx` destination, its basename drawn as "drawn spec +// basenames" below says), each followed by a commit. After every // operation: `query nodes` enumerates exactly the mapped identity set, // every node's four hashes are byte-identical to the previous sweep under // the operation's identity map (SPEC 6.2, 5.4), `check` exits 0 — all @@ -20,18 +21,28 @@ // 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, its basename drawn as below), 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 6.2's enumeration — the origin parent, the target +// parent, the moved subtree's nodes, and each other node with +// own-content bytes on a line the deletion joins or drops or the +// insertion splits — each `changed` iff its own-content sequence +// differs across the move, a moved node's 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 +51,172 @@ // 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). +// Drawn spec basenames and added imports (TEST-SPEC §16 P-5: every import +// a drawn move adds is held to T6.5-22(a)'s assertion, the drawn spec +// sources' basenames including names from 6.5's barred classes, so that a +// basename-derived choice meets them). The assertion needs no wiring here: +// the subprocess driver judges every performed `move` the suite drives — +// these bodies' moves included — with T6.5-22(a)'s check +// (helpers/subprocess.ts → helpers/added-import-identifiers.ts), and a +// breach rejects the invocation with a diagnosed `HarnessAssertionError`. +// Both arms stage their spec sources under basenames drawn per trial +// (`SPEC_BASENAME_CLASSES`: plain names, reserved and strict-mode-barred +// words, `require` and `exports`, `__`-prefixed names, global-object +// properties, Annex B's `escape` and `unescape`, `Iterator`, +// `AsyncIterator`, and `SuppressedError`, and the compiler-provided `S`, +// `Spec`, and `text`), and draw arm 1's file-move destinations and arm 2's +// created target file from them too. The imports the drawn space adds are +// arm 2's created-target ones — the created file gains an import of each +// file its moved text references, and each file referencing into the +// moved subtree gains one of the created file (an existing-file target +// needs none; see "every staged spec source begins with an empty line" +// below) — so both the workspace's basenames and the created file's meet a +// basename-derived choice. The generator draws such a move in 40% of the +// trials offering one (genSectionMoveTrial; an unbiased pick drew none +// under the fixed seeds). Model space keeps PROP-03's `specs/A.mdx`…: the +// staged paths are its path table applied (arm 1's `TrialState.paths`, +// arm 2's `buildSectionMove`). +// What holds whatever a basename is: the generator's own import bindings +// are `M<j>`, chosen by file index and never from a basename, so every +// staged header and reference derives (14.20) — a basename stands only +// inside a specifier literal; every draw stays valid, a basename being an +// ASCII identifier with no character 7.1 or 14.19 bars and no `.` (so no +// `.xspec.` derived-file name), and `specs/<name>.mdx` lying in the +// configuration's one spec group; and every destination stays clear of +// 6.5's destination refusals no derivability check sees — fresh (no +// basename staged or drawn earlier in the trial, so no identity is reused +// either), flat under `specs/` (its derived `specs/<name>.xspec.*` paths +// neither are nor contain another source's or derived file's path), and, +// with no code group and no Markdown emission configured, free of any +// module-linking designation or exposed derived file. +// +// 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 is checked live under +// S-9 (SPEC 14.20): `P5_FORM_VECTORS` (below buildSectionMove) spells each +// layout the generator can draw — at the origin, and as its moved text +// lands at the destination — the S-9 self-test +// (test/self/s9-fixture-well-formedness.test.ts) proves every one derives +// before any product exists, and every draw's staged files are judged the +// same way before the product sees them (`drawSources` on the registrations; +// helpers/property.ts). The forms obey 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`) — and it must parse +// again at the destination, where the moved text lands at a line start +// followed by a terminator with the parent's forcers gone (SPEC 6.5), so +// an inline layout's boundary lines agree: the opening tag's remainder and +// the closing tag's lead are both grammar whitespace (nothing, spaces, a +// tab — each tag then a flow-position tag there; T6.2-3's staging (a)) or +// both prose the flow attempt cannot take (`k9 lead`/`k9 tail`, U+000B, +// U+000C — each tag then staying in text position; staging (b)), the +// `body</S>` variant (staging (c)) closing inside the paragraph its last +// body line makes under a prose remainder; the one-sided spellings SPEC +// 6.2 and 6.5 refuse are never drawn (TEST-SPEC §16 P-5; T6.5-16's arms). +// The staged layouts: +// * flow — the PROP-03 form; any subtree (child sections, blanks, +// comments, embeddings); clean boundaries, moved subtree keeps every +// hash; +// * inline — a multi-line in-line element: parent prose immediately +// before the opening tag (`plead. <S …>`), the tag's remainder on that +// line (nothing, spaces, a tab, `k9 lead`, U+000B, or U+000C), the +// body's prose lines, then the closing tag's lead (alike) with parent +// prose after it — remainder and lead agreeing in kind as above, the +// flow-bound kind forced in-line at the origin by parent prose on both +// sides, the text-bound kind by its own remainder and lead (the +// parent's decorations then free) — or, under a prose remainder, the +// closing tag joined to the last body line (`body</S>`); 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 (P-5: the boundary lines hold prose +// outside the construct alone) — were one staged, the oracle would predict +// it `changed` by the same own-content comparison, 6.2's enumeration +// reaching every node with bytes on such a line (T6.2-3's sibling +// stagings (d)/(e) are its deterministic anchors). 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. Every staged spec +// source begins with an empty line (TEST-SPEC §16 P-5; `renderP5Workspace`, +// the piece builder, and buildSectionMove's own check): offset 0 is then a +// line-start admissible offset for any import addition — the added +// declaration an ESM block that empty line ends, whatever the file's first +// item — which SPEC 6.5's preference takes over any other, so no root's +// own content changes through an addition (6.2's import-addition case stays +// undrawn, T6.5-13(h)/(j) anchoring it deterministically); the kept empty +// line is each root's first run on both sides alike. The additions the +// drawn space makes are the created-target ones ("drawn spec basenames" +// above): at an existing-file target PROP-03's complete downward import +// DAG already binds each file a moved subtree references, and every file +// referencing into the subtree already imports the target (moveCandidates' +// window). The purity arm's workspaces are staged the same way. +// +// 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. @@ -86,9 +234,10 @@ // derived files exist; derived files match no spec group and are inert to // baseline reconstruction). // - Identity reuse never occurs: fresh segments come from the model's -// per-file counters and fresh file names from a trial counter, so the 9.3 -// deleted/added identity-collision edge case stays out of the input space -// (it is deterministic-test material). +// per-file counters and fresh file names from a trial counter (P-6) or a +// draw excluding every basename the trial has staged or drawn (P-5), so +// the 9.3 deleted/added identity-collision edge case stays out of the +// input space (it is deterministic-test material). // // P-5 and P-6 are outside every CERTIFICATIONS.md fixture scope (its // preamble: conformers for P-4/P-5/P-6 would be near-complete second @@ -106,16 +255,36 @@ import { decodeNodeRowsReport, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; -import type { Choices, Gen } from "../../helpers/property.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, DrawSource, 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 { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; -import { TestWorkspace } from "../../helpers/workspace.js"; +import type { FileContents, WorkspaceDecl } from "../../helpers/workspace.js"; +import { TestWorkspace, mdxPathsOf } from "../../helpers/workspace.js"; import type { BodyItem, Edit, EditClass, + ProseItem, RefModel, SectionItem, WorkspaceModel, @@ -124,6 +293,9 @@ import { applyEdit, genEditOfClass, genWorkspaceModel, + refIdentity, + renderOpenTag, + renderRef, renderWorkspace, semanticsOf, } from "./section-16-p4.js"; @@ -151,7 +323,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 +347,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 +367,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 +386,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 { @@ -555,19 +518,32 @@ interface TrialState { model: WorkspaceModel; /** Model-space path per file index (`specs/A.mdx`…), fixed for the trial. */ readonly modelPaths: readonly string[]; - /** Current workspace path per file index (file moves mutate this). */ + /** + * Current workspace path per file index: the staged paths (P-5's drawn + * basenames; the model paths in P-6), then as file moves leave them. + */ readonly paths: string[]; - /** Fresh-name counter for file-move destinations. */ + /** P-6's fresh-name counter for file-move destinations. */ movedCounter: number; } -function initTrialState(model: WorkspaceModel): TrialState { +/** The trial state over `model`, staged at `paths` (default: model space). */ +function initTrialState( + model: WorkspaceModel, + paths?: readonly string[], +): TrialState { const cloned = structuredClone(model); const modelPaths = Object.keys(renderWorkspace(cloned)); + if (paths !== undefined && paths.length !== modelPaths.length) { + throw new Error( + `P-5 harness defect: ${String(paths.length)} staged paths for ` + + `${String(modelPaths.length)} model files`, + ); + } return { model: cloned, modelPaths, - paths: [...modelPaths], + paths: [...(paths ?? modelPaths)], movedCounter: 0, }; } @@ -583,11 +559,111 @@ function specBasename(path: string): string { return match[1]; } +/** `A` → `specs/A.mdx`: where a drawn basename is staged. */ +function specPath(basename: string): string { + return `specs/${basename}.mdx`; +} + +/** + * The spec basenames P-5 draws (module header, "drawn spec basenames"; + * TEST-SPEC §16 P-5, T6.5-22), one list per class — the plain class first, + * the shrink target: PROP-03's `A`, `B`, `C` and the former fresh-name + * spelling `N<k>`, seven names, more than the six one trial draws at most + * (three files, three file moves). Every name is an ASCII identifier + * without `.`; no name repeats, and no two differ in ASCII case alone + * (checked at module load). + */ +const SPEC_BASENAME_CLASSES: readonly (readonly string[])[] = [ + ["A", "B", "C", "N0", "N1", "N2", "N3"], + // Reserved words (`default`, `enum`, `await`, `yield` among them) and the + // names strict-mode code bars as bindings (`let` … `arguments`). + // prettier-ignore + [ + "let", "await", "yield", "default", "enum", "class", "new", "null", + "this", "typeof", "import", "export", "static", "implements", + "interface", "package", "private", "protected", "public", "eval", + "arguments", + ], + ["require", "exports"], + ["__x", "__dirname", "__filename"], + // Global-object properties of ECMAScript 2024's clause 19 (value, + // function, constructor, and other properties). + // prettier-ignore + [ + "globalThis", "Infinity", "NaN", "undefined", "isFinite", "isNaN", + "parseFloat", "parseInt", "decodeURI", "encodeURIComponent", + "AggregateError", "Array", "Object", "Promise", "Proxy", "Symbol", "Map", + "WeakRef", "WeakSet", "Atomics", "JSON", "Math", "Reflect", + ], + // Annex B's global-object properties (B.2.1). + ["escape", "unescape"], + // Barred by name, no ECMAScript 2024 global-object property. + ["Iterator", "AsyncIterator", "SuppressedError"], + // The compiler-provided names no spec source binds (2.1). + ["S", "Spec", "text"], +]; + +{ + const seen = new Map<string, string>(); + for (const name of SPEC_BASENAME_CLASSES.flat()) { + const folded = name.toLowerCase(); + const clash = seen.get(folded); + if (clash !== undefined || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) { + throw new Error( + `P-5 harness defect: the drawn basename ${JSON.stringify(name)} ` + + `${clash === undefined ? "is no ASCII identifier" : `clashes with ${JSON.stringify(clash)}`}`, + ); + } + seen.set(folded, name); + } +} + +/** + * Draw a basename `taken` does not hold: a class, uniformly (the plain one + * an eighth of the time), then a name of it not yet taken — a plain one + * when the class is spent. Shrinks toward the first free plain name. + */ +function drawSpecBasename( + choices: Choices, + taken: ReadonlySet<string>, +): string { + const names = choices.weightedPick( + SPEC_BASENAME_CLASSES.map((list) => [1, list] as const), + ); + const free = names.filter((name) => !taken.has(name)); + if (free.length > 0) return choices.pick(free); + const plain = SPEC_BASENAME_CLASSES[0].filter((name) => !taken.has(name)); + if (plain.length === 0) { + throw new Error("P-5 harness defect: every plain basename is taken"); + } + return choices.pick(plain); +} + +/** `count` distinct basenames, one per model file in file order. */ +function drawSpecBasenames(choices: Choices, count: number): string[] { + const names: string[] = []; + for (let index = 0; index < count; index += 1) { + names.push(drawSpecBasename(choices, new Set(names))); + } + return names; +} + /** Workspace identity of a model identity under the current path table. */ function workspaceIdentityFn(state: TrialState): IdentityFn { + return pathTableIdentityFn(state.modelPaths, state.paths); +} + +/** + * The identity a model identity has where file `i`'s model path + * `modelPaths[i]` stands at `paths[i]` (the table read when this is called). + */ +function pathTableIdentityFn( + modelPaths: readonly string[], + paths: readonly string[], +): IdentityFn { const byModelPath = new Map<string, string>(); - state.modelPaths.forEach((modelPath, index) => { - byModelPath.set(modelPath, state.paths[index]); + modelPaths.forEach((modelPath, index) => { + byModelPath.set(modelPath, paths[index]); }); return (identity) => { const hash = identity.indexOf("#"); @@ -770,7 +846,7 @@ function applyRename(state: TrialState, op: RenameOp): AppliedOp { function applyMoveFile(state: TrialState, op: MoveFileOp): AppliedOp { const oldPath = state.paths[op.file]; - const newPath = `specs/${op.newName}.mdx`; + const newPath = specPath(op.newName); const modelPath = state.modelPaths[op.file]; const wsMap: Record<string, string> = { [oldPath]: newPath }; const walkDotteds = (items: readonly BodyItem[], parent: string): void => { @@ -800,71 +876,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 // @@ -875,13 +886,35 @@ function applySectionMove(state: TrialState, op: SectionMoveOp): AppliedOp { // (module header, H-4). function currentFileBytes(state: TrialState, fileIndex: number): string { - const rendered = renderWorkspace(state.model)[state.modelPaths[fileIndex]]; + return withImportHeader( + renderWorkspace(state.model)[state.modelPaths[fileIndex]], + fileIndex, + state.paths, + ); +} + +/** + * File `fileIndex`'s PROP-03 rendering with its import header naming the + * files at `paths` (file `j` bound to `M<j>` whatever its basename, the + * pinned 2.1 form; module header). + */ +function withImportHeader( + rendered: string, + fileIndex: number, + paths: readonly string[], +): string { if (fileIndex === 0) return rendered; const lines = rendered.split("\n"); const header: string[] = []; for (let j = 0; j < fileIndex; j += 1) { + if (!lines[j].startsWith(`import M${String(j)} from "./`)) { + throw new Error( + `P-5/P-6 harness defect: line ${String(j + 1)} of file ` + + `${String(fileIndex)}'s rendering is no PROP-03 import line`, + ); + } header.push( - `import M${String(j)} from "./${specBasename(state.paths[j])}.xspec"`, + `import M${String(j)} from "./${specBasename(paths[j])}.xspec"`, ); } header.push(""); @@ -889,14 +922,18 @@ function currentFileBytes(state: TrialState, fileIndex: number): string { } /** - * Apply one staged edit: mutate the model and rewrite the changed files in - * the workspace at their current paths. Returns a description for contexts. + * Apply one staged edit to the state: mutate the model and return the + * changed files' bytes at their current paths, with a description for + * contexts. Pure over the state (no workspace I/O), so the S-9 per-draw + * check replays it before the product sees the trial. */ -async function applyEditStep( +function applyEditToState( state: TrialState, - workspace: TestWorkspace, edit: Edit, -): Promise<string> { +): { + readonly description: string; + readonly files: readonly (readonly [path: string, bytes: string])[]; +} { const beforeFiles = renderWorkspace(state.model); const { after, description } = applyEdit(state.model, edit); state.model = after; @@ -909,8 +946,30 @@ async function applyEditStep( `P-6 harness defect: the edit "${description}" staged no byte change`, ); } - for (const index of changedIndexes) { - await workspace.file(state.paths[index], currentFileBytes(state, index)); + return { + description, + files: changedIndexes.map((index): readonly [string, string] => [ + state.paths[index], + currentFileBytes(state, index), + ]), + }; +} + +/** + * Apply one staged edit: mutate the model and rewrite the changed files in + * the workspace at their current paths. Returns a description for contexts. + */ +async function applyEditStep( + state: TrialState, + workspace: TestWorkspace, + edit: Edit, +): Promise<string> { + const { description, files } = applyEditToState(state, edit); + for (const [path, bytes] of files) { + // S-9: a draw's source, judged per draw by the property runner + // (stagedReplaySources) — `per-draw` exempts it from the builder's + // undeclared-staging guard. + await workspace.file(path, bytes, { mdx: "per-draw" }); } return description; } @@ -973,12 +1032,18 @@ async function sweepHashes( interface PurityTrial { readonly model: WorkspaceModel; + /** Drawn basename per model file: its staged `specs/<name>.mdx`. */ + readonly basenames: readonly string[]; readonly ops: readonly PureOp[]; } const genPurityTrial: Gen<PurityTrial> = (choices) => { const model = genWorkspaceModel(choices); - const state = initTrialState(model); + const basenames = drawSpecBasenames(choices, model.files.length); + const state = initTrialState(model, basenames.map(specPath)); + // Every basename staged or drawn so far: a destination is fresh (module + // header — no identity reuse, no destination refusal). + const taken = new Set(basenames); const ops: PureOp[] = []; do { const sections = sectionsOf(state.model); @@ -999,29 +1064,71 @@ const genPurityTrial: Gen<PurityTrial> = (choices) => { newSeg: `s${String(state.model.files[site.file].nextSeg)}`, }; } else { - op = { - kind: "moveFile", - file: choices.intInclusive(0, state.model.files.length - 1), - newName: `N${String(state.movedCounter)}`, - }; + const file = choices.intInclusive(0, state.model.files.length - 1); + const newName = drawSpecBasename(choices, taken); + taken.add(newName); + op = { kind: "moveFile", file, newName }; } applyPureOp(state, op); ops.push(op); } while (ops.length < 3 && choices.boolean(0.6)); - return { model, ops }; + return { model, basenames, ops }; }; +// The configuration every trial's workspace stages beside the rendered +// sources: section-5.6.ts's SPECS_ONLY_CONFIG, the same expression moved +// into a TypeScript staged-source record of this module's own +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), well-formed: +// every trial after a body's first stages it after that body's first product +// invocation — an initial file S-7's sweep never reaches — so the ledger +// self-test judges it before any product exists. It stays plain in its +// owner, whose bodies stage it before any invocation only. +const P5_P6_SPECS_ONLY_CONFIG = stagedTs( + "P-5/P-6 xspec.config.ts — one spec group (section-5.6.ts's SPECS_ONLY_CONFIG)", + SPECS_ONLY_CONFIG, +); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the one configuration file P-5 and P-6 share, the record above — + * judged as a record by test/self/s9-staged-sources.test.ts too, and here + * beside every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); neither composes a code + * source. + */ +export const P5_P6_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-5/P-6 configuration (P5_P6_SPECS_ONLY_CONFIG)", + "xspec.config.ts", + P5_P6_SPECS_ONLY_CONFIG.source, + ], +]; + +/** + * A trial's workspace declaration: the configuration (a staged-source + * record) beside the rendered sources, every `.mdx` one declared per draw — + * judged by the property runner before the body saw the draw (`drawSources` + * on the registrations below; S-9), as every initial `.mdx` file a trial + * stages after the body's first product invocation must be + * (helpers/workspace.ts). + */ +function drawWorkspace( + rendered: Readonly<Record<string, FileContents>>, +): WorkspaceDecl { + const files = { "xspec.config.ts": P5_P6_SPECS_ONLY_CONFIG, ...rendered }; + return { files, mdx: { perDraw: mdxPathsOf(files) } }; +} + async function runPurityTrial( product: ProductBinding, trial: PurityTrial, ): Promise<void> { - const state = initTrialState(trial.model); - const workspace = await TestWorkspace.create({ - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - ...renderWorkspace(state.model), - }, - }); + const state = initTrialState(trial.model, trial.basenames.map(specPath)); + const workspace = await TestWorkspace.create( + drawWorkspace(renderP5Workspace(state.model, state.paths)), + ); try { await workspace.gitInit(); const commits = [await workspace.gitCommitAll("baseline 0")]; @@ -1095,15 +1202,239 @@ 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; + /** + * The opening tag's remainder: moved-root bytes after it on its line, + * inside the construct (`inline` only). + */ + readonly leadInside: string | null; + /** + * The closing tag's lead: moved-root bytes before it on its line, inside + * the construct (`inline` only; null when `closeJoined`). + */ + readonly tailInside: string | null; + /** Origin-parent bytes immediately after the closing tag (same line). */ + readonly tailOutside: string | null; + /** + * `inline` only: the closing tag follows the last body line's prose + * directly (`body</S>`, T6.2-3's staging (c)) instead of standing on a + * line of its own — that prose is then the closing tag's lead. + */ + readonly closeJoined: boolean; +} + +const FLOW_LAYOUT: MovedLayout = { + form: "flow", + leadOutside: null, + leadInside: null, + tailInside: null, + tailOutside: null, + closeJoined: false, +}; + +// Fixed decoration bytes (deterministic staging, HARNESS-01): MDX-safe plain +// prose per the PROP-03 alphabet; the grammar-whitespace residues two spaces +// and one tab (SPEC 6.2's "spaces and tabs"); and U+000B and U+000C — +// whitespace under SPEC 1.4, none to the grammar — built from code points, +// never escape spellings. +const LEAD_OUTSIDE = "plead. "; +const LEAD_INSIDE = "k9 lead"; +const TAIL_INSIDE = "k9 tail"; +const TAIL_OUTSIDE = "ptail"; +const WS_RESIDUE = " "; +const TAB_RESIDUE = String.fromCodePoint(0x0009); +const VT = String.fromCodePoint(0x000b); +const FF = String.fromCodePoint(0x000c); + +/** + * Grammar-whitespace remainders and leads: nothing, spaces, a tab. The tag + * one adjoins, alone on its line at the destination, is a flow-position tag + * there (SPEC 6.2; T6.2-3's staging (a)). + */ +const WHITESPACE_RESIDUES: readonly (string | null)[] = [ + null, + WS_RESIDUE, + TAB_RESIDUE, +]; +/** + * Remainders the flow attempt cannot take — prose, U+000B, U+000C — which + * keep the opening tag in text position at the destination (T6.2-3's + * stagings (b) and (c)). + */ +const PROSE_REMAINDERS: readonly string[] = [LEAD_INSIDE, VT, FF]; +/** Leads alike, for a closing tag on a line of its own (staging (b)). */ +const PROSE_LEADS: readonly string[] = [TAIL_INSIDE, VT, FF]; + +const OUTSIDE_LEADS: readonly (string | null)[] = [null, LEAD_OUTSIDE]; +const OUTSIDE_TAILS: readonly (string | null)[] = [ + null, + WS_RESIDUE, + TAIL_OUTSIDE, +]; + +/** + * The multi-line in-line layouts whose moved text opens and closes in flow + * position at the destination: remainder and lead both grammar whitespace + * (T6.2-3's staging (a)), the origin's in-line form then forced by the + * parent's prose on both sides (module header's balance rule). Fixed order, + * simplest first (shrinking). + */ +const INLINE_FLOW_BOUND_LAYOUTS: readonly MovedLayout[] = + WHITESPACE_RESIDUES.flatMap((leadInside) => + WHITESPACE_RESIDUES.map((tailInside): MovedLayout => ({ + form: "inline", + leadOutside: LEAD_OUTSIDE, + leadInside, + tailInside, + tailOutside: TAIL_OUTSIDE, + closeJoined: false, + })), + ); + +/** + * The multi-line in-line layouts whose moved text stays in text position on + * both sides at the destination: a prose remainder (U+000B/U+000C included) + * with a prose lead on the closing tag's own line (T6.2-3's staging (b)) or + * with the closing tag joined to the last body line (staging (c)); the + * remainder and lead force the origin's in-line form themselves, so the + * parent's decorations vary freely. Fixed order, simplest first. + */ +const INLINE_TEXT_BOUND_LAYOUTS: readonly MovedLayout[] = OUTSIDE_LEADS.flatMap( + (leadOutside) => + PROSE_REMAINDERS.flatMap((leadInside) => + [...PROSE_LEADS, null].flatMap((tailInside) => + OUTSIDE_TAILS.map((tailOutside): MovedLayout => ({ + form: "inline", + leadOutside, + leadInside, + tailInside, + tailOutside, + closeJoined: tailInside === null, + })), + ), + ), +); + +/** + * Every multi-line in-line layout the generator draws: the boundary lines + * agree in kind — both flow-bound or both text-bound at the destination — + * so the moved text derives at a line start (SPEC 6.5, 14.20); the + * one-sided spellings SPEC 6.2 and 6.5 refuse (a whitespace remainder with + * a prose lead or the reverse, `body</S>` under a whitespace remainder) are + * never drawn (TEST-SPEC §16 P-5; T6.5-16's arms). + */ +const INLINE_LAYOUTS: readonly MovedLayout[] = [ + ...INLINE_FLOW_BOUND_LAYOUTS, + ...INLINE_TEXT_BOUND_LAYOUTS, +]; + +/** + * The 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, + closeJoined: false, +}; + +/** 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 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), + closeJoined: false, + }), + ]); + options.push([2, () => EMPTY_INLINE_LAYOUT]); + } else { + if (section.items.every(isPlainProse)) { + // Half flow-bound, half text-bound: a flat pick over the union would + // underdraw the smaller family. + options.push([ + 10, + () => + choices.boolean(0.5) + ? choices.pick(INLINE_FLOW_BOUND_LAYOUTS) + : choices.pick(INLINE_TEXT_BOUND_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's validity rule). + leadOutside: plain ? choices.pick(OUTSIDE_LEADS) : null, + leadInside: null, + tailInside: null, + tailOutside: plain ? choices.pick(OUTSIDE_TAILS) : null, + closeJoined: false, + }), + ]); + } + } + 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; + /** Drawn basename per model file: its staged `specs/<name>.mdx`. */ + readonly basenames: readonly string[]; + /** + * The created target file's drawn basename (`specs/<name>.mdx`, in the + * spec group, 6.5), apart from every staged one; null unless the move + * creates its target file. + */ + readonly createdBasename: string | null; } 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; } @@ -1116,12 +1447,23 @@ 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. `createdAdds` counts the import + * declarations such a move adds (module header, "drawn spec basenames"): + * one in the created file per file the subtree references outside itself, + * and one per file referencing into the subtree from outside it. */ function moveCandidates( model: WorkspaceModel, moved: SectionSite, -): MoveCandidate[] { +): { + readonly candidates: MoveCandidate[]; + readonly createdOk: boolean; + readonly createdAdds: number; +} { const movedKeys = new Set( subtreeDotteds(moved.section, moved.dotted).map( (dotted) => `${String(moved.file)}#${dotted}`, @@ -1148,20 +1490,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 +1540,11 @@ function moveCandidates( } consider(site.file, site.dotted, ancestorKeys); } - return candidates; + return { + candidates, + createdOk: maxOut < minIn, + createdAdds: outFiles.size + inFiles.size, + }; } const genSectionMoveTrial: Gen<SectionMoveTrial> = (choices) => { @@ -1213,17 +1561,29 @@ const genSectionMoveTrial: Gen<SectionMoveTrial> = (choices) => { }).after; } const sections = sectionsOf(model); - // Bias toward subtree-bearing moves (descendant re-identification and the - // richer cascades) when any exist; a plain pick underexercises them under - // the fixed seeds. Shrinks toward the unbiased simple pick. + // Bias toward import-adding moves when any exist: a created target file + // whose moved subtree references another file or is referenced from one + // — the only moves of the drawn space that add an import, each judged by + // T6.5-22(a)'s driver hook, the drawn basenames steering a + // basename-derived binding (module header, "drawn spec basenames"); the + // unbiased picks below drew none under the fixed seeds. Otherwise toward + // subtree-bearing moves (descendant re-identification and the richer + // cascades) when any exist; a plain pick underexercises them under the + // fixed seeds. Shrinks toward the unbiased simple pick. + const importAdding = sections.filter((site) => { + const { createdOk, createdAdds } = moveCandidates(model, site); + return createdOk && createdAdds > 0; + }); + const addsImports = importAdding.length > 0 && choices.boolean(0.4); const withChildren = sections.filter((site) => site.section.items.some((item) => item.kind === "section"), ); - const moved = - withChildren.length > 0 && choices.boolean(0.5) + const moved = addsImports + ? choices.pick(importAdding) + : 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,42 +1592,962 @@ 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: a created target file for an import-adding move, and + // sometimes otherwise (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 (addsImports || (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; + } + // The staged basenames, then the created target's apart from them + // (module header, "drawn spec basenames"), drawn last: no other draw + // depends on them. + const basenames = drawSpecBasenames(choices, model.files.length); + const createdBasename = + target.toFile === null + ? drawSpecBasename(choices, new Set(basenames)) + : null; 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, + basenames, + createdBasename, + }; +}; + +// --- 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 empty line every P-5 spec source begins with (module header). */ +const LEADING_EMPTY_LINE = "\n"; + +/** + * The PROP-03 rendering with every spec source begun by an empty line — the + * bytes P-5 stages, both arms (module header: TEST-SPEC §16 P-5's line-start + * admissible offset for any import addition) — keyed by, and its import + * headers naming, the staged `paths` (default: model space). + */ +function renderP5Workspace( + model: WorkspaceModel, + paths?: readonly string[], +): Record<string, string> { + const rendered = Object.entries(renderWorkspace(model)); + const staged = paths ?? rendered.map(([path]) => path); + if (staged.length !== rendered.length) { + stagingDefect( + `${String(staged.length)} staged paths for ` + + `${String(rendered.length)} model files`, + ); + } + return Object.fromEntries( + rendered.map(([, source], fileIndex) => [ + staged[fileIndex], + LEADING_EMPTY_LINE + withImportHeader(source, fileIndex, staged), + ]), + ); +} + +/** + * The file's piece tree: byte-identical to renderP5Workspace's output at + * the same `paths` when `deco` is empty — locked by an equality assertion + * per trial — with the arm-2 boundary decorations applied where staged + * (module header). `paths` spells the import header; `stagedIdentity` maps + * the model identities the pieces name (embedding targets, `depends`) to + * the staged ones (default: model space). + */ +function buildFilePieces( + model: WorkspaceModel, + fileIndex: number, + paths: readonly string[], + expansionOf: (identity: string) => string, + deco: FileDecorations, + stagedIdentity: IdentityFn = (identity) => identity, +): 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: stagedIdentity(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((ref) => + stagedIdentity(refIdentity(ref)), + ); + 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 { + const proseItems = item.items.filter(isPlainProse); + if (proseItems.length !== item.items.length) { + stagingDefect( + `inline layout on ${dotted}, whose body is not all ` + + `plain-text prose (module header)`, + ); + } + const body: SectionMovePiece[] = [ + { kind: "content", text: `${layout.leadInside ?? ""}\n` }, + ]; + if (layout.closeJoined) { + // T6.2-3's staging (c): the closing tag follows the last body + // line's prose directly, that prose being its lead. + if (proseItems.length === 0 || layout.tailInside !== null) { + stagingDefect( + `joined-close inline layout on ${dotted} without a body ` + + `line, or with a lead of its own`, + ); + } + const last = proseItems.length - 1; + proseItems.forEach((prose, index) => { + body.push(...prosePieces(prose, index !== last)); + }); + } else { + body.push(...walk(proseItems, 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; + }; + + // Every staged spec source begins with an empty line (TEST-SPEC §16 P-5; + // module header) — a kept line, the root's first run. + const pieces: SectionMovePiece[] = [newline]; + for (let j = 0; j < fileIndex; j += 1) { + pieces.push({ + kind: "removal", + text: `import M${String(j)} from "./${specBasename(paths[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; + // Model space is PROP-03's; everything this returns — the oracle's + // documents and nodes, the staged files, the argv — speaks the staged + // paths of the drawn basenames (module header, "drawn spec basenames"). + const modelPaths = Object.keys(renderWorkspace(model)); + const paths = trial.basenames.map(specPath); + const rendered = renderP5Workspace(model, paths); + const stagedIdentity = pathTableIdentityFn(modelPaths, paths); + const sems = semanticsOf(model); + const expansionOf = expansionSentinels(sems); + + const originPath = paths[trial.fromFile]; + const { toFile } = target; + const coincident = toFile === trial.fromFile; + let targetPath: string; + if (toFile !== null) { + if (trial.createdBasename !== null) { + stagingDefect( + `a created-target basename for a target in file ${String(toFile)}`, + ); + } + targetPath = paths[toFile]; + } else { + if (trial.createdBasename === null) { + stagingDefect("a created target without a drawn basename"); + } + targetPath = specPath(trial.createdBasename); + if (paths.includes(targetPath)) { + stagingDefect(`the created target ${targetPath} is a staged file`); + } + } + + // Builder-vs-renderer byte lock (module header): the undecorated piece + // tree reproduces the rendering 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, paths, expansionOf, {}, stagedIdentity), + ); + if (undecorated !== rendered[paths[fileIndex]]) { + stagingDefect( + `piece-tree builder diverges from renderWorkspace for ` + + `${paths[fileIndex]} (${modelPaths[fileIndex]} in model space)`, + ); + } + } + + 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, + paths, + expansionOf, + { + moved: { dotted: trial.dotted, layout: trial.layout }, + ...(coincident ? targetSideDeco : {}), + }, + stagedIdentity, + ), + }; + const targetDocument: SectionMoveDocument | { createdPath: string } = + toFile === null + ? { createdPath: targetPath } + : coincident + ? origin + : { + path: targetPath, + pieces: buildFilePieces( + model, + toFile, + paths, + expansionOf, + targetSideDeco, + stagedIdentity, + ), + }; + + const involvedPaths = new Set([originPath, targetPath]); + const otherNodes: SectionMoveGraphNode[] = []; + for (const [modelIdentity, sem] of sems) { + const identity = stagedIdentity(modelIdentity); + const hash = identity.indexOf("#"); + const path = hash === -1 ? identity : identity.slice(0, hash); + if (involvedPaths.has(path)) continue; + otherNodes.push({ + identity, + children: sem.children.map(stagedIdentity), + edgeTargets: sem.edgeTargets.map(stagedIdentity), + }); + } + + 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); + } + // The generator's own guarantee (TEST-SPEC §16 P-5): every staged spec + // source begins with an empty line, decorated or not. + for (const [path, source] of Object.entries(files)) { + if (!source.startsWith(LEADING_EMPTY_LINE)) { + stagingDefect(`${path} does not begin with an empty line`); + } + } + 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` + + `${trial.layout.closeJoined ? ", joined close" : ""}` + + `${toFile === null ? ", created target" : ""}` + + `${trial.selfCloseTargetParent ? ", self-closing target parent" : ""}` + + `${trial.stripFinalNewline ? ", terminator-less EOF" : ""})`, + }; +} + +// --- fixed form vectors (TEST-SPEC 17 S-9; 16 preamble) ---------------------- +// +// Every decorated byte form the arm-2 staging can draw (module header) — +// each inline layout on a single-prose and on a multi-prose moved section, +// the moved text of each distinct inline layout as it lands (SPEC 6.5: at +// a line start, followed by a terminator, the parent's decorations gone) +// at top level and inside a flow-position parent, which is what excludes +// the one-sided spellings, the empty section's inline form, every +// self-closing and collapse +// decoration, the undecorated collapse with an embedding, the flow layout +// on every body shape, the target-side forms (an empty target parent +// rendered self-closing, the root target's final terminator stripped) on +// the coincident file and on a separate target file, the import header +// naming a file under each drawn basename — plus the P-6 replay +// rewrite after a file move (the import header naming the moved path), +// each spelled through the very builder the draws use, over one fixed +// model. The S-9 self-test proves every vector derives before any product +// exists; every draw's staged files are judged the same way before the +// product sees them (`drawSources` on the registrations below). + +function vectorProse(text: string): ProseItem { + return { kind: "prose", parts: [{ kind: "text", text }] }; +} + +function vectorEmbedProse(dotted: string): ProseItem { + return { + kind: "prose", + parts: [ + { kind: "text", text: "k9 lead " }, + { kind: "embed", ref: { file: 0, dotted, spell: 0 } }, + { kind: "text", text: " k9 tail" }, + ], + }; +} + +function vectorSection( + seg: string, + items: BodyItem[], + props: Partial< + Pick<SectionItem, "tags" | "coverageNone" | "deps" | "depsSingle"> + > = {}, +): SectionItem { + return { + kind: "section", + seg, + tags: null, + coverageNone: false, + deps: null, + depsSingle: false, + items, + ...props, + }; +} + +const P5_FORM_MODEL: WorkspaceModel = { + files: [ + { + nextSeg: 5, + items: [ + vectorProse("a0 first"), + // s0: one plain prose item — flow, inline, collapse. + vectorSection("s0", [vectorProse("k9 body")]), + // s1: empty — flow, self-closing, the empty inline form. + vectorSection("s1", []), + // s2: several plain prose items — flow, inline. + vectorSection("s2", [vectorProse("k9 one"), vectorProse("k9 two")]), + // s3: a rich subtree — flow only. + vectorSection("s3", [ + vectorEmbedProse("s0"), + { kind: "blank" }, + { kind: "comment", words: "note am" }, + vectorSection("s0", [vectorProse("k9 deep")], { + tags: ["t1", "beta.x"], + coverageNone: true, + deps: [{ file: 0, dotted: "s0", spell: 1 }], + depsSingle: true, + }), + ]), + // s4: one prose item holding an embedding — undecorated collapse. + vectorSection("s4", [vectorEmbedProse("s1")]), + ], + }, + { + nextSeg: 1, + items: [ + vectorProse("b0 first"), + // The empty target parent, and a root target with a single final + // terminator (strippable). + vectorSection("s0", [], { + deps: [ + { file: 0, dotted: "s2", spell: 2 }, + { file: 0, dotted: "", spell: 0 }, + ], + }), + ], + }, + ], }; +function p5Vector( + name: string, + fileIndex: number, + deco: FileDecorations, + paths: readonly string[] = Object.keys(renderWorkspace(P5_FORM_MODEL)), +): readonly [string, string] { + const expansionOf = expansionSentinels(semanticsOf(P5_FORM_MODEL)); + return [ + name, + sectionMoveSourceText( + buildFilePieces(P5_FORM_MODEL, fileIndex, paths, expansionOf, deco), + ), + ]; +} + +function layoutName(layout: MovedLayout): string { + const part = (label: string, value: string | null): string => + `${label}=${value === null ? "none" : JSON.stringify(value)}`; + return ( + `${layout.form} layout (${part("leadOutside", layout.leadOutside)}, ` + + `${part("leadInside", layout.leadInside)}, ` + + `${part("tailInside", layout.tailInside)}, ` + + `${part("tailOutside", layout.tailOutside)}` + + `${layout.closeJoined ? ", joined close" : ""})` + ); +} + +/** + * The moved text of each inline layout as it lands (SPEC 6.5): the layout + * stripped of the parent's decorations, at a line start and followed by a + * terminator — distinct forms only, in first-seen order. + */ +function landedLayouts(layouts: readonly MovedLayout[]): MovedLayout[] { + const seen = new Map<string, MovedLayout>(); + for (const layout of layouts) { + const landed: MovedLayout = { + ...layout, + leadOutside: null, + tailOutside: null, + }; + if (!seen.has(layoutName(landed))) seen.set(layoutName(landed), landed); + } + return [...seen.values()]; +} + +function outsideLayout( + form: "selfClose" | "collapse", + leadOutside: string | null, + tailOutside: string | null, +): MovedLayout { + return { + form, + leadOutside, + leadInside: null, + tailInside: null, + tailOutside, + closeJoined: false, + }; +} + +/** The P-6 replay rewrite of file 1 after file 0 moved to `specs/N0.mdx`. */ +function movedHeaderVector(): string { + const state = initTrialState(P5_FORM_MODEL); + applyMoveFile(state, { kind: "moveFile", file: 0, newName: "N0" }); + return currentFileBytes(state, 1); +} + +/** The fixed form-vector set of the P-5 staging (S-9): name and source. */ +export const P5_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + ...["s0", "s1", "s2", "s3", "s4"].map((dotted) => + p5Vector(`flow layout on ${dotted}`, 0, { + moved: { dotted, layout: FLOW_LAYOUT }, + }), + ), + ...INLINE_LAYOUTS.flatMap((layout) => + ["s0", "s2"].map((dotted) => + p5Vector(`${layoutName(layout)} on ${dotted}`, 0, { + moved: { dotted, layout }, + }), + ), + ), + p5Vector(`${layoutName(EMPTY_INLINE_LAYOUT)} on the empty s1`, 0, { + moved: { dotted: "s1", layout: EMPTY_INLINE_LAYOUT }, + }), + ...landedLayouts(INLINE_LAYOUTS).flatMap((layout) => [ + p5Vector( + `${layoutName(layout)} as the moved text lands at top level (s0)`, + 0, + { moved: { dotted: "s0", layout } }, + ), + p5Vector( + `${layoutName(layout)} as the moved text lands inside the ` + + `flow-position parent s3 (s3.s0)`, + 0, + { moved: { dotted: "s3.s0", layout } }, + ), + ]), + ...OUTSIDE_LEADS.flatMap((leadOutside) => + OUTSIDE_TAILS.map((tailOutside) => { + const layout = outsideLayout("selfClose", leadOutside, tailOutside); + return p5Vector(`${layoutName(layout)} on the empty s1`, 0, { + moved: { dotted: "s1", layout }, + }); + }), + ), + ...OUTSIDE_LEADS.flatMap((leadOutside) => + OUTSIDE_TAILS.map((tailOutside) => { + const layout = outsideLayout("collapse", leadOutside, tailOutside); + return p5Vector(`${layoutName(layout)} on s0`, 0, { + moved: { dotted: "s0", layout }, + }); + }), + ), + p5Vector( + "collapse layout, undecorated, on s4 (an embedding in its prose)", + 0, + { + moved: { dotted: "s4", layout: outsideLayout("collapse", null, null) }, + }, + ), + p5Vector( + "coincident target side: the empty s1 rendered self-closing beside a flow-moved s0", + 0, + { moved: { dotted: "s0", layout: FLOW_LAYOUT }, selfCloseDotted: "s1" }, + ), + p5Vector( + "coincident target side: the final terminator stripped beside a flow-moved s0", + 0, + { moved: { dotted: "s0", layout: FLOW_LAYOUT }, stripFinalNewline: true }, + ), + p5Vector("target file, undecorated (importing the earlier file)", 1, {}), + // The import header under every drawn basename (module header, "drawn + // spec basenames"): the earlier file staged as `specs/<name>.mdx`. + ...SPEC_BASENAME_CLASSES.flat().map((basename) => + p5Vector( + `target file importing the earlier file staged as specs/${basename}.mdx`, + 1, + {}, + [specPath(basename), specPath(basename === "B" ? "C" : "B")], + ), + ), + p5Vector("target file: the empty target parent rendered self-closing", 1, { + selfCloseDotted: "s0", + }), + p5Vector( + "target file: the final terminator stripped (mid-line root insertion)", + 1, + { + stripFinalNewline: true, + }, + ), + p5Vector("target file: both target-side forms", 1, { + selfCloseDotted: "s0", + stripFinalNewline: true, + }), + [ + "P-6 replay rewrite after a file move: the import header names the moved path", + movedHeaderVector(), + ], +]; + +// --- S-9's per-draw check (helpers/property.ts `drawSources`) ---------------- + +function stagedPuritySources(trial: PurityTrial): DrawSource[] { + return Object.entries( + renderP5Workspace(trial.model, trial.basenames.map(specPath)), + ); +} + +function stagedSectionMoveSources(trial: SectionMoveTrial): DrawSource[] { + return Object.entries(buildSectionMove(trial).files); +} + +/** + * The replay's staged sources: the initial workspace and, per edit step, + * the files the edit rewrites at their then-current paths — the state + * evolved exactly as runReplayTrial evolves it (edits applied, pure + * operations replayed on the path table and the model). + */ +function stagedReplaySources(trial: ReplayTrial): DrawSource[] { + const state = initTrialState(trial.model); + const sources: DrawSource[] = Object.entries(renderWorkspace(state.model)); + trial.steps.forEach((step, index) => { + if (step.kind === "edit") { + const { description, files } = applyEditToState(state, step.edit); + for (const [path, bytes] of files) { + sources.push([ + path, + bytes, + `after step ${String(index + 1)} — ${description}`, + ]); + } + } else if (step.kind === "op") { + applyPureOp(state, step.op); + } + }); + return sources; +} + +// --- 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 workspace = await TestWorkspace.create({ - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - ...renderWorkspace(state.model), - }, + 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(drawWorkspace(built.files)); try { await workspace.gitInit(); const base = await workspace.gitCommitAll("pre-move baseline"); @@ -1275,17 +2555,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 +2574,21 @@ 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 6.2's enumeration ` + + `— the origin parent, the target parent, the moved subtree's nodes, ` + + `and each other node with bytes on a line the deletion joins or ` + + `drops or the insertion splits — each changed iff its own-content ` + + `sequence differs (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(); @@ -1429,12 +2706,9 @@ async function runReplayTrial( trial: ReplayTrial, ): Promise<void> { const state = initTrialState(trial.model); - const workspace = await TestWorkspace.create({ - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - ...renderWorkspace(state.model), - }, - }); + const workspace = await TestWorkspace.create( + drawWorkspace(renderWorkspace(state.model)), + ); try { await workspace.gitInit(); interface Snapshot { @@ -1501,7 +2775,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"}`; @@ -1523,15 +2816,21 @@ async function runReplayTrial( function renderPurityTrial(trial: PurityTrial): string { return JSON.stringify({ - files: renderWorkspace(trial.model), + files: renderP5Workspace(trial.model, trial.basenames.map(specPath)), ops: trial.ops, }); } 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 +2848,18 @@ 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; every import a drawn move adds passes " + + "T6.5-22(a), the spec basenames drawn from 6.5's barred classes (SPEC 2.1, 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( @@ -1563,7 +2868,12 @@ const P_5 = defineProductTest({ async (trial) => { await runPurityTrial(product, trial); }, - { runs: 3, maxShrinkExecutions: 60, render: renderPurityTrial }, + { + runs: 3, + maxShrinkExecutions: 60, + render: renderPurityTrial, + drawSources: stagedPuritySources, + }, ); await checkProperty( "P-5 random section moves", @@ -1571,7 +2881,12 @@ const P_5 = defineProductTest({ async (trial) => { await runSectionMoveTrial(product, trial); }, - { runs: 5, maxShrinkExecutions: 80, render: renderSectionMoveTrial }, + { + runs: 8, + maxShrinkExecutions: 80, + render: renderSectionMoveTrial, + drawSources: stagedSectionMoveSources, + }, ); }, }); @@ -1596,7 +2911,12 @@ const P_6 = defineProductTest({ async (trial) => { await runReplayTrial(product, trial); }, - { runs: 4, maxShrinkExecutions: 60, render: renderReplayTrial }, + { + runs: 4, + maxShrinkExecutions: 60, + render: renderReplayTrial, + drawSources: stagedReplaySources, + }, ); }, }); diff --git a/test/suite/registry/section-16-p7.ts b/test/suite/registry/section-16-p7.ts index dc04d7ec..190eea3a 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 @@ -117,12 +131,16 @@ import { matchFromPattern, matchToPattern, } from "../../helpers/oracles/glob.js"; -import type { Choices, Gen } from "../../helpers/property.js"; +import type { Choices, DrawSource, 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 { + TestWorkspace, + mdxPathsOf, + tsPathsOf, +} from "../../helpers/workspace.js"; import { assertConditionCounts, assertSameJson, @@ -196,10 +214,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 +298,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 @@ -606,6 +680,55 @@ function mdxSection(id: string): string { return `<S id="${id}">\nText for ${id}.\n</S>\n`; } +/** + * The fixed form-vector set of the P-7 stagings (S-9; the §16 preamble): + * the one template both arms compose — `mdxSection` — under the discovery + * arm's `s<index>` ids (paths capped at 8) and the capture arm's `t<index>` + * ids (targets capped at 5), the first and the last admissible index each. + */ +export const P7_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + ["discovery arm: mdxSection of the first path (s0)", mdxSection("s0")], + ["discovery arm: mdxSection of the last path (s7)", mdxSection("s7")], + ["capture arm: mdxSection of the first target (t0)", mdxSection("t0")], + ["capture arm: mdxSection of the last target (t4)", mdxSection("t4")], +]; + +// S-9's per-draw check (helpers/property.ts `drawSources`): every file each +// arm stages — the configuration the draw composed (judged well-formed +// TypeScript), one `mdxSection` per drawn path (judged for derivability), +// and, in the capture arm, each code source (judged well-formed TypeScript: +// a code group discovers it whatever its name, and no capture-side source +// name ends in a TypeScript-default suffix, so each is marked a code +// source). The bodies stage exactly these files, from the same pure +// functions. +function stagedDiscoverySources(trial: DiscoveryTrial): DrawSource[] { + return [ + ["xspec.config.ts", discoveryConfig(trial)], + ...trial.paths.map((path, index): DrawSource => [ + path, + mdxSection(`s${String(index)}`), + ]), + ]; +} + +function stagedCaptureSources(trial: CaptureTrial): DrawSource[] { + return [ + ["xspec.config.ts", captureConfig(trial)], + ...trial.targets.map((target, j): DrawSource => [ + target, + mdxSection(`t${String(j)}`), + ]), + ...trial.sources.map((source): DrawSource => [ + source, + codeSource(source, trial.targets), + "code source", + "code-source", + ]), + ]; +} + /** One `{file, ids}` listing entry, and a bytewise-sorted copy for compare. */ interface ListingEntry { readonly file: string; @@ -620,6 +743,16 @@ function sortedListing(entries: readonly ListingEntry[]): ListingEntry[] { ); } +/** The discovery arm's configuration: one spec group over the patterns. */ +function discoveryConfig(trial: DiscoveryTrial): string { + return ( + `import { defineConfig } from "xspec"\n\nexport default defineConfig({\n` + + ` specs: {\n g: [${trial.patterns + .map((pattern) => JSON.stringify(pattern)) + .join(", ")}]\n }\n})\n` + ); +} + function renderDiscoveryTrial(trial: DiscoveryTrial): string { return JSON.stringify({ patterns: trial.patterns, paths: trial.paths }); } @@ -630,11 +763,7 @@ async function assertDiscoveryAgreement( trial: DiscoveryTrial, ): Promise<void> { const files: Record<string, string> = { - "xspec.config.ts": - `import { defineConfig } from "xspec"\n\nexport default defineConfig({\n` + - ` specs: {\n g: [${trial.patterns - .map((pattern) => JSON.stringify(pattern)) - .join(", ")}]\n }\n})\n`, + "xspec.config.ts": discoveryConfig(trial), }; const expected: ListingEntry[] = []; trial.paths.forEach((path, index) => { @@ -644,7 +773,17 @@ async function assertDiscoveryAgreement( expected.push({ file: path, ids: [id] }); } }); - const workspace = await TestWorkspace.create({ files }); + // S-9: the draw's sources, judged by the property runner before the body + // saw them (`stagedDiscoverySources` above) — declared per draw, as every + // initial `.mdx` file a trial stages after the body's first product + // invocation must be (helpers/workspace.ts); the configuration the draw + // composed is declared per draw too (`ts.perDraw`: judged well-formed at + // staging, past the undeclared-staging guard's TypeScript arm). + const workspace = await TestWorkspace.create({ + files, + mdx: { perDraw: mdxPathsOf(files) }, + ts: { perDraw: tsPathsOf(files) }, + }); try { const context = `P-7 discovery: patterns ${JSON.stringify(trial.patterns)} over staged ` + @@ -702,7 +841,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 +856,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 +887,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 +903,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 +926,7 @@ function toPatternSegments( ], [2, { kind: "star" }], [1, { kind: "question" }], + [1, { kind: "lit", text: dollarLiteral(choices) }], ]), ); } @@ -974,13 +1124,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 { @@ -1006,7 +1162,21 @@ async function assertCaptureAgreement( files[source] = codeSource(source, trial.targets); } const expected = expectedFindingRenderings(trial); - const workspace = await TestWorkspace.create({ files }); + // S-9: every staged file is the draw's, judged by the property runner + // before the body saw it (`stagedCaptureSources` above). The targets are + // declared per draw; a code source never ends in `.mdx` (the path + // alphabet spells no `m`, `d`, or `x`), so that list is exactly the + // targets. The configuration and the code sources the draw composed are + // declared per draw too (`ts.perDraw`: judged well-formed at staging, past + // the undeclared-staging guard's TypeScript arm); a code source never ends + // in a TypeScript-default suffix (the alphabet spells no `t` or `j`), so + // `tsPathsOf` lists the configuration alone and each code source is + // listed by name — a code group discovers it whatever its name. + const workspace = await TestWorkspace.create({ + files, + mdx: { perDraw: mdxPathsOf(files) }, + ts: { perDraw: [...new Set([...tsPathsOf(files), ...trial.sources])] }, + }); try { const base = `P-7 captures: rules ${JSON.stringify(trial.rules)} over sources ` + @@ -1057,6 +1227,112 @@ async function assertCaptureAgreement( } } +// --------------------------------------------------------------------------- +// S-9's fixed TypeScript form-vector set +// --------------------------------------------------------------------------- + +/** + * Each distinct character an alphabet draws, then é — drawn on the Linux leg + * alone (`withByteProbe`), spelled here on every platform, so the vectors + * cover every leg's draws. + */ +function everyCharacterOf(alphabet: Weighted): string { + return [...new Set([...alphabet.map(([, char]) => char), E_ACUTE])].join(""); +} + +const DISCOVERY_LITERALS = everyCharacterOf(DISCOVERY_PATTERN_LITERAL_ALPHABET); +const CAPTURE_LITERALS = everyCharacterOf(PATTERN_LITERAL_ALPHABET); +const CAPTURE_PATH_CHARACTERS = everyCharacterOf(CAPTURE_PATH_ALPHABET); + +/** The capture arm's maximal target list (5, the cap), every path byte. */ +const VECTOR_TARGETS: readonly string[] = [ + `tgt/${CAPTURE_PATH_CHARACTERS}.mdx`, + "tgt/a.mdx", + "tgt/b/c.mdx", + "tgt/$/$0.mdx", + "tgt/q/.a.mdx", +]; + +/** + * The fixed TypeScript form-vector set of the P-7 stagings (TEST-SPEC 17 + * S-9; the §16 preamble): every configuration file and code source the two + * arms compose, through the templates the bodies stage from — + * `discoveryConfig` over one pattern and over two (the generator's maximum), + * spelling every discovery literal, `*`, `?`, and `**`; `captureConfig` over + * one rule, target, and source and over two rules, five targets, and three + * sources (the caps), its patterns spelling every capture-side literal, + * the captures `$1`–`$3`, and the literal `$` forms (`$0`, `$` fused to a + * non-digit, a trailing `$`); and `codeSource` at depths 0, 1, and 3 over + * one target and five, spelling every capture-side path byte. Each is + * `[name, staged path, source]`: the path selects the grammar (plain + * TypeScript, no capture-side source name ending `.tsx`), and a capture + * source is a code source whatever its name (a code group globs it). + */ +export const P7_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string] +> = [ + [ + "discovery arm: discoveryConfig over one pattern", + "xspec.config.ts", + discoveryConfig({ + patterns: [`**/${DISCOVERY_LITERALS}*?/w*`], + paths: [], + }), + ], + [ + "discovery arm: discoveryConfig over two patterns", + "xspec.config.ts", + discoveryConfig({ + patterns: [`${DISCOVERY_LITERALS}/**`, "w/*?/**"], + paths: ["w/a.mdx"], + }), + ], + [ + "capture arm: captureConfig over one rule, target, and source", + "xspec.config.ts", + captureConfig({ + rules: [{ name: "r0", from: "a$1", to: "tgt/$1.mdx" }], + sources: ["ab"], + targets: ["tgt/b.mdx"], + }), + ], + [ + "capture arm: captureConfig over two rules, five targets, and three sources", + "xspec.config.ts", + captureConfig({ + rules: [ + { + name: "r0", + from: `**/$0$a$$1/${CAPTURE_LITERALS}*?$`, + to: "tgt/**/$$1$0.mdx", + }, + { + name: "r1", + from: "a$2$3/$(*", + to: `tgt/${CAPTURE_LITERALS}$/$3$2.mdx`, + }, + ], + sources: [`${CAPTURE_PATH_CHARACTERS}/q`, "a/b/c", "$0"], + targets: VECTOR_TARGETS, + }), + ], + [ + "capture arm: codeSource at depth 0 over one target", + "ab", + codeSource("ab", ["tgt/b.mdx"]), + ], + [ + "capture arm: codeSource at depth 1 over five targets", + `${CAPTURE_PATH_CHARACTERS}/q`, + codeSource(`${CAPTURE_PATH_CHARACTERS}/q`, VECTOR_TARGETS), + ], + [ + "capture arm: codeSource at depth 3 over five targets", + "a/b/c/$0", + codeSource("a/b/c/$0", VECTOR_TARGETS), + ], +]; + // --------------------------------------------------------------------------- // The registered property test // --------------------------------------------------------------------------- @@ -1065,11 +1341,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. @@ -1081,7 +1358,12 @@ const P_7 = defineProductTest({ async (trial) => { await assertDiscoveryAgreement(product, trial); }, - { runs: 8, maxShrinkExecutions: 120, render: renderDiscoveryTrial }, + { + runs: 8, + maxShrinkExecutions: 120, + render: renderDiscoveryTrial, + drawSources: stagedDiscoverySources, + }, ); await checkProperty( "P-7 captures (policy)", @@ -1089,7 +1371,12 @@ const P_7 = defineProductTest({ async (trial) => { await assertCaptureAgreement(product, trial); }, - { runs: 5, maxShrinkExecutions: 120, render: renderCaptureTrial }, + { + runs: 5, + maxShrinkExecutions: 120, + render: renderCaptureTrial, + drawSources: stagedCaptureSources, + }, ); }, }); diff --git a/test/suite/registry/section-16-p8.ts b/test/suite/registry/section-16-p8.ts index 129a7411..b3f5e8d6 100644 --- a/test/suite/registry/section-16-p8.ts +++ b/test/suite/registry/section-16-p8.ts @@ -9,15 +9,30 @@ // // * every command terminates — operationalized by the subprocess driver's // hang guard (helpers/subprocess.ts): a run killed by the per-invocation -// timeout or by the runaway-output cap is converted into a *diagnosed -// assertion failure* (H-8), because termination is this property's -// assertion, not merely its harness hygiene; +// timeout is converted into a *diagnosed assertion failure* (H-8; S-3: +// hangs are reported as failures), because termination is this +// property's assertion, not merely its harness hygiene. The guard is +// dimensioned to the staged answer scale, `view --text` over the +// largest draw included (H-11; `FUZZ_COMMAND_TIMEOUT_MS`), and its kill +// is reported unshrunk, as P-11's is, since every shrink candidate that +// re-observes it costs a full guard. A run killed by the driver's +// output-capture cap is never converted: an exhausted capture limit is +// a loud harness error, which `checkProperty` reports with the seed — +// never a falsified property, since a truncated capture is +// indistinguishable from a partial document (H-11; S-8 dimensions the +// cap to the staged answer scale and pins this division); // * 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); +// * whenever JSON output is in effect, stdout is never a partial JSON +// document: exit 0/1 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). JSON output is in effect by flag or by surface, as 12.0 reads +// it (`jsonOutputInEffect`): a `--json` token read as a flag, or a +// JSON-only surface — `query`, `occurrences`, `view`, `at`, and +// `inventory` (11), `version` (12.6), `review export` (10.7) — with or +// without `--json`; every other run is held to the exit partition +// alone; // * 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 @@ -59,28 +74,123 @@ // mid-code-point); // * shuffle — a drawn byte range removed and reinserted at a drawn // position (closers before openers, headers displaced); -// * garbage — the whole file replaced by 0–64 uniformly drawn bytes. +// * garbage — the whole file replaced by 0–64 uniformly drawn bytes; +// * fragment — `<>…</>` insertion and unbalancing: a balanced fragment +// around a drawn interior (empty, prose, a section, an +// embedding, a comment, a nested fragment, a multi-line +// interior), a lone `<>` or `</>`, or a fragment wrapping a +// drawn byte range (crossing whatever constructs it spans); +// * braces — brace content at the comment/expression/parse-failure +// boundaries of SPEC 2.7 and 14.20, applied to a drawn +// `{…}` container of the file (one seeded when it holds +// none): comment ↔ expression rewrites (the content wrapped +// in a block comment, an expression beside it, comments and +// line comments before or after it — the U+000A-, U+000D-, +// and U+2028-ended and run-on forms of T2.3-3/T2.7-4 — +// empty braces, two comments, and the ECMAScript-whitespace +// and non-whitespace singletons of T2.7-4); the content +// replaced by a boundary expression (the early errors, +// comma sequences, `await`, and function forms of T14-12, +// the negative embedding forms of T2.3-3, JSX and a section +// inside braces, two expressions, an unclosed call, a lone +// `]`, an unclosed block comment); the spread grammar pair +// of T2.7-3 and its neighbours inserted on a `<S ` tag; +// empty braces in flow, text, and attribute-value position +// (`d={}`, `d={ /* c */ }`, `coverage={}`); and unbalanced +// braces at EOF (an unclosed embedding, brace, comment, or +// attribute value appended, a stray `}`, or the file's last +// `}` deleted); +// * esmBlock — ESM-block mutations on a drawn `import`/`export` line +// (one seeded at a drawn line start when the file holds +// none): comment insertion (own-line line and block +// comments before it, a block comment spanning lines, a +// comment on its line before the declaration, trailing +// comments, an own-line comment after it with no blank line +// between); terminator changes (`;` appended or removed, the +// line's terminator rewritten to CR, CRLF, U+2028, U+2029, a +// blank line, or a space joining the next line); indentation +// (spaces or a tab before the declaration); splitting and +// joining blocks (a second declaration after a blank line +// or directly on the next line — a duplicate binding, a +// fresh binding, a side-effect-only import, import +// attributes, an export naming no declaration, an export +// holding JSX — or the blank line after the block deleted); +// and a statement at a line start (`const x = 1` and its +// kin directly after the declaration, the T14-12 negative, +// or at any drawn line start). +// +// The refined classes stage the forms most likely to make a product +// misjudge the well-formedness boundary (T14-12) — every spelling a fixed +// constant of at most a few dozen bytes, so no refined draw approaches a +// tower's growth (S-8) — while the property asserts no parse verdict on any +// of them: its contract stays the robustness clauses above, and the verdicts +// themselves are T2.3-3's, T2.7-3's, T2.7-4's, T3-7's, and T14-12's. // // The command sweep spans the SPEC 12 surface: `build` (both output forms — -// the human form via the drawn menu), `check`, `ids`, `show`, all five -// `query` subcommands, `coverage`, `impact --base` (no repository is staged: -// an unreadable baseline is itself an exit-2 outcome, 6.3/12.0), `review` -// reads and `review create`, `rename`, and file-form `move`. Mutating -// commands may legitimately succeed and modify the workspace when the -// mutations happen to be benign — P-8 constrains their termination, exit -// class, and JSON form only; the modifies-nothing arm is `build`'s -// (SPEC 12.1). An implementation-time dry-run over the committed default -// seeds at the registered 12 runs per seed verified that every menu entry, -// every mutation kind, and every mutation target occurs — giant MDX section -// towers (depths 512 and 2048), all three BOM flavors, and a mid-file BOM -// included — so the CI-pinned trial set (E-5) exercises the full surface -// deterministically, with staged files bounded (~32 KiB max). +// the human form via the drawn menu), `check`, `ids`, `show` (a node, with +// and without `--json`, and a file), all six `query` subcommands (`reachable` +// over a base dependency path, with and without `--json`), the other query +// surfaces of 11 — `occurrences` (unfiltered and under `--file`), `view` +// (with and without `--text`), `at` at an in-range offset of a base file, and +// `inventory` (with and without `--json`) — `version` (12.6, with and without +// `--json`), `coverage`, `impact --base` (no repository is staged: an +// unreadable baseline is itself an exit-2 outcome, 6.3/12.0), `review list`, +// `create`, and `next`, the review subcommands naming a session or an item — +// `status`, `show`, `split`, `resolve`, and `export`, each with and without +// `--json` — as composites (`armSteps`: the session created first, then for +// an item form a JSON read of it yielding the item the form names, each step +// under the same assertions), `rename` and file-form `move` (each performed +// with `--json` and previewed with and without it, 6.6), and section-form +// `move` (6.5) — a base section into a file the base holds and into one it +// lacks, which the move creates — performed and previewed, each with and +// without `--json`; `--test-hold`, a usage error beside `--preview`, never +// appears. Each JSON-only surface has a form drawn without `--json`, so the +// by-surface half of 12.0's rule is exercised, and every command runs with +// JSON output in effect — `build` in the fixed arm. Mutating commands may +// legitimately succeed and modify the workspace when the mutations happen to +// be benign — P-8 constrains their termination, exit class, and JSON form +// only; the modifies-nothing arm is `build`'s (SPEC 12.1). Two facts about +// the CI-pinned draws are guarded permanently, before any product runs, by +// the fixed-seed draw guard (test/self/p8-fixed-seed-draws.test.ts), which +// replays P-8's own draws at its registered `P8_RUNS_PER_SEED` runs per seed: +// every `COMMAND_MENU` entry is drawn at least once, and an intact MDX +// section tower at least 2048 levels deep is staged (P-8's giant-nesting +// floor); the same file pins `jsonOutputInEffect`, the menu's bare form per +// JSON-only surface and JSON form per command, the review composites' +// expansion, and the mutating commands' performed, preview, and section +// forms. Beyond those, a dry-run over the committed default seeds at the +// registered 12 runs per seed (`drawFixedSeedTrials`, the S-8 replay; re-run +// when the review composites' 2–6 forms per trial moved the draws) +// verified that every mutation kind — the three refined classes included — +// and every mutation target occurs (64 mutations): a giant MDX section +// tower (depth 2048) and TypeScript towers of depths 512, 2048, and 4096, +// all three BOM flavors and a mid-file BOM, lone, balanced, and +// range-wrapping fragments, a spread attribute, empty braces, EOF-unbalanced +// braces, a non-whitespace-singleton container, a boundary expression +// replacing a container's content, and seeded and existing declaration +// lines with comment, terminator, indentation, and statement mutations — +// so the CI-pinned trial set (E-5) exercises every kind deterministically, +// with staged files bounded (22 KiB max); the modes it misses (a deleted +// closer, an ECMAScript-whitespace singleton, a split or joined block, a +// statement at a drawn line start) are reached under +// `XSPEC_PROPERTY_SEED=random` runs, not by the pinned set. // // 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 { VALUE_FLAGS } from "../../helpers/added-import-identifiers.js"; +import { + decodeNextReport, + decodeSessionStatusReport, +} from "../../helpers/adapters/index.js"; import { assertJsonOutputConvention, fail } from "../../helpers/assertions.js"; import type { Choices, Gen } from "../../helpers/property.js"; import { checkProperty, listOf } from "../../helpers/property.js"; @@ -88,7 +198,6 @@ 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"; @@ -96,6 +205,11 @@ import { assertSnapshotsEqual, snapshotDirectory, } from "../../helpers/snapshot.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { buildOk } from "./support.js"; @@ -153,15 +267,81 @@ 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 base workspace's files as staged-source records (S-9's + * before-any-product clause and its TypeScript clause): the `.mdx` sources + * as MDX records (helpers/staged-mdx.ts), the configuration and the code + * source as TypeScript records (helpers/staged-ts.ts), all well-formed. P-8 + * stages the base workspace afresh per trial and P-11 its unmutated base + * files per trial — from the second trial on, after the body's first product + * invocation: initial files S-7's sweep never reaches — so the ledger + * self-test judges them before any product exists. `FUZZ_BASE_FILES` keeps + * the strings: the generators mutate their bytes, and a mutated file is + * staged plain, declared `unchecked` (fuzz). + */ +export const FUZZ_BASE_RECORDS: ReadonlyMap<string, StagedMdx | StagedTs> = + new Map<string, StagedMdx | StagedTs>([ + [ + "xspec.config.ts", + stagedTs( + "P-8/P-11 xspec.config.ts — the fuzz base configuration", + BASE_CONFIG, + ), + ], + ["specs/A.mdx", stagedMdx("P-8/P-11 specs/A.mdx", BASE_SPEC_A)], + ["specs/B.mdx", stagedMdx("P-8/P-11 specs/B.mdx", BASE_SPEC_B)], + [ + "src/app.ts", + stagedTs("P-8/P-11 src/app.ts — the fuzz base code source", BASE_CODE), + ], + ]); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the base workspace's configuration and code source P-8 and + * P-11 share, unmutated — the records above, judged as records by + * test/self/s9-staged-sources.test.ts too, and here beside every generated + * configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts). A mutated file is + * fuzz, staged `unchecked`: the document does not declare its + * well-formedness (16). + */ +export const P8_P11_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = FUZZ_BASE_FILES.filter(([path]) => path.endsWith(".ts")).map( + ([path, text]): readonly [string, string, string] => [ + `P-8/P-11 base ${path}`, + path, + text, + ], +); + +/** The base workspace as initial `files`: each entry its record. */ +function fuzzBaseWorkspaceFiles(): Record<string, InitialFileContents> { + return Object.fromEntries( + FUZZ_BASE_FILES.map(([path, text]) => [ + path, + FUZZ_BASE_RECORDS.get(path) ?? text, + ]), + ); +} // --------------------------------------------------------------------------- // The command menu (SPEC 12 surface). Every entry is drawn by trials; the @@ -171,29 +351,250 @@ const MUTATION_TARGETS: readonly string[] = BASE_FILES.map(([path]) => path); // or files named in arguments are usage errors). Entries are data, never // interpreted by a shell (H-2). -const COMMAND_MENU: ReadonlyArray<readonly string[]> = [ +/** + * The byte offset of `anchor`'s first occurrence in a base source, plus + * `delta` — a menu argument computed from the constant bytes it names, so it + * cannot drift from them. + */ +function baseByteOffset(text: string, anchor: string, delta: number): number { + const index = text.indexOf(anchor); + if (index < 0) { + throw new Error( + `P-8 harness defect: the base source holds no ${JSON.stringify(anchor)}`, + ); + } + return Buffer.byteLength(text.slice(0, index), "utf8") + delta; +} + +/** + * `at`'s in-range offset (SPEC 11.5) over the base `specs/A.mdx`: the byte + * just inside its braced embedding `{text("a.b")}`, within a reference + * occurrence's range (5.7) — so over the base workspace the answer carries + * that occurrence and its resolved target beside the innermost section + * construct. Spelled in decimal digits, as 11.5 requires. Over a mutated + * file the offset can lie past its end: a legitimate usage error, exit 2 + * (11.5, 12.0). + */ +const AT_OFFSET = String(baseByteOffset(BASE_SPEC_A, '{text("a.b")}', 1)); + +/** + * The review session every `review` form of the menu names (a valid session + * name, SPEC 10.1): the static `create` and `next` forms name it directly, + * the composite forms through {@link SESSION_SLOT}. + */ +const REVIEW_SESSION = "r1"; + +/** + * Composite-form slots, spelled after SPEC 10.7's synopsis. A menu form + * holding `<session>` is a review composite: its arm first runs `review + * create --strategy audit --name r1 --json`, then the form with `r1` in the + * slot. A form also holding `<item-id>` names an item: between the two, a + * JSON read of the session yields the item that fills the slot + * ({@link armSteps}). An independently drawn `show`, `split`, or `resolve` + * would only ever meet the no-session usage error; the composite reaches a + * session's items whenever the fuzzed workspace admits a session. Neither + * token ever reaches the product. + */ +export const SESSION_SLOT = "<session>"; +export const ITEM_ID_SLOT = "<item-id>"; + +/** + * The item id a composite names when its read yields none — the create + * failed on the fuzzed workspace, the read exited non-zero, or the session + * holds no item to name: an id no product-derived item of the session + * carries, so the drawn command meets the usage error of an unknown review + * item (12.0), or whatever error precedes it (the unknown session, the gate + * of 13.3). Should a product ever derive this very id, the command runs on + * that item instead — every outcome is within P-8's contract either way. + */ +const ABSENT_ITEM_ID = "p8-absent-item"; + +/** + * P-8's command menu. Exported for the fixed-seed draw guard + * (test/self/p8-fixed-seed-draws.test.ts), which replays P-8's own draws at + * {@link P8_RUNS_PER_SEED} and fails unless every entry is drawn at least + * once — so a form added here, or a draw shift, that the fixed CI seeds + * (E-5) never reach is caught before any product runs. A pick takes one + * PRNG value whatever the menu's length, so the fixed seeds' picks are fixed + * values, and which forms they reach depends on the menu's length — their + * residues modulo it — not on the forms' order: inserting a form anywhere + * can leave one undrawn, wherever it stands. + * + * Every command runs with JSON output in effect at least once — `build` + * through the fixed arm ({@link FIXED_BUILD_ARM}), every other command + * through a menu form — and the same guard file pins that, beside the + * by-surface forms and the mutating commands' forms. + */ +export const COMMAND_MENU: ReadonlyArray<readonly string[]> = [ ["build"], ["check", "--json"], ["check"], ["ids", "--json"], ["ids", "--tree"], ["show", "specs/A.mdx#a"], + // 12.4's human report has its `--json` form too (12.0: every command + // supports it), held to the JSON contract. + ["show", "specs/A.mdx#a", "--json"], ["show", "specs/A.mdx"], ["query", "node", "specs/A.mdx#a.b", "--json"], ["query", "nodes", "--json"], ["query", "edges", "--json"], ["query", "subtree", "specs/A.mdx#a", "--json"], ["query", "ancestors", "specs/A.mdx#a.b", "--json"], + // `b`'s `d` reference to `A.a` joins the two over the base workspace by a + // one-edge dependency path (11.1). + [ + "query", + "reachable", + "--from", + "specs/B.mdx#b", + "--to", + "specs/A.mdx#a", + "--json", + ], + ["query", "reachable", "--from", "specs/B.mdx#b", "--to", "specs/A.mdx#a"], + ["occurrences"], + ["occurrences", "--file", "specs/B.mdx"], + ["view", "specs/A.mdx"], + // `B`'s `{text(A.c)}` expansion consults `specs/A.mdx` too (11.4). + ["view", "specs/B.mdx", "--text"], + ["at", "specs/A.mdx", AT_OFFSET], + ["inventory", "--json"], + ["inventory"], ["coverage", "--json"], ["coverage"], ["impact", "--base", "HEAD", "--json"], ["review", "list", "--json"], - ["review", "create", "--strategy", "audit", "--name", "r1", "--json"], - ["review", "next", "r1", "--json"], + [ + "review", + "create", + "--strategy", + "audit", + "--name", + REVIEW_SESSION, + "--json", + ], + ["review", "next", REVIEW_SESSION, "--json"], + // The review subcommands naming a session, and an item, as composites + // (`armSteps`); `export` is JSON-only (10.7), so its bare form is held to + // the JSON contract too. + ["review", "status", SESSION_SLOT, "--json"], + ["review", "status", SESSION_SLOT], + ["review", "show", SESSION_SLOT, ITEM_ID_SLOT, "--json"], + ["review", "show", SESSION_SLOT, ITEM_ID_SLOT], + ["review", "split", SESSION_SLOT, ITEM_ID_SLOT, "--json"], + ["review", "split", SESSION_SLOT, ITEM_ID_SLOT], + [ + "review", + "resolve", + SESSION_SLOT, + ITEM_ID_SLOT, + "--status", + "no-change", + "--json", + ], + ["review", "resolve", SESSION_SLOT, ITEM_ID_SLOT, "--status", "no-change"], + ["review", "export", SESSION_SLOT, "--json"], + ["review", "export", SESSION_SLOT], + // The mutating commands, performed and previewed. A preview (6.6) plans + // the operation over the fuzzed workspace and performs nothing; it + // supports `--json` (the preview document of 12.7) and is no JSON-only + // surface, so its bare form is held to the exit partition alone. + // `--test-hold` never appears: beside `--preview` it is a usage error + // (6.6, 12.0), and P-8 drives no seam. ["rename", "specs/A.mdx", "c", "c2", "--json"], + ["rename", "specs/A.mdx", "c", "c2", "--preview", "--json"], + ["rename", "specs/A.mdx", "c", "c2", "--preview"], ["move", "specs/B.mdx", "specs/moved.mdx", "--json"], + ["move", "specs/B.mdx", "specs/moved.mdx", "--preview", "--json"], + ["move", "specs/B.mdx", "specs/moved.mdx", "--preview"], + // The section form (6.5) moves the base's `c` out of `specs/A.mdx`: into + // `specs/B.mdx`, a file the base holds, its ID kept (a cross-file move + // keeping its ID is valid, 6.5) — `b`'s `{text(A.c)}` turns local and + // the moved `d` and embedding chains are rooted at B's binding of A — and + // into `specs/C.mdx`, which the base lacks, so the move creates it, `c` + // re-identified as `e` — C's import of A composed in and an import of C + // added to B. Over the base workspace every form here performs or + // previews with exit 0; a fuzzed one can refuse it (exit 1) or name an + // origin the trial's mutations or an earlier form removed (exit 2). + ["move", "specs/A.mdx#c", "specs/B.mdx#c", "--json"], + ["move", "specs/A.mdx#c", "specs/B.mdx#c"], + ["move", "specs/A.mdx#c", "specs/B.mdx#c", "--preview", "--json"], + ["move", "specs/A.mdx#c", "specs/B.mdx#c", "--preview"], + ["move", "specs/A.mdx#c", "specs/C.mdx#e", "--json"], + ["move", "specs/A.mdx#c", "specs/C.mdx#e"], + ["move", "specs/A.mdx#c", "specs/C.mdx#e", "--preview", "--json"], + ["move", "specs/A.mdx#c", "specs/C.mdx#e", "--preview"], + ["version", "--json"], + ["version"], ]; +/** + * The arm every trial runs first, before its drawn forms: `build --json`, + * whose failure must modify nothing (SPEC 12.1) — `build`'s run under JSON + * output, the menu holding its bare form. + */ +export const FIXED_BUILD_ARM: readonly string[] = ["build", "--json"]; + +/** + * One invocation of a drawn form's arm. A composite's read carries the + * session read it is (`yieldsItem`): its JSON answer yields the item that + * fills {@link ITEM_ID_SLOT} in the step after it. + */ +export interface ArmStep { + readonly argv: readonly string[]; + readonly yieldsItem?: "next" | "status"; +} + +/** + * The invocations a drawn menu form runs, in order, each under + * `runFuzzArm`'s assertions. A static form is its one step. A review + * composite ({@link SESSION_SLOT}) runs: + * + * 1. `review create --strategy audit --name r1 --json`, creating the + * session whenever the fuzzed workspace admits one (an `r1` an earlier + * form of the trial created makes it a refused create, exit 1 — the + * reads below then meet that session); + * 2. for a form naming an item, a JSON read of the session (10.7): + * `review status r1 --json` for `split`, taking the first item in item + * order — over the base workspace `specs/A.mdx`'s implicit root, an + * audit scope node with children (10.6: root nodes included), so the + * split performs over a benign draw — and `review next r1 --json` for + * `show` and `resolve`, taking the first item needing review and + * unblocked, so the resolve performs; + * 3. the form itself, `r1` in the session slot and the read's item — else + * {@link ABSENT_ITEM_ID} — in the item slot. + */ +export function armSteps(form: readonly string[]): readonly ArmStep[] { + if (!form.includes(SESSION_SLOT)) return [{ argv: form }]; + const steps: ArmStep[] = [ + { + argv: [ + "review", + "create", + "--strategy", + "audit", + "--name", + REVIEW_SESSION, + "--json", + ], + }, + ]; + if (form.includes(ITEM_ID_SLOT)) { + const read = form[1] === "split" ? "status" : "next"; + steps.push({ + argv: ["review", read, REVIEW_SESSION, "--json"], + yieldsItem: read, + }); + } + steps.push({ + argv: form.map((token) => + token === SESSION_SLOT ? REVIEW_SESSION : token, + ), + }); + return steps; +} + // --------------------------------------------------------------------------- // Mutations. Each apply function is pure (bytes in, bytes out) and draws all // of its parameters through `Choices`, so identical tapes re-derive identical @@ -256,7 +657,7 @@ const UTF16LE_BOM: readonly number[] = [0xff, 0xfe]; const UTF16BE_BOM: readonly number[] = [0xfe, 0xff]; /** Line-terminator sequences LF is rewritten to / runs are built from. */ -const TERMINATOR_SEQUENCES: ReadonlyArray< +export const TERMINATOR_SEQUENCES: ReadonlyArray< readonly [string, readonly number[]] > = [ ["CR", [0x0d]], @@ -269,8 +670,22 @@ const TERMINATOR_SEQUENCES: ReadonlyArray< ]; // Every nesting draw is genuinely giant (P-8 "giant nesting"); the smallest -// entry first so counterexamples shrink toward the shallowest tower. -const NESTING_DEPTHS: readonly number[] = [512, 2048, 4096]; +// entry first so counterexamples shrink toward the shallowest tower. Exported +// (with `sectionTowerSource` and `MAX_MUTATIONS_PER_TRIAL`) so S-8's capacity +// gate derives the suite's staged maxima from the generator itself (H-11). +export const NESTING_DEPTHS: readonly number[] = [512, 2048, 4096]; + +/** + * The MDX section tower a nesting mutation stages: `depth` nested `<S id="g">` + * openers, balanced ones closing around one content line, unclosed ones left + * open (an unparseable file). Byte-exact — S-2/S-8 stage and size the same + * tower the fuzz draws stage. + */ +export function sectionTowerSource(depth: number, balanced: boolean): string { + return balanced + ? `${'<S id="g">\n'.repeat(depth)}deep.\n${"</S>\n".repeat(depth)}` + : '<S id="g">\n'.repeat(depth); +} function spliceBytes( bytes: Uint8Array, @@ -292,7 +707,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; } @@ -387,9 +802,7 @@ function mutateNesting( let shape: string; if (path.endsWith(".mdx")) { shape = balanced ? "balanced section tower" : "unclosed section tower"; - tower = balanced - ? `${'<S id="g">\n'.repeat(depth)}deep.\n${"</S>\n".repeat(depth)}` - : '<S id="g">\n'.repeat(depth); + tower = sectionTowerSource(depth, balanced); } else { shape = balanced ? "balanced parenthesis tower" @@ -445,6 +858,534 @@ function mutateGarbage(choices: Choices, bytes: Uint8Array): MutationResult { }; } +// --------------------------------------------------------------------------- +// The refined mutation classes (module header: fragment, braces, esmBlock). +// Each edits the evolving bytes at a drawn anchor found by scanning them — a +// `{…}` container, a `<S ` tag, an `import`/`export` line, a line start — or +// at a drawn offset, and seeds an anchor when the file holds none, so every +// mode is meaningful on every target after every earlier mutation of the +// trial. Byte-level throughout: a container's content is spliced as bytes, +// never decoded, so ill-formed UTF-8 an earlier mutation staged survives +// untouched (H-10: the staged bytes are a pure function of the tape). + +const LF = 0x0a; +const CR = 0x0d; + +function utf8(text: string): number[] { + return [...Buffer.from(text, "utf8")]; +} + +/** Non-overlapping offsets of every occurrence of `needle` in `bytes`. */ +function findAll(bytes: Uint8Array, needle: readonly number[]): number[] { + const hits: number[] = []; + if (needle.length === 0) return hits; + for (let i = 0; i + needle.length <= bytes.length; i += 1) { + let match = true; + for (let j = 0; j < needle.length; j += 1) { + if (bytes[i + j] !== needle[j]) { + match = false; + break; + } + } + if (match) { + hits.push(i); + i += needle.length - 1; + } + } + return hits; +} + +/** Offsets at which a line begins: 0 and the byte after each LF, CRLF, or lone CR. */ +function lineStarts(bytes: Uint8Array): number[] { + const starts = [0]; + for (let i = 0; i < bytes.length; i += 1) { + if (bytes[i] === LF || (bytes[i] === CR && bytes[i + 1] !== LF)) { + starts.push(i + 1); + } + } + return starts; +} + +/** The offset of the terminator (or EOF) ending the line that begins at `start`. */ +function lineEnd(bytes: Uint8Array, start: number): number { + let i = start; + while (i < bytes.length && bytes[i] !== LF && bytes[i] !== CR) i += 1; + return i; +} + +/** The length of the terminator at `offset`: 2 for CRLF, 1 for LF or CR, 0 at EOF. */ +function terminatorLength(bytes: Uint8Array, offset: number): number { + if (offset >= bytes.length) return 0; + if (bytes[offset] === CR && bytes[offset + 1] === LF) return 2; + return 1; +} + +/** A half-open byte range [start, end). */ +interface ByteSpan { + readonly start: number; + readonly end: number; +} + +/** Every `{…}` span of the file: an opening brace through the nearest later closing brace. */ +function braceContainers(bytes: Uint8Array): ByteSpan[] { + const spans: ByteSpan[] = []; + let i = 0; + while (i < bytes.length) { + if (bytes[i] !== 0x7b) { + i += 1; + continue; + } + let j = i + 1; + while (j < bytes.length && bytes[j] !== 0x7d) j += 1; + if (j >= bytes.length) break; + spans.push({ start: i, end: j + 1 }); + i = j + 1; + } + return spans; +} + +// Code points spelled by number (never as escape spellings in source): +const NBSP = String.fromCharCode(0xa0); // U+00A0 — ECMAScript whitespace +const ZWNBSP = String.fromCharCode(0xfeff); // U+FEFF — ECMAScript whitespace +const LS = String.fromCharCode(0x2028); // U+2028 — ECMAScript line terminator +const PS = String.fromCharCode(0x2029); // U+2029 — ECMAScript line terminator +const NEL = String.fromCharCode(0x85); // U+0085 — neither (14.20) +const ZWSP = String.fromCharCode(0x200b); // U+200B — neither (14.20) +const BACKSLASH = String.fromCharCode(0x5c); + +// --- fragment --------------------------------------------------------------- + +/** Fragment interiors, [name, text]; empty first (the shrink target). */ +const FRAGMENT_INTERIORS: ReadonlyArray<readonly [string, string]> = [ + ["empty", ""], + ["prose", "frag"], + ["a section", '<S id="f">frag.</S>'], + ["an embedding", '{text("a.b")}'], + ["a comment", "{/* c */}"], + ["a nested fragment", "<>nested</>"], + ["a multi-line interior", "\nfrag line\n"], +]; + +function mutateFragment(choices: Choices, bytes: Uint8Array): MutationResult { + const mode = choices.pick([ + "balanced", + "openOnly", + "closeOnly", + "wrapRange", + ] as const); + const offset = choices.intInclusive(0, bytes.length); + if (mode === "balanced") { + const [name, interior] = choices.pick(FRAGMENT_INTERIORS); + return { + bytes: spliceBytes(bytes, offset, 0, utf8(`<>${interior}</>`)), + description: `insert a balanced fragment (${name}) at ${String(offset)}`, + }; + } + if (mode === "openOnly" || mode === "closeOnly") { + const tag = mode === "openOnly" ? "<>" : "</>"; + return { + bytes: spliceBytes(bytes, offset, 0, utf8(tag)), + description: `insert a lone fragment tag ${tag} at ${String(offset)}`, + }; + } + const close = choices.intInclusive(offset, bytes.length); + const opened = spliceBytes(bytes, offset, 0, utf8("<>")); + return { + bytes: spliceBytes(opened, close + 2, 0, utf8("</>")), + description: `wrap bytes [${String(offset)}, ${String(close)}) in a fragment`, + }; +} + +// --- braces ------------------------------------------------------------------ + +/** + * Content rewrites of a `{…}` container at the comment/expression boundary + * (SPEC 2.7, 14.20; T2.3-3, T2.7-4): [name, "wrap", prefix, suffix] keeps the + * content bytes between the two spellings; [name, "replace", text] replaces + * them. Names describe the form; the forms with U+2028 and the whitespace + * singletons carry their code points, so descriptions name rather than + * quote them. + */ +type ContentRewrite = + | readonly [string, "wrap", string, string] + | readonly [string, "replace", string]; + +const CONTENT_REWRITES: readonly ContentRewrite[] = [ + ["the content wrapped in a block comment", "wrap", "/* ", " */"], + ["an expression beside the content", "wrap", "", " 1"], + ["a block comment before the content", "wrap", "/* n */ ", ""], + ["a block comment after the content", "wrap", "", " /* n */"], + ["a line comment ended by U+000A before the content", "wrap", "// n\n", ""], + ["a line comment ended by U+000D before the content", "wrap", "// n\r", ""], + [ + "a run-on line comment (a brace on the commented-out line)", + "wrap", + "// c}\n", + "", + ], + [ + "a line comment ended by U+2028, then a brace and U+000A", + "wrap", + `// c${LS}}\n`, + "", + ], + ["empty braces", "replace", ""], + ["two block comments", "replace", " /* a */ /* b */ "], + ["a line comment reaching the closing brace", "replace", "// c"], + ["U+00A0 alone", "replace", NBSP], + ["U+FEFF alone", "replace", ZWNBSP], + ["U+2028 alone", "replace", LS], + ["U+2029 alone", "replace", PS], + ["U+0085 alone", "replace", NEL], + ["U+200B alone", "replace", ZWSP], +]; + +/** + * Boundary expressions replacing a container's content (SPEC 14.20's + * derivability contract, T14-12; the embedding forms of T2.3-3): well-formed + * expressions that are no embedding, early-error forms, two expressions, and + * syntax failures. + */ +const BOUNDARY_EXPRESSIONS: readonly string[] = [ + "1", + "a, b", + "1 = 2", + "let", + "010", + "await x", + "function(){}", + '(text)("a")', + 'text?.("a")', + 'text("a"), 1', + `te${BACKSLASH}u0078t("a")`, + 'text("a") text("b")', + "text(", + "]", + '<S id="q">in braces</S>', + "<b/>", + "<></>", + "/* unclosed", +]; + +/** Spread attributes inserted on a `<S ` tag (T2.7-3's grammar pair and neighbours). */ +const SPREAD_ATTRIBUTES: readonly string[] = [ + " {...(a, b)}", + " {...a, b}", + " {...a}", + " {...}", + " {... /* c */ a}", + " {...a /* c */}", +]; + +/** Empty-brace insertions: [text, anchor] — on a `<S ` tag or at any offset. */ +const EMPTY_BRACE_INSERTIONS: ReadonlyArray<readonly [string, "tag" | "any"]> = + [ + ["{}", "any"], + ["{ }", "any"], + ["{ /* c */ }", "any"], + ["{// c\n}", "any"], + [" d={}", "tag"], + [" d={ /* c */ }", "tag"], + [" coverage={}", "tag"], + ]; + +/** Tails appended at EOF leaving a brace unbalanced (or a stray closer). */ +const EOF_BRACE_TAILS: readonly string[] = [ + '{text("a")', + "{", + "{/* c", + "{// c}", + "{// c\n", + "}", + '<S id="z" d={', + '<S id="z" d={[A.a]', +]; + +/** The offset just after a drawn `<S ` tag name, or a drawn offset when the file spells none. */ +function drawTagOffset(choices: Choices, bytes: Uint8Array): number { + const tags = findAll(bytes, utf8("<S ")); + return tags.length > 0 + ? choices.pick(tags) + 2 + : choices.intInclusive(0, bytes.length); +} + +function mutateBraces(choices: Choices, bytes: Uint8Array): MutationResult { + const mode = choices.pick([ + "rewriteContent", + "boundaryExpression", + "spreadAttribute", + "emptyBraces", + "unbalancedAtEof", + ] as const); + if (mode === "rewriteContent" || mode === "boundaryExpression") { + let staged = bytes; + let span: ByteSpan; + let seeded = ""; + const spans = braceContainers(bytes); + if (spans.length > 0) { + span = choices.pick(spans); + } else { + const offset = choices.intInclusive(0, bytes.length); + const embedding = utf8('{text("a.b")}'); + staged = spliceBytes(bytes, offset, 0, embedding); + span = { start: offset, end: offset + embedding.length }; + seeded = " (seeded, the file holding no container)"; + } + const content = staged.subarray(span.start + 1, span.end - 1); + if (mode === "rewriteContent") { + const rewrite = choices.pick(CONTENT_REWRITES); + const replaced = + rewrite[1] === "wrap" + ? [...utf8(rewrite[2]), ...content, ...utf8(rewrite[3])] + : utf8(rewrite[2]); + return { + bytes: spliceBytes(staged, span.start + 1, content.length, replaced), + description: `rewrite the container at ${String(span.start)}${seeded}: ${rewrite[0]}`, + }; + } + const expression = choices.pick(BOUNDARY_EXPRESSIONS); + return { + bytes: spliceBytes( + staged, + span.start + 1, + content.length, + utf8(expression), + ), + description: + `replace the content of the container at ${String(span.start)}${seeded} ` + + `with ${JSON.stringify(expression)}`, + }; + } + if (mode === "spreadAttribute") { + const attribute = choices.pick(SPREAD_ATTRIBUTES); + const offset = drawTagOffset(choices, bytes); + return { + bytes: spliceBytes(bytes, offset, 0, utf8(attribute)), + description: `insert the spread attribute ${JSON.stringify(attribute.trim())} at ${String(offset)}`, + }; + } + if (mode === "emptyBraces") { + const [text, anchor] = choices.pick(EMPTY_BRACE_INSERTIONS); + const offset = + anchor === "tag" + ? drawTagOffset(choices, bytes) + : choices.intInclusive(0, bytes.length); + return { + bytes: spliceBytes(bytes, offset, 0, utf8(text)), + description: `insert empty braces ${JSON.stringify(text.trim())} at ${String(offset)}`, + }; + } + const lastCloser = bytes.lastIndexOf(0x7d); + if (lastCloser >= 0 && choices.boolean(0.25)) { + return { + bytes: spliceBytes(bytes, lastCloser, 1, []), + description: `delete the file's last closing brace at ${String(lastCloser)}`, + }; + } + const tail = choices.pick(EOF_BRACE_TAILS); + return { + bytes: spliceBytes(bytes, bytes.length, 0, utf8(tail)), + description: `append ${JSON.stringify(tail)} at EOF (unbalanced braces)`, + }; +} + +// --- esmBlock ---------------------------------------------------------------- + +const ESM_LINE_LEADS: ReadonlyArray<readonly number[]> = [ + utf8("import "), + utf8("export "), +]; + +/** Start offsets of the lines beginning with `import ` or `export `. */ +function esmLineStarts(bytes: Uint8Array): number[] { + return lineStarts(bytes).filter((start) => + ESM_LINE_LEADS.some((lead) => + lead.every((byte, i) => bytes[start + i] === byte), + ), + ); +} + +const SEEDED_DECLARATION = 'import Z from "./A.xspec"'; + +/** + * Comment insertions around a declaration line, [name, text, anchor]: before + * the line (own-line forms), at its start (a comment before the declaration + * on its line), at its end (trailing), or on the line after it with no blank + * line between (SPEC 14.20's block; T3-7's forms). + */ +const ESM_COMMENT_INSERTIONS: ReadonlyArray< + readonly [string, string, "before" | "lineStart" | "lineEnd" | "after"] +> = [ + ["an own-line line comment before it", "// note\n", "before"], + ["an own-line block comment before it", "/* c */\n", "before"], + ["a block comment spanning lines before it", "/* a\n b */\n", "before"], + [ + "a block comment before the declaration on its line", + "/* c */ ", + "lineStart", + ], + ["a trailing line comment", " // note", "lineEnd"], + ["a trailing block comment", " /* c */", "lineEnd"], + [ + "an own-line line comment after it, no blank line between", + "// tail\n", + "after", + ], + [ + "an own-line block comment after it, no blank line between", + "/* tail */\n", + "after", + ], +]; + +/** Terminator rewrites of a declaration line, [name, text] replacing its terminator. */ +const ESM_TERMINATOR_REWRITES: ReadonlyArray<readonly [string, string]> = [ + ["CR", "\r"], + ["CRLF", "\r\n"], + ["U+2028", LS], + ["U+2029", PS], + ["a blank line", "\n\n"], + ["a space (joining the next line)", " "], + ["`;` and LF", ";\n"], +]; + +/** Indentation prefixes, [name, text]. */ +const ESM_INDENTS: ReadonlyArray<readonly [string, string]> = [ + ["one space", " "], + ["three spaces", " "], + ["four spaces", " "], + ["a tab", "\t"], +]; + +/** Second declarations joining or following a block, [name, text]. */ +const ESM_SECOND_DECLARATIONS: ReadonlyArray<readonly [string, string]> = [ + ["a duplicate binding", 'import A from "./A.xspec"'], + ["a fresh binding", SEEDED_DECLARATION], + ["a side-effect-only import", 'import "./A.xspec"'], + ["import attributes", 'import A from "./A.xspec" with { type: "json" }'], + ["an export naming no declaration", "export { nope }"], + ["an export holding JSX", "export const x = <b/>"], + ["a default export", "export default 1"], +]; + +/** Statements at a line start, [name, text]. */ +const ESM_STATEMENTS: ReadonlyArray<readonly [string, string]> = [ + ["a declaration statement", "const x = 1"], + ["an expression statement", "x;"], + ["a bare `let`", "let"], + ["an exported declaration", "export const x = 1"], + ["a statement holding a spec import", 'const y = import("./A.xspec")'], +]; + +function mutateEsmBlock(choices: Choices, bytes: Uint8Array): MutationResult { + const mode = choices.pick([ + "comment", + "terminator", + "indent", + "split", + "join", + "statement", + ] as const); + // Anchor: a drawn declaration line, seeded at a drawn line start when the + // file holds none. + let staged = bytes; + let start: number; + let seeded = ""; + const declarations = esmLineStarts(bytes); + if (declarations.length > 0) { + start = choices.pick(declarations); + } else { + start = choices.pick(lineStarts(bytes)); + staged = spliceBytes(bytes, start, 0, utf8(`${SEEDED_DECLARATION}\n\n`)); + seeded = " (seeded, the file holding no declaration line)"; + } + const end = lineEnd(staged, start); + const terminator = terminatorLength(staged, end); + const nextLine = end + terminator; + const at = `the declaration line at ${String(start)}${seeded}`; + if (mode === "comment") { + const [name, text, anchor] = choices.pick(ESM_COMMENT_INSERTIONS); + const offset = + anchor === "before" || anchor === "lineStart" + ? start + : anchor === "lineEnd" + ? end + : nextLine; + const insert = anchor === "after" && terminator === 0 ? `\n${text}` : text; + return { + bytes: spliceBytes(staged, offset, 0, utf8(insert)), + description: `insert ${name} around ${at}`, + }; + } + if (mode === "terminator") { + if (choices.boolean(0.3)) { + const semicolon = end > start && staged[end - 1] === 0x3b; + return { + bytes: semicolon + ? spliceBytes(staged, end - 1, 1, []) + : spliceBytes(staged, end, 0, [0x3b]), + description: `${semicolon ? "remove" : "append"} the \`;\` of ${at}`, + }; + } + const [name, text] = choices.pick(ESM_TERMINATOR_REWRITES); + return { + bytes: spliceBytes(staged, end, terminator, utf8(text)), + description: `rewrite the terminator of ${at} to ${name}`, + }; + } + if (mode === "indent") { + const [name, text] = choices.pick(ESM_INDENTS); + return { + bytes: spliceBytes(staged, start, 0, utf8(text)), + description: `indent ${at} by ${name}`, + }; + } + if (mode === "split") { + const [name, text] = choices.pick(ESM_SECOND_DECLARATIONS); + return { + bytes: spliceBytes(staged, end, 0, utf8(`\n\n${text}`)), + description: `add ${name} in a separate block after ${at}`, + }; + } + if (mode === "join") { + if (choices.boolean(0.4)) { + // Delete the blank line(s) after the block so the following content + // joins it. + let blankEnd = nextLine; + while (blankEnd < staged.length) { + const lineTerminator = terminatorLength(staged, blankEnd); + if (lineTerminator === 0 || lineEnd(staged, blankEnd) !== blankEnd) { + break; + } + blankEnd += lineTerminator; + } + if (blankEnd > nextLine) { + return { + bytes: spliceBytes(staged, nextLine, blankEnd - nextLine, []), + description: `delete the blank line(s) after ${at}`, + }; + } + } + const [name, text] = choices.pick(ESM_SECOND_DECLARATIONS); + return { + bytes: spliceBytes(staged, end, 0, utf8(`\n${text}`)), + description: `add ${name} on the line after ${at} (one block)`, + }; + } + const [name, text] = choices.pick(ESM_STATEMENTS); + if (choices.boolean(0.6)) { + return { + bytes: spliceBytes(staged, end, 0, utf8(`\n${text}`)), + description: `add ${name} on the line after ${at} (one block)`, + }; + } + const lineStart = choices.pick(lineStarts(staged)); + return { + bytes: spliceBytes(staged, lineStart, 0, utf8(`${text}\n`)), + description: `insert ${name} at the line start ${String(lineStart)}${seeded}`, + }; +} + type Mutator = ( choices: Choices, bytes: Uint8Array, @@ -461,8 +1402,28 @@ const MUTATION_KINDS: ReadonlyArray<readonly [number, Mutator]> = [ [2, (c, b) => mutateTruncate(c, b)], [2, (c, b) => mutateShuffle(c, b)], [2, (c, b) => mutateGarbage(c, b)], + [2, (c, b) => mutateFragment(c, b)], + [3, (c, b) => mutateBraces(c, b)], + [3, (c, b) => mutateEsmBlock(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 @@ -472,43 +1433,69 @@ export interface FuzzTrial { readonly files: ReadonlyArray<readonly [string, Uint8Array]>; /** Human-readable description of each applied mutation. */ readonly mutations: readonly string[]; - /** Drawn command invocations, run after the fixed `build --json` arm. */ + /** + * Drawn `COMMAND_MENU` forms, run after the fixed `build --json` arm — a + * review composite as the invocations of its arm ({@link armSteps}). + */ readonly commands: ReadonlyArray<readonly string[]>; } +/** Mutations applied per trial: 1 + a draw in [0, MAX_MUTATIONS_PER_TRIAL - 1]. */ +export const MAX_MUTATIONS_PER_TRIAL = 3; + +/** + * Menu forms drawn per trial: 2, then up to this many while `listOf`'s + * continue draws (0.8 each) hold. Raised from 4 with the review composites + * (41 forms), so the fixed seeds draw every form at P8_RUNS_PER_SEED; the + * 54 forms since the previews and the section form of `move` are drawn at + * it too (162 picks), the count unchanged. Changing it moves every later + * trial of a seed, the giant-nesting floor's included (the fixed-seed draw + * guard re-checks both), where a menu form added or removed moves no + * mutation draw. + */ +const MAX_COMMANDS_PER_TRIAL = 6; + /** 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")), ]), ); const mutations: string[] = []; - const mutationCount = 1 + choices.intInclusive(0, 2); + const mutationCount = + 1 + choices.intInclusive(0, MAX_MUTATIONS_PER_TRIAL - 1); for (let i = 0; i < mutationCount; i += 1) { const path = choices.pick(MUTATION_TARGETS); - const 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}`); } const commands = listOf((c: Choices) => c.pick(COMMAND_MENU), { min: 2, - max: 4, + max: MAX_COMMANDS_PER_TRIAL, })(choices); return { files: [...files.entries()], mutations, commands }; }; -/** Counterexample rendering: the mutation log and the drawn commands. */ +/** + * Counterexample rendering: the mutation log and the drawn commands, a + * review composite as the list of every invocation its arm runs + * ({@link armSteps}; `<item-id>` stands for the item its read yields, else + * {@link ABSENT_ITEM_ID}). + */ export function renderFuzzTrial(trial: FuzzTrial): string { return JSON.stringify({ mutations: trial.mutations, - commands: trial.commands.map((argv) => argv.join(" ")), + commands: trial.commands.map((form) => { + const steps = armSteps(form).map((step) => step.argv.join(" ")); + return steps.length === 1 ? steps[0] : steps; + }), }); } @@ -518,42 +1505,97 @@ export function renderFuzzTrial(trial: FuzzTrial): string { /** * Per-invocation hang guard for fuzz runs. Purely the H-8 guard bounding the * observation "the command terminates" — never an assertion input beyond - * that (H-10); generously above any plausible parse time for these staged - * inputs (≤ ~100 KiB per file), and small enough that a falsified - * termination clause shrinks within the test budget. + * that (H-10) — dimensioned to the staged answer scale (H-11), not to parse + * time alone: the menu's answer surfaces emit documents far larger than + * their inputs. The largest answer a menu form admits over a P-8 draw is + * `view specs/B.mdx --text` over `specs/B.mdx` carrying two appended + * depth-4096 balanced section towers (B's own maximum: the LF → U+2028 + * rewrite that grows P-11's maximum leaves B unparseable, its import line + * swallowing the file). Measured against the built product (Phase 10's, at + * c62f451) through `runProduct` on a 4-core machine, alone, under the + * unprivileged namespace: that invocation emits 25.0 MB and terminates in + * 3.8–5.0 s (two runs); every other menu form over the generator maximum + * (`specs/A.mdx` or `specs/B.mdx` carrying those towers, with or without + * the U+2028 rewrite) — `view` 23.7 MB in at most 2.4 s, every other form + * in at most 2.1 s, `inventory` and `version` under 0.4 s — and any form + * over an unmutated-scale draw ~0.5 s — the review composites' steps among + * them: a session exists only over a valid workspace, which no tower draw + * leaves (its nested `g` ids break 1.3), so over the generator maximum + * `review create --json` answers the 13.3 gate's report at `check --json`'s + * 4.7 MB and every step naming the session exits 2, each in at most 2.1 s + * (re-measured with the composites), and the same holds of `rename` and + * `move` performed or previewed in either form and of `show --json`: each + * refuses or answers the gate's report there (the 4.7 MB JSON form, the + * 1.8 MB human one) in at most 2.0 s, and under 0.6 s over a TypeScript + * tower (re-measured with the previews and the section form). 60 s is 12× + * the `view --text` + * maximum: the ≥ 4× margin a conforming product is owed over its measured + * answer time, plus headroom for slower CI runners and a slower product, + * and still more than twice the 25.4 s of the largest answer any + * shared-generator draw admits (P-11's `view --text` over `specs/A.mdx` + * under that rewrite, 125.8 MB), which no menu form requests. So a + * conforming product is never killed while + * still emitting its answer (H-11: an exhausted harness limit is a harness + * defect, never a diagnosed product failure), and a genuinely hanging one + * costs one guard per diagnosis, reported unshrunk (`runFuzzCommand`). + * Re-measure with a temporary self-test staging `FUZZ_BASE_FILES` with + * `sectionTowerSource(4096, true)` appended twice to the viewed file (see + * AGENTS.md) whenever a generator bound or a menu form's surface moves. */ -const FUZZ_COMMAND_TIMEOUT_MS = 10_000; +const FUZZ_COMMAND_TIMEOUT_MS = 60_000; /** - * Run one command over the fuzzed workspace, converting the hang-guard and - * runaway-output kills — exactly those — into diagnosed assertion failures: - * P-8's first clause is that every command terminates. Anything else thrown - * by the driver stays a harness error (H-8). + * The two subprocess-driver guards a fuzz command run is held to (P-8's + * `runFuzzCommand`, P-11's `runAvailabilityCommand`): the per-invocation + * hang guard — by default the property module's `FUZZ_COMMAND_TIMEOUT_MS` — + * and the output-capture cap — by default the driver's + * `DEFAULT_MAX_OUTPUT_BYTES` (H-11). The registered P-8 and P-11 bodies pass + * neither; S-8's self-test (test/self/s8-answer-scale-capacity.test.ts) + * lowers both, to pin which kill is a diagnosed failure of the termination + * clause and which a harness error. */ -async function runFuzzCommand( +export interface FuzzRunGuards { + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; +} + +/** + * Run one command over the fuzzed workspace, converting the hang-guard kill + * — exactly that — into a diagnosed assertion failure: P-8's first clause is + * that every command terminates (S-3: hangs are reported as failures). An + * exhausted capture limit (`ProductRunOutputOverflowError`) is never + * converted: it propagates out of the property body as a harness error that + * `checkProperty` reports with the seed — never a falsified property (H-11) + * — and so does anything else the driver throws (H-8). + */ +export async function runFuzzCommand( product: ProductBinding, workspace: TestWorkspace, argv: readonly string[], + guards: FuzzRunGuards = {}, ): Promise<RunResult> { try { return await runProduct(product, { cwd: workspace.root, argv, - timeoutMs: FUZZ_COMMAND_TIMEOUT_MS, + timeoutMs: guards.timeoutMs ?? FUZZ_COMMAND_TIMEOUT_MS, + maxOutputBytes: guards.maxOutputBytes, }); } catch (error) { + // The hang-guard kill is reported unshrunk (`shrinkable: false`), as + // P-11's is: a shrink candidate can re-observe it only by waiting out + // the guard again — one full guard per candidate — so shrinking's + // execution budget would stop bounding the body's wall clock (the + // entry's `timeoutMs`). The drawn trial — at most three mutations and, + // after the staging build, 19 invocations: the fixed `build --json` and + // six drawn forms, a review composite three at most — is the reported + // counterexample, and its seed replays it (H-10). if (error instanceof ProductRunTimeoutError) { fail( `P-8: every command must terminate on fuzzed input (TEST-SPEC §16 P-8; ` + `SPEC 12.0), but the invocation was still running when the harness's ` + `hang guard killed it — ${error.message}`, - ); - } - if (error instanceof ProductRunOutputOverflowError) { - fail( - `P-8: every command must terminate on fuzzed input with bounded output ` + - `(TEST-SPEC §16 P-8; SPEC 12.0), but the invocation emitted unbounded ` + - `output until the harness's runaway-output guard killed it — ${error.message}`, + { shrinkable: false }, ); } throw error; @@ -561,9 +1603,10 @@ async function runFuzzCommand( } /** - * The 12.0 exit-code partition for a run without `--json`: no signal death, - * exit code exactly 0, 1, or 2. (`assertJsonOutputConvention` asserts the - * same partition plus the stdout contract for `--json` runs.) + * The 12.0 exit-code partition for a run without JSON output in effect: no + * signal death, exit code exactly 0, 1, or 2. (`assertJsonOutputConvention` + * asserts the same partition plus the stdout contract for the runs with + * JSON output in effect, `jsonOutputInEffect`.) */ function assertExitPartition(result: RunResult, context: string): void { if (result.signal !== null) { @@ -586,27 +1629,88 @@ function describeCommand(argv: readonly string[]): string { return `\`xspec ${argv.join(" ")}\``; } +/** + * The JSON-only surfaces of SPEC 12.0 (10.7, 11, 12.6) — a single JSON + * document their only output form, with or without `--json` — by command + * word: the five query surfaces of 11 (`query`, `occurrences`, `view`, `at`, + * `inventory`) and `version` (12.6). `review export` (10.7) is the one + * JSON-only subcommand form ({@link JSON_ONLY_REVIEW_SUBCOMMANDS}). + */ +const JSON_ONLY_COMMANDS: ReadonlySet<string> = new Set([ + "query", + "occurrences", + "view", + "at", + "inventory", + "version", +]); + +/** `review`'s JSON-only subcommands (SPEC 10.7: `export`). */ +const JSON_ONLY_REVIEW_SUBCOMMANDS: ReadonlySet<string> = new Set(["export"]); + +/** + * Whether JSON output is in effect for an invocation, read as SPEC 12.0 + * reads it: a `--json` token read as a flag — not another flag's value + * (`VALUE_FLAGS`, arity fixed by name), not after the `--` that ends flag + * reading — or a JSON-only surface, named by the first non-flag tokens once + * flags, their values, and `--` are removed: the command word and, for + * `review`, its subcommand (12.0's invocation grammar). Exported for the + * self-test that pins this reading (test/self/p8-fixed-seed-draws.test.ts). + */ +export function jsonOutputInEffect(argv: readonly string[]): boolean { + const words: string[] = []; + let flagsEnded = false; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] as string; + if (!flagsEnded && token.startsWith("--")) { + if (token === "--") { + flagsEnded = true; + } else if (token === "--json") { + return true; + } else if (VALUE_FLAGS.has(token.slice(2))) { + index += 1; + } + continue; + } + words.push(token); + } + const [command, subcommand] = words; + if (command === undefined) return false; + if (JSON_ONLY_COMMANDS.has(command)) return true; + return ( + command === "review" && + subcommand !== undefined && + JSON_ONLY_REVIEW_SUBCOMMANDS.has(subcommand) + ); +} + /** * Run one command with the P-8 assertions: termination (via - * `runFuzzCommand`), the 12.0 exit partition, and — when the invocation - * carries `--json` — the never-a-partial-JSON-document contract. For `build` - * invocations the modifies-nothing arm rides along: on a non-zero exit the - * whole workspace tree must be byte-identical around the run (SPEC 12.1). + * `runFuzzCommand`), the 12.0 exit partition, and — whenever JSON output is + * in effect ({@link jsonOutputInEffect}: `--json` given, or a JSON-only + * surface with or without it) — the never-a-partial-JSON-document contract, + * the 12.7 error document on exit 2 included. For `build` invocations the + * modifies-nothing arm rides along: on a non-zero exit the whole workspace + * tree must be byte-identical around the run (SPEC 12.1). Answers the exit + * code and, when JSON output is in effect, the parsed document. `step` + * places an invocation within a review composite's arm, for the diagnosis. */ async function runFuzzArm( product: ProductBinding, workspace: TestWorkspace, argv: readonly string[], trial: FuzzTrial, -): Promise<void> { + step = "", +): Promise<FuzzArmOutcome> { const context = - `P-8 ${describeCommand(argv)} over the fuzzed workspace ` + + `P-8 ${describeCommand(argv)}${step} over the fuzzed workspace ` + `(mutations: ${JSON.stringify(trial.mutations)})`; const isBuild = argv[0] === "build"; const before = isBuild ? await snapshotDirectory(workspace.root) : undefined; const result = await runFuzzCommand(product, workspace, argv); - if (argv.includes("--json")) { - assertJsonOutputConvention(result, context); + let document: unknown; + if (jsonOutputInEffect(argv)) { + document = assertJsonOutputConvention(result, context); } else { assertExitPartition(result, context); } @@ -620,6 +1724,67 @@ async function runFuzzArm( `byte-for-byte as they were (SPEC 12.1; P-8)`, ); } + return { exitCode: result.exitCode, document, context }; +} + +/** + * What one fuzz arm observed: its exit code, the parsed JSON document when + * JSON output is in effect (else `undefined`), and its diagnosis context. + */ +interface FuzzArmOutcome { + readonly exitCode: number | null; + readonly document: unknown; + readonly context: string; +} + +/** + * The item a composite's read yields (SPEC 10.7), decoded through the review + * adapter (H-3: a document that lacks the item's information fails loudly — + * a diagnosed failure, never a silent fallback): from `review next --json`, + * the item it reports unless the session is fully resolved; from `review + * status --json`, the first item in item order. A read that exits non-zero + * — no session, a corrupt one, the 13.3 gate over a failing workspace — + * yields none, as does an empty or fully-resolved session. + */ +function yieldedItemId( + read: "next" | "status", + outcome: FuzzArmOutcome, +): string | undefined { + if (outcome.exitCode !== 0) return undefined; + if (read === "next") { + return decodeNextReport(outcome.document, outcome.context).item?.id; + } + return decodeSessionStatusReport(outcome.document, outcome.context).items[0] + ?.id; +} + +/** + * Run one drawn menu form: its arm's steps ({@link armSteps}) in order, each + * under the P-8 assertions, a composite's read filling the item slot of the + * step after it. + */ +async function runFuzzForm( + product: ProductBinding, + workspace: TestWorkspace, + form: readonly string[], + trial: FuzzTrial, +): Promise<void> { + const steps = armSteps(form); + let itemId = ABSENT_ITEM_ID; + for (const [index, step] of steps.entries()) { + const argv = step.argv.map((token) => + token === ITEM_ID_SLOT ? itemId : token, + ); + const place = + steps.length === 1 + ? "" + : ` (step ${String(index + 1)} of ${String(steps.length)} of the ` + + `review composite for the drawn \`${form.join(" ")}\`)`; + const outcome = await runFuzzArm(product, workspace, argv, trial, place); + if (step.yieldsItem !== undefined) { + itemId = yieldedItemId(step.yieldsItem, outcome) ?? ABSENT_ITEM_ID; + } + } } /** The P-8 property body for one trial (see the module header). */ @@ -627,8 +1792,10 @@ async function runFuzzTrial( product: ProductBinding, trial: FuzzTrial, ): Promise<void> { + // S-9: the base files are the harness's constants, staged afresh per + // trial — as records from the second trial on too (`FUZZ_BASE_RECORDS`). const workspace = await TestWorkspace.create({ - files: Object.fromEntries(BASE_FILES), + files: fuzzBaseWorkspaceFiles(), }); try { // Staging: the base workspace is SPEC-valid; a successful build leaves @@ -640,11 +1807,14 @@ async function runFuzzTrial( "state for the modifies-nothing arm, SPEC 12.1)", ); for (const [path, bytes] of trial.files) { - await workspace.file(path, bytes); + // S-9: a mutated file's well-formedness is undeclared (fuzz) — an + // `.mdx` source's derivability and a code source's or the + // configuration's TypeScript well-formedness alike. + await workspace.file(path, bytes, { mdx: "unchecked", ts: "unchecked" }); } - await runFuzzArm(product, workspace, ["build", "--json"], trial); - for (const argv of trial.commands) { - await runFuzzArm(product, workspace, argv, trial); + await runFuzzArm(product, workspace, FIXED_BUILD_ARM, trial); + for (const form of trial.commands) { + await runFuzzForm(product, workspace, form, trial); } } finally { await workspace.dispose(); @@ -654,17 +1824,39 @@ async function runFuzzTrial( // --------------------------------------------------------------------------- // The registered fuzz test +/** + * P-8's registered trials per seed of the fixed seed set (E-5; P-8 passes no + * `seeds`, so `DEFAULT_PROPERTY_SEEDS` apply). Each seed's trials are one + * sequential PRNG stream, so the trials the CI run stages are exactly + * `drawFixedSeedTrials(genFuzzTrial, P8_RUNS_PER_SEED)` — which the fixed-seed + * draw guard (test/self/p8-fixed-seed-draws.test.ts) replays, asserting that + * P-8's own draws stage the giant-nesting floor (TEST-SPEC §16 P-8) and draw + * every {@link COMMAND_MENU} form. Lowering this, or a generator or menu + * change that moves the draws, must keep that guard green. + */ +export const P8_RUNS_PER_SEED = 12; + const P_8 = defineProductTest({ id: "P-8", title: - "fuzz: over byte-mutated MDX/TS/config (invalid UTF-8, BOMs, giant nesting, " + - "pathological line terminators), every command terminates, never emits a " + + "fuzz: over byte-mutated MDX/TS/config (fragments, brace content at the " + + "2.7/14.20 boundaries, ESM-block mutations, invalid UTF-8, BOMs, giant " + + "nesting, pathological line terminators), every command terminates, never emits a " + "partial JSON document under --json, always exits 0, 1, or 2, and failing " + "`build`s modify nothing (SPEC 12.0, 12.1; TEST-SPEC §16 P-8)", // Wall-clock hang guard only (H-10): three fixed seeds (E-5), one staging - // build plus a 3–5 command sweep with per-arm snapshots per trial, plus - // the shrink budget on falsification. - timeoutMs: 420_000, + // build plus the fixed `build --json` arm and 2–6 drawn forms (a review + // composite up to three invocations) with per-arm snapshots per trial, + // plus the shrink budget on falsification. A conforming sweep runs ~123 s + // against the built product on a 4-core machine (alone, under the + // unprivileged namespace; ~3.4 s a trial, 162 picks of the 54 menu forms + // running 210 drawn-form invocations); a hang's diagnosis adds one + // per-invocation guard (`FUZZ_COMMAND_TIMEOUT_MS`, 60 s), unshrunk — + // ~185 s in all — and a falsification at most the 100 shrink executions, + // each a trial at most as large as the falsified one (~340 s at the mean + // trial) — ~465 s in all. This budget leaves CI runners 3× headroom over + // the hang's diagnosis and keeps the shrink budget inside it. + timeoutMs: 600_000, run: async (product) => { await checkProperty( "P-8 parser robustness", @@ -672,7 +1864,11 @@ const P_8 = defineProductTest({ async (trial) => { await runFuzzTrial(product, trial); }, - { runs: 12, maxShrinkExecutions: 100, render: renderFuzzTrial }, + { + runs: P8_RUNS_PER_SEED, + maxShrinkExecutions: 100, + render: renderFuzzTrial, + }, ); }, }); diff --git a/test/suite/registry/section-16-p9.ts b/test/suite/registry/section-16-p9.ts index 4413e675..17a4c0b2 100644 --- a/test/suite/registry/section-16-p9.ts +++ b/test/suite/registry/section-16-p9.ts @@ -117,24 +117,51 @@ import { decodeSessionStatusReport, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, fail } from "../../helpers/assertions.js"; -import type { Choices, Gen } from "../../helpers/property.js"; +import type { Choices, DrawSource, 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 { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; -import { TestWorkspace } from "../../helpers/workspace.js"; +import { TestWorkspace, mdxPathsOf } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. Audit // sessions need no code group — they derive `subtree-coherence` items only. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// A TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript +// and timing clauses), well-formed: every trial stages it afresh, from the +// second trial on after the body's first product invocation — an initial +// file S-7's sweep never reaches, so the ledger self-test judges it before +// any product exists. +const SPECS_ONLY_CONFIG = stagedTs( + "P-9 xspec.config.ts — one spec group", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +/** + * S-9's fixed TypeScript form-vector set (TEST-SPEC 17 S-9; the §16 + * preamble): the property's one configuration file, the record above — judged + * as a record by test/self/s9-staged-sources.test.ts too, and here beside + * every generated configuration and code source + * (test/self/s9-typescript-well-formedness.test.ts); P-9 composes no code + * source. + */ +export const P9_TS_FORM_VECTORS: ReadonlyArray< + readonly [name: string, path: string, source: string | Uint8Array] +> = [ + [ + "P-9 configuration (SPECS_ONLY_CONFIG)", + "xspec.config.ts", + SPECS_ONLY_CONFIG.source, + ], +]; // --------------------------------------------------------------------------- // Workspace model @@ -1008,7 +1035,10 @@ async function executeOp( case "edit": { applyP9Edit(model, op.edit); for (const [rel, contents] of Object.entries(renderP9Workspace(model))) { - await workspace.file(rel, contents); + // S-9: a draw's source, judged per draw by the property runner + // (stagedP9Sources) — `per-draw` exempts it from the builder's + // undeclared-staging guard. + await workspace.file(rel, contents, { mdx: "per-draw" }); } await buildOk( product, @@ -1128,17 +1158,149 @@ async function executeOp( } } +// --------------------------------------------------------------------------- +// S-9's fixed form-vector set (TEST-SPEC 17 S-9; the §16 preamble): the +// generator's forms enumerated over a fixed model — every anchor character +// and the whole prose interior alphabet, both files, sections nested to the +// depth cap with the child cap reached, a leaf and a childless top-level +// section — and, after each edit class in turn (an equal-text prose edit +// taking the `x` suffix, sections added under a root and under a section, +// a nested and a top-level section deleted), the re-rendered file, exactly +// as `stagedP9Sources` derives a draw's sources. + +/** A prose line as `proseText` composes it: an anchor, then the interior. */ +function formProse( + anchor: (typeof ANCHOR_CHARS)[number], + interior: string, +): string { + return anchor + interior; +} + +/** The whole interior alphabet, spelled once each. */ +const P9_INTERIOR_ALPHABET = PROSE_REST.map(([, character]) => character).join( + "", +); + +const P9_FORM_MODEL: P9WorkspaceModel = { + files: [ + { + prose: formProse("a", P9_INTERIOR_ALPHABET), + sections: [ + { + seg: "s0", + prose: formProse("b", "aberzK0"), + children: [ + { + seg: "s1", + prose: formProse("k", "9 .,-:a"), + children: [ + { seg: "s2", prose: formProse("z", ""), children: [] }, + ], + }, + { seg: "s3", prose: formProse("n", " a"), children: [] }, + ], + }, + { seg: "s4", prose: formProse("d", "a"), children: [] }, + { seg: "s5", prose: formProse("p", ".a"), children: [] }, + ], + nextSeg: 6, + }, + { + prose: formProse("w", "a-b"), + sections: [{ seg: "s0", prose: formProse("0", "a"), children: [] }], + nextSeg: 1, + }, + ], +}; + +/** Every edit class the generator draws, applied in sequence to the model. */ +const P9_FORM_EDITS: readonly P9Edit[] = [ + { kind: "editProse", node: "specs/A.mdx", text: formProse("7", "ab") }, + // The same text as the current prose: the edit takes the `x` suffix. + { kind: "editProse", node: "specs/A.mdx#s0.s1.s2", text: formProse("z", "") }, + { + kind: "addSection", + parent: "specs/B.mdx", + seg: "s1", + prose: formProse("a", ":"), + }, + { + kind: "addSection", + parent: "specs/A.mdx#s4", + seg: "s6", + prose: formProse("b", ","), + }, + { kind: "deleteSection", node: "specs/A.mdx#s0.s1" }, + { kind: "deleteSection", node: "specs/A.mdx#s5" }, +]; + +function p9FormVectors(): ReadonlyArray< + readonly [name: string, source: string] +> { + const model = structuredClone(P9_FORM_MODEL); + const vectors: (readonly [string, string])[] = Object.entries( + renderP9Workspace(model), + ).map(([path, source]): readonly [string, string] => [ + `initial rendering of ${path}`, + source, + ]); + P9_FORM_EDITS.forEach((edit, index) => { + applyP9Edit(model, edit); + const path = filePath( + strictFileIndexOf( + model, + edit.kind === "addSection" ? edit.parent : edit.node, + ), + ); + vectors.push([ + `after edit ${String(index + 1)} (${describeEdit(edit)}): ${path}`, + renderP9Workspace(model)[path]!, + ]); + }); + return vectors; +} + +/** The fixed form-vector set of the P-9 rendering (S-9): name and source. */ +export const P9_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = p9FormVectors(); + +/** + * S-9's per-draw check (helpers/property.ts `drawSources`): the initial + * workspace and, after each edit operation, the re-rendered files — the + * model evolved exactly as runP9Op evolves it (the session operations touch + * no source). + */ +function stagedP9Sources(trial: P9Trial): DrawSource[] { + const model = structuredClone(trial.initial); + const sources: DrawSource[] = Object.entries(renderP9Workspace(model)); + trial.ops.forEach((op, index) => { + if (op.kind !== "edit") return; + applyP9Edit(model, op.edit); + for (const [rel, contents] of Object.entries(renderP9Workspace(model))) { + sources.push([rel, contents, `after op ${String(index + 1)} (edit)`]); + } + }); + return sources; +} + /** One trial: stage, create, run the op sequence, sweep after every step. */ async function runP9Trial( product: ProductBinding, trial: P9Trial, ): Promise<void> { const model = structuredClone(trial.initial); + const files = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + ...renderP9Workspace(model), + }; + // S-9: the draw's sources, judged by the property runner before the body + // saw them (`stagedP9Sources` above) — declared per draw, as every initial + // `.mdx` file a trial stages after the body's first product invocation + // must be (helpers/workspace.ts). const workspace = await TestWorkspace.create({ - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - ...renderP9Workspace(model), - }, + files, + mdx: { perDraw: mdxPathsOf(files) }, }); try { await buildOk( @@ -1194,7 +1356,12 @@ const P_9 = defineProductTest({ async (trial) => { await runP9Trial(product, trial); }, - { runs: 3, maxShrinkExecutions: 50, render: renderP9Trial }, + { + runs: 3, + maxShrinkExecutions: 50, + render: renderP9Trial, + drawSources: stagedP9Sources, + }, ); }, }); diff --git a/test/suite/registry/section-2.1.ts b/test/suite/registry/section-2.1.ts index 8d0d63cc..37aee49c 100644 --- a/test/suite/registry/section-2.1.ts +++ b/test/suite/registry/section-2.1.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §2.1 (imports) — SUITE-06: T2.1-1 … T2.1-5. +// TEST-SPEC §2.1 (imports) — SUITE-06: T2.1-1 … T2.1-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), @@ -19,44 +19,91 @@ // support.ts byteWindow); every other staged construct lies beyond the // following blank line, outside the widened window. -import type { Finding } from "../../helpers/adapters/index.js"; +import type { Finding, ViewNode } from "../../helpers/adapters/index.js"; import { DEPENDENCY_EDGE_KINDS, decodeEdgesReport, + decodeNodeReport, + decodeViewReport, + renderPathValue, } from "../../helpers/adapters/index.js"; -import { fail } from "../../helpers/assertions.js"; +import { + assertBytesEqual, + assertFileBytes, + fail, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertSameJson, buildFindings, buildOk, byteWindow, + expectFindingFreeReport, runJson, + stageBesideRoot, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. Files -// outside `specs/` (the exists-but-undiscovered arm) belong to no group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// outside `specs/` (the exists-but-undiscovered arm) belong to no group. A +// staged-source record (S-9's timing clause): the arm workspaces of T2.1-2, +// T2.1-3, and T2.1-5 after each body's first are created after its first +// invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T2.1-2/T2.1-3/T2.1-5 xspec.config.ts — the specs-only configuration of every arm workspace", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +// The same plus one code group whose glob matches `.mdx` files under `docs/` +// (SPEC 7.2): a file so matched is a discovered code source and no spec +// source, the target class of T2.1-2's code-group-only arm (2.1; T11.4-4's +// unavailable view target). That arm's workspace is created after the +// body's first invocation: a staged-source record (S-9's timing clause). +const SPECS_AND_DOCS_CODE_CONFIG = stagedTs( + "T2.1-2 xspec.config.ts — one spec group and the `docs` code group globbing `.mdx` names", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + docs: ["docs/**/*.mdx"] + } +}) +`, +); + +// The escape character, built from its code point so that no tool layer +// decodes the six-character escape spellings staged below on their way into +// the file (the pattern of section-1.4.ts). +const BACKSLASH = String.fromCodePoint(0x5c); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, + config: string | StagedTs = SPECS_ONLY_CONFIG, ): 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); @@ -75,9 +122,14 @@ async function withWorkspace<T>( // exact-count assertion. const IMPORTING_FILE_REST = '\n\n<S id="alpha">\nAlpha behavior.\n</S>\n'; -// A valid imported module for arms where the target file legitimately exists. +// A valid imported module for arms where the target file legitimately +// exists — a staged-source record, since T2.1-2's and T2.1-3's later arms +// stage it after their bodies' first invocations (S-9's timing clause). const VALID_BASE_FILES = { - "specs/BASE.mdx": '<S id="core">\nCore behavior.\n</S>\n', + "specs/BASE.mdx": stagedMdx( + "T2.1-2/T2.1-3 specs/BASE.mdx", + '<S id="core">\nCore behavior.\n</S>\n', + ), } as const; /** One invalid-import arm: a workspace differing only in its import line. */ @@ -87,7 +139,46 @@ interface InvalidImportArm { /** The offending import statement, staged at the very start of the file. */ readonly importLine: string; /** Files staged beside the importing file and the configuration. */ - readonly extraFiles: Readonly<Record<string, string>>; + readonly extraFiles: Readonly<Record<string, InitialFileContents>>; + /** + * Configuration override (defaults to SPECS_ONLY_CONFIG) — a staged-source + * record, as every arm workspace after a body's first is created after + * its first invocation (S-9's timing clause). + */ + readonly config?: StagedTs; + /** + * Files staged OUTSIDE the workspace root, at paths relative to the root's + * parent directory (support.ts stageBesideRoot) — the above-root arm's real + * `outside/BASE.mdx`, which resolution must never reach (SPEC 2.1). + */ + readonly outsideFiles?: Readonly<Record<string, string>>; +} + +/** An invalid-import arm with its staged `specs/A.mdx` as a record. */ +interface InvalidImportStaging { + readonly arm: InvalidImportArm; + /** `arm.importLine + IMPORTING_FILE_REST`, the file the arm stages. */ + readonly source: StagedMdx; +} + +/** + * Pair each arm of `testId` with its importing file as a staged-source + * record, composed once at module load — the arms' workspaces (all but a + * body's first) are created after the body's first product invocation, so + * S-9's timing clause makes each a ledger record; the whole table converts + * uniformly. + */ +function invalidImportStagings( + testId: string, + arms: readonly InvalidImportArm[], +): readonly InvalidImportStaging[] { + return arms.map((arm) => ({ + arm, + source: stagedMdx( + `${testId} ${arm.name} specs/A.mdx`, + arm.importLine + IMPORTING_FILE_REST, + ), + })); } /** @@ -97,16 +188,17 @@ interface InvalidImportArm { */ async function runInvalidImportArm( product: ProductBinding, - arm: InvalidImportArm, + { arm, source }: InvalidImportStaging, testId: string, ): Promise<void> { const context = `${testId} \`build --json\` over ${arm.name}`; await withWorkspace( { ...arm.extraFiles, - "specs/A.mdx": arm.importLine + IMPORTING_FILE_REST, + "specs/A.mdx": source, }, async (workspace) => { + await stageBesideRoot(workspace, arm.outsideFiles ?? {}); const findings = await buildFindings(product, workspace, context); assertConditionCounts(findings, { "14.15": 1 }, context); assertFindingLocated( @@ -115,6 +207,7 @@ async function runInvalidImportArm( `${context}: the 14.15 finding`, ); }, + arm.config, ); } @@ -215,7 +308,10 @@ const INVALID_SPECIFIER_ARMS: readonly InvalidImportArm[] = [ importLine: 'import EXTRA from "../docs/EXTRA.xspec"', extraFiles: { ...VALID_BASE_FILES, - "docs/EXTRA.mdx": '<S id="extra">\nOutside every spec group.\n</S>\n', + "docs/EXTRA.mdx": stagedMdx( + "T2.1-2 docs/EXTRA.mdx matched by no spec group", + '<S id="extra">\nOutside every spec group.\n</S>\n', + ), }, }, { @@ -223,8 +319,56 @@ const INVALID_SPECIFIER_ARMS: readonly InvalidImportArm[] = [ importLine: 'import TYPO from "./typo.xspec"', extraFiles: VALID_BASE_FILES, }, + { + // `docs/EXTRA.mdx` is matched only by the code group `docs` (SPEC 7.2): + // a discovered code source, never a spec source. Its content is + // well-formed TypeScript, so the code source itself contributes no + // finding and the import's 14.15 stands alone. The record carries both + // S-9 declarations — the path is an `.mdx` path and a code source whose + // name the TypeScript default does not reach — and the arm's workspace + // is created after the body's first invocation (S-9's timing clause). + name: "a specifier designating an `.mdx` file matched only by a code group (a discovered code source)", + importLine: 'import EXTRA from "../docs/EXTRA.xspec"', + extraFiles: { + ...VALID_BASE_FILES, + "docs/EXTRA.mdx": stagedMdx( + "T2.1-2 docs/EXTRA.mdx matched only by a code group", + "export {};\n", + "well-formed", + "well-formed", + ), + }, + config: SPECS_AND_DOCS_CODE_CONFIG, + }, + { + // From `specs/` (depth 1) the two `..` segments reach depth -1: the + // ascent passes above the workspace root, so the specifier designates + // nothing (SPEC 2.1) although the root's parent really holds + // `outside/BASE.mdx` — the discriminator against filesystem resolution. + name: "a specifier whose ascent passes above the workspace root, the root's parent holding a real `outside/BASE.mdx`", + importLine: 'import BASE from "../../outside/BASE.xspec"', + extraFiles: VALID_BASE_FILES, + outsideFiles: { + "outside/BASE.mdx": '<S id="core">\nCore behavior, outside.\n</S>\n', + }, + }, + { + // The six-character escape of `A` in the name segment is read verbatim + // (SPEC 2.4): the segment spells a name containing the escape character, + // which no discovered path spells. `specs/BASE.mdx` exists, so a product + // interpreting the escape resolves the import, builds clean, and fails + // the arm. + name: `a specifier spelled with an escape sequence ("./B${BACKSLASH}u0041SE.xspec"), read verbatim`, + importLine: `import BASE from "./B${BACKSLASH}u0041SE.xspec"`, + extraFiles: VALID_BASE_FILES, + }, ]; +const INVALID_SPECIFIER_STAGINGS = invalidImportStagings( + "T2.1-2", + INVALID_SPECIFIER_ARMS, +); + // T2.1-2, positive arm: `../` resolves against the importing file's // directory. Run from the workspace root, `../BASE.xspec` resolves correctly // only relative to `specs/sub/` — a product resolving against the working @@ -239,10 +383,56 @@ const PARENT_SPECIFIER_IMPORTER = [ "", ].join("\n"); +// T2.1-2, lexical positives (SPEC 2.1: resolution is lexical — a `.` or +// empty segment designates the same directory and `..` the parent — and no +// spelling is required to be canonical). Four importers in `specs/`, each +// spelling `specs/BASE.mdx` non-canonically, share one workspace that holds +// no `specs/sub/` at all, so `./sub/../BASE.xspec` resolves only lexically — +// a product resolving through the filesystem, or requiring the canonical +// spelling, fails that arm. Each import line is its file's only import +// declaration. +const LEXICAL_IMPORTERS: readonly { + readonly file: string; + readonly section: string; + readonly specifier: string; +}[] = [ + { file: "specs/P1.mdx", section: "p1", specifier: "./sub/../BASE.xspec" }, + { file: "specs/P2.mdx", section: "p2", specifier: ".//BASE.xspec" }, + { file: "specs/P3.mdx", section: "p3", specifier: "././BASE.xspec" }, + { file: "specs/P4.mdx", section: "p4", specifier: "../specs/BASE.xspec" }, +]; + +function lexicalImporterSource(importer: { + readonly section: string; + readonly specifier: string; +}): string { + return [ + `import BASE from "${importer.specifier}"`, + "", + `<S id="${importer.section}" d={BASE.core}>`, + "Derived behavior.", + "</S>", + "", + ].join("\n"); +} + +// The lexical workspace is created after the `../` arm's invocations, so +// its four importers are staged-source records, computed once at module +// load from the table above (S-9's timing clause). +const LEXICAL_IMPORTER_FILES: Readonly<Record<string, StagedMdx>> = + Object.fromEntries( + LEXICAL_IMPORTERS.map((importer) => [ + importer.file, + stagedMdx( + `T2.1-2 lexical importer ${importer.file}`, + lexicalImporterSource(importer), + ), + ]), + ); + const T2_1_2 = defineProductTest({ id: "T2.1-2", - title: - "`../` specifiers resolve against the importing file's directory; absolute, bare, non-`.xspec`, undiscovered-target, and nonexistent-target specifiers each fail with 14.15 (SPEC 2.1, 14.15)", + title: `\`../\` specifiers resolve against the importing file's directory, and resolution is lexical: \`./sub/../BASE.xspec\` (no \`sub/\` on disk), \`.//BASE.xspec\`, \`././BASE.xspec\`, and \`../specs/BASE.xspec\` each designate specs/BASE.mdx — the import valid, the reference through it resolving, \`view\` reporting the resolved target; absolute, bare, non-\`.xspec\`, undiscovered-target (an \`.mdx\` matched by no group; one matched only by a code group), nonexistent-target, above-the-root (a real \`outside/BASE.mdx\` at the root's parent), and escape-spelled ("./B${BACKSLASH}u0041SE.xspec", read verbatim) specifiers each fail with 14.15 (SPEC 2.1, 2.4, 14.15; T11.4-4)`, run: async (product) => { await withWorkspace( { @@ -279,8 +469,78 @@ const T2_1_2 = defineProductTest({ ); }, ); - for (const arm of INVALID_SPECIFIER_ARMS) { - await runInvalidImportArm(product, arm, "T2.1-2"); + // Lexical positives: one workspace, four non-canonical spellings of + // specs/BASE.mdx, no specs/sub/ on disk (module comment above). + await withWorkspace( + { ...VALID_BASE_FILES, ...LEXICAL_IMPORTER_FILES }, + async (workspace) => { + await buildOk( + product, + workspace, + "T2.1-2 `build` with four non-canonical specifiers of " + + "specs/BASE.mdx (`./sub/../`, `.//`, `././`, `../specs/`; no " + + "specs/sub/ on disk) — each import is valid (SPEC 2.1)", + ); + for (const importer of LEXICAL_IMPORTERS) { + const node = `${importer.file}#${importer.section}`; + const context = `T2.1-2 \`query edges --from ${node}\``; + const edges = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--from", node], + context, + ), + context, + ); + assertEdgeSetEqual( + edges, + [{ from: node, to: "specs/BASE.mdx#core", kind: "depends" }], + `${context}: \`${importer.specifier}\` resolves lexically to ` + + "specs/BASE.mdx, so the reference through the binding " + + "resolves to its node (SPEC 2.1, 2.2)", + ); + } + // `view` reports each import's resolved target — the designated + // file, never the specifier's spelling (SPEC 11.4; T11.4-4). One + // bare whole-domain `view`: the five discovered sources in byte + // order of path, BASE first. + const viewContext = "T2.1-2 bare `view` over the lexical workspace"; + const report = decodeViewReport( + await runJson(product, workspace, ["view"], viewContext), + { text: false }, + viewContext, + ); + assertConditionCounts( + report.findings, + {}, + `${viewContext}: every non-canonical specifier is valid, so no ` + + "finding accompanies the view (SPEC 2.1, 11.4)", + ); + assertSameJson( + report.views.map((view) => view.file), + ["specs/BASE.mdx", ...LEXICAL_IMPORTERS.map((i) => i.file)], + `${viewContext}: every discovered spec source is viewed, in byte ` + + "order of workspace-relative path (SPEC 11.4, 12.7)", + ); + for (const [index, importer] of LEXICAL_IMPORTERS.entries()) { + const view = report.views[index + 1]!; + assertSameJson( + view.imports.map((entry) => ({ + name: entry.name, + target: entry.target, + })), + [{ name: "BASE", target: "specs/BASE.mdx" }], + `${viewContext} — ${importer.file}: its one import declaration ` + + `(\`${importer.specifier}\`) binds BASE and reports the ` + + "resolved target specs/BASE.mdx — the designated file, not " + + "the specifier's spelling (SPEC 2.1, 11.4; T11.4-4)", + ); + } + }, + ); + for (const staging of INVALID_SPECIFIER_STAGINGS) { + await runInvalidImportArm(product, staging, "T2.1-2"); } }, }); @@ -320,49 +580,177 @@ const INVALID_BINDING_ARMS: readonly InvalidImportArm[] = [ }, ]; +const INVALID_BINDING_STAGINGS = invalidImportStagings( + "T2.1-3", + INVALID_BINDING_ARMS, +); + // T2.1-3, positive arm: two imports binding the same module under different // names. Each binding is exercised by its own section's `d` reference, so -// validity is grounded in both bindings actually resolving. -const TWO_LEAF_BASE_SOURCE = [ - '<S id="a">', - "Leaf a.", - "</S>", - "", - '<S id="b">', - "Leaf b.", - "</S>", - "", -].join("\n"); +// validity is grounded in both bindings actually resolving. The arm's +// workspace is created after the invalid-binding arms' invocations, so both +// sources are staged-source records (S-9's timing clause). +const TWO_LEAF_BASE_SOURCE = stagedMdx( + "T2.1-3 two-names arm specs/BASE.mdx", + [ + '<S id="a">', + "Leaf a.", + "</S>", + "", + '<S id="b">', + "Leaf b.", + "</S>", + "", + ].join("\n"), +); -const TWO_NAMES_IMPORTER_SOURCE = [ - 'import BASE from "./BASE.xspec"', - 'import ALSO from "./BASE.xspec"', - "", - '<S id="one" d={BASE.a}>', - "Uses the first binding.", - "</S>", - "", - '<S id="two" d={ALSO.b}>', - "Uses the second binding.", - "</S>", - "", -].join("\n"); +const TWO_NAMES_IMPORTER_SOURCE = stagedMdx( + "T2.1-3 two-names arm specs/A.mdx", + [ + 'import BASE from "./BASE.xspec"', + 'import ALSO from "./BASE.xspec"', + "", + '<S id="one" d={BASE.a}>', + "Uses the first binding.", + "</S>", + "", + '<S id="two" d={ALSO.b}>', + "Uses the second binding.", + "</S>", + "", + ].join("\n"), +); -// T2.1-3, duplicate-binding arm: two imports of two different modules binding -// the one identifier `BASE` (SPEC 2.1: no two imports in a file may bind the -// same identifier — 14.15, not a parse failure). Each import line is a known -// byte range for the location assertion. +// T2.1-3, duplicate-binding arms: two imports of two different modules +// binding the one identifier `BASE` (SPEC 2.1: no two imports in a file may +// bind the same identifier — 14.15 in a well-formed file, never 14.20). SPEC +// 14.20 decides well-formedness by derivability alone, and a duplicate +// lexically declared name is an ECMAScript early error — excluded from +// derivability — so the file is well-formed under both stagings the condition +// distinguishes: the two declarations in one ESM block (consecutive lines, no +// blank line between — the arm a product delegating ECMAScript's early errors +// to its parser fails, T14-12) and in separate blocks (a blank line between +// them ends the first block, 2.7). Each import line is a known byte range for +// the location assertion. const DUP_BINDING_FIRST = 'import BASE from "./B1.xspec"'; const DUP_BINDING_SECOND = 'import BASE from "./B2.xspec"'; -const DUP_BINDING_SOURCE = `${DUP_BINDING_FIRST}\n${DUP_BINDING_SECOND}${IMPORTING_FILE_REST}`; + +/** One duplicate-binding staging: the two declarations joined by `separator`. */ +interface DuplicateBindingArm { + readonly name: string; + /** What stands between the declarations: one ESM block, or two. */ + readonly separator: "\n" | "\n\n"; + /** The staged `specs/A.mdx`: both declarations, then the file's rest. */ + readonly source: StagedMdx; +} + +/** + * Compose an arm's `specs/A.mdx` into its staged-source record — the arms' + * workspaces are created after the body's first invocation (S-9's timing + * clause). S-9: two imports binding one identifier are an ECMAScript early + * error 14.20 admits — the named allowance. It covers the separate-blocks + * staging too: the stock parser judges all of a file's ESM blocks as one + * module, so it rejects the second declaration under the same rule beyond + * derivability ("Identifier 'BASE' has already been declared"). + */ +function duplicateBindingArm( + name: string, + separator: DuplicateBindingArm["separator"], +): DuplicateBindingArm { + return { + name, + separator, + source: stagedMdx( + `T2.1-3 two imports binding one identifier in ${name} specs/A.mdx`, + `${DUP_BINDING_FIRST}${separator}${DUP_BINDING_SECOND}${IMPORTING_FILE_REST}`, + { allowances: ["duplicate-import-binding"] }, + ), + }; +} + +const DUPLICATE_BINDING_ARMS: readonly DuplicateBindingArm[] = [ + duplicateBindingArm("one ESM block (consecutive lines)", "\n"), + duplicateBindingArm("separate ESM blocks (a blank line between)", "\n\n"), +]; + +// The two modules the colliding imports designate, staged in both arms. +const DUP_BINDING_B1 = stagedMdx( + "T2.1-3 duplicate-binding arms specs/B1.mdx", + '<S id="b1">\nFirst module.\n</S>\n', +); +const DUP_BINDING_B2 = stagedMdx( + "T2.1-3 duplicate-binding arms specs/B2.mdx", + '<S id="b2">\nSecond module.\n</S>\n', +); + +/** + * Run one duplicate-binding arm: `build --json` exits 1 and every finding is + * 14.15 — never 14.20 — located within one of the two colliding import + * statements' byte windows. SPEC 2.1 defines one condition over the colliding + * pair; whether a product reports the collision once or per import is not + * fixed, so one or two findings are accepted — every one of them must be + * 14.15, name the file, and point at one of the two import statements. + */ +async function runDuplicateBindingArm( + product: ProductBinding, + arm: DuplicateBindingArm, +): Promise<void> { + const context = + "T2.1-3 `build --json` over two imports binding the same identifier in " + + arm.name; + await withWorkspace( + { + "specs/B1.mdx": DUP_BINDING_B1, + "specs/B2.mdx": DUP_BINDING_B2, + "specs/A.mdx": arm.source, + }, + async (workspace) => { + const findings = await buildFindings(product, workspace, context); + const conditions = findings.map((finding) => finding.condition); + if ( + findings.length < 1 || + findings.length > 2 || + conditions.some((condition) => condition !== "14.15") + ) { + fail( + `${context}: expected the colliding pair to report condition 14.15 — ` + + `one finding for the collision, or one per import — and never ` + + `14.20: the file is well-formed, a duplicate lexically declared ` + + `name being an early error excluded from derivability (SPEC ` + + `14.20); got ${JSON.stringify(conditions)}`, + ); + } + const windows = [ + byteWindow("", DUP_BINDING_FIRST), + byteWindow(`${DUP_BINDING_FIRST}${arm.separator}`, DUP_BINDING_SECOND), + ]; + for (const finding of findings) { + const findingContext = `${context}: a 14.15 finding`; + assertFindingLocated(finding, { file: "specs/A.mdx" }, findingContext); + 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)})`, + ); + } + } + } + }, + ); +} const T2_1_3 = defineProductTest({ id: "T2.1-3", title: - "named, namespace, and side-effect-only imports, duplicate-identifier bindings, and bindings of `S`/`Spec`/`text` each fail with 14.15; two imports binding one module under different names are valid (SPEC 2.1, 14.15)", + "named, namespace, and side-effect-only imports, duplicate-identifier bindings (in one ESM block and in separate blocks — 14.15 in a well-formed file, never 14.20), and bindings of `S`/`Spec`/`text` each fail with 14.15; two imports binding one module under different names are valid (SPEC 2.1, 14.15, 14.20)", run: async (product) => { - for (const arm of INVALID_BINDING_ARMS) { - await runInvalidImportArm(product, arm, "T2.1-3"); + for (const staging of INVALID_BINDING_STAGINGS) { + await runInvalidImportArm(product, staging, "T2.1-3"); } // Positive arm: same module, two names — valid, and both bindings work. @@ -406,61 +794,11 @@ const T2_1_3 = defineProductTest({ }, ); - // Duplicate-binding arm. SPEC 2.1 defines one condition over the - // colliding pair; whether a product reports the collision once or per - // import is not fixed, so one or two findings are accepted — every one - // of them must be 14.15, name the file, and point at one of the two - // import statements. - const dupContext = - "T2.1-3 `build --json` over two imports binding the same identifier"; - await withWorkspace( - { - "specs/B1.mdx": '<S id="b1">\nFirst module.\n</S>\n', - "specs/B2.mdx": '<S id="b2">\nSecond module.\n</S>\n', - "specs/A.mdx": DUP_BINDING_SOURCE, - }, - async (workspace) => { - const findings = await buildFindings(product, workspace, dupContext); - const conditions = findings.map((finding) => finding.condition); - if ( - findings.length < 1 || - findings.length > 2 || - conditions.some((condition) => condition !== "14.15") - ) { - fail( - `${dupContext}: expected the colliding pair to report condition 14.15 — ` + - `one finding for the collision, or one per import — got ` + - `${JSON.stringify(conditions)}`, - ); - } - const windows = [ - byteWindow("", DUP_BINDING_FIRST), - byteWindow(`${DUP_BINDING_FIRST}\n`, DUP_BINDING_SECOND), - ]; - for (const finding of findings) { - const findingContext = `${dupContext}: a 14.15 finding`; - assertFindingLocated( - finding, - { 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)})`, - ); - } - } - }, - ); + // Duplicate-binding arms: the two declarations in one ESM block, then in + // separate blocks — 14.15 under both, never 14.20. + for (const arm of DUPLICATE_BINDING_ARMS) { + await runDuplicateBindingArm(product, arm); + } }, }); @@ -550,16 +888,21 @@ const CYCLE_B_SOURCE = [ // The self-import arm: the import cycle of length one exists whether or not // the binding is used, so it stays unused — the import itself is the defect. -const SELF_IMPORT_SOURCE = - 'import SELF from "./SELF.xspec"' + IMPORTING_FILE_REST; +// Its workspace is created after the two-file arm's invocation, so the +// source is a staged-source record (S-9's timing clause). +const SELF_IMPORT_SOURCE = stagedMdx( + "T2.1-5 self-import specs/SELF.mdx", + 'import SELF from "./SELF.xspec"' + IMPORTING_FILE_REST, +); /** * Assert an import-cycle report: every finding is 14.9 (nothing else is * 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 +923,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) { @@ -629,10 +974,239 @@ const T2_1_5 = defineProductTest({ }); /** TEST-SPEC §2.1, in canonical ID order (SUITE-06). */ +// T2.1-6: an ESM block inside a section element. SPEC 14.20 decides +// well-formedness by derivability alone, and the MDX grammar derives an ESM +// block wherever flow content may stand — inside a section element too, the +// form 6.5's in-section exclusion presupposes and T6.5-17 refuses as moved +// text. The block is separated from the tag line and from the body line by a +// blank line on each side, so it interrupts no paragraph and runs to its +// blank line (2.7). A sibling section outside `m` references the binding +// through its `d` value, so the binding resolves from inside the section (the +// embedding) and from outside it (SPEC 2.1). Everything staged is ASCII, so +// string lengths are byte counts (SPEC 1.7). +const T2_1_6_DECLARATION = 'import X from "./X.xspec"'; +const T2_1_6_DECLARATION_PREFIX = '<S id="m">\n\n'; +const T2_1_6_SOURCE = + T2_1_6_DECLARATION_PREFIX + + T2_1_6_DECLARATION + + "\n\nbody {text(X.a)}\n</S>\n\n" + + '<S id="n" d={X.a}>\nSibling uses the binding.\n</S>\n'; +// The target: an in-line section whose subtree text is exactly `Alpha text.` +// (SPEC 3: the tag pair deleted in place; the line's terminator lies outside +// the construct), so the embedding above expands mid-line with no terminator +// of its own. +const T2_1_6_TARGET_SOURCE = '<S id="a">Alpha text.</S>\n'; +// The declaration's source range — its own characters (SPEC 11.4, 1.7). +const T2_1_6_DECLARATION_RANGE = { + start: Buffer.byteLength(T2_1_6_DECLARATION_PREFIX, "utf8"), + end: + Buffer.byteLength(T2_1_6_DECLARATION_PREFIX, "utf8") + + Buffer.byteLength(T2_1_6_DECLARATION, "utf8"), +}; +// Hand-derived per SPEC 3: `m`'s tag-only lines and the declaration's line +// each drop with their terminators (left empty purely by removals); the blank +// source lines keep theirs; the embedding is replaced in place by `a`'s +// subtree text. `m` has no child, so its own text is its subtree text — the +// construct's whole contribution (SPEC 1.6). +const T2_1_6_M_TEXT = "\n\nbody Alpha text.\n"; +// The file: `m`'s contribution, the blank line between the sections, then +// `n`'s (its tag-only lines dropping alike). +const T2_1_6_COMPILED = `${T2_1_6_M_TEXT}\nSibling uses the binding.\n`; + +// SPECS_ONLY_CONFIG plus Markdown emission enabled (SPEC 7.3), for the +// byte-asserted compiled output. +const SPECS_ONLY_EMITTING_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`; + +/** The positional section tree's identities and nesting (SPEC 11.4). */ +function treeShape(node: ViewNode): unknown { + return { identity: node.identity, children: node.children.map(treeShape) }; +} + +const T2_1_6 = defineProductTest({ + id: "T2.1-6", + title: + "an ESM block inside a section element derives: the file builds with `check` clean, the binding resolves from inside the section and from a sibling outside it, the declaration's line drops from the compiled Markdown and from the section's own text, `view` lists the declaration under `imports`, and the `contains` structure is as without the block (SPEC 2.1, 14.20, 3, 1.6, 11.4)", + run: async (product) => { + await withWorkspace( + { + "specs/X.mdx": T2_1_6_TARGET_SOURCE, + "specs/A.mdx": T2_1_6_SOURCE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T2.1-6 `build` over an ESM block inside a section element — " + + "well-formed, derivability alone deciding (SPEC 14.20, 2.1)", + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + "T2.1-6 `check` after the build (SPEC 12.2)", + ); + + // References through the binding resolve from inside the section + // (the embedding) and from the sibling outside it (the `d` value): + // exactly one edge leaves each node — in particular no `contains` + // edge leaves `m`, which holds no node (SPEC 2.1, 5.2). + const referencingNodes = [ + { node: "specs/A.mdx#m", kind: "embeds" }, + { node: "specs/A.mdx#n", kind: "depends" }, + ] as const; + for (const { node, kind } of referencingNodes) { + const context = `T2.1-6 \`query edges --from ${node}\``; + const edges = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--from", node], + context, + ), + context, + ); + assertEdgeSetEqual( + edges, + [{ from: node, to: "specs/X.mdx#a", kind }], + `${context}: the reference through the in-section binding ` + + `resolves (SPEC 2.1), and no other edge leaves the node`, + ); + } + + // The `contains` structure is as without the block: the root + // containing `m` and its sibling, `m` containing no node. + const containsContext = "T2.1-6 `query edges --kinds contains`"; + const contains = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--kinds", "contains"], + containsContext, + ), + containsContext, + ); + assertEdgeSetEqual( + contains, + [ + { from: "specs/A.mdx", to: "specs/A.mdx#m", kind: "contains" }, + { from: "specs/A.mdx", to: "specs/A.mdx#n", kind: "contains" }, + { from: "specs/X.mdx", to: "specs/X.mdx#a", kind: "contains" }, + ], + `${containsContext}: the root contains \`m\` and its sibling, and ` + + `\`m\` contains no node — the structure as without the block ` + + `(SPEC 1.2, 5.2)`, + ); + + // Compiled Markdown, byte-asserted with emission enabled (SPEC 3). + await assertFileBytes( + workspace.path("specs/A.md"), + T2_1_6_COMPILED, + "T2.1-6 specs/A.md: the declaration removed and its line dropped " + + "with its terminator, the tag-only lines dropped, the blank " + + "lines kept, the embedding replaced in place (SPEC 3, 13.2)", + ); + + // The section's own text (SPEC 1.6): the empty lines kept, the + // declaration's line gone. + const nodeContext = "T2.1-6 `query node specs/A.mdx#m`"; + const node = decodeNodeReport( + await runJson( + product, + workspace, + ["query", "node", "specs/A.mdx#m"], + nodeContext, + ), + nodeContext, + ); + if (node.identity !== "specs/A.mdx#m") { + fail( + `${nodeContext}: expected the report to be about ` + + `"specs/A.mdx#m" (SPEC 1.5), got identity ` + + `${JSON.stringify(node.identity)}`, + ); + } + assertBytesEqual( + node.ownText, + T2_1_6_M_TEXT, + `${nodeContext}: own text — the empty lines kept, the ` + + `declaration's line gone, the embedding expanded (SPEC 1.6, 3)`, + ); + assertBytesEqual( + node.subtreeText, + T2_1_6_M_TEXT, + `${nodeContext}: subtree text — a childless section's own text ` + + `(SPEC 1.6)`, + ); + + // `view`: the declaration listed under `imports` with its range and + // resolved target; the positional tree as without the block. + const viewContext = "T2.1-6 `view specs/A.mdx`"; + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", "specs/A.mdx"], + viewContext, + ), + { text: false }, + viewContext, + ); + assertSameJson( + report.findings, + [], + `${viewContext}: the consulted domain is finding-free (SPEC 11.2)`, + ); + if (report.views.length !== 1) { + fail( + `${viewContext}: expected exactly one per-file view (SPEC 11.4), ` + + `got ${String(report.views.length)}`, + ); + } + const view = report.views[0]!; + assertSameJson( + view.imports, + [ + { + range: T2_1_6_DECLARATION_RANGE, + name: "X", + target: "specs/X.mdx", + }, + ], + `${viewContext}: the in-section declaration listed under imports ` + + `with the range of its own characters, its binding name, and ` + + `its resolved target (SPEC 11.4, 1.7, 2.1)`, + ); + assertSameJson( + treeShape(view.root), + { + identity: "specs/A.mdx", + children: [ + { identity: "specs/A.mdx#m", children: [] }, + { identity: "specs/A.mdx#n", children: [] }, + ], + }, + `${viewContext}: the positional tree — the root containing \`m\` ` + + `and its sibling, \`m\` containing no node (SPEC 11.4)`, + ); + }, + SPECS_ONLY_EMITTING_CONFIG, + ); + }, +}); + export const section21Tests: readonly ProductTestEntry[] = [ T2_1_1, T2_1_2, T2_1_3, T2_1_4, T2_1_5, + T2_1_6, ]; diff --git a/test/suite/registry/section-2.2-2.3.ts b/test/suite/registry/section-2.2-2.3.ts index e7fca7e1..3c1f941e 100644 --- a/test/suite/registry/section-2.2-2.3.ts +++ b/test/suite/registry/section-2.2-2.3.ts @@ -1,5 +1,5 @@ // TEST-SPEC §2.2 (dependency prop) and §2.3 (embedding requirement text) — -// SUITE-07: T2.2-1 … T2.2-5, T2.3-1, T2.3-2. +// SUITE-07: T2.2-1 … T2.2-5, T2.3-1, T2.3-2, T2.3-3. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -22,38 +22,67 @@ // containing section, with the same external/local duality as `d`, targeting // any depth, whole files via the module binding included. -import type { GraphEdge, NodeReport } from "../../helpers/adapters/index.js"; +import type { + Finding, + GraphEdge, + NodeReport, +} from "../../helpers/adapters/index.js"; import { decodeEdgesReport, decodeNodeReport, + decodeViewReport, } from "../../helpers/adapters/index.js"; -import { assertFileBytes, fail } from "../../helpers/assertions.js"; +import { + assertFileBytes, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import type { UnparseableStaging } from "./support.js"; import { + assertConditionCounts, assertEdgeSetEqual, assertSameJson, + buildFindings, buildOk, + expectExit, + expectFindingFreeReport, runJson, } from "./support.js"; -// Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): exactly one spec group. A +// staged-source record (S-9's timing clause): T2.3-3's invalid-container +// workspaces and its unparseable one are created after its first +// invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T2.3-3 xspec.config.ts — the specs-only configuration of the invalid-container and unparseable workspaces", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // As above with Markdown emission enabled (default destination: next to each // source file, `specs/A.mdx` → `specs/A.md`; SPEC 7.3, 13.2). The spec-group // globs match only `.mdx` files, so no glob matches an emit destination and -// the discovered set is unaffected by emission (13.4). -const EMIT_TRUE_CONFIG = `import { defineConfig } from "xspec" +// the discovered set is unaffected by emission (13.4). A staged-source +// record (S-9's timing clause): T2.3-3's embedding workspaces after its +// first are created after its first invocation. +const EMIT_TRUE_CONFIG = stagedTs( + "T2.3-3 xspec.config.ts — Markdown emission enabled, the embedding workspaces", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -61,12 +90,13 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -337,7 +367,12 @@ const T2_2_3 = defineProductTest({ // byte-identical across the variants. Both variants are built in the same // workspace directory, so nothing but the prop's presence varies. const T2_2_4_EMPTY_ARRAY = '<S id="node" d={[]}>\nNode behavior.\n</S>\n'; -const T2_2_4_OMITTED = '<S id="node">\nNode behavior.\n</S>\n'; +// Staged after the `d={[]}` build and queries — a staged-source record, +// judged before any product exists (S-9, test/self/s9-staged-sources.test.ts). +const T2_2_4_OMITTED = stagedMdx( + "T2.2-4 specs/A.mdx with the d prop omitted (the rebuilt variant)", + '<S id="node">\nNode behavior.\n</S>\n', +); const T2_2_4_NODE = "specs/A.mdx#node"; const T2_2_4 = defineProductTest({ @@ -684,6 +719,474 @@ const T2_3_2 = defineProductTest({ }); /** TEST-SPEC §2.2–2.3, in canonical ID order (SUITE-07). */ +// --------------------------------------------------------------------------- +// T2.3-3 +// --------------------------------------------------------------------------- + +// SPEC 2.3 fixes what an embedding is: an expression container, in flow or +// text position, whose one expression (14.20) is a call — optional chaining +// excluded — whose callee is the identifier `text` itself, spelled plainly, +// neither parenthesized nor escaped (2.4), whatever whitespace and comments +// stand beside the call; a container holding any other expression is an +// invalid construct (14.16), and one whose content the grammar derives as no +// expression leaves the file unparseable (14.20, 2.7). Every staging below is +// one file: the target `a` as an in-line section — its subtree text exactly +// `Alpha text.` (SPEC 3: the tag pair deleted in place, the line's terminator +// lying outside the construct) — a blank line, then a section holding the +// form under test alone on its line(s), in flow position. Everything staged +// is ASCII, so string lengths are byte counts (SPEC 1.7), and every range is +// computed from the staged bytes. The line terminator and the backslash are +// built from code points, never spelled as escapes in this source. +const T2_3_3_LF = String.fromCharCode(0x0a); // U+000A +const T2_3_3_BACKSLASH = String.fromCharCode(0x5c); // U+005C +const T2_3_3_FILE = "specs/A.mdx"; +const T2_3_3_TARGET_TAG = '<S id="a">Alpha text.</S>'; +const T2_3_3_TARGET_SUBTREE = "Alpha text."; +/** The file's head: the target's line and a blank line. */ +const T2_3_3_HEAD = `${T2_3_3_TARGET_TAG}${T2_3_3_LF}${T2_3_3_LF}`; + +interface T233Staging { + readonly source: string; + /** The form's container: opening brace through closing brace (SPEC 5.7). */ + readonly container: { readonly start: number; readonly end: number }; + /** The enclosing section's construct range (SPEC 1.7). */ + readonly section: { readonly start: number; readonly end: number }; +} + +/** The head, then `<S id="…">` LF `<form>` LF `</S>` LF, with both ranges. */ +function stageT233(sectionId: string, form: string): T233Staging { + const opening = `<S id="${sectionId}">${T2_3_3_LF}`; + const closing = `${T2_3_3_LF}</S>`; + const sectionStart = Buffer.byteLength(T2_3_3_HEAD, "utf8"); + const containerStart = sectionStart + Buffer.byteLength(opening, "utf8"); + const containerEnd = containerStart + Buffer.byteLength(form, "utf8"); + return { + source: `${T2_3_3_HEAD}${opening}${form}${closing}${T2_3_3_LF}`, + container: { start: containerStart, end: containerEnd }, + section: { + start: sectionStart, + end: containerEnd + Buffer.byteLength(closing, "utf8"), + }, + }; +} + +// The embedding forms (SPEC 2.3; TEST-SPEC T2.3-3): each an embedding whatever +// whitespace and comments stand beside the call — what follows the one +// expression being whitespace and comments alone (14.20); the two-line forms' +// line comment ended by the interior terminator under 14.20's deletion +// judgement; and the run-on form's first `}` lying on the commented-out line, +// closing nothing, the container running to the second `}` at which its +// content derives as the call (14.20, 2.7; the empty twin is T2.7-4's). A +// product ending every container at its first `}`, recognizing the run-on +// for empty containers alone, or stripping only leading trivia when +// classifying the callee fails one of these arms. +const T2_3_3_EMBEDDING_FORMS: readonly { + readonly label: string; + readonly form: string; +}[] = [ + { label: "whitespace around the call", form: '{ text("a") }' }, + { label: "a block comment before the call", form: '{/* n */ text("a")}' }, + { label: "a block comment after the call", form: '{text("a") /* n */}' }, + { + label: "a line comment before the call, ended by the interior terminator", + form: `{// n${T2_3_3_LF}text("a")}`, + }, + { + label: "the run-on form, its first brace on the commented-out line", + form: `{// c}${T2_3_3_LF}text("a")}`, + }, +]; + +// Hand-derived per SPEC 3, the same for every embedding staging: the target's +// line keeps `Alpha text.` and its terminator (the tags deleted in place); the +// blank line stays; the section's tag-only lines drop with their terminators; +// the container — comment, whitespace, and interior terminator included — is +// replaced whole by the target's subtree text, the (joined) line keeping its +// remaining content and its last terminator. +const T2_3_3_EMBEDDING_COMPILED = + `${T2_3_3_TARGET_SUBTREE}${T2_3_3_LF}${T2_3_3_LF}` + + `${T2_3_3_TARGET_SUBTREE}${T2_3_3_LF}`; + +// The invalid-container forms (SPEC 2.3, 2.4, 2.7; 14.16): each derives as one +// expression that is no plain `text(...)` call — one condition-16 finding +// located brace through brace, no edge, no occurrence, never 14.6 and never +// 14.8 — and its bytes are content under 11.2's by-form classification: +// preserved byte-for-byte in the enclosing section's text and located by the +// finding (T11.2-4). The escaped callee spells `te`, a backslash, `u0078t`: +// read as spelled, it is not `text` (SPEC 2.4). +const T2_3_3_INVALID_FORMS: readonly { + readonly label: string; + readonly form: string; +}[] = [ + { label: "a parenthesized callee", form: '{(text)("a")}' }, + { + label: "an escaped callee — spelled escaped, not `text` (SPEC 2.4)", + form: `{te${T2_3_3_BACKSLASH}u0078t("a")}`, + }, + { label: "an optional call", form: '{text?.("a")}' }, + { + label: "a comma sequence — one expression, not a call (SPEC 14.20)", + form: '{text("a"), 1}', + }, + { + label: "`await` before the call — `await` derives (SPEC 14.20)", + form: '{await text("a")}', + }, +]; + +/** One form staged in its section, with its record and its arm label. */ +interface T233ArmStaging { + /** The arm's name in contexts. */ + readonly arm: string; + readonly form: string; + readonly staging: T233Staging; + /** `staging.source` as a staged-source record. */ + readonly source: StagedMdx; +} + +/** + * The forms of one kind staged in section `sectionId` (`stageT233`), each + * with its record — computed once at module load: every workspace but the + * body's first is created after its first product invocation, so S-9's + * timing clause makes each staging a ledger record, and the table converts + * uniformly. + */ +function t233ArmStagings( + kind: string, + sectionId: string, + forms: readonly { readonly label: string; readonly form: string }[], +): readonly T233ArmStaging[] { + return forms.map(({ label, form }) => { + const staging = stageT233(sectionId, form); + const arm = `T2.3-3 ${kind} ${JSON.stringify(form)} (${label})`; + return { + arm, + form, + staging, + source: stagedMdx(`${arm} ${T2_3_3_FILE}`, staging.source), + }; + }); +} + +const T2_3_3_EMBEDDING_STAGINGS = t233ArmStagings( + "embedding form", + "p", + T2_3_3_EMBEDDING_FORMS, +); +const T2_3_3_INVALID_STAGINGS = t233ArmStagings( + "invalid container", + "n", + T2_3_3_INVALID_FORMS, +); + +// `{text("a") text("b")}`: no expression the grammar derives — 14.20 (SPEC +// 2.7), never 14.16 — its zero-length range at the offset SPEC 14's +// syntax-failure rule fixes: the byte length of the longest whole-character +// prefix of the file with which some well-formed file begins. The prefix +// through the space after the first call begins one (`{text("a") }` continues +// it); no well-formed file continues a complete expression with an +// identifier; so the offset is the second `text`'s (T14-11 re-asserts it; +// T14-12). Authoring note: the stock MDX parser positions its rejection one +// byte earlier, at that space (acorn's "Unexpected content after expression" +// points at the end of the parsed expression) — the rule of 14, not the +// parser's message, fixes the offset. +const T2_3_3_UNPARSEABLE_PREFIX = '{text("a") '; +const T2_3_3_UNPARSEABLE_FORM = `${T2_3_3_UNPARSEABLE_PREFIX}text("b")}`; + +/** + * The unparseable staging and its pinned offset — the very bytes the arm + * below drives, staged alone in section `u`, the offset the container's + * start plus the byte length of the prefix through the space after the + * first call — exported for T14-11's re-assertion of the offset the same + * way (TEST-SPEC T14-11's closing clause). `specs/A.mdx` is a staged-source + * record declared unparseable (S-9), registered at load: the arm below and + * T14-11 each stage it after their bodies' first product invocations. + */ +export const T2_3_3_UNPARSEABLE_STAGING: UnparseableStaging = (() => { + const staging = stageT233("u", T2_3_3_UNPARSEABLE_FORM); + return { + name: + `\`${T2_3_3_UNPARSEABLE_FORM}\` — no expression the grammar derives, ` + + "the zero-length range at the offset of the second `text` (T2.3-3)", + kind: "spec-source", + file: T2_3_3_FILE, + files: { + [T2_3_3_FILE]: stagedMdx( + `T2.3-3/T14-11 unparseable form ${JSON.stringify(T2_3_3_UNPARSEABLE_FORM)} (no expression the grammar derives) ${T2_3_3_FILE}`, + staging.source, + "unparseable", + ), + }, + offset: + staging.container.start + + Buffer.byteLength(T2_3_3_UNPARSEABLE_PREFIX, "utf8"), + }; +})(); + +/** The one finding of `condition` (its count asserted beforehand). */ +function t233FindingOf( + 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]!; +} + +/** + * Assert a located finding's concern exactly (SPEC 14, 12.7): `path` null, + * and exactly one location — in specs/A.mdx, at exactly `range`. + */ +function assertT233FindingRange( + finding: Finding, + range: { readonly start: number; readonly end: number }, + context: string, +): void { + assertSameJson( + finding.path, + null, + `${context} — a located condition's concerned path is null (SPEC 12.7)`, + ); + assertSameJson( + finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + [{ file: T2_3_3_FILE, range: { start: range.start, end: range.end } }], + `${context} (message: ${JSON.stringify(finding.message)})`, + ); +} + +const T2_3_3 = defineProductTest({ + id: "T2.3-3", + title: + 'an embedding is a container whose one expression is a plain `text(...)` call, whatever whitespace and comments stand beside it: five forms (the run-on `{// c}` form included) each build clean, record their `embeds` edge and a full-container occurrence with no `comments` entry, and compile to the target\'s subtree text (byte-asserted); five invalid-container forms are each one 14.16 finding brace through brace — never 14.6 or 14.8 — with no occurrence, their bytes content under `view --text`; and `{text("a") text("b")}` is 14.20 at the offset of the second `text` (SPEC 2.3, 2.4, 2.7, 3, 5.7, 11.2, 11.4, 14.16, 14.20)', + run: async (product) => { + // --- the embedding forms, each staged alone in section `p` --------------- + for (const { arm, staging, source } of T2_3_3_EMBEDDING_STAGINGS) { + await withWorkspace( + EMIT_TRUE_CONFIG, + { [T2_3_3_FILE]: source }, + async (workspace) => { + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${arm} — \`build\` exit 0 with no finding: the container is an ` + + `embedding, the whitespace and comments beside its call ` + + `notwithstanding (SPEC 2.3, 14.20)`, + ); + await assertFileBytes( + workspace.path("specs/A.md"), + T2_3_3_EMBEDDING_COMPILED, + `${arm} — emitted Markdown: the whole container — comment, ` + + `whitespace, and interior terminator included — replaced by ` + + `the target's subtree text (SPEC 3, 2.3)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", arm), + [{ from: "specs/A.mdx#p", to: "specs/A.mdx#a", kind: "embeds" }], + `${arm} — the complete \`embeds\` edge set: the one edge from the ` + + `containing section to the target (SPEC 2.3, 5.2)`, + ); + const viewContext = `${arm} \`view ${T2_3_3_FILE}\``; + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", T2_3_3_FILE], + viewContext, + ), + { text: false }, + viewContext, + ); + assertSameJson( + report.findings, + [], + `${viewContext} — the consulted domain is finding-free (SPEC 11.2)`, + ); + if (report.views.length !== 1) { + fail( + `${viewContext}: expected exactly one per-file view (SPEC 11.4), ` + + `got ${String(report.views.length)}`, + ); + } + const view = report.views[0]!; + assertSameJson( + view.comments, + [], + `${viewContext} — an embedding is no MDX comment: no \`comments\` ` + + `entry, the comment beside its call notwithstanding (SPEC 11.4, 2.7)`, + ); + assertSameJson( + view.occurrences, + [ + { + file: T2_3_3_FILE, + range: staging.container, + kind: "embeds", + source: { identity: "specs/A.mdx#p", range: staging.section }, + target: "specs/A.mdx#a", + }, + ], + `${viewContext} — exactly one occurrence, spanning the full ` + + `container, opening brace through closing brace — the interior ` + + `terminator included for a two-line form — with its source ` + + `section and its target (SPEC 5.7, 11.4, 1.7)`, + ); + }, + ); + } + + // --- the invalid-container forms, each staged alone in section `n` ------ + for (const { arm, form, staging, source } of T2_3_3_INVALID_STAGINGS) { + await withWorkspace( + SPECS_ONLY_CONFIG, + { [T2_3_3_FILE]: source }, + async (workspace) => { + const buildContext = `${arm} \`build --json\``; + const findings = await buildFindings( + product, + workspace, + buildContext, + ); + assertConditionCounts( + findings, + { "14.16": 1 }, + `${buildContext} — exactly one finding, condition 16: the ` + + `container is no embedding, so never 14.6 and never 14.8 ` + + `(SPEC 2.3, 14.16)`, + ); + assertT233FindingRange( + t233FindingOf(findings, "14.16", buildContext), + staging.container, + `${buildContext} — the invalid container located from its ` + + `opening brace through its closing brace (SPEC 14)`, + ); + + // 11.2's by-form classification: the container's bytes are + // content, preserved byte-for-byte in the enclosing section's text + // and located by the finding; no occurrence, no comments entry. + const viewContext = `${arm} \`view --text ${T2_3_3_FILE}\``; + const result = await expectExit( + product, + workspace, + ["view", "--text", T2_3_3_FILE], + 1, + `${viewContext} — the finding accompanies, so exit 1 with the ` + + `full answer (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout(result, viewContext), + { text: true }, + viewContext, + ); + assertConditionCounts( + report.findings, + { "14.16": 1 }, + `${viewContext} — the domain file's one finding accompanies the ` + + `answer (SPEC 11.2)`, + ); + assertT233FindingRange( + t233FindingOf(report.findings, "14.16", viewContext), + staging.container, + `${viewContext} — the container located by its finding (SPEC 11.2, 14)`, + ); + if (report.views.length !== 1) { + fail( + `${viewContext}: expected exactly one per-file view (SPEC 11.4), ` + + `got ${String(report.views.length)}`, + ); + } + const view = report.views[0]!; + assertSameJson( + [view.occurrences, view.comments], + [[], []], + `${viewContext} — no occurrence and no \`comments\` entry: the ` + + `container is neither an embedding nor a comment (SPEC 5.7, 11.4, 2.7)`, + ); + const enclosingText = `${form}${T2_3_3_LF}`; + assertSameJson( + { + identity: view.root.identity, + subtreeText: view.root.subtreeText, + children: view.root.children.map((child) => ({ + identity: child.identity, + ownText: child.ownText, + subtreeText: child.subtreeText, + children: child.children.length, + })), + }, + { + identity: T2_3_3_FILE, + subtreeText: + `${T2_3_3_TARGET_SUBTREE}${T2_3_3_LF}${T2_3_3_LF}` + + enclosingText, + children: [ + { + identity: "specs/A.mdx#a", + ownText: T2_3_3_TARGET_SUBTREE, + subtreeText: T2_3_3_TARGET_SUBTREE, + children: 0, + }, + { + identity: "specs/A.mdx#n", + ownText: enclosingText, + subtreeText: enclosingText, + children: 0, + }, + ], + }, + `${viewContext} — the container matches no removal rule's form, so ` + + `its bytes are content: preserved byte-for-byte, with its line's ` + + `terminator, as the enclosing section's whole text (the tag-only ` + + `lines dropped), and in the root's subtree text — the file's ` + + `compiled output — after the target's line and the blank line; ` + + `the target's text defined and exact (SPEC 11.2, 1.6, 3)`, + ); + }, + ); + } + + // --- `{text("a") text("b")}`: unparseable, at the second `text` ----------- + const { offset } = T2_3_3_UNPARSEABLE_STAGING; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + // S-9: the export's record, declared unparseable — the one form + // TEST-SPEC declares so (14.20). + ...T2_3_3_UNPARSEABLE_STAGING.files, + }, + }); + try { + const context = `T2.3-3 \`build --json\` over ${JSON.stringify(T2_3_3_UNPARSEABLE_FORM)}`; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.20": 1 }, + `${context} — no expression the grammar derives: the file is ` + + `unparseable, 14.20 alone and never 14.16 (SPEC 2.7, 14.20)`, + ); + assertT233FindingRange( + t233FindingOf(findings, "14.20", context), + { start: offset, end: offset }, + `${context} — the one zero-length range at the offset of the second ` + + `\`text\`: the byte length of the longest whole-character prefix ` + + `with which some well-formed file begins — through the space after ` + + `the first call (SPEC 14; T14-11, T14-12)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + export const section22to23Tests: readonly ProductTestEntry[] = [ T2_2_1, T2_2_2, @@ -692,4 +1195,5 @@ export const section22to23Tests: readonly ProductTestEntry[] = [ T2_2_5, T2_3_1, T2_3_2, + T2_3_3, ]; diff --git a/test/suite/registry/section-2.4.ts b/test/suite/registry/section-2.4.ts index 3a8faf40..1c6682b0 100644 --- a/test/suite/registry/section-2.4.ts +++ b/test/suite/registry/section-2.4.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §2.4 (static argument rule) — SUITE-08: T2.4-1 … T2.4-4. +// TEST-SPEC §2.4 (static argument rule) — SUITE-08: T2.4-1 … T2.4-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), @@ -19,7 +19,12 @@ // dynamic references and other arities are invalid (14.8). A chain segment is // exactly one ID segment, and no segment contains `.` (1.4), so a dotted // computed index resolves to nothing (14.5/14.6/14.7 by context), while a -// local string names a whole dotted path (2.2). +// local string names a whole dotted path (2.2). The value of a static +// string literal is the characters between its delimiters exactly as +// spelled — no escape sequence or character reference interpreted — and a +// chain segment's identifier is read as spelled likewise, so an +// escape-spelled segment names no node (2.4; T2.4-5, whose code-source +// half rides the same standard-tooling channel as T2.4-4's type-error arm). // // Location assertions: fixtures are pure ASCII and composed as // `prefix + construct + suffix` with exactly known parts, so string indices @@ -28,40 +33,72 @@ // locations, see support.ts byteWindow); every other staged construct lies // outside the widened window. -import type { GraphEdge } from "../../helpers/adapters/index.js"; -import { decodeEdgesReport } from "../../helpers/adapters/index.js"; +import type { + DependencyEdgeKind, + Finding, + GraphEdge, + OccurrenceRecord, + SourceRange, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeOccurrencesReport, +} from "../../helpers/adapters/index.js"; +import { parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertCompileErrorAt, + assertNoCompileErrors, ConsumerProject, } from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import type { OccurrenceUnit } from "./section-5.7.js"; +import { expectedUnitMultiset, renderOccurrenceUnit } from "./section-5.7.js"; +import type { UnparseableStaging } from "./support.js"; import { assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertSameJson, buildFindings, buildOk, byteWindow, + expectExit, + findingsInSourceOrder, runJson, } from "./support.js"; -// Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): exactly one spec group. A +// staged-source record (S-9's timing clause): the arm workspaces of T2.4-2, +// T2.4-3, and T2.4-4 after each body's first are created after its first +// invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T2.4-2/T2.4-3/T2.4-4 xspec.config.ts — the specs-only configuration of every arm workspace", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus one code group, for T2.4-4's TypeScript marker arm // (SPEC 7.2): the marker's file must be a discovered code source for `build` -// to analyze it (4.5, 14.7). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// to analyze it (4.5, 14.7). A staged-source record (S-9's timing clause): +// that arm's workspace is created after T2.4-4's first invocation; T2.4-5's +// first workspace stages it too. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T2.4-4 xspec.config.ts — one spec group and one code group, the marker arm", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -71,12 +108,13 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -98,7 +136,7 @@ async function withWorkspace<T>( async function queryEdgesOfKind( product: ProductBinding, workspace: TestWorkspace, - kind: "depends" | "embeds", + kind: DependencyEdgeKind, context: string, ): Promise<readonly GraphEdge[]> { const label = `${context} \`query edges --kinds ${kind}\``; @@ -121,14 +159,22 @@ async function queryEdgesOfKind( // `d` and in `text(...)`: double- and single-quoted string literals, a // dot-access chain, computed access via a static string literal (double- and // single-quoted index alike — "a plain single- or double-quoted string"), and -// mixed chains (dot-then-computed and computed-then-dot). The imported module -// carries non-identifier segments (`login-v2`, `pin-2`, `auth.sub-x`) so the -// computed arms use the form 2.4 defines them for (1.4). +// mixed chains (dot-then-computed and computed-then-dot), and dot access +// naming a reserved word (`BASE.delete`: a non-computed access's name is any +// identifier name the file's grammar admits there, a reserved word included, +// SPEC 2.4 — ECMAScript 2024 admits `delete` after `.`, and `delete` is a +// valid ID segment, 1.4). The imported module carries non-identifier +// segments (`login-v2`, `pin-2`, `auth.sub-x`) so the computed arms use the +// form 2.4 defines them for (1.4), and the reserved-word segment `delete`. const T2_4_1_BASE = [ '<S id="login-v2">', "Dashed target.", "</S>", "", + '<S id="delete">', + "Reserved-word target.", + "</S>", + "", '<S id="pin-2">', "Second dashed target.", "</S>", @@ -182,12 +228,17 @@ const T2_4_1_SOURCE = [ "Mixed dot and computed chains.", "</S>", "", + '<S id="reserved" d={BASE.delete}>', + "Dot access naming a reserved word.", + "</S>", + "", '<S id="embed">', 'Double: {text("alpha")}', "Single: {text('beta')}", "Dot: {text(BASE.auth.login)}", 'Computed: {text(BASE["login-v2"])}', 'Mixed: {text(BASE.auth["sub-x"])}', + "Reserved: {text(BASE.delete)}", "</S>", "", ].join("\n"); @@ -195,7 +246,7 @@ const T2_4_1_SOURCE = [ const T2_4_1 = defineProductTest({ id: "T2.4-1", title: - "double- and single-quoted string literals and property chains with dot access, computed access via static string literal, and mixed chains are all accepted in `d` and `text(...)` — the workspace builds and each form records its edge to the right target (SPEC 2.4, 2.2, 2.3)", + "double- and single-quoted string literals and property chains with dot access, computed access via static string literal, mixed chains, and dot access naming a reserved word (`BASE.delete`) are all accepted in `d` and `text(...)` — the workspace builds and each form records its edge to the right target (SPEC 2.4, 2.2, 2.3)", run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -250,6 +301,11 @@ const T2_4_1 = defineProductTest({ to: "specs/BASE.mdx#auth.login", kind: "depends", }, + { + from: "specs/A.mdx#reserved", + to: "specs/BASE.mdx#delete", + kind: "depends", + }, ], "T2.4-1 the complete `depends` edge set — one edge per accepted `d` form, " + "each resolved to the node its spelling names (SPEC 2.4, 2.2)", @@ -282,6 +338,11 @@ const T2_4_1 = defineProductTest({ to: "specs/BASE.mdx#auth.sub-x", kind: "embeds", }, + { + from: "specs/A.mdx#embed", + to: "specs/BASE.mdx#delete", + kind: "embeds", + }, ], "T2.4-1 the complete `embeds` edge set — one edge per accepted `text(...)` " + "form, each resolved to the node its spelling names (SPEC 2.4, 2.3)", @@ -295,17 +356,52 @@ const T2_4_1 = defineProductTest({ // T2.4-2 // --------------------------------------------------------------------------- -// The dynamic forms of SPEC 2.4, each run in `d` and in `text(...)`, in MDX. -// Every arm's workspace is otherwise valid — `specs/BASE.mdx` provides the -// `auth` node the chain forms name, and the local `alpha` node exists so a -// product wrongly treating the template literal as a static string would -// resolve it and exit 0 (caught by the exit-1 expectation) — making the one -// dynamic reference the only condition present (the exact count has teeth). +// The dynamic forms of SPEC 2.4, each run in `d` and in `text(...)`, in MDX, +// and beside them the TypeScript-only spellings a spec source's grammar does +// not derive. Every arm's workspace is otherwise valid — `specs/BASE.mdx` +// provides the `auth` node the chain forms name, and the local `alpha` node +// exists so a product wrongly treating the template literal as a static +// string would resolve it and exit 0 (caught by the exit-1 expectation) — +// making the one offending construct the only condition present (the exact +// count has teeth). +// +// Two kinds of arm (SPEC 2.4): a form ECMAScript 2024 derives that makes the +// reference dynamic is 14.8 — the "another meaning" case `d={BASE.a<X>y}` +// included: two comparisons, a well-formed container holding no static +// chain, never 14.20, its finding located at the whole expression (SPEC 14; +// T14-11) — while TypeScript-only syntax (a non-null assertion `BASE.a!`, a +// type assertion `BASE.a as X`) is a parse failure of the file, 14.20 alone +// and never 14.8, with the one zero-length range at the offset SPEC 14's +// syntax-failure rule fixes: the byte length of the longest whole-character +// prefix of the file with which some well-formed file begins. Precomputed +// per form from the fixture's exact bytes: for `BASE.a!` the byte after `!` +// — the closing brace in `d`, the closing parenthesis in `text(...)` — since +// `!` may begin `!=`, so the prefix through `!` begins a well-formed file; +// for `BASE.a as X` the offset of `as`, no well-formed file continuing a +// member expression with an identifier (the prefix through the space before +// it does begin one). The stock MDX 3 parser (S-9, `deriveMdx`) rejects each +// of the four stagings at that offset or one byte before it — its own +// position, not the rule's — and the workspace builder holds each to its +// `unparseable` declaration. The TypeScript-source counterparts, where the +// same spellings are dynamic (14.8), are T4.5-3's and T4.3-2's. interface DynamicFormArm { - /** Which SPEC 2.4 dynamic form this is (failure diagnostics). */ + /** Which SPEC 2.4 form this is (failure diagnostics). */ readonly name: string; - /** The offending reference expression, used verbatim in `d` and `text`. */ + /** The offending expression, used verbatim in `d` and in `text(...)`. */ readonly expression: string; + /** + * TypeScript-only syntax (14.20, never 14.8): the syntax failure's offset + * relative to the expression's first byte, `expression.length` naming the + * byte after it (the closing brace in `d`, the closing parenthesis in + * `text(...)`). + */ + readonly unparseableAt?: number; + /** + * The `d` arm's 14.8 finding is pinned exactly at the whole expression — + * first token through last, the braces excluded (SPEC 14; T14-11) — where + * the entry fixes that range, rather than within the widened byte window. + */ + readonly exactExpressionRange?: true; } const DYNAMIC_FORM_ARMS: readonly DynamicFormArm[] = [ @@ -313,12 +409,33 @@ const DYNAMIC_FORM_ARMS: readonly DynamicFormArm[] = [ { name: "an identifier as index", expression: "BASE[key]" }, { name: "a call as index", expression: "BASE[getKey()]" }, { name: "optional chaining", expression: "BASE?.auth" }, - { name: "a non-null assertion", expression: "BASE!.auth" }, { name: "a parenthesized chain", expression: "(BASE.auth)" }, { name: "a conditional expression", expression: "true ? BASE.auth : BASE.auth", }, + { + // ECMAScript reads `BASE.auth<X>y` as two comparisons, `(BASE.auth < X) + // > y`: a well-formed container holding no static chain (SPEC 2.4). + name: 'the "another meaning" case, two comparisons', + expression: "BASE.auth<X>y", + exactExpressionRange: true, + }, + { + // `!` may begin `!=`: the prefix through `!` begins a well-formed file, + // the one through the next byte (`}` or `)`) does not. + name: "a non-null assertion (TypeScript-only)", + expression: "BASE.auth!", + unparseableAt: "BASE.auth!".length, + }, + { + // No well-formed file continues a member expression with an identifier: + // the prefix through the space before `as` begins one, the one through + // its `a` does not. + name: "a type assertion (TypeScript-only)", + expression: "BASE.auth as X", + unparseableAt: "BASE.auth ".length, + }, ]; // Shared preamble of every dynamic-form fixture: the import the chain forms @@ -328,31 +445,252 @@ const DYNAMIC_FORM_ARMS: readonly DynamicFormArm[] = [ const DYNAMIC_ARM_PREAMBLE = 'import BASE from "./BASE.xspec"\n\n<S id="alpha">\nAlpha behavior.\n</S>\n\n'; -const DYNAMIC_ARM_BASE_FILES = { - "specs/BASE.mdx": '<S id="auth">\nAuth behavior.\n</S>\n', +// The module every arm stages beside `specs/A.mdx`, as a staged-source +// record: every arm workspace after the body's first is created after its +// first invocation, and T14-11 re-stages the TypeScript-only forms' +// workspaces after its own (S-9's timing clause). +const DYNAMIC_ARM_BASE_RECORDS = { + "specs/BASE.mdx": stagedMdx( + "T2.4-2/T14-11 specs/BASE.mdx", + '<S id="auth">\nAuth behavior.\n</S>\n', + ), } as const; +/** The byte range of `expression` when it follows `prefix` in the file. */ +function expressionRange( + prefix: string, + expression: string, +): { readonly start: number; readonly end: number } { + const start = Buffer.byteLength(prefix, "utf8"); + return { start, end: start + Buffer.byteLength(expression, "utf8") }; +} + /** - * Run one rejected-form arm: `build --json` exits 1 with exactly one finding, - * condition 14.8, located within the offending construct's own byte window in - * `specs/A.mdx` (SPEC 14: errors identify file and location). + * Assert a located finding's concern exactly (SPEC 14, 12.7): `path` null, + * and exactly one location — in specs/A.mdx, at exactly `range`. */ -async function runRejectedFormArm( +function assertT242FindingRange( + finding: Finding, + range: { readonly start: number; readonly end: number }, + context: string, +): void { + assertSameJson( + finding.path, + null, + `${context} — a located condition's concerned path is null (SPEC 12.7)`, + ); + assertSameJson( + finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + [{ file: "specs/A.mdx", range: { start: range.start, end: range.end } }], + `${context} (message: ${JSON.stringify(finding.message)})`, + ); +} + +/** One arm's staged `specs/A.mdx` and where its offending bytes stand. */ +interface RejectedFormStaging { + /** The whole staged source. */ + readonly source: string; + /** + * The offending construct's byte window — the opening tag carrying the + * braced `d` value, or the embedding container (end-widened by one byte, + * `byteWindow`). + */ + readonly window: { readonly start: number; readonly end: number }; + /** The expression's own byte range, first token through last. */ + readonly expression: { readonly start: number; readonly end: number }; +} + +/** + * One form's two stagings of `specs/A.mdx` (SPEC 2.4: each form is run in + * `d` and in `text(...)`): in `d`, the offending construct is the opening + * tag carrying the braced reference (2.7: a braced `d` value that is not a + * static reference or array literal of them is a dynamic argument, 14.8), + * its expression starting right after `d={`; in `text(...)`, the embedding + * container on its own line inside an otherwise valid section, its + * expression starting right after `{text(`. + */ +function stageDynamicForm(arm: DynamicFormArm): { + readonly d: RejectedFormStaging; + readonly text: RejectedFormStaging; +} { + const dTagPrefix = '<S id="bad" d={'; + const dConstruct = `${dTagPrefix}${arm.expression}}>`; + const textPrefix = DYNAMIC_ARM_PREAMBLE + '<S id="bad">\n'; + const callPrefix = "{text("; + const textConstruct = `${callPrefix}${arm.expression})}`; + return { + d: { + source: DYNAMIC_ARM_PREAMBLE + dConstruct + "\nBad reference.\n</S>\n", + window: byteWindow(DYNAMIC_ARM_PREAMBLE, dConstruct), + expression: expressionRange( + DYNAMIC_ARM_PREAMBLE + dTagPrefix, + arm.expression, + ), + }, + text: { + source: textPrefix + textConstruct + "\n</S>\n", + window: byteWindow(textPrefix, textConstruct), + expression: expressionRange(textPrefix + callPrefix, arm.expression), + }, + }; +} + +/** + * A TypeScript-only form's syntax-failure offset: `unparseableAt` bytes past + * its expression's start (SPEC 14's rule, precomputed per form above). + */ +function syntaxFailureOffset( + staging: RejectedFormStaging, + unparseableAt: number, +): number { + return staging.expression.start + unparseableAt; +} + +/** + * One arm's two stagings (`stageDynamicForm`) with their staged-source + * records, evaluated once at module load: every arm workspace after the + * body's first is created after its first product invocation (S-9's timing + * clause), and the table converts uniformly. A TypeScript-only form's two + * records are declared unparseable (14.20) — the very records the exported + * `T2_4_2_UNPARSEABLE_STAGINGS` carries, which T14-11 re-stages after its own + * invocations. + */ +interface DynamicFormStagings { + readonly arm: DynamicFormArm; + readonly d: RejectedFormStaging; + readonly text: RejectedFormStaging; + /** `d.source` and `text.source` as staged-source records. */ + readonly records: { readonly d: StagedMdx; readonly text: StagedMdx }; +} + +const DYNAMIC_FORM_STAGINGS: readonly DynamicFormStagings[] = + DYNAMIC_FORM_ARMS.map((arm): DynamicFormStagings => { + const { d, text } = stageDynamicForm(arm); + const [ids, mdx] = + arm.unparseableAt === undefined + ? (["T2.4-2", "well-formed"] as const) + : (["T2.4-2/T14-11", "unparseable"] as const); + return { + arm, + d, + text, + records: { + d: stagedMdx(`${ids} ${arm.name} in \`d\` specs/A.mdx`, d.source, mdx), + text: stagedMdx( + `${ids} ${arm.name} in \`text(...)\` specs/A.mdx`, + text.source, + mdx, + ), + }, + }; + }); + +/** + * The TypeScript-only forms' stagings — the very records the arms below + * drive, `specs/A.mdx` beside `specs/BASE.mdx`, in `d` and in `text(...)` — + * each with its syntax failure's offset, exported for T14-11's re-assertion + * of the offsets the same way (TEST-SPEC T14-11's closing clause); each + * `specs/A.mdx` record is declared unparseable (S-9). + */ +export const T2_4_2_UNPARSEABLE_STAGINGS: readonly UnparseableStaging[] = + DYNAMIC_FORM_STAGINGS.flatMap(({ arm, d, text, records }) => { + const { unparseableAt } = arm; + if (unparseableAt === undefined) { + return []; + } + return ( + [ + ["`d`", d, records.d], + ["`text(...)`", text, records.text], + ] as const + ).map(([position, staging, record]): UnparseableStaging => ({ + name: `${arm.name} in ${position}: \`${arm.expression}\` (T2.4-2)`, + kind: "spec-source", + file: "specs/A.mdx", + files: { ...DYNAMIC_ARM_BASE_RECORDS, "specs/A.mdx": record }, + offset: syntaxFailureOffset(staging, unparseableAt), + })); + }); + +/** + * Run one dynamic-form arm: `build --json` exits 1 with exactly one finding, + * condition 14.8, located within the offending construct's own byte window + * in `specs/A.mdx` (SPEC 14: errors identify file and location) — or, where + * `exactRange` is given, exactly at that range (SPEC 14; T14-11). `source` + * is `staging.source` as its staged-source record. + */ +async function runDynamicFormArm( product: ProductBinding, - source: string, - window: { readonly start: number; readonly end: number }, + staging: RejectedFormStaging, + source: StagedMdx, + exactRange: { readonly start: number; readonly end: number } | undefined, context: string, ): Promise<void> { await withWorkspace( SPECS_ONLY_CONFIG, - { ...DYNAMIC_ARM_BASE_FILES, "specs/A.mdx": source }, + { ...DYNAMIC_ARM_BASE_RECORDS, "specs/A.mdx": source }, async (workspace) => { const findings = await buildFindings(product, workspace, context); - assertConditionCounts(findings, { "14.8": 1 }, context); - assertFindingLocated( + assertConditionCounts( + findings, + { "14.8": 1 }, + `${context} — a form ECMAScript 2024 derives that makes the ` + + `reference dynamic: 14.8, never 14.20 (SPEC 2.4)`, + ); + if (exactRange === undefined) { + assertFindingLocated( + findings[0]!, + { file: "specs/A.mdx", window: staging.window }, + `${context}: the 14.8 finding`, + ); + } else { + assertT242FindingRange( + findings[0]!, + exactRange, + `${context}: the 14.8 finding, located at the whole expression ` + + `its braces enclose — first token through last, the braces ` + + `excluded (SPEC 14; T14-11)`, + ); + } + }, + ); +} + +/** + * Run one TypeScript-only arm: the file is not well-formed MDX (SPEC 2.4, + * 14.20), so `build --json` exits 1 with exactly one finding, 14.20 — the + * masked file reports nothing else, never 14.8 — carrying the one + * zero-length range at `offset`, the syntax failure's offset under SPEC 14's + * rule. S-9: `source` is the staging's record, declared unparseable. + */ +async function runUnparseableFormArm( + product: ProductBinding, + source: StagedMdx, + offset: number, + context: string, +): Promise<void> { + await withWorkspace( + SPECS_ONLY_CONFIG, + { ...DYNAMIC_ARM_BASE_RECORDS, "specs/A.mdx": source }, + async (workspace) => { + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.20": 1 }, + `${context} — TypeScript-only syntax in a spec source is a parse ` + + `failure of the file: 14.20 alone, never a dynamic reference ` + + `(SPEC 2.4, 14.20)`, + ); + assertT242FindingRange( findings[0]!, - { file: "specs/A.mdx", window }, - `${context}: the 14.8 finding`, + { start: offset, end: offset }, + `${context} — the one zero-length range at the offset SPEC 14's ` + + `syntax-failure rule fixes: the byte length of the longest ` + + `whole-character prefix with which some well-formed file begins ` + + `(SPEC 14; T14-11)`, ); }, ); @@ -361,30 +699,51 @@ async function runRejectedFormArm( const T2_4_2 = defineProductTest({ id: "T2.4-2", title: - "each dynamic form — template literal argument; identifier or call as index; optional chaining; non-null assertion; parenthesized chain; conditional expression — fails with 14.8, in `d` and in `text(...)`, in MDX (SPEC 2.4, 14.8)", + 'each dynamic form — template literal argument; identifier or call as index; optional chaining; parenthesized chain; conditional expression; the "another meaning" case `BASE.a<X>y`, two comparisons, its `d` finding at the whole expression — fails with 14.8, in `d` and in `text(...)`, in MDX, while TypeScript-only syntax — a non-null assertion, a type assertion — is 14.20 alone at the offset SPEC 14\'s syntax-failure rule fixes (SPEC 2.4, 14.8, 14.20)', run: async (product) => { - for (const arm of DYNAMIC_FORM_ARMS) { - // In `d`: the offending construct is the opening tag carrying the - // braced reference (SPEC 2.7: a braced `d` value that is not a static - // reference or array literal of them is a dynamic argument, 14.8). - const dConstruct = `<S id="bad" d={${arm.expression}}>`; - await runRejectedFormArm( - product, - DYNAMIC_ARM_PREAMBLE + dConstruct + "\nBad reference.\n</S>\n", - byteWindow(DYNAMIC_ARM_PREAMBLE, dConstruct), - `T2.4-2 \`build --json\` with ${arm.name} in \`d\``, - ); + for (const entry of DYNAMIC_FORM_STAGINGS) { + // The form in `d` and in `text(...)` (`stageDynamicForm`), each staged + // as its record: the TypeScript-only forms' records are the ones the + // exported `T2_4_2_UNPARSEABLE_STAGINGS` carries. + const { arm, d: dStaging, text: textStaging, records } = entry; + const { unparseableAt } = arm; + const dContext = `T2.4-2 \`build --json\` with ${arm.name} in \`d\``; + if (unparseableAt === undefined) { + await runDynamicFormArm( + product, + dStaging, + records.d, + arm.exactExpressionRange === true ? dStaging.expression : undefined, + dContext, + ); + } else { + await runUnparseableFormArm( + product, + records.d, + syntaxFailureOffset(dStaging, unparseableAt), + dContext, + ); + } - // In `text(...)`: the offending construct is the embedding expression - // on its own line inside an otherwise valid section. - const textConstruct = `{text(${arm.expression})}`; - const textPrefix = DYNAMIC_ARM_PREAMBLE + '<S id="bad">\n'; - await runRejectedFormArm( - product, - textPrefix + textConstruct + "\n</S>\n", - byteWindow(textPrefix, textConstruct), - `T2.4-2 \`build --json\` with ${arm.name} in \`text(...)\``, - ); + const textContext = `T2.4-2 \`build --json\` with ${arm.name} in \`text(...)\``; + if (unparseableAt === undefined) { + // An embedding's 14.8 is located by its full braced container (SPEC + // 14), asserted within the construct's window as the sibling arms are. + await runDynamicFormArm( + product, + textStaging, + records.text, + undefined, + textContext, + ); + } else { + await runUnparseableFormArm( + product, + records.text, + syntaxFailureOffset(textStaging, unparseableAt), + textContext, + ); + } } }, }); @@ -402,9 +761,33 @@ const T2_4_2 = defineProductTest({ const ARITY_PREAMBLE = '<S id="alpha">\nAlpha behavior.\n</S>\n\n<S id="beta">\nBeta behavior.\n</S>\n\n<S id="bad">\n'; -const ARITY_ARMS: readonly { name: string; construct: string }[] = [ - { name: "zero arguments", construct: "{text()}" }, - { name: "two arguments", construct: '{text("alpha", "beta")}' }, +/** One arity arm: the call, and the file it stands in as a record. */ +interface ArityArm { + readonly name: string; + readonly construct: string; + /** The preamble, the call, and the section's close, as `specs/A.mdx`. */ + readonly source: StagedMdx; +} + +/** + * Compose an arm's file into its staged-source record — the second arm's + * workspace is created after the body's first invocation (S-9's timing + * clause), and the table converts uniformly. + */ +function arityArm(name: string, construct: string): ArityArm { + return { + name, + construct, + source: stagedMdx( + `T2.4-3 text called with ${name} specs/A.mdx`, + ARITY_PREAMBLE + construct + "\n</S>\n", + ), + }; +} + +const ARITY_ARMS: readonly ArityArm[] = [ + arityArm("zero arguments", "{text()}"), + arityArm("two arguments", '{text("alpha", "beta")}'), ]; const T2_4_3 = defineProductTest({ @@ -416,7 +799,7 @@ const T2_4_3 = defineProductTest({ const context = `T2.4-3 \`build --json\` with \`text\` called with ${arm.name}`; await withWorkspace( SPECS_ONLY_CONFIG, - { "specs/A.mdx": ARITY_PREAMBLE + arm.construct + "\n</S>\n" }, + { "specs/A.mdx": arm.source }, async (workspace) => { const findings = await buildFindings(product, workspace, context); assertConditionCounts(findings, { "14.8": 1 }, context); @@ -444,8 +827,13 @@ const T2_4_3 = defineProductTest({ // resolves to nothing — its single segment `a.b` can name no node — while // `BASE["a"]["b"]` and `BASE.a.b` resolve to node `a.b` and the same-file // local string `d={"a.b"}` names the whole dotted *path* (SPEC 2.2). -const SEGMENT_EXACT_BASE = - '<S id="a">\nA text.\n\n<S id="a.b">\nB text.\n</S>\n</S>\n'; +// Staged in all four arms' workspaces, the three after the first created +// after the body's first invocation — a staged-source record (S-9's +// timing clause). +const SEGMENT_EXACT_BASE = stagedMdx( + "T2.4-4 specs/BASE.mdx", + '<S id="a">\nA text.\n\n<S id="a.b">\nB text.\n</S>\n</S>\n', +); // 14.5 arm: the dotted computed index in `d`. const T2_4_4_D_PREFIX = 'import BASE from "./BASE.xspec"\n\n'; @@ -456,8 +844,11 @@ const T2_4_4_D_SOURCE = // 14.6 arm: the same chain as the `text(...)` argument. const T2_4_4_TEXT_PREFIX = 'import BASE from "./BASE.xspec"\n\n<S id="bad">\n'; const T2_4_4_TEXT_CONSTRUCT = '{text(BASE["a.b"])}'; -const T2_4_4_TEXT_SOURCE = - T2_4_4_TEXT_PREFIX + T2_4_4_TEXT_CONSTRUCT + "\n</S>\n"; +// The 14.6 arm's workspace follows the 14.5 arm's invocation: a record. +const T2_4_4_TEXT_SOURCE = stagedMdx( + "T2.4-4 text arm specs/A.mdx", + T2_4_4_TEXT_PREFIX + T2_4_4_TEXT_CONSTRUCT + "\n</S>\n", +); // 14.7 arm: the same chain as a TypeScript dependency marker (SPEC 4.5). The // file is staged valid first — `BASE.a` is a resolving marker — so the @@ -466,12 +857,18 @@ const T2_4_4_TEXT_SOURCE = // generates the spec module (13.1), and the failing second build modifies // nothing (12.1), leaving the generated module in place for the type-error // arm compiled under standard TypeScript tooling (HARNESS-05, SPEC 13.1). -const T2_4_4_VALID_CONSUMER = - 'import BASE from "../specs/BASE.xspec";\n\nBASE.a;\n'; +// The arm's workspace is created after the body's first invocation, so both +// consumers are staged-source records (S-9's timing clause). +const T2_4_4_VALID_CONSUMER = stagedTs( + "T2.4-4 marker arm src/app.ts — the resolving marker `BASE.a`", + 'import BASE from "../specs/BASE.xspec";\n\nBASE.a;\n', +); const T2_4_4_MARKER_PREFIX = 'import BASE from "../specs/BASE.xspec";\n\n'; const T2_4_4_MARKER_CONSTRUCT = 'BASE["a.b"];'; -const T2_4_4_MARKER_CONSUMER = - T2_4_4_MARKER_PREFIX + T2_4_4_MARKER_CONSTRUCT + "\n"; +const T2_4_4_MARKER_CONSUMER = stagedTs( + "T2.4-4 marker arm src/app.ts — the dotted computed index, staged after `build`", + T2_4_4_MARKER_PREFIX + T2_4_4_MARKER_CONSTRUCT + "\n", +); // Positive arms: segment-per-index computed chain and dot chain resolve to // node `a.b`; the local string names the dotted path within the declaring @@ -479,30 +876,34 @@ const T2_4_4_MARKER_CONSUMER = // same-named nodes are the decoys — a product resolving the local string in // the imported file records `to: specs/BASE.mdx#a.b` and fails the exact // edge-set comparison. -const T2_4_4_POSITIVE_SOURCE = [ - 'import BASE from "./BASE.xspec"', - "", - '<S id="a">', - "Local a text.", - "", - '<S id="a.b">', - "Local b text.", - "</S>", - "</S>", - "", - '<S id="viaBrackets" d={BASE["a"]["b"]}>', - "Segment-per-index computed chain.", - "</S>", - "", - '<S id="viaDots" d={BASE.a.b}>', - "Dot chain.", - "</S>", - "", - '<S id="viaLocal" d={"a.b"}>', - "Local string naming the two-segment path.", - "</S>", - "", -].join("\n"); +// The positive arms' workspace is the body's last: a record. +const T2_4_4_POSITIVE_SOURCE = stagedMdx( + "T2.4-4 positive arms specs/A.mdx", + [ + 'import BASE from "./BASE.xspec"', + "", + '<S id="a">', + "Local a text.", + "", + '<S id="a.b">', + "Local b text.", + "</S>", + "</S>", + "", + '<S id="viaBrackets" d={BASE["a"]["b"]}>', + "Segment-per-index computed chain.", + "</S>", + "", + '<S id="viaDots" d={BASE.a.b}>', + "Dot chain.", + "</S>", + "", + '<S id="viaLocal" d={"a.b"}>', + "Local string naming the two-segment path.", + "</S>", + "", + ].join("\n"), +); const T2_4_4 = defineProductTest({ id: "T2.4-4", @@ -651,10 +1052,621 @@ const T2_4_4 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T2.4-5 +// --------------------------------------------------------------------------- + +// Verbatim literals (SPEC 2.4): the value of a static string literal is the +// characters between its delimiters exactly as spelled — no escape sequence +// or character reference interpreted — so a spelling whose interpreted value +// would name a node names nothing: a local value containing `\` or `&` names +// no valid identity (1.4) and a computed key spelled with an escape is a +// segment no node has. Every negative arm is staged beside the node its +// interpreted value would name — the local `login` and local path `a.b` in +// the declaring file, and `a.b` and `login` in the imported BASE — so a +// product interpreting the spelling resolves it, reports nothing for it, +// and fails the exact condition counts. (The escape-spelled computed key +// `BASE["a\u002Eb"]` is 14.5 for a verbatim reader and for an interpreting +// one alike — its interpreted key `a.b` is a single segment naming nothing, +// T2.4-4 — while `text(BASE["\u0061"]["b"])` discriminates: interpreted, +// its keys would resolve to `a.b`.) +// +// The code-source half: the marker `BASE.lo\u0067in` — a chain segment +// carrying a Unicode escape spells a name containing `\`, which no segment +// contains (2.4) — is 14.7, records no edge and no occurrence (5.7), while +// the escape-free control `BASE.login` beside it records its edge, witnessed +// through `occurrences` (11.3), which answers on the failing workspace +// (11.2) — `query edges` does not (13.3), so the edge behind the control's +// occurrence record is the witness, and a second record for the escaped +// spelling (its tuple identical to the control's) is a phantom the exact +// multiset rejects. The discriminating half rides H-2's standard-tooling +// channel: the consumer file type-checks clean against the prior valid +// generation (TypeScript reads the escaped identifier as `login`, which the +// generated module exports), so a product deriving resolution from the +// interpreted name — no finding, an edge, a record — fails this arm, and +// 14.7's type-error clause is not what makes the finding (SPEC 14.7: it +// holds only for a spelling free of escape sequences). +// +// The MDX half of the segment rule, in the same failing workspace: the +// same segment escape in a spec source — `d={BASE.lo\u0067in}` and +// `{text(BASE.lo\u0067in)}`, BASE's module holding `login` — is 14.5 and +// 14.6 respectively and records no edge and no occurrence, though ECMAScript +// decodes the escape to `login`: a spec source reaches its expressions +// through ECMAScript 2024's grammar (14.20), never TypeScript's, so the +// code-source arm cannot stand in for it, and a product resolving through +// the decoded name records both edges (and their occurrences) and drops +// both findings. The escape-free control `d={BASE.login}` beside them +// records its edge, witnessed by its occurrence record, as the code-source +// control's is. +// +// The escaped root, in a second workspace that is valid: which binding roots +// a chain is the language's scoping question, never a spelling one (2.4, +// 2.1, 4.5). `d={B\u0041SE.login}` and `{text(B\u0041SE.login)}` in MDX +// and the marker `B\u0041SE.login` in a code source, each segment +// escape-free, are rooted at the `BASE` import binding (ECMAScript and +// TypeScript alike read the escaped identifier reference as `BASE`), so the +// workspace builds with no finding and each spelling records its edge +// (`query edges` answers there) and its occurrence — failing a product that +// reads the root as spelled too, finds no binding named `B\u0041SE`, and +// reports the reference (14.5, 14.6) or passes the marker over. Each staged +// MDX file derives (S-9), and each code file is text TypeScript 5.9.3 +// accepts both as module code and as script code (14.20) — the builder's +// staging-time judges hold every one of them to that. +// +// The escape spellings are composed from the backslash's code point (the +// Task 18 pattern) so they stand in the staged files as their six-character +// sequences, never decoded on the way in; the character reference `.` is +// plain ASCII. Fixtures stay pure ASCII, so string indices are byte offsets. +const BACKSLASH = String.fromCodePoint(0x5c); +/** The ten characters `lo\u0067in` as spelled; interpreted they read `login`. */ +const ESCAPED_LOGIN = `lo${BACKSLASH}u0067in`; +/** The eight characters `a\u002Eb` as spelled; interpreted they read `a.b`. */ +const ESCAPED_DOTTED_KEY = `a${BACKSLASH}u002Eb`; +/** The six characters `\u0061` as spelled; interpreted they read `a`. */ +const ESCAPED_A_KEY = `${BACKSLASH}u0061`; +/** + * The nine characters `B\u0041SE` as spelled: an escaped identifier + * reference, which ECMAScript and TypeScript alike read as `BASE`. + */ +const ESCAPED_ROOT = `B${BACKSLASH}u0041SE`; +/** The character-reference spelling whose interpreted value reads `a.b`. */ +const REFERENCE_DOTTED_PATH = "a.b"; + +// The imported module: `login` (what the escaped marker's interpreted name, +// and TypeScript's reading of it, would name) and `a` with child `a.b` (what +// the interpreted computed keys would name). +const T2_4_5_BASE = [ + '<S id="login">', + "The node an interpreted escape spelling would name.", + "</S>", + "", + '<S id="a">', + "A text.", + "", + '<S id="a.b">', + "B text.", + "</S>", + "</S>", + "", +].join("\n"); + +// The declaring file's valid head — the initial state (its import unused, +// SPEC 2.1) — holding the local `login` and the local path `a.b` the +// interpreted local spellings would name. +const T2_4_5_HEAD = [ + 'import BASE from "./BASE.xspec"', + "", + '<S id="login">', + "The local node an interpreted escape spelling would name.", + "</S>", + "", + '<S id="a">', + "Local a text.", + "", + '<S id="a.b">', + "Local b text.", + "</S>", + "</S>", + "", + "", +].join("\n"); + +/** One verbatim-literal spelling and the condition it must report. */ +interface VerbatimArm { + /** The spelling as it stands in the source (failure diagnostics). */ + readonly name: string; + readonly condition: "14.5" | "14.6"; + /** The offending construct: the opening tag for `d`, the container for `text`. */ + readonly construct: string; +} + +// The escape-free control `d={BASE.login}` beside the MDX segment-escape +// arms: its tag's head and its one reference — the span its occurrence +// record covers (SPEC 5.7: a `d` reference occurrence spans that one +// reference's own expression). +const T2_4_5_MDX_CONTROL_HEAD = '<S id="ctl" d={'; +const T2_4_5_MDX_CONTROL_CHAIN = "BASE.login"; + +// specs/A.mdx after the edit, as an exact sequence of parts: the head, then +// each arm's construct with its surrounding text, so every construct's byte +// window follows from the parts before it. Arm order within a condition is +// source order — the order `findingsInSourceOrder` selects. The last two +// arms are the MDX segment escapes, the escape-free control after them. +const T2_4_5_MDX_PARTS: readonly (string | VerbatimArm)[] = [ + T2_4_5_HEAD, + { + name: `d={"${ESCAPED_LOGIN}"}`, + condition: "14.5", + construct: `<S id="e1" d={"${ESCAPED_LOGIN}"}>`, + }, + "\nEscape-spelled local dependency.\n</S>\n\n", + '<S id="e2">\n', + { + name: `{text("${ESCAPED_LOGIN}")}`, + condition: "14.6", + construct: `{text("${ESCAPED_LOGIN}")}`, + }, + "\n</S>\n\n", + { + name: `d={"${REFERENCE_DOTTED_PATH}"}`, + condition: "14.5", + construct: `<S id="e3" d={"${REFERENCE_DOTTED_PATH}"}>`, + }, + "\nReference-spelled local dependency.\n</S>\n\n", + { + name: `d={BASE["${ESCAPED_DOTTED_KEY}"]}`, + condition: "14.5", + construct: `<S id="e4" d={BASE["${ESCAPED_DOTTED_KEY}"]}>`, + }, + "\nEscape-spelled computed key.\n</S>\n\n", + '<S id="e5">\n', + { + name: `{text(BASE["${ESCAPED_A_KEY}"]["b"])}`, + condition: "14.6", + construct: `{text(BASE["${ESCAPED_A_KEY}"]["b"])}`, + }, + "\n</S>\n\n", + { + name: `d={BASE.${ESCAPED_LOGIN}}`, + condition: "14.5", + construct: `<S id="e6" d={BASE.${ESCAPED_LOGIN}}>`, + }, + "\nEscape-spelled chain segment.\n</S>\n\n", + '<S id="e7">\n', + { + name: `{text(BASE.${ESCAPED_LOGIN})}`, + condition: "14.6", + construct: `{text(BASE.${ESCAPED_LOGIN})}`, + }, + "\n</S>\n\n", + T2_4_5_MDX_CONTROL_HEAD, + T2_4_5_MDX_CONTROL_CHAIN, + "}>\nThe escape-free control beside the escape-spelled segments.\n</S>\n", +]; + +/** The edited specs/A.mdx: the parts in order. */ +const T2_4_5_MDX_TEXT = T2_4_5_MDX_PARTS.map((part) => + typeof part === "string" ? part : part.construct, +).join(""); + +// Staged into specs/A.mdx after the valid initial build — a staged-source +// record, judged before any product exists (S-9, +// test/self/s9-staged-sources.test.ts). +const T2_4_5_MDX_SOURCE = stagedMdx( + "T2.4-5 specs/A.mdx with the eight verbatim spellings and the escape-free control", + T2_4_5_MDX_TEXT, +); + +/** The staged arms of one condition in source order, each with its byte window. */ +function verbatimArmsOf( + condition: VerbatimArm["condition"], +): readonly { arm: VerbatimArm; window: { start: number; end: number } }[] { + const arms: { arm: VerbatimArm; window: { start: number; end: number } }[] = + []; + let prefix = ""; + for (const part of T2_4_5_MDX_PARTS) { + if (typeof part === "string") { + prefix += part; + continue; + } + if (part.condition === condition) { + arms.push({ arm: part, window: byteWindow(prefix, part.construct) }); + } + prefix += part.construct; + } + return arms; +} + +// src/app.ts: the escape-free control marker at the top level (the initial +// state, valid), then — the edit — the escape-spelled marker beside it, a +// staged-source record (S-9's timing clause: staged after the initial +// `build`). +const T2_4_5_APP_PREFIX = 'import BASE from "../specs/BASE.xspec";\n\n'; +const T2_4_5_APP_CONTROL_CHAIN = "BASE.login"; +const T2_4_5_APP_INITIAL = `${T2_4_5_APP_PREFIX}${T2_4_5_APP_CONTROL_CHAIN};\n`; +const T2_4_5_APP_ESCAPED_MARKER = `BASE.${ESCAPED_LOGIN};`; +const T2_4_5_APP_EDITED = stagedTs( + "T2.4-5 src/app.ts — the escape-spelled marker beside the control, staged after `build`", + `${T2_4_5_APP_INITIAL}${T2_4_5_APP_ESCAPED_MARKER}\n`, +); + +// The control's occurrence: from the whole file (no named unit encloses it, +// SPEC 4.6) to BASE's `login`, spanning the bare chain alone, exclusive of +// the statement terminator (5.7). +const T2_4_5_CONTROL_UNIT: OccurrenceUnit = { + what: "the escape-free control marker `BASE.login` at the top level of src/app.ts", + file: "src/app.ts", + kind: "references", + source: "src/app.ts", + target: "specs/BASE.mdx#login", + count: 1, +}; +/** The exact byte range (SPEC 1.7) of `spelling` staged right after `prefix`. */ +function rangeAfter(prefix: string, spelling: string): SourceRange { + const start = Buffer.byteLength(prefix, "utf8"); + return { start, end: start + Buffer.byteLength(spelling, "utf8") }; +} +const T2_4_5_CONTROL_RANGE = rangeAfter( + T2_4_5_APP_PREFIX, + T2_4_5_APP_CONTROL_CHAIN, +); + +// The MDX control's occurrence: from its section `ctl` to BASE's `login`, +// spanning the one reference's own expression `BASE.login`, the braces +// excluded (SPEC 5.7). +const T2_4_5_MDX_CONTROL_UNIT: OccurrenceUnit = { + what: "the escape-free control `d={BASE.login}` in specs/A.mdx", + file: "specs/A.mdx", + kind: "depends", + source: "specs/A.mdx#ctl", + target: "specs/BASE.mdx#login", + count: 1, +}; +const T2_4_5_MDX_CONTROL_RANGE = rangeAfter( + T2_4_5_MDX_TEXT.slice( + 0, + T2_4_5_MDX_TEXT.indexOf(T2_4_5_MDX_CONTROL_HEAD) + + T2_4_5_MDX_CONTROL_HEAD.length, + ), + T2_4_5_MDX_CONTROL_CHAIN, +); + +/** One expected occurrence record: its unit, and the span its spelling occupies. */ +interface ExpectedOccurrence { + readonly unit: OccurrenceUnit; + readonly spelling: string; + readonly range: SourceRange; +} + +/** The failing workspace's two records: both escape-free controls. */ +const T2_4_5_CONTROL_OCCURRENCES: readonly ExpectedOccurrence[] = [ + { + unit: T2_4_5_MDX_CONTROL_UNIT, + spelling: T2_4_5_MDX_CONTROL_CHAIN, + range: T2_4_5_MDX_CONTROL_RANGE, + }, + { + unit: T2_4_5_CONTROL_UNIT, + spelling: T2_4_5_APP_CONTROL_CHAIN, + range: T2_4_5_CONTROL_RANGE, + }, +]; + +// The escaped-root workspace (valid): specs/A.mdx holds +// `d={B\u0041SE.login}` in section `r1` and `{text(B\u0041SE.login)}` in +// section `r2`, src/app.ts the marker `B\u0041SE.login` at its top level, +// and BASE's module holds `login`. The workspace is created after the body's +// first product invocation, so each staged source is a record (S-9's timing +// clause), the configuration included (SPEC_AND_CODE_CONFIG). +const T2_4_5_ROOT_CHAIN = `${ESCAPED_ROOT}.login`; +const T2_4_5_ROOT_D_PREFIX = + 'import BASE from "./BASE.xspec"\n\n<S id="r1" d={'; +const T2_4_5_ROOT_EMBED = `{text(${T2_4_5_ROOT_CHAIN})}`; +const T2_4_5_ROOT_EMBED_PREFIX = + `${T2_4_5_ROOT_D_PREFIX}${T2_4_5_ROOT_CHAIN}}>\n` + + "Escaped-root dependency.\n</S>\n\n" + + '<S id="r2">\n'; +const T2_4_5_ROOT_BASE = stagedMdx( + "T2.4-5 specs/BASE.mdx — the escaped-root workspace's imported module", + T2_4_5_BASE, +); +const T2_4_5_ROOT_MDX = stagedMdx( + "T2.4-5 specs/A.mdx — the escaped-root `d` and `text(...)` arms", + `${T2_4_5_ROOT_EMBED_PREFIX}${T2_4_5_ROOT_EMBED}\n</S>\n`, +); +const T2_4_5_ROOT_APP = stagedTs( + "T2.4-5 src/app.ts — the escaped-root marker at the top level", + `${T2_4_5_APP_PREFIX}${T2_4_5_ROOT_CHAIN};\n`, +); + +// Each escaped-root spelling's record, from the node that holds it to BASE's +// `login` (SPEC 5.7): the `d` reference spans its own expression, the +// embedding its whole `{text(...)}` container, and the marker its bare chain +// (no named unit encloses it, so its source is the whole file, 4.6). +const T2_4_5_ROOT_OCCURRENCES: readonly ExpectedOccurrence[] = [ + { + unit: { + what: "the escaped-root `d` reference in section `r1` of specs/A.mdx", + file: "specs/A.mdx", + kind: "depends", + source: "specs/A.mdx#r1", + target: "specs/BASE.mdx#login", + count: 1, + }, + spelling: T2_4_5_ROOT_CHAIN, + range: rangeAfter(T2_4_5_ROOT_D_PREFIX, T2_4_5_ROOT_CHAIN), + }, + { + unit: { + what: "the escaped-root embedding in section `r2` of specs/A.mdx", + file: "specs/A.mdx", + kind: "embeds", + source: "specs/A.mdx#r2", + target: "specs/BASE.mdx#login", + count: 1, + }, + spelling: T2_4_5_ROOT_EMBED, + range: rangeAfter(T2_4_5_ROOT_EMBED_PREFIX, T2_4_5_ROOT_EMBED), + }, + { + unit: { + what: "the escaped-root marker at the top level of src/app.ts", + file: "src/app.ts", + kind: "references", + source: "src/app.ts", + target: "specs/BASE.mdx#login", + count: 1, + }, + spelling: T2_4_5_ROOT_CHAIN, + range: rangeAfter(T2_4_5_APP_PREFIX, T2_4_5_ROOT_CHAIN), + }, +]; + +/** + * The complete occurrence answer is exactly the expected records: the + * (file, kind, source, target) multiset first, then each record's exact span + * (SPEC 5.7), found by its tuple — which the multiset has pinned to occur + * exactly once — so a record at another spelling's span fails by name. + */ +function assertOccurrencesExactly( + records: readonly OccurrenceRecord[], + expected: readonly ExpectedOccurrence[], + context: string, +): void { + assertSameJson( + records.map(renderOccurrenceUnit).sort(), + expectedUnitMultiset(expected.map(({ unit }) => unit)), + `${context}: the complete (file, [kind], source -> target) record ` + + `multiset is exactly ${expected.map(({ unit }) => unit.what).join("; ")}`, + ); + for (const { unit, spelling, range } of expected) { + const [tuple] = expectedUnitMultiset([{ ...unit, count: 1 }]); + assertSameJson( + records + .filter((record) => renderOccurrenceUnit(record) === tuple) + .map((record) => record.range), + [range], + `${context}: the record for ${unit.what} spans \`${spelling}\` ` + + `exactly (SPEC 5.7)`, + ); + } +} + +/** + * The eight staged spellings and nothing else: exactly four 14.5, three + * 14.6, one 14.7 (SPEC 14: every condition reported, and nothing for the two + * escape-free controls or the nodes beside the arms), each located within + * its own construct's byte window. + */ +function assertVerbatimFindings( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts( + findings, + { "14.5": 4, "14.6": 3, "14.7": 1 }, + `${context}: exactly the eight verbatim spellings are reported — the ` + + `escape-spelled local \`d\` literal, the reference-spelled local \`d\` ` + + `literal, the escape-spelled computed key, and the escape-spelled ` + + `chain segment in \`d\` are 14.5; the escape-spelled local \`text\` ` + + `literal, the escape-spelled computed keys in \`text\`, and the ` + + `escape-spelled chain segment in \`text\` are 14.6; the escape-spelled ` + + `marker is 14.7 — and nothing for the escape-free controls ` + + `\`d={BASE.login}\` and \`BASE.login\` (SPEC 2.4, 1.4, 14.5–14.7): a ` + + `product interpreting a spelling resolves it to the node staged beside ` + + `it and drops its finding`, + ); + for (const condition of ["14.5", "14.6"] as const) { + const reported = findingsInSourceOrder(findings, condition); + const staged = verbatimArmsOf(condition); + staged.forEach(({ arm, window }, index) => { + assertFindingLocated( + reported[index]!, + { file: "specs/A.mdx", window }, + `${context}: the ${condition} finding for \`${arm.name}\` locates ` + + `that spelling's own construct (SPEC 14, 2.4)`, + ); + }); + } + assertFindingLocated( + findingsInSourceOrder(findings, "14.7")[0]!, + { + file: "src/app.ts", + window: byteWindow(T2_4_5_APP_INITIAL, T2_4_5_APP_ESCAPED_MARKER), + }, + `${context}: the 14.7 finding locates the escape-spelled marker ` + + `\`${T2_4_5_APP_ESCAPED_MARKER}\` — a segment carrying a Unicode ` + + `escape spells a name containing \`\\\`, which no segment contains, so ` + + `the reference resolves nowhere (SPEC 2.4, 1.4, 4.5, 14.7) — never ` + + `the control beside it`, + ); +} + +const T2_4_5 = defineProductTest({ + id: "T2.4-5", + title: `verbatim literals: every static string literal is read exactly as spelled, no escape sequence or character reference interpreted, so a spelling whose interpreted value would name a node names nothing — beside the local \`login\` and \`a.b\` and BASE's \`a.b\` and \`login\`, \`d={"${ESCAPED_LOGIN}"}\` and \`{text("${ESCAPED_LOGIN}")}\` are 14.5 and 14.6, \`d={"${REFERENCE_DOTTED_PATH}"}\` 14.5, \`d={BASE["${ESCAPED_DOTTED_KEY}"]}\` and \`{text(BASE["${ESCAPED_A_KEY}"]["b"])}\` 14.5 and 14.6, and the MDX segment escapes \`d={BASE.${ESCAPED_LOGIN}}\` and \`{text(BASE.${ESCAPED_LOGIN})}\` 14.5 and 14.6 though ECMAScript decodes the escape to \`login\`, each at its own construct; the TypeScript marker \`BASE.${ESCAPED_LOGIN}\` is 14.7 — the escaped spellings record no edge and no occurrence, \`occurrences\` listing exactly the escape-free controls \`d={BASE.login}\` and \`BASE.login\` beside them, each at its exact span — while the consumer file type-checks clean against the prior valid generation (TypeScript reads the escaped identifier as \`login\`, which exists), so a product resolving the interpreted name fails; the root is a scoping question, never a spelling one — \`d={${ESCAPED_ROOT}.login}\`, \`{text(${ESCAPED_ROOT}.login)}\`, and the marker \`${ESCAPED_ROOT}.login\` are rooted at the \`BASE\` binding, the workspace builds with no finding, and each records its edge and its occurrence (SPEC 2.4, 1.4, 2.1, 4.5, 5.7, 11.3, 14.5–14.7)`, + run: async (product) => { + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + "specs/BASE.mdx": T2_4_5_BASE, + "specs/A.mdx": T2_4_5_HEAD, + "src/app.ts": T2_4_5_APP_INITIAL, + }, + async (workspace) => { + // Staging: the initial state is valid, so the workspace shape (the + // configuration, both import forms, the control marker) is proven + // accepted before the verbatim spellings become the only defects, + // and the build generates the spec modules (SPEC 13.1) — the prior + // valid generation the type-check arm compiles against, which the + // failing build below leaves in place (12.1). + await buildOk( + product, + workspace, + "T2.4-5 initial `build` (staging: the valid head with the " + + "escape-free control marker; generates specs/BASE.xspec.ts, " + + "SPEC 13.1)", + ); + + await workspace.file("specs/A.mdx", T2_4_5_MDX_SOURCE); + await workspace.file("src/app.ts", T2_4_5_APP_EDITED); + + const buildContext = + "T2.4-5 `build --json` over the eight verbatim spellings and the two escape-free controls"; + assertVerbatimFindings( + await buildFindings(product, workspace, buildContext), + buildContext, + ); + + // The discriminating half (H-2's standard-tooling channel): against + // the prior valid generation, the consumer file — the control and + // the escape-spelled marker — type-checks clean, since TypeScript + // reads `BASE.lo\u0067in` as `BASE.login`, which the generated + // module exports (SPEC 4.1). So the 14.7 finding above is owed to + // the verbatim reading alone (SPEC 2.4), not to a missing property: + // 14.7's type-error clause holds only for a spelling free of escape + // sequences (14.7). + const consumer = await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: ["src/app.ts"], + }); + assertNoCompileErrors( + consumer, + `T2.4-5 the consumer file holding the control \`BASE.login\` and ` + + `the escape-spelled marker \`${T2_4_5_APP_ESCAPED_MARKER}\` must ` + + `type-check clean against the prior valid generation — ` + + `TypeScript reads the escaped identifier as \`login\`, which the ` + + `generated module exports (SPEC 14.7, 2.4, 4.1)`, + ); + + // No edge, no occurrence for the escaped marker or the two MDX + // segment escapes; each control records its edge. `occurrences` + // answers on the failing workspace, the domain's findings + // accompanying (SPEC 11.2, 11.3; exit 1), and the complete record + // multiset is exactly the two controls' records — each one's (file, + // kind, source, target) tuple and its exact span — so a record for + // an escaped spelling (a tuple identical to its control's, at + // another span) is caught by count, and a record at the escaped span + // in place of the control's by the span. + const occContext = "T2.4-5 `occurrences` over the failing workspace"; + const result = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${occContext} — an answer carrying any finding exits 1, the full ` + + `answer document still emitted (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout(result, occContext), + occContext, + ); + assertVerbatimFindings(report.findings, occContext); + assertOccurrencesExactly( + report.occurrences, + T2_4_5_CONTROL_OCCURRENCES, + `${occContext} — the escape-spelled marker and the two escape-` + + `spelled MDX segments resolve nowhere, so they record no edge and ` + + `no occurrence, while each escape-free control records its own ` + + `(SPEC 2.4, 5.7, 4.5, 11.3)`, + ); + }, + ); + + // The escaped root, in a second workspace that is valid: the root is + // the language's scoping question, never a spelling one (SPEC 2.4, + // 2.1, 4.5), so `B\u0041SE` roots each chain at the `BASE` import + // binding. The build exits 0 — no finding anywhere — and the exact edge + // sets of each kind and the exact occurrence answer pin every escaped- + // root spelling to its own edge and record: a product reading the root + // as spelled reports the MDX references unresolved (14.5, 14.6), failing + // the build, and passes the marker over, failing its edge and record. + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + "specs/BASE.mdx": T2_4_5_ROOT_BASE, + "specs/A.mdx": T2_4_5_ROOT_MDX, + "src/app.ts": T2_4_5_ROOT_APP, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `T2.4-5 \`build\` over the escaped-root arms \`d={${T2_4_5_ROOT_CHAIN}}\`, ` + + `\`${T2_4_5_ROOT_EMBED}\`, and the marker \`${T2_4_5_ROOT_CHAIN}\` — ` + + `each rooted at the \`BASE\` import binding, so the workspace is ` + + `valid and reports no finding (SPEC 2.4, 2.1, 4.5)`, + ); + for (const kind of ["depends", "embeds", "references"] as const) { + assertEdgeSetEqual( + await queryEdgesOfKind( + product, + workspace, + kind, + "T2.4-5 escaped root", + ), + T2_4_5_ROOT_OCCURRENCES.filter( + ({ unit }) => unit.kind === kind, + ).map(({ unit }) => ({ from: unit.source, to: unit.target, kind })), + `T2.4-5 the escaped-root workspace's complete \`${kind}\` edge ` + + `set — the escaped-root spelling of that kind records its edge ` + + `to \`specs/BASE.mdx#login\`, rooted at the \`BASE\` import ` + + `binding (SPEC 2.4, 2.1, 4.5, 5.2)`, + ); + } + const occContext = + "T2.4-5 `occurrences` over the escaped-root workspace"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + `${occContext} — a finding-free answer exits 0 (SPEC 11.3)`, + ), + occContext, + ); + assertConditionCounts( + report.findings, + {}, + `${occContext}: no finding accompanies the answer — each ` + + `escaped-root spelling resolves (SPEC 2.4, 11.2)`, + ); + assertOccurrencesExactly( + report.occurrences, + T2_4_5_ROOT_OCCURRENCES, + `${occContext} — each escaped-root spelling records its occurrence ` + + `(SPEC 2.4, 5.7, 11.3)`, + ); + }, + ); + }, +}); + /** TEST-SPEC §2.4, in canonical ID order (SUITE-08). */ export const section24Tests: readonly ProductTestEntry[] = [ T2_4_1, T2_4_2, T2_4_3, T2_4_4, + T2_4_5, ]; diff --git a/test/suite/registry/section-2.5-2.6.ts b/test/suite/registry/section-2.5-2.6.ts index 305f893a..42be8758 100644 --- a/test/suite/registry/section-2.5-2.6.ts +++ b/test/suite/registry/section-2.5-2.6.ts @@ -29,6 +29,14 @@ // (§VIOL-VALID-WIDE, §VIOL-VALID-CTRL): T2.6-1/T2.6-2 split only on the true // whitespace characters of SPEC 1.4 — U+00A0/U+0085/U+2028 and non-whitespace // control characters appear nowhere in their fixtures. +// +// Tag sets are compared literally in the 12.7 value form — an array in byte +// order, duplicates collapsed, the datum the H-3 decode enforces +// (`decodeTagSet`; T12.7-1) — never re-sorted or de-duplicated by the harness +// (H-3): T2.6-1's reversed spelling `tags="b a"` and T2.6-2's `tags="a b a a"` +// each report exactly `["a", "b"]` through `query node` and `query nodes`, +// the surface where the form is asserted for those commands (CERTIFICATIONS.md +// §CONF-VALID, §CONF-AVAIL: T11.4-3 asserts it on the `view` node). import type { CoverageProfileReport, @@ -37,6 +45,7 @@ import type { NodeReport, NodeRow, NodeSummary, + SourceRange, } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, @@ -55,16 +64,19 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertConditionCounts, assertEdgeSetEqual, - assertFindingLocated, assertSameJson, buildFindings, buildOk, - byteWindow, expectExit, runJson, sortedIdentities, @@ -81,15 +93,20 @@ const FF = "\u000C"; const CR = "\u000D"; // Minimal declarative configuration (SPEC 7): exactly one spec group — the -// CONF-VALID scope (T2.6-1, T2.6-2) and the negative 14.17 arms. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// CONF-VALID scope (T2.6-1, T2.6-2) and the negative 14.17 arms. A +// staged-source record (S-9's timing clause): T2.5-3's invalid-value +// workspaces are created after its first invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T2.5-3 xspec.config.ts — the specs-only configuration of the invalid-value workspaces", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One coverage profile in default (`leaves`) targeting, boundary and target // both the sole spec group ("main" is unambiguous, so boundaryKind MUST be @@ -147,8 +164,12 @@ export default defineConfig({ // Tag selection surfaces (T2.6-3): a coverage profile restricted by // `targetTags` (SPEC 7.4) and a forbidden policy rule whose `from` and `to` -// are both `tags` selectors (SPEC 7.5). -const TAG_SELECT_CONFIG = `import { defineConfig } from "xspec" +// are both `tags` selectors (SPEC 7.5). A staged-source record (S-9's timing +// clause): that arm's workspace is created after the body's first +// invocation. +const TAG_SELECT_CONFIG = stagedTs( + "T2.6-3 xspec.config.ts — the `targetTags` profile and the `tags` policy rule", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -172,12 +193,13 @@ export default defineConfig({ } ] }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -190,11 +212,6 @@ async function withWorkspace<T>( } } -/** Reported tags in byte order — SPEC fixes the set, not the row order. */ -function sortedTags(tags: readonly string[]): string[] { - return [...tags].sort(); -} - /** * `query node <identity>` decoded through the full H-3 node adapter, with * the resolved identity checked so a mis-addressed report cannot satisfy the @@ -469,9 +486,11 @@ const T2_5_2_BASELINE = [ // The same workspace with exactly one edit: `meta`'s own text (its ownHash // changes, so `meta` is `changed` relative to the baseline, SPEC 5.5/5.6). -const T2_5_2_EDITED = T2_5_2_BASELINE.replace( - "Meta text.", - "Meta text, edited.", +// Staged after the coverage queries — a staged-source record, judged before +// any product exists (S-9, test/self/s9-staged-sources.test.ts). +const T2_5_2_EDITED = stagedMdx( + "T2.5-2 specs/A.mdx with only the coverage-none node's own text edited", + T2_5_2_BASELINE.replace("Meta text.", "Meta text, edited."), ); const T2_5_2_META = "specs/A.mdx#meta"; @@ -641,7 +660,12 @@ const T2_5_2 = defineProductTest({ // (the coverage attribute is a metadataHash input, SPEC 5.5). const T2_5_3_EXPLICIT = '<S id="node" coverage="required">\nNode behavior.\n</S>\n'; -const T2_5_3_OMITTED = '<S id="node">\nNode behavior.\n</S>\n'; +// The omitted variant is staged after the explicit variant's build and +// queries — a staged-source record (S-9, test/self/s9-staged-sources.test.ts). +const T2_5_3_OMITTED = stagedMdx( + "T2.5-3 specs/A.mdx with the coverage prop omitted (the default variant)", + '<S id="node">\nNode behavior.\n</S>\n', +); const T2_5_3_NODE = "specs/A.mdx#node"; // Shared negative-arm template (the SUITE-02/03 discipline): a valid sibling @@ -649,13 +673,131 @@ const T2_5_3_NODE = "specs/A.mdx#node"; // location assertion has teeth. const SIBLING = '<S id="ok">\nA valid sibling section.\n</S>\n\n'; +/** `\` (U+005C), built from its code point (the SUITE-03 discipline). */ +const BACKSLASH = String.fromCodePoint(0x5c); + +/** + * Verbatim spellings (SPEC 2.4): the six-character Unicode escape of `o` + * (backslash, `u`, `006F`) and the decimal character reference of `n` + * (`n`), each embedded so that an interpreting reader would spell + * `none`. A quoted attribute value is the characters between its delimiters + * exactly as spelled — no escape sequence or character reference is + * interpreted — so each is neither `required` nor `none` (SPEC 2.4, 2.5 → + * 14.17). + */ +const ESCAPE_SPELLED_NONE = `n${BACKSLASH}u006Fne`; +const REFERENCE_SPELLED_NONE = "none"; + // Representatives of "any other value" (SPEC 2.5, 2.7 → 14.17): an unknown // token, a case variant (values compare byte-wise, no case folding — SPEC -// 12.0), and the empty string. -const INVALID_COVERAGE_VALUES: readonly string[] = ["optional", "None", ""]; +// 12.0), the empty string, and the two verbatim spellings above. +const INVALID_COVERAGE_VALUES: readonly string[] = [ + "optional", + "None", + "", + ESCAPE_SPELLED_NONE, + REFERENCE_SPELLED_NONE, +]; -function coverageConstruct(value: string): string { - return `<S id="sec" coverage="${value}">\nSection with the coverage value under test.\n</S>`; +/** Why a staged value is invalid — the clause a diagnosis must cite. */ +function coverageValueRationale(value: string): string { + if (value === ESCAPE_SPELLED_NONE || value === REFERENCE_SPELLED_NONE) { + return ( + "SPEC 2.4: a quoted attribute value is read verbatim — no escape sequence " + + "or character reference interpreted — so this spelling is neither " + + "`required` nor `none` (14.17)" + ); + } + return "SPEC 2.5: the only defined values are `required` and `none` (14.17)"; +} + +/** A staged file whose `coverage` attribute is under test. */ +interface CoverageStaging { + readonly source: string; + /** + * The `coverage` attribute's own characters — name through closing quote, + * the attribute range of SPEC 11.4 — as byte offsets (SPEC 1.7). + */ + readonly attribute: SourceRange; +} + +/** + * One section after the sibling whose `coverage` attribute carries the value + * under test; the attribute is pinned by its own characters as the range a + * 14.17 finding on it must locate (SPEC 14; T14-11). Offsets are UTF-8 byte + * lengths of the text before the attribute. + */ +function coverageStaging(value: string): CoverageStaging { + const prefix = `${SIBLING}<S id="sec" `; + const attribute = `coverage="${value}"`; + const start = Buffer.byteLength(prefix, "utf8"); + return { + source: `${prefix}${attribute}>\nSection with the coverage value under test.\n</S>\n`, + attribute: { start, end: start + Buffer.byteLength(attribute, "utf8") }, + }; +} + +/** One invalid value's staging with its record. */ +interface InvalidCoverageStaging { + readonly value: string; + readonly staged: CoverageStaging; + /** `staged.source` as a staged-source record. */ + readonly source: StagedMdx; +} + +// The invalid values' stagings, computed once at module load: their +// workspaces are created after the required-variant workspace's +// invocations, so each is a staged-source record (S-9's timing clause). +const INVALID_COVERAGE_STAGINGS: readonly InvalidCoverageStaging[] = + INVALID_COVERAGE_VALUES.map((value) => { + const staged = coverageStaging(value); + return { + value, + staged, + source: stagedMdx( + `T2.5-3 coverage=${JSON.stringify(value)} specs/A.mdx`, + staged.source, + ), + }; + }); + +/** + * Assert `build --json` reported exactly one finding, condition 14.17, + * locating exactly one range — the offending `coverage` attribute's own + * characters in `specs/A.mdx` (SPEC 14: an attribute condition locates the + * attribute range of 11.4; 12.7 locations). + */ +function assertSingle1417AtAttribute( + findings: readonly Finding[], + attribute: SourceRange, + context: string, +): void { + assertConditionCounts(findings, { "14.17": 1 }, context); + const finding = findings[0]!; + if (finding.locations.length !== 1) { + fail( + `${context}: a 14.17 finding on a \`coverage\` value locates exactly one ` + + "construct — the offending attribute (SPEC 14: an attribute condition " + + "locates the attribute's own characters); got " + + `${String(finding.locations.length)} locations (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + const location = finding.locations[0]!; + if (location.file !== "specs/A.mdx") { + fail( + `${context}: the 14.17 finding must locate in the workspace-relative source ` + + `file (SPEC 14, 1.5, 12.7); expected "specs/A.mdx", got ` + + `${JSON.stringify(location.file)} (message: ${JSON.stringify(finding.message)})`, + ); + } + assertSameJson( + { start: location.range.start, end: location.range.end }, + attribute, + `${context}: the 14.17 finding locates exactly the \`coverage\` attribute's ` + + "own characters — name through closing quote, the attribute range of SPEC " + + "11.4 — as zero-based byte offsets, end-exclusive (SPEC 14, 1.7, 12.7)", + ); } async function expectVariantRequired( @@ -705,7 +847,7 @@ async function expectVariantRequired( const T2_5_3 = defineProductTest({ id: "T2.5-3", title: - '`coverage="required"` is accepted and behaves as the default (same required-set membership, reported attribute, and metadataHash as the omitted variant); any other value fails with 14.17 (SPEC 2.5, 2.7)', + '`coverage="required"` is accepted and behaves as the default (same required-set membership, reported attribute, and metadataHash as the omitted variant); any other value fails with 14.17 located at the attribute — a value spelled with an escape sequence or character reference included, read verbatim (SPEC 2.4, 2.5, 2.7, 14)', run: async (product) => { await withWorkspace( PROFILE_CONFIG, @@ -744,21 +886,16 @@ const T2_5_3 = defineProductTest({ }, ); - for (const value of INVALID_COVERAGE_VALUES) { - const construct = coverageConstruct(value); - const context = `T2.5-3 \`build --json\` with coverage=${JSON.stringify(value)}`; + for (const { value, staged, source } of INVALID_COVERAGE_STAGINGS) { + const context = + `T2.5-3 \`build --json\` with coverage=${JSON.stringify(value)} ` + + `(${coverageValueRationale(value)})`; await withWorkspace( SPECS_ONLY_CONFIG, - { "specs/A.mdx": `${SIBLING}${construct}\n` }, + { "specs/A.mdx": source }, async (workspace) => { const findings = await buildFindings(product, workspace, context); - assertConditionCounts(findings, { "14.17": 1 }, context); - assertFindingLocated( - findings[0]!, - { file: "specs/A.mdx", window: byteWindow(SIBLING, construct) }, - `${context}: the 14.17 finding (SPEC 2.5: the only defined values are ` + - "`required` and `none`)", - ); + assertSingle1417AtAttribute(findings, staged.attribute, context); }, ); } @@ -769,20 +906,26 @@ const T2_5_3 = defineProductTest({ // T2.6-1 // --------------------------------------------------------------------------- -// Four spellings that must all yield exactly the tag set {a, b} (SPEC 2.6): -// a single space; a run mixing tab, vertical tab, form feed, and space; a -// run containing the line terminators CR LF plus a tab (line endings inside -// a quoted attribute value are well-formed MDX in flow context, and both CR -// and LF are 1.4 whitespace, so the split result is terminator-normalization -// independent); and leading/trailing whitespace around a single-space -// separator. Every separator character is drawn from SPEC 1.4's exact -// whitespace class — never U+00A0/U+0085/U+2028 (CERTIFICATIONS.md -// §VIOL-VALID-WIDE: T2.6-1 splits only on true 1.4 whitespace). +// Five spellings that must all yield exactly the tag set {a, b} (SPEC 2.6), +// reported as exactly `["a", "b"]` — the 12.7 tag-set form, byte order, +// compared literally: a single space; a run mixing tab, vertical tab, form +// feed, and space; a run containing the line terminators CR LF plus a tab +// (line endings inside a quoted attribute value are well-formed MDX in flow +// context, and both CR and LF are 1.4 whitespace, so the split result is +// terminator-normalization independent); leading/trailing whitespace around +// a single-space separator; and the reversed spelling `b a`, whose tokens +// arrive in the opposite order — a product echoing the spelled order +// (`["b", "a"]`) fails the form-exact decode (SPEC 12.7, 12.0), and its +// metadataHash still equals the others' (SPEC 5.5 hashes the sorted tags). +// Every separator character is drawn from SPEC 1.4's exact whitespace class +// — never U+00A0/U+0085/U+2028 (CERTIFICATIONS.md §VIOL-VALID-WIDE: T2.6-1 +// splits only on true 1.4 whitespace). const T2_6_1_ARM_IDS = [ "plain", "mixed", "terminators", "padded", + "reversed", ] as readonly string[]; const T2_6_1_SOURCE = [ @@ -802,6 +945,10 @@ const T2_6_1_SOURCE = [ "Leading and trailing whitespace ignored.", "</S>", "", + '<S id="reversed" tags="b a">', + "Reversed spelling - reported in byte order.", + "</S>", + "", '<S id="other" tags="c">', "Different tag - the filter discriminator.", "</S>", @@ -815,7 +962,7 @@ const T2_6_1_SOURCE = [ const T2_6_1 = defineProductTest({ id: "T2.6-1", title: - '`tags="a b"`, runs of mixed 1.4 whitespace as separators, and leading/trailing whitespace all yield tags {a, b} — asserted via `query node` and `query nodes --tag` (SPEC 2.6, 1.4)', + '`tags="a b"`, runs of mixed 1.4 whitespace as separators, leading/trailing whitespace, and the reversed spelling `tags="b a"` all yield tags {a, b} — reported as exactly `["a", "b"]`, the 12.7 tag-set form in byte order, compared literally and never re-sorted, with one metadataHash across the five spellings — asserted via `query node` and `query nodes --tag` (SPEC 2.6, 1.4, 12.7, 5.5)', run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -827,10 +974,12 @@ const T2_6_1 = defineProductTest({ "T2.6-1 `build` over the tag-splitting spellings", ); - // Every arm reports exactly the set {a, b} (as sorted tags), and — - // since the metadataHash input is the split, sorted tag set together - // with the (empty) `d` set and default coverage (SPEC 5.5) — all - // arms' metadataHashes are identical: the spellings are equivalent. + // Every arm reports exactly `["a", "b"]` — the 12.7 tag-set form, + // byte order, compared literally (the reversed spelling included) — + // and, since the metadataHash input is the split, sorted tag set + // together with the (empty) `d` set and default coverage (SPEC + // 5.5), all arms' metadataHashes are identical: the spellings are + // equivalent. const hashes = new Set<string>(); for (const id of T2_6_1_ARM_IDS) { const identity = `specs/A.mdx#${id}`; @@ -841,15 +990,16 @@ const T2_6_1 = defineProductTest({ "T2.6-1", ); assertSameJson( - sortedTags(summary.tags), + summary.tags, ["a", "b"], - `T2.6-1 tags of ${identity} — the spelling splits to exactly {a, b} (SPEC 2.6)`, + `T2.6-1 tags of ${identity} — the spelling splits to exactly {a, b}, ` + + "reported in the 12.7 tag-set form, byte order (SPEC 2.6, 12.7)", ); hashes.add(summary.metadataHash); } if (hashes.size !== 1) { fail( - "T2.6-1 all four spellings carry identical metadata (empty `d` set, " + + "T2.6-1 all five spellings carry identical metadata (empty `d` set, " + "default coverage, tag set {a, b}), so their metadataHashes must be " + `identical (SPEC 2.6, 5.5); got ${String(hashes.size)} distinct values: ` + JSON.stringify([...hashes]), @@ -857,7 +1007,7 @@ const T2_6_1 = defineProductTest({ } // The `--tag` filter view (SPEC 2.6, 11): each of `a` and `b` - // selects exactly the four arms — never `other` or `untagged` — and + // selects exactly the five arms — never `other` or `untagged` — and // `c` selects exactly `other`. const armIdentities = T2_6_1_ARM_IDS.map( (id) => `specs/A.mdx#${id}`, @@ -872,14 +1022,15 @@ const T2_6_1 = defineProductTest({ assertSameJson( sortedIdentities(rows), armIdentities, - `T2.6-1 \`query nodes --tag ${tag}\` lists exactly the four spellings' ` + + `T2.6-1 \`query nodes --tag ${tag}\` lists exactly the five spellings' ` + "nodes (SPEC 2.6, 11)", ); for (const row of rows) { assertSameJson( - sortedTags(row.tags), + row.tags, ["a", "b"], - `T2.6-1 tags reported for ${row.identity} by \`query nodes --tag ${tag}\``, + `T2.6-1 tags reported for ${row.identity} by \`query nodes --tag ${tag}\` ` + + "— the 12.7 tag-set form, byte order (SPEC 12.7)", ); } } @@ -923,8 +1074,17 @@ const T2_6_2_STATIC = [ // omitted (SPEC 2.6), observable as an equal metadataHash (SPEC 5.5). The // whitespace-only value uses only true 1.4 whitespace (§VIOL-VALID-WIDE). const T2_6_2_OMITTED = '<S id="node">\nVariant node.\n</S>\n'; -const T2_6_2_EMPTY = '<S id="node" tags="">\nVariant node.\n</S>\n'; -const T2_6_2_WS_ONLY = `<S id="node" tags=" ${TAB} ">\nVariant node.\n</S>\n`; +// The two variants staged into specs/B.mdx after the tag queries — +// staged-source records, one per row of the body's variant table, named +// with the row's label (S-9, test/self/s9-staged-sources.test.ts). +const T2_6_2_EMPTY = stagedMdx( + 'T2.6-2 tags=""', + '<S id="node" tags="">\nVariant node.\n</S>\n', +); +const T2_6_2_WS_ONLY = stagedMdx( + "T2.6-2 whitespace-only tags value", + `<S id="node" tags=" ${TAB} ">\nVariant node.\n</S>\n`, +); const T2_6_2_NODE = "specs/B.mdx#node"; const T2_6_2 = defineProductTest({ @@ -942,9 +1102,10 @@ const T2_6_2 = defineProductTest({ "T2.6-2 `build` with duplicate tags and the omitted-variant node", ); - // Duplicates collapse: the reported tags are the two-element set, and - // the metadataHash equals the plain spelling's (the hash input is the - // collapsed, sorted tag set, SPEC 2.6, 5.5). + // Duplicates collapse: the reported tags are exactly `["a", "b"]` — + // the 12.7 tag-set form, compared literally — and the metadataHash + // equals the plain spelling's (the hash input is the collapsed, + // sorted tag set, SPEC 2.6, 5.5). const dup = await queryNodeMetadata( product, workspace, @@ -952,10 +1113,10 @@ const T2_6_2 = defineProductTest({ "T2.6-2", ); assertSameJson( - sortedTags(dup.tags), + dup.tags, ["a", "b"], 'T2.6-2 tags of the `tags="a b a a"` node — duplicates collapse to the set ' + - "{a, b} (SPEC 2.6)", + "{a, b}, reported in the 12.7 tag-set form (SPEC 2.6, 12.7)", ); const plain = await queryNodeMetadata( product, @@ -983,7 +1144,7 @@ const T2_6_2 = defineProductTest({ "T2.6-2 the omitted-prop variant carries no tags (SPEC 2.6)", ); - const variants: readonly { label: string; source: string }[] = [ + const variants: readonly { label: string; source: StagedMdx }[] = [ { label: 'tags=""', source: T2_6_2_EMPTY }, { label: "whitespace-only tags value", source: T2_6_2_WS_ONLY }, ]; @@ -1047,25 +1208,30 @@ const T2_6_3_MD_COMPILED = "Parent text.\n\nChild text.\n\nInline kept text.\n"; // Tag-selection workspace (SPEC 7.4, 7.5): `tagged` carries the profile's // target tag; `src` carries the policy's `from` tag and depends on `tagged`; // `untagged` and `srcPlain` carry no tags — their edge is the negative -// control for the policy rule, and `untagged` for `targetTags`. -const T2_6_3_SELECT_SOURCE = [ - '<S id="tagged" tags="core">', - "Core-tagged leaf.", - "</S>", - "", - '<S id="untagged">', - "Untagged leaf.", - "</S>", - "", - '<S id="src" tags="ui" d={"tagged"}>', - "The ui-tagged source depends on the core-tagged leaf.", - "</S>", - "", - '<S id="srcPlain" d={"untagged"}>', - "The untagged source depends on the untagged leaf.", - "</S>", - "", -].join("\n"); +// control for the policy rule, and `untagged` for `targetTags`. Its +// workspace is created after the rendering workspace's invocations, so the +// source is a staged-source record (S-9's timing clause). +const T2_6_3_SELECT_SOURCE = stagedMdx( + "T2.6-3 tag-selection specs/A.mdx", + [ + '<S id="tagged" tags="core">', + "Core-tagged leaf.", + "</S>", + "", + '<S id="untagged">', + "Untagged leaf.", + "</S>", + "", + '<S id="src" tags="ui" d={"tagged"}>', + "The ui-tagged source depends on the core-tagged leaf.", + "</S>", + "", + '<S id="srcPlain" d={"untagged"}>', + "The untagged source depends on the untagged leaf.", + "</S>", + "", + ].join("\n"), +); const T2_6_3_TAGGED = "specs/A.mdx#tagged"; const T2_6_3_UNTAGGED = "specs/A.mdx#untagged"; @@ -1100,9 +1266,10 @@ const T2_6_3 = defineProductTest({ "T2.6-3", ); assertSameJson( - sortedTags(parent.tags), + parent.tags, ["ptag", "render-probe"], - "T2.6-3 the parent carries its own tags (the inheritance control)", + "T2.6-3 the parent carries its own tags (the inheritance control), " + + "in the 12.7 tag-set form (SPEC 12.7)", ); const child = await queryNode( product, @@ -1231,15 +1398,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-2.7.ts b/test/suite/registry/section-2.7.ts index 7e02622a..6c68b544 100644 --- a/test/suite/registry/section-2.7.ts +++ b/test/suite/registry/section-2.7.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §2.7 (permitted constructs) — SUITE-10: T2.7-1 … T2.7-3. +// TEST-SPEC §2.7 (permitted constructs) — SUITE-10: T2.7-1 … T2.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), @@ -10,45 +10,96 @@ // SPEC 2.7: beyond standard Markdown content, a source file may contain only // spec module imports, `<S>`/`<Spec>` sections, `{text(...)}` embeddings, and // MDX comments — any other JSX element, any other expression container, and -// any export statement are invalid (14.16). Comments are pure annotations: +// any export statement are invalid (14.16); a section is an MDX element +// node, so `<S>` spelled inside an expression container is part of that +// container's expression — content under 3, never a section (T2.7-1's +// container arm: one 14.16 brace through brace, no node in the view's tree, +// the container's bytes preserved in the enclosing text, 11.2, 11.4; `ids` +// and `query` are gated on the failing workspace, 13.3, so the absence is +// asserted in the view alone). Comments are pure annotations: // they do not enter own text or any hash, and Markdown output removes them // (3). The defined props are `id`, `d`, `coverage`, and `tags`; a repeated // prop (defined or unknown), an unknown prop, and a spread attribute are // invalid (14.17); `id`/`coverage`/`tags` values MUST be quoted-form static // string literals — single- or double-quoted alike (2.4) — and any other -// value form is invalid (14.17); `d` MUST be a braced expression — a quoted -// or valueless `d` is invalid (14.17), and a braced `d` value that is not a +// value form, a braced expression or the bare valueless name alike, is +// invalid (14.17) — a bare `<S id>` is condition 17, never the missing-id +// condition 1 (14.1); `d` MUST be a braced expression — a quoted or +// valueless `d` is invalid (14.17), and a braced `d` value that is not a // static reference or an array literal of them is a dynamic argument (14.8). +// A fragment (`<>` through `</>`) is an invalid element (14.16): one finding +// from `<>` through `</>`, no node, its enclosed content preserved under +// `view --text` (T2.7-1's fragment arm); an attribute value expression is +// part of its element, no container of condition 16 — `<S id="x" d={1}>` +// reports 14.8 alone, `<div a={1}></div>` exactly one 14.16. A spread +// attribute's grammar pair (14.20): `{...(a, b)}` is well-formed — 14.17 at +// the whole braced construct — while `{...a, b}` is not, 14.20 at the +// offset of its comma (T2.7-3's pair, staged under S-9's `unparseable` +// declaration). The comment forms of 2.7 beyond the usual `{/* … */}` — +// `{}`, ECMAScript-only and ASCII whitespace between braces (each space +// separator Unicode 15.1 places outside Latin-1 among them, one arm per code +// point), a block-comment sequence, line-comment containers ended by U+000A +// or U+000D, and the run-on `{// c}` U+000A `}` — each behave as T2.7-2's +// comment (removed from Markdown output, absent from own text, no finding, +// listed under `view`'s `comments` brace through brace); an expression +// beside a comment is 14.16; U+0085, U+200B, or U+180E between braces, +// `{// c` U+2028/U+2029 `}` U+000A `}`, and `{// c}` with no later `}` are +// 14.20 at the offsets SPEC 14 fixes (T2.7-4, each 14.20 staging declared +// `unparseable`). // // Location assertions follow the SUITE-08 discipline: negative fixtures are // pure ASCII, composed as `prefix + construct + suffix` with exactly known // parts, so string indices are byte offsets and each finding must fall within // the offending construct's own byte window (end-widened by one byte, see // support.ts byteWindow); the valid sibling section and every other staged -// construct lie outside the widened window. +// construct lie outside the widened window. T2.7-1's container arm pins an +// exact range instead — the container brace through brace (14) — and its +// fixture carries one multibyte character before the container, so every +// pinned offset is a UTF-8 byte length (1.7) that a code-unit count misses. +// +// The valueless-`tags` file is exported as VALUELESS_TAGS_FIXTURE — its exact +// bytes, attribute offsets, and finding location — because TEST-SPEC T11.4-3 +// stages the same bytes for `view`: build and view share one fixture, the +// 14.17 beside the view being the condition the build reports here. // // No certification fixture scopes any T2.7 test (CERTIFICATIONS.md keeps the // 2.7 negative matrix among the representatively-certified ones), so only // TEST-SPEC's own requirements bind these fixtures. +import { Buffer } from "node:buffer"; + import type { + Finding, ImpactReport, ImpactRequirementEntry, NodeReport, + SourceRange, + ViewNode, } from "../../helpers/adapters/index.js"; import { decodeImpactReport, decodeNodeReport, + decodeViewReport, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertFileBytes, fail, + parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import type { + FindingSourceExpectation, + UnparseableStaging, +} from "./support.js"; import { assertConditionCounts, assertFindingLocated, @@ -56,24 +107,33 @@ import { buildFindings, buildOk, byteWindow, + expectExit, runJson, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group — the -// negative arms need nothing else. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// negative arms need nothing else. A staged-source record (S-9's timing +// clause): the arm workspaces of T2.7-1, T2.7-3, and T2.7-4 after each +// body's first are created after its first invocation. +const SPECS_ONLY_CONFIG = stagedTs( + "T2.7-1/T2.7-3/T2.7-4 xspec.config.ts — the specs-only configuration of every arm workspace", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // Markdown emission next to each source (SPEC 7.3, 13.2) for the arms that // byte-assert compiled output (T2.7-2's comment removal, T2.7-3's quoting -// equivalence). -const EMIT_TRUE_CONFIG = `import { defineConfig } from "xspec" +// equivalence). A staged-source record (S-9's timing clause): T2.7-3's +// positive quoting arm's workspace is created after its first invocation. +const EMIT_TRUE_CONFIG = stagedTs( + "T2.7-3 xspec.config.ts — Markdown emission enabled, the positive quoting arm", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -81,17 +141,33 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); // Shared negative-arm template (the SUITE-02/03 discipline): a valid sibling // first, so the offending construct is a proper sub-range of the file and the // location assertion has teeth. -const SIBLING = '<S id="ok">\nA valid sibling section.\n</S>\n\n'; +const SIBLING_CONSTRUCT = '<S id="ok">\nA valid sibling section.\n</S>'; +const SIBLING = `${SIBLING_CONSTRUCT}\n\n`; + +// T2.7-3's one-defect file: the sibling, then the offending section — its +// opening tag the staged construct, its body and closing tag fixed — at one +// workspace-relative path. +const INVALID_PROP_FILE = "specs/A.mdx"; +const INVALID_PROP_BODY = "\nBody text.\n</S>"; -/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +/** The one-defect file's exact bytes for an offending opening tag. */ +function invalidPropSource(construct: string): string { + return `${SIBLING}${construct}${INVALID_PROP_BODY}\n`; +} + +/** + * Stage a fresh workspace (config plus `files` — an unparseable staging's + * source a record carrying its S-9 declaration), run `body`, dispose (H-1). + */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -158,6 +234,71 @@ function assertHashStable( } } +/** UTF-8 byte length of `text` — the offset unit of SPEC 1.7. */ +function utf8Bytes(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** + * Assert a finding carries exactly one location — `file` at `range`, as byte + * offsets (SPEC 1.7) — and, locating in source, concerns no path (12.7: + * `path` is null for located conditions). The exact-range form of the + * module's location assertions, for the arms where SPEC 14 pins the range + * beyond a construct's window. + */ +function assertSoleLocationExactly( + finding: Finding, + file: string, + range: SourceRange, + context: string, +): void { + assertSameJson( + finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + [{ file, range: { start: range.start, end: range.end } }], + `${context} (message: ${JSON.stringify(finding.message)})`, + ); + if (finding.path !== null) { + fail( + `${context}: a finding locating in source concerns no path — \`path\` ` + + `is null for located conditions (SPEC 12.7, 14); got ` + + `${JSON.stringify(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } +} + +/** + * Byte range (SPEC 1.7) of exactly one occurrence of `part` within + * `construct`, the construct itself standing at byte `constructStart` of its + * file. An absent or ambiguous part is a staging defect — a harness error, + * never a product failure: a precomputed range must name its bytes uniquely. + */ +function constructPartRange( + construct: string, + part: string, + constructStart: number, + where: string, +): SourceRange { + const first = construct.indexOf(part); + if (first === -1) { + throw new Error( + `${where}: staging locator — part ${JSON.stringify(part)} not found ` + + `in ${JSON.stringify(construct)}`, + ); + } + if (construct.indexOf(part, first + 1) !== -1) { + throw new Error( + `${where}: staging locator — part ${JSON.stringify(part)} is ` + + `ambiguous in ${JSON.stringify(construct)}`, + ); + } + const start = constructStart + utf8Bytes(construct.slice(0, first)); + return { start, end: start + utf8Bytes(part) }; +} + // --------------------------------------------------------------------------- // T2.7-1 // --------------------------------------------------------------------------- @@ -166,7 +307,13 @@ function assertHashStable( // defect in its otherwise valid fixture). The JSX-element and // expression-container arms sit inside a section — nesting inside `<S>` earns // no exemption; the export statement is top-level ESM with a valid section -// after it, so its window has content on both sides. +// after it, so its window has content on both sides. The attribute-value +// pair (SPEC 14.16: an attribute value expression is part of its element, no +// container of the condition): `<S id="x" d={1}>` reports 14.8 alone — the +// braced `d` value holding a number is a dynamic argument (2.7), its braces +// no expression container — and `<div a={1}></div>` exactly one condition-16 +// finding, the element's; in each arm the absence of any second finding is +// the assertion, so the count is exact over every condition. const T2_7_1_SECTION_PREFIX = `${SIBLING}<S id="sec">\nSection text.\n\n`; interface ForeignConstructArm { @@ -178,6 +325,13 @@ interface ForeignConstructArm { readonly construct: string; /** Everything after the offending construct, exactly. */ readonly suffix: string; + /** + * The one condition the arm reports: 14.16, or 14.8 for the attribute-value + * control, whose braced `d` value is a dynamic argument and no container. + */ + readonly condition: "14.16" | "14.8"; + /** The SPEC reason the count is exactly one (failure diagnostics). */ + readonly reason: string; } const FOREIGN_CONSTRUCT_ARMS: readonly ForeignConstructArm[] = [ @@ -186,46 +340,409 @@ const FOREIGN_CONSTRUCT_ARMS: readonly ForeignConstructArm[] = [ prefix: T2_7_1_SECTION_PREFIX, construct: "<div>foreign block</div>", suffix: "\n</S>\n", + condition: "14.16", + reason: + "only imports, sections, `text(...)` embeddings, and MDX comments are " + + "permitted; any other JSX element is invalid (SPEC 2.7, 14.16)", }, { name: "an expression container other than `text(...)` or an MDX comment", prefix: T2_7_1_SECTION_PREFIX, construct: "{40 + 2}", suffix: "\n</S>\n", + condition: "14.16", + reason: + "only imports, sections, `text(...)` embeddings, and MDX comments are " + + "permitted; any other expression container is invalid (SPEC 2.7, 14.16)", }, { name: "an export statement", prefix: SIBLING, construct: "export const flag = 1", suffix: '\n\n<S id="sec">\nSection text.\n</S>\n', + condition: "14.16", + reason: + "only imports, sections, `text(...)` embeddings, and MDX comments are " + + "permitted; any export statement is invalid (SPEC 2.7, 14.16)", + }, + { + name: 'a braced `d` value holding a number (`<S id="x" d={1}>`)', + prefix: SIBLING, + construct: '<S id="x" d={1}>', + suffix: "\nX text.\n</S>\n", + condition: "14.8", + reason: + "an attribute value expression is part of its element, no container " + + "of condition 16 — the braced `d` value holding a number is a dynamic " + + "argument, 14.8 alone, never beside a second, condition-16 finding " + + "(SPEC 14.16, 2.7)", + }, + { + name: "a foreign element carrying an attribute value expression (`<div a={1}></div>`)", + prefix: T2_7_1_SECTION_PREFIX, + construct: "<div a={1}></div>", + suffix: "\n</S>\n", + condition: "14.16", + reason: + "exactly one condition-16 finding, the element's — its attribute value " + + "expression is part of the element, no container earning a second " + + "finding (SPEC 14.16, 2.7)", }, ]; +/** A foreign-construct arm with its file as a staged-source record. */ +interface ForeignConstructStaging { + readonly arm: ForeignConstructArm; + /** `arm.prefix + arm.construct + arm.suffix`, staged as `specs/A.mdx`. */ + readonly source: StagedMdx; +} + +// The arms' files, composed once at module load: every arm workspace after +// the body's first is created after its first invocation (S-9's timing +// clause), and the table converts uniformly. +const FOREIGN_CONSTRUCT_STAGINGS: readonly ForeignConstructStaging[] = + FOREIGN_CONSTRUCT_ARMS.map((arm) => ({ + arm, + source: stagedMdx( + `T2.7-1 ${arm.name} specs/A.mdx`, + arm.prefix + arm.construct + arm.suffix, + ), + })); + +// The enclosed-construct arms: a construct spelled on its own line inside +// `sec`, invalid (14.16) yet creating no node and preserved as content. +// (a) The section-in-container arm. SPEC 2.7: a section is an MDX element +// node, so `<S>` spelled inside an expression container is part of that +// container's expression — content under 3, never a section. The container +// is one invalid expression container (14.16), located from its opening +// brace through its closing brace (14). (b) The fragment arm. SPEC 2.7: a +// fragment (`<>` through `</>`) is an invalid element (14.16), located from +// `<>` through `</>` (14); no node is created for it, and its enclosed +// content stays content — a stray element is preserved by its own tags +// (11.2; the sections and embeddings a stray element encloses are T11.2-4's +// enclosure arm). In both, the construct's bytes match no removal rule's +// form and are content, preserved byte-for-byte in the enclosing section's +// own and subtree text (11.2, 1.6); the view's section tree — defined by +// construct nesting alone, existing whatever findings the file carries — +// holds no node for the construct or anything it encloses (11.4). `ids` and +// `query` are gated on these failing workspaces (13.3), so the absence is +// asserted in the view's tree alone. Each fixture is the module's ASCII +// template plus one multibyte character (`é`, two bytes) before the +// construct, so the pinned construct and section ranges are byte offsets +// (1.7) — a product counting code units mislocates every construct after it. +const T2_7_1_ENCLOSED_FILE = "specs/A.mdx"; +const T2_7_1_ENCLOSED_HEAD = "Section text é.\n\n"; +const T2_7_1_ENCLOSED_TAIL_LINES = "\n\nTail text.\n"; +const T2_7_1_ENCLOSED_PREFIX = `${SIBLING}<S id="sec">\n${T2_7_1_ENCLOSED_HEAD}`; +const T2_7_1_ENCLOSED_SEC_CLOSE = `${T2_7_1_ENCLOSED_TAIL_LINES}</S>`; + +/** The view's tree projected to what these arms pin (T11.2-4's projection). */ +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), + }; +} + +/** One enclosed-construct fixture: its bytes, the construct's range, the tree. */ +interface EnclosedConstructFixture { + /** The construct's own characters, exactly. */ + readonly construct: string; + /** The file's exact bytes. */ + readonly source: string; + /** The construct's own characters as a byte range (SPEC 14, 1.7). */ + readonly range: SourceRange; + /** The section tree with its text values (SPEC 11.4, 1.6, 3). */ + readonly tree: TextTreeExpectation; +} + +function enclosedConstructFixture(construct: string): EnclosedConstructFixture { + const source = `${T2_7_1_ENCLOSED_PREFIX}${construct}${T2_7_1_ENCLOSED_SEC_CLOSE}\n`; + // Text values per SPEC 1.6 and 3: each section's tag lines are emptied + // purely by removals and drop with their terminators; the blank line + // between the sibling and `sec` is the root's own contribution and keeps; + // the construct is content and stays byte-for-byte; the root's subtree + // text is the children's contributions interleaved with its own in + // document order. + const okText = "A valid sibling section.\n"; + const secText = T2_7_1_ENCLOSED_HEAD + construct + T2_7_1_ENCLOSED_TAIL_LINES; + const rootOwn = "\n"; + return { + construct, + source, + range: { + start: utf8Bytes(T2_7_1_ENCLOSED_PREFIX), + end: utf8Bytes(T2_7_1_ENCLOSED_PREFIX + construct), + }, + tree: { + identity: T2_7_1_ENCLOSED_FILE, + range: { start: 0, end: utf8Bytes(source) }, + ownText: rootOwn, + subtreeText: okText + rootOwn + secText, + children: [ + { + identity: `${T2_7_1_ENCLOSED_FILE}#ok`, + range: { start: 0, end: utf8Bytes(SIBLING_CONSTRUCT) }, + ownText: okText, + subtreeText: okText, + children: [], + }, + { + identity: `${T2_7_1_ENCLOSED_FILE}#sec`, + range: { + start: utf8Bytes(SIBLING), + end: utf8Bytes( + T2_7_1_ENCLOSED_PREFIX + construct + T2_7_1_ENCLOSED_SEC_CLOSE, + ), + }, + ownText: secText, + subtreeText: secText, + children: [], + }, + ], + }, + }; +} + +/** One enclosed-construct arm: its fixture and how its assertions read. */ +interface EnclosedConstructArmParts { + readonly fixture: EnclosedConstructFixture; + /** The arm's name in contexts. */ + readonly name: string; + /** What the one 14.16 finding is and why nothing stands beside it. */ + readonly soleFinding: string; + /** How SPEC 14 bounds the finding's range. */ + readonly rangeRule: string; + /** Why the tree holds no node within the construct's range. */ + readonly noNode: string; + /** The pinned tree, in words. */ + readonly treeShape: string; +} + +/** The arm with its fixture's bytes as a staged-source record. */ +interface EnclosedConstructArm extends EnclosedConstructArmParts { + /** `fixture.source` as the record `runEnclosedConstructArm` stages. */ + readonly source: StagedMdx; +} + +/** + * Compose an arm with its record, named for the test that runs it: the + * arm's workspace is created after that body's earlier invocations (S-9's + * timing clause). + */ +function enclosedConstructArm( + recordName: string, + parts: EnclosedConstructArmParts, +): EnclosedConstructArm { + return { ...parts, source: stagedMdx(recordName, parts.fixture.source) }; +} + +const T2_7_1_CONTAINER_ARM = enclosedConstructArm( + "T2.7-1 a section spelled inside an expression container specs/A.mdx", + { + fixture: enclosedConstructFixture('{<S id="x">Inner x text.</S>}'), + name: "a section spelled inside an expression container", + soleFinding: + "exactly one condition-16 finding, the container's, and none beside — " + + "the `<S>` spelled inside it is part of the container's expression, " + + "earning no finding of its own (SPEC 2.7, 14.16)", + rangeRule: + "the 14.16 finding locates the expression container from its opening " + + "brace through its closing brace, as byte offsets (SPEC 14, 1.7)", + noNode: + 'no node for the `<S id="x">` spelled inside the expression container — ' + + "it is part of the container's expression, never a section", + treeShape: + "the section tree by construct nesting — the root, `ok`, and `sec`, no " + + 'node for the enclosed `<S id="x">` — with the container\'s bytes ' + + "preserved byte-for-byte as content in `sec`'s own and subtree text", + }, +); + +const T2_7_1_FRAGMENT_ARM = enclosedConstructArm( + "T2.7-1 a fragment specs/A.mdx", + { + fixture: enclosedConstructFixture("<>Fragment text.</>"), + name: "a fragment (`<>` through `</>`)", + soleFinding: + "exactly one condition-16 finding, the fragment's, and none beside — a " + + "fragment is an invalid element, and the content it encloses earns no " + + "finding of its own (SPEC 2.7, 14.16)", + rangeRule: + "the 14.16 finding locates the fragment from `<>` through `</>`, as " + + "byte offsets (SPEC 14, 1.7)", + noNode: + "no node for the fragment — an invalid element is no section and " + + "creates no node, and this one encloses no section", + treeShape: + "the section tree by construct nesting — the root, `ok`, and `sec`, no " + + "node for the fragment — with the fragment and its enclosed content " + + "preserved byte-for-byte as content in `sec`'s own and subtree text (a " + + "stray element is preserved by its own tags, SPEC 11.2)", + }, +); + +/** Every node of a view tree, document order (explicit stack, AGENTS.md). */ +function everyViewNode(root: ViewNode): ViewNode[] { + const nodes: ViewNode[] = []; + const stack: ViewNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + nodes.push(node); + for (let index = node.children.length - 1; index >= 0; index -= 1) { + stack.push(node.children[index]!); + } + } + return nodes; +} + +/** + * An enclosed-construct fixture's findings: exactly one, condition 16, + * located once — the construct's own characters, in byte offsets — and + * concerning no path. + */ +function assertEnclosedConstructFinding( + findings: readonly Finding[], + arm: EnclosedConstructArm, + context: string, +): void { + assertConditionCounts( + findings, + { "14.16": 1 }, + `${context}: ${arm.soleFinding}`, + ); + assertSoleLocationExactly( + findings[0]!, + T2_7_1_ENCLOSED_FILE, + arm.fixture.range, + `${context}: ${arm.rangeRule}`, + ); +} + +/** + * One enclosed-construct arm of `testId`: `build --json`, then the bare + * `view --text` (T2.7-1's container and fragment arms, T2.7-4's expression + * arm). + */ +async function runEnclosedConstructArm( + product: ProductBinding, + testId: string, + arm: EnclosedConstructArm, +): Promise<void> { + await withWorkspace( + SPECS_ONLY_CONFIG, + { [T2_7_1_ENCLOSED_FILE]: arm.source }, + async (workspace) => { + const buildContext = `${testId} \`build --json\` with ${arm.name}`; + assertEnclosedConstructFinding( + await buildFindings(product, workspace, buildContext), + arm, + buildContext, + ); + + const viewContext = `${testId} bare \`view --text\` with ${arm.name}`; + const result = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${viewContext}: the construct's finding accompanies the answer, so ` + + "exit 1 with the full view (SPEC 11.2, 12.0)", + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${viewContext}: a single JSON document is the only output form (SPEC 11)`, + ), + { text: true }, + viewContext, + ); + assertEnclosedConstructFinding( + report.findings, + arm, + `${viewContext}: the accompanying findings (SPEC 11.2)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [T2_7_1_ENCLOSED_FILE], + `${viewContext}: one per-file view, the parseable file's (SPEC 11.4)`, + ); + const view = report.views[0]!; + const { start, end } = arm.fixture.range; + const intruders = everyViewNode(view.root).filter( + (node) => node.range.start >= start && node.range.start < end, + ); + if (intruders.length > 0) { + fail( + `${viewContext}: ${arm.noNode}, so the view's tree holds no node ` + + `within the construct's range [${String(start)}, ${String(end)}) ` + + `(SPEC 2.7, 11.4); got ${intruders + .map( + (node) => + `${JSON.stringify(node.identity)} [${String(node.range.start)}, ` + + `${String(node.range.end)})`, + ) + .join(", ")}`, + ); + } + assertSameJson( + projectTextNode(view.root), + arm.fixture.tree, + `${viewContext}: ${arm.treeShape}, the tag lines dropped, and the ` + + "root's subtree text in document order (SPEC 2.7, 11.2, 11.4, 1.6, 3)", + ); + assertSameJson( + [view.imports, view.occurrences, view.comments], + [[], [], []], + `${viewContext}: the construct is no embedding and no comment — no ` + + "occurrence record and no comment range; no import staged (SPEC " + + "2.7, 5.7, 12.7)", + ); + }, + ); +} + const T2_7_1 = defineProductTest({ id: "T2.7-1", title: - "a JSX element other than `<S>`/`<Spec>`, an expression container other than `text(...)` or an MDX comment, and an export statement each fail with 14.16 (SPEC 2.7)", + "a JSX element other than `<S>`/`<Spec>`, an expression container other than `text(...)` or an MDX comment, and an export statement each fail with 14.16; a fragment is one 14.16 from `<>` through `</>`, no node, its enclosed content preserved under `view --text`; an attribute value expression is part of its element — `<S id=\"x\" d={1}>` reports 14.8 alone and `<div a={1}></div>` exactly one 14.16; a section spelled inside an expression container is part of the container's expression — one 14.16 brace through brace, no node in the view's tree, its bytes content in the enclosing text (SPEC 2.7, 14.16, 11.2, 11.4)", run: async (product) => { - for (const arm of FOREIGN_CONSTRUCT_ARMS) { + for (const { arm, source } of FOREIGN_CONSTRUCT_STAGINGS) { const context = `T2.7-1 \`build --json\` with ${arm.name}`; await withWorkspace( SPECS_ONLY_CONFIG, - { "specs/A.mdx": arm.prefix + arm.construct + arm.suffix }, + { "specs/A.mdx": source }, async (workspace) => { const findings = await buildFindings(product, workspace, context); - assertConditionCounts(findings, { "14.16": 1 }, context); + assertConditionCounts( + findings, + { [arm.condition]: 1 }, + `${context}: ${arm.reason}`, + ); assertFindingLocated( findings[0]!, { file: "specs/A.mdx", window: byteWindow(arm.prefix, arm.construct), }, - `${context}: the 14.16 finding (SPEC 2.7: only imports, sections, ` + - "`text(...)` embeddings, and MDX comments are permitted)", + `${context}: the ${arm.condition} finding (SPEC 2.7, 14)`, ); }, ); } + await runEnclosedConstructArm(product, "T2.7-1", T2_7_1_CONTAINER_ARM); + await runEnclosedConstructArm(product, "T2.7-1", T2_7_1_FRAGMENT_ARM); }, }); @@ -273,32 +790,46 @@ const T2_7_2_BOUNDARY_SEC_TEXT = "Alpha text beta.\n\n\n\nGamma text.\n"; // The stability arms: each applies exactly one comment-only edit to the // committed baseline. Every variant compiles to T2_7_2_COMPILED — comments // (and their whole-line deletion) leave own content untouched (SPEC 1.6, 3). -const T2_7_2_STABILITY_ARMS: readonly { name: string; source: string }[] = [ - { - name: "editing only the inline comment's content", - source: T2_7_2_BASELINE.replace( +// Each arm's variant is staged after the baseline capture — a staged-source +// record per row, named with the row's name, judged before any product +// exists (S-9, test/self/s9-staged-sources.test.ts). +const stabilityArm = ( + name: string, + source: string, +): { name: string; source: StagedMdx } => ({ + name, + source: stagedMdx(`T2.7-2 ${name}`, source), +}); +const T2_7_2_STABILITY_ARMS: readonly { name: string; source: StagedMdx }[] = [ + stabilityArm( + "editing only the inline comment's content", + T2_7_2_BASELINE.replace( "{/* inline note */}", "{/* inline note, reworded */}", ), - }, - { - name: "editing only the own-line comment's content", - source: T2_7_2_BASELINE.replace( + ), + stabilityArm( + "editing only the own-line comment's content", + T2_7_2_BASELINE.replace( "{/* own-line note */}", "{/* a different remark */}", ), - }, - { - name: "deleting the inline comment sharing its line with retained non-whitespace content", - source: T2_7_2_BASELINE.replace("{/* inline note */}", ""), - }, - { - name: "deleting the own-line comment together with its entire line (construct plus terminator)", - source: T2_7_2_BASELINE.replace("{/* own-line note */}\n", ""), - }, + ), + stabilityArm( + "deleting the inline comment sharing its line with retained non-whitespace content", + T2_7_2_BASELINE.replace("{/* inline note */}", ""), + ), + stabilityArm( + "deleting the own-line comment together with its entire line (construct plus terminator)", + T2_7_2_BASELINE.replace("{/* own-line note */}\n", ""), + ), ]; -const T2_7_2_BOUNDARY = T2_7_2_BASELINE.replace("{/* own-line note */}", ""); +// The boundary arm, staged after the stability arms — a staged-source record. +const T2_7_2_BOUNDARY = stagedMdx( + "T2.7-2 specs/A.mdx with only the own-line comment's construct characters deleted (the boundary arm)", + T2_7_2_BASELINE.replace("{/* own-line note */}", ""), +); const T2_7_2_ROOT = "specs/A.mdx"; const T2_7_2_SEC = "specs/A.mdx#sec"; @@ -628,10 +1159,17 @@ const T2_7_2 = defineProductTest({ // --------------------------------------------------------------------------- // The invalid-prop matrix (SPEC 2.7 → 14.17/14.8), each arm a fresh minimal -// workspace whose offending opening tag is the one staged defect. The quoted +// workspace whose offending opening tag is the one staged defect, reported +// as exactly one finding of the arm's condition and nothing else. The quoted // `d` names the existing sibling, so a product wrongly accepting quoted-form // `d` resolves it and builds clean — caught by the exit-1 expectation rather -// than accidentally passing via an unresolved-reference finding. +// than accidentally passing via an unresolved-reference finding. The three +// valueless string-prop arms are the bare-name forms — `<S id>`, +// `<S id="x" coverage>`, `<S id="x" tags>` — of 2.7's "any other value +// form" (14.17); the bare `<S id>` is condition 17, never the missing-id +// condition 1 (14.1), so a product reading it as an absent `id` fails on the +// bearer's own code (its masking of children: T1.3-6; the view side of the +// bare-name forms: T11.2-2, T11.4-3). interface InvalidPropArm { /** Which SPEC 2.7 prop rule this violates (failure diagnostics). */ readonly name: string; @@ -639,6 +1177,187 @@ interface InvalidPropArm { readonly construct: string; /** The SPEC 14 condition the arm must report. */ readonly condition: "14.17" | "14.8"; + /** + * A condition the arm discriminates against — the one a product misreading + * the construct would report instead — checked ahead of the exact count so + * the failure states the SPEC reason (the count alone rejects it too). + */ + readonly forbids?: { readonly condition: string; readonly reason: string }; + /** + * Where the one finding locates exactly, when SPEC 14 pins it beyond the + * opening tag's window: the attribute's own characters — for a spread + * attribute its whole braced construct (14, 11.4; T14-11) — spelled + * exactly once in `construct`. + */ + readonly locate?: string; + /** + * The arm's file as a staged-source record another test stages too, + * when one exists — T11.4-3's shared fixture (`VALUELESS_TAGS_STAGED`); + * the staging table composes every other arm's record itself. + */ + readonly shared?: StagedMdx; +} + +/** A staged attribute's exact bytes — the `view` entry form of SPEC 11.4. */ +export interface StagedAttribute { + /** The attribute's name as spelled. */ + readonly name: string; + /** Its byte range within the file (SPEC 1.7). */ + readonly range: SourceRange; + /** Its source text: the name through its value's last character, or the bare name. */ + readonly text: string; +} + +/** + * T2.7-3's valueless-`tags` file, the fixture T11.4-3 shares for `view` + * (TEST-SPEC T11.4-3: one fixture for build and view — the build arm asserts + * its one 14.17; the view arm asserts the bearer's identity defined beside + * it, its attribute entries, and its interpreted tags unavailable, SPEC + * 11.2/11.4). Pure ASCII, so string indices are byte offsets; every offset — + * the bearer's and the valid sibling's — derives from the exact parts, and + * T2.7-3 slices each back against `source` before staging. + */ +export interface ValuelessTagsFixture { + /** Workspace-relative path the file is staged at. */ + readonly file: string; + /** The file's exact bytes: the valid sibling section, then the bearer. */ + readonly source: string; + /** The bearer's spelled `id` value — well-formed, its identity defined (SPEC 11.2). */ + readonly id: string; + /** The bearer's opening tag, exactly — the one staged defect. */ + readonly construct: string; + /** The bearer's construct range, opening tag through closing tag (SPEC 1.7). */ + readonly sectionRange: SourceRange; + /** The bearer's attributes in tag order: `id="x"`, then the bare `tags`. */ + readonly attributes: readonly StagedAttribute[]; + /** + * The valid sibling preceding the bearer — `<S id="ok">`, the file's first + * bytes — every datum of it plain under SPEC 11.2 (the defaults for its + * absent `tags` and `coverage`): the control beside the one defect. + */ + readonly sibling: { + /** Its spelled `id` value. */ + readonly id: string; + /** Its construct range, opening tag through closing tag (SPEC 1.7). */ + readonly sectionRange: SourceRange; + /** Its attributes in tag order: the one `id="ok"`. */ + readonly attributes: readonly StagedAttribute[]; + }; + /** Where the one 14.17 finding must locate: the opening tag's window (SPEC 14). */ + readonly finding: FindingSourceExpectation; +} + +function valuelessTagsFixture(): ValuelessTagsFixture { + const construct = '<S id="x" tags>'; + const idText = 'id="x"'; + const tagsText = "tags"; + const tagStart = Buffer.byteLength(SIBLING, "utf8"); + const idStart = tagStart + Buffer.byteLength("<S ", "utf8"); + const tagsStart = idStart + Buffer.byteLength(`${idText} `, "utf8"); + const siblingIdText = 'id="ok"'; + const siblingIdStart = Buffer.byteLength("<S ", "utf8"); + return { + file: INVALID_PROP_FILE, + source: invalidPropSource(construct), + id: "x", + construct, + sectionRange: { + start: tagStart, + end: tagStart + Buffer.byteLength(construct + INVALID_PROP_BODY, "utf8"), + }, + attributes: [ + { + name: "id", + range: { start: idStart, end: idStart + idText.length }, + text: idText, + }, + { + name: "tags", + range: { start: tagsStart, end: tagsStart + tagsText.length }, + text: tagsText, + }, + ], + sibling: { + id: "ok", + sectionRange: { + start: 0, + end: Buffer.byteLength(SIBLING_CONSTRUCT, "utf8"), + }, + attributes: [ + { + name: "id", + range: { + start: siblingIdStart, + end: siblingIdStart + siblingIdText.length, + }, + text: siblingIdText, + }, + ], + }, + finding: { + file: INVALID_PROP_FILE, + window: byteWindow(SIBLING, construct), + }, + }; +} + +export const VALUELESS_TAGS_FIXTURE: ValuelessTagsFixture = + valuelessTagsFixture(); + +/** The valueless-`tags` arm's diagnostic name (its row and its record's name). */ +const VALUELESS_TAGS_ARM_NAME = + 'a valueless `tags` (`<S id="x" tags>`, the file T11.4-3 shares)'; + +/** + * The fixture's file as a staged-source record (helpers/staged-mdx.ts): + * T2.7-3's valueless-`tags` arm stages it after the body's first product + * invocation, and T11.4-3's shared workspace — its third — stages the same + * bytes, so the one record carries both IDs (S-9's before-any-product + * clause), made from the exported fixture's `source`. + */ +export const VALUELESS_TAGS_STAGED: StagedMdx = stagedMdx( + `T2.7-3/T11.4-3 ${VALUELESS_TAGS_ARM_NAME} ${INVALID_PROP_FILE}`, + VALUELESS_TAGS_FIXTURE.source, +); + +/** + * The exported fixture's declared offsets against its own bytes (staging + * integrity, T11.4-3's slice-check precedent): each attribute's range slices + * to its text and the section range to the bearer's whole construct, so + * T11.4-3 asserts `view` against offsets the build arm has verified — and + * runs this same check itself, since it may run alone. + */ +export function assertValuelessTagsFixture(): void { + const fixture = VALUELESS_TAGS_FIXTURE; + const context = "T2.7-3 staging: the exported valueless-`tags` fixture"; + const bytes = Buffer.from(fixture.source, "utf8"); + const slice = (range: SourceRange): string => + bytes.subarray(range.start, range.end).toString("utf8"); + for (const attribute of fixture.attributes) { + assertSameJson( + slice(attribute.range), + attribute.text, + `${context}: the \`${attribute.name}\` attribute's range slices to its text`, + ); + } + assertSameJson( + slice(fixture.sectionRange), + fixture.construct + INVALID_PROP_BODY, + `${context}: the section range slices to the bearer's whole construct`, + ); + for (const attribute of fixture.sibling.attributes) { + assertSameJson( + slice(attribute.range), + attribute.text, + `${context}: the sibling's \`${attribute.name}\` attribute's range ` + + `slices to its text`, + ); + } + assertSameJson( + slice(fixture.sibling.sectionRange), + SIBLING_CONSTRUCT, + `${context}: the sibling's section range slices to its whole construct`, + ); } const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ @@ -656,6 +1375,21 @@ const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ name: "a spread attribute", construct: '<S id="sec" {...extra}>', condition: "14.17", + locate: "{...extra}", + }, + { + name: "a spread attribute whose braces hold a parenthesized comma sequence (`{...(a, b)}`, well-formed)", + construct: '<S id="x" {...(a, b)}>', + condition: "14.17", + locate: "{...(a, b)}", + forbids: { + condition: "14.20", + reason: + "a spread attribute's braces hold `...` followed by exactly one " + + "AssignmentExpression, and a parenthesized comma sequence is one, " + + "so `{...(a, b)}` is well-formed MDX — the spread is an invalid " + + "prop, 14.17, never a parse failure (SPEC 14.20, 2.7; T14-12)", + }, }, { name: "a braced `id` value", @@ -672,6 +1406,29 @@ const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ construct: '<S id="sec" tags={"a"}>', condition: "14.17", }, + { + name: "a valueless `id` (the bare name `<S id>`)", + construct: "<S id>", + condition: "14.17", + forbids: { + condition: "14.1", + reason: + "a bare `<S id>` spells an id value not in quoted static-string " + + "form — condition 17, never condition 1 (SPEC 14.1, 2.7): a product " + + "reading the bare name as an absent `id` and reporting missing-id fails", + }, + }, + { + name: 'a valueless `coverage` (`<S id="x" coverage>`)', + construct: '<S id="x" coverage>', + condition: "14.17", + }, + { + name: VALUELESS_TAGS_ARM_NAME, + construct: VALUELESS_TAGS_FIXTURE.construct, + condition: "14.17", + shared: VALUELESS_TAGS_STAGED, + }, { name: "a quoted `d` value", construct: '<S id="sec" d="ok">', @@ -694,11 +1451,82 @@ const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ }, ]; +/** An invalid-prop arm with its one-defect file as a record. */ +interface InvalidPropStaging { + readonly arm: InvalidPropArm; + /** + * `invalidPropSource(arm.construct)` — or the arm's `shared` record, the + * same bytes — staged as `specs/A.mdx`. + */ + readonly source: StagedMdx; +} + +// The arms' files, composed once at module load: every arm workspace after +// the body's first is created after its first invocation (S-9's timing +// clause), and the table converts uniformly; an arm whose file another +// test shares takes that one record (`shared`) rather than a second +// spelling of its bytes. +const INVALID_PROP_STAGINGS: readonly InvalidPropStaging[] = + INVALID_PROP_ARMS.map((arm) => ({ + arm, + source: + arm.shared ?? + stagedMdx( + `T2.7-3 ${arm.name} ${INVALID_PROP_FILE}`, + invalidPropSource(arm.construct), + ), + })); + // A repeated unknown prop is simultaneously repeated and unknown — two causes // of the one condition 14.17, so SPEC fixes the condition of every finding // but not one exact count. Its arm asserts all findings are 14.17 at the // construct instead of a count. const REPEATED_UNKNOWN_CONSTRUCT = '<S id="sec" wibble="a" wibble="b">'; +// Its workspace follows the arms' invocations: a staged-source record. +const REPEATED_UNKNOWN_SOURCE = stagedMdx( + "T2.7-3 a repeated unknown prop specs/A.mdx", + invalidPropSource(REPEATED_UNKNOWN_CONSTRUCT), +); + +// The spread grammar pair's ill-formed half (SPEC 14.20: a spread attribute's +// braces hold `...` followed by exactly one AssignmentExpression, so +// `{...a, b}` is not well-formed while `{...(a, b)}` — the arm above — is): +// the file is unparseable, 14.20 alone, its one zero-length range at the +// offset SPEC 14's syntax-failure rule fixes — the byte length of the longest +// whole-character prefix with which some well-formed file begins: the prefix +// through `{...a` begins one (`}>` closes the spread), the prefix through +// the comma none (after an identifier operand only a comma operator can +// follow, which no AssignmentExpression derives) — the comma's own offset +// (T14-11, T14-12). Staged under S-9's `unparseable` declaration; an +// unparseable file masks every other condition (T14-3), so the count is +// exact over all conditions, and a product parsing past the comma and +// reporting the spread as an invalid prop fails on the condition. +const T2_7_3_SPREAD_UNPARSEABLE_CONSTRUCT = '<S id="x" {...a, b}>'; +const T2_7_3_SPREAD_COMMA_OFFSET = utf8Bytes(`${SIBLING}<S id="x" {...a`); + +/** + * The failing half's staging — the very bytes the arm below drives, the + * one-defect file `invalidPropSource` composes — with the comma's offset, + * exported for T14-11's re-assertion of the offset the same way (TEST-SPEC + * T14-11's closing clause). `specs/A.mdx` is a staged-source record declared + * unparseable (S-9): the arm below and T14-11 each stage it after their + * bodies' first product invocations. + */ +export const T2_7_3_SPREAD_UNPARSEABLE_STAGING: UnparseableStaging = { + name: + "a spread attribute `{...a, b}` — `...` followed by more than one " + + "assignment expression, the zero-length range at its comma (T2.7-3)", + kind: "spec-source", + file: INVALID_PROP_FILE, + files: { + [INVALID_PROP_FILE]: stagedMdx( + `T2.7-3/T14-11 a spread attribute \`{...a, b}\` (the spread grammar pair's ill-formed half) ${INVALID_PROP_FILE}`, + invalidPropSource(T2_7_3_SPREAD_UNPARSEABLE_CONSTRUCT), + "unparseable", + ), + }, + offset: T2_7_3_SPREAD_COMMA_OFFSET, +}; // The positive quoting arm (SPEC 2.7: single- or double-quoted alike; 2.4): // the two spellings of one workspace, rebuilt in place. Byte equality is @@ -711,46 +1539,130 @@ const REPEATED_UNKNOWN_CONSTRUCT = '<S id="sec" wibble="a" wibble="b">'; // graph-data bytes are not pinned across the *different* sources: SPEC fixes // their information, not their bytes (13.1, 13.3; H-4) — the T1.1-2 // tag-equivalence precedent. -const T2_7_3_DOUBLE_QUOTED = - '<S id="login" coverage="none" tags="a b">\nLogin behavior.\n</S>\n'; -const T2_7_3_SINGLE_QUOTED = - "<S id='login' coverage='none' tags='a b'>\nLogin behavior.\n</S>\n"; +// The quoting arm's workspace follows the invalid-prop arms' invocations: +// a staged-source record (S-9's timing clause). +const T2_7_3_DOUBLE_QUOTED = stagedMdx( + "T2.7-3 specs/A.mdx with double-quoted id/coverage/tags values", + '<S id="login" coverage="none" tags="a b">\nLogin behavior.\n</S>\n', +); +// Staged after the double-quoted variant's build and queries — a +// staged-source record (S-9, test/self/s9-staged-sources.test.ts). +const T2_7_3_SINGLE_QUOTED = stagedMdx( + "T2.7-3 specs/A.mdx with single-quoted id/coverage/tags values", + "<S id='login' coverage='none' tags='a b'>\nLogin behavior.\n</S>\n", +); const T2_7_3_QUOTED_COMPILED = "Login behavior.\n"; const T2_7_3_QUOTED_IDENTITIES = ["specs/A.mdx", "specs/A.mdx#login"] as const; const T2_7_3 = defineProductTest({ id: "T2.7-3", title: - "repeated props (defined or unknown), unknown props, spread attributes, braced `id`/`coverage`/`tags` values, and quoted or valueless `d` fail with 14.17; a braced `d` holding a non-reference expression fails with 14.8; single-quoted `id`/`coverage`/`tags` build byte-identically in outputs to the double-quoted variants (SPEC 2.7, 2.4)", + 'repeated props (defined or unknown), unknown props, spread attributes, braced or valueless `id`/`coverage`/`tags` values — the bare `<S id>` reporting 14.17 and never 14.1 — and quoted or valueless `d` fail with 14.17, each arm exactly one finding located at its opening tag, a spread attribute\'s at its whole braced construct; the spread grammar pair — `{...(a, b)}` well-formed, 14.17 at the braced construct, `{...a, b}` not, 14.20 at its comma; a braced `d` holding a non-reference expression fails with 14.8; single-quoted `id`/`coverage`/`tags` build byte-identically in outputs to the double-quoted variants (SPEC 2.7, 2.4, 14.1, 14.20); the `<S id="x" tags>` file is exported as the fixture T11.4-3 shares for `view`', run: async (product) => { - for (const arm of INVALID_PROP_ARMS) { + // The exported fixture's offsets are verified before its arm stages it + // (T11.4-3 stages the same bytes for `view`). + assertValuelessTagsFixture(); + + for (const { arm, source } of INVALID_PROP_STAGINGS) { const context = `T2.7-3 \`build --json\` with ${arm.name}`; + const { forbids } = arm; await withWorkspace( SPECS_ONLY_CONFIG, - { "specs/A.mdx": `${SIBLING}${arm.construct}\nBody text.\n</S>\n` }, + { [INVALID_PROP_FILE]: source }, async (workspace) => { const findings = await buildFindings(product, workspace, context); + if (forbids !== undefined) { + const wrong = findings.find( + (finding) => finding.condition === forbids.condition, + ); + if (wrong !== undefined) { + fail( + `${context}: ${forbids.reason}; got a ${forbids.condition} ` + + `finding (message: ${JSON.stringify(wrong.message)})`, + ); + } + } assertConditionCounts(findings, { [arm.condition]: 1 }, context); - assertFindingLocated( - findings[0]!, - { - file: "specs/A.mdx", - window: byteWindow(SIBLING, arm.construct), - }, - `${context}: the ${arm.condition} finding (SPEC 2.7)`, - ); + if (arm.locate === undefined) { + assertFindingLocated( + findings[0]!, + { + file: INVALID_PROP_FILE, + window: byteWindow(SIBLING, arm.construct), + }, + `${context}: the ${arm.condition} finding (SPEC 2.7)`, + ); + } else { + assertSoleLocationExactly( + findings[0]!, + INVALID_PROP_FILE, + constructPartRange( + arm.construct, + arm.locate, + utf8Bytes(SIBLING), + context, + ), + `${context}: the ${arm.condition} finding locates the ` + + "attribute's own characters — the spread's whole braced " + + "construct, as byte offsets (SPEC 14, 11.4, 1.7; T14-11)", + ); + } }, ); } + // The spread grammar pair's ill-formed half: 14.20 alone, at the comma. + const spreadUnparseable = + "T2.7-3 `build --json` with a spread attribute whose braces hold a " + + "bare comma sequence (`{...a, b}`, not well-formed)"; + await withWorkspace( + SPECS_ONLY_CONFIG, + T2_7_3_SPREAD_UNPARSEABLE_STAGING.files, + async (workspace) => { + const findings = await buildFindings( + product, + workspace, + spreadUnparseable, + ); + const wrong = findings.find((finding) => finding.condition === "14.17"); + if (wrong !== undefined) { + fail( + `${spreadUnparseable}: a spread attribute's braces hold \`...\` ` + + "followed by exactly one AssignmentExpression, which `a, b` " + + "is not, so the file is not well-formed MDX — 14.20, never " + + "the invalid-prop condition a product parsing past the comma " + + "would report (SPEC 14.20, 2.7; T14-12); got a 14.17 finding " + + `(message: ${JSON.stringify(wrong.message)})`, + ); + } + assertConditionCounts( + findings, + { "14.20": 1 }, + `${spreadUnparseable}: 14.20 alone — an unparseable file masks ` + + "every other condition (SPEC 14.20; T14-3)", + ); + assertSoleLocationExactly( + findings[0]!, + INVALID_PROP_FILE, + { + start: T2_7_3_SPREAD_COMMA_OFFSET, + end: T2_7_3_SPREAD_COMMA_OFFSET, + }, + `${spreadUnparseable}: the one zero-length range at the comma's ` + + "offset — the byte length of the longest whole-character prefix " + + "with which some well-formed file begins, the prefix through " + + "`{...a` beginning one and the prefix through the comma none " + + "(SPEC 14, 14.20; T14-11, T14-12)", + ); + }, + ); + // Repeated unknown prop: every finding is 14.17, at the construct. const repeatedUnknown = "T2.7-3 `build --json` with a repeated unknown prop"; await withWorkspace( SPECS_ONLY_CONFIG, - { - "specs/A.mdx": `${SIBLING}${REPEATED_UNKNOWN_CONSTRUCT}\nBody text.\n</S>\n`, - }, + { [INVALID_PROP_FILE]: REPEATED_UNKNOWN_SOURCE }, async (workspace) => { const findings = await buildFindings( product, @@ -774,7 +1686,7 @@ const T2_7_3 = defineProductTest({ assertFindingLocated( finding, { - file: "specs/A.mdx", + file: INVALID_PROP_FILE, window: byteWindow(SIBLING, REPEATED_UNKNOWN_CONSTRUCT), }, `${repeatedUnknown}: a 14.17 finding`, @@ -843,9 +1755,563 @@ const T2_7_3 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T2.7-4 +// --------------------------------------------------------------------------- + +// The comment forms of SPEC 2.7 beyond T2.7-2's usual `{/* … */}`: an MDX +// comment is an expression container whose content — the characters between +// its braces — is nothing but whitespace and JavaScript comments as 14.20 +// counts them: whitespace and line terminators ECMAScript's (1.4), a line +// comment running through the first U+000A or U+000D, and a brace on a +// commented-out line closing nothing, so the run-on `{// c}` runs to the +// next `}`. Every such form behaves as T2.7-2's comment: removed from +// Markdown output (byte-asserted, 3), absent from own text (`query node`), +// no finding, `build` exit 0, and listed in `view`'s `comments` with its full +// container range, opening brace through closing brace (11.4). A container +// holding anything else is no comment: an invalid expression container +// (14.16) where the grammar derives its content, an unparseable file (14.20) +// where it does not — U+0085, U+200B, and U+180E being neither ECMAScript +// whitespace nor line terminators, and the comment grammar's own failures +// (T14-12). ECMAScript's space separators are the latest Unicode version's +// — Unicode 15.1's (14.20) — so every Zs code point outside Latin-1 between +// braces is a comment too, one arm each, while U+180E, a space separator up +// to Unicode 6.2 and a format character under 15.1, is neither whitespace +// nor an identifier character: 14.20 at its offset. The code points are +// spelled from their values, never as escape literals. +const CARRIAGE_RETURN = String.fromCodePoint(0x0d); +const NO_BREAK_SPACE = String.fromCodePoint(0xa0); +const ZERO_WIDTH_NO_BREAK_SPACE = String.fromCodePoint(0xfeff); +const LINE_SEPARATOR = String.fromCodePoint(0x2028); +const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029); +const NEXT_LINE = String.fromCodePoint(0x85); +const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b); +const MONGOLIAN_VOWEL_SEPARATOR = String.fromCodePoint(0x180e); + +/** + * The space separators (general category Zs) that Unicode 15.1 places + * outside Latin-1 — U+1680, U+2000 through U+200A, U+202F, U+205F, and + * U+3000 — one comment arm each (T2.7-4). + */ +const SPACE_SEPARATORS_PAST_LATIN_1: readonly number[] = [ + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, + 0x2008, 0x2009, 0x200a, 0x202f, 0x205f, 0x3000, +]; + +/** A code point's conventional name, `U+` and at least four hex digits. */ +function codePointName(code: number): string { + return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`; +} + +/** One comment form of 2.7: its container's exact characters and staging. */ +interface CommentForm { + /** The form's own file, workspace-relative (one file per form). */ + readonly file: string; + /** The arm's name in contexts. */ + readonly name: string; + /** The container's own characters, opening brace through closing brace. */ + readonly construct: string; + /** + * `twin`: an inline occurrence sharing its line with retained + * non-whitespace on both sides, then an own-line occurrence between blank + * lines; `lone-inline`: the inline occurrence alone — the carriage-return + * twin is staged with no later `}` in its file, so that a product ending + * line comments at U+000A alone reads its `}` as lying on the + * commented-out line, finds the container unclosed (14.20), and fails. + */ + readonly layout: "twin" | "lone-inline"; +} + +const T2_7_4_COMMENT_FORMS: readonly CommentForm[] = [ + { + file: "specs/empty.mdx", + name: "the empty container `{}`", + construct: "{}", + layout: "twin", + }, + { + file: "specs/space.mdx", + name: "ASCII whitespace between braces (`{ }`)", + construct: "{ }", + layout: "twin", + }, + { + file: "specs/blocks.mdx", + name: "a block-comment sequence `{ /* a */ /* b */ }`", + construct: "{ /* a */ /* b */ }", + layout: "twin", + }, + { + file: "specs/line-lf.mdx", + name: "a line-comment container ended by U+000A before its closing brace", + construct: "{// c\n}", + layout: "twin", + }, + { + file: "specs/line-cr.mdx", + name: + "a line-comment container ended by U+000D before its closing brace, " + + "no later `}` in the file", + construct: `{// c${CARRIAGE_RETURN}}`, + layout: "lone-inline", + }, + { + file: "specs/run-on.mdx", + name: + "the run-on form `{// c}` U+000A `}`, its first brace on the " + + "commented-out line closing nothing", + construct: "{// c}\n}", + layout: "twin", + }, + { + file: "specs/nbsp.mdx", + name: "U+00A0 between braces", + construct: `{${NO_BREAK_SPACE}}`, + layout: "twin", + }, + { + file: "specs/feff.mdx", + name: "U+FEFF between braces", + construct: `{${ZERO_WIDTH_NO_BREAK_SPACE}}`, + layout: "twin", + }, + { + file: "specs/ls.mdx", + name: "U+2028 between braces", + construct: `{${LINE_SEPARATOR}}`, + layout: "twin", + }, + { + file: "specs/ps.mdx", + name: "U+2029 between braces", + construct: `{${PARAGRAPH_SEPARATOR}}`, + layout: "twin", + }, + // One arm per space separator outside Latin-1, each in its own file: a + // product whose brace-side whitespace is ASCII's plus the four code points + // 14.20 names takes `{` U+3000 `}` (the ideographic space CJK input methods + // produce) for no empty expression and masks that file. + ...SPACE_SEPARATORS_PAST_LATIN_1.map((code): CommentForm => ({ + file: `specs/zs-${code.toString(16)}.mdx`, + name: `${codePointName(code)} between braces, a space separator of Unicode 15.1`, + construct: `{${String.fromCodePoint(code)}}`, + layout: "twin", + })), +]; + +// Each form's file: one section holding the form inline — `Alpha ` before +// it and ` beta.` after it on its line — and, in the twin layout, once more +// on a line of its own between blank lines, then a closing paragraph. +const T2_7_4_OPEN = '<S id="sec">\nAlpha '; +const T2_7_4_AFTER_INLINE = " beta.\n\n"; +const T2_7_4_AFTER_OWN_LINE = "\n\n"; +const T2_7_4_CLOSE = "Gamma text.\n</S>\n"; + +/** A comment form staged: its bytes, its container ranges, its text. */ +interface CommentFormFixture { + readonly form: CommentForm; + /** The file's exact bytes. */ + readonly source: string; + /** Every container's range, brace through brace, document order (11.4). */ + readonly comments: readonly SourceRange[]; + /** The section's own text and the file's compiled output (SPEC 1.6, 3). */ + readonly text: string; +} + +function commentFormFixture(form: CommentForm): CommentFormFixture { + const { construct } = form; + const inlineStart = utf8Bytes(T2_7_4_OPEN); + const inline: SourceRange = { + start: inlineStart, + end: inlineStart + utf8Bytes(construct), + }; + const throughInline = T2_7_4_OPEN + construct + T2_7_4_AFTER_INLINE; + // Hand-derived per SPEC 3 (T3-3's rules): the tag lines are emptied purely + // by removals and drop with their terminators; the inline container is + // deleted exactly in place — a line terminator among its own characters + // deleted with it, joining the lines it spanned into one — leaving the + // author's two spaces; already-empty lines are kept. No construct byte + // survives anywhere in the text. + if (form.layout === "lone-inline") { + return { + form, + source: throughInline + T2_7_4_CLOSE, + comments: [inline], + text: "Alpha beta.\n\nGamma text.\n", + }; + } + // The own-line container's line — the lines a multi-line form spans merged + // into one — is left empty purely by the removal and drops with its + // terminator; the blank lines around it keep. + const ownLineStart = utf8Bytes(throughInline); + return { + form, + source: throughInline + construct + T2_7_4_AFTER_OWN_LINE + T2_7_4_CLOSE, + comments: [ + inline, + { start: ownLineStart, end: ownLineStart + utf8Bytes(construct) }, + ], + text: "Alpha beta.\n\n\nGamma text.\n", + }; +} + +const T2_7_4_FIXTURES: readonly CommentFormFixture[] = + T2_7_4_COMMENT_FORMS.map(commentFormFixture); + +/** The emitted Markdown path beside a form's source (SPEC 7.3, 13.2). */ +function emittedMarkdownPath(file: string): string { + return file.replace(/\.mdx$/u, ".md"); +} + +/** + * The comment forms, all in one emitting workspace: `build` exit 0 with no + * finding, each file's emitted Markdown byte-exact with the form removed, + * each section's own text comment-free, and each container listed under + * `view`'s `comments` by its full range. + */ +async function runCommentForms(product: ProductBinding): Promise<void> { + const files: Record<string, string> = {}; + for (const fixture of T2_7_4_FIXTURES) { + files[fixture.form.file] = fixture.source; + } + await withWorkspace(EMIT_TRUE_CONFIG, files, async (workspace) => { + await buildOk( + product, + workspace, + "T2.7-4 `build` with emission over the comment forms of 2.7 — every " + + "form is a comment, so no finding and exit 0 (SPEC 2.7)", + ); + for (const fixture of T2_7_4_FIXTURES) { + const context = `T2.7-4 (${fixture.form.name})`; + // Removed from Markdown output: byte equality of the whole emitted + // file against the hand-derived compilation (SPEC 2.7, 3; T3-3). + await assertFileBytes( + workspace.path(emittedMarkdownPath(fixture.form.file)), + fixture.text, + `${context}: emitted Markdown — the comment is removed by its own ` + + "characters, exact deletion in place, an emptied own line dropped " + + "with its terminator and a multi-line form's lines merged (SPEC " + + "2.7, 3; T3-3)", + ); + // Not part of own text (`query node`): exact bytes (SPEC 1.6). + const node = await queryNode( + product, + workspace, + `${fixture.form.file}#sec`, + context, + ); + assertBytesEqual( + node.ownText, + fixture.text, + `${context}: own text — the comment is not part of it (SPEC 2.7, 1.6)`, + ); + } + + // Listed in `view`'s `comments` with the full container range, opening + // brace through closing brace, in document order (SPEC 11.4) — nothing + // else in the view: no import staged, no embedding. + const viewContext = "T2.7-4 bare `view --text` over the comment forms"; + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", "--text"], + `${viewContext}: a finding-free answer, exit 0 (SPEC 11.2, 12.0)`, + ), + { text: true }, + viewContext, + ); + assertSameJson( + report.findings, + [], + `${viewContext}: every form is a comment — no finding (SPEC 2.7)`, + ); + assertSameJson( + report.views.map((view) => view.file), + T2_7_4_FIXTURES.map((fixture) => fixture.form.file).sort(), + `${viewContext}: one per-file view per discovered spec source, by byte ` + + "order of workspace-relative path (SPEC 11.4)", + ); + for (const fixture of T2_7_4_FIXTURES) { + const context = `${viewContext} (${fixture.form.name})`; + const view = report.views.find( + (candidate) => candidate.file === fixture.form.file, + )!; + assertSameJson( + view.comments, + fixture.comments, + `${context}: \`comments\` lists each container's source range — its ` + + "full braced container, opening brace through closing brace, as " + + "byte offsets (SPEC 11.4, 1.7)", + ); + assertSameJson( + [view.imports, view.occurrences], + [[], []], + `${context}: a comment is no import and no embedding — no import ` + + "entry and no occurrence record (SPEC 2.7, 5.7, 11.4)", + ); + const section = view.root.children[0]; + assertSameJson( + [view.root.children.length, section?.identity], + [1, `${fixture.form.file}#sec`], + `${context}: the tree holds the root and the one section (SPEC 11.4)`, + ); + assertBytesEqual( + typeof section?.ownText === "string" ? section.ownText : "", + fixture.text, + `${context}: the section's own text under \`--text\` — comment-free ` + + "(SPEC 11.4, 1.6)", + ); + } + }); +} + +// An expression beside comments is no comment: an invalid expression +// container (14.16), the grammar deriving its content — T2.7-1's +// enclosed-construct machinery pins the one finding brace through brace, the +// view's tree without a node for it, its bytes preserved as content (11.2), +// and no `comments` entry. +const T2_7_4_EXPRESSION_ARM = enclosedConstructArm( + "T2.7-4 an expression beside a comment specs/A.mdx", + { + fixture: enclosedConstructFixture("{/* a */ 1}"), + name: "an expression beside a comment (`{/* a */ 1}`)", + soleFinding: + "exactly one condition-16 finding, the container's, and none beside — " + + "an expression beside comments is no comment but an invalid expression " + + "container, the grammar deriving its content (SPEC 2.7, 14.16)", + rangeRule: + "the 14.16 finding locates the expression container from its opening " + + "brace through its closing brace, as byte offsets (SPEC 14, 1.7)", + noNode: "no node within the container — it encloses no section", + treeShape: + "the section tree by construct nesting — the root, `ok`, and `sec` — " + + "with the container's bytes preserved byte-for-byte as content in " + + "`sec`'s own and subtree text (the by-form classification of 11.2)", + }, +); + +/** + * A form 2.7 makes unparseable (14.20), staged alone as a staged-source + * record declared `unparseable` (S-9): the one zero-length range at the + * offset SPEC 14 fixes — the byte length of the longest whole-character + * prefix with which some well-formed file begins — precomputed from the + * staged bytes. + */ +interface UnparseableCommentArm { + /** The arm's name in contexts. */ + readonly name: string; + /** + * The file's exact bytes, as its record: every arm's workspace follows + * the body's first product invocation, and T14-11 re-stages it after its + * own (`T2_7_4_UNPARSEABLE_STAGINGS`). + */ + readonly source: StagedMdx; + /** The pinned offset (SPEC 1.7 bytes). */ + readonly offset: number; + /** Why SPEC 14 fixes that offset. */ + readonly rule: string; +} + +const T2_7_4_UNPARSEABLE_FILE = "specs/A.mdx"; +const T2_7_4_UNPARSEABLE_PREFIX = `${SIBLING}<S id="bad">\nAlpha.\n\n`; +const T2_7_4_UNPARSEABLE_SUFFIX = "\n\nOmega.\n</S>\n"; + +/** An arm staging `source` alone, registered as its record at load (S-9). */ +function unparseableCommentArm( + name: string, + source: string, + offset: number, + rule: string, +): UnparseableCommentArm { + return { + name, + source: stagedMdx( + `T2.7-4/T14-11 ${name} ${T2_7_4_UNPARSEABLE_FILE}`, + source, + "unparseable", + ), + offset, + rule, + }; +} + +/** A construct on a line of its own inside the section after the sibling. */ +function unparseableInSection( + construct: string, + within: number, + name: string, + rule: string, +): UnparseableCommentArm { + return unparseableCommentArm( + name, + T2_7_4_UNPARSEABLE_PREFIX + construct + T2_7_4_UNPARSEABLE_SUFFIX, + utf8Bytes(T2_7_4_UNPARSEABLE_PREFIX) + within, + rule, + ); +} + +const T2_7_4_CODE_POINT_RULE = + "the zero-length range at the offset of the code point — neither U+0085 " + + "nor U+200B is ECMAScript whitespace or a line terminator (14.20 excludes " + + "both by name), so the content is no empty expression, and neither begins " + + "any token of the grammar, so it derives no expression either; the prefix " + + "through `{` begins a well-formed file (SPEC 14, 14.20, 1.4; T14-11)"; +const T2_7_4_FORMAT_CHARACTER_RULE = + "the zero-length range at the offset of the code point — U+180E, a space " + + "separator up to Unicode 6.2, is a format character under Unicode 15.1, " + + "whose space separators ECMAScript's are (14.20): neither whitespace, a " + + "line terminator, nor an identifier character, so the content is no empty " + + "expression, and it begins no token of the grammar, so it derives no " + + "expression either; the prefix through `{` begins a well-formed file " + + "(SPEC 14, 14.20, 1.4; T14-11)"; +const T2_7_4_FIRST_BRACE_RULE = + "the zero-length range at the offset of the first `}` — the deletion " + + "judgement runs the line comment through U+000A, so the first brace " + + "closes nothing, while the lexical grammar ends the comment at the " + + "separator and finds a brace token; the prefix through the separator " + + "begins a well-formed file, the one through the brace none (SPEC 14, " + + "14.20; T14-12)"; +const T2_7_4_RUN_ON_TAIL = "{// c}\n"; +const T2_7_4_RUN_ON_SOURCE = `${SIBLING}${T2_7_4_RUN_ON_TAIL}`; + +const T2_7_4_UNPARSEABLE_ARMS: readonly UnparseableCommentArm[] = [ + unparseableInSection( + `{${NEXT_LINE}}`, + utf8Bytes("{"), + "U+0085 between braces", + T2_7_4_CODE_POINT_RULE, + ), + unparseableInSection( + `{${ZERO_WIDTH_SPACE}}`, + utf8Bytes("{"), + "U+200B between braces", + T2_7_4_CODE_POINT_RULE, + ), + unparseableInSection( + `{${MONGOLIAN_VOWEL_SEPARATOR}}`, + utf8Bytes("{"), + "U+180E between braces", + T2_7_4_FORMAT_CHARACTER_RULE, + ), + unparseableInSection( + `{// c${LINE_SEPARATOR}}\n}`, + utf8Bytes(`{// c${LINE_SEPARATOR}`), + "`{// c` U+2028 `}` U+000A `}`", + T2_7_4_FIRST_BRACE_RULE, + ), + unparseableInSection( + `{// c${PARAGRAPH_SEPARATOR}}\n}`, + utf8Bytes(`{// c${PARAGRAPH_SEPARATOR}`), + "`{// c` U+2029 `}` U+000A `}`", + T2_7_4_FIRST_BRACE_RULE, + ), + unparseableCommentArm( + "`{// c}` as the file's last construct, no later `}` in the file", + T2_7_4_RUN_ON_SOURCE, + utf8Bytes(T2_7_4_RUN_ON_SOURCE), + "the zero-length range at the file's byte length — the first `}` " + + "lies on the commented-out line and closes nothing, and the whole " + + "file is a prefix of a well-formed one, a later `}` closing the " + + "container (SPEC 14, 14.20, 2.7; T14-12)", + ), +]; + +/** + * The six stagings as T14-11 re-asserts them (TEST-SPEC T14-11's closing + * clause): each arm's record as `specs/A.mdx` with its offset — the same + * staging `runUnparseableCommentArm` drives, declared unparseable (S-9). + */ +export const T2_7_4_UNPARSEABLE_STAGINGS: readonly UnparseableStaging[] = + T2_7_4_UNPARSEABLE_ARMS.map((arm): UnparseableStaging => ({ + name: `${arm.name} (T2.7-4)`, + kind: "spec-source", + file: T2_7_4_UNPARSEABLE_FILE, + files: { [T2_7_4_UNPARSEABLE_FILE]: arm.source }, + offset: arm.offset, + })); + +/** One unparseable form: `build --json`, then the bare `view --text`. */ +async function runUnparseableCommentArm( + product: ProductBinding, + arm: UnparseableCommentArm, +): Promise<void> { + const assertUnparseable = ( + findings: readonly Finding[], + context: string, + ): void => { + assertConditionCounts( + findings, + { "14.20": 1 }, + `${context}: 14.20 alone — the container's content is no empty ` + + "expression and derives no expression, so the file is not " + + "well-formed MDX, and an unparseable file masks every other " + + "condition (SPEC 2.7, 14.20; T14-3)", + ); + assertSoleLocationExactly( + findings[0]!, + T2_7_4_UNPARSEABLE_FILE, + { start: arm.offset, end: arm.offset }, + `${context}: ${arm.rule}`, + ); + }; + await withWorkspace( + SPECS_ONLY_CONFIG, + { [T2_7_4_UNPARSEABLE_FILE]: arm.source }, + async (workspace) => { + const buildContext = `T2.7-4 \`build --json\` with ${arm.name}`; + assertUnparseable( + await buildFindings(product, workspace, buildContext), + buildContext, + ); + const viewContext = `T2.7-4 bare \`view --text\` with ${arm.name}`; + const result = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${viewContext}: the parse-failure finding accompanies the answer, ` + + "so exit 1 (SPEC 11.2, 11.4, 12.0; T14-4)", + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${viewContext}: a single JSON document is the only output form (SPEC 11)`, + ), + { text: true }, + viewContext, + ); + assertUnparseable( + report.findings, + `${viewContext}: the accompanying findings (SPEC 11.2; T14-4)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [], + `${viewContext}: an unparseable requested file contributes no view ` + + "(SPEC 11.4), and it is the only discovered spec source", + ); + }, + ); +} + +const T2_7_4 = defineProductTest({ + id: "T2.7-4", + title: + "every comment form of 2.7 — `{}`, ASCII and ECMAScript-only whitespace between braces (U+00A0, U+FEFF, U+2028, U+2029, and each space separator Unicode 15.1 places outside Latin-1 — U+1680, U+2000 through U+200A, U+202F, U+205F, U+3000 — one arm per code point), a block-comment sequence, line-comment containers ended by U+000A or U+000D before their closing brace, and the run-on `{// c}` U+000A `}` — is removed from Markdown output byte-exactly, absent from own text, finding-free with `build` exit 0, and listed in `view`'s `comments` brace through brace; an expression beside a comment is one 14.16 brace through brace with no `comments` entry; U+0085, U+200B, or U+180E (a format character under Unicode 15.1) between braces, `{// c` U+2028/U+2029 `}` U+000A `}`, and `{// c}` with no later `}` are 14.20 at the offsets SPEC 14 fixes (SPEC 2.7, 14.16, 14.20, 1.4, 3, 11.2, 11.4)", + run: async (product) => { + await runCommentForms(product); + await runEnclosedConstructArm(product, "T2.7-4", T2_7_4_EXPRESSION_ARM); + for (const arm of T2_7_4_UNPARSEABLE_ARMS) { + await runUnparseableCommentArm(product, arm); + } + }, +}); + /** TEST-SPEC §2.7, in canonical ID order (SUITE-10). */ export const section27Tests: readonly ProductTestEntry[] = [ T2_7_1, T2_7_2, T2_7_3, + T2_7_4, ]; diff --git a/test/suite/registry/section-3.ts b/test/suite/registry/section-3.ts index d883c0b7..307f63a3 100644 --- a/test/suite/registry/section-3.ts +++ b/test/suite/registry/section-3.ts @@ -1,5 +1,5 @@ // TEST-SPEC §3 (Markdown compilation) — SUITE-11: T3-1, T3-2, T3-3, T3-4, -// T3-5, T3-6. +// T3-5, T3-6, T3-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), @@ -13,33 +13,75 @@ // Certification staging constraints (CERTIFICATIONS.md §CONF-MD, // §VIOL-MD-CLASS, §VIOL-MD-CR), binding alongside the test text: // - U+00A0, U+0085, and U+2028 appear on removal-affected lines only in -// T3-3's class-boundary arms; every other fixture in this module keeps -// them out entirely. +// T3-3's class-boundary arms (its ESM-block arm, a line left holding +// U+2028 alone between two removed imports, among them); every other +// fixture in this module keeps them out entirely. // - 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). +// - T3-7 (outside CONF-MD's scope — its `view` and `query node` surfaces +// are not served there) keeps the same discipline: LF only, no exotic +// byte; its JavaScript comments beside imports are the ESM-block class +// P-2 composes and the scope admits. -import { assertFileBytes, fail } from "../../helpers/assertions.js"; +import { Buffer } from "node:buffer"; + +import type { + SourceRange, + ViewImportEntry, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeNodeIdentityRowsReport, + decodeNodeReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { + assertBytesEqual, + assertFileBytes, + fail, +} from "../../helpers/assertions.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { defineProductTest } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.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 -// destination (`specs/**/*.md`, SPEC 7.3). -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// destination (`specs/**/*.md`, SPEC 7.3). This configuration and the two +// `markdown` variants below are T3-6's emission-matrix variants, each staged +// in its own workspace — every one after the first created after the body's +// first invocation — so the variant table holds staged-source records (S-9's +// timing clause). +const SPECS_ONLY_CONFIG = stagedTs( + "T3-6 xspec.config.ts — `markdown` absent (the emission matrix's first variant)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // As above with `markdown: { emit: false }` (SPEC 7.3). -const EMIT_FALSE_CONFIG = `import { defineConfig } from "xspec" +const EMIT_FALSE_CONFIG = stagedTs( + "T3-6 xspec.config.ts — `markdown: { emit: false }`", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -47,11 +89,14 @@ export default defineConfig({ }, markdown: { emit: false } }) -`; +`, +); // As above with emission enabled (default destination: next to each source // file, `specs/A.mdx` → `specs/A.md`; SPEC 7.3, 13.2). -const EMIT_TRUE_CONFIG = `import { defineConfig } from "xspec" +const EMIT_TRUE_CONFIG = stagedTs( + "T3-6 xspec.config.ts — `markdown: { emit: true }`", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -59,7 +104,8 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); // --------------------------------------------------------------------------- // T3-1 @@ -73,9 +119,34 @@ 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). -const REMOVALS_SOURCE = [ +// +// 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. +// +// `gamma` is an in-line (text-position) section (S-9; T3-3's staging +// constraint): its opening tag stands at a line start but is followed there +// by content, so the flow attempt fails and the paragraph fallback applies, +// and its closing tag stands within that paragraph — at the end of the +// code-span line — never at the start of a later line, where a flow closing +// tag interrupts the paragraph and leaves the in-line element unclosed (the +// fixture's former shape, a non-derivation under the grammar 14.20 fixes, +// kept as such in `test/self/s9-fixture-well-formedness.test.ts`). The +// second fence follows at top level: a fence interrupts a paragraph, so no +// in-line section can span it. Both tags are deleted in place on lines that +// keep other content — the opening tag at its line's start, the closing tag +// at its line's end — so both lines are kept (SPEC 3). +const SPAN_LINE = 'Inline code span: `<S id="x">{text("a")}` stays literal.'; +/** The staged `specs/A.mdx`, exported for the S-9 self-test (its exact bytes). */ +export const REMOVALS_SOURCE = [ 'import BASE from "./BASE.xspec"', // removed; line drops (SPEC 3) "", "# Removals fixture", @@ -90,6 +161,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.", @@ -99,15 +171,23 @@ const REMOVALS_SOURCE = [ "Beta prose.", "</Spec>", "", - '<S id="gamma">Gamma keeps this line.', // tag deleted in place, content kept + '<S id="gamma">Gamma keeps this line.', // in-line tag deleted in place, content kept "More gamma prose.", - "</S>", + `${SPAN_LINE}</S>`, // code-span bytes literal; the closing tag deleted in place at the line's end + "```md", // a second fence, at top level: construct-like bytes on every line, literal + '<S id="x">', + 'import X from "./X.xspec"', + '{text("a")}', + "```", "", ].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. `gamma`'s two tag lines both keep content beside +// the deleted tag, so both are kept; the second fence, outside every section, +// is preserved as it is. const REMOVALS_COMPILED = [ "", "# Removals fixture", @@ -120,6 +200,7 @@ const REMOVALS_COMPILED = [ "", "```text", "fenced content with spaces ", + "<div>", "```", "", "Middle word.", // "Middle " + "word." after exact in-place comment deletion @@ -128,13 +209,45 @@ const REMOVALS_COMPILED = [ "", "Gamma keeps this line.", // the in-place-deleted opening tag's line, kept "More gamma prose.", + SPAN_LINE, // the in-place-deleted closing tag's line, kept with the code span + "```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 +260,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(); } @@ -253,6 +408,7 @@ const T3_2 = defineProductTest({ const NBSP = "\u{00A0}"; const NEL = "\u{0085}"; const LS = "\u{2028}"; +const PS = "\u{2029}"; // One fixture, one arm per line, with distinct kept marker lines between arms // so a misplaced drop is diagnosed precisely. LF terminators throughout, a @@ -261,8 +417,52 @@ const LS = "\u{2028}"; // subtree text, SPEC 1.1) are defined before use; embeddings sit at root // level, so the root embeds its own root-level sections — forward edges only, // no cycle (SPEC 5.3 forbids embedding an *ancestor*). +// Multi-line constructs (SPEC 3: a terminator among a removed construct's own +// characters is deleted with it, joining the lines it spanned into one, judged +// by the drop rule as a whole): the two multi-line comments, and TEST-SPEC +// T3-3's three multi-line opening-tag arms — each a three-line tag whose two +// interior terminators are among its own characters: the own-lines drop form +// (`<S`, LF, ` id="mt1"`, LF, `>` alone on its lines, a flow tag; the +// merged line is left empty purely by the removal and drops), the flow-start +// kept form with residue after the `>` alone (`> bar</S>` its third line; +// ` bar` kept with its terminator), and the in-line kept form with residue +// on both sides (`foo <S`, LF, ` id="mt3"`, LF, ` coverage="required"> +// bar</S>`, merging to `foo bar`). Every shape derives under the MDX 3 +// grammar SPEC 14.20 fixes (TEST-SPEC S-9 gates it; AGENTS.md's parser +// recipe checks it): a tag opening after text on its line is an in-line tag +// inside a paragraph, whose later lines — the section's included — are +// paragraph-continuation lines, none beginning a construct that interrupts +// a paragraph (a `>` whatever its indentation, among others), with the +// closing tag standing within that paragraph, never at the start of a later +// line, where it is a flow tag that interrupts the paragraph and leaves the +// in-line element unclosed — so both kept forms close on the residue's +// line; a tag opening at line start may end on a bare `>` line (the concrete +// flow attempt takes it, fails on the residue, and the fallback paragraph +// spans all three lines). For the same reason a blank line ends each ESM +// block — MDX 3's ESM construct runs to a blank line, so an import directly +// followed by text does not derive — and a blank line precedes the second +// one, which interrupts no paragraph. +// The class-boundary arms stage every code point TEST-SPEC T3-3 names: +// U+00A0, U+0085, U+2028 (the three §VIOL-MD-CLASS widens) and U+2029 (which +// neither violator touches — its arm is expected unmoved under both). The +// classes hold inside an ESM block too — the ESM-block arm, which CONF-MD's +// scope names: a second ESM block spelling two imports on one physical line +// separated by U+2028, an ECMAScript line terminator, so the block derives +// under 14.20 (AGENTS.md's parser recipe: one `mdxjsEsm` node holding two +// `ImportDeclaration`s); each declaration is removed by its own characters +// alone (SPEC 3), and the line, left holding U+2028 plus its terminator, is +// kept — U+2028 is no whitespace and no terminator of 3 (1.4) — where a +// product applying ECMAScript's line terminators to line dropping drops it +// (§VIOL-MD-CLASS's expected failure; §VIOL-MD-CR leaves it unmoved). Each +// import resolves to a base file of its own (`specs/BASE2.mdx`, +// `specs/BASE3.mdx`), the bindings unused as `BASE` is (2.1). +const ESM_ARM_BASE2_SOURCE = '<S id="base2">\nBase two.\n</S>\n'; +const ESM_ARM_BASE3_SOURCE = '<S id="base3">\nBase three.\n</S>\n'; +const ESM_BLOCK_LINE = + 'import B2 from "./BASE2.xspec"' + LS + 'import B3 from "./BASE3.xspec"'; const DROP_SOURCE = [ 'import BASE from "./BASE.xspec"', // drop: a line holding only an import + "", // kept: already empty in the source — and the ESM block's end (14.20) "K1 after-import", 'defs: x <S id="sp"> </S> y', // kept: removal-affected line retaining content '<S id="empty" />', // drop: left empty purely by removals @@ -287,21 +487,42 @@ const DROP_SOURCE = [ "K10 after-nel", "{/* c */}" + LS, // kept: left holding only U+2028 — not whitespace (1.4) "K11 after-ls", + "{/* c */}" + PS, // kept: left holding only U+2029 — not whitespace (1.4) + "K12 after-ps", "{/* c */}\t", // drop: left holding only U+0009 — whitespace (1.4) - "K12 after-tab-drop", + "K13 after-tab-drop", "{/* c */} ", // drop: left holding only U+0020 — whitespace (1.4) - "K13 after-space-drop", + "K14 after-space-drop", "foo {/* first", // multi-line comment with retained residue on both sides… "tail */} bar", // …deleted exactly: lines merge to "foo bar" - "K14 after-merge", + "K15 after-merge", "{/* own-lines", // own-lines multi-line comment (empty residues)… "multiline */}", // …merged line left empty purely by removals → drops - "K15 final", + "K16 after-own-lines", + "<S", // a multi-line opening tag on its own lines: `<S`, LF, ` id="mt1"`,… + ' id="mt1"', // …LF, `>` — one construct whose two interior terminators are + ">", // deleted with it; the three lines merge into one, left empty → drops + "mt1 body", // kept: a line keeping content keeps its terminator + "</S>", // drop: a line holding only a closing tag + "K17 after-multiline-tag-drop", + "<S", // the flow-start kept form: a tag at line start with retained… + ' id="mt2"', // …non-whitespace after its `>` alone; the three lines merge + "> bar</S>", // into one, kept with its residue ` bar` and its terminator + "K18 after-flow-start-kept", + "foo <S", // the in-line kept form: retained non-whitespace before the… + ' id="mt3"', // …`<S` and after the `>`; the tag's two interior terminators + ' coverage="required"> bar</S>', // deleted with it: merges to `foo bar` + "K19 after-inline-kept", + "", // kept: already empty in the source — the second ESM block's lead-in + ESM_BLOCK_LINE, // the ESM-block arm: both imports removed by their own characters; kept holding U+2028 alone + "", // kept: already empty in the source — the second ESM block's end (14.20) + "K20 final", "", ].join("\n"); // Hand-derived compiled output: exactly the kept lines above, in order. const DROP_COMPILED = [ + "", // the blank line ending the ESM block: already empty, kept "K1 after-import", "defs: x y", // both of sp's tags deleted in place; the space content stays "K2 after-defs", @@ -321,24 +542,38 @@ const DROP_COMPILED = [ "K10 after-nel", LS, "K11 after-ls", - "K12 after-tab-drop", - "K13 after-space-drop", + PS, + "K12 after-ps", + "K13 after-tab-drop", + "K14 after-space-drop", "foo bar", // the comment's own line terminator deleted with it: one line - "K14 after-merge", - "K15 final", + "K15 after-merge", + "K16 after-own-lines", + "mt1 body", // the tag's three lines merged into one empty line, dropped + "K17 after-multiline-tag-drop", + " bar", // mt2: the tag's three lines merged into one, kept with its residue + "K18 after-flow-start-kept", + "foo bar", // mt3: the merged line keeps the residue on both sides + "K19 after-inline-kept", + "", // the blank line before the second ESM block: already empty, kept + LS, // the ESM-block arm: U+2028 alone, kept with its terminator — not whitespace (1.4) + "", // the blank line ending the second ESM block: already empty, kept + "K20 final", "", ].join("\n"); const T3_3 = defineProductTest({ id: "T3-3", title: - "line-drop rule: lines left empty or whitespace-only purely by removals (import, tags, comments, empty expansion) drop with their terminators; already-empty, content-retaining, and whitespace-only-but-non-empty-expansion lines are kept; U+00A0/U+0085/U+2028 are not whitespace while U+0009/U+0020 are; multi-line comment deletion merges the surrounding residues (SPEC 3, 1.4)", + "line-drop rule: lines left empty or whitespace-only purely by removals (import, tags, comments, empty expansion) drop with their terminators; already-empty, content-retaining, and whitespace-only-but-non-empty-expansion lines are kept; U+00A0/U+0085/U+2028/U+2029 are not whitespace while U+0009/U+0020 are, inside an ESM block too (two imports on one physical line separated by U+2028 leave their line holding U+2028 alone, kept with its terminator); a multi-line comment and a multi-line opening tag (own-lines, flow-start, and in-line forms) are each deleted exactly, merging the lines they span into one — dropped when left empty purely by the removal, kept with its residue otherwise (SPEC 3, 1.4)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { "xspec.config.ts": EMIT_TRUE_CONFIG, "specs/A.mdx": DROP_SOURCE, "specs/BASE.mdx": REMOVALS_BASE_SOURCE, + "specs/BASE2.mdx": ESM_ARM_BASE2_SOURCE, + "specs/BASE3.mdx": ESM_ARM_BASE3_SOURCE, }, }); try { @@ -350,7 +585,7 @@ const T3_3 = defineProductTest({ await assertFileBytes( workspace.path("specs/A.md"), DROP_COMPILED, - "T3-3 emitted specs/A.md — every drop arm, counter-case, class boundary, and multi-line-construct merge of the line-drop rule (SPEC 3, 1.4)", + "T3-3 emitted specs/A.md — every drop arm, counter-case, class boundary (the ESM-block arm included), and multi-line-construct merge of the line-drop rule (SPEC 3, 1.4)", ); } finally { await workspace.dispose(); @@ -458,9 +693,18 @@ const T3_5 = defineProductTest({ // T3-6 // --------------------------------------------------------------------------- -const SCOPE_A_SOURCE = '<S id="a">\nAlpha.\n</S>\n'; +// T3-6 stages both sources in each variant's fresh workspace, the second +// and third after the first variant's `build`: staged-source records, judged +// before any product exists (S-9, test/self/s9-staged-sources.test.ts). +const SCOPE_A_SOURCE = stagedMdx( + "T3-6 specs/A.mdx", + '<S id="a">\nAlpha.\n</S>\n', +); const SCOPE_A_COMPILED = "Alpha.\n"; -const SCOPE_B_SOURCE = '<S id="b">\nBeta.\n</S>\n'; +const SCOPE_B_SOURCE = stagedMdx( + "T3-6 specs/sub/B.mdx", + '<S id="b">\nBeta.\n</S>\n', +); const SCOPE_B_COMPILED = "Beta.\n"; /** @@ -482,8 +726,13 @@ async function assertNotEmitted( } } -// The three `markdown` variants of the emission-scope matrix (SPEC 7.3, 13.2). -const EMISSION_VARIANTS = [ +// The three `markdown` variants of the emission-scope matrix (SPEC 7.3, 13.2), +// each configuration a staged-source record (above). +const EMISSION_VARIANTS: readonly { + readonly key: string; + readonly config: StagedTs; + readonly emits: boolean; +}[] = [ { key: "`markdown` absent", config: SPECS_ONLY_CONFIG, emits: false }, { key: "`markdown: { emit: false }`", @@ -491,7 +740,7 @@ const EMISSION_VARIANTS = [ emits: false, }, { key: "`markdown: { emit: true }`", config: EMIT_TRUE_CONFIG, emits: true }, -] as const; +]; const T3_6 = defineProductTest({ id: "T3-6", @@ -544,6 +793,321 @@ const T3_6 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T3-7 +// --------------------------------------------------------------------------- + +// ESM-block comments and the `;`-terminated import (SPEC 3, 2.1, 2.7). An +// import's removal is its declaration's own characters alone, so a +// JavaScript comment beside it in its ESM block is no MDX comment (2.7: an +// MDX comment is an empty expression container) — it stays as content, and +// the line-drop rule of 3 then judges the line with the comment's bytes +// among its remaining content. Four blocks in one file (`specs/main.mdx`), +// each ended by a blank line (MDX 3's ESM construct runs to a blank line, +// 14.20; AGENTS.md's parser recipe) and followed by a kept marker line so a +// misplaced drop is diagnosed precisely, every declaration valid and used — +// the section `s` depends on one node of each imported file (2.1, 2.2): +// - the trailing-comment arm: `import A from "./A.xspec" // note` — the +// declaration's characters deleted, the line is left ` // note` plus its +// terminator: non-whitespace content, kept, not dropped as +// whitespace-only; +// - the own-line arm: `import D …`, `// note`, `import E …` on consecutive +// lines of one block — the two import lines are left empty purely by the +// removals and drop with their terminators; the comment line survives +// byte-for-byte with its terminator; +// - the block-comment arm: `import F …` heading the block (an ESM block +// begins with a declaration at its first line's start, 14.20) and +// `/* c */ import B from "./B.xspec"` its second line — the line keeps +// `/* c */ ` and its terminator; +// - the terminator arm: `import C from "./C.xspec";` alone on its line — +// the `;` ends ECMAScript's ImportDeclaration (14.20), so it is among the +// declaration's own characters: removed with it, the line dropped as left +// empty purely by the removal. A product deleting through the specifier +// literal alone leaves a stray `;` line as content and fails the byte +// assertion. +// The same bytes are the root's own text (SPEC 1.6: the root's contribution +// with the section's excised — the section's opening-tag line drops inside +// its construct, its closing-tag line and the comment line drop after it), +// read through `query node` and `view --text`; `view`'s `comments` lists the +// one MDX comment the file stages (`{/* mdx */}` on its own line, dropped +// from the output per 3) and none of the JavaScript comments (11.4: MDX +// comments alone); and every import is listed under `imports` with its +// declaration's byte range, the terminator arm's ending after the `;` +// (11.4; T11.4-4), its binding name, and its resolved target. Every staged +// shape derives under the grammar 14.20 fixes (S-9: the workspace builder +// judges each file at staging; the S-9 self-test judges the exported +// source). LF terminators only and no exotic byte anywhere (the module's +// certification constraints, though T3-7 lies outside CONF-MD's scope: its +// `view` and `query node` surfaces are not served there). + +/** + * A line of an ESM block: plain text (a comment line), or an import + * declaration with the bytes beside it on its line — `before` the comment + * preceding it in the block-comment arm, `after` the comment following it + * in the trailing-comment arm, empty otherwise. `declaration` is the + * declaration's own characters, a spelled `;` included (SPEC 3, 14.20); + * `binding` its default binding's identifier, also its target file's stem + * (`specs/<binding>.mdx`, holding the section `<binding, lower-cased>`). + */ +type EsmBlockLine = + | string + | { + readonly before: string; + readonly declaration: string; + readonly binding: string; + readonly after: string; + }; + +interface EsmBlockArm { + readonly name: string; + /** The block's lines, each LF-terminated; a blank line then ends it. */ + readonly lines: readonly EsmBlockLine[]; + /** The block's compiled lines, hand-derived per SPEC 3, each LF-terminated. */ + readonly compiled: readonly string[]; + /** The kept marker line following the block's blank line. */ + readonly marker: string; +} + +function importLine( + binding: string, + spelling: { + readonly before?: string; + readonly after?: string; + readonly semicolon?: boolean; + } = {}, +): EsmBlockLine { + return { + before: spelling.before ?? "", + declaration: `import ${binding} from "./${binding}.xspec"${spelling.semicolon === true ? ";" : ""}`, + binding, + after: spelling.after ?? "", + }; +} + +const T3_7_ARMS: readonly EsmBlockArm[] = [ + { + name: "trailing line comment", + lines: [importLine("A", { after: " // note" })], + // The declaration deleted; ` // note` is non-whitespace content, so the + // line is kept with its terminator. + compiled: [" // note"], + marker: "K1 after-trailing-comment", + }, + { + name: "own-line line comment between two imports", + lines: [importLine("D"), "// note", importLine("E")], + // Both import lines dropped; the comment line survives with its + // terminator. + compiled: ["// note"], + marker: "K2 after-own-line-comment", + }, + { + name: "block comment before the second line's import", + lines: [importLine("F"), importLine("B", { before: "/* c */ " })], + // The first line dropped; the second keeps `/* c */ ` and its terminator. + compiled: ["/* c */ "], + marker: "K3 after-block-comment", + }, + { + name: "semicolon-terminated import", + lines: [importLine("C", { semicolon: true })], + // The `;` removed with the declaration; the line, left empty purely by + // the removal, drops with its terminator. + compiled: [], + marker: "K4 after-semicolon", + }, +]; + +const T3_7_FILE = "specs/main.mdx"; +const T3_7_EMITTED = "specs/main.md"; +const T3_7_SECTION_BODY = "Body.\n"; +const T3_7_MDX_COMMENT = "{/* mdx */}"; + +interface T37Fixture { + /** The staged `specs/main.mdx`. */ + readonly source: string; + /** Its compiled Markdown — the root's subtree text (SPEC 3, 1.6). */ + readonly compiled: string; + /** The root's own text: the compiled output with the section's excised. */ + readonly rootOwnText: string; + /** The expected `imports` member, in document order (SPEC 11.4). */ + readonly imports: readonly ViewImportEntry[]; + /** The expected `comments` member: the one MDX comment (SPEC 11.4). */ + readonly comments: readonly SourceRange[]; + /** The imported target files, path to source. */ + readonly targets: Readonly<Record<string, string>>; +} + +/** + * Compose the fixture from the arms: each block's lines, a blank line, its + * marker line, a blank line; then the section using every binding, and the + * MDX comment. The compiled expectation is assembled the same way from the + * arms' hand-derived compiled lines, and each declaration's byte range is + * recorded as it is laid down (SPEC 1.7). + */ +function composeT37Fixture(arms: readonly EsmBlockArm[]): T37Fixture { + let source = ""; + let compiled = ""; + const imports: ViewImportEntry[] = []; + const bindings: string[] = []; + const pos = (): number => Buffer.byteLength(source, "utf8"); + for (const arm of arms) { + for (const line of arm.lines) { + if (typeof line === "string") { + source += `${line}\n`; + continue; + } + source += line.before; + const start = pos(); + source += line.declaration; + imports.push({ + range: { start, end: pos() }, + name: line.binding, + target: `specs/${line.binding}.mdx`, + }); + bindings.push(line.binding); + source += `${line.after}\n`; + } + source += `\n${arm.marker}\n\n`; + compiled += `${arm.compiled.map((line) => `${line}\n`).join("")}\n${arm.marker}\n\n`; + } + const rootOwnText = compiled; + const references = bindings + .map((binding) => `${binding}.${binding.toLowerCase()}`) + .join(", "); + source += `<S id="s" d={[${references}]}>\n${T3_7_SECTION_BODY}</S>\n`; + const commentStart = pos(); + source += `${T3_7_MDX_COMMENT}\n`; + compiled += T3_7_SECTION_BODY; + const targets = Object.fromEntries( + bindings.map((binding) => [ + `specs/${binding}.mdx`, + `<S id="${binding.toLowerCase()}">\n${binding} text.\n</S>\n`, + ]), + ); + return { + source, + compiled, + rootOwnText, + imports, + comments: [ + { + start: commentStart, + end: commentStart + Buffer.byteLength(T3_7_MDX_COMMENT, "utf8"), + }, + ], + targets, + }; +} + +const T3_7_FIXTURE = composeT37Fixture(T3_7_ARMS); +/** The staged `specs/main.mdx`, exported for the S-9 self-test (its exact bytes). */ +export const T3_7_SOURCE = T3_7_FIXTURE.source; + +const T3_7 = defineProductTest({ + id: "T3-7", + title: + "JavaScript comments inside an ESM block are content, not MDX comments — an import's removal is its declaration's characters alone: a line keeps its trailing ` // note` and terminator, an own-line `// note` between two imports of one block survives while the import lines drop, and a line keeps the `/* c */ ` preceding its import; a semicolon-terminated declaration's `;` is among its own characters, its line dropped as left empty purely by the removal; the same bytes are the root's own text (`query node`, `view --text`), `view`'s `comments` lists none of them, and every import is listed under `imports` with its declaration range, the `;`-terminated one's ending after the `;` (SPEC 3, 2.1, 2.7, 1.6, 11.4)", + run: async (product) => { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": EMIT_TRUE_CONFIG, + [T3_7_FILE]: T3_7_FIXTURE.source, + ...T3_7_FIXTURE.targets, + }, + }); + try { + await buildOk( + product, + workspace, + "T3-7 `build` of the ESM-block comment fixture — every block well-formed, every import valid and used, no finding, exit 0 (SPEC 3, 2.1, 14.20)", + ); + await assertFileBytes( + workspace.path(T3_7_EMITTED), + T3_7_FIXTURE.compiled, + "T3-7 emitted specs/main.md — each import removed by its declaration's own characters alone, a spelled `;` included: the trailing ` // note` kept on its line, the own-line `// note` surviving between two dropped import lines, `/* c */ ` kept before a removed import, and the `;`-terminated import's line dropped (SPEC 3, 2.7)", + ); + + // The same bytes are the root's own text (SPEC 1.6): `query node`. + const nodeLabel = "T3-7 `query node specs/main.mdx` (the root)"; + const node = decodeNodeReport( + await runJson( + product, + workspace, + ["query", "node", T3_7_FILE], + nodeLabel, + ), + nodeLabel, + ); + if (node.identity !== T3_7_FILE) { + fail( + `${nodeLabel}: expected the report to be about ${JSON.stringify(T3_7_FILE)} (SPEC 1.5), ` + + `got identity ${JSON.stringify(node.identity)}`, + ); + } + assertBytesEqual( + node.ownText, + T3_7_FIXTURE.rootOwnText, + `${nodeLabel}: own text — the JavaScript comments' bytes are content of the root, the section's contribution excised (SPEC 1.6, 3)`, + ); + assertBytesEqual( + node.subtreeText, + T3_7_FIXTURE.compiled, + `${nodeLabel}: subtree text — for the root, the entire compiled output (SPEC 1.6)`, + ); + + // `view --text`: the root's own text again; `comments` the MDX comment + // alone; `imports` every declaration with its range (SPEC 11.4). + const viewContext = "T3-7 bare `view --text`"; + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", "--text"], + `${viewContext}: a finding-free answer, exit 0 (SPEC 11.2, 12.0)`, + ), + { text: true }, + viewContext, + ); + assertSameJson( + report.findings, + [], + `${viewContext}: every block well-formed, every import valid — no finding (SPEC 2.1, 14.20)`, + ); + const view = report.views.find( + (candidate) => candidate.file === T3_7_FILE, + ); + if (view === undefined) { + fail( + `${viewContext}: no per-file view for ${T3_7_FILE} among ${JSON.stringify(report.views.map((candidate) => candidate.file))} (SPEC 11.4)`, + ); + } + assertSameJson( + view.imports, + T3_7_FIXTURE.imports, + `${viewContext}: \`imports\` lists every declaration in document order with its byte-exact range — the \`;\`-terminated one's ending after the \`;\` — its binding name, and its resolved target (SPEC 11.4, 1.7, 2.1; T11.4-4)`, + ); + assertSameJson( + view.comments, + T3_7_FIXTURE.comments, + `${viewContext}: \`comments\` lists the one MDX comment by its full braced container and none of the JavaScript comments — MDX comments alone (SPEC 11.4, 2.7)`, + ); + if (typeof view.root.ownText !== "string") { + fail( + `${viewContext}: the root's own text under \`--text\` should be a plain string (SPEC 11.4, 11.2), got ${JSON.stringify(view.root.ownText)}`, + ); + } + assertBytesEqual( + view.root.ownText, + T3_7_FIXTURE.rootOwnText, + `${viewContext}: the root's own text — the same bytes as \`query node\` reports, the JavaScript comments among them (SPEC 11.4, 1.6, 3)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §3, in canonical ID order (SUITE-11). */ export const section3Tests: readonly ProductTestEntry[] = [ T3_1, @@ -552,4 +1116,5 @@ export const section3Tests: readonly ProductTestEntry[] = [ T3_4, T3_5, T3_6, + T3_7, ]; diff --git a/test/suite/registry/section-4.3-4.4.ts b/test/suite/registry/section-4.3-4.4.ts index 35c1d8e6..e1f3b1ad 100644 --- a/test/suite/registry/section-4.3-4.4.ts +++ b/test/suite/registry/section-4.3-4.4.ts @@ -20,38 +20,100 @@ // 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`] `` read as +// `SPEC["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 -// throw per 4.4"; TEST-SPEC §14 names T4.4-1 the primary test for 14.11): -// the home-context finding — a discovered code file with the cross-module -// call fails `build --json` with exactly one located 14.11 finding — plus, -// over an undiscovered consumer, the TypeScript type error at the consumer -// reference and the runtime throw, reached "via the emitted JS" (standard -// tsc emits despite the asserted type error — the TEST-SPEC alternative to -// suppressing it; either way at the consumer's responsibility). -// - T4.4-1 "an error identifying both A (the node's module) and B (the -// called module)": the two spec sources carry distinctive name stems -// (ALPHAMOD, BRAVOMOD) contained in every rendering of a module's identity -// — file name, workspace-relative path, `.xspec` specifier, root-node -// identity — and the assertion is that the thrown error's standard textual -// renderings (String(error), its message, the JSON of its enumerable own -// properties) contain both stems (H-3 robust matching; an error from which -// neither module is recoverable identifies nothing). +// - T4.3-2's TypeScript-only arms (`SPEC.a!`, `SPEC.a as X`, `<X>SPEC.a`, +// `SPEC.a satisfies X` as the `text` argument) are dynamic references in a +// TypeScript source — 14.8 at the call, the file well-formed — never a +// parse failure: 14.20 is the spec-source reading of the same spellings +// (T2.4-2), and the exact count {"14.8": 1} excludes it. The angle-bracket +// form parses only as plain TypeScript, which the `.ts` file name selects +// (SPEC 2.4, 14.20). +// - T4.3-2's "no edge, no occurrence" attaches to the dynamic node-form +// list (the seven arms marked `dynamicNodeForm`). `query edges` refuses +// to answer on a failing workspace (13.3), so the occurrence record is +// the edge's witness (SPEC 5.7, 11.2), as in T4.4-1: after each such +// arm's `build`, `occurrences` on the same failing workspace must exit 1 +// with its full answer emitted, its findings exactly {"14.8": 1}, that +// finding located in the offending statement's byte window as `build`'s +// is, and its record list empty (SPEC 11.2, 11.3). It runs unfiltered, +// consulting the whole discovered set; the spec source holds no +// reference, so `--file src/app.ts` would give the same verdict, and the +// unfiltered answer's guarantee is absolute (11.3). The string-argument +// and arity arms carry no such check: SPEC 5.7 lets an invalid call whose +// argument resolves record its occurrence beside its finding (14.11's +// cross-module call), and TEST-SPEC's clause does not cover them. +// - T4.4-1 asserts the condition's facets (SPEC 14.11: reported by +// `build`/`check`, its edge and occurrence standing beside it, +// "additionally a TypeScript type error and a runtime throw per 4.4"; +// TEST-SPEC §14 names T4.4-1 the primary test for 14.11) at the paths +// TEST-SPEC pins verbatim (`specs/A.mdx`, the node's module; `specs/B.mdx`, +// the called module): the home-context finding — a discovered code file +// with the cross-module call fails `build --json` and `check --json` with +// exactly one 14.11 finding, located at the call alone, callee through +// closing parenthesis (5.7; T14-11), `path` null, `identities` exactly +// `["specs/B.mdx"]` — and the occurrence beside it (`occurrences` on the +// failing workspace, 11.2: the one record projected exactly — `embeds`, +// the call's span, source the whole-file location `src/app.ts` with the +// file's own range (4.6, 1.7), target `specs/A.mdx#a` — the finding +// accompanying, exit 1); the resolving-argument arms (`textB(A.missing)` +// condition 7 alone, `textB(A.a!)` condition 8 alone, no record for +// either); the invalid-called-path arm (the called module discovered as +// `specs/B#.mdx`: 14.19 concerning it beside the 14.11 whose `identities` +// are exactly `[]`, the record still listed); plus, over an undiscovered +// consumer, the TypeScript type error at the consumer reference and the +// runtime throw, reached "via the emitted JS" (standard tsc emits despite +// the asserted type error — the TEST-SPEC alternative to suppressing it; +// either way at the consumer's responsibility). On `check`, the condition +// is counted exactly, 14.10 included: the never-built workspace holds no +// record (a failing `build` writes nothing, 12.1), so neither +// whatever-validity form of 14.10 exists, and on a workspace failing +// `build`'s validations the mismatch forms — the absent derived files +// and graph data — go unreported (SPEC 14.10, 13.3); no other condition +// is admitted. +// - T4.4-1 "an error whose message contains both modules' source files' +// workspace-relative paths": the thrown value's `message` property must be +// a string holding `specs/A.mdx` and `specs/B.mdx` as substrings — +// `/`-separated, the `.mdx` sources — so a product naming the generated +// module files (`specs/A.xspec.ts`), using native separators, naming bare +// stems, or naming one module alone fails, and a thrown value without a +// string `message` (a bare string, say) is diagnosed as such (SPEC 4.4, +// 1.5). The consumer reports `{ threw, message, rendering }`, the +// rendering (`String(error)`) carried for the diagnosis alone. // - T4.4-2 "each alias accepts only its own module's nodes": acceptance is // the clean compile of both own-module calls plus their byte-exact runtime // values; "only" is a compile error at each cross-module argument (the // failing location is the consumer reference under test, TEST-SPEC §4 // preamble). -import type { GraphEdge } from "../../helpers/adapters/index.js"; -import { decodeEdgesReport } from "../../helpers/adapters/index.js"; +import type { + Finding, + GraphEdge, + OccurrenceRecord, + OccurrencesReport, + SourceRange, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeOccurrencesReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, @@ -60,6 +122,9 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertCompileErrorAt, @@ -69,31 +134,49 @@ import { runConsumer, } from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertConditionCounts, assertEdgeSetEqual, + assertFindingConcernsPath, + assertFindingIdentities, assertFindingLocated, + assertSameJson, buildFindings, buildOk, byteWindow, + expectExit, + findingsInSourceOrder, + runFindingsReport, runJson, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. The // consumer files under `consumer/` are outside every group by construction. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// A staged-source record, judged before any product exists (S-9's timing +// clause; test/self/s9-staged-sources.test.ts): T4.4-1's consumer facet +// creates its workspace after the body's first invocation; T4.4-2 stages it +// before its own. +const SPECS_ONLY_CONFIG = stagedTs( + "T4.4-1 xspec.config.ts — one spec group only, the consumer facet's", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus one code group (SPEC 7.2): TypeScript files under // `src/` are discovered code sources, so `build` analyzes their imports and -// spec-module usage (4, 4.5). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// spec-module usage (4, 4.5). A staged-source record: every arm workspace +// of T4.3-2 past the first, and T4.4-1's after its first facet, is created +// after a product invocation; T4.3-1 stages it before its own. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T4.3-2/T4.4-1 xspec.config.ts — one spec group and one code group, the arm workspaces' default", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -103,12 +186,13 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -276,9 +360,13 @@ const T4_3_1 = defineProductTest({ // One spec source with a nested `a.b`, shared by every arm, so each dynamic // chain would resolve if read statically — the form is each arm's sole // defect. +// Staged in every arm's fresh workspace — past the first, after a product +// invocation: a staged-source record (S-9, test/self/s9-staged-sources.test.ts). const T4_3_2_SPEC_FILES = { - "specs/A.mdx": + "specs/A.mdx": stagedMdx( + "T4.3-2 specs/A.mdx", '<S id="a">\nAlpha behavior.\n<S id="a.b">\nBeta behavior.\n</S>\n</S>\n', + ), } as const; /** One invalid `text` argument arm: a workspace differing only in src/app.ts. */ @@ -289,6 +377,13 @@ interface InvalidTextArgumentArm { readonly lines: readonly string[]; /** The offending statement — exactly one of the lines. */ readonly offending: string; + /** + * Whether this is one of TEST-SPEC's dynamic node-form arms, of which it + * says "no edge, no occurrence" (T4.3-2): `occurrences` on the failing + * workspace must list no record. False for the string-argument and arity + * arms, which that clause does not cover (module header). + */ + readonly dynamicNodeForm: boolean; } const T4_3_2_ARMS: readonly InvalidTextArgumentArm[] = [ @@ -296,6 +391,7 @@ const T4_3_2_ARMS: readonly InvalidTextArgumentArm[] = [ name: "a string argument to `text` (the string form is MDX-only, SPEC 4.3)", lines: ['import { text } from "../specs/A.xspec";', "", 'text("a");'], offending: 'text("a");', + dynamicNodeForm: false, }, { name: @@ -308,6 +404,24 @@ const T4_3_2_ARMS: readonly InvalidTextArgumentArm[] = [ "text(SPEC[key]);", ], offending: "text(SPEC[key]);", + dynamicNodeForm: true, + }, + // TEST-SPEC's `` text(SPEC[`a`]) ``, `SPEC`'s module holding `a`: template + // literals are not static (SPEC 2.4), so a product reading the + // no-substitution template literal as the static string literal `"a"` + // resolves the chain, records the edge, and reports nothing — failing the + // exit-1 and exact {"14.8": 1} expectations. + { + name: + "a computed index by template literal as the `text` argument " + + "(template literals are not static, SPEC 2.4)", + lines: [ + 'import SPEC, { text } from "../specs/A.xspec";', + "", + "text(SPEC[`a`]);", + ], + offending: "text(SPEC[`a`]);", + dynamicNodeForm: true, }, { name: @@ -319,15 +433,116 @@ const T4_3_2_ARMS: readonly InvalidTextArgumentArm[] = [ "text(SPEC.a?.b);", ], offending: "text(SPEC.a?.b);", + dynamicNodeForm: true, + }, + // The four TypeScript-only forms SPEC 2.4 makes dynamic in a TypeScript + // source — never a parse failure there (the spec-source counterparts are + // T2.4-2's 14.20 arms). `type X = unknown;` declares the asserted type so + // the file carries no TypeScript error at all — a type alias is type-level + // and collides with no binding (SPEC 2.4, 4.5) — keeping the form each + // arm's sole defect. The angle-bracket assertion parses only under the + // plain-TypeScript grammar the staged `.ts` file name selects (SPEC + // 14.20) — never `.tsx`. + { + name: + "a non-null assertion on the chain as the `text` argument " + + "(TypeScript-only syntax, dynamic in a TypeScript source, SPEC 2.4)", + lines: [ + 'import SPEC, { text } from "../specs/A.xspec";', + "", + "text(SPEC.a!);", + ], + offending: "text(SPEC.a!);", + dynamicNodeForm: true, + }, + { + name: + "a type assertion `as X` on the chain as the `text` argument " + + "(TypeScript-only syntax, dynamic in a TypeScript source, SPEC 2.4)", + lines: [ + 'import SPEC, { text } from "../specs/A.xspec";', + "", + "type X = unknown;", + "text(SPEC.a as X);", + ], + offending: "text(SPEC.a as X);", + dynamicNodeForm: true, + }, + { + name: + "an angle-bracket assertion `<X>` on the chain as the `text` argument, " + + "in a `.ts` file where it parses (TypeScript-only syntax, dynamic in a " + + "TypeScript source, SPEC 2.4)", + lines: [ + 'import SPEC, { text } from "../specs/A.xspec";', + "", + "type X = unknown;", + "text(<X>SPEC.a);", + ], + offending: "text(<X>SPEC.a);", + dynamicNodeForm: true, + }, + { + name: + "a `satisfies X` operator on the chain as the `text` argument " + + "(TypeScript-only syntax, dynamic in a TypeScript source, SPEC 2.4)", + lines: [ + 'import SPEC, { text } from "../specs/A.xspec";', + "", + "type X = unknown;", + "text(SPEC.a satisfies X);", + ], + offending: "text(SPEC.a satisfies X);", + dynamicNodeForm: true, + }, + { + name: "a zero-argument `text()` call (arity, SPEC 2.4)", + lines: ['import { text } from "../specs/A.xspec";', "", "text();"], + offending: "text();", + dynamicNodeForm: false, + }, + { + 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);", + dynamicNodeForm: false, }, ]; +/** + * An invalid `text` argument arm laid out at module load: its `src/app.ts` + * — the arm's lines, each one terminated — as a staged-source record (S-9's + * timing clause: every arm's workspace past the first is created after a + * product invocation, so S-7's sweep never reaches it), one per row. + */ +interface LaidOutTextArgumentArm { + readonly arm: InvalidTextArgumentArm; + readonly app: StagedTs; +} + +// T4.3-2's arms in run order, one record per row. +const T4_3_2_LAID_OUT_ARMS: readonly LaidOutTextArgumentArm[] = T4_3_2_ARMS.map( + (arm) => ({ + arm, + app: stagedTs( + `T4.3-2 arm ${arm.name} src/app.ts`, + arm.lines.map((line) => line + "\n").join(""), + ), + }), +); + 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, a computed index by template literal (`` text(SPEC[`a`]) ``, the module holding `a`: template literals are not static), an optional-chaining chain, and the TypeScript-only forms 2.4 makes dynamic in a TypeScript source, never a parse failure there (`text(SPEC.a!)`, `text(SPEC.a as X)`, `text(<X>SPEC.a)` in a `.ts` file, `text(SPEC.a satisfies X)`), each as the `text` argument, the file well-formed — each dynamic form 14.8 located at the call with no edge and no occurrence: `occurrences` on the failing workspace exits 1 with its full answer, the one 14.8 finding accompanying it located as `build`'s is, and lists no record; 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, 5.7, 11.2, 11.3)", run: async (product) => { - for (const arm of T4_3_2_ARMS) { + for (const { arm, app } of T4_3_2_LAID_OUT_ARMS) { const at = arm.lines.indexOf(arm.offending); if (at === -1 || arm.lines.lastIndexOf(arm.offending) !== at) { // A harness defect (never a product failure): the offending @@ -338,7 +553,6 @@ const T4_3_2 = defineProductTest({ `section-4.3-4.4.ts`, ); } - const source = arm.lines.map((line) => line + "\n").join(""); const prefix = arm.lines .slice(0, at) .map((line) => line + "\n") @@ -347,7 +561,7 @@ const T4_3_2 = defineProductTest({ const context = `T4.3-2 \`build --json\` over ${arm.name}`; await withWorkspace( SPEC_AND_CODE_CONFIG, - { ...T4_3_2_SPEC_FILES, "src/app.ts": source }, + { ...T4_3_2_SPEC_FILES, "src/app.ts": app }, async (workspace) => { const findings = await buildFindings(product, workspace, context); assertConditionCounts(findings, { "14.8": 1 }, context); @@ -356,6 +570,35 @@ const T4_3_2 = defineProductTest({ { file: "src/app.ts", window }, `${context}: the 14.8 finding`, ); + if (arm.dynamicNodeForm) { + // "No edge, no occurrence": `query edges` refuses to answer on a + // failing workspace (13.3), so the occurrence record is the + // edge's witness (SPEC 5.7, 11.2) — module header. + const occContext = `T4.3-2 \`occurrences\` over ${arm.name}`; + const report = await occurrencesOnFailingWorkspace( + product, + workspace, + occContext, + ); + assertConditionCounts( + report.findings, + { "14.8": 1 }, + `${occContext} — the call's finding accompanies the answer, ` + + `none beside it (SPEC 11.2)`, + ); + assertFindingLocated( + report.findings[0]!, + { file: "src/app.ts", window }, + `${occContext}: the 14.8 finding, located as \`build\`'s is`, + ); + assertSameJson( + report.occurrences.map(projectRecord), + [], + `${occContext}: no record — a dynamic node-form \`text\` ` + + `argument records no edge and no occurrence (SPEC 5.7, ` + + `11.2; TEST-SPEC T4.3-2)`, + ); + } }, ); } @@ -363,125 +606,468 @@ const T4_3_2 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T4.4-1 — cross-module text call: finding, type error, runtime throw +// T4.4-1 — cross-module text call: finding, occurrence, type error, throw // --------------------------------------------------------------------------- -// Two spec modules with distinctive name stems: every rendering of a module's -// identity — file name, workspace-relative path, `.xspec` specifier, -// root-node identity — contains its stem, so "an error identifying both -// modules" must contain both (module-header operationalization). Neither -// stem occurs anywhere else in the fixtures. -const ALPHAMOD_STEM = "ALPHAMOD"; -const BRAVOMOD_STEM = "BRAVOMOD"; +// Two spec modules at the paths TEST-SPEC T4.4-1 pins verbatim — the node's +// module `specs/A.mdx` (node `a`) and the called module `specs/B.mdx` (node +// `b`) — so the runtime message's substrings, the finding's `identities`, +// and the occurrence's target are exact literals. Shared with T4.4-2. T4.4-1's +// facets past the first create their workspaces after its first invocation — +// facet 3 staging the `B` source at the invalid path `specs/B#.mdx` — so both +// sources are staged-source records (S-9, test/self/s9-staged-sources.test.ts). const T4_4_SPEC_FILES = { - "specs/ALPHAMOD.mdx": '<S id="first">\nAlpha module first behavior.\n</S>\n', - "specs/BRAVOMOD.mdx": - '<S id="second">\nBravo module second behavior.\n</S>\n', + "specs/A.mdx": stagedMdx( + "T4.4-1/T4.4-2 specs/A.mdx", + '<S id="a">\nAlpha module first behavior.\n</S>\n', + ), + "specs/B.mdx": stagedMdx( + "T4.4-1/T4.4-2 specs/B.mdx (T4.4-1's facet 3 stages it at specs/B#.mdx)", + '<S id="b">\nBravo module second behavior.\n</S>\n', + ), } as const; // Hand-derived subtree texts (SPEC 3/1.6). -const ALPHA_FIRST_TEXT = "Alpha module first behavior.\n"; -const BRAVO_SECOND_TEXT = "Bravo module second behavior.\n"; - -// The home-context finding arm: a discovered code-group file whose only -// defect is the cross-module call — the argument is a static chain (2.4) -// that resolves, the callee is a spec module's `text` export (4.5), only the -// modules differ (14.11). -const T4_4_1_IMPORT_PREFIX = - 'import ALPHA from "../specs/ALPHAMOD.xspec";\n' + - 'import { text as textB } from "../specs/BRAVOMOD.xspec";\n' + +const A_NODE_TEXT = "Alpha module first behavior.\n"; +const B_NODE_TEXT = "Bravo module second behavior.\n"; + +// The home-context arms: a discovered code-group file whose only defect is +// the cross-module call — the argument a static chain (2.4) that resolves, +// the callee a spec module's `text` export (4.5), only the modules differ +// (14.11) — and its resolving-argument variants (condition 7 or 8 alone, +// SPEC 14.11's own examples). +const T4_4_1_APP_FILE = "src/app.ts"; +const T4_4_1_APP_PREFIX = + 'import A from "../specs/A.xspec";\n' + + 'import { text as textB } from "../specs/B.xspec";\n' + + "\n"; +/** The cross-module call — callee through closing parenthesis (SPEC 5.7). */ +const T4_4_1_CROSS_CALL = "textB(A.a)"; +const T4_4_1_UNRESOLVED_CALL = "textB(A.missing)"; +const T4_4_1_DYNAMIC_CALL = "textB(A.a!)"; +const T4_4_1_NODE_IDENTITY = "specs/A.mdx#a"; +const T4_4_1_CALLED_MODULE_IDENTITY = "specs/B.mdx"; + +// Invalid called path (T11.2-3): the called module discovered at a path +// holding `#` (14.19). Its import is valid — the file is discovered (2.1, +// 4) — and the call is still condition 11 with `identities` exactly `[]`: +// no identity over an invalid path is ever emitted (SPEC 1.5, 11.2), while +// the node's module and the calling file keep their identities, so the +// occurrence is still recorded. +const T4_4_1_INVALID_CALLED_PATH = "specs/B#.mdx"; +const T4_4_1_INVALID_APP_PREFIX = + 'import A from "../specs/A.xspec";\n' + + 'import { text as textB } from "../specs/B#.xspec";\n' + "\n"; -const T4_4_1_CROSS_STATEMENT = "textB(ALPHA.first);"; + +/** The discovered code file: the imports, one call statement, a newline. */ +function appSource(prefix: string, call: string): string { + return `${prefix}${call};\n`; +} + +/** The call's exact range: after `prefix`, callee through `)` (SPEC 5.7). */ +function callRange(prefix: string, call: string): SourceRange { + const start = Buffer.byteLength(prefix, "utf8"); + return { start, end: start + Buffer.byteLength(call, "utf8") }; +} + +// The resolving-argument arms (facet 2) and the invalid-called-path facet +// (facet 3) create their workspaces after the body's first invocation, so +// their `src/app.ts` sources are staged-source records laid out at module +// load (S-9's timing clause; test/self/s9-staged-sources.test.ts), one per +// arm; facet 1's source, staged before that invocation, stays plain. +const T4_4_1_RESOLVING_ARMS = [ + { + call: T4_4_1_UNRESOLVED_CALL, + condition: "14.7", + what: "an unresolved argument", + }, + { + call: T4_4_1_DYNAMIC_CALL, + condition: "14.8", + what: "a non-static argument", + }, +].map((arm) => ({ + ...arm, + app: stagedTs( + `T4.4-1 src/app.ts with ${arm.what}, \`${arm.call}\``, + appSource(T4_4_1_APP_PREFIX, arm.call), + ), +})); +const T4_4_1_INVALID_APP_SOURCE = appSource( + T4_4_1_INVALID_APP_PREFIX, + T4_4_1_CROSS_CALL, +); +const T4_4_1_INVALID_APP = stagedTs( + `T4.4-1 src/app.ts importing the called module from the invalid path ${T4_4_1_INVALID_CALLED_PATH}`, + T4_4_1_INVALID_APP_SOURCE, +); + +/** + * The cross-module call's one occurrence record, projected (SPEC 5.7, 12.7): + * the referencing file, the call's span, `embeds`, the source graph node — + * no named unit encloses a top-level statement, so the whole-file location, + * identity the path alone, range the entire file (4.6, 1.7) — and the + * node's identity as target. + */ +function expectedCrossCallRecord(source: string, range: SourceRange): unknown { + return { + file: T4_4_1_APP_FILE, + range, + kind: "embeds", + source: { + identity: T4_4_1_APP_FILE, + range: { start: 0, end: Buffer.byteLength(source, "utf8") }, + }, + target: T4_4_1_NODE_IDENTITY, + }; +} + +/** An occurrence record in the projection `expectedCrossCallRecord` uses. */ +function projectRecord(record: OccurrenceRecord): unknown { + return { + file: renderPathValue(record.file), + range: record.range, + kind: record.kind, + source: + "unavailable" in record.source + ? { unavailable: true } + : { identity: record.source.identity, range: record.source.range }, + target: record.target, + }; +} + +/** + * The condition-11 finding contract (SPEC 14.11, 12.7; TEST-SPEC T4.4-1): + * located at the call alone — exactly one location, in the code file, the + * call's exact range (callee through closing parenthesis, T14-11) — + * concerning no path, its `identities` exactly `identities`. + */ +function assertCrossModuleFinding( + finding: Finding, + range: SourceRange, + identities: readonly string[], + context: string, +): void { + assertSameJson( + finding.locations.map((location) => ({ + file: renderPathValue(location.file), + range: location.range, + })), + [{ file: T4_4_1_APP_FILE, range }], + `${context}: the condition-11 finding locates the call alone — one ` + + `location, in ${T4_4_1_APP_FILE}, spanning the call from its callee ` + + `through the closing parenthesis, argument included (SPEC 14.11, 5.7; ` + + `T14-11) — message: ${JSON.stringify(finding.message)}`, + ); + if (finding.path !== null) { + fail( + `${context}: a finding locating in source concerns no path — \`path\` ` + + `is null for located conditions (SPEC 12.7, 14); got ` + + `${JSON.stringify(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + assertFindingIdentities( + finding, + identities, + `${context}: the condition-11 finding names the called module — ` + + `\`identities\` exactly the root identity of the spec module whose ` + + `\`text\` export is called, never the node's module and never a ` + + `generated-module path, and exactly \`[]\` where that module's path ` + + `is invalid (SPEC 14.11, 1.5, 12.7; T12.7-1)`, + ); +} + +/** + * `occurrences` over a workspace failing `build`: it answers from the + * current sources, the domain's findings accompanying, exit 1 with the full + * answer still emitted (SPEC 11.2, 11.3). + */ +async function occurrencesOnFailingWorkspace( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<OccurrencesReport> { + 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)`, + ); + return decodeOccurrencesReport(parseJsonStdout(result, context), context); +} // The consumer for the type-error and runtime facets, outside every group: // the call carries the expected (asserted) type error, tsc emits regardless, -// and the emitted JS reports whether the call threw plus every standard -// textual rendering of the thrown error. -const T4_4_1_CONSUMER_SOURCE = [ - 'import ALPHA from "../specs/ALPHAMOD.xspec";', - 'import { text as textB } from "../specs/BRAVOMOD.xspec";', - "", - "let threw = false;", - "const renderings: string[] = [];", - "try {", - " const returned: unknown = textB(ALPHA.first);", - " renderings.push(String(returned));", - "} catch (error) {", - " threw = true;", - " renderings.push(String(error));", - ' if (error !== null && typeof error === "object") {', - " const message = (error as { message?: unknown }).message;", - ' if (typeof message === "string") {', - " renderings.push(message);", - " }", - " try {", - " renderings.push(JSON.stringify(error));", - " } catch {", - ' renderings.push("[JSON.stringify threw]");', - " }", - " }", - "}", - "process.stdout.write(JSON.stringify({ threw, renderings }));", - "", -].join("\n"); +// and the emitted JS reports whether the call threw, the thrown value's +// `message` property when it is a string (the datum SPEC 4.4 binds; null +// otherwise), and `String(error)` for the diagnosis alone. A staged-source +// record (S-9's timing clause): the consumer facet's workspace is created +// after the body's first invocation. +const T4_4_1_CONSUMER_SOURCE = stagedTs( + "T4.4-1 consumer/cross.ts — the cross-module consumer, outside every group", + [ + 'import A from "../specs/A.xspec";', + 'import { text as textB } from "../specs/B.xspec";', + "", + "let threw = false;", + "let message: string | null = null;", + 'let rendering = "";', + "try {", + " const returned: unknown = textB(A.a);", + " rendering = String(returned);", + "} catch (error) {", + " threw = true;", + " rendering = String(error);", + ' if (error !== null && typeof error === "object") {', + " const candidate = (error as { message?: unknown }).message;", + ' if (typeof candidate === "string") {', + " message = candidate;", + " }", + " }", + "}", + "process.stdout.write(JSON.stringify({ threw, message, rendering }));", + "", + ].join("\n"), +); /** Decode the cross-module consumer's report (harness-authored contract). */ function decodeCrossOutcome(payload: unknown): { readonly threw: boolean; - readonly renderings: readonly string[]; + readonly message: string | null; + readonly rendering: string; } { const candidate = payload as { threw?: unknown; - renderings?: unknown; + message?: unknown; + rendering?: unknown; } | null; if ( candidate === null || typeof candidate !== "object" || typeof candidate.threw !== "boolean" || - !Array.isArray(candidate.renderings) || - candidate.renderings.some((value) => typeof value !== "string") + (candidate.message !== null && typeof candidate.message !== "string") || + typeof candidate.rendering !== "string" ) { fail( "T4.4-1: the cross-module consumer must report " + - "`{ threw, renderings }` — harness-authored consumer contract; got " + + "`{ threw, message, rendering }` — harness-authored consumer " + + "contract; got " + JSON.stringify(payload), ); } - return candidate as { threw: boolean; renderings: string[] }; + return candidate as { + threw: boolean; + message: string | null; + rendering: string; + }; } +/** The workspace-relative source paths the runtime message must contain. */ +const T4_4_1_MESSAGE_PATHS = [ + ["specs/A.mdx", "the node's module"], + ["specs/B.mdx", "the called module"], +] as const; + const T4_4_1 = defineProductTest({ id: "T4.4-1", title: - "passing a node from module A to module B's `text` export is 14.11 — exactly one located finding from `build` — and a TypeScript type error at the consumer reference; executed via the emitted JS, the call throws at runtime with an error identifying both A (the node's module) and B (the called module) (SPEC 4.4, 14.11, 13.1)", + "passing a node from module A to module B's `text` export is 14.11: `build` and `check` report exactly one condition-11 finding located at the call, callee through closing parenthesis, `identities` exactly [\"specs/B.mdx\"], exit 1, and the call's `embeds` occurrence stands beside it — `occurrences` on the failing workspace lists exactly one record, source the whole-file location, target specs/A.mdx#a; `textB(A.missing)` is condition 7 alone and `textB(A.a!)` condition 8 alone, no record for either; with the called module discovered as `specs/B#.mdx` the finding's `identities` are exactly [] beside 14.19, the record still listed; at an undiscovered consumer the call is a TypeScript type error and, executed via the emitted JS, throws an error whose message contains both `specs/A.mdx` and `specs/B.mdx` (SPEC 4.4, 14.11, 5.7, 11.2, 1.5, 13.1)", run: async (product) => { - // Facet 1 — the home-context condition: exactly one 14.11 finding, - // located at the cross-module call in the discovered code file. + // Facet 1 — the home-context condition on `build` and `check`, and the + // occurrence beside it. { - const context = - "T4.4-1 `build --json` over a discovered code file passing module " + - "A's node to module B's `text` export"; - const window = byteWindow(T4_4_1_IMPORT_PREFIX, T4_4_1_CROSS_STATEMENT); + const source = appSource(T4_4_1_APP_PREFIX, T4_4_1_CROSS_CALL); + const range = callRange(T4_4_1_APP_PREFIX, T4_4_1_CROSS_CALL); await withWorkspace( SPEC_AND_CODE_CONFIG, - { - ...T4_4_SPEC_FILES, - "src/app.ts": T4_4_1_IMPORT_PREFIX + T4_4_1_CROSS_STATEMENT + "\n", + { ...T4_4_SPEC_FILES, [T4_4_1_APP_FILE]: source }, + async (workspace) => { + const buildContext = + "T4.4-1 `build --json` over a discovered code file passing " + + "module A's node to module B's `text` export"; + const built = await buildFindings(product, workspace, buildContext); + assertConditionCounts(built, { "14.11": 1 }, buildContext); + assertCrossModuleFinding( + built[0]!, + range, + [T4_4_1_CALLED_MODULE_IDENTITY], + buildContext, + ); + + // `check` performs all build validations (SPEC 12.2); the + // condition is counted exactly, 14.10 included (module header). + const checkContext = "T4.4-1 `check --json` over the same workspace"; + const checked = await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations ` + + `and exits 1 on any finding (SPEC 12.2, 14.11)`, + ); + assertConditionCounts( + checked, + { "14.11": 1 }, + `${checkContext} — the condition-11 finding and nothing beside ` + + `it: the never-built failing workspace holds no record, and ` + + `14.10's mismatch forms go unreported there (SPEC 12.2, ` + + `14.11, 14.10)`, + ); + assertCrossModuleFinding( + checked[0]!, + range, + [T4_4_1_CALLED_MODULE_IDENTITY], + checkContext, + ); + + // The edge and occurrence stand beside the finding (SPEC 14.11, + // 5.7): `query edges` refuses to answer on a failing workspace + // (13.3), so the occurrence record is the edge's witness (11.2). + const occContext = "T4.4-1 `occurrences` over the failing workspace"; + const report = await occurrencesOnFailingWorkspace( + product, + workspace, + occContext, + ); + assertConditionCounts( + report.findings, + { "14.11": 1 }, + `${occContext} — the call's finding accompanies the answer, ` + + `none beside it (SPEC 11.2)`, + ); + assertCrossModuleFinding( + report.findings[0]!, + range, + [T4_4_1_CALLED_MODULE_IDENTITY], + occContext, + ); + assertSameJson( + report.occurrences.map(projectRecord), + [expectedCrossCallRecord(source, range)], + `${occContext}: exactly one record, the cross-module call's — ` + + `\`embeds\`, spanning the call from its callee through the ` + + `closing parenthesis, source the whole-file location ` + + `\`${T4_4_1_APP_FILE}\` with the file's own range, target ` + + `\`${T4_4_1_NODE_IDENTITY}\` (SPEC 5.7, 4.6, 1.7, 14.11)`, + ); }, + ); + } + + // Facet 2 — a resolving argument is required: condition 7 or 8 alone, + // no condition 11 beside it, and no occurrence (SPEC 14.11, 5.7). + for (const arm of T4_4_1_RESOLVING_ARMS) { + const window = byteWindow(T4_4_1_APP_PREFIX, arm.call); + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_4_SPEC_FILES, [T4_4_1_APP_FILE]: arm.app }, async (workspace) => { + const context = `T4.4-1 \`build --json\` with ${arm.what}, \`${arm.call}\``; const findings = await buildFindings(product, workspace, context); - assertConditionCounts(findings, { "14.11": 1 }, context); + assertConditionCounts( + findings, + { [arm.condition]: 1 }, + `${context} — condition ${arm.condition} alone, no condition 11 ` + + `beside it: the cross-module condition needs an argument that ` + + `resolves (SPEC 14.11)`, + ); assertFindingLocated( findings[0]!, - { file: "src/app.ts", window }, - `${context}: the 14.11 finding`, + { file: T4_4_1_APP_FILE, window }, + `${context}: the condition-${arm.condition} finding`, + ); + const occContext = `T4.4-1 \`occurrences\` with ${arm.what}, \`${arm.call}\``; + const report = await occurrencesOnFailingWorkspace( + product, + workspace, + occContext, + ); + assertConditionCounts( + report.findings, + { [arm.condition]: 1 }, + `${occContext} — the finding accompanies the answer (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectRecord), + [], + `${occContext}: no record — a cross-module call whose argument ` + + `does not resolve or is not static records no edge and no ` + + `occurrence (SPEC 5.7, 11.2; T5.7-4)`, + ); + }, + ); + } + + // Facet 3 — invalid called path: the finding still reported, its + // `identities` exactly `[]`, the occurrence still recorded. + { + const source = T4_4_1_INVALID_APP_SOURCE; + const range = callRange(T4_4_1_INVALID_APP_PREFIX, T4_4_1_CROSS_CALL); + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + "specs/A.mdx": T4_4_SPEC_FILES["specs/A.mdx"], + [T4_4_1_INVALID_CALLED_PATH]: T4_4_SPEC_FILES["specs/B.mdx"], + [T4_4_1_APP_FILE]: T4_4_1_INVALID_APP, + }, + async (workspace) => { + const context = + "T4.4-1 `build --json` with the called module discovered at " + + `the invalid path \`${T4_4_1_INVALID_CALLED_PATH}\``; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.19": 1, "14.11": 1 }, + `${context} — the path's condition 19 beside the call's ` + + `condition 11, the import itself valid since the file is ` + + `discovered (SPEC 14.19, 14.11, 2.1)`, + ); + assertFindingConcernsPath( + findingsInSourceOrder(findings, "14.19")[0]!, + T4_4_1_INVALID_CALLED_PATH, + `${context}: the condition-19 finding`, + ); + assertCrossModuleFinding( + findingsInSourceOrder(findings, "14.11")[0]!, + range, + [], + context, + ); + const occContext = + "T4.4-1 `occurrences` with the called module at the invalid path"; + const report = await occurrencesOnFailingWorkspace( + product, + workspace, + occContext, + ); + assertConditionCounts( + report.findings, + { "14.19": 1, "14.11": 1 }, + `${occContext} — both domain files' findings accompany the ` + + `answer (SPEC 11.2)`, + ); + assertCrossModuleFinding( + findingsInSourceOrder(report.findings, "14.11")[0]!, + range, + [], + occContext, + ); + assertSameJson( + report.occurrences.map(projectRecord), + [expectedCrossCallRecord(source, range)], + `${occContext}: the record still listed — the calling file and ` + + `the node's module keep their identities, only the called ` + + `module's is undefined (SPEC 11.2, 1.5, 5.7)`, ); }, ); } - // Facets 2 and 3 — the consumer-side type error and the runtime throw, - // over generated modules from a valid build (consumer outside every - // group). + // Facet 4 — the consumer-side type error and the runtime throw, over + // generated modules from a valid build (consumer outside every group). await withWorkspace( SPECS_ONLY_CONFIG, { ...T4_4_SPEC_FILES, "consumer/cross.ts": T4_4_1_CONSUMER_SOURCE }, @@ -497,7 +1083,7 @@ const T4_4_1 = defineProductTest({ }); assertCompileErrorAt( project, - project.locate("consumer/cross.ts", "textB(ALPHA.first)", { + project.locate("consumer/cross.ts", T4_4_1_CROSS_CALL, { charOffset: "textB(".length, }), {}, @@ -525,26 +1111,29 @@ const T4_4_1 = defineProductTest({ if (!outcome.threw) { fail( "T4.4-1: the cross-module call did not throw at runtime — " + - "`textB(ALPHA.first)` returned " + - JSON.stringify(outcome.renderings[0] ?? "") + - " (SPEC 4.4: at runtime the call MUST throw an error " + - "identifying both the node's module and the called module)", + `\`${T4_4_1_CROSS_CALL}\` returned ` + + JSON.stringify(outcome.rendering) + + " (SPEC 4.4: at runtime the call MUST throw an error whose " + + "message names both the node's module and the called module)", + ); + } + if (outcome.message === null) { + fail( + "T4.4-1: the thrown value carries no string `message` — SPEC " + + "4.4 requires an error whose message names both modules by " + + "their source files' workspace-relative paths; thrown: " + + JSON.stringify(outcome.rendering), ); } - const combined = outcome.renderings.join("\n"); - for (const [stem, role] of [ - [ALPHAMOD_STEM, "A (the node's module)"], - [BRAVOMOD_STEM, "B (the called module)"], - ] as const) { - if (!combined.includes(stem)) { + for (const [path, role] of T4_4_1_MESSAGE_PATHS) { + if (!outcome.message.includes(path)) { fail( - `T4.4-1: the runtime error must identify ${role} — no ` + - `standard rendering of the thrown error (String(error), ` + - `its message, the JSON of its enumerable own properties) ` + - `mentions the module's distinctive source name ` + - `${JSON.stringify(stem)}, which every rendering of the ` + - `module's identity contains (SPEC 4.4); renderings: ` + - JSON.stringify(outcome.renderings), + `T4.4-1: the runtime error's message must name ${role} by ` + + `its source file's workspace-relative, \`/\`-separated path ` + + `— the substring ${JSON.stringify(path)} — never a ` + + `generated module file, a native-separator spelling, a bare ` + + `stem, or one module alone (SPEC 4.4, 1.5); message: ` + + JSON.stringify(outcome.message), ); } } @@ -560,22 +1149,22 @@ const T4_4_1 = defineProductTest({ // Acceptance: both own-module calls in one file compile clean and return // their own module's subtree texts at runtime. const T4_4_2_ACCEPT_SOURCE = [ - 'import ALPHA, { text as textA } from "../specs/ALPHAMOD.xspec";', - 'import BRAVO, { text as textB } from "../specs/BRAVOMOD.xspec";', + 'import A, { text as textA } from "../specs/A.xspec";', + 'import B, { text as textB } from "../specs/B.xspec";', "", - "process.stdout.write(textA(ALPHA.first));", - "process.stdout.write(textB(BRAVO.second));", + "process.stdout.write(textA(A.a));", + "process.stdout.write(textB(B.b));", "", ].join("\n"); // "Only": each alias rejects the other module's node — a compile error at // each cross-module argument. const T4_4_2_REJECT_SOURCE = [ - 'import ALPHA, { text as textA } from "../specs/ALPHAMOD.xspec";', - 'import BRAVO, { text as textB } from "../specs/BRAVOMOD.xspec";', + 'import A, { text as textA } from "../specs/A.xspec";', + 'import B, { text as textB } from "../specs/B.xspec";', "", - "textA(BRAVO.second);", - "textB(ALPHA.first);", + "textA(B.b);", + "textB(A.a);", "", ].join("\n"); @@ -619,7 +1208,7 @@ const T4_4_2 = defineProductTest({ ); assertBytesEqual( run.stdoutBytes, - ALPHA_FIRST_TEXT + BRAVO_SECOND_TEXT, + A_NODE_TEXT + B_NODE_TEXT, "T4.4-2 each aliased `text` returns its own module's subtree " + "text at runtime, byte-exact (SPEC 4.4, 4.3, 1.6)", ); @@ -630,7 +1219,7 @@ const T4_4_2 = defineProductTest({ }); assertCompileErrorAt( reject, - reject.locate("consumer/reject.ts", "textA(BRAVO.second)", { + reject.locate("consumer/reject.ts", "textA(B.b)", { charOffset: "textA(".length, }), {}, @@ -639,7 +1228,7 @@ const T4_4_2 = defineProductTest({ ); assertCompileErrorAt( reject, - reject.locate("consumer/reject.ts", "textB(ALPHA.first)", { + reject.locate("consumer/reject.ts", "textB(A.a)", { charOffset: "textB(".length, }), {}, diff --git a/test/suite/registry/section-4.5.ts b/test/suite/registry/section-4.5.ts index 4a8d46f8..d13a54b5 100644 --- a/test/suite/registry/section-4.5.ts +++ b/test/suite/registry/section-4.5.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §4.5 (dependency markers) — SUITE-15: T4.5-1 … T4.5-7. +// TEST-SPEC §4.5 (dependency markers) — SUITE-15: T4.5-1 … T4.5-9. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -24,16 +24,90 @@ // 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 -// product cannot legitimately reclassify the finding as an unresolved -// reference (14.7): the sole present condition is 14.8, and the exact +// (`SPEC[key]` with key = "a"; TEST-SPEC's `SPEC?.a`, optional on the +// root binding, read as `SPEC.a`, with `a` staged; the `a.b` chains with +// `a.b` staged; `` SPEC[`login-v2`] `` read as `SPEC["login-v2"]`, over +// its own module holding `login-v2`, as TEST-SPEC pins it), so a product +// cannot legitimately reclassify the finding as an unresolved reference +// (14.7): the sole present condition is 14.8, and the exact // condition-count assertion simultaneously pins "not 14.18" (SPEC 4.5). +// - T4.5-3's TypeScript-only arms (`SPEC.a!;`, `SPEC.a as X;`, `<X>SPEC.a;`, +// `SPEC.a satisfies X;`) are dynamic references in a TypeScript source — +// 14.8 at the statement's expression, the file well-formed — never a parse +// failure: 14.20 is the spec-source reading of the same spellings (T2.4-2), +// and the exact count {"14.8": 1} excludes it. The angle-bracket form +// parses only as plain TypeScript, which the `.ts` file name selects (SPEC +// 2.4, 14.20). +// - T4.5-3's "no edge and no occurrence" attaches to every arm, the +// harness's extra spellings included: each is a non-static bare reference +// in expression-statement position. `query edges` refuses to answer on +// the failing workspace (13.3), so the occurrence record is the edge's +// witness (SPEC 5.7, 11.2), as in T4.5-4, T4.5-8, and T4.5-9: after each +// arm's `build`, `occurrences --file src/app.ts` on the same workspace +// must exit 1 with its full answer emitted, its findings exactly +// {"14.8": 1}, that finding located in the offending statement's byte +// window as `build`'s is, and its record list empty (SPEC 11.2, 11.3). +// The check is T4.5-3's alone (`assertArmFailsWith`'s `noOccurrence` +// option): TEST-SPEC gives T4.5-5's 14.18 arms no such clause. // - T4.5-5 arms likewise stage exactly one unsanctioned value-level use // each; the exact condition-count assertion {"14.18": 1} pins the // classification (SPEC 4.5, 14.18). +// - T4.5-4's callee-side arm stages exactly one shadowed `text(SPEC.a)` call +// beside one module-scope control call. The workspace fails `build`, so +// `query` reports the findings without answering (SPEC 13.3): the arm's +// edge-level observation is `occurrences --file` over the code file, +// which answers on the failing workspace (11.2) — a construct that +// records no edge records no occurrence (5.7), so the exact record set +// (the control's `embeds` record alone, its range, source, and target +// pinned) is simultaneously the no-edge assertion for the shadowed call. +// - T4.5-8 "`query edges` reports no edge from the file": every colliding +// arm's workspace fails `build`, so `query` reports exactly the build +// findings and exits 1 without answering (SPEC 13.3) — the observation is +// that findings-only document (the form-exact decode admits no `edges` +// member beside it) together with `occurrences --file`, which answers on +// the failing workspace (11.2) and lists no record for the spellings (5.7). +// Reference-spelling findings (14.5–14.7) are asserted byte-exact at the +// span 14 fixes — terminators and delimiters excluded — and so is a code +// arm's colliding non-import construct (a declarator, or a declaration +// ending in its closing brace: no terminator makes its span ambiguous), +// while the import declaration a 14.15 locates, its own characters +// spelling a `;`, and the spec-source arms' declarations use the +// end-widened window below. +// - T4.5-8's further located forms stage the supporting declaration the +// spelled construct needs on the line before it — an ambient +// `declare const o: Record<string, number>;` for the binding pattern +// `const { SPEC } = o`, an ambient `declare function dec(...)` for the +// decorated `@dec class SPEC {}` — each binding no `SPEC`, so the +// collision stays the arm's sole defect; the located construct is fixed +// from the exact bytes as for the original forms (SPEC 14, 1.7). The +// spec-source case's two arms both declare S-9's `duplicate-import-binding` +// allowance: the stock parser judges all of a file's ESM blocks as one +// module, so an export declaration binding the import's identifier is the +// same early error in one block and across two — 14.20 admits both, each +// a finding in a well-formed file (T14-12). +// - T4.5-9 stages `src/t.ts`, a non-spec module exporting a `text` +// function, so the `./t` imports of its non-spec and type-only arms name +// an existing module — the import's validity is a consumer-side matter +// outside xspec's validations (SPEC 6.4), staged so that the collision is +// each cell's sole defect. The argument forms (`SPEC.a`, `"x"`, `B.a`) +// run against every colliding declaration; the second module `B.a` needs +// is bound by the colliding import itself in the second-spec-`text` arm +// and by a further `import B from "../specs/B.xspec"` otherwise, an +// unused spec import recording nothing (2.1). As for T4.5-8, the no-edge +// observation on the failing workspace is `occurrences --file` (11.2, +// 5.7); the type-level control is the one cell where `query edges` +// answers. // - Location assertions: every offending statement is staged at a known byte // offset in a pure-ASCII `src/app.ts`, so string indices are byte offsets // and each finding must fall within the offending statement's own byte @@ -43,22 +117,31 @@ import type { CoverageProfileReport, CoverageReport, + Finding, GraphEdge, ImpactedCodeEntry, + OccurrenceRecord, } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, decodeEdgesReport, + decodeFindingsReport, decodeImpactReport, + decodeOccurrencesReport, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, assertStderrEmpty, fail, + parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertNoCompileErrors, @@ -67,10 +150,14 @@ import { runConsumer, } from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import { assertRequirementCategories, impactAgainst } from "./section-5.6.js"; +import { assertImpactedCode } from "./section-9.js"; import { assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertFindingLocatesExactly, assertSameJson, buildFindings, buildOk, @@ -82,8 +169,14 @@ import { // One spec group plus one code group (SPEC 7.2): TypeScript files under // `src/` are discovered code sources, so `build` analyzes their spec-module -// usage (4, 4.5). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// usage (4, 4.5). A staged-source record, judged before any product exists +// (S-9's timing clause; test/self/s9-staged-sources.test.ts): T4.5-2's +// upstream arm, T4.5-4's callee side, and every arm workspace of T4.5-3, +// T4.5-5, T4.5-8, and T4.5-9 past the body's first are created after a +// product invocation; T4.5-1, T4.5-6, and T4.5-7 stage it before theirs. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T4.5-2/T4.5-3/T4.5-4/T4.5-5/T4.5-8/T4.5-9 xspec.config.ts — one spec group and one code group, the arm workspaces' default", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -93,7 +186,8 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); // A two-level document (root → print → print.hello), so a leaf edit changes // the root's subtreeHash through a two-step contains chain (SPEC 5.5). @@ -110,16 +204,27 @@ const PRINT_SPEC_SOURCE = [ // One spec source with a nested `a.b`, shared by the 14.8/14.18 arms and the // edge-kind fixtures, so every staged chain resolves if read statically — -// the form (or the usage) is each arm's sole defect. +// the form (or the usage) is each arm's sole defect. Staged in workspaces +// created after a product invocation (a later arm's, T4.5-4's callee side): +// one staged-source record, named for every test staging it (S-9, +// test/self/s9-staged-sources.test.ts). const AB_SPEC_FILES = { - "specs/A.mdx": + "specs/A.mdx": stagedMdx( + "T4.5-3/T4.5-4/T4.5-5/T4.5-6/T4.5-7 specs/A.mdx", '<S id="a">\nAlpha behavior.\n<S id="a.b">\nBeta behavior.\n</S>\n</S>\n', + ), } as const; -/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +/** + * Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). + * An `.mdx` entry, a code source, or the configuration is plain contents, + * judged well-formed at staging, or a staged-source record — MDX or + * TypeScript — staged under the record's own declaration (S-9; + * helpers/workspace.ts). + */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -191,24 +296,46 @@ interface OffendingStatementArm { readonly lines: readonly string[]; /** The offending statement — exactly one of the lines. */ readonly offending: string; + /** + * The arm's spec sources, when not the shared `a`/`a.b` source + * (`AB_SPEC_FILES`) — staged-source records, as every arm's workspace + * past a body's first is created after a product invocation (S-9). + */ + readonly specFiles?: Readonly<Record<string, StagedMdx>>; +} + +/** An arm's staged `src/app.ts` and its offending statement's window. */ +interface StagedOffendingStatement { + /** + * The whole file — every line LF-terminated — as a staged-source record + * (S-9's timing clause: every arm's workspace past a body's first is + * created after a product invocation, so S-7's sweep never reaches it), + * registered at module load. + */ + readonly source: StagedTs; + /** The offending statement's byte window (support.ts byteWindow). */ + readonly window: { readonly start: number; readonly end: number }; +} + +/** One offending-statement arm paired with its staging. */ +interface OffendingStatementStaging { + readonly arm: OffendingStatementArm; + readonly staged: StagedOffendingStatement; } /** - * Stage one arm over the shared `a`/`a.b` spec source and assert `build - * --json` reports exactly one finding of `condition`, located within the - * offending statement's byte window. The exact condition-count assertion is - * simultaneously the classification assertion (14.8 vs 14.18, SPEC 4.5). + * Lay out an arm's `src/app.ts` and locate its offending statement's byte + * window. The statement must appear exactly once among the staged lines — + * otherwise a harness defect (never a product failure). It registers a + * record, so it runs at module load only (`stageOffendingStatements`, + * `T4_5_4_CALLEE_STAGED`). */ -async function assertArmFailsWith( - product: ProductBinding, +function stageOffendingStatement( testId: string, arm: OffendingStatementArm, - condition: string, -): Promise<void> { +): StagedOffendingStatement { const at = arm.lines.indexOf(arm.offending); if (at === -1 || arm.lines.lastIndexOf(arm.offending) !== at) { - // A harness defect (never a product failure): the offending statement - // must appear exactly once among the staged lines. throw new Error( `${testId} fixture broke: the offending statement must appear exactly ` + `once (${arm.name}) — fix the arm table in section-4.5.ts`, @@ -219,11 +346,49 @@ async function assertArmFailsWith( .slice(0, at) .map((line) => line + "\n") .join(""); - const window = byteWindow(prefix, arm.offending); + return { + source: stagedTs(`${testId} arm ${arm.name} src/app.ts`, source), + window: byteWindow(prefix, arm.offending), + }; +} + +/** + * Stage an arm table once, at module load, in table order — a record + * registers at module level only (S-9), so the body iterates the result. + */ +function stageOffendingStatements( + testId: string, + arms: readonly OffendingStatementArm[], +): readonly OffendingStatementStaging[] { + return arms.map((arm) => ({ + arm, + staged: stageOffendingStatement(testId, arm), + })); +} + +/** + * Stage one arm over its spec sources — the shared `a`/`a.b` source unless + * the arm names its own — and assert `build --json` reports exactly one + * finding of `condition`, located within the offending statement's byte + * window. The exact condition-count assertion is simultaneously the + * classification assertion (14.8 vs 14.18, SPEC 4.5). With `noOccurrence` + * (T4.5-3's "no edge and no occurrence"; T4.5-5 passes no options, its + * 14.18 arms carrying no such clause), `occurrences --file src/app.ts` on + * the same failing workspace must then list no record + * (`assertArmRecordsNoOccurrence`). + */ +async function assertArmFailsWith( + product: ProductBinding, + testId: string, + { arm, staged }: OffendingStatementStaging, + condition: string, + options: { readonly noOccurrence?: boolean } = {}, +): Promise<void> { + const { source, window } = staged; const context = `${testId} \`build --json\` over ${arm.name}`; await withWorkspace( SPEC_AND_CODE_CONFIG, - { ...AB_SPEC_FILES, "src/app.ts": source }, + { ...(arm.specFiles ?? AB_SPEC_FILES), "src/app.ts": source }, async (workspace) => { const findings = await buildFindings(product, workspace, context); assertConditionCounts(findings, { [condition]: 1 }, context); @@ -232,10 +397,73 @@ async function assertArmFailsWith( { file: "src/app.ts", window }, `${context}: the ${condition} finding`, ); + if (options.noOccurrence === true) { + await assertArmRecordsNoOccurrence( + product, + workspace, + `${testId} \`occurrences --file src/app.ts\` over ${arm.name}`, + window, + condition, + ); + } }, ); } +/** + * An arm's "no edge and no occurrence" on its failing workspace (T4.5-3). + * `query edges` refuses to answer there (SPEC 13.3), so the occurrence + * record is the edge's witness (5.7, 11.2): `occurrences --file src/app.ts` + * answers from the current sources, so it exits 1 with its full answer + * emitted, the code file's one finding — the one `condition` finding + * `build` reported, located in the offending statement's byte window as + * `build`'s is — accompanying it, and lists no record: a dynamic reference + * spelling records no edge and no occurrence (SPEC 4.5, 5.7, 11.2, 11.3). + */ +async function assertArmRecordsNoOccurrence( + product: ProductBinding, + workspace: TestWorkspace, + context: string, + window: { readonly start: number; readonly end: number }, + condition: string, +): Promise<void> { + const result = await expectExit( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + 1, + `${context} — the answer carries the code file's finding, so exit 1 ` + + `with the full answer document (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, + { [condition]: 1 }, + `${context}: the code file's one finding accompanies the answer, none ` + + `beside it (SPEC 11.2, 11.3)`, + ); + assertFindingLocated( + report.findings[0]!, + { file: "src/app.ts", window }, + `${context}: the accompanying ${condition} finding, located as ` + + `\`build\`'s is (SPEC 11.2, 14)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + [], + `${context}: no record — a non-static bare reference in ` + + `expression-statement position records no edge and no occurrence, ` + + `its position reported by its finding's range alone (SPEC 4.5, 5.7, ` + + `11.2; TEST-SPEC T4.5-3)`, + ); +} + // --------------------------------------------------------------------------- // T4.5-1 — marker semantics and runtime harmlessness // --------------------------------------------------------------------------- @@ -394,20 +622,67 @@ export default defineConfig({ `; // The bare reference to the default export, at file top level — the root -// marker, and the workspace's only dependency edge. -const T4_5_2_APP_SOURCE = [ - 'import SPEC from "../specs/MAIN.xspec";', - "", - "SPEC;", - "", -].join("\n"); +// marker, and the workspace's only dependency edge. A staged-source record +// (S-9's timing clause): the upstream arm's workspace is created after the +// root-marker arm's invocations; the root-marker arm stages it before them. +const T4_5_2_APP_SOURCE = stagedTs( + "T4.5-2 src/app.ts — the root marker, the upstream arm's (also the root-marker arm's)", + ['import SPEC from "../specs/MAIN.xspec";', "", "SPEC;", ""].join("\n"), +); // The leaf edit: one own-content run of print.hello changes, so the root's // subtreeHash (and effectiveHash) change through the contains chain // (SPEC 5.5) while no other file is touched. -const T4_5_2_EDITED_SPEC_SOURCE = PRINT_SPEC_SOURCE.replace( - "Prints a greeting.", - "Prints a much louder greeting.", +// Staged after the coverage queries — a staged-source record, judged before +// any product exists (S-9, test/self/s9-staged-sources.test.ts). +const T4_5_2_EDITED_SPEC_SOURCE = stagedMdx( + "T4.5-2 specs/MAIN.mdx with the leaf's text edited after the baseline commit", + PRINT_SPEC_SOURCE.replace( + "Prints a greeting.", + "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"; + +// The upstream arm's workspace is created after the root-marker arm's +// invocations, so its two initial sources are staged-source records too +// (S-9, test/self/s9-staged-sources.test.ts). +const T4_5_2_UPSTREAM_MAIN_SOURCE = stagedMdx( + "T4.5-2 specs/MAIN.mdx of the upstream arm", + [ + '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"); +const T4_5_2_UPSTREAM_OTHER_V1 = stagedMdx( + "T4.5-2 specs/OTHER.mdx with the embedded target's text at v1 (the upstream arm's initial source)", + upstreamOtherSource("Upstream behavior, v1."), +); +// The upstream edit, staged after the edge queries — a staged-source record +// (S-9, test/self/s9-staged-sources.test.ts). +const T4_5_2_UPSTREAM_OTHER_V2 = stagedMdx( + "T4.5-2 specs/OTHER.mdx with the embedded target's text at v2 (the upstream edit)", + upstreamOtherSource("Upstream behavior, v2."), ); /** Resolve one named profile from a coverage report, diagnosed (H-8). */ @@ -438,7 +713,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 +821,133 @@ 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": T4_5_2_UPSTREAM_OTHER_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", T4_5_2_UPSTREAM_OTHER_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)", + ); + }, + ); }, }); @@ -555,6 +957,19 @@ const T4_5_2 = defineProductTest({ const T4_5_3_IMPORT = 'import SPEC from "../specs/A.xspec";'; +// The template-literal arm's spec source: `SPEC`'s module holding +// `login-v2`, as TEST-SPEC T4.5-3 pins it — a segment no dot access reaches +// (T1.4-3), so `SPEC["login-v2"]` would resolve if read statically and the +// template literal is the arm's sole defect. The arm is not the body's +// first, so its workspace is created after a product invocation: a +// staged-source record (S-9, test/self/s9-staged-sources.test.ts). +const T4_5_3_LOGIN_SPEC_FILES = { + "specs/A.mdx": stagedMdx( + "T4.5-3 template-literal arm specs/A.mdx — the module holding login-v2", + '<S id="login-v2">\nLogin behavior.\n</S>\n', + ), +} as const; + const T4_5_3_ARMS: readonly OffendingStatementArm[] = [ { name: @@ -563,6 +978,17 @@ const T4_5_3_ARMS: readonly OffendingStatementArm[] = [ lines: [T4_5_3_IMPORT, "", 'const key = "a";', "SPEC[key];"], offending: "SPEC[key];", }, + // TEST-SPEC's pinned optional-chaining spelling, optional on the root + // binding itself: read statically, `SPEC.a` resolves (the shared source + // holds `a`), so the `?.` is the arm's sole defect. `SPEC.a?.b;` below, + // optional deeper in the chain, is an extra spelling of the same rule. + { + name: + "optional chaining on the root binding as a bare expression statement " + + "(TEST-SPEC's `SPEC?.a;`, SPEC 2.4, 4.5)", + lines: [T4_5_3_IMPORT, "", "SPEC?.a;"], + offending: "SPEC?.a;", + }, { name: "an optional-chaining chain as a bare expression statement " + @@ -577,27 +1003,76 @@ const T4_5_3_ARMS: readonly OffendingStatementArm[] = [ lines: [T4_5_3_IMPORT, "", "SPEC.a!.b;"], offending: "SPEC.a!.b;", }, + // The TypeScript-only forms SPEC 2.4 makes dynamic in a TypeScript source + // — never a parse failure there (the spec-source counterparts are T2.4-2's + // 14.20 arms). `type X = unknown;` declares the asserted type so the file + // carries no TypeScript error at all — a type alias is type-level and + // collides with no binding (SPEC 2.4, 4.5) — keeping the form each arm's + // sole defect. The angle-bracket assertion parses only under the + // plain-TypeScript grammar the staged `.ts` file name selects (SPEC + // 14.20) — never `.tsx`. + { + name: + "a non-null assertion ending the chain as a bare expression statement " + + "(TEST-SPEC's `SPEC.a!;`; TypeScript-only syntax, dynamic in a " + + "TypeScript source, SPEC 2.4, 4.5)", + lines: [T4_5_3_IMPORT, "", "SPEC.a!;"], + offending: "SPEC.a!;", + }, + { + name: + "a type assertion `as X` as a bare expression statement " + + "(TypeScript-only syntax, dynamic in a TypeScript source, SPEC 2.4, 4.5)", + lines: [T4_5_3_IMPORT, "", "type X = unknown;", "SPEC.a as X;"], + offending: "SPEC.a as X;", + }, + { + name: + "an angle-bracket assertion `<X>` as a bare expression statement, in a " + + "`.ts` file where it parses (TypeScript-only syntax, dynamic in a " + + "TypeScript source, SPEC 2.4, 4.5)", + lines: [T4_5_3_IMPORT, "", "type X = unknown;", "<X>SPEC.a;"], + offending: "<X>SPEC.a;", + }, + { + name: + "a `satisfies X` operator as a bare expression statement " + + "(TypeScript-only syntax, dynamic in a TypeScript source, SPEC 2.4, 4.5)", + lines: [T4_5_3_IMPORT, "", "type X = unknown;", "SPEC.a satisfies X;"], + offending: "SPEC.a satisfies X;", + }, { name: "a parenthesized chain as a bare expression statement (SPEC 2.4, 4.5)", lines: [T4_5_3_IMPORT, "", "(SPEC.a).b;"], offending: "(SPEC.a).b;", }, + // TEST-SPEC's `` SPEC[`login-v2`]; ``: a product reading the + // no-substitution template literal as the static string literal + // `"login-v2"` resolves the chain, records the edge, and reports nothing + // — failing the exit-1 and exact {"14.8": 1} expectations. { name: - "a template-literal computed index as a bare expression statement " + - "(template literals are not static, SPEC 2.4, 4.5)", - lines: [T4_5_3_IMPORT, "", "SPEC[`a`];"], - offending: "SPEC[`a`];", + "a template-literal computed index as a bare expression statement, " + + "`SPEC`'s module holding `login-v2` (template literals are not " + + "static, SPEC 2.4, 4.5)", + lines: [T4_5_3_IMPORT, "", "SPEC[`login-v2`];"], + offending: "SPEC[`login-v2`];", + specFiles: T4_5_3_LOGIN_SPEC_FILES, }, ]; +// T4.5-3's arms staged at module load, one record per row (S-9). +const T4_5_3_STAGINGS = stageOffendingStatements("T4.5-3", T4_5_3_ARMS); + const T4_5_3 = defineProductTest({ id: "T4.5-3", title: - "a non-static bare reference in expression-statement position — computed index by variable, optional chaining, non-null assertion, parentheses, template-literal index — fails with exactly one located 14.8 finding (invalid argument, not 14.18) (SPEC 4.5, 2.4, 14.8)", + "a non-static bare reference in expression-statement position — computed index by variable, optional chaining (`SPEC?.a;`, optional on the root binding, and `SPEC.a?.b;`), non-null assertion, parentheses, template-literal index (`` SPEC[`login-v2`]; ``, the module holding `login-v2`: template literals are not static), and the TypeScript-only forms 2.4 makes dynamic in a TypeScript source, never a parse failure there (`SPEC.a as X;`, `<X>SPEC.a;` in a `.ts` file, `SPEC.a satisfies X;`) — fails with exactly one located 14.8 finding (invalid argument, not 14.18), the file well-formed, with no edge and no occurrence: `occurrences --file src/app.ts` on the failing workspace exits 1 with its full answer, the one 14.8 finding accompanying it located as `build`'s is, and lists no record (SPEC 4.5, 2.4, 5.7, 11.2, 11.3, 14.8)", run: async (product) => { - for (const arm of T4_5_3_ARMS) { - await assertArmFailsWith(product, "T4.5-3", arm, "14.8"); + for (const staging of T4_5_3_STAGINGS) { + await assertArmFailsWith(product, "T4.5-3", staging, "14.8", { + noOccurrence: true, + }); } }, }); @@ -606,11 +1081,41 @@ const T4_5_3 = defineProductTest({ // T4.5-4 — shadowing: chains rooted at a local are not spec references // --------------------------------------------------------------------------- -// The identical statement text `SPEC.a.b;` appears twice: once at top level -// rooted at the import binding (a marker, the control), once inside a -// function whose local `const SPEC` shadows the import — TypeScript scoping -// resolves that chain to the local, so it is not a spec reference (SPEC 4.5: -// rooting is scope-aware and value-level). +// The identical statement text `SPEC.a.b;` appears at top level rooted at the +// import binding (a marker, the control) and inside the scope of each +// shadowing local — TypeScript scoping resolves those chains to the local, so +// they are not spec references (SPEC 4.5: rooting is scope-aware and +// value-level). The locals: a `const SPEC` in `localScope`; a `using SPEC = +// f()` in a block of `usingBlockScope`; and an `await using SPEC = f()` in +// the async function `awaitUsingScope` (SPEC 2.4, 4.5: variable +// declarations, `using` and `await using` included). Each shadowed chain +// lies in a named code unit (4.6), so a product ignoring the scoping would +// record an edge from that unit — distinguishable from the file-level +// control's. The same chain outside the `using` block — still inside +// `usingBlockScope`, past the block's closing brace — is rooted at the +// import again and records its edge from `src/app.ts#usingBlockScope`: the +// block, not the function, bounds the `using` local's scope. The block's +// shadowed chain and that edge share a source unit and a target, so the +// edge set alone cannot tell them apart; the occurrence records (5.7) can — +// each spelling rooted at the import records exactly one, at its own range, +// and a shadowed chain none. The ambient `declare function f` binds no +// `SPEC` and is no named unit (4.6); TypeScript 5.9.3 accepts the file both +// as module code and as script code (14.20). +const T4_5_4_USING_BLOCK_UNIT = [ + "function usingBlockScope(): void {", + " {", + " using SPEC = f();", + " SPEC.a.b;", + " }", + " SPEC.a.b;", + "}", +].join("\n"); +const T4_5_4_AWAIT_USING_UNIT = [ + "async function awaitUsingScope(): Promise<void> {", + " await using SPEC = f();", + " SPEC.a.b;", + "}", +].join("\n"); const T4_5_4_APP_SOURCE = [ 'import SPEC from "../specs/A.xspec";', "", @@ -624,12 +1129,271 @@ const T4_5_4_APP_SOURCE = [ "", "localScope();", "", + "declare function f(): Disposable & { a: { b: string } };", + "", + T4_5_4_USING_BLOCK_UNIT, + "", + T4_5_4_AWAIT_USING_UNIT, + "", + "usingBlockScope();", + "void awaitUsingScope();", + "", ].join("\n"); +/** + * The byte position of `needle` within `haystack` (pure ASCII, so string + * indices are byte offsets), required to occur exactly once — a missing or + * repeated anchor is a harness defect, never a product failure. + */ +function t454UniqueAt(haystack: string, needle: string, what: string): number { + const at = haystack.indexOf(needle); + if ( + at === -1 || + haystack.indexOf(needle, at + 1) !== -1 || + !/^[\x20-\x7e\n]*$/.test(haystack) + ) { + throw new Error( + `T4.5-4 fixture broke: ${what} must occur exactly once in a pure-ASCII ` + + `src/app.ts — fix the staged source in section-4.5.ts (harness bug)`, + ); + } + return at; +} + +// The occurrence records `occurrences --file src/app.ts` must list over the +// main workspace, in occurrence order (SPEC 5.7: file, then range start, +// then range end): the top-level control marker, whose source is the +// whole-file location (identity the path alone, range the entire file), and +// the chain past the `using` block, whose source is the enclosing function +// declaration — `function` through its closing brace (1.7, 4.6). Each own +// range is the bare chain, the `;` excluded (5.7, 14). No record for a chain +// a local roots: the `const`, `using`, and `await using` scopes' chains. +const T4_5_4_EXPECTED_RECORDS: readonly (readonly unknown[])[] = (() => { + const source = T4_5_4_APP_SOURCE; + const chain = "SPEC.a.b"; + const controlStart = + t454UniqueAt(source, "\n\nSPEC.a.b;\n", "the top-level control marker") + 2; + const unitStart = t454UniqueAt( + source, + T4_5_4_USING_BLOCK_UNIT, + "the `usingBlockScope` declaration", + ); + const pastBlockStart = + unitStart + + t454UniqueAt( + T4_5_4_USING_BLOCK_UNIT, + " }\n SPEC.a.b;\n", + "the chain past the `using` block", + ) + + 6; + return [ + [ + "src/app.ts", + controlStart, + controlStart + chain.length, + "references", + ["src/app.ts", 0, source.length], + "specs/A.mdx#a.b", + ], + [ + "src/app.ts", + pastBlockStart, + pastBlockStart + chain.length, + "references", + [ + "src/app.ts#usingBlockScope", + unitStart, + unitStart + T4_5_4_USING_BLOCK_UNIT.length, + ], + "specs/A.mdx#a.b", + ], + ]; +})(); + +// Callee side (SPEC 4.5: rooting is scope-aware and value-level for the +// `text` binding as for the node chain). Inside `shadowScope`, a local +// `function text` shadows the imported `text`, so `text(SPEC.a)` there has a +// non-spec callee and a node argument — the "passing to any other function" +// of 4.5, 14.18 (T4.5-5) — while the identical call at module scope (the +// control) is an ordinary `text` call recording its `embeds` edge (4.3), and +// the import binding, used by the control alone, stays a valid import (2.1, +// 4). The two targets differ (`a` shadowed, `a.b` control), so a record a +// by-name product would emit for the shadowed call is distinguishable from +// the control's by target, not only by range. +const T4_5_4_CALLEE_IMPORT = 'import SPEC, { text } from "../specs/A.xspec";'; +const T4_5_4_CALLEE_CONTROL_CALL = "text(SPEC.a.b)"; +const T4_5_4_CALLEE_SHADOWED_LINE = " text(SPEC.a);"; +const T4_5_4_CALLEE_ARM: OffendingStatementArm = { + name: "a shadowing local `text` as the callee (SPEC 4.5)", + lines: [ + T4_5_4_CALLEE_IMPORT, + "", + `${T4_5_4_CALLEE_CONTROL_CALL};`, + "", + "function shadowScope(): void {", + " function text(x: unknown): void { void x; }", + T4_5_4_CALLEE_SHADOWED_LINE, + "}", + "", + "shadowScope();", + ], + offending: T4_5_4_CALLEE_SHADOWED_LINE, +}; +// The callee side creates its workspace after the body's first invocation: +// its `src/app.ts` is staged at module load, a staged-source record (S-9). +const T4_5_4_CALLEE_STAGED = stageOffendingStatement( + "T4.5-4", + T4_5_4_CALLEE_ARM, +); + +/** + * An occurrence record's every datum (SPEC 5.7) as one JSON-safe tuple — + * file, own range, kind, source (identity plus range, or the unavailability + * marker), target — so whole records compare key-order-free. + */ +function occurrenceTuple(record: OccurrenceRecord): readonly unknown[] { + const source = + "unavailable" in record.source + ? "(source unavailable)" + : [ + record.source.identity, + record.source.range.start, + record.source.range.end, + ]; + return [ + record.file, + record.range.start, + record.range.end, + record.kind, + source, + record.target, + ]; +} + +/** + * The T4.5-4 callee-side arm: `build` and `check` report exactly one + * condition-18 finding located at the shadowed use (exit 1), and + * `occurrences --file` over the code file — answering on the failing + * workspace (SPEC 11.2) — carries that finding and lists exactly the control + * call's `embeds` record, none for the shadowed call (5.7, 11.3). + */ +async function assertT454CalleeSide(product: ProductBinding): Promise<void> { + const { source, window } = T4_5_4_CALLEE_STAGED; + const shadowedUse = { file: "src/app.ts", window } as const; + // The control record: its own range spans the call expression, callee + // through closing parenthesis, the statement terminator excluded; its + // source is the whole-file location, no named unit enclosing it — + // identity the path alone, range the entire file (SPEC 5.7, 4.6, 1.7). + const controlStart = Buffer.byteLength(`${T4_5_4_CALLEE_IMPORT}\n\n`, "utf8"); + const controlEnd = + controlStart + Buffer.byteLength(T4_5_4_CALLEE_CONTROL_CALL, "utf8"); + const expectedRecords: readonly (readonly unknown[])[] = [ + [ + "src/app.ts", + controlStart, + controlEnd, + "embeds", + ["src/app.ts", 0, Buffer.byteLength(source.source, "utf8")], + "specs/A.mdx#a.b", + ], + ]; + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...AB_SPEC_FILES, "src/app.ts": source }, + async (workspace) => { + // `build`: exactly one finding, condition 18, located at the shadowed + // use. The exact count pins the classification and, with it, the + // import's validity — nothing is reported beside the one 14.18 + // (SPEC 2.1, 4.5, 14.18). + const buildContext = + "T4.5-4 `build --json` with a shadowing local `text` as the callee"; + const buildFound = await buildFindings(product, workspace, buildContext); + assertConditionCounts(buildFound, { "14.18": 1 }, buildContext); + assertFindingLocated( + buildFound[0]!, + shadowedUse, + `${buildContext}: the 14.18 finding locates at the shadowed use ` + + `(SPEC 4.5, 14.18)`, + ); + + // `check`: the same validation, exit 1 (SPEC 12.2). Staleness of the + // never-built workspace's derived files is 14.10's own business + // (12.2, 14) — set aside, the findings are exactly the one 14.18, + // located at the shadowed use. + const checkContext = + "T4.5-4 `check --json` with a shadowing local `text` as the callee"; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations and ` + + `exits 1 on the finding (SPEC 12.2, 4.5, 14.18)`, + ); + const checkFound = decodeFindingsReport( + parseJsonStdout(checkResult, checkContext), + checkContext, + ).findings.filter((finding) => finding.condition !== "14.10"); + assertConditionCounts(checkFound, { "14.18": 1 }, checkContext); + assertFindingLocated( + checkFound[0]!, + shadowedUse, + `${checkContext}: the 14.18 finding locates at the shadowed use ` + + `(SPEC 4.5, 14.18)`, + ); + + // `occurrences --file src/app.ts`, answering on the failing workspace + // (SPEC 11.2): the code file's finding accompanies (exit 1, the full + // answer still emitted), and the record set is exactly the control + // call's `embeds` record — the shadowed call records no edge and so + // no occurrence (5.7). A product resolving the callee by name would + // list a second record (target `specs/A.mdx#a`) and carry no finding. + const occContext = + "T4.5-4 `occurrences --file src/app.ts` on the failing workspace"; + const occResult = await expectExit( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + 1, + `${occContext} — the answer carries the domain's 14.18 finding, so ` + + `exit 1 with the full answer document (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertConditionCounts( + report.findings, + { "14.18": 1 }, + `${occContext}: the code file's one finding accompanies the answer ` + + `(SPEC 11.2, 11.3)`, + ); + assertFindingLocated( + report.findings[0]!, + shadowedUse, + `${occContext}: the accompanying 14.18 locates at the shadowed use ` + + `(SPEC 11.2, 14)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + expectedRecords, + `${occContext}: exactly the control call's \`embeds\` record — file, ` + + `own range (callee through closing parenthesis), kind, whole-file ` + + `source, target — and none for the shadowed call, which records ` + + `no edge and no occurrence (SPEC 4.5, 5.7, 4.6, 1.7, 11.3)`, + ); + }, + ); +} + const T4_5_4 = defineProductTest({ id: "T4.5-4", title: - "a local declaration shadowing the import binding: chains rooted at the local are not spec references — no edge, no error, the program builds — while the identical statement rooted at the import records its marker edge (SPEC 4.5)", + "a local declaration shadowing the import binding — a `const`, a `using SPEC = f()` in a block, and an `await using SPEC = f()` in an async function among the locals: chains rooted at the local are not spec references — no edge, no occurrence, no error, the program builds — while the identical statement rooted at the import, outside each local's scope, records its edge; callee side: an inner-scope `function text` shadowing the imported `text` makes `text(SPEC.a)` in that scope a call to another function — `build` and `check` report exactly one condition-18 finding located at that use, exit 1, and `occurrences --file` over the code file, answering on the failing workspace, carries the finding and lists no record for it while the identical call outside the scope lists its `embeds` occurrence (SPEC 4.5, 2.4, 5.7, 11.2, 11.3, 14.18)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -653,26 +1417,81 @@ const T4_5_4 = defineProductTest({ ); // No edge: the workspace's complete `references` edge set is the - // top-level control marker's file-attributed edge — a product that - // ignored scoping would record a second edge from the function unit. - const expected: readonly GraphEdge[] = [ - { from: "src/app.ts", to: "specs/A.mdx#a.b", kind: "references" }, - ]; + // top-level control marker's file-attributed edge plus the edge of + // the chain past the `using` block, attributed to its enclosing + // function — a product that ignored the `const` or `await using` + // scoping would record a further edge from `localScope` or + // `awaitUsingScope`, and one scoping a block's `using` local to the + // whole function would drop the `usingBlockScope` edge. + const controlEdge: GraphEdge = { + from: "src/app.ts", + to: "specs/A.mdx#a.b", + kind: "references", + }; assertEdgeSetEqual( await queryEdgesOfKind(product, workspace, "references", "T4.5-4"), - expected, - "T4.5-4 chains rooted at the shadowing local record no edge; the " + - "identical top-level statement rooted at the import records " + - "exactly its marker edge (SPEC 4.5, 4.6)", + [ + controlEdge, + { + from: "src/app.ts#usingBlockScope", + to: "specs/A.mdx#a.b", + kind: "references", + }, + ], + "T4.5-4 chains rooted at a shadowing local — `const`, `using` in " + + "a block, `await using` in an async function — record no edge; " + + "the identical top-level statement rooted at the import records " + + "exactly its marker edge, and the same chain outside the `using` " + + "block's scope records its edge from the enclosing function " + + "(SPEC 4.5, 2.4, 4.6)", ); assertEdgeSetEqual( await queryEdgesFrom(product, workspace, "src/app.ts", "T4.5-4"), - expected, - "T4.5-4 the file's complete outgoing edge set is the control " + - "marker's edge (SPEC 4.5, 4.6)", + [controlEdge], + "T4.5-4 the whole-file location's complete outgoing edge set is " + + "the control marker's edge (SPEC 4.5, 4.6)", + ); + + // No edge, per spelling: `occurrences --file src/app.ts` lists + // exactly the records of the two chains the import roots — the + // control and the chain past the `using` block — and none for a + // chain a local roots (SPEC 5.7, 11.3): a product ignoring the + // block's `using` local would list a third record at that chain's + // own range, an edge the edge set cannot distinguish (same source + // unit, same target). The workspace is valid, so the answer is + // finding-free, exit 0 (11.2). + const occContext = + "T4.5-4 `occurrences --file src/app.ts` over the shadowing locals"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + occContext, + ), + occContext, + ); + assertSameJson( + report.findings.map((finding) => finding.condition), + [], + `${occContext}: no finding accompanies the answer — the shadowed ` + + `chains fall under no condition (SPEC 4.5, 11.2)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + T4_5_4_EXPECTED_RECORDS, + `${occContext}: exactly the records of the chains the import ` + + `roots — the top-level control (whole-file source) and the ` + + `chain past the \`using\` block (source \`usingBlockScope\`) — ` + + `each at its bare chain, and none for a chain the \`const\`, ` + + `\`using\`, or \`await using\` local roots (SPEC 4.5, 2.4, 5.7, ` + + `4.6, 1.7, 11.3)`, ); }, ); + + // Callee side: the shadowing local as the callee of `text(...)`. + await assertT454CalleeSide(product); }, }); @@ -731,13 +1550,16 @@ const T4_5_5_ARMS: readonly OffendingStatementArm[] = [ }, ]; +// T4.5-5's arms staged at module load, one record per row (S-9). +const T4_5_5_STAGINGS = stageOffendingStatements("T4.5-5", T4_5_5_ARMS); + const T4_5_5 = defineProductTest({ id: "T4.5-5", title: "the sanctioned value-level uses are exact — aliasing a node to a variable, destructuring the module, re-exporting the binding, storing a node in an array or object, passing a node to a function other than a spec module's `text` export, and passing or storing `text` other than as a callee each fail with exactly one located 14.18 finding (SPEC 4.5, 14.18)", run: async (product) => { - for (const arm of T4_5_5_ARMS) { - await assertArmFailsWith(product, "T4.5-5", arm, "14.18"); + for (const staging of T4_5_5_STAGINGS) { + await assertArmFailsWith(product, "T4.5-5", staging, "14.18"); } }, }); @@ -792,7 +1614,11 @@ const T4_5_6 = defineProductTest({ // Type-level references in several positions: a type-alias `typeof` query on // the root and on a chain, a type-level indexed access, an interface property // annotation, and a parameter annotation. No value-level use of the binding -// exists anywhere in the file. +// exists anywhere in the file. TEST-SPEC's closing clause — an import type +// naming a `.xspec` module is no such reference but a module-linking form, +// 14.15 — defers to T4-2, whose import-type arms +// (`type T = import("./NAME.xspec").default`, +// `let v: typeof import("./NAME.xspec")`) assert it (section-4.ts). const T4_5_7_APP_SOURCE = [ 'import SPEC from "../specs/A.xspec";', "", @@ -814,7 +1640,7 @@ const T4_5_7_APP_SOURCE = [ const T4_5_7 = defineProductTest({ id: "T4.5-7", title: - "`typeof SPEC.a.b` and other type-level references are unrestricted: the workspace builds with no edges recorded, and rename rewrites nothing in the file — type-level references may be left naming vacated identities while the workspace stays valid (SPEC 4.5, 6.4)", + "`typeof SPEC.a.b` and other type-level references are unrestricted: the workspace builds with no edges recorded, and rename rewrites nothing in the file — type-level references may be left naming vacated identities while the workspace stays valid; an import type naming a `.xspec` module is no such reference but a module-linking form, 14.15, asserted by T4-2's import-type arms (SPEC 4.5, 6.4)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -877,6 +1703,1205 @@ const T4_5_7 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T4.5-8 — same-scope collisions: an identifier the import and a value-level +// declaration of the module scope both bind roots no resolving chain +// --------------------------------------------------------------------------- + +// Sibling targets `a` and `b`: both staged chains (`SPEC.a`, `text(SPEC.b)`) +// resolve if the import roots them, so the same-scope declaration is each +// arm's sole defect (SPEC 2.4, 4.5) — a product reporting the chains as +// unresolved for any other reason has nothing else to point at. +// The one source T4.5-8 stages at two paths — `specs/A.mdx` of the code-side +// arms and `specs/BASE.mdx` of the spec-source arms — as one staged-source +// record (S-9, test/self/s9-staged-sources.test.ts): every arm's workspace +// past the first is created after a product invocation. +const T4_5_8_AB_SOURCE = stagedMdx( + "T4.5-8 specs/A.mdx (also specs/BASE.mdx of the spec-source arms)", + '<S id="a">\nAlpha behavior.\n</S>\n\n<S id="b">\nBeta behavior.\n</S>\n', +); +const T4_5_8_SPEC_FILES = { + "specs/A.mdx": T4_5_8_AB_SOURCE, +} as const; + +// The import binds both the default export and `text`, so `text(SPEC.b)` is +// a spec module `text` call (4.3) whose argument chain is rooted at `SPEC`. +const T4_5_8_IMPORT = 'import SPEC, { text } from "../specs/A.xspec";'; +const T4_5_8_MARKER_CHAIN = "SPEC.a"; +const T4_5_8_TEXT_CALL = "text(SPEC.b)"; + +/** One module-scope declaration staged beside the import (SPEC 2.4, 4.5). */ +export interface SameScopeDeclarationArm { + /** Which declaration form this is (failure diagnostics). */ + readonly name: string; + /** + * The declaration's line(s) of `src/app.ts`, exactly (pure ASCII) — a + * form needing a supporting declaration carries it on a preceding line. + */ + readonly line: string; + /** + * For a colliding form: the construct binding the name — the characters + * a condition-15 finding locates (SPEC 14, 1.7) — exactly as it occurs + * within `line`. A type-level control collides with nothing and locates + * nothing. + */ + readonly construct: string; +} + +// The colliding forms: a variable, function, class, or enum declaration, or +// a namespace binding a value (SPEC 2.4). The located construct is the +// variable DECLARATOR (`SPEC = 1`, the `const` statement excluded) or the +// declaration's own characters (14, 1.7). The further located forms follow +// 14's declarator and decorator rules as 1.7 reads them: a declarator +// without initializer is its name alone; a binding pattern spans the pattern +// through the initializer; a decorator list is part of the class it +// decorates, whose own characters begin at its first decorator; and a +// leading `export`, with whatever separates it from the construct's first +// token, is excluded. +// +// The four further forms T14-11 names are shared with its 14.15 range arm +// (section-14.ts) through `T4_5_8_FURTHER_LOCATED_FORMS` — one staging of +// each form, keyed for file naming there: each `line` exact, its +// `construct` the characters a condition-15 finding locates (SPEC 14, 1.7). +export const T4_5_8_FURTHER_LOCATED_FORMS: Readonly< + Record< + "let" | "pattern" | "decorated" | "exportedClass", + SameScopeDeclarationArm + > +> = { + let: { + name: "a declarator without initializer `let SPEC;` (SPEC 2.4, 14)", + line: "let SPEC;", + construct: "SPEC", + }, + pattern: { + name: "a binding pattern `const { SPEC } = o` (SPEC 2.4, 14)", + line: "declare const o: Record<string, number>;\nconst { SPEC } = o;", + construct: "{ SPEC } = o", + }, + decorated: { + name: "a decorated class `@dec class SPEC {}` (SPEC 2.4, 14, 1.7)", + line: "declare function dec(value: unknown, context: unknown): void;\n@dec class SPEC {}", + construct: "@dec class SPEC {}", + }, + exportedClass: { + name: "an exported class `export class SPEC {}` (SPEC 2.4, 14, 1.7)", + line: "export class SPEC {}", + construct: "class SPEC {}", + }, +}; + +const T4_5_8_COLLIDING_ARMS: readonly SameScopeDeclarationArm[] = [ + { + name: "a variable declaration `const SPEC = 1` (SPEC 2.4)", + line: "const SPEC = 1;", + construct: "SPEC = 1", + }, + // The `using` and `await using` forms (SPEC 2.4: variable declarations, + // `using` and `await using` included), each at the module's top level — + // TypeScript 5.9.3 accepts both as module code and as script code + // (14.20). The located construct is the declarator `SPEC = f()`, the + // `using` or `await using` excluded (14, 1.7); the ambient `declare + // function f` on the line before binds no `SPEC`. + { + name: "a `using` declaration `using SPEC = f()` at module scope (SPEC 2.4)", + line: "declare function f(): Disposable;\nusing SPEC = f();", + construct: "SPEC = f()", + }, + { + name: "an `await using` declaration `await using SPEC = f()` at module scope (SPEC 2.4)", + line: "declare function f(): AsyncDisposable;\nawait using SPEC = f();", + construct: "SPEC = f()", + }, + { + name: "a function declaration `function SPEC() {}` (SPEC 2.4)", + line: "function SPEC() {}", + construct: "function SPEC() {}", + }, + { + name: "a class declaration `class SPEC {}` (SPEC 2.4)", + line: "class SPEC {}", + construct: "class SPEC {}", + }, + { + name: "an enum declaration `enum SPEC {}` (SPEC 2.4)", + line: "enum SPEC {}", + construct: "enum SPEC {}", + }, + { + name: "a namespace binding a value `namespace SPEC { export const v = 1 }` (SPEC 2.4)", + line: "namespace SPEC { export const v = 1 }", + construct: "namespace SPEC { export const v = 1 }", + }, + ...Object.values(T4_5_8_FURTHER_LOCATED_FORMS), + { + name: "an exported function `export function SPEC() {}` (SPEC 2.4, 14, 1.7)", + line: "export function SPEC() {}", + construct: "function SPEC() {}", + }, +]; + +// The type-level controls: an interface, a type alias, and a namespace +// binding no value collide with nothing — the import roots the chains, the +// edges are recorded, no finding, exit 0 (SPEC 2.4, 4.5). An inner-scope +// `const SPEC = 1` shadows instead — T4.5-4's business. +const T4_5_8_CONTROL_ARMS: readonly SameScopeDeclarationArm[] = [ + { + name: "an interface `interface SPEC {}` (type-level, SPEC 2.4)", + line: "interface SPEC {}", + construct: "", + }, + { + name: "a type alias `type SPEC = number` (type-level, SPEC 2.4)", + line: "type SPEC = number;", + construct: "", + }, + { + name: "a namespace binding no value `namespace SPEC { export type T = number }` (type-level, SPEC 2.4)", + line: "namespace SPEC { export type T = number }", + construct: "", + }, +]; + +/** A staged `src/app.ts` for one arm and its constructs' byte positions. */ +interface StagedSameScopeArm { + /** + * The whole file — import, blank, declaration, blank, marker, `text` + * call — as a staged-source record (registered at module load, so the + * arms are staged once, into `T4_5_8_COLLIDING_STAGINGS` and + * `T4_5_8_CONTROL_STAGINGS`: every arm's workspace past the body's first + * is created after a product invocation, S-9's timing clause). + */ + readonly source: StagedTs; + /** The import declaration's end-widened byte window (support.ts byteWindow). */ + readonly importWindow: { readonly start: number; readonly end: number }; + /** The colliding construct's end-widened byte window (`""` construct: unused). */ + readonly constructWindow: { readonly start: number; readonly end: number }; + /** + * The colliding construct's own characters, exactly (SPEC 14, 1.7): a + * declarator or a declaration that ends in no statement terminator, so + * its precomputed offsets are unambiguous (`""` construct: unused). + */ + readonly constructRange: { readonly start: number; readonly end: number }; + /** The marker's bare chain, exactly (SPEC 14: terminator excluded). */ + readonly markerRange: { readonly start: number; readonly end: number }; + /** The `text(...)` call, callee through closing parenthesis, exactly (14). */ + readonly textCallRange: { readonly start: number; readonly end: number }; +} + +/** + * Lay out an arm's `src/app.ts` — the import, the declaration, then the + * marker statement and the `text(...)` call statement, each on its own line + * — and fix every construct's byte position from the exact bytes. The file + * is pure ASCII, so string indices are byte offsets. A construct missing + * from its line is a harness defect, never a product failure. It registers + * a record, so it runs at module load only. + */ +function stageSameScopeArm(arm: SameScopeDeclarationArm): StagedSameScopeArm { + const constructAt = arm.line.indexOf(arm.construct); + if (constructAt === -1) { + throw new Error( + `T4.5-8 fixture broke: the located construct must occur within the ` + + `declaration line (${arm.name}) — fix the arm table in section-4.5.ts`, + ); + } + const declarationPrefix = `${T4_5_8_IMPORT}\n\n`; + const markerPrefix = `${declarationPrefix}${arm.line}\n\n`; + const textCallPrefix = `${markerPrefix}${T4_5_8_MARKER_CHAIN};\n`; + const source = `${textCallPrefix}${T4_5_8_TEXT_CALL};\n`; + const exact = ( + prefix: string, + construct: string, + ): { start: number; end: number } => { + const start = Buffer.byteLength(prefix, "utf8"); + return { start, end: start + Buffer.byteLength(construct, "utf8") }; + }; + return { + source: stagedTs(`T4.5-8 src/app.ts beside ${arm.name}`, source), + importWindow: byteWindow("", T4_5_8_IMPORT), + constructWindow: byteWindow( + declarationPrefix + arm.line.slice(0, constructAt), + arm.construct, + ), + constructRange: exact( + declarationPrefix + arm.line.slice(0, constructAt), + arm.construct, + ), + markerRange: exact(markerPrefix, T4_5_8_MARKER_CHAIN), + textCallRange: exact(textCallPrefix, T4_5_8_TEXT_CALL), + }; +} + +/** One module-scope declaration arm paired with its staging. */ +interface SameScopeArmStaging { + readonly arm: SameScopeDeclarationArm; + readonly staged: StagedSameScopeArm; +} + +// The arms staged once, at module load, in table order — a record registers +// at module level only (S-9), so the body iterates these tables. +const T4_5_8_COLLIDING_STAGINGS: readonly SameScopeArmStaging[] = + T4_5_8_COLLIDING_ARMS.map((arm) => ({ arm, staged: stageSameScopeArm(arm) })); +const T4_5_8_CONTROL_STAGINGS: readonly SameScopeArmStaging[] = + T4_5_8_CONTROL_ARMS.map((arm) => ({ arm, staged: stageSameScopeArm(arm) })); + +/** A finding's locations as JSON-safe `[file, start, end]` tuples (12.7 order). */ +function locationTuples(finding: Finding): readonly (readonly unknown[])[] { + return finding.locations.map((location) => [ + location.file, + location.range.start, + location.range.end, + ]); +} + +/** + * Assert a reference-spelling finding locates exactly one range — the span + * its occurrence would occupy (SPEC 14, 5.7), byte-exact — in `file`, and + * concerns no path (12.7). + */ +function assertSpellingFinding( + finding: Finding, + file: string, + range: { readonly start: number; readonly end: number }, + context: string, +): void { + assertFindingLocatesExactly(finding, [{ file, window: range }], context); + assertSameJson( + locationTuples(finding), + [[file, range.start, range.end]], + `${context}: the one location is byte-exact — the spelling's own span, ` + + `terminators and delimiters excluded (SPEC 14, 5.7, 1.7)`, + ); +} + +/** + * Assert the findings a colliding arm's workspace reports, in 12.7 order: + * condition 7 for the marker chain, condition 7 for the `text(...)` call + * (each byte-exact at the span its occurrence would occupy, SPEC 14), then + * the one condition-15 collision locating the import declaration and the + * colliding construct, both within their own byte windows and nothing + * else (14: every colliding declaration, no representative chosen). + */ +function assertSameScopeCollisionFindings( + findings: readonly Finding[], + staged: StagedSameScopeArm, + context: string, +): void { + assertSameJson( + findings.map((finding) => finding.condition), + ["14.7", "14.7", "14.15"], + `${context}: exactly the two unresolved chains (condition 7, one per ` + + `spelling) beside the one collision (condition 15), in 12.7 order — ` + + `numbered conditions in numeric order, then by location (SPEC 2.4, ` + + `4.5, 14.7, 14.15, 12.7)`, + ); + assertSpellingFinding( + findings[0]!, + "src/app.ts", + staged.markerRange, + `${context}: the marker's 14.7 locates the bare reference chain, ` + + `exclusive of the statement terminator (SPEC 14, 5.7)`, + ); + assertSpellingFinding( + findings[1]!, + "src/app.ts", + staged.textCallRange, + `${context}: the \`text(...)\` call's 14.7 locates the call expression, ` + + `callee through closing parenthesis (SPEC 14, 5.7)`, + ); + assertFindingLocatesExactly( + findings[2]!, + [ + { file: "src/app.ts", window: staged.importWindow }, + { file: "src/app.ts", window: staged.constructWindow }, + ], + `${context}: the 14.15 locates every colliding declaration — the import ` + + `by its own characters and the non-import by the construct binding ` + + `the name, a declarator by its own characters with the \`const\` ` + + `statement excluded (SPEC 14, 1.7, 2.4)`, + ); + // The non-import's location byte-asserted against its precomputed offsets + // (TEST-SPEC T4.5-8): the declarator's own characters — name or binding + // pattern through initializer, the `const`, `let`, `using`, or `await + // using` statement excluded — or the declaration's own characters, a + // decorator list included and a leading `export` excluded (SPEC 14, 1.7). + // None ends in a statement terminator, so its exact span is unambiguous; + // the import's own characters keep the end-widened window above. + assertSameJson( + locationTuples(findings[2]!)[1], + ["src/app.ts", staged.constructRange.start, staged.constructRange.end], + `${context}: the 14.15's second location is byte-exact — the colliding ` + + `construct's own characters, nothing before or after them (SPEC 14, ` + + `1.7, 2.4)`, + ); +} + +/** + * One colliding arm: `build` and `check` report the collision beside + * condition 7 for each chain, exit 1; `query edges --from src/app.ts` on the + * failing workspace reports exactly those findings and exits 1 without + * answering — the findings-only document, no edge (SPEC 13.3, 12.7); and + * `occurrences --file src/app.ts`, answering on the failing workspace + * (11.2), carries them and lists no record — a chain rooted at the collided + * identifier records no edge and no occurrence (5.7). + */ +async function assertSameScopeCollisionArm( + product: ProductBinding, + { arm, staged }: SameScopeArmStaging, +): Promise<void> { + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_5_8_SPEC_FILES, "src/app.ts": staged.source }, + async (workspace) => { + const buildContext = `T4.5-8 \`build --json\` beside ${arm.name}`; + assertSameScopeCollisionFindings( + await buildFindings(product, workspace, buildContext), + staged, + buildContext, + ); + + // `check`: the same validation, exit 1 (SPEC 12.2). Staleness of the + // never-built workspace's derived files is 14.10's own business — + // set aside, the findings are exactly the collision arm's. + const checkContext = `T4.5-8 \`check --json\` beside ${arm.name}`; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations and ` + + `exits 1 on the findings (SPEC 12.2, 2.4, 4.5)`, + ); + assertSameScopeCollisionFindings( + decodeFindingsReport( + parseJsonStdout(checkResult, checkContext), + checkContext, + ).findings.filter((finding) => finding.condition !== "14.10"), + staged, + checkContext, + ); + + // `query edges --from src/app.ts`: the workspace fails `build`'s + // validations, so the read reports exactly those findings and exits 1 + // without answering (SPEC 13.3) — the findings-only document, which + // the form-exact decode enforces: no `edges` member beside it, so no + // edge from the file is reported (SPEC 2.4, 5.7, 12.7). + const queryContext = `T4.5-8 \`query edges --from src/app.ts\` beside ${arm.name}`; + const queryResult = await expectExit( + product, + workspace, + ["query", "edges", "--from", "src/app.ts"], + 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)`, + ); + assertSameScopeCollisionFindings( + decodeFindingsReport( + parseJsonStdout(queryResult, queryContext), + `${queryContext} — a refusing read's report is the findings-only ` + + `document {"findings": […]}: no edge answered (SPEC 12.7, 13.3)`, + ).findings, + staged, + queryContext, + ); + + // `occurrences --file src/app.ts`, answering on the failing workspace + // (SPEC 11.2): the code file's findings accompany (exit 1, the full + // answer still emitted), and the record set is empty — the two chains + // rooted at the collided identifier record no edge and no occurrence + // (5.7); their positions reach consumers through the findings alone. + const occContext = `T4.5-8 \`occurrences --file src/app.ts\` beside ${arm.name}`; + const occResult = await expectExit( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + 1, + `${occContext} — the answer carries the domain's findings, so exit ` + + `1 with the full answer document (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertSameScopeCollisionFindings( + report.findings, + staged, + `${occContext}: the code file's findings accompany the answer ` + + `(SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + [], + `${occContext}: no record for the marker or the \`text(...)\` call — ` + + `a chain rooted at an identifier the import and a same-scope ` + + `value-level declaration both bind records no edge and no ` + + `occurrence, its position reported by its finding's range alone ` + + `(SPEC 2.4, 5.7, 11.2)`, + ); + }, + ); +} + +/** + * One type-level control arm: the declaration collides with nothing, so the + * import roots both chains — `build` and `check` exit 0 (no finding), and + * the file's complete outgoing edge set is the marker's `references` edge + * and the call's `embeds` edge (SPEC 2.4, 4.5, 4.3). + */ +async function assertSameScopeControlArm( + product: ProductBinding, + { arm, staged }: SameScopeArmStaging, +): Promise<void> { + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_5_8_SPEC_FILES, "src/app.ts": staged.source }, + async (workspace) => { + await buildOk( + product, + workspace, + `T4.5-8 \`build\` beside ${arm.name} — a type-level declaration of ` + + `the module scope collides with nothing (SPEC 2.4, 4.5)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `T4.5-8 \`check\` beside ${arm.name} — no finding (SPEC 2.4, 4.5)`, + ); + assertEdgeSetEqual( + await queryEdgesFrom(product, workspace, "src/app.ts", "T4.5-8"), + [ + { from: "src/app.ts", to: "specs/A.mdx#a", kind: "references" }, + { from: "src/app.ts", to: "specs/A.mdx#b", kind: "embeds" }, + ], + `T4.5-8 beside ${arm.name}: the import roots both chains — the ` + + `marker's \`references\` edge and the call's \`embeds\` edge are ` + + `the file's complete outgoing edge set (SPEC 2.4, 4.5, 4.3, 4.6)`, + ); + }, + ); +} + +// The spec-source case (SPEC 2.4, 2.1, 2.7): `specs/COL.mdx` holds an +// export statement declaring `BASE` beside the import binding `BASE`. The +// export statement is itself invalid (14.16), the collision is 14.15 +// (locating the import and the declarator), and the `d` and `text(...)` +// spellings rooted at `BASE` are unresolved (14.5, 14.6) — no edge, no +// occurrence — though both targets exist in `specs/BASE.mdx`. +const T4_5_8_BASE_FILES = { + "specs/BASE.mdx": T4_5_8_AB_SOURCE, +} as const; +const T4_5_8_MDX_IMPORT = 'import BASE from "./BASE.xspec"'; +const T4_5_8_MDX_EXPORT = "export const BASE = 1"; +const T4_5_8_MDX_DECLARATOR = "BASE = 1"; +const T4_5_8_MDX_D_REFERENCE = "BASE.a"; +const T4_5_8_MDX_EMBEDDING = "{text(BASE.b)}"; +const T4_5_8_MDX_SECTION_OPEN = `<S id="c" d={${T4_5_8_MDX_D_REFERENCE}}>`; +const T4_5_8_MDX_BODY_PREFIX = "Gamma behavior "; +/** + * One staging of the spec-source case: how the import and the export + * statement are laid out in `specs/COL.mdx` (SPEC 2.7, 2.1). + */ +interface SpecSourceCollisionArm { + /** Which layout this is (failure diagnostics). */ + readonly name: string; + /** + * What separates the import's line from the export statement: U+000A + * alone keeps both in one ESM block (the export on the line after the + * import); a blank line ends the block, so the export opens a second one. + */ + readonly separator: string; +} + +const T4_5_8_SPEC_SOURCE_ARMS: readonly SpecSourceCollisionArm[] = [ + { name: "the two declarations in one ESM block", separator: "\n" }, + { name: "the two declarations across two ESM blocks", separator: "\n\n" }, +]; + +/** A staged `specs/COL.mdx` of one arm and its constructs' byte positions. */ +interface StagedSpecSourceCollision { + /** + * The whole file — import, export statement, blank, section `c` — as a + * staged-source record naming S-9's `duplicate-import-binding` allowance + * (registered at module load, so the layouts are staged once, into + * `T4_5_8_SPEC_SOURCE_STAGINGS`). + */ + readonly source: StagedMdx; + /** The import declaration's end-widened byte window (support.ts byteWindow). */ + readonly importWindow: { readonly start: number; readonly end: number }; + /** The declarator `BASE = 1`'s end-widened byte window. */ + readonly declaratorWindow: { readonly start: number; readonly end: number }; + /** The export statement's end-widened byte window. */ + readonly exportWindow: { readonly start: number; readonly end: number }; + /** The `d` value's expression, exactly (SPEC 14: braces excluded). */ + readonly dReferenceRange: { readonly start: number; readonly end: number }; + /** The embedding's full braced container, exactly (14). */ + readonly embeddingRange: { readonly start: number; readonly end: number }; +} + +/** + * Lay out an arm's `specs/COL.mdx` — the import, the arm's separator, the + * export statement, a blank line, then section `c` — and fix every + * construct's byte position from the exact bytes (pure ASCII, so string + * indices are byte offsets). The one-block layout is byte-for-byte the + * document's staging (the export on the line after the import). + */ +function stageSpecSourceCollision( + arm: SpecSourceCollisionArm, +): StagedSpecSourceCollision { + const exportPrefix = `${T4_5_8_MDX_IMPORT}${arm.separator}`; + const sectionPrefix = `${exportPrefix}${T4_5_8_MDX_EXPORT}\n\n`; + const dPrefix = `${sectionPrefix}<S id="c" d={`; + const bodyPrefix = `${sectionPrefix}${T4_5_8_MDX_SECTION_OPEN}\n${T4_5_8_MDX_BODY_PREFIX}`; + const source = `${bodyPrefix}${T4_5_8_MDX_EMBEDDING}\n</S>\n`; + const exact = ( + prefix: string, + construct: string, + ): { start: number; end: number } => { + const start = Buffer.byteLength(prefix, "utf8"); + return { start, end: start + Buffer.byteLength(construct, "utf8") }; + }; + return { + // S-9: the export declaration binding the import's identifier is an + // ECMAScript early error 14.20 admits — the named allowance (its scope + // spans both layouts; helpers/mdx-derivability.ts). + source: stagedMdx(`T4.5-8 specs/COL.mdx with ${arm.name}`, source, { + allowances: ["duplicate-import-binding"], + }), + importWindow: byteWindow("", T4_5_8_MDX_IMPORT), + declaratorWindow: byteWindow( + `${exportPrefix}export const `, + T4_5_8_MDX_DECLARATOR, + ), + exportWindow: byteWindow(exportPrefix, T4_5_8_MDX_EXPORT), + dReferenceRange: exact(dPrefix, T4_5_8_MDX_D_REFERENCE), + embeddingRange: exact(bodyPrefix, T4_5_8_MDX_EMBEDDING), + }; +} + +/** One layout paired with its staging. */ +interface SpecSourceCollisionStaging { + readonly arm: SpecSourceCollisionArm; + readonly staged: StagedSpecSourceCollision; +} + +// The two layouts staged once, at module load, in table order — a record +// registers at module level only (S-9), so the body indexes this table. +const T4_5_8_SPEC_SOURCE_STAGINGS: readonly SpecSourceCollisionStaging[] = + T4_5_8_SPEC_SOURCE_ARMS.map((arm) => ({ + arm, + staged: stageSpecSourceCollision(arm), + })); + +/** + * The spec-source case's findings in 12.7 order: 14.5 at the `d` value's + * expression (the braces excluded), 14.6 at the embedding's full braced + * container, 14.15 locating the import declaration and the declarator + * `BASE = 1`, and 14.16 at the export statement whole (SPEC 14, 2.4, 2.7) + * — the same four whichever ESM block holds the export (T14-12: a finding + * in a well-formed file, never a parse failure). + */ +function assertSpecSourceCollisionFindings( + findings: readonly Finding[], + staged: StagedSpecSourceCollision, + context: string, +): void { + const file = "specs/COL.mdx"; + assertSameJson( + findings.map((finding) => finding.condition), + ["14.5", "14.6", "14.15", "14.16"], + `${context}: exactly the unresolved \`d\` reference (14.5), the ` + + `unresolved embedding (14.6), the collision (14.15), and the export ` + + `statement's invalidity (14.16), in 12.7 order — never 14.20, the ` + + `file being well-formed (SPEC 2.4, 2.1, 2.7, 14, 14.20)`, + ); + assertSpellingFinding( + findings[0]!, + file, + staged.dReferenceRange, + `${context}: the 14.5 locates the \`d\` value's expression, the braces ` + + `excluded (SPEC 14, 5.7)`, + ); + assertSpellingFinding( + findings[1]!, + file, + staged.embeddingRange, + `${context}: the 14.6 locates the embedding's full braced container, ` + + `opening brace through closing brace (SPEC 14, 5.7)`, + ); + assertFindingLocatesExactly( + findings[2]!, + [ + { file, window: staged.importWindow }, + { file, window: staged.declaratorWindow }, + ], + `${context}: the 14.15 locates the import by its own characters and ` + + `the declarator the export statement holds by its own characters ` + + `(SPEC 14, 1.7, 2.4, 2.1)`, + ); + assertFindingLocatesExactly( + findings[3]!, + [{ file, window: staged.exportWindow }], + `${context}: the 14.16 locates the export statement whole (SPEC 14, 2.7)`, + ); +} + +/** + * One spec-source arm: `build` and `check` report the four findings, exit + * 1; the gated `query edges --from specs/COL.mdx#c` reports them without + * answering (SPEC 13.3); and `occurrences --file specs/COL.mdx` carries + * them and lists no record — no edge and no occurrence for the spellings + * rooted at the collided identifier (5.7, 11.2). The staged record names + * S-9's `duplicate-import-binding` allowance: the export declaration binding + * the import's identifier is the early error the stock parser raises for one + * module, which every ESM block of a file is to it, so both layouts need + * it — and 14.20 admits both (a finding in a well-formed file, T14-12). + */ +async function assertSpecSourceCollision( + product: ProductBinding, + staging: SpecSourceCollisionStaging, +): Promise<void> { + const { arm, staged } = staging; + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_5_8_BASE_FILES, "specs/COL.mdx": staged.source }, + async (workspace) => { + const buildContext = `T4.5-8 \`build --json\` over the spec source declaring its import binding, ${arm.name}`; + assertSpecSourceCollisionFindings( + await buildFindings(product, workspace, buildContext), + staged, + buildContext, + ); + + const checkContext = `T4.5-8 \`check --json\` over the spec source declaring its import binding, ${arm.name}`; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations and ` + + `exits 1 on the findings (SPEC 12.2, 2.4)`, + ); + assertSpecSourceCollisionFindings( + decodeFindingsReport( + parseJsonStdout(checkResult, checkContext), + checkContext, + ).findings.filter((finding) => finding.condition !== "14.10"), + staged, + checkContext, + ); + + const queryContext = `T4.5-8 \`query edges --from specs/COL.mdx#c\` on the failing workspace, ${arm.name}`; + const queryResult = await expectExit( + product, + workspace, + ["query", "edges", "--from", "specs/COL.mdx#c"], + 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)`, + ); + assertSpecSourceCollisionFindings( + decodeFindingsReport( + parseJsonStdout(queryResult, queryContext), + `${queryContext} — a refusing read's report is the findings-only ` + + `document {"findings": […]}: no edge answered (SPEC 12.7, 13.3)`, + ).findings, + staged, + queryContext, + ); + + const occContext = `T4.5-8 \`occurrences --file specs/COL.mdx\` on the failing workspace, ${arm.name}`; + const occResult = await expectExit( + product, + workspace, + ["occurrences", "--file", "specs/COL.mdx"], + 1, + `${occContext} — the answer carries the domain's findings, so exit ` + + `1 with the full answer document (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertSpecSourceCollisionFindings( + report.findings, + staged, + `${occContext}: the spec source's findings accompany the answer ` + + `(SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + [], + `${occContext}: no record for the \`d\` reference or the embedding ` + + `rooted at the collided identifier — no edge, no occurrence, each ` + + `positioned by its finding's range alone (SPEC 2.4, 5.7, 11.2)`, + ); + }, + ); +} + +const T4_5_8 = defineProductTest({ + id: "T4.5-8", + title: + "same-scope collisions: an identifier the spec module import binds that a module-scope `const`, `using`, `await using`, `function`, `class`, `enum`, or value-binding `namespace` declaration also binds at value level roots no resolving chain — `build` and `check` report the condition-15 collision, locating the import by its own characters and the non-import by the construct binding the name, byte-exact (the declarator `SPEC = 1`, the `const` statement excluded; `SPEC = f()` for `using SPEC = f()` and `await using SPEC = f()`, the `using` or `await using` excluded; the further forms `let SPEC;` at `SPEC` alone, `const { SPEC } = o` at `{ SPEC } = o`, `@dec class SPEC {}` from its `@`, and `export class SPEC {}` / `export function SPEC() {}` from `class` / `function`, the `export` excluded), beside condition 7 for the marker `SPEC.a` and the call `text(SPEC.b)`, each at the span its occurrence would occupy, exit 1; the gated `query edges` reports no edge from the file and `occurrences` no record for the spellings; type-level `interface`, `type`, and value-free `namespace` declarations collide with nothing — edges recorded, no finding, exit 0; and a spec source holding `export const BASE = 1` beside `import BASE` reports 14.16 for the export statement, 14.15 locating the import and the declarator, and 14.5/14.6 for the `d` and `text(...)` spellings rooted at `BASE`, no edge and no occurrence recorded for them — the two declarations in one ESM block and, a second arm, across two blocks, each a finding in a well-formed file, never 14.20 (SPEC 2.4, 4.5, 2.1, 2.7, 5.7, 11.2, 12.7, 13.3, 14, 14.15, 14.20)", + run: async (product) => { + for (const staging of T4_5_8_COLLIDING_STAGINGS) { + await assertSameScopeCollisionArm(product, staging); + } + for (const staging of T4_5_8_CONTROL_STAGINGS) { + await assertSameScopeControlArm(product, staging); + } + for (const staging of T4_5_8_SPEC_SOURCE_STAGINGS) { + await assertSpecSourceCollision(product, staging); + } + }, +}); + +// --------------------------------------------------------------------------- +// T4.5-9 — a call through a colliding `text` identifier is no spec module's +// `text` call: no edge, no occurrence, no condition of a `text` call, while +// the spec binding or node its argument spells is used outside the +// sanctioned uses (14.18), beside the collision (14.15) +// --------------------------------------------------------------------------- + +// Two spec modules each holding a node `a` (the second for the cross-module +// argument), and a non-spec module exporting `text` so the `./t` imports +// name an existing module (a consumer-side matter, SPEC 6.4). +// Every cell's workspace past the first is created after a product +// invocation: the two spec sources and the non-spec module are +// staged-source records (S-9). +const T4_5_9_FILES = { + "specs/A.mdx": stagedMdx( + "T4.5-9 specs/A.mdx", + '<S id="a">\nAlpha behavior.\n</S>\n', + ), + "specs/B.mdx": stagedMdx( + "T4.5-9 specs/B.mdx", + '<S id="a">\nBravo behavior.\n</S>\n', + ), + "src/t.ts": stagedTs( + "T4.5-9 src/t.ts — the non-spec module exporting `text`", + "export function text(value: unknown): string {\n return String(value);\n}\n", + ), +} as const; + +// The spec import binding `SPEC` and `text` — the `text` every arm's +// colliding declaration also binds (SPEC 4.5, 2.4). +const T4_5_9_IMPORT = 'import SPEC, { text } from "../specs/A.xspec";'; +// The second module's default binding for the cross-module argument, staged +// where the colliding declaration does not bind it already (SPEC 4.4). +const T4_5_9_B_IMPORT = 'import B from "../specs/B.xspec";'; + +/** One module-scope declaration binding `text` beside the spec import. */ +interface CollidingTextArm { + /** Which colliding form this is (failure diagnostics). */ + readonly name: string; + /** The declaration's line of `src/app.ts`, exactly (pure ASCII). */ + readonly line: string; + /** + * The construct a condition-15 finding locates (SPEC 14): an import + * declaration by its own characters, a function declaration whole, or a + * variable declarator — exactly as it occurs within `line`. + */ + readonly construct: string; + /** Whether `line` itself binds `B` to the second spec module. */ + readonly bindsB: boolean; +} + +const T4_5_9_COLLIDING_ARMS: readonly CollidingTextArm[] = [ + { + name: 'a second, non-spec import binding `text` (`import { text } from "./t"`)', + line: 'import { text } from "./t";', + construct: 'import { text } from "./t";', + bindsB: false, + }, + { + name: 'a second spec module\'s `text` (`import B, { text } from "../specs/B.xspec"`)', + line: 'import B, { text } from "../specs/B.xspec";', + construct: 'import B, { text } from "../specs/B.xspec";', + bindsB: true, + }, + { + name: 'a type-only import (`import type { text } from "./t"`)', + line: 'import type { text } from "./t";', + construct: 'import type { text } from "./t";', + bindsB: false, + }, + { + name: "a function declaration `function text(x: unknown) {}`", + line: "function text(x: unknown) {}", + construct: "function text(x: unknown) {}", + bindsB: false, + }, + { + name: 'a variable declaration `const text = (x: unknown) => ""`', + line: 'const text = (x: unknown) => "";', + construct: 'text = (x: unknown) => ""', + bindsB: false, + }, +]; + +/** One argument of the call through the colliding `text` (SPEC 4.5). */ +interface CollidingCallArgument { + /** Which argument this is (failure diagnostics). */ + readonly name: string; + /** The argument's characters, exactly (pure ASCII). */ + readonly spelling: string; + /** + * Whether the argument spells a spec module node — used there outside the + * sanctioned uses, one 14.18 at its whole static chain (14) — or a string + * literal spelling no binding or node (no 14.18, and no 14.8). + */ + readonly spellsNode: boolean; + /** Whether the argument spells the second module's node, needing `B` bound. */ + readonly needsB: boolean; +} + +const T4_5_9_ARGUMENTS: readonly CollidingCallArgument[] = [ + { + name: "the imported module's node `SPEC.a`", + spelling: "SPEC.a", + spellsNode: true, + needsB: false, + }, + { + name: 'the string literal `"x"`', + spelling: '"x"', + spellsNode: false, + needsB: false, + }, + { + name: "a valid second module's node `B.a`", + spelling: "B.a", + spellsNode: true, + needsB: true, + }, +]; + +/** A staged `src/app.ts` for one cell and its constructs' byte positions. */ +interface StagedCollidingTextCall { + /** + * The whole file — the imports and the declaration, a blank line, the + * call — as a staged-source record (registered at module load, so the + * cells are staged once, into `T4_5_9_CELLS`: every cell's workspace past + * the body's first is created after a product invocation, S-9's timing + * clause). + */ + readonly source: StagedTs; + /** The spec import's end-widened byte window (support.ts byteWindow). */ + readonly importWindow: { readonly start: number; readonly end: number }; + /** The colliding construct's end-widened byte window. */ + readonly constructWindow: { readonly start: number; readonly end: number }; + /** The argument's static chain, exactly — the 14.18 range; none for the literal. */ + readonly argumentRange: + { readonly start: number; readonly end: number } | undefined; +} + +/** + * Lay out a cell's `src/app.ts` — the spec import, then `import B` where + * the argument needs it and the colliding declaration does not bind it, + * then the colliding declaration, a blank line, and the call statement — + * and fix every construct's byte position from the exact bytes (pure + * ASCII, so string indices are byte offsets). A construct missing from its + * line is a harness defect, never a product failure. It registers a + * record, so it runs at module load only. + */ +function stageCollidingTextCall( + arm: CollidingTextArm, + argument: CollidingCallArgument, +): StagedCollidingTextCall { + const constructAt = arm.line.indexOf(arm.construct); + if (constructAt === -1) { + throw new Error( + `T4.5-9 fixture broke: the located construct must occur within the ` + + `declaration line (${arm.name}) — fix the arm table in section-4.5.ts`, + ); + } + const bImport = argument.needsB && !arm.bindsB ? `${T4_5_9_B_IMPORT}\n` : ""; + const declarationPrefix = `${T4_5_9_IMPORT}\n${bImport}`; + const argumentPrefix = `${declarationPrefix}${arm.line}\n\ntext(`; + const source = `${argumentPrefix}${argument.spelling});\n`; + const argumentStart = Buffer.byteLength(argumentPrefix, "utf8"); + return { + source: stagedTs( + `T4.5-9 src/app.ts beside ${arm.name}, the argument ${argument.name}`, + source, + ), + importWindow: byteWindow("", T4_5_9_IMPORT), + constructWindow: byteWindow( + declarationPrefix + arm.line.slice(0, constructAt), + arm.construct, + ), + argumentRange: argument.spellsNode + ? { + start: argumentStart, + end: argumentStart + Buffer.byteLength(argument.spelling, "utf8"), + } + : undefined, + }; +} + +/** One cell — a colliding declaration and a call argument — and its staging. */ +interface CollidingTextCallCell { + readonly arm: CollidingTextArm; + readonly argument: CollidingCallArgument; + readonly staged: StagedCollidingTextCall; +} + +// The cells staged once, at module load, in run order (each colliding +// declaration with each argument) — a record registers at module level only +// (S-9), so the body iterates this table. +const T4_5_9_CELLS: readonly CollidingTextCallCell[] = + T4_5_9_COLLIDING_ARMS.flatMap((arm) => + T4_5_9_ARGUMENTS.map((argument) => ({ + arm, + argument, + staged: stageCollidingTextCall(arm, argument), + })), + ); + +/** + * Assert the findings a cell's workspace reports, in 12.7 order: the one + * condition-15 collision locating the spec import and the colliding + * declaration (each within its own byte window, nothing else), then — for + * an argument spelling a node — one 14.18 byte-exact at the argument's + * whole static chain; no condition of a `text` call (14.6, 14.7, 14.8, + * 14.11) beside them, the call being no spec module's (SPEC 4.5, 14). + */ +function assertCollidingTextCallFindings( + findings: readonly Finding[], + staged: StagedCollidingTextCall, + argument: CollidingCallArgument, + context: string, +): void { + assertSameJson( + findings.map((finding) => finding.condition), + argument.spellsNode ? ["14.15", "14.18"] : ["14.15"], + `${context}: exactly the collision (condition 15)` + + (argument.spellsNode + ? ` beside the unsupported use of the node the argument spells ` + + `(condition 18)` + : `, the argument spelling no binding or node`) + + `, in 12.7 order — no 14.6, 14.7, 14.8, or 14.11: a call through a ` + + `colliding \`text\` identifier is no spec module's \`text\` call ` + + `(SPEC 4.5, 2.4, 14.15, 14.18)`, + ); + assertFindingLocatesExactly( + findings[0]!, + [ + { file: "src/app.ts", window: staged.importWindow }, + { file: "src/app.ts", window: staged.constructWindow }, + ], + `${context}: the 14.15 locates both declarations binding \`text\` — ` + + `the spec import and the colliding declaration, each by the ` + + `construct binding the name (SPEC 14, 1.7, 2.4)`, + ); + if (staged.argumentRange !== undefined) { + assertSpellingFinding( + findings[1]!, + "src/app.ts", + staged.argumentRange, + `${context}: the 14.18 locates the binding's identifier extended by ` + + `its longest static chain — the argument \`${argument.spelling}\` ` + + `exactly (SPEC 14, 14.18)`, + ); + } +} + +/** + * One cell: `build` and `check` report the cell's findings, exit 1, and + * `occurrences --file src/app.ts`, answering on the failing workspace + * (11.2), carries them and lists no record — the call records no edge and + * no occurrence (SPEC 4.5, 5.7, T5.7-4). + */ +async function assertCollidingTextCallCell( + product: ProductBinding, + { arm, argument, staged }: CollidingTextCallCell, +): Promise<void> { + const cell = `${arm.name}, the argument ${argument.name}`; + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_5_9_FILES, "src/app.ts": staged.source }, + async (workspace) => { + const buildContext = `T4.5-9 \`build --json\` beside ${cell}`; + assertCollidingTextCallFindings( + await buildFindings(product, workspace, buildContext), + staged, + argument, + buildContext, + ); + + // `check`: the same validation, exit 1 (SPEC 12.2); the never-built + // workspace's staleness is 14.10's own business — set aside. + const checkContext = `T4.5-9 \`check --json\` beside ${cell}`; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations and ` + + `exits 1 on the findings (SPEC 12.2, 4.5)`, + ); + assertCollidingTextCallFindings( + decodeFindingsReport( + parseJsonStdout(checkResult, checkContext), + checkContext, + ).findings.filter((finding) => finding.condition !== "14.10"), + staged, + argument, + checkContext, + ); + + // `occurrences --file src/app.ts`, answering on the failing workspace + // (SPEC 11.2): the code file's findings accompany (exit 1, the full + // answer still emitted), and the record set is empty — the call + // records no edge and so no occurrence (5.7). + const occContext = `T4.5-9 \`occurrences --file src/app.ts\` beside ${cell}`; + const occResult = await expectExit( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + 1, + `${occContext} — the answer carries the domain's findings, so exit ` + + `1 with the full answer document (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertCollidingTextCallFindings( + report.findings, + staged, + argument, + `${occContext}: the code file's findings accompany the answer ` + + `(SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + [], + `${occContext}: no record for the call — a call through a ` + + `colliding \`text\` identifier is no spec module's \`text\` call, ` + + `recording no edge and no occurrence, its argument positioned by ` + + `the 14.18 range alone (SPEC 4.5, 5.7, 11.2)`, + ); + }, + ); +} + +// The control: a type alias `type text = number` beside the import collides +// with nothing (SPEC 2.4), so `text(SPEC.a)` is the spec module's `text` +// call — its `embeds` edge and occurrence recorded, no finding (4.3, 5.7). +const T4_5_9_CONTROL_LINE = "type text = number;"; +const T4_5_9_CONTROL_CALL = "text(SPEC.a)"; +// The control's workspace is created after the body's invocations: its +// `src/app.ts` is a staged-source record (S-9). +const T4_5_9_CONTROL_CALL_PREFIX = `${T4_5_9_IMPORT}\n${T4_5_9_CONTROL_LINE}\n\n`; +const T4_5_9_CONTROL_SOURCE = stagedTs( + "T4.5-9 src/app.ts beside the type alias `type text = number` (the control)", + `${T4_5_9_CONTROL_CALL_PREFIX}${T4_5_9_CONTROL_CALL};\n`, +); + +async function assertCollidingTextControl( + product: ProductBinding, +): Promise<void> { + const callPrefix = T4_5_9_CONTROL_CALL_PREFIX; + const source = T4_5_9_CONTROL_SOURCE; + const callStart = Buffer.byteLength(callPrefix, "utf8"); + const callEnd = callStart + Buffer.byteLength(T4_5_9_CONTROL_CALL, "utf8"); + // The record's own range spans the call, callee through closing + // parenthesis, the terminator excluded; its source is the whole-file + // location, no named unit enclosing it (SPEC 5.7, 4.6, 1.7). + const expectedRecords: readonly (readonly unknown[])[] = [ + [ + "src/app.ts", + callStart, + callEnd, + "embeds", + ["src/app.ts", 0, Buffer.byteLength(source.source, "utf8")], + "specs/A.mdx#a", + ], + ]; + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_5_9_FILES, "src/app.ts": source }, + async (workspace) => { + await buildOk( + product, + workspace, + "T4.5-9 `build` beside the type alias `type text = number` — a " + + "type-level declaration of the module scope collides with " + + "nothing (SPEC 2.4, 4.5)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T4.5-9 `check` beside the type alias `type text = number` — no " + + "finding (SPEC 2.4, 4.5)", + ); + assertEdgeSetEqual( + await queryEdgesFrom(product, workspace, "src/app.ts", "T4.5-9"), + [{ from: "src/app.ts", to: "specs/A.mdx#a", kind: "embeds" }], + "T4.5-9 beside the type alias: the import roots the call — its " + + "`embeds` edge is the file's complete outgoing edge set (SPEC " + + "2.4, 4.5, 4.3, 4.6)", + ); + const occContext = + "T4.5-9 `occurrences --file src/app.ts` beside the type alias `type text = number`"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + `${occContext} — a clean domain answers with no finding, exit 0 ` + + `(SPEC 11.2, 11.3)`, + ), + occContext, + ); + assertConditionCounts( + report.findings, + {}, + `${occContext}: no finding accompanies the answer (SPEC 2.4, 11.2)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + expectedRecords, + `${occContext}: exactly the call's \`embeds\` record — file, own ` + + `range (callee through closing parenthesis), kind, whole-file ` + + `source, target (SPEC 5.7, 11.3, 4.3)`, + ); + }, + ); +} + +const T4_5_9 = defineProductTest({ + id: "T4.5-9", + title: + 'call through a colliding `text` identifier: a call whose callee `text` the spec import and a second non-spec import, a second spec module\'s `text` import, a type-only import, a `function text(x: unknown) {}`, or a `const text = (x: unknown) => ""` of the same module scope also bind is no spec module\'s `text` call — `build` and `check` report 14.15 locating both declarations and, for `text(SPEC.a)`, 14.18 at `SPEC.a` (the identifier extended by its longest static chain), exit 1, no 14.6, 14.7, 14.8, or 14.11; `text("x")` beside the same collision reports no 14.8 and no 14.18; `text(B.a)`, `B` a valid second module\'s default binding, reports 14.18 at `B.a`, never 14.11; no edge and no occurrence — `occurrences --file` on the failing workspace lists none beside the findings; and the type alias `type text = number` beside the import collides with nothing, the call recording its `embeds` edge and occurrence, no finding (SPEC 4.5, 2.4, 5.7, 11.2, 14, 14.15, 14.18)', + run: async (product) => { + for (const cell of T4_5_9_CELLS) { + await assertCollidingTextCallCell(product, cell); + } + await assertCollidingTextControl(product); + }, +}); + /** TEST-SPEC §4.5, in canonical ID order (SUITE-15). */ export const section45Tests: readonly ProductTestEntry[] = [ T4_5_1, @@ -886,4 +2911,6 @@ export const section45Tests: readonly ProductTestEntry[] = [ T4_5_5, T4_5_6, T4_5_7, + T4_5_8, + T4_5_9, ]; diff --git a/test/suite/registry/section-4.6.ts b/test/suite/registry/section-4.6.ts index 6ca444f9..e525dbad 100644 --- a/test/suite/registry/section-4.6.ts +++ b/test/suite/registry/section-4.6.ts @@ -23,6 +23,43 @@ // position, targeting the same section — so the two set-equality // assertions accept only a product attributing both forms to the table's // one unit per placement. +// - T4.6-1 constructor, legacy `module`, and decorated arms: the constructor +// is a member of the matrix's class (`Service.constructor`); `module +// legacy` and `module P.Q` are staged beside `namespace ns` and `namespace +// A.B` under fresh names, so each chain occurs once (no `@N`); `dec` is a +// plain function declared in the file (itself a unit, `path#dec`, +// recording no edge). Only attribution is observed here — the decorated +// declarations' ranges are T1.7-2's. +// - T4.6-1 `using` and `await using` arms: staged under the names TEST-SPEC +// spells (`using f` at top level, `await using h` inside an async function +// `g`), each initialized with an arrow function holding the placement's +// marker and `text(...)` call; the module-scope `f` and the +// namespace-scoped `A.B.f` are distinct chains, so neither takes an `@N` +// suffix. The file +// stays accepted by TypeScript 5.9.3 both as module code and as script +// code (14.20), which the workspace builder's staging-time judge confirms +// (S-9). Their ranges are T1.7-2's. +// - T4.6-3 "value-side boundary … never to a unit named `s` (asserted via +// `query edges`)": the two value-side `text(...)` calls target dedicated +// sections, so the workspace's complete `embeds` edge set (`--kinds +// embeds`) pins each call's attributed unit — `path#f` and `path` — +// exactly; additionally every endpoint of the unfiltered `query edges` +// answer is swept for a unit chain spelled after either constant (`#s`, +// `#t`, the nested `#f.s`, and their `@N`-suffixed forms), so a product +// that made a plain-identifier constant a named unit fails on the whole +// answer, not only on the `embeds` set. +// - T4.6-3 "binds no unit" (wrappers, default exports of non-constructs, +// body-less and `declare` declarations, the escape-spelled name, the +// declaration files): `query nodes` lists requirement nodes only (SPEC +// 11.1), so a unit's non-existence is observed through attribution — +// every marker targets a dedicated section, so the complete `references` +// set pins the file (or the enclosing named unit) as its source, and the +// unfiltered endpoint sweep additionally rejects any identity spelled +// after a forbidden name (`#w1`, `#default`, `#foo`, a `.d.ts` file's +// `#f`) or `@N`-suffixed in the body-less file. The escape spelling is +// composed from the backslash's code point (`BACKSLASH`), never written +// as a six-character sequence in this source (the tool-parameter layer +// would decode it), and the fixture is byte-checked for it. // - T4.6-4 "coverage boundary membership": SPEC 8 covers a target when a // permitted path exists from a boundary node to it, and 8.2 reports one // shortest covering path as a node-identity sequence (12.0) — from the @@ -38,12 +75,17 @@ import { } from "../../helpers/adapters/index.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { defineProductTest } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertEdgeSetEqual, assertSameJson, buildOk, + expectFindingFreeReport, runJson, } from "./support.js"; @@ -64,8 +106,8 @@ export default defineConfig({ /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -116,6 +158,19 @@ async function queryEdgesFrom( ); } +/** Unfiltered workspace-wide `query edges` — every kind, decoded (SPEC 11.1). */ +async function queryAllEdges( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<readonly GraphEdge[]> { + const label = `${context} \`query edges\``; + return decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], label), + label, + ); +} + // --------------------------------------------------------------------------- // T4.6-1 — the attribution matrix over all named-unit forms // --------------------------------------------------------------------------- @@ -166,6 +221,12 @@ const T4_6_1_PLACEMENTS: readonly AttributionPlacement[] = [ target: "plainprop", forms: "text-only", }, + { + name: "inside a constructor (a unit named `constructor`: `Service.constructor`)", + unit: "src/app.ts#Service.constructor", + target: "ctor", + forms: "both", + }, { name: "a class member property initialized with an arrow function", unit: "src/app.ts#Service.arrowProp", @@ -224,6 +285,24 @@ const T4_6_1_PLACEMENTS: readonly AttributionPlacement[] = [ target: "varclass", forms: "both", }, + { + name: + "a `using` declaration initialized with an arrow function, at top " + + "level (`using f = () => { ... }` — SPEC 4.6 counts it as a variable " + + "declaration)", + unit: "src/app.ts#f", + target: "usingdecl", + forms: "both", + }, + { + name: + "an `await using` declaration initialized with an arrow function " + + "inside an async function `g` (`await using h = () => { ... }` — a " + + "variable declaration too; nested chain g.h)", + unit: "src/app.ts#g.h", + target: "awaitusing", + forms: "both", + }, { name: "directly inside a namespace", unit: "src/app.ts#ns", @@ -250,6 +329,44 @@ const T4_6_1_PLACEMENTS: readonly AttributionPlacement[] = [ target: "dottedfn", forms: "both", }, + { + name: + "directly inside a legacy `module X` namespace (the same declaration " + + "as `namespace X`: the unit named exactly as `namespace X` derives)", + unit: "src/app.ts#legacy", + target: "legacy", + forms: "both", + }, + { + name: + "directly inside a legacy dotted `module P.Q` (as `namespace P.Q`: one " + + "unit per dot-separated name)", + unit: "src/app.ts#P.Q", + target: "legacydotted", + forms: "both", + }, + { + name: + "inside a decorated method of a decorated class (`@dec class Deco { " + + "@dec m() { ... } }`, `dec` declared in the file)", + unit: "src/app.ts#Deco.m", + target: "decomethod", + forms: "both", + }, + { + name: "in a decorated class's static block (bare class unit)", + unit: "src/app.ts#Deco", + target: "decostatic", + forms: "marker-only", + }, + { + name: + "inside a method of an exported decorated class (`export @dec class " + + "DecoExport { m() { ... } }`)", + unit: "src/app.ts#DecoExport.m", + target: "expdeco", + forms: "both", + }, { name: "inside a named default export", unit: "src/named.ts#namedDefault", @@ -283,6 +400,11 @@ const T4_6_1_APP_SOURCE = [ " SPEC.staticblock;", " }", "", + " constructor() {", + " SPEC.ctor;", + " text(SPEC.ctor);", + " }", + "", " plainProp = text(SPEC.plainprop);", "", " arrowProp = () => {", @@ -337,6 +459,18 @@ const T4_6_1_APP_SOURCE = [ " }", "};", "", + "using f = () => {", + " SPEC.usingdecl;", + " text(SPEC.usingdecl);", + "};", + "", + "async function g(): Promise<void> {", + " await using h = () => {", + " SPEC.awaitusing;", + " text(SPEC.awaitusing);", + " };", + "}", + "", "namespace ns {", " SPEC.ns;", " text(SPEC.ns);", @@ -357,6 +491,36 @@ const T4_6_1_APP_SOURCE = [ " }", "}", "", + "module legacy {", + " SPEC.legacy;", + " text(SPEC.legacy);", + "}", + "", + "module P.Q {", + " SPEC.legacydotted;", + " text(SPEC.legacydotted);", + "}", + "", + "function dec(_value: unknown, _context: unknown): void {}", + "", + "@dec class Deco {", + " static {", + " SPEC.decostatic;", + " }", + "", + " @dec m(): void {", + " SPEC.decomethod;", + " text(SPEC.decomethod);", + " }", + "}", + "", + "export @dec class DecoExport {", + " m(): void {", + " SPEC.expdeco;", + " text(SPEC.expdeco);", + " }", + "}", + "", ].join("\n"); // A file holds at most one default export, so the named-default arm lives in @@ -425,7 +589,7 @@ const T4_6_1_EXPECTED_EMBEDS: readonly GraphEdge[] = T4_6_1_PLACEMENTS.filter( const T4_6_1 = defineProductTest({ id: "T4.6-1", title: - "markers and `text(...)` calls attribute to the innermost enclosing named unit — file top level to the file; function declarations, class methods, getters, setters, function-, arrow-, and class-valued class properties and variables, namespaces, and a named default export to `path#unit` with the dot-joined chain outermost first (`Service.method`, `ns.fn`); a class static block and a plain non-function property initializer to the bare class unit; `namespace A.B` declares one unit per dot-separated name — with each placement's `references` and `embeds` edges sourced at the same unit (SPEC 4.6, 4.5, 4.3)", + "markers and `text(...)` calls attribute to the innermost enclosing named unit — file top level to the file; function declarations, class methods, a constructor (`Service.constructor`, a unit named `constructor`), getters, setters, function-, arrow-, and class-valued class properties and variables, `using` and `await using` declarations initialized with an arrow function (variable declarations: `using f` at top level to `f`, `await using h` in an async function `g` to `g.h`), namespaces — `namespace X` and the legacy `module X`, the same declaration — and a named default export to `path#unit` with the dot-joined chain outermost first (`Service.method`, `ns.fn`); a class static block and a plain non-function property initializer to the bare class unit; `namespace A.B` and the legacy `module P.Q` each declare one unit per dot-separated name; decorated declarations are units like undecorated ones (`dec` declared in the file: `@dec class Deco { @dec m() { ... } }` to `Deco.m`, its static block to the bare `Deco`, `export @dec class DecoExport { m() { ... } }` to `DecoExport.m`) — with each placement's `references` and `embeds` edges sourced at the same unit (SPEC 4.6, 4.5, 4.3)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -545,6 +709,15 @@ const T4_6_2 = defineProductTest({ // section, so the complete `references` edge set pins each arm's attribution // — in particular, an edge sourced at a `#`-named unit such as // `src/app.ts#Carrier.#priv` fails the equality. +// +// Value-side boundary (SPEC 4.6: a variable declaration is a named unit only +// when its initializer is a function expression, an arrow function, or a +// class expression): `const s = text(SPEC.valfn)` inside `f` and +// `const t = text(SPEC.valtop)` at top level bind the calls' returned +// strings — no node and no `text` binding is stored, so 4.5 is untouched and +// `build` succeeds — and each call's `embeds` edge (4.3, from the calling +// code location) attributes to the innermost enclosing named unit, +// `src/app.ts#f`, or to the file, never to a unit named after the constant. const T4_6_3_SPEC_SOURCE = [ '<S id="iife">', "Target for the top-level IIFE placement.", @@ -574,10 +747,74 @@ const T4_6_3_SPEC_SOURCE = [ "Target for the private-name class member.", "</S>", "", + '<S id="valfn">', + "Target for the value-side text call inside a named function.", + "</S>", + "", + '<S id="valtop">', + "Target for the value-side text call at file top level.", + "</S>", + "", + '<S id="wrapparen">', + "Target for the parenthesized arrow initializer.", + "</S>", + "", + '<S id="wrapas">', + "Target for the as-cast arrow initializer.", + "</S>", + "", + '<S id="wrapsat">', + "Target for the satisfies-qualified function-expression initializer.", + "</S>", + "", + '<S id="wrapnn">', + "Target for the non-null-asserted arrow initializer.", + "</S>", + "", + '<S id="wraphosted">', + "Target for the wrapped initializer inside a named function.", + "</S>", + "", + '<S id="dobj">', + "Target for the arrow held by a default-exported object literal.", + "</S>", + "", + '<S id="dident">', + "Target for the arrow-valued variable exported by identifier.", + "</S>", + "", + '<S id="dcall">', + "Target for the arrow argument of a default-exported call.", + "</S>", + "", + '<S id="dlit">', + "Target for the top-level marker beside a default-exported literal.", + "</S>", + "", + '<S id="overload">', + "Target for the function implementation behind two overloads.", + "</S>", + "", + '<S id="methodoverload">', + "Target for the method implementation behind two overloads.", + "</S>", + "", + '<S id="abstractgetter">', + "Target for the getter behind a sibling class's abstract getter.", + "</S>", + "", + '<S id="ambient">', + "Target for the method behind a declare class's body-less method.", + "</S>", + "", + '<S id="escaped">', + "Target for the function whose name is spelled with an escape.", + "</S>", + "", ].join("\n"); const T4_6_3_APP_SOURCE = [ - 'import SPEC from "../specs/N.xspec";', + 'import SPEC, { text } from "../specs/N.xspec";', "", "(function () {", " SPEC.iife;", @@ -615,18 +852,396 @@ const T4_6_3_APP_SOURCE = [ " }", "}", "", + "function f(): void {", + " const s = text(SPEC.valfn);", + "}", + "", + "const t = text(SPEC.valtop);", + "", +].join("\n"); + +// Wrappers and value forms (SPEC 4.6: an initializer that merely wraps a +// named form — parenthesized, `as`-cast, `satisfies`-qualified, or non-null- +// asserted — is another expression and binds no unit): four top-level +// constants whose markers attribute to the file, and one inside `wrapHost` +// whose marker attributes to that enclosing named unit — never to `#w1` … +// `#w5`. +const T4_6_3_WRAP_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + "const w1 = (() => {", + " SPEC.wrapparen;", + "});", + "", + "const w2 = ((): void => {", + " SPEC.wrapas;", + "}) as () => void;", + "", + "const w3 = (function () {", + " SPEC.wrapsat;", + "}) satisfies () => void;", + "", + "const w4 = (() => {", + " SPEC.wrapnn;", + "})!;", + "", + "function wrapHost(): void {", + " const w5 = ((): void => {", + " SPEC.wraphosted;", + " }) as () => void;", + "}", + "", +].join("\n"); + +// Default exports of an object literal, an identifier, a call, and a literal +// value bind no unit (SPEC 4.6) — a file holds at most one default export, so +// each form has its own file. The marker inside the arrow held by the object +// literal's property attributes to the file (an object-literal property is no +// class member); the arrow argument of the exported call is anonymous and +// nothing named encloses it — the file; the identifier form's marker sits in +// the arrow-valued variable `impl`, a named unit in its own right, so the +// export adds no `default` unit above or beside it; the literal form can hold +// no marker, so its file carries a top-level one. A product making any of +// these a `default` unit sources an edge at `path#default`, failing the set. +const T4_6_3_DEFAULT_OBJECT_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + "export default {", + " run: () => {", + " SPEC.dobj;", + " },", + "};", + "", +].join("\n"); + +const T4_6_3_DEFAULT_IDENTIFIER_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + "const impl = (): void => {", + " SPEC.dident;", + "};", + "", + "export default impl;", + "", +].join("\n"); + +const T4_6_3_DEFAULT_CALL_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + "function wrap(fn: () => void): () => void {", + " return fn;", + "}", + "", + "export default wrap(() => {", + " SPEC.dcall;", + "});", + "", +].join("\n"); + +const T4_6_3_DEFAULT_LITERAL_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + "SPEC.dlit;", + "", + "export default 42;", + "", +].join("\n"); + +// Declarations binding no executable code — overload signatures, body-less +// method signatures, abstract members, and `declare` ambient declarations — +// are no units and occupy no document-order slot (SPEC 4.6): the function +// implementation behind two overloads is `#f`, never `#f@3`; the method +// implementation behind two overloads is `#M.m`; with two block-scoped +// classes `C` in sibling blocks, the first abstract with `abstract get v()`, +// the second's getter is `#C.v`, never `#C.v@2` (the chain `C.v` occurs once +// — the abstract getter takes no slot); and the method of the block-scoped +// class `Amb` behind a top-level `declare class Amb` with a body-less `m` is +// `#Amb.m`, never `#Amb.m@2`. +const T4_6_3_DECL_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + "function f(a: string): void;", + "function f(a: number): void;", + "function f(a: string | number): void {", + " SPEC.overload;", + " void a;", + "}", + "", + "class M {", + " m(a: string): void;", + " m(a: number): void;", + " m(a: string | number): void {", + " SPEC.methodoverload;", + " void a;", + " }", + "}", + "", + "{", + " abstract class C {", + " abstract get v(): number;", + " }", + "}", + "", + "{", + " class C {", + " get v(): number {", + " SPEC.abstractgetter;", + " return 1;", + " }", + " }", + "}", + "", + "declare class Amb {", + " m(): void;", + "}", + "", + "{", + " class Amb {", + " m(): void {", + " SPEC.ambient;", + " }", + " }", + "}", + "", ].join("\n"); +/** The backslash, built from its code point (see the module header). */ +const BACKSLASH = String.fromCodePoint(0x5c); + +/** The escape-spelled function name: `f`, the six-character escape of `o`, `o`. */ +const T4_6_3_ESCAPED_NAME = `f${BACKSLASH}u006Fo`; + +// A unit name spelled with an escape sequence binds no unit (SPEC 4.6, 2.4: +// read as spelled, never interpreted to `foo`), so the marker attributes to +// the file — never to `#foo`, nor to a unit spelled after the escape. +const T4_6_3_ESCAPED_SOURCE = [ + 'import SPEC from "../specs/N.xspec";', + "", + `function ${T4_6_3_ESCAPED_NAME}(): void {`, + " SPEC.escaped;", + "}", + "", +].join("\n"); + +/** The edges the wrapper, default-export, body-less, and escape arms owe. */ +const T4_6_3_FORM_REFERENCES: readonly GraphEdge[] = [ + { from: "src/wrap.ts", to: "specs/N.mdx#wrapparen", kind: "references" }, + { from: "src/wrap.ts", to: "specs/N.mdx#wrapas", kind: "references" }, + { from: "src/wrap.ts", to: "specs/N.mdx#wrapsat", kind: "references" }, + { from: "src/wrap.ts", to: "specs/N.mdx#wrapnn", kind: "references" }, + { + from: "src/wrap.ts#wrapHost", + to: "specs/N.mdx#wraphosted", + kind: "references", + }, + { from: "src/dobj.ts", to: "specs/N.mdx#dobj", kind: "references" }, + { from: "src/dident.ts#impl", to: "specs/N.mdx#dident", kind: "references" }, + { from: "src/dcall.ts", to: "specs/N.mdx#dcall", kind: "references" }, + { from: "src/dlit.ts", to: "specs/N.mdx#dlit", kind: "references" }, + { from: "src/decl.ts#f", to: "specs/N.mdx#overload", kind: "references" }, + { + from: "src/decl.ts#M.m", + to: "specs/N.mdx#methodoverload", + kind: "references", + }, + { + from: "src/decl.ts#C.v", + to: "specs/N.mdx#abstractgetter", + kind: "references", + }, + { from: "src/decl.ts#Amb.m", to: "specs/N.mdx#ambient", kind: "references" }, + { from: "src/esc.ts", to: "specs/N.mdx#escaped", kind: "references" }, +]; + +/** + * Per file, the unit-chain segments no named unit may be spelled after: the + * value-side constants `s` and `t` of `src/app.ts` (`src/app.ts#s`, the + * nested `src/app.ts#f.s`), the wrapper constants `w1` … `w5`, `default` and + * the object-literal property `run` in the four default-export files, and + * `foo` beside the escape spelling itself in `src/esc.ts` — each in its bare + * and `@N`-suffixed forms — the misattributions the arms forbid (SPEC 4.6). + */ +const T4_6_3_FORBIDDEN_SEGMENTS: Readonly<Record<string, readonly string[]>> = { + "src/app.ts": ["s", "t"], + "src/wrap.ts": ["w1", "w2", "w3", "w4", "w5"], + "src/dobj.ts": ["default", "run"], + "src/dident.ts": ["default"], + "src/dcall.ts": ["default"], + "src/dlit.ts": ["default"], + "src/esc.ts": ["foo", T4_6_3_ESCAPED_NAME], +}; + +/** + * Whether a graph-node identity names a forbidden code unit: a chain of a + * listed file with a segment spelled after one of its forbidden names, or + * any `@N`-suffixed chain of `src/decl.ts` — its body-less declarations + * occupy no document-order slot, so no unit there is disambiguated. + */ +function namesForbiddenUnit(identity: string): boolean { + const hash = identity.indexOf("#"); + if (hash < 0) return false; + const file = identity.slice(0, hash); + const chain = identity.slice(hash + 1); + if (file === "src/decl.ts") return /@\d+$/u.test(chain); + const forbidden = T4_6_3_FORBIDDEN_SEGMENTS[file]; + if (forbidden === undefined) return false; + return chain + .replace(/@\d+$/u, "") + .split(".") + .some((segment) => forbidden.includes(segment)); +} + +// Declaration files, ambient by kind (SPEC 4.6; TypeScript's file-name rule: +// a name ending in `.d.mts` or `.d.cts`, or in `.ts` with `.d.` earlier in +// its last path segment): `x.d.ts`, `x.d.mts`, `x.d.cts`, and `x.d.css.ts` +// are code-group sources under the group's three globs, each holding a +// body-bearing `function f` around a marker and a top-level marker. Every +// such file is well-formed (14.20: TypeScript's ambient-context checks are +// post-parse), so `build` and `check` exit 0 on the otherwise valid +// workspace; `f` is no unit, so both markers attribute to the whole file — +// never to `path#f` — while the control `x.dts.ts` (no `.d.` in its last +// segment) keeps `path#f`. Each marker targets its own section, so the +// complete `references` set pins every attribution. The occurrence's +// whole-file `source` range is T1.7-2's assertion. The arm's workspace is +// created after the body's first invocation, so its configuration and its +// five code files are staged-source records (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const T4_6_3_DECLARATION_FILE_CONFIG = stagedTs( + "T4.6-3 xspec.config.ts — a code group spanning `.ts`, `.mts`, and `.cts`, the declaration-file arm's", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts", "src/**/*.mts", "src/**/*.cts"] + } +}) +`, +); + +/** One code file of the declaration-file arm. */ +interface DeclarationFileArm { + readonly file: string; + /** The section the marker inside `f` targets. */ + readonly inner: string; + /** The section the top-level marker targets. */ + readonly top: string; + /** The inner marker's attributed location — the file, or `#f` for the control. */ + readonly innerUnit: string; +} + +const T4_6_3_DECLARATION_FILE_ARMS: readonly DeclarationFileArm[] = [ + { + file: "src/x.d.ts", + inner: "dtsfn", + top: "dtstop", + innerUnit: "src/x.d.ts", + }, + { + file: "src/x.d.mts", + inner: "dmtsfn", + top: "dmtstop", + innerUnit: "src/x.d.mts", + }, + { + file: "src/x.d.cts", + inner: "dctsfn", + top: "dctstop", + innerUnit: "src/x.d.cts", + }, + { + file: "src/x.d.css.ts", + inner: "dcssfn", + top: "dcsstop", + innerUnit: "src/x.d.css.ts", + }, + { + file: "src/x.dts.ts", + inner: "ctrlfn", + top: "ctrltop", + innerUnit: "src/x.dts.ts#f", + }, +]; + +/** The declaration-file arm's control: no `.d.` in its last path segment. */ +const T4_6_3_DECLARATION_CONTROL = "src/x.dts.ts"; + +// Staged in T4.6-3's second workspace, created after the first's invocations: +// a staged-source record (S-9, test/self/s9-staged-sources.test.ts). +const T4_6_3_DECLARATION_SPEC_SOURCE = stagedMdx( + "T4.6-3 specs/D.mdx of the declaration-file arm", + T4_6_3_DECLARATION_FILE_ARMS.flatMap((arm) => [ + `<S id="${arm.inner}">`, + `Target for the marker inside f of ${arm.file}.`, + "</S>", + "", + `<S id="${arm.top}">`, + `Target for the top-level marker of ${arm.file}.`, + "</S>", + "", + ]).join("\n"), +); + +/** The one shape every declaration-arm file takes (the control included). */ +function declarationFileSource(arm: DeclarationFileArm): string { + return [ + 'import SPEC from "../specs/D.xspec";', + "", + "function f(): void {", + ` SPEC.${arm.inner};`, + "}", + "", + `SPEC.${arm.top};`, + "", + ].join("\n"); +} + +// One record per declaration-arm file, laid out at module load (S-9). +const T4_6_3_DECLARATION_FILES: Readonly<Record<string, StagedTs>> = + Object.fromEntries( + T4_6_3_DECLARATION_FILE_ARMS.map((arm) => [ + arm.file, + stagedTs( + `T4.6-3 ${arm.file} of the declaration-file arm`, + declarationFileSource(arm), + ), + ]), + ); + +const T4_6_3_DECLARATION_REFERENCES: readonly GraphEdge[] = + T4_6_3_DECLARATION_FILE_ARMS.flatMap((arm): GraphEdge[] => [ + { from: arm.innerUnit, to: `specs/D.mdx#${arm.inner}`, kind: "references" }, + { from: arm.file, to: `specs/D.mdx#${arm.top}`, kind: "references" }, + ]); + +/** Whether an identity names a code unit of a declaration file (never one). */ +function namesDeclarationFileUnit(identity: string): boolean { + return T4_6_3_DECLARATION_FILE_ARMS.some( + (arm) => + arm.file !== T4_6_3_DECLARATION_CONTROL && + identity.startsWith(`${arm.file}#`), + ); +} + const T4_6_3 = defineProductTest({ id: "T4.6-3", title: - "markers inside constructs that are not named units — an IIFE, a function stored via destructuring, and computed-name, string-literal-name, numeric-literal-name (`123() {}`), and private (`#priv() {}`) class members — attribute to the nearest enclosing named unit or the file; the private-member arm attributes to the bare class unit, never to a `#`-named unit (SPEC 4.6)", + "markers inside constructs that are not named units — an IIFE, a function stored via destructuring, and computed-name, string-literal-name, numeric-literal-name (`123() {}`), and private (`#priv() {}`) class members — attribute to the nearest enclosing named unit or the file; the private-member arm attributes to the bare class unit, never to a `#`-named unit; value-side boundary: `const s = text(SPEC.a)` attributes to `path#f` inside `f` and to `path` at top level, never to a unit named after the constant; wrappers and value forms — a parenthesized, `as`-cast, `satisfies`-qualified, or non-null-asserted initializer — bind no unit (the file, or the enclosing named unit); a default export of an object literal, an identifier, a call, or a literal value binds no unit; overload signatures, body-less method signatures, abstract members, and `declare` ambient declarations are no units and occupy no document-order slot (`path#f`, never `path#f@3`; `path#C.m`; `path#C.v`, never `path#C.v@2`); an escape-spelled function name binds no unit (the file, never `path#foo`); declaration files ambient by kind (`x.d.ts`, `x.d.mts`, `x.d.cts`, `x.d.css.ts`) are well-formed, `build` and `check` exit 0, and both their markers attribute to the whole file, never to `path#f`, while the control `x.dts.ts` keeps `path#f` (SPEC 4.6, 2.4, 14.20)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, { "specs/N.mdx": T4_6_3_SPEC_SOURCE, "src/app.ts": T4_6_3_APP_SOURCE, + "src/wrap.ts": T4_6_3_WRAP_SOURCE, + "src/dobj.ts": T4_6_3_DEFAULT_OBJECT_SOURCE, + "src/dident.ts": T4_6_3_DEFAULT_IDENTIFIER_SOURCE, + "src/dcall.ts": T4_6_3_DEFAULT_CALL_SOURCE, + "src/dlit.ts": T4_6_3_DEFAULT_LITERAL_SOURCE, + "src/decl.ts": T4_6_3_DECL_SOURCE, + "src/esc.ts": T4_6_3_ESCAPED_SOURCE, }, async (workspace) => { await buildOk( @@ -677,12 +1292,117 @@ const T4_6_3 = defineProductTest({ to: "specs/N.mdx#priv", kind: "references", }, + // The wrapper, default-export, body-less/`declare`, and + // escape-spelled arms, one dedicated target each. + ...T4_6_3_FORM_REFERENCES, ], "T4.6-3 markers inside not-named-units attribute to the nearest " + "enclosing named unit or the file: the top-level IIFE and the " + "destructuring-stored function to the file, the nested IIFE to " + "its hosting function, and every oddly-named class member to " + - "the bare class unit — never to a `#`-named unit (SPEC 4.6)", + "the bare class unit — never to a `#`-named unit; a merely " + + "wrapping initializer (parenthesized, `as`, `satisfies`, `!`) " + + "binds no unit — the file, or the enclosing `wrapHost`; a " + + "default export of an object literal, an identifier, a call, or " + + "a literal binds no `default` unit; overload signatures, " + + "body-less methods, abstract members, and `declare` " + + "declarations are no units and take no document-order slot " + + "(`#f`, `#M.m`, `#C.v`, `#Amb.m` — never `@N`); and an " + + "escape-spelled function name binds no unit — the file, never " + + "`#foo` (SPEC 4.6, 2.4)", + ); + // Value-side boundary: the complete `embeds` set pins each call's + // attributed unit — the constant's enclosing function, or the file. + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", "T4.6-3"), + [ + { + from: "src/app.ts#f", + to: "specs/N.mdx#valfn", + kind: "embeds", + }, + { + from: "src/app.ts", + to: "specs/N.mdx#valtop", + kind: "embeds", + }, + ], + "T4.6-3 value-side boundary: `const s = text(SPEC.valfn)` inside " + + "`f` attributes its `embeds` edge to `src/app.ts#f`, and the " + + "top-level `const t = text(SPEC.valtop)` to the file " + + "`src/app.ts` — never to a unit named after the constant " + + "(SPEC 4.6; the stored value is the returned string, 4.5)", + ); + // … and no location spelled after either constant exists anywhere + // in the unfiltered edge enumeration — as a source or a target. + const allEdges = await queryAllEdges(product, workspace, "T4.6-3"); + assertSameJson( + allEdges + .flatMap((edge) => [edge.from, edge.to]) + .filter(namesForbiddenUnit) + .sort(), + [], + "T4.6-3: no endpoint of the unfiltered `query edges` answer names " + + "a forbidden code unit — one spelled after the plain-identifier " + + "constants `s` or `t` (`src/app.ts#s`, `src/app.ts#f.s`), the " + + "wrapper constants `w1` … `w5`, `default` or `run` in a " + + "default-export file, `foo` or the escape spelling itself in " + + "`src/esc.ts`, or any `@N`-suffixed chain of `src/decl.ts` — " + + "a variable whose initializer is a call or a mere wrapper, a " + + "default export of a non-construct, a body-less declaration, " + + "and an escape-spelled name are no named units (SPEC 4.6, 2.4)", + ); + }, + ); + // Declaration files, ambient by kind: a second workspace whose code group + // spans `.ts`, `.mts`, and `.cts` (SPEC 4.6, 14.20). + await withWorkspace( + T4_6_3_DECLARATION_FILE_CONFIG, + { + "specs/D.mdx": T4_6_3_DECLARATION_SPEC_SOURCE, + ...T4_6_3_DECLARATION_FILES, + }, + async (workspace) => { + const context = "T4.6-3 declaration files"; + await buildOk( + product, + workspace, + `${context}: \`build\` exits 0 — \`x.d.ts\`, \`x.d.mts\`, ` + + "`x.d.cts`, and `x.d.css.ts`, each holding a body-bearing " + + "`function f` and a top-level marker, are well-formed (14.20: " + + "TypeScript's ambient-context checks are post-parse), so the " + + "otherwise valid workspace builds", + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check\` exits 0 finding-free over the built ` + + "workspace — the declaration files carry no finding (SPEC 12.2, " + + "14.20)", + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "references", context), + T4_6_3_DECLARATION_REFERENCES, + `${context}: in \`x.d.ts\`, \`x.d.mts\`, \`x.d.cts\`, and ` + + "`x.d.css.ts` the body-bearing `function f` is no unit — a " + + "declaration in an ambient context, the file ambient by kind — " + + "so its marker and the top-level marker both attribute to the " + + "whole file `path`, never to `path#f`, while the control " + + "`x.dts.ts` (no `.d.` in its last path segment) keeps `path#f` " + + "(SPEC 4.6)", + ); + const allEdges = await queryAllEdges(product, workspace, context); + assertSameJson( + allEdges + .flatMap((edge) => [edge.from, edge.to]) + .filter(namesDeclarationFileUnit) + .sort(), + [], + `${context}: no endpoint of the unfiltered \`query edges\` answer ` + + "names a code unit of a declaration file (`src/x.d.ts#f` or any " + + "other chain) — a declaration in an ambient context is no unit " + + "(SPEC 4.6)", ); }, ); diff --git a/test/suite/registry/section-4.ts b/test/suite/registry/section-4.ts index c143cd80..644a7439 100644 --- a/test/suite/registry/section-4.ts +++ b/test/suite/registry/section-4.ts @@ -20,12 +20,17 @@ // (14.15); the permitted bindings are the default and `text` exports, each // optionally aliased, possibly type-only; every other module-linking form // whose specifier ends in `.xspec` — a dynamic `import()` with a static -// specifier, export declarations in all forms, `import X = require(…)` — is -// invalid (14.15), as is any of the four module-linking forms whose relative -// specifier designates a derived-file path (13.4) other than through -// `.xspec`; a dynamic `import()` with a non-static specifier is not analyzed -// and records nothing; imports colliding on a binding identifier are 14.15 -// when either import is a spec module import. +// specifier, export declarations in all forms, `import X = require(…)`, an +// import type, a string-named module declaration (its name the specifier) — +// is invalid (14.15), as is any of the six module-linking forms whose +// relative specifier designates a derived-file path (13.4) other than +// through `.xspec`; no other construct names a module — a dynamic +// `import()` whose specifier is not a static string literal (an +// identifier, a template literal) is not analyzed and records nothing, and a +// `require(…)` call's argument, a triple-slash directive, or a string +// literal elsewhere is never read, validated, or rewritten (6.5) as a +// specifier; imports colliding on a binding identifier are 14.15 when either +// import is a spec module import. // // Conservative operationalizations (noted per H-4 — wording is free, so only // the stated observables are asserted): @@ -40,16 +45,41 @@ // byte offsets and each 14.15 finding must fall within the offending // statement's own byte window (end-widened by one byte for line-granular // locations, support.ts byteWindow). +// - T4-2 "no occurrence": the side-effect-only import's positive arm compares +// the `occurrences` document's complete (file, kind, target) multiset with +// the two ordinary consumers' records — a phantom record is an extra tuple +// — leaving each record's source datum and exact span to T5.7-2/T5.7-3. +// - T4-2 "`query edges` listing the marker's edge alone" (the +// no-other-construct arm): the whole workspace's dependency edges are +// compared with the marker's one edge; the spec source's structural +// `contains` edge (SPEC 5.2) is not the arm's subject and is set aside. -import type { GraphEdge } from "../../helpers/adapters/index.js"; -import { decodeEdgesReport } from "../../helpers/adapters/index.js"; +import { Buffer } from "node:buffer"; +import type { + DependencyEdgeKind, + Finding, + GraphEdge, + OccurrenceRecord, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeFindingsReport, + decodeOccurrencesReport, + decodePreviewReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, + assertFileBytes, fail, + parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { assertCompileErrorAt, @@ -59,16 +89,20 @@ import { runConsumer, } from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertFindingLocatesExactly, + assertSameJson, buildFindings, buildOk, byteWindow, expectExit, readGeneratedModule, runJson, + stageBesideRoot, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. @@ -83,8 +117,12 @@ export default defineConfig({ // One spec group plus one code group (SPEC 7.2): TypeScript files under // `src/` are discovered code sources, so `build` analyzes their imports and -// spec-module usage (4, 4.5). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// spec-module usage (4, 4.5). A staged-source record (S-9's timing clause): +// every arm workspace of T4-2 and T4-5 past each body's first is created +// after a product invocation; T4-3 and T4-4 stage it before theirs. +const SPEC_AND_CODE_CONFIG = stagedTs( + "T4-2/T4-5 xspec.config.ts — one spec group and one code group, the arm workspaces' default", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -94,12 +132,17 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); // The same plus enabled Markdown emission, for the T4-2 arms whose derived // path is a configured Markdown emit destination — those exist exactly while // emission is enabled (SPEC 7.3), with the default next-to-source placement. -const MARKDOWN_EMIT_CONFIG = `import { defineConfig } from "xspec" +// A staged-source record: those arms' workspaces are created after T4-2's +// first invocation. +const MARKDOWN_EMIT_CONFIG = stagedTs( + "T4-2 xspec.config.ts — the code-group configuration with Markdown emission enabled, the emit-destination arms", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -110,12 +153,35 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); + +// A spec group reaching `.mdx` files under `src/`, beside the code group's +// `.ts` files (SPEC 7.1, 7.2: the two globs match disjoint sets; the +// generated `src/NAME.xspec.ts` is a derived file, excluded from every group, +// 13.4), for the T4-2 arms whose specifier TEST-SPEC pins relative to the +// spec source's own directory — the lexical positives and the escape-spelled +// name segment. A staged-source record: those arms' workspaces are created +// after T4-2's first invocation. +const COLOCATED_CONFIG = stagedTs( + "T4-2 xspec.config.ts — a spec group reaching `src/**/*.mdx` beside the code group, the colocated arms", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx", "src/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -245,11 +311,33 @@ const T4_1 = defineProductTest({ // `specs/BASE.mdx` (so arms whose specifier names it carry the specifier or // binding form as their only defect), any arm-specific extra files, and // `src/app.ts` holding exactly the arm's statements — the offending -// construct(s) start at byte 0, everything pure ASCII. +// construct(s) start at byte 0, everything pure ASCII. Every arm's workspace +// past the first is created after a product invocation, so its `.mdx` entries +// — the shared base and an arm's extras alike — are staged-source records, +// judged before any product exists (S-9, test/self/s9-staged-sources.test.ts), +// and so are its configuration and its code sources: each arm's `src/app.ts` +// is laid out into a record at module load (`layOutInvalidTsImportArm`), one +// per row. const T4_2_BASE_FILES = { - "specs/BASE.mdx": '<S id="core">\nCore behavior.\n</S>\n', + "specs/BASE.mdx": stagedMdx( + "T4-2 specs/BASE.mdx", + '<S id="core">\nCore behavior.\n</S>\n', + ), } as const; +// The spec source the colocated arms discover beside the code files +// (COLOCATED_CONFIG): `src/NAME.mdx`, one node `core` — staged by the +// escape-spelled arm and the lexical positives. +const COLOCATED_SPEC_SOURCE = stagedMdx( + "T4-2 src/NAME.mdx", + '<S id="core">\nCore behavior.\n</S>\n', +); + +// The escape character, built from its code point so that no tool layer +// decodes the six-character escape spelling staged below on its way into the +// file (the pattern of section-1.4.ts). +const BACKSLASH = String.fromCodePoint(0x5c); + /** One invalid TS import-rule arm: a workspace differing only in src/app.ts. */ interface InvalidTsImportArm { /** Which SPEC 4 invalid case this is (failure diagnostics). */ @@ -263,9 +351,15 @@ interface InvalidTsImportArm { */ readonly maxFindings?: number; /** Configuration override (defaults to SPEC_AND_CODE_CONFIG). */ - readonly config?: string; + readonly config?: StagedTs; /** Files staged beside the base files. */ - readonly extraFiles?: Readonly<Record<string, string>>; + readonly extraFiles?: Readonly<Record<string, InitialFileContents>>; + /** + * Files staged OUTSIDE the workspace root, at paths relative to the root's + * parent directory (support.ts stageBesideRoot) — the above-root arm's real + * `outside/NAME.mdx`, which resolution must never reach (SPEC 2.1, 4). + */ + readonly outsideFiles?: Readonly<Record<string, string>>; } // The `.xspec`-specifier rules (SPEC 4: target must be a discovered spec @@ -277,7 +371,10 @@ const XSPEC_RULE_ARMS: readonly InvalidTsImportArm[] = [ name: "a `.xspec` specifier designating an existing file that is not a discovered spec source", statements: ['import MOD from "../docs/EXTRA.xspec";'], extraFiles: { - "docs/EXTRA.mdx": '<S id="extra">\nOutside every spec group.\n</S>\n', + "docs/EXTRA.mdx": stagedMdx( + "T4-2 arm undiscovered existing file docs/EXTRA.mdx", + '<S id="extra">\nOutside every spec group.\n</S>\n', + ), }, }, { @@ -292,6 +389,29 @@ const XSPEC_RULE_ARMS: readonly InvalidTsImportArm[] = [ name: "an absolute specifier ending `.xspec`", statements: ['import MOD from "/specs/BASE.xspec";'], }, + { + // From `src/` (depth 1) the two `..` segments reach depth -1: the ascent + // passes above the workspace root, so the specifier designates nothing + // (SPEC 2.1, 4) although the root's parent really holds + // `outside/NAME.mdx` — the discriminator against filesystem resolution. + name: "a `.xspec` specifier whose ascent passes above the workspace root, the root's parent holding a real `outside/NAME.mdx`", + statements: ['import MOD from "../../outside/NAME.xspec";'], + outsideFiles: { + "outside/NAME.mdx": '<S id="core">\nCore behavior, outside.\n</S>\n', + }, + }, + { + // The six-character escape of `A` in the name segment is read verbatim + // (SPEC 2.4): the segment spells a name containing the escape character, + // which no discovered path spells. `src/NAME.mdx` is a discovered spec + // source beside the code file (COLOCATED_CONFIG), so a product + // interpreting the escape resolves the import, builds clean, and fails + // the arm. + name: `a \`.xspec\` specifier spelled with an escape sequence in a name segment ("./N${BACKSLASH}u0041ME.xspec"), read verbatim`, + statements: [`import MOD from "./N${BACKSLASH}u0041ME.xspec";`], + config: COLOCATED_CONFIG, + extraFiles: { "src/NAME.mdx": COLOCATED_SPEC_SOURCE }, + }, { name: "a named binding other than `text`", statements: ['import { core } from "../specs/BASE.xspec";'], @@ -326,9 +446,72 @@ const XSPEC_RULE_ARMS: readonly InvalidTsImportArm[] = [ name: "an `import X = require(…)` declaration with a `.xspec` specifier", statements: ['import MOD = require("../specs/BASE.xspec");'], }, + // An import type is a module-linking form, never a free type-level + // reference (SPEC 4, 4.5; T4.5-7 defers here). Its `./NAME.xspec` + // specifier designates a discovered spec source — `src/NAME.mdx` beside + // the code file (COLOCATED_CONFIG) — so the form alone is the defect: a + // product validating the target alone, or reading the import type as a + // type-level reference, builds clean and fails the arm. + { + name: 'an import type naming a `.xspec` module (`type T = import("./NAME.xspec").default`)', + statements: ['type T = import("./NAME.xspec").default;'], + config: COLOCATED_CONFIG, + extraFiles: { "src/NAME.mdx": COLOCATED_SPEC_SOURCE }, + }, + { + name: 'an import type in a `typeof` query naming a `.xspec` module (`let v: typeof import("./NAME.xspec")`)', + statements: ['let v: typeof import("./NAME.xspec");'], + config: COLOCATED_CONFIG, + extraFiles: { "src/NAME.mdx": COLOCATED_SPEC_SOURCE }, + }, + // A string-named module declaration, its name the specifier (SPEC 4), + // `declare` or not, a module augmentation included. TypeScript's own + // complaints here — a relative ambient module name, a quoted name outside + // an ambient context — are post-parse checks, so each file is well-formed + // (14.20) and its finding 14.15's. The augmentation's file holds + // `export {}` (a module, so the declaration augments the discovered spec + // source's module), staged after the declaration so the declaration is the + // statement the finding locates. + { + name: 'a module augmentation `declare module "./NAME.xspec" { }`, its file holding `export {}`', + statements: ['declare module "./NAME.xspec" { }', "export {};"], + config: COLOCATED_CONFIG, + extraFiles: { "src/NAME.mdx": COLOCATED_SPEC_SOURCE }, + }, + { + name: 'the undeclared string-named module declaration `module "./NAME.xspec" { }`', + statements: ['module "./NAME.xspec" { }'], + config: COLOCATED_CONFIG, + extraFiles: { "src/NAME.mdx": COLOCATED_SPEC_SOURCE }, + }, + { + // Non-relative and designating nothing, its name still ends in `.xspec`: + // a product checking relative specifiers alone builds clean and fails. + name: 'the non-relative ambient wildcard `declare module "*.xspec" { }`', + statements: ['declare module "*.xspec" { }'], + }, ]; -// Derived-path arms (SPEC 4, 13.4): the full cross product of the four +// The side-effect-only form (SPEC 4): `import "./NAME.xspec"` binds nothing, +// records nothing, and is valid in a code-group file (the positive arm +// below) — "its specifier held to the rule above like any other's" — so the +// two specifier defects TEST-SPEC pins fail with 14.15 through it: a `.xspec` +// specifier designating no discovered spec source (`./missing.xspec` +// designates `src/missing.mdx`, no such file), and a relative specifier +// designating a derived-file path (`../specs/BASE.xspec.ts`, a file name +// containing `.xspec.`, 13.4) without being a spec module import. +const SIDE_EFFECT_IMPORT_ARMS: readonly InvalidTsImportArm[] = [ + { + name: 'a side-effect-only import whose `.xspec` specifier designates a nonexistent file (`import "./missing.xspec"`)', + statements: ['import "./missing.xspec";'], + }, + { + name: 'a side-effect-only import whose relative specifier designates a derived-file path (`import "../specs/BASE.xspec.ts"`)', + statements: ['import "../specs/BASE.xspec.ts";'], + }, +]; + +// Derived-path arms (SPEC 4, 13.4): the full cross product of the six // module-linking forms SPEC 4 names and the three derived-file path kinds of // 13.4 — a file name containing `.xspec.` (the generated module's own path, // consumed only through its `.xspec` specifier), a path under `.xspec/`, and @@ -356,12 +539,22 @@ const MODULE_LINKING_FORMS: readonly { form: "a static-specifier dynamic `import()`", statement: (specifier) => `void import("${specifier}");`, }, + { + form: "an import type", + statement: (specifier) => `type T = import("${specifier}").default;`, + }, + { + // Alone in its file (a script), so an ambient module declaration whose + // relative name TypeScript rejects only post-parse (14.20). + form: "a string-named module declaration", + statement: (specifier) => `declare module "${specifier}" { }`, + }, ]; const DERIVED_PATH_KINDS: readonly { readonly kind: string; readonly specifier: string; - readonly config?: string; + readonly config?: StagedTs; }[] = [ { kind: "a file name containing `.xspec.` (`../specs/BASE.xspec.ts` directly)", @@ -398,7 +591,10 @@ const DUPLICATE_BINDING_ARMS: readonly InvalidTsImportArm[] = [ 'import MOD from "../specs/OTHER.xspec";', ], extraFiles: { - "specs/OTHER.mdx": '<S id="other">\nOther behavior.\n</S>\n', + "specs/OTHER.mdx": stagedMdx( + "T4-2 arm two spec module imports binding one identifier specs/OTHER.mdx", + '<S id="other">\nOther behavior.\n</S>\n', + ), }, maxFindings: 2, }, @@ -420,6 +616,50 @@ const DUPLICATE_BINDING_ARMS: readonly InvalidTsImportArm[] = [ }, ]; +/** + * An invalid-import arm laid out at module load: its `src/app.ts` — the + * statements, one per line from byte 0 — as a staged-source record (S-9's + * timing clause: every arm's workspace past the first is created after a + * product invocation), and each statement's end-widened byte window + * (support.ts byteWindow), fixed from the same bytes. + */ +interface LaidOutInvalidTsImportArm { + readonly arm: InvalidTsImportArm; + readonly app: StagedTs; + readonly windows: readonly { + readonly start: number; + readonly end: number; + }[]; +} + +/** + * Lay out one arm's `src/app.ts` and its statements' byte windows. It + * registers a record, so it runs at module load only (`T4_2_INVALID_ARMS`). + */ +function layOutInvalidTsImportArm( + arm: InvalidTsImportArm, +): LaidOutInvalidTsImportArm { + const windows: { start: number; end: number }[] = []; + let prefix = ""; + for (const statement of arm.statements) { + windows.push(byteWindow(prefix, statement)); + prefix += statement + "\n"; + } + return { + arm, + app: stagedTs(`T4-2 arm ${arm.name} src/app.ts`, prefix), + windows, + }; +} + +// T4-2's invalid-import arms in run order, one record per row. +const T4_2_INVALID_ARMS: readonly LaidOutInvalidTsImportArm[] = [ + ...XSPEC_RULE_ARMS, + ...SIDE_EFFECT_IMPORT_ARMS, + ...DERIVED_PATH_ARMS, + ...DUPLICATE_BINDING_ARMS, +].map(layOutInvalidTsImportArm); + /** * Run one invalid-import arm: `build --json` exits 1 with only 14.15 * findings — exactly one for a single-statement arm, one or two for a @@ -429,24 +669,19 @@ const DUPLICATE_BINDING_ARMS: readonly InvalidTsImportArm[] = [ */ async function runInvalidTsImportArm( product: ProductBinding, - arm: InvalidTsImportArm, + { arm, app, windows }: LaidOutInvalidTsImportArm, testId: string, ): Promise<void> { const context = `${testId} \`build --json\` over ${arm.name}`; - const windows: { start: number; end: number }[] = []; - let prefix = ""; - for (const statement of arm.statements) { - windows.push(byteWindow(prefix, statement)); - prefix += statement + "\n"; - } await withWorkspace( arm.config ?? SPEC_AND_CODE_CONFIG, { ...T4_2_BASE_FILES, ...arm.extraFiles, - "src/app.ts": prefix, + "src/app.ts": app, }, async (workspace) => { + await stageBesideRoot(workspace, arm.outsideFiles ?? {}); const findings = await buildFindings(product, workspace, context); const max = arm.maxFindings ?? 1; if (max === 1) { @@ -473,19 +708,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)})`, + ); + } } } }, @@ -497,40 +730,436 @@ async function runInvalidTsImportArm( // discovered spec source — a product that analyzed the call (e.g. by constant // propagation) would have to report the always-invalid static-specifier // dynamic import (14.15) and fail the exit-0 expectation; a conforming -// product records nothing from the file. -const NON_STATIC_DYNAMIC_SOURCE = [ - 'const target = "../specs/BASE.xspec";', - "void import(target);", - "", -].join("\n"); +// product records nothing from the file. Every positive arm's workspace is +// created after T4-2's first invocation, so its code sources are +// staged-source records (S-9's timing clause), as are those below. +const NON_STATIC_DYNAMIC_SOURCE = stagedTs( + "T4-2 src/app.ts — the dynamic `import()` with a non-static specifier", + ['const target = "../specs/BASE.xspec";', "void import(target);", ""].join( + "\n", + ), +); // Positive arm: two colliding non-spec imports — neither is a spec module // import, so the collision is no xspec finding, and the unused colliding // bindings trigger no xspec error (TypeScript's own complaint is outside // xspec's validations). -const NON_SPEC_COLLISION_SOURCE = [ - 'import MOD from "node:path";', - 'import MOD from "node:url";', - "", -].join("\n"); +const NON_SPEC_COLLISION_SOURCE = stagedTs( + "T4-2 src/app.ts — two colliding non-spec imports", + ['import MOD from "node:path";', 'import MOD from "node:url";', ""].join( + "\n", + ), +); + +// Positive arms: lexical resolution in TS (SPEC 4: the resolution of 2.1). +// The spec source `src/NAME.mdx` sits beside its consumers under +// COLOCATED_CONFIG, and the workspace holds no `src/sub/` at all, so +// `./sub/../NAME.xspec` resolves only lexically; `.//NAME.xspec` spells the +// same directory through an empty segment. Each consumer's bare marker +// records a `references` edge to the resolved node (4.5): a product failing +// to resolve either spelling reports 14.15 and fails the build, and one +// resolving it elsewhere records the wrong edge target. Each consumer is a +// staged-source record, one per row. +function lexicalTsConsumerSource(specifier: string): string { + return [`import NAME from "${specifier}";`, "", "NAME.core;", ""].join("\n"); +} + +const LEXICAL_TS_CONSUMERS: readonly { + readonly file: string; + readonly specifier: string; + readonly source: StagedTs; +}[] = [ + { file: "src/one.ts", specifier: "./sub/../NAME.xspec" }, + { file: "src/two.ts", specifier: ".//NAME.xspec" }, +].map((consumer) => ({ + ...consumer, + source: stagedTs( + `T4-2 lexical positive ${consumer.file} — the marker through \`${consumer.specifier}\``, + lexicalTsConsumerSource(consumer.specifier), + ), +})); + +// Positive arm: a side-effect-only import in a code-group file (SPEC 4: it +// binds nothing, records nothing, and is valid). It is staged alone in +// `src/side.ts` beside two ordinary consumers — a bare marker (`references`, +// 4.5) and a `text(...)` call (`embeds`, 4.3) — so both consulted surfaces +// are live: the consumers' edges and occurrences are the workspace's only +// ones, and a product that treats the side-effect form as an import defect +// (14.15 at `build` or `check`) or records anything from it is caught — an +// edge by `query edges --from src/side.ts`, a phantom occurrence as an extra +// tuple in the exact (file, kind, target) multiset (SPEC 5.7: an import +// declaration, its binding used or not, records no occurrence). The three +// code sources are staged-source records. +const SIDE_EFFECT_IMPORT_FILE = "src/side.ts"; +const SIDE_EFFECT_IMPORT_SOURCE = stagedTs( + "T4-2 side-effect arm src/side.ts — the side-effect-only import, alone", + 'import "../specs/BASE.xspec";\n', +); + +const SIDE_EFFECT_ORDINARY_CONSUMERS: Readonly<Record<string, StagedTs>> = { + "src/one.ts": stagedTs( + "T4-2 side-effect arm src/one.ts — the ordinary marker consumer", + ['import BASE from "../specs/BASE.xspec";', "", "BASE.core;", ""].join( + "\n", + ), + ), + "src/two.ts": stagedTs( + "T4-2 side-effect arm src/two.ts — the ordinary `text(...)` consumer", + [ + 'import BASE, { text } from "../specs/BASE.xspec";', + "", + "process.stdout.write(text(BASE.core));", + "", + ].join("\n"), + ), +}; + +// The consumers' edges (SPEC 4.3, 4.5; top-level statements, so each source +// node is the code file itself) and, behind them, the workspace's complete +// occurrence multiset as (file, kind, target) units (SPEC 5.7). +const SIDE_EFFECT_CONSUMER_EDGES: readonly { + readonly from: string; + readonly to: string; + readonly kind: DependencyEdgeKind; +}[] = [ + { from: "src/one.ts", to: "specs/BASE.mdx#core", kind: "references" }, + { from: "src/two.ts", to: "specs/BASE.mdx#core", kind: "embeds" }, +]; + +function renderOccurrenceTriple( + file: string, + kind: DependencyEdgeKind, + target: string, +): string { + return `${file} [${kind}] -> ${target}`; +} + +// Positive arm: no other construct names a module (SPEC 4). `src/c.ts` +// imports `specs/NAME.mdx`'s module through an import declaration and marks +// its node `core` (a `references` edge, 4.5), and beside them holds seven +// spellings that name no module: a triple-slash reference directive to the +// generated module's path (a comment to the grammar, 14.20 — staged first, +// where TypeScript reads directives), two `require(…)` calls (a string +// literal in an argument position), a string literal bound by a `const`, and +// three dynamic `import()` calls whose specifier is a template literal (not +// static, 2.4, so not analyzed) — the same three spellings that would each +// be 14.15 as a module-linking form's specifier (a `.xspec` specifier +// outside an import declaration, a missing source, a derived-file path). +// None raises a finding or records an edge, and a file move of NAME.mdx into +// another directory rewrites the import declaration's specifier alone (6.5), +// its preview reporting for the file exactly that one +// `import-specifier-rewrite` (6.6, 12.7): a product validating or rewriting +// the other spellings, or reading a no-substitution template literal as a +// static string literal, fails. Its workspace is created after T4-2's first +// invocation, so both sources are staged-source records (S-9). +const NO_OTHER_CONSTRUCT_FILE = "src/c.ts"; +const NO_OTHER_CONSTRUCT_DIRECTIVE = + '/// <reference path="../specs/NAME.xspec.ts" />'; +const NO_OTHER_CONSTRUCT_IMPORT_PREFIX = "import NAME from "; + +/** `src/c.ts` with its import declaration's specifier literal `specifier`. */ +function noOtherConstructSource(specifier: string): string { + return [ + NO_OTHER_CONSTRUCT_DIRECTIVE, + `${NO_OTHER_CONSTRUCT_IMPORT_PREFIX}${specifier};`, + "", + "NAME.core;", + 'require("../specs/NAME.xspec");', + 'require("./missing.xspec");', + 'const p = "../specs/NAME.xspec";', + "void import(`../specs/NAME.xspec`);", + "void import(`./missing.xspec`);", + "void import(`../specs/NAME.xspec.ts`);", + "", + ].join("\n"); +} + +const NO_OTHER_CONSTRUCT_SPECIFIER = '"../specs/NAME.xspec"'; +const NO_OTHER_CONSTRUCT_SOURCE = stagedTs( + "T4-2 no-other-construct arm src/c.ts — a marked import beside two `require(…)` calls, a triple-slash reference, a string literal, and three template-literal `import()` calls", + noOtherConstructSource(NO_OTHER_CONSTRUCT_SPECIFIER), +); +const NO_OTHER_CONSTRUCT_SPEC_SOURCE = stagedMdx( + "T4-2 no-other-construct arm specs/NAME.mdx", + '<S id="core">\nCore behavior.\n</S>\n', +); + +// The file move, into a directory `specs/sub/` that does not yet exist. +const NO_OTHER_CONSTRUCT_MOVE_ARGV = [ + "move", + "specs/NAME.mdx", + "specs/sub/NAME.mdx", +] as const; + +// The import declaration's specifier literal, its delimiters included (SPEC +// 6.6), in pre-move byte coordinates: the preview's one edit for the file. +const NO_OTHER_CONSTRUCT_SPECIFIER_RANGE = (() => { + const start = Buffer.byteLength( + `${NO_OTHER_CONSTRUCT_DIRECTIVE}\n${NO_OTHER_CONSTRUCT_IMPORT_PREFIX}`, + "utf8", + ); + return { + start, + end: start + Buffer.byteLength(NO_OTHER_CONSTRUCT_SPECIFIER, "utf8"), + }; +})(); + +// `src/c.ts` after the move: that literal alone replaced by the canonical +// relative spelling of the moved source's module from `src/` (SPEC 6.5, +// 2.1), its double quotes kept (6.4); every other byte — the seven +// spellings' included — unchanged. +const NO_OTHER_CONSTRUCT_MOVED_SOURCE = noOtherConstructSource( + '"../specs/sub/NAME.xspec"', +); const T4_2 = defineProductTest({ id: "T4-2", - title: - "TS import rules: undiscovered/nonexistent/bare/absolute `.xspec` specifiers, bindings other than the default and `text`, static-specifier dynamic `import()`, every export-declaration form, `import X = require(…)`, and derived-path specifiers through each module-linking form all fail with 14.15; a non-static dynamic `import()` is not analyzed; binding collisions are 14.15 when either import is a spec module import, and no finding otherwise (SPEC 4, 2.1, 13.4, 14.15)", - // Many small workspaces (27 negative arms plus two positive ones), each - // one product invocation: a wider hang budget than the default (H-8 hang - // guard only, never an assertion — H-10). + title: `TS import rules: undiscovered/nonexistent/bare/absolute \`.xspec\` specifiers, one whose ascent passes above the root (a real \`outside/NAME.mdx\` at the root's parent), one escape-spelled in a name segment ("./N${BACKSLASH}u0041ME.xspec", read verbatim), bindings other than the default and \`text\`, static-specifier dynamic \`import()\`, every export-declaration form, \`import X = require(…)\`, import types (\`type T = import("./NAME.xspec").default\`, \`let v: typeof import("./NAME.xspec")\`), string-named module declarations (\`declare module "./NAME.xspec" { }\` as an augmentation, its file holding \`export {}\`, the undeclared \`module "./NAME.xspec" { }\`, and the non-relative wildcard \`declare module "*.xspec" { }\`), and derived-path specifiers through each of the six module-linking forms all fail with 14.15; \`./sub/../NAME.xspec\` (no \`sub/\` on disk) and \`.//NAME.xspec\` resolve lexically to NAME.mdx and markers through them record edges; a non-static dynamic \`import()\` is not analyzed; no other construct names a module — in \`src/c.ts\` beside a marked import, two \`require(…)\` calls, a triple-slash reference, a string literal, and three template-literal \`import()\` calls raise no finding and record no edge (\`build\` and \`check\` exit 0, \`query edges\` the marker's edge alone), and a file move of NAME.mdx rewrites the import declaration's specifier alone, those seven spellings byte-unchanged, its preview reporting for the file one \`import-specifier-rewrite\` spanning that specifier and no other edit; binding collisions are 14.15 when either import is a spec module import, and no finding otherwise; a side-effect-only \`import "./NAME.xspec"\` binds nothing and is valid in a code-group file — \`build\` and \`check\` exit 0, no edge, no occurrence — while \`import "./missing.xspec"\` and \`import "../specs/NAME.xspec.ts"\` fail with 14.15 (SPEC 4, 2.1, 2.4, 4.5, 5.7, 6.5, 6.6, 13.4, 14.15)`, + // Many small workspaces (42 negative arms plus five positive ones), each + // one product invocation or a few: a wider hang budget than the default + // (H-8 hang guard only, never an assertion — H-10). timeoutMs: 240_000, run: async (product) => { - for (const arm of [ - ...XSPEC_RULE_ARMS, - ...DERIVED_PATH_ARMS, - ...DUPLICATE_BINDING_ARMS, - ]) { + for (const arm of T4_2_INVALID_ARMS) { await runInvalidTsImportArm(product, arm, "T4-2"); } + // Lexical positives: `./sub/../NAME.xspec` (no src/sub/ on disk) and + // `.//NAME.xspec` resolve to src/NAME.mdx; the markers record edges. + await withWorkspace( + COLOCATED_CONFIG, + { + ...T4_2_BASE_FILES, + "src/NAME.mdx": COLOCATED_SPEC_SOURCE, + ...Object.fromEntries( + LEXICAL_TS_CONSUMERS.map((consumer) => [ + consumer.file, + consumer.source, + ]), + ), + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T4-2 `build` with `./sub/../NAME.xspec` (no src/sub/ on disk) " + + "and `.//NAME.xspec` imported by code files beside src/NAME.mdx " + + "— each resolves lexically to the discovered spec source " + + "(SPEC 4, 2.1)", + ); + for (const consumer of LEXICAL_TS_CONSUMERS) { + assertEdgeSetEqual( + await queryEdgesFrom(product, workspace, consumer.file, "T4-2"), + [ + { + from: consumer.file, + to: "src/NAME.mdx#core", + kind: "references", + }, + ], + `T4-2 the marker through \`${consumer.specifier}\` records its ` + + "`references` edge to src/NAME.mdx#core — the specifier " + + "resolved lexically (SPEC 4, 2.1, 4.5)", + ); + } + }, + ); + + // Side-effect-only import: valid in a code-group file beside ordinary + // consumers — `build` and `check` exit 0, no edge, no occurrence. + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + ...T4_2_BASE_FILES, + [SIDE_EFFECT_IMPORT_FILE]: SIDE_EFFECT_IMPORT_SOURCE, + ...SIDE_EFFECT_ORDINARY_CONSUMERS, + }, + async (workspace) => { + await buildOk( + product, + workspace, + 'T4-2 `build` with a side-effect-only `import "../specs/BASE.xspec"` ' + + "alone in src/side.ts beside two ordinary consumers (SPEC 4: the " + + "form binds nothing and is valid in a code-group file)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T4-2 `check` over the same workspace (SPEC 4: the side-effect-only " + + "import is valid)", + ); + assertEdgeSetEqual( + await queryEdgesFrom( + product, + workspace, + SIDE_EFFECT_IMPORT_FILE, + "T4-2", + ), + [], + "T4-2 the side-effect-only import records no edge: none leaves " + + "src/side.ts (SPEC 4)", + ); + for (const edge of SIDE_EFFECT_CONSUMER_EDGES) { + assertEdgeSetEqual( + await queryEdgesFrom(product, workspace, edge.from, "T4-2"), + [edge], + `T4-2 the ordinary consumer ${edge.from} beside it records its ` + + `\`${edge.kind}\` edge (SPEC 4.3, 4.5) — the edge surface is live`, + ); + } + const context = "T4-2 `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], context), + context, + ); + if (report.findings.length !== 0) { + fail( + `${context}: the valid workspace consults finding-free (SPEC 11.2) ` + + `— got ${JSON.stringify(report.findings.map((finding) => finding.condition ?? finding.code))}`, + ); + } + const actual = report.occurrences + .map((record) => + renderOccurrenceTriple( + renderPathValue(record.file), + record.kind, + record.target, + ), + ) + .sort(); + const expected = SIDE_EFFECT_CONSUMER_EDGES.map((edge) => + renderOccurrenceTriple(edge.from, edge.kind, edge.to), + ).sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail( + `${context}: the workspace's occurrences are exactly the two ` + + `ordinary consumers' — the side-effect-only import records none ` + + `(SPEC 4, 5.7); expected ${JSON.stringify(expected)}, got ` + + JSON.stringify(actual), + ); + } + }, + ); + + // No other construct names a module: beside a marked import, seven + // spellings raise nothing and record nothing, and a file move rewrites + // the import declaration's specifier alone. + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + "specs/NAME.mdx": NO_OTHER_CONSTRUCT_SPEC_SOURCE, + [NO_OTHER_CONSTRUCT_FILE]: NO_OTHER_CONSTRUCT_SOURCE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T4-2 `build` with src/c.ts holding, beside a marked import, two " + + "`require(…)` calls, a triple-slash reference, a string literal, " + + "and three template-literal `import()` calls (SPEC 4: no other " + + "construct names a module, so none is validated)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T4-2 `check` over the same workspace (SPEC 4: no other construct " + + "names a module)", + ); + // The dependency edges (SPEC 5.2: `contains` is structural, the spec + // source's own) across the whole workspace: the marker's alone. + const edgesLabel = "T4-2 `query edges` over src/c.ts's workspace"; + const edges = decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], edgesLabel), + edgesLabel, + ); + assertEdgeSetEqual( + edges.filter((edge) => edge.kind !== "contains"), + [ + { + from: NO_OTHER_CONSTRUCT_FILE, + to: "specs/NAME.mdx#core", + kind: "references", + }, + ], + `${edgesLabel}: the marker's \`references\` edge is the only ` + + "dependency edge — the seven other spellings name no module and " + + "record no edge (SPEC 4, 4.5)", + ); + + // The move's preview: for src/c.ts, one `import-specifier-rewrite` + // spanning the import declaration's specifier literal, and no other + // edit (SPEC 6.6, 12.7). + const previewLabel = `T4-2 \`${NO_OTHER_CONSTRUCT_MOVE_ARGV.join(" ")} --preview --json\``; + const preview = decodePreviewReport( + await runJson( + product, + workspace, + [...NO_OTHER_CONSTRUCT_MOVE_ARGV, "--preview", "--json"], + previewLabel, + ), + previewLabel, + ); + assertSameJson( + preview.findings, + [], + `${previewLabel}: the preview of the valid file-form move ` + + "completes with findings [] (SPEC 6.6)", + ); + if (preview.files === null) { + fail( + `${previewLabel}: the completed preview reports its plan — ` + + "`files` non-null (SPEC 6.6, 12.7)", + ); + } + assertSameJson( + preview.files + .filter( + (entry) => + renderPathValue(entry.file) === NO_OTHER_CONSTRUCT_FILE, + ) + .map((entry) => entry.edits), + [ + [ + { + class: "import-specifier-rewrite", + range: NO_OTHER_CONSTRUCT_SPECIFIER_RANGE, + }, + ], + ], + `${previewLabel}: the preview lists ${NO_OTHER_CONSTRUCT_FILE} ` + + "once, its edits exactly one `import-specifier-rewrite` spanning " + + "the import declaration's specifier literal, delimiters " + + "included — no edit to the seven other spellings (SPEC 4, 6.5, " + + "6.6, 12.7)", + ); + + // The move itself: the import declaration's specifier rewritten, + // every other byte of src/c.ts unchanged. + await expectExit( + product, + workspace, + NO_OTHER_CONSTRUCT_MOVE_ARGV, + 0, + `T4-2 \`${NO_OTHER_CONSTRUCT_MOVE_ARGV.join(" ")}\` — a valid ` + + "file-form move into another directory (SPEC 6.5)", + ); + await assertFileBytes( + workspace.path(NO_OTHER_CONSTRUCT_FILE), + NO_OTHER_CONSTRUCT_MOVED_SOURCE, + `T4-2 ${NO_OTHER_CONSTRUCT_FILE} after the move: the import ` + + "declaration's specifier alone rewritten to " + + '"../specs/sub/NAME.xspec", the two `require(…)` arguments, the ' + + "triple-slash reference, the string literal, and the three " + + "template-literal `import()` specifiers byte-unchanged (SPEC 4, " + + "6.5)", + ); + }, + ); + // Non-static dynamic import: build succeeds, no edges recorded. await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -788,10 +1417,358 @@ const T4_4 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T4-5 +// --------------------------------------------------------------------------- + +// Two spec sources — `A`, the module every arm's chains name (`SPEC.a`, +// `text(SPEC.b)`), and `B`, the second spec module the first pairing's +// type-only import designates — plus `src/t.ts`, the non-spec module `./t` +// the other pairings import: a discovered code source recording nothing, +// exporting a value `SPEC` so that each non-spec import designates a real +// binding (consumer-side resolution is outside xspec's validations either +// way, SPEC 4.5). Every arm's workspace past the first is created after a +// product invocation: the two spec sources, the configuration, and both code +// sources — `src/t.ts` here, each arm's `src/app.ts` in `T4_5_ARMS` — are +// staged-source records (S-9). +const T4_5_FILES = { + "specs/A.mdx": stagedMdx( + "T4-5 specs/A.mdx", + '<S id="a">\nAlpha behavior.\n</S>\n\n<S id="b">\nBeta behavior.\n</S>\n', + ), + "specs/B.mdx": stagedMdx( + "T4-5 specs/B.mdx", + '<S id="x">\nOther behavior.\n</S>\n', + ), + "src/t.ts": stagedTs( + "T4-5 src/t.ts — the non-spec module `./t` exporting `SPEC`", + "export const SPEC = 1;\n", + ), +} as const; + +// `text` bound by its own declaration — two declarations of one module +// binding distinct identifiers, valid under SPEC 4 — so that every arm's +// `text(SPEC.b)` is a spec module `text` call (4.3) whose argument chain is +// rooted at the collided identifier, whatever the pairing does to `SPEC`. +const T4_5_TEXT_IMPORT = 'import { text } from "../specs/A.xspec";'; +const T4_5_MARKER_CHAIN = "SPEC.a"; +const T4_5_TEXT_CALL = "text(SPEC.b)"; + +/** One pairing of a spec module import with a colliding import (SPEC 4.5). */ +interface TypeOnlyCollisionPairing { + /** Which pairing this is (failure diagnostics). */ + readonly name: string; + /** The spec module import binding `SPEC` — value-level or type-only. */ + readonly spec: string; + /** The other import binding `SPEC` — spec or non-spec, type-only or not. */ + readonly other: string; +} + +// The four pairings (TEST-SPEC T4-5): the type-only exemption of SPEC 4.5 +// reaches a chain the language roots at one binding, and a colliding +// identifier roots it at none — whether or not either import is type-only. +const T4_5_PAIRINGS: readonly TypeOnlyCollisionPairing[] = [ + { + name: "a value-level spec import beside a type-only import of a second spec module", + spec: 'import SPEC from "../specs/A.xspec";', + other: 'import type SPEC from "../specs/B.xspec";', + }, + { + name: "a value-level spec import beside a type-only non-spec import", + spec: 'import SPEC from "../specs/A.xspec";', + other: 'import type { SPEC } from "./t";', + }, + { + name: "a type-only spec import beside a value-level non-spec import", + spec: 'import type SPEC from "../specs/A.xspec";', + other: 'import { SPEC } from "./t";', + }, + { + name: "a type-only spec import beside a type-only non-spec import", + spec: 'import type SPEC from "../specs/A.xspec";', + other: 'import type { SPEC } from "./t";', + }, +]; + +/** A staged `src/app.ts` for one arm and its constructs' byte positions. */ +interface StagedTypeOnlyCollisionArm { + /** The pairing and its declaration order (failure diagnostics). */ + readonly name: string; + /** + * The whole file: `text` import, the pair, blank, marker, `text` call — a + * staged-source record (S-9's timing clause). + */ + readonly source: StagedTs; + /** + * The two colliding import declarations' end-widened byte windows + * (support.ts byteWindow), in file order — the `text` import is no + * colliding declaration and lies in neither. + */ + readonly collidingWindows: readonly { + readonly start: number; + readonly end: number; + }[]; + /** The marker's bare chain, exactly (SPEC 14: terminator excluded). */ + readonly markerRange: { readonly start: number; readonly end: number }; + /** The `text(...)` call, callee through closing parenthesis, exactly (14). */ + readonly textCallRange: { readonly start: number; readonly end: number }; +} + +/** + * Lay out an arm's `src/app.ts` — the `text` import, then the pairing's two + * declarations in the given order, one per line, then the marker statement + * and the `text(...)` call statement after a blank line — and fix every + * construct's byte position from the exact bytes. The file is pure ASCII, + * so string indices are byte offsets. It registers the file as a record, so + * it runs at module load only (`T4_5_ARMS`). + */ +function stageTypeOnlyCollisionArm( + pairing: TypeOnlyCollisionPairing, + order: "spec-first" | "other-first", +): StagedTypeOnlyCollisionArm { + const declarations = + order === "spec-first" + ? [pairing.spec, pairing.other] + : [pairing.other, pairing.spec]; + let prefix = `${T4_5_TEXT_IMPORT}\n`; + const collidingWindows = declarations.map((declaration) => { + const window = byteWindow(prefix, declaration); + prefix += `${declaration}\n`; + return window; + }); + const markerPrefix = `${prefix}\n`; + const textCallPrefix = `${markerPrefix}${T4_5_MARKER_CHAIN};\n`; + const source = `${textCallPrefix}${T4_5_TEXT_CALL};\n`; + const exact = ( + before: string, + construct: string, + ): { start: number; end: number } => { + const start = Buffer.byteLength(before, "utf8"); + return { start, end: start + Buffer.byteLength(construct, "utf8") }; + }; + const name = `${pairing.name} (${order === "spec-first" ? "the spec import declared first" : "the other import declared first"})`; + return { + name, + source: stagedTs(`T4-5 arm ${name} src/app.ts`, source), + collidingWindows, + markerRange: exact(markerPrefix, T4_5_MARKER_CHAIN), + textCallRange: exact(textCallPrefix, T4_5_TEXT_CALL), + }; +} + +// T4-5's eight arms in run order — each pairing in both declaration orders — +// one record per row. +const T4_5_ARMS: readonly StagedTypeOnlyCollisionArm[] = T4_5_PAIRINGS.flatMap( + (pairing) => + (["spec-first", "other-first"] as const).map((order) => + stageTypeOnlyCollisionArm(pairing, order), + ), +); + +/** A finding's locations as JSON-safe `[file, start, end]` tuples (12.7 order). */ +function t45LocationTuples(finding: Finding): readonly (readonly unknown[])[] { + return finding.locations.map((location) => [ + location.file, + location.range.start, + location.range.end, + ]); +} + +/** + * Assert a reference-spelling finding locates exactly one range — the span + * its occurrence would occupy (SPEC 14, 5.7), byte-exact — in `src/app.ts` + * and concerns no path (12.7). + */ +function assertT45SpellingFinding( + finding: Finding, + range: { readonly start: number; readonly end: number }, + context: string, +): void { + assertFindingLocatesExactly( + finding, + [{ file: "src/app.ts", window: range }], + context, + ); + assertSameJson( + t45LocationTuples(finding), + [["src/app.ts", range.start, range.end]], + `${context}: the one location is byte-exact — the spelling's own span, ` + + `terminators and delimiters excluded (SPEC 14, 5.7, 1.7)`, + ); +} + +/** + * Assert the findings a T4-5 arm's workspace reports, in 12.7 order: + * condition 7 for the marker chain, condition 7 for the `text(...)` call + * (each byte-exact at the span its occurrence would occupy, SPEC 14), then + * the one condition-15 collision locating both colliding import + * declarations — each within its own byte window, the `text` import not + * among them — and nothing else (14: every colliding declaration, no + * representative chosen). Never the type-only exemption's silence (T4-4) — + * no finding at all — nor an edge-recording resolution: both are what a + * product rooting the chain at whichever binding TypeScript's own + * resolution prefers exhibits instead (SPEC 2.4, 4.5). + */ +function assertTypeOnlyCollisionFindings( + findings: readonly Finding[], + staged: StagedTypeOnlyCollisionArm, + context: string, +): void { + assertSameJson( + findings.map((finding) => finding.condition), + ["14.7", "14.7", "14.15"], + `${context}: exactly the two unresolved chains (condition 7, one per ` + + `spelling) beside the one collision (condition 15), in 12.7 order — ` + + `numbered conditions in numeric order, then by location; a colliding ` + + `identifier roots no chain whether or not either import is type-only ` + + `(SPEC 2.4, 4.5, 14.7, 14.15, 12.7) — never the type-only exemption's ` + + `silence (T4-4)`, + ); + assertT45SpellingFinding( + findings[0]!, + staged.markerRange, + `${context}: the marker's 14.7 locates the bare reference chain, ` + + `exclusive of the statement terminator (SPEC 14, 5.7)`, + ); + assertT45SpellingFinding( + findings[1]!, + staged.textCallRange, + `${context}: the \`text(...)\` call's 14.7 locates the call expression, ` + + `callee through closing parenthesis (SPEC 14, 5.7)`, + ); + assertFindingLocatesExactly( + findings[2]!, + staged.collidingWindows.map((window) => ({ file: "src/app.ts", window })), + `${context}: the 14.15 locates both colliding import declarations, each ` + + `by its own characters, and not the \`text\` import beside them ` + + `(SPEC 14, 1.7, 2.1, 4)`, + ); +} + +/** + * An occurrence record's every datum (SPEC 5.7) as one JSON-safe tuple — + * file, own range, kind, source (identity plus range, or the unavailability + * marker), target — so whole records compare key-order-free. + */ +function t45OccurrenceTuple(record: OccurrenceRecord): readonly unknown[] { + const source = + "unavailable" in record.source + ? "(source unavailable)" + : [ + record.source.identity, + record.source.range.start, + record.source.range.end, + ]; + return [ + record.file, + record.range.start, + record.range.end, + record.kind, + source, + record.target, + ]; +} + +/** + * One arm: `build` and `check` report the collision beside condition 7 for + * each chain, exit 1; and `occurrences --file src/app.ts`, answering on the + * failing workspace (SPEC 11.2), carries those findings and lists no record + * for the two spellings — a chain rooted at the collided identifier records + * no edge and no occurrence (5.7, T5.7-4). + */ +async function assertTypeOnlyCollisionArm( + product: ProductBinding, + staged: StagedTypeOnlyCollisionArm, +): Promise<void> { + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...T4_5_FILES, "src/app.ts": staged.source }, + async (workspace) => { + const buildContext = `T4-5 \`build --json\` over ${staged.name}`; + assertTypeOnlyCollisionFindings( + await buildFindings(product, workspace, buildContext), + staged, + buildContext, + ); + + // `check`: the same validation, exit 1 (SPEC 12.2). Staleness of the + // never-built workspace's derived files is 14.10's own business — + // set aside, the findings are exactly the arm's. + const checkContext = `T4-5 \`check --json\` over ${staged.name}`; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations and ` + + `exits 1 on the findings (SPEC 12.2, 2.4, 4.5)`, + ); + assertTypeOnlyCollisionFindings( + decodeFindingsReport( + parseJsonStdout(checkResult, checkContext), + checkContext, + ).findings.filter((finding) => finding.condition !== "14.10"), + staged, + checkContext, + ); + + // `occurrences --file src/app.ts`, answering on the failing workspace + // (SPEC 11.2): the code file's findings accompany (exit 1, the full + // answer still emitted), and the record set is empty — the two chains + // rooted at the collided identifier record no edge and no occurrence + // (5.7); their positions reach consumers through the findings alone. + const occContext = `T4-5 \`occurrences --file src/app.ts\` over ${staged.name}`; + const occResult = await expectExit( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + 1, + `${occContext} — the answer carries the domain's findings, so exit ` + + `1 with the full answer document (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertTypeOnlyCollisionFindings( + report.findings, + staged, + `${occContext}: the code file's findings accompany the answer ` + + `(SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences.map(t45OccurrenceTuple), + [], + `${occContext}: no record for the marker or the \`text(...)\` call — ` + + `a chain rooted at an identifier two imports bind, either type-only ` + + `or not, records no edge and no occurrence, its position reported ` + + `by its finding's range alone; never the type-only exemption's ` + + `silence (SPEC 2.4, 4.5, 5.7, 11.2, T4-4)`, + ); + }, + ); +} + +const T4_5 = defineProductTest({ + id: "T4-5", + title: + "type-only import collisions — an identifier a spec module import binds that another import also binds, either or both type-only (a second spec module's type-only default, a type-only non-spec binding, a value-level non-spec binding beside a type-only spec import, both type-only), staged in both declaration orders, roots no chain: `build` and `check` report 14.15 locating both declarations and 14.7 for the marker and the `text` call (exit 1), and `occurrences --file` on the failing workspace lists no record for them beside the findings — never the type-only exemption's silence (SPEC 2.4, 4, 4.5, 5.7, 11.2, 14.7, 14.15)", + run: async (product) => { + for (const staged of T4_5_ARMS) { + await assertTypeOnlyCollisionArm(product, staged); + } + }, +}); + /** TEST-SPEC §4 preamble, in canonical ID order (SUITE-12). */ export const section4Tests: readonly ProductTestEntry[] = [ T4_1, T4_2, T4_3, T4_4, + T4_5, ]; diff --git a/test/suite/registry/section-5.1-5.3.ts b/test/suite/registry/section-5.1-5.3.ts index 7a12e61c..bf61aee1 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 @@ -47,8 +50,12 @@ import { import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertEdgeSetEqual, buildFindings, @@ -57,15 +64,21 @@ import { runJson, } from "./support.js"; -// Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Minimal declarative configuration (SPEC 7): exactly one spec group. Every +// T5.3-1 cycle arm past the first stages it in a workspace created after the +// body's first invocation, so it is a staged-source record (S-9's timing +// clause; test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T5.3-1 xspec.config.ts — exactly one spec group, every cycle arm's workspace", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // One spec group plus one code group (SPEC 7.2): TypeScript files under // `src/` are discovered code sources, so `build` analyzes their spec-module @@ -84,8 +97,8 @@ export default defineConfig({ /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -118,45 +131,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 +151,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 +177,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)}`, ); } } @@ -381,9 +366,21 @@ const T5_2_1 = defineProductTest({ /** One cycle fixture: its files plus the CycleExpectation it stages. */ interface CycleArm extends CycleExpectation { readonly name: string; - readonly files: Readonly<Record<string, string>>; + readonly files: Readonly<Record<string, InitialFileContents>>; } +// The self-`depends` arm's source, exported: T14-4's and T14-6's sweeps +// (section-14.ts) stage the same bytes as their 14.9 entry's specs/a.mdx +// (T5.3-1 is 14.9's primary test) — ONE staged-source record (S-9). +export const SELF_DEPENDS_STAGED = stagedMdx( + "T5.3-1/T14-4/T14-6 self-depends specs/A.mdx (T14-4's and T14-6's sweep specs/a.mdx, the 14.9 dependency-cycle entry)", + ['<S id="s" d={"s"}>', "Depends on itself.", "</S>", ""].join("\n"), +); + +// Every arm past the first stages its files after the first arm's `check`: +// the `.mdx` entries are staged-source records, judged before any product +// exists (S-9, test/self/s9-staged-sources.test.ts); the first arm's are +// converted uniformly. const T5_3_1_ARMS: readonly CycleArm[] = [ { // Both directions need external references, hence mutual imports — the @@ -392,22 +389,28 @@ const T5_3_1_ARMS: readonly CycleArm[] = [ "a `depends` cycle A→B→A across files (with its unavoidable mutual-" + "import spec import cycle)", files: { - "specs/A.mdx": [ - 'import B from "./B.xspec"', - "", - '<S id="a" d={B.b}>', - "A behavior.", - "</S>", - "", - ].join("\n"), - "specs/B.mdx": [ - 'import A from "./A.xspec"', - "", - '<S id="b" d={A.a}>', - "B behavior.", - "</S>", - "", - ].join("\n"), + "specs/A.mdx": stagedMdx( + "T5.3-1 cross-file depends cycle specs/A.mdx", + [ + 'import B from "./B.xspec"', + "", + '<S id="a" d={B.b}>', + "A behavior.", + "</S>", + "", + ].join("\n"), + ), + "specs/B.mdx": stagedMdx( + "T5.3-1 cross-file depends cycle specs/B.mdx", + [ + 'import A from "./A.xspec"', + "", + '<S id="b" d={A.a}>', + "B behavior.", + "</S>", + "", + ].join("\n"), + ), }, cycle: ["specs/A.mdx#a", "specs/B.mdx#b"], importCycleFiles: ["specs/A.mdx", "specs/B.mdx"], @@ -418,44 +421,40 @@ const T5_3_1_ARMS: readonly CycleArm[] = [ // ancestor shapes are the two arms below). name: "a mixed cycle through `contains` + `embeds`", files: { - "specs/A.mdx": [ - '<S id="p">', - "P behavior.", - "", - '<S id="p.q">', - 'Q embeds: {text("x")}', - "</S>", - "</S>", - "", - '<S id="x">', - 'X embeds: {text("p")}', - "</S>", - "", - ].join("\n"), + "specs/A.mdx": stagedMdx( + "T5.3-1 mixed contains+embeds cycle specs/A.mdx", + [ + '<S id="p">', + "P behavior.", + "", + '<S id="p.q">', + 'Q embeds: {text("x")}', + "</S>", + "</S>", + "", + '<S id="x">', + 'X embeds: {text("p")}', + "</S>", + "", + ].join("\n"), + ), }, cycle: ["specs/A.mdx#p", "specs/A.mdx#p.q", "specs/A.mdx#x"], }, { name: "a self-`depends` (a dependency cycle of length one)", files: { - "specs/A.mdx": [ - '<S id="s" d={"s"}>', - "Depends on itself.", - "</S>", - "", - ].join("\n"), + "specs/A.mdx": SELF_DEPENDS_STAGED, }, cycle: ["specs/A.mdx#s"], }, { name: "a self-`embeds` (a dependency cycle of length one)", files: { - "specs/A.mdx": [ - '<S id="s">', - 'Embeds itself: {text("s")}', - "</S>", - "", - ].join("\n"), + "specs/A.mdx": stagedMdx( + "T5.3-1 self-embeds specs/A.mdx", + ['<S id="s">', 'Embeds itself: {text("s")}', "</S>", ""].join("\n"), + ), }, cycle: ["specs/A.mdx#s"], }, @@ -464,40 +463,46 @@ const T5_3_1_ARMS: readonly CycleArm[] = [ // chain runs through: a → a.b → a.b.c → a. name: "a section depending on its own ancestor (grandparent)", files: { - "specs/A.mdx": [ - '<S id="a">', - "Alpha behavior.", - "", - '<S id="a.b">', - "Beta behavior.", - "", - '<S id="a.b.c" d={"a"}>', - "Gamma depends on its grandparent.", - "</S>", - "</S>", - "</S>", - "", - ].join("\n"), + "specs/A.mdx": stagedMdx( + "T5.3-1 depends on own grandparent specs/A.mdx", + [ + '<S id="a">', + "Alpha behavior.", + "", + '<S id="a.b">', + "Beta behavior.", + "", + '<S id="a.b.c" d={"a"}>', + "Gamma depends on its grandparent.", + "</S>", + "</S>", + "</S>", + "", + ].join("\n"), + ), }, cycle: ["specs/A.mdx#a", "specs/A.mdx#a.b", "specs/A.mdx#a.b.c"], }, { name: "a section embedding its own ancestor (grandparent)", files: { - "specs/A.mdx": [ - '<S id="a">', - "Alpha behavior.", - "", - '<S id="a.b">', - "Beta behavior.", - "", - '<S id="a.b.c">', - 'Gamma embeds its grandparent: {text("a")}', - "</S>", - "</S>", - "</S>", - "", - ].join("\n"), + "specs/A.mdx": stagedMdx( + "T5.3-1 embeds own grandparent specs/A.mdx", + [ + '<S id="a">', + "Alpha behavior.", + "", + '<S id="a.b">', + "Beta behavior.", + "", + '<S id="a.b.c">', + 'Gamma embeds its grandparent: {text("a")}', + "</S>", + "</S>", + "</S>", + "", + ].join("\n"), + ), }, cycle: ["specs/A.mdx#a", "specs/A.mdx#a.b", "specs/A.mdx#a.b.c"], }, diff --git a/test/suite/registry/section-5.4.ts b/test/suite/registry/section-5.4.ts index 3b26cfef..44118b5d 100644 --- a/test/suite/registry/section-5.4.ts +++ b/test/suite/registry/section-5.4.ts @@ -41,24 +41,33 @@ import { import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. No code // groups exist in any fixture here, so impacted code never enters play. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// T5.4-1 and T5.4-2 each stage it again in a workspace created after their +// first invocations, so it is a staged-source record (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T5.4-1/T5.4-2 xspec.config.ts — exactly one spec group, every workspace's", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); /** Stage a fresh spec-only workspace, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -154,17 +163,17 @@ async function readSource( } /** - * Replace exactly one occurrence of `search` — the manual "author edits the - * file" step of these fixtures. The anchors are spec-mandated rewrite forms + * Diagnose that `search` occurs exactly once in `content` — the anchor of + * the manual "author edits the file" step of these fixtures, which + * `TestWorkspace.edit()` then stages (the first occurrence replaced in the + * product-rewritten bytes, judged at staging time under the path's S-9 + * declaration: no harness constant equals those bytes, so the staged-source + * ledger has nothing to hold). The anchors are spec-mandated rewrite forms * (SPEC 6.4), so a missing or ambiguous anchor is a diagnosed assertion - * failure about the product's rewriting, never a harness crash (H-8). + * failure about the product's rewriting, never a harness crash (H-8) — + * checked here, before `edit()`'s own plain refusal could see it. */ -function replaceOnce( - content: string, - search: string, - replacement: string, - context: string, -): string { +function anchorOnce(content: string, search: string, context: string): void { const first = content.indexOf(search); if (first === -1) { fail( @@ -180,9 +189,6 @@ function replaceOnce( JSON.stringify(content), ); } - return ( - content.slice(0, first) + replacement + content.slice(first + search.length) - ); } // --------------------------------------------------------------------------- @@ -242,25 +248,36 @@ const REINTRO_APPENDED = [ "", ].join("\n"); +// The rename-rewritten file's last bytes — `f`'s closing run (" end.", the +// closing tag, the terminator), which the rename leaves in place (SPEC 6.4: +// minimal in-place edits) — the anchor behind which the appended authorship +// is staged through `TestWorkspace.edit()`. +const REINTRO_TAIL = " end.\n</S>\n"; + // Fixture 2 — the journaled variant: the vacated identity is re-borne through // the journal (`rename a→b`, then `rename c→a`) rather than by authorship. // `w` depends on both bearers. const JOURNALED_W = "specs/A.mdx#w"; -const JOURNALED_BASELINE = [ - '<S id="a">', - "Alpha behavior.", - "</S>", - "", - '<S id="c">', - "Gamma behavior.", - "</S>", - "", - '<S id="w" d={["a", "c"]}>', - "Depends on both.", - "</S>", - "", -].join("\n"); +// Created after fixture 1's invocations: a staged-source record (S-9, +// test/self/s9-staged-sources.test.ts). +const JOURNALED_BASELINE = stagedMdx( + "T5.4-1 specs/A.mdx the journaled-variant baseline (fixture 2)", + [ + '<S id="a">', + "Alpha behavior.", + "</S>", + "", + '<S id="c">', + "Gamma behavior.", + "</S>", + "", + '<S id="w" d={["a", "c"]}>', + "Depends on both.", + "</S>", + "", + ].join("\n"), +); const T5_4_1 = defineProductTest({ id: "T5.4-1", @@ -294,13 +311,35 @@ const T5_4_1 = defineProductTest({ REINTRO_ROOT, "T5.4-1 reading the product-rewritten source after the rename", ); - const respelled = replaceOnce( + anchorOnce( renamed, 'Embeds: {text("b")}', - 'Embeds: {text("a")}', 'T5.4-1 re-spelling `e`\'s embedding back to "a" after authoring N2', ); - await workspace.file(REINTRO_ROOT, respelled + REINTRO_APPENDED); + await workspace.edit( + REINTRO_ROOT, + 'Embeds: {text("b")}', + 'Embeds: {text("a")}', + ); + // The appended authorship (N2 and `g`): an edit extending the file's + // last bytes, `f`'s closing run, which the rename must have left as + // the file's end (SPEC 6.4: minimal in-place edits) — diagnosed. + const appendContext = + "T5.4-1 appending N2 and `g` after the rename-rewritten `f`"; + anchorOnce(renamed, REINTRO_TAIL, appendContext); + if (!renamed.endsWith(REINTRO_TAIL)) { + fail( + `${appendContext}: expected the rewritten source to end with ` + + `${JSON.stringify(REINTRO_TAIL)} (SPEC 6.4: minimal in-place ` + + `edits leave the file's tail in place); source: ` + + JSON.stringify(renamed), + ); + } + await workspace.edit( + REINTRO_ROOT, + REINTRO_TAIL, + REINTRO_TAIL + REINTRO_APPENDED, + ); await buildOk( product, workspace, @@ -584,16 +623,21 @@ const SPELLING_B_SOURCE = ['<S id="b">', "Target behavior.", "</S>", ""].join( // the two static quote styles (SPEC 2.4). const MOVE_T = "specs/A.mdx#t"; const MOVE_R = "specs/A.mdx#r"; -const MOVE_A_SOURCE = [ - '<S id="t">', - "Target behavior.", - "</S>", - "", - '<S id="r" d={"t"}>', - "Referrer behavior.", - "</S>", - "", -].join("\n"); +// The move fixture is created after the rename fixture's invocations: a +// staged-source record (S-9, test/self/s9-staged-sources.test.ts). +const MOVE_A_SOURCE = stagedMdx( + "T5.4-2 specs/A.mdx the move fixture", + [ + '<S id="t">', + "Target behavior.", + "</S>", + "", + '<S id="r" d={"t"}>', + "Referrer behavior.", + "</S>", + "", + ].join("\n"), +); const T5_4_2 = defineProductTest({ id: "T5.4-2", @@ -693,15 +737,12 @@ const T5_4_2 = defineProductTest({ "specs/A.mdx", "T5.4-2 reading the source for the manual computed→dot re-spelling", ); - await workspace.file( - "specs/A.mdx", - replaceOnce( - current, - 'B["b3"]', - "B.b3", - "T5.4-2 manually re-spelling the computed access to dot access", - ), + anchorOnce( + current, + 'B["b3"]', + "T5.4-2 manually re-spelling the computed access to dot access", ); + await workspace.edit("specs/A.mdx", 'B["b3"]', "B.b3"); await buildOk( product, workspace, @@ -796,17 +837,14 @@ const T5_4_2 = defineProductTest({ "specs/A.mdx", "T5.4-2 reading the source for the manual quote-style re-spelling", ); - await workspace.file( - "specs/A.mdx", - replaceOnce( - current, - 'd={"t"}', - "d={'t'}", - "T5.4-2 manually re-spelling the local string between quote styles " + - "(the move back must have produced a double-quoted local " + - "reference, SPEC 6.4)", - ), + anchorOnce( + current, + 'd={"t"}', + "T5.4-2 manually re-spelling the local string between quote styles " + + "(the move back must have produced a double-quoted local " + + "reference, SPEC 6.4)", ); + await workspace.edit("specs/A.mdx", 'd={"t"}', "d={'t'}"); await buildOk( product, workspace, diff --git a/test/suite/registry/section-5.5.ts b/test/suite/registry/section-5.5.ts index 8bb77db7..48db4973 100644 --- a/test/suite/registry/section-5.5.ts +++ b/test/suite/registry/section-5.5.ts @@ -57,8 +57,12 @@ import { import { assertAcrossDirectoriesDeterministic } from "../../helpers/determinism.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, @@ -69,18 +73,37 @@ import { // Minimal declarative configuration (SPEC 7): exactly one spec group. No code // groups exist in any fixture here, so impacted code never enters play. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// T5.5-2 stages it again in its kind-distinction workspace, created after +// its first invocations, so it is a staged-source record (S-9's timing +// clause; test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T5.5-2 xspec.config.ts — exactly one spec group, every workspace's", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +// Every `.mdx` source a body below stages after its base-state `build` — an +// arm's variant, an edited fixture — is a staged-source record created at +// module load from the same template call the staging used, so that the S-9 +// self-test (test/self/s9-staged-sources.test.ts) judges it well-formed +// before any product exists; the initial `files` of each workspace stay +// plain contents (S-7's sweep reaches them against the stub). + +/** An arm's variant as a staged-source record, named with the arm's label. */ +interface StagedArm { + readonly label: string; + readonly source: StagedMdx; +} /** Stage a fresh spec-only workspace, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -384,77 +407,101 @@ const OWN_P_SUBTREE_TOGGLED = "Twin text.\nTwin text.\n" + "tail run\n"; -const OWNHASH_CHANGED_ARMS: readonly { - readonly label: string; - readonly source: string; -}[] = [ - { - label: "an own-text run is edited", - source: ownHashSource({ tailRun: "tail run, edited" }), - }, - { - label: "a child is added", - source: ownHashSource({ withExtraChild: true }), - }, - { - label: "a child is removed", - source: ownHashSource({ withC1: false }), - }, - { - label: - "two byte-identical children are reordered (identical text, distinct " + +// The changed arms, each staged after the base-state build — a staged-source +// record per row, named with the row's label (S-9). +const ownHashArm = (label: string, shape: OwnHashShape): StagedArm => ({ + label, + source: stagedMdx(`T5.5-2 ${label}`, ownHashSource(shape)), +}); +const OWNHASH_CHANGED_ARMS: readonly StagedArm[] = [ + ownHashArm("an own-text run is edited", { tailRun: "tail run, edited" }), + ownHashArm("a child is added", { withExtraChild: true }), + ownHashArm("a child is removed", { withC1: false }), + ownHashArm( + "two byte-identical children are reordered (identical text, distinct " + "canonical identities at the excision points)", - source: ownHashSource({ twinOrder: ["p.c3", "p.c2"] }), - }, - { - label: "an embedded reference is added", - source: ownHashSource({ tailRun: 'tail run {text("t2")}' }), - }, - { - label: "an embedded reference is removed", - source: ownHashSource({ firstRun: "run0 run1" }), - }, - { - label: "an embedded reference is retargeted", - source: ownHashSource({ firstRun: 'run0 {text("t2")} run1' }), - }, - { - label: - "an embedded reference is repositioned between runs (the two " + + { twinOrder: ["p.c3", "p.c2"] }, + ), + ownHashArm("an embedded reference is added", { + tailRun: 'tail run {text("t2")}', + }), + ownHashArm("an embedded reference is removed", { firstRun: "run0 run1" }), + ownHashArm("an embedded reference is retargeted", { + firstRun: 'run0 {text("t2")} run1', + }), + ownHashArm( + "an embedded reference is repositioned between runs (the two " + "embeddings exchange positions around byte-identical runs — the run " + "bytes and the reference multiset are unchanged, only positions differ)", - source: ownHashSource({ middleRun: 'L {text("t2")} M {text("t")} R' }), - }, + { middleRun: 'L {text("t2")} M {text("t")} R' }, + ), ]; -// Kind-distinction fixture: at the baseline `p` holds child `p.k` between the -// runs "before\n" and "after\n". A journaled section move relocates the child -// to another file, and a manual edit embeds the moved node (imported form) at -// the child's former position, glued so the excised expression leaves `after` -// as remaining line content — the own-content sequences of the two states are -// byte-identical runs around one reference of the same canonical identity -// (the journal walks B.mdx#k back to A.mdx#p.k, SPEC 5.4), differing only in -// reference kind: child vs embedding (SPEC 1.6, 5.5). +// The unchanged arms and the line-drop toggle, staged after the changed arms +// — staged-source records. +const T5_5_2_CHILD_EDITED = stagedMdx( + "T5.5-2 specs/A.mdx with the child p.c1's text edited (the unchanged arm)", + ownHashSource({ c1Text: "Child one, edited." }), +); +const T5_5_2_TARGET_EDITED = stagedMdx( + "T5.5-2 specs/A.mdx with the embedded target t's text edited (the unchanged arm)", + ownHashSource({ targetText: "Target one, edited." }), +); +const T5_5_2_TOGGLED = stagedMdx( + "T5.5-2 specs/A.mdx with the toggle target t3 made non-empty (the line-drop-toggle arm)", + ownHashSource({ toggleTargetText: "Now present." }), +); + +// Kind-distinction fixture, in TEST-SPEC T5.5-2's pinned geometry: the child +// construct and its replacement are in-line — within one line of `p`, flanked +// by content on that line (`foo <S id="p.k">Kid text.</S> baz` at the +// baseline, `foo {text(B.k)} baz` after). A journaled section move relocates +// the child to another file — its own characters deleted in place, the line +// left holding `foo ` and ` baz` (SPEC 6.5) — and a manual edit embeds the +// moved node (imported form) at the child's exact former position. Both +// states compose `p` from the same line head and tail (`kindParent`), so +// every other byte of `p` is identical; the import stands at the file's top, +// outside `p`. The own-content sequences of the two states are then the +// byte-identical runs `foo ` and ` baz` (with the line's terminator) around +// one reference of the same canonical identity (the journal walks B.mdx#k +// back to A.mdx#p.k, SPEC 5.4), differing only in reference kind: child vs +// embedding (SPEC 1.6, 5.5). The geometry is what keeps the arm live: on its +// own lines a construct's straddling lines drop with their terminators +// (SPEC 3), while an own-line `{text(...)}` keeps its line in own content +// (the excised expression counts as remaining line content, SPEC 1.6), so +// the runs would differ as well and a product hashing references +// kind-blindly would pass the arm vacuously. const KIND_P = "specs/A.mdx#p"; -const KIND_BASELINE = [ - '<S id="p">', - "before", - '<S id="p.k">', - "Kid text.", - "</S>", - "after", - "</S>", - "", -].join("\n"); -const KIND_MANUAL = [ - 'import B from "./B.xspec"', - "", - '<S id="p">', - "before", - "{text(B.k)}after", - "</S>", - "", -].join("\n"); +const KIND_LINE_HEAD = "foo "; +const KIND_LINE_TAIL = " baz"; +const KIND_CHILD_TEXT = "Kid text."; + +/** `specs/A.mdx`'s section `p`, `construct` standing in-line in its one line. */ +function kindParent(construct: string): string { + return [ + '<S id="p">', + `${KIND_LINE_HEAD}${construct}${KIND_LINE_TAIL}`, + "</S>", + "", + ].join("\n"); +} + +// `p`'s subtree text in both states (SPEC 1.6 and 3 fix these bytes): its tag +// lines drop with their terminators, and at the construct's position stands +// the child's contribution at the baseline and the embedding's expansion — +// the moved node's subtree text, the same characters — after. +const KIND_P_SUBTREE = `${KIND_LINE_HEAD}${KIND_CHILD_TEXT}${KIND_LINE_TAIL}\n`; +// The kind arm's workspace is created after the matrix workspace's +// invocations: its baseline is a staged-source record (S-9, +// test/self/s9-staged-sources.test.ts). +const KIND_BASELINE = stagedMdx( + "T5.5-2 specs/A.mdx with the in-line child construct at its position (the kind arm's baseline)", + kindParent(`<S id="p.k">${KIND_CHILD_TEXT}</S>`), +); +const KIND_MANUAL = stagedMdx( + "T5.5-2 specs/A.mdx with the moved child embedded in imported form at its former in-line position (the kind arm)", + ['import B from "./B.xspec"', "", kindParent("{text(B.k)}")].join("\n"), +); const T5_5_2 = defineProductTest({ id: "T5.5-2", @@ -524,10 +571,7 @@ const T5_5_2 = defineProductTest({ // Unchanged: a child's text is edited — only the child's hashes // change; the parent's own content holds the child as an identity at // an excision point, not its text (SPEC 1.6, 5.5). - await workspace.file( - "specs/A.mdx", - ownHashSource({ c1Text: "Child one, edited." }), - ); + await workspace.file("specs/A.mdx", T5_5_2_CHILD_EDITED); const childEditContext = "T5.5-2 unchanged arm (child's text edited)"; const pAfterChildEdit = await queryHashes( product, @@ -563,10 +607,7 @@ const T5_5_2 = defineProductTest({ // Unchanged: an embedded target's text is edited (non-empty to // non-empty) — the target's text is no part of the embedder's own // content (SPEC 1.6). - await workspace.file( - "specs/A.mdx", - ownHashSource({ targetText: "Target one, edited." }), - ); + await workspace.file("specs/A.mdx", T5_5_2_TARGET_EDITED); const targetEditAfter = await queryHashes( product, workspace, @@ -586,10 +627,7 @@ const T5_5_2 = defineProductTest({ // ownHash is byte-identical: for own content the excised expression // counts as remaining line content and the target's text is no part // of it (SPEC 1.6). - await workspace.file( - "specs/A.mdx", - ownHashSource({ toggleTargetText: "Now present." }), - ); + await workspace.file("specs/A.mdx", T5_5_2_TOGGLED); const toggleContext = "T5.5-2 line-drop-toggle arm"; const t3Toggled = await queryNode( product, @@ -633,14 +671,22 @@ const T5_5_2 = defineProductTest({ await buildOk( product, workspace, - "T5.5-2 kind arm: `build` over the child-construct baseline", + "T5.5-2 kind arm: `build` over the in-line child-construct baseline", ); - const before = await queryHashes( + const before = await queryNode( product, workspace, KIND_P, "T5.5-2 kind arm, baseline:", ); + // Fixture anchor (SPEC 1.6, 3): the child's contribution stands in-line + // between the runs `foo ` and ` baz`. + assertBytesEqual( + before.subtreeText, + KIND_P_SUBTREE, + "T5.5-2 kind arm: baseline subtree text of `p` — the in-line child's " + + "contribution between the line's head and tail (SPEC 1.6, 3)", + ); await expectExit( product, @@ -656,18 +702,28 @@ const T5_5_2 = defineProductTest({ product, workspace, "T5.5-2 kind arm: `build` after embedding the moved node (imported " + - "form) at the child's former position", + "form) at the child's former in-line position", ); - const after = await queryHashes( + const after = await queryNode( product, workspace, KIND_P, "T5.5-2 kind arm, after the replacement:", ); + // Fixture anchor: the embedding expands at the child's exact former + // position, every surrounding byte identical, so `p`'s subtree text is + // byte-identical across the replacement (SPEC 1.6, 3, 6.5). + assertBytesEqual( + after.subtreeText, + KIND_P_SUBTREE, + "T5.5-2 kind arm: subtree text of `p` after the replacement — the " + + "embedding expands to the moved node's subtree text at the child's " + + "exact former position (SPEC 1.6, 3, 6.5)", + ); assertHashChanged( - before.ownHash, - after.ownHash, + before.hashes.ownHash, + after.hashes.ownHash, "the parent's ownHash — its own-content sequences are equal runs " + "around one reference of the same canonical identity differing " + "only in kind (child vs embedding, SPEC 1.6, 5.4, 5.5)", @@ -760,6 +816,49 @@ const SUB_S1A = "specs/A.mdx#s1.a"; const SUB_S2 = "specs/A.mdx#s2"; const SUB_S2T = "specs/A.mdx#s2.t"; +// The changed arms — each at depth 2 so `s1`'s own content is untouched: the +// subtreeHash change travels through the descendant chain alone — staged +// after the base-state build: a staged-source record per row, named with the +// row's label (S-9). +const subtreeArm = (label: string, shape: SubtreeShape): StagedArm => ({ + label, + source: stagedMdx(`T5.5-3 ${label}`, subtreeSource(shape)), +}); +const SUBTREE_CHANGED_ARMS: readonly StagedArm[] = [ + subtreeArm("a descendant is added", { + grandchildren: [ + ["g1", "Gamma one."], + ["g2", "Gamma two."], + ["g3", "Gamma three."], + ], + }), + subtreeArm("a descendant is removed", { + grandchildren: [["g2", "Gamma two."]], + }), + subtreeArm("descendants are reordered", { + grandchildren: [ + ["g2", "Gamma two."], + ["g1", "Gamma one."], + ], + }), + subtreeArm("an in-subtree own-content change (a grandchild's run edited)", { + grandchildren: [ + ["g1", "Gamma one, edited."], + ["g2", "Gamma two."], + ], + }), +]; + +// The unchanged arms, staged after the changed arms — staged-source records. +const T5_5_3_SIBLING_EDITED = stagedMdx( + "T5.5-3 specs/A.mdx with the sibling subtree s2's intro edited (the unchanged arm)", + subtreeSource({ s2Intro: "S2 intro, edited." }), +); +const T5_5_3_TARGET_EDITED = stagedMdx( + "T5.5-3 specs/A.mdx with the embedded target s2.t's text edited (the unchanged arm)", + subtreeSource({ embedTargetText: "Embed target original, edited." }), +); + const T5_5_3 = defineProductTest({ id: "T5.5-3", title: @@ -800,46 +899,8 @@ const T5_5_3 = defineProductTest({ // Changed arms — each at depth 2 so `s1`'s own content is untouched: // the subtreeHash change travels through the descendant chain alone. - const changedArms: readonly { - readonly label: string; - readonly shape: SubtreeShape; - }[] = [ - { - label: "a descendant is added", - shape: { - grandchildren: [ - ["g1", "Gamma one."], - ["g2", "Gamma two."], - ["g3", "Gamma three."], - ], - }, - }, - { - label: "a descendant is removed", - shape: { grandchildren: [["g2", "Gamma two."]] }, - }, - { - label: "descendants are reordered", - shape: { - grandchildren: [ - ["g2", "Gamma two."], - ["g1", "Gamma one."], - ], - }, - }, - { - label: - "an in-subtree own-content change (a grandchild's run edited)", - shape: { - grandchildren: [ - ["g1", "Gamma one, edited."], - ["g2", "Gamma two."], - ], - }, - }, - ]; - for (const arm of changedArms) { - await workspace.file("specs/A.mdx", subtreeSource(arm.shape)); + for (const arm of SUBTREE_CHANGED_ARMS) { + await workspace.file("specs/A.mdx", arm.source); const after = await queryHashes( product, workspace, @@ -863,10 +924,7 @@ const T5_5_3 = defineProductTest({ // Unchanged: a sibling-subtree edit. Control: the sibling's own // subtreeHash changed, so the edit demonstrably registered. - await workspace.file( - "specs/A.mdx", - subtreeSource({ s2Intro: "S2 intro, edited." }), - ); + await workspace.file("specs/A.mdx", T5_5_3_SIBLING_EDITED); const siblingContext = "T5.5-3 unchanged arm (sibling-subtree edit)"; const s1AfterSibling = await queryHashes( product, @@ -896,10 +954,7 @@ const T5_5_3 = defineProductTest({ // Unchanged: an embedded target outside the subtree is edited — the // embedder's subtree carries the target as an identity, not its text // (SPEC 1.6, 5.5). Control: the target's subtreeHash changed. - await workspace.file( - "specs/A.mdx", - subtreeSource({ embedTargetText: "Embed target original, edited." }), - ); + await workspace.file("specs/A.mdx", T5_5_3_TARGET_EDITED); const targetContext = "T5.5-3 unchanged arm (embedded target outside the subtree edited)"; const s1AfterTarget = await queryHashes( @@ -1010,6 +1065,52 @@ const EFF_DUAL = "specs/A.mdx#dual"; const EFF_TWIN_A = "specs/A.mdx#twinA"; const EFF_TWIN_B = "specs/A.mdx#twinB"; +// The edge arms — the `d` edit sits on the child `q.inner` — staged after the +// base-state build: a staged-source record per row, named with the row's +// label (S-9). +const effectiveArm = (label: string, shape: EffectiveShape): StagedArm => ({ + label, + source: stagedMdx(`T5.5-4 ${label}`, effectiveSource(shape)), +}); +const EFFECTIVE_EDGE_ARMS: readonly StagedArm[] = [ + effectiveArm("a dependency edge is added in the subtree", { + innerAttrs: ' d={["t1", "t2"]}', + }), + effectiveArm("a dependency edge is removed in the subtree", { + innerAttrs: "", + }), + effectiveArm("a dependency edge is retargeted in the subtree", { + innerAttrs: ' d={"t2"}', + }), +]; + +// The remaining arms, staged in this order after the edge arms — +// staged-source records. +const T5_5_4_CHAIN_EDITED = stagedMdx( + "T5.5-4 specs/A.mdx with the dependency target chain's text edited (the transitive arm)", + effectiveSource({ chainText: "Chain tail, edited." }), +); +const T5_5_4_TWIN_A = stagedMdx( + "T5.5-4 specs/A.mdx with q.inner retargeted to twinA (the twin-retarget arm)", + effectiveSource({ innerAttrs: ' d={"twinA"}' }), +); +const T5_5_4_TWIN_B = stagedMdx( + "T5.5-4 specs/A.mdx with q.inner retargeted to twinB (the twin-retarget arm)", + effectiveSource({ innerAttrs: ' d={"twinB"}' }), +); +const T5_5_4_UNRELATED_EDITED = stagedMdx( + "T5.5-4 specs/A.mdx with the unrelated node's text edited (the unchanged arm)", + effectiveSource({ unrelatedText: "Unrelated text, edited." }), +); +const T5_5_4_DUAL_MINUS_D = stagedMdx( + "T5.5-4 specs/A.mdx with dual's `d` reference removed, the embedding kept (the per-edge-pairs arm)", + effectiveSource({ dualAttrs: "" }), +); +const T5_5_4_DUAL_MINUS_EMBED = stagedMdx( + "T5.5-4 specs/A.mdx with dual's embedding removed, the `d` reference kept (the per-edge-pairs arm)", + effectiveSource({ dualLine: "Dual intro. tail." }), +); + const T5_5_4 = defineProductTest({ id: "T5.5-4", title: @@ -1072,28 +1173,8 @@ const T5_5_4 = defineProductTest({ // edit sits on the child `q.inner`; the ancestor `q` must see its // effectiveHash change while its ownHash and subtreeHash stay // byte-identical (`d` props are no part of own content, SPEC 1.6, 3). - const edgeArms: readonly { - readonly label: string; - readonly innerAttrs: string; - }[] = [ - { - label: "a dependency edge is added in the subtree", - innerAttrs: ' d={["t1", "t2"]}', - }, - { - label: "a dependency edge is removed in the subtree", - innerAttrs: "", - }, - { - label: "a dependency edge is retargeted in the subtree", - innerAttrs: ' d={"t2"}', - }, - ]; - for (const arm of edgeArms) { - await workspace.file( - "specs/A.mdx", - effectiveSource({ innerAttrs: arm.innerAttrs }), - ); + for (const arm of EFFECTIVE_EDGE_ARMS) { + await workspace.file("specs/A.mdx", arm.source); const context = `T5.5-4 edge arm (${arm.label})`; const inner = await queryHashes( product, @@ -1139,10 +1220,7 @@ const T5_5_4 = defineProductTest({ // Transitive: editing `chain`'s text changes chain's effectiveHash, // hence t1's (dependency target), hence q.inner's, hence q's — while // q's own content and subtree are untouched. - await workspace.file( - "specs/A.mdx", - effectiveSource({ chainText: "Chain tail, edited." }), - ); + await workspace.file("specs/A.mdx", T5_5_4_CHAIN_EDITED); const transitiveContext = "T5.5-4 transitive arm (a dependency target's dependency edited)"; const qTransitive = await queryHashes( @@ -1174,10 +1252,7 @@ const T5_5_4 = defineProductTest({ // Retarget between the equal-effectiveHash twins: identities enter // the target pairs, so the two variants' hashes differ even though // the targets' effectiveHashes are equal. - await workspace.file( - "specs/A.mdx", - effectiveSource({ innerAttrs: ' d={"twinA"}' }), - ); + await workspace.file("specs/A.mdx", T5_5_4_TWIN_A); const twinContext = "T5.5-4 twin-retarget arm"; const innerOnA = await queryHashes( product, @@ -1191,10 +1266,7 @@ const T5_5_4 = defineProductTest({ EFF_Q, `${twinContext}, targeting twinA:`, ); - await workspace.file( - "specs/A.mdx", - effectiveSource({ innerAttrs: ' d={"twinB"}' }), - ); + await workspace.file("specs/A.mdx", T5_5_4_TWIN_B); const innerOnB = await queryHashes( product, workspace, @@ -1230,10 +1302,7 @@ const T5_5_4 = defineProductTest({ ); // Unchanged: an unrelated node changes. - await workspace.file( - "specs/A.mdx", - effectiveSource({ unrelatedText: "Unrelated text, edited." }), - ); + await workspace.file("specs/A.mdx", T5_5_4_UNRELATED_EDITED); const unrelatedContext = "T5.5-4 unchanged arm (unrelated node edited)"; assertSameJson( await queryHashes(product, workspace, EFF_Q, `${unrelatedContext}:`), @@ -1247,7 +1316,7 @@ const T5_5_4 = defineProductTest({ // byte-identical — the discriminating arm: a product deduplicating // pairs per distinct target sees {(t1, h)} on both sides and reports // no change. Removing the embedding alone changes it too. - await workspace.file("specs/A.mdx", effectiveSource({ dualAttrs: "" })); + await workspace.file("specs/A.mdx", T5_5_4_DUAL_MINUS_D); const minusDContext = "T5.5-4 per-edge-pairs arm (the `d` reference removed, the embedding kept)"; const dualMinusD = await queryHashes( @@ -1269,10 +1338,7 @@ const T5_5_4 = defineProductTest({ "per distinct target: two identical (t1, hash) pairs became one", minusDContext, ); - await workspace.file( - "specs/A.mdx", - effectiveSource({ dualLine: "Dual intro. tail." }), - ); + await workspace.file("specs/A.mdx", T5_5_4_DUAL_MINUS_EMBED); const minusEmbedContext = "T5.5-4 per-edge-pairs arm (the embedding removed, the `d` reference kept)"; const dualMinusEmbed = await queryHashes( @@ -1328,6 +1394,46 @@ const META_OTHER_FILE = ['<S id="other">', "Other file text.", "</S>", ""].join( "\n", ); +// The arms, each staged after the base-state build in the body's order — +// changed, unchanged, order-insensitivity — a staged-source record per row, +// named with the row's label (S-9). +const metadataArm = (label: string, shape: MetadataShape): StagedArm => ({ + label, + source: stagedMdx(`T5.5-5 ${label}`, metadataSource(shape)), +}); +// Changed arms: `d` target set, coverage attribute, tags. +const METADATA_CHANGED_ARMS: readonly StagedArm[] = [ + metadataArm("the `d` target set changes", { + mAttrs: ' d={["t1"]} tags="beta alpha"', + }), + metadataArm("the coverage attribute changes", { + mAttrs: ' d={["t1", "t2"]} coverage="none" tags="beta alpha"', + }), + metadataArm("the tags change", { + mAttrs: ' d={["t1", "t2"]} tags="beta gamma"', + }), +]; +// Unchanged arms (the iff's other direction): a text edit, and an added +// embedded reference — own content (1.6), surfacing through ownHash. +const METADATA_UNCHANGED_ARMS: readonly StagedArm[] = [ + metadataArm("the node's text is edited", { + mLine: "Meta node text, edited.", + }), + metadataArm("an embedded text(...) reference is added", { + mLine: 'Meta node text. {text("t3")}', + }), +]; +// Order-insensitivity arms: each differs from the committed baseline in +// exactly the reordering. +const METADATA_REORDER_ARMS: readonly StagedArm[] = [ + metadataArm("the references within one multi-element `d` array reordered", { + mAttrs: ' d={["t2", "t1"]} tags="beta alpha"', + }), + metadataArm("a multi-tag `tags` list reordered", { + mAttrs: ' d={["t1", "t2"]} tags="alpha beta"', + }), +]; + const T5_5_5 = defineProductTest({ id: "T5.5-5", title: @@ -1378,28 +1484,8 @@ const T5_5_5 = defineProductTest({ } // Changed arms: `d` target set, coverage attribute, tags. - const changedArms: readonly { - readonly label: string; - readonly mAttrs: string; - }[] = [ - { - label: "the `d` target set changes", - mAttrs: ' d={["t1"]} tags="beta alpha"', - }, - { - label: "the coverage attribute changes", - mAttrs: ' d={["t1", "t2"]} coverage="none" tags="beta alpha"', - }, - { - label: "the tags change", - mAttrs: ' d={["t1", "t2"]} tags="beta gamma"', - }, - ]; - for (const arm of changedArms) { - await workspace.file( - "specs/A.mdx", - metadataSource({ mAttrs: arm.mAttrs }), - ); + for (const arm of METADATA_CHANGED_ARMS) { + await workspace.file("specs/A.mdx", arm.source); const after = await queryHashes( product, workspace, @@ -1418,24 +1504,8 @@ const T5_5_5 = defineProductTest({ // embedded reference, which is own content (1.6) and surfaces through // ownHash, never metadataHash. The ownHash change is the control that // each edit registered. - const unchangedArms: readonly { - readonly label: string; - readonly mLine: string; - }[] = [ - { - label: "the node's text is edited", - mLine: "Meta node text, edited.", - }, - { - label: "an embedded text(...) reference is added", - mLine: 'Meta node text. {text("t3")}', - }, - ]; - for (const arm of unchangedArms) { - await workspace.file( - "specs/A.mdx", - metadataSource({ mLine: arm.mLine }), - ); + for (const arm of METADATA_UNCHANGED_ARMS) { + await workspace.file("specs/A.mdx", arm.source); const context = `T5.5-5 unchanged arm (${arm.label})`; const after = await queryHashes( product, @@ -1463,25 +1533,8 @@ const T5_5_5 = defineProductTest({ // over it must report no categories at all (target sets enter sorted // by canonical identity, tags sorted, SPEC 5.5) — no requirement // entries, per the suite's fixed T1.5-1 interpretation of 9.3. - const reorderArms: readonly { - readonly label: string; - readonly mAttrs: string; - }[] = [ - { - label: - "the references within one multi-element `d` array reordered", - mAttrs: ' d={["t2", "t1"]} tags="beta alpha"', - }, - { - label: "a multi-tag `tags` list reordered", - mAttrs: ' d={["t1", "t2"]} tags="alpha beta"', - }, - ]; - for (const arm of reorderArms) { - await workspace.file( - "specs/A.mdx", - metadataSource({ mAttrs: arm.mAttrs }), - ); + for (const arm of METADATA_REORDER_ARMS) { + await workspace.file("specs/A.mdx", arm.source); const context = `T5.5-5 order-insensitivity arm (${arm.label})`; assertSameJson( await queryHashes(product, workspace, META_M, `${context}:`), diff --git a/test/suite/registry/section-5.6.ts b/test/suite/registry/section-5.6.ts index 6662fdbc..4c40555c 100644 --- a/test/suite/registry/section-5.6.ts +++ b/test/suite/registry/section-5.6.ts @@ -40,6 +40,7 @@ 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 { stagedMdx } from "../../helpers/staged-mdx.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit } from "./support.js"; @@ -929,6 +930,16 @@ const metaSource = (coverage: string, tags: string): string => "", ].join("\n"); +// Arm 2's staging follows arm 1's `build` and `impact` (the body's first +// product invocations), so it is a ledger record (S-9's before-any-product +// clause; helpers/staged-mdx.ts) — the same template call, moved to module +// level. Arm 1's staging precedes the body's first invocation and stays a +// plain `file()` (S-7's sweep reaches it against the stub). +const T5_6_4_TAGS_EDITED = stagedMdx( + "T5.6-4 arm 2: tags alpha beta to alpha gamma, coverage none", + metaSource("none", "alpha gamma"), +); + const T5_6_4 = defineProductTest({ id: "T5.6-4", title: @@ -969,7 +980,7 @@ const T5_6_4 = defineProductTest({ // Arm 2: tags `alpha beta` → `alpha gamma` against the second // baseline, so the tags edit is the only difference. - await workspace.file(T4_META, metaSource("none", "alpha gamma")); + await workspace.file(T4_META, T5_6_4_TAGS_EDITED); await buildOk( product, workspace, diff --git a/test/suite/registry/section-5.7.ts b/test/suite/registry/section-5.7.ts new file mode 100644 index 00000000..45a52e1c --- /dev/null +++ b/test/suite/registry/section-5.7.ts @@ -0,0 +1,2351 @@ +// 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertEdgeSetEqual, + assertFindingIdentities, + assertFindingLocated, + assertFindingLocatesExactly, + assertSameJson, + buildFindings, + buildOk, + byteWindow, + expectExit, + findingsInSourceOrder, + 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. T5.7-4's collision +// workspace and T11.3-1's later fixture workspaces (section-11.3.ts) stage it +// after a product invocation, so it is a staged-source record (S-9's timing +// clause; test/self/s9-staged-sources.test.ts). +export const SPEC_AND_CODE_CONFIG = stagedTs( + "T5.7-4/T11.3-1 xspec.config.ts — one spec group and one code group", + `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. + */ +export 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. */ +export 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; + +// T11.3-1 restages this fixture after its first product invocation, so +// S-7's sweep never reaches those stagings against the stub: staged-source +// records (helpers/staged-mdx.ts; S-9's before-any-product clause) made +// from the strings the expectation tables keep, staged here and there. +export const SPAN_BASE_STAGED = stagedMdx( + "T5.7-2/T11.3-1 specs/BASE.mdx (the spans fixture)", + SPAN_BASE_SOURCE, +); +export const SPAN_MAIN_STAGED = stagedMdx( + "T5.7-2/T11.3-1 specs/MAIN.mdx (the spans fixture)", + SPAN_MAIN_SOURCE, +); + +// 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)`, + ); + } +} + +// Token bounds inside a `d` value (TEST-SPEC T5.7-2's second staging). A `d` +// occurrence's bounds are ECMAScript tokens' (SPEC 1.4, 14): the span is the +// reference's own expression, whatever whitespace and comments its braces +// hold beside it — a product excluding ASCII whitespace alone spans into a +// U+00A0, U+FEFF, or U+3000 neighbor and fails the first three arms, and one +// whose class is ASCII's plus the code points SPEC 14.20 names (U+00A0, +// U+FEFF, U+2028, U+2029) spans into the third arm's U+3000 and U+202F — +// Unicode 15.1 space separators ECMAScript's whitespace takes (SPEC 14.20) +// that 14.20 does not name — and fails it (T2.7-4). Line comments inside +// the value take 14.20's deletion judgement and run-on rule as a container's +// (2.7, T2.7-4): `d={// c` U+000A `BASE.a}` ends its comment at the +// terminator, and in the run-on twin `d={// c}` U+000A `BASE.a}` the first +// `}` lies on the commented-out line and closes nothing, the value running to +// the second `}` — each a well-formed `d` (no 14.20, no 14.8, `build` exit 0) +// whose one occurrence spans `BASE.a` alone; a product scanning an attribute +// value to its first `}` reports 14.8 or 14.20 there and fails. Each form is +// staged in a workspace of its own, so a form the product mishandles is +// diagnosed by name without masking the others. The first staging's +// multi-byte `pre` section precedes the value, so byte offsets diverge from +// code-point and UTF-16 counting for every form, and the U+00A0 (2 bytes), +// U+FEFF (3 bytes), and U+3000 (3 bytes) spellings shift the span's own start +// as well. The four code points are composed from their values, never spelled +// as escapes, so the staged bytes are exactly those the entry names. +const NBSP = String.fromCodePoint(0xa0); // U+00A0 — no-break space +const ZWNBSP = String.fromCodePoint(0xfeff); // U+FEFF — inside the file, so no byte-order mark +const IDEOGRAPHIC_SPACE = String.fromCodePoint(0x3000); // U+3000 — a Unicode 15.1 space separator (Zs) +const NARROW_NBSP = String.fromCodePoint(0x202f); // U+202F — narrow no-break space, a Unicode 15.1 space separator (Zs) +const LF = "\n"; // U+000A — the line terminator that ends a line comment + +interface TokenBoundArm { + readonly what: string; + /** The characters between `d={` and `BASE.a`. */ + readonly before: string; + /** The characters between `BASE.a` and the value's closing `}`. */ + readonly after: string; +} + +const TOKEN_BOUND_ARMS: readonly TokenBoundArm[] = [ + { + what: + "U+00A0 on each side of the reference — `d={` U+00A0 `BASE.a` U+00A0 " + + "`}` (ECMAScript whitespace, not ASCII; SPEC 1.4)", + before: NBSP, + after: NBSP, + }, + { + what: + "U+FEFF before the reference — `d={` U+FEFF `BASE.a` `}` (ECMAScript " + + "whitespace inside the file, no byte-order mark; SPEC 1.4)", + before: ZWNBSP, + after: "", + }, + { + what: + "U+3000 before and U+202F after the reference — `d={` U+3000 " + + "`BASE.a` U+202F `}` (Unicode 15.1 space separators ECMAScript's " + + "whitespace takes and SPEC 14.20 does not name; SPEC 1.4, 14.20)", + before: IDEOGRAPHIC_SPACE, + after: NARROW_NBSP, + }, + { + what: "a block comment before the reference — `d={ /* c */ BASE.a }`", + before: " /* c */ ", + after: " ", + }, + { + what: + "a line comment ended by the terminator — `d={// c` U+000A `BASE.a}` " + + "(14.20's deletion judgement)", + before: "// c" + LF, + after: "", + }, + { + what: + "the run-on twin — `d={// c}` U+000A `BASE.a}`, its first `}` on the " + + "commented-out line closing nothing, the value running to the second " + + "`}` (14.20's run-on rule, SPEC 2.7)", + before: "// c}" + LF, + after: "", + }, +]; + +// One spec group and no code group: the stagings hold no code source. Every +// token-bound workspace is created after the body's first `build`, so the +// configuration is a staged-source record too (S-9's timing clause). +const SPEC_ONLY_CONFIG = stagedTs( + "T5.7-2 xspec.config.ts — one spec group and no code group, the token-bound arms'", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); + +// The referencing source is composed from the exact parts the expected range +// cites: the first staging's multi-byte head, the tag up to `d={`, the arm's +// leading characters, the reference, the arm's trailing characters, and the +// value's closing `}`. +// Every token-bound workspace is created after the body's first `build` +// (the span workspace's): the base source and each arm's composed source +// are staged-source records (S-9, test/self/s9-staged-sources.test.ts). +const TOKEN_BASE_SOURCE = stagedMdx( + "T5.7-2/T5.7-4/T11.3-1 specs/BASE.mdx (the token-bounds fixture's base; the no-occurrence fixture's specs/BASE.mdx spells the same bytes)", + '<S id="a">\nA text.\n</S>\n', +); +const TOKEN_TAG_PRE = '<S id="s" d={'; +const TOKEN_REF = "BASE.a"; +const TOKEN_TAG_POST = "}>\nS text.\n</S>\n"; +const TOKEN_S_ID = "specs/MAIN.mdx#s"; +const TOKEN_A_ID = "specs/BASE.mdx#a"; + +function tokenBoundSource(arm: TokenBoundArm): string { + return ( + SPAN_MAIN_HEAD + + TOKEN_TAG_PRE + + arm.before + + TOKEN_REF + + arm.after + + TOKEN_TAG_POST + ); +} + +function tokenBoundRange(arm: TokenBoundArm): SourceRange { + return rangeAfter(SPAN_MAIN_HEAD + TOKEN_TAG_PRE + arm.before, TOKEN_REF); +} + +/** One token-bound arm with its composed `specs/MAIN.mdx` bytes and record. */ +interface TokenBoundStaging { + readonly arm: TokenBoundArm; + /** The composed bytes — the range self-check slices them. */ + readonly source: string; + /** The same bytes as the staged-source record `create()` stages (S-9). */ + readonly main: StagedMdx; +} + +// The template calls evaluated once at module load, in arm order. +const TOKEN_BOUND_STAGINGS: readonly TokenBoundStaging[] = TOKEN_BOUND_ARMS.map( + (arm) => { + const source = tokenBoundSource(arm); + return { + arm, + source, + main: stagedMdx( + `T5.7-2 token bounds: ${arm.what} specs/MAIN.mdx`, + source, + ), + }; + }, +); + +// The workspace's complete edge set (SPEC 5.2): each file's `contains` edges +// and the one `depends` edge the occurrence stands behind. +const TOKEN_EXPECTED_EDGES: readonly GraphEdge[] = [ + { from: "specs/BASE.mdx", to: TOKEN_A_ID, kind: "contains" }, + { from: "specs/MAIN.mdx", to: SPAN_PRE_ID, kind: "contains" }, + { from: "specs/MAIN.mdx", to: TOKEN_S_ID, kind: "contains" }, + { from: TOKEN_S_ID, to: TOKEN_A_ID, kind: "depends" }, +]; + +async function assertTokenBoundArm( + product: ProductBinding, + staging: TokenBoundStaging, +): Promise<void> { + const { arm, source, main } = staging; + const range = tokenBoundRange(arm); + const label = `T5.7-2 token bounds — ${arm.what}`; + // Fixture self-check (harness-side, before any product invocation): the + // precomputed range slices the staged bytes to exactly `BASE.a`. + const sliced = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (sliced !== TOKEN_REF) { + fail( + `${label}: fixture self-check — the precomputed byte range ` + + `[${String(range.start)}, ${String(range.end)}) slices the staged ` + + `bytes to ${JSON.stringify(sliced)}, expected ` + + `${JSON.stringify(TOKEN_REF)} (a harness-side staging error, not a ` + + `product failure)`, + ); + } + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_ONLY_CONFIG, + "specs/BASE.mdx": TOKEN_BASE_SOURCE, + "specs/MAIN.mdx": main, + }, + }); + try { + await buildOk( + product, + workspace, + `${label}: \`build\` exit 0 — the value is a well-formed \`d\` holding ` + + `one static reference beside ECMAScript whitespace and comments, ` + + `never 14.20 and never 14.8 (SPEC 2.7, 1.4, 14)`, + ); + + const edgesContext = `${label}: \`query edges\``; + assertEdgeSetEqual( + decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], edgesContext), + edgesContext, + ), + TOKEN_EXPECTED_EDGES, + `${edgesContext}: the workspace's complete edge set — the reference ` + + `records its \`depends\` edge (SPEC 2.2, 5.2)`, + ); + + const context = `${label}: \`occurrences\``; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], context), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain carries no finding (SPEC 11.2, 11.3)`, + ); + if (report.occurrences.length !== 1) { + fail( + `${context}: expected exactly one occurrence record — the staged ` + + `reference's; the import declaration records none (SPEC 5.7) — ` + + `got ${String(report.occurrences.length)}: ` + + JSON.stringify(report.occurrences.map(renderOccurrenceUnit)), + ); + } + const record = report.occurrences[0]!; + if ( + renderPathValue(record.file) !== "specs/MAIN.mdx" || + record.kind !== "depends" || + "unavailable" in record.source || + record.source.identity !== TOKEN_S_ID || + record.target !== TOKEN_A_ID + ) { + fail( + `${context}: expected the one record to be specs/MAIN.mdx [depends] ` + + `${TOKEN_S_ID} -> ${TOKEN_A_ID}; got ${renderOccurrenceUnit(record)}`, + ); + } + assertSameJson( + record.range, + range, + `${context}: the occurrence spans \`BASE.a\` alone — the brace-side ` + + `whitespace and comments excluded as ASCII whitespace is — against ` + + `precomputed byte offsets (SPEC 5.7, 1.4, 1.7)`, + ); + } finally { + await workspace.dispose(); + } +} + +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; token bounds inside a `d` value take ECMAScript's whitespace — its space separators Unicode 15.1's — and comments — `d={` U+00A0 `BASE.a` U+00A0 `}`, `d={` U+FEFF `BASE.a` `}`, `d={` U+3000 `BASE.a` U+202F `}`, `d={ /* c */ BASE.a }`, and the line-comment forms `d={// c` U+000A `BASE.a}` and its run-on twin `d={// c}` U+000A `BASE.a}` (14.20's deletion judgement and run-on rule) are each a well-formed `d` recording one occurrence spanning `BASE.a` alone (SPEC 5.7, 1.4, 1.7, 2.7, 3, 4.4, 11.3, 14)", + 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_STAGED, + "specs/MAIN.mdx": SPAN_MAIN_STAGED, + "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(); + } + + for (const staging of TOKEN_BOUND_STAGINGS) + await assertTokenBoundArm(product, staging); + }, +}); + +// --------------------------------------------------------------------------- +// 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"; + +// T11.3-1 restages this fixture after its first product invocation (S-9's +// before-any-product clause): records made from the strings the pins use. +export const ORD_ZED_STAGED = stagedMdx( + "T5.7-3/T11.3-1 specs/Zed.mdx (the order fixture)", + ORD_ZED_SOURCE, +); +export const ORD_ALPHA_STAGED = stagedMdx( + "T5.7-3/T11.3-1 specs/alpha.mdx (the order fixture)", + ORD_ALPHA_SOURCE, +); + +// 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_STAGED, + [ORD_ALPHA_FILE]: ORD_ALPHA_STAGED, + [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. +// +// The entry's collision and cross-module classes — a chain rooted at an +// identifier an import and a same-scope value-level declaration both bind +// (T4.5-8), a type-only collision's chains (T4-5), a call through a +// colliding `text` identifier (T4.5-9), and a cross-module call whose +// argument does not resolve or is not static beside the resolving one +// that records its occurrence (T4.4-1) — are staged in a second workspace +// of their own (below the expected table), so the exported table this +// workspace pins, which T11.3-1 expands by position, is untouched. + +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; + +// T11.3-1 restages this fixture after its first product invocation (S-9's +// before-any-product clause): records made from the strings the pins use. +// specs/BASE.mdx spells the token-bounds fixture's base byte for byte, so it +// is that record (one record per byte sequence, staged at every site; an +// alias placed after the record, never a second spelling of its bytes). +export const NO_OCC_BASE_STAGED = TOKEN_BASE_SOURCE; +export const NO_OCC_SPARE_STAGED = stagedMdx( + "T5.7-4/T11.3-1 specs/SPARE.mdx (the no-occurrence fixture)", + NO_OCC_SPARE_SOURCE, +); +export const NO_OCC_MAIN_STAGED = stagedMdx( + "T5.7-4/T11.3-1 specs/MAIN.mdx (the no-occurrence fixture)", + NO_OCC_MAIN_SOURCE, +); + +// 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)`, + ); +} + +// --------------------------------------------------------------------------- +// T5.7-4 (second workspace) — the collision and cross-module classes +// --------------------------------------------------------------------------- + +// The entry's further no-occurrence classes (SPEC 5.7, 2.4, 4, 4.4, 4.5), +// staged in one workspace beside two resolving control spellings so the +// complete record multiset individuates each class: +// +// - `src/collide.ts` (T4.5-8's class): an identifier the spec module import +// and a same-scope value-level declaration (`const SPEC = 1`) both bind +// roots no resolving chain — the marker `SPEC.a` and the call +// `text(SPEC.b)` are condition 7 each, beside the one condition-15 +// collision locating both declarations; no edge, no occurrence. +// - `src/typed.ts` (T4-5's class): the value import beside a type-only +// `import type SPEC from "../specs/B.xspec"` — a colliding identifier +// roots a chain at no binding whether or not either import is type-only +// (4): 14.15 locating both imports, 14.7 for each chain, no record — +// never the type-only exemption's silence (T4-4), which a product rooting +// the chain at whichever binding TypeScript's own resolution prefers +// exhibits instead. +// - `src/calltext.ts` (T4.5-9's class): `text` bound by the import and by a +// same-scope `function text(x: unknown) {}` — the call `text(SPEC.a)` is +// no spec module's `text` call: 14.15 locating both declarations, 14.18 +// at `SPEC.a` (no 14.6, 14.7, 14.8, or 14.11), no record. +// - `src/cross.ts` (T4.4-1's class): `textB(A.missing)` is condition 7 +// alone and `textB(A.a!)` condition 8 alone — no record for either — while +// the resolving cross-module call `textB(A.a)` records its `embeds` +// occurrence beside its condition-11 finding (`identities` exactly the +// called module), 5.7's one occurrence-bearing finding. +// - `src/ctrl.ts` (the positive control): the same import, marker, and call +// colliding with nothing, recording their two edges — so a product that +// answers no records at all fails by count, and one that roots a colliding +// chain at one of its bindings emits a phantom tuple the exact comparison +// rejects. +// +// Every code file is pure ASCII and composed from exact parts, so each +// pinned construct's window is the UTF-8 byte length of the parts before it +// (`partWindow`): a 14.15 finding is pinned to exactly two locations, one +// per colliding declaration, and every spelling finding to one location +// within its construct's window (the byte-exact spans are T4.5-8's, +// T4.5-9's, and T4.4-1's own business). + +// The collision workspace is created after the entry workspace's +// invocations: both spec sources are staged-source records (S-9, +// test/self/s9-staged-sources.test.ts). +const COLLISION_A_SOURCE = stagedMdx( + "T5.7-4 collision workspace specs/A.mdx", + '<S id="a">\nAlpha text.\n</S>\n\n<S id="b">\nBeta text.\n</S>\n', +); +const COLLISION_B_SOURCE = stagedMdx( + "T5.7-4 collision workspace specs/B.mdx", + '<S id="bb">\nBravo text.\n</S>\n', +); + +/** The positive control: the import roots both chains (SPEC 2.4, 4.5). */ +const COLLISION_CTRL_SOURCE = + 'import SPEC, { text } from "../specs/A.xspec";\n\nSPEC.a;\ntext(SPEC.b);\n'; + +// T4.5-8's class — parts: [0] the import, [2] the declarator `SPEC = 1` +// (the located construct, the `const` statement excluded, SPEC 14), [4] +// the marker chain, [6] the `text(...)` call. +const COLLIDE_PARTS: readonly string[] = [ + 'import SPEC, { text } from "../specs/A.xspec";', + "\n\nconst ", + "SPEC = 1", + ";\n\n", + "SPEC.a", + ";\n", + "text(SPEC.b)", + ";\n", +]; + +// T4-5's class — parts: [0] the value import, [2] the type-only import of +// a second spec module, then `text` bound by a separate import of A (two +// declarations of one module binding distinct identifiers, valid under 4), +// [4] the marker chain, [6] the `text(...)` call. +const TYPED_PARTS: readonly string[] = [ + 'import SPEC from "../specs/A.xspec";', + "\n", + 'import type SPEC from "../specs/B.xspec";', + '\nimport { text } from "../specs/A.xspec";\n\n', + "SPEC.a", + ";\n", + "text(SPEC.b)", + ";\n", +]; + +// T4.5-9's class — parts: [0] the import binding `text`, [2] the same-scope +// function declaration binding it too, [4] the argument chain `SPEC.a` the +// 14.18 locates (the identifier extended by its longest static chain). +const CALLTEXT_PARTS: readonly string[] = [ + 'import SPEC, { text } from "../specs/A.xspec";', + "\n\n", + "function text(x: unknown) {}", + "\n\ntext(", + "SPEC.a", + ");\n", +]; + +// T4.4-1's class — parts: [1] the unresolved-argument call (14.7 alone), +// [3] the non-static-argument call (14.8 alone), [5] the resolving +// cross-module call (14.11, its occurrence recorded beside it). +const CROSS_PARTS: readonly string[] = [ + 'import A from "../specs/A.xspec";\n' + + 'import { text as textB } from "../specs/B.xspec";\n\n', + "textB(A.missing)", + ";\n", + "textB(A.a!)", + ";\n", + "textB(A.a)", + ";\n", +]; + +// The five code files are staged in the collision workspace too, after the +// entry workspace's invocations: staged-source records, the same +// expressions wrapped in place (S-9's timing clause). +const COLLISION_CODE_FILES = { + "src/calltext.ts": stagedTs( + "T5.7-4 collision workspace src/calltext.ts", + CALLTEXT_PARTS.join(""), + ), + "src/collide.ts": stagedTs( + "T5.7-4 collision workspace src/collide.ts", + COLLIDE_PARTS.join(""), + ), + "src/cross.ts": stagedTs( + "T5.7-4 collision workspace src/cross.ts", + CROSS_PARTS.join(""), + ), + "src/ctrl.ts": stagedTs( + "T5.7-4 collision workspace src/ctrl.ts", + COLLISION_CTRL_SOURCE, + ), + "src/typed.ts": stagedTs( + "T5.7-4 collision workspace src/typed.ts", + TYPED_PARTS.join(""), + ), +} as const; + +/** A part's end-widened byte window from the exact parts before it. */ +function partWindow( + parts: readonly string[], + index: number, +): { readonly start: number; readonly end: number } { + return byteWindow(parts.slice(0, index).join(""), parts[index]!); +} + +// The complete expected record multiset: the control's two spellings and the +// resolving cross-module call — nothing for the colliding classes' spellings +// (four chains rooted at a colliding identifier, the call through the +// colliding `text`, the two non-resolving cross-module calls). Every source +// is the whole file — no named unit encloses a top-level statement (SPEC +// 4.6) — so a record's source identity is the file's own path. +const COLLISION_UNITS: readonly OccurrenceUnit[] = [ + { + what: "control marker `SPEC.a` in src/ctrl.ts", + file: "src/ctrl.ts", + kind: "references", + source: "src/ctrl.ts", + target: "specs/A.mdx#a", + count: 1, + }, + { + what: "control call `text(SPEC.b)` in src/ctrl.ts", + file: "src/ctrl.ts", + kind: "embeds", + source: "src/ctrl.ts", + target: "specs/A.mdx#b", + count: 1, + }, + { + what: "resolving cross-module call `textB(A.a)` in src/cross.ts", + file: "src/cross.ts", + kind: "embeds", + source: "src/cross.ts", + target: "specs/A.mdx#a", + count: 1, + }, +]; + +// The staged defects, exactly (SPEC 14: every condition reported and nothing +// else — so the control file and the resolving cross-module call's argument +// provably trigger nothing beside these). +const COLLISION_EXPECTED_CONDITIONS = { + "14.7": 5, + "14.8": 1, + "14.11": 1, + "14.15": 3, + "14.18": 1, +} as const; + +/** One expected finding: its file and, in location order, its bearers' windows. */ +interface CollisionFindingExpectation { + /** What the finding reports (failure diagnostics). */ + readonly what: string; + /** The workspace-relative file every location names. */ + readonly file: string; + /** One window per expected location, in 12.7 location order. */ + readonly windows: readonly { readonly start: number; readonly end: number }[]; +} + +// Per condition, the expected findings in (file, first-location start) order +// — `findingsInSourceOrder`'s — each pinned to exactly one location per +// bearer within the bearer's window. File order is byte order: +// `src/calltext.ts` < `src/collide.ts` < `src/cross.ts` < `src/typed.ts`. +const COLLISION_EXPECTED_FINDINGS: Readonly< + Record< + keyof typeof COLLISION_EXPECTED_CONDITIONS, + readonly CollisionFindingExpectation[] + > +> = { + "14.7": [ + { + what: "the marker `SPEC.a` rooted at the doubly-bound `SPEC`", + file: "src/collide.ts", + windows: [partWindow(COLLIDE_PARTS, 4)], + }, + { + what: "the call `text(SPEC.b)` rooted at the doubly-bound `SPEC`", + file: "src/collide.ts", + windows: [partWindow(COLLIDE_PARTS, 6)], + }, + { + what: "the cross-module call `textB(A.missing)`, its argument unresolved (condition 7 alone, SPEC 14.11)", + file: "src/cross.ts", + windows: [partWindow(CROSS_PARTS, 1)], + }, + { + what: "the marker `SPEC.a` rooted at the type-only-collided `SPEC`", + file: "src/typed.ts", + windows: [partWindow(TYPED_PARTS, 4)], + }, + { + what: "the call `text(SPEC.b)` rooted at the type-only-collided `SPEC`", + file: "src/typed.ts", + windows: [partWindow(TYPED_PARTS, 6)], + }, + ], + "14.8": [ + { + what: "the cross-module call `textB(A.a!)`, its argument not static (condition 8 alone, SPEC 14.11)", + file: "src/cross.ts", + windows: [partWindow(CROSS_PARTS, 3)], + }, + ], + "14.11": [ + { + what: "the resolving cross-module call `textB(A.a)`", + file: "src/cross.ts", + windows: [partWindow(CROSS_PARTS, 5)], + }, + ], + "14.15": [ + { + what: "`text` bound by the import and by `function text(x: unknown) {}`", + file: "src/calltext.ts", + windows: [partWindow(CALLTEXT_PARTS, 0), partWindow(CALLTEXT_PARTS, 2)], + }, + { + what: "`SPEC` bound by the import and by the declarator `SPEC = 1`", + file: "src/collide.ts", + windows: [partWindow(COLLIDE_PARTS, 0), partWindow(COLLIDE_PARTS, 2)], + }, + { + what: "`SPEC` bound by the value import and by the type-only import", + file: "src/typed.ts", + windows: [partWindow(TYPED_PARTS, 0), partWindow(TYPED_PARTS, 2)], + }, + ], + "14.18": [ + { + what: "`SPEC.a` passed to the call through the colliding `text` identifier (no `text` call)", + file: "src/calltext.ts", + windows: [partWindow(CALLTEXT_PARTS, 4)], + }, + ], +}; + +/** + * The collision workspace's findings: the condition multiset exact, then + * every finding pinned to its file and, in location order, to exactly one + * location per bearer within the bearer's window — a 14.15 locating every + * colliding declaration, a spelling finding its one construct — and the + * 14.11's `identities` exactly the called module. + */ +function assertCollisionFindings( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts( + findings, + COLLISION_EXPECTED_CONDITIONS, + `${context}: exactly the staged defects are reported — five condition-7 ` + + `chains (two rooted at the doubly-bound \`SPEC\`, two at the ` + + `type-only-collided \`SPEC\`, the unresolved-argument cross-module ` + + `call), one 14.8 (the non-static cross-module argument), one 14.11 ` + + `(the resolving cross-module call), three 14.15 collisions, one 14.18 ` + + `(the argument of the call through the colliding \`text\`) — and ` + + `NOTHING for the control file or beside them (SPEC 14, 2.4, 4, 4.4, ` + + `4.5)`, + ); + for (const [condition, expectations] of Object.entries( + COLLISION_EXPECTED_FINDINGS, + )) { + // The count is pinned above, so each expectation has its finding. + const actual = findingsInSourceOrder(findings, condition); + expectations.forEach((expectation, index) => { + assertFindingLocatesExactly( + actual[index]!, + expectation.windows.map((window) => ({ + file: expectation.file, + window, + })), + `${context}: the ${condition} finding for ${expectation.what} locates ` + + (expectation.windows.length === 1 + ? "its one construct" + : "every colliding declaration, one location each,") + + ` within the construct's window and nothing else (SPEC 14, 1.7)`, + ); + }); + } + assertFindingIdentities( + findingsInSourceOrder(findings, "14.11")[0]!, + ["specs/B.mdx"], + `${context}: the 14.11's \`identities\` hold exactly the called module's ` + + `root identity (SPEC 14.11, 12.7)`, + ); +} + +/** + * The second workspace: `build --json` pins the staging premise, then a bare + * `occurrences` (exit 1, the domain's findings accompanying, SPEC 11.2) + * reports records for exactly the three resolving spellings — none for a + * chain rooted at a colliding identifier, the call through a colliding + * `text`, or a non-resolving cross-module call, while the resolving + * cross-module call's record stands beside its finding (5.7). + */ +async function assertCollisionClassesRecordNothing( + product: ProductBinding, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/A.mdx": COLLISION_A_SOURCE, + "specs/B.mdx": COLLISION_B_SOURCE, + ...COLLISION_CODE_FILES, + }, + }); + try { + const buildContext = + "T5.7-4 collision workspace `build --json` (staging premise)"; + assertCollisionFindings( + await buildFindings(product, workspace, buildContext), + buildContext, + ); + + const context = "T5.7-4 collision workspace `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, + ); + assertCollisionFindings(report.findings, context); + assertSameJson( + report.occurrences.map(renderOccurrenceUnit).sort(), + expectedUnitMultiset(COLLISION_UNITS), + `${context}: the complete (file, [kind], source -> target) record ` + + `multiset — exactly the control's marker and call and the resolving ` + + `cross-module call (SPEC 5.7, 11.2): no record for a chain rooted at ` + + `an identifier an import and a same-scope value-level declaration ` + + `both bind (T4.5-8), a type-only collision's chains (T4-5), a call ` + + `through a colliding \`text\` identifier (T4.5-9), or a cross-module ` + + `call whose argument does not resolve or is not static (T4.4-1) — ` + + `each reaching consumers only through its finding's range — while ` + + `the resolving cross-module call records its occurrence beside its ` + + `condition-11 finding`, + ); + } finally { + await workspace.dispose(); + } +} + +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 chain rooted at an identifier an import and a same-scope value-level declaration both bind (its collision and unresolved findings beside it), a type-only collision's chains, a call through a colliding `text` identifier, a cross-module call whose argument does not resolve or is not static (the resolving cross-module call recording its occurrence beside its condition-11 finding), 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, 4.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_STAGED, + "specs/SPARE.mdx": NO_OCC_SPARE_STAGED, + "specs/MAIN.mdx": NO_OCC_MAIN_STAGED, + "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(); + } + + // The second workspace: the entry's collision and cross-module classes + // beside resolving controls (SPEC 5.7, 2.4, 4, 4.4, 4.5). + await assertCollisionClassesRecordNothing(product); + }, +}); + +/** 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 6b2f08c2..df322016 100644 --- a/test/suite/registry/section-6.1.ts +++ b/test/suite/registry/section-6.1.ts @@ -13,13 +13,18 @@ // workspace state; content is otherwise opaque — assertions here stick to the // stated observable contract (line-oriented, append-only form; H-4). // -// Staging constraint (CERTIFICATIONS.md §CONF-CORE — T6.1-1 and T6.1-2 are -// in-scope): their fixtures stay within CONF-CORE's scope — one configured -// spec group of `.mdx` sources without imports, embeddings, `d` props, or -// tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git; the only -// mutating commands driven are `rename` and file-form `move`. In this -// git-less scope `impact --base` is the exit-2 unreadable-baseline case -// (SPEC 6.3, 12.0). +// Staging constraint (CERTIFICATIONS.md §CONF-CORE — T6.1-2 is in-scope): +// its fixture stays within CONF-CORE's scope — one configured spec group of +// `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, +// `markdown`, `coverage`, or `policy` keys; no git; the only mutating +// commands driven are `rename` and file-form `move`. T6.1-1 lies outside +// every certification scope (CERTIFICATIONS.md §Exclusions): its +// never-modifies sweep covers every command surface, so its workspace adds +// what the sweep needs on top of the same files — a git baseline commit (a +// resolvable `impact --base HEAD`, SPEC 6.3), an `audit` review session +// (`create`/`split`/`resolve` and the read subcommands, 10.7), the 11.2 +// surfaces (`occurrences`, `view`, `at`), `inventory`, `version`, and the +// 6.6 previews of both operations. // // Conservative operationalizations (noted per H-4): // - "One entry per line" + "the journal is written only by rename and move" @@ -37,10 +42,35 @@ // precedes it), so a finding pointing at line 1 or at the whole file // without naming the line fails — the discrimination the TEST-SPEC asks // for. +// +// Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): T6.1-3's directory- and symlink-occupant arms +// create their workspaces after the garbage-line arm's invocations, so +// `CORE_FILES`'s two `.mdx` entries are ledger records — judged by +// test/self/s9-staged-sources.test.ts before any product exists — named +// with every body that stages the map (T6.1-1's sweep workspace and +// T6.1-2's two directories precede any invocation and would not need them). import { Buffer } from "node:buffer"; import type { Finding } from "../../helpers/adapters/index.js"; -import { decodeFindingsReport } from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeCoverageReport, + decodeExportReport, + decodeFindingsReport, + decodeIdsReport, + decodeImpactReport, + decodeInventoryDocument, + decodeItemReport, + decodeNextReport, + decodeNodeRowsReport, + decodeOccurrencesReport, + decodePreviewReport, + decodeSessionListReport, + decodeSessionStatusReport, + decodeVersionDocument, + decodeViewReport, +} from "../../helpers/adapters/index.js"; import { assertBytesEqual, fail, @@ -48,36 +78,55 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { buildOk, expectExit } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group, no -// other keys — the CONF-CORE workspace shape. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// other keys — the CONF-CORE workspace shape. T6.1-3's later arms stage it in +// workspaces created after the body's first invocation, so it is a +// staged-source record (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.1-3 xspec.config.ts — exactly one spec group, the CONF-CORE workspace shape", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // Importless `.mdx` sources: no imports, embeddings, `d` props, or tags // (CONF-CORE scope). A.mdx carries a child so `rename` exercises descendant -// rewriting; B.mdx is the file-form `move` subject. -const CORE_FILES: Readonly<Record<string, string>> = { +// rewriting; B.mdx is the file-form `move` subject. Both are staged-source +// records (module header): the same expressions, wrapped in place, staged +// under the records' well-formed declaration by every workspace built from +// the map. +const CORE_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": [ - '<S id="a">', - "Alpha text.", - '<S id="a.k">', - "Kid text.", - "</S>", - "</S>", - "", - ].join("\n"), - "specs/B.mdx": ['<S id="b">', "Beta text.", "</S>", ""].join("\n"), + "specs/A.mdx": stagedMdx( + "T6.1-1/T6.1-2/T6.1-3 specs/A.mdx", + [ + '<S id="a">', + "Alpha text.", + '<S id="a.k">', + "Kid text.", + "</S>", + "</S>", + "", + ].join("\n"), + ), + "specs/B.mdx": stagedMdx( + "T6.1-1/T6.1-2/T6.1-3 specs/B.mdx", + ['<S id="b">', "Beta text.", "</S>", ""].join("\n"), + ), }; const JOURNAL_PATH = ".xspec/journal"; @@ -95,6 +144,32 @@ async function withCoreWorkspace<T>( } } +/** + * Stage T6.1-1's sweep workspace: the same files plus a git baseline commit + * holding the configuration and sources exactly as staged, so `impact --base + * HEAD` resolves (SPEC 6.3). The commit precedes `build`, so the ref holds + * no derived file and no journal — a journal absent at the ref is the empty + * journal, a prefix of every journal, and the current entries replay onto + * the baseline identities (6.3). + */ +async function withSweepWorkspace<T>( + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create({ files: CORE_FILES }); + try { + await workspace.gitInit(); + await workspace.gitCommitAll("baseline"); + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +/** Snapshot exclusion pruning the top-level `.git` subtree. */ +function excludeGitDir(relPathBytes: Uint8Array): boolean { + return Buffer.from(relPathBytes).toString("latin1") === ".git"; +} + /** * Read the journal's exact bytes, failing diagnosed (H-8) when the path does * not hold a plain file (SPEC 6.1: the file comes into existence with the @@ -170,7 +245,8 @@ function assertJournalAppend( * Byte-compare the journal around one command (T6.1-1 "never modify it"): * read it, run the command asserting the exact exit code (H-5), read it * again, assert byte identity — a deleted or replaced journal fails via - * `readJournal`'s plain-file check. + * `readJournal`'s plain-file check. Returns the run so the caller can decode + * its answer. */ async function assertLeavesJournalUnchanged( product: ProductBinding, @@ -178,13 +254,13 @@ async function assertLeavesJournalUnchanged( argv: readonly string[], exitCode: number, context: string, -): Promise<void> { +): Promise<RunResult> { const command = argv.join(" "); const before = await readJournal( workspace, `${context}: before \`${command}\``, ); - await expectExit( + const result = await expectExit( product, workspace, argv, @@ -202,6 +278,7 @@ async function assertLeavesJournalUnchanged( `written only by \`rename\` and \`move\` (byte-compare around the ` + `command; SPEC 6.1, 13.4)`, ); + return result; } // --------------------------------------------------------------------------- @@ -211,9 +288,9 @@ async function assertLeavesJournalUnchanged( const T6_1_1 = defineProductTest({ id: "T6.1-1", title: - "no journal exists after `build` in a fresh workspace; the file appears at .xspec/journal with the first rename/move; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); build, check, coverage, impact, review, query never modify it (byte-compare around each) (SPEC 6.1, 13.4)", + "no journal exists after `build` in a fresh workspace; the file appears at .xspec/journal with the first rename/move; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); every other command surface — build, check, ids, show, coverage, impact, review (reads and create/split/resolve alike), query, occurrences, view, at, inventory, version, and rename/move --preview — never modifies it (byte-compare around each on the journal-bearing workspace; the previews leave the whole workspace byte-identical) (SPEC 6.1, 6.6, 13.4)", run: async (product) => { - await withCoreWorkspace(async (workspace) => { + await withSweepWorkspace(async (workspace) => { // Fresh workspace: `build` succeeds and creates no journal. await buildOk( product, @@ -292,37 +369,139 @@ const T6_1_1 = defineProductTest({ "T6.1-1", ); - // `build`, `check`, `coverage`, `impact`, `review`, `query` never - // modify the journal — byte-compare around each, with the journal - // present and non-empty so an append or truncation is visible. Exit - // codes (H-5): `build` 0 (the workspace is valid); `check` 0 (rename - // and move finish by regenerating derived files exactly as `build` - // does, SPEC 6.4/6.5, and the product-written journal is well-formed); - // `coverage` 0 reporting zero profiles (no `coverage` key, SPEC 7.4); - // `impact --base HEAD` 2 — the git-less workspace makes the baseline - // unreadable, a usage error (SPEC 6.3, 12.0), and even the refused - // invocation must leave the journal untouched; `review list` 0 (a read - // subcommand, informational with no sessions, SPEC 12.0); `query - // nodes` 0. - const readArms: readonly { - readonly argv: readonly string[]; - readonly exitCode: number; - }[] = [ - { argv: ["build"], exitCode: 0 }, - { argv: ["check"], exitCode: 0 }, - { argv: ["coverage"], exitCode: 0 }, - { argv: ["impact", "--base", "HEAD"], exitCode: 2 }, - { argv: ["review", "list"], exitCode: 0 }, - { argv: ["query", "nodes"], exitCode: 0 }, - ]; - for (const arm of readArms) { + // Every other command surface never modifies the journal (SPEC 6.1 + // "written only by rename and move"; 13.4): byte-compare it around + // each invocation, with the journal present and non-empty so an + // append or truncation is visible. Exit codes (H-5) are all 0: the + // workspace is valid; `check` finds nothing (rename and move finish by + // regenerating derived files exactly as `build` does, SPEC 6.4/6.5, + // and the product-written journal is well-formed); `coverage` reports + // zero profiles (no `coverage` key, 7.4); `impact --base HEAD` + // resolves against the staged baseline commit, the current journal + // replaying onto it (6.3); the `review` subcommands drive an `audit` + // session (10.6, 10.7); the 11 surfaces answer complete, finding-free + // documents; `version` is workspace-independent (12.6); and each + // preview succeeds exactly as its real operation would (6.6). Answers + // are decoded form-exactly through the adapters (H-3) — no value + // assertion is this test's business beyond a normal completion. + const sweepContext = "T6.1-1 never-modifies sweep"; + const sweep = async ( + argv: readonly string[], + exitCode: number, + ): Promise<RunResult> => await assertLeavesJournalUnchanged( product, workspace, - arm.argv, - arm.exitCode, - "T6.1-1 read sweep", + argv, + exitCode, + sweepContext, + ); + const sweepJson = async <T>( + argv: readonly string[], + decode: (doc: unknown, context: string) => T, + ): Promise<T> => { + const context = `${sweepContext}: \`${argv.join(" ")}\` answer`; + return decode(parseJsonStdout(await sweep(argv, 0), context), context); + }; + + await sweep(["build"], 0); + await sweep(["check"], 0); + await sweepJson(["ids", "--json"], decodeIdsReport); + await sweep(["show", "specs/A.mdx#a3"], 0); + await sweepJson(["coverage", "--json"], decodeCoverageReport); + await sweepJson( + ["impact", "--base", "HEAD", "--json"], + decodeImpactReport, + ); + + // `review`: `create` under the audit strategy — one subtree-coherence + // item per requirement node, leaves unblocked (10.6) — then the reads, + // a `split` of `a3`'s item (its scope root has the child `a3.k`), and a + // `resolve` of the first unblocked item `next` reports (10.7). + await sweep( + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + ); + await sweepJson(["review", "list", "--json"], decodeSessionListReport); + const status = await sweepJson( + ["review", "status", "s", "--json"], + decodeSessionStatusReport, + ); + const splitRow = status.items.find( + (row) => + row.kind === "subtree-coherence" && row.scope === "specs/A.mdx#a3", + ); + if (splitRow === undefined) { + fail( + `${sweepContext}: the audit session must hold a subtree-coherence ` + + `item scoped to specs/A.mdx#a3 — one item per requirement node ` + + `(SPEC 10.6); \`review status s --json\` listed ` + + `${String(status.items.length)} item(s)`, + ); + } + await sweepJson(["review", "next", "s", "--json"], decodeNextReport); + await sweep(["review", "split", "s", splitRow.id], 0); + const next = await sweepJson( + ["review", "next", "s", "--json"], + decodeNextReport, + ); + if (next.item === undefined) { + fail( + `${sweepContext}: \`review next s --json\` must report an ` + + `unblocked needing-review item — the leaf items of an audit ` + + `session are unblocked and unresolved (SPEC 10.6, 10.7); the ` + + `session reports itself fully resolved`, + ); + } + await sweep( + ["review", "resolve", "s", next.item.id, "--status", "updated"], + 0, + ); + await sweepJson( + ["review", "show", "s", next.item.id, "--json"], + decodeItemReport, + ); + await sweepJson(["review", "export", "s"], decodeExportReport); + + await sweepJson(["query", "nodes"], decodeNodeRowsReport); + await sweepJson(["occurrences"], decodeOccurrencesReport); + await sweepJson(["view"], (doc, context) => + decodeViewReport(doc, { text: false }, context), + ); + await sweepJson(["at", "specs/A.mdx", "0"], decodeAtReport); + await sweepJson(["inventory"], decodeInventoryDocument); + await sweepJson(["version"], decodeVersionDocument); + + // Previews modify nothing at all (SPEC 6.6: no sources, no journal, no + // derived files, no graph data): beyond the journal compare, the whole + // workspace tree (the git directory aside — a preview reads no git) + // is byte-compared around each. `rename`, the file form of `move`, + // and the section form — every operation with a preview. + const previewArms: readonly (readonly string[])[] = [ + ["rename", "specs/A.mdx", "a3", "a4", "--preview"], + ["rename", "specs/A.mdx", "a3", "a4", "--preview", "--json"], + ["move", "specs/Bmoved.mdx", "specs/B.mdx", "--preview", "--json"], + [ + "move", + "specs/A.mdx#a3.k", + "specs/Bmoved.mdx#b.k", + "--preview", + "--json", + ], + ]; + for (const argv of previewArms) { + const command = argv.join(" "); + const result = await assertLeavesUnchanged( + workspace.root, + () => sweep(argv, 0), + `T6.1-1 preview sweep: \`${command}\` must modify nothing — no ` + + `sources, no journal, no derived files, no graph data (SPEC 6.6)`, + { exclude: excludeGitDir }, ); + if (argv.includes("--json")) { + const context = `T6.1-1 preview sweep: \`${command}\` answer`; + decodePreviewReport(parseJsonStdout(result, context), context); + } } }); }, @@ -454,9 +633,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 +646,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.2.ts b/test/suite/registry/section-6.2.ts index 8e3fde44..27d18198 100644 --- a/test/suite/registry/section-6.2.ts +++ b/test/suite/registry/section-6.2.ts @@ -42,7 +42,12 @@ // category. // - T6.2-3's category table pins what SPEC 5.6 decides and bounds what it // leaves open. Decided: which nodes are `changed` (exactly the origin and -// target parents, plus the impure-boundary moved node in the impure arm); +// target parents, plus the impure-boundary moved node in the impure arms, +// plus the sibling whose residue is left alone on a line the deletion +// joins or the insertion splits in the sibling stagings (d)/(e) — 6.2's +// enumeration beyond the parents and the moved subtree); own texts on +// the pinned nodes byte-asserted through `query node` (SPEC 1.6: exact +// bytes) as the sharp witness of SPEC 3's drop-rule decisions; // the file roots' `descendant-changed` (their changed parent is a // descendant present on both sides); the dependents' `upstream-changed` // with exact attribution (a dependency-edge target's effectiveHash changed; @@ -59,6 +64,16 @@ // TEST-SPEC (SPEC 5.6 bounds it to originating nodes), so it is asserted // as a subset of the fixture's originating-node set, the empty list // accepted — the SUITE-20 convention. +// +// Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): every workspace a body here creates after its +// first product invocation — T6.2-3's impure-boundary arm, its impure +// matrix (a)–(c) and sibling stagings (d)/(e), T6.2-4's pinned shape (2) +// and `changed` twin — takes its initial `.mdx` sources as ledger records, +// judged by test/self/s9-staged-sources.test.ts before any product exists +// (T6.2-4's shape (1), the body's first workspace, converted uniformly with +// shape (2)); T6.2-1's, T6.2-2's, and T6.2-3's clean-boundary workspace +// precede any invocation and stay plain. import type { ChangeCategory, @@ -71,15 +86,26 @@ import { decodeNodeReport, decodeNodeRowsReport, } from "../../helpers/adapters/index.js"; -import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { + assertBytesEqual, + assertFileBytes, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { StagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, expectExit, + expectFindingFreeReport, runJson, sortedIdentities, } from "./support.js"; @@ -98,20 +124,26 @@ export default defineConfig({ }) `; -// Exactly one spec group (SPEC 7), for the T6.2-3/T6.2-4 fixtures. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Exactly one spec group (SPEC 7), for the T6.2-3/T6.2-4 fixtures — a +// staged-source record, since both stage it in workspaces created after +// their first invocations (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.2-3/T6.2-4 xspec.config.ts — exactly one spec group, every staging workspace's", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -922,13 +954,27 @@ const C3_WATCH_SOURCE = [ "", ].join("\n"); -// Impure-boundary arm (SPEC 6.2's worked case): the moved section's opening -// tag is preceded on its origin line by non-whitespace (`Lead-in prose.`) and -// followed there only by whitespace (two spaces before the terminator). The -// within-construct remainder and terminator contribute to its own content at -// the origin — the line is kept, `Lead-in prose.` remains after tag removal — -// but not at the destination, where insertion starts the construct at line -// start and the tag-only line is dropped (SPEC 3, 6.5). +// Impure-boundary arm (SPEC 6.2's worked case; T6.2-3's staging (a)): the +// moved section's opening tag is preceded on its origin line by non-whitespace +// (`Lead-in prose. `) and followed there by nothing but the terminator, and +// its closing tag is preceded on its line by two spaces and followed there by +// non-whitespace (` Trailing prose.`). Both boundary lines are kept at the +// origin (each retains content outside the construct), so the opening line's +// terminator and the closing line's two spaces — within-construct characters +// — contribute to the moved node's own content there: its own text is U+000A, +// `Impure line one.`, U+000A, `Impure line two.`, U+000A, two spaces. At the +// destination the moved text lands at a line's start followed by U+000A +// (SPEC 6.5), each tag alone on its line — a flow-position tag — and both +// boundary lines are dropped, left empty or whitespace-only purely by the +// removals (SPEC 3): its own text there is `Impure line one.`, U+000A, +// `Impure line two.`, U+000A. The shape derives at both sides (S-9): at the +// origin the opening tag, following non-whitespace, is a text-position tag, +// and ` </S> Trailing prose.` is a paragraph-continuation line — the flow +// attempt fails on the prose after the tag — closing the element within its +// paragraph (T3-3's staging constraint); the former staging, whose closing +// tag stood alone at a later line's start, left the in-line element unclosed +// under the grammar 14.20 fixes and is kept as a non-derivation in +// `test/self/s9-fixture-well-formedness.test.ts`. const I3_ROOM = "specs/Room.mdx"; const I3_OP = "specs/Room.mdx#op"; const I3_IMP_PRE = "specs/Room.mdx#op.imp"; @@ -939,39 +985,84 @@ const I3_DEPS = "specs/Deps.mdx"; const I3_W_TOP = "specs/Deps.mdx#watch"; const I3_W_ONIMP = "specs/Deps.mdx#watch.onimp"; -const I3_ROOM_SOURCE = [ +/** The impure origin, exported for the S-9 self-test (its exact staged bytes). */ +export const I3_ROOM_SOURCE = [ '<S id="op">', "Op holder text.", "", - 'Lead-in prose.<S id="op.imp" coverage="none" tags="edge imp"> ', + 'Lead-in prose. <S id="op.imp" coverage="none" tags="edge imp">', // a text-position tag after non-whitespace "Impure line one.", "Impure line two.", - "</S>", + " </S> Trailing prose.", // two spaces, the closing tag, non-whitespace after it "</S>", "", ].join("\n"); -const I3_HALL_SOURCE = ['<S id="tp">', "Hall parent text.", "</S>", ""].join( - "\n", +// The impure-boundary arm's workspace follows the clean-boundary arm's +// invocations, so its three initial sources are staged-source records +// (module header): Room.mdx's made from the exported string the S-9 +// fixture self-test imports, Hall.mdx's and Deps.mdx's wrapping their +// expressions in place. +const T6_2_3_ROOM = stagedMdx( + "T6.2-3 impure-boundary arm specs/Room.mdx", + I3_ROOM_SOURCE, ); -// A dependent of the moved node itself: its dependency edge is -// identity-mapped through the journal, so its `upstream-changed` — caused by -// the moved node's own-content change — is attributed exactly to the moved -// node: the unambiguous "5.6 cascades attributed to it". -const I3_DEPS_SOURCE = [ - 'import Room from "./Room.xspec"', - "", - '<S id="watch">', - "Dependents holder text.", +const I3_HALL_SOURCE = stagedMdx( + "T6.2-3 impure-boundary arm specs/Hall.mdx", + ['<S id="tp">', "Hall parent text.", "</S>", ""].join("\n"), +); + +// The two rewritten files as SPEC 6.5 fixes them — the premise of the hash +// reasoning above, exported for the S-9 self-test. Origin: the moved text (the +// construct's own characters, `<S id="op.imp" …>` through `</S>`) is deleted +// in place, joining `Lead-in prose. ` and ` Trailing prose.` into one line +// that is kept (neither empty nor whitespace-only); Room.mdx needs no import +// edit. Destination: the moved text, its `id` rewritten by prefix replacement +// to `tp.imp`, is inserted immediately before `tp`'s closing tag — an offset +// at the start of a line, so no terminator precedes it — followed by U+000A; +// Hall.mdx needs no import edit either, the moved text carrying no reference. +// Deps.mdx is not pinned: its rewritten `d` reference is rooted at an added +// import's fresh identifier, implementation latitude (SPEC 6.5). +export const I3_ROOM_MOVED_SOURCE = [ + '<S id="op">', + "Op holder text.", "", - '<S id="watch.onimp" d={Room.op.imp}>', - "Depends on the impure moved node.", + "Lead-in prose. Trailing prose.", "</S>", + "", +].join("\n"); +export const I3_HALL_MOVED_SOURCE = [ + '<S id="tp">', + "Hall parent text.", + '<S id="tp.imp" coverage="none" tags="edge imp">', + "Impure line one.", + "Impure line two.", + " </S>", "</S>", "", ].join("\n"); +// A dependent of the moved node itself: its dependency edge is +// identity-mapped through the journal, so its `upstream-changed` — caused by +// the moved node's own-content change — is attributed exactly to the moved +// node: the unambiguous "5.6 cascades attributed to it". +const I3_DEPS_SOURCE = stagedMdx( + "T6.2-3 impure-boundary arm specs/Deps.mdx", + [ + 'import Room from "./Room.xspec"', + "", + '<S id="watch">', + "Dependents holder text.", + "", + '<S id="watch.onimp" d={Room.op.imp}>', + "Depends on the impure moved node.", + "</S>", + "</S>", + "", + ].join("\n"), +); + /** The three hashes T6.2-3 pins for every node of a moved subtree. */ function assertKeptSectionMoveHashes( before: NodeHashes, @@ -992,10 +1083,854 @@ function assertKeptSectionMoveHashes( } } +// --------------------------------------------------------------------------- +// T6.2-3's three impure stagings, each moved to top level and into a +// flow-position parent (the shape × destination matrix) +// --------------------------------------------------------------------------- + +// U+000B and U+000C, built from code points (never escape spellings). +const VT = String.fromCodePoint(0x000b); +const FF = String.fromCodePoint(0x000c); + +/** + * One of T6.2-3's three impure stagings, spelled exactly as the entry does: + * the origin file's top level holds `foo <S id="m">` + `body` + `</S>` + + * `afterClose`, U+000A — the file root is the origin parent — and the moved + * text `<S id="…">` + `body` + `</S>` lands at the destination's line start + * followed by U+000A (SPEC 6.5). The own texts are hand-derived from SPEC + * 3's drop rule as each shape's comment spells; they are the runs of the S-6 + * vectors (test/self/s6-section-move-oracle.test.ts), which also prove that + * every composition before and after derives (S-9), as the builder's staging + * check does for the origin and target files again here. + */ +interface ImpureShape { + readonly tag: string; + readonly label: string; + /** The moved section's body: the bytes between its tags. */ + readonly body: string; + /** What follows the closing tag on its origin line. */ + readonly afterClose: string; + /** The origin file after the deletion — also the origin root's one run. */ + readonly originAfter: string; + /** The moved node's own text at the origin (SPEC 1.6). */ + readonly ownBefore: string; + /** The moved node's own text at the destination. */ + readonly ownAfter: string; + /** Why the two differ, for the diagnosed messages. */ + readonly reason: string; +} + +const M3_SHAPES: readonly ImpureShape[] = [ + { + // (a) `foo <S id="m">`, U+000A, `body`, U+000A, two spaces, `</S> bar`: + // at the origin both boundary lines are kept (`foo` and `bar` lie + // outside the construct), so the opening line's terminator and the + // closing line's two spaces — within-construct characters — contribute + // to the node; at the destination each tag is alone on its line, a + // flow-position tag, and both lines — `<S id="…">` and ` </S>` — are + // left empty or whitespace-only purely by the removals and drop with + // their terminators (SPEC 3): the node keeps `body`, U+000A alone. + tag: "(a)", + label: "the worked shape with two spaces before its closing tag", + body: "\nbody\n ", + afterClose: " bar", + originAfter: "foo bar\n", + ownBefore: "\nbody\n ", + ownAfter: "body\n", + reason: + "the opening line's terminator and the closing line's two spaces " + + "contribute at the origin, where both boundary lines are kept, and " + + "not at the destination, where each tag stands alone on its line and " + + "both lines drop (SPEC 6.2's worked case, 3)", + }, + ...( + [ + ["U+000B", VT], + ["U+000C", FF], + ] as const + ).map(([name, ws]): ImpureShape => ({ + // (b) `foo <S id="m">`, ws, U+000A, `body`, U+000A, ws, `</S> bar`: + // whitespace under SPEC 1.4, so both destination lines are left + // whitespace-only by the removals and drop with their terminators, + // but no whitespace to the MDX grammar, so both tags stay in text + // position there, the section closing within its paragraph (SPEC 6.2). + tag: "(b)", + label: `the both-sided ${name} spelling`, + body: `${ws}\nbody\n${ws}`, + afterClose: " bar", + originAfter: "foo bar\n", + ownBefore: `${ws}\nbody\n${ws}`, + ownAfter: "body\n", + reason: + `the ${name} after the opening tag with that line's terminator, ` + + `and the ${name} before the closing tag, contribute at the origin, ` + + `where both boundary lines are kept, and not at the destination, ` + + `where both lines are whitespace-only under SPEC 1.4 purely by the ` + + `removals and drop (SPEC 6.2, 3)`, + })), + ...( + [ + ["U+000B", VT], + ["U+000C", FF], + ] as const + ).map(([name, ws]): ImpureShape => ({ + // (c) `foo <S id="m">`, ws, U+000A, `body</S>`: an in-line section + // closed within its paragraph (T3-3's constraint), whose difference + // is realized on the opening line alone — the destination line + // `<S id="…">`, ws is whitespace-only purely by the removal and + // drops, the tag staying an in-line tag there too; `body</S>` is kept + // at both sides, its terminator outside the construct. + tag: "(c)", + label: `the body</S> variant with a ${name} remainder`, + body: `${ws}\nbody`, + afterClose: "", + originAfter: "foo \n", + ownBefore: `${ws}\nbody`, + ownAfter: "body", + reason: + `the ${name} after the opening tag and that line's terminator ` + + `contribute at the origin, where the line is kept by \`foo\`, and ` + + `not at the destination, where the line is whitespace-only under ` + + `SPEC 1.4 purely by the removal and drops — the difference realized ` + + `on the opening line alone (SPEC 6.2, 3)`, + })), +]; + +/** A destination T6.2-3 stages each impure shape into. */ +interface ImpureDestination { + readonly name: string; + /** The staged target file — a staged-source record (module header). */ + readonly source: StagedMdx; + /** The `move` operand's ID part — the new identity by prefix replacement. */ + readonly newId: string; + readonly parent: string; + readonly moved: string; + /** The target file as SPEC 6.5 fixes it around the moved text. */ + readonly compose: (movedText: string) => string; + /** The target file's rows of the category table (module header, H-4). */ + readonly rows: (changed: readonly string[]) => readonly ExpectedNodeImpact[]; +} + +const M3_ORIGIN = "specs/ca.mdx"; +const M3_MV_PRE = "specs/ca.mdx#m"; +const M3_TARGET = "specs/cb.mdx"; +const M3_K = "specs/cb.mdx#k"; +const M3_TOP_POST = "specs/cb.mdx#m"; +const M3_P = "specs/cb.mdx#p"; +const M3_FLOW_POST = "specs/cb.mdx#p.m"; +const M3_DEPS = "specs/Deps.mdx"; +const M3_W_TOP = "specs/Deps.mdx#watch"; +const M3_W_ONM = "specs/Deps.mdx#watch.onm"; + +// A dependent of the moved node: the sharp "5.6 cascades attributed to it" +// (as the I3 arm's Deps.mdx). Not byte-pinned after the move — its rewritten +// `d` reference is rooted at an added import's fresh identifier (SPEC 6.5). +const M3_DEPS_SOURCE = stagedMdx( + "T6.2-3 impure stagings (a)–(c) specs/Deps.mdx", + [ + 'import Ca from "./ca.xspec"', + "", + '<S id="watch">', + "Dependents holder text.", + "", + '<S id="watch.onm" d={Ca.m}>', + "Depends on the impure moved node.", + "</S>", + "</S>", + "", + ].join("\n"), +); + +const M3_DESTINATIONS: readonly ImpureDestination[] = [ + { + // `<S id="k">z</S>`, U+000A — a file ending with a terminator, so the + // insertion point, its end, is a line start (SPEC 6.5); the target root + // is the parent, `changed` by the gained child reference. + name: "another file's top level", + source: stagedMdx( + "T6.2-3 impure stagings into another file's top level specs/cb.mdx", + '<S id="k">z</S>\n', + ), + newId: "m", + parent: M3_TARGET, + moved: M3_TOP_POST, + compose: (movedText) => `<S id="k">z</S>\n${movedText}\n`, + rows: (changed) => [ + { + identity: M3_TARGET, + categories: [ + { category: "changed", within: changed }, + { + category: "descendant-changed", + within: [M3_TOP_POST], + optional: true, + }, + ], + }, + { identity: M3_K, categories: [] }, + { + identity: M3_TOP_POST, + categories: [{ category: "changed", within: changed }], + }, + ], + }, + { + // `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A — a flow-position + // parent, its tags alone on their lines, the insertion point before its + // closing tag a line start; `p`'s tag-only lines drop at both sides + // (SPEC 3), so the root keeps its own content and is + // `descendant-changed` through `p` alone (the moved node may join the + // attribution — the two-sided ambiguity). + name: "a flow-position parent", + source: stagedMdx( + "T6.2-3 impure stagings into a flow-position parent specs/cb.mdx", + '<S id="p">\nx\n</S>\n', + ), + newId: "p.m", + parent: M3_P, + moved: M3_FLOW_POST, + compose: (movedText) => `<S id="p">\nx\n${movedText}\n</S>\n`, + rows: (changed) => [ + { + identity: M3_TARGET, + categories: [ + { + category: "descendant-changed", + within: [M3_P, M3_FLOW_POST], + mustInclude: [M3_P], + }, + ], + }, + { + identity: M3_P, + categories: [ + { category: "changed", within: changed }, + { + category: "descendant-changed", + within: [M3_FLOW_POST], + optional: true, + }, + ], + }, + { + identity: M3_FLOW_POST, + categories: [{ category: "changed", within: changed }], + }, + ], + }, +]; + +/** One impure shape with its composed origin file as a record. */ +interface ImpureOriginStaging { + readonly shape: ImpureShape; + /** `specs/ca.mdx`: the shape's composition, staged at both destinations. */ + readonly origin: StagedMdx; +} + +// Every impure staging follows the clean-boundary arm's invocations, so its +// initial sources are staged-source records (module header): the origin +// composition, once per shape, evaluated at module load in shape order — +// the same template the matrix cell staged, moved here — and staged at both +// destinations; each destination's target file and the shared Deps.mdx +// wrapped in place above. +const M3_ORIGIN_STAGINGS: readonly ImpureOriginStaging[] = M3_SHAPES.map( + (shape) => ({ + shape, + origin: stagedMdx( + `T6.2-3 impure staging ${shape.tag}, ${shape.label} specs/ca.mdx`, + `foo <S id="m">${shape.body}</S>${shape.afterClose}\n`, + ), + }), +); + +/** One cell of the matrix: stage, build, move, and assert everything pinned. */ +async function runImpureStaging( + product: ProductBinding, + { shape, origin }: ImpureOriginStaging, + destination: ImpureDestination, +): Promise<void> { + const context = `T6.2-3 impure staging ${shape.tag}, ${shape.label}, into ${destination.name}`; + const movedText = `<S id="${destination.newId}">${shape.body}</S>`; + const newIdentity = `${M3_TARGET}#${destination.newId}`; + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [M3_ORIGIN]: origin, + [M3_TARGET]: destination.source, + [M3_DEPS]: M3_DEPS_SOURCE, + }, + async (workspace) => { + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + await buildOk(product, workspace, `${context}: \`build\``); + + const before = await queryNode( + product, + workspace, + M3_MV_PRE, + `${context} pre-move`, + ); + assertBytesEqual( + before.ownText, + shape.ownBefore, + `${context}: the moved node's own text at the origin — ${shape.reason}; ` + + `SPEC 1.6: exact bytes, the runs joined at the excision points`, + ); + + await expectExit( + product, + workspace, + ["move", M3_MV_PRE, newIdentity], + 0, + `${context}: \`move ${M3_MV_PRE} ${newIdentity}\``, + ); + + // Premise of the hash reasoning: the two rewritten files hold exactly + // the bytes SPEC 6.5 fixes. + await assertFileBytes( + workspace.path(M3_ORIGIN), + shape.originAfter, + `${context}: ${M3_ORIGIN} after the move — the moved text deleted in ` + + `place, the remainder of its boundary line kept (SPEC 6.5, 3)`, + ); + await assertFileBytes( + workspace.path(M3_TARGET), + destination.compose(movedText), + `${context}: ${M3_TARGET} after the move — the moved text, its \`id\` ` + + `rewritten by prefix replacement, inserted at a line start and ` + + `followed by U+000A (SPEC 6.5)`, + ); + + const after = await queryNode( + product, + workspace, + destination.moved, + `${context} post-move`, + ); + assertBytesEqual( + after.ownText, + shape.ownAfter, + `${context}: the moved node's own text at the destination — ` + + `${shape.reason}; SPEC 1.6: exact bytes`, + ); + assertSameJson( + after.hashes.metadataHash, + before.hashes.metadataHash, + `${context}: the moved node's metadataHash is kept unconditionally ` + + `(SPEC 6.2, 5.5)`, + ); + if (after.hashes.ownHash === before.hashes.ownHash) { + fail( + `${context}: the moved node's ownHash must change — ${shape.reason}; ` + + `both sides report ownHash ${JSON.stringify(after.hashes.ownHash)}`, + ); + } + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} \`check --json\` after the move — clean: every rewritten ` + + `composition derives (SPEC 6.5 refuses a move whose rewritten files ` + + `would not be well-formed; S-9) and the rewritten reference resolves`, + ); + + const label = `${context}: \`impact --base <pre-move ref> --json\``; + const changed = [M3_ORIGIN, destination.parent, destination.moved]; + assertImpactTable( + await impactAgainst(product, workspace, base, label), + [ + // The origin parent — the file root — `changed`, its departed + // child's change tolerated in its attribution (two-sided ambiguity). + { + identity: M3_ORIGIN, + categories: [ + { category: "changed", within: changed }, + { + category: "descendant-changed", + within: [destination.moved], + optional: true, + }, + ], + }, + ...destination.rows(changed), + // The dependent of the moved node: the unambiguous 5.6 cascade + // attributed to it, and no other node `changed`. + { + identity: M3_W_ONM, + categories: [ + { category: "upstream-changed", exact: [destination.moved] }, + ], + }, + { + identity: M3_W_TOP, + categories: [ + { category: "upstream-changed", exact: [destination.moved] }, + ], + }, + { + identity: M3_DEPS, + categories: [ + { category: "upstream-changed", exact: [destination.moved] }, + ], + }, + ], + label, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// T6.2-3's sibling stagings: 6.2's "any sibling with bytes there alike" +// --------------------------------------------------------------------------- + +// (d) at the origin: a flow-position parent whose second line is a paragraph +// of two in-line siblings, `p.m` moved to another file's top level. The +// deletion leaves `<S id="p.s"> </S>` alone on its line, and the drop rule of +// SPEC 3 decides that line differently — kept before (`text` remaining once +// the tags are removed), dropped after (whitespace-only purely by removals) — +// so `p.s` loses its run ` `, while the moved node's `text` rides a kept line +// at both sides. A dependent of `p.s` realizes "the 5.6 cascades attributed +// to it" sharply; the moved subtree has no dependency edges, so the table +// bounds its effectiveHash as in the clean arm. +const D3_A = "specs/a.mdx"; +const D3_P = "specs/a.mdx#p"; +const D3_PS = "specs/a.mdx#p.s"; +const D3_PM = "specs/a.mdx#p.m"; +const D3_B = "specs/b.mdx"; +const D3_K = "specs/b.mdx#k"; +const D3_M = "specs/b.mdx#m"; +const D3_DEPS = "specs/Deps.mdx"; +const D3_W_TOP = "specs/Deps.mdx#watch"; +const D3_W_ONS = "specs/Deps.mdx#watch.ons"; + +// The sibling stagings (d) and (e) follow the earlier arms' invocations, so +// their initial sources are staged-source records (module header), each +// expression wrapped in place. +const D3_A_SOURCE = stagedMdx( + "T6.2-3 sibling staging (d) specs/a.mdx", + '<S id="p">\n<S id="p.s"> </S><S id="p.m">text</S>\n</S>\n', +); +const D3_A_MOVED = '<S id="p">\n<S id="p.s"> </S>\n</S>\n'; +const D3_B_SOURCE = stagedMdx( + "T6.2-3 sibling staging (d) specs/b.mdx", + '<S id="k">z</S>\n', +); +const D3_B_MOVED = '<S id="k">z</S>\n<S id="m">text</S>\n'; +const D3_DEPS_SOURCE = stagedMdx( + "T6.2-3 sibling staging (d) specs/Deps.mdx", + [ + 'import A from "./a.xspec"', + "", + '<S id="watch">', + "Dependents holder text.", + "", + '<S id="watch.ons" d={A.p.s}>', + "Depends on the sibling left alone on its line.", + "</S>", + "</S>", + "", + ].join("\n"), +); + +// (e) at the destination: a text-position target parent `foo <S id="p">`, +// U+000A, `<S id="p.s">`, U+000C, `</S></S> tail`, U+000A receives +// `<S id="m">text</S>` (alone on its origin line) into `p.n`. The insertion +// point, preceded by `p.s`'s closing tag, is not at a line start, so 6.5's +// added terminator splits the line, leaving `<S id="p.s">`, U+000C, `</S>` +// alone on its line — kept in text position by the U+000C, no whitespace to +// the grammar (SPEC 6.2), and dropped as whitespace-only under SPEC 1.4 — so +// `p.s` loses its run, while `foo ` and ` tail` ride kept lines at both +// sides: the target root keeps its own content. +const E3_A = "specs/ea.mdx"; +const E3_AA = "specs/ea.mdx#a"; +const E3_M_PRE = "specs/ea.mdx#m"; +const E3_B = "specs/eb.mdx"; +const E3_P = "specs/eb.mdx#p"; +const E3_PS = "specs/eb.mdx#p.s"; +const E3_PN = "specs/eb.mdx#p.n"; +const E3_DEPS = "specs/Deps.mdx"; +const E3_W_TOP = "specs/Deps.mdx#watch"; +const E3_W_ONS = "specs/Deps.mdx#watch.ons"; + +const E3_A_SOURCE = stagedMdx( + "T6.2-3 sibling staging (e) specs/ea.mdx", + '<S id="a">x</S>\n<S id="m">text</S>\n', +); +const E3_A_MOVED = '<S id="a">x</S>\n'; +const E3_B_SOURCE = stagedMdx( + "T6.2-3 sibling staging (e) specs/eb.mdx", + `foo <S id="p">\n<S id="p.s">${FF}</S></S> tail\n`, +); +const E3_B_MOVED = `foo <S id="p">\n<S id="p.s">${FF}</S>\n<S id="p.n">text</S>\n</S> tail\n`; +const E3_DEPS_SOURCE = stagedMdx( + "T6.2-3 sibling staging (e) specs/Deps.mdx", + [ + 'import Eb from "./eb.xspec"', + "", + '<S id="watch">', + "Dependents holder text.", + "", + '<S id="watch.ons" d={Eb.p.s}>', + "Depends on the sibling left alone on its line.", + "</S>", + "</S>", + "", + ].join("\n"), +); + +/** The sibling's pinned outcome: its one run gone, so its ownHash changes. */ +function assertSiblingRunGone( + before: NodeReport, + after: NodeReport, + runBefore: string, + identity: string, + context: string, +): void { + assertBytesEqual( + before.ownText, + runBefore, + `${context}: ${identity}'s own text before the move — its one run, on a ` + + `line the drop rule of SPEC 3 keeps (SPEC 1.6: exact bytes)`, + ); + assertBytesEqual( + after.ownText, + "", + `${context}: ${identity}'s own text after the move — its run gone: left ` + + `alone on its line, whitespace-only purely by removals, the line drops ` + + `with its terminator (SPEC 3, 1.4; 6.2's sibling with bytes there)`, + ); + if (after.hashes.ownHash === before.hashes.ownHash) { + fail( + `${context}: ${identity}'s ownHash must change — its own content ` + + `sequence lost the run ${JSON.stringify(runBefore)} (SPEC 6.2, 5.5); ` + + `both sides report ownHash ${JSON.stringify(after.hashes.ownHash)}`, + ); + } +} + +/** Staging (d): the sibling residue left alone on the deletion's merged line. */ +async function runSiblingOriginStaging(product: ProductBinding): Promise<void> { + const context = "T6.2-3 sibling staging (d), at the origin"; + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [D3_A]: D3_A_SOURCE, + [D3_B]: D3_B_SOURCE, + [D3_DEPS]: D3_DEPS_SOURCE, + }, + async (workspace) => { + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + await buildOk(product, workspace, `${context}: \`build\``); + + const siblingBefore = await queryNode( + product, + workspace, + D3_PS, + `${context} pre-move`, + ); + const movedBefore = await queryNode( + product, + workspace, + D3_PM, + `${context} pre-move`, + ); + + await expectExit( + product, + workspace, + ["move", D3_PM, D3_M], + 0, + `${context}: \`move ${D3_PM} ${D3_M}\``, + ); + + await assertFileBytes( + workspace.path(D3_A), + D3_A_MOVED, + `${context}: ${D3_A} after the move — the moved text deleted in ` + + `place, \`<S id="p.s"> </S>\` left alone on its line (SPEC 6.5)`, + ); + await assertFileBytes( + workspace.path(D3_B), + D3_B_MOVED, + `${context}: ${D3_B} after the move — the moved text on a line of ` + + `its own at the file's end, followed by U+000A (SPEC 6.5)`, + ); + + const siblingAfter = await queryNode( + product, + workspace, + D3_PS, + `${context} post-move`, + ); + const movedAfter = await queryNode( + product, + workspace, + D3_M, + `${context} post-move`, + ); + assertSiblingRunGone(siblingBefore, siblingAfter, " ", D3_PS, context); + for (const [report, side] of [ + [movedBefore, "before"], + [movedAfter, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + "text", + `${context}: the moved node's own text ${side} the move — its ` + + `bytes \`text\` on a kept line at both sides (SPEC 1.6, 3)`, + ); + } + assertKeptSectionMoveHashes( + movedBefore.hashes, + movedAfter.hashes, + D3_PM, + D3_M, + context, + ); + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} \`check --json\` after the move — clean: every rewritten ` + + `composition derives (SPEC 6.5; S-9)`, + ); + + const label = `${context}: \`impact --base <pre-move ref> --json\``; + const changed = [D3_P, D3_PS, D3_B]; + assertImpactTable( + await impactAgainst(product, workspace, base, label), + [ + // The sibling: `changed` by its dropped line — 6.2's enumeration + // beyond the parents and the moved subtree. + { + identity: D3_PS, + categories: [{ category: "changed", within: changed }], + }, + // The parents; `p`'s changed sibling child is present on both + // sides, so its descendant-changed is required and exact. + { + identity: D3_P, + categories: [ + { category: "changed", within: changed }, + { category: "descendant-changed", exact: [D3_PS] }, + ], + }, + { + identity: D3_B, + categories: [{ category: "changed", within: changed }], + }, + { + identity: D3_A, + categories: [ + { category: "descendant-changed", exact: [D3_P, D3_PS] }, + ], + }, + // The moved node keeps its hashes and carries no category; the + // untouched sibling of the target likewise. + { identity: D3_M, categories: [] }, + { identity: D3_K, categories: [] }, + // The 5.6 cascade of the sibling's change, attributed to it. + { + identity: D3_W_ONS, + categories: [{ category: "upstream-changed", exact: [D3_PS] }], + }, + { + identity: D3_W_TOP, + categories: [{ category: "upstream-changed", exact: [D3_PS] }], + }, + { + identity: D3_DEPS, + categories: [{ category: "upstream-changed", exact: [D3_PS] }], + }, + ], + label, + ); + }, + ); +} + +/** Staging (e): the sibling residue left alone on the line the insertion splits. */ +async function runSiblingDestinationStaging( + product: ProductBinding, +): Promise<void> { + const context = "T6.2-3 sibling staging (e), at the destination"; + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [E3_A]: E3_A_SOURCE, + [E3_B]: E3_B_SOURCE, + [E3_DEPS]: E3_DEPS_SOURCE, + }, + async (workspace) => { + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + await buildOk(product, workspace, `${context}: \`build\``); + + const siblingBefore = await queryNode( + product, + workspace, + E3_PS, + `${context} pre-move`, + ); + const rootBefore = await queryNode( + product, + workspace, + E3_B, + `${context} pre-move`, + ); + const movedBefore = await queryNode( + product, + workspace, + E3_M_PRE, + `${context} pre-move`, + ); + + await expectExit( + product, + workspace, + ["move", E3_M_PRE, E3_PN], + 0, + `${context}: \`move ${E3_M_PRE} ${E3_PN}\``, + ); + + await assertFileBytes( + workspace.path(E3_A), + E3_A_MOVED, + `${context}: ${E3_A} after the move — the moved text's line deleted ` + + `whole (SPEC 6.5, 3)`, + ); + await assertFileBytes( + workspace.path(E3_B), + E3_B_MOVED, + `${context}: ${E3_B} after the move — the insertion before \`p\`'s ` + + `closing tag, not at a line start, preceded by an added terminator ` + + `that splits the line and followed by U+000A (SPEC 6.5)`, + ); + + const siblingAfter = await queryNode( + product, + workspace, + E3_PS, + `${context} post-move`, + ); + const rootAfter = await queryNode( + product, + workspace, + E3_B, + `${context} post-move`, + ); + const movedAfter = await queryNode( + product, + workspace, + E3_PN, + `${context} post-move`, + ); + assertSiblingRunGone(siblingBefore, siblingAfter, FF, E3_PS, context); + for (const [report, side] of [ + [rootBefore, "before"], + [rootAfter, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + "foo tail\n", + `${context}: the target root's own text ${side} the move — ` + + `\`foo \` and \` tail\`, U+000A on kept lines at both sides, joined ` + + `at \`p\`'s excision point (SPEC 1.6, 3)`, + ); + } + assertSameJson( + rootAfter.hashes.ownHash, + rootBefore.hashes.ownHash, + `${context}: the target root keeps its ownHash — its own content is ` + + `unchanged by the split line (SPEC 6.2)`, + ); + for (const [report, side] of [ + [movedBefore, "before"], + [movedAfter, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + "text", + `${context}: the moved node's own text ${side} the move — alone on ` + + `its line at both sides (SPEC 1.6, 3)`, + ); + } + assertKeptSectionMoveHashes( + movedBefore.hashes, + movedAfter.hashes, + E3_M_PRE, + E3_PN, + context, + ); + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} \`check --json\` after the move — clean: every rewritten ` + + `composition derives (SPEC 6.5; S-9)`, + ); + + const label = `${context}: \`impact --base <pre-move ref> --json\``; + const changed = [E3_A, E3_P, E3_PS]; + assertImpactTable( + await impactAgainst(product, workspace, base, label), + [ + { + identity: E3_PS, + categories: [{ category: "changed", within: changed }], + }, + { + identity: E3_P, + categories: [ + { category: "changed", within: changed }, + { category: "descendant-changed", exact: [E3_PS] }, + ], + }, + // The origin parent is the file root; the target root keeps its + // own content and cascades only. + { + identity: E3_A, + categories: [{ category: "changed", within: changed }], + }, + { + identity: E3_B, + categories: [ + { category: "descendant-changed", exact: [E3_P, E3_PS] }, + ], + }, + { identity: E3_AA, categories: [] }, + { identity: E3_PN, categories: [] }, + { + identity: E3_W_ONS, + categories: [{ category: "upstream-changed", exact: [E3_PS] }], + }, + { + identity: E3_W_TOP, + categories: [{ category: "upstream-changed", exact: [E3_PS] }], + }, + { + identity: E3_DEPS, + categories: [{ category: "upstream-changed", exact: [E3_PS] }], + }, + ], + label, + ); + }, + ); +} + const T6_2_3 = defineProductTest({ id: "T6.2-3", + // Fourteen stagings (~90 CLI invocations, ~22 s alone): headroom for a + // saturated box. + timeoutMs: 240_000, title: - "section move impurity: on a clean-boundary fixture every moved node keeps ownHash, subtreeHash, and metadataHash, the origin and target parents are each `changed` with ordinary cascades attributed to them, and no other node is `changed`; a moved section with an impure origin boundary (SPEC 6.2's worked case) is itself additionally `changed` with the 5.6 cascades attributed to it, its metadataHash still unchanged (SPEC 6.2, 3, 5.6, 6.5)", + "section move impurity: on a clean-boundary fixture every moved node keeps ownHash, subtreeHash, and metadataHash, the origin and target parents are each `changed` with ordinary cascades attributed to them, and no other node is `changed`; a moved section with an impure origin boundary (SPEC 6.2's worked case) is itself additionally `changed` with the 5.6 cascades attributed to it, its metadataHash still unchanged — in each of the three impure stagings (the worked shape with spaces before its closing tag, the both-sided U+000B/U+000C spelling, the `body</S>` variant with such a remainder), moved to top level and into a flow-position parent alike, the bytes named gone from its own text at the destination; and a sibling with bytes on the deletion's merged line or on the line the insertion splits is `changed` exactly when the drop rule of 3 decides that line differently, the moved node keeping its hashes and carrying no category (SPEC 6.2, 3, 1.4, 5.6, 6.5)", run: async (product) => { // --- Clean-boundary arm --- await withWorkspace( @@ -1118,7 +2053,7 @@ const T6_2_3 = defineProductTest({ await withWorkspace( SPECS_ONLY_CONFIG, { - [I3_ROOM]: I3_ROOM_SOURCE, + [I3_ROOM]: T6_2_3_ROOM, [I3_HALL]: I3_HALL_SOURCE, [I3_DEPS]: I3_DEPS_SOURCE, }, @@ -1143,6 +2078,24 @@ const T6_2_3 = defineProductTest({ `${context}: \`move specs/Room.mdx#op.imp specs/Hall.mdx#tp.imp\``, ); + // Premise of the hash reasoning: the two rewritten files hold exactly + // the bytes SPEC 6.5 fixes — the origin's boundary lines joined into + // one kept line, the moved text at the destination's line start + // followed by U+000A with each tag alone on its line. + await assertFileBytes( + workspace.path(I3_ROOM), + I3_ROOM_MOVED_SOURCE, + `${context}: specs/Room.mdx after the move — the moved text deleted ` + + `in place, the joined boundary line kept (SPEC 6.5, 3)`, + ); + await assertFileBytes( + workspace.path(I3_HALL), + I3_HALL_MOVED_SOURCE, + `${context}: specs/Hall.mdx after the move — the moved text, its ` + + `\`id\` rewritten, inserted before the parent's closing tag at a ` + + `line start and followed by U+000A (SPEC 6.5)`, + ); + const impAfter = await queryNode( product, workspace, @@ -1159,10 +2112,12 @@ const T6_2_3 = defineProductTest({ if (impAfter.hashes.ownHash === impBefore.hashes.ownHash) { fail( `${context}: the moved node's ownHash must change — at the origin ` + - `its opening tag's line is kept (preceded by non-whitespace), so ` + - `the within-construct remainder and terminator contribute to its ` + - `own content, while at the destination the tag-only line is ` + - `dropped (SPEC 6.2's worked case, 3); both sides report ownHash ` + + `both boundary lines are kept (the opening tag preceded by ` + + `non-whitespace, the closing tag followed by it), so the opening ` + + `line's terminator and the closing line's two spaces contribute ` + + `to its own content, while at the destination each tag stands ` + + `alone on its line and both lines are dropped (SPEC 6.2's worked ` + + `case, 3); both sides report ownHash ` + `${JSON.stringify(impAfter.hashes.ownHash)}`, ); } @@ -1255,130 +2210,534 @@ const T6_2_3 = defineProductTest({ ); }, ); + + // --- The three impure stagings (a)–(c), each at both destinations --- + for (const staging of M3_ORIGIN_STAGINGS) { + for (const destination of M3_DESTINATIONS) { + await runImpureStaging(product, staging, destination); + } + } + + // --- The sibling stagings (d) and (e) --- + await runSiblingOriginStaging(product); + await runSiblingDestinationStaging(product); }, }); // --------------------------------------------------------------------------- -// T6.2-4 — same-parent final-position move +// T6.2-4 — same-parent final-position move: the two pinned pure shapes and +// the `changed` twin // --------------------------------------------------------------------------- -// The parent's last child is moved onto itself under a new ID: removal plus -// re-insertion at its own former position reproduces the parent's own content -// exactly (SPEC 6.2), so the move changes no hash and is pure in effect — -// asserted with the same full-workspace sweep and empty impact as T6.2-1. -// A referencing file's `d` and `text(...)` spellings are rewritten to the new -// identity while its hashes stay put (SPEC 5.4). -const P4_FILE = "specs/P.mdx"; -const P4_TOP = "specs/P.mdx#p"; -const P4_FIRST = "specs/P.mdx#p.first"; -const P4_LAST_PRE = "specs/P.mdx#p.last"; -const P4_LAST_POST = "specs/P.mdx#p.final"; -const P4_WATCH = "specs/Watch.mdx"; -const P4_W_TOP = "specs/Watch.mdx#watch"; - -const P4_SOURCE = [ - '<S id="p">', - "Parent text.", - "", - '<S id="p.first">', - "First child text.", - "</S>", - "", - '<S id="p.last" coverage="none" tags="tail">', - "Tail child text.", - "</S>", - "</S>", - "", -].join("\n"); +// Moving a parent's last child onto itself (same parent, same final position, +// new ID) is pure in effect exactly when the re-insertion reproduces the +// parent's own content sequence byte for byte — SPEC 6.2's `may`, which the +// fixture's shape decides, not the operation. TEST-SPEC pins two shapes whose +// re-insertion does (a harness deriving them from an arbitrary last child, +// whose closing tag may share a kept line with the parent's, fails): +// +// (1) a flow-form last child, its opening and closing tags alone on their +// lines, the parent's closing tag alone on the following line: the +// deletion removes exactly the construct's lines (the joined line it +// leaves empty dropping with line 4's terminator, SPEC 6.5, 3), leaving +// `<S id="p">`, U+000A, `</S>`, U+000A, and the insertion before that +// `</S>`, at a line start (no terminator added), restores them — the +// composed file byte-identical to the original but for the `id` +// attribute; +// (2) T6.5-13(f)'s top-level shape — the file's unterminated last section +// moved onto its own position, the root the coincident parent: what the +// deletion leaves before the file's end is line 1's terminator, so none +// is added, and the result gains only the moved text's own terminator. +// +// In each, no hash changes — the parent's own content sequence reproduced, +// the re-inserted child entering by its canonical identity (SPEC 5.4) — and +// no node carries any category apart from the identity mapping; a dependent +// of the moved node in another file, whose `d` and `text(...)` spellings the +// move rewrites (SPEC 6.5), keeps its hashes like every other node (the +// purity claim covers a real rewrite). The parents' and the moved nodes' own +// texts are byte-asserted through `query node` on both sides as the sharp +// witness of SPEC 3's drop-rule decisions (SPEC 1.6: exact bytes). +// +// The `changed` twin — 6.2's `may` on its other side — is T6.5-13(e)'s +// shape under the same command: the composed text derives (S-9) and gives +// `p`'s run after its child the moved text's terminator, U+000A, where it +// was empty, so `p` is `changed`, its ownHash with it, its metadataHash +// kept, with the 5.6 cascades attributed to it (the root +// `descendant-changed`; a dependent of `p` in another file and that file's +// root `upstream-changed`); the moved node keeps its hashes (`x` on a kept +// line at both sides) and carries no category; the root keeps its own +// content; no other node is `changed`. + +const P4_FILE = "specs/a.mdx"; +const P4_DEPS = "specs/Deps.mdx"; +const P4_W_TOP = "specs/Deps.mdx#watch"; -const P4_WATCH_SOURCE = [ - 'import P from "./P.xspec"', - "", - '<S id="watch" d={P.p.last}>', - "Depends on the tail child. Embeds: {text(P.p.last)}", - "</S>", - "", -].join("\n"); +/** + * The other-file dependent: `d` plus an embedding of one node of + * `specs/a.mdx` (two edge kinds, one target) — the exact `upstream-changed` + * attribution in the twin; a rewritten spelling with unchanged hashes in the + * pure shapes. + */ +function p4DepsSource(target: string, role: string): string { + return [ + 'import A from "./a.xspec"', + "", + `<S id="watch" d={A.${target}}>`, + `Depends on the ${role}. Embeds: {text(A.${target})}`, + "</S>", + "", + ].join("\n"); +} -const P4_PRE_IDENTITIES = [ - P4_FILE, - P4_TOP, - P4_FIRST, - P4_LAST_PRE, - P4_WATCH, - P4_W_TOP, -]; -const P4_IDENTITY_MAP: Readonly<Record<string, string>> = { - [P4_LAST_PRE]: P4_LAST_POST, -}; -const P4_POST_IDENTITIES = P4_PRE_IDENTITIES.map( - (identity) => P4_IDENTITY_MAP[identity] ?? identity, +// Pinned shape (1): the flow-form last child. +const F1_P = "specs/a.mdx#p"; +const F1_M_PRE = "specs/a.mdx#p.m"; +const F1_M_POST = "specs/a.mdx#p.n"; +const F1_SOURCE = stagedMdx( + "T6.2-4 pinned shape (1), the flow-form last child specs/a.mdx", + '<S id="p">\n<S id="p.m">\ny\n</S>\n</S>\n', ); +const F1_MOVED = '<S id="p">\n<S id="p.n">\ny\n</S>\n</S>\n'; + +// Pinned shape (2): T6.5-13(f)'s top-level shape, no final terminator. +const F2_A = "specs/a.mdx#a"; +const F2_M_PRE = "specs/a.mdx#m"; +const F2_M_POST = "specs/a.mdx#n"; +const F2_SOURCE = stagedMdx( + "T6.2-4 pinned shape (2), T6.5-13(f)'s top-level shape specs/a.mdx", + '<S id="a">x</S>\n<S id="m">\ny\n</S>', +); +const F2_MOVED = '<S id="a">x</S>\n<S id="n">\ny\n</S>\n'; -const T6_2_4 = defineProductTest({ - id: "T6.2-4", - title: - "same-parent final-position move: moving a parent's last child onto itself (same parent, same final position, new ID) changes no hash in the workspace (full sweep) and is pure in effect — `impact --base <pre-move ref>` reports no categories and no impacted code — apart from the identity mapping (SPEC 6.2, 6.5)", - run: async (product) => { - await withWorkspace( - SPECS_ONLY_CONFIG, - { [P4_FILE]: P4_SOURCE, [P4_WATCH]: P4_WATCH_SOURCE }, - async (workspace) => { - await workspace.gitInit(); - const base = await workspace.gitCommitAll("pre-move baseline"); - await buildOk( - product, - workspace, - "T6.2-4 `build` over the staged workspace", - ); +// The `changed` twin: T6.5-13(e)'s shape, under shape (1)'s command. +const F3_SOURCE = stagedMdx( + "T6.2-4 `changed` twin, T6.5-13(e)'s shape specs/a.mdx", + 'foo <S id="p">\n<S id="p.m">x</S></S> baz\n', +); +// The twin's dependent: the template call, moved to module level as a +// record; the runner asserts the file's bytes unchanged after the move +// against the record's source. +const T6_2_4_TWIN_DEPS = stagedMdx( + "T6.2-4 `changed` twin, T6.5-13(e)'s shape specs/Deps.mdx", + p4DepsSource("p", "coincident parent"), +); +const F3_MOVED = 'foo <S id="p">\n<S id="p.n">x</S>\n</S> baz\n'; + +/** One pinned pure shape of T6.2-4 (module header). */ +interface PureFinalPositionStaging { + readonly label: string; + /** The staged source — a staged-source record (module header). */ + readonly source: StagedMdx; + /** The composed file, byte-exact. */ + readonly moved: string; + /** Why the composition is what it is (the byte assertion's diagnosis). */ + readonly composition: string; + readonly movedPre: string; + readonly movedPost: string; + /** The moved node's ID at both sides, as the dependent spells it. */ + readonly idPre: string; + readonly idPost: string; + /** The coincident parent, and its own text at both sides. */ + readonly parent: string; + readonly parentOwnText: string; + readonly parentOwnTextWhy: string; + /** The moved node's own text at both sides. */ + readonly movedOwnText: string; + /** Every requirement node of the fixture before the move. */ + readonly preIdentities: readonly string[]; +} - const before = await sweepHashes( - product, - workspace, - P4_PRE_IDENTITIES, - "T6.2-4 pre-move sweep", - ); +const F1_STAGING: PureFinalPositionStaging = { + label: "T6.2-4 pinned shape (1), the flow-form last child", + source: F1_SOURCE, + moved: F1_MOVED, + composition: + "the deletion removes exactly the construct's lines — the joined line it " + + "leaves empty dropping with line 4's terminator — and the insertion " + + "before the parent's `</S>`, at a line start with no terminator added, " + + "restores them: byte-identical to the original but for the `id` " + + "attribute (SPEC 6.5, 3)", + movedPre: F1_M_PRE, + movedPost: F1_M_POST, + idPre: "p.m", + idPost: "p.n", + parent: F1_P, + parentOwnText: "", + parentOwnTextWhy: + "both of its runs empty: its opening tag's line, the child's closing " + + "tag's line, and its own closing tag's line each dropped, left empty " + + "purely by removals (SPEC 3)", + movedOwnText: "y\n", + preIdentities: [P4_FILE, F1_P, F1_M_PRE, P4_DEPS, P4_W_TOP], +}; - await expectExit( - product, - workspace, - ["move", "specs/P.mdx#p.last", "specs/P.mdx#p.final"], - 0, - "T6.2-4 `move specs/P.mdx#p.last specs/P.mdx#p.final`", - ); +const F2_STAGING: PureFinalPositionStaging = { + label: "T6.2-4 pinned shape (2), T6.5-13(f)'s top-level shape", + source: F2_SOURCE, + moved: F2_MOVED, + composition: + "what the deletion leaves before the file's end is line 1's terminator, " + + "so none is added before the moved text, which its own U+000A follows: " + + "the original with `m` re-identified and a final terminator (SPEC 6.5)", + movedPre: F2_M_PRE, + movedPost: F2_M_POST, + idPre: "m", + idPost: "n", + parent: P4_FILE, + parentOwnText: "\n", + parentOwnTextWhy: + "U+000A at both sides — line 1's terminator after `a`'s excised " + + "contribution on that kept line, the construct's own lines dropped, the " + + "added final terminator dropping with the closing tag's line (SPEC 3)", + movedOwnText: "y\n", + preIdentities: [P4_FILE, F2_A, F2_M_PRE, P4_DEPS, P4_W_TOP], +}; - // Premise: the referencing file's spellings were rewritten to the new - // identity (SPEC 6.5) — the purity claim covers a real rewrite. - assertRewriteHappened( - await readSourceText(workspace, P4_WATCH, "T6.2-4 rewrite premise"), - P4_WATCH, - "p.last", - "p.final", - "T6.2-4 rewrite premise", - ); +/** One pinned pure shape with its dependent's source as a record. */ +interface PureFinalPositionRun { + readonly staging: PureFinalPositionStaging; + /** `specs/Deps.mdx`: the template call the runner staged. */ + readonly deps: StagedMdx; +} - const after = await sweepHashes( - product, - workspace, - P4_POST_IDENTITIES, - "T6.2-4 post-move sweep", +// T6.2-4's stagings follow one another's invocations, so their initial +// sources are staged-source records (module header): the pinned shapes' +// sources wrapped in place, the dependents' template calls evaluated once +// at module load, in shape order — the same calls the runner made, moved +// here — and the `changed` twin's above. +const PURE_FINAL_POSITION_RUNS: readonly PureFinalPositionRun[] = [ + F1_STAGING, + F2_STAGING, +].map((staging) => ({ + staging, + deps: stagedMdx( + `${staging.label} specs/Deps.mdx`, + p4DepsSource(staging.idPre, "moved node"), + ), +})); + +/** A pinned pure shape: byte-exact composition, full sweep, empty impact. */ +async function runPureFinalPositionStaging( + product: ProductBinding, + { staging, deps }: PureFinalPositionRun, +): Promise<void> { + const context = staging.label; + const identityMap: Readonly<Record<string, string>> = { + [staging.movedPre]: staging.movedPost, + }; + const postIdentities = staging.preIdentities.map( + (identity) => identityMap[identity] ?? identity, + ); + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [P4_FILE]: staging.source, + [P4_DEPS]: deps, + }, + async (workspace) => { + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + await buildOk(product, workspace, `${context}: \`build\``); + + const before = await sweepHashes( + product, + workspace, + staging.preIdentities, + `${context} pre-move sweep`, + ); + const parentBefore = await queryNode( + product, + workspace, + staging.parent, + `${context} pre-move`, + ); + const movedBefore = await queryNode( + product, + workspace, + staging.movedPre, + `${context} pre-move`, + ); + + await expectExit( + product, + workspace, + ["move", staging.movedPre, staging.movedPost], + 0, + `${context}: \`move ${staging.movedPre} ${staging.movedPost}\``, + ); + + await assertFileBytes( + workspace.path(P4_FILE), + staging.moved, + `${context}: ${P4_FILE} after the move — ${staging.composition}`, + ); + + // Premise: the dependent's spellings were rewritten to the new identity + // (SPEC 6.5) — the purity claim covers a real rewrite. + assertRewriteHappened( + await readSourceText(workspace, P4_DEPS, `${context} rewrite premise`), + P4_DEPS, + `A.${staging.idPre}`, + `A.${staging.idPost}`, + `${context} rewrite premise`, + ); + + const after = await sweepHashes( + product, + workspace, + postIdentities, + `${context} post-move sweep`, + ); + assertHashesPreserved( + before, + after, + identityMap, + "the same-parent final-position `move` of a pinned shape", + context, + ); + + const parentAfter = await queryNode( + product, + workspace, + staging.parent, + `${context} post-move`, + ); + const movedAfter = await queryNode( + product, + workspace, + staging.movedPost, + `${context} post-move`, + ); + for (const [report, side] of [ + [parentBefore, "before"], + [parentAfter, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + staging.parentOwnText, + `${context}: the coincident parent ${staging.parent}'s own text ` + + `${side} the move — ${staging.parentOwnTextWhy}; the re-insertion ` + + `reproduces its sequence (SPEC 6.2; 1.6: exact bytes)`, ); - assertHashesPreserved( - before, - after, - P4_IDENTITY_MAP, - "the same-parent final-position `move`", - "T6.2-4", + } + for (const [report, identity, side] of [ + [movedBefore, staging.movedPre, "before"], + [movedAfter, staging.movedPost, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + staging.movedOwnText, + `${context}: the moved node ${identity}'s own text ${side} the ` + + `move — \`y\`, U+000A on its kept interior line, its tags' lines ` + + `dropped at both sides (SPEC 1.6, 3)`, ); + } - const label = "T6.2-4 `impact --base <pre-move ref> --json`"; - assertPureImpact( - await impactAgainst(product, workspace, base, label), - "a same-parent final-position `move`", - label, + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean: the composed ` + + `file derives (SPEC 6.5; S-9)`, + ); + + const label = `${context}: \`impact --base <pre-move ref> --json\``; + assertPureImpact( + await impactAgainst(product, workspace, base, label), + "a same-parent final-position `move` of a pinned shape", + label, + ); + }, + ); +} + +/** The `changed` twin: the coincident parent alone `changed`. */ +async function runChangedTwinStaging(product: ProductBinding): Promise<void> { + const context = "T6.2-4 `changed` twin, T6.5-13(e)'s shape"; + await withWorkspace( + SPECS_ONLY_CONFIG, + { [P4_FILE]: F3_SOURCE, [P4_DEPS]: T6_2_4_TWIN_DEPS }, + async (workspace) => { + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + await buildOk(product, workspace, `${context}: \`build\``); + + const parentBefore = await queryNode( + product, + workspace, + F1_P, + `${context} pre-move`, + ); + const rootBefore = await queryNode( + product, + workspace, + P4_FILE, + `${context} pre-move`, + ); + const movedBefore = await queryNode( + product, + workspace, + F1_M_PRE, + `${context} pre-move`, + ); + + await expectExit( + product, + workspace, + ["move", F1_M_PRE, F1_M_POST], + 0, + `${context}: \`move ${F1_M_PRE} ${F1_M_POST}\``, + ); + + await assertFileBytes( + workspace.path(P4_FILE), + F3_MOVED, + `${context}: ${P4_FILE} after the move — the origin deletion's range ` + + `ends exactly at the insertion point, which line 1's terminator ` + + `precedes in the composed text, so no terminator is added before ` + + `the moved text, and its own U+000A follows it (SPEC 6.5; ` + + `T6.5-13(e))`, + ); + await assertFileBytes( + workspace.path(P4_DEPS), + T6_2_4_TWIN_DEPS.source, + `${context}: ${P4_DEPS} after the move — its spellings resolve to ` + + `\`p\`, whose identity the mapping leaves alone: nothing rewritten, ` + + `no other byte changed (SPEC 6.5)`, + ); + + const parentAfter = await queryNode( + product, + workspace, + F1_P, + `${context} post-move`, + ); + const rootAfter = await queryNode( + product, + workspace, + P4_FILE, + `${context} post-move`, + ); + const movedAfter = await queryNode( + product, + workspace, + F1_M_POST, + `${context} post-move`, + ); + + assertBytesEqual( + parentBefore.ownText, + "\n", + `${context}: \`p\`'s own text before the move — line 1's terminator, ` + + `on a kept line, before its child; its run after the child empty, ` + + `its closing tag following the child's at once (SPEC 1.6, 3)`, + ); + assertBytesEqual( + parentAfter.ownText, + "\n\n", + `${context}: \`p\`'s own text after the move — its run after the ` + + `child now the moved text's terminator, U+000A, on the kept line ` + + `holding \`x\`, where it was empty (SPEC 6.2, 6.5, 3)`, + ); + if (parentAfter.hashes.ownHash === parentBefore.hashes.ownHash) { + fail( + `${context}: \`p\`'s ownHash must change — its own content ` + + `sequence gained the run U+000A after its child (SPEC 6.2, 5.5); ` + + `both sides report ownHash ` + + `${JSON.stringify(parentAfter.hashes.ownHash)}`, ); - }, - ); + } + assertSameJson( + parentAfter.hashes.metadataHash, + parentBefore.hashes.metadataHash, + `${context}: \`p\` keeps its metadataHash — a section move changes ` + + `no node's \`d\` targets, coverage, or tags (SPEC 6.2, 5.5)`, + ); + for (const [report, side] of [ + [rootBefore, "before"], + [rootAfter, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + "foo baz\n", + `${context}: the root's own text ${side} the move — \`foo \` and ` + + `\` baz\`, U+000A on kept lines at both sides, joined at \`p\`'s ` + + `excision point (SPEC 1.6, 3; T6.5-13(e))`, + ); + } + assertSameJson( + rootAfter.hashes.ownHash, + rootBefore.hashes.ownHash, + `${context}: the root keeps its ownHash — its own content is ` + + `unchanged, \`p\` the one parent between its runs (SPEC 6.2)`, + ); + for (const [report, identity, side] of [ + [movedBefore, F1_M_PRE, "before"], + [movedAfter, F1_M_POST, "after"], + ] as const) { + assertBytesEqual( + report.ownText, + "x", + `${context}: the moved node ${identity}'s own text ${side} the ` + + `move — \`x\` on a kept line at both sides (SPEC 1.6, 3)`, + ); + } + assertSameJson( + movedAfter.hashes, + movedBefore.hashes, + `${context}: the moved node ${F1_M_PRE} (now ${F1_M_POST}) keeps its ` + + `hashes — the identity mapping changes no hash, its one run rides ` + + `a kept line at both sides, and it has no children and no ` + + `dependency edges (SPEC 6.2, 5.4, 5.5)`, + ); + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean: the composed ` + + `text derives (SPEC 6.5; S-9)`, + ); + + const label = `${context}: \`impact --base <pre-move ref> --json\``; + assertImpactTable( + await impactAgainst(product, workspace, base, label), + [ + { + identity: F1_P, + categories: [{ category: "changed", within: [F1_P] }], + }, + { + identity: P4_FILE, + categories: [{ category: "descendant-changed", exact: [F1_P] }], + }, + { identity: F1_M_POST, categories: [] }, + { + identity: P4_W_TOP, + categories: [{ category: "upstream-changed", exact: [F1_P] }], + }, + { + identity: P4_DEPS, + categories: [{ category: "upstream-changed", exact: [F1_P] }], + }, + ], + label, + ); + }, + ); +} + +const T6_2_4 = defineProductTest({ + id: "T6.2-4", + // Three stagings (~40 CLI invocations, ~12 s alone): headroom for a + // saturated box. + timeoutMs: 180_000, + title: + "same-parent final-position move: moving a parent's last child onto itself (same parent, same final position, new ID) is pure in effect exactly when the re-insertion reproduces the parent's own content sequence — in the two pinned shapes (a flow-form last child whose tags, and its parent's closing tag, stand alone on their lines; T6.5-13(f)'s top-level shape) the composed file is byte-exact, no hash in the workspace changes (full sweep), and `impact --base <pre-move ref>` reports no categories apart from the identity mapping; in the `changed` twin (T6.5-13(e)'s shape) the coincident parent alone is `changed`, its ownHash with it and its metadataHash kept, with the 5.6 cascades attributed to it, the moved node and the root keeping their content (SPEC 6.2, 6.5, 3, 5.4, 5.6)", + run: async (product) => { + for (const run of PURE_FINAL_POSITION_RUNS) + await runPureFinalPositionStaging(product, run); + await runChangedTwinStaging(product); }, }); diff --git a/test/suite/registry/section-6.3.ts b/test/suite/registry/section-6.3.ts index 18f83929..969676e2 100644 --- a/test/suite/registry/section-6.3.ts +++ b/test/suite/registry/section-6.3.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §6.3 (baseline resolution) — SUITE-23: T6.3-1…T6.3-4. +// TEST-SPEC §6.3 (baseline resolution) — SUITE-23: T6.3-1…T6.3-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), @@ -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,51 +43,104 @@ // - 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 // git-reading invocation). +// - T6.3-5's repository-resolution arms report a leaf edit of a two-node +// file (`specs/A.mdx#a` under its file root): SPEC 5.6's leaf-edit table +// (T5.6-1) — the section `changed`, the file root `descendant-changed` +// attributed to it, no other identity; identities are workspace-relative +// to the configuration's directory (SPEC 1.5), so a `sub/`-prefixed +// spelling or the outer repository's extra section fails the exact set. +// - "`review create --base v1` records the inner commit" (SPEC 10.7): the +// recorded creation parameters are product-shaped (H-4), so the inner +// repository's `v1` commit hash must appear among their string leaves as +// `review export` emits them — the full hash or an abbreviation of at +// least 7 hex digits — and the outer repository's `v1` hash must not; the +// §10.2/§10.6 string-leaf operationalization. Beside it, no item of the +// session names the extra section that only the outer `v1` holds. +// - The no-repository arm's actionable error: stderr must echo the ref +// (`HEAD`) or speak of the repository/git — either tells the operator why +// no baseline resolves. Its staging premise — the workspace lies inside +// no repository's working tree — is checked through git itself under the +// product's isolation and reported as a harness condition, never a +// product verdict, should the OS temporary directory lie inside one. +// - The absent-configuration arms: the offending file is the configuration +// absent at its repository-relative path in the ref's tree, so stderr +// must name it (`xspec.config.ts`). // // Journal tampering below stays product-independent (H-4): a strictly longer // baseline journal can be a prefix of no shorter current journal, whatever // the entry bytes are; the appended garbage line is structureless bytes no // conforming entry format accepts (T6.1-3's sanctioned staging), appended as // a whole line under either final-line convention. +// +// Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): every workspace a body here creates after its +// first product invocation — T6.3-2's second arm, T6.3-4's four later arms, +// T6.3-5's arms (b)–(d) — takes its initial `.mdx` sources as ledger +// records, judged by test/self/s9-staged-sources.test.ts before any product +// exists; where a body's first workspace stages the same expression, the +// record stands there too (converted uniformly). T6.3-1's and T6.3-3's one +// workspace each precede any invocation and stay plain. import { Buffer } from "node:buffer"; +import { execFile } from "node:child_process"; import * as fsp from "node:fs/promises"; -import type { ImpactReport } from "../../helpers/adapters/index.js"; -import { decodeImpactReport } from "../../helpers/adapters/index.js"; +import * as os from "node:os"; +import { promisify } from "node:util"; +import type { + ExportReport, + ImpactReport, +} from "../../helpers/adapters/index.js"; +import { + decodeExportReport, + decodeImpactReport, +} from "../../helpers/adapters/index.js"; import { - assertStdoutEmpty, + assertExitCode, fail, parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; -import { summarizeResult } from "../../helpers/subprocess.js"; +import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertSameJson, buildFindings, buildOk, + expectErrorDocument, expectExit, } from "./support.js"; // Exactly one spec group over specs/ (SPEC 7) — every fixture here except -// T6.3-1's, whose group membership is the moving part. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// T6.3-1's, whose group membership is the moving part. T6.3-2, T6.3-4, and +// T6.3-5 stage it after a product invocation — in a later workspace, at +// `xspec.config.ts` or `inner/xspec.config.ts`, and by `file()` at +// `sub/xspec.previous.config.ts` and `sub/xspec.config.ts` — so it is a +// staged-source record (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.3-2/T6.3-4/T6.3-5 xspec.config.ts — exactly one spec group over specs/, staged at every configuration path", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); const JOURNAL_PATH = ".xspec/journal"; const LF = 0x0a; @@ -98,7 +152,7 @@ const GARBAGE_LINE = "?? harness-injected garbage: not a journal entry ??"; /** Stage a fresh workspace (`files` must include the config), run, dispose. */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ files }); @@ -165,23 +219,55 @@ async function appendJournalLine( } /** - * `impact --base <ref> --json`: exit 0 (impact is informational, SPEC 9.3; + * Run the product from an explicit absolute working directory (H-2) — + * T6.3-5 drives it from the repository root and from the workspace's + * subdirectory alike — asserting the exact exit code (H-5). + */ +async function expectExitAt( + product: ProductBinding, + cwd: string, + argv: readonly string[], + exitCode: number, + context: string, +): Promise<RunResult> { + const result = await runProduct(product, { cwd, argv }); + assertExitCode(result, exitCode, context); + return result; +} + +/** + * `impact … --json` from `cwd`: exit 0 (impact is informational, SPEC 9.3; * H-5) with exactly one JSON document, decoded as the impact report (H-3). */ +async function impactAt( + product: ProductBinding, + cwd: string, + argv: readonly string[], + context: string, +): Promise<ImpactReport> { + const result = await expectExitAt( + product, + cwd, + [...argv, "--json"], + 0, + context, + ); + return decodeImpactReport(parseJsonStdout(result, context), context); +} + +/** `impact --base <ref> --json` from the workspace root. */ async function impactAgainst( product: ProductBinding, workspace: TestWorkspace, ref: string, context: string, ): Promise<ImpactReport> { - const result = await expectExit( + return await impactAt( product, - workspace, - ["impact", "--base", ref, "--json"], - 0, + workspace.root, + ["impact", "--base", ref], context, ); - return decodeImpactReport(parseJsonStdout(result, context), context); } /** @@ -212,9 +298,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, @@ -222,18 +309,34 @@ async function expectBaselineUsageError( argv: readonly string[], context: string, ): Promise<RunResult> { - const result = await expectExit( + return await expectBaselineUsageErrorAt( product, - workspace, + workspace.root, + argv, + context, + ); +} + +/** {@link expectBaselineUsageError} from an explicit working directory. */ +async function expectBaselineUsageErrorAt( + product: ProductBinding, + cwd: string, + argv: readonly string[], + context: string, +): Promise<RunResult> { + const result = await expectExitAt( + product, + cwd, [...argv, "--json"], 2, `${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; } @@ -435,15 +538,21 @@ const T6_3_1 = defineProductTest({ // One file with a child (so the rename's prefix replacement gives the replay // a descendant mapping too) — the subject of a single journaled rename. const J2_FILE = "specs/A.mdx"; -const J2_SOURCE = [ - '<S id="a">', - "Alpha text.", - '<S id="a.k">', - "Kid text.", - "</S>", - "</S>", - "", -].join("\n"); +// Staged in both arms' workspaces; the second follows the first's +// invocations, so it is a staged-source record (module header) — the +// expression wrapped in place. +const J2_SOURCE = stagedMdx( + "T6.3-2 specs/A.mdx", + [ + '<S id="a">', + "Alpha text.", + '<S id="a.k">', + "Kid text.", + "</S>", + "</S>", + "", + ].join("\n"), +); const T6_3_2 = defineProductTest({ id: "T6.3-2", @@ -608,31 +717,53 @@ const T6_3_3 = defineProductTest({ // A minimal rename subject for the journal-tampering arms. const F4_FILE = "specs/A.mdx"; -const F4_SOURCE = ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"); +// Every arm after the first creates its workspace after the earlier arms' +// invocations, so the initial `.mdx` sources are staged-source records +// (module header), each expression wrapped in place: the shared A.mdx +// (staged in the first arm too), the broken source under its `unparseable` +// declaration — the record carries what the workspace declaration's entry +// for the path used to — and the precedence arm's invalid, parseable source. +const F4_SOURCE = stagedMdx( + "T6.3-4 specs/A.mdx", + ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), +); // The invalid-baseline-sources arm: committed broken (unparseable — an // unclosed section tag, 14.20), then fixed in the working tree. const F4_BROKEN_FILE = "specs/Broken.mdx"; -const F4_BROKEN_SOURCE = [ - '<S id="broken">', - "Text that never closes.", - "", -].join("\n"); +const F4_BROKEN_SOURCE = stagedMdx( + "T6.3-4 invalid-baseline-sources arm: specs/Broken.mdx committed broken", + ['<S id="broken">', "Text that never closes.", ""].join("\n"), + "unparseable", +); const F4_FIXED_SOURCE = [ '<S id="broken">', "Text that never closes.", "</S>", "", ].join("\n"); +// The fix is staged after the body's earlier arms invoked the product, so +// it is a ledger record (S-9's before-any-product clause; +// helpers/staged-mdx.ts): the same constant, carrying the call's former +// `well-formed` option as its declaration — the record's declaration +// governs the write, whatever the path's initial record declared. +const T6_3_4_FIXED = stagedMdx( + "T6.3-4 invalid-baseline-sources arm: specs/Broken.mdx fixed in the working tree", + F4_FIXED_SOURCE, + "well-formed", +); // The precedence arm's invalid current source: an unresolved local `d` // reference (14.5) — a build validation failure. -const F4_INVALID_SOURCE = [ - '<S id="a" d={["nope"]}>', - "Alpha text depending on nothing that exists.", - "</S>", - "", -].join("\n"); +const F4_INVALID_SOURCE = stagedMdx( + "T6.3-4 precedence arm specs/A.mdx", + [ + '<S id="a" d={["nope"]}>', + "Alpha text depending on nothing that exists.", + "</S>", + "", + ].join("\n"), +); // A ref that resolves to nothing in any fixture repository, with a spelling // unlikely to appear in an error message for any other reason. @@ -796,7 +927,10 @@ const T6_3_4 = defineProductTest({ const base = await workspace.gitCommitAll( "baseline with an unparseable source", ); - await workspace.file(F4_BROKEN_FILE, F4_FIXED_SOURCE); + // S-9: the fixed source derives — the record's `well-formed` + // declaration governs this write, the initial record's `unparseable` + // one the staging above. + await workspace.file(F4_BROKEN_FILE, T6_3_4_FIXED); await buildOk( product, workspace, @@ -882,8 +1016,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)`, ); @@ -900,10 +1034,717 @@ const T6_3_4 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T6.3-5 — repository and path of the baseline +// --------------------------------------------------------------------------- + +// SPEC 6.3 fixes where a baseline resolves: the repository whose working tree +// contains the current configuration file — the innermost, where repositories +// nest — whatever repository the working directory lies in; the baseline +// configuration is the file at the current configuration file's +// repository-relative path in the ref's tree, the baseline root its +// directory. Four stagings, each a fresh workspace (H-1): +// (a) the workspace under `sub/` of a repository at the root, driven from +// `R/sub` (the configuration found by the upward search) and from `R` +// with `--config sub/xspec.config.ts`; +// (b) an inner repository at `inner/` inside an outer one at the root — +// a nested repository initialized after the outer committed the inner +// workspace's files (with an extra section), and separately a submodule +// (the outer's `v1` a gitlink, no files) — both repositories tagged `v1` +// at differing trees, driven from `R`; +// (c) a workspace lying in no repository at all; +// (d) refs whose trees hold no file at `sub/xspec.config.ts`. + +const R5_SUB = "sub"; +const R5_SUB_CONFIG = "sub/xspec.config.ts"; +// The configuration under another name (arm d): a commit holding this file +// and no `sub/xspec.config.ts`. +const R5_SUB_RENAMED_CONFIG = "sub/xspec.previous.config.ts"; +const R5_SUB_A = "sub/specs/A.mdx"; +const R5_INNER = "inner"; +const R5_INNER_CONFIG = "inner/xspec.config.ts"; +const R5_INNER_A = "inner/specs/A.mdx"; +// Identities are workspace-relative — to the configuration's directory +// (SPEC 1.5, 6.3) — so the same two nodes answer under every root. +const R5_A = "specs/A.mdx"; +const R5_A_TOP = "specs/A.mdx#a"; +const R5_EXTRA_TOP = "specs/A.mdx#extra"; +const R5_TEXT_V0 = "Alpha text."; +const R5_TEXT_V1 = "Alpha text, edited."; +const R5_TAG = "v1"; +const R5_SESSION = "s"; + +/** `specs/A.mdx`: section `a` holding `text`, then optionally `extra`. */ +function r5Source(text: string, withExtra: boolean): string { + const lines = ['<S id="a">', text, "</S>"]; + if (withExtra) lines.push('<S id="extra">', "Extra text.", "</S>"); + return lines.join("\n") + "\n"; +} + +// Every staging after arm (a)'s product invocations is a ledger record +// (S-9's before-any-product clause; helpers/staged-mdx.ts) — the same +// template calls, moved to module level. The source without the extra +// section is ONE record (identical bytes), staged as an initial file at +// (a)'s and (d)'s `sub/specs/A.mdx` ((a)'s workspace, the body's first, +// converted uniformly), the submodule arm's `inner/specs/A.mdx`, and (c)'s +// `specs/A.mdx`, and by `file()` as the nested-repository arm's inner v1; +// the source with the extra section is that arm's initial +// `inner/specs/A.mdx`, the outer v1. Arm (a)'s own edit precedes the body's +// first invocation and stays a plain staging (S-7's sweep reaches it +// against the stub). +const T6_3_5_WITHOUT_EXTRA = stagedMdx( + "T6.3-5 specs/A.mdx without the extra section: (a) and (d) under sub/, (b) under inner/ (the nested repository's v1, the submodule's initial file), (c) at the root", + r5Source(R5_TEXT_V0, false), +); +const T6_3_5_WITH_EXTRA = stagedMdx( + "T6.3-5 (b) nested-repository arm: inner/specs/A.mdx with the extra section, the outer repository's v1", + r5Source(R5_TEXT_V0, true), +); +const T6_3_5_INNER_EDITED = stagedMdx( + "T6.3-5 (b): the current edit of the inner workspace's source", + r5Source(R5_TEXT_V1, false), +); + +// Identity for commits scripted inside the inner repository: the builder's +// commit helper serves the workspace root's repository only, so the inner +// history is scripted through `git -C inner` under the builder's isolated +// git environment, the identity given inline (hash determinism is not +// needed here — the two `v1` tags need only name distinct commits). +const INNER_IDENTITY = [ + "-c", + "user.name=xspec fixture", + "-c", + "user.email=fixture@xspec.invalid", +] as const; + +/** + * Initialize a repository at `dir` (a subdirectory of the workspace), commit + * everything under it, tag the commit `v1`, and return its hash. + */ +async function initInnerRepositoryAtV1( + workspace: TestWorkspace, + dir: string, +): Promise<string> { + await workspace.git(["-C", dir, "init", "--quiet", "-b", "main"]); + await workspace.git(["-C", dir, "config", "core.autocrlf", "false"]); + await workspace.git(["-C", dir, "add", "-A"]); + await workspace.git([ + ...INNER_IDENTITY, + "-C", + dir, + "commit", + "--quiet", + "-m", + "inner v1: the workspace without the extra section", + ]); + await workspace.git(["-C", dir, "tag", R5_TAG]); + return (await workspace.git(["-C", dir, "rev-parse", R5_TAG])).stdout.trim(); +} + +/** Staging premise: the two `v1` tags name distinct commits. */ +function assertDistinctTags( + outerV1: string, + innerV1: string, + context: string, +): void { + if ( + /^[0-9a-f]{40}$/.test(outerV1) && + /^[0-9a-f]{40}$/.test(innerV1) && + outerV1 !== innerV1 + ) { + return; + } + fail( + `${context} staging premise: the outer and inner repositories' ${R5_TAG} ` + + `tags must name two distinct commits; got outer ${outerV1}, inner ` + + `${innerV1}`, + ); +} + +const execFileAsync = promisify(execFile); + +/** + * Staging premise for the no-repository arm (c): the workspace must lie + * inside no repository's working tree. The builder places every workspace + * under the OS temporary directory; should that directory itself lie inside + * a repository, the arm cannot be staged, and the premise fails as a harness + * condition (a plain error, never a product verdict). Checked through git + * itself under the product's isolation — ambient `GIT_*` dropped, no + * ceiling — the way the product's own git reads discover a repository. + */ +export async function assertOutsideAnyRepository( + root: string, + context: string, +): Promise<void> { + const env: Record<string, string> = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value === undefined || name.toUpperCase().startsWith("GIT_")) continue; + env[name] = value; + } + env["GIT_CONFIG_NOSYSTEM"] = "1"; + env["GIT_CONFIG_GLOBAL"] = os.devNull; + env["GIT_TERMINAL_PROMPT"] = "0"; + let toplevel: string; + try { + const { stdout } = await execFileAsync( + "git", + ["-C", root, "rev-parse", "--show-toplevel"], + { env, timeout: 60_000 }, + ); + toplevel = stdout.trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw error; + return; // git finds no working tree enclosing the root: the premise holds + } + throw new Error( + `${context}: staging premise — the workspace root ${root} must lie ` + + `inside no repository's working tree, but git finds one at ` + + `${toplevel} (the OS temporary directory lies inside a repository; ` + + `T6.3-5's no-repository arm cannot be staged on this machine)`, + ); +} + +/** Merge an impact report's entries per node identity (the SUITE-20 way). */ +function mergeReportByIdentity( + report: ImpactReport, +): Map<string, { deleted: Set<boolean>; categories: Map<string, string[]> }> { + const merged = new Map< + string, + { deleted: Set<boolean>; categories: Map<string, string[]> } + >(); + for (const entry of report.requirements) { + for (const identity of entry.nodes) { + let node = merged.get(identity); + if (node === undefined) { + node = { deleted: new Set(), categories: new Map() }; + merged.set(identity, node); + } + node.deleted.add(entry.deleted); + for (const category of entry.categories) { + const attributed = node.categories.get(category.category) ?? []; + attributed.push(...category.attributedTo); + node.categories.set(category.category, attributed); + } + } + } + return merged; +} + +/** + * Assert an impact report shows exactly the leaf edit of `specs/A.mdx#a`: + * the section `changed` (attributed within itself) and its file root + * `descendant-changed` attributed to it — SPEC 5.6's leaf-edit table + * (T5.6-1) — under the workspace-relative identities of the configuration's + * directory, no other identity, and no impacted code. + */ +function assertLeafEditOnly(report: ImpactReport, context: string): void { + const merged = mergeReportByIdentity(report); + assertSameJson( + [...merged.keys()].sort(), + [R5_A, R5_A_TOP].sort(), + `${context}: the report names exactly the edited section and its file ` + + `root, workspace-relative to the configuration's directory (SPEC 1.5, ` + + `6.3: identities like \`sub/specs/A.mdx#a\` come from a root taken ` + + `at the repository instead of the configuration's directory; ` + + `\`${R5_EXTRA_TOP}\` from a baseline read at the outer repository's ` + + `${R5_TAG} instead of the inner's — the repository whose working tree ` + + `contains the configuration)`, + ); + const expected: Record<string, { category: string; within: string[] }> = { + [R5_A_TOP]: { category: "changed", within: [R5_A_TOP] }, + [R5_A]: { category: "descendant-changed", within: [R5_A_TOP] }, + }; + for (const [identity, { category, within }] of Object.entries(expected)) { + const node = merged.get(identity); + if (node === undefined) continue; // already failed above + if (node.deleted.has(true)) { + fail( + `${context}: ${identity} is present on both sides and must not be ` + + `flagged deleted (SPEC 9.3)`, + ); + } + assertSameJson( + [...node.categories.keys()].sort(), + [category], + `${context}: ${identity} carries exactly \`${category}\` — the ` + + `edited section is \`changed\`, its ancestor \`descendant-changed\` ` + + `(SPEC 5.6's leaf edit)`, + ); + const attributed = [...new Set(node.categories.get(category) ?? [])]; + for (const attribution of attributed) { + if (!within.includes(attribution)) { + fail( + `${context}: the ${category} category of ${identity} is ` + + `attributed to ${JSON.stringify(attribution)}, outside the ` + + `originating node ${R5_A_TOP} (SPEC 5.6)`, + ); + } + } + if (category === "descendant-changed" && attributed.length === 0) { + fail( + `${context}: the descendant-changed category of ${identity} must ` + + `be attributed to the edited leaf ${R5_A_TOP} (SPEC 5.6)`, + ); + } + } + assertSameJson( + report.code, + { direct: [], transitive: [] }, + `${context}: no code groups are configured, so no code location is ` + + `impacted (SPEC 9.2)`, + ); +} + +/** Every string leaf of a JSON value, walked with an explicit stack. */ +function stringLeaves(value: unknown): string[] { + const leaves: string[] = []; + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + leaves.push(current); + } else if (Array.isArray(current)) { + for (const item of current) stack.push(item); + } else if (current !== null && typeof current === "object") { + for (const item of Object.values(current)) stack.push(item); + } + } + return leaves; +} + +/** Does any leaf spell `commit` — the full hash or an abbreviation (≥ 7)? */ +function leavesNameCommit(leaves: readonly string[], commit: string): boolean { + return leaves.some( + (leaf) => + leaf.length >= 7 && /^[0-9a-f]+$/.test(leaf) && commit.startsWith(leaf), + ); +} + +/** + * Assert the exported session records the inner repository's `v1` commit as + * the baseline `--base` resolved to at creation (SPEC 10.7; T10.5-6 observes + * the same recording through re-derivation) — the module header's + * string-leaf operationalization — and not the outer repository's `v1`. + */ +function assertRecordsInnerCommit( + exported: ExportReport, + innerV1: string, + outerV1: string, + context: string, +): void { + const leaves = stringLeaves(exported.creationParameters); + if (!leavesNameCommit(leaves, innerV1)) { + fail( + `${context}: \`review create --base ${R5_TAG}\` must record the ` + + `commit identity the ref resolved to at creation (SPEC 10.7) — ` + + `${R5_TAG} of the inner repository, ${innerV1}, the repository ` + + `whose working tree contains the configuration (SPEC 6.3) — among ` + + `the recorded creation parameters \`review export\` emits (as the ` + + `full hash or an abbreviation); got ${JSON.stringify(exported.creationParameters)}`, + ); + } + if (leavesNameCommit(leaves, outerV1)) { + fail( + `${context}: the recorded creation parameters name the OUTER ` + + `repository's ${R5_TAG}, ${outerV1} — the ref resolved in the ` + + `working directory's repository rather than the innermost one ` + + `containing the configuration (SPEC 6.3, 10.7); got ` + + `${JSON.stringify(exported.creationParameters)}`, + ); + } +} + +/** + * Assert no item of the exported session names the extra section — a node + * only the outer repository's `v1` holds, so any item naming it (as scope, + * context, or origin) was derived against the wrong repository's baseline. + */ +function assertNoItemNamesExtra(exported: ExportReport, context: string): void { + for (const item of exported.items) { + const named = [ + item.scope.node, + ...item.context.map((state) => state.node), + ...item.origin.map((origin) => origin.node), + ]; + if (named.includes(R5_EXTRA_TOP)) { + fail( + `${context}: item ${item.id} (${item.kind}) names ${R5_EXTRA_TOP}, ` + + `a node present only in the outer repository's ${R5_TAG}: the ` + + `session was derived against a baseline read from the working ` + + `directory's repository, not the inner one containing the ` + + `configuration (SPEC 6.3, 10.5, 10.7); item: ${JSON.stringify(item)}`, + ); + } + } +} + +/** + * The shared body of both nested-repository stagings (b), driven from the + * outer repository's root: `build`, `impact --base v1`, and `review create + * --base v1` plus its `export`, each naming the configuration through + * `--config inner/xspec.config.ts`. + */ +async function runNestedRepositoryArm( + product: ProductBinding, + workspace: TestWorkspace, + innerV1: string, + outerV1: string, + context: string, +): Promise<void> { + const configArgs = ["--config", R5_INNER_CONFIG]; + await expectExitAt( + product, + workspace.root, + ["build", ...configArgs], + 0, + `${context}: \`build --config ${R5_INNER_CONFIG}\` from R`, + ); + const report = await impactAt( + product, + workspace.root, + ["impact", "--base", R5_TAG, ...configArgs], + `${context}: \`impact --base ${R5_TAG} --config ${R5_INNER_CONFIG} ` + + `--json\` from R — the ref resolves in the inner repository, the one ` + + `whose working tree contains the configuration, not in the working ` + + `directory's (SPEC 6.3)`, + ); + assertLeafEditOnly( + report, + `${context}: \`impact --base ${R5_TAG}\` from R (the extra section on ` + + `neither side — neither deleted nor present)`, + ); + await expectExitAt( + product, + workspace.root, + ["review", "create", "--base", R5_TAG, ...configArgs, "--name", R5_SESSION], + 0, + `${context}: \`review create --base ${R5_TAG} --config ` + + `${R5_INNER_CONFIG} --name ${R5_SESSION}\` from R resolves ${R5_TAG} ` + + `in the inner repository (SPEC 6.3, 10.7)`, + ); + const label = + `${context}: \`review export ${R5_SESSION} --config ${R5_INNER_CONFIG} ` + + `--json\` from R`; + const exportResult = await expectExitAt( + product, + workspace.root, + ["review", "export", R5_SESSION, ...configArgs, "--json"], + 0, + label, + ); + const exported = decodeExportReport( + parseJsonStdout(exportResult, label), + label, + ); + assertRecordsInnerCommit(exported, innerV1, outerV1, context); + assertNoItemNamesExtra(exported, context); +} + +const T6_3_5 = defineProductTest({ + id: "T6.3-5", + title: + "repository and path of the baseline: a workspace rooted in a repository subdirectory reports an edit against the baseline reconstructed from `sub/` at the ref — the configuration read from `sub/xspec.config.ts` in that tree, identities workspace-relative to `sub/` — from `R/sub` and from `R` with `--config` alike; with nested repositories (a nested repository and a submodule, both tagged `v1` at differing trees) `impact --base v1 --config inner/xspec.config.ts` from `R` resolves `v1` in the inner repository — the extra section on neither side — and `review create --base v1` records the inner commit; a configuration outside any working tree makes `impact --base HEAD` exit 2, as does a ref whose tree holds no file at the configuration's repository-relative path, each with an actionable error, nothing modified (SPEC 6.3, 7, 10.7, 12.0; T10.5-6)", + timeoutMs: 240_000, + run: async (product) => { + // --- (a) A workspace rooted in a repository subdirectory --- + await withWorkspace( + { + [R5_SUB_CONFIG]: SPECS_ONLY_CONFIG, + [R5_SUB_A]: T6_3_5_WITHOUT_EXTRA, + }, + async (workspace) => { + const context = "T6.3-5 (a) repository-subdirectory arm"; + await workspace.gitInit(); + const c1 = await workspace.gitCommitAll( + "c1: the workspace under sub/ of the repository", + ); + await workspace.file(R5_SUB_A, r5Source(R5_TEXT_V1, false)); + const subDir = workspace.path(R5_SUB); + await expectExitAt( + product, + subDir, + ["build"], + 0, + `${context}: \`build\` from R/sub (the configuration found by the ` + + `upward search, SPEC 7)`, + ); + + const fromSub = await impactAt( + product, + subDir, + ["impact", "--base", c1], + `${context}: \`impact --base c1 --json\` from R/sub`, + ); + assertLeafEditOnly(fromSub, `${context}: from R/sub`); + + const fromRoot = await impactAt( + product, + workspace.root, + ["impact", "--base", c1, "--config", R5_SUB_CONFIG], + `${context}: \`impact --base c1 --config ${R5_SUB_CONFIG} --json\` ` + + `from R`, + ); + assertLeafEditOnly( + fromRoot, + `${context}: from R with --config ${R5_SUB_CONFIG}`, + ); + assertSameJson( + { requirements: fromRoot.requirements, code: fromRoot.code }, + { requirements: fromSub.requirements, code: fromSub.code }, + `${context}: the report is the same from either working ` + + `directory — the baseline is reconstructed from sub/ at c1, its ` + + `configuration read from ${R5_SUB_CONFIG} in that tree, ` + + `whatever repository the working directory lies in (SPEC 6.3)`, + ); + }, + ); + + // --- (b) Nested repositories: a nested repository --- + await withWorkspace( + { + [R5_INNER_CONFIG]: SPECS_ONLY_CONFIG, + [R5_INNER_A]: T6_3_5_WITH_EXTRA, + }, + async (workspace) => { + const context = "T6.3-5 (b) nested-repository arm"; + // The outer repository commits the inner workspace's files — with + // the extra section — and tags v1, before the inner repository + // exists. + await workspace.gitInit(); + const outerV1 = await workspace.gitCommitAll( + "outer v1: the inner workspace's files, with the extra section", + ); + await workspace.git(["tag", R5_TAG]); + // The inner repository's v1: the workspace without the extra + // section. + await workspace.file(R5_INNER_A, T6_3_5_WITHOUT_EXTRA); + const innerV1 = await initInnerRepositoryAtV1(workspace, R5_INNER); + assertDistinctTags(outerV1, innerV1, context); + const outerTree = ( + await workspace.git(["show", `${R5_TAG}:${R5_INNER_A}`]) + ).stdout; + const innerTree = ( + await workspace.git(["-C", R5_INNER, "show", `${R5_TAG}:${R5_A}`]) + ).stdout; + if ( + !outerTree.includes('id="extra"') || + innerTree.includes('id="extra"') + ) { + fail( + `${context} staging premise: the outer ${R5_TAG} must hold ` + + `${R5_INNER_A} with the extra section and the inner ${R5_TAG} ` + + `${R5_A} without it; got outer ${JSON.stringify(outerTree)}, ` + + `inner ${JSON.stringify(innerTree)}`, + ); + } + // The current edit. + await workspace.file(R5_INNER_A, T6_3_5_INNER_EDITED); + await runNestedRepositoryArm( + product, + workspace, + innerV1, + outerV1, + context, + ); + }, + ); + + // --- (b) Nested repositories: a submodule --- + await withWorkspace( + { + [R5_INNER_CONFIG]: SPECS_ONLY_CONFIG, + [R5_INNER_A]: T6_3_5_WITHOUT_EXTRA, + }, + async (workspace) => { + const context = "T6.3-5 (b) submodule arm"; + // The inner repository first (its v1 the workspace without any extra + // section), then the outer one, which adds it as a submodule in + // place — a gitlink and no files at its v1. + const innerV1 = await initInnerRepositoryAtV1(workspace, R5_INNER); + await workspace.gitInit(); + await workspace.git([ + "-c", + "protocol.file.allow=always", + "submodule", + "--quiet", + "add", + `./${R5_INNER}`, + R5_INNER, + ]); + const outerV1 = await workspace.gitCommitAll( + "outer v1: the inner workspace as a submodule (a gitlink, no files)", + ); + await workspace.git(["tag", R5_TAG]); + assertDistinctTags(outerV1, innerV1, context); + const gitlink = ( + await workspace.git(["ls-tree", R5_TAG, "--", R5_INNER]) + ).stdout; + const filesAtOuter = ( + await workspace.git(["ls-tree", "-r", R5_TAG, "--", R5_INNER_CONFIG]) + ).stdout; + if (!/^160000 commit /.test(gitlink) || filesAtOuter.trim() !== "") { + fail( + `${context} staging premise: the outer ${R5_TAG} must hold ` + + `${R5_INNER} as a gitlink and no ${R5_INNER_CONFIG}; got ` + + `${JSON.stringify(gitlink)} and ${JSON.stringify(filesAtOuter)}`, + ); + } + // The current edit. + await workspace.file(R5_INNER_A, T6_3_5_INNER_EDITED); + await runNestedRepositoryArm( + product, + workspace, + innerV1, + outerV1, + context, + ); + }, + ); + + // --- (c) A configuration outside any working tree --- + await withWorkspace( + { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [R5_A]: T6_3_5_WITHOUT_EXTRA, + }, + async (workspace) => { + const context = "T6.3-5 (c) no-repository arm"; + await assertOutsideAnyRepository(workspace.root, context); + await buildOk(product, workspace, `${context}: \`build\``); + const argv = ["impact", "--base", "HEAD"]; + const result = await assertLeavesUnchanged( + workspace.root, + async () => + await expectBaselineUsageError( + product, + workspace, + argv, + `${context}: \`impact --base HEAD\` where the configuration ` + + `file lies inside no repository's working tree — no ` + + `baseline can be reconstructed`, + ), + `${context}: \`impact --base HEAD\` modifies nothing (SPEC 6.3, 12.0)`, + ); + assertStderrNames( + result, + /HEAD|repositor|\bgit\b/i, + `echo the ref (HEAD) or speak of the repository the configuration ` + + `lies in none of`, + context, + ); + }, + ); + + // --- (d) A ref whose tree holds no file at the configuration's path --- + await withWorkspace( + { [R5_SUB_A]: T6_3_5_WITHOUT_EXTRA }, + async (workspace) => { + const context = "T6.3-5 (d) configuration-absent-at-ref arm"; + await workspace.gitInit(); + const predating = await workspace.gitCommitAll( + "c0: sources under sub/, no configuration file yet", + ); + await workspace.file(R5_SUB_RENAMED_CONFIG, SPECS_ONLY_CONFIG); + const renamed = await workspace.gitCommitAll( + "c0': the configuration under another name", + ); + await fsp.rm(workspace.path(R5_SUB_RENAMED_CONFIG)); + await workspace.file(R5_SUB_CONFIG, SPECS_ONLY_CONFIG); + await workspace.gitCommitAll( + `c1: the configuration at ${R5_SUB_CONFIG}`, + ); + const subDir = workspace.path(R5_SUB); + await expectExitAt( + product, + subDir, + ["build"], + 0, + `${context}: \`build\` from R/sub (staging premise: the current ` + + `workspace is valid, so each exit 2 below is attributable to ` + + `the baseline alone)`, + ); + + const refs = [ + [predating, `a commit predating ${R5_SUB_CONFIG}`], + [ + renamed, + "a commit in which the configuration file bore another name", + ], + ] as const; + const roots = [ + [subDir, "R/sub", []], + [workspace.root, "R", ["--config", R5_SUB_CONFIG]], + ] as const; + for (const [ref, description] of refs) { + for (const [cwd, where, extra] of roots) { + const argv = ["impact", "--base", ref, ...extra]; + const command = argv.join(" "); + const result = await assertLeavesUnchanged( + workspace.root, + async () => + await expectBaselineUsageErrorAt( + product, + cwd, + argv, + `${context}: \`${command}\` from ${where} — ${description}: ` + + `the ref's tree holds no file at the configuration's ` + + `repository-relative path (${R5_SUB_CONFIG}), so the ` + + `baseline cannot be reconstructed`, + ), + `${context}: \`${command}\` from ${where} modifies nothing ` + + `(SPEC 6.3, 12.0)`, + ); + assertStderrNames( + result, + /xspec\.config\.ts/, + `name the offending file — the configuration absent at ` + + `${R5_SUB_CONFIG} in the tree of ${description}`, + `${context} (\`${command}\` from ${where})`, + ); + } + } + + // `review create --base` fails the same way and modifies nothing + // (SPEC 10.7): no session file, no other write. + const createArgv = [ + "review", + "create", + "--base", + predating, + "--name", + R5_SESSION, + ]; + const createResult = await assertLeavesUnchanged( + workspace.root, + async () => + await expectBaselineUsageErrorAt( + product, + subDir, + createArgv, + `${context}: \`${createArgv.join(" ")}\` from R/sub — a ` + + `commit predating ${R5_SUB_CONFIG}`, + ), + `${context}: \`review create --base\` refused at baseline ` + + `resolution modifies nothing — no session file, no other write ` + + `(SPEC 10.7, 6.3)`, + ); + assertStderrNames( + createResult, + /xspec\.config\.ts/, + `name the offending file — the configuration absent at ` + + `${R5_SUB_CONFIG} in the predating commit's tree`, + `${context} (\`review create --base\`)`, + ); + }, + ); + }, +}); + /** TEST-SPEC §6.3, in canonical ID order (SUITE-23). */ export const section63Tests: readonly ProductTestEntry[] = [ T6_3_1, T6_3_2, T6_3_3, T6_3_4, + T6_3_5, ]; diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index 8ecd11e9..fd7f50dd 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -14,12 +14,21 @@ // a form cannot be kept, the rewritten part uses dot access for segments that // are valid TypeScript identifiers, double-quoted computed access for // segments that are not, and double-quoted string literals. Type-level -// references record no edges and are not rewritten. A nonexistent `<file>` or -// old ID is a usage error (12.0) checked before source validation, 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. +// references record no edges and are not rewritten. A nonexistent `<file>` — +// absent on disk, or an `.mdx` present on disk but matched by no spec group: +// a file named in an argument exists as a member of the discovered set +// (12.0) — or old ID is a usage error (12.0) checked before source +// validation, and so is +// a `<file>` naming a discovered code source — a wrong-kind operand, judged +// like existence before any content question (6.4); the old ID's existence is +// parse-local, judged over spelled identities (11.2): a bearer whose node +// 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,23 +43,83 @@ // 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-2 stages every *affected* reference part in dot access or -// double-quoted form, so each expected byte is pinned whichever way 6.4's -// preserve-then-default rule is read; single-quoted spellings appear only -// in untouched parts and untouched references, whose byte-wise preservation -// T6.4-2 pins explicitly. Whole rewritten source files are compared -// byte-exactly ("only the affected parts change" pins all other bytes). +// - T6.4-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 form-exact performed-operation decoder +// (adapters/forms.ts — exactly `{"findings", "mapping"}`, `findings` `[]`, +// `mapping` one `{"from", "to"}` per mapped identity ordered by `from` +// bytes, SPEC 12.7; H-3) and asserted to carry exactly the identity pairs +// the operation journaled, as that ordered array: 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), +// listed in `from`-byte order. Any other member set, or the mapping in +// another shape or order, fails (T6.4-1). +// - T6.4-2 stages every keepable form on the *affected* segment itself — +// computed access in both quote kinds, dot access, local string literals +// and `id` attributes in both quote kinds — and composes each expected +// post-rename file from SPEC 6.4's rules: only the renamed segment's +// characters change, quote kind and access form are kept, and the +// double-quoted computed fallback applies to a dot segment whose new name +// is not a TS identifier alone — that identifier test being 1.4's test of +// characters alone at TypeScript 5.9.3's ESNext level (14.20): arms 5 to 9 +// rename a dot-access `login` to `delete`, U+00E9, and U+2EBF0 (dot +// kept) and to `2fa` and U+1C89 then `x` (the fallback, whatever the +// runtime's Unicode tables admit). Whole files are compared byte-exactly, +// `.mdx` and `.ts` alike (markers and `text(...)` calls included), and +// two files holding only unaffected references must come through +// byte-identical ("only the affected parts change" pins all other +// bytes: untouched segments and references, prose and comments spelling +// the old name). // - T6.4-3/T6.4-6 "modifies nothing" is a whole-workspace-root byte snapshot // compare around the refused command, with the pre-refusal `build`'s // derived files present — a product that rewrites before validating, or -// 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 are asserted exact — `identities` the +// concerned identity as the sole element, in 1.5's form over the +// destination file, or the collision's located bearers in location order +// (SPEC 14, 12.7; support.ts assertRefusalIdentities); 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 whole table +// run inside one whole-root modifies-nothing compare (12.0), the base +// arm first pinning through `ids --json` that the stray `.mdx` present +// on disk is outside the discovered set (12.3). The masking arm +// asserts exit 1 with a findings report of exactly one 14.20 naming the +// unparseable file with a location (SPEC 14, H-3). The parse-local +// existence arms (SPEC 6.4, 11.2) assert the invalid-workspace refusal +// 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-5's move arm compares the product to itself across twin workspaces +// (H-6; H-4's product-to-itself exception for the journal's opaque entry): +// the twins differ only by the code file holding the `typeof` reference, +// so the section move's preview plan — `mapping` and `files`, form-exact +// 12.7 members; a file bearing only a type-level reference is no file the +// operation would rewrite (SPEC 6.6, 6.4) — its applied mapping, and the +// journal's one appended entry must agree byte-for-byte between them, +// while the code file keeps its staged bytes and the workspace stays +// valid. // - T6.4-7 "byte-identical to a fresh build of the rewritten sources" is the // H-6 two-directory protocol: a second workspace is seeded with the // post-rename configuration, sources, and journal (derived files are @@ -59,18 +128,29 @@ // whole byte trees — generated modules, Markdown output, and graph data // all included, normalizing nothing. +import { Buffer } from "node:buffer"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { GraphEdge, NodeReport } from "../../helpers/adapters/index.js"; +import { StagedMdx, stagedMdx } from "../../helpers/staged-mdx.js"; +import { StagedTs, stagedTs } from "../../helpers/staged-ts.js"; +import type { + AppliedMappingPair, + GraphEdge, + NodeReport, + PreviewFileEntry, +} from "../../helpers/adapters/index.js"; import { + decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, + decodeIdsReport, decodeNodeReport, decodeNodeRowsReport, + decodePreviewReport, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -78,23 +158,46 @@ import { assertDirectoriesEqual, assertLeavesUnchanged, } from "../../helpers/snapshot.js"; -import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import type { + ArgvValue, + ProductBinding, + RunResult, +} from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import type { + BearerLocationExpectation, + FindingSourceExpectation, +} from "./support.js"; import { + REPLACEMENT_CHARACTER, + assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertFindingLocatesExactly, + assertFindingMentionsLocation, + assertRefusalIdentities, assertSameJson, buildFindings, buildOk, + byteWindow, + expectErrorDocument, expectExit, + expectSyntaxClassUsageError, runJson, sortedIdentities, + stageConfigurationStateTwins, } from "./support.js"; // One spec group plus one code group (SPEC 7.2), for fixtures whose rewrites -// span MDX and TypeScript sources. -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +// span MDX and TypeScript sources. A staged-source record: T6.4-2, T6.4-4, +// T6.4-5, and T6.6-3 (as RENAME_USAGE_CONFIG) stage it in workspaces +// created after a product invocation (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPEC_AND_CODE_CONFIG = stagedTs( + "T6.4-2/T6.4-4/T6.4-5/T6.6-3 xspec.config.ts — one spec group and one code group, the rewrite and usage-error fixtures'", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -104,17 +207,23 @@ export default defineConfig({ app: ["src/**/*.ts"] } }) -`; +`, +); // Exactly one spec group (SPEC 7), for the refusal and usage-error fixtures. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// A staged-source record: T6.4-4 and T6.6-3 (as RENAME_REFUSAL_CONFIG) stage +// it in workspaces created after a product invocation (S-9's timing clause). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.4-4/T6.6-3 xspec.config.ts — exactly one spec group, the refusal and usage-error fixtures'", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); // Specs, code, and Markdown emission (SPEC 7.3), so T6.4-7's compare covers // generated modules, Markdown output, and graph data alike. @@ -134,10 +243,15 @@ export default defineConfig({ const JOURNAL_PATH = ".xspec/journal"; const LF = 0x0a; -/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +/** + * Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). + * An `.mdx` entry of a workspace created after the body's first product + * invocation is a staged-source record (the record-accepting initial + * `files`; helpers/staged-mdx.ts), staged under the record's declaration. + */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -293,41 +407,120 @@ 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, the exact `identities` where 14 pins + * them, 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; + /** + * The finding's complete location set — exactly one location per listed + * bearer, none beside, index-wise in 12.7's within-finding order (SPEC 14: + * `refused-id-collision` locates every colliding bearer — the + * every-participant strictness of T6.4-3's two-bearer arm, T14-7). + * Declared beside `locatedAt`, whose SOME-quantified check consumers + * asserting it alone still apply. + */ + readonly locatedAtEach?: readonly BearerLocationExpectation[]; + /** + * The finding's exact `identities` (SPEC 12.7, 14): stated for every + * reason SPEC 14 pins — the concerned identity as the sole element, in + * 1.5's form over the operation's destination file (whether or not the ID + * is valid), or `refused-id-collision`'s located bearers' identities in + * location order — and omitted where 12.7 leaves the composition unpinned + * (support.ts assertRefusalIdentities guards both ways). + */ + readonly identities?: readonly string[]; +} + /** * 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.locatedAtEach !== undefined) { + assertFindingLocatesExactly( + finding, + expected.locatedAtEach, + `${context}: the refusal's complete located-bearer set`, + ); + } + assertRefusalIdentities( + finding, + expected.finding, + expected.identities, + `${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 +534,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 +671,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 +713,37 @@ 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 in the form-exact + // performed-operation document of 12.7 (H-3: exactly `{"findings", + // "mapping"}`, `findings` `[]`, pairs ordered by `from` bytes). The + // fixture pins the journaled mapping completely and in order: 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 @@ -682,186 +900,286 @@ const T6_4_1 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T6.4-2 — minimal edits (byte-exact) +// T6.4-2 — minimal edits (byte-exact, every keepable form kept) // --------------------------------------------------------------------------- -// Arm A: the new segment `neo` is a valid TypeScript identifier, so every -// staged form is keepable and every rewrite is the minimal in-place edit — -// dot stays dot, double-quoted computed stays double-quoted computed, -// double-quoted string literals stay double-quoted. Untouched parts carry the -// contrasting spellings (single-quoted computed segments before and after the -// affected segment, a single-quoted local string, a whole untouched -// single-quoted-computed reference) and must be preserved byte-wise. -const M2A_CORE_BEFORE = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.mid">', - "Mid text.", - "", - '<S id="top.mid.kid-x">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={["top.mid", \'top.res\']}>', - "Embeds: {text(\"top.mid.kid-x\")} and {text('top.res')}", - "</S>", - "", - '<S id="top.res">', - "Res text.", - "</S>", - "</S>", - "", -].join("\n"); +// Each fixture below is a template over the renamed segment's spelling, so +// every expected post-rename file is composed from SPEC 6.4's rules — only +// the renamed segment's characters change; the quote kind of a computed +// access, a string literal, or an `id` attribute and the access form of a +// chain segment are kept wherever the new name admits them — while every +// other byte (untouched segments and references, prose and comments that +// spell the old name, whole files holding no affected reference) is the +// staged byte verbatim. +// +// Fixture L (arms 1 and 2): the renamed segment `login-v2` is not a TS +// identifier, so its chain references are computed — double-quoted +// (`["login-v2"]`) and single-quoted (`['login-v2']`) — and its local string +// references and `id` attributes come in both quote kinds; the renamed +// section's own `id` and one rewritten descendant's `id` are single-quoted +// (SPEC 2.7). Arm 1 renames it to the identifier-valid `login2`: the +// double-quoted computed segment stays computed and double-quoted (never +// `.login2`), the single-quoted one keeps its single quotes, and so do the +// single-quoted local strings and `id` values. Arm 2 renames it to +// `login-v3`: the same forms, all kept. + +/** Fixture L's `specs/Core.mdx`; `seg` spells the renamed segment. */ +function coreL(seg: string): string { + return [ + `<S id='${seg}'>`, + "Login text; the prose spelling login-v2 is no reference and stays.", + "", + `<S id='${seg}.kid'>`, + "Kid text.", + "</S>", + "", + `<S id="${seg}.aux" d={['${seg}.kid']}>`, + `Aux: {text('${seg}.kid')}`, + "</S>", + "</S>", + "", + `<S id="other" d={["${seg}", '${seg}.kid', 'other.leaf']}>`, + `Other: {text('${seg}')} and {text("${seg}.aux")} and {text('other.leaf')}`, + "", + '<S id="other.leaf">', + "Leaf text.", + "</S>", + "</S>", + "", + ].join("\n"); +} -const M2A_CORE_AFTER = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.neo">', - "Mid text.", - "", - '<S id="top.neo.kid-x">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={["top.neo", \'top.res\']}>', - "Embeds: {text(\"top.neo.kid-x\")} and {text('top.res')}", - "</S>", - "", - '<S id="top.res">', - "Res text.", - "</S>", - "</S>", - "", -].join("\n"); +/** Fixture L's `specs/Refs.mdx`: external chains through both quote kinds. */ +function refsL(seg: string): string { + return [ + 'import Core from "./Core.xspec"', + "", + `<S id="refs" d={[Core["${seg}"], Core['${seg}'].kid, Core["${seg}"]["aux"], Core['other'].leaf]}>`, + `Embeds: {text(Core['${seg}'])} and {text(Core["${seg}"].kid)} and {text(Core['other'])}`, + "</S>", + "", + ].join("\n"); +} -const M2A_REFS_BEFORE = [ - 'import Core from "./Core.xspec"', - "", - '<S id="refs" d={[Core.top.mid, Core.top["mid"], Core[\'top\'].mid]}>', - 'Embeds: {text(Core.top.mid["kid-x"])}', - "Also: {text(Core.top.mid['kid-x'])}", - "Watch: {text(Core.top['res'])}", - "</S>", - "", -].join("\n"); +/** Fixture L's `src/app.ts`: markers and `text(...)` calls, both quote kinds. */ +function appL(seg: string): string { + return [ + 'import CORE, { text } from "../specs/Core.xspec";', + "", + '// A comment is no reference: CORE["login-v2"] stays as written here.', + "export function login(): string {", + ` CORE["${seg}"];`, + ` CORE['${seg}'].kid;`, + ` return text(CORE['${seg}']) + text(CORE["${seg}"]["aux"]);`, + "}", + "", + "export function other(): string {", + " CORE.other.leaf;", + " return text(CORE['other']);", + "}", + "", + ].join("\n"); +} -const M2A_REFS_AFTER = [ +// Fixture L's untouched sources: unaffected identities only, referenced in +// single-quoted and computed spellings beside a single-quoted `id` — the +// rename must leave both files byte-identical. +const OTHER_MDX_L = [ 'import Core from "./Core.xspec"', "", - '<S id="refs" d={[Core.top.neo, Core.top["neo"], Core[\'top\'].neo]}>', - 'Embeds: {text(Core.top.neo["kid-x"])}', - "Also: {text(Core.top.neo['kid-x'])}", - "Watch: {text(Core.top['res'])}", + "<S id=\"unrelated\" d={[Core.other, Core['other'].leaf]}>", + "Unrelated: {text(Core[\"other\"].leaf)} and {text('unrelated.sub')}", + "", + "<S id='unrelated.sub'>", + "Sub text.", + "</S>", "</S>", "", ].join("\n"); -const M2A_APP_BEFORE = [ +const OTHER_TS_L = [ 'import CORE, { text } from "../specs/Core.xspec";', "", - "CORE.top.mid;", - 'CORE.top.mid["kid-x"];', - "CORE['top'].mid;", - 'text(CORE.top["mid"]);', + "CORE.other;", + "text(CORE['other'].leaf);", "", ].join("\n"); -const M2A_APP_AFTER = [ - 'import CORE, { text } from "../specs/Core.xspec";', - "", - "CORE.top.neo;", - 'CORE.top.neo["kid-x"];', - "CORE['top'].neo;", - 'text(CORE.top["neo"]);', - "", -].join("\n"); +// Fixture M (arms 3 and 4): the renamed segment `mid` is a TS identifier, +// referenced in dot access, in computed access of both quote kinds, and in +// local strings of both quote kinds. Arm 3 renames it to `neo`: dot stays dot +// and every computed segment keeps its quotes. Arm 4 renames it to `neo-2`: +// dot access cannot hold it and becomes double-quoted computed access (the +// 6.4 fallback), while the computed segments keep their quote kinds and the +// string literals hold any segment — untouched dot parts after the converted +// segment (`.kid-x`, `.mid` after `['top']`) stay as they are. + +/** Fixture M's `specs/Core.mdx`; `seg` spells the renamed segment. */ +function coreM(seg: string): string { + return [ + '<S id="top">', + "Top text.", + "", + `<S id="top.${seg}">`, + "Mid text.", + "", + `<S id="top.${seg}.kid-x">`, + "Kid text.", + "</S>", + "</S>", + "", + `<S id="top.aid" d={["top.${seg}", 'top.${seg}.kid-x', 'top.res']}>`, + `Embeds: {text("top.${seg}.kid-x")} and {text('top.${seg}')} and {text('top.res')}`, + "</S>", + "", + '<S id="top.res">', + "Res text.", + "</S>", + "</S>", + "", + ].join("\n"); +} -// Arm B: the new segment `neo-2` is not a TypeScript identifier. A dot-access -// affected part cannot keep its form and is written as double-quoted computed -// access; a double-quoted computed affected part keeps its form; untouched -// dot parts after the converted segment, and string-literal forms (which hold -// any segment), are preserved. -const M2B_CORE_BEFORE = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.mid">', - "Mid text.", - "", - '<S id="top.mid.kid">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={"top.mid"}>', - 'Embeds: {text("top.mid.kid")}', - "</S>", - "</S>", - "", -].join("\n"); +/** + * Fixture M's `specs/Refs.mdx`; `dot` spells an affected dot-access segment + * (`.mid`, `.neo`, or the `["neo-2"]` fallback), `seg` an affected computed one. + */ +function refsM(dot: string, seg: string): string { + return [ + 'import Core from "./Core.xspec"', + "", + `<S id="refs" d={[Core.top${dot}, Core.top["${seg}"], Core.top['${seg}'], Core['top']${dot}]}>`, + `Embeds: {text(Core.top${dot}["kid-x"])} and {text(Core.top['${seg}']['kid-x'])} and {text(Core.top['res'])}`, + "</S>", + "", + ].join("\n"); +} -const M2B_CORE_AFTER = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.neo-2">', - "Mid text.", - "", - '<S id="top.neo-2.kid">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={"top.neo-2"}>', - 'Embeds: {text("top.neo-2.kid")}', - "</S>", - "</S>", - "", -].join("\n"); +/** Fixture M's `src/app.ts`: markers and `text(...)` calls, every access form. */ +function appM(dot: string, seg: string): string { + return [ + 'import CORE, { text } from "../specs/Core.xspec";', + "", + `CORE.top${dot};`, + `CORE.top${dot}["kid-x"];`, + `CORE.top['${seg}'];`, + `CORE['top']${dot};`, + `text(CORE.top["${seg}"]);`, + `text(CORE.top['${seg}']["kid-x"]);`, + "", + ].join("\n"); +} -const M2B_REFS_BEFORE = [ +const OTHER_MDX_M = [ 'import Core from "./Core.xspec"', "", - '<S id="refs" d={[Core.top.mid, Core.top["mid"]]}>', - "Embeds: {text(Core.top.mid.kid)}", - "</S>", - "", -].join("\n"); - -const M2B_REFS_AFTER = [ - 'import Core from "./Core.xspec"', + "<S id=\"unrelated\" d={[Core.top.res, Core['top']['res']]}>", + "Unrelated: {text(Core.top[\"res\"])} and {text('unrelated.sub')}", "", - '<S id="refs" d={[Core.top["neo-2"], Core.top["neo-2"]]}>', - 'Embeds: {text(Core.top["neo-2"].kid)}', + "<S id='unrelated.sub'>", + "Sub text.", + "</S>", "</S>", "", ].join("\n"); -const M2B_APP_BEFORE = [ +const OTHER_TS_M = [ 'import CORE, { text } from "../specs/Core.xspec";', "", - "CORE.top.mid;", - "text(CORE.top.mid.kid);", + "CORE.top.res;", + "text(CORE['top'].res);", "", ].join("\n"); -const M2B_APP_AFTER = [ - 'import CORE, { text } from "../specs/Core.xspec";', - "", - 'CORE.top["neo-2"];', - 'text(CORE.top["neo-2"].kid);', - "", -].join("\n"); +// Fixture M over `login` (arms 5 to 9): fixture M's templates with the +// renamed segment staged as `login` — TEST-SPEC T6.4-2's `BASE.login`, the +// `BASE` here being `Core.top` in MDX and `CORE.top` in TS — renamed to +// names whose dot form 1.4's test of characters alone decides, at the +// TypeScript release and language level 14.20 fixes (5.9.3, ESNext). The +// reserved word `delete` (property access admits any identifier name, 2.4), +// U+00E9 (a non-ASCII letter), and U+2EBF0 (a Unicode 15.1 ideograph that +// 5.9.3 admits at ESNext but not at ES5) stay dot access. `2fa` (its first +// character can only continue an identifier) and U+1C89 followed by `x` (a +// Unicode 16 letter 5.9.3 admits nowhere in an identifier, though a runtime +// whose tables postdate 15.1 admits it) take the double-quoted computed +// fallback. Every computed segment keeps its quote kind and every local +// string its quotes, as in arms 3 and 4. The characters are built from +// their code points, never spelled as escapes. +const E_ACUTE = String.fromCodePoint(0x00e9); +const IDEOGRAPH = String.fromCodePoint(0x2ebf0); +const TJE_X = `${String.fromCodePoint(0x1c89)}x`; + +/** + * The arms' stagings. Arm 1's workspace is the body's first; arms 2 to 9 + * follow arm 1's invocations, so every `.mdx` entry is a ledger record + * (S-9's before-any-product clause; helpers/staged-mdx.ts) — the same + * expression the body composed, moved to module level, arm 1's entries + * converted uniformly — and every `.ts` entry is a TypeScript record + * likewise (helpers/staged-ts.ts). `runMinimalEditArm` stages a map as it + * is and reads a record's bytes back for the untouched-file compare. + */ +const T6_4_2_L_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/Core.mdx": stagedMdx( + "T6.4-2 arms 1 and 2 specs/Core.mdx", + coreL("login-v2"), + ), + "specs/Refs.mdx": stagedMdx( + "T6.4-2 arms 1 and 2 specs/Refs.mdx", + refsL("login-v2"), + ), + "specs/Other.mdx": stagedMdx( + "T6.4-2 arms 1 and 2 specs/Other.mdx", + OTHER_MDX_L, + ), + "src/app.ts": stagedTs("T6.4-2 arms 1 and 2 src/app.ts", appL("login-v2")), + "src/other.ts": stagedTs("T6.4-2 arms 1 and 2 src/other.ts", OTHER_TS_L), +}; +const T6_4_2_M_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/Core.mdx": stagedMdx( + "T6.4-2 arms 3 and 4 specs/Core.mdx", + coreM("mid"), + ), + "specs/Refs.mdx": stagedMdx( + "T6.4-2 arms 3 and 4 specs/Refs.mdx", + refsM(".mid", "mid"), + ), + "specs/Other.mdx": stagedMdx( + "T6.4-2 arms 3 and 4 specs/Other.mdx", + OTHER_MDX_M, + ), + "src/app.ts": stagedTs("T6.4-2 arms 3 and 4 src/app.ts", appM(".mid", "mid")), + "src/other.ts": stagedTs("T6.4-2 arms 3 and 4 src/other.ts", OTHER_TS_M), +}; +const T6_4_2_M_LOGIN_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/Core.mdx": stagedMdx( + "T6.4-2 arms 5 to 9 specs/Core.mdx", + coreM("login"), + ), + "specs/Refs.mdx": stagedMdx( + "T6.4-2 arms 5 to 9 specs/Refs.mdx", + refsM(".login", "login"), + ), + "specs/Other.mdx": stagedMdx( + "T6.4-2 arms 5 to 9 specs/Other.mdx", + OTHER_MDX_M, + ), + "src/app.ts": stagedTs( + "T6.4-2 arms 5 to 9 src/app.ts", + appM(".login", "login"), + ), + "src/other.ts": stagedTs("T6.4-2 arms 5 to 9 src/other.ts", OTHER_TS_M), +}; -/** One T6.4-2 arm: stage, build, rename, byte-compare every rewritten file. */ +/** + * One T6.4-2 arm: stage, build, rename, then byte-compare every staged file + * against its composed expectation — the rewritten files against their + * post-rename composition, the untouched ones against their staged bytes. + */ async function runMinimalEditArm( product: ProductBinding, + oldId: string, newId: string, - sources: Readonly<Record<string, string>>, + sources: Readonly<Record<string, InitialFileContents>>, expected: Readonly<Record<string, string>>, context: string, ): Promise<void> { @@ -870,19 +1188,33 @@ async function runMinimalEditArm( await expectExit( product, workspace, - ["rename", "specs/Core.mdx", "top.mid", newId], + ["rename", "specs/Core.mdx", oldId, newId], 0, - `${context}: \`rename specs/Core.mdx top.mid ${newId}\``, + `${context}: \`rename specs/Core.mdx ${oldId} ${newId}\``, ); for (const [rel, bytes] of Object.entries(expected)) { + const staged = sources[rel]; + const touched = + bytes !== + (staged instanceof StagedMdx || staged instanceof StagedTs + ? staged.source + : staged); await assertFileBytes( workspace.path(rel), bytes, - `${context}: ${rel} after the rename — rewrites are minimal in-place ` + - `edits: quote style and access form of untouched reference parts ` + - `are preserved byte-wise and only the affected parts change; where ` + - `a form cannot be kept, a non-identifier segment is written as ` + - `double-quoted computed access (SPEC 6.4, 2.4; H-4)`, + `${context}: ${rel} after the rename — ` + + (touched + ? `the rewritten file must differ from its original in the ` + + `rewritten segments alone: rewrites are minimal in-place edits ` + + `keeping each reference's quote style and access form, and each ` + + `\`id\` attribute's quotes, wherever the new name admits them ` + + `(dot stays dot, computed stays computed in its own quote kind, ` + + `a string literal keeps its quotes); only a dot-access segment ` + + `whose new name is not a TS identifier falls back to ` + + `double-quoted computed access (SPEC 6.4, 2.4, 2.7; H-4)` + : `a file holding no reference to an affected identity must come ` + + `through byte-identical (SPEC 6.4: only the affected parts ` + + `change)`), ); } }); @@ -891,44 +1223,117 @@ async function runMinimalEditArm( const T6_4_2 = defineProductTest({ id: "T6.4-2", title: - "minimal edits: quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise and only the affected parts change; where the form cannot be kept, a new segment that is not a TS identifier is written as double-quoted computed access (SPEC 6.4, 2.4)", + "minimal edits: quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise and only the affected parts change; the rewritten segment keeps every keepable form — a computed segment stays computed in its own quote kind whether or not the new name is a TS identifier, dot stays dot for an identifier-valid name (a reserved word, `delete`, a non-ASCII letter, U+00E9, and U+2EBF0 included — 1.4 judging characters alone at TypeScript 5.9.3's ESNext level), single-quoted local strings and single-quoted `id` attributes keep their quotes — and only a dot segment whose new name is not a TS identifier becomes double-quoted computed access (`2fa`, and U+1C89 then `x`, which 5.9.3 admits nowhere in an identifier whatever a runtime's Unicode tables say); every rewritten `.mdx` and `.ts` file is byte-equal to its composed expectation and untouched files stay byte-identical (SPEC 6.4, 1.4, 2.4, 2.7)", run: async (product) => { - // Arm A: keepable forms — every rewrite is the in-place minimal edit. + const expectedL = (seg: string) => ({ + "specs/Core.mdx": coreL(seg), + "specs/Refs.mdx": refsL(seg), + "specs/Other.mdx": OTHER_MDX_L, + "src/app.ts": appL(seg), + "src/other.ts": OTHER_TS_L, + }); + + // Arm 1: computed segment → identifier-valid name; forms kept. + await runMinimalEditArm( + product, + "login-v2", + "login2", + T6_4_2_L_FILES, + expectedL("login2"), + "T6.4-2 arm 1 (login-v2 → login2: computed and single-quoted forms kept)", + ); + + // Arm 2: computed segment → non-identifier name; forms kept. + await runMinimalEditArm( + product, + "login-v2", + "login-v3", + T6_4_2_L_FILES, + expectedL("login-v3"), + "T6.4-2 arm 2 (login-v2 → login-v3: computed and single-quoted forms kept)", + ); + + const expectedM = (dot: string, seg: string) => ({ + "specs/Core.mdx": coreM(seg), + "specs/Refs.mdx": refsM(dot, seg), + "specs/Other.mdx": OTHER_MDX_M, + "src/app.ts": appM(dot, seg), + "src/other.ts": OTHER_TS_M, + }); + + // Arm 3: dot segment → identifier-valid name; dot stays dot. await runMinimalEditArm( product, + "top.mid", "top.neo", - { - "specs/Core.mdx": M2A_CORE_BEFORE, - "specs/Refs.mdx": M2A_REFS_BEFORE, - "src/app.ts": M2A_APP_BEFORE, - }, - { - "specs/Core.mdx": M2A_CORE_AFTER, - "specs/Refs.mdx": M2A_REFS_AFTER, - "src/app.ts": M2A_APP_AFTER, - }, - "T6.4-2 identifier-segment arm (top.mid → top.neo)", + T6_4_2_M_FILES, + expectedM(".neo", "neo"), + "T6.4-2 arm 3 (top.mid → top.neo: dot stays dot, computed keeps quotes)", ); - // Arm B: the dot form cannot hold `neo-2` — double-quoted computed access. + // Arm 4: dot segment → non-identifier name; the double-quoted computed + // fallback for dot access alone, every computed segment keeping its quotes. await runMinimalEditArm( product, + "top.mid", "top.neo-2", + T6_4_2_M_FILES, + expectedM('["neo-2"]', "neo-2"), + "T6.4-2 arm 4 (top.mid → top.neo-2: dot falls back to double-quoted computed access, computed keeps quotes)", + ); + + // Arms 5 to 9: fixture M over `login`, each new name's dot form decided + // by 1.4's test of characters alone at TypeScript 5.9.3's ESNext level + // (14.20) — never by a runtime's Unicode tables, nor at ES5. + const loginArms: readonly { + readonly seg: string; + readonly label: string; + readonly dot: string; + readonly why: string; + }[] = [ { - "specs/Core.mdx": M2B_CORE_BEFORE, - "specs/Refs.mdx": M2B_REFS_BEFORE, - "src/app.ts": M2B_APP_BEFORE, + seg: "delete", + label: "delete", + dot: ".delete", + why: "a reserved word is an identifier name property access admits, so dot stays dot", }, { - "specs/Core.mdx": M2B_CORE_AFTER, - "specs/Refs.mdx": M2B_REFS_AFTER, - "src/app.ts": M2B_APP_AFTER, + seg: E_ACUTE, + label: "U+00E9", + dot: `.${E_ACUTE}`, + why: "a non-ASCII letter begins an identifier, so dot stays dot", }, - "T6.4-2 non-identifier-segment arm (top.mid → top.neo-2)", - ); + { + seg: IDEOGRAPH, + label: "U+2EBF0", + dot: `.${IDEOGRAPH}`, + why: "TypeScript 5.9.3 admits U+2EBF0 at ESNext (not at ES5), so dot stays dot", + }, + { + seg: "2fa", + label: "2fa", + dot: '["2fa"]', + why: "a digit can only continue an identifier, so dot falls back to double-quoted computed access", + }, + { + seg: TJE_X, + label: "U+1C89 then x", + dot: `["${TJE_X}"]`, + why: "TypeScript 5.9.3 admits U+1C89 nowhere in an identifier, whatever the runtime's Unicode tables, so dot falls back to double-quoted computed access", + }, + ]; + for (const [index, arm] of loginArms.entries()) { + await runMinimalEditArm( + product, + "top.login", + `top.${arm.seg}`, + T6_4_2_M_LOGIN_FILES, + expectedM(arm.dot, arm.seg), + `T6.4-2 arm ${index + 5} (top.login → top.${arm.label}: ${arm.why}; computed keeps quotes)`, + ); + } }, }); - // --------------------------------------------------------------------------- // T6.4-3 — validation refusals (exit 1, nothing modified) // --------------------------------------------------------------------------- @@ -938,7 +1343,8 @@ const T6_4_2 = defineProductTest({ // only the differs-from-old check, `a.sib` only the collision check, `x.mid` // and `b.c` only the structural parent rules. The remaining 6.4 clause — all // rewritten references resolve — admits no discriminating fixture (TEST-SPEC -// T6.4-3) and is exercised as the always-passing side of T6.4-1. +// T6.4-3) and is exercised as the always-passing side of T6.4-1. The +// two-bearer collision arm stages its own file beside this one (below). const V3_FILE = "specs/A.mdx"; const V3_SOURCE = [ '<S id="a">', @@ -959,14 +1365,297 @@ 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, +); + +// T6.4-3's two-bearer collision arm (TEST-SPEC T6.4-3, T14-7): a second +// file holding `a` with child `a.c` beside `b` with child `b.c`, so that +// `rename a b` makes the new ID `b` AND the prefix-replaced `b.c` each +// collide with an ID remaining in the file (SPEC 6.4: the new ID, and each +// ID the prefix replacement produces; IDs are unique within a source file, +// 1.3, so this file's `a` beside V3_SOURCE's `a` is valid, and rename's +// collision check reads the origin file alone). The refusal is ONE +// `refused-id-collision` finding locating BOTH bearers (SPEC 14: one +// finding per reason, locating every colliding bearer) — a product +// locating the first alone fails. `b.c`'s construct lies inside `b`'s, so +// the bearer expectations carry the start bound that attributes the first +// location to `b` alone (support.ts BearerLocationExpectation). Exported +// whole — file, source bytes, argv, and the two expected bearer locations — +// so T14-7 asserts the same collision over the shared staging. +export const TWO_BEARER_COLLISION_FILE = "specs/B.mdx"; +const TWO_BEARER_CHILD_CONSTRUCT = '<S id="b.c">\nBeta child text.\n</S>'; +const TWO_BEARER_PARENT_CONSTRUCT = [ + '<S id="b">', + "Beta text.", + "", + TWO_BEARER_CHILD_CONSTRUCT, + "</S>", +].join("\n"); +export const TWO_BEARER_COLLISION_SOURCE = [ + '<S id="a">', + "Alpha text.", + "", + '<S id="a.c">', + "Alpha child text.", + "</S>", + "</S>", + "", + TWO_BEARER_PARENT_CONSTRUCT, + "", +].join("\n"); +export const TWO_BEARER_COLLISION_ARGV: readonly string[] = [ + "rename", + TWO_BEARER_COLLISION_FILE, + "a", + "b", +]; +const TWO_BEARER_CHILD_WINDOW = byteWindow( + TWO_BEARER_COLLISION_SOURCE.slice( + 0, + TWO_BEARER_COLLISION_SOURCE.indexOf(TWO_BEARER_CHILD_CONSTRUCT), + ), + TWO_BEARER_CHILD_CONSTRUCT, +); +/** + * The two colliding bearers in 12.7's within-finding order: `b`, whose + * construct encloses `b.c`'s — its location starts before the child's + * construct at any precision — then `b.c`. + */ +export const TWO_BEARER_COLLISION_BEARERS: readonly BearerLocationExpectation[] = + [ + { + file: TWO_BEARER_COLLISION_FILE, + window: byteWindow( + TWO_BEARER_COLLISION_SOURCE.slice( + 0, + TWO_BEARER_COLLISION_SOURCE.indexOf(TWO_BEARER_PARENT_CONSTRUCT), + ), + TWO_BEARER_PARENT_CONSTRUCT, + ), + startBefore: TWO_BEARER_CHILD_WINDOW.start, + }, + { + file: TWO_BEARER_COLLISION_FILE, + window: TWO_BEARER_CHILD_WINDOW, + }, + ]; +/** The arm as one T6.4-3 refusal case (a member of RENAME_REFUSAL_CASES). */ +export const TWO_BEARER_COLLISION_CASE: RenameRefusalCase = { + argv: TWO_BEARER_COLLISION_ARGV, + expected: { + finding: "refused-id-collision", + // The SOME-quantified concern every consumer asserts: the SECOND bearer, + // so a product locating the first alone fails there too. + locatedAt: { + file: TWO_BEARER_COLLISION_FILE, + window: TWO_BEARER_CHILD_WINDOW, + }, + locatedAtEach: TWO_BEARER_COLLISION_BEARERS, + // The located bearers' identities in location order — `b` then `b.c`, + // matching the bearer set (SPEC 14, 12.7). + identities: [ + `${TWO_BEARER_COLLISION_FILE}#b`, + `${TWO_BEARER_COLLISION_FILE}#b.c`, + ], + }, + reason: + "new ID `b` and the prefix-replaced `b.c` each colliding with an ID " + + "remaining in the file — one finding locating both bearers", +}; + +/** + * One T6.4-3 refusal case: the full rename argv (without `--json`), the one + * refusal finding the staging isolates (SPEC 14), and its diagnosis context. + */ +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; +// The `.mdx` entries are ledger records (S-9's before-any-product clause; +// helpers/staged-mdx.ts): T6.4-3 stages the set again for its +// configuration-state twins after its premise `build`, so each record is +// named with every test staging the set — T6.4-3, T6.6-3, T14-7 — and made +// from the string constant the location windows still read. +export const RENAME_REFUSAL_FILES: Readonly< + Record<string, InitialFileContents> +> = { + [V3_FILE]: stagedMdx("T6.4-3/T6.6-3/T14-7 specs/A.mdx", V3_SOURCE), + [TWO_BEARER_COLLISION_FILE]: stagedMdx( + "T6.4-3/T6.6-3/T14-7 specs/B.mdx", + TWO_BEARER_COLLISION_SOURCE, + ), +}; + +// T6.4-3's barred-character arms (TEST-SPEC T6.4-3; SPEC 1.4's +// quote-and-escape bullet): one per character the bullet bars — the double +// quote, the single quote, the escape character (backslash), the +// character-reference character `&`, U+2028, and U+2029 — each spelled +// between two letters in a one-segment `<new-id>` renaming V3_SOURCE's +// top-level `a` (as T1.4-1 spells them: the literal, validly encoded +// character). Each `<new-id>` is a well-formed argument value (12.0: valid +// UTF-8, no U+FFFD) that no spelling rule decides — unlike T12.0-10's +// `--to` and `--tag` arms — so each arm exits 1 with `refused-invalid-id` +// alone, never exit 2, its `identities` exactly the new identity with the +// character verbatim: SPEC 14 reports no prefix-produced identity beside +// it, and `refused-structural-parent` is evaluated only over intrinsically +// valid IDs. A product whose new-ID check omits a character its source +// validation bars (T1.4-1) performs the rename — writing the character into +// `id` attributes, a workspace failing validation behind a reported success +// — and fails the exit, the finding, and the modifies-nothing compare +// (workspace and journal alike: the compare spans the whole root). Each +// character is built from its code point, so no tool layer can normalize +// the spelling away. Exported for T6.5-4's section-form twins +// (section-6.5.ts): one list of the characters the bullet bars. +export const BARRED_NEW_ID_CHARACTERS: readonly (readonly [number, string])[] = + [ + [0x22, "the double quote"], + [0x27, "the single quote"], + [0x5c, "the escape character (backslash)"], + [0x26, "the character-reference character `&`"], + [0x2028, "LINE SEPARATOR"], + [0x2029, "PARAGRAPH SEPARATOR"], + ]; +const BARRED_CHARACTER_RENAME_CASES: readonly RenameRefusalCase[] = + BARRED_NEW_ID_CHARACTERS.map(([codePoint, name]): RenameRefusalCase => { + const newId = `a${String.fromCodePoint(codePoint)}b`; + return { + argv: ["rename", V3_FILE, "a", newId], + expected: { + finding: "refused-invalid-id", + identities: [`${V3_FILE}#${newId}`], + }, + reason: + `new ID invalid per 1.4 — its one segment carries ${name} ` + + `(U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}), ` + + `which 1.4's quote-and-escape bullet bars; a well-formed argument ` + + `value, so exit 1, never exit 2`, + }; + }); + +// Each arm's expected refusal finding (SPEC 14): the exact stable code, with +// its exact `identities` — the concerned identity as the sole element, in +// 1.5's form over the file (`refused-invalid-id` and +// `refused-structural-parent` concern the offending identity; +// `refused-identity-unchanged` the unchanged one), or the located colliding +// bearers' identities in location order (`refused-id-collision` locates +// every colliding bearer, the collision arms also locating each remaining +// bearer) — SPEC 14, 12.7. 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", + identities: [`${V3_FILE}#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", + identities: [`${V3_FILE}#a.mi d`], + }, + reason: "new ID invalid per 1.4 — its segment contains whitespace", + }, + ...BARRED_CHARACTER_RENAME_CASES, + { + argv: ["rename", V3_FILE, "a.mid", "a.mid"], + expected: { + finding: "refused-identity-unchanged", + identities: [`${V3_FILE}#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 }, + identities: [`${V3_FILE}#a.sib`], + }, + reason: "new ID colliding with an existing ID in the file", + }, + TWO_BEARER_COLLISION_CASE, + { + argv: ["rename", V3_FILE, "a.mid", "x.mid"], + expected: { + finding: "refused-structural-parent", + identities: [`${V3_FILE}#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", + identities: [`${V3_FILE}#b.c`], + }, + reason: + "new ID violating the structural parent rules — a top-level section's " + + "ID has exactly one segment (1.3)", + }, +]; + +// A `<new-id>` containing U+FFFD, and one that is not valid UTF-8 (raw bytes +// in the OS argument vector, Linux leg): each a malformed argument value, a +// usage error of the syntax class judged before every per-operand check +// (SPEC 12.0), so neither ever reaches the 1.4 check — exit 2, never +// `refused-invalid-id` (SPEC 6.4; T12.0-5). Both rename the existing +// `a.mid`, so the malformed value is each invocation's only defect. The +// character is built from its code point so no tool layer can normalize the +// spelling away; the bytes are 0xFF, which no valid UTF-8 contains. +const V3_REPLACEMENT_NEW_ID = `a.m${REPLACEMENT_CHARACTER}d`; +const V3_NON_UTF8_NEW_ID: Uint8Array = Buffer.concat([ + Buffer.from("a.m", "utf8"), + Buffer.from([0xff]), + Buffer.from("d", "utf8"), +]); +const V3_MALFORMED_NEW_ID_ARGVS: readonly (readonly [ + readonly ArgvValue[], + string, +])[] = [ + [ + ["rename", V3_FILE, "a.mid", V3_REPLACEMENT_NEW_ID, "--json"], + "new ID containing U+FFFD", + ], + [ + ["rename", V3_FILE, "a.mid", V3_NON_UTF8_NEW_ID, "--json"], + "new ID not valid UTF-8 (raw argv bytes, Linux leg)", + ], +]; + 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: among its arms one per character 1.4's quote-and-escape bullet bars (the double quote, the single quote, the escape character, `&`, U+2028, and U+2029), each spelled between two letters in a one-segment `<new-id>` renaming the top-level `a`: a well-formed argument value that no spelling rule decides, so refused-invalid-id alone with the character verbatim in its identities, exit 1, never exit 2, workspace and journal byte-unchanged), equal to the old ID, colliding with an existing ID, or violating structural parent rules each refuses the rename and modifies nothing (workspace byte-compare) — each refusal reported as the form-exact 12.7 findings-only report holding exactly one finding with its exact stable refusal code (refused-invalid-id, refused-identity-unchanged, refused-id-collision, refused-structural-parent) and the concerned identity or located colliding bearer — the collision staged with one bearer and with two (`rename a b` where `a.c` sits beside `b` and `b.c`: the new ID and the prefix-replaced `b.c` each collide, one refused-id-collision finding locating exactly both bearers `b` and `b.c`, a product locating the first alone failing); a `<new-id>` containing U+FFFD, or not valid UTF-8 (raw argv bytes, Linux leg), never reaches the 1.4 check — a malformed argument value, a syntax-class usage error: exit 2 with the plain usage error's document (`code` null), byte-identical with the configuration file invalid or missing, modifying nothing — never `refused-invalid-id` (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 +1664,53 @@ 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)", - ); + + // Malformed `<new-id>` values never reach the 1.4 check (SPEC 6.4, + // 12.0): each is a syntax-class usage error — exit 2 with the plain + // usage error's document (`code` null), never `refused-invalid-id` + // (exit 1 with a finding) — reported without loading configuration + // (byte-identical with the configuration file invalid or missing, + // T12.0-10's discipline) and modifying nothing. The byte arm is + // Linux-leg staging: argv is a byte channel there (the driver's + // POSIX trampoline); other platforms cannot carry the argument. + const twins = await stageConfigurationStateTwins(RENAME_REFUSAL_FILES); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, reason] of V3_MALFORMED_NEW_ID_ARGVS) { + if ( + process.platform !== "linux" && + argv.some((arg) => typeof arg !== "string") + ) { + continue; + } + await expectSyntaxClassUsageError( + product, + workspace, + twins, + argv, + `T6.4-3 (${reason} — a malformed argument value, never ` + + `\`refused-invalid-id\`)`, + ); + } + }, + "T6.4-3: a malformed <new-id> modifies nothing (SPEC 6.4, 12.0)", + ); + } finally { + await twins.dispose(); + } }, ); }, @@ -1019,67 +1720,255 @@ const T6_4_3 = defineProductTest({ // T6.4-4 — usage errors (exit 2) and unparseable-origin masking // --------------------------------------------------------------------------- +// T6.4-4's later arms — ordering, masking, duplicate spellings, undefined +// ancestor, spells no identity — each stage a fresh workspace after the base +// arm's invocations, so every `.mdx` source below is a ledger record wrapped +// in place (S-9's before-any-product clause; helpers/staged-mdx.ts), the base +// arm's — the body's first workspace — converted uniformly; a record an +// exported set stages under T6.6-3 too is named with both. The masking arm's +// unparseable origin carries its declaration on the record. T6.5-5's usage +// stagings (section-6.5.ts) mirror these byte for byte, and identical bytes +// are ONE record (S-9's naming rule), so the seven records below are exported +// to that module and named with T6.5-5 too, never registered twice. const U4_FILE = "specs/A.mdx"; -const U4_SOURCE = [ - '<S id="a">', - "Alpha text.", - "", - '<S id="a.mid">', - "Mid text.", - "</S>", - "</S>", - "", -].join("\n"); +export const U4_SOURCE = stagedMdx( + "T6.4-4/T6.5-5/T6.6-3 specs/A.mdx", + [ + '<S id="a">', + "Alpha text.", + "", + '<S id="a.mid">', + "Mid text.", + "</S>", + "</S>", + "", + ].join("\n"), +); // The ordering arm's unrelated validation error: an unresolved local `d` // reference (14.5) in a file untouched by the rename arguments. const U4_BAD_FILE = "specs/Bad.mdx"; -const U4_BAD_SOURCE = [ - '<S id="bad" d={"nope"}>', - "Bad text depending on nothing that exists.", - "</S>", - "", -].join("\n"); +export const U4_BAD_SOURCE = stagedMdx( + "T6.4-4/T6.5-5/T6.6-3 specs/Bad.mdx", + [ + '<S id="bad" d={"nope"}>', + "Bad text depending on nothing that exists.", + "</S>", + "", + ].join("\n"), +); // The masking arm's unparseable origin file: an unclosed section tag (14.20). const U4_BROKEN_FILE = "specs/Broken.mdx"; -const U4_BROKEN_SOURCE = [ - '<S id="broken">', - "Text that never closes.", - "", -].join("\n"); +export const U4_BROKEN_SOURCE = stagedMdx( + "T6.4-4/T6.5-5 masking arm specs/Broken.mdx", + ['<S id="broken">', "Text that never closes.", ""].join("\n"), + "unparseable", +); + +// 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). A staged-source record: T6.4-4's ordering arm and +// T6.6-3 stage it in workspaces created after a product invocation (S-9's +// timing clause). +const U4_CODE_FILE = "src/app.ts"; +const U4_CODE_SOURCE = stagedTs( + "T6.4-4/T6.6-3 src/app.ts — a discovered code source bearing no requirement IDs, the wrong-kind `<file>` operand", + "export function noop(): void {}\n", +); + +// The second nonexistent-`<file>` spelling (TEST-SPEC T6.4-4): a valid `.mdx` +// present on disk, holding a section spelling the old ID, but outside every +// configured spec group (`specs/**/*.mdx`, SPEC 7) — a file named in an +// argument exists as a member of the discovered set (SPEC 12.0, 11.4's +// operand rule), so this operand is as nonexistent as an absent path. It +// spells the same old ID `a` that the discovered `specs/A.mdx` bears, so a +// product probing the filesystem for the operand finds the old ID whether it +// then reads it from the named file or looks it up over the discovered set +// — and proceeds instead of exiting 2. Its `a` beside `specs/A.mdx`'s is no +// duplicate-ID condition even when discovered (SPEC 14 condition 3 is a +// duplicate within a file), so the base arm pins the stray file's absence +// from the discovered set directly, through `ids --json` (SPEC 12.3). +const U4_STRAY_FILE = "docs/Stray.mdx"; +export const U4_STRAY_SOURCE = stagedMdx( + "T6.4-4/T6.5-5/T6.6-3 docs/Stray.mdx", + ['<S id="a">', "Stray text outside every spec group.", "</S>", ""].join("\n"), +); + +// Parse-local existence fixtures (SPEC 6.4, 11.2). Two sections both +// spelling the same ID: every bearer's node identity is undefined (11.2, +// duplicate spellings), yet each spells `dup`, so the old ID exists and the +// duplicate-ID finding (14.3) refuses instead of any usage error. +const U4_DUP_FILE = "specs/Dup.mdx"; +export const U4_DUP_SOURCE = stagedMdx( + "T6.4-4/T6.5-5 duplicate-spellings arm specs/Dup.mdx", + [ + '<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"; +export const U4_ANC_SOURCE = stagedMdx( + "T6.4-4/T6.5-5 undefined-ancestor arm specs/Anc.mdx", + [ + "<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"; +export const U4_SOLO_SOURCE = stagedMdx( + "T6.4-4/T6.5-5/T6.6-3 specs/Solo.mdx", + ['<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>, absent on disk", + ], + [ + ["rename", U4_STRAY_FILE, "a", "a2"], + "nonexistent <file>, an .mdx present on disk (holding a section " + + "spelling the old ID) but matched by no spec group — a file named in " + + "an argument exists as a member of the discovered set (SPEC 12.0)", + ], + [["rename", U4_FILE, "nope", "nope2"], "nonexistent old ID"], + [ + ["rename", U4_CODE_FILE, "a", "a2"], + "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 + the undiscovered stray `.mdx`), exported for T6.6-3: on it, exit 2 + * beside unrelated validation errors realizes "argument checks precede" — + * previewed or not. */ +export const RENAME_USAGE_CONFIG = SPEC_AND_CODE_CONFIG; +export const RENAME_USAGE_ORDERING_FILES: Readonly< + Record<string, InitialFileContents> +> = { + [U4_FILE]: U4_SOURCE, + [U4_BAD_FILE]: U4_BAD_SOURCE, + [U4_CODE_FILE]: U4_CODE_SOURCE, + [U4_STRAY_FILE]: U4_STRAY_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, InitialFileContents>> = + { + [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>` — absent on disk, or an `.mdx` present on disk but matched by no spec group (a file named in an argument exists as a member of the discovered set) — a nonexistent old ID, and a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); an old ID inside an unparseable origin file is masked — the validation findings are reported and the command exits 1; and old-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2) — while an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings (SPEC 6.4, 11.2, 12.0, 14, 14.20)", run: async (product) => { // --- Base arm: a valid workspace --- await withWorkspace( - SPECS_ONLY_CONFIG, - { [U4_FILE]: U4_SOURCE }, + SPEC_AND_CODE_CONFIG, + { + [U4_FILE]: U4_SOURCE, + [U4_CODE_FILE]: U4_CODE_SOURCE, + [U4_STRAY_FILE]: U4_STRAY_SOURCE, + }, async (workspace) => { const context = "T6.4-4 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); - 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`, + // Staging premise: the stray file is outside the discovered set — + // `ids --json` lists the discovered spec sources (SPEC 12.3), and it + // lists `specs/A.mdx` but never `docs/Stray.mdx`. Pinning this makes + // the exit-2 assertion below demonstrably a discovered-set judgement + // over a file present on disk, not a filesystem miss. + const idsLabel = `${context}: \`ids --json\` premise`; + const listed = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsLabel), + idsLabel, + ).files.map((entry) => entry.file); + if (!listed.includes(U4_FILE) || listed.includes(U4_STRAY_FILE)) { + fail( + `${context}: staging premise — the discovered spec sources must ` + + `include ${U4_FILE} and exclude the stray ${U4_STRAY_FILE} ` + + `(outside every spec group, SPEC 7; a file named in an ` + + `argument exists as a member of the discovered set, SPEC ` + + `12.0), but \`ids --json\` listed ${JSON.stringify(listed)}`, + ); + } + // Every usage error modifies nothing (SPEC 12.0): one whole-root + // byte compare around the table — derived files, sources, and the + // stray file alike. + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectRenameUsageError( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 12.0)`, ); }, ); // --- 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,19 +1987,21 @@ const T6_4_4 = defineProductTest({ `at least one validation finding (SPEC 14)`, ); } - await expectRenameUsageError( - product, - workspace, - ["rename", "specs/Missing.mdx", "a", "a2"], - `${context}, nonexistent <file> with unrelated validation errors ` + - `present — the existence checks precede source validation (12.0)`, - ); - await expectRenameUsageError( - product, - workspace, - ["rename", U4_FILE, "nope", "nope2"], - `${context}, nonexistent old ID with unrelated validation errors ` + - `present — the existence checks precede source validation (12.0)`, + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectRenameUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors ` + + `present — the existence and wrong-kind checks precede ` + + `source validation (SPEC 6.4, 12.0)`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 12.0)`, ); }, ); @@ -1150,6 +2041,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)`, + ); + }, + ); }, }); @@ -1157,17 +2138,24 @@ const T6_4_4 = defineProductTest({ // T6.4-5 — type-level references // --------------------------------------------------------------------------- +// The move arm's twin workspaces follow the rename arm's invocations, so +// both spec sources are ledger records wrapped in place (S-9's +// before-any-product clause; helpers/staged-mdx.ts) — the rename arm's, the +// body's first workspace, converted uniformly. const T5_CORE = "specs/Core.mdx"; -const T5_CORE_SOURCE = [ - '<S id="core">', - "Core text.", - "", - '<S id="core.mid">', - "Mid text.", - "</S>", - "</S>", - "", -].join("\n"); +const T5_CORE_SOURCE = stagedMdx( + "T6.4-5 specs/Core.mdx", + [ + '<S id="core">', + "Core text.", + "", + '<S id="core.mid">', + "Mid text.", + "</S>", + "</S>", + "", + ].join("\n"), +); // One code file bearing a value-level marker (rewritten, 6.4) and a // `typeof`-level reference to the same node (not rewritten, 4.5) — the @@ -1190,11 +2178,179 @@ const T5_APP_AFTER = [ "", ].join("\n"); +// Move arm (SPEC 6.4: a rename or move alike can leave type-level references +// naming vacated identities). Twin workspaces differing only by the code +// file: `core.mid` is moved by the section form into an existing target +// parent in another file; the `typeof` reference to it is left naming the +// vacated identity byte-for-byte, and the move's plan, applied mapping, and +// journal entry are the same as without the reference. The code file holds +// the type-level reference alone — nothing the operation rewrites — so its +// staged bytes are its expected bytes. +const T5_TARGET = "specs/Target.mdx"; +const T5_TARGET_SOURCE = stagedMdx( + "T6.4-5 specs/Target.mdx", + ['<S id="hub">', "Hub text.", "</S>", ""].join("\n"), +); +// The move arm's workspaces are created after the rename arm's invocations, +// so its code file is a staged-source record (S-9's timing clause). +const T5_MOVE_APP_SOURCE = stagedTs( + "T6.4-5 src/app.ts — the move arm's code file, its `typeof`-level reference alone", + [ + 'import CORE from "../specs/Core.xspec";', + "", + "type MidNode = typeof CORE.core.mid;", + "", + ].join("\n"), +); +const T5_MOVE_ARGV: readonly string[] = [ + "move", + `${T5_CORE}#core.mid`, + `${T5_TARGET}#hub.mid`, +]; +// The complete mapping the section move journals: the moved node alone, +// re-identified by prefix replacement of `core.mid` with `hub.mid` (SPEC +// 6.5) — it has no descendants, and no identity outside the subtree moves. +const T5_MOVE_MAPPING: readonly AppliedMappingPair[] = [ + { from: `${T5_CORE}#core.mid`, to: `${T5_TARGET}#hub.mid` }, +]; + +interface T5MoveTwinOutcome { + /** The preview's `files` plan (form-exact 12.7). */ + readonly files: readonly PreviewFileEntry[]; + /** The journal's exact bytes after the move: its one appended entry. */ + readonly journal: Uint8Array; +} + +/** + * Drive the T6.4-5 section move on one twin: `build` (the valid-workspace + * precondition), the `--preview --json` plan, then the real move with + * `--json` — its report the applied mapping (SPEC 6.5: reported as rename + * does; T6.4-1's protocol) — and the journal's one appended entry (SPEC + * 6.1). Returns the preview's `files` and the journal bytes for the + * cross-twin compare. + */ +async function driveT5MoveTwin( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<T5MoveTwinOutcome> { + const context = `T6.4-5 move arm, ${label}`; + await buildOk( + product, + workspace, + `${context}: \`build\` over the staged workspace`, + ); + const journalBefore = await workspace.kind(JOURNAL_PATH); + if (journalBefore !== "absent") { + fail( + `${context}: staging premise — no journal file exists before the ` + + `first journaled operation (SPEC 6.1); found ${journalBefore} at ` + + `${JOURNAL_PATH}`, + ); + } + const previewArgv = [...T5_MOVE_ARGV, "--preview", "--json"]; + const preview = decodePreviewReport( + await runJson( + product, + workspace, + previewArgv, + `${context}: \`${previewArgv.join(" ")}\` — the preview succeeds ` + + `exactly when the real operation would proceed (SPEC 6.6)`, + ), + context, + ); + assertSameJson( + preview.findings, + [], + `${context}: a preview whose real operation would proceed reports ` + + `findings [] (SPEC 6.6, 12.7)`, + ); + if ( + preview.mapping === null || + preview.files === null || + preview.delta === null + ) { + fail( + `${context}: a successful preview reports its plan — \`mapping\`, ` + + `\`files\`, and \`delta\` are null exactly on refusal (SPEC 6.6, ` + + `12.7); got mapping ${preview.mapping === null ? "null" : "present"}, ` + + `files ${preview.files === null ? "null" : "present"}, delta ` + + `${preview.delta === null ? "null" : "present"}`, + ); + } + assertSameJson( + preview.mapping, + T5_MOVE_MAPPING, + `${context}: the preview's \`mapping\` is the complete identity mapping ` + + `the move would journal — the moved node alone, re-identified by ` + + `prefix replacement (SPEC 6.6, 6.5, 12.7)`, + ); + const moveArgv = [...T5_MOVE_ARGV, "--json"]; + assertAppliedMapping( + decodeAppliedMappingReport( + await runJson( + product, + workspace, + moveArgv, + `${context}: \`${moveArgv.join(" ")}\``, + ), + context, + ), + T5_MOVE_MAPPING, + `${context}: the successful section move's report is the applied ` + + `mapping — exactly the identity pair the operation journaled (SPEC ` + + `6.5, 6.4, 6.6, 12.0)`, + ); + const journal = await readJournal( + workspace, + `${context}: the journal after the move`, + ); + const lines = journalLineCount(journal); + if (lines !== 1) { + fail( + `${context}: the move must append its full mapping to the journal as ` + + `exactly one line-oriented entry — the journal came into existence ` + + `with this first journaled operation (SPEC 6.5, 6.1); found ` + + `${String(lines)} line(s) in ${String(journal.length)} bytes`, + ); + } + return { files: preview.files, journal }; +} + +/** + * The moved workspace stays xspec-valid: neither `build` nor `check` + * reports any finding — in particular none for a type-level reference to + * the vacated identity (SPEC 6.4: a consumer type error outside xspec's + * validations; 4.5: type-level references are unrestricted). + */ +async function assertT5ValidAfterMove( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<void> { + const context = `T6.4-5 move arm, ${label}`; + await buildOk( + product, + workspace, + `${context}: \`build\` after the move — no finding for the type-level ` + + `reference to the vacated identity (SPEC 6.4, 4.5)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: \`check\` after the move — no finding for the type-level ` + + `reference to the vacated identity (SPEC 6.4, 4.5, 12.2)`, + ); +} + const T6_4_5 = defineProductTest({ id: "T6.4-5", title: - "type-level references: a `typeof`-level reference to the old identity is not rewritten by rename, and the workspace stays xspec-valid — the consumer type error is outside xspec's validations, so `build` and `check` report no finding for it (SPEC 6.4, 4.5)", + "type-level references: a `typeof`-level reference to the old identity is not rewritten by rename, and the workspace stays xspec-valid — the consumer type error is outside xspec's validations, so `build` and `check` report no finding for it; move arm: the same `typeof` reference to a node then section-moved into another file is left byte-unchanged, naming the vacated identity, the workspace valid, and the move's preview plan, applied mapping, and journal entry the same as on a twin workspace without the reference (SPEC 6.4, 6.5, 4.5)", run: async (product) => { + // Rename arm. await withWorkspace( SPEC_AND_CODE_CONFIG, { [T5_CORE]: T5_CORE_SOURCE, [T5_APP]: T5_APP_BEFORE }, @@ -1239,6 +2395,79 @@ const T6_4_5 = defineProductTest({ ); }, ); + + // Move arm: twin workspaces differing only by the code file holding the + // `typeof` reference to the node the section move re-identifies. + const withReference = await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + [T5_CORE]: T5_CORE_SOURCE, + [T5_TARGET]: T5_TARGET_SOURCE, + [T5_APP]: T5_MOVE_APP_SOURCE, + }, + async (workspace) => { + const outcome = await driveT5MoveTwin( + product, + workspace, + "with the `typeof` reference", + ); + await assertFileBytes( + workspace.path(T5_APP), + T5_MOVE_APP_SOURCE.source, + "T6.4-5 move arm: the code file after the section move — its " + + "`typeof`-level reference keeps naming the vacated identity " + + "byte-for-byte, and its import stays (its binding had no " + + "references, SPEC 6.5): type-level references record no edges " + + "and are not rewritten, by a rename or move alike (SPEC 6.4, 4.5)", + ); + await assertT5ValidAfterMove( + product, + workspace, + "with the `typeof` reference", + ); + return outcome; + }, + ); + const withoutReference = await withWorkspace( + SPEC_AND_CODE_CONFIG, + { [T5_CORE]: T5_CORE_SOURCE, [T5_TARGET]: T5_TARGET_SOURCE }, + async (workspace) => { + const outcome = await driveT5MoveTwin( + product, + workspace, + "twin without the reference", + ); + await assertT5ValidAfterMove( + product, + workspace, + "twin without the reference", + ); + return outcome; + }, + ); + // Product-to-itself compare across the twins (H-6; H-4's exception for + // the journal's opaque entry): the type-level reference changes nothing + // the move plans, reports, or journals. Both twins' preview `mapping` + // and applied mapping were asserted above against the same fixture + // pair, so they agree; the `files` plan and the journal entry are + // compared here directly. + assertSameJson( + withReference.files, + withoutReference.files, + "T6.4-5 move arm: the preview's `files` plan with the `typeof` " + + "reference vs the twin without it — every file the operation would " + + "rewrite, with every edit, must be the same: a code file bearing " + + "only a type-level reference is no file the move rewrites (SPEC 6.6, " + + "6.4, 12.7; H-6)", + ); + assertBytesEqual( + withReference.journal, + withoutReference.journal, + "T6.4-5 move arm: the journal's one appended entry with the `typeof` " + + "reference vs the twin without it — the move's journaled mapping is " + + "the same as without the reference (SPEC 6.4, 6.1; H-4/H-6 " + + "product-to-itself compare, normalizing nothing)", + ); }, }); @@ -1269,11 +2498,18 @@ const P6_OTHER_INVALID = [ "</S>", "", ].join("\n"); +// The break is staged after the body's `build`, so it is a ledger record +// (S-9's before-any-product clause; helpers/staged-mdx.ts) — the same +// constant. +const T6_4_6_OTHER_INVALID = stagedMdx( + "T6.4-6 specs/Other.mdx overwritten with an unresolved local d reference", + 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, @@ -1286,11 +2522,16 @@ 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); + await workspace.file(P6_OTHER_FILE, T6_4_6_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 " + @@ -1409,7 +2650,7 @@ const T6_4_7 = defineProductTest({ `6.1, 13.4); found ${kind}`, ); } - await fresh.file(rel, await renamed.readBytes(rel)); + await fresh.copyFrom(renamed, rel); } await buildOk( product, diff --git a/test/suite/registry/section-6.5-ii.ts b/test/suite/registry/section-6.5-ii.ts new file mode 100644 index 00000000..c21db9af --- /dev/null +++ b/test/suite/registry/section-6.5-ii.ts @@ -0,0 +1,1240 @@ +// TEST-SPEC §6.5 (move), second half — SUITE-25 (continued): T6.5-11, +// TypeScript `text(...)` calls across the section-form move. T6.5-1…T6.5-10 +// are section-6.5.ts's business; this module keeps that file's edits bounded +// (the section-10.7-i/-ii precedent). +// +// 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, +// and rejects a product only via diagnosed assertion failures (H-8). +// +// SPEC 6.5 (reference spellings; import edits): a TypeScript `text(...)` +// call whose target the section form carries into another file is rooted, +// callee and argument, at bindings of the module its target joins — the +// callee at that module's `text` binding (4.3, 4.4), the argument at its +// default binding — and so rewritten whole, over its occurrence's span +// (5.7: callee through closing parenthesis), and is never the cross-module +// call of 14.11. An import is added exactly where the file lacks a binding a +// spelling is rooted at — one declaration per module, binding exactly the +// lacked bindings — spelled `import X, { text as Y } from "…"` or +// `import { text as Y } from "…"`, the named binding `{ text }` where its +// identifier is `text` itself, single spaces, no `;`, the specifier +// double-quoted in the canonical relative spelling, inserted as a line of +// its own at an admissible offset, a line-start one taken over any other. +// An existing import is removed exactly when an occurrence used a binding +// of its before the rewrite and none uses any binding of its after it — its +// declaration deleted in place, a line left empty purely by the deletion +// dropped with its terminator (3) — while a type-level spelling of a binding +// (4.5) is no occurrence and keeps no import: a removal can leave one naming +// a vanished binding, a consumer type error outside xspec's validations. +// Beyond these edits a move changes no bytes. SPEC 6.6/12.7: the preview +// reports, per rewritten file, each edit as class plus pre-operation range — +// a `reference-rewrite` over the occurrence's span, an `import-addition` as +// a zero-length range at the offset the real operation uses, an +// `import-removal` spanning the declaration plus its adjunct drops. +// +// Conservative operationalizations (noted per H-4): +// - The fresh identifiers are the only unpinned runs (TEST-SPEC T6.5-11). +// They are read off the rewritten call — the one place 6.4/6.5's pinned +// spelling makes them observable: exactly one `<callee>(<root>.y)` in the +// file, `<callee>` the added `text` binding (or `text`), or (e)'s held +// `tt`, and `<root>` the target module's default binding, added or (b)'s +// held `T` — the added declaration is then composed byte-exactly with +// those identifiers substituted, the file's post-move bytes WITHOUT it +// composed from the rules of 6.5 and 3 (the origin declaration's line +// removed where both its bindings lose their last use, the call's +// occurrence span replaced by the rewritten call — "the call's span as a +// second isolated run"), and the single inserted run isolated by diff +// (`assertExactDeclarationInsertion`) must read as exactly that +// declaration followed by U+000A: in (a), (b), (e), and (f) at the one +// composed position where the origin declaration's line stood, directly +// after the heading import's line — the file's one line-start admissible +// offset (the removal's start and end, each following a statement's end +// and timely, compose to it; every later line start lies inside `f` or is +// untimely, a statement standing between it and `O`'s declaration) — and +// in (c) and (d), which TEST-SPEC places nowhere, at some offset lying at +// the start of a line. Where the file keeps a binding (the retained +// origin import of (c), `K` of (a) and (f), the existing `T` of (b), `tt` +// of (e) and (f)) the fresh identifiers may not be it — asserted directly +// (a collision is also TS2300 under the compile), never narrowing 6.5's +// latitude — and a held binding roots the call exactly where 6.5 roots it +// there: `T` the argument in (b), timely, its declaration preceding +// `O`'s; `tt` the callee in (e), timely, its declaration preceding `t`'s; +// `tt` never the callee in (f), untimely, `f` standing between `t`'s +// declaration and its own (T6.5-23(k)'s mirror). +// - "`build` and `check` are clean (no 14.11, no 14.7)" is each command's +// `--json` report decoded as exactly `{"findings": []}` at exit 0. +// - (a)'s, (e)'s, and (f)'s preview parity runs on a second, identically +// staged workspace after the arm's real move — the real move first, so +// the headline observation (the move succeeding, the call never becoming +// the cross-module call of 14.11) is the first diagnosis. Byte +// determinism (6.1, 6.5) makes the two workspaces comparable: the +// `import-addition`'s pre-operation offset must be the origin +// declaration's removal's start or its end (TEST-SPEC T6.5-11: one +// composed position, the choice 6.5's latitude, T6.6-4 (b)), and, mapped +// to composed coordinates (6.5's composition — an offset strictly inside +// the removal's or the rewrite's range is diagnosed; the removal's start +// and end compose to one position), must be one of the offsets at which +// the real move's inserted run reads as the disciplined declaration. +// - (b), (c), and (d) pin no preview (TEST-SPEC pins (a)'s, (e)'s, and +// (f)'s), and no arm pins the origin's and target's plan entries +// (T6.6-4's business); the origin, target, and third-module files' +// post-move bytes are asserted whole as soundness guards, composed from +// 6.5 and 3 with no latitude, as T6.5-8's arms compose them. +// - (d)'s discriminating expectation — the standard-tooling compile fails +// after the move — is asserted through H-2's tooling channel as at least +// one error diagnostic located within the type alias statement's +// characters (the vanished binding's error), the file having compiled +// clean before the move (a fixture self-check every arm makes). + +import { Buffer } from "node:buffer"; +import type { + GraphEdge, + PreviewEdit, + PreviewFileEntry, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodePreviewReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { assertFileBytes, fail } from "../../helpers/assertions.js"; +import { assertExactDeclarationInsertion } from "../../helpers/import-insertion.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { + ConsumerProject, + assertNoCompileErrors, +} from "../../helpers/tooling.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import { + assertEdgeSetEqual, + assertSameJson, + buildOk, + expectExit, + expectFindingFreeReport, + runJson, +} from "./support.js"; + +// One spec group plus one code group (SPEC 7.2): the code file is a +// discovered code source, so its call records an occurrence and its edge. A +// staged-source record: T6.5-11 stages it in workspaces created after a +// product invocation — the preview-parity twins of (a), (e), and (f), and +// arms (b)–(f) (S-9's timing clause; test/self/s9-staged-sources.test.ts). +const CONFIG = stagedTs( + "T6.5-11 xspec.config.ts — one spec group and one code group", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); + +const ORIGIN = "specs/origin.mdx"; +const TARGET = "specs/target.mdx"; +/** (a)'s and (f)'s retained third module, whose node `a` `f`'s marker names. */ +const THIRD = "specs/k.mdx"; +const CODE = "src/c.ts"; +/** The canonical relative spelling of the target module from `src/`. */ +const TARGET_SPECIFIER = "../specs/target.xspec"; +const MOVE_ARGV = ["move", `${ORIGIN}#x`, `${TARGET}#y`] as const; +const MOVE_LABEL = MOVE_ARGV.join(" "); + +// The origin holds the moved `x` and, for (c)'s second call, the unmoved +// `w`; the target holds `z`, (b)'s marker target. Both are ledger records +// (S-9's before-any-product clause; helpers/staged-mdx.ts): every arm after +// the first stages them after the body's first product invocation. +const ORIGIN_BEFORE = stagedMdx( + "T6.5-11 specs/origin.mdx", + [ + '<S id="x">', + "Moved x text.", + "</S>", + "", + '<S id="w">', + "Unmoved w text.", + "</S>", + "", + ].join("\n"), +); + +// Composed from SPEC 6.5 and 3: the construct's own characters deleted in +// place; the merged line that deletion leaves holds only the closing tag's +// terminator and is dropped with it; the blank line that separated the two +// sections was blank before the deletion and stays. No import is gained: +// nothing left in the origin references a moved node. +const ORIGIN_AFTER = ["", '<S id="w">', "Unmoved w text.", "</S>", ""].join( + "\n", +); + +const TARGET_BEFORE = stagedMdx( + "T6.5-11 specs/target.mdx", + ['<S id="z">', "Target z text.", "</S>", ""].join("\n"), +); + +// Composed from SPEC 6.5: top-level `y`, so the moved text — re-identified +// by prefix replacement `x` → `y` — is inserted at the end of the file +// followed by U+000A; the existing final line is terminated, so the +// insertion point lies at a line start and no preceding U+000A is added. +const TARGET_AFTER = [ + '<S id="z">', + "Target z text.", + "</S>", + '<S id="y">', + "Moved x text.", + "</S>", + "", +].join("\n"); + +// The retained third module of (a) and (f): `K`'s import heads their code +// file so that the origin declaration's removal starts after a statement's +// end (TEST-SPEC T6.5-11), and `f`'s marker `K.a` keeps it. The move never +// touches it. A ledger record, as the origin and target are. +const THIRD_TEXT = ['<S id="a">', "Third-module a text.", "</S>", ""].join( + "\n", +); +const THIRD_BEFORE = stagedMdx("T6.5-11 specs/k.mdx", THIRD_TEXT); + +// The import declarations the arms' code files hold, spelled as TEST-SPEC +// T6.5-11 gives them: single spaces, no statement terminator. +/** The origin import every arm's code file holds. */ +const ORIGIN_IMPORT = 'import O, { text as t } from "../specs/origin.xspec"'; +/** (a)'s and (f)'s third-module import, heading the file. */ +const THIRD_IMPORT = 'import K from "../specs/k.xspec"'; +/** (b)'s existing default binding of the target module, heading the file. */ +const TARGET_DEFAULT_IMPORT = 'import T from "../specs/target.xspec"'; +/** (e)'s and (f)'s existing `text` binding of the target module. */ +const TARGET_TEXT_IMPORT = 'import { text as tt } from "../specs/target.xspec"'; +/** The call under test, inside the function `f`. */ +const CALL_BEFORE = "t(O.x)"; +/** (a)'s and (f)'s marker inside `f`, on the third module's node `a`. */ +const THIRD_MARKER = "K.a"; +/** (d)'s type-level spelling of the origin binding (SPEC 4.5). */ +const TYPE_ALIAS = "export type N = typeof O.x;"; + +/** + * The function `f` holding one `text(...)` call as its return value, after + * an optional marker statement ((a)'s and (f)'s `K.a`). + */ +function functionF(call: string, marker?: string): readonly string[] { + return [ + "export function f(): string {", + ...(marker === undefined ? [] : [` ${marker};`]), + ` return ${call};`, + "}", + ]; +} + +/** (c)'s second function, calling on the unmoved node `w`. */ +const FUNCTION_G = ["export function g(): string {", " return t(O.w);", "}"]; + +/** The bindings a rewritten call is rooted at, read off the call. */ +interface CallBindings { + /** + * The callee — the target module's `text` binding: the added one (or + * `text` itself), or (e)'s held `tt`. + */ + readonly callee: string; + /** + * The argument's root — the target module's default binding: the added + * one, or (b)'s held `T`. + */ + readonly root: string; +} + +/** The rewritten call in 6.4/6.5's pinned spelling. */ +function rewrittenCall(bindings: CallBindings): string { + return `${bindings.callee}(${bindings.root}.y)`; +} + +/** `{ text as Y }`, or `{ text }` where the identifier is `text` (6.5). */ +function namedTextBinding(callee: string): string { + return callee === "text" ? "{ text }" : `{ text as ${callee} }`; +} + +/** `import X, { text as Y } from "../specs/target.xspec"` (SPEC 6.5). */ +function fullDeclaration(bindings: CallBindings): string { + return ( + `import ${bindings.root}, ${namedTextBinding(bindings.callee)} from ` + + `"${TARGET_SPECIFIER}"` + ); +} + +/** `import { text as Y } from "../specs/target.xspec"` (SPEC 6.5). */ +function textOnlyDeclaration(bindings: CallBindings): string { + return `import ${namedTextBinding(bindings.callee)} from "${TARGET_SPECIFIER}"`; +} + +/** `import X from "../specs/target.xspec"` (SPEC 6.5; (e)). */ +function defaultOnlyDeclaration(bindings: CallBindings): string { + return `import ${bindings.root} from "${TARGET_SPECIFIER}"`; +} + +/** + * The offset of the start of line 2 of a file whose line 1 is `firstLine` + * terminated by U+000A — in the composed text of (a), (b), (e), and (f), + * where the origin declaration's line stood. + */ +function lineTwoOffset(firstLine: string): number { + return utf8Length(firstLine) + 1; +} + +const EMBEDS_F_TO_Y: GraphEdge = { + from: `${CODE}#f`, + to: `${TARGET}#y`, + kind: "embeds", +}; + +/** (a)'s and (f)'s marker edge: `K.a` inside `f`, untouched by the move. */ +const REFERENCES_F_TO_A: GraphEdge = { + from: `${CODE}#f`, + to: `${THIRD}#a`, + kind: "references", +}; + +/** One T6.5-11 arm: `src/c.ts` before the move and its pinned outcome. */ +interface CallMoveArm { + readonly label: string; + readonly summary: string; + /** + * `src/c.ts` before the move — a staged-source record + * (helpers/staged-ts.ts): every workspace but (a)'s first is created after + * a product invocation (S-9's timing clause), and the table is converted + * uniformly. `codeText` reads it back as text. + */ + readonly code: StagedTs; + /** Its composed post-move bytes WITHOUT the added import (SPEC 6.5, 3). */ + readonly base: (bindings: CallBindings) => string; + /** The added declaration's exact characters (SPEC 6.5). */ + readonly declaration: (bindings: CallBindings) => string; + /** The bindings the file lacks, for diagnoses. */ + readonly lacked: string; + /** + * The target module's default binding the file already holds, which + * roots the argument (b). + */ + readonly existingRoot?: string; + /** + * The target module's `text` binding the file already holds, value-level, + * unshadowed, and timely, which roots the callee (e). + */ + readonly existingCallee?: string; + /** + * A target-module `text` binding the file holds that is untimely for the + * callee, which therefore never roots it (f), with why. + */ + readonly untimelyCallee?: { readonly name: string; readonly why: string }; + /** Identifiers the fresh bindings may not be: bindings the file keeps. */ + readonly forbidden: readonly { + readonly name: string; + readonly why: string; + }[]; + /** + * The insertion offset into `base` TEST-SPEC pins — where the origin + * declaration's line stood, the file's one line-start admissible offset — + * with how the diagnosis names it; absent, any offset lying at the start + * of a line is accepted ((c), (d)). + */ + readonly placement?: { readonly offset: number; readonly where: string }; + /** Whether the origin declaration is removed with its line, or kept. */ + readonly originImport: "removed" | "kept"; + /** Whether the third module `specs/k.mdx` is staged ((a), (f)). */ + readonly third: boolean; + /** Whether TEST-SPEC pins the arm's preview parity ((a), (e), (f)). */ + readonly preview: boolean; + /** The workspace's complete `embeds` edge set after the move. */ + readonly embeds: readonly GraphEdge[]; + /** The workspace's complete `references` edge set after the move. */ + readonly references: readonly GraphEdge[]; + readonly compile: "clean" | "fails-at-type-alias"; +} + +/** (a)'s and (f)'s pinned placement, directly after `K`'s line. */ +const AFTER_THIRD_IMPORT = { + offset: lineTwoOffset(THIRD_IMPORT), + where: + "the start of line 2 of the composed text, where the origin " + + "declaration's line stood, directly after `K`'s line — the one " + + "composed position the removal's start and end make, each following " + + "a statement's end and timely; every later line start lies inside " + + "the statement `f` (no top-level position) or past it, untimely, `f` " + + "standing between it and `O`'s declaration (SPEC 6.5; T6.5-23(n))", +} as const; + +const CALL_MOVE_ARMS: readonly CallMoveArm[] = [ + { + label: "(a)", + summary: + "`K`'s import heads the file, then `import O, { text as t }` from the " + + "origin module; `f` holds the marker `K.a` and calls `t(O.x)`, both " + + "origin bindings losing their last use", + code: stagedTs( + "T6.5-11 (a) src/c.ts", + [ + THIRD_IMPORT, + ORIGIN_IMPORT, + ...functionF(CALL_BEFORE, THIRD_MARKER), + "", + ].join("\n"), + ), + base: (bindings) => + [ + THIRD_IMPORT, + ...functionF(rewrittenCall(bindings), THIRD_MARKER), + "", + ].join("\n"), + declaration: fullDeclaration, + lacked: "the target module's default and `text` bindings", + forbidden: [ + { + name: "K", + why: "the default binding of the retained third-module import", + }, + ], + placement: AFTER_THIRD_IMPORT, + originImport: "removed", + third: true, + preview: true, + embeds: [EMBEDS_F_TO_Y], + references: [REFERENCES_F_TO_A], + compile: "clean", + }, + { + label: "(b)", + summary: + 'the file already holds `import T from "../specs/target.xspec"` — ' + + "heading it, before the origin import, its default binding used by " + + "the marker `T.z` — and lacks its `text`", + code: stagedTs( + "T6.5-11 (b) src/c.ts", + [ + TARGET_DEFAULT_IMPORT, + ORIGIN_IMPORT, + "T.z;", + ...functionF(CALL_BEFORE), + "", + ].join("\n"), + ), + base: (bindings) => + [ + TARGET_DEFAULT_IMPORT, + "T.z;", + ...functionF(rewrittenCall(bindings)), + "", + ].join("\n"), + declaration: textOnlyDeclaration, + lacked: "the target module's `text` binding alone", + existingRoot: "T", + forbidden: [ + { + name: "T", + why: "the identifier the file's existing target-module import binds", + }, + ], + placement: { + offset: lineTwoOffset(TARGET_DEFAULT_IMPORT), + where: + "the start of line 2 of the composed text, where the origin " + + "declaration's line stood, directly after `T`'s line, as in (a) — " + + "the one composed position the removal's start and end make, each " + + "following a statement's end and timely; every later line start is " + + "untimely, the marker statement `T.z;` standing between it and " + + "`O`'s declaration (SPEC 6.5)", + }, + originImport: "removed", + third: false, + preview: false, + embeds: [EMBEDS_F_TO_Y], + references: [{ from: CODE, to: `${TARGET}#z`, kind: "references" }], + compile: "clean", + }, + { + label: "(c)", + summary: + "a second call `t(O.w)` in `g` on the unmoved node keeps both origin " + + "bindings in use, so the origin declaration stays", + code: stagedTs( + "T6.5-11 (c) src/c.ts", + [ + ORIGIN_IMPORT, + "", + ...functionF(CALL_BEFORE), + "", + ...FUNCTION_G, + "", + ].join("\n"), + ), + base: (bindings) => + [ + ORIGIN_IMPORT, + "", + ...functionF(rewrittenCall(bindings)), + "", + ...FUNCTION_G, + "", + ].join("\n"), + declaration: fullDeclaration, + lacked: "the target module's default and `text` bindings", + forbidden: [ + { + name: "O", + why: "the default binding of the file's retained origin import", + }, + { + name: "t", + why: "the `text` binding of the file's retained origin import", + }, + ], + originImport: "kept", + third: false, + preview: false, + embeds: [ + EMBEDS_F_TO_Y, + { from: `${CODE}#g`, to: `${ORIGIN}#w`, kind: "embeds" }, + ], + references: [], + compile: "clean", + }, + { + label: "(d)", + summary: + "the file's only value-level use of `O` is the moved call while " + + "`type N = typeof O.x` remains — a type-level spelling keeps no import", + code: stagedTs( + "T6.5-11 (d) src/c.ts", + [ORIGIN_IMPORT, "", TYPE_ALIAS, "", ...functionF(CALL_BEFORE), ""].join( + "\n", + ), + ), + base: (bindings) => + ["", TYPE_ALIAS, "", ...functionF(rewrittenCall(bindings)), ""].join( + "\n", + ), + declaration: fullDeclaration, + lacked: "the target module's default and `text` bindings", + forbidden: [], + originImport: "removed", + third: false, + preview: false, + embeds: [EMBEDS_F_TO_Y], + references: [], + compile: "fails-at-type-alias", + }, + { + label: "(e)", + summary: + "the file holds the target module's `text` binding — `import { text " + + "as tt }` heading it, unused before the move — and lacks its default; " + + "`f` calls `t(O.x)`, the only use of `O` and of `t`", + code: stagedTs( + "T6.5-11 (e) src/c.ts", + [TARGET_TEXT_IMPORT, ORIGIN_IMPORT, ...functionF(CALL_BEFORE), ""].join( + "\n", + ), + ), + base: (bindings) => + [TARGET_TEXT_IMPORT, ...functionF(rewrittenCall(bindings)), ""].join( + "\n", + ), + declaration: defaultOnlyDeclaration, + lacked: "the target module's default binding alone", + existingCallee: "tt", + forbidden: [ + { + name: "tt", + why: "the `text` binding the file's existing target-module import binds", + }, + ], + placement: { + offset: lineTwoOffset(TARGET_TEXT_IMPORT), + where: + "the start of line 2 of the composed text, where the origin " + + "declaration's line stood, directly after `tt`'s line, as in (a) — " + + "the one composed position the removal's start and end make, each " + + "following a statement's end and timely; every later line start " + + "lies inside the statement `f` or past it, untimely (SPEC 6.5)", + }, + originImport: "removed", + third: false, + preview: true, + embeds: [EMBEDS_F_TO_Y], + references: [], + compile: "clean", + }, + { + label: "(f)", + summary: + "(a)'s file with `import { text as tt }` from the target module " + + "appended after `f` — `tt` untimely for the callee, `f` standing " + + "between `t`'s declaration and its own", + code: stagedTs( + "T6.5-11 (f) src/c.ts", + [ + THIRD_IMPORT, + ORIGIN_IMPORT, + ...functionF(CALL_BEFORE, THIRD_MARKER), + TARGET_TEXT_IMPORT, + "", + ].join("\n"), + ), + base: (bindings) => + [ + THIRD_IMPORT, + ...functionF(rewrittenCall(bindings), THIRD_MARKER), + TARGET_TEXT_IMPORT, + "", + ].join("\n"), + declaration: fullDeclaration, + lacked: + "the target module's default and `text` bindings — its held `tt` " + + "untimely for the callee", + untimelyCallee: { + name: "tt", + why: + "the target module's `text` binding the file holds, untimely for " + + "the callee: its declaration follows `t`'s with the statement `f` " + + "between them, and a binding declared after the replaced one is " + + "timely only with no top-level statement but import declarations " + + "between the two", + }, + forbidden: [ + { + name: "K", + why: "the default binding of the retained third-module import", + }, + { + name: "tt", + why: "the `text` binding the file's existing target-module import binds", + }, + ], + placement: AFTER_THIRD_IMPORT, + originImport: "removed", + third: true, + preview: true, + embeds: [EMBEDS_F_TO_Y], + references: [REFERENCES_F_TO_A], + compile: "clean", + }, +]; + +function utf8Length(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** + * An arm's staged `src/c.ts` as text: the string its record was made from. + * Every code file this module stages is a string; anything else is a defect + * of the arm table. + */ +function codeText(arm: CallMoveArm): string { + const text = arm.code.source; + if (typeof text !== "string") { + throw new Error( + `T6.5-11 ${arm.label}: the staged ${CODE} must be a string (the arm ` + + "table composes text, never bytes)", + ); + } + return text; +} + +/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +async function withWorkspace<T>( + files: Readonly<Record<string, InitialFileContents>>, + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": CONFIG, ...files }, + }); + try { + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +function armFiles( + arm: CallMoveArm, +): Readonly<Record<string, InitialFileContents>> { + return { + [ORIGIN]: ORIGIN_BEFORE, + [TARGET]: TARGET_BEFORE, + ...(arm.third ? { [THIRD]: THIRD_BEFORE } : {}), + [CODE]: arm.code, + }; +} + +/** + * Read the code file as UTF-8 text, failing diagnosed (H-8) when the path + * does not hold a plain file. + */ +async function readCodeText( + workspace: TestWorkspace, + context: string, +): Promise<string> { + const kind = await workspace.kind(CODE); + if (kind !== "file") { + fail(`${context}: expected a plain file at ${CODE}; found ${kind}`); + } + return new TextDecoder("utf-8", { fatal: false }).decode( + await workspace.readBytes(CODE), + ); +} + +// The rewritten call in 6.4/6.5's pinned spelling: an identifier callee, an +// identifier root, dot access for the identifier-valid `y`, the argument +// alone. `t(O.w)` (c) and `typeof O.x` (d) never match. +const REWRITTEN_CALL = + /([A-Za-z_$][A-Za-z0-9_$]*)\(([A-Za-z_$][A-Za-z0-9_$]*)\.y\)/g; + +/** + * The bindings the rewritten call is rooted at — the value-unpinned fresh + * identifiers (SPEC 6.5), read off the one place the pinned spelling makes + * them observable; diagnosed when the call is not rewritten as pinned, is + * rooted at anything but the existing default or `text` binding where the + * file holds a timely one, has its callee rooted at a held `text` binding + * untimely for it, or binds an identifier the file keeps. + */ +function readRewrittenCall( + text: string, + arm: CallMoveArm, + context: string, +): CallBindings { + const matches = [...text.matchAll(REWRITTEN_CALL)]; + const match = matches.length === 1 ? matches[0] : undefined; + const callee = match?.[1]; + const root = match?.[2]; + if (callee === undefined || root === undefined) { + fail( + `${context}: ${CODE} must hold exactly one rewritten call ` + + `\`<callee>(<root>.y)\` — the moved call's occurrence span, callee ` + + `through closing parenthesis, replaced by a call whose callee is ` + + `the target module's \`text\` binding and whose argument is its ` + + `default binding's \`y\` (SPEC 6.5, 5.7, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + if (arm.existingRoot !== undefined && root !== arm.existingRoot) { + fail( + `${context}: the rewritten call's argument must be rooted at the ` + + `existing default binding \`${arm.existingRoot}\` the file already ` + + `holds of the target module — an import is added only where the ` + + `file lacks the binding, binding exactly the lacked ones, so no ` + + `second default binding (SPEC 6.5); the call reads ` + + `${JSON.stringify(rewrittenCall({ callee, root }))}`, + ); + } + if (arm.existingCallee !== undefined && callee !== arm.existingCallee) { + fail( + `${context}: the rewritten call's callee must be re-rooted at the ` + + `existing \`text\` binding \`${arm.existingCallee}\` the file ` + + `already holds of the target module — value-level, unshadowed at ` + + `the call, and timely for the callee, its declaration preceding ` + + `\`t\`'s — so the addition binds the lacked default binding alone ` + + `and the call becomes exactly ` + + `\`${arm.existingCallee}(<X>.y)\` (SPEC 6.5); the call reads ` + + `${JSON.stringify(rewrittenCall({ callee, root }))} — a product ` + + `judging the \`text\` binding lacked whenever the default is`, + ); + } + if (arm.untimelyCallee !== undefined && callee === arm.untimelyCallee.name) { + fail( + `${context}: the rewritten call's callee is \`${callee}\`, ` + + `${arm.untimelyCallee.why} — a held binding roots a spelling only ` + + `where it is timely for it, so one added declaration binds both ` + + `the default and \`text\` and the callee is its \`text\` binding ` + + `(SPEC 6.5; T6.5-23(k)); the call reads ` + + `${JSON.stringify(rewrittenCall({ callee, root }))} — a product ` + + `checking a held \`text\` binding's timeliness only when it would ` + + `otherwise add nothing`, + ); + } + for (const kept of arm.forbidden) { + for (const [role, name, held] of [ + ["callee", callee, arm.existingCallee], + ["argument root", root, arm.existingRoot], + ] as const) { + if (name === kept.name && name !== held) { + fail( + `${context}: the rewritten call's ${role} is \`${name}\`, ` + + `${kept.why} — an added import binds fresh identifiers ` + + `colliding with no binding already in the file (SPEC 6.5, ` + + `2.1, 4)`, + ); + } + } + } + return { callee, root }; +} + +/** The real move's observations an arm's preview parity needs. */ +interface RealMoveOutcome { + readonly bindings: CallBindings; + /** Every composed-coordinates offset at which the added run reads. */ + readonly offsets: readonly number[]; +} + +/** + * Stage one arm, run the section-form move, and assert the code file is its + * composed post-move bytes with exactly the declaration the lacked bindings + * require added under 6.5's line discipline — at the arm's pinned offset, + * where the origin declaration's line stood, or, unpinned, at a line-start + * offset — the origin, target, and third-module files as composed, `check` + * and `build` clean, the edge sets exact, and the standard-tooling compile + * as pinned. + */ +async function runCallMoveArm( + product: ProductBinding, + arm: CallMoveArm, +): Promise<RealMoveOutcome> { + const context = `T6.5-11 ${arm.label}`; + return await withWorkspace(armFiles(arm), async (workspace) => { + // Premise: the staging is valid and the code file compiles clean under + // standard tooling, so a later failure is the move's, not the staging's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging (${arm.summary})`, + ); + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [CODE], + }), + `${context} premise: ${CODE} compiles clean before the move under ` + + `standard tooling — the origin import resolves against the ` + + `generated module and the call's argument is a node (SPEC 4, 13.1; ` + + `a fixture self-check)`, + ); + await expectExit( + product, + workspace, + [...MOVE_ARGV], + 0, + `${context} \`${MOVE_LABEL}\` — a valid move over the workspace the ` + + `premise \`build\` accepted succeeds: the call whose target the ` + + `section form carries into the target file is rooted, callee and ` + + `argument, at bindings of the target module and rewritten whole, ` + + `never becoming the cross-module call of 14.11 — a refusal naming ` + + `a cross-module call at this step is the product rooting the callee ` + + `at the origin's \`text\` binding (SPEC 6.5, 4.3, 4.4)`, + ); + + const text = await readCodeText(workspace, context); + const bindings = readRewrittenCall(text, arm, context); + const declaration = arm.declaration(bindings); + const readings = assertExactDeclarationInsertion( + { + rel: CODE, + base: Buffer.from(arm.base(bindings), "utf8"), + actual: await workspace.readBytes(CODE), + declaration, + }, + `${context}: ${CODE} after the move is its composed post-move bytes — ` + + `the call's occurrence span replaced by ` + + `${JSON.stringify(rewrittenCall(bindings))}, the origin declaration ` + + (arm.originImport === "removed" + ? "removed with its line (both its bindings lost their last use)" + : "kept byte-for-byte (its bindings are still used)") + + `, every other byte unchanged — with exactly one declaration added, ` + + `binding ${arm.lacked}: ${JSON.stringify(declaration)} followed by ` + + `U+000A (SPEC 6.5, 2.1, 5.7, 3)`, + ); + const pinned = arm.placement; + if (pinned !== undefined) { + // Bytes are the only observable: the pin holds exactly when the run + // reads as the disciplined declaration at the pinned offset. + if (!readings.some((reading) => reading.offset === pinned.offset)) { + fail( + `${context}: ${CODE} — the added declaration ` + + `${JSON.stringify(declaration)} followed by U+000A must stand at ` + + `${pinned.where} (offset ${String(pinned.offset)} of the ` + + `composed text), the file's one line-start admissible offset; ` + + `the inserted run reads instead at composed offset(s) ` + + `${readings.map((reading) => String(reading.offset)).join(", ")} ` + + `(SPEC 6.5, 3; T6.5-8's discipline)`, + ); + } + } else if (!readings.some((reading) => reading.atLineStart)) { + fail( + `${context}: ${CODE} — the added declaration was inserted at a ` + + `mid-line offset (U+000A, the declaration, U+000A; read at ` + + `composed offset(s) ` + + `${readings.map((reading) => String(reading.offset)).join(", ")}) ` + + `while the file holds line-start admissible offsets — its start, ` + + `every later line's start, its end after the final terminator — ` + + `and an admissible offset at the start of a line is taken over ` + + `any other (SPEC 6.5)`, + ); + } + await assertFileBytes( + workspace.path(ORIGIN), + ORIGIN_AFTER, + `${context}: ${ORIGIN} after the move — the moved section deleted in ` + + `place with its emptied merged line dropped, the blank line kept, ` + + `no import gained (SPEC 6.5, 3; H-4, normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(TARGET), + TARGET_AFTER, + `${context}: ${TARGET} after the move — the re-identified moved text ` + + `appended at the end of the file plus U+000A, otherwise ` + + `byte-identical (SPEC 6.5, 3; H-4, normalizing nothing)`, + ); + if (arm.third) { + await assertFileBytes( + workspace.path(THIRD), + THIRD_TEXT, + `${context}: ${THIRD} after the move — the retained third module, ` + + `whose node \`K.a\` names is neither moved nor referenced by the ` + + `moved text, untouched (SPEC 6.5; H-4, normalizing nothing)`, + ); + } + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} \`check --json\` after the move — clean: no 14.11 (the ` + + `call is rooted at the target module's bindings), no 14.7 (the ` + + `rewritten call and every kept spelling resolve), no staleness ` + + `after the finishing regeneration (SPEC 6.5, 6.4, 12.2)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context} \`build --json\` after the move — clean: no 14.11, no ` + + `14.7, the fresh bindings colliding with nothing (SPEC 6.5, 14.15)`, + ); + for (const kind of ["embeds", "references"] as const) { + const label = `${context} \`query edges --kinds ${kind}\``; + const edges = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--kinds", kind], + label, + ), + label, + ); + assertEdgeSetEqual( + edges, + kind === "embeds" ? arm.embeds : arm.references, + `${label}: the complete \`${kind}\` edge set after the move — ` + + (kind === "embeds" + ? `the rewritten call's edge from ${CODE}#f to the moved node's ` + + `new identity ${TARGET}#y, and no other call's changed` + : `a marker's edge alone; the calls and a type-level spelling ` + + `record none`) + + ` (SPEC 6.5, 4.3, 4.5, 4.6, 5.2)`, + ); + } + + // The language service snapshots files on first access, and the move + // rewrote them: a fresh project. + const project = await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [CODE], + }); + if (arm.compile === "clean") { + assertNoCompileErrors( + project, + `${context}: ${CODE} after the move compiles clean under standard ` + + `tooling — the added declaration binds fresh identifiers, the ` + + `rewritten call's callee is the target module's \`text\` and its ` + + `argument a node of that module, and the regenerated modules ` + + `resolve (SPEC 6.5, 4.3, 4.5)`, + ); + } else { + assertTypeAliasCompileFailure(project, context); + } + return { bindings, offsets: readings.map((reading) => reading.offset) }; + }); +} + +/** + * (d): the removal leaves `type N = typeof O.x` naming the vanished binding + * — a consumer type error outside xspec's validations (SPEC 6.5, 4.5, 6.4) + * — so the standard-tooling compile fails with an error located within the + * type alias statement's characters. + */ +function assertTypeAliasCompileFailure( + project: ConsumerProject, + context: string, +): void { + const errors = project.errors(); + const alias = project.locate(CODE, TYPE_ALIAS); + const within = errors.filter( + (diagnostic) => + diagnostic.file === alias.file && + diagnostic.start !== undefined && + diagnostic.start.offset >= alias.offset && + diagnostic.start.offset < alias.offset + TYPE_ALIAS.length, + ); + if (within.length === 0) { + fail( + `${context}: ${CODE} after the move must fail the standard-tooling ` + + `compile at ${JSON.stringify(TYPE_ALIAS)} — the origin import, its ` + + `bindings' only occurrence moved, is removed while the type-level ` + + `spelling keeps no import and now names a vanished binding, a ` + + `consumer type error outside xspec's validations (SPEC 6.5, 4.5, ` + + `6.4); ` + + (errors.length === 0 + ? "the file compiled clean" + : `the ${String(errors.length)} error(s) lie elsewhere: ` + + errors.map((diagnostic) => diagnostic.message).join("; ")), + ); + } +} + +/** The 12.7 edit order: range start, then range end, then class-name bytes. */ +function compareEdits(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 Buffer.compare( + Buffer.from(a.class, "utf8"), + Buffer.from(b.class, "utf8"), + ); +} + +function projectEdits(edits: readonly PreviewEdit[]): readonly PreviewEdit[] { + return edits.map((edit) => ({ + class: edit.class, + range: { start: edit.range.start, end: edit.range.end }, + })); +} + +function renderEdits(edits: readonly PreviewEdit[]): string { + return edits + .map( + (edit) => + `${edit.class} [${String(edit.range.start)}, ${String(edit.range.end)})`, + ) + .join(", "); +} + +interface ByteRange { + readonly start: number; + readonly end: number; +} + +/** + * The composed-coordinates position of a pre-operation addition offset + * under 6.5's composition: the removal's range collapses to one position + * (its start and its end both compose to it, T6.6-4 (b)), the rewrite's + * range to its replacement; an offset strictly inside either is + * inadmissible (`null`). + */ +function composedOffset( + offset: number, + removal: ByteRange, + rewrite: ByteRange, + rewrittenLength: number, +): number | null { + if (offset > removal.start && offset < removal.end) return null; + if (offset > rewrite.start && offset < rewrite.end) return null; + let composed = offset; + if (offset >= removal.end) composed -= removal.end - removal.start; + if (offset >= rewrite.end) + composed += rewrittenLength - (rewrite.end - rewrite.start); + return composed; +} + +/** + * The byte range of `needle`'s one occurrence in `text`, `needle` standing + * at a line start and followed by U+000A — `withTerminator` widens the range + * over that terminator. Throws (a harness defect) when the arm table stages + * no such occurrence or several. + */ +function stagedLineRange( + text: string, + needle: string, + withTerminator: boolean, + label: string, +): ByteRange { + const at = text.indexOf(needle); + if ( + at < 0 || + text.indexOf(needle, at + 1) >= 0 || + (at > 0 && text[at - 1] !== "\n") || + text[at + needle.length] !== "\n" + ) { + throw new Error( + `${label}: the staged ${CODE} must hold ${JSON.stringify(needle)} ` + + "exactly once, as a line of its own (a harness defect)", + ); + } + const start = utf8Length(text.slice(0, at)); + return { + start, + end: start + utf8Length(needle) + (withTerminator ? 1 : 0), + }; +} + +/** + * An arm's preview parity (SPEC 6.6, 12.7) — (a)'s, (e)'s, and (f)'s: on a + * second, identically staged workspace, the preview's entry for the code + * file holds exactly the `import-removal` spanning the origin declaration + * with its adjunct drop, the `reference-rewrite` spanning the call's + * occurrence, and one zero-length `import-addition` at the removal's start + * or at its end, whose offset, composed, is where the real move inserted + * the declaration. + */ +async function runPreviewParityArm( + product: ProductBinding, + arm: CallMoveArm, + real: RealMoveOutcome, +): Promise<void> { + const context = `T6.5-11 ${arm.label} preview parity`; + await withWorkspace(armFiles(arm), async (workspace) => { + await buildOk(product, workspace, `${context} \`build\` over the staging`); + const label = `${context} \`${MOVE_LABEL} --preview --json\``; + const preview = decodePreviewReport( + await runJson( + product, + workspace, + [...MOVE_ARGV, "--preview", "--json"], + label, + ), + label, + ); + assertSameJson( + preview.findings, + [], + `${label}: the preview of a valid move completes with findings [] ` + + `(SPEC 6.6)`, + ); + if (preview.files === null) { + fail( + `${label}: the completed preview reports its plan — \`files\` ` + + `non-null (SPEC 6.6, 12.7)`, + ); + } + const entries = preview.files.filter((entry) => entry.file === CODE); + const entry: PreviewFileEntry | undefined = + entries.length === 1 ? entries[0] : undefined; + if (entry === undefined) { + fail( + `${label}: \`files\` must hold exactly one entry for ${CODE}, the ` + + `code file the move rewrites (SPEC 6.6, 12.7); got ` + + `[${preview.files.map((file) => renderPathValue(file.file)).join(", ")}]`, + ); + } + const code = codeText(arm); + if (arm.originImport !== "removed") { + throw new Error( + `${context}: preview parity is pinned for arms whose origin ` + + "declaration is removed (a harness defect)", + ); + } + // The origin declaration's own characters plus the terminator of the + // line its deletion leaves empty (SPEC 6.5, 3). + const removal = stagedLineRange(code, ORIGIN_IMPORT, true, context); + const callStart = utf8Length(code.slice(0, code.indexOf(CALL_BEFORE))); + const rewrite: ByteRange = { + start: callStart, + end: callStart + utf8Length(CALL_BEFORE), + }; + const additions = entry.edits.filter( + (edit) => edit.class === "import-addition", + ); + const addition = additions.length === 1 ? additions[0] : undefined; + if (addition === undefined) { + fail( + `${label}: ${CODE} — exactly one \`import-addition\`, the one ` + + `declaration the rewrite adds there (SPEC 6.5, 6.6); the entry ` + + `reports ${renderEdits(entry.edits)}`, + ); + } + if (addition.range.start !== addition.range.end) { + fail( + `${label}: ${CODE} — the \`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)})`, + ); + } + const offset = addition.range.start; + const expected: PreviewEdit[] = [ + { class: "import-removal", range: removal }, + { class: "reference-rewrite", range: rewrite }, + { class: "import-addition", range: { start: offset, end: offset } }, + ]; + expected.sort(compareEdits); + assertSameJson( + projectEdits(entry.edits), + projectEdits(expected), + `${label}: ${CODE} — exactly the three edits the move makes there, ` + + `class-plus-range in the 12.7 order: the \`import-removal\` ` + + `spanning the origin declaration with its adjunct drop ` + + `[${String(removal.start)}, ${String(removal.end)}) — its own ` + + `characters and the terminator of the line its deletion leaves ` + + `empty — the \`reference-rewrite\` spanning the call's occurrence, ` + + `callee through closing parenthesis [${String(rewrite.start)}, ` + + `${String(rewrite.end)}), and the zero-length \`import-addition\` ` + + `at the insertion offset (SPEC 6.6, 6.5, 5.7, 3, 12.7)`, + ); + if (offset !== removal.start && offset !== removal.end) { + fail( + `${label}: ${CODE} — the \`import-addition\` stands at offset ` + + `${String(offset)}, neither the origin declaration's removal's ` + + `start (${String(removal.start)}) nor its end ` + + `(${String(removal.end)}): the one composed position those two ` + + `make, where the origin declaration's line stood, is the file's ` + + `one line-start admissible offset — every later line start lies ` + + `inside a statement or is untimely — the choice between them 6.5's ` + + `latitude, the real operation's bytes the same either way ` + + `(SPEC 6.5, 6.6; T6.6-4 (b))`, + ); + } + const composed = composedOffset( + offset, + removal, + rewrite, + utf8Length(rewrittenCall(real.bindings)), + ); + if (composed === null) { + fail( + `${label}: the \`import-addition\` offset ${String(offset)} lies ` + + `strictly inside another edit's range — the removal ` + + `[${String(removal.start)}, ${String(removal.end)}) or the rewrite ` + + `[${String(rewrite.start)}, ${String(rewrite.end)}) — where an ` + + `addition's offset never lies (SPEC 6.5, 6.6)`, + ); + } + if (!real.offsets.includes(composed)) { + fail( + `${label}: the \`import-addition\` offset ${String(offset)} ` + + `(composed position ${String(composed)}) is not where the real ` + + `operation inserted the declaration — the offset the preview ` + + `reports is exactly the one the operation uses (SPEC 6.5, 6.6); ` + + `the real move's inserted run reads at composed offset(s) ` + + `${real.offsets.map(String).join(", ")}`, + ); + } + }); +} + +const T6_5_11 = defineProductTest({ + id: "T6.5-11", + title: + "TypeScript `text(...)` calls across the move: a call whose target the section form carries into another file is rewritten whole — callee through the target module's `text` binding, argument through its default binding — over its occurrence's span, never becoming the cross-module call of 14.11, and imports are added binding exactly the lacked bindings and removed exactly when an occurrence used a binding of theirs before and none after; over `specs/origin.mdx#x` → `specs/target.mdx#y` and `src/c.ts`: (a) `import K from \"../specs/k.xspec\"` heading `import O, { text as t }`, then `f` holding the marker `K.a` and `t(O.x)` — after the move the file compiles clean under standard tooling, `build` and `check` are clean, `query edges` reports the one `embeds` edge from `src/c.ts#f` to `specs/target.mdx#y`, and the bytes are the composed post-move file with the single added run exactly `import <X>, { text as <Y> } from \"../specs/target.xspec\"` (or `{ text }` where the fresh identifier is `text` itself) followed by U+000A where the origin declaration's line stood, directly after `K`'s line, the call's span replaced by `<Y>(<X>.y)`, the origin declaration removed with its line, no other byte changed; (b) the file headed by `import T from \"../specs/target.xspec\"` (used by the marker `T.z`), then the origin import, gains exactly `import { text as <Y> } from …` (or `{ text }`) where the origin declaration's line stood, the argument rewritten through the existing `T`, the origin import removed; (c) a second call `t(O.w)` on an unmoved node keeps the origin declaration byte-for-byte, the moved call alone rewritten; (d) `type N = typeof O.x` keeps no import — the origin import is removed, `build` and `check` are clean, and the standard-tooling compile fails at the alias; (e) the file headed by `import { text as tt } from \"../specs/target.xspec\"`, lacking the default, gains exactly `import <X> from …` where the origin declaration's line stood, the call becoming `tt(<X>.y)`; (f) (a)'s file with `import { text as tt }` appended after `f`, untimely for the callee, gains one declaration binding both, as in (a), the call becoming `<Y>(<X>.y)`, `<Y>` never `tt` — (e) and (f) each clean under `build`, `check`, and the compile, with the one `embeds` edge; and the `--preview` of (a), (e), and (f) reports for `src/c.ts` one `reference-rewrite` over the call's span, one `import-addition` at the origin declaration's removal's start or end, where the real operation then inserts, and one `import-removal` spanning the origin declaration with its adjunct drop (SPEC 6.5, 4.3, 4.5, 4.6, 5.7, 6.6, 12.7)", + run: async (product) => { + for (const arm of CALL_MOVE_ARMS) { + const real = await runCallMoveArm(product, arm); + if (arm.preview) await runPreviewParityArm(product, arm, real); + } + }, +}); + +/** TEST-SPEC §6.5, second half (SUITE-25 continued): T6.5-11. */ +export const section65iiTests: readonly ProductTestEntry[] = [T6_5_11]; diff --git a/test/suite/registry/section-6.5-iii.ts b/test/suite/registry/section-6.5-iii.ts new file mode 100644 index 00000000..ab60ad9e --- /dev/null +++ b/test/suite/registry/section-6.5-iii.ts @@ -0,0 +1,6871 @@ +// TEST-SPEC §6.5 (move), third part — SUITE-25 (continued): T6.5-12, the +// target file's own references to moved nodes, T6.5-13, admissible offsets +// and composition in pre-operation coordinates, T6.5-14, a created target +// file's fixed content, T6.5-15, joint import removals over an ESM block, +// T6.5-16, `refused-invalid-rewrite`, T6.5-17, `refused-moved-import`, +// T6.5-18, the shadow-aware binding choice, and T6.5-19, the in-section +// exclusion. +// T6.5-1…T6.5-10 are section-6.5.ts's business and T6.5-11 +// section-6.5-ii.ts's; this module keeps both files' edits bounded (the +// section-10.7-i/-ii precedent). +// +// 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, +// and rejects a product only via diagnosed assertion failures (H-8). +// +// SPEC 6.5 (reference spellings): the operation roots each reference +// spelling whose target's identity the mapping changes at the form in which +// it resolves in the file where it will stand after the operation — local +// form where that file is the source of the module its target then belongs +// to, "as is the target file's own reference, through a binding of the +// origin module, to a node of the moved subtree" (the fourth conversion +// direction; T6.5-7 pins imported→local inside the moved text, T6.5-8 the +// two local→imported directions). SPEC 6.4 pins a converted reference's +// spelling: a double-quoted string literal carrying the identity's +// characters verbatim. SPEC 6.5 (import edits): an existing spec module +// import is removed exactly when an occurrence used a binding of its before +// the rewrite and none uses any binding of its after it; a removal deletes +// the declaration's own characters in place, and a line left empty purely +// by that deletion is dropped with its terminator (3) — a block every line +// of which is dropped is headed by nothing, its first declaration removed +// with the rest — while an import a use remains for stays byte-for-byte. +// SPEC 6.5 (created target file): a created target file's initial content +// is fixed instead of chosen — the declarations it needs, each followed by +// U+000A, in an order the implementation fixes deterministically, then, +// when there is at least one, one further U+000A (the empty line ending the +// ESM block), then the moved text and its terminator; an added declaration +// is spelled `import X from "…"`, the specifier double-quoted in the +// canonical relative spelling, its identifier fresh: colliding with no +// binding already in the file, distinct from the others added there, and +// none of the compiler-provided names (2.1). +// +// Conservative operationalizations (noted per H-4): +// - T6.5-12 stages no import addition (no reference the moved text carries +// needs a binding the target lacks), so the target's post-move bytes are +// composed whole from 6.4/6.5 and 3 with no latitude and asserted +// byte-equal: the moved text landing at end of file after a terminated +// final line (no preceding U+000A, T6.5-2), the target's own two +// references rewritten in 6.4's pinned spellings (`d={"y"}`, +// `{text("y")}`), and, in the removal arm, the origin-module +// declaration's line dropped with its terminator while the blank line +// that followed it — blank before the deletion — stays, so the file +// opens with that terminator (T6.5-7's exact extent); in the retention +// arm the declaration and its line are untouched. +// - T6.5-14's fresh identifiers and the two declarations' order are the +// only unpinned runs. The identifiers are read off the moved text's +// rewritten references — the one place 6.4's pinned spellings make them +// observable: exactly one `d={<O>.s}` and exactly one `{text(<X>.q)}` in +// the created file — and the whole file is then composed byte-exactly for +// each of the two orders, each declaration `import <I> from "<canonical +// specifier>"` (6.5's spelling; the specifier through +// `canonicalSpecifier`), the actual bytes accepted when equal to either. +// The identifiers must be distinct and none of the compiler-provided +// names (SPEC 2.1, 6.5); a product binding the origin's own `X` again is +// admissible (the created file held no binding to collide with). +// - "`build` and `check` are clean" is each command's `--json` report +// decoded as exactly `{"findings": []}` at exit 0. +// - T6.5-14's category expectation (SPEC 5.6; P-5's added-node convention) +// is asserted against `impact --base <pre-move ref> --json` over a +// baseline committed before the move: the created file's root carries +// exactly `changed` — an added node receives no category through its own +// hashes — its attribution bounded by the fixture's originating nodes, +// the departed/arrived child tolerated as T6.2-3 tolerates it and the +// empty list accepted (the SUITE-20 convention); the moved subtree, the +// referenced sibling leaf, and the third module's nodes appear in no +// entry; the origin root carries `changed` (its own content lost a child, +// 5.5) and at most the two-sided `descendant-changed` T6.2-3 tolerates, +// attributed to the moved node; no entry is `deleted`; no code location +// is impacted (no code group is configured). +// - T6.5-13 (arms (a) through (f)) composes the receiving file whole for +// each arm, value-blind in the fresh identifier alone, read from the +// added declaration line (exactly one `import <X> from "./x.xspec"`); +// the cross-file origin keeps a second use of `X` in a sibling, so its +// declaration stays and the origin's expectation is the deletion's +// alone (the removal side is T6.5-10's and T6.5-14's business). The +// preview is taken before the real move and its edits for the +// receiving file are asserted exactly afterwards (12.7's order, the +// class-name tie-break included); for the same-file arms (e)/(f) the +// `target-insertion` is admitted at the insertion point or at the +// collapsed deletion's start (two pre-operation offsets, one composed +// position: T6.6-4(b)'s latitude). The root's own content is observed +// through `query node` (own text and ownHash) — (d)'s `changed` root +// through its ownHash changing. Each composed expectation is checked to +// derive (S-9's premise) before the move — a `HarnessStagingError`, +// never a verdict — and again, diagnosed, with the product's +// identifiers substituted. +// - T6.5-13 (arms (g) through (l)) keeps the same shape: the receiving +// file — the target, or in (i) the origin and in (k) the third file +// `specs/c.mdx` — is composed value-blind in the fresh identifiers of the +// declarations it gains, one line per lacked module read off the bytes +// (distinct, none reserved); (g)'s two declarations are admitted in +// either order (6.5 leaves it to the implementation) and the move is +// repeated in a fresh workspace for byte-identical bytes (H-6); (i)'s +// and (k)'s `import-addition` is admitted at the collapsed deletion's +// or removal's start or at the file's byte length (6.5's latitude, as +// the entry allows); (h), (j), and (k) assert `impact --base` over a +// baseline committed after the pre-move `build` with T6.5-14's pin +// convention — the parents `changed` within the SUITE-20 bound, `p`'s +// and the origin parent's `descendant-changed` tolerated within the +// departed or arrived child, the target root (`p`'s ancestor) required +// `descendant-changed` attributed to `p` with the arrived child +// tolerated beside it, (j)'s root required `upstream-changed` through +// its embedding of `p` (5.6: a dependency-edge target's effectiveHash +// changed; `embeds` is an edge kind, 5.2), the dependent file's root +// required `upstream-changed` as a dependent's ancestor (5.6), the +// `d={B}` dependent required `upstream-changed` with the target root +// among its attribution, and every other node named by no entry; (j)'s +// own text carries the `{text("p")}` embedding fully expanded (1.6: +// `p`'s subtree text, the moved body line's `{text(<X>.a)}` replaced by +// `a`'s); (l) and its indented twin assert the pre-move `build --json` +// clean and `view`'s `imports` empty before, the added declaration alone +// after (its range the declaration's own characters, as T11.4-4 pins an +// import's). +// - T6.5-15 stages its target already binding, under the origin's own +// identifiers, every module the moved text references, so no import is +// added and no spelling rewritten (each moved spelling is rooted at a +// binding the target holds, 6.5) and both files compose whole with no +// latitude; the preview assertion is over the origin entry's +// `import-removal` edits alone — the class the entry pins — each spanning +// the declaration plus the terminator of the line its deletion leaves +// empty (6.5's extent, 6.6's range rule), compared in the preview's order; +// the compiled Markdown is read from `specs/o.md` after `check --json` and +// `build --json`, `markdown.emit` on (7.3); each composed expectation is +// checked to derive (S-9) before its move, a `HarnessStagingError`, never +// a verdict. +// - T6.5-16 composes every rewritten file's would-be text from 6.5's exact +// edits and 3's line drops — the deletion, the insertion before the target +// parent's closing tag or at the file's end with its terminators, the +// self-closing parent's paired-form rewrite — and verifies in the test +// that each concerned file's text does not derive and every other +// rewritten file's does (S-9; a `HarnessStagingError` either way, never a +// verdict); "nothing modified" is the whole-root byte snapshot around the +// `move … --json` (sources, derived files, the journal absent or +// byte-unchanged); the refusal report holds exactly one finding per +// applicable reason, its codes compared as a set, and the +// `refused-invalid-rewrite` finding's `locations`, `identities`, and +// `path` exactly; the controls the entry composes here — the in-line +// section into each of (c)'s three parents (T6.5-2's fourth geometry +// stages the first alone), (d)'s prose remainder, (e)'s block-ending empty +// line — are performed and byte-asserted with `check` and `build` clean, +// those it names by ID (T6.2-3(a), (b), (c), (e)) being that test's +// stagings. Arms (f)–(i), the applicability arms, and the created-target +// arm are still to be registered; the preview twins are T6.6-3's. +// - T6.5-18 runs four arms (`A18_ARMS`: the shadowed default, the +// type-only default (a), the type-only `text` (b), and the shadowed +// callee), each its own workspace of ledger records, the origin staged +// with a kept sibling `w` after the moved `x` (the entry leaves the rest +// of the file open), so the origin stays a non-empty file and its +// post-move bytes are composed whole from 6.5 and 3; `src/c.ts` is +// composed value-blind in the fresh identifier alone, read off the +// rewritten marker or call, and the added declaration isolated by diff +// and pinned where the origin declaration's line stood (T6.5-8's +// discipline, `assertExactDeclarationInsertion`); the preview is taken +// before the real move, its `import-addition` admitted at the origin +// declaration's removal's start or its end, one composed position +// (T6.6-4(b)'s latitude); the compile-clean premise and observation ride +// the TypeScript tooling driver (H-2) as T6.5-9's do. +// - T6.5-19 rides T6.5-13's arm runner (`runA13Arm`, its diagnoses under +// the caller's test ID): (a) is a cross-file arm over T6.5-13's shared +// origin and third module (the origin keeps a second use of `X`, so +// its expectation is the deletion's alone), (b) stages T6.5-13(i)'s +// existing target `<S id="k">z</S>`, U+000A, the moved `m` appended +// after its final terminator; the entry's named offsets are probed +// under `deriveMdx` over the receiving file as the other edits leave +// it, the declaration inserted per 6.5's terminator rule +// (`r16Declared`), as staging premises. + +import { Buffer } from "node:buffer"; +import type { + ChangeCategory, + Finding, + GraphEdge, + ImpactReport, + NodeReport, + PreviewEdit, + PreviewEditClass, + PreviewFileEntry, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeImpactReport, + decodeNodeReport, + decodePreviewReport, + decodeViewReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { assertFileBytes, fail } from "../../helpers/assertions.js"; +import { + assertExactDeclarationInsertion, + canonicalSpecifier, +} from "../../helpers/import-insertion.js"; +import { deriveMdx } from "../../helpers/mdx-derivability.js"; +import { HarnessStagingError } from "../../helpers/permissions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import { StagedMdx, stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { + ConsumerProject, + assertNoCompileErrors, +} from "../../helpers/tooling.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import { + assertEdgeSetEqual, + assertFindingIdentities, + assertSameJson, + buildOk, + expectExit, + expectFindingFreeReport, + runFindingsReport, + runJson, +} from "./support.js"; + +// One spec group (SPEC 7.1), no code group: every staged `.mdx` under +// `specs/` is a discovered spec source. A staged-source record: T6.5-12 +// through T6.5-14, T6.5-16, T6.5-17, and T6.5-19 stage it in workspaces +// created after a product invocation, as T6.5-23, T6.6-3, T6.6-4, and T14-7 +// do through R16_CONFIG (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const CONFIG = stagedTs( + "T6.5-12/T6.5-13/T6.5-14/T6.5-16/T6.5-17/T6.5-19/T6.5-23/T6.6-3/T6.6-4/T14-7 xspec.config.ts — one spec group, no code group, the module's configuration (R16_CONFIG)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); + +/** + * The configuration every arm of this module is staged under + * (`withWorkspace`'s default, T6.5-13's arms included) — exported for + * T6.6-3's preview twins, which stage T6.5-16's and T6.5-17's arms byte for + * byte (TEST-SPEC T6.6-3: "staged identically"), and for T6.6-4's tie-break + * stagings, T6.5-13's (b), (d), and (g) restaged likewise + * (`A13_TIE_BREAK_ARMS`), and for T6.5-23(g), which moves T6.5-13's + * cross-file text (section-6.5-v.ts). + */ +export const R16_CONFIG = CONFIG; + +// One spec group plus one code group (SPEC 7.1), for T6.5-18's code file. A +// staged-source record: T6.5-18 stages it in workspaces created after a +// product invocation — every arm after the first (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPEC_AND_CODE_CONFIG = stagedTs( + "T6.5-18 xspec.config.ts — one spec group and one code group", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); + +/** The compiler-provided names a spec source's added import may not bind (SPEC 2.1). */ +const MDX_RESERVED_NAMES: readonly string[] = ["S", "Spec", "text"]; + +/** + * Stage a fresh workspace (`config` plus `files`), run `body`, dispose (H-1). + * Every `.mdx` entry a body stages after its first product invocation is a + * ledger record (S-9's before-any-product clause; helpers/staged-mdx.ts), + * staged under the record's declaration; the arm tables below carry them. + */ +async function withWorkspace<T>( + files: Readonly<Record<string, InitialFileContents>>, + body: (workspace: TestWorkspace) => Promise<T>, + config: string | StagedTs = CONFIG, +): Promise<T> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": config, ...files }, + }); + try { + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +/** + * The text of a staged spec source: the string a ledger record was made + * from, or the plain string itself — for the S-9 vectors and the tables' + * self-checks, which read the staged bytes as text. Every source this + * module stages is a string; anything else is a defect of the table. + */ +function stagedText(contents: InitialFileContents): string { + const text = contents instanceof StagedMdx ? contents.source : contents; + if (typeof text !== "string") { + throw new Error( + "section-6.5-iii: a staged spec source must be a string (the tables " + + "compose text, never bytes)", + ); + } + return text; +} + +/** + * Read a workspace source file as UTF-8 text, failing diagnosed (H-8) when + * the path does not hold a plain file. + */ +async function readSourceText( + workspace: TestWorkspace, + rel: string, + context: string, +): Promise<string> { + const kind = await workspace.kind(rel); + if (kind !== "file") { + fail(`${context}: expected a plain file at ${rel}; found ${kind}`); + } + return new TextDecoder("utf-8", { fatal: false }).decode( + await workspace.readBytes(rel), + ); +} + +/** + * The workspace's complete edge set of one dependency kind, via + * `query edges --kinds <kind>` (SPEC 11), for exact-set comparison (5.2). + */ +async function queryEdgesOfKind( + product: ProductBinding, + workspace: TestWorkspace, + kind: "depends" | "embeds" | "references", + context: string, +): Promise<readonly GraphEdge[]> { + const label = `${context} \`query edges --kinds ${kind}\``; + return decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--kinds", kind], + label, + ), + label, + ); +} + +/** The complete `depends` and `embeds` edge sets after a move (SPEC 6.5, 5.2). */ +async function assertEdgeSets( + product: ProductBinding, + workspace: TestWorkspace, + expected: { depends: readonly GraphEdge[]; embeds: readonly GraphEdge[] }, + reason: string, + context: string, +): Promise<void> { + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "depends", context), + expected.depends, + `${context}: the complete \`depends\` edge set after the move — ${reason} (SPEC 6.5, 5.2)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", context), + expected.embeds, + `${context}: the complete \`embeds\` edge set after the move — ${reason} (SPEC 6.5, 5.2, 2.3)`, + ); +} + +/** `check --json` and `build --json` clean: exactly `{"findings": []}` at exit 0. */ +async function assertCleanAfterMove( + product: ProductBinding, + workspace: TestWorkspace, + reason: string, + context: string, +): Promise<void> { + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} \`check --json\` after the move — clean: ${reason}; no ` + + `staleness after the finishing regeneration (SPEC 6.5, 6.4, 12.2, 14.10)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context} \`build --json\` after the move — clean: the rewritten ` + + `workspace is valid, the embedding's expansion included (SPEC 6.5, 12.1, 3)`, + ); +} + +/** + * A spec file the move wrote derives under the stock MDX 3 grammar (S-9, + * `deriveMdx`): 6.5 fixes a created file's content so that it derives + * exactly when the moved text alone at a line's start would, and a product's + * own `check` cannot judge that where its grammar is wider than 14.20's. + */ +async function assertSpecDerives( + workspace: TestWorkspace, + rel: string, + reason: string, + context: string, +): Promise<void> { + const bytes = await workspace.readBytes(rel); + const verdict = deriveMdx(bytes); + if (verdict.derives) return; + const where = + verdict.position === undefined + ? "" + : ` at line ${String(verdict.position.line)}, column ${String(verdict.position.column)} (offset ${String(verdict.position.offset)})`; + fail( + `${context}: ${rel} after the move is not well-formed under the stock ` + + `MDX 3 grammar (S-9)${where}: ${verdict.reason} — ${reason} (SPEC 6.5, ` + + `14.20); the file reads ${JSON.stringify(Buffer.from(bytes).toString("utf8"))}`, + ); +} + +// --------------------------------------------------------------------------- +// T6.5-12 The target file's own references to moved nodes +// --------------------------------------------------------------------------- + +const R12_ORIGIN = "specs/a.mdx"; +const R12_TARGET = "specs/b.mdx"; +const R12_MOVE_ARGV = ["move", "specs/a.mdx#x", "specs/b.mdx#y"] as const; + +/** The target's binding of the origin module (SPEC 2.1). */ +const R12_IMPORT = 'import A from "./a.xspec"'; + +// The origin: `w` stays, `x` is moved. The construct's tags stand alone on +// their lines, so its deletion in place leaves one merged empty line, dropped +// with its terminator (SPEC 6.5, 3); the blank line before it, blank already, +// stays — the origin ends with `</S>`, U+000A, U+000A. +const R12_ORIGIN_BEFORE = stagedMdx( + "T6.5-12 specs/a.mdx", + [ + '<S id="w">', + "W text.", + "</S>", + "", + '<S id="x">', + "X text.", + "</S>", + "", + ].join("\n"), +); +const R12_ORIGIN_AFTER = ['<S id="w">', "W text.", "</S>", "", ""].join("\n"); + +// The target's own section, before and after: `d={A.x}` and `{text(A.x)}` +// — references through the origin binding to the moved node — become +// local-form references to `y`, in 6.4's pinned spelling for converted +// references (double-quoted string literals). +const R12_OWN_LINES_BEFORE = [ + '<S id="b" d={A.x}>', + "Bee text.", + "", + "{text(A.x)}", + "</S>", +]; +const R12_OWN_LINES_AFTER = [ + '<S id="b" d={"y"}>', + "Bee text.", + "", + '{text("y")}', + "</S>", +]; +// The retention arm's further use of `A`: a `d` reference to the unmoved `w`. +const R12_OTHER_LINES = ['<S id="c" d={A.w}>', "Cee text.", "</S>"]; +// The moved text after re-identification (`x` → `y`), landing at the end of +// the file (top-level `<new-id>`): the file's final line is terminated, so +// the insertion point is at the start of a line and no U+000A precedes it; +// one follows (SPEC 6.5; T6.5-2). +const R12_MOVED_LINES_AFTER = ['<S id="y">', "X text.", "</S>"]; + +const R12_B_TO_Y_DEPENDS: GraphEdge = { + from: `${R12_TARGET}#b`, + to: `${R12_TARGET}#y`, + kind: "depends", +}; +const R12_B_TO_Y_EMBEDS: GraphEdge = { + from: `${R12_TARGET}#b`, + to: `${R12_TARGET}#y`, + kind: "embeds", +}; + +interface R12Arm { + readonly name: string; + /** The target as staged: a ledger record (S-9). */ + readonly targetBefore: StagedMdx; + /** Composed from SPEC 6.4/6.5 and 3 — never from product output. */ + readonly targetAfter: string; + readonly importFate: string; + readonly depends: readonly GraphEdge[]; + readonly embeds: readonly GraphEdge[]; +} + +const R12_ARMS: readonly R12Arm[] = [ + { + name: "last uses — import removed", + targetBefore: stagedMdx( + "T6.5-12 (last uses — import removed) specs/b.mdx", + [R12_IMPORT, "", ...R12_OWN_LINES_BEFORE, ""].join("\n"), + ), + // The two rewritten references were the `A` binding's last uses, so the + // declaration's own characters are deleted in place and its line, left + // empty purely by that deletion, is dropped with its terminator — the + // block it alone made up is left with no line, so its first declaration + // goes with the rest (SPEC 6.5, 3); the blank line that followed it was + // blank before the deletion and stays, so the file now opens with that + // terminator (T6.5-7's exact extent). + targetAfter: [ + "", + ...R12_OWN_LINES_AFTER, + ...R12_MOVED_LINES_AFTER, + "", + ].join("\n"), + importFate: + "the `A` import, its binding's last uses rewritten, removed with " + + "6.5's exact extent — its own characters deleted in place and its " + + "emptied line dropped with its terminator, the blank line that " + + "followed kept", + depends: [R12_B_TO_Y_DEPENDS], + embeds: [R12_B_TO_Y_EMBEDS], + }, + { + name: "a use remains — import kept", + targetBefore: stagedMdx( + "T6.5-12 (a use remains — import kept) specs/b.mdx", + [ + R12_IMPORT, + "", + ...R12_OWN_LINES_BEFORE, + "", + ...R12_OTHER_LINES, + "", + ].join("\n"), + ), + // `c`'s `d={A.w}` still uses the binding after the rewrite, so the + // declaration and its line are untouched (SPEC 6.5). + targetAfter: [ + R12_IMPORT, + "", + ...R12_OWN_LINES_AFTER, + "", + ...R12_OTHER_LINES, + ...R12_MOVED_LINES_AFTER, + "", + ].join("\n"), + importFate: + "the `A` import kept byte-for-byte with its line — `d={A.w}` still " + + "uses its binding after the rewrite", + depends: [ + R12_B_TO_Y_DEPENDS, + { from: `${R12_TARGET}#c`, to: `${R12_ORIGIN}#w`, kind: "depends" }, + ], + embeds: [R12_B_TO_Y_EMBEDS], + }, +]; + +/** A stale or misrooted spelling of the target's own references, loosely read — for the diagnosis alone. */ +const R12_STALE = /\bA\.x\b/; +const R12_MISROOTED = /(?:d=\{|text\()\s*([A-Za-z_$][A-Za-z0-9_$]*)\.y\b/; + +/** + * Name the headline deviation of a target that is not byte-equal to its + * composed expectation, when one of the two forms TEST-SPEC names shows. + */ +function r12Deviation(actual: string): string { + if (R12_STALE.test(actual)) { + return ( + "the target still spells `A.x`, naming a vacated identity — an " + + "unresolved reference behind a reported success" + ); + } + const misrooted = R12_MISROOTED.exec(actual); + if (misrooted !== null) { + return ( + `the target roots its own reference to the moved node at a binding ` + + `(\`${misrooted[1] ?? ""}\`) of its own module instead of local form` + ); + } + return ""; +} + +async function runR12Arm( + product: ProductBinding, + arm: R12Arm, + context: string, +): Promise<void> { + await withWorkspace( + { [R12_ORIGIN]: R12_ORIGIN_BEFORE, [R12_TARGET]: arm.targetBefore }, + async (workspace) => { + // Premise: the staging is valid — every reference resolves through + // the target's `A` binding — so a later failure is the move's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + + await expectExit( + product, + workspace, + [...R12_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#x specs/b.mdx#y\``, + ); + + const deviation = r12Deviation( + await readSourceText(workspace, R12_TARGET, context), + ); + await assertFileBytes( + workspace.path(R12_TARGET), + arm.targetAfter, + `${context}: ${R12_TARGET} after the move — ` + + (deviation === "" ? "" : `${deviation}; `) + + `the target's own references through its origin-module binding ` + + `to the moved node are rooted in local form, in 6.4's pinned ` + + `spellings for converted references (\`d={"y"}\`, ` + + `\`{text("y")}\`); ${arm.importFate}; the moved text lands at ` + + `the end of the file plus U+000A with no preceding terminator ` + + `(the final line was terminated); every other byte is unchanged ` + + `(SPEC 6.5, 6.4, 3; H-4, normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(R12_ORIGIN), + R12_ORIGIN_AFTER, + `${context}: ${R12_ORIGIN} after the move — the moved construct ` + + `deleted in place, its merged empty line dropped with its ` + + `terminator, the blank line before it kept, \`w\` untouched ` + + `(SPEC 6.5, 3)`, + ); + + await assertEdgeSets( + product, + workspace, + { depends: arm.depends, embeds: arm.embeds }, + `the target's own section reports its \`depends\` and \`embeds\` ` + + `edges to ${R12_TARGET}#y, the moved node's new identity` + + (arm.depends.length > 1 + ? `, and \`c\`'s retained reference its edge to ${R12_ORIGIN}#w` + : ""), + context, + ); + await assertCleanAfterMove( + product, + workspace, + "every rewritten reference resolves in local form and every kept " + + "spelling through its binding", + context, + ); + }, + ); +} + +const T6_5_12 = defineProductTest({ + id: "T6.5-12", + title: + "the target file's own references to moved nodes (the fourth conversion direction): the target imports the origin module as `A` and spells `d={A.x}` and `{text(A.x)}` in a section of its own; after `move specs/a.mdx#x specs/b.mdx#y` they read `d={\"y\"}` and `{text(\"y\")}` (6.4's double-quoted string literals), the `A` import removed with its line when those were its binding's last uses (one arm) and kept byte-for-byte when a `d={A.w}` to an unmoved node remains (the other), the target otherwise byte-identical apart from the moved text's landing and its own rewrites, each file byte-equal to expected bytes composed from 6.4/6.5 and 3; `query edges` reports the section's `depends` and `embeds` edges to `specs/b.mdx#y`; `build` and `check` are clean — a product leaving `A.x` naming a vacated identity or rooting the reference at a binding of the target's own module fails (SPEC 6.5, 6.4, 3; H-4)", + run: async (product) => { + for (const arm of R12_ARMS) { + await runR12Arm(product, arm, `T6.5-12 (${arm.name})`); + } + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-14 Created target file's fixed content +// --------------------------------------------------------------------------- + +const F14_ORIGIN = "specs/a.mdx"; +const F14_ORIGIN_MODULE = "specs/a.xspec"; +const F14_CREATED = "specs/new.mdx"; +const F14_THIRD = "specs/x.mdx"; +const F14_THIRD_MODULE = "specs/x.xspec"; +const F14_MOVE_ARGV = ["move", "specs/a.mdx#m", "specs/new.mdx#m"] as const; + +// The referenced sibling leaf of the moved section: a top-level section +// beside `m` under the origin's root, none of its bytes on the moved +// construct's boundary lines (both stand alone on their lines with a blank +// line between the constructs), depending on and embedding nothing — so the +// move leaves its canonical identity and effectiveHash unchanged (SPEC 5.5) +// and the moved node, whose `d` reference targets it, is never +// `upstream-changed` (5.6). +const F14_SIBLING_LINES = ['<S id="s">', "Sib text.", "</S>"]; +const F14_THIRD_SOURCE = ['<S id="q">', "Q text.", "</S>", ""].join("\n"); +/** The third module as arm (ii) stages it — a ledger record (S-9); the string stays for the untouched-file compare. */ +const F14_THIRD_STAGED = stagedMdx( + "T6.5-14 (two declarations) specs/x.mdx", + F14_THIRD_SOURCE, +); + +// Arm (i): no declaration needed — the moved subtree's one reference is +// local to it (`m.b` depends on `m.a`), and the move keeps the ID, so no +// `id` attribute and no local-form reference is rewritten (SPEC 6.5): the +// created file is exactly the moved text — the construct's own characters, +// opening `<` through the closing tag's `>` — followed by U+000A. +const F14_LOCAL_MOVED_LINES = [ + '<S id="m">', + "Moved text.", + "", + '<S id="m.a">', + "A text.", + "</S>", + "", + '<S id="m.b" d={"m.a"}>', + "B text.", + "</S>", + "</S>", +]; +const F14_LOCAL_MOVED_TEXT = F14_LOCAL_MOVED_LINES.join("\n"); +const F14_LOCAL_ORIGIN_BEFORE = [ + ...F14_SIBLING_LINES, + "", + ...F14_LOCAL_MOVED_LINES, + "", +].join("\n"); +const F14_LOCAL_CREATED = `${F14_LOCAL_MOVED_TEXT}\n`; +// The origin after either arm's deletion: the moved construct deleted in +// place, its merged empty line dropped with its terminator, the blank line +// before it (blank already) kept — `</S>`, U+000A, U+000A (SPEC 6.5, 3). +const F14_LOCAL_ORIGIN_AFTER = [...F14_SIBLING_LINES, "", ""].join("\n"); + +const F14_LOCAL_DEPENDS: readonly GraphEdge[] = [ + { from: `${F14_CREATED}#m.b`, to: `${F14_CREATED}#m.a`, kind: "depends" }, +]; + +// Arm (ii): two declarations needed. The moved text carries a local `d` +// reference to the sibling `s` — converted to imported form through the +// created file's declaration of the origin module, `d={<O>.s}` (6.4: dot +// access, the segment being identifier-valid) — and a `{text(X.q)}` +// embedding through the origin's binding of the third module, rooted after +// the move at the created file's own fresh binding `<X>` of that module +// (T6.5-10's shape). +function f14ImportedMovedLines( + originRoot: string, + thirdRoot: string, +): string[] { + return [ + `<S id="m" d={${originRoot}.s}>`, + "Moved text.", + "", + `{text(${thirdRoot}.q)}`, + "</S>", + ]; +} +const F14_DECL_ORIGIN_BEFORE = stagedMdx( + "T6.5-14 (two declarations) specs/a.mdx", + [ + 'import X from "./x.xspec"', + "", + ...F14_SIBLING_LINES, + "", + '<S id="m" d={"s"}>', + "Moved text.", + "", + "{text(X.q)}", + "</S>", + "", + ].join("\n"), +); +// The origin's `X` binding loses its only use with the moved text, so its +// declaration is deleted in place and its emptied line dropped with its +// terminator — the block it alone made up left with no line — while the +// blank line that followed it stays: the file opens with that terminator +// (SPEC 6.5, 3; T6.5-7's exact extent, T6.5-10 (a)). +const F14_DECL_ORIGIN_AFTER = ["", ...F14_SIBLING_LINES, "", ""].join("\n"); + +const F14_REWRITTEN_DEPENDS = /d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.s\}/g; +const F14_REWRITTEN_EMBEDS = /\{text\(([A-Za-z_$][A-Za-z0-9_$]*)\.q\)\}/g; + +const F14_DECL_DEPENDS: readonly GraphEdge[] = [ + { from: `${F14_CREATED}#m`, to: `${F14_ORIGIN}#s`, kind: "depends" }, +]; +const F14_DECL_EMBEDS: readonly GraphEdge[] = [ + { from: `${F14_CREATED}#m`, to: `${F14_THIRD}#q`, kind: "embeds" }, +]; + +/** + * The identifier one of the created file's rewritten references is rooted + * at, read off 6.4's pinned spelling; diagnosed when the reference is not + * spelled as 6.4 pins it, or is present more or less than once. + */ +function f14ReferenceRoot( + text: string, + pattern: RegExp, + form: string, + context: string, +): string { + const matches = [...text.matchAll(pattern)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${F14_CREATED} must hold exactly one ${form} — the moved ` + + `reference rooted at the created file's binding of its target's ` + + `module, in 6.4's pinned spelling (SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * Name the headline deviation of a created file that is not byte-equal to + * either composed expectation, when one of the forms TEST-SPEC names shows. + */ +function f14DeclDeviation( + actual: string, + declarations: readonly string[], + movedText: string, +): string { + if (actual.startsWith(movedText)) { + return "the moved text stands first — the declarations must precede it"; + } + const heads = [ + declarations.join("\n"), + [...declarations].reverse().join("\n"), + ]; + for (const head of heads) { + if (actual === `${head}\n${movedText}\n`) { + return "no empty line ends the ESM block before the moved text"; + } + if (actual === `${head}\n\n\n${movedText}\n`) { + return "two empty lines stand between the ESM block and the moved text"; + } + } + if (!heads.some((head) => actual.startsWith(`${head}\n`))) { + return "the declarations do not stand contiguous at the start of the file"; + } + return ""; +} + +/** Categories one node must carry, and may carry, after the move (SPEC 5.6). */ +interface CategoryPin { + readonly identity: string; + /** Categories the node must carry; empty = named by no entry. */ + readonly required: readonly ChangeCategory[]; + /** Attribution bound of `changed` (the SUITE-20 convention). */ + readonly changedWithin?: readonly string[]; + /** Categories tolerated beside the required ones, with their attribution bound. */ + readonly optional?: readonly { + readonly category: ChangeCategory; + readonly within: readonly string[]; + }[]; + /** + * Attribution of required categories other than `changed`: bounded by + * `within`, and, where given, including each of `mustInclude` (SPEC 5.6: + * every category is attributed to its originating nodes). + */ + readonly attributed?: readonly { + readonly category: ChangeCategory; + readonly within: readonly string[]; + readonly mustInclude?: readonly string[]; + }[]; +} + +/** + * Assert an `impact` report's requirement-level content against the pinned + * expectations (SPEC 5.6, 6.2, 9.1): every identity named is a current, + * journal-mapped one; no entry is `deleted`; a pinned node carries exactly + * its required categories plus at most the tolerated ones, each attributed + * within its bound; no code location is impacted. + */ +function assertImpactPins( + report: ImpactReport, + known: readonly string[], + pins: readonly CategoryPin[], + context: string, +): void { + const merged = new Map<string, Map<ChangeCategory, string[]>>(); + for (const entry of report.requirements) { + for (const identity of entry.nodes) { + if (!known.includes(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-operation identity here means ` + + `the product failed to unify identities through the journal ` + + `(SPEC 6.2, 6.3, 9.2); entry: ${JSON.stringify(entry)}`, + ); + } + if (entry.deleted) { + fail( + `${context}: an entry names ${JSON.stringify(identity)} as deleted — ` + + `a journaled move deletes nothing: the moved subtree keeps its ` + + `identity through the journal mapping (SPEC 6.2, 6.3, 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); + } + } + } + + const checkAttribution = ( + identity: string, + category: ChangeCategory, + attributed: readonly string[], + within: readonly string[], + ): void => { + for (const source of [...new Set(attributed)].sort()) { + if (!within.includes(source)) { + fail( + `${context}: the ${category} category of ${identity} is attributed ` + + `to ${JSON.stringify(source)}, outside its originating-node bound ` + + `${JSON.stringify([...within].sort())} (SPEC 5.6: every category ` + + `is attributed to its originating nodes)`, + ); + } + } + }; + + for (const pin of pins) { + const actual = + merged.get(pin.identity) ?? new Map<ChangeCategory, string[]>(); + const names = [...actual.keys()].sort(); + if (pin.required.length === 0 && names.length > 0) { + fail( + `${context}: ${pin.identity} must receive no category — its hashes ` + + `are unchanged and its identity maps through the journal (SPEC 5.5, ` + + `5.6, 6.2) — 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(names)}`, + ); + } + const tolerated = new Set<ChangeCategory>([ + ...pin.required, + ...(pin.optional ?? []).map((entry) => entry.category), + ]); + for (const name of names) { + if (!tolerated.has(name)) { + fail( + `${context}: ${pin.identity} carries the category ${name}, which ` + + `SPEC 5.6 gives it no ground for — expected exactly ` + + `${JSON.stringify([...pin.required].sort())}` + + (pin.optional === undefined + ? "" + : ` (${JSON.stringify(pin.optional.map((entry) => entry.category).sort())} tolerated)`) + + ` (SPEC 5.6, 6.2)`, + ); + } + } + for (const name of pin.required) { + if (!actual.has(name)) { + fail( + `${context}: ${pin.identity} must carry ${name} (SPEC 5.6, 6.2), ` + + `but the report gives it ` + + (names.length === 0 + ? "no category" + : `only ${JSON.stringify(names)}`), + ); + } + } + const changed = actual.get("changed"); + if (changed !== undefined && pin.changedWithin !== undefined) { + checkAttribution(pin.identity, "changed", changed, pin.changedWithin); + } + for (const entry of pin.optional ?? []) { + const attributed = actual.get(entry.category); + if (attributed !== undefined) { + checkAttribution( + pin.identity, + entry.category, + attributed, + entry.within, + ); + } + } + for (const entry of pin.attributed ?? []) { + const attributed = actual.get(entry.category) ?? []; + checkAttribution(pin.identity, entry.category, attributed, entry.within); + for (const source of entry.mustInclude ?? []) { + if (!attributed.includes(source)) { + fail( + `${context}: the ${entry.category} category of ${pin.identity} ` + + `is attributed to ${JSON.stringify([...new Set(attributed)].sort())}, ` + + `which omits ${JSON.stringify(source)}, an originating node of ` + + `the change it cascades from (SPEC 5.6: every category is ` + + `attributed to its originating nodes)`, + ); + } + } + } + } + + assertSameJson( + report.code, + { direct: [], transitive: [] }, + `${context}: no code location is impacted — the workspace configures no ` + + `code group (SPEC 9.2)`, + ); +} + +/** + * The category expectation TEST-SPEC T6.5-14 states, against the baseline + * committed before the move: the created file's root `changed` by addition + * alone, carrying no other category; the clean-boundary moved subtree, the + * referenced sibling leaf, and the third module's nodes carrying none; the + * origin root `changed` (its own content lost a child) with T6.2-3's + * two-sided `descendant-changed` tolerated, attributed to the moved node. + */ +async function assertF14Categories( + product: ProductBinding, + workspace: TestWorkspace, + base: string, + movedSubtree: readonly string[], + bystanders: readonly string[], + context: string, +): Promise<void> { + const label = `${context} \`impact --base <pre-move ref> --json\``; + const report = decodeImpactReport( + await runJson( + product, + workspace, + ["impact", "--base", base, "--json"], + label, + ), + label, + ); + const movedRoot = `${F14_CREATED}#m`; + const originating = [F14_ORIGIN, F14_CREATED, movedRoot]; + assertImpactPins( + report, + [F14_ORIGIN, F14_CREATED, ...movedSubtree, ...bystanders], + [ + { + identity: F14_CREATED, + required: ["changed"], + changedWithin: originating, + }, + { + identity: F14_ORIGIN, + required: ["changed"], + changedWithin: originating, + optional: [{ category: "descendant-changed", within: [movedRoot] }], + }, + ...movedSubtree.map((identity) => ({ identity, required: [] })), + ...bystanders.map((identity) => ({ identity, required: [] })), + ], + `${label} — the created file's root is \`changed\` by addition alone, ` + + `carrying no other category (an added node receives none through its ` + + `own hashes; SPEC 5.6, P-5's convention); the clean-boundary moved ` + + `subtree carries none — the moved node's metadataHash its target's ` + + `canonical identity, preserved, and its effectiveHash the target's, ` + + `unchanged (SPEC 5.5; T6.2-3); the referenced sibling leaf and the ` + + `third module's nodes carry none`, + ); +} + +/** Arm (i): a created target needing no declaration. */ +async function runF14LocalArm(product: ProductBinding): Promise<void> { + const context = "T6.5-14 (no declaration)"; + await withWorkspace( + { [F14_ORIGIN]: F14_LOCAL_ORIGIN_BEFORE }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + + await expectExit( + product, + workspace, + [...F14_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#m specs/new.mdx#m\` onto an absent ` + + `target path — created (SPEC 6.5)`, + ); + + const actual = await readSourceText(workspace, F14_CREATED, context); + let deviation = ""; + if (actual === `\n${F14_LOCAL_CREATED}`) { + deviation = "a leading empty line was added"; + } else if (actual === `${F14_LOCAL_CREATED}\n`) { + deviation = "a trailing empty line was added"; + } else if (actual === F14_LOCAL_MOVED_TEXT) { + deviation = "no terminator follows the moved text"; + } + await assertFileBytes( + workspace.path(F14_CREATED), + F14_LOCAL_CREATED, + `${context}: ${F14_CREATED} as the move created it — ` + + (deviation === "" ? "" : `${deviation}; `) + + `needing no declaration (the moved text's one reference is local ` + + `to its subtree and the ID is kept, so nothing in it is rewritten), ` + + `the created content is exactly the moved text followed by U+000A: ` + + `no leading empty line, no trailing one, a terminator present ` + + `(SPEC 6.5; H-4, normalizing nothing)`, + ); + await assertSpecDerives( + workspace, + F14_CREATED, + "the created content derives exactly when the moved text alone at a " + + "line's start would", + context, + ); + await assertFileBytes( + workspace.path(F14_ORIGIN), + F14_LOCAL_ORIGIN_AFTER, + `${context}: ${F14_ORIGIN} after the move — the moved construct ` + + `deleted in place, its merged empty line dropped with its ` + + `terminator, the blank line before it kept, the sibling untouched ` + + `(SPEC 6.5, 3)`, + ); + + await assertEdgeSets( + product, + workspace, + { depends: F14_LOCAL_DEPENDS, embeds: [] }, + `the moved subtree's local reference reported under the new ` + + `identities, ${F14_CREATED}#m.b to ${F14_CREATED}#m.a`, + context, + ); + await assertCleanAfterMove( + product, + workspace, + "the kept local reference resolves within the created file", + context, + ); + await assertF14Categories( + product, + workspace, + base, + [`${F14_CREATED}#m`, `${F14_CREATED}#m.a`, `${F14_CREATED}#m.b`], + [`${F14_ORIGIN}#s`], + context, + ); + }, + ); +} + +/** Arm (ii): a created target needing exactly two declarations. */ +async function runF14DeclarationsArm(product: ProductBinding): Promise<void> { + const context = "T6.5-14 (two declarations)"; + await withWorkspace( + { [F14_ORIGIN]: F14_DECL_ORIGIN_BEFORE, [F14_THIRD]: F14_THIRD_STAGED }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await workspace.gitInit(); + const base = await workspace.gitCommitAll("pre-move baseline"); + + await expectExit( + product, + workspace, + [...F14_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#m specs/new.mdx#m\` onto an absent ` + + `target path — created with the two declarations it needs (SPEC 6.5)`, + ); + + // The two unpinned identifiers, read off the rewritten references. + const actual = await readSourceText(workspace, F14_CREATED, context); + const originRoot = f14ReferenceRoot( + actual, + F14_REWRITTEN_DEPENDS, + '`d={<O>.s}` (the local `d={"s"}` converted to imported form)', + context, + ); + const thirdRoot = f14ReferenceRoot( + actual, + F14_REWRITTEN_EMBEDS, + "`{text(<X>.q)}` (the embedding re-rooted at the created file's own binding)", + context, + ); + for (const [role, root] of [ + ["origin-module", originRoot], + ["third-module", thirdRoot], + ] as const) { + if (MDX_RESERVED_NAMES.includes(root)) { + fail( + `${context}: the ${role} declaration binds \`${root}\`, one of the ` + + `compiler-provided names an added import in a spec source may ` + + `not bind (SPEC 6.5, 2.1)`, + ); + } + } + if (originRoot === thirdRoot) { + fail( + `${context}: both added declarations bind \`${originRoot}\` — the ` + + `identifiers added to one file must be distinct (SPEC 6.5, 2.1, ` + + `14.15)`, + ); + } + + // The whole file, composed byte-exactly for each order the product + // may fix: each declaration followed by U+000A, then one further + // U+000A, then the moved text (its references rooted at the two + // bindings) followed by U+000A. + const declarations = [ + `import ${originRoot} from "${canonicalSpecifier("specs", F14_ORIGIN_MODULE)}"`, + `import ${thirdRoot} from "${canonicalSpecifier("specs", F14_THIRD_MODULE)}"`, + ]; + const movedText = f14ImportedMovedLines(originRoot, thirdRoot).join("\n"); + const candidates = [declarations, [...declarations].reverse()].map( + (order) => `${order.join("\n")}\n\n${movedText}\n`, + ); + if (!candidates.includes(actual)) { + const deviation = f14DeclDeviation(actual, declarations, movedText); + fail( + `${context}: ${F14_CREATED} as the move created it — ` + + (deviation === "" ? "" : `${deviation}; `) + + `a created target's content is fixed: the declarations it needs ` + + `(\`${declarations[0] ?? ""}\` and \`${declarations[1] ?? ""}\`, ` + + `in either order), each followed by U+000A, then one further ` + + `U+000A — the empty line ending the ESM block — then the moved ` + + `text and its terminator (SPEC 6.5; H-4, normalizing nothing)\n` + + ` actual: ${JSON.stringify(actual)}\n` + + ` expected: ${JSON.stringify(candidates[0])}\n` + + ` or: ${JSON.stringify(candidates[1])}`, + ); + } + await assertSpecDerives( + workspace, + F14_CREATED, + "the empty line ends the ESM block, so the content derives exactly " + + "when the moved text alone at a line's start would", + context, + ); + await assertFileBytes( + workspace.path(F14_ORIGIN), + F14_DECL_ORIGIN_AFTER, + `${context}: ${F14_ORIGIN} after the move — the moved construct ` + + `deleted in place with its merged empty line, and the \`X\` ` + + `import, its only use gone with the moved text, removed with 6.5's ` + + `exact extent (its emptied line dropped with its terminator, the ` + + `blank line that followed kept), the sibling untouched (SPEC 6.5, 3)`, + ); + await assertFileBytes( + workspace.path(F14_THIRD), + F14_THIRD_SOURCE, + `${context}: ${F14_THIRD} after the move — the third module, whose ` + + `node is embedded but not moved, untouched (SPEC 6.5; H-4)`, + ); + + await assertEdgeSets( + product, + workspace, + { depends: F14_DECL_DEPENDS, embeds: F14_DECL_EMBEDS }, + `the moved node's converted \`d\` reference reported to ` + + `${F14_ORIGIN}#s and its re-rooted embedding to ${F14_THIRD}#q, ` + + `under its new identity`, + context, + ); + await assertCleanAfterMove( + product, + workspace, + "every rewritten reference resolves through the created file's " + + "bindings, the fresh identifiers colliding with nothing (14.15)", + context, + ); + await assertF14Categories( + product, + workspace, + base, + [`${F14_CREATED}#m`], + [`${F14_ORIGIN}#s`, F14_THIRD, `${F14_THIRD}#q`], + context, + ); + }, + ); +} + +const T6_5_14 = defineProductTest({ + id: "T6.5-14", + title: + "created target file's fixed content: a section moved onto an absent target path creates the file with content 6.5 fixes instead of leaving chosen — byte-asserted with the fresh identifiers and the declarations' order alone unpinned: needing no declaration (the moved text's one reference local to its subtree, the ID kept), the created file is exactly the moved text followed by U+000A (a leading empty line, a trailing one, or no terminator failing); needing declarations — the moved text carrying a local `d` reference to a sibling leaf outside the subtree, converted to imported form through the created file's declaration of the origin module, and a `{text(X.q)}` embedding through the origin's binding of a third module — it is exactly `import <O> from \"./a.xspec\"` and `import <X> from \"./x.xspec\"` in either order, each followed by U+000A, then U+000A, then the moved text with its references rooted at `<O>` and `<X>`, followed by U+000A (no empty line, two, the moved text first, or the declarations elsewhere failing), the identifiers distinct and none of the compiler-provided names; the created content derives (S-9), `build` and `check` are clean, `query edges` reports the moved node's edges under its new identity, the origin and the third module are byte-equal to their composed expectations, and against a baseline committed before the move the created file's root is `changed` by addition alone, carrying no other category, the clean-boundary moved subtree and the referenced sibling leaf carrying none (SPEC 6.5, 6.4, 2.1, 3, 5.5, 5.6; H-4)", + run: async (product) => { + await runF14LocalArm(product); + await runF14DeclarationsArm(product); + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-13 Admissible offsets, the line-start preference, and composition in +// pre-operation coordinates — arms (a) through (l), the entry's variants +// included, in the table A13_ARMS. +// --------------------------------------------------------------------------- + +const A13_ORIGIN = "specs/a.mdx"; +const A13_TARGET = "specs/b.mdx"; +const A13_THIRD = "specs/x.mdx"; +const A13_THIRD_MODULE = "specs/x.xspec"; +const A13_THIRD_SOURCE = ['<S id="a">', "A text.", "</S>", ""].join("\n"); +/** + * The third module as staged, `a` alone — a ledger record (S-9's + * before-any-product clause; helpers/staged-mdx.ts) shared by every site + * staging these bytes, identical bytes being one record staged at each: + * T6.5-13's cross-file arms and (g), T6.5-19's (a), and T6.6-4's tie-break + * restagings at `specs/x.mdx`; T6.5-16's (g) family, T6.5-17's arms, and + * their T6.6-3 and T14-7 restagings at `specs/x.mdx` (`R16_G_THIRD_STAGED`, + * `M17_X_STAGED` below); T6.5-15's `specs/A.mdx`; T6.5-23(g)'s + * `specs/x.mdx` and `specs/k.mdx` (section-6.5-v.ts). The string stays for + * the untouched-file compares and the S-9 vectors. + */ +export const A13_THIRD_STAGED = stagedMdx( + "T6.5-13/T6.5-15/T6.5-16/T6.5-17/T6.5-19/T6.5-23/T6.6-3/T6.6-4/T14-7 the module holding a alone (specs/x.mdx; T6.5-15's specs/A.mdx; T6.5-23's specs/k.mdx)", + A13_THIRD_SOURCE, +); +/** The canonical specifier the receiving file's added declaration carries (SPEC 6.5, 2.1). */ +const A13_THIRD_SPECIFIER = canonicalSpecifier("specs", A13_THIRD_MODULE); +/** (g)'s second third module, `specs/y.mdx`, and its canonical specifier. */ +const A13_FOURTH = "specs/y.mdx"; +const A13_FOURTH_MODULE = "specs/y.xspec"; +const A13_FOURTH_SPECIFIER = canonicalSpecifier("specs", A13_FOURTH_MODULE); +/** The target file's module — the declaration (i) and (k) assert is of it. */ +const A13_TARGET_MODULE = "specs/b.xspec"; +const A13_TARGET_SPECIFIER = canonicalSpecifier("specs", A13_TARGET_MODULE); + +/** + * The moved section of the cross-file arms — a clean-boundary flow-form + * section (T6.2-3's shape: opening and closing tags each alone on their + * lines) whose one body line carries, beside prose, the `{text(X.a)}` + * embedding through the origin's binding of the third module (T6.5-10's + * shape) — spelled with the ID it bears and the binding its embedding is + * rooted at. Exported for T6.5-23(g), which moves it (section-6.5-v.ts). + */ +export function a13MovedLines(id: string, root: string): string[] { + return [`<S id="${id}">`, `Moved {text(${root}.a)} text.`, "</S>"]; +} + +// The cross-file origin: the `X` declaration heads the file and the sibling +// `s` keeps a use of `X`, so the declaration stays after the move (SPEC 6.5: +// an import is removed exactly when no occurrence uses a binding of its +// after the rewrite) and the origin's post-move bytes are the deletion's +// alone — the construct deleted in place, its three lines joined into one +// empty line dropped with its terminator, the blank line before it kept +// (SPEC 6.5, 3). +const A13_ORIGIN_HEAD: readonly string[] = [ + 'import X from "./x.xspec"', + "", + '<S id="s">', + "Sib {text(X.a)} text.", + "</S>", + "", +]; +const A13_ORIGIN_BEFORE = [ + ...A13_ORIGIN_HEAD, + ...a13MovedLines("m", "X"), + "", +].join("\n"); +const A13_ORIGIN_AFTER = [...A13_ORIGIN_HEAD, ""].join("\n"); +/** The cross-file origin as staged — one record (S-9) for T6.5-13's cross-file arms, T6.5-19's (a), T6.5-23's (g) (section-6.5-v.ts), and T6.6-4's tie-break restagings. */ +export const A13_ORIGIN_STAGED = stagedMdx( + "T6.5-13/T6.5-19/T6.5-23/T6.6-4 specs/a.mdx the cross-file origin", + A13_ORIGIN_BEFORE, +); + +/** 6.5's exact spelling of the declaration the receiving file needs (the third module's unless `specifier` says otherwise). */ +function a13Declaration( + ident: string, + specifier: string = A13_THIRD_SPECIFIER, +): string { + return `import ${ident} from "${specifier}"`; +} + +/** A zero-length preview edit at `offset` (SPEC 6.6: an insertion point). */ +function a13At(cls: PreviewEditClass, offset: number): PreviewEdit { + return { class: cls, range: { start: offset, end: offset } }; +} + +/** A preview edit spanning `[start, end)` in pre-operation coordinates. */ +function a13Span( + cls: PreviewEditClass, + start: number, + end: number, +): PreviewEdit { + return { class: cls, range: { start, end } }; +} + +/** One other file's expected post-move bytes, with the reason they are what they are. */ +export interface A13Other { + readonly rel: string; + readonly bytes: string; + readonly reason: string; +} + +/** + * The category expectation of an arm asserting `impact --base <pre-move + * ref> --json` (SPEC 5.6, 6.2): every current identity the report may name, + * the pins, and the reason the enumeration is what it is. + */ +interface A13ImpactExpectation { + readonly known: readonly string[]; + readonly pins: readonly CategoryPin[]; + readonly reason: string; +} + +/** One byte-asserted arm of T6.5-13. */ +interface A13Arm { + /** The entry's letter, e.g. `(a)`, `(b, terminated)`. */ + readonly key: string; + /** The placement 6.5 fixes here, for diagnoses. */ + readonly summary: string; + /** The staging beside the configuration. */ + readonly files: Readonly<Record<string, InitialFileContents>>; + /** The move's argv. */ + readonly argv: readonly string[]; + /** The receiving file: the one whose post-move bytes the arm composes value-blind. */ + readonly receiving: string; + /** + * The canonical specifiers of the declarations the receiving file gains — + * one per module it lacks a binding of (SPEC 6.5); `compose` takes the + * fresh identifiers they bind in this order. Empty where none is needed. + */ + readonly added: readonly string[]; + /** + * The receiving file's admissible post-move byte forms given the fresh + * identifiers, in `added`'s order: one form, or one per order the + * implementation may fix among several added declarations (SPEC 6.5). + */ + readonly compose: (idents: readonly string[]) => readonly string[]; + /** Every other file's expected post-move bytes. */ + readonly others: readonly A13Other[]; + /** The admissible edit lists of the receiving file's preview entry, each in 12.7's order. */ + readonly previewEdits: readonly (readonly PreviewEdit[])[]; + /** The receiving file's root: its own text before and after, and whether its ownHash changes. */ + readonly root: { + readonly identity: string; + readonly ownTextBefore: string; + readonly ownTextAfter: string; + readonly ownHashChanges: boolean; + }; + /** `build --json` exactly `{"findings": []}` over the staging too, not exit 0 alone. */ + readonly cleanBefore?: boolean; + /** + * The receiving file's `view` (SPEC 11.4): `imports` empty before the + * move and, after it, exactly the added declarations — each `name` the + * identifier read off the bytes, each `target` the module's source, each + * range the declaration's own characters. + */ + readonly viewImports?: boolean; + /** The `impact --base` expectation against a baseline committed before the move. */ + readonly impact?: (idents: readonly string[]) => A13ImpactExpectation; + /** Repeat the move in a fresh workspace and require byte-identical receiving bytes (H-6). */ + readonly repeatable?: boolean; +} + +/** The source each added declaration's canonical specifier designates (SPEC 2.1, 11.4). */ +const A13_MODULE_SOURCES: Readonly<Record<string, string>> = { + [A13_THIRD_SPECIFIER]: A13_THIRD, + [A13_FOURTH_SPECIFIER]: A13_FOURTH, + [A13_TARGET_SPECIFIER]: A13_TARGET, +}; + +/** The shared origin after a cross-file arm's move (SPEC 6.5, 3). */ +const A13_ORIGIN_REASON = + "the moved construct deleted in place, its emptied line dropped with its " + + "terminator, the blank line before it kept, the `X` declaration kept for " + + "the sibling's use"; +/** A module embedded but not moved (SPEC 6.5). */ +const A13_BYSTANDER_REASON = + "the third module, embedded but not moved, untouched"; + +/** A cross-file arm: `m` moved out of the shared origin into a target file. */ +function a13CrossArm(spec: { + readonly key: string; + readonly summary: string; + /** The target as staged: a ledger record (S-9). */ + readonly target: StagedMdx; + readonly newId: string; + readonly compose: (ident: string) => string; + readonly previewEdits: readonly PreviewEdit[]; + readonly ownTextBefore: string; + readonly ownTextAfter: string; + readonly ownHashChanges: boolean; +}): A13Arm { + return { + key: spec.key, + summary: spec.summary, + files: { + [A13_ORIGIN]: A13_ORIGIN_STAGED, + [A13_THIRD]: A13_THIRD_STAGED, + [A13_TARGET]: spec.target, + }, + argv: ["move", `${A13_ORIGIN}#m`, `${A13_TARGET}#${spec.newId}`], + receiving: A13_TARGET, + added: [A13_THIRD_SPECIFIER], + compose: (idents) => [spec.compose(idents[0] ?? "")], + others: [ + { rel: A13_ORIGIN, bytes: A13_ORIGIN_AFTER, reason: A13_ORIGIN_REASON }, + { rel: A13_THIRD, bytes: A13_THIRD_SOURCE, reason: A13_BYSTANDER_REASON }, + ], + previewEdits: [spec.previewEdits], + root: { + identity: A13_TARGET, + ownTextBefore: spec.ownTextBefore, + ownTextAfter: spec.ownTextAfter, + ownHashChanges: spec.ownHashChanges, + }, + }; +} + +/** + * A same-file arm's admissible preview entries: the origin deletion + * spanning the moved construct's own characters extended to `deletionEnd` + * (over the terminator of a line the deletion leaves empty, when one is + * there to drop), the `id-rewrite` of the moved section's own `id` + * attribute nested inside it, and the target insertion zero-length at the + * insertion point — or at the deletion's start: the two pre-operation + * offsets a collapsed deletion makes one composed position, the bytes the + * same either way (T6.6-4(b)'s latitude) — each list in 12.7's order. + */ +function a13SameFileEdits( + source: string, + movedText: string, + idAttribute: string, + insertion: number, + deletionEnd: number, +): readonly (readonly PreviewEdit[])[] { + const start = source.indexOf(movedText); + const idStart = start + movedText.indexOf(idAttribute); + const deletion = a13Span("origin-deletion", start, deletionEnd); + const rewrite = a13Span("id-rewrite", idStart, idStart + idAttribute.length); + return [ + [deletion, rewrite, a13At("target-insertion", insertion)], + [a13At("target-insertion", start), deletion, rewrite], + ]; +} + +// (a): the target holds two admissible offsets — the file's end after its +// final terminator (a line start) and the end of the `</S>` line before that +// terminator (mid-line, which would leave a kept empty line). +const A13_A_TARGET = ['<S id="p">', "x", "</S>", ""].join("\n"); +// (a)'s sibling: the only line-start admissible offset is the start of the +// empty line after the `</S>` line — an added line at the file's end would +// follow a paragraph line as paragraph text, and one at the start of the +// `trailing` line would absorb it. +const A13_A_SIBLING_TARGET = ['<S id="p">', "x", "</S>", "", "trailing"].join( + "\n", +); +// (b): 6.5's worked self-closing case, the tag's line lacking a terminator. +const A13_B_TARGET = '<S id="p" />'; +// (c): no final terminator, so the file's end — mid-line — is the only +// admissible offset (offset 0 absorbs the tag line; every other line start +// lies inside the section). +const A13_C_TARGET = ['<S id="p">', "x", "</S>"].join("\n"); +// (d): a paragraph line alone, unterminated; the target parent of a +// top-level `new-id` is the root. +const A13_D_TARGET = "para"; +// The targets as staged — ledger records (S-9); the strings stay for the +// offsets the preview edits pin. Identical bytes are one record: (a)'s +// target is T6.5-17's arms' and T6.5-16's (g) control's `specs/b.mdx` too +// (`M17_TARGET_SOURCE`, `R16_G_CONTROL_TARGET` below; T6.6-3 and T14-7 +// restage T6.5-17's arms), (b)'s is (g)'s, and (d)'s two variants are +// T6.5-16's top-level (g) twin's (restaged by T6.6-3 and T14-7); (b), (d), +// and (g) are T6.6-4's tie-break restagings. +const A13_A_STAGED = stagedMdx( + "T6.5-13/T6.5-16/T6.5-17/T6.6-3/T14-7 specs/b.mdx the flow-form parent p holding x", + A13_A_TARGET, +); +const A13_A_SIBLING_STAGED = stagedMdx( + "T6.5-13 arm (a, sibling) specs/b.mdx", + A13_A_SIBLING_TARGET, +); +const A13_B_STAGED = stagedMdx( + "T6.5-13/T6.6-4 arms (b) and (g) specs/b.mdx", + A13_B_TARGET, +); +const A13_B_TERMINATED_STAGED = stagedMdx( + "T6.5-13 arm (b, terminated) specs/b.mdx", + `${A13_B_TARGET}\n`, +); +const A13_C_STAGED = stagedMdx("T6.5-13 arm (c) specs/b.mdx", A13_C_TARGET); +const A13_D_STAGED = stagedMdx( + "T6.5-13/T6.5-16/T6.6-3/T6.6-4/T14-7 specs/b.mdx the paragraph line para, unterminated", + A13_D_TARGET, +); +const A13_D_TERMINATED_STAGED = stagedMdx( + "T6.5-13/T6.5-16/T6.6-3/T6.6-4/T14-7 specs/b.mdx the paragraph line para, terminated", + `${A13_D_TARGET}\n`, +); + +/** (a)/(c)'s composition: the moved text before `</S>`, then the declaration. */ +function a13ComposeIntoP(ident: string, tail: readonly string[]): string { + return [ + '<S id="p">', + "x", + ...a13MovedLines("p.n", ident), + "</S>", + a13Declaration(ident), + ...tail, + ].join("\n"); +} + +/** (b)'s composition, both variants: the paired form around the moved text, then the declaration. */ +function a13ComposeSelfClosing(ident: string): string { + return [ + '<S id="p">', + ...a13MovedLines("p.n", ident), + "</S>", + a13Declaration(ident), + "", + ].join("\n"); +} + +/** (d)'s composition, both variants: `para`, the moved text, the declaration. */ +function a13ComposeAfterPara(ident: string): string { + return ["para", ...a13MovedLines("n", ident), a13Declaration(ident), ""].join( + "\n", + ); +} + +// (e): a same-file move inside a text-position parent; the origin +// deletion's range ends exactly at the insertion point. +const A13_E_MOVED = '<S id="p.m">x</S>'; +const A13_E_BEFORE = ['foo <S id="p">', `${A13_E_MOVED}</S> baz`, ""].join( + "\n", +); +const A13_E_AFTER = [ + 'foo <S id="p">', + '<S id="p.n">x</S>', + "</S> baz", + "", +].join("\n"); +const A13_E_STAGED = stagedMdx("T6.5-13 arm (e) specs/a.mdx", A13_E_BEFORE); +// (f): a same-file top-level move of the file's last section, whose +// unterminated last line the deletion drops. +const A13_F_MOVED = ['<S id="m">', "y", "</S>"].join("\n"); +const A13_F_BEFORE = ['<S id="a">x</S>', A13_F_MOVED].join("\n"); +const A13_F_AFTER = ['<S id="a">x</S>', '<S id="n">', "y", "</S>", ""].join( + "\n", +); +const A13_F_STAGED = stagedMdx("T6.5-13 arm (f) specs/a.mdx", A13_F_BEFORE); + +// (g): two declarations added to one spec file — the moved text carries +// embeddings through two third-module bindings the target lacks, into (b)'s +// self-closing target; the origin's sibling keeps a use of each, so both +// declarations stay there (SPEC 6.5). +const A13_FOURTH_SOURCE = ['<S id="b">', "B text.", "</S>", ""].join("\n"); +/** The fourth module as staged, `b` alone — one record (S-9) for (g) and its T6.6-4 restaging, T6.5-17's (b) and its T6.6-3 and T14-7 restagings (`M17_Y_STAGED` below), T6.5-15's `specs/B.mdx`, and T14-7's destination-spelling arm's `specs/b.mdx` (section-14.ts, by import). */ +export const A13_FOURTH_STAGED = stagedMdx( + "T6.5-13/T6.5-15/T6.5-17/T6.6-3/T6.6-4/T14-7 the module holding b alone (specs/y.mdx; T6.5-15's specs/B.mdx; T14-7's destination-spelling arm specs/b.mdx)", + A13_FOURTH_SOURCE, +); + +/** The moved section of (g): one body line embedding through both third-module bindings. */ +function a13TwiceMovedLines(id: string, x: string, y: string): string[] { + return [ + `<S id="${id}">`, + `Moved {text(${x}.a)} and {text(${y}.b)} text.`, + "</S>", + ]; +} + +const A13_G_ORIGIN_HEAD: readonly string[] = [ + 'import X from "./x.xspec"', + 'import Y from "./y.xspec"', + "", + '<S id="s">', + "Sib {text(X.a)} and {text(Y.b)} text.", + "</S>", + "", +]; +const A13_G_ORIGIN_BEFORE = stagedMdx( + "T6.5-13/T6.6-4 arm (g) specs/a.mdx", + [...A13_G_ORIGIN_HEAD, ...a13TwiceMovedLines("m", "X", "Y"), ""].join("\n"), +); +const A13_G_ORIGIN_AFTER = [...A13_G_ORIGIN_HEAD, ""].join("\n"); + +/** + * (g)'s composition: the paired form around the moved text, the appended + * closing tag, then the two declarations contiguous — in either order, the + * one the implementation fixes read from the result (SPEC 6.5). + */ +function a13ComposeTwoDeclarations( + idents: readonly string[], +): readonly string[] { + const [x = "", y = ""] = idents; + const declarations = [ + a13Declaration(x), + a13Declaration(y, A13_FOURTH_SPECIFIER), + ]; + return [declarations, [...declarations].reverse()].map((order) => + [ + '<S id="p">', + ...a13TwiceMovedLines("p.n", x, y), + "</S>", + ...order, + "", + ].join("\n"), + ); +} + +// (i): the third line-start kind 6.2 names — the origin deletion drops the +// file's unterminated last line, so the composed file's end, line 1's +// terminator preceding it, is a line start; the declaration asserted is +// the origin's own, its `d={"m"}` converting to imported form through the +// target module's declaration (T6.5-8's origin direction). The target is an +// existing file ending in a terminator, needing no declaration. +const A13_I_MOVED = ['<S id="m">', "y", "</S>"].join("\n"); +const A13_I_ORIGIN_BEFORE = ['<S id="a" d={"m"} />', A13_I_MOVED].join("\n"); +const A13_EXISTING_TARGET = '<S id="k">z</S>\n'; +const A13_I_TARGET_AFTER = `${A13_EXISTING_TARGET}${A13_I_MOVED}\n`; +const A13_I_LINE_2 = A13_I_ORIGIN_BEFORE.indexOf(A13_I_MOVED); +const A13_I_REFERENCE = A13_I_ORIGIN_BEFORE.indexOf('"m"'); +const A13_I_ORIGIN_STAGED = stagedMdx( + "T6.5-13 arm (i) specs/a.mdx", + A13_I_ORIGIN_BEFORE, +); +/** The existing target as staged, `k` alone — one record (S-9) for (i), (k), T6.5-19's (b), and T6.5-16's arms staging `R16_K` (below) whole, with their T6.6-3 and T14-7 restagings. */ +const A13_K_STAGED = stagedMdx( + "T6.5-13/T6.5-16/T6.5-19/T6.6-3/T14-7 specs/b.mdx holding k alone", + A13_EXISTING_TARGET, +); + +/** (i)'s composition: the converted reference, then the target module's declaration at the composed file's end. */ +function a13ComposeOriginDeclaration( + idents: readonly string[], +): readonly string[] { + const [t = ""] = idents; + return [ + [ + `<S id="a" d={${t}.m} />`, + a13Declaration(t, A13_TARGET_SPECIFIER), + "", + ].join("\n"), + ]; +} + +// (l): the admissibility exclusion for lines that were no ESM block's +// before the edit — the target's first two lines are one paragraph (an ESM +// block cannot interrupt a paragraph, 14.20), so its `import B …` line is +// content: `specs/B.mdx` is absent and the pre-move `build` is clean all +// the same; the indented twin heads the file with a paragraph line likewise. +const A13_L_HEAD: readonly string[] = [ + "// note", + 'import B from "./B.xspec"', + "", +]; +const A13_L_INDENTED_HEAD: readonly string[] = [ + ' import B from "./B.xspec"', + "", +]; + +/** A paragraph-headed (l) arm: the head's lines, then (a)'s target; the root's own text the head verbatim. */ +function a13ParagraphHeadedArm( + key: string, + summary: string, + head: readonly string[], +): A13Arm { + const target = [...head, '<S id="p">', "x", "</S>", ""].join("\n"); + const ownText = `${head.join("\n")}\n`; + return { + ...a13CrossArm({ + key, + summary, + target: stagedMdx(`T6.5-13 arm ${key} ${A13_TARGET}`, target), + newId: "p.n", + compose: (ident) => [...head, a13ComposeIntoP(ident, [""])].join("\n"), + previewEdits: [ + a13At("target-insertion", target.indexOf("</S>")), + a13At("import-addition", target.length), + ], + ownTextBefore: ownText, + ownTextAfter: ownText, + ownHashChanges: false, + }), + cleanBefore: true, + viewImports: true, + }; +} + +// (h)/(j): a dependent of the target root in another file — `d={B}`, the +// bare imported module (T2.2-2) — so the root's `changed` cascades to it as +// `upstream-changed` (SPEC 5.6) though the move touched no requirement of +// that file; the move leaves the file untouched (the root's identity, its +// target, is unchanged). +const A13_DEPENDENT = "specs/dep.mdx"; +const A13_DEPENDENT_SOURCE = [ + 'import B from "./b.xspec"', + "", + '<S id="k" d={B}>', + "Dep text.", + "</S>", + "", +].join("\n"); +const A13_DEPENDENT_STAGED = stagedMdx( + "T6.5-13 arms (h) and (j) specs/dep.mdx", + A13_DEPENDENT_SOURCE, +); + +// (h): the mid-line addition off the file's end — the end of the `</S>` +// line before its terminator is the only admissible offset (offset 0 would +// absorb the tag's line, every line start from `x` through `</S>` lies +// inside `p`, the start of the `trailing` line would absorb that line, and +// the file's end follows a paragraph line). +const A13_H_TARGET = ['<S id="p">', "x", "</S>", "trailing"].join("\n"); +const A13_H_STAGED = stagedMdx("T6.5-13 arm (h) specs/b.mdx", A13_H_TARGET); + +// (j): the file's-end addition whose ended line is kept — the closing +// tag's line a flow line holding a tag and an expression container alone +// (14.20; T3-3's constraint), the root embedding its own child, no cycle +// (5.3); own text carries the embedding fully expanded (SPEC 1.6): `p`'s +// subtree text before the move, `p`'s grown subtree text — the moved body +// line with `{text(<X>.a)}` replaced by `a`'s subtree text — then the added +// terminator after it. +const A13_J_TARGET = ['<S id="p">', "x", '</S>{text("p")}'].join("\n"); +const A13_J_STAGED = stagedMdx("T6.5-13 arm (j) specs/b.mdx", A13_J_TARGET); +const A13_A_SUBTREE_TEXT = "A text.\n"; +const A13_J_OWN_BEFORE = "x\n"; +const A13_J_OWN_AFTER = `x\nMoved ${A13_A_SUBTREE_TEXT} text.\n\n`; + +/** (j)'s composition: the moved text before the closing tag, the tag's line ended, then the declaration. */ +function a13ComposeBeforeEmbeddingLine(ident: string): string { + return [ + '<S id="p">', + "x", + ...a13MovedLines("p.n", ident), + '</S>{text("p")}', + a13Declaration(ident), + "", + ].join("\n"); +} + +/** + * (h)/(j)'s category expectation against the pre-move baseline (SPEC 5.6, + * 6.2): the target root `changed` beside `p` and the origin parent, with + * their ordinary cascades — the root, `p`'s ancestor, `descendant-changed` + * attributed to `p` (5.6: every ancestor of a `changed` node; the arrived + * child tolerated beside it, T6.2-3's bound) and, where the root embeds + * `p` through `{text("p")}`, `upstream-changed` attributed to `p` (5.6: a + * dependency-edge target's effectiveHash changed; `embeds` is an edge + * kind, 5.2); `p` and the origin parent at most `descendant-changed` + * within the child that arrived or departed (T6.2-3's two-sided + * tolerance); the other-file dependent `upstream-changed` with the root + * among the nodes it is attributed to, and its own root `upstream-changed` + * likewise, as a dependent's ancestor (5.6); the moved node, the sibling, + * and the third module's nodes carrying none; no other node `changed`. + */ +function a13DependentImpact(rootEmbedsChild: boolean): A13ImpactExpectation { + const parent = `${A13_TARGET}#p`; + const moved = `${A13_TARGET}#p.n`; + const dependent = `${A13_DEPENDENT}#k`; + const originating = [A13_TARGET, parent, A13_ORIGIN, moved]; + return { + known: [ + A13_ORIGIN, + `${A13_ORIGIN}#s`, + A13_TARGET, + parent, + moved, + A13_THIRD, + `${A13_THIRD}#a`, + A13_DEPENDENT, + dependent, + ], + pins: [ + { + identity: A13_TARGET, + required: [ + "changed", + "descendant-changed", + ...(rootEmbedsChild ? ["upstream-changed" as const] : []), + ], + changedWithin: originating, + attributed: [ + { + category: "descendant-changed", + within: [parent, moved], + mustInclude: [parent], + }, + ...(rootEmbedsChild + ? [ + { + category: "upstream-changed" as const, + within: originating, + mustInclude: [parent], + }, + ] + : []), + ], + }, + { + identity: parent, + required: ["changed"], + changedWithin: [parent, moved], + optional: [{ category: "descendant-changed", within: [moved] }], + }, + { + identity: A13_ORIGIN, + required: ["changed"], + changedWithin: [A13_ORIGIN, moved], + optional: [{ category: "descendant-changed", within: [moved] }], + }, + { + identity: dependent, + required: ["upstream-changed"], + attributed: [ + { + category: "upstream-changed", + within: originating, + mustInclude: [A13_TARGET], + }, + ], + }, + { + identity: A13_DEPENDENT, + required: ["upstream-changed"], + attributed: [ + { + category: "upstream-changed", + within: originating, + mustInclude: [A13_TARGET], + }, + ], + }, + { identity: moved, required: [] }, + { identity: `${A13_ORIGIN}#s`, required: [] }, + { identity: A13_THIRD, required: [] }, + { identity: `${A13_THIRD}#a`, required: [] }, + ], + reason: + "the target root is `changed` — the addition, elsewhere than at a " + + "line's start, splits a line of its own content (SPEC 6.2) — beside " + + "`p` and the origin parent `changed` with their ordinary cascades " + + "(T6.2-3) — the root `descendant-changed` attributed to `p`, its " + + "`changed` child, and, where it embeds `p`, `upstream-changed` (SPEC " + + "5.6, 5.2); its other-file dependent (`d={B}`) is `upstream-changed` " + + "with the root among the originating nodes it is attributed to, as " + + "is that dependent's own root, a dependent's ancestor (SPEC 5.6); " + + "the moved node carries no category — its runs, its embedding's " + + "canonical identity, and its metadataHash unchanged (5.5); no other " + + "node is `changed`", + }; +} + +/** (h)/(j): a cross-file arm with the dependent file staged beside and the category expectation asserted. */ +function a13DependentArm( + spec: Parameters<typeof a13CrossArm>[0], + rootEmbedsChild: boolean, +): A13Arm { + const arm = a13CrossArm(spec); + return { + ...arm, + files: { ...arm.files, [A13_DEPENDENT]: A13_DEPENDENT_STAGED }, + others: [ + ...arm.others, + { + rel: A13_DEPENDENT, + bytes: A13_DEPENDENT_SOURCE, + reason: + "the dependent file untouched — its `d={B}` names the target " + + "root, whose identity the move keeps", + }, + ], + impact: () => a13DependentImpact(rootEmbedsChild), + }; +} + +// (k): the removal-side line start — a third spec source `specs/c.mdx` +// referencing the moved section through its origin-module binding, the +// declaration the block's only line and the file's unterminated last: the +// reference re-roots to the target module, the declaration's last use is +// gone, and the removal drops the unterminated last line, so over the +// composed text the file's end — line 1's terminator preceding it — is a +// line start. The moved section is local to its subtree, beside a sibling; +// neither the origin nor the existing target needs a declaration. +const A13_SPEC_C = "specs/c.mdx"; +const A13_K_ORIGIN_HEAD: readonly string[] = [ + '<S id="s">', + "Sib text.", + "</S>", + "", +]; +const A13_K_MOVED = ['<S id="m">', "Moved text.", "</S>"].join("\n"); +const A13_K_ORIGIN_BEFORE = stagedMdx( + "T6.5-13 arm (k) specs/a.mdx", + [...A13_K_ORIGIN_HEAD, A13_K_MOVED, ""].join("\n"), +); +const A13_K_ORIGIN_AFTER = [...A13_K_ORIGIN_HEAD, ""].join("\n"); +const A13_K_TARGET_AFTER = `${A13_EXISTING_TARGET}${A13_K_MOVED}\n`; +const A13_K_C_BEFORE = [ + '<S id="q" d={A.m} />', + 'import A from "./a.xspec"', +].join("\n"); +const A13_K_C_LINE_2 = A13_K_C_BEFORE.indexOf("import A"); +const A13_K_C_REFERENCE = A13_K_C_BEFORE.indexOf("A.m"); +const A13_K_C_STAGED = stagedMdx("T6.5-13 arm (k) specs/c.mdx", A13_K_C_BEFORE); + +/** (k)'s composition: the re-rooted reference, then the target module's declaration at the composed file's end. */ +function a13ComposeThirdFileDeclaration( + idents: readonly string[], +): readonly string[] { + const [t = ""] = idents; + return [ + [ + `<S id="q" d={${t}.m} />`, + a13Declaration(t, A13_TARGET_SPECIFIER), + "", + ].join("\n"), + ]; +} + +/** + * (k)'s category expectation (SPEC 5.6, 6.2, 5.5): `c.mdx`'s root keeps + * its own content and `q` carries no category — its `d` target's canonical + * identity, mapped through the journal, and its effectiveHash unchanged; + * the two parents, the origin's and the target's roots, `changed` with + * their ordinary cascades (T6.2-3); the clean-boundary moved node, the + * sibling, and the target's existing child carrying none. + */ +function a13ThirdFileImpact(): A13ImpactExpectation { + const moved = `${A13_TARGET}#m`; + return { + known: [ + A13_ORIGIN, + `${A13_ORIGIN}#s`, + A13_TARGET, + `${A13_TARGET}#k`, + moved, + A13_SPEC_C, + `${A13_SPEC_C}#q`, + ], + pins: [ + { + identity: A13_ORIGIN, + required: ["changed"], + changedWithin: [A13_ORIGIN, moved], + optional: [{ category: "descendant-changed", within: [moved] }], + }, + { + identity: A13_TARGET, + required: ["changed"], + changedWithin: [A13_TARGET, moved], + optional: [{ category: "descendant-changed", within: [moved] }], + }, + { identity: A13_SPEC_C, required: [] }, + { identity: `${A13_SPEC_C}#q`, required: [] }, + { identity: moved, required: [] }, + { identity: `${A13_ORIGIN}#s`, required: [] }, + { identity: `${A13_TARGET}#k`, required: [] }, + ], + reason: + "`c.mdx`'s root keeps its own content — the removal and the addition " + + "each drop a whole line (SPEC 6.2, 3) — and `q` carries no category, " + + "its `d` target's canonical identity, mapped through the journal, and " + + "its effectiveHash unchanged (5.5); the two parents, the origin's and " + + "the target's roots, are `changed` with their ordinary cascades " + + "(T6.2-3); no other node is `changed`", + }; +} + +const A13_ARMS: readonly A13Arm[] = [ + a13CrossArm({ + key: "(a)", + summary: + "the preference — of the target's two admissible offsets, the file's " + + "end after its final terminator (a line start) is taken over the end " + + "of the `</S>` line before that terminator (mid-line, which would " + + "leave a kept empty line): the moved text and its terminator inserted " + + "before `</S>`, the declaration plus U+000A appended after the final " + + "terminator, nothing else", + target: A13_A_STAGED, + newId: "p.n", + compose: (ident) => a13ComposeIntoP(ident, [""]), + previewEdits: [ + a13At("target-insertion", A13_A_TARGET.indexOf("</S>")), + a13At("import-addition", A13_A_TARGET.length), + ], + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, + }), + a13CrossArm({ + key: "(a, sibling)", + summary: + "the only line-start admissible offset is the start of the empty line " + + "after the `</S>` line — the file's end would follow a paragraph line " + + "as paragraph text, and the start of the `trailing` line would absorb " + + "it — so the declaration stands at that empty line's start, the empty " + + "line kept after it", + target: A13_A_SIBLING_STAGED, + newId: "p.n", + compose: (ident) => a13ComposeIntoP(ident, ["", "trailing"]), + previewEdits: [ + a13At("target-insertion", A13_A_SIBLING_TARGET.indexOf("</S>")), + a13At("import-addition", A13_A_SIBLING_TARGET.indexOf("\n\n") + 1), + ], + ownTextBefore: "\ntrailing", + ownTextAfter: "\ntrailing", + ownHashChanges: false, + }), + a13CrossArm({ + key: "(b)", + summary: + "6.5's worked self-closing case — an addition at offset 0 would absorb " + + "the tag's line into its ESM block, so the tag's end is the only " + + "admissible offset: the parent rewritten to the paired form, the moved " + + "text between its tags, the declaration after the appended closing " + + "tag, each preceded by the terminator a tag's `>` requires", + target: A13_B_STAGED, + newId: "p.n", + compose: a13ComposeSelfClosing, + previewEdits: [ + a13Span("target-parent-rewrite", 0, A13_B_TARGET.length), + a13At("import-addition", A13_B_TARGET.length), + a13At("target-insertion", A13_B_TARGET.length), + ], + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, + }), + a13CrossArm({ + key: "(b, terminated)", + summary: + "the self-closing case with a final terminator after the tag — the " + + "same bytes result, the addition at the file's end, a line start, " + + "taken over the tag's end", + target: A13_B_TERMINATED_STAGED, + newId: "p.n", + compose: a13ComposeSelfClosing, + previewEdits: [ + a13Span("target-parent-rewrite", 0, A13_B_TARGET.length), + a13At("target-insertion", A13_B_TARGET.length), + a13At("import-addition", A13_B_TARGET.length + 1), + ], + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, + }), + a13CrossArm({ + key: "(c)", + summary: + "the forced mid-line case — offset 0 absorbs the tag line and every " + + "other line start lies inside the section, so the file's end, " + + "mid-line, is the only admissible offset: the added terminator ends " + + "the `</S>` line, which drops as it did before, then the declaration " + + "and its terminator", + target: A13_C_STAGED, + newId: "p.n", + compose: (ident) => a13ComposeIntoP(ident, [""]), + previewEdits: [ + a13At("target-insertion", A13_C_TARGET.indexOf("</S>")), + a13At("import-addition", A13_C_TARGET.length), + ], + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, + }), + a13CrossArm({ + key: "(d)", + summary: + "a top-level `new-id` at the end of a file whose unterminated last " + + "line is a paragraph line — the moved text, preceded by a terminator, " + + "then the declaration at the same offset, admissible after the flow " + + "closing tag's line where the reverse order would make it paragraph " + + "text; no empty line between", + target: A13_D_STAGED, + newId: "n", + compose: a13ComposeAfterPara, + previewEdits: [ + a13At("import-addition", A13_D_TARGET.length), + a13At("target-insertion", A13_D_TARGET.length), + ], + ownTextBefore: "para", + ownTextAfter: "para\n", + ownHashChanges: true, + }), + a13CrossArm({ + key: "(d, terminated)", + summary: + "the terminated variant — the target insertion at a line start, so no " + + "terminator is added before the moved text, and the declaration's " + + "only line-start admissible offset is the file's end again (offset 0 " + + "would absorb `para` into the block; the end of the `para` line " + + "before its terminator would leave the declaration paragraph text)", + target: A13_D_TERMINATED_STAGED, + newId: "n", + compose: a13ComposeAfterPara, + previewEdits: [ + a13At("import-addition", A13_D_TARGET.length + 1), + a13At("target-insertion", A13_D_TARGET.length + 1), + ], + ownTextBefore: "para\n", + ownTextAfter: "para\n", + ownHashChanges: true, + }), + { + key: "(e)", + summary: + "the target insertion judged over the composed text — the origin " + + "deletion's range ends exactly at the insertion point, which line 1's " + + "terminator precedes once the deletion is composed, so no terminator " + + "is added; a product judging the pre-operation text (the point " + + "preceded by `>`) emits an empty line before the moved text, ending " + + "the paragraph with `p` unclosed", + files: { [A13_ORIGIN]: A13_E_STAGED }, + argv: ["move", `${A13_ORIGIN}#p.m`, `${A13_ORIGIN}#p.n`], + receiving: A13_ORIGIN, + added: [], + compose: () => [A13_E_AFTER], + others: [], + previewEdits: a13SameFileEdits( + A13_E_BEFORE, + A13_E_MOVED, + 'id="p.m"', + A13_E_BEFORE.indexOf("</S> baz"), + A13_E_BEFORE.indexOf(A13_E_MOVED) + A13_E_MOVED.length, + ), + root: { + identity: A13_ORIGIN, + ownTextBefore: "foo baz\n", + ownTextAfter: "foo baz\n", + ownHashChanges: false, + }, + }, + { + key: "(f)", + summary: + "an insertion at the end of a file whose unterminated last line the " + + "origin deletion drops — what the deletion leaves before the file's " + + "end is line 1's terminator, so none is added: T6.2-4's " + + "pure-in-effect final-position move", + files: { [A13_ORIGIN]: A13_F_STAGED }, + argv: ["move", `${A13_ORIGIN}#m`, `${A13_ORIGIN}#n`], + receiving: A13_ORIGIN, + added: [], + compose: () => [A13_F_AFTER], + others: [], + previewEdits: a13SameFileEdits( + A13_F_BEFORE, + A13_F_MOVED, + 'id="m"', + A13_F_BEFORE.length, + A13_F_BEFORE.length, + ), + root: { + identity: A13_ORIGIN, + ownTextBefore: "\n", + ownTextAfter: "\n", + ownHashChanges: false, + }, + }, + { + key: "(g)", + summary: + "two declarations added to one spec file — after the appended closing " + + "tag, U+000A, then the two declarations on contiguous lines, each " + + "followed by U+000A alone, one ESM block with no empty line between " + + "them, in an order the product fixes, the first preceded by the " + + "terminator the tag's `>` requires; the preview holds exactly two " + + "`import-addition` entries, one per added declaration, both " + + "zero-length at the tag's end and ordered before the " + + "`target-insertion` there", + files: { + [A13_ORIGIN]: A13_G_ORIGIN_BEFORE, + [A13_THIRD]: A13_THIRD_STAGED, + [A13_FOURTH]: A13_FOURTH_STAGED, + [A13_TARGET]: A13_B_STAGED, + }, + argv: ["move", `${A13_ORIGIN}#m`, `${A13_TARGET}#p.n`], + receiving: A13_TARGET, + added: [A13_THIRD_SPECIFIER, A13_FOURTH_SPECIFIER], + compose: a13ComposeTwoDeclarations, + others: [ + { + rel: A13_ORIGIN, + bytes: A13_G_ORIGIN_AFTER, + reason: + "the moved construct deleted in place, its emptied line dropped " + + "with its terminator, the blank line before it kept, both " + + "declarations kept for the sibling's uses", + }, + { rel: A13_THIRD, bytes: A13_THIRD_SOURCE, reason: A13_BYSTANDER_REASON }, + { + rel: A13_FOURTH, + bytes: A13_FOURTH_SOURCE, + reason: A13_BYSTANDER_REASON, + }, + ], + previewEdits: [ + [ + a13Span("target-parent-rewrite", 0, A13_B_TARGET.length), + a13At("import-addition", A13_B_TARGET.length), + a13At("import-addition", A13_B_TARGET.length), + a13At("target-insertion", A13_B_TARGET.length), + ], + ], + root: { + identity: A13_TARGET, + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, + }, + repeatable: true, + }, + a13DependentArm( + { + key: "(h)", + summary: + "the mid-line addition off the file's end — offset 0 would absorb " + + "the tag's line into the block, every line start from `x` through " + + "`</S>` lies inside `p`, the start of the `trailing` line would " + + "absorb that line, and the file's end follows a paragraph line, so " + + "the end of the `</S>` line before its terminator is the only " + + "admissible offset: the added terminator ends the `</S>` line, " + + "which drops as it did before, the declaration's line drops whole, " + + "and the `</S>` line's original terminator is left an empty line, " + + "kept", + target: A13_H_STAGED, + newId: "p.n", + compose: (ident) => a13ComposeIntoP(ident, ["", "trailing"]), + previewEdits: [ + a13At("target-insertion", A13_H_TARGET.indexOf("</S>")), + a13At("import-addition", A13_H_TARGET.indexOf("</S>") + 4), + ], + ownTextBefore: "trailing", + ownTextAfter: "\ntrailing", + ownHashChanges: true, + }, + false, + ), + { + key: "(i)", + summary: + "the third line-start kind — the deletion's range runs from the start " + + 'of line 2 to the file\'s end, leaving the converted `<S id="a" ' + + "d={<T>.m} />` line and its terminator; offset 0 would absorb the " + + "tag's line into the block, so the composed file's end — line 1's " + + "terminator preceding it, a line start — is taken with no terminator " + + "added: an insertion where the origin deletion's range ends reads " + + "what the deletion leaves", + files: { + [A13_ORIGIN]: A13_I_ORIGIN_STAGED, + [A13_TARGET]: A13_K_STAGED, + }, + argv: ["move", `${A13_ORIGIN}#m`, `${A13_TARGET}#m`], + receiving: A13_ORIGIN, + added: [A13_TARGET_SPECIFIER], + compose: a13ComposeOriginDeclaration, + others: [ + { + rel: A13_TARGET, + bytes: A13_I_TARGET_AFTER, + reason: + "the moved text appended at the file's end after its final " + + "terminator — a line start, so none is added before it — followed " + + "by its own, the ID kept", + }, + ], + previewEdits: [ + [ + a13Span("reference-rewrite", A13_I_REFERENCE, A13_I_REFERENCE + 3), + a13At("import-addition", A13_I_LINE_2), + a13Span("origin-deletion", A13_I_LINE_2, A13_I_ORIGIN_BEFORE.length), + ], + [ + a13Span("reference-rewrite", A13_I_REFERENCE, A13_I_REFERENCE + 3), + a13Span("origin-deletion", A13_I_LINE_2, A13_I_ORIGIN_BEFORE.length), + a13At("import-addition", A13_I_ORIGIN_BEFORE.length), + ], + ], + root: { + identity: A13_ORIGIN, + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: true, + }, + }, + a13DependentArm( + { + key: "(j)", + summary: + "the file's-end addition whose ended line is kept — offset 0 would " + + "absorb the tag's line into the block and every other line start " + + "lies inside `p`, so the file's end, mid-line, is the only " + + "admissible offset: the added terminator ends the " + + '`</S>{text("p")}` line, which is kept, its excised embedding ' + + "counting as remaining line content (1.6) so that the drop rule of " + + "3 spares it, unlike (c)'s bare `</S>` line", + target: A13_J_STAGED, + newId: "p.n", + compose: a13ComposeBeforeEmbeddingLine, + previewEdits: [ + a13At("target-insertion", A13_J_TARGET.indexOf("</S>")), + a13At("import-addition", A13_J_TARGET.length), + ], + ownTextBefore: A13_J_OWN_BEFORE, + ownTextAfter: A13_J_OWN_AFTER, + ownHashChanges: true, + }, + true, + ), + { + key: "(k)", + summary: + "the removal-side line start — `A.m` re-roots to `<T>.m`, `A`'s " + + "declaration, its last use gone, is removed with its unterminated " + + "last line, and over the composed text offset 0 would absorb the " + + "tag's line into the block, so the composed file's end — line 1's " + + "terminator preceding it, a line start — is the only admissible " + + "offset, taken with no terminator added: an insertion where a " + + "removal's range ends reads what the removal leaves", + files: { + [A13_ORIGIN]: A13_K_ORIGIN_BEFORE, + [A13_TARGET]: A13_K_STAGED, + [A13_SPEC_C]: A13_K_C_STAGED, + }, + argv: ["move", `${A13_ORIGIN}#m`, `${A13_TARGET}#m`], + receiving: A13_SPEC_C, + added: [A13_TARGET_SPECIFIER], + compose: a13ComposeThirdFileDeclaration, + others: [ + { + rel: A13_ORIGIN, + bytes: A13_K_ORIGIN_AFTER, + reason: + "the moved construct deleted in place, its emptied line dropped " + + "with its terminator, the blank line before it kept, the sibling " + + "untouched", + }, + { + rel: A13_TARGET, + bytes: A13_K_TARGET_AFTER, + reason: + "the moved text appended at the file's end after its final " + + "terminator — a line start, so none is added before it — followed " + + "by its own, the ID kept", + }, + ], + previewEdits: [ + [ + a13Span("reference-rewrite", A13_K_C_REFERENCE, A13_K_C_REFERENCE + 3), + a13At("import-addition", A13_K_C_LINE_2), + a13Span("import-removal", A13_K_C_LINE_2, A13_K_C_BEFORE.length), + ], + [ + a13Span("reference-rewrite", A13_K_C_REFERENCE, A13_K_C_REFERENCE + 3), + a13Span("import-removal", A13_K_C_LINE_2, A13_K_C_BEFORE.length), + a13At("import-addition", A13_K_C_BEFORE.length), + ], + ], + root: { + identity: A13_SPEC_C, + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, + }, + impact: () => a13ThirdFileImpact(), + }, + a13ParagraphHeadedArm( + "(l)", + "the admissibility exclusion for lines that were no ESM block's before " + + "the edit — offset 0 heads a block joining the paragraph's lines, " + + "deriving yet inadmissible; the start of line 2, the start of the " + + "empty line, and every mid-line offset of the paragraph leave the " + + 'added line paragraph text; every line start from `<S id="p">` on ' + + "absorbs the tag's line or lies inside `p`; so the file's end after " + + "the final terminator is the only admissible offset: the moved text " + + "and its terminator inserted before `</S>`, the declaration plus " + + "U+000A appended, the paragraph's bytes untouched", + A13_L_HEAD, + ), + a13ParagraphHeadedArm( + "(l, indented)", + 'the indented twin — ` import B from "./B.xspec"` heading the file ' + + "in the paragraph's place, a paragraph line likewise: offset 0 and " + + "the offset after its two spaces each head a block absorbing that " + + "line, deriving yet inadmissible, so the file's end is again the " + + "only admissible offset, the expectations the same", + A13_L_INDENTED_HEAD, + ), +]; + +// --------------------------------------------------------------------------- +// T6.6-4(e)'s tie-break stagings — the T6.5-13 entries TEST-SPEC T6.6-4 +// names for the 12.7 comparator's final tie-break, exported so that +// section-6.6.ts restages them byte for byte (the contracts above unchanged). +// --------------------------------------------------------------------------- + +/** An embedding of the moved text whose root binding the rewrite may change (SPEC 6.5). */ +export interface A13Embedding { + /** The occurrence's whole `{text(...)}` container as staged (SPEC 5.7). */ + readonly container: string; + /** The origin's binding the chain is rooted at. */ + readonly binding: string; +} + +/** + * One of T6.6-4(e)'s pinned tie-break stagings: (b), the self-closing + * target parent (`import-addition` and `target-insertion` both zero-length + * at the tag's end); (d), both variants (the same cross-class coincidence + * at the end of a paragraph-ended file); and (g), the same-class + * coincidence (two `import-addition` entries at one offset, their count the + * observation). Beside the T6.5-13 arm's staging and contracts it carries + * what T6.6-4 needs to compose the origin entry of the preview's plan + * (SPEC 6.6): the origin's path, the moved construct's spelling as staged, + * its `id` attribute, and the moved text's embeddings — in `added`'s + * order, the i-th rooted at the origin's binding of the i-th added module, + * so the i-th fresh identifier decides whether its spelling changes. + */ +export interface A13TieBreakArm { + readonly key: string; + readonly summary: string; + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly argv: readonly string[]; + readonly receiving: string; + readonly added: readonly string[]; + readonly compose: (idents: readonly string[]) => readonly string[]; + readonly others: readonly A13Other[]; + /** The receiving file's one admissible edit list, in 12.7's order. */ + readonly receivingEdits: readonly PreviewEdit[]; + /** The origin file — the move's first operand's path. */ + readonly origin: string; + /** The moved construct exactly as the origin stages it. */ + readonly movedConstruct: string; + /** The moved section's `id` attribute, e.g. `id="m"`. */ + readonly movedIdAttribute: string; + readonly embeddings: readonly A13Embedding[]; +} + +/** + * The T6.5-13 arm `key` names, joined with the origin geometry T6.6-4 + * composes from; a mismatch is a defect of this table, never a product + * verdict, so it throws a plain error. + */ +function a13TieBreakArm( + key: string, + movedLines: readonly string[], + embeddings: readonly A13Embedding[], +): A13TieBreakArm { + const arm = A13_ARMS.find((candidate) => candidate.key === key); + if (arm === undefined) { + throw new Error(`A13_TIE_BREAK_ARMS: no T6.5-13 arm is keyed ${key}`); + } + const [receivingEdits, ...more] = arm.previewEdits; + if (receivingEdits === undefined || more.length > 0) { + throw new Error( + `A13_TIE_BREAK_ARMS ${key}: a tie-break arm pins exactly one ` + + `admissible edit list for its receiving file (TEST-SPEC T6.6-4)`, + ); + } + const operand = arm.argv[1] ?? ""; + const hash = operand.indexOf("#"); + const origin = operand.slice(0, hash); + const movedConstruct = movedLines.join("\n"); + if ( + hash === -1 || + !stagedText(arm.files[origin] ?? "").includes(movedConstruct) + ) { + throw new Error( + `A13_TIE_BREAK_ARMS ${key}: the origin ${origin} must stage the moved ` + + `construct ${JSON.stringify(movedConstruct)}`, + ); + } + if (embeddings.length !== arm.added.length) { + throw new Error( + `A13_TIE_BREAK_ARMS ${key}: one embedding per added declaration, in ` + + `the order of the added declarations`, + ); + } + for (const embedding of embeddings) { + if (!movedConstruct.includes(embedding.container)) { + throw new Error( + `A13_TIE_BREAK_ARMS ${key}: the moved construct must hold the ` + + `embedding ${JSON.stringify(embedding.container)}`, + ); + } + } + return { + key: arm.key, + summary: arm.summary, + files: arm.files, + argv: arm.argv, + receiving: arm.receiving, + added: arm.added, + compose: arm.compose, + others: arm.others, + receivingEdits, + origin, + movedConstruct, + movedIdAttribute: `id="${operand.slice(hash + 1)}"`, + embeddings, + }; +} + +/** The cross-file arms' one embedding, rooted at the origin's `X` (SPEC 6.5). */ +const A13_TIE_BREAK_EMBEDDING: A13Embedding = { + container: "{text(X.a)}", + binding: "X", +}; + +export const A13_TIE_BREAK_ARMS: readonly A13TieBreakArm[] = [ + a13TieBreakArm("(b)", a13MovedLines("m", "X"), [A13_TIE_BREAK_EMBEDDING]), + a13TieBreakArm("(d)", a13MovedLines("m", "X"), [A13_TIE_BREAK_EMBEDDING]), + a13TieBreakArm("(d, terminated)", a13MovedLines("m", "X"), [ + A13_TIE_BREAK_EMBEDDING, + ]), + a13TieBreakArm("(g)", a13TwiceMovedLines("m", "X", "Y"), [ + A13_TIE_BREAK_EMBEDDING, + { container: "{text(Y.b)}", binding: "Y" }, + ]), +]; + +/** + * S-9's premise: a composed expectation derives under the stock MDX 3 + * grammar. A failure here is the arm's own defect — a harness error, never + * a product verdict. + */ +function a13AssertPremiseDerives( + text: string, + arm: A13Arm, + testId = "T6.5-13", +): void { + const verdict = deriveMdx(text); + if (verdict.derives) return; + throw new HarnessStagingError( + "mdx-derivability", + arm.receiving, + `${testId} ${arm.key}: the composed expectation does not derive under ` + + `the stock MDX 3 grammar (${verdict.reason}) — the arm's premise, not ` + + `a product verdict; the text reads ${JSON.stringify(text)}`, + ); +} + +/** + * The fresh identifiers the receiving file's added declarations bind, read + * off the declaration lines — for each lacked module exactly one line + * `import <X> from "<specifier>"`, its other characters T6.5-8's (SPEC 6.5, + * 2.1) — distinct from one another and none of the compiler-provided names. + * Exported for T6.6-4's tie-break stagings, which read them the same way. + */ +export function a13ReadAddedIdentifiers( + actual: string, + arm: Pick<A13Arm, "added" | "receiving">, + context: string, +): readonly string[] { + const idents: string[] = []; + for (const specifier of arm.added) { + const escaped = specifier.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + const pattern = new RegExp( + `^import ([A-Za-z_$][A-Za-z0-9_$]*) from "${escaped}"$`, + "gm", + ); + const matches = [...actual.matchAll(pattern)]; + const ident = matches.length === 1 ? matches[0]?.[1] : undefined; + if (ident === undefined) { + fail( + `${context}: ${arm.receiving} after the move holds exactly one added ` + + `declaration of the module "${specifier}" — a line of its own ` + + `spelled \`import <X> from "${specifier}"\`, single spaces, no ` + + `statement terminator, the specifier double-quoted in its ` + + `canonical spelling — the one binding the spellings rooted at it ` + + `(SPEC 6.5, 2.1); found ${String(matches.length)} in ${JSON.stringify(actual)}`, + ); + } + if (MDX_RESERVED_NAMES.includes(ident)) { + fail( + `${context}: the added declaration binds \`${ident}\`, one of the ` + + `compiler-provided names an added import in a spec source may not ` + + `bind (SPEC 6.5, 2.1)`, + ); + } + if (idents.includes(ident)) { + fail( + `${context}: the added declarations bind \`${ident}\` twice — each ` + + `binds a fresh identifier distinct from the others added there ` + + `(SPEC 6.5)`, + ); + } + idents.push(ident); + } + return idents; +} + +/** Name the headline deviation of a receiving file byte-equal to none of its composed forms. */ +function a13Deviation( + actual: string, + expected: readonly string[], + added: number, +): string { + if (added > 0 && actual.startsWith("import ")) { + return ( + "a declaration heads the file at offset 0 — an inadmissible offset: " + + "the ESM block it begins absorbs the line after it (14.20), and 6.5 " + + "takes the admissible line-start offset the arm names" + ); + } + if (expected.some((form) => actual === `${form}\n`)) { + return ( + "a kept empty line follows the declaration — the mid-line offset " + + "before the closing-tag line's terminator was taken over the " + + "line-start offset at the file's end" + ); + } + return ""; +} + +/** `query node <root>` decoded, for the root's own text and ownHash (SPEC 1.6, 5.5). */ +async function a13QueryRoot( + product: ProductBinding, + workspace: TestWorkspace, + identity: string, + context: string, +): Promise<NodeReport> { + const label = `${context} \`query node ${identity}\``; + const node = decodeNodeReport( + await runJson(product, workspace, ["query", "node", identity], label), + label, + ); + if (node.identity !== identity) { + fail( + `${label}: the report is about ${JSON.stringify(node.identity)}, not ` + + `the root ${JSON.stringify(identity)} (SPEC 1.5, 11.1)`, + ); + } + return node; +} + +/** The receiving file's entry of the move's preview (SPEC 6.6, 12.7). */ +async function a13PreviewEntry( + product: ProductBinding, + workspace: TestWorkspace, + arm: A13Arm, + context: string, +): Promise<PreviewFileEntry> { + const label = `${context} \`${arm.argv.join(" ")} --preview --json\``; + const report = decodePreviewReport( + await runJson( + product, + workspace, + [...arm.argv, "--preview", "--json"], + label, + ), + label, + ); + if (report.files === null) { + fail( + `${label}: a preview exiting 0 succeeds as the real operation would ` + + `and reports its \`files\` — \`null\` is a refused preview's form ` + + `(SPEC 6.6, 12.7); findings: ${JSON.stringify(report.findings)}`, + ); + } + const entry = report.files.find( + (candidate) => candidate.file === arm.receiving, + ); + if (entry === undefined) { + fail( + `${label}: \`files\` holds an entry for ${arm.receiving}, a file the ` + + `operation rewrites — its insertion or deletion` + + (arm.added.length > 0 ? " and import addition" : "") + + ` (SPEC 6.6, 12.7); got ` + + `[${report.files.map((candidate) => JSON.stringify(candidate.file)).join(", ")}]`, + ); + } + return entry; +} + +/** + * The preview's edits for the receiving file are exactly one of the arm's + * admissible lists: each class plus a range in current, pre-operation + * coordinates, the insertion points zero-length at the offsets the real + * operation then uses, in 12.7's order — class-name bytes breaking the + * tie between coinciding insertion points (SPEC 6.6, 12.7; T6.6-4(b)). + */ +function a13AssertPreviewEdits( + entry: PreviewFileEntry, + arm: A13Arm, + context: string, +): void { + const actual = entry.edits.map((edit) => ({ + class: edit.class, + range: { start: edit.range.start, end: edit.range.end }, + })); + const shown = JSON.stringify(actual); + if ( + arm.previewEdits.some((candidate) => JSON.stringify(candidate) === shown) + ) { + return; + } + fail( + `${context} preview: ${arm.receiving}'s edits are exactly ` + + arm.previewEdits + .map((candidate) => JSON.stringify(candidate)) + .join(" or ") + + ` — each class plus a range in current, pre-operation coordinates, ` + + `the insertion points zero-length at the offsets the real operation ` + + `then uses (the bytes above agree with them), ordered by range start, ` + + `then range end, then class-name bytes (SPEC 6.6, 12.7; T6.6-4(b)); ` + + `got ${shown}`, + ); +} + +/** + * The receiving file's `view` entry lists under `imports` exactly the + * declarations `idents` names (SPEC 11.4): none before the move — the + * paragraph's `import B …` line being content, no ESM block's (14.20) — + * and afterwards the added declaration alone, its range the declaration's + * own characters in the rewritten bytes, its name the fresh identifier, its + * target the module's source. + */ +async function a13AssertViewImports( + product: ProductBinding, + workspace: TestWorkspace, + arm: A13Arm, + idents: readonly string[], + actual: string, + context: string, + when: "before" | "after", +): Promise<void> { + const label = `${context} \`view ${arm.receiving}\` ${when} the move`; + const report = decodeViewReport( + await runJson(product, workspace, ["view", arm.receiving], label), + { text: false }, + label, + ); + const view = report.views.find( + (candidate) => candidate.file === arm.receiving, + ); + if (view === undefined) { + fail( + `${label}: \`views\` holds the requested file's view — a parseable ` + + `discovered spec source (SPEC 11.4); got ` + + `[${report.views.map((candidate) => JSON.stringify(candidate.file)).join(", ")}]`, + ); + } + const expected = idents.map((ident, index) => { + const specifier = arm.added[index] ?? ""; + const line = `import ${ident} from "${specifier}"`; + const start = actual.indexOf(line); + return { + range: { start, end: start + line.length }, + name: ident, + target: A13_MODULE_SOURCES[specifier] ?? "", + }; + }); + assertSameJson( + view.imports, + expected, + `${label}: \`imports\` lists ` + + (when === "before" + ? "no declaration — the file's `import B …` line is a paragraph's, " + + "content and no ESM block's (SPEC 14.20, 11.4; T3-1's grammar boundary)" + : "the added declaration alone — its range the declaration's own " + + "characters, its name the fresh identifier, its target the third " + + "module's source; the paragraph's line still content (SPEC 11.4, 6.5)"), + ); +} + +/** `impact --base <pre-move ref> --json` against the arm's pins (SPEC 5.6, 6.2). */ +async function a13AssertImpact( + product: ProductBinding, + workspace: TestWorkspace, + base: string, + expectation: A13ImpactExpectation, + context: string, +): Promise<void> { + const label = `${context} \`impact --base <pre-move ref> --json\``; + const report = decodeImpactReport( + await runJson( + product, + workspace, + ["impact", "--base", base, "--json"], + label, + ), + label, + ); + assertImpactPins( + report, + expectation.known, + expectation.pins, + `${label} — ${expectation.reason}`, + ); +} + +/** + * H-6: the order the implementation fixes among several added declarations + * is byte-identical across repeated runs — the move performed again in a + * fresh workspace of the same staging yields the same receiving bytes. + */ +async function a13AssertRepeatable( + product: ProductBinding, + arm: A13Arm, + first: string, +): Promise<void> { + const context = `T6.5-13 ${arm.key} (repeated run)`; + await withWorkspace(arm.files, async (workspace) => { + await buildOk(product, workspace, `${context} \`build\` over the staging`); + await expectExit( + product, + workspace, + [...arm.argv], + 0, + `${context} \`${arm.argv.join(" ")}\` — a performable move (SPEC 6.5)`, + ); + const again = await readSourceText(workspace, arm.receiving, context); + if (again !== first) { + fail( + `${context}: ${arm.receiving} after the same move in a fresh ` + + `workspace is byte-identical to the first run's — the order the ` + + `implementation fixes among the added declarations, and their ` + + `identifiers, are deterministic for a given operation and ` + + `workspace state (SPEC 6.5, 6.1; H-6)\n` + + ` first: ${JSON.stringify(first)}\n` + + ` again: ${JSON.stringify(again)}`, + ); + } + }); +} + +/** The placeholder identifiers the premise check composes with (any valid identifiers serve). */ +const A13_PLACEHOLDER_IDENTS: readonly string[] = ["X", "Y", "Z"]; + +/** + * Run one arm: preview, move, bytes, preview agreement, the root's own + * content, clean `check`/`build`, and the arm's `view` and `impact` + * expectations where it states them; returns the receiving file's bytes. + * `testId` heads the diagnoses (T6.5-19's arms share this runner). + */ +async function runA13Arm( + product: ProductBinding, + arm: A13Arm, + testId = "T6.5-13", +): Promise<string> { + const context = `${testId} ${arm.key}`; + for (const form of arm.compose( + A13_PLACEHOLDER_IDENTS.slice(0, arm.added.length), + )) { + a13AssertPremiseDerives(form, arm, testId); + } + return await withWorkspace(arm.files, async (workspace) => { + await buildOk(product, workspace, `${context} \`build\` over the staging`); + if (arm.cleanBefore === true) { + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context} \`build --json\` over the staging — clean: the target's ` + + `paragraph-headed \`import B …\` line is content, no declaration ` + + `of an absent module (SPEC 14.20, 12.1; T3-1's grammar boundary)`, + ); + } + let base = ""; + if (arm.impact !== undefined) { + await workspace.gitInit(); + base = await workspace.gitCommitAll("pre-move baseline"); + } + const before = await a13QueryRoot( + product, + workspace, + arm.root.identity, + context, + ); + if (before.ownText !== arm.root.ownTextBefore) { + fail( + `${context}: before the move, the root ${arm.root.identity}'s own ` + + `text is ${JSON.stringify(arm.root.ownTextBefore)} — its runs ` + + `outside the child constructs joined at the excision points, the ` + + `lines the removals leave empty dropped with their terminators ` + + `(SPEC 1.6, 3); \`query node\` reports ${JSON.stringify(before.ownText)}`, + ); + } + if (arm.viewImports === true) { + await a13AssertViewImports( + product, + workspace, + arm, + [], + "", + context, + "before", + ); + } + const entry = await a13PreviewEntry(product, workspace, arm, context); + await expectExit( + product, + workspace, + [...arm.argv], + 0, + `${context} \`${arm.argv.join(" ")}\` — a performable move (SPEC 6.5)`, + ); + + const actual = await readSourceText(workspace, arm.receiving, context); + const idents = a13ReadAddedIdentifiers(actual, arm, context); + const expected = arm.compose(idents); + for (const form of idents.length > 0 ? expected : []) { + const verdict = deriveMdx(form); + if (!verdict.derives) { + fail( + `${context}: composed with the identifiers the product bound, ` + + `${JSON.stringify(idents)}, the expected ${arm.receiving} does ` + + `not derive under the stock MDX 3 grammar (${verdict.reason}) — ` + + `an added declaration binds no admissible identifier (SPEC 6.5, ` + + `2.1, 14.20)`, + ); + } + } + if (!expected.includes(actual)) { + const deviation = a13Deviation(actual, expected, arm.added.length); + fail( + `${context}: ${arm.receiving} after the move — ` + + (deviation === "" ? "" : `${deviation}; `) + + `${arm.summary} (SPEC 6.5, 6.4, 3; H-4, normalizing nothing)\n` + + ` actual: ${JSON.stringify(actual)}\n` + + expected + .map((form) => ` expected: ${JSON.stringify(form)}`) + .join("\n"), + ); + } + for (const other of arm.others) { + await assertFileBytes( + workspace.path(other.rel), + other.bytes, + `${context}: ${other.rel} after the move — ${other.reason} (SPEC 6.5, 3; H-4)`, + ); + } + a13AssertPreviewEdits(entry, arm, context); + + const after = await a13QueryRoot( + product, + workspace, + arm.root.identity, + context, + ); + if (after.ownText !== arm.root.ownTextAfter) { + fail( + `${context}: after the move, the root ${arm.root.identity}'s own ` + + `text is ${JSON.stringify(arm.root.ownTextAfter)} — ` + + (arm.root.ownTextAfter === arm.root.ownTextBefore + ? "unchanged: an added line at a line's start is dropped whole, " + + "and a boundary line the drop rule decides as before " + + "contributes as before" + : "changed as 6.2's file's-end exception and its qualifier " + + "decide: the previously unterminated paragraph line, or the " + + "ended line whose excised embedding counts as content, kept " + + "with the added terminator, or the remainder line kept where " + + "the whole line was dropped") + + ` (SPEC 6.2, 1.6, 3); \`query node\` reports ${JSON.stringify(after.ownText)}`, + ); + } + const changed = after.hashes.ownHash !== before.hashes.ownHash; + if (changed !== arm.root.ownHashChanges) { + fail( + `${context}: the root ${arm.root.identity}'s ownHash ` + + (arm.root.ownHashChanges + ? "changes — the gained child reference enters its own content " + + "at its position (SPEC 5.5, 6.2)" + : "is unchanged — its own content sequence keeps the same runs " + + "around the same child references by canonical identity " + + "(SPEC 6.2, 5.5, 5.4)") + + `; before ${before.hashes.ownHash}, after ${after.hashes.ownHash}`, + ); + } + if (arm.viewImports === true) { + await a13AssertViewImports( + product, + workspace, + arm, + idents, + actual, + context, + "after", + ); + } + await assertCleanAfterMove(product, workspace, arm.summary, context); + if (arm.impact !== undefined) { + await a13AssertImpact( + product, + workspace, + base, + arm.impact(idents), + context, + ); + } + return actual; + }); +} + +const T6_5_13 = defineProductTest({ + id: "T6.5-13", + title: + "admissible offsets, the line-start preference, and composition in pre-operation coordinates: byte-asserted section-move arms whose receiving file is composed whole from 6.4/6.5 and 3, value-blind in the fresh identifier alone (read from the added declaration, whose other characters are T6.5-8's), the moved text carrying `{text(X.a)}` through the origin's binding of a third module the target lacks so that exactly `import <X> from \"./x.xspec\"` is added — (a) the preference: a target holding a line-start admissible offset (the file's end after its final terminator) and a mid-line one takes the line start, the declaration appended after the final terminator; its sibling, whose only line-start admissible offset is the start of an empty line after the `</S>` line, places the declaration there with the empty line kept; (b) the self-closing target parent, its line unterminated: the tag's end the only admissible offset, the result `<S id=\"p\">`, U+000A, the moved text, U+000A, `</S>`, U+000A, the declaration, U+000A, the preview's `target-parent-rewrite` spanning the tag and `target-insertion` and `import-addition` both zero-length at the tag's end in 12.7's tie-break order; with a final terminator the same bytes, the addition at the file's end; (c) the forced mid-line case, the file's end the only admissible offset, the added terminator ending the `</S>` line; (d) a top-level `new-id` at the end of a paragraph-ended file, terminated or not: `para`, U+000A, the moved text, U+000A, the declaration, U+000A, `target-insertion` and `import-addition` both zero-length at the file's byte length; (e) a same-file move in `foo <S id=\"p\">` / `<S id=\"p.m\">x</S></S> baz`, the insertion judged over the composed text so that no terminator is added; (f) a same-file top-level move of a file's last section whose unterminated last line the deletion drops, no terminator added; (g) two declarations added to (b)'s self-closing target — after the appended closing tag, U+000A, the two declarations contiguous, each followed by U+000A alone, in an order the product fixes, byte-identical across a repeated run (H-6), the preview holding exactly two `import-addition` entries zero-length at the tag's end before the `target-insertion` there; (h) the mid-line addition off the file's end — a target ending in an unterminated `trailing` paragraph line, the end of the `</S>` line before its terminator the only admissible offset, the `</S>` line's original terminator left an empty line, kept, the root's own text going from `trailing` to U+000A, `trailing` and its ownHash with it, `impact --base` against a commit made immediately before the move reporting the target root `changed` beside `p` and the origin parent, an other-file `d={B}` dependent `upstream-changed` with the root among its attribution, the moved node carrying no category, no other node `changed`; (i) the origin deletion dropping the file's unterminated last line — the origin's own `d={\"m\"}` converting to `d={<T>.m}` through the target module's declaration added at the composed file's end with no terminator, the `import-addition` at the deletion's start or the file's byte length, the origin root `changed` by its lost child alone, its own text empty before and after; (j) the file's-end addition whose ended `</S>{text(\"p\")}` line is kept, its excised embedding counting as content — the root's own text, the embedding fully expanded, gaining a trailing U+000A beside the moved text's arrival, its ownHash with it, `impact --base` reporting (h)'s enumeration; (k) the removal-side line start — a third file `<S id=\"q\" d={A.m} />`, U+000A, `import A from \"./a.xspec\"` with no final terminator, `A.m` re-rooted to `<T>.m`, `A`'s declaration removed with its unterminated last line and the target module's declaration added at the composed file's end, the preview's `reference-rewrite` spanning `A.m`, `import-removal` spanning line 2, `import-addition` at the removal's start or the file's byte length, the third file's root keeping its own content and `q` carrying no category, the two parents `changed`; (l) the admissibility exclusion for lines that were no ESM block's — a target headed by the paragraph `// note`, `import B from \"./B.xspec\"`, `specs/B.mdx` absent and the pre-move `build` clean, `view` listing no import: offset 0 heads a block joining the paragraph's lines, deriving yet inadmissible, so the declaration is appended at the file's end, the paragraph's bytes untouched, `view` listing under `imports` the added declaration alone, the root unchanged; its indented twin, ` import B from \"./B.xspec\"` heading the file, alike — each arm's preview offsets agreeing with the real bytes, the receiving root's own text and ownHash compared through `query node` before and after (unchanged in (a), (b), (c), (e), (f), (g), (k), (l); (d)'s, (h)'s, (i)'s, and (j)'s roots `changed`), `build` and `check` clean after each move, every composed form verified to derive (S-9) (SPEC 6.5, 6.4, 6.6, 6.2, 3, 1.6, 5.5, 5.6, 11.4, 12.7; H-4, H-6)", + run: async (product) => { + for (const arm of A13_ARMS) { + const bytes = await runA13Arm(product, arm); + if (arm.repeatable === true) { + await a13AssertRepeatable(product, arm, bytes); + } + } + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-15 Joint import removals over an ESM block +// --------------------------------------------------------------------------- +// +// SPEC 6.5 (import edits): in a spec source, whose ESM block the grammar +// bounds line-sensitively (14.20), the removals in one block are judged +// together, over the block as all of them would leave it. Where they would +// leave it headed by anything but a declaration at the start of its first +// line — a JavaScript comment or an indented declaration — the remaining +// declarations would derive as paragraph text, so the block's first +// declaration stays, whether or not any other declaration would remain, its +// binding unused (2.1) and no removal reported for it (6.6), and the others +// are removed; a block they would leave with no line at all, every line +// dropped (3), is headed by nothing, its first declaration removed with the +// rest. Every arm moves `specs/o.mdx#m`, whose subtree carries every use of +// the bindings said to lose theirs, into `specs/t.mdx#m`, a target already +// binding, under the origin's identifiers, each module the moved text +// references: the moved spellings are rooted at bindings the target holds +// (6.5), so no import is added and nothing is rewritten, and both files +// compose whole from 6.5 and 3 with no latitude. The origin's compiled +// Markdown (3) is read after the move from `specs/o.md`, emission on (7.3). + +const J15_ORIGIN = "specs/o.mdx"; +const J15_ORIGIN_MARKDOWN = "specs/o.md"; +const J15_TARGET = "specs/t.mdx"; +const J15_MOVE_ARGV = ["move", "specs/o.mdx#m", "specs/t.mdx#m"] as const; + +/** + * The module's configuration with Markdown emission on (SPEC 7.3, 13.2). A + * staged-source record: every arm after the first stages it in a workspace + * created after a product invocation (S-9's timing clause). + */ +const J15_EMIT_CONFIG = stagedTs( + "T6.5-15 xspec.config.ts — one spec group with Markdown emission", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`, +); + +/** `import <B> from "./<B>.xspec"` — 2.1's one permitted form. */ +function j15Declaration(binding: string): string { + return `import ${binding} from "./${binding}.xspec"`; +} + +const J15_A = j15Declaration("A"); +const J15_B = j15Declaration("B"); +const J15_C = j15Declaration("C"); + +/** The module `binding` names: `specs/A.mdx` holds the section `a`, and so on. */ +function j15Module(binding: string): string { + return `<S id="${binding.toLowerCase()}">\n${binding} text.\n</S>\n`; +} + +/** + * A module T6.5-13 stages too, byte for byte: its record is reused rather + * than registered twice (S-9), the generator held to the record's bytes — + * a mismatch is a defect of this table, never a product verdict. + */ +function j15SharedModule(binding: string, record: StagedMdx): StagedMdx { + if (record.source !== j15Module(binding)) { + throw new Error( + `T6.5-15 staging: specs/${binding}.mdx must hold the bytes of the ` + + `record ${JSON.stringify(record.name)}`, + ); + } + return record; +} + +/** + * The imported modules as staged — ledger records (S-9): `A` and `B` are + * T6.5-13's third and fourth modules, `C` this test's own. + */ +const J15_MODULES: Readonly<Record<string, StagedMdx>> = { + "specs/A.mdx": j15SharedModule("A", A13_THIRD_STAGED), + "specs/B.mdx": j15SharedModule("B", A13_FOURTH_STAGED), + "specs/C.mdx": stagedMdx( + "T6.5-15 specs/C.mdx the module holding c alone", + j15Module("C"), + ), +}; + +/** Lines each followed by U+000A (SPEC 3). */ +function j15Join(lines: readonly string[]): string { + return lines.map((line) => `${line}\n`).join(""); +} + +/** A section alone on its lines, its `d` value optional (SPEC 2.2, 1.1). */ +function j15Section(id: string, d: string | undefined, text: string): string[] { + return [`<S id="${id}"${d === undefined ? "" : ` d={${d}}`}>`, text, "</S>"]; +} + +/** + * The `import-removal` edit for line `index` of the staged block: the + * declaration's own characters plus the terminator of the line its deletion + * leaves empty — 6.5's extent, spanned whole as 6.6 has a removal's range + * span every byte its edit removes — in pre-operation coordinates. + */ +function j15Removal(block: readonly string[], index: number): PreviewEdit { + const before = j15Join(block.slice(0, index)); + const through = j15Join(block.slice(0, index + 1)); + return a13Span( + "import-removal", + Buffer.byteLength(before, "utf8"), + Buffer.byteLength(through, "utf8"), + ); +} + +interface J15Arm { + readonly key: string; + /** The joint judgment's outcome over this block, for the diagnosis. */ + readonly summary: string; + /** The origin's ESM block, line by line, as staged. */ + readonly block: readonly string[]; + /** Indices into `block` of the declarations the joint judgment removes. */ + readonly removed: readonly number[]; + /** The block as the removals leave it. */ + readonly blockAfter: readonly string[]; + /** + * The kept block's compiled Markdown lines (SPEC 3): each declaration + * removed by its own characters, its line dropped when left empty or + * whitespace-only, a JavaScript comment staying as content (T3-7). + */ + readonly compiledBlock: readonly string[]; + /** The kept section's `d` value: a use a binding keeps outside the moved subtree. */ + readonly keptUse?: string; + /** The moved section's `d` value: every use of the bindings losing theirs. */ + readonly movedUse: string; + /** The modules the moved text references, bound by the target under these identifiers. */ + readonly targetBindings: readonly string[]; +} + +const J15_ARMS: readonly J15Arm[] = [ + { + key: "(a) own-line comment between two declarations", + summary: + "both bindings lose their last use; removing both would leave the " + + "block headed by `// note`, a comment, so A's declaration stays " + + "byte-for-byte, its binding unused, and B's alone is removed with its " + + "line", + block: [J15_A, "// note", J15_B], + removed: [2], + blockAfter: [J15_A, "// note"], + compiledBlock: ["// note"], + movedUse: "[A.a, B.b]", + targetBindings: ["A", "B"], + }, + { + key: "(b) trailing comment on the first declaration's line", + summary: + "both bindings lose their last use; removing A's declaration alone " + + "from its line would leave ` // note` heading the block, a comment, " + + "so A's declaration stays byte-for-byte, its binding unused, and B's " + + "alone is removed with its line", + block: [`${J15_A} // note`, J15_B], + removed: [1], + blockAfter: [`${J15_A} // note`], + compiledBlock: [" // note"], + movedUse: "[A.a, B.b]", + targetBindings: ["A", "B"], + }, + { + key: "(c) indented second declaration, its binding still used", + summary: + "A loses its last use while B keeps one in the kept section; removing " + + "A's declaration would leave the block headed by the indented " + + "` import B …`, deriving as paragraph text, so A's declaration stays " + + "byte-for-byte, its binding unused, and nothing is removed", + block: [J15_A, ` ${J15_B}`], + removed: [], + blockAfter: [J15_A, ` ${J15_B}`], + compiledBlock: [], + keptUse: "B.b", + movedUse: "A.a", + targetBindings: ["A"], + }, + { + key: "(d) every declaration losing its use, no line left", + summary: + "both bindings lose their last use and the removals leave the block " + + "no line at all, so it is headed by nothing: every declaration is " + + "removed, the first included, both lines dropped with their " + + "terminators", + block: [J15_A, J15_B], + removed: [0, 1], + blockAfter: [], + compiledBlock: [], + movedUse: "[A.a, B.b]", + targetBindings: ["A", "B"], + }, + { + key: "(e) the control: the first declaration keeps its use", + summary: + "A keeps its use in the kept section while B and C lose theirs: B's " + + "and C's declarations are removed with their lines and the block is " + + "left headed by A at its first line's start, `// note` its second " + + "line", + block: [J15_A, J15_B, "// note", J15_C], + removed: [1, 3], + blockAfter: [J15_A, "// note"], + compiledBlock: ["// note"], + keptUse: "A.a", + movedUse: "[B.b, C.c]", + targetBindings: ["B", "C"], + }, +]; + +interface J15Staging { + readonly originBefore: string; + readonly originAfter: string; + readonly targetBefore: string; + readonly targetAfter: string; + /** The origin's compiled Markdown after the move (SPEC 3). */ + readonly compiled: string; + /** The origin's `import-removal` preview edits, in 12.7's order. */ + readonly removals: readonly PreviewEdit[]; +} + +/** + * Compose an arm's files whole (no latitude, H-4): the origin as staged — + * the block, an empty line, the kept section `k`, then the moved section + * `m`, each tag alone on its line — and as the joint judgment and the + * deletion leave it (the moved construct's emptied line dropped with its + * terminator, 3); the target as staged — its declarations, an empty line, + * its own section `t` using the same bindings — and with the moved text + * appended after its final terminator (6.5: the end of the file for a + * top-level `new-id`, a line-start offset, so no terminator precedes it). + */ +function j15Compose(arm: J15Arm): J15Staging { + const kept = j15Section("k", arm.keptUse, "K text."); + const moved = j15Section("m", arm.movedUse, "M text."); + const targetBefore = j15Join([ + ...arm.targetBindings.map((binding) => j15Declaration(binding)), + "", + ...j15Section("t", arm.movedUse, "T text."), + ]); + return { + originBefore: j15Join([...arm.block, "", ...kept, ...moved]), + originAfter: j15Join([...arm.blockAfter, "", ...kept]), + targetBefore, + targetAfter: targetBefore + j15Join(moved), + compiled: j15Join([...arm.compiledBlock, "", "K text."]), + removals: arm.removed.map((index) => j15Removal(arm.block, index)), + }; +} + +/** An arm with its composition, composed once at load, and its two staged files as ledger records (S-9). */ +interface J15ArmStaging { + readonly arm: J15Arm; + readonly staging: J15Staging; + readonly origin: StagedMdx; + readonly target: StagedMdx; +} + +const J15_STAGINGS: readonly J15ArmStaging[] = J15_ARMS.map((arm) => { + const staging = j15Compose(arm); + return { + arm, + staging, + origin: stagedMdx(`T6.5-15 ${arm.key} ${J15_ORIGIN}`, staging.originBefore), + target: stagedMdx(`T6.5-15 ${arm.key} ${J15_TARGET}`, staging.targetBefore), + }; +}); + +/** + * Every MDX text T6.5-15 stages or asserts as a move's result, for the S-9 + * self-test (test/self/s9-fixture-well-formedness.test.ts): the staged + * pre-move files are judged by the workspace builder as they are staged and + * the composed expectations before each move, so an expectation the stock + * MDX 3 grammar rejects would pin a text 6.5 refuses as a move's result. + */ +export const J15_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = J15_STAGINGS.flatMap(({ arm, staging }) => [ + [`T6.5-15 ${arm.key}: ${J15_ORIGIN} as staged`, staging.originBefore], + [`T6.5-15 ${arm.key}: ${J15_ORIGIN} after the move`, staging.originAfter], + [`T6.5-15 ${arm.key}: ${J15_TARGET} as staged`, staging.targetBefore], + [`T6.5-15 ${arm.key}: ${J15_TARGET} after the move`, staging.targetAfter], +]); + +/** A composed expectation must derive (S-9): a staging defect, never a verdict. */ +function j15AssertPremiseDerives(text: string, rel: string, arm: J15Arm): void { + const verdict = deriveMdx(text); + if (verdict.derives) return; + throw new HarnessStagingError( + "mdx-derivability", + rel, + `T6.5-15 ${arm.key}: the composed expectation for ${rel} does not ` + + `derive under the stock MDX 3 grammar (${verdict.reason}) — the arm's ` + + `premise, not a product verdict; the text reads ${JSON.stringify(text)}`, + ); +} + +/** + * The preview's `import-removal` edits for the origin — the class the entry + * pins — with their ranges, in the preview's order (SPEC 6.6, 12.7). + */ +async function j15PreviewRemovals( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<readonly PreviewEdit[]> { + const label = `${context} \`${J15_MOVE_ARGV.join(" ")} --preview --json\``; + const report = decodePreviewReport( + await runJson( + product, + workspace, + [...J15_MOVE_ARGV, "--preview", "--json"], + label, + ), + label, + ); + if (report.files === null) { + fail( + `${label}: a preview exiting 0 succeeds as the real operation would ` + + `and reports its \`files\` — \`null\` is a refused preview's form ` + + `(SPEC 6.6, 12.7); findings: ${JSON.stringify(report.findings)}`, + ); + } + const entry = report.files.find((candidate) => candidate.file === J15_ORIGIN); + if (entry === undefined) { + fail( + `${label}: \`files\` holds an entry for ${J15_ORIGIN}, the file the ` + + `operation deletes the section from (SPEC 6.6, 12.7); got ` + + `[${report.files.map((candidate) => JSON.stringify(candidate.file)).join(", ")}]`, + ); + } + return entry.edits + .filter((edit) => edit.class === "import-removal") + .map((edit) => a13Span("import-removal", edit.range.start, edit.range.end)); +} + +/** The removals as `[start, end)` ranges, for the messages. */ +function j15DescribeRemovals(removals: readonly PreviewEdit[]): string { + if (removals.length === 0) return "none"; + return removals + .map((edit) => `[${String(edit.range.start)}, ${String(edit.range.end)})`) + .join(", "); +} + +/** Which declarations the origin keeps or loses against the joint judgment. */ +function j15Deviation(actual: string, arm: J15Arm): string { + const notes: string[] = []; + for (const binding of ["A", "B", "C"]) { + const declaration = j15Declaration(binding); + if (!arm.block.some((line) => line.includes(declaration))) continue; + const stays = arm.blockAfter.some((line) => line.includes(declaration)); + const present = actual.includes(declaration); + if (present && !stays) { + notes.push( + `${binding}'s declaration is kept where the joint judgment removes it`, + ); + } else if (!present && stays) { + notes.push( + `${binding}'s declaration is removed where it stays` + + (arm.blockAfter.indexOf(arm.block[0] ?? "") === 0 && + arm.block[0]?.includes(declaration) === true + ? " (the block's first declaration, whose removal would leave " + + "the block headed by a comment or an indented declaration)" + : " (its binding still used)"), + ); + } + } + return notes.join("; "); +} + +async function runJ15Arm( + product: ProductBinding, + entry: J15ArmStaging, +): Promise<void> { + const { arm, staging } = entry; + const context = `T6.5-15 ${arm.key}`; + j15AssertPremiseDerives(staging.originAfter, J15_ORIGIN, arm); + j15AssertPremiseDerives(staging.targetAfter, J15_TARGET, arm); + await withWorkspace( + { + "xspec.config.ts": J15_EMIT_CONFIG, + [J15_ORIGIN]: entry.origin, + [J15_TARGET]: entry.target, + ...J15_MODULES, + }, + async (workspace) => { + // Premise: the staging is valid — every block well-formed, every + // import valid and used — so a later failure is the move's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging — every ESM block ` + + `well-formed, every import valid and used (SPEC 14.20, 2.1)`, + ); + const removals = await j15PreviewRemovals(product, workspace, context); + assertSameJson( + removals, + staging.removals, + `${context} preview: ${J15_ORIGIN}'s \`import-removal\` edits are ` + + `exactly ${j15DescribeRemovals(staging.removals)} — ` + + `${arm.summary}; each removal spans the declaration plus the ` + + `terminator of the line its deletion leaves empty, in ` + + `pre-operation coordinates, and none is reported for a ` + + `declaration that stays (SPEC 6.5, 6.6, 12.7, 3)`, + ); + await expectExit( + product, + workspace, + [...J15_MOVE_ARGV], + 0, + `${context} \`${J15_MOVE_ARGV.join(" ")}\``, + ); + const actual = await readSourceText(workspace, J15_ORIGIN, context); + if (actual !== staging.originAfter) { + const deviation = j15Deviation(actual, arm); + fail( + `${context}: ${J15_ORIGIN} after the move — ` + + (deviation === "" ? "" : `${deviation}; `) + + `${arm.summary}; the moved construct is deleted in place, its ` + + `emptied line dropped with its terminator, and every other byte ` + + `is unchanged (SPEC 6.5, 3, 2.1; H-4, normalizing nothing)\n` + + ` actual: ${JSON.stringify(actual)}\n` + + ` expected: ${JSON.stringify(staging.originAfter)}`, + ); + } + await assertFileBytes( + workspace.path(J15_TARGET), + staging.targetAfter, + `${context}: ${J15_TARGET} after the move — the moved text lands at ` + + `the end of the file plus U+000A with no preceding terminator ` + + `(the final line was terminated), its spellings rooted at the ` + + `bindings the target already holds under the origin's ` + + `identifiers, so no import is added and nothing is rewritten; ` + + `every other byte is unchanged (SPEC 6.5, 3; H-4)`, + ); + await assertCleanAfterMove( + product, + workspace, + "every kept declaration's binding is unused yet valid (2.1) and " + + "every moved spelling resolves through a binding the target holds", + context, + ); + await assertFileBytes( + workspace.path(J15_ORIGIN_MARKDOWN), + staging.compiled, + `${context}: the origin's compiled Markdown after the move — each ` + + `kept declaration removed by its own characters alone, its line ` + + `dropped when left empty or whitespace-only, a JavaScript comment ` + + `staying as content with its line, \`k\`'s tags removed with ` + + `their lines (SPEC 3, 2.7; T3-7)`, + ); + }, + ); +} + +const T6_5_15 = defineProductTest({ + id: "T6.5-15", + title: + "joint import removals over an ESM block: in a spec source the removals in one block are judged together, over the block as all of them would leave it — where they would leave it headed by a JavaScript comment or an indented declaration, the block's first declaration stays byte-for-byte, its binding unused and valid, no removal reported for it, while the others are removed with their lines; a block they would leave with no line at all loses its first declaration with the rest — byte-asserted arms each moving `specs/o.mdx#m`, whose subtree carries every use of the bindings said to lose theirs, into `specs/t.mdx#m`, a target already binding the referenced modules under the origin's identifiers (no import added, nothing rewritten): (a) `import A …`, `// note`, `import B …` on successive lines, both losing their last use — A stays, B is removed with its line, the preview reporting one `import-removal`, B's, none for A; (b) `import A … // note` above `import B …` — the same outcome; (c) `import A …` above the indented ` import B …`, A losing its last use and B keeping one in the kept section — A stays, nothing removed, no removal reported; (d) `import A …`, `import B …` both losing theirs, the removals leaving no line — every declaration removed, both lines dropped, two removals reported; (e) the control `import A …`, `import B …`, `// note`, `import C …`, A keeping its use, B and C losing theirs — B's and C's lines removed, the block left headed by A at its first line's start with `// note` its second line; the origin and the target each asserted byte-equal to expectations composed from 6.5 and 3, `check` and `build` clean after each move, and the origin's compiled Markdown asserted with the comment lines staying as content and the kept declarations removed by their own characters (SPEC 6.5, 3, 2.1, 6.6, 12.7, 14.20)", + run: async (product) => { + for (const entry of J15_STAGINGS) { + await runJ15Arm(product, entry); + } + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-16 `refused-invalid-rewrite` — the finding form, arms (a)–(i), the +// applicability arms, and the created-target arms +// --------------------------------------------------------------------------- +// +// SPEC 6.5 (validation and refusals): a section-form move whose exact edits +// would leave a rewritten file other than well-formed MDX (14.20) — the +// origin as its deletion leaves it, the target as its parent rewrite and +// insertion leave it or as its creation composes it — or a file the rewrite +// must add an import to holding no admissible offset, is refused: exit 1, +// nothing modified, and exactly one `refused-invalid-rewrite` finding per +// operation however many files or shapes it covers — locating the moved +// section's construct in the origin file (1.7) plus, for each addition no +// offset admits, every reference spelling rooted at its binding by its +// occurrence span (5.7; the (g) family and (i)), its `identities` the +// workspace-relative paths of the files concerned in byte order, its `path` +// null (SPEC 14, 12.7). The grammar reads the edited text line-sensitively: +// a tag alone on its line is a flow-position tag, which interrupts a +// paragraph and closes no text-position tag; a U+000B or U+000C — whitespace +// under 1.4, none to the grammar — keeps the tag it adjoins in text position +// at a line's start; and an ESM block absorbs the non-blank lines after it +// (SPEC 6.2, 6.5, 14.20). +// +// Each refused arm composes the would-be text of every rewritten file from +// 6.5's exact edits — the deletion with 3's line drops; the insertion +// immediately before the target parent's closing tag or at the file's end, +// U+000A after it and one before it when the point is not at a line start; +// the self-closing parent's paired-form rewrite — and verifies in the test +// that each concerned file's would-be text does not derive and every other +// rewritten file's does (S-9's `deriveMdx`; a `HarnessStagingError` either +// way, never a verdict), so the refusal stands on the stated ground alone; +// the pre-move workspace derives (the builder's S-9 check) and builds (the +// valid-workspace precondition, 6.4). Nothing modified is the whole-root +// byte snapshot compare around the command — sources, derived files, and +// the journal, absent or byte-unchanged. The controls the entry composes +// here — (c)'s in-line section into each text-position parent (T6.5-2's +// fourth geometry stages the first parent alone), (d)'s prose remainder, +// and (e)'s block-ending empty line — are performed and byte-asserted with +// `check` and `build` clean; those it names by ID — T6.2-3(a), (b), (c), +// and (e) — are that test's stagings. The preview twins are T6.6-3's. + +const R16_ORIGIN = "specs/a.mdx"; +const R16_TARGET = "specs/b.mdx"; +/** + * (e)'s origin: the entry spells the target's imported module `specs/A.mdx`, + * and `a.mdx` beside `A.mdx` would collide on a case-insensitive filesystem. + */ +const R16_E_ORIGIN = "specs/o.mdx"; +const R16_E_MODULE_SOURCE = "specs/A.mdx"; +/** + * A bystander section: the target's existing content and the origin's kept + * sibling — T6.5-13's existing target byte for byte, so a target holding it + * alone is staged as that one ledger record (S-9), `R16_K_STAGED`. + */ +const R16_K = A13_EXISTING_TARGET; +const R16_K_STAGED = A13_K_STAGED; +// U+000B and U+000C, built from code points (never escape spellings). +const R16_VT = String.fromCodePoint(0x000b); +const R16_FF = String.fromCodePoint(0x000c); +/** The flow-form section, its tags alone on their lines (arms (f)–(i) and the applicability arms). */ +const R16_FLOW_SECTION = '<S id="m">\nx\n</S>'; +/** The origin holding `k` then the flow-form section — one record (S-9) for (c)'s first shape, the collision arm, and the alone arms, with their T6.6-3 and T14-7 restagings. */ +const R16_FLOW_ORIGIN_STAGED = stagedMdx( + "T6.5-16/T6.6-3/T14-7 specs/a.mdx holding k then the flow-form section m", + `${R16_K}${R16_FLOW_SECTION}\n`, +); +/** The text-position parent of (c)'s first arm, as a whole target file. */ +const R16_TEXT_PARENT = 'foo <S id="p">bar</S> baz\n'; +/** That parent as staged — one record (S-9) for (c)'s first parent, (g) terminated, (i), and the alone arms, with their T6.6-3 and T14-7 restagings. */ +const R16_TEXT_PARENT_STAGED = stagedMdx( + "T6.5-16/T6.6-3/T14-7 specs/b.mdx the text-position parent, terminated", + R16_TEXT_PARENT, +); + +/** One expected `locations` entry: a file and a 1.7 byte range. */ +export interface R16Location { + readonly file: string; + readonly start: number; + readonly end: number; +} + +export interface R16RefusedArm { + readonly key: string; + /** Why the would-be text does not derive, for the diagnoses. */ + readonly summary: string; + /** The pre-move spec files, each deriving (the builder's S-9 check). */ + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly argv: readonly string[]; + /** The would-be text of each file the refusal concerns: verified underivable (S-9). */ + readonly illFormed: Readonly<Record<string, string>>; + /** The would-be text of every other rewritten file: verified to derive (S-9). */ + readonly wellFormed: Readonly<Record<string, string>>; + /** The finding's `identities`: the concerned files' paths in byte order (SPEC 14). */ + readonly identities: readonly string[]; + /** + * The finding's `locations` in 12.7's order: the moved section's construct + * range in the origin file (1.7), and, for an addition no offset admits, + * every reference spelling rooted at its binding (the (g) family, (i)). + */ + readonly locations: readonly R16Location[]; + /** Every other applicable reason's code, reported beside (the applicability arms). */ + readonly beside?: readonly string[]; + /** A beside reason's finding `path`, where the entry pins it (`refused-invalid-destination`, T14-7). */ + readonly besidePath?: Readonly<Record<string, string>>; + /** For a file holding no admissible offset for an addition it needs: the (g) family's premise. */ + readonly noOffset?: R16NoOffset; +} + +/** + * One offset the entry names for an addition no offset admits: the file + * with the declaration's line inserted there — U+000A after it, one before + * it when the offset is not at a line start (SPEC 6.5) — and whether the + * text derives. An underivable probe is inadmissible outright (14.20); a + * deriving one is inadmissible on 6.5's other grounds — the added line + * paragraph text after a paragraph line, no declaration, or a block joining + * lines that were no ESM block's before the edit — which the arm's comment + * reasons and no parse decides (T6.5-13 pins the same rule's admissible + * side by hand). + */ +export interface R16OffsetProbe { + readonly name: string; + readonly text: string; + readonly derives: boolean; +} + +/** + * The concerned file of an addition no offset admits, as every other edit + * of the rewrite leaves it — verified deriving (S-9), so the refusal's + * ground is the offsets alone — and the entry's named offsets probed. + */ +export interface R16NoOffset { + readonly file: string; + readonly composed: string; + readonly probes: readonly R16OffsetProbe[]; +} + +/** + * An arm refused for another reason alone — `refused-invalid-rewrite` not + * applicable, no would-be text existing to judge — exit 1, nothing + * modified, exactly the expected reasons reported (SPEC 6.5, 14). + */ +export interface R16AloneArm { + readonly key: string; + readonly summary: string; + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly argv: readonly string[]; + /** The report's codes, exactly, as a set. */ + readonly codes: readonly string[]; +} + +interface R16ControlArm { + readonly key: string; + /** What the performed move leaves, for the diagnoses. */ + readonly summary: string; + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly argv: readonly string[]; + /** Every rewritten file's bytes after the move, composed from 6.5 and 3 with no latitude. */ + readonly expected: Readonly<Record<string, string>>; + /** + * A receiving file whose added declaration binds a fresh identifier — + * 6.5's latitude — its bytes composed from the identifier read back off + * the one line `import <X> from "<specifier>"` (T6.5-13's reading). + */ + readonly added?: { + readonly rel: string; + readonly specifier: string; + readonly compose: (ident: string) => string; + }; + /** `impact --base <pre-move ref>` against pins (SPEC 5.6, 6.2), the baseline committed before the move. */ + readonly impact?: A13ImpactExpectation; +} + +/** The moved section's construct range (1.7): the bytes of `prefix` to its end. */ +function r16Construct( + file: string, + prefix: string, + construct: string, +): R16Location { + const start = Buffer.byteLength(prefix, "utf8"); + return { file, start, end: start + Buffer.byteLength(construct, "utf8") }; +} + +// (a) the `body</S>` variant: `foo <S id="m">`, then a space, a tab, or +// nothing, U+000A, `body</S>`, moved to top level — at a line's start its +// opening tag is a flow-position tag, which the text-position closing tag +// cannot close (SPEC 6.2); the deletion leaves `foo `, U+000A, deriving. The +// control is T6.2-3(c)'s U+000B/U+000C remainder. +function r16ArmA(name: string, ws: string): R16RefusedArm { + const moved = `<S id="m">${ws}\nbody</S>`; + const key = `(a) the body</S> variant, ${name} after its opening tag`; + return { + key, + summary: + `at the target's line start the opening tag \`<S id="m">\`, followed ` + + `by ${name}, is a flow-position tag, which the text-position closing ` + + `tag of \`body</S>\` cannot close (SPEC 6.2, 14.20)`, + files: { + [R16_ORIGIN]: stagedMdx( + `T6.5-16/T6.6-3/T14-7 ${key} ${R16_ORIGIN}`, + `foo ${moved}\n`, + ), + [R16_TARGET]: R16_K_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#m"], + illFormed: { [R16_TARGET]: `${R16_K}${moved}\n` }, + wellFormed: { [R16_ORIGIN]: "foo \n" }, + identities: [R16_TARGET], + locations: [r16Construct(R16_ORIGIN, "foo ", moved)], + }; +} + +// (b) the one-sided spellings of 6.2's worked three-line shape — the +// character among the whitespace following the opening tag, the closing tag +// then alone on its line at the destination, or preceding the closing tag, +// the opening tag then alone on its line there — the tag it adjoins staying +// in text position while the other is a flow-position tag, and neither +// closes the other (SPEC 6.2); at the origin the closing tag is followed by +// ` bar`, and the deletion leaves `foo bar`, U+000A, deriving. The +// both-sided and none-sided spellings are the movable controls T6.2-3(b) +// and (a). +function r16ArmB( + side: "opening" | "closing", + name: string, + ws: string, +): R16RefusedArm { + const moved = + side === "opening" + ? `<S id="m">${ws}\nbody\n</S>` + : `<S id="m">\nbody\n${ws}</S>`; + const key = `(b) the worked shape with ${name} ${side === "opening" ? "following its opening tag" : "preceding its closing tag"}`; + return { + key, + summary: + side === "opening" + ? `the ${name} following the opening tag keeps it in text position ` + + `at the target's line start while the closing tag, alone on its ` + + `line, is a flow-position tag that closes no text-position tag ` + + `(SPEC 6.2, 14.20)` + : `the opening tag alone on its line at the target is a flow-position ` + + `tag while the ${name} preceding the closing tag keeps that tag in ` + + `text position, closing no flow-position tag (SPEC 6.2, 14.20)`, + files: { + [R16_ORIGIN]: stagedMdx( + `T6.5-16/T6.6-3/T14-7 ${key} ${R16_ORIGIN}`, + `foo ${moved} bar\n`, + ), + [R16_TARGET]: R16_K_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#m"], + illFormed: { [R16_TARGET]: `${R16_K}${moved}\n` }, + wellFormed: { [R16_ORIGIN]: "foo bar\n" }, + identities: [R16_TARGET], + locations: [r16Construct(R16_ORIGIN, "foo ", moved)], + }; +} + +// (c) a section opening a flow-position tag — its tags alone on their +// lines; a self-closing tag; a single-line section whose line holds nothing +// outside its tags and expression containers, a flow line whose tags are +// flow-position tags (14.20; T3-3's constraint) — moved into a parent whose +// tags stand in text position: the moved text lands alone on its line, a +// flow line interrupting the paragraph that holds the parent's opening tag, +// which then closes nothing (SPEC 6.5, 6.2). The control is a single-line +// in-line section holding prose outside its tags, whose line is a paragraph +// continuation there (T6.5-2's fourth geometry). +interface R16MovedShape { + readonly name: string; + /** The construct as staged in the origin, alone on its line after `R16_K`. */ + readonly origin: string; + /** The construct re-identified under `p.n` (prefix replacement). */ + readonly moved: string; + /** The origin file as staged, `R16_K` then the construct — a ledger record (S-9), one per shape. */ + readonly staged: StagedMdx; +} + +/** + * A moved shape with its origin file as staged: `ids` the tests staging it + * (the refused shapes' T6.6-3 and T14-7 restagings included). The first + * shape's origin is the flow-form origin's record, passed in; a passed + * record must hold the composition's bytes (a defect of this table + * otherwise, never a verdict). + */ +function r16MovedShape( + ids: string, + name: string, + origin: string, + moved: string, + staged?: StagedMdx, +): R16MovedShape { + const source = `${R16_K}${origin}\n`; + if (staged !== undefined && staged.source !== source) { + throw new Error( + `T6.5-16 (c) staging: ${JSON.stringify(staged.name)} does not hold ` + + `the origin composed for ${name}`, + ); + } + return { + name, + origin, + moved, + staged: + staged ?? stagedMdx(`${ids} specs/a.mdx holding k then ${name}`, source), + }; +} + +const R16_C_SHAPES: readonly R16MovedShape[] = [ + r16MovedShape( + "T6.5-16/T6.6-3/T14-7", + "a flow-position section", + R16_FLOW_SECTION, + '<S id="p.n">\nx\n</S>', + R16_FLOW_ORIGIN_STAGED, + ), + r16MovedShape( + "T6.5-16/T6.6-3/T14-7", + "a self-closing section", + '<S id="m" />', + '<S id="p.n" />', + ), + r16MovedShape( + "T6.5-16/T6.6-3/T14-7", + "a single-line section holding nothing outside its tags", + '<S id="m"><S id="m.q" /></S>', + '<S id="p.n"><S id="p.n.q" /></S>', + ), +]; + +const R16_C_CONTROL_SHAPE: R16MovedShape = r16MovedShape( + "T6.5-16", + 'the in-line section `<S id="m">x</S>`', + '<S id="m">x</S>', + '<S id="p.n">x</S>', +); + +interface R16Parent { + readonly name: string; + /** The staged target file. */ + readonly source: string; + /** That file as staged — a ledger record (S-9), one per parent (the first parent's is `R16_TEXT_PARENT_STAGED`). */ + readonly staged: StagedMdx; + /** How 6.5's edits leave the target around the moved text. */ + readonly compose: (moved: string) => string; +} + +const R16_C_LATER_CLOSING_PARENT = 'foo <S id="p">bar\n</S> baz\n'; +const R16_C_SELF_CLOSING_PARENT = 'foo <S id="p" /> baz\n'; + +const R16_C_PARENTS: readonly R16Parent[] = [ + { + // The insertion point, before `</S>` after `bar`, is not at a line + // start: U+000A before the moved text and after it. + name: 'the text-position parent `foo <S id="p">bar</S> baz`', + source: R16_TEXT_PARENT, + staged: R16_TEXT_PARENT_STAGED, + compose: (moved) => `foo <S id="p">bar\n${moved}\n</S> baz\n`, + }, + { + // The closing tag on a later line: the insertion point, at that line's + // start, takes no terminator before the moved text. + name: "the text-position parent with its closing tag on a later line", + source: R16_C_LATER_CLOSING_PARENT, + staged: stagedMdx( + "T6.5-16/T6.6-3/T14-7 specs/b.mdx the text-position parent with its closing tag on a later line", + R16_C_LATER_CLOSING_PARENT, + ), + compose: (moved) => `foo <S id="p">bar\n${moved}\n</S> baz\n`, + }, + { + // The self-closing parent is first rewritten to the paired form — its + // `/` and the whitespace before it deleted, `</S>` appended after `>` — + // and the insertion before that closing tag is not at a line start. + name: "the self-closing parent after its paired-form rewrite", + source: R16_C_SELF_CLOSING_PARENT, + staged: stagedMdx( + "T6.5-16/T6.6-3/T14-7 specs/b.mdx the self-closing parent", + R16_C_SELF_CLOSING_PARENT, + ), + compose: (moved) => `foo <S id="p">\n${moved}\n</S> baz\n`, + }, +]; + +function r16ArmC(shape: R16MovedShape, parent: R16Parent): R16RefusedArm { + return { + key: `(c) ${shape.name} into ${parent.name}`, + summary: + `the moved text stands alone on its line inside the parent, a flow ` + + `line whose tags are flow-position tags interrupting the paragraph ` + + `that holds the parent's text-position opening tag, which then closes ` + + `nothing (SPEC 6.5, 6.2, 14.20; T3-3's constraint)`, + files: { [R16_ORIGIN]: shape.staged, [R16_TARGET]: parent.staged }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.n"], + illFormed: { [R16_TARGET]: parent.compose(shape.moved) }, + wellFormed: { [R16_ORIGIN]: R16_K }, + identities: [R16_TARGET], + locations: [r16Construct(R16_ORIGIN, R16_K, shape.origin)], + }; +} + +function r16ControlC(parent: R16Parent): R16ControlArm { + return { + key: `(c) control: ${R16_C_CONTROL_SHAPE.name} into ${parent.name}`, + summary: + `the in-line section's line is a paragraph continuation inside the ` + + `parent — the prose outside its tags denies the flow attempt — so the ` + + `composed target derives and the move is performed, the origin's ` + + `emptied line dropped with its terminator (SPEC 6.5, 3, 14.20)`, + files: { + [R16_ORIGIN]: R16_C_CONTROL_SHAPE.staged, + [R16_TARGET]: parent.staged, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.n"], + expected: { + [R16_ORIGIN]: R16_K, + [R16_TARGET]: parent.compose(R16_C_CONTROL_SHAPE.moved), + }, + }; +} + +// (d) a deletion leaving what followed the construct on its line at the +// line's start, inside the text-position parent `foo <S id="p">bar`, +// U+000A, the line, U+000A, `</S> baz`: each pre-move file derives (its +// second line a paragraph continuation, the bytes after the construct +// text), and each deletion leaves a list marker, a setext underline, a +// flow-position tag, or a flow-position expression interrupting the +// paragraph that holds the parent's opening tag; and, one arm more, the +// parent's own closing tag as the remainder, left alone on its line — a +// flow-position tag closing no text-position tag (SPEC 6.5, 6.2). The moved +// section `p.m` lands at the top level of a clean target, its line a +// paragraph line, deriving. +const R16_D_MOVED = '<S id="p.m">x</S>'; +const R16_D_PREFIX = 'foo <S id="p">bar\n'; +const R16_D_TARGET_AFTER = `${R16_K}<S id="m">x</S>\n`; + +/** + * (d)'s origin as staged for a `remainder`: the text-position parent holding + * `p.m` then the remainder on its line — one ledger record (S-9) per + * remainder, the list-marker origin being the missing-parent applicability + * arm's too; each is restaged by T6.6-3 and T14-7. + */ +const R16_D_ORIGINS = new Map<string, StagedMdx>(); +function r16DOrigin(remainder: string): StagedMdx { + const known = R16_D_ORIGINS.get(remainder); + if (known !== undefined) return known; + const record = stagedMdx( + `T6.5-16/T6.6-3/T14-7 specs/a.mdx (d) the parent p holding p.m then ${JSON.stringify(remainder)} on its line`, + `${R16_D_PREFIX}${R16_D_MOVED}${remainder}\n</S> baz\n`, + ); + R16_D_ORIGINS.set(remainder, record); + return record; +} + +function r16ArmD(name: string, remainder: string): R16RefusedArm { + return { + key: `(d) the deletion leaving ${name} at its line's start`, + summary: + `deleting the construct leaves ${JSON.stringify(remainder)} at its ` + + `line's start, ${name} interrupting the paragraph that holds the ` + + `parent's text-position opening tag, which then closes nothing ` + + `(SPEC 6.5, 6.2, 14.20)`, + files: { [R16_ORIGIN]: r16DOrigin(remainder), [R16_TARGET]: R16_K_STAGED }, + argv: ["move", "specs/a.mdx#p.m", "specs/b.mdx#m"], + illFormed: { [R16_ORIGIN]: `${R16_D_PREFIX}${remainder}\n</S> baz\n` }, + wellFormed: { [R16_TARGET]: R16_D_TARGET_AFTER }, + identities: [R16_ORIGIN], + locations: [r16Construct(R16_ORIGIN, R16_D_PREFIX, R16_D_MOVED)], + }; +} + +const R16_D_CLOSING_ARM: R16RefusedArm = { + key: "(d) the deletion leaving the parent's own closing tag alone on its line", + summary: + "deleting the construct leaves `</S>` alone on its line, a " + + "flow-position tag closing no text-position tag, the paragraph holding " + + "the parent's opening tag interrupted (SPEC 6.5, 6.2, 14.20)", + files: { + [R16_ORIGIN]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 specs/a.mdx (d) the parent p holding p.m then its own closing tag", + `${R16_D_PREFIX}${R16_D_MOVED}</S>\n`, + ), + [R16_TARGET]: R16_K_STAGED, + }, + argv: ["move", "specs/a.mdx#p.m", "specs/b.mdx#m"], + illFormed: { [R16_ORIGIN]: `${R16_D_PREFIX}</S>\n` }, + wellFormed: { [R16_TARGET]: R16_D_TARGET_AFTER }, + identities: [R16_ORIGIN], + locations: [r16Construct(R16_ORIGIN, R16_D_PREFIX, R16_D_MOVED)], +}; + +const R16_D_CONTROL: R16ControlArm = { + key: "(d) control: plain prose after the construct", + summary: + "the deletion leaves ` more` a paragraph-continuation line — the line " + + "keeps content, so 3 drops nothing — and the moved section's line is a " + + "paragraph line at the target's end (SPEC 6.5, 3, 14.20)", + files: { + [R16_ORIGIN]: stagedMdx( + "T6.5-16 (d) control: plain prose after the construct specs/a.mdx", + `${R16_D_PREFIX}${R16_D_MOVED} more\n</S> baz\n`, + ), + [R16_TARGET]: R16_K_STAGED, + }, + argv: ["move", "specs/a.mdx#p.m", "specs/b.mdx#m"], + expected: { + [R16_ORIGIN]: `${R16_D_PREFIX} more\n</S> baz\n`, + [R16_TARGET]: R16_D_TARGET_AFTER, + }, +}; + +// The insertion-side counterpart: a target `foo <S id="p">`, U+000A, +// `<S id="p.s"> </S></S> tail`, U+000A — deriving, its second line a +// paragraph continuation — receives `<S id="m">text</S>`, alone on its +// origin line, into `p.n`; the insertion, preceded on its line by `p.s`'s +// closing tag, splits the line with an added terminator (6.5), leaving +// `<S id="p.s"> </S>` alone on its line — a flow line, its tags +// flow-position tags closing no text-position tag and interrupting the +// paragraph that holds `p`'s opening tag. The control is T6.2-3(e), the +// U+000C spelling of that line, kept in text position and performed. +const R16_D_INSERTION_MOVED = '<S id="m">text</S>'; +const R16_D_INSERTION_ARM: R16RefusedArm = { + key: "(d) the insertion leaving a sibling's tags alone on their line", + summary: + "the insertion, preceded on its line by `p.s`'s closing tag, splits " + + 'the line with an added terminator, leaving `<S id="p.s"> </S>` ' + + "alone on its line — a flow line whose tags close no text-position " + + "tag and interrupt the paragraph holding `p`'s opening tag (SPEC 6.5, " + + "6.2, 14.20)", + files: { + [R16_ORIGIN]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (d) the insertion leaving a sibling's tags alone on their line specs/a.mdx", + `${R16_K}${R16_D_INSERTION_MOVED}\n`, + ), + [R16_TARGET]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (d) the insertion leaving a sibling's tags alone on their line specs/b.mdx", + 'foo <S id="p">\n<S id="p.s"> </S></S> tail\n', + ), + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.n"], + illFormed: { + [R16_TARGET]: + 'foo <S id="p">\n<S id="p.s"> </S>\n<S id="p.n">text</S>\n</S> tail\n', + }, + wellFormed: { [R16_ORIGIN]: R16_K }, + identities: [R16_TARGET], + locations: [r16Construct(R16_ORIGIN, R16_K, R16_D_INSERTION_MOVED)], +}; + +// (e) a top-level `new-id` insertion at the end of a file whose last line +// belongs to an ESM block — a target holding only `import A from +// "./A.xspec"` (`specs/A.mdx` discovered, the binding unused, 2.1), +// terminated and unterminated — is absorbed into the block (the +// unterminated variant's insertion point, not at a line start, takes an +// added terminator first: the same composed text). The control: the same +// declaration followed by U+000A, U+000A — the empty line ending the +// block — receives the moved text at its end, a line start, none added. +const R16_E_DECLARATION = 'import A from "./A.xspec"'; +const R16_E_MOVED = '<S id="m">\nx\n</S>'; +const R16_E_ORIGIN_BEFORE = stagedMdx( + "T6.5-16/T6.6-3/T14-7 (e) specs/o.mdx", + `${R16_K}${R16_E_MOVED}\n`, +); +const R16_E_MODULE = stagedMdx( + "T6.5-16/T6.6-3/T14-7 (e) specs/A.mdx holding q", + '<S id="q">Q text.</S>\n', +); +const R16_E_ARGV = ["move", "specs/o.mdx#m", "specs/b.mdx#m"] as const; + +function r16ArmE(name: string, target: string): R16RefusedArm { + return { + key: `(e) the insertion after a block's last line, the target ${name}`, + summary: + `the moved text, inserted at the end of a file whose last line is an ` + + `ESM block's, is absorbed into the block, which runs to the next ` + + `blank line or the file's end (SPEC 6.5, 14.20)`, + files: { + [R16_E_ORIGIN]: R16_E_ORIGIN_BEFORE, + [R16_TARGET]: stagedMdx( + `T6.5-16/T6.6-3/T14-7 (e) the target ${name} ${R16_TARGET}`, + target, + ), + [R16_E_MODULE_SOURCE]: R16_E_MODULE, + }, + argv: [...R16_E_ARGV], + illFormed: { [R16_TARGET]: `${R16_E_DECLARATION}\n${R16_E_MOVED}\n` }, + wellFormed: { [R16_E_ORIGIN]: R16_K }, + identities: [R16_TARGET], + locations: [r16Construct(R16_E_ORIGIN, R16_K, R16_E_MOVED)], + }; +} + +const R16_E_CONTROL: R16ControlArm = { + key: "(e) control: the empty line ending the block", + summary: + "the target's empty line ends its ESM block, so the moved text and " + + "U+000A are appended after that line's terminator — a line start, no " + + "terminator added — and the declaration's binding stays unused and " + + "valid (SPEC 6.5, 2.1, 14.20)", + files: { + [R16_E_ORIGIN]: R16_E_ORIGIN_BEFORE, + [R16_TARGET]: stagedMdx( + "T6.5-16 (e) control specs/b.mdx", + `${R16_E_DECLARATION}\n\n`, + ), + [R16_E_MODULE_SOURCE]: R16_E_MODULE, + }, + argv: [...R16_E_ARGV], + expected: { + [R16_E_ORIGIN]: R16_K, + [R16_TARGET]: `${R16_E_DECLARATION}\n\n${R16_E_MOVED}\n`, + }, +}; + +// (f) a moved section standing in a block quote — `> <S id="m">`, `> x`, +// `> </S>` — whose moved text, the construct's own characters from its +// opening tag through its closing tag (SPEC 6.5), carries the `>` prefixes +// of its interior lines: at the destination's line start its opening tag +// stands outside any quote while `> </S>` puts its closing tag inside one, +// where it closes no tag standing outside (SPEC 6.5, 14.20). The deletion +// leaves `> `, U+000A — the line keeps its quote marker, so 3 drops +// nothing, and an empty quote derives. The control — the same section +// standing in no quote — is T6.5-2's flow-form arms. +const R16_F_MOVED = '<S id="m">\n> x\n> </S>'; +const R16_F_ARM: R16RefusedArm = { + key: "(f) a section standing in a block quote", + summary: + "the moved text carries the `>` prefixes of its interior lines, so at " + + "the destination its closing tag stands inside a quote its opening tag " + + "stands outside, closing nothing there (SPEC 6.5, 14.20)", + files: { + [R16_ORIGIN]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (f) specs/a.mdx", + `> ${R16_F_MOVED}\n`, + ), + [R16_TARGET]: R16_K_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#m"], + illFormed: { [R16_TARGET]: `${R16_K}${R16_F_MOVED}\n` }, + wellFormed: { [R16_ORIGIN]: "> \n" }, + identities: [R16_TARGET], + locations: [r16Construct(R16_ORIGIN, "> ", R16_F_MOVED)], +}; + +// (g) an addition no offset admits. The moved text `<S id="m">x +// {text(X.a)}</S>` — a single-line in-line section alone on its origin line +// — carries an embedding rooted at the origin's binding `X` of a third +// module `specs/x.mdx` the target lacks, so the rewrite must add a +// declaration of that module to the target (its identifier the product's +// latitude, SPEC 6.5; the probes spell `X`) at an admissible offset: one at +// which the file, as every edit leaves it, derives with the added line an +// ESM block's declaration standing inside no section, the block's other +// lines an ESM block's before the edit (SPEC 6.5, 14.20). The origin's +// binding is used by the kept sibling `k` too, so the deletion alone leaves +// the origin (no removal, 6.5), deriving. The finding locates, beside the +// construct, the embedding's braced container — the spelling rooted at the +// lacked binding, by its occurrence span (SPEC 5.7, 14) — both in the +// origin, in start order (12.7). +const R16_G_THIRD = "specs/x.mdx"; +/** The third module — T6.5-13's byte for byte, staged as that one ledger record (S-9). */ +const R16_G_THIRD_STAGED = A13_THIRD_STAGED; +const R16_G_SPECIFIER = canonicalSpecifier("specs", "specs/x.xspec"); +/** The origin's declaration of the third module, and the line a receiving file would need. */ +const R16_G_DECLARATION = `import X from "${R16_G_SPECIFIER}"`; +const R16_G_CONTAINER = "{text(X.a)}"; +const R16_G_MOVED = `<S id="m">x ${R16_G_CONTAINER}</S>`; +/** The origin as the deletion leaves it: the declaration, an empty line, the kept sibling. */ +const R16_G_ORIGIN_KEPT = `${R16_G_DECLARATION}\n\n<S id="k">z ${R16_G_CONTAINER}</S>\n`; +const R16_G_ORIGIN_BEFORE = stagedMdx( + "T6.5-16/T6.6-3/T14-7 (g) specs/a.mdx the origin holding k and m embedding X.a", + `${R16_G_ORIGIN_KEPT}${R16_G_MOVED}\n`, +); +const R16_G_LOCATIONS: readonly R16Location[] = [ + r16Construct(R16_ORIGIN, R16_G_ORIGIN_KEPT, R16_G_MOVED), + r16Construct(R16_ORIGIN, `${R16_G_ORIGIN_KEPT}<S id="m">x `, R16_G_CONTAINER), +]; +/** (c)'s first parent as the target insertion into `p.n` leaves it, the declaration absent, terminated or not. */ +function r16ComposedIntoP(terminated: boolean): string { + return `foo <S id="p">bar\n<S id="p.n">x ${R16_G_CONTAINER}</S>\n</S> baz${terminated ? "\n" : ""}`; +} + +/** + * 6.5's line of an added declaration at `offset` of `text` — the file as the + * other edits leave it: U+000A after the declaration, and one before it + * when the offset is not at a line start, judged over `text`. + */ +function r16Declared( + text: string, + offset: number, + declaration: string, +): string { + const lineStart = offset === 0 || text[offset - 1] === "\n"; + return `${text.slice(0, offset)}${lineStart ? "" : "\n"}${declaration}\n${text.slice(offset)}`; +} + +function r16Probe( + name: string, + text: string, + offset: number, + derives: boolean, + declaration: string = R16_G_DECLARATION, +): R16OffsetProbe { + return { name, text: r16Declared(text, offset, declaration), derives }; +} + +// The text-position target `foo <S id="p">bar</S> baz`, with and without a +// final terminator: offset 0 would absorb the paragraph line into the +// block, the file's end follows a paragraph line, and every other offset +// splits the paragraph. +function r16ArmG(name: string, terminated: boolean): R16RefusedArm { + const composed = r16ComposedIntoP(terminated); + return { + key: `(g) no admissible offset in the text-position target, ${name}`, + summary: + `the target lacks the third module's binding the embedding is rooted ` + + `at, and no offset admits the declaration: offset 0 absorbs the ` + + `paragraph's first line into the block (14.20), the file's end ` + + `follows a paragraph line, leaving the added line paragraph text, and ` + + `every other offset splits the paragraph (SPEC 6.5)`, + files: { + [R16_ORIGIN]: R16_G_ORIGIN_BEFORE, + [R16_TARGET]: terminated + ? R16_TEXT_PARENT_STAGED + : stagedMdx( + "T6.5-16/T6.6-3/T14-7 specs/b.mdx the text-position parent, unterminated", + R16_TEXT_PARENT.slice(0, -1), + ), + [R16_G_THIRD]: R16_G_THIRD_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.n"], + illFormed: {}, + wellFormed: { [R16_ORIGIN]: R16_G_ORIGIN_KEPT }, + identities: [R16_TARGET], + locations: R16_G_LOCATIONS, + noOffset: { + file: R16_TARGET, + composed, + probes: [ + r16Probe("offset 0", composed, 0, false), + r16Probe("the file's end", composed, composed.length, true), + ], + }, + }; +} + +// The pseudo-block twin: the same target headed by T6.5-13(l)'s paragraph +// `// note`, U+000A, `import B from "./B.xspec"`, U+000A, U+000A — content, +// no block (an ESM block cannot interrupt a paragraph; `specs/B.mdx` is +// absent and the pre-move `build` clean all the same). Offset 0 heads a +// block joining the paragraph's lines — deriving yet inadmissible, lines +// that were no ESM block's before the edit; the start of line 2, the start +// of the empty line, and the file's end each follow a paragraph line; the +// start of the `foo` line heads a block absorbing that line (14.20); every +// other offset splits a paragraph line. A product judging admissibility by +// derivability alone performs the move at offset 0, the paragraph turned +// into live declarations. +const R16_G_PSEUDO_HEAD = '// note\nimport B from "./B.xspec"\n\n'; +const R16_G_PSEUDO_COMPOSED = `${R16_G_PSEUDO_HEAD}${r16ComposedIntoP(true)}`; +const R16_G_PSEUDO_ARM: R16RefusedArm = { + key: "(g) the pseudo-block twin: the target headed by a paragraph of declarations", + summary: + "offset 0 heads a block joining the paragraph's lines — deriving yet " + + "inadmissible, lines that were no ESM block's before the edit — the " + + "start of line 2, the start of the empty line, and the file's end each " + + "follow a paragraph line, the start of the `foo` line heads a block " + + "absorbing that line (14.20), and every other offset splits a paragraph " + + "line (SPEC 6.5); a product judging admissibility by derivability alone " + + "performs the move at offset 0", + files: { + [R16_ORIGIN]: R16_G_ORIGIN_BEFORE, + [R16_TARGET]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (g) the pseudo-block twin specs/b.mdx", + `${R16_G_PSEUDO_HEAD}${R16_TEXT_PARENT}`, + ), + [R16_G_THIRD]: R16_G_THIRD_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.n"], + illFormed: {}, + wellFormed: { [R16_ORIGIN]: R16_G_ORIGIN_KEPT }, + identities: [R16_TARGET], + locations: R16_G_LOCATIONS, + noOffset: { + file: R16_TARGET, + composed: R16_G_PSEUDO_COMPOSED, + probes: [ + r16Probe( + "offset 0, a block joining the paragraph's lines", + R16_G_PSEUDO_COMPOSED, + 0, + true, + ), + r16Probe( + "the start of line 2", + R16_G_PSEUDO_COMPOSED, + "// note\n".length, + true, + ), + r16Probe( + "the start of the empty line", + R16_G_PSEUDO_COMPOSED, + R16_G_PSEUDO_HEAD.length - 1, + true, + ), + r16Probe( + "the start of the `foo` line", + R16_G_PSEUDO_COMPOSED, + R16_G_PSEUDO_HEAD.length, + false, + ), + r16Probe( + "the file's end", + R16_G_PSEUDO_COMPOSED, + R16_G_PSEUDO_COMPOSED.length, + true, + ), + ], + }, +}; + +// The top-level twin: the same moved text to the top level of a +// paragraph-ended target `para`, U+000A, terminated and unterminated — the +// composed text `para`, U+000A, the moved text, U+000A either way (the +// target insertion at a line start in the terminated variant, preceded by +// an added terminator in the other), deriving: the moved text's line is a +// paragraph continuation of `para`, the prose outside its tags denying the +// flow attempt (14.20), whatever precedes the line. No offset admits the +// declaration: offset 0 absorbs `para` into the block; the end of the +// `para` line and every offset inside it leave the added line after a +// paragraph line; and the file's end, the target insertion's offset, where +// the declaration stands after the moved text (6.5's fixed order), leaves +// it after the moved text's own paragraph line — 6.5's end-of-file +// qualifier decided on its refusing side. The controls are T6.5-13(d) and +// R16_G_CONTROL below. +const R16_G_TOP_COMPOSED = `para\n${R16_G_MOVED}\n`; +function r16ArmGTop(name: string, target: StagedMdx): R16RefusedArm { + return { + key: `(g) the top-level twin: the paragraph-ended target, ${name}`, + summary: + "the in-line moved text's line is a paragraph continuation of `para` " + + "(14.20), and no offset admits the declaration: offset 0 absorbs " + + "`para` into the block, the end of the `para` line and every offset " + + "inside it leave the added line after a paragraph line, and the " + + "file's end — where the declaration stands after the moved text, " + + "6.5's fixed order — leaves it after the moved text's own paragraph " + + "line (SPEC 6.5)", + files: { + [R16_ORIGIN]: R16_G_ORIGIN_BEFORE, + [R16_TARGET]: target, + [R16_G_THIRD]: R16_G_THIRD_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#m"], + illFormed: {}, + wellFormed: { [R16_ORIGIN]: R16_G_ORIGIN_KEPT }, + identities: [R16_TARGET], + locations: R16_G_LOCATIONS, + noOffset: { + file: R16_TARGET, + composed: R16_G_TOP_COMPOSED, + probes: [ + r16Probe("offset 0", R16_G_TOP_COMPOSED, 0, false), + r16Probe( + "the end of the `para` line", + R16_G_TOP_COMPOSED, + "para".length, + true, + ), + r16Probe( + "the file's end, after the moved text", + R16_G_TOP_COMPOSED, + R16_G_TOP_COMPOSED.length, + true, + ), + ], + }, + }; +} + +// The origin-side twin: an origin `foo <S id="p">bar <S id="p.m">x</S></S> +// {text("p.m")} baz` with no terminator, `p.m` moved into a clean +// flow-position target; the kept reference's conversion to imported form +// needs an import of the target module, which the origin holds no +// admissible offset for — offset 0 absorbs its one line into the block +// (14.20), the file's end, after an unterminated paragraph line, leaves the +// added line paragraph text, and every other offset splits the line. The +// deletion's result derives; the reference's rewritten spelling falls +// inside its braces, an expression either way. The controls are T6.5-13's +// arms, whose receiving files each hold an admissible offset. +const R16_G_SIDE_PREFIX = 'foo <S id="p">bar '; +const R16_G_SIDE_MOVED = '<S id="p.m">x</S>'; +const R16_G_SIDE_REFERENCE = '{text("p.m")}'; +const R16_G_SIDE_TAIL = `</S> ${R16_G_SIDE_REFERENCE} baz`; +const R16_G_SIDE_COMPOSED = `${R16_G_SIDE_PREFIX}${R16_G_SIDE_TAIL}`; +const R16_G_SIDE_DECLARATION = `import B from "${canonicalSpecifier("specs", "specs/b.xspec")}"`; +const R16_G_SIDE_ARM: R16RefusedArm = { + key: "(g) the origin-side twin: the kept reference's import the origin has no offset for", + summary: + 'the origin\'s kept `{text("p.m")}` converts to imported form, rooted ' + + "at a binding of the target module the origin lacks, and no offset " + + "admits the declaration: offset 0 absorbs the file's one line into the " + + "block (14.20), the file's end, after an unterminated paragraph line, " + + "leaves the added line paragraph text, and every other offset splits " + + "the line (SPEC 6.5)", + files: { + [R16_ORIGIN]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (g) the origin-side twin specs/a.mdx", + `${R16_G_SIDE_PREFIX}${R16_G_SIDE_MOVED}${R16_G_SIDE_TAIL}`, + ), + [R16_TARGET]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (g) the origin-side twin specs/b.mdx holding q", + '<S id="q">\nz\n</S>\n', + ), + }, + argv: ["move", "specs/a.mdx#p.m", "specs/b.mdx#q.n"], + illFormed: {}, + wellFormed: { [R16_TARGET]: '<S id="q">\nz\n<S id="q.n">x</S>\n</S>\n' }, + identities: [R16_ORIGIN], + locations: [ + r16Construct(R16_ORIGIN, R16_G_SIDE_PREFIX, R16_G_SIDE_MOVED), + r16Construct( + R16_ORIGIN, + `${R16_G_SIDE_PREFIX}${R16_G_SIDE_MOVED}</S> `, + R16_G_SIDE_REFERENCE, + ), + ], + noOffset: { + file: R16_ORIGIN, + composed: R16_G_SIDE_COMPOSED, + probes: [ + r16Probe( + "offset 0", + R16_G_SIDE_COMPOSED, + 0, + false, + R16_G_SIDE_DECLARATION, + ), + r16Probe( + "the file's end, after the unterminated line", + R16_G_SIDE_COMPOSED, + R16_G_SIDE_COMPOSED.length, + true, + R16_G_SIDE_DECLARATION, + ), + ], + }, +}; + +// (i) two files concerned, one finding: an origin `specs/z.mdx` of (d)'s +// shape — its deletion leaving `- item` at the line's start — whose import +// of the third module has its only occurrence in the moved text, so the +// declaration is removed and its line dropped, the empty line kept (SPEC +// 6.5, 3); and the target of (g)'s shape, lacking that module, no offset +// admitting the declaration. An insertion point exists, so both texts are +// judged: exactly one finding, `identities` both paths in byte order — the +// target first — and `locations` the construct and the embedding's braced +// container, both in the origin, in start order (SPEC 14, 12.7). +const R16_I_ORIGIN = "specs/z.mdx"; +const R16_I_PREFIX = `${R16_G_DECLARATION}\n\nfoo <S id="p">bar\n`; +const R16_I_MOVED = `<S id="p.m">x ${R16_G_CONTAINER}</S>`; +const R16_I_COMPOSED = r16ComposedIntoP(true); +const R16_I_ARM: R16RefusedArm = { + key: "(i) two files concerned, one finding", + summary: + "the origin's deletion leaves `- item` at its line's start (14.20) and " + + "the target, lacking the third module's binding, holds no admissible " + + "offset for the declaration: exactly one finding, `identities` both " + + "paths in byte order, the target first, `locations` the construct and " + + "the embedding's braced container, both in the origin, in start order " + + "(SPEC 6.5, 14, 12.7)", + files: { + [R16_I_ORIGIN]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (i) specs/z.mdx", + `${R16_I_PREFIX}${R16_I_MOVED}- item\n</S> baz\n`, + ), + [R16_TARGET]: R16_TEXT_PARENT_STAGED, + [R16_G_THIRD]: R16_G_THIRD_STAGED, + }, + argv: ["move", "specs/z.mdx#p.m", "specs/b.mdx#p.n"], + illFormed: { [R16_I_ORIGIN]: '\nfoo <S id="p">bar\n- item\n</S> baz\n' }, + wellFormed: {}, + identities: [R16_TARGET, R16_I_ORIGIN], + locations: [ + r16Construct(R16_I_ORIGIN, R16_I_PREFIX, R16_I_MOVED), + r16Construct( + R16_I_ORIGIN, + `${R16_I_PREFIX}<S id="p.m">x `, + R16_G_CONTAINER, + ), + ], + noOffset: { + file: R16_TARGET, + composed: R16_I_COMPOSED, + probes: [ + r16Probe("offset 0", R16_I_COMPOSED, 0, false), + r16Probe("the file's end", R16_I_COMPOSED, R16_I_COMPOSED.length, true), + ], + }, +}; + +// (h) the same-file variant: the flow-form section `m` moved into the +// text-position parent `p` of its own file — the one file as deletion and +// insertion both leave it not well-formed (SPEC 6.5, 14.20), its path once +// in `identities`. The control is the same-file move of T6.5-13(e). +const R16_H_ARM: R16RefusedArm = { + key: "(h) the same-file variant", + summary: + "origin and target coincide: the deletion drops the section's lines and " + + "the insertion puts its flow-position tags inside the text-position " + + "parent `p`, the one file not well-formed — its path once in " + + "`identities` (SPEC 6.5, 6.2, 14.20)", + files: { + [R16_ORIGIN]: stagedMdx( + "T6.5-16/T6.6-3/T14-7 (h) the same-file variant specs/a.mdx", + `${R16_TEXT_PARENT}${R16_FLOW_SECTION}\n`, + ), + }, + argv: ["move", "specs/a.mdx#m", "specs/a.mdx#p.n"], + illFormed: { + [R16_ORIGIN]: 'foo <S id="p">bar\n<S id="p.n">\nx\n</S>\n</S> baz\n', + }, + wellFormed: {}, + identities: [R16_ORIGIN], + locations: [r16Construct(R16_ORIGIN, R16_TEXT_PARENT, R16_FLOW_SECTION)], +}; + +// Applicability (SPEC 6.5): the refusal is reported beside every other +// applicable reason; judged only under an intrinsically valid `<new-id>`; +// the origin's would-be text judged always, the target's only when an +// insertion point exists; and a created target's path spelled whatever its +// validity, the creation's composition judged on its own. + +// (c)'s first shape staged beside `refused-id-collision` — a section `p.n` +// already in the target — reports both reasons. +const R16_COLLISION_ARM: R16RefusedArm = { + key: "(c) beside refused-id-collision: a section p.n already in the target", + summary: + "the flow-form section's tags would stand alone on their lines inside " + + "the text-position parent (SPEC 6.5, 14.20) and `p.n` collides with " + + "the section the target already holds: both reasons reported, each " + + "once (SPEC 6.5, 14)", + files: { + [R16_ORIGIN]: R16_FLOW_ORIGIN_STAGED, + [R16_TARGET]: stagedMdx( + "T6.5-16/T6.6-3 (c) beside refused-id-collision specs/b.mdx", + 'foo <S id="p">bar <S id="p.n">n</S></S> baz\n', + ), + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.n"], + illFormed: { + [R16_TARGET]: + 'foo <S id="p">bar <S id="p.n">n</S>\n<S id="p.n">\nx\n</S>\n</S> baz\n', + }, + wellFormed: { [R16_ORIGIN]: R16_K }, + identities: [R16_TARGET], + locations: [r16Construct(R16_ORIGIN, R16_K, R16_FLOW_SECTION)], + beside: ["refused-id-collision"], +}; + +// A missing target parent beside an origin deletion of shape (d): no +// insertion point exists, so the target's text is not judged, while the +// origin's, judged always, is not well-formed — both reasons, `identities` +// the origin path alone. +const R16_D_MISSING_PARENT_ARM: R16RefusedArm = { + key: "(d) beside refused-missing-target-parent: the origin judged, the target not", + summary: + "the target holds no `q`, so no insertion point exists and the target's " + + "text is not judged, while the origin's deletion, judged always, leaves " + + "`- item` at its line's start (SPEC 6.5, 14.20): both reasons reported, " + + "`identities` the origin path alone (SPEC 14)", + files: { [R16_ORIGIN]: r16DOrigin("- item"), [R16_TARGET]: R16_K_STAGED }, + argv: ["move", "specs/a.mdx#p.m", "specs/b.mdx#q.n"], + illFormed: { [R16_ORIGIN]: `${R16_D_PREFIX}- item\n</S> baz\n` }, + wellFormed: {}, + identities: [R16_ORIGIN], + locations: [r16Construct(R16_ORIGIN, R16_D_PREFIX, R16_D_MOVED)], + beside: ["refused-missing-target-parent"], +}; + +// A created target: (a)'s shape (a space after its opening tag) moved to an +// absent path, its content composed as T6.5-14 fixes — the re-identified +// moved text and U+000A, no declaration needed — underivable: at the line's +// start its opening tag is a flow-position tag, its closing tag inside the +// paragraph `body</S>` (SPEC 6.5, 6.2, 14.20). The path is spelled in +// `identities` whatever its validity: `specs/new.txt`, lacking the `.mdx` +// extension, beside `refused-invalid-destination` with that path (T14-7); +// the valid `specs/new.mdx` alone — exit 1, nothing created (the +// whole-root compare). +const R16_CREATED_MOVED = '<S id="m"> \nbody</S>'; +/** The created-target arms' origin — one record (S-9) for both arms and their T6.6-3 and T14-7 restagings. */ +const R16_CREATED_ORIGIN_STAGED = stagedMdx( + "T6.5-16/T6.6-3/T14-7 the created-target arms specs/a.mdx", + `foo ${R16_CREATED_MOVED}\n`, +); +function r16CreatedArm( + created: string, + beside: readonly string[], +): R16RefusedArm { + const alone = beside.length === 0; + return { + key: `the created target ${created}${alone ? " alone" : ` beside ${beside.join(", ")}`}`, + summary: + `the creation's composition — the re-identified moved text and ` + + `U+000A — is judged on its own: at the line's start its opening tag ` + + `is a flow-position tag, which the text-position closing tag of ` + + `\`body</S>\` cannot close (SPEC 6.5, 6.2, 14.20), the path spelled ` + + (alone + ? `alone, valid, nothing created` + : `beside its own invalidity, ${beside.join(", ")} with that path`), + files: { [R16_ORIGIN]: R16_CREATED_ORIGIN_STAGED }, + argv: ["move", "specs/a.mdx#m", `${created}#y`], + illFormed: { [created]: '<S id="y"> \nbody</S>\n' }, + wellFormed: { [R16_ORIGIN]: "foo \n" }, + identities: [created], + locations: [r16Construct(R16_ORIGIN, "foo ", R16_CREATED_MOVED)], + beside, + ...(beside.includes("refused-invalid-destination") + ? { besidePath: { "refused-invalid-destination": created } } + : {}), + }; +} + +// Refused for another reason alone, `refused-invalid-rewrite` not +// applicable: (c)'s first shape under the intrinsically invalid `<new-id>` +// `p.then` (`then` a forbidden segment, SPEC 1.4) — an invalid one spelled +// verbatim leaves the would-be text undefined; and a missing target parent +// beside the flow-form section and a text-position target file, the +// origin's deletion leaving it well-formed — no target text exists to +// judge. +export const R16_ALONE_ARMS: readonly R16AloneArm[] = [ + { + key: "(c)'s shape under the invalid new-id p.then: refused-invalid-id alone", + summary: + "the refusal is judged only under an intrinsically valid `<new-id>`: " + + "`then` is a forbidden segment (SPEC 1.4), so `refused-invalid-id` is " + + "reported alone, no would-be text judged (SPEC 6.5, 14)", + files: { + [R16_ORIGIN]: R16_FLOW_ORIGIN_STAGED, + [R16_TARGET]: R16_TEXT_PARENT_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#p.then"], + codes: ["refused-invalid-id"], + }, + { + key: "a missing target parent beside a clean origin: refused-missing-target-parent alone", + summary: + "the flow-form section's deletion leaves the origin well-formed and " + + "the target holds no `q`, so no insertion point exists and no target " + + "text is judged: `refused-missing-target-parent` alone (SPEC 6.5, 14)", + files: { + [R16_ORIGIN]: R16_FLOW_ORIGIN_STAGED, + [R16_TARGET]: R16_TEXT_PARENT_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#q.n"], + codes: ["refused-missing-target-parent"], + }, +]; + +// (g)'s performed control: the same in-line moved text to the top level +// of `<S id="p">`, U+000A, `x`, U+000A, `</S>`, U+000A — its only admissible +// offset the end of the `</S>` line before its terminator (offset 0 +// absorbing the tag's line; the end of that line heading a block inside +// `p`, deriving yet excluded, T6.5-19; the start of the `x` line absorbing +// it; the end of the `x` line and the start of the `</S>` line leaving the +// added line paragraph text; the file's end, a line start after a flow line +// before the operation, following the moved text's paragraph line after +// it), T6.5-13(h)'s forced mid-line placement: the added terminator ends +// the `</S>` line, the declaration's line follows, and the line's original +// terminator is left an empty line before the moved text — which pins that +// the in-line moved text's own line is a paragraph line whatever precedes +// it. `build` and `check` clean; the root `changed` — a parent gaining a +// child reference, its run after `p` now U+000A, the kept remainder line, +// and its run after `m` U+000A — beside the origin root, which loses one; +// `p` keeps its own content (the added terminator ends its closing tag's +// line, dropped as before), the moved node keeps its hashes (its one line +// contributes at the destination what it did at the origin), and the third +// module is untouched (SPEC 6.2, 5.6). A product treating the file's end +// after a top-level target insertion as admissible outright, or judging +// admissibility over the pre-operation text, places the declaration after +// the moved text. +/** The flow-ended target — T6.5-13's (a) target byte for byte, staged as that one ledger record (S-9), `A13_A_STAGED`; the string composes the expectation. */ +const R16_G_CONTROL_TARGET = A13_A_TARGET; +const R16_G_CONTROL: R16ControlArm = { + key: "(g) control: the in-line moved text to the top level of a flow-ended target", + summary: + "the end of the `</S>` line before its terminator is the only " + + "admissible offset — offset 0 absorbing the tag's line, the end of the " + + "opening tag's line heading a block inside `p` (excluded), the start " + + "of the `x` line absorbing it, the end of the `x` line and the start of " + + "the `</S>` line leaving the added line paragraph text, and the file's " + + "end following the moved text's own paragraph line — so the added " + + "terminator ends the `</S>` line, the declaration's line follows, and " + + "the line's original terminator is left an empty line before the moved " + + "text (SPEC 6.5, 6.2, 14.20)", + files: { + [R16_ORIGIN]: R16_G_ORIGIN_BEFORE, + [R16_TARGET]: A13_A_STAGED, + [R16_G_THIRD]: R16_G_THIRD_STAGED, + }, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#m"], + expected: { [R16_ORIGIN]: R16_G_ORIGIN_KEPT }, + added: { + rel: R16_TARGET, + specifier: R16_G_SPECIFIER, + compose: (ident) => + `${R16_G_CONTROL_TARGET.slice(0, -1)}\nimport ${ident} from "${R16_G_SPECIFIER}"\n\n<S id="m">x {text(${ident}.a)}</S>\n`, + }, + impact: { + known: [ + R16_ORIGIN, + `${R16_ORIGIN}#k`, + R16_TARGET, + `${R16_TARGET}#p`, + `${R16_TARGET}#m`, + R16_G_THIRD, + `${R16_G_THIRD}#a`, + ], + pins: [ + { + identity: R16_TARGET, + required: ["changed"], + changedWithin: [R16_TARGET], + }, + { + identity: R16_ORIGIN, + required: ["changed"], + changedWithin: [R16_ORIGIN], + }, + { identity: `${R16_TARGET}#p`, required: [] }, + { identity: `${R16_TARGET}#m`, required: [] }, + { identity: `${R16_ORIGIN}#k`, required: [] }, + { identity: R16_G_THIRD, required: [] }, + { identity: `${R16_G_THIRD}#a`, required: [] }, + ], + reason: + "the target root is `changed` — a parent gaining a child reference, " + + "its run after `p` now U+000A, the kept remainder line, and its run " + + "after `m` U+000A — beside the origin root, which loses one, each " + + "attributed to itself; `p` keeps its own content, the moved node its " + + "hashes, and the third module is untouched (SPEC 6.2, 5.6)", + }, +}; + +/** T6.5-16's refused arms, in the entry's order (exported for T6.6-3's preview twins). */ +export const R16_REFUSED_ARMS: readonly R16RefusedArm[] = [ + r16ArmA("a space", " "), + r16ArmA("a tab", "\t"), + r16ArmA("nothing", ""), + r16ArmB("opening", "U+000C", R16_FF), + r16ArmB("closing", "U+000C", R16_FF), + r16ArmB("opening", "U+000B", R16_VT), + r16ArmB("closing", "U+000B", R16_VT), + ...R16_C_SHAPES.flatMap((shape) => + R16_C_PARENTS.map((parent) => r16ArmC(shape, parent)), + ), + r16ArmD("a list marker", "- item"), + r16ArmD("a setext underline", "==="), + r16ArmD("a flow-position tag", '<S id="p.q" />'), + r16ArmD("a flow-position expression", "{/* c */}"), + R16_D_CLOSING_ARM, + R16_D_INSERTION_ARM, + r16ArmE("terminated", `${R16_E_DECLARATION}\n`), + r16ArmE("unterminated", R16_E_DECLARATION), + R16_F_ARM, + r16ArmG("terminated", true), + r16ArmG("unterminated", false), + R16_G_PSEUDO_ARM, + r16ArmGTop("terminated", A13_D_TERMINATED_STAGED), + r16ArmGTop("unterminated", A13_D_STAGED), + R16_G_SIDE_ARM, + R16_H_ARM, + R16_I_ARM, + R16_COLLISION_ARM, + R16_D_MISSING_PARENT_ARM, + r16CreatedArm("specs/new.txt", ["refused-invalid-destination"]), + r16CreatedArm("specs/new.mdx", []), +]; + +const R16_CONTROL_ARMS: readonly R16ControlArm[] = [ + ...R16_C_PARENTS.map((parent) => r16ControlC(parent)), + R16_D_CONTROL, + R16_E_CONTROL, + R16_G_CONTROL, +]; + +/** + * Every MDX text T6.5-16 stages, or asserts as a move's result or as the + * deriving side of a refused rewrite, for the S-9 self-test + * (test/self/s9-fixture-well-formedness.test.ts): a staged file the stock + * MDX 3 grammar rejects would fail the valid-workspace precondition, and a + * control's expectation it rejects would pin a text 6.5 refuses. + */ +export const R16_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + ...R16_REFUSED_ARMS.flatMap((arm) => [ + ...Object.entries(arm.files).map( + ([rel, text]) => + [`T6.5-16 ${arm.key}: ${rel} as staged`, stagedText(text)] as const, + ), + ...Object.entries(arm.wellFormed).map( + ([rel, text]) => + [ + `T6.5-16 ${arm.key}: ${rel} as the edits would leave it`, + text, + ] as const, + ), + ...(arm.noOffset === undefined + ? [] + : [ + [ + `T6.5-16 ${arm.key}: ${arm.noOffset.file} as the other edits leave it`, + arm.noOffset.composed, + ] as const, + ...arm.noOffset.probes + .filter((probe) => probe.derives) + .map( + (probe) => + [ + `T6.5-16 ${arm.key}: ${arm.noOffset!.file} with the declaration at ${probe.name}`, + probe.text, + ] as const, + ), + ]), + ]), + ...R16_CONTROL_ARMS.flatMap((arm) => [ + ...Object.entries(arm.files).map( + ([rel, text]) => + [`T6.5-16 ${arm.key}: ${rel} as staged`, stagedText(text)] as const, + ), + ...Object.entries(arm.expected).map( + ([rel, text]) => + [`T6.5-16 ${arm.key}: ${rel} after the move`, text] as const, + ), + ...(arm.added === undefined + ? [] + : [ + [ + `T6.5-16 ${arm.key}: ${arm.added.rel} after the move, the declaration binding X`, + arm.added.compose("X"), + ] as const, + ]), + ]), + ...R16_ALONE_ARMS.flatMap((arm) => + Object.entries(arm.files).map( + ([rel, text]) => + [`T6.5-16 ${arm.key}: ${rel} as staged`, stagedText(text)] as const, + ), + ), +]; + +/** + * Every would-be text T6.5-16 refuses, for the S-9 self-test: each is the + * concerned file as 6.5's exact edits would leave it, which must not derive + * — the ground of the refusal. + */ +export const R16_REFUSED_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = R16_REFUSED_ARMS.flatMap((arm) => [ + ...Object.entries(arm.illFormed).map( + ([rel, text]) => + [`T6.5-16 ${arm.key}: ${rel} as the edits would leave it`, text] as const, + ), + ...(arm.noOffset?.probes ?? []) + .filter((probe) => !probe.derives) + .map( + (probe) => + [ + `T6.5-16 ${arm.key}: ${arm.noOffset!.file} with the declaration at ${probe.name}`, + probe.text, + ] as const, + ), +]); + +/** A would-be text the entry declares underivable must not derive (S-9): a staging defect, never a verdict. */ +function r16AssertUnderivable(text: string, rel: string, key: string): void { + if (!deriveMdx(text).derives) return; + throw new HarnessStagingError( + "mdx-derivability", + rel, + `T6.5-16 ${key}: the would-be text of ${rel} derives under the stock ` + + `MDX 3 grammar, so it is no ground for refused-invalid-rewrite — the ` + + `arm's premise, not a product verdict; the text reads ${JSON.stringify(text)}`, + ); +} + +/** A composed text the entry declares deriving must derive (S-9): a staging defect, never a verdict. */ +function r16AssertDerives( + text: string, + rel: string, + key: string, + testId = "T6.5-16", +): void { + const verdict = deriveMdx(text); + if (verdict.derives) return; + throw new HarnessStagingError( + "mdx-derivability", + rel, + `${testId} ${key}: the composed text for ${rel} does not derive under ` + + `the stock MDX 3 grammar (${verdict.reason}) — the arm's premise, not ` + + `a product verdict; the text reads ${JSON.stringify(text)}`, + ); +} + +/** The report's codes, as `condition ?? code` (the H-3 decoder's derived condition where one is pinned). */ +function r16Codes(findings: readonly Finding[]): string[] { + return findings.map( + (finding) => finding.condition ?? finding.code ?? "(code-less)", + ); +} + +/** Two code lists hold the same multiset. */ +function r16SameCodes( + actual: readonly string[], + expected: readonly string[], +): boolean { + const sortedActual = [...actual].sort(); + return ( + actual.length === expected.length && + [...expected].sort().every((code, index) => sortedActual[index] === code) + ); +} + +function r16RenderLocations(locations: readonly R16Location[]): string { + return locations + .map( + ({ file, start, end }) => + `${JSON.stringify(file)} [${String(start)}, ${String(end)})`, + ) + .join("; "); +} + +/** + * The refusal's report: exactly one finding per applicable reason — + * `refused-invalid-rewrite` and the arm's `beside` reasons, none further — + * the `refused-invalid-rewrite` finding's `locations` exactly the arm's in + * 12.7's order, its `identities` exactly the concerned paths in byte + * order, its `path` null (SPEC 14, 12.7, 1.7). + */ +function r16AssertFinding( + findings: readonly Finding[], + arm: R16RefusedArm, + context: string, +): void { + const expectedCodes = ["refused-invalid-rewrite", ...(arm.beside ?? [])]; + const actualCodes = r16Codes(findings); + if (!r16SameCodes(actualCodes, expectedCodes)) { + fail( + `${context}: the report holds exactly one finding per applicable ` + + `reason — ${JSON.stringify(expectedCodes)}, \`refused-invalid-rewrite\` ` + + `once per operation however many files or shapes it covers, and no ` + + `reason beside — ${arm.summary} (SPEC 6.5, 14, 12.7); got ` + + `${JSON.stringify(actualCodes)}`, + ); + } + const finding = findings.find( + (candidate) => candidate.code === "refused-invalid-rewrite", + )!; + const actualRendered = finding.locations + .map( + (location) => + `${renderPathValue(location.file)} [${String(location.range.start)}, ` + + `${String(location.range.end)})`, + ) + .join("; "); + const exact = + finding.locations.length === arm.locations.length && + arm.locations.every((expected, index) => { + const location = finding.locations[index]!; + return ( + location.file === expected.file && + location.range.start === expected.start && + location.range.end === expected.end + ); + }); + if (!exact) { + fail( + `${context}: the finding's \`locations\` are exactly ` + + `[${r16RenderLocations(arm.locations)}] — the moved section's ` + + `construct range in the origin file, in pre-operation coordinates` + + (arm.locations.length > 1 + ? `, then every reference spelling rooted at an addition no offset ` + + `admits, by its occurrence span, in 12.7's order` + : "") + + ` (SPEC 14, 1.7, 5.7, 12.7); got [${actualRendered}] ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } + assertFindingIdentities( + finding, + arm.identities, + `${context}: the finding's \`identities\` — the workspace-relative ` + + `paths of the files concerned, each whose would-be text is not ` + + `well-formed MDX or which holds no admissible offset for an addition ` + + `it needs, in byte order (SPEC 14, 12.7)`, + ); + if (finding.path !== null) { + fail( + `${context}: the finding's \`path\` is null — a refusal locating in ` + + `source concerns no path (SPEC 14, 12.7); got ` + + `${renderPathValue(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + for (const [code, path] of Object.entries(arm.besidePath ?? {})) { + const beside = findings.find((candidate) => candidate.code === code)!; + if (beside.path !== path) { + fail( + `${context}: the \`${code}\` finding reported beside carries ` + + `\`path\` ${JSON.stringify(path)} — the destination spelled ` + + `whatever its validity (SPEC 6.5, 14; T14-7); got ` + + `${renderPathValue(beside.path)} (message: ` + + `${JSON.stringify(beside.message)})`, + ); + } + } +} + +async function runR16RefusedArm( + product: ProductBinding, + arm: R16RefusedArm, +): Promise<void> { + const context = `T6.5-16 ${arm.key}`; + for (const [rel, text] of Object.entries(arm.illFormed)) { + r16AssertUnderivable(text, rel, arm.key); + } + for (const [rel, text] of Object.entries(arm.wellFormed)) { + r16AssertDerives(text, rel, arm.key); + } + if (arm.noOffset !== undefined) { + // The refusal's ground is the offsets alone: the concerned file derives + // as the other edits leave it, and each named offset holds the verdict + // the entry states for the declaration inserted there. + r16AssertDerives(arm.noOffset.composed, arm.noOffset.file, arm.key); + for (const probe of arm.noOffset.probes) { + const rel = `${arm.noOffset.file} with the declaration at ${probe.name}`; + if (probe.derives) r16AssertDerives(probe.text, rel, arm.key); + else r16AssertUnderivable(probe.text, rel, arm.key); + } + } + const command = arm.argv.join(" "); + await withWorkspace(arm.files, async (workspace) => { + // Premise: the pre-move workspace is valid (6.4's precondition), every + // staged file well-formed, so the refusal is the rewrite's alone; the + // build also lays down the derived files the compare below covers. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging — the pre-move workspace is ` + + `valid, every staged file well-formed (SPEC 6.4, 6.5, 14.20)`, + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + const findings = await runFindingsReport( + product, + workspace, + [...arm.argv, "--json"], + 1, + `${context} \`${command} --json\` — refused: ${arm.summary}; ` + + `exit 1 with the form-exact 12.7 findings-only report ` + + `(SPEC 6.5, 14, 12.0, 12.7)`, + ); + r16AssertFinding(findings, arm, context); + }, + `${context}: \`${command}\` refused — modifies nothing: every source ` + + `and derived file byte-identical, the journal absent or ` + + `byte-unchanged (SPEC 6.5, 14)`, + ); + }); +} + +async function runR16AloneArm( + product: ProductBinding, + arm: R16AloneArm, +): Promise<void> { + const context = `T6.5-16 ${arm.key}`; + const command = arm.argv.join(" "); + await withWorkspace(arm.files, async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging — the pre-move workspace is ` + + `valid, every staged file well-formed (SPEC 6.4, 6.5, 14.20)`, + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + const findings = await runFindingsReport( + product, + workspace, + [...arm.argv, "--json"], + 1, + `${context} \`${command} --json\` — refused: ${arm.summary}; ` + + `exit 1 with the form-exact 12.7 findings-only report ` + + `(SPEC 6.5, 14, 12.0, 12.7)`, + ); + const actualCodes = r16Codes(findings); + if (!r16SameCodes(actualCodes, arm.codes)) { + fail( + `${context}: the report holds exactly one finding per applicable ` + + `reason — ${JSON.stringify(arm.codes)} and no ` + + `\`refused-invalid-rewrite\` beside it: ${arm.summary} ` + + `(SPEC 6.5, 14, 12.7); got ${JSON.stringify(actualCodes)}`, + ); + } + }, + `${context}: \`${command}\` refused — modifies nothing: every source ` + + `and derived file byte-identical, the journal absent or ` + + `byte-unchanged (SPEC 6.5, 14)`, + ); + }); +} + +async function runR16ControlArm( + product: ProductBinding, + arm: R16ControlArm, + testId = "T6.5-16", +): Promise<void> { + const context = `${testId} ${arm.key}`; + for (const [rel, text] of Object.entries(arm.expected)) { + r16AssertDerives(text, rel, arm.key, testId); + } + if (arm.added !== undefined) { + r16AssertDerives(arm.added.compose("X"), arm.added.rel, arm.key, testId); + } + const command = arm.argv.join(" "); + await withWorkspace(arm.files, async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging — the pre-move workspace is ` + + `valid, every staged file well-formed (SPEC 6.4, 6.5, 14.20)`, + ); + let base = ""; + if (arm.impact !== undefined) { + await workspace.gitInit(); + base = await workspace.gitCommitAll("pre-move baseline"); + } + await expectExit( + product, + workspace, + [...arm.argv], + 0, + `${context} \`${command}\` — the movable control is performed: ` + + `${arm.summary}`, + ); + for (const [rel, text] of Object.entries(arm.expected)) { + await assertFileBytes( + workspace.path(rel), + text, + `${context}: ${rel} after the move — ${arm.summary}; composed from ` + + `6.5's exact edits and 3's line drops with no latitude, every ` + + `other byte unchanged (SPEC 6.5, 3; H-4)`, + ); + } + if (arm.added !== undefined) { + const { rel, specifier, compose } = arm.added; + const actual = await readSourceText(workspace, rel, context); + const ident = r16ReadAddedIdentifier(actual, specifier); + await assertFileBytes( + workspace.path(rel), + compose(ident ?? "X"), + `${context}: ${rel} after the move — ${arm.summary}; the added ` + + `declaration \`import <X> from "${specifier}"\` (its identifier ` + + `${ident === undefined ? "unreadable off the file, spelled X here" : `read back as \`${ident}\``}) ` + + `at the one admissible offset, the rest composed from 6.5's exact ` + + `edits with no latitude (SPEC 6.5, 2.1, 3; H-4)`, + ); + } + await assertCleanAfterMove( + product, + workspace, + "the performed control leaves a valid workspace", + context, + ); + if (arm.impact !== undefined) { + await a13AssertImpact(product, workspace, base, arm.impact, context); + } + }); +} + +/** + * The fresh identifier a receiving file's one added declaration of + * `specifier` binds, read off its line (`import <X> from "<specifier>"`, + * SPEC 6.5); undefined when no such line stands alone — the caller then + * pins the composition with `X`, and the byte assertion diagnoses. + */ +function r16ReadAddedIdentifier( + actual: string, + specifier: string, +): string | undefined { + const escaped = specifier.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + const pattern = new RegExp( + `^import ([A-Za-z_$][A-Za-z0-9_$]*) from "${escaped}"$`, + "gm", + ); + const matches = [...actual.matchAll(pattern)]; + const ident = matches.length === 1 ? matches[0]?.[1] : undefined; + if (ident === undefined || MDX_RESERVED_NAMES.includes(ident)) { + return undefined; + } + return ident; +} + +const T6_5_16 = defineProductTest({ + id: "T6.5-16", + title: + "refused-invalid-rewrite: a section-form move whose exact edits would leave a rewritten file other than well-formed MDX — the origin as its deletion leaves it, the target as its parent rewrite and insertion leave it or as its creation composes it, the one file when origin and target coincide — or a file the rewrite must add an import to holding no admissible offset, is refused: exit 1, nothing modified (the workspace byte-compared, the journal absent or byte-unchanged), exactly one `refused-invalid-rewrite` finding per operation, beside every other applicable reason, locating the moved section's construct range in the origin file plus, for an addition no offset admits, the reference spelling rooted at its binding by its occurrence span, its `identities` the concerned files' workspace-relative paths in byte order, its `path` null; each arm's would-be text verified underivable, every other rewritten file's verified to derive, and each named offset of an addition none admits probed (S-9) from a valid pre-move workspace: (a) the `body</S>` variant — `foo <S id=\"m\">`, then a space, a tab, or nothing, U+000A, `body</S>` — moved to top level; (b) the one-sided U+000C and U+000B spellings of 6.2's worked three-line shape; (c) a flow-position section, a self-closing section, and a single-line section holding nothing outside its tags, each moved into `foo <S id=\"p\">bar</S> baz`, into the parent with its closing tag on a later line, and into the self-closing parent after its paired-form rewrite; (d) a deletion leaving a list marker, a setext underline, a flow-position tag, a flow-position expression, or the parent's own closing tag at its line's start inside a text-position parent, and the insertion-side counterpart leaving `<S id=\"p.s\"> </S>` alone on its line; (e) a top-level insertion at the end of a file whose last line is an ESM block's, terminated and unterminated; (f) a section standing in a block quote, its moved text carrying the `>` prefixes of its interior lines; (g) a text-position target lacking the module the moved embedding is rooted at, terminated and unterminated, its pseudo-block twin headed by a paragraph of declarations, its top-level twin into the paragraph-ended `para`, terminated and unterminated, and its origin-side twin whose kept reference needs an import the unterminated origin holds no offset for — the finding locating the construct and the embedding's braced container; (h) the same-file variant, one path; (i) two files concerned, one finding, `identities` exactly [\"specs/b.mdx\", \"specs/z.mdx\"]; the applicability arms — (c)'s shape beside `refused-id-collision`; a missing target parent beside an origin deletion of shape (d), `identities` the origin alone, and, the origin clean, `refused-missing-target-parent` alone; (c)'s shape under `p.then`, `refused-invalid-id` alone; and (a)'s shape to the absent `specs/new.txt#y`, beside `refused-invalid-destination` with that path, and to `specs/new.mdx#y`, alone, nothing created; with the movable controls performed and byte-asserted, `check` and `build` clean — the in-line section `<S id=\"m\">x</S>` into each of (c)'s parents, (d)'s plain-prose remainder ` more` kept as a paragraph-continuation line, (e)'s declaration followed by the empty line ending its block, and (g)'s in-line moved text to the top level of `<S id=\"p\">`, `x`, `</S>`, the declaration at the end of the `</S>` line before the moved text and the root `changed` (SPEC 6.5, 6.2, 3, 2.1, 1.7, 5.7, 14, 12.7, 14.20)", + run: async (product) => { + for (const arm of R16_REFUSED_ARMS) { + await runR16RefusedArm(product, arm); + } + for (const arm of R16_ALONE_ARMS) { + await runR16AloneArm(product, arm); + } + for (const arm of R16_CONTROL_ARMS) { + await runR16ControlArm(product, arm); + } + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-17: `refused-moved-import`. SPEC 6.5: a section-form move whose moved +// text holds an import declaration is refused, "judged over the moved text +// as it stands, whatever the edits would leave" — the exact edits would +// carry the declaration into the target file, where its specifier resolves +// from that file's directory (2.1) and its binding may collide with one the +// file holds (14.15), while every origin-kept reference rooted at its +// binding would lose it (2.4), outcomes no rewrite of 6.5 covers — so such +// a section is movable once the declaration stands outside it. SPEC 14 and +// 12.7: one finding, locating each such declaration in the origin file by +// its own characters (the import range of 11.4), its `identities` empty, +// its `path` null; reported beside every other applicable reason and, +// unlike `refused-invalid-rewrite`, under no intrinsic-validity qualifier: +// an invalid `<new-id>` reports `refused-invalid-id` beside it. +// +// The fixture is T2.1-6's form — an ESM block inside a section element, +// parted from the tag line and from the body line by a blank line on each +// side, so it interrupts no paragraph and runs to its blank line (14.20): +// `<S id="m">`, U+000A, U+000A, `import X from "./x.xspec"`, U+000A, U+000A, +// `body {text(X.a)}`, U+000A, `</S>` — after a sibling `k`, so no pinned +// offset is the section's own. Everything staged is ASCII, so string +// lengths are byte counts (1.7). The pre-move `build` (exit 0) repeats +// T2.1-6's positive observation, so the refusal is the moved text's alone. +// The preview twins are T6.6-3's (the arms are exported for them). + +const M17_ORIGIN = "specs/a.mdx"; +const M17_TARGET = "specs/b.mdx"; +const M17_X = "specs/x.mdx"; +const M17_Y = "specs/y.mdx"; +// The third and fourth modules and the target are T6.5-13's third module, +// fourth module, and (a) target byte for byte — each staged as that one +// ledger record (S-9), never registered twice. +const M17_X_STAGED = A13_THIRD_STAGED; +const M17_Y_STAGED = A13_FOURTH_STAGED; +/** The target: a flow-form section holding no ESM block, its last line terminated (the string composes the control's expectation). */ +const M17_TARGET_SOURCE = A13_A_TARGET; +const M17_TARGET_STAGED = A13_A_STAGED; +const M17_X_SPECIFIER = canonicalSpecifier("specs", "specs/x.xspec"); +const M17_X_DECLARATION = `import X from "${M17_X_SPECIFIER}"`; +const M17_Y_DECLARATION = `import Y from "${canonicalSpecifier("specs", "specs/y.xspec")}"`; +/** The sibling heading the origin (`<S id="k">z</S>`, U+000A): no pinned offset is the section's own. */ +const M17_K = R16_K; +/** The moved section's opening tag and the blank line parting it from the block. */ +const M17_OPENING = '<S id="m">\n\n'; +const M17_ARGV = ["move", "specs/a.mdx#m", "specs/b.mdx#m"] as const; + +/** The origin: the sibling, then the section holding `declarations` in its block, a blank line, and `body` (T2.1-6's form). */ +function m17Origin(declarations: readonly string[], body: string): string { + return `${M17_K}${M17_OPENING}${declarations.join("\n")}\n\n${body}\n</S>\n`; +} + +/** + * Each declaration's own characters in the origin file — the import range of + * 11.4, its terminator excluded — in start order (SPEC 14, 12.7, 1.7). + */ +function m17Locations(declarations: readonly string[]): R16Location[] { + const locations: R16Location[] = []; + let start = Buffer.byteLength(`${M17_K}${M17_OPENING}`, "utf8"); + for (const declaration of declarations) { + const end = start + Buffer.byteLength(declaration, "utf8"); + locations.push({ file: M17_ORIGIN, start, end }); + start = end + 1; // the declaration line's U+000A + } + return locations; +} + +/** A section-form move refused as `refused-moved-import`: exit 1, nothing modified, the finding form-exact. */ +export interface M17RefusedArm { + readonly key: string; + /** Why the moved text is refused, for the diagnoses. */ + readonly summary: string; + /** The pre-move spec files, each deriving (the builder's S-9 check). */ + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly argv: readonly string[]; + /** The finding's `locations`: each declaration's own characters in the origin, in start order (SPEC 14, 11.4, 12.7). */ + readonly locations: readonly R16Location[]; + /** Every other applicable reason's code, reported beside (SPEC 14). */ + readonly beside?: readonly string[]; +} + +const M17_A_FILES: Readonly<Record<string, InitialFileContents>> = { + [M17_ORIGIN]: stagedMdx( + "T6.5-17/T6.6-3/T14-7 arms (a) and (d) specs/a.mdx", + m17Origin([M17_X_DECLARATION], "body {text(X.a)}"), + ), + [M17_TARGET]: M17_TARGET_STAGED, + [M17_X]: M17_X_STAGED, +}; + +/** T6.5-17's refused arms, in the entry's order (exported for T6.6-3's preview twins). */ +export const M17_REFUSED_ARMS: readonly M17RefusedArm[] = [ + { + key: "(a) one declaration in the section's block", + summary: + "the moved text holds the block's one declaration, `import X from " + + '"./x.xspec"`, which the moved body references through `X` ' + + "(SPEC 6.5, 2.1)", + files: M17_A_FILES, + argv: [...M17_ARGV], + locations: m17Locations([M17_X_DECLARATION]), + }, + { + key: "(b) two declarations on successive lines: two locations", + summary: + "the moved text holds two declarations, `import X …` and `import Y …` " + + "on successive lines of the one block, each located by its own " + + "characters in start order (SPEC 6.5, 14, 12.7)", + files: { + [M17_ORIGIN]: stagedMdx( + "T6.5-17/T6.6-3/T14-7 arm (b) specs/a.mdx", + m17Origin( + [M17_X_DECLARATION, M17_Y_DECLARATION], + "body {text(X.a)} {text(Y.b)}", + ), + ), + [M17_TARGET]: M17_TARGET_STAGED, + [M17_X]: M17_X_STAGED, + [M17_Y]: M17_Y_STAGED, + }, + argv: [...M17_ARGV], + locations: m17Locations([M17_X_DECLARATION, M17_Y_DECLARATION]), + }, + { + key: "(c) an unused binding: the block's declaration referenced nowhere", + summary: + "the block's declaration binds `X`, referenced nowhere (a valid, " + + "unused binding, SPEC 2.1), and the composition would otherwise be " + + "well-formed — refused all the same, judged over the moved text as " + + "it stands (SPEC 6.5)", + files: { + [M17_ORIGIN]: stagedMdx( + "T6.5-17/T6.6-3/T14-7 arm (c) specs/a.mdx", + m17Origin([M17_X_DECLARATION], "body"), + ), + [M17_TARGET]: M17_TARGET_STAGED, + [M17_X]: M17_X_STAGED, + }, + argv: [...M17_ARGV], + locations: m17Locations([M17_X_DECLARATION]), + }, + { + key: "(d) beside refused-invalid-id: the new ID `then`, no intrinsic-validity qualifier", + summary: + "(a)'s moved text under the `<new-id>` `then` — a forbidden segment " + + "(SPEC 1.4) — reports `refused-invalid-id` and `refused-moved-import` " + + "both: the reason is judged over the moved text as it stands, under no " + + "intrinsic-validity qualifier, unlike `refused-invalid-rewrite` " + + "(SPEC 6.5, 14)", + files: M17_A_FILES, + argv: ["move", "specs/a.mdx#m", "specs/b.mdx#then"], + locations: m17Locations([M17_X_DECLARATION]), + beside: ["refused-invalid-id"], + }, +]; + +// (e) the positive counterpart: the same workspace with the declaration +// moved to the file's top-level ESM block and a second reference through +// `X` standing outside the moved subtree (the sibling `k`), so the origin's +// declaration keeps a use. The move is performed: the origin's deletion +// leaves the sibling and the kept declaration byte-for-byte (its emptied +// line dropped with its terminator, 3); the moved text lands at the +// target's end after its final terminator (a line start, no terminator +// added) with its embedding re-rooted through an added binding of `x.mdx`'s +// module (T6.5-10's fourth direction); and the added declaration's +// admissible offsets, judged over the target as every other edit leaves it +// (SPEC 6.5), are: offset 0, absorbing the `<S id="p">` line (underivable); +// the `x` line's start and the `</S>` line's start, inside `p` (excluded); +// the end of the `</S>` line before its terminator, deriving yet mid-line +// (T6.5-13(h)'s forced placement, not taken while a line-start offset is +// admissible); the pre-operation file's end, where the moved text lands, +// absorbing its `<S id="m">` line (underivable); the moved text's interior +// line starts, inside `m` (the blank line after its opening tag derives, +// heading a block inside `m` — excluded); and the file's end after the +// moved text's closing tag and terminator, deriving — the line-start +// admissible offset "the file's end after a final terminator included, +// taken over any other" (6.5: "the moved text's closing tag, U+000A, the +// declaration, U+000A — no empty line between"). The composition is +// therefore pinned, value-blind in the fresh identifier alone (read back +// off the declaration line, T6.5-13's reading); `check` and `build` clean. +const M17_CONTROL_ORIGIN_KEPT = `${M17_X_DECLARATION}\n\n<S id="k">z {text(X.a)}</S>\n`; +const M17_CONTROL_MOVED = '<S id="m">\n\nbody {text(X.a)}\n</S>'; +const M17_CONTROL: R16ControlArm = { + key: "(e) control: the declaration in the top-level block, a second reference outside the moved subtree", + summary: + "the origin keeps its declaration byte-for-byte for the sibling's " + + "remaining use and loses the moved construct with its line; the target " + + "gains the moved text after its final terminator, the embedding " + + "re-rooted through an added binding of `x.mdx`'s module, the " + + "declaration on the line after the moved text's closing tag — the one " + + "line-start admissible offset (SPEC 6.5, 2.1, 3, 14.20)", + files: { + [M17_ORIGIN]: stagedMdx( + "T6.5-17 (e) control specs/a.mdx", + `${M17_CONTROL_ORIGIN_KEPT}${M17_CONTROL_MOVED}\n`, + ), + [M17_TARGET]: M17_TARGET_STAGED, + [M17_X]: M17_X_STAGED, + }, + argv: [...M17_ARGV], + expected: { [M17_ORIGIN]: M17_CONTROL_ORIGIN_KEPT }, + added: { + rel: M17_TARGET, + specifier: M17_X_SPECIFIER, + compose: (ident) => + `${M17_TARGET_SOURCE}<S id="m">\n\nbody {text(${ident}.a)}\n</S>\nimport ${ident} from "${M17_X_SPECIFIER}"\n`, + }, +}; + +/** + * Every MDX form T6.5-17 stages or asserts as the control's result, for the + * S-9 self-test: each refused arm's files (T2.1-6's in-section block, which + * derives), the control's files, its expected origin, and its target + * composed with the identifier `X`. + */ +export const M17_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + ...M17_REFUSED_ARMS.flatMap((arm) => + Object.entries(arm.files).map( + ([rel, text]) => + [`T6.5-17 ${arm.key}: ${rel} as staged`, stagedText(text)] as const, + ), + ), + ...Object.entries(M17_CONTROL.files).map( + ([rel, text]) => + [ + `T6.5-17 ${M17_CONTROL.key}: ${rel} as staged`, + stagedText(text), + ] as const, + ), + ...Object.entries(M17_CONTROL.expected).map( + ([rel, text]) => + [`T6.5-17 ${M17_CONTROL.key}: ${rel} after the move`, text] as const, + ), + [ + `T6.5-17 ${M17_CONTROL.key}: ${M17_CONTROL.added!.rel} after the move, composed with X`, + M17_CONTROL.added!.compose("X"), + ] as const, +]; + +/** + * The refusal's report: exactly one finding per applicable reason — + * `refused-moved-import` once per operation however many declarations the + * moved text holds, plus the arm's `beside` reasons, none further — the + * `refused-moved-import` finding's `locations` exactly each declaration's + * own characters in the origin in start order, its `identities` exactly + * `[]`, its `path` null (SPEC 6.5, 14, 11.4, 12.7). + */ +function m17AssertFinding( + findings: readonly Finding[], + arm: M17RefusedArm, + context: string, +): void { + const expectedCodes = ["refused-moved-import", ...(arm.beside ?? [])]; + const actualCodes = r16Codes(findings); + if (!r16SameCodes(actualCodes, expectedCodes)) { + fail( + `${context}: the report holds exactly one finding per applicable ` + + `reason — ${JSON.stringify(expectedCodes)}, \`refused-moved-import\` ` + + `once per operation however many declarations the moved text ` + + `holds, reported beside every other applicable reason and under no ` + + `intrinsic-validity qualifier — ${arm.summary} (SPEC 6.5, 14, ` + + `12.7); got ${JSON.stringify(actualCodes)}`, + ); + } + const finding = findings.find( + (candidate) => candidate.code === "refused-moved-import", + )!; + const actualRendered = finding.locations + .map( + (location) => + `${renderPathValue(location.file)} [${String(location.range.start)}, ` + + `${String(location.range.end)})`, + ) + .join("; "); + const exact = + finding.locations.length === arm.locations.length && + arm.locations.every((expected, index) => { + const location = finding.locations[index]!; + return ( + location.file === expected.file && + location.range.start === expected.start && + location.range.end === expected.end + ); + }); + if (!exact) { + fail( + `${context}: the finding's \`locations\` are exactly ` + + `[${r16RenderLocations(arm.locations)}] — each import declaration ` + + `the moved text holds, by its own characters in the origin file ` + + `(the import range of 11.4, its terminator excluded), in start ` + + `order, in pre-operation coordinates (SPEC 14, 11.4, 1.7, 12.7); ` + + `got [${actualRendered}] (message: ${JSON.stringify(finding.message)})`, + ); + } + assertFindingIdentities( + finding, + [], + `${context}: the finding's \`identities\` — empty: the reason concerns ` + + `no identity (SPEC 14, 12.7)`, + ); + if (finding.path !== null) { + fail( + `${context}: the finding's \`path\` is null — a refusal locating in ` + + `source concerns no path (SPEC 14, 12.7); got ` + + `${renderPathValue(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } +} + +async function runM17RefusedArm( + product: ProductBinding, + arm: M17RefusedArm, +): Promise<void> { + const context = `T6.5-17 ${arm.key}`; + const command = arm.argv.join(" "); + await withWorkspace(arm.files, async (workspace) => { + // Premise: the pre-move workspace is valid (6.4's precondition), the + // in-section block deriving (T2.1-6's positive observation repeated), + // so the refusal is the moved text's alone; the build also lays down + // the derived files the compare below covers. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging — the pre-move workspace is ` + + `valid, the section's ESM block deriving inside it (SPEC 6.4, 6.5, ` + + `14.20; T2.1-6)`, + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + const findings = await runFindingsReport( + product, + workspace, + [...arm.argv, "--json"], + 1, + `${context} \`${command} --json\` — refused: ${arm.summary}; ` + + `exit 1 with the form-exact 12.7 findings-only report ` + + `(SPEC 6.5, 14, 12.0, 12.7)`, + ); + m17AssertFinding(findings, arm, context); + }, + `${context}: \`${command}\` refused — modifies nothing: every source ` + + `and derived file byte-identical, the journal absent or ` + + `byte-unchanged (SPEC 6.5, 14)`, + ); + }); +} + +const T6_5_17 = defineProductTest({ + id: "T6.5-17", + title: + "refused-moved-import: a section-form move whose moved text holds an import declaration is refused, judged over the moved text as it stands, whatever the edits would leave — exit 1, nothing modified (the workspace byte-compared, the journal absent or byte-unchanged), exactly one `refused-moved-import` finding whose `locations` are each such declaration's own characters in the origin file (the import range of 11.4) in start order, its `identities` exactly [], its `path` null — from a valid pre-move workspace whose origin section holds T2.1-6's blank-line-separated ESM block (the pre-move `build` exit 0 repeating that positive observation): (a) one declaration in the block; (b) two, `import X …` and `import Y …` on successive lines, two locations; (c) an unused binding, the block's declaration referenced nowhere, refused all the same; (d) reported beside every other applicable reason and under no intrinsic-validity qualifier — the `<new-id>` `then` reports `refused-invalid-id` and `refused-moved-import` both; and (e) the positive counterpart performed: the declaration in the file's top-level block and a second reference through `X` in a sibling, the move succeeding with the moved embedding re-rooted through an added binding of `x.mdx`'s module in the target, placed at the one line-start admissible offset after the moved text's closing tag, the origin's declaration kept byte-for-byte for its remaining use, `check` and `build` clean (SPEC 6.5, 14, 11.4, 12.7, 2.1, 3)", + run: async (product) => { + for (const arm of M17_REFUSED_ARMS) { + await runM17RefusedArm(product, arm); + } + await runR16ControlArm(product, M17_CONTROL, "T6.5-17"); + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-18: shadow-aware, value-level binding choice. SPEC 6.5 roots a +// rewritten spelling at a binding the file already holds only where no +// local declaration shadows it at the occurrence (4.5) — "one the file +// already holds that no local declaration shadows at the occurrence" — and +// otherwise, the file holding none, at the binding of the declaration the +// operation adds, an import being added exactly where the file lacks a +// binding the spelling is rooted at. T6.5-7's TS arm exercises the +// unshadowed choice (the existing target binding used, no import added); +// this test the shadowed one, whose wrong outcome is silent to the product +// alone, and the value-level one: a spelling in external form is rooted at +// a value-level binding of its target's module (4.5), a binding introduced +// type-only being a type-level name (4), a chain rooted at which records no +// edge and raises no finding (T4-4). +// +// Four arms, each its own workspace over `specs/origin.mdx` holding `x` +// (beside a kept sibling `w`, so the origin stays a non-empty file after +// the deletion — a staging choice the entry leaves open), `specs/target.mdx` +// holding `z`, and `src/c.ts`, each under +// `move specs/origin.mdx#x specs/target.mdx#y`: +// - base: `import T`, then `import O`, the module-scope marker `T.z`, and a +// function `f` holding `const T = 1` beside the marker `O.x` — the +// inner-scope `T` shadows the import within `f` (T4.5-4) and is no +// same-scope collision of 14.15 (T4.5-8), `O.x` resolving through the +// unshadowed `O`. The marker is rooted at a fresh `<F>` an added +// `import <F>` binds — equal to `T` under no reading, the module-scope +// import and the local of `f` both binding it (6.5's freshness spanning +// the local, T6.5-9). A product rooting by name at the shadowed `T` +// writes `T.y`, adds no import, and records no edge from `f`: a chain +// rooted at a local is no spec reference (4.5). +// - (a): the target module's default bound type-only (`import type T`), `T` +// spelled at type level alone (`let v: typeof T.z`): the marker `O.x` is +// rooted at an added `import <F>`, never at `T`. +// - (b): the target module's `text` bound type-only (`{ type text as tt }` +// beside the value-level default `T`): the call `textO(O.x)`, its target +// carried into the target module, is rewritten whole to `<Y>(T.y)` through +// an added `import { text as <Y> }` (or `{ text }`), never `tt(T.y)`. +// - callee: the callee side of the shadow rule — `import T, { text as tt }` +// and `f` holding `const tt = 1` beside the call `t(O.x)`: the call +// becomes `<Y>(T.y)` through the same added declaration, never +// `tt(T.y)`, which calls the local. +// In each the origin declaration — line 2, directly after the target +// module's declaration heading the file — loses its last use and is +// removed with its line (3), and the added line stands where that line +// stood: the removal's start and its end compose to one position, each +// following a statement's end and timely (6.5), while every later line +// start is untimely, a statement other than an import declaration (`T.z`, +// `let v: …`) standing between it and the origin declaration — the file's +// one line-start admissible offset, pinned under T6.5-8's diff-isolated +// discipline (`assertExactDeclarationInsertion`, the fresh identifier read +// off the rewritten occurrence). Everything staged is ASCII, so string +// lengths are byte counts (1.7). + +const A18_ORIGIN = "specs/origin.mdx"; +const A18_TARGET = "specs/target.mdx"; +const A18_APP = "src/c.ts"; +const A18_ARGV = ["move", "specs/origin.mdx#x", "specs/target.mdx#y"] as const; +const A18_MOVE_LABEL = A18_ARGV.join(" "); +/** The target module's canonical relative specifier from `src/` (SPEC 6.5). */ +const A18_TARGET_SPECIFIER = "../specs/target.xspec"; + +const A18_ORIGIN_TEXT = [ + '<S id="x">', + "Origin x text.", + "</S>", + "", + '<S id="w">', + "Kept w text.", + "</S>", + "", +].join("\n"); + +// The origin and target are ledger records (S-9's before-any-product +// clause; helpers/staged-mdx.ts): every arm after the first stages them +// after the body's first product invocation. +const A18_ORIGIN_BEFORE = stagedMdx( + "T6.5-18 specs/origin.mdx", + A18_ORIGIN_TEXT, +); + +// Composed from SPEC 6.5 and 3: the moved construct's own characters are +// deleted in place; the line that deletion leaves empty is dropped with its +// terminator; the blank line after it was blank before the deletion and +// stays, so the file opens with that terminator (T6.5-12's removal arm). +const A18_ORIGIN_AFTER = ["", '<S id="w">', "Kept w text.", "</S>", ""].join( + "\n", +); + +const A18_TARGET_TEXT = ['<S id="z">', "Target z text.", "</S>", ""].join("\n"); + +const A18_TARGET_BEFORE = stagedMdx( + "T6.5-18 specs/target.mdx", + A18_TARGET_TEXT, +); + +// Composed from SPEC 6.5: a top-level `y`, so the moved text — re-identified +// by prefix replacement `x` → `y` — is inserted at the end of the file, +// followed by U+000A; the final line is terminated, so the insertion point +// lies at a line start and no preceding U+000A is added (T6.5-8's TS arm). +const A18_TARGET_AFTER = [ + '<S id="z">', + "Target z text.", + "</S>", + '<S id="y">', + "Origin x text.", + "</S>", + "", +].join("\n"); + +/** Every MDX form T6.5-18 stages or asserts as the move's result (S-9). */ +export const A18_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + [`T6.5-18: ${A18_ORIGIN} as staged`, A18_ORIGIN_TEXT], + [`T6.5-18: ${A18_TARGET} as staged`, A18_TARGET_TEXT], + [`T6.5-18: ${A18_ORIGIN} after the move`, A18_ORIGIN_AFTER], + [`T6.5-18: ${A18_TARGET} after the move`, A18_TARGET_AFTER], +]; + +// The declarations the arms' `src/c.ts` hold, spelled as TEST-SPEC T6.5-18 +// gives them: single spaces, no statement terminator. +const A18_T_DEFAULT = 'import T from "../specs/target.xspec"'; +const A18_O_DEFAULT = 'import O from "../specs/origin.xspec"'; +const A18_T_TYPE_ONLY = 'import type T from "../specs/target.xspec"'; +const A18_T_TYPE_TEXT = + 'import T, { type text as tt } from "../specs/target.xspec"'; +const A18_O_TEXT_O = 'import O, { text as textO } from "../specs/origin.xspec"'; +const A18_T_TEXT = 'import T, { text as tt } from "../specs/target.xspec"'; +const A18_O_TEXT_T = 'import O, { text as t } from "../specs/origin.xspec"'; + +/** A complete edge set of one kind, with why it is the expected one. */ +interface A18EdgeSet { + readonly kind: "references" | "embeds"; + readonly edges: readonly GraphEdge[]; + readonly why: string; +} + +/** One arm's staging and expectations, as the table spells them. */ +interface A18ArmSpec { + /** The arm's label in diagnoses: `base`, `(a)`, `(b)`, or `callee`. */ + readonly label: string; + /** The staging in words, for the premise diagnoses. */ + readonly summary: string; + /** Line 1: the target module's declaration, kept byte-for-byte. */ + readonly targetDeclaration: string; + /** Line 2: the origin declaration, its last use gone after the move. */ + readonly originDeclaration: string; + /** The lines after the two declarations, each terminated by U+000A. */ + readonly rest: readonly string[]; + /** The occurrence the move rewrites, as staged (occurring once). */ + readonly occurrence: string; + /** + * A marker chain, rewritten to `<F>.y` through an added default binding, + * or a whole `text(...)` call, rewritten to `<Y>(T.y)` through an added + * `text` binding and the held `T` (SPEC 6.5, 4.3, 5.7). + */ + readonly shape: "marker" | "call"; + /** + * The held binding the wrong outcome roots at (`T` for a marker, `tt` + * for a callee), with why 6.5 bars it there. + */ + readonly barred: { readonly name: string; readonly why: string }; + /** The base arm's premise: the complete `references` set before the move. */ + readonly before?: A18EdgeSet; + /** The complete edge set of one kind after the move. */ + readonly after: A18EdgeSet; + /** Why the workspace is clean after the move, for the `check` diagnosis. */ + readonly clean: string; + /** Why the file compiles clean after the move, for the H-2 diagnosis. */ + readonly compiles: string; +} + +/** An arm with its staged `src/c.ts` and the byte ranges its edits span. */ +interface A18Arm extends A18ArmSpec { + /** `src/c.ts` as staged: a ledger record (S-9's timing clause). */ + readonly code: StagedTs; + /** The staged text of `src/c.ts`. */ + readonly staged: string; + /** The origin declaration with its line's terminator (SPEC 6.5, 3; 6.6). */ + readonly removal: { readonly start: number; readonly end: number }; + /** The occurrence's span (SPEC 5.7), in pre-operation coordinates. */ + readonly rewrite: { readonly start: number; readonly end: number }; +} + +/** + * Build an arm at module load: its `src/c.ts` record (every arm after the + * first stages it after the body's first product invocation, S-9) and its + * edits' pre-operation ranges; throws — a harness defect — unless the + * staging is ASCII (string lengths then byte counts) and holds the + * occurrence exactly once, after the origin declaration's line. + */ +function a18Arm(spec: A18ArmSpec): A18Arm { + const staged = [ + spec.targetDeclaration, + spec.originDeclaration, + ...spec.rest, + "", + ].join("\n"); + for (let i = 0; i < staged.length; i += 1) { + if (staged.charCodeAt(i) > 0x7f) { + throw new Error( + `T6.5-18 ${spec.label}: the staged ${A18_APP} must be ASCII, so ` + + "that string lengths are byte counts (a harness defect)", + ); + } + } + const removalStart = spec.targetDeclaration.length + 1; + const removalEnd = removalStart + spec.originDeclaration.length + 1; + const at = staged.indexOf(spec.occurrence); + if (at < removalEnd || staged.indexOf(spec.occurrence, at + 1) >= 0) { + throw new Error( + `T6.5-18 ${spec.label}: the staged ${A18_APP} must hold the ` + + `occurrence ${JSON.stringify(spec.occurrence)} exactly once, after ` + + "the origin declaration's line (a harness defect)", + ); + } + return { + ...spec, + code: stagedTs(`T6.5-18 ${spec.label} ${A18_APP}`, staged), + staged, + removal: { start: removalStart, end: removalEnd }, + rewrite: { start: at, end: at + spec.occurrence.length }, + }; +} + +const A18_ARMS: readonly A18Arm[] = [ + a18Arm({ + label: "base", + summary: + "`import T`, then `import O`, the module-scope marker `T.z`, and `f` " + + "holding `const T = 1` beside the marker `O.x` — the local `T` " + + "shadows the import within `f` (SPEC 4.5; T4.5-4) and is no " + + "same-scope collision of 14.15 (T4.5-8), `O.x` resolving through the " + + "unshadowed `O`", + targetDeclaration: A18_T_DEFAULT, + originDeclaration: A18_O_DEFAULT, + rest: ["T.z", "function f() {", " const T = 1", " O.x", "}"], + occurrence: "O.x", + shape: "marker", + barred: { + name: "T", + why: + "rooted by name at `T`, which the local `const T = 1` shadows at " + + "the occurrence, so the chain is no spec reference (SPEC 4.5): 6.5 " + + "roots a rewritten spelling at a binding the file already holds " + + "only where no local declaration shadows it at the occurrence, and " + + "otherwise at the binding of the declaration it adds — a fresh " + + "identifier equal to `T` under no reading, the module-scope import " + + "and the local of `f` both binding it (SPEC 6.5, 2.1; T6.5-9)", + }, + before: { + kind: "references", + edges: [ + { from: `${A18_APP}#f`, to: `${A18_ORIGIN}#x`, kind: "references" }, + { from: A18_APP, to: `${A18_TARGET}#z`, kind: "references" }, + ], + why: + "`O.x` through the unshadowed `O`, attributed to `f`, and `T.z` " + + "attributed to the file (SPEC 4.5, 4.6, 5.2)", + }, + after: { + kind: "references", + edges: [ + { from: `${A18_APP}#f`, to: `${A18_TARGET}#y`, kind: "references" }, + { from: A18_APP, to: `${A18_TARGET}#z`, kind: "references" }, + ], + why: + "the rewritten marker reported from `src/c.ts#f` under the moved " + + "node's new identity through the fresh binding, beside `T.z`'s " + + "edge as before the move; a chain rooted by name at the shadowed " + + "`T` records no edge (SPEC 6.5, 4.5, 4.6, 5.2)", + }, + clean: + "the added import and the rewritten marker resolve (no 14.7), and " + + "two imports binding one module under distinct identifiers collide " + + "with nothing (2.1, 14.15)", + compiles: + "the added import binds an identifier colliding with no binding of " + + "the file (TS2440, TS2300), and the rewritten marker resolves " + + "against the regenerated modules through it, never through the " + + "local `const T = 1` (SPEC 6.5, 4.5; H-2)", + }), + a18Arm({ + label: "(a)", + summary: + "`import type T`, then `import O`, `let v: typeof T.z`, and the " + + "marker `O.x` — the target module's default bound type-only, `T` " + + "spelled at type level alone", + targetDeclaration: A18_T_TYPE_ONLY, + originDeclaration: A18_O_DEFAULT, + rest: ["let v: typeof T.z", "O.x"], + occurrence: "O.x", + shape: "marker", + barred: { + name: "T", + why: + "rooted at `T`, which `import type T` binds type-only — a " + + "type-level name (SPEC 4), a chain rooted at it recording no edge " + + "and raising no finding (4.5; T4-4) — while 6.5 roots a spelling in " + + "external form at a value-level binding of its target's module, " + + "and, the file holding none, at the binding of the declaration it " + + "adds: a fresh identifier equal to `T` under no reading, a " + + "declaration of the file binding it (SPEC 6.5, 2.1; T6.5-9)", + }, + after: { + kind: "references", + edges: [{ from: A18_APP, to: `${A18_TARGET}#y`, kind: "references" }], + why: + "the rewritten marker, at module scope, reported from `src/c.ts` " + + "under the moved node's new identity through the added value-level " + + "binding; `typeof T.z`, rooted at the type-only `T`, a type-level " + + "name, records none, nor would a marker rooted there (SPEC 6.5, 4, " + + "4.5, 4.6; T4-4)", + }, + clean: + "the added import and the rewritten marker resolve (no 14.7), and the " + + "type-only import and the added one of the same module collide with " + + "nothing (2.1, 14.15)", + compiles: + "the added import binds a fresh value-level identifier through which " + + "the rewritten marker resolves against the regenerated modules — " + + "`T`, bound type-only, cannot be used as a value (TS1361) (SPEC 6.5, " + + "4, 4.5; H-2)", + }), + a18Arm({ + label: "(b)", + summary: + "`import T, { type text as tt }`, then `import O, { text as textO }`, " + + "`T.z`, and the call `textO(O.x)` — the target module's `text` bound " + + "type-only beside its value-level default `T`", + targetDeclaration: A18_T_TYPE_TEXT, + originDeclaration: A18_O_TEXT_O, + rest: ["T.z", "textO(O.x)"], + occurrence: "textO(O.x)", + shape: "call", + barred: { + name: "tt", + why: + "`{ type text as tt }` binds the target module's `text` type-only, " + + "a type-level name (SPEC 4) no value-level call can use — a " + + "consumer type error — while 6.5 roots a call's callee at a " + + "value-level `text` binding of its module, and, the file holding " + + "none, at the binding of a declaration it adds binding that " + + "module's `text` alone (SPEC 6.5, 4.3, 4.5)", + }, + after: { + kind: "embeds", + edges: [{ from: A18_APP, to: `${A18_TARGET}#y`, kind: "embeds" }], + why: + "the rewritten call, at module scope, reported from `src/c.ts` " + + "under the moved node's new identity (SPEC 6.5, 4.3, 4.6, 5.2)", + }, + clean: + "the rewritten call passes a node of the target module to that " + + "module's `text` (no 14.11, no 14.18), and the added import collides " + + "with nothing (2.1, 14.15)", + compiles: + "the added `text` binding is value-level and fresh, and the rewritten " + + "call resolves through it — `tt`, bound type-only, cannot be called " + + "as a value (TS1361) (SPEC 6.5, 4, 4.3; H-2)", + }), + a18Arm({ + label: "callee", + summary: + "`import T, { text as tt }`, then `import O, { text as t }`, `T.z`, " + + "and `f` holding `const tt = 1` beside the call `t(O.x)` — the local " + + "`tt` shadows the import within `f` as the base arm's `T` does", + targetDeclaration: A18_T_TEXT, + originDeclaration: A18_O_TEXT_T, + rest: ["T.z", "function f() {", " const tt = 1", " t(O.x)", "}"], + occurrence: "t(O.x)", + shape: "call", + barred: { + name: "tt", + why: + "rooted by name at the import's `tt`, which the local `const tt = " + + "1` shadows at the call — a node passed to a function other than " + + "`text`, which `check` reports (14.18), and a consumer type error " + + "besides: 6.5 roots a call's callee at its module's `text` binding, " + + "a held one only where no local declaration shadows it at the " + + "occurrence, and otherwise at the binding of a declaration it adds " + + "binding that module's `text` alone (SPEC 6.5, 4.3, 4.5)", + }, + after: { + kind: "embeds", + edges: [{ from: `${A18_APP}#f`, to: `${A18_TARGET}#y`, kind: "embeds" }], + why: + "the rewritten call reported from `src/c.ts#f` under the moved " + + "node's new identity (SPEC 6.5, 4.3, 4.6, 5.2)", + }, + clean: + "the rewritten call passes its node to the target module's `text`, " + + "not to the local `tt` (no 14.18), and the added import collides " + + "with nothing (2.1, 14.15)", + compiles: + "the added `text` binding is fresh and unshadowed at the call, and " + + "the rewritten call resolves through it — the local `tt`, a number, " + + "is not callable (TS2349) (SPEC 6.5, 4.3, 4.5; H-2)", + }), +]; + +// The rewritten marker: a root not preceded by an identifier character or a +// `.`, then `.y` not followed by one (`T.z`, `typeof T.z`, and the +// declarations never match). +const A18_MARKER_REWRITTEN = + /(?<![A-Za-z0-9_$.])([A-Za-z_$][A-Za-z0-9_$]*)\.y(?![A-Za-z0-9_$])/g; + +// The rewritten call: a callee not preceded by an identifier character or a +// `.`, then `(`, the argument's root, `.y`, and `)`. +const A18_CALL_REWRITTEN = + /(?<![A-Za-z0-9_$.])([A-Za-z_$][A-Za-z0-9_$]*)\(([A-Za-z_$][A-Za-z0-9_$]*)\.y\)/g; + +/** A rewritten occurrence and the declaration its fresh binding needs. */ +interface A18Rewrite { + /** The rewritten occurrence's characters, in 6.4's pinned spelling. */ + readonly occurrence: string; + /** The added declaration's exact characters (SPEC 6.5's spelling). */ + readonly declaration: string; +} + +/** + * Read the rewritten occurrence off `src/c.ts` after the move — the fresh + * binding value-unpinned (SPEC 6.5), read off the one place 6.4's pinned + * spelling makes it observable (dot access, `y` being identifier-valid) — + * and diagnose the wrong outcomes: an occurrence not spelled as 6.4 pins + * it or rewritten more or less than once, one rooted at the arm's barred + * held binding, and a call whose argument is not re-rooted at the held `T` + * or whose callee is `T`. + */ +function a18ReadRewrite( + arm: A18Arm, + text: string, + context: string, +): A18Rewrite { + if (arm.shape === "marker") { + const matches = [...text.matchAll(A18_MARKER_REWRITTEN)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${A18_APP} after the move must hold exactly one ` + + `marker \`<binding>.y\` — the marker on the moved node rewritten, ` + + `over its occurrence span (5.7), to the moved node's new identity ` + + `through a value-level binding of the target module in 6.4's ` + + `pinned spelling (dot access, \`y\` being identifier-valid; SPEC ` + + `6.5, 6.4); found ${String(matches.length)} in ` + + `${JSON.stringify(text)}`, + ); + } + if (root === arm.barred.name) { + fail( + `${context}: the marker is rewritten to \`${root}.y\` — ` + + `${arm.barred.why}; the file reads ${JSON.stringify(text)}`, + ); + } + return { + occurrence: `${root}.y`, + declaration: `import ${root} from "${A18_TARGET_SPECIFIER}"`, + }; + } + const matches = [...text.matchAll(A18_CALL_REWRITTEN)]; + const match = matches.length === 1 ? matches[0] : undefined; + const callee = match?.[1]; + const root = match?.[2]; + if (callee === undefined || root === undefined) { + fail( + `${context}: ${A18_APP} after the move must hold exactly one call ` + + `\`<callee>(<binding>.y)\` — the call whose target the move carries ` + + `into the target module rewritten whole, over its occurrence's span ` + + `(5.7), callee and argument rooted at bindings of that module, the ` + + `argument in 6.4's pinned spelling (SPEC 6.5, 6.4, 4.3); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + const shown = `\`${callee}(${root}.y)\``; + if (callee === arm.barred.name) { + fail( + `${context}: the call is rewritten to ${shown}, its callee ` + + `\`${callee}\` — ${arm.barred.why}; the file reads ` + + `${JSON.stringify(text)}`, + ); + } + if (root !== "T") { + fail( + `${context}: the call is rewritten to ${shown} — its argument must ` + + `be re-rooted at the held \`T\`, the target module's default ` + + `binding: value-level, unshadowed at the occurrence, and timely, ` + + `its declaration preceding the origin declaration's — so the ` + + `declaration added binds that module's \`text\` alone, with no ` + + `default binding (SPEC 6.5, 4.5); the file reads ` + + `${JSON.stringify(text)}`, + ); + } + if (callee === "T") { + fail( + `${context}: the call is rewritten to ${shown} — its callee is the ` + + `held default binding \`T\`, not the target module's \`text\`: the ` + + `added declaration binds that module's \`text\` to a fresh ` + + `identifier, equal to neither \`T\` nor \`tt\`, which declarations ` + + `of the file already bind (SPEC 6.5, 2.1, 4.3); the file reads ` + + `${JSON.stringify(text)}`, + ); + } + const named = callee === "text" ? "{ text }" : `{ text as ${callee} }`; + return { + occurrence: `${callee}(T.y)`, + declaration: `import ${named} from "${A18_TARGET_SPECIFIER}"`, + }; +} + +/** + * `src/c.ts`'s expected post-move bytes WITHOUT the added declaration (SPEC + * 6.4/6.5, 3): the origin declaration removed with its line — its own + * characters deleted in place leave the line empty, so it is dropped with + * its terminator — the occurrence's span (5.7) replaced by its rewritten + * spelling, every other byte as staged. + */ +function a18Compose(arm: A18Arm, rewritten: string): string { + const staged = arm.staged; + return ( + staged.slice(0, arm.removal.start) + + staged.slice(arm.removal.end, arm.rewrite.start) + + rewritten + + staged.slice(arm.rewrite.end) + ); +} + +/** + * `src/c.ts`'s preview edits with the addition at the pre-operation offset + * `addition`: the `import-removal` spanning the origin declaration with its + * adjunct drop, the zero-length `import-addition`, and the + * `reference-rewrite` spanning the occurrence — in 12.7's order: range + * start, then range end, then class-name bytes (SPEC 6.6, 12.7; T6.6-4). + */ +function a18PreviewEdits( + arm: A18Arm, + addition: number, +): readonly PreviewEdit[] { + const edits: PreviewEdit[] = [ + { + class: "import-removal", + range: { start: arm.removal.start, end: arm.removal.end }, + }, + { class: "import-addition", range: { start: addition, end: addition } }, + { + class: "reference-rewrite", + range: { start: arm.rewrite.start, end: arm.rewrite.end }, + }, + ]; + 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"), + ), + ); +} + +/** The preview's entry for `src/c.ts`, taken before the real move (SPEC 6.6: a preview modifies nothing). */ +async function a18PreviewEntry( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<PreviewFileEntry> { + const label = `${context} \`${A18_MOVE_LABEL} --preview --json\` before the real move`; + const report = decodePreviewReport( + await runJson( + product, + workspace, + [...A18_ARGV, "--preview", "--json"], + label, + ), + label, + ); + if (report.files === null) { + fail( + `${label}: a preview exiting 0 succeeds as the real operation would ` + + `and reports its \`files\` — \`null\` is a refused preview's form ` + + `(SPEC 6.6, 12.7); findings: ${JSON.stringify(report.findings)}`, + ); + } + const entries = report.files.filter( + (candidate) => candidate.file === A18_APP, + ); + const entry = entries.length === 1 ? entries[0] : undefined; + if (entry === undefined) { + fail( + `${label}: \`files\` holds exactly one entry for ${A18_APP}, a file ` + + `the operation rewrites — its occurrence rewrite, import addition, ` + + `and import removal (SPEC 6.6, 12.7); got ` + + `[${report.files.map((candidate) => JSON.stringify(candidate.file)).join(", ")}]`, + ); + } + return entry; +} + +/** + * Preview parity (SPEC 6.6, 12.7): `src/c.ts`'s edits are exactly one + * `reference-rewrite` spanning the occurrence, one zero-length + * `import-addition` at the origin declaration's removal's start or at its + * end — the one composed position those two make, where the real operation + * inserts the declaration (the bytes pinned there), the choice between + * them 6.5's latitude (T6.6-4(b)) — and one `import-removal` spanning the + * origin declaration with its adjunct drop, in 12.7's order. + */ +function a18AssertPreviewEdits( + entry: PreviewFileEntry, + arm: A18Arm, + context: string, +): void { + const candidates = [arm.removal.start, arm.removal.end].map((offset) => + a18PreviewEdits(arm, offset), + ); + const actual = entry.edits.map((edit) => ({ + class: edit.class, + range: { start: edit.range.start, end: edit.range.end }, + })); + const shown = JSON.stringify(actual); + if (candidates.some((candidate) => JSON.stringify(candidate) === shown)) { + return; + } + fail( + `${context} preview: ${A18_APP}'s edits are exactly ` + + candidates.map((candidate) => JSON.stringify(candidate)).join(" or ") + + ` — one \`reference-rewrite\` spanning the ` + + `${arm.shape === "marker" ? "marker" : "call"}'s occurrence (5.7), ` + + `one zero-length \`import-addition\` at the origin declaration's ` + + `removal's start or at its end (the one composed position those two ` + + `make, where the real operation inserts the declaration; T6.6-4(b)), ` + + `and one \`import-removal\` spanning the origin declaration with its ` + + `adjunct drop, ordered by range start, then range end, then ` + + `class-name bytes (SPEC 6.6, 12.7; T6.6-4); got ${shown}`, + ); +} + +/** + * Stage one arm, take the preview, run the move, and assert its outcome: + * `src/c.ts` is its composed post-move bytes with exactly the declaration + * the lacked binding requires added where the origin declaration's line + * stood, the origin and target files are as composed, the preview's edits + * for `src/c.ts` are the move's, the arm's edge set is exact, `check` and + * `build` are clean, and the file compiles clean (H-2). + */ +async function runA18Arm(product: ProductBinding, arm: A18Arm): Promise<void> { + const context = `T6.5-18 ${arm.label}`; + await withWorkspace( + { + [A18_ORIGIN]: A18_ORIGIN_BEFORE, + [A18_TARGET]: A18_TARGET_BEFORE, + [A18_APP]: arm.code, + }, + async (workspace) => { + // Premise: a valid workspace — so a later failure is the move's, not + // the staging's. + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context} premise \`build --json\` over the staging — clean: ` + + arm.summary, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context} premise \`check --json\` after that build — clean: the ` + + `staging is valid and nothing is stale (SPEC 12.2, 14.10)`, + ); + // Fixture self-check through H-2's standard-tooling channel: the + // staging compiles clean, so a later diagnostic is the move's. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [A18_APP], + }), + `${context} premise: ${A18_APP} compiles clean before the move ` + + `under standard tooling — ${arm.summary}; the generated modules ` + + `resolve (SPEC 4, 13.1; a fixture self-check)`, + ); + if (arm.before !== undefined) { + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, arm.before.kind, context), + arm.before.edges, + `${context} premise: the complete \`${arm.before.kind}\` edge set ` + + `before the move — ${arm.before.why}`, + ); + } + + // The preview first: it modifies nothing (6.6), and its edits for + // `src/c.ts` are asserted once the real operation's bytes are. + const previewEntry = await a18PreviewEntry(product, workspace, context); + + await expectExit( + product, + workspace, + [...A18_ARGV], + 0, + `${context} \`${A18_MOVE_LABEL}\` — a valid move over the workspace ` + + `the premise \`build\` accepted succeeds (SPEC 6.5)`, + ); + + const actual = await workspace.readBytes(A18_APP); + const rewrite = a18ReadRewrite( + arm, + Buffer.from(actual).toString("utf8"), + context, + ); + // T6.5-8's diff-isolated discipline: the file composed without the + // added declaration, the single added run isolated by diff and read + // as the exact declaration under 6.5's line discipline. + const readings = assertExactDeclarationInsertion( + { + rel: A18_APP, + base: Buffer.from(a18Compose(arm, rewrite.occurrence), "utf8"), + actual, + declaration: rewrite.declaration, + }, + `${context}: ${A18_APP} after the move is its composed post-move ` + + `bytes — the origin declaration removed with its line (its last ` + + `use gone; SPEC 6.5, 3), the occurrence's span replaced by ` + + `${JSON.stringify(rewrite.occurrence)} (5.7), every other byte ` + + `as staged — with exactly one declaration added, binding the ` + + `lacked binding: ${JSON.stringify(rewrite.declaration)} followed ` + + `by U+000A (single spaces, no statement terminator, the specifier ` + + `double-quoted in its canonical relative spelling from src/; SPEC ` + + `6.5, 2.1, 6.4, 3; T6.5-8)`, + ); + // Bytes are the only observable: the pin holds exactly when the run + // reads as the disciplined declaration where the origin + // declaration's line stood — the start of line 2 of the composed + // text, directly after the target module's declaration. + const pinned = arm.removal.start; + if (!readings.some((reading) => reading.offset === pinned)) { + fail( + `${context}: ${A18_APP} — the added declaration ` + + `${JSON.stringify(rewrite.declaration)} followed by U+000A must ` + + `stand where the origin declaration's line stood, directly after ` + + `the target module's declaration (offset ${String(pinned)} of the ` + + `composed text, the start of its line 2): the removal's start ` + + `and its end make that one composed position, each following a ` + + `statement's end and timely, while every later line start is ` + + `untimely, a statement other than an import declaration standing ` + + `between it and the origin declaration — the file's one ` + + `line-start admissible offset, taken over any other (SPEC 6.5, 3; ` + + `T6.5-8's discipline); the inserted run reads instead at ` + + `composed offset(s) ` + + `${readings.map((reading) => String(reading.offset)).join(", ")}`, + ); + } + await assertFileBytes( + workspace.path(A18_ORIGIN), + A18_ORIGIN_AFTER, + `${context}: ${A18_ORIGIN} after the move — the moved construct ` + + `deleted in place, the line its deletion empties dropped with its ` + + `terminator, the blank line that was blank before kept, the ` + + `sibling untouched (SPEC 6.5, 3; H-4, normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(A18_TARGET), + A18_TARGET_AFTER, + `${context}: ${A18_TARGET} after the move — the re-identified moved ` + + `text appended after the final terminator plus U+000A, otherwise ` + + `byte-identical (SPEC 6.5; H-4, normalizing nothing)`, + ); + + a18AssertPreviewEdits(previewEntry, arm, context); + + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, arm.after.kind, context), + arm.after.edges, + `${context}: the complete \`${arm.after.kind}\` edge set after the ` + + `move — ${arm.after.why}`, + ); + await assertCleanAfterMove(product, workspace, arm.clean, context); + // A fresh project: the language service snapshots files on first + // access, and the move rewrote them. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [A18_APP], + }), + `${context}: ${A18_APP} after the move compiles with no ` + + `diagnostics under standard tooling — ${arm.compiles}`, + ); + }, + SPEC_AND_CODE_CONFIG, + ); +} + +const T6_5_18 = defineProductTest({ + id: "T6.5-18", + title: + "shadow-aware, value-level binding choice: over `specs/origin.mdx` holding `x` and `specs/target.mdx` holding `z`, `move specs/origin.mdx#x specs/target.mdx#y` roots each rewritten spelling in `src/c.ts` at a value-level binding of the target module that no local declaration shadows at the occurrence, and otherwise at the binding of a declaration it adds — four arms, each a valid workspace before the move (`build` and `check` clean, the file compiling clean under standard tooling): the base arm (`import T`, then `import O`, the module-scope marker `T.z`, and `f` holding `const T = 1` beside the marker `O.x`) gains `import <F> from \"../specs/target.xspec\"`, `<F>` never `T`, the marker becoming `<F>.y`, `query edges` reporting `src/c.ts#f` → `specs/target.mdx#y` beside `T.z`'s edge as before; (a) the target's default bound type-only (`import type T`, then `import O`, `let v: typeof T.z`, `O.x`) becomes exactly `import type T`, `import <F>`, `let v: typeof T.z`, `<F>.y`, with the marker's `references` edge from `src/c.ts`; (b) the target's `text` bound type-only (`import T, { type text as tt }`, then `import O, { text as textO }`, `T.z`, `textO(O.x)`) gains exactly `import { text as <Y> } from \"../specs/target.xspec\"` (or `{ text }`), the call becoming `<Y>(T.y)`, with its `embeds` edge from `src/c.ts`; and the callee shadow (`import T, { text as tt }`, then `import O, { text as t }`, `T.z`, and `f` holding `const tt = 1` beside `t(O.x)`) gains the same declaration, the call becoming `<Y>(T.y)`, with its `embeds` edge from `src/c.ts#f` — `<Y>` never `T` or `tt`; in each the origin declaration removed with its line and the single added run exactly the declaration followed by U+000A where that line stood, directly after the target module's declaration — the file's one line-start admissible offset — no other byte changed (T6.5-8's diff-isolated discipline), the origin and target files byte-equal to their compositions, `check` and `build` clean afterward, the file compiling clean (H-2), and the `--preview` taken before the move reporting for `src/c.ts` exactly one `reference-rewrite` spanning the marker or the call, one `import-addition` at the origin declaration's removal's start or end, and one `import-removal` spanning it with its adjunct drop (6.6, 12.7; T6.6-4) — a product rooting at the shadowed or type-only binding writes `T.y` or `tt(T.y)`, adds no import, and fails the byte contract (SPEC 6.5, 4, 4.3, 4.5, 4.6, 2.1, 3, 5.7, 6.6, 12.7)", + run: async (product) => { + for (const arm of A18_ARMS) { + await runA18Arm(product, arm); + } + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-19 The in-section exclusion +// --------------------------------------------------------------------------- +// +// SPEC 6.5: in a spec source an admissible offset is one at which the file, +// as every edit of the rewrite leaves it, is well-formed with the added line +// an import declaration of an ESM block standing inside no section +// construct of the file so left, the inserted one included. An ESM block +// derives inside a section element (14.20; T2.1-6), so derivability does +// not decide the exclusion — and no arm of T6.5-13 or T6.5-16(g) does +// either: each in-section line start there also absorbs the following line +// or follows a paragraph line, and the in-section offsets that derive are +// mid-line, never preferred. Each receiving file here holds exactly one +// line start at which the added line derives as a declaration — inside a +// section, at the start of an interior empty line — and exactly one +// admissible offset, mid-line at the file's end, so the two readings take +// different offsets: a product judging admissibility by derivability and +// T6.5-13(l)'s exclusion alone, then applying the line-start preference, +// inserts at the empty line's start — the workspace valid, `check` clean, +// every node's own content as it was, the declaration's line dropping +// whole (3) — and fails the byte contract and the preview's offset while +// passing T6.5-13 whole. (a) judges the target file; (b) the origin file as +// its deletion leaves it ("of the file so left"), the declaration it +// asserts being the origin's own (T6.5-13(i)'s conversion). Both arms are +// composed and observed as T6.5-13's are (`runA13Arm`): value-blind in the +// fresh identifier alone, the receiving root's own text and ownHash through +// `query node` before and after, the real bytes agreeing with the preview's +// offsets (T6.6-4(b)), `build` and `check` clean after each move. Every +// form here derives under the grammar 14.20 fixes (S-9), the excluded +// in-section forms included: the entry's named offsets are probed under +// `deriveMdx` before the move — the in-section and paragraph-text forms +// deriving, the absorbing ones not — a staging premise (a +// `HarnessStagingError`), never a verdict. + +/** (a)'s target: an interior empty line inside `p`, the `</S>` line unterminated. */ +const A19_A_TARGET = ['<S id="p">', "", "x", "</S>"].join("\n"); +const A19_A_STAGED = stagedMdx("T6.5-19 arm (a) specs/b.mdx", A19_A_TARGET); +/** (a)'s target as the target insertion alone leaves it, the declaration absent (6.5's composed text; the identifier `X`). */ +const A19_A_COMPOSED = [ + '<S id="p">', + "", + "x", + ...a13MovedLines("p.n", "X"), + "</S>", +].join("\n"); +const A19_A_DECLARATION = a13Declaration("X"); +/** The end of (a)'s tag line before its terminator; the empty line starts one byte on, the `x` line two. */ +const A19_A_TAG_END = A19_A_COMPOSED.indexOf("\n"); + +/** (a)'s composition: the moved text before `</S>`, the tag's line ended by the added terminator, then the declaration. */ +function a19ComposeIntoP(ident: string): string { + return [ + '<S id="p">', + "", + "x", + ...a13MovedLines("p.n", ident), + "</S>", + a13Declaration(ident), + "", + ].join("\n"); +} + +const A19_A_ARM: A13Arm = a13CrossArm({ + key: "(a)", + summary: + "the target side — offset 0 would absorb the tag's line into the " + + "block; the start of the empty line heads a block that empty line " + + "ends, deriving inside `p`: a line start, yet inadmissible; the end of " + + "the tag's line heads such a block too, mid-line; the start of the `x` " + + "line would absorb that line; the end of the `x` line leaves the added " + + "line paragraph text; at the start of the `</S>` line, the insertion " + + "point, the declaration would stand after the moved text and absorb " + + "the `</S>` line; so the file's end, mid-line after `</S>`, is the " + + "only admissible offset: the added terminator ends the `</S>` line, " + + "which drops as it did before, then the declaration and its terminator", + target: A19_A_STAGED, + newId: "p.n", + compose: a19ComposeIntoP, + previewEdits: [ + a13At("target-insertion", A19_A_TARGET.indexOf("</S>")), + a13At("import-addition", A19_A_TARGET.length), + ], + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: false, +}); + +// (b): the origin `<S id="m">`, U+000A, `z`, U+000A, `</S>`, U+000A, +// `<S id="a" d={"m"}>`, U+000A, U+000A, `y`, U+000A, `</S>` with no final +// terminator; `m` moved to the top level of an existing target needing no +// declaration (the moved text local to its subtree), its ID kept; the +// origin's own `d={"m"}` converts to `d={<T>.m}` through the target +// module's declaration the origin lacks. The deletion's range runs from the +// file's start through line 3's terminator — the construct's own +// characters and the terminator of the line their removal leaves empty +// (SPEC 3, 6.6) — leaving `<S id="a" d={<T>.m}>`, U+000A, U+000A, `y`, +// U+000A, `</S>`, over which the file's end is the only admissible offset. +const A19_B_MOVED = ['<S id="m">', "z", "</S>"].join("\n"); +const A19_B_ORIGIN_BEFORE = [ + A19_B_MOVED, + '<S id="a" d={"m"}>', + "", + "y", + "</S>", +].join("\n"); +const A19_B_ORIGIN_STAGED = stagedMdx( + "T6.5-19 arm (b) specs/a.mdx", + A19_B_ORIGIN_BEFORE, +); +/** The origin deletion's end: the construct's own characters plus line 3's terminator. */ +const A19_B_DELETION_END = A19_B_MOVED.length + 1; +/** The `d` reference occurrence: that one reference's own expression, `"m"` (SPEC 5.7). */ +const A19_B_REFERENCE = A19_B_ORIGIN_BEFORE.indexOf('d={"m"}') + 3; +const A19_B_TARGET_AFTER = `${A13_EXISTING_TARGET}${A19_B_MOVED}\n`; +/** (b)'s origin as the deletion and the reference rewrite leave it, the declaration absent (the identifier `X`). */ +const A19_B_COMPOSED = ['<S id="a" d={X.m}>', "", "y", "</S>"].join("\n"); +const A19_B_DECLARATION = a13Declaration("X", A13_TARGET_SPECIFIER); +/** The end of (b)'s tag line before its terminator; the empty line starts one byte on, the `y` line two. */ +const A19_B_TAG_END = A19_B_COMPOSED.indexOf("\n"); + +/** (b)'s composition: the converted reference, the `</S>` line ended by the added terminator, then the target module's declaration. */ +function a19ComposeOriginDeclaration( + idents: readonly string[], +): readonly string[] { + const [t = ""] = idents; + return [ + [ + `<S id="a" d={${t}.m}>`, + "", + "y", + "</S>", + a13Declaration(t, A13_TARGET_SPECIFIER), + "", + ].join("\n"), + ]; +} + +const A19_B_ARM: A13Arm = { + key: "(b)", + summary: + "the origin side, `of the file so left` — over the origin as the " + + "deletion leaves it, the composed file's start would absorb the tag's " + + "line into the block; the start of the empty line heads a block that " + + "line ends, deriving inside `a`: a line start, yet inadmissible; the " + + "end of the tag's line heads such a block mid-line; the start of the " + + "`y` line would absorb that line; the end of the `y` line and the " + + "start of the `</S>` line each leave the added line paragraph text; so " + + "the file's end, mid-line after `</S>`, is the only admissible offset: " + + "the added terminator ends the `</S>` line, which drops as it did " + + "before, then the target module's declaration and its terminator", + files: { + [A13_ORIGIN]: A19_B_ORIGIN_STAGED, + [A13_TARGET]: A13_K_STAGED, + }, + argv: ["move", `${A13_ORIGIN}#m`, `${A13_TARGET}#m`], + receiving: A13_ORIGIN, + added: [A13_TARGET_SPECIFIER], + compose: a19ComposeOriginDeclaration, + others: [ + { + rel: A13_TARGET, + bytes: A19_B_TARGET_AFTER, + reason: + "the moved text appended at the file's end after its final " + + "terminator — a line start, so none is added before it — followed " + + "by its own, the ID kept", + }, + ], + previewEdits: [ + [ + a13Span("origin-deletion", 0, A19_B_DELETION_END), + a13Span("reference-rewrite", A19_B_REFERENCE, A19_B_REFERENCE + 3), + a13At("import-addition", A19_B_ORIGIN_BEFORE.length), + ], + ], + root: { + identity: A13_ORIGIN, + ownTextBefore: "", + ownTextAfter: "", + ownHashChanges: true, + }, +}; + +/** + * One arm of T6.5-19: T6.5-13's arm plus the entry's named offsets over the + * receiving file as every other edit leaves it, each probed with the + * declaration inserted per 6.5's terminator rule (`r16Declared`). + */ +interface A19Arm { + readonly arm: A13Arm; + /** The receiving file as every other edit of the rewrite leaves it, the declaration absent. */ + readonly composed: string; + /** The entry's named offsets, each with the verdict it states. */ + readonly probes: readonly R16OffsetProbe[]; +} + +const A19_A: A19Arm = { + arm: A19_A_ARM, + composed: A19_A_COMPOSED, + probes: [ + r16Probe( + "offset 0, absorbing the tag's line into the block", + A19_A_COMPOSED, + 0, + false, + A19_A_DECLARATION, + ), + r16Probe( + "the end of the tag's line before its terminator, heading a block inside `p` (mid-line)", + A19_A_COMPOSED, + A19_A_TAG_END, + true, + A19_A_DECLARATION, + ), + r16Probe( + "the start of the empty line, heading a block that line ends inside `p` (a line start, inadmissible)", + A19_A_COMPOSED, + A19_A_TAG_END + 1, + true, + A19_A_DECLARATION, + ), + r16Probe( + "the start of the `x` line, absorbing that line", + A19_A_COMPOSED, + A19_A_TAG_END + 2, + false, + A19_A_DECLARATION, + ), + r16Probe( + "the end of the `x` line, leaving the added line paragraph text", + A19_A_COMPOSED, + A19_A_TAG_END + 3, + true, + A19_A_DECLARATION, + ), + r16Probe( + "the start of the `</S>` line after the moved text (the insertion point), absorbing the `</S>` line", + A19_A_COMPOSED, + A19_A_COMPOSED.lastIndexOf("</S>"), + false, + A19_A_DECLARATION, + ), + r16Probe( + "the file's end, mid-line after `</S>` — the one admissible offset, the result", + A19_A_COMPOSED, + A19_A_COMPOSED.length, + true, + A19_A_DECLARATION, + ), + ], +}; + +const A19_B: A19Arm = { + arm: A19_B_ARM, + composed: A19_B_COMPOSED, + probes: [ + r16Probe( + "the composed file's start, absorbing the tag's line into the block", + A19_B_COMPOSED, + 0, + false, + A19_B_DECLARATION, + ), + r16Probe( + "the end of the tag's line before its terminator, heading a block inside `a` (mid-line)", + A19_B_COMPOSED, + A19_B_TAG_END, + true, + A19_B_DECLARATION, + ), + r16Probe( + "the start of the empty line, heading a block that line ends inside `a` (a line start, inadmissible)", + A19_B_COMPOSED, + A19_B_TAG_END + 1, + true, + A19_B_DECLARATION, + ), + r16Probe( + "the start of the `y` line, absorbing that line", + A19_B_COMPOSED, + A19_B_TAG_END + 2, + false, + A19_B_DECLARATION, + ), + r16Probe( + "the end of the `y` line, leaving the added line paragraph text", + A19_B_COMPOSED, + A19_B_TAG_END + 3, + true, + A19_B_DECLARATION, + ), + r16Probe( + "the start of the `</S>` line, leaving the added line paragraph text", + A19_B_COMPOSED, + A19_B_COMPOSED.lastIndexOf("</S>"), + true, + A19_B_DECLARATION, + ), + r16Probe( + "the file's end, mid-line after `</S>` — the one admissible offset, the result", + A19_B_COMPOSED, + A19_B_COMPOSED.length, + true, + A19_B_DECLARATION, + ), + ], +}; + +const A19_ARMS: readonly A19Arm[] = [A19_A, A19_B]; + +/** The vectors of `entry`'s probes holding the verdict `derives`, named by arm, file, and offset. */ +function a19ProbeVectors( + entry: A19Arm, + derives: boolean, +): readonly (readonly [name: string, source: string])[] { + return entry.probes + .filter((probe) => probe.derives === derives) + .map( + (probe) => + [ + `T6.5-19 ${entry.arm.key}: ${entry.arm.receiving} with the declaration at ${probe.name}`, + probe.text, + ] as const, + ); +} + +/** + * Every MDX form T6.5-19 stages, composes as the other edits leave a file, + * or names as deriving — the excluded in-section forms included, the + * exclusion, not derivability, deciding them (S-9). + */ +export const A19_FORM_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [ + [`T6.5-19 (a): ${A13_ORIGIN} as staged`, A13_ORIGIN_BEFORE], + [`T6.5-19 (a): ${A13_THIRD} as staged`, A13_THIRD_SOURCE], + [`T6.5-19 (a): ${A13_TARGET} as staged`, A19_A_TARGET], + [`T6.5-19 (a): ${A13_ORIGIN} after the move`, A13_ORIGIN_AFTER], + [ + `T6.5-19 (a): ${A13_TARGET} as the target insertion leaves it`, + A19_A_COMPOSED, + ], + ...a19ProbeVectors(A19_A, true), + [`T6.5-19 (b): ${A13_ORIGIN} as staged`, A19_B_ORIGIN_BEFORE], + [`T6.5-19 (b): ${A13_TARGET} as staged`, A13_EXISTING_TARGET], + [`T6.5-19 (b): ${A13_TARGET} after the move`, A19_B_TARGET_AFTER], + [ + `T6.5-19 (b): ${A13_ORIGIN} as the deletion and the reference rewrite leave it`, + A19_B_COMPOSED, + ], + ...a19ProbeVectors(A19_B, true), +]; + +/** + * Every offset T6.5-19 names as absorbing the line after it — offset 0, the + * start of the body line, and (a)'s insertion point after the moved text — + * heads a block that runs on into a tag or prose line: none derives (S-9). + */ +export const A19_UNDERIVABLE_VECTORS: ReadonlyArray< + readonly [name: string, source: string] +> = [...a19ProbeVectors(A19_A, false), ...a19ProbeVectors(A19_B, false)]; + +/** A form the entry names as absorbing must not derive (S-9): a staging defect, never a verdict. */ +function a19AssertUnderivable(text: string, rel: string, key: string): void { + if (!deriveMdx(text).derives) return; + throw new HarnessStagingError( + "mdx-derivability", + rel, + `T6.5-19 ${key}: ${rel} derives under the stock MDX 3 grammar, where ` + + `the entry names the offset as absorbing the line after it — the ` + + `arm's premise, not a product verdict; the text reads ${JSON.stringify(text)}`, + ); +} + +/** + * The entry's premises (S-9): the receiving file as every other edit + * leaves it derives, and each named offset holds the verdict the entry + * states — the in-section and paragraph-text forms deriving, the absorbing + * ones not. A contradiction is a staging defect, never a verdict. + */ +function a19AssertPremises(entry: A19Arm): void { + const rel = entry.arm.receiving; + r16AssertDerives( + entry.composed, + `${rel} as every other edit leaves it`, + entry.arm.key, + "T6.5-19", + ); + for (const probe of entry.probes) { + const where = `${rel} with the declaration at ${probe.name}`; + if (probe.derives) { + r16AssertDerives(probe.text, where, entry.arm.key, "T6.5-19"); + } else { + a19AssertUnderivable(probe.text, where, entry.arm.key); + } + } +} + +const T6_5_19 = defineProductTest({ + id: "T6.5-19", + title: + "the in-section exclusion: in a spec source 6.5 admits only an offset whose added declaration's ESM block stands inside no section construct of the file as every edit of the rewrite leaves it, the inserted one included — an ESM block derives inside a section element (14.20), so derivability does not decide the exclusion, and no arm of T6.5-13 or T6.5-16(g) does either; two byte-asserted arms composed and observed as T6.5-13's are (value-blind in the fresh identifier alone, `build` and `check` clean after each move, the receiving root's own text and ownHash compared through `query node` before and after, the real operation's bytes agreeing with the preview's offsets, T6.6-4(b)), each receiving file holding exactly one line start at which the added line derives as a declaration — inside a section, at an interior empty line's start — and exactly one admissible offset, mid-line at the file's end, so that the two readings take different offsets: (a) the target side — `<S id=\"p\">`, U+000A, U+000A, `x`, U+000A, `</S>` with no final terminator, moved into `p.n` with T6.5-13(h)'s moved text, the result exactly `<S id=\"p\">`, U+000A, U+000A, `x`, U+000A, the moved text, U+000A, `</S>`, U+000A, `import <X> from \"./x.xspec\"`, U+000A, the preview's `target-insertion` at the `</S>` line's start and `import-addition` at the file's byte length, the root's own text and ownHash unchanged; (b) the origin side, `of the file so left` — `<S id=\"m\">`, U+000A, `z`, U+000A, `</S>`, U+000A, `<S id=\"a\" d={\"m\"}>`, U+000A, U+000A, `y`, U+000A, `</S>` with no final terminator, `move specs/a.mdx#m specs/b.mdx#m` into an existing target needing no declaration, the origin's own `d={\"m\"}` converting to `d={<T>.m}` through the target module's declaration the origin lacks, the deletion's range from the file's start through line 3's terminator, the result exactly `<S id=\"a\" d={<T>.m}>`, U+000A, U+000A, `y`, U+000A, `</S>`, U+000A, `import <T> from \"./b.xspec\"`, U+000A, the preview's `origin-deletion` spanning that range, `reference-rewrite` spanning `\"m\"`, and `import-addition` at the file's byte length, the origin root `changed` by its lost child reference alone, its own text empty before and after — every form verified to derive under the stock grammar (S-9), the excluded in-section forms included, and every absorbing offset verified not to; a product reading the exclusion out of 6.5, applying it in the target file alone, or judging it by derivability inserts at the empty line's start and fails the byte contract and the preview's offset while passing T6.5-13 whole (SPEC 6.5, 6.4, 6.2, 3, 6.6, 12.7, 1.6, 5.5; H-4)", + run: async (product) => { + for (const entry of A19_ARMS) { + a19AssertPremises(entry); + await runA13Arm(product, entry.arm, "T6.5-19"); + } + }, +}); + +/** TEST-SPEC §6.5, third part, in canonical ID order (SUITE-25). */ +export const section65iiiTests: readonly ProductTestEntry[] = [ + T6_5_12, + T6_5_13, + T6_5_14, + T6_5_15, + T6_5_16, + T6_5_17, + T6_5_18, + T6_5_19, +]; diff --git a/test/suite/registry/section-6.5-iv.ts b/test/suite/registry/section-6.5-iv.ts new file mode 100644 index 00000000..7a687c9d --- /dev/null +++ b/test/suite/registry/section-6.5-iv.ts @@ -0,0 +1,2496 @@ +// TEST-SPEC §6.5 (move), fourth part — SUITE-25 (continued): T6.5-20, +// destination refusals over derived paths, T6.5-21, the +// exposed-derived-file refusal, and T6.5-22's (b) lures, barred and +// captured names ((a) is the subprocess driver's, +// helpers/added-import-identifiers.ts). T6.5-1…T6.5-10 are section-6.5.ts's +// business, T6.5-11 section-6.5-ii.ts's, and T6.5-12…T6.5-19 +// section-6.5-iii.ts's; this module keeps those files' edits bounded (the +// section-10.7-i/-ii precedent). +// +// 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, +// and rejects a product only via diagnosed assertion failures (H-8). +// +// SPEC 6.5 (`refused-invalid-destination`, 14): a move is refused when its +// destination file path — a target file to be created included — or a +// derived path it would generate is a directory component of another derived +// path the sources would generate after the move (13.1, 13.2, 7.3), or lies +// under one: the finishing regeneration's writes would then meet a plain +// file where they need a directory (14.22), or replace a directory holding a +// source or another derived path (13.4). The relation reads the derived +// paths the sources would generate, whether or not anything occupies them; +// T6.5-4's relation — a directory component OCCUPIED by a non-directory — +// stays apart wherever nothing is built, and where the two meet at one +// component (a built module path) they are one reason, one finding (14). +// The source relation — arm (c) — refuses alike a derived path the +// destination would generate that is the path of, or a directory component +// of the path of, a discovered source other than a relocated origin: the +// regeneration would replace or hide that source (13.4; an emit destination +// so added over a code source first excluding it from every group). +// +// Conservative operationalizations (noted per H-4): +// - The common contract, asserted for every refused move by +// `expectD20Refusal`: inside a whole-root modifies-nothing compare (the +// compare-around machinery VIOL-CORE-CHATTYREADS certifies; the journal +// absent or byte-unchanged with everything else), `move … --json` exits 1 +// and its stdout decodes as the form-exact 12.7 findings-only report +// holding exactly one finding — `refused-invalid-destination`, nothing +// beside it (never 14.22: a refused operation reports refusal reasons +// alone, 14) — whose `path` is the destination as spelled (for the section +// form, the target file's path, the operand before `#`) and whose +// `locations` is `[]`. Its `identities` composition is unpinned (12.7) and +// not asserted. The `--preview` twin of every refused move is T6.6-3's, +// staged identically from `d20RefusedStagings` through +// `runD20RefusedStaging` (one code path). +// - Each staging is one fresh workspace; its refused moves — the file form, +// then the section form creating the target at the file form's +// destination, keeping the ID `x` (`specs/Z.mdx` holds the one section +// `x`) — run one after the other on it: each modifies nothing (asserted), +// so the second meets the identical staging. +// - The companion legs read the companion paths of `specs/A.mdx` as +// T13.4-9(e) reads them (support.ts `readRecordedCompanionPaths`): a +// scratch twin holding, under the staging's configuration, `specs/A.mdx`'s +// bytes at that path alone is built, and the recorded entries +// `specs/A.xspec.<suffix>` other than the module are the companions — the +// product's own suffixes, none for a product writing no companions. One +// staging per companion path. +// - "Staged before any build": the staging's workspace is created and the +// moves run with no `build` before them, so no derived path is occupied +// and the relation is judged over the derived paths the sources would +// generate, never over files on disk. TEST-SPEC leaves (b)'s first staging +// open on this; it is staged before any build too, so a product judging +// the relation over what is on disk alone performs the move there as well. +// - The after-build staging re-pins its premise: after the premise `build`, +// `specs/A.xspec.ts` is a plain file (the module that build wrote, 13.1) — +// a product writing no module there fails diagnosed at the premise, never +// at a refusal the arm does not stage. +// - Arm (c), emission next to sources throughout. TEST-SPEC names the code +// group of the staging beside `specs/B.md` (`specs/*.md`) and replaces it +// ("instead") only from the staging beside `specs/B.md/x.ts` on +// (`specs/**/*.ts`), so the staging beside `specs/B.md/C.mdx` keeps the +// first; each code source holds `export const v = 1` and U+000A, as +// T13.4-11(b)'s does. The companion stagings read the destination's +// companion paths from a twin holding `specs/Z.mdx`'s bytes at +// `specs/A.mdx` under the `specs/**/*.ts` configuration (TEST-SPEC). The +// exemption's stagings hold `specs/B.md/C.mdx` alone under the first +// configuration — no `specs/Z.mdx`, which they never move — and are built +// first: the performed file form (`runD20Exemption`, after the refused +// stagings, no T6.6-3 twin) re-pins the build's Markdown and module beneath +// `specs/B.md`, and its "`specs/B.md` a plain file holding its Markdown" +// compares that file with what a twin holding the moved bytes at +// `specs/B.mdx`, freshly built under the same configuration, emits there +// (T13.4-11's twin protocol); the refused section form is a table staging +// whose premise is `specs/B.md/C.md` a plain file after the build. +// - Arm (d): TEST-SPEC names no code group for `src/c.ts`; one group +// globbing `src/**/*.ts` reaches it. Each refused staging holds +// `specs/Z.mdx` and `src/c.ts` alone and is staged before any build (the +// entry leaves this open), so nothing occupies `specs/B.md` or +// `specs/Z.md`. "Emission disabled" is spelled `markdown: { emit: false }`: +// the refused stagings' configuration but for that one boolean (SPEC 7.3: +// with `emit` false, as with `markdown` absent, no path is a Markdown emit +// destination). The controls are performed arms with no T6.6-3 twin, run +// in the body after the refused stagings and the exemption +// (`runD20Performed`): exit 0 with the performed-operation report, +// `specs/B.mdx` holding the moved bytes (the moved file holds no import +// and nothing imports it), then `check` clean. +// - Arm (e): `specs/A.mdx` holds the one section `x` and no import, a +// record of its own (unlike (a)'s). Its retired paths are the module +// `specs/A.xspec.ts`, the Markdown `specs/A.md`, and each companion path, +// read as T13.4-9(e) reads them (`readD20RetiredCompanions`). "Its +// derived paths written beneath the fresh directory" is pinned as plain +// files at the destination's module and Markdown paths and at each +// companion path that a scratch twin, holding the moved bytes at the +// destination, records (read the same way). The after-build controls are +// file-form moves alone, as the entry states them, each premise re-pinned +// (the retired path a plain file after the build); the section-form +// control is the module path's alone, staged before any build. +// +// SPEC 6.5 (`refused-exposed-derived-file`, 14): a file-form move, while +// Markdown emission is enabled, is refused when its origin's emit +// destination — no longer an emit destination once the relocation removes +// the origin, so no longer excluded from discovery (13.4) — holds an +// occupant discovery would then yield as a source (7). T6.5-21's notes: +// - The common contract, asserted for every refused move by +// `expectD21Refusal`: inside a whole-root modifies-nothing compare (the +// journal absent or byte-unchanged with everything else), `move … --json` +// exits 1 and its stdout decodes as the form-exact 12.7 findings-only +// report holding exactly the move's findings, nothing beside, in 14's +// listed order — `refused-exposed-derived-file` concerning the origin's +// emit destination `specs/A.md`, `locations` `[]`, and `identities` `[]` +// (TEST-SPEC pins the member; support.ts `IDENTITY_PINNED_REFUSAL_CODES` +// classifies the reason so), and in the two-reason move +// `refused-invalid-destination` before it, concerning the destination as +// spelled, `locations` `[]`, its `identities` unpinned (12.7) and not +// asserted. The `--preview` twin of every refused move is T6.6-3's, +// staged identically from `D21_REFUSED_STAGINGS` through +// `runD21RefusedStaging` (one code path); T12.7-2 and T14-7 stage the +// same table. +// - `specs/A.mdx` holds the section `x` and a section `y`, no import or +// reference, and nothing imports it: the file form rewrites no byte, and +// (e)'s section form leaves `y` behind, so the Markdown its regeneration +// writes in place differs from the premise build's. +// - (a)'s second spec glob `specs/*.md` joins the one spec group's globs +// (TEST-SPEC: "a second spec glob"); (b)'s plain file of the user's holds +// well-formed TypeScript (`export const v = 1`, T13.4-11(b)'s code-source +// form), so once exposed it would be a valid code source, the workspace +// otherwise valid: the refusal is 6.5's reason alone. The after-build +// stagings re-pin their premise: after the `build`, `specs/A.md` is the +// plain file that build wrote (`d21BuildPremise`). +// - The multi-reason order runs in (a)'s staging as its second refused move +// (each refused move modifies nothing, so it meets the identical +// staging), exported (`D21_TWO_REASON_MOVE`) for T12.7-2. +// - The controls are performed arms with no T6.6-3 twin. (c)'s "emits +// `specs/sub/A.md`" and (e)'s "`specs/A.md` regenerated in place, holding +// `A.mdx`'s Markdown as the move leaves it, and `specs/sub/A.md` emitted" +// are pinned by T13.4-11's twin protocol (`d21AssertLikeTwin`): a twin +// holding the post-move sources, freshly built under the same +// configuration, emits the same bytes there — in (c) the moved bytes, a +// record, at `specs/sub/A.mdx`; in (e) the moved workspace's own sources, +// carried by `copyFrom` once judged well-formed (a product's malformed +// bytes fail diagnosed, never as a staging error). (d)'s link is staged by +// section-13.4.ts's `stageLinkToOutsideFile`, the staging T13.4-11(c) +// certifies, and compared by its `assertOutsideLinkTargetUnchanged`. +// (e)'s `--preview` runs first, inside a modifies-nothing compare: exit 0, +// `findings` [], the plan members present. + +import type { Finding } from "../../helpers/adapters/index.js"; +import { + decodeAppliedMappingReport, + decodeFindingsReport, + decodePreviewReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { judgeAddedImportsOfFile } from "../../helpers/added-import-identifiers.js"; +import { + assertFileBytes, + assertFilesEqual, + fail, + HarnessAssertionError, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { deriveMdx } from "../../helpers/mdx-derivability.js"; +import { + analyzeNames, + nameVerdict, + type ReceivingFileKind, +} from "../../helpers/oracles/name-analysis.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { type StagedMdx, stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { + EntryKind, + InitialFileContents, +} from "../../helpers/workspace.js"; +import { + assertOutsideLinkTargetUnchanged, + stageLinkToOutsideFile, +} from "./section-13.4.js"; +import { + assertConditionCounts, + assertFindingConcernsPath, + assertRefusalIdentities, + buildOk, + expectExit, + expectFindingFreeReport, + readRecordedCompanionPaths, + runJson, +} from "./support.js"; + +// --------------------------------------------------------------------------- +// Stagings — every file a staged-source record (S-9's timing clause: every +// staging but the body's first follows a product invocation, and T6.6-3 +// stages them all after many) +// --------------------------------------------------------------------------- + +/** The one spec glob of every staging (TEST-SPEC: "throughout"). */ +const D20_SPEC_GLOB = "specs/**/*.mdx"; + +/** + * A configuration: the one spec group, then the one code group globbing + * `codeGlob` (or none), then `markdown` as given (or absent). + */ +function d20ConfigText( + markdown: string | null, + codeGlob: string | null = null, +): string { + const code = + codeGlob === null ? "" : `,\n code: {\n app: ["${codeGlob}"]\n }`; + const tail = markdown === null ? "" : `,\n markdown: ${markdown}`; + return ( + `import { defineConfig } from "xspec"\n\n` + + `export default defineConfig({\n` + + ` specs: {\n` + + ` main: ["${D20_SPEC_GLOB}"]\n` + + ` }${code}${tail}\n` + + `})\n` + ); +} + +const D20_SPECS_CONFIG = stagedTs( + "T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx alone, no Markdown emission ((a)'s module and companion stagings and the companion twin)", + d20ConfigText(null), +); +const D20_OUT_CONFIG = stagedTs( + "T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx, Markdown emitted under outDir \"out\" ((a)'s outDir staging, (b)'s first)", + d20ConfigText('{ emit: true, outDir: "out" }'), +); +const D20_NESTED_OUT_CONFIG = stagedTs( + 'T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx, Markdown emitted under outDir "specs/B.mdx/md" ((b)\'s second staging)', + d20ConfigText('{ emit: true, outDir: "specs/B.mdx/md" }'), +); +/** (c)'s configurations: emission next to sources, and one code group. */ +const D20_EMIT_NEXT = "{ emit: true }"; +const D20_C_MD_CONFIG = stagedTs( + "T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx, a code group globbing specs/*.md, Markdown emitted next to sources ((c)'s stagings beside specs/B.md and beside specs/B.md/C.mdx, and its exemption's)", + d20ConfigText(D20_EMIT_NEXT, "specs/*.md"), +); +const D20_C_TS_CONFIG = stagedTs( + "T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx, a code group globbing specs/**/*.ts, Markdown emitted next to sources ((c)'s stagings beside specs/B.md/x.ts, specs/A.xspec.ts/c.ts, and specs/A.xspec.<suffix>/c.ts, and the companion twin)", + d20ConfigText(D20_EMIT_NEXT, "specs/**/*.ts"), +); + +/** The moved file: the section `x` the section-form moves move. */ +const D20_Z = "specs/Z.mdx"; +const D20_Z_SOURCE = stagedMdx( + "T6.5-20/T6.6-3 specs/Z.mdx — the moved file, holding the one section x", + ['<S id="x">', "Zed text.", "</S>", ""].join("\n"), +); +/** The section the section-form moves move, keeping its ID. */ +const D20_SECTION = "x"; + +/** (a)'s discovered source, whose module path is `specs/A.xspec.ts`. */ +const D20_A = "specs/A.mdx"; +const D20_A_SOURCE = stagedMdx( + "T6.5-20/T6.6-3 specs/A.mdx — (a)'s discovered source, module path specs/A.xspec.ts (the companion twin's source as well)", + ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), +); +const D20_A_MODULE = "specs/A.xspec.ts"; + +/** (a)'s outDir staging: a discovered source emitting `out/specs/x.md`. */ +const D20_X = "specs/x.mdx"; +const D20_X_SOURCE = stagedMdx( + "T6.5-20/T6.6-3 specs/x.mdx — (a)'s outDir staging, emitting out/specs/x.md", + ['<S id="w">', "Emitted text.", "</S>", ""].join("\n"), +); + +/** (b)'s first staging: a discovered source emitting `out/specs/a.md/b.md`. */ +const D20_AB = "specs/a.md/b.mdx"; +const D20_AB_SOURCE = stagedMdx( + "T6.5-20/T6.6-3 specs/a.md/b.mdx — (b)'s discovered source, emitting out/specs/a.md/b.md", + ['<S id="b">', "Beta text.", "</S>", ""].join("\n"), +); + +/** + * (c)'s destination `specs/B.mdx`, whose emit path next to sources is + * `specs/B.md` (13.2). + */ +const D20_C_DESTINATION = "specs/B.mdx"; +const D20_C_EMIT_PATH = "specs/B.md"; + +/** + * (c)'s discovered code sources — `specs/B.md`, `specs/B.md/x.ts`, + * `specs/A.xspec.ts/c.ts`, and `specs/A.xspec.<suffix>/c.ts` — each well- + * formed TypeScript holding `export const v = 1` (TEST-SPEC: "the same + * content"). + */ +const D20_CODE_SOURCE = stagedTs( + "T6.5-20/T6.6-3 (c)'s discovered code sources specs/B.md, specs/B.md/x.ts, specs/A.xspec.ts/c.ts, and specs/A.xspec.<suffix>/c.ts — export const v = 1", + "export const v = 1\n", +); + +/** + * (c)'s discovered `specs/B.md/C.mdx`, below the emit path `specs/B.md`: + * holding no import and the one section `x` — the exemption's origin, alone + * under `specs/B.md`, of either form. + */ +const D20_BC = "specs/B.md/C.mdx"; +const D20_BC_SOURCE = stagedMdx( + "T6.5-20/T6.6-3 specs/B.md/C.mdx — (c)'s discovered source below the emit path specs/B.md, holding no import and the one section x (the exemption's origin; at specs/B.mdx, the exemption twin's source)", + ['<S id="x">', "Cee text.", "</S>", ""].join("\n"), +); + +/** + * (d)'s code group, the one glob reaching the code source `src/c.ts` + * (TEST-SPEC names none): its refused stagings and emission-enabled controls + * emit next to sources, its emission-disabled controls under the same + * configuration but for `emit: false` (SPEC 7.3: no path is then a Markdown + * emit destination). + */ +const D20_D_CODE_GLOB = "src/**/*.ts"; +const D20_D_CONFIG = stagedTs( + "T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx, a code group globbing src/**/*.ts, Markdown emitted next to sources ((d)'s refused stagings and its emission-enabled controls)", + d20ConfigText(D20_EMIT_NEXT, D20_D_CODE_GLOB), +); +const D20_D_NO_EMIT_CONFIG = stagedTs( + "T6.5-20 xspec.config.ts — specs/**/*.mdx, a code group globbing src/**/*.ts, Markdown emission disabled by emit false ((d)'s emission-disabled controls)", + d20ConfigText("{ emit: false }", D20_D_CODE_GLOB), +); + +/** (d)'s code source, holding one construct alone. */ +const D20_D_CODE = "src/c.ts"; + +/** + * One (d) staging of `src/c.ts`: the construct it holds alone, its line + * followed by U+000A, naming `specs/B.md` — the emit path of (c)'s and + * (d)'s destination `specs/B.mdx` — by the relative specifier + * `../specs/B.md`. + */ +interface D20DesignationCode { + /** The construct (diagnostics), e.g. `an import declaration`. */ + readonly construct: string; + readonly source: StagedTs; +} + +function d20DesignationCode( + tests: string, + construct: string, + line: string, +): D20DesignationCode { + return { + construct, + source: stagedTs( + `${tests} src/c.ts — (d) ${construct} alone: ${line}`, + `${line}\n`, + ), + }; +} + +/** + * (d)'s six stagings, one per module-linking form SPEC 4 names, each text + * TypeScript 5.9.3 accepts both as module code and as script code (S-9 + * judges each record both ways): with emission enabled each makes the move + * to `specs/B.mdx` refused, the specifier designating the destination's + * would-be emit path (SPEC 6.5, 4, 14.15); with emission disabled no emit + * destination arises, and the move is performed (the controls). + */ +const D20_D_FORMS: readonly D20DesignationCode[] = [ + d20DesignationCode( + "T6.5-20/T6.6-3", + "an import declaration", + 'import "../specs/B.md"', + ), + d20DesignationCode( + "T6.5-20/T6.6-3", + "an export declaration with a module specifier", + 'export * from "../specs/B.md"', + ), + d20DesignationCode( + "T6.5-20/T6.6-3", + "an import X = require(…) declaration", + 'import X = require("../specs/B.md")', + ), + d20DesignationCode( + "T6.5-20/T6.6-3", + "a dynamic import() whose specifier is a static string literal", + 'import("../specs/B.md")', + ), + d20DesignationCode( + "T6.5-20/T6.6-3", + "an import type", + 'type T = import("../specs/B.md")', + ), + d20DesignationCode( + "T6.5-20/T6.6-3", + "a string-named module declaration", + 'declare module "../specs/B.md" { }', + ), +]; + +/** + * (d)'s emission-enabled controls: the path named only by constructs that + * are no module-linking form (SPEC 4) — a `require(…)` call's argument, a + * triple-slash directive, and a dynamic `import()` whose template literal + * is no static string literal (2.4) — so the move is performed. + */ +const D20_D_NON_FORMS: readonly D20DesignationCode[] = [ + d20DesignationCode( + "T6.5-20", + "a require(…) call's argument", + 'require("../specs/B.md")', + ), + d20DesignationCode( + "T6.5-20", + "a triple-slash reference directive", + '/// <reference path="../specs/B.md" />', + ), + d20DesignationCode( + "T6.5-20", + "a dynamic import() whose specifier is a template literal", + "import(`../specs/B.md`)", + ), +]; + +/** + * (e)'s configuration: the one spec group, Markdown emitted next to + * sources, no code group. + */ +const D20_E_CONFIG = stagedTs( + "T6.5-20/T6.6-3 xspec.config.ts — specs/**/*.mdx alone, Markdown emitted next to sources ((e)'s stagings and its companion twins)", + d20ConfigText(D20_EMIT_NEXT), +); + +/** + * (e)'s only source `specs/A.mdx`, holding the one section `x` and no + * import (unlike (a)'s `specs/A.mdx`). + */ +const D20_E_A_SOURCE = stagedMdx( + "T6.5-20/T6.6-3 specs/A.mdx — (e)'s only source, holding the one section x and no import (the companion twins' source, at specs/A.mdx and at each performed destination)", + ['<S id="x">', "Alpha text.", "</S>", ""].join("\n"), +); + +/** `specs/A.mdx`'s Markdown path, emitted next to sources (13.2). */ +const D20_A_MARKDOWN = "specs/A.md"; + +/** + * One refused move of T6.5-20 (the module header's common contract): + * exported, with its staging, for T6.6-3's preview twin. + */ +export interface D20RefusedMove { + /** The move's argv, `--json` excluded. */ + readonly argv: readonly string[]; + /** + * The finding's concerned path: the destination as spelled — for the + * section form, the target file's path (SPEC 14, 6.5). + */ + readonly path: string; +} + +/** + * One refused staging of T6.5-20: a fresh workspace (`config` plus `files`), + * the premise `build` when staged after one, then each refused move in turn. + * Exported for T6.6-3, which stages each identically (TEST-SPEC T6.6-3). + */ +export interface D20RefusedStaging { + /** The arm and staging (diagnostics), e.g. `(a) module path`. */ + readonly key: string; + readonly config: StagedTs; + readonly files: Readonly<Record<string, InitialFileContents>>; + /** + * `null` for a staging staged before any build; else a derived path the + * premise `build` must leave a plain file — the occupant the staging needs + * ((a)'s built module path; for (c)'s exemption staging, the Markdown that + * build writes beneath the emit path) — its premise re-pinned (the module + * header's note). + */ + readonly builtOccupant: string | null; + readonly moves: readonly D20RefusedMove[]; +} + +/** + * The pair at one destination: the file-form move of `specs/Z.mdx` there, + * then the section-form move of its section `x` creating that target file. + */ +function d20Pair(destination: string): readonly D20RefusedMove[] { + return [ + { argv: ["move", D20_Z, destination], path: destination }, + { + argv: [ + "move", + `${D20_Z}#${D20_SECTION}`, + `${destination}#${D20_SECTION}`, + ], + path: destination, + }, + ]; +} + +/** (a)'s files: the discovered `specs/A.mdx` beside the moved file. */ +const D20_A_FILES: Readonly<Record<string, InitialFileContents>> = { + [D20_A]: D20_A_SOURCE, + [D20_Z]: D20_Z_SOURCE, +}; + +/** + * (a), the module path: the destination `specs/A.xspec.ts/B.mdx` lies under + * `specs/A.mdx`'s module path, staged before any build — nothing occupies + * the module path, so T6.5-4's occupied-component relation stays apart. + */ +const D20_A_MODULE_STAGING: D20RefusedStaging = { + key: "(a) under the module path specs/A.xspec.ts, before any build", + config: D20_SPECS_CONFIG, + files: D20_A_FILES, + builtOccupant: null, + moves: d20Pair(`${D20_A_MODULE}/B.mdx`), +}; + +/** + * (a), one companion path `specs/A.xspec.<suffix>` of `specs/A.mdx` (read + * by `readRecordedCompanionPaths`): the destination + * `specs/A.xspec.<suffix>/B.mdx`, staged before any build — a product whose + * relation leaves out companions performs it, its regeneration then writing + * the companion over the directory holding the moved file. + */ +function d20CompanionStaging(companion: string): D20RefusedStaging { + return { + key: `(a) under the companion path ${companion}, before any build`, + config: D20_SPECS_CONFIG, + files: D20_A_FILES, + builtOccupant: null, + moves: d20Pair(`${companion}/B.mdx`), + }; +} + +/** + * (a), the module path after a `build`: `specs/A.xspec.ts` is then the plain + * file that build wrote, occupying a directory component of the destination + * (T6.5-4's relation) while being the derived path the destination lies + * under — one reason either way, exactly one finding per move (SPEC 6.5, + * 14). + */ +const D20_A_MODULE_BUILT_STAGING: D20RefusedStaging = { + key: "(a) under the module path specs/A.xspec.ts, after a build (the two relations meeting at one component)", + config: D20_SPECS_CONFIG, + files: D20_A_FILES, + builtOccupant: D20_A_MODULE, + moves: d20Pair(`${D20_A_MODULE}/B.mdx`), +}; + +/** + * (a), a derived path of the destination under one while the destination + * itself lies under none: under `outDir: "out"` beside `specs/x.mdx` + * (emitting `out/specs/x.md`), the destination `specs/x.md/y.mdx` emits + * `out/specs/x.md/y.md`, under `out/specs/x.md`; `specs/x.md`, the + * destination's own component, is no derived path. Staged before any build. + */ +const D20_A_OUTDIR_STAGING: D20RefusedStaging = { + key: "(a) the destination's emit path out/specs/x.md/y.md under out/specs/x.md, before any build", + config: D20_OUT_CONFIG, + files: { [D20_X]: D20_X_SOURCE, [D20_Z]: D20_Z_SOURCE }, + builtOccupant: null, + moves: d20Pair("specs/x.md/y.mdx"), +}; + +/** + * (b), a directory component of another derived path: under `outDir: + * "out"` beside `specs/a.md/b.mdx` (emitting `out/specs/a.md/b.md`), the + * destination `specs/a.mdx` emits `out/specs/a.md`, a directory component of + * that path. Staged before any build (the module header's note). + */ +const D20_B_COMPONENT_STAGING: D20RefusedStaging = { + key: "(b) the destination's emit path out/specs/a.md a directory component of out/specs/a.md/b.md, before any build", + config: D20_OUT_CONFIG, + files: { [D20_AB]: D20_AB_SOURCE, [D20_Z]: D20_Z_SOURCE }, + builtOccupant: null, + moves: d20Pair("specs/a.mdx"), +}; + +/** + * (b), the destination itself such a component: under `outDir: + * "specs/B.mdx/md"`, staged before any build so nothing occupies + * `specs/B.mdx` (`refused-destination-exists` otherwise, T6.5-4), every emit + * destination after the move — the destination's own + * `specs/B.mdx/md/specs/B.md` among them — lies under the destination's + * path. + */ +const D20_B_DESTINATION_STAGING: D20RefusedStaging = { + key: '(b) the destination specs/B.mdx a directory component of every emit destination under outDir "specs/B.mdx/md", before any build', + config: D20_NESTED_OUT_CONFIG, + files: { [D20_Z]: D20_Z_SOURCE }, + builtOccupant: null, + moves: d20Pair("specs/B.mdx"), +}; + +/** + * (c), a source replaced: beside a discovered code source `specs/B.md` (the + * code group globbing `specs/*.md`), the destination `specs/B.mdx` emits + * `specs/B.md`, that source's path — an emit destination so added first + * excluding the source from every group (13.4). Every (c) staging is staged + * before any build, so nothing occupies `specs/Z.md` (the module header's + * note). + */ +const D20_C_CODE_STAGING: D20RefusedStaging = { + key: `(c) the emit path ${D20_C_EMIT_PATH} the path of a discovered code source, before any build`, + config: D20_C_MD_CONFIG, + files: { [D20_Z]: D20_Z_SOURCE, [D20_C_EMIT_PATH]: D20_CODE_SOURCE }, + builtOccupant: null, + moves: d20Pair(D20_C_DESTINATION), +}; + +/** + * (c), a source hidden: separately, beside a discovered `specs/B.md/C.mdx` + * (the same configuration), the emit path `specs/B.md` a directory + * component of that source's path — `C.mdx`'s derived paths, under the + * emit path, meeting (b)'s relation as well: one reason, one finding. + */ +const D20_C_SPEC_STAGING: D20RefusedStaging = { + key: `(c) the emit path ${D20_C_EMIT_PATH} a directory component of the discovered ${D20_BC}, before any build`, + config: D20_C_MD_CONFIG, + files: { [D20_Z]: D20_Z_SOURCE, [D20_BC]: D20_BC_SOURCE }, + builtOccupant: null, + moves: d20Pair(D20_C_DESTINATION), +}; + +/** + * (c), beside a discovered code source `specs/B.md/x.ts`, the only file + * beneath `specs/B.md` (the code group globbing `specs/**\/*.ts` instead): + * a code source generates no derived path, so the source relation alone + * refuses — a product vetting derived paths against sources for equality + * alone performs it. + */ +const D20_C_BENEATH_STAGING: D20RefusedStaging = { + key: `(c) the emit path ${D20_C_EMIT_PATH} a directory component of the discovered code source ${D20_C_EMIT_PATH}/x.ts, before any build`, + config: D20_C_TS_CONFIG, + files: { + [D20_Z]: D20_Z_SOURCE, + [`${D20_C_EMIT_PATH}/x.ts`]: D20_CODE_SOURCE, + }, + builtOccupant: null, + moves: d20Pair(D20_C_DESTINATION), +}; + +/** + * (c), `move specs/Z.mdx specs/A.mdx` beside a discovered code source + * `specs/A.xspec.ts/c.ts` (that group; its file name holding no `.xspec.`, + * so no exclusion applies, 13.4): the destination's module path + * `specs/A.xspec.ts` a directory component of its path. + */ +const D20_C_MODULE_STAGING: D20RefusedStaging = { + key: `(c) the module path ${D20_A_MODULE} a directory component of the discovered code source ${D20_A_MODULE}/c.ts, before any build`, + config: D20_C_TS_CONFIG, + files: { [D20_Z]: D20_Z_SOURCE, [`${D20_A_MODULE}/c.ts`]: D20_CODE_SOURCE }, + builtOccupant: null, + moves: d20Pair(D20_A), +}; + +/** + * (c), separately, one staging per companion path `specs/A.xspec.<suffix>` + * of the destination (read by `readRecordedCompanionPaths` from a twin + * holding `specs/Z.mdx`'s bytes at `specs/A.mdx`): beside a discovered code + * source `specs/A.xspec.<suffix>/c.ts` instead — a product whose relations + * leave out companions performs it, its regeneration writing the plain file + * over the directory and so deleting the source (13.4). + */ +function d20CodeCompanionStaging(companion: string): D20RefusedStaging { + return { + key: `(c) the companion path ${companion} a directory component of the discovered code source ${companion}/c.ts, before any build`, + config: D20_C_TS_CONFIG, + files: { [D20_Z]: D20_Z_SOURCE, [`${companion}/c.ts`]: D20_CODE_SOURCE }, + builtOccupant: null, + moves: d20Pair(D20_A), + }; +} + +/** + * (c)'s exemption staging under the section form, which relocates no + * origin: after a `build`, `move specs/B.md/C.mdx#x specs/B.mdx#x` creating + * the target `specs/B.mdx` — the source left below the emit path being no + * relocated origin. Its premise: the `build` wrote `C.mdx`'s Markdown + * beneath the emit path. + */ +const D20_C_EXEMPTION_SECTION_STAGING: D20RefusedStaging = { + key: `(c) the exemption staging under the section form, after a build: ${D20_BC} below the emit path ${D20_C_EMIT_PATH} no relocated origin`, + config: D20_C_MD_CONFIG, + files: { [D20_BC]: D20_BC_SOURCE }, + builtOccupant: `${D20_C_EMIT_PATH}/C.md`, + moves: [ + { + argv: [ + "move", + `${D20_BC}#${D20_SECTION}`, + `${D20_C_DESTINATION}#${D20_SECTION}`, + ], + path: D20_C_DESTINATION, + }, + ], +}; + +/** + * (d), a module-linking form made to designate a derived-file path: with + * emission next to sources, the code source `src/c.ts` holds one form + * alone, its relative specifier `../specs/B.md` — valid while no + * `specs/B.mdx` exists, `specs/B.md` then no derived-file path — and the + * move to `specs/B.mdx` (of either form) would make it the destination's + * emit path (SPEC 6.5, 4, 14.15). Staged before any build (the module + * header's note). + */ +function d20DesignationStaging(code: D20DesignationCode): D20RefusedStaging { + return { + key: `(d) ${D20_D_CODE} holding ${code.construct} alone, designating the destination's emit path ${D20_C_EMIT_PATH}, before any build`, + config: D20_D_CONFIG, + files: { [D20_Z]: D20_Z_SOURCE, [D20_D_CODE]: code.source }, + builtOccupant: null, + moves: d20Pair(D20_C_DESTINATION), + }; +} + +/** + * (e)'s retired paths: `specs/A.mdx`'s module, its Markdown, then each of + * its companion paths (`companions`, from `readD20RetiredCompanions`) — + * generated by no source once a file-form move relocates `specs/A.mdx`. + */ +function d20RetiredPaths(companions: readonly string[]): readonly string[] { + return [D20_A_MODULE, D20_A_MARKDOWN, ...companions]; +} + +/** + * (e)'s companion paths of `specs/A.mdx`, read as T13.4-9(e) reads them: a + * scratch twin holding (e)'s `specs/A.mdx` alone under (e)'s configuration + * (`readRecordedCompanionPaths`; none for a product writing no companions). + */ +async function readD20RetiredCompanions( + product: ProductBinding, + context: string, +): Promise<readonly string[]> { + return readRecordedCompanionPaths( + product, + D20_E_CONFIG, + D20_A, + D20_E_A_SOURCE, + `${context} (e)'s companion paths of ${D20_A}`, + ); +} + +/** + * (e)'s refused controls, each performed arm's file-form move staged after + * a `build` instead: the retired path `retired` is then the plain file + * that build wrote, occupying a directory component of the destination + * `<retired>/B.mdx` — refused by T6.5-4's relation alone (SPEC 6.5). + */ +function d20RetiredBuiltStaging(retired: string): D20RefusedStaging { + const destination = `${retired}/B.mdx`; + return { + key: `(e) under the retired path ${retired}, after a build (T6.5-4's relation alone, ${retired} the plain file that build wrote)`, + config: D20_E_CONFIG, + files: { [D20_A]: D20_E_A_SOURCE }, + builtOccupant: retired, + moves: [{ argv: ["move", D20_A, destination], path: destination }], + }; +} + +/** + * (e)'s section-form control, staged before any build: `move + * specs/A.mdx#x specs/A.xspec.ts/B.mdx#x` creating the target relocates no + * origin, so `specs/A.mdx` still generates `specs/A.xspec.ts` after the + * move and the target lies under that derived path (SPEC 6.5). + */ +const D20_E_SECTION_STAGING: D20RefusedStaging = { + key: `(e) the section form under the module path ${D20_A_MODULE}, before any build (relocating no origin, ${D20_A} still generating it)`, + config: D20_E_CONFIG, + files: { [D20_A]: D20_E_A_SOURCE }, + builtOccupant: null, + moves: [ + { + argv: [ + "move", + `${D20_A}#${D20_SECTION}`, + `${D20_A_MODULE}/B.mdx#${D20_SECTION}`, + ], + path: `${D20_A_MODULE}/B.mdx`, + }, + ], +}; + +/** + * Every refused staging of T6.5-20, in the entry's order — (d)'s six, then + * (c)'s exemption staging under the section form, as the entry's + * section-form paragraph states it, then (e)'s controls — the companion legs + * read first (`readRecordedCompanionPaths`, as T13.4-9(e) reads them: for + * (a), a scratch twin holding `specs/A.mdx`'s bytes alone under (a)'s + * configuration; for (c), one holding `specs/Z.mdx`'s bytes at + * `specs/A.mdx` under (c)'s `specs/**\/*.ts` configuration; for (e), one + * holding (e)'s `specs/A.mdx` alone under (e)'s configuration). Exported + * for T6.6-3, whose preview twins stage each identically; the reads are the + * caller's first product invocations of the table. + */ +export async function d20RefusedStagings( + product: ProductBinding, + context: string, +): Promise<readonly D20RefusedStaging[]> { + const companions = await readRecordedCompanionPaths( + product, + D20_SPECS_CONFIG, + D20_A, + D20_A_SOURCE, + `${context} (a)'s companion paths of ${D20_A}`, + ); + const codeCompanions = await readRecordedCompanionPaths( + product, + D20_C_TS_CONFIG, + D20_A, + D20_Z_SOURCE, + `${context} (c)'s companion paths of the destination ${D20_A} (a twin ` + + `holding ${D20_Z}'s bytes there)`, + ); + const retiredCompanions = await readD20RetiredCompanions(product, context); + return [ + D20_A_MODULE_STAGING, + ...companions.map(d20CompanionStaging), + D20_A_MODULE_BUILT_STAGING, + D20_A_OUTDIR_STAGING, + D20_B_COMPONENT_STAGING, + D20_B_DESTINATION_STAGING, + D20_C_CODE_STAGING, + D20_C_SPEC_STAGING, + D20_C_BENEATH_STAGING, + D20_C_MODULE_STAGING, + ...codeCompanions.map(d20CodeCompanionStaging), + ...D20_D_FORMS.map(d20DesignationStaging), + D20_C_EXEMPTION_SECTION_STAGING, + ...d20RetiredPaths(retiredCompanions).map(d20RetiredBuiltStaging), + D20_E_SECTION_STAGING, + ]; +} + +/** + * Stage one refused staging in a fresh workspace (H-1) — the premise + * `build` first when the staging is built after one, its premise re-pinned + * (the module header's note) — and hand each refused move to `perMove` in + * turn: T6.5-20's own contract, or T6.6-3's preview equivalence. Exported + * for T6.6-3 (one code path for "staged identically"). + */ +export async function runD20RefusedStaging( + product: ProductBinding, + staging: D20RefusedStaging, + context: string, + perMove: ( + workspace: TestWorkspace, + move: D20RefusedMove, + context: string, + ) => Promise<void>, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": staging.config, ...staging.files }, + }); + try { + if (staging.builtOccupant !== null) { + await buildOk( + product, + workspace, + `${context}: the premise \`build\` — the staged workspace passes ` + + `\`build\`'s validations (SPEC 12.1)`, + ); + const kind = await workspace.kind(staging.builtOccupant); + if (kind !== "file") { + fail( + `${context}: staging premise — after the premise \`build\`, ` + + `${staging.builtOccupant} is the plain file that build wrote, ` + + `a derived file of a staged source (SPEC 13.1, 13.2, 13.4); ` + + `found ${kind}`, + ); + } + } + for (const move of staging.moves) { + await perMove(workspace, move, `${context}: \`${move.argv.join(" ")}\``); + } + } finally { + await workspace.dispose(); + } +} + +/** + * T6.5-20's common contract for one refused move (the module header's + * note): inside a whole-root modifies-nothing compare, exit 1 and exactly one + * `refused-invalid-destination` finding — `path` the destination as + * spelled, `locations` `[]` — nothing beside it, never 14.22 (SPEC 6.5, 14, + * 12.7). + */ +async function expectD20Refusal( + product: ProductBinding, + workspace: TestWorkspace, + move: D20RefusedMove, + context: string, +): Promise<void> { + const command = [...move.argv, "--json"].join(" "); + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + [...move.argv, "--json"], + 1, + `${context} — the move is refused, a validation failure: exit 1 ` + + `(SPEC 6.5, 12.0)`, + ); + const findings: readonly Finding[] = decodeFindingsReport( + parseJsonStdout(result, `${context}: \`${command}\``), + `${context}: \`${command}\` — a refused operation's report is the ` + + `form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, + ).findings; + assertConditionCounts( + findings, + { "refused-invalid-destination": 1 }, + `${context} — exactly one refused-invalid-destination finding and ` + + `nothing beside it: one finding per reason, the relations meeting ` + + `at one component being one reason, and never 14.22 — a refused ` + + `operation reports refusal reasons alone (SPEC 6.5, 14, T14-7)`, + ); + const finding = findings[0]!; + assertFindingConcernsPath( + finding, + move.path, + `${context}: the refused-invalid-destination finding concerns the ` + + `destination path as spelled (SPEC 14, 6.5)`, + ); + if (finding.locations.length !== 0) { + fail( + `${context}: the refused-invalid-destination finding concerns a ` + + `path, so its \`locations\` is [] (SPEC 14, 12.7, T14-7); got ` + + JSON.stringify( + finding.locations.map((location) => ({ + file: renderPathValue(location.file), + range: location.range, + })), + ), + ); + } + }, + `${context}: the refused move modifies nothing — sources, derived ` + + `files, and the journal (absent or byte-unchanged) alike (SPEC 6.5)`, + ); +} + +/** + * (c)'s exemption, performed: after a `build`, `move specs/B.md/C.mdx + * specs/B.mdx` — `C.mdx` holding no import and the only source under + * `specs/B.md`, so the one source below the emit path is the relocated + * origin (SPEC 6.5) — exits 0 with the performed-operation report, leaving + * `specs/B.mdx` holding the moved bytes and `specs/B.md` a plain file + * holding its Markdown — the bytes a twin holding the moved bytes at + * `specs/B.mdx`, freshly built under the same configuration, emits there — + * the emitted file replacing the vacated directory with nothing under it + * (13.4, T13.4-11); `check` clean. A performed arm: no T6.6-3 twin. + */ +async function runD20Exemption(product: ProductBinding): Promise<void> { + const context = + `T6.5-20 (c) the exemption, performed after a build: ` + + `\`move ${D20_BC} ${D20_C_DESTINATION}\``; + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": D20_C_MD_CONFIG, [D20_BC]: D20_BC_SOURCE }, + }); + try { + await buildOk( + product, + workspace, + `${context}: the premise \`build\` — the staged workspace passes ` + + `\`build\`'s validations (SPEC 12.1)`, + ); + for (const rel of [ + `${D20_C_EMIT_PATH}/C.md`, + `${D20_C_EMIT_PATH}/C.xspec.ts`, + ]) { + const kind = await workspace.kind(rel); + if (kind !== "file") { + fail( + `${context}: staging premise — the premise \`build\` writes ` + + `${D20_BC}'s Markdown and module beneath the emit path ` + + `${D20_C_EMIT_PATH}, plain files (SPEC 13.1, 13.2, 7.3); found ` + + `${kind} at ${rel}`, + ); + } + } + const command = ["move", D20_BC, D20_C_DESTINATION, "--json"]; + const label = `${context}: \`${command.join(" ")}\``; + decodeAppliedMappingReport( + await runJson( + product, + workspace, + command, + `${label} — the one source below the emit path is the relocated ` + + `origin, so the move is performed: exit 0 (SPEC 6.5, 12.0)`, + ), + `${label} — a performed move reports the form-exact 12.7 ` + + `performed-operation document, \`findings\` [] beside its applied ` + + `mapping (SPEC 6.5, 6.4, 12.7)`, + ); + await assertFileBytes( + workspace.path(D20_C_DESTINATION), + D20_BC_SOURCE.source, + `${context}: ${D20_C_DESTINATION} holds the moved bytes — ${D20_BC} ` + + `holds no import and nothing imports it, so the relocation rewrites ` + + `no byte (SPEC 6.5)`, + ); + const emitted = await workspace.kind(D20_C_EMIT_PATH); + if (emitted !== "file") { + fail( + `${context}: ${D20_C_EMIT_PATH} is a plain file holding ` + + `${D20_C_DESTINATION}'s Markdown, the emitted file replacing the ` + + `vacated directory with nothing under it (SPEC 13.4, 13.2, ` + + `T13.4-11); found ${emitted}`, + ); + } + const twin = await TestWorkspace.create({ + files: { + "xspec.config.ts": D20_C_MD_CONFIG, + [D20_C_DESTINATION]: D20_BC_SOURCE, + }, + }); + try { + await buildOk( + product, + twin, + `${context}: the twin's \`build\` — the moved bytes at ` + + `${D20_C_DESTINATION} alone under the same configuration, exit 0 ` + + `(SPEC 12.1)`, + ); + await assertFilesEqual( + workspace.path(D20_C_EMIT_PATH), + twin.path(D20_C_EMIT_PATH), + `${context}: ${D20_C_EMIT_PATH} after the move vs the Markdown a ` + + `twin holding the moved bytes at ${D20_C_DESTINATION}, freshly ` + + `built, emits there — ${D20_C_DESTINATION}'s Markdown (SPEC 13.2, ` + + `13.4, 3)`, + ); + } finally { + await twin.dispose(); + } + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean (SPEC 6.5, ` + + `13.4, 14.10)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** + * One performed file-form move of T6.5-20 — (d)'s controls and (e)'s + * retired-path arms, each staged before any build — no T6.6-3 twin. + */ +interface D20PerformedMove { + readonly config: StagedTs; + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly origin: string; + readonly destination: string; + /** The origin's bytes, which the destination holds after the move. */ + readonly moved: StagedMdx; + /** + * Further paths the move's finishing regeneration leaves plain files — + * (e)'s destination's derived paths beneath the fresh directory. + */ + readonly written: readonly string[]; +} + +/** + * Perform one `D20PerformedMove` in a fresh workspace (H-1): `move <origin> + * <destination> --json` exits 0 with the form-exact performed-operation + * report, the destination holds the moved bytes — the moved file holds no + * import and nothing imports it, so the relocation rewrites no byte (SPEC + * 6.5) — each `written` path is a plain file, and `check` is clean + * afterward. + */ +async function runD20Performed( + product: ProductBinding, + performed: D20PerformedMove, + context: string, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": performed.config, ...performed.files }, + }); + try { + const command = ["move", performed.origin, performed.destination, "--json"]; + const label = `${context}: \`${command.join(" ")}\``; + decodeAppliedMappingReport( + await runJson( + product, + workspace, + command, + `${label} — no relation of SPEC 6.5 refuses it, so the move is ` + + `performed: exit 0 (SPEC 6.5, 12.0)`, + ), + `${label} — a performed move reports the form-exact 12.7 ` + + `performed-operation document, \`findings\` [] beside its applied ` + + `mapping (SPEC 6.5, 6.4, 12.7)`, + ); + await assertFileBytes( + workspace.path(performed.destination), + performed.moved.source, + `${context}: ${performed.destination} holds the moved bytes — ` + + `${performed.origin} holds no import and nothing imports it, so ` + + `the relocation rewrites no byte (SPEC 6.5)`, + ); + for (const rel of performed.written) { + const kind = await workspace.kind(rel); + if (kind !== "file") { + fail( + `${context}: after the move, the destination's derived path ` + + `${rel} is a plain file written beneath the fresh directory by ` + + `the finishing regeneration (SPEC 6.5, 6.4, 13.1, 13.2, 13.4); ` + + `found ${kind}`, + ); + } + } + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean (SPEC 6.5, ` + + `14.15, 13.4, 14.10)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** + * (d)'s controls, each performed with `check` clean afterward (the moved + * file `specs/Z.mdx`, holding no import, to `specs/B.mdx`): emission + * disabled under each of the six stagings, no emit destination arising + * (SPEC 7.3); and emission enabled with `specs/B.md` named only by a + * construct that is no module-linking form (SPEC 4). + */ +async function runD20DesignationControls( + product: ProductBinding, +): Promise<void> { + const controls: readonly (readonly [StagedTs, D20DesignationCode, string])[] = + [ + ...D20_D_FORMS.map( + (code) => [D20_D_NO_EMIT_CONFIG, code, "emission disabled"] as const, + ), + ...D20_D_NON_FORMS.map( + (code) => + [ + D20_D_CONFIG, + code, + "emission enabled, no module-linking form", + ] as const, + ), + ]; + for (const [config, code, condition] of controls) { + await runD20Performed( + product, + { + config, + files: { [D20_Z]: D20_Z_SOURCE, [D20_D_CODE]: code.source }, + origin: D20_Z, + destination: D20_C_DESTINATION, + moved: D20_Z_SOURCE, + written: [], + }, + `T6.5-20 (d)'s control, performed (${condition}): ${D20_D_CODE} ` + + `holding ${code.construct} alone, before any build`, + ); + } +} + +/** + * (e), performed: with emission next to sources and (e)'s `specs/A.mdx` + * the only source, staged before any build, the file-form move of + * `specs/A.mdx` to `<retired>/B.mdx` under each retired path — its module, + * Markdown, and companion paths (`readD20RetiredCompanions`) — exits 0, the + * destination holding the moved bytes and its derived paths written beneath + * the fresh directory, `check` clean (SPEC 6.5: the relation reads the + * derived paths the sources would generate after the move). Those derived + * paths are the destination's module and Markdown (13.1, 13.2) and each + * companion path a scratch twin holding the moved bytes at the destination + * records, read as T13.4-9(e) reads them. + */ +async function runD20RetiredPerformed(product: ProductBinding): Promise<void> { + const companions = await readD20RetiredCompanions(product, "T6.5-20"); + for (const retired of d20RetiredPaths(companions)) { + const destination = `${retired}/B.mdx`; + const context = + `T6.5-20 (e) performed under the retired path ${retired}, before ` + + `any build`; + const stem = destination.slice(0, -".mdx".length); + const written = [ + `${stem}.xspec.ts`, + `${stem}.md`, + ...(await readRecordedCompanionPaths( + product, + D20_E_CONFIG, + destination, + D20_E_A_SOURCE, + `${context}: the companion paths of the destination ${destination}`, + )), + ]; + await runD20Performed( + product, + { + config: D20_E_CONFIG, + files: { [D20_A]: D20_E_A_SOURCE }, + origin: D20_A, + destination, + moved: D20_E_A_SOURCE, + written, + }, + context, + ); + } +} + +const T6_5_20 = defineProductTest({ + id: "T6.5-20", + title: + 'destination refusals over derived paths, arms (a) through (e): 6.5 refuses, as `refused-invalid-destination` concerning the destination path, a move whose destination would leave the finishing regeneration a write it cannot make — each refused move exits 1 and modifies nothing (whole-root byte compare, the journal absent or byte-unchanged), reporting exactly that one finding, `path` the destination (the section form\'s target file) as spelled, `locations` `[]`, never 14.22, a refused operation reporting refusal reasons alone, and its `--preview` reports the same (T6.6-3); spec globs `specs/**/*.mdx` throughout, so each destination is otherwise valid: (a) under a derived path the sources would generate after the move — beside a discovered `specs/A.mdx`, `move specs/Z.mdx specs/A.xspec.ts/B.mdx` and the section form `move specs/Z.mdx#x specs/A.xspec.ts/B.mdx#x` creating that target, and the same pair under each companion path `specs/A.xspec.<suffix>` of `specs/A.mdx` (read from `inventory`\'s `recorded` set after a scratch twin\'s build, as T13.4-9(e) reads them; none for a product writing no companions), each staged before any build so nothing occupies the derived path; the module-path pair once more after a `build`, `specs/A.xspec.ts` then the plain file that build wrote, the two relations meeting at one component — exactly one finding per move; and, under `markdown.outDir: "out"` beside a discovered `specs/x.mdx`, `move specs/Z.mdx specs/x.md/y.mdx` and its section form, staged before any build, the destination\'s emit path `out/specs/x.md/y.md` lying under `out/specs/x.md` while the destination itself lies under no derived path; (b) a directory component of another derived path — under `markdown.outDir: "out"` beside a discovered `specs/a.md/b.mdx`, `move specs/Z.mdx specs/a.mdx` and `move specs/Z.mdx#x specs/a.mdx#x`, the emit path `out/specs/a.md` a directory component of `out/specs/a.md/b.md`; and, under `markdown.outDir: "specs/B.mdx/md"` staged before any build, `move specs/Z.mdx specs/B.mdx` and `move specs/Z.mdx#x specs/B.mdx#x`, every emit destination lying under the destination\'s path; (c) a source hidden or replaced, with emission next to sources and each refused staging staged before any build, so nothing occupies `specs/Z.md` — `move specs/Z.mdx specs/B.mdx` and its section form beside a discovered code source `specs/B.md` (a code group globbing `specs/*.md`), beside a discovered `specs/B.md/C.mdx`, and beside a discovered code source `specs/B.md/x.ts`, the only file beneath `specs/B.md` (a code group globbing `specs/**/*.ts`, the file holding `export const v = 1`); `move specs/Z.mdx specs/A.mdx` and its section form beside a discovered code source `specs/A.xspec.ts/c.ts`, and, one staging per companion path of the destination (read from a twin holding `specs/Z.mdx`\'s bytes at `specs/A.mdx`), beside `specs/A.xspec.<suffix>/c.ts` instead; and the exemption, performed — `move specs/B.md/C.mdx specs/B.mdx` after a `build`, `C.mdx` holding no import and the only source under `specs/B.md`, exits 0, leaving `specs/B.mdx` holding the moved bytes and `specs/B.md` a plain file holding its Markdown (what a freshly built twin emits there), `check` clean — while its section form, `move specs/B.md/C.mdx#x specs/B.mdx#x` after a `build`, is refused, the source left below the emit path being no relocated origin; (d) a module-linking form made to designate a derived-file path — with emission next to sources, a code source `src/c.ts` (a code group globbing `src/**/*.ts`) holding one module-linking form alone, its relative specifier `../specs/B.md`, one staging per form SPEC 4 names, each the line followed by U+000A — `import "../specs/B.md"`, `export * from "../specs/B.md"`, `import X = require("../specs/B.md")`, `import("../specs/B.md")`, `type T = import("../specs/B.md")`, and `declare module "../specs/B.md" { }`, each accepted by TypeScript 5.9.3 both as module code and as script code — makes `move specs/Z.mdx specs/B.mdx` and its section form `move specs/Z.mdx#x specs/B.mdx#x` refused, each staged before any build, the specifier designating the destination\'s would-be emit path `specs/B.md`; its controls, each performed (exit 0, the destination holding the moved bytes) with `check` clean afterward: emission disabled (`emit: false`) under each of the six stagings, and emission enabled with the path named only by `require("../specs/B.md")`, by `/// <reference path="../specs/B.md" />`, and by the template-literal `import()`, none a module-linking form; (e) the derived paths a file-form move retires — with emission next to sources and `specs/A.mdx`, holding a section `x` and no import, the only source, each staged before any build: `move specs/A.mdx specs/A.xspec.ts/B.mdx`, `move specs/A.mdx specs/A.md/B.mdx`, and, one staging per companion path of `specs/A.mdx` (read as T13.4-9(e) reads them), `move specs/A.mdx specs/A.xspec.<suffix>/B.mdx` each exit 0, the destination holding the moved bytes and its derived paths — its module, its Markdown, and each companion path a scratch twin holding the moved bytes at the destination records — written beneath the fresh directory, `check` clean; its controls, refused under the common contract: each of those file-form moves staged after a `build` instead (T6.5-4\'s relation alone, the retired path then the plain file that build wrote), and the section form `move specs/A.mdx#x specs/A.xspec.ts/B.mdx#x`, staged before any build, `specs/A.mdx` still generating `specs/A.xspec.ts` (SPEC 6.5, 4, 14.15, 13.4, 13.1, 13.2, 7.3, 14, 12.7)', + run: async (product) => { + for (const staging of await d20RefusedStagings(product, "T6.5-20")) { + await runD20RefusedStaging( + product, + staging, + `T6.5-20 ${staging.key}`, + (workspace, move, context) => + expectD20Refusal(product, workspace, move, context), + ); + } + await runD20Exemption(product); + await runD20DesignationControls(product); + await runD20RetiredPerformed(product); + }, +}); + +// =========================================================================== +// T6.5-21 — `refused-exposed-derived-file` (the module header's T6.5-21 +// notes) +// =========================================================================== + +/** Every T6.5-21 staging's moved file: the file-form moves' origin. */ +const D21_ORIGIN = "specs/A.mdx"; +/** The origin's emit destination next to sources (13.2): the exposed path. */ +const D21_EMIT_PATH = "specs/A.md"; +/** Every file-form move's destination, and (e)'s created target file. */ +const D21_DESTINATION = "specs/sub/A.mdx"; +/** The destination's emit destination next to sources (13.2). */ +const D21_DESTINATION_EMIT_PATH = "specs/sub/A.md"; +/** The section (e)'s section form moves, keeping its ID. */ +const D21_SECTION = "x"; +/** The glob reaching `specs/A.md`: (a)'s second spec glob, (b)'s code glob. */ +const D21_MD_GLOB = "specs/*.md"; + +/** + * A configuration of T6.5-21: the one spec group globbing `specGlobs`, then + * the one code group globbing `codeGlob` (or none), Markdown emitted next to + * sources (TEST-SPEC: "with emission next to sources"). + */ +function d21ConfigText( + specGlobs: readonly string[], + codeGlob: string | null, +): string { + const globs = specGlobs.map((glob) => `"${glob}"`).join(", "); + const code = + codeGlob === null ? "" : `,\n code: {\n app: ["${codeGlob}"]\n }`; + return ( + `import { defineConfig } from "xspec"\n\n` + + `export default defineConfig({\n` + + ` specs: {\n` + + ` main: [${globs}]\n` + + ` }${code},\n` + + ` markdown: ${D20_EMIT_NEXT}\n` + + `})\n` + ); +} + +const D21_SPEC_MD_CONFIG = stagedTs( + "T6.5-21/T6.6-3/T12.7-2 xspec.config.ts — specs/**/*.mdx and a second spec glob specs/*.md in the one spec group, Markdown emitted next to sources ((a)'s staging with its two-reason move, (d)'s, (e)'s, and (e)'s twin)", + d21ConfigText([D20_SPEC_GLOB, D21_MD_GLOB], null), +); +const D21_CODE_MD_CONFIG = stagedTs( + "T6.5-21/T6.6-3 xspec.config.ts — specs/**/*.mdx, a code group globbing specs/*.md, Markdown emitted next to sources ((b)'s staging)", + d21ConfigText([D20_SPEC_GLOB], D21_MD_GLOB), +); +const D21_UNREACHED_CONFIG = stagedTs( + "T6.5-21 xspec.config.ts — specs/**/*.mdx alone, no glob reaching specs/A.md, Markdown emitted next to sources ((c)'s staging and its twin)", + d21ConfigText([D20_SPEC_GLOB], null), +); + +/** + * The moved file `specs/A.mdx`: the section `x` (e)'s section form moves and + * a section `y` that stays — so the Markdown (e)'s regeneration writes in + * place differs from the premise build's — no import or reference, and + * nothing imports it, so no relocation rewrites a byte (SPEC 6.5). + */ +const D21_A_SOURCE = stagedMdx( + "T6.5-21/T6.6-3/T12.7-2 specs/A.mdx — the moved file, sections x and y, no import or reference (every staging's origin; at specs/sub/A.mdx, (c)'s twin's source)", + [ + `<S id="${D21_SECTION}">`, + "Alpha text.", + "</S>", + "", + '<S id="y">', + "Why text.", + "</S>", + "", + ].join("\n"), +); + +/** + * (b)'s occupant `specs/A.md`, a plain file of the user's staged before any + * emission: well-formed TypeScript (T13.4-11(b)'s code-source form), so the + * code group would discover a valid code source there once the relocation + * leaves the path no emit destination — the workspace otherwise valid, the + * refusal 6.5's reason alone. + */ +const D21_USER_OCCUPANT = stagedTs( + "T6.5-21/T6.6-3 specs/A.md — (b)'s plain file of the user's at the origin's emit destination, before any emission (export const v = 1)", + "export const v = 1\n", +); + +/** + * One finding a refused move of T6.5-21 reports, the move's findings listed + * in 14's order: its stable code, its concerned path (`locations` `[]`, both + * reasons concerning a path), and its `identities` exactly where they are + * pinned — `[]` for `refused-exposed-derived-file` (TEST-SPEC), unstated for + * `refused-invalid-destination` (12.7: informational) — under support.ts + * `assertRefusalIdentities`'s discipline. Exported with the stagings for + * T6.6-3, T12.7-2, and T14-7. + */ +export interface D21ExpectedFinding { + readonly code: "refused-invalid-destination" | "refused-exposed-derived-file"; + readonly path: string; + readonly identities?: readonly string[]; +} + +/** One refused move of T6.5-21: its argv (`--json` excluded) and findings. */ +export interface D21RefusedMove { + readonly argv: readonly string[]; + readonly findings: readonly D21ExpectedFinding[]; +} + +/** + * One refused staging of T6.5-21: a fresh workspace (`config` plus `files`), + * the premise `build` when `built` — after which `specs/A.md` must be the + * plain file that build wrote, its premise re-pinned — then each refused + * move in turn. Exported for T6.6-3, T12.7-2, and T14-7, which stage each + * identically through `runD21RefusedStaging`. + */ +export interface D21RefusedStaging { + /** The arm (diagnostics), e.g. `(a) …`. */ + readonly key: string; + readonly config: StagedTs; + readonly files: Readonly<Record<string, InitialFileContents>>; + readonly built: boolean; + readonly moves: readonly D21RefusedMove[]; +} + +/** The exposure finding every refused move reports: `path` `specs/A.md`. */ +const D21_EXPOSED_FINDING: D21ExpectedFinding = { + code: "refused-exposed-derived-file", + path: D21_EMIT_PATH, + identities: [], +}; + +/** The file-form move of every arm: `move specs/A.mdx specs/sub/A.mdx`. */ +const D21_FILE_MOVE: D21RefusedMove = { + argv: ["move", D21_ORIGIN, D21_DESTINATION], + findings: [D21_EXPOSED_FINDING], +}; + +/** The two-reason move's destination, holding T6.5-4's barred `'`. */ +const D21_BARRED_DESTINATION = "specs/a'b.mdx"; + +/** + * The multi-reason order (14, 12.7), in (a)'s staging: `move specs/A.mdx + * "specs/a'b.mdx"` reports `refused-invalid-destination` (`path` the + * destination as spelled), then `refused-exposed-derived-file` — 14 listing + * the latter after the former and before `refused-invalid-rewrite`. + * Exported for T12.7-2, which asserts the same order. + */ +export const D21_TWO_REASON_MOVE: D21RefusedMove = { + argv: ["move", D21_ORIGIN, D21_BARRED_DESTINATION], + findings: [ + { code: "refused-invalid-destination", path: D21_BARRED_DESTINATION }, + D21_EXPOSED_FINDING, + ], +}; + +/** + * (a), the product-emitted Markdown: after a `build`, `specs/A.md` holds + * `A.mdx`'s Markdown while the second spec glob `specs/*.md` would discover + * it, as a spec-group file without `.mdx`, once it is no emit destination — + * the file-form move, then the two-reason move, each refused. Exported for + * T12.7-2 (its two-reason move) and T14-7. + */ +export const D21_A_STAGING: D21RefusedStaging = { + key: "(a) the product-emitted Markdown at specs/A.md, after a build, under a second spec glob specs/*.md", + config: D21_SPEC_MD_CONFIG, + files: { [D21_ORIGIN]: D21_A_SOURCE }, + built: true, + moves: [D21_FILE_MOVE, D21_TWO_REASON_MOVE], +}; + +/** + * (b), a user-authored file before any emission: no build ever run, + * `specs/A.md` a plain file of the user's, under a code group globbing + * `specs/*.md` instead. + */ +const D21_B_STAGING: D21RefusedStaging = { + key: "(b) a plain file of the user's at specs/A.md, no build ever run, under a code group globbing specs/*.md", + config: D21_CODE_MD_CONFIG, + files: { [D21_ORIGIN]: D21_A_SOURCE, [D21_EMIT_PATH]: D21_USER_OCCUPANT }, + built: false, + moves: [D21_FILE_MOVE], +}; + +/** + * Every refused staging of T6.5-21, in the entry's order. Exported for + * T6.6-3, whose preview twins stage each identically, and T14-7. + */ +export const D21_REFUSED_STAGINGS: readonly D21RefusedStaging[] = [ + D21_A_STAGING, + D21_B_STAGING, +]; + +/** + * Stage one refused staging of T6.5-21 in a fresh workspace (H-1) — the + * premise `build` first when the staging is built after one, its premise + * re-pinned — and hand each refused move to `perMove` in turn: T6.5-21's own + * contract, T6.6-3's preview equivalence, T12.7-2's order, or T14-7's + * report. Exported for them (one code path for "staged identically"). + */ +export async function runD21RefusedStaging( + product: ProductBinding, + staging: D21RefusedStaging, + context: string, + perMove: ( + workspace: TestWorkspace, + move: D21RefusedMove, + context: string, + ) => Promise<void>, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": staging.config, ...staging.files }, + }); + try { + if (staging.built) { + await d21BuildPremise(product, workspace, context); + } + for (const move of staging.moves) { + await perMove(workspace, move, `${context}: \`${move.argv.join(" ")}\``); + } + } finally { + await workspace.dispose(); + } +} + +/** + * The premise `build` of a staging built after one: exit 0, and + * `specs/A.md` then the plain file holding `A.mdx`'s Markdown that build + * wrote (13.2, 7.3) — a product writing none there fails diagnosed at the + * premise, never at an assertion the arm does not stage. + */ +async function d21BuildPremise( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + await buildOk( + product, + workspace, + `${context}: the premise \`build\` — the staged workspace passes ` + + `\`build\`'s validations (SPEC 12.1)`, + ); + const kind = await workspace.kind(D21_EMIT_PATH); + if (kind !== "file") { + fail( + `${context}: staging premise — after the premise \`build\`, ` + + `${D21_EMIT_PATH} is the plain file holding ${D21_ORIGIN}'s ` + + `Markdown that build wrote, emission being next to sources (SPEC ` + + `13.2, 7.3); found ${kind}`, + ); + } +} + +/** + * T6.5-21's contract for one refused move: inside a whole-root + * modifies-nothing compare (the journal absent or byte-unchanged with + * everything else), `move … --json` exits 1 and its stdout decodes as the + * form-exact 12.7 findings-only report holding exactly the move's findings, + * nothing beside, in 14's listed order — each concerning its path, its + * `locations` `[]`, its `identities` asserted where pinned (SPEC 6.5, 14, + * 12.7). + */ +async function expectD21Refusal( + product: ProductBinding, + workspace: TestWorkspace, + move: D21RefusedMove, + context: string, +): Promise<void> { + const argv = [...move.argv, "--json"]; + const command = argv.join(" "); + const counts: Record<string, number> = {}; + for (const expected of move.findings) { + counts[expected.code] = (counts[expected.code] ?? 0) + 1; + } + const codes = move.findings.map((expected) => expected.code).join(", then "); + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + argv, + 1, + `${context} — the move is refused, a validation failure: exit 1 ` + + `(SPEC 6.5, 12.0)`, + ); + const findings: readonly Finding[] = decodeFindingsReport( + parseJsonStdout(result, `${context}: \`${command}\``), + `${context}: \`${command}\` — a refused operation's report is the ` + + `form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, + ).findings; + assertConditionCounts( + findings, + counts, + `${context} — exactly ${codes}, one finding per reason and nothing ` + + `beside: a refused operation reports its refusal reasons alone ` + + `(SPEC 6.5, 14)`, + ); + move.findings.forEach((expected, index) => { + const finding = findings[index]!; + const label = + `${context}: finding ${String(index + 1)} of ` + + `${String(move.findings.length)}`; + if (finding.code !== expected.code) { + fail( + `${label} is ${expected.code} — the reasons in 14's listed ` + + `order, ${codes} (SPEC 14, 12.7); got ` + + `${JSON.stringify(finding.code)}`, + ); + } + assertFindingConcernsPath( + finding, + expected.path, + `${label}, ${expected.code}, concerns ${expected.path} (SPEC 14, ` + + `6.5)`, + ); + if (finding.locations.length !== 0) { + fail( + `${label}, ${expected.code}, concerns a path, so its ` + + `\`locations\` is [] (SPEC 14, 12.7); got ` + + JSON.stringify( + finding.locations.map((location) => ({ + file: renderPathValue(location.file), + range: location.range, + })), + ), + ); + } + assertRefusalIdentities( + finding, + expected.code, + expected.identities, + `${label}, ${expected.code} (SPEC 14, 12.7)`, + ); + }); + }, + `${context}: the refused move modifies nothing — sources, derived ` + + `files, and the journal (absent or byte-unchanged) alike (SPEC 6.5)`, + ); +} + +/** Fail unless `rel` holds an entry of kind `expected` (diagnosed). */ +async function d21ExpectKind( + workspace: TestWorkspace, + rel: string, + expected: EntryKind, + why: string, +): Promise<void> { + const kind = await workspace.kind(rel); + if (kind !== expected) { + fail(`${why}; found ${kind} at ${rel}`); + } +} + +/** + * A performed file-form move of a control: `move specs/A.mdx + * specs/sub/A.mdx --json` exits 0 with the form-exact performed-operation + * report, and `specs/sub/A.mdx` holds the moved bytes — `A.mdx` holds no + * import and nothing imports it, so the relocation rewrites no byte (SPEC + * 6.5). + */ +async function d21PerformFileMove( + product: ProductBinding, + workspace: TestWorkspace, + why: string, + context: string, +): Promise<void> { + const command = [...D21_FILE_MOVE.argv, "--json"]; + const label = `${context}: \`${command.join(" ")}\``; + decodeAppliedMappingReport( + await runJson( + product, + workspace, + command, + `${label} — ${why}, so the move is performed: exit 0 (SPEC 6.5, ` + + `12.0)`, + ), + `${label} — a performed move reports the form-exact 12.7 ` + + `performed-operation document, \`findings\` [] beside its applied ` + + `mapping (SPEC 6.5, 6.4, 12.7)`, + ); + await assertFileBytes( + workspace.path(D21_DESTINATION), + D21_A_SOURCE.source, + `${context}: ${D21_DESTINATION} holds the moved bytes — ${D21_ORIGIN} ` + + `holds no import and nothing imports it, so the relocation rewrites ` + + `no byte (SPEC 6.5)`, + ); +} + +/** + * The twin protocol (T13.4-11's): each path of `compared` after the move + * holds what a twin — `twinFiles` under `config`, plus each path of + * `copied` carrying the moved workspace's own bytes, first judged + * well-formed (a performed move's rewritten files are, SPEC 6.5, 14.20; a + * product's malformed bytes fail diagnosed here, never as a harness staging + * error) — freshly built, emits there (SPEC 13.2, 13.4, 3). + */ +async function d21AssertLikeTwin( + product: ProductBinding, + workspace: TestWorkspace, + config: StagedTs, + twinFiles: Readonly<Record<string, InitialFileContents>>, + copied: readonly string[], + compared: readonly string[], + context: string, +): Promise<void> { + for (const rel of copied) { + const verdict = deriveMdx(await workspace.readBytes(rel)); + if (!verdict.derives) { + fail( + `${context}: after the move, ${rel} is well-formed MDX — a ` + + `performed move leaves every rewritten file well-formed (SPEC ` + + `6.5, 14.20); the stock MDX 3 parser rejects it: ${verdict.reason}`, + ); + } + } + const twin = await TestWorkspace.create({ + files: { "xspec.config.ts": config, ...twinFiles }, + }); + try { + for (const rel of copied) { + await twin.copyFrom(workspace, rel); + } + await buildOk( + product, + twin, + `${context}: the twin's \`build\` — the post-move sources alone under ` + + `the same configuration, exit 0 (SPEC 12.1)`, + ); + for (const rel of compared) { + await assertFilesEqual( + workspace.path(rel), + twin.path(rel), + `${context}: ${rel} after the move vs the Markdown a twin holding ` + + `the post-move sources, freshly built, emits there (SPEC 13.2, ` + + `13.4, 3)`, + ); + } + } finally { + await twin.dispose(); + } +} + +/** + * (c), performed: no glob reaching `specs/A.md` — after a `build`, the + * file-form move succeeds, its finishing regeneration removing the stale + * `specs/A.md`, recorded and no longer generated, and emitting + * `specs/sub/A.md`: what a twin holding the moved bytes at + * `specs/sub/A.mdx`, freshly built, emits there (SPEC 6.5, 13.4, 13.2). + */ +async function runD21Unreached(product: ProductBinding): Promise<void> { + const context = + `T6.5-21 (c) no glob reaching ${D21_EMIT_PATH}, performed after a ` + + `build`; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": D21_UNREACHED_CONFIG, + [D21_ORIGIN]: D21_A_SOURCE, + }, + }); + try { + await d21BuildPremise(product, workspace, context); + await d21PerformFileMove( + product, + workspace, + `no glob reaches ${D21_EMIT_PATH}, so the vacated emit destination ` + + `exposes nothing to discovery`, + context, + ); + await d21ExpectKind( + workspace, + D21_EMIT_PATH, + "absent", + `${context}: the finishing regeneration removes the stale ` + + `${D21_EMIT_PATH}, recorded and no longer generated (SPEC 13.4, ` + + `12.1, 6.5)`, + ); + await d21AssertLikeTwin( + product, + workspace, + D21_UNREACHED_CONFIG, + { [D21_DESTINATION]: D21_A_SOURCE }, + [], + [D21_DESTINATION_EMIT_PATH], + context, + ); + } finally { + await workspace.dispose(); + } +} + +/** + * (d), performed: after a `build`, `specs/A.md` replaced by a symbolic link + * to a file outside the workspace (section-13.4.ts's shared link staging), + * (a)'s `specs/*.md` glob present — discovery never yields a link (7), so + * the move succeeds, its finishing regeneration removing the recorded link + * as the link itself, its target byte-identical (SPEC 13.4, T13.4-11). + */ +async function runD21LinkOccupant(product: ProductBinding): Promise<void> { + const context = + `T6.5-21 (d) a symbolic link as the occupant of ${D21_EMIT_PATH}, ` + + `performed after a build`; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": D21_SPEC_MD_CONFIG, + [D21_ORIGIN]: D21_A_SOURCE, + }, + }); + try { + await d21BuildPremise(product, workspace, context); + const link = await stageLinkToOutsideFile( + workspace, + D21_EMIT_PATH, + "T6.5-21-d-target.md", + ); + await d21PerformFileMove( + product, + workspace, + `discovery never yields a symbolic link (SPEC 7), so the vacated emit ` + + `destination holds no occupant discovery would yield as a source`, + context, + ); + await d21ExpectKind( + workspace, + D21_EMIT_PATH, + "absent", + `${context}: the finishing regeneration removes the recorded link at ` + + `${D21_EMIT_PATH} as the link itself, never its target (SPEC 13.4)`, + ); + await assertOutsideLinkTargetUnchanged(link, context); + } finally { + await workspace.dispose(); + } +} + +/** + * (e), performed: the section form in (a)'s staging — after a `build`, + * `move specs/A.mdx#x specs/sub/A.mdx#x` creating the target file relocates + * no origin, so `specs/A.md` stays `A.mdx`'s emit destination and 6.5's + * reason, for a file-form move alone, does not apply. Its `--preview` + * succeeds alike (exit 0, `findings` [], the plan members present, nothing + * modified; SPEC 6.6); the move exits 0 with no finding, `specs/sub/A.mdx` + * created, `specs/A.md` regenerated in place and `specs/sub/A.md` emitted — + * each the Markdown a twin holding the post-move sources, freshly built, + * emits there — and `check` is clean afterward. + */ +async function runD21SectionForm(product: ProductBinding): Promise<void> { + const argv = [ + "move", + `${D21_ORIGIN}#${D21_SECTION}`, + `${D21_DESTINATION}#${D21_SECTION}`, + ]; + const context = + `T6.5-21 (e) the section form in (a)'s staging, performed after a ` + + `build: \`${argv.join(" ")}\``; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": D21_SPEC_MD_CONFIG, + [D21_ORIGIN]: D21_A_SOURCE, + }, + }); + try { + await d21BuildPremise(product, workspace, context); + const previewArgv = [...argv, "--preview", "--json"]; + const previewLabel = `${context}: \`${previewArgv.join(" ")}\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const preview = await expectExit( + product, + workspace, + previewArgv, + 0, + `${previewLabel} — a section move relocates no origin, so the ` + + `reason does not apply and the preview succeeds alike (SPEC ` + + `6.6, 6.5)`, + ); + const report = decodePreviewReport( + parseJsonStdout(preview, previewLabel), + `${previewLabel} — the form-exact 12.7 preview document (SPEC ` + + `12.7, H-3)`, + ); + if (report.findings.length !== 0) { + fail( + `${previewLabel}: a preview whose real operation would proceed ` + + `reports findings [] (SPEC 6.6, 12.7); got ` + + JSON.stringify(report.findings.map((finding) => finding.code)), + ); + } + if ( + report.mapping === null || + report.files === null || + report.delta === null + ) { + fail( + `${previewLabel}: a successful preview reports its plan — ` + + `\`mapping\`, \`files\`, and \`delta\` are null exactly on ` + + `refusal (SPEC 6.6, 12.7)`, + ); + } + }, + `${previewLabel}: the preview modifies nothing (SPEC 6.6)`, + ); + const command = [...argv, "--json"]; + const label = `${context}: \`${command.join(" ")}\``; + decodeAppliedMappingReport( + await runJson( + product, + workspace, + command, + `${label} — a section move relocates no origin, so the reason does ` + + `not apply: exit 0 (SPEC 6.5, 12.0)`, + ), + `${label} — a performed move reports the form-exact 12.7 ` + + `performed-operation document, \`findings\` [] — no finding — ` + + `beside its applied mapping (SPEC 6.5, 12.7)`, + ); + await d21ExpectKind( + workspace, + D21_DESTINATION, + "file", + `${context}: the move creates the target file ${D21_DESTINATION} ` + + `(SPEC 6.5)`, + ); + await d21ExpectKind( + workspace, + D21_EMIT_PATH, + "file", + `${context}: ${D21_EMIT_PATH} stays ${D21_ORIGIN}'s emit destination, ` + + `regenerated in place (SPEC 6.5, 13.2)`, + ); + await d21ExpectKind( + workspace, + D21_DESTINATION_EMIT_PATH, + "file", + `${context}: the finishing regeneration emits ` + + `${D21_DESTINATION_EMIT_PATH} (SPEC 6.5, 13.2)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean (SPEC 6.5, ` + + `13.4, 14.10)`, + ); + await d21AssertLikeTwin( + product, + workspace, + D21_SPEC_MD_CONFIG, + {}, + [D21_ORIGIN, D21_DESTINATION], + [D21_EMIT_PATH, D21_DESTINATION_EMIT_PATH], + context, + ); + } finally { + await workspace.dispose(); + } +} + +const T6_5_21 = defineProductTest({ + id: "T6.5-21", + title: + "`refused-exposed-derived-file`: a file-form move, while emission is enabled, whose origin's emit destination holds an occupant discovery would yield as a source once the relocation leaves that path no emit destination is refused — exit 1, nothing modified (whole-root byte compare, the journal absent or byte-unchanged), exactly one finding, code `refused-exposed-derived-file`, `path` the origin's emit destination `specs/A.md`, `locations` `[]` and `identities` `[]`, the `--preview` reporting the same (T6.6-3); each arm stages `move specs/A.mdx specs/sub/A.mdx` with emission next to sources and spec globs `specs/**/*.mdx`: (a) after a `build`, `specs/A.md` holding `A.mdx`'s Markdown while a second spec glob `specs/*.md` would discover it as a spec-group file without `.mdx`, and (b) no build ever run, `specs/A.md` a plain file of the user's under a code group globbing `specs/*.md` — both refused; controls, each performed: (c) no glob reaching `specs/A.md` — after a `build` the move exits 0, its finishing regeneration removing the stale `specs/A.md` and emitting `specs/sub/A.md`; (d) after a `build`, `specs/A.md` replaced by a symbolic link to a file outside the workspace, (a)'s glob present — discovery never yields a link, so the move exits 0, the regeneration removing the recorded link as the link itself, its target byte-identical; (e) in (a)'s staging, the section form `move specs/A.mdx#x specs/sub/A.mdx#x` creating the target file relocates no origin — exit 0 with no finding, its `--preview` succeeding alike, `specs/sub/A.mdx` created, `specs/A.md` regenerated in place holding `A.mdx`'s Markdown as the move leaves it and `specs/sub/A.md` emitted (what a freshly built twin of the post-move sources emits), `check` clean; and the multi-reason order: `move specs/A.mdx \"specs/a'b.mdx\"` in (a)'s staging reports `refused-invalid-destination` (T6.5-4's barred character, `path` the destination) then `refused-exposed-derived-file` (SPEC 6.5, 13.4, 7, 13.2, 7.3, 6.6, 14, 12.7)", + run: async (product) => { + for (const staging of D21_REFUSED_STAGINGS) { + await runD21RefusedStaging( + product, + staging, + `T6.5-21 ${staging.key}`, + (workspace, move, context) => + expectD21Refusal(product, workspace, move, context), + ); + } + await runD21Unreached(product); + await runD21LinkOccupant(product); + await runD21SectionForm(product); + }, +}); + +// --------------------------------------------------------------------------- +// T6.5-22 Barred and captured names — (b)'s lures +// --------------------------------------------------------------------------- +// +// SPEC 6.5 (Added imports) holds an added import's identifiers to more than +// freshness against the module scope: each is one module code, strict +// throughout, admits as a binding; none is `require` or `exports`, begins +// with `__`, or names a global the compiler's emitted code may read (clause +// 19's and Annex B's global-object properties, `Iterator`, `AsyncIterator`, +// `SuppressedError`), nor, in a TSX source, `React` or the leading +// identifier of a factory a `@jsx` or `@jsxFrag` pragma in any of its +// comments names; each is bound by no declaration already in the file, in +// any scope and at value or type level, equal to no name the file +// references, distinct from the others added, and in a spec source none of +// `S`, `Spec`, `text`. T6.5-22(a), the universal assertion, is the +// subprocess driver's: every performed move through it is judged on exit 0 +// (helpers/added-import-identifiers.ts, with S-6's name analysis), whichever +// test performs it. This section is (b): the lures, each a section move +// whose receiving file needs an import of a target module named to steer a +// basename- or stem-derived choice onto a barred or captured name, under +// (a)'s assertion and with `check` clean after the move. +// +// Conservative operationalizations (noted per H-4): +// - One fresh workspace per lure (H-1): the configuration (one spec group, +// `specs/**/*.mdx`; one code group, `src/**/*.ts` and `src/**/*.tsx`), the +// origin `specs/A.mdx` holding the top-level sections `a`, `m`, and `w`, +// and the lure's one receiving file — all staged-source records, every +// lure after the first being staged after the body's first invocation +// (S-9's timing clause). +// - The receiving file binds the origin module as `A` and roots one +// reference at it to the moved section — a spec source's embedding +// `{text(A.a)}`, a code source's marker `A.m`, as TEST-SPEC's +// `{text(await.a)}` and `yield.m` spell them — and one to `w`, which stays +// (`d={A.w}`; the marker `A.w`). `A` keeps a use, so the move removes no +// import: the receiving file's edits are the rewritten reference and the +// one declaration added for the target module's default binding (a chain +// is rooted at the default export, 2.1, 4.5). +// - The move is `move specs/A.mdx#<id> <target>#<id> --json`, the moved +// section's ID kept, top-level, into a target file the move creates: +// TEST-SPEC names the target paths and leaves their occupancy open; +// nothing occupies them, so the lured name is the target's basename alone. +// - Before staging, the body holds the lure to its premise, a harness error +// otherwise: S-6's name analysis (vetted by its own vectors) finds the +// lured name barred, declared, or referenced in the receiver, as the +// lure's entry states. +// - Per lure: the move exits 0 with the form-exact performed-operation +// report (12.7) — the driver judging the added identifiers on that exit; +// the body then reads the receiving file and judges it again through +// `judgeAddedImportsOfFile` (one code path with the driver), asserting the +// lure's premise beside: exactly one declaration added, its specifier's +// value the target module's canonical relative spelling (6.5) — so a +// product binding the lured name fails at the added bytes whatever the +// driver judged; then `check --json` is clean (12.2, 12.7). +// - Every lure runs; each one's diagnosed failure is collected and the body +// fails once at the end, naming every lure that failed, so one run +// diagnoses every breach. A harness error — anything but a diagnosed +// assertion failure, a hang included — propagates at once. + +const B22_ORIGIN = "specs/A.mdx"; + +// One spec group and one code group reaching every receiver (SPEC 7.1, +// 7.2): a staged-source record, staged by every lure after the body's first +// invocation (S-9's timing clause). +const B22_CONFIG = stagedTs( + "T6.5-22 xspec.config.ts — one spec group and one code group globbing src/**/*.ts and src/**/*.tsx", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts", "src/**/*.tsx"] + } +}) +`, +); + +// The origin: the moved sections `a` (a spec source's lures) and `m` (a +// code source's), and the kept `w`, each standing alone on its lines. +const B22_ORIGIN_SOURCE = stagedMdx( + "T6.5-22 specs/A.mdx (the origin: the moved a and m, the kept w)", + [ + '<S id="a">', + "A text.", + "</S>", + "", + '<S id="m">', + "M text.", + "</S>", + "", + '<S id="w">', + "W text.", + "</S>", + "", + ].join("\n"), +); + +/** A lure's receiving file. */ +interface B22Receiver { + readonly file: string; + readonly kind: ReceivingFileKind; + /** The staged text. */ + readonly text: string; + /** The staged-source record (S-9). */ + readonly staged: StagedMdx | StagedTs; + /** The moved section: `a` for a spec source, `m` for a code source. */ + readonly section: "a" | "m"; + /** The canonical relative spelling of `specs/` from the receiver's + * directory, `./` or `../specs/` (6.5, 2.1). */ + readonly toSpecs: "./" | "../specs/"; +} + +/** + * A receiving file at module load: its lines, each followed by U+000A, as a + * staged-source record — an MDX one for `specs/host.mdx`, a TypeScript one + * of the grammar the name selects otherwise (14.20). + */ +function b22Receiver( + file: string, + what: string, + lines: readonly string[], +): B22Receiver { + const text = [...lines, ""].join("\n"); + const name = `T6.5-22 ${file} (${what})`; + if (file.endsWith(".mdx")) { + return { + file, + kind: "spec-source", + text, + staged: stagedMdx(name, text), + section: "a", + toSpecs: "./", + }; + } + const tsx = file.endsWith(".tsx"); + return { + file, + kind: tsx ? "tsx" : "typescript", + text, + staged: stagedTs(name, text, "well-formed", tsx ? "tsx" : "ts"), + section: "m", + toSpecs: "../specs/", + }; +} + +/** A code receiver's origin declaration (see the notes). */ +const B22_IMPORT_A = 'import A from "../specs/A.xspec"'; + +// The receivers of the thirteen named targets, one per kind of file the +// entry names: a spec source and a `.ts` code source. +const B22_SPEC_HOST = b22Receiver( + "specs/host.mdx", + "the spec-source receiver of the thirteen named targets", + [ + 'import A from "./A.xspec"', + "", + '<S id="host" d={A.w}>', + "Host text, quoting {text(A.a)}.", + "</S>", + ], +); +const B22_TS_HOST = b22Receiver( + "src/host.ts", + "the .ts receiver of the thirteen named targets", + [ + B22_IMPORT_A, + "", + "export function area(width: number, height: number): number {", + " return width * height;", + "}", + "", + "A.m", + "A.w", + ], +); + +// The `.tsx` receivers: `React`'s two, each pragma's, and the two whose +// pragma TypeScript's own reading ignores (a line comment; a block comment +// inside a function body after the file's first statement). The pragmas +// TypeScript reads lead their files, among its leading comments. +const B22_VIEW = b22Receiver( + "src/view.tsx", + "the .tsx receiver of specs/React.mdx holding classic-runtime JSX", + [B22_IMPORT_A, "", "export const view = <div />;", "", "A.m", "A.w"], +); +const B22_PLAIN = b22Receiver( + "src/plain.tsx", + "the .tsx receiver of specs/React.mdx holding no JSX", + [B22_IMPORT_A, "", "export const plain = 1;", "", "A.m", "A.w"], +); +/** A `.tsx` receiver led by the comment `pragma`, holding `jsx`. */ +function b22PragmaReceiver( + file: string, + what: string, + pragma: string, + jsx: string, +): B22Receiver { + return b22Receiver(file, what, [ + pragma, + B22_IMPORT_A, + "", + `export const view = ${jsx};`, + "", + "A.m", + "A.w", + ]); +} +const B22_JSX_DOC = b22PragmaReceiver( + "src/jsx-doc.tsx", + "the .tsx receiver of specs/h.mdx carrying /** @jsx h */", + "/** @jsx h */", + "<div />", +); +const B22_JSX_UPPER = b22PragmaReceiver( + "src/jsx-upper.tsx", + "the .tsx receiver of specs/h.mdx carrying /* @JSX h */", + "/* @JSX h */", + "<div />", +); +const B22_JSX_PREACT = b22PragmaReceiver( + "src/jsx-preact.tsx", + "the .tsx receiver of specs/preact.mdx carrying /** @jsx preact.h */", + "/** @jsx preact.h */", + "<div />", +); +const B22_JSX_FRAG = b22PragmaReceiver( + "src/jsx-frag.tsx", + "the .tsx receiver of specs/Frag.mdx carrying /** @jsxFrag Frag */ alone", + "/** @jsxFrag Frag */", + "<></>", +); +const B22_JSX_LINE = b22PragmaReceiver( + "src/jsx-line.tsx", + "the .tsx receiver of specs/h.mdx carrying the line comment // @jsx h", + "// @jsx h", + "<div />", +); +const B22_JSX_INNER = b22Receiver( + "src/jsx-inner.tsx", + "the .tsx receiver of specs/h.mdx carrying /** @jsx h */ inside a function body, after the first statement", + [ + B22_IMPORT_A, + "", + "export function render() {", + " /** @jsx h */", + " return <div />;", + "}", + "", + "A.m", + "A.w", + ], +); + +// The `.ts` receivers whose own names capture the lure: `helper` declared +// only inside a function and only as a type, `Record` mentioned only in a +// type annotation, and an undeclared global `test` called — each spelled as +// TEST-SPEC spells it. +const B22_HELPER_FN = b22Receiver( + "src/helper-fn.ts", + "the .ts receiver of specs/helper.mdx declaring helper only inside a function", + [ + B22_IMPORT_A, + "", + "function g() { const helper = 1; return helper }", + "", + "A.m", + "A.w", + ], +); +const B22_HELPER_TYPE = b22Receiver( + "src/helper-type.ts", + "the .ts receiver of specs/helper.mdx declaring helper only as a type", + [B22_IMPORT_A, "", "type helper = number", "", "A.m", "A.w"], +); +const B22_RECORD = b22Receiver( + "src/record.ts", + "the .ts receiver of specs/Record.mdx whose only mention of Record is a type annotation", + [B22_IMPORT_A, "", "let r: Record<string, number> = {}", "", "A.m", "A.w"], +); +const B22_TEST_CALL = b22Receiver( + "src/test-call.ts", + "the .ts receiver of specs/test.mdx calling an undeclared global test(…)", + [B22_IMPORT_A, "", 'test("adds", () => {', " A.m", "})", "", "A.w"], +); + +/** One lure: its receiver, the lured name its target's basename spells, + * the name's standing there, and why 6.5 keeps it from an added import. */ +interface B22Lure { + readonly lured: string; + readonly receiver: B22Receiver; + readonly standing: "barred" | "declared" | "referenced"; + readonly why: string; +} + +// The thirteen named targets, every barred class of 6.5 with a fixed lure +// (TEST-SPEC T6.5-22(b); §16's anchoring beside P-5's drawn basenames). +const B22_NAMED_TARGETS: ReadonlyArray<readonly [lured: string, why: string]> = + [ + [ + "let", + "a word strict code admits as no binding, whose binding 14.20's derivability admits", + ], + [ + "await", + "a reserved word a script's code admits as a binding; a spec source's `{text(await.a)}` does not derive, so the post-move `check` sees such a product first", + ], + [ + "yield", + "the one reserved word 6.5 names whose binding and use derive in both kinds of file, so (a) alone sees a product binding it", + ], + ["eval", "a word strict code admits as no binding"], + [ + "Object", + "a constructor among clause 19's global-object properties, read by a lowered object spread", + ], + [ + "require", + "reserved by TypeScript's compiler in a module it emits in any format but ECMAScript's", + ], + ["exports", "barred beside `require`"], + ["__x", "barred by its `__` prefix alone"], + ["escape", "Annex B's global-object property (B.2.1)"], + ["unescape", "Annex B's global-object property (B.2.1)"], + [ + "Iterator", + "barred by name, being no ECMAScript 2024 global-object property", + ], + [ + "AsyncIterator", + "barred by name, being no ECMAScript 2024 global-object property", + ], + [ + "SuppressedError", + "barred by name, being no ECMAScript 2024 global-object property", + ], + ]; + +/** Every lure, in TEST-SPEC T6.5-22(b)'s order. */ +const B22_LURES: readonly B22Lure[] = [ + ...B22_NAMED_TARGETS.flatMap(([lured, why]): B22Lure[] => [ + { lured, receiver: B22_SPEC_HOST, standing: "barred", why }, + { lured, receiver: B22_TS_HOST, standing: "barred", why }, + ]), + { + lured: "React", + receiver: B22_VIEW, + standing: "barred", + why: "barred in every TSX source, here one holding classic-runtime JSX", + }, + { + lured: "React", + receiver: B22_PLAIN, + standing: "barred", + why: "barred in every TSX source, here one holding no JSX, which a product barring it only where the file spells JSX misses", + }, + { + lured: "h", + receiver: B22_JSX_DOC, + standing: "barred", + why: "the factory `/** @jsx h */` names", + }, + { + lured: "h", + receiver: B22_JSX_UPPER, + standing: "barred", + why: "the factory `/* @JSX h */` names, the pragma's name matched regardless of ASCII case (12.0's second exception)", + }, + { + lured: "preact", + receiver: B22_JSX_PREACT, + standing: "barred", + why: "the leading identifier of the factory `/** @jsx preact.h */` names", + }, + { + lured: "Frag", + receiver: B22_JSX_FRAG, + standing: "barred", + why: "the fragment factory `/** @jsxFrag Frag */` names, which a product reading `@jsx` pragmas alone misses", + }, + { + lured: "h", + receiver: B22_JSX_LINE, + standing: "barred", + why: "the factory the line comment `// @jsx h` names: TypeScript's own pragma reading ignores it, 6.5 bars it", + }, + { + lured: "h", + receiver: B22_JSX_INNER, + standing: "barred", + why: "the factory an in-function `/** @jsx h */` after the first statement names: TypeScript's own pragma reading ignores it, 6.5 bars it", + }, + { + lured: "helper", + receiver: B22_HELPER_FN, + standing: "declared", + why: "declared only inside a function: 6.5's freshness spans every scope", + }, + { + lured: "helper", + receiver: B22_HELPER_TYPE, + standing: "declared", + why: "declared only as a type: 6.5's freshness spans the type level", + }, + { + lured: "Record", + receiver: B22_RECORD, + standing: "referenced", + why: "a lib type's name the file references only in a type annotation, which 6.5's reference clause alone bars, at type level", + }, + { + lured: "test", + receiver: B22_TEST_CALL, + standing: "referenced", + why: "an undeclared global the file calls: binding it captures the call, a use of a spec module binding — a condition-18 finding in the post-move `check` (4.5)", + }, +]; + +/** + * The lure's premise, a harness defect when it fails: S-6's name analysis + * of the staged receiver gives the lured name the standing the entry + * states, so the move steers a stem-derived choice onto a name 6.5 keeps + * from an added import there. + */ +function b22AssertLures(lure: B22Lure, context: string): void { + const verdict = nameVerdict( + analyzeNames(lure.receiver.kind, lure.receiver.text), + lure.lured, + ); + const holds = + lure.standing === "barred" + ? verdict.barred !== undefined + : lure.standing === "declared" + ? verdict.declared + : verdict.referenced; + if (!holds) { + throw new Error( + `${context}: a harness defect — S-6's name analysis does not find ` + + `\`${lure.lured}\` ${lure.standing} in the staged ` + + `${lure.receiver.file} (${JSON.stringify(verdict)}), so the lure ` + + `lures nothing`, + ); + } +} + +/** The receiving file's text after the move, failing diagnosed unless it + * is a plain file of valid UTF-8 (SPEC 6.5 rewrites it in place; 1.6). */ +async function b22ReadReceiver( + workspace: TestWorkspace, + file: string, + context: string, +): Promise<string> { + const kind = await workspace.kind(file); + if (kind !== "file") { + fail( + `${context}: after the move, ${file} must still be a plain file — ` + + `the move rewrites it in place (SPEC 6.5); found ${kind}`, + ); + } + const bytes = await workspace.readBytes(file); + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + fail( + `${context}: after the move, ${file} is not valid UTF-8 — 6.5 keeps ` + + `every file a move rewrites well-formed (SPEC 1.6, 14.20)`, + ); + } +} + +/** One lure in its own fresh workspace (see the notes). */ +async function runB22Lure( + product: ProductBinding, + lure: B22Lure, +): Promise<void> { + const { lured, receiver } = lure; + const target = `specs/${lured}.mdx`; + const specifier = `${receiver.toSpecs}${lured}.xspec`; + const argv = [ + "move", + `${B22_ORIGIN}#${receiver.section}`, + `${target}#${receiver.section}`, + "--json", + ]; + const context = + `T6.5-22 (b) the lure ${target} received by ${receiver.file} ` + + `(\`${lured}\`: ${lure.why})`; + b22AssertLures(lure, context); + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": B22_CONFIG, + [B22_ORIGIN]: B22_ORIGIN_SOURCE, + [receiver.file]: receiver.staged, + }, + }); + try { + const label = `${context}: \`${argv.join(" ")}\``; + decodeAppliedMappingReport( + await runJson( + product, + workspace, + argv, + `${label} — a valid section move into a target file it creates, ` + + `so it is performed: exit 0 (SPEC 6.5, 12.0)`, + ), + `${label} — the form-exact 12.7 performed-operation document ` + + `(SPEC 6.5, 12.7)`, + ); + const after = await b22ReadReceiver(workspace, receiver.file, context); + const judgement = judgeAddedImportsOfFile( + receiver.file, + receiver.kind, + receiver.text, + after, + ); + if (judgement.problems.length > 0) { + fail( + `${context}: T6.5-22(a)'s constraints on the added identifiers ` + + `(SPEC 6.5), read from the added bytes:\n` + + judgement.problems.map((problem) => ` - ${problem}`).join("\n"), + ); + } + const added = judgement.added; + if (added.length !== 1 || added[0]?.specifier !== specifier) { + fail( + `${context}: the move adds exactly one import declaration to ` + + `${receiver.file}, for the target module's default binding, its ` + + `specifier the canonical ${JSON.stringify(specifier)} (SPEC 6.5: ` + + `one added declaration per module whose bindings the file's ` + + `spellings are rooted at and it lacks; \`A\` keeps its use by ` + + `\`w\`); the declarations added: ` + + JSON.stringify(added.map((declaration) => declaration.text)), + ); + } + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean (SPEC 6.5, ` + + `4.5, 14.20)`, + ); + } finally { + await workspace.dispose(); + } +} + +const T6_5_22 = defineProductTest({ + id: "T6.5-22", + title: + "barred and captured names, (b)'s lures under (a)'s universal assertion (the subprocess driver judges every performed move's added identifiers on exit 0): each a section move from `specs/A.mdx` into a target file it creates, whose receiving file needs an import of the target module, named to steer a basename- or stem-derived choice onto a barred or captured name — `specs/let.mdx`, `specs/await.mdx`, `specs/yield.mdx`, `specs/eval.mdx`, `specs/Object.mdx`, `specs/require.mdx`, `specs/exports.mdx`, `specs/__x.mdx`, `specs/escape.mdx`, `specs/unescape.mdx`, `specs/Iterator.mdx`, `specs/AsyncIterator.mdx`, and `specs/SuppressedError.mdx`, each received once by a spec source (`{text(A.a)}`) and once by a `.ts` code source (the marker `A.m`); `specs/React.mdx` by a `.tsx` receiver holding classic-runtime JSX (`<div />`) and by one holding none; `specs/h.mdx` by `.tsx` receivers carrying `/** @jsx h */` and `/* @JSX h */`, `specs/preact.mdx` by one carrying `/** @jsx preact.h */`, and `specs/Frag.mdx` by one carrying `/** @jsxFrag Frag */` alone; `specs/h.mdx` by `.tsx` receivers whose pragma TypeScript ignores, `// @jsx h` and an in-function `/** @jsx h */` after the first statement; `specs/helper.mdx` by `.ts` receivers declaring `helper` only inside a function and only as a type; `specs/Record.mdx` by a `.ts` receiver whose only mention of `Record` is `let r: Record<string, number> = {}`; and `specs/test.mdx` by a `.ts` receiver calling an undeclared global `test(…)` — each move exits 0 and adds exactly one import declaration, of the target module (its canonical specifier), whose identifiers, read from the added bytes, breach none of 6.5's constraints (the lured name in particular), and `check` is clean after it (SPEC 6.5, 2.1, 4, 4.5, 14.20, 12.7)", + timeoutMs: 300_000, + run: async (product) => { + const failures: string[] = []; + for (const lure of B22_LURES) { + try { + await runB22Lure(product, lure); + } catch (error) { + if (!(error instanceof HarnessAssertionError)) throw error; + failures.push(error.message); + } + } + if (failures.length > 0) { + fail( + `T6.5-22 (b): ${String(failures.length)} of ` + + `${String(B22_LURES.length)} lures failed, each diagnosed:\n` + + failures.map((failure) => `* ${failure}`).join("\n"), + ); + } + }, +}); + +/** TEST-SPEC §6.5, fourth part, in canonical ID order (SUITE-25). */ +export const section65ivTests: readonly ProductTestEntry[] = [ + T6_5_20, + T6_5_21, + T6_5_22, +]; diff --git a/test/suite/registry/section-6.5-v.ts b/test/suite/registry/section-6.5-v.ts new file mode 100644 index 00000000..aefbc82c --- /dev/null +++ b/test/suite/registry/section-6.5-v.ts @@ -0,0 +1,2625 @@ +// TEST-SPEC §6.5 (move), fifth part — SUITE-25 (continued): T6.5-23, +// statement boundaries, the directive prologue, and timeliness. T6.5-1… +// T6.5-10 are section-6.5.ts's business, T6.5-11 section-6.5-ii.ts's, +// T6.5-12…T6.5-19 section-6.5-iii.ts's, and T6.5-20…T6.5-22 +// section-6.5-iv.ts's; this module keeps those files' edits bounded (the +// section-10.7-i/-ii precedent). +// +// 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, +// and rejects a product only via diagnosed assertion failures (H-8). +// +// SPEC 6.5 (Added imports): a declaration added to a file existing before +// the operation stands at an admissible offset — one lying inside none of +// the file's statements before the edit, so that it splits none, and, in a +// TypeScript source, a top-level declaration whose bindings are timely for +// every spelling rooted at them, at or after the end of the file's +// directive prologue, following the end of a top-level statement with +// nothing but whitespace (1.4) between, the prologue's end and the +// statement's each judged, like timeliness, over the file before the edit. +// An admissible offset at the start of a line is taken over any other; the +// choice among several such, or among mid-line ones where no line start is +// admissible, is the implementation's latitude. +// +// Conservative operationalizations (noted per H-4): +// - One fresh workspace per staging (H-1), every file a staged-source record +// (S-9's timing clause: every staging but the body's first follows a +// product invocation). Arms (a) through (e): one spec group +// (`specs/**/*.mdx`) and one code group (`src/**/*.ts`); +// `specs/origin.mdx` holding the top-level sections +// `x`, moved, and `w`, kept; `specs/target.mdx` holding `z` (the target +// (o) names, one staging throughout); the staging's `src/c.ts`; and, for +// (e)'s 6.5 shape, `specs/c.mdx` holding `c`. Each arm is TEST-SPEC's +// `move specs/origin.mdx#x specs/target.mdx#y`: the marker `O.x` sits on +// the moved node and `O.w` on a kept one, so the origin declaration stays +// and the receiver gains one declaration, binding the target module's +// default. +// - Per staging (`runS23Arm`): `build --json` clean over the staging (the +// premise — a valid workspace, so a later failure is the move's); the +// `--preview --json` twin taken first (a preview modifies nothing, 6.6); +// then the real move with `--json`, exit 0 and the form-exact 12.7 +// performed-operation document. +// - The byte contract: `src/c.ts` after the move is a plain file of valid +// UTF-8 that TypeScript 5.9.3 accepts both as module code and as script +// code (`judgeTypeScript`, S-9's judge, held as an assertion on the +// product's bytes; 14.20), and it is exactly the staged bytes composed in +// pre-operation coordinates (6.6): the marker's span replaced by `<X>.y`, +// and `import <X> from "../specs/target.xspec"` inserted under 6.5's line +// discipline — followed by U+000A, preceded by one exactly when the offset +// is not at the start of a line of the composed text with the insertion +// absent (3's terminators, `atLineStart`) — at one of the admissible +// offsets TEST-SPEC names for the staging. Value-blind in `<X>` alone, +// read from the one declaration the file gained (`judgeAddedImportsOfFile`, +// T6.5-22(a)'s judgement over the file's two texts, one code path with the +// subprocess driver's hook, which judges every performed move besides), +// byte-exact in every other character (T6.5-8's discipline). Each staging's +// admissible offsets compose pairwise distinct bytes, so the bytes name the +// offset the real operation used. +// - The preview parity (T6.6-4(b)): the preview's entry for `src/c.ts` holds +// exactly one `import-addition` edit, zero-length, at that offset. The +// entry's other edits are T6.6-4's and T6.6-3's business, unasserted here. +// - `check --json`, then `build --json`, exactly `{"findings": []}` after the +// move. +// - (c)'s standard-tooling compile (H-2): the consumer project over +// `src/c.ts` (`test/helpers/tooling.ts`) reports no diagnostic after the +// premise build and none after the move — the `@ts-expect-error` directive +// governing `const n: number = "x"`, its one type error, both times. +// - Before any product is driven on a staging, each admissible offset's +// composed form, a placeholder in `<X>`'s place, is held to the same +// TypeScript judge: a form it rejects is a harness defect (S-9: the +// derivability of every composed form), never a product verdict. +// - Every staging runs; each one's diagnosed failure is collected and the +// body fails once at the end, naming every staging that failed, so one run +// diagnoses every placement. A harness error — anything but a diagnosed +// assertion failure, a hang included — propagates at once. Where the +// product's bytes read as the declaration inserted at another offset, the +// diagnosis names that offset and, where TEST-SPEC states it, why 6.5 +// excludes it. +// - Arms (f) and (g) widen the staging: each names its own move, files, and +// receiver (`S23Arm`). (f)'s seven stagings are TEST-SPEC's `move +// specs/A.mdx#m specs/B.mdx#m` over `specs/A.mdx` holding `m` and `k` and +// `specs/B.mdx` holding `b` and no `m`, the receiver `src/c.ts` under the +// arms' two groups. Where the receiver gains a declaration it is `import +// <X> from "../specs/B.xspec"`, the marker `A.m` rewritten in place to +// `<X>.m`, composed and judged as above; where it gains none (the control, +// the two exempt-side stagings, the precedence branch) the file is exactly +// the staged bytes with `A.m` rewritten to `B.m` — no declaration added +// (T6.5-22(a)'s judgement reads none), no other byte changed. The preview +// is taken wherever the receiver gains a declaration (the +// `import-addition` parity) and wherever TEST-SPEC states the preview's +// edits — the exempt side: one `reference-rewrite` and no +// `import-addition` or `import-removal`; the precedence branch: one +// `reference-rewrite` spanning [70, 73) and the same absences; the +// control states none, so its preview is not taken. After 6.5's example +// and the precedence branch, `query edges --from src/c.ts --to +// specs/B.mdx#m --kinds references` answers exactly the marker's edge (a +// top-level statement's reference is attributed to the file, 4.6). +// - (g)'s receiver is the spec source `specs/target.mdx`, receiving into +// `p.n` T6.5-13(h)'s moved text: T6.5-13's cross-file origin `specs/a.mdx` +// and third module `specs/x.mdx` (section-6.5-iii.ts's records, under its +// one-spec-group R16_CONFIG), the same `a`-alone record at `specs/k.mdx` +// for `K.a`, and `move specs/a.mdx#m specs/target.mdx#p.n`. The moved text +// — its lines, its embedding re-rooted at `<X>`, and a U+000A — is a +// zero-length span of the rewrite at the start of the `</S>` line, as +// T6.5-13 composes it; `import <X> from "./x.xspec"` stands at offset 0, +// 28, or 59 (6.5's latitude among the three line starts). Its forms are +// judged by S-9's MDX judge (`deriveMdx`) in place of the TypeScript one, +// the form composed at the start of line 2 among the premises: it derives, +// TEST-SPEC states, though it splits `K`'s declaration. +// - Arms (h) through (j) stage `f` = `export function f() { O.x }`, so `O`'s +// declaration loses its last use and is removed with its line: the removal +// is a rewrite spelled `""` over TEST-SPEC's range (cross-checked against +// the staged text at module load), and the declaration stands at the +// removal's end, the one admissible offset. The removal's start composes +// the same bytes (an insertion before an edit beginning there), so the +// preview's `import-addition` parity alone tells the two apart +// (T6.6-4(b)). (h) states its preview's edits — one `reference-rewrite` +// spanning the marker, one `import-removal` spanning [0, 38) — and the +// marker's `references` edge from `src/c.ts#f`, the unit holding it (4.6). +// - (k) is (f)'s move over a call: `ta(A.m)` rewritten whole, the added +// declaration binding `B`'s module's `text` alone — `import { text as <Y> } +// from "../specs/B.xspec"`, or `import { text } …` where the identifier is +// `text` itself (`S23Spelling`; value-blind in `<Y>` alone, each +// spelling's composed forms among the premises); its control adds nothing, +// the call re-rooted at `tb(B.m)`. Both state the preview's one +// `reference-rewrite` spanning the call and no `import-removal`, and the +// call's `embeds` edge from `src/c.ts` to `specs/B.mdx#m`. +// - (l) and (m) are (f)'s move over two declarations of `A`'s module, `A1` +// and `A2`, `specs/A.mdx` holding `m`, its child `m.c`, and `k`: the +// markers `A1.m` and `A2.m.c` are two rewrites, (l) rooting both at the +// one added binding (`<X>.m`, `<X>.m.c`), (m) `A1.m` alone, `A2.m.c` +// re-rooted at the held `B` (`B.m.c`); each at offset 34 alone (the +// line-start preference), the preview's two `reference-rewrite` edits and +// no `import-removal`, and both markers' `references` edges from +// `src/c.ts`. +// - (n) is (d)'s file with `f` spread over lines, and with `namespace N {` +// in its place: 37 or 38; the line starts inside the body are judged +// among the premises as deriving (`deriving`), as TEST-SPEC states. +// - (o)'s receiver is the spec source `specs/third.mdx` (R16_CONFIG, no +// code group): `O.x` re-rooted at the later-declared `T`, nothing added, +// `O`'s declaration removed with its line, [0, 31) (a rewrite spelled +// `""`); the preview's `reference-rewrite` [45, 48) and `import-removal` +// [0, 31), and `p`'s `depends` edge to `specs/target.mdx#y`. +// - (p) stages each of U+0020, U+0009, U+000B, and U+000C (built from code +// points) between `O`'s declaration and its line's terminator: 39 alone, +// the preview's `reference-rewrite` [61, 64) and no `import-removal`. + +import { Buffer } from "node:buffer"; +import type { + EdgeKind, + PreviewEdit, + PreviewEditClass, + PreviewFileEntry, +} from "../../helpers/adapters/index.js"; +import { + decodeAppliedMappingReport, + decodeEdgesReport, + decodePreviewReport, +} from "../../helpers/adapters/index.js"; +import { + type AddedImportDeclaration, + judgeAddedImportsOfFile, +} from "../../helpers/added-import-identifiers.js"; +import { fail, HarnessAssertionError } from "../../helpers/assertions.js"; +import { atLineStart } from "../../helpers/import-insertion.js"; +import { deriveMdx } from "../../helpers/mdx-derivability.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { type StagedMdx, stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { + assertNoCompileErrors, + ConsumerProject, +} from "../../helpers/tooling.js"; +import { judgeTypeScript } from "../../helpers/ts-derivability.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import { + A13_ORIGIN_STAGED, + A13_THIRD_STAGED, + a13MovedLines, + R16_CONFIG, +} from "./section-6.5-iii.js"; +import { + assertEdgeSetEqual, + expectFindingFreeReport, + runJson, +} from "./support.js"; + +// --------------------------------------------------------------------------- +// T6.5-23 Statement boundaries, the directive prologue, and timeliness +// --------------------------------------------------------------------------- + +const S23_ORIGIN = "specs/origin.mdx"; +const S23_TARGET = "specs/target.mdx"; +const S23_C_SPEC = "specs/c.mdx"; +/** The receiving file of every staging below but (g)'s. */ +const S23_APP = "src/c.ts"; +/** The arms' move (TEST-SPEC: "a section move of `specs/origin.mdx#x` to + * `specs/target.mdx#y`"). */ +const S23_ARGV = ["move", "specs/origin.mdx#x", "specs/target.mdx#y"] as const; +/** The target module's canonical relative specifier from `src/` (6.5). */ +const S23_TARGET_SPECIFIER = "../specs/target.xspec"; + +// One spec group and one code group (SPEC 7.1, 7.2): a staged-source record, +// staged by every staging after the body's first invocation (S-9's timing +// clause). +const S23_CONFIG = stagedTs( + "T6.5-23 xspec.config.ts — one spec group and one code group globbing src/**/*.ts", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`, +); + +// The origin: the moved section `x` and the kept `w`, each alone on its +// lines. +const S23_ORIGIN_SOURCE: StagedMdx = stagedMdx( + "T6.5-23 specs/origin.mdx (the moved x, the kept w)", + [ + '<S id="x">', + "Origin x text.", + "</S>", + "", + '<S id="w">', + "Kept w text.", + "</S>", + "", + ].join("\n"), +); + +// The target: an existing discovered source holding `z` ((o)'s target), so +// the move inserts the re-identified `y` into it. +const S23_TARGET_SOURCE: StagedMdx = stagedMdx( + "T6.5-23 specs/target.mdx (z)", + ['<S id="z">', "Target z text.", "</S>", ""].join("\n"), +); + +// (e)'s 6.5 shape embeds `c` through `textC(C.c)`. +const S23_C_SOURCE: StagedMdx = stagedMdx( + "T6.5-23 (e) specs/c.mdx (c, embedded by 6.5's own shape)", + ['<S id="c">', "C text.", "</S>", ""].join("\n"), +); + +/** The origin module's declaration, `O`'s, at [0, 37) where it heads a line. */ +const S23_IMPORT_O = 'import O from "../specs/origin.xspec"'; + +/** `f` (TEST-SPEC): the marker `O.x` on the moved node, `O.w` on a kept one. */ +const S23_F = "export function f() { O.x; O.w }"; + +// The two characters (d) stages after `O`'s declaration, built from their +// code points (never escape-spelled in this source). +/** U+00A0 NO-BREAK SPACE: whitespace under ECMAScript, none under 1.4. */ +const S23_NBSP = String.fromCodePoint(0xa0); +/** U+2028 LINE SEPARATOR: an ECMAScript line terminator, none under 3. */ +const S23_LSEP = String.fromCodePoint(0x2028); + +/** One span of the receiving file the rewrite replaces, in pre-operation + * coordinates (6.6) — zero-length for an insertion ((g)'s moved text). */ +interface S23Rewrite { + readonly start: number; + readonly end: number; + /** The replacement's characters, given the added binding's identifier. */ + readonly spelled: (ident: string) => string; +} + +/** + * An added declaration's exact spelling other than `import <X> from "…"` + * (SPEC 6.5's spellings: `import { text as Y } from "…"`, the named binding + * `{ text }` where its identifier is `text` itself) — (k)'s. + */ +interface S23Spelling { + /** The declaration's characters, given the added binding's identifier. */ + readonly declaration: (ident: string) => string; + /** The form in words, for diagnoses. */ + readonly form: string; + /** Identifiers the spelling treats apart ((k): `text` itself), whose + * composed forms the premises judge beside the placeholder's. */ + readonly also: readonly string[]; +} + +/** The one declaration a staging's receiver gains (SPEC 6.5). */ +interface S23Addition { + /** Its module's canonical specifier, as 6.5 spells it from the receiver. */ + readonly specifier: string; + /** Its spelling where it binds no module default — `undefined`: exactly + * `import <X> from "<specifier>"`, binding the module's default. */ + readonly spelled?: S23Spelling; + /** The admissible offsets TEST-SPEC names, pre-operation bytes. */ + readonly offsets: readonly number[]; + /** Why the receiver gains exactly this declaration, for diagnoses. */ + readonly why: string; +} + +/** A span a preview edit covers, pre-operation bytes (SPEC 6.6, 12.7). */ +interface S23Span { + readonly start: number; + readonly end: number; +} + +/** + * What the receiver's preview entry reports beside the `import-addition` + * parity — exactly one, zero-length at the offset the real operation used, + * where the receiver gains a declaration; none where it gains none: its + * `reference-rewrite` edits (their exact spans, or their count where + * TEST-SPEC names no span) and its `import-removal` edits (their exact + * spans), each unasserted where undefined — T6.6-4's and T6.6-3's business. + */ +interface S23PreviewExpectation { + readonly rewrites?: number | readonly S23Span[]; + readonly removals?: readonly S23Span[]; +} + +/** A `query edges` answer after the move: exactly this edge between its two + * graph nodes, of its kind (SPEC 11.1, 5.2, 4.6). */ +interface S23Edge { + readonly from: string; + readonly to: string; + readonly kind: EdgeKind; + /** Whose edge it is, for diagnoses. */ + readonly what: string; +} + +/** One byte-asserted staging of T6.5-23. */ +interface S23Arm { + /** The entry's arm and staging, e.g. `(a) between two directives`. */ + readonly key: string; + /** The receiving file — the one whose post-move bytes the arm composes. */ + readonly receiver: string; + /** Its kind: the grammar S-9 and T6.5-22(a)'s judgement read it under. */ + readonly kind: "typescript" | "spec-source"; + /** The staged receiver. */ + readonly text: string; + /** Its staged-source record (S-9). */ + readonly staged: StagedTs | StagedMdx; + /** Every other file of the staging, the configuration included. */ + readonly files: Readonly<Record<string, InitialFileContents>>; + /** The move. */ + readonly argv: readonly string[]; + /** The rewrite's replaced spans. */ + readonly rewrites: readonly S23Rewrite[]; + /** The declaration the receiver gains, or `undefined`: it gains none. */ + readonly addition: S23Addition | undefined; + /** Where 6.5 admits the added line here and why — or why nothing is + * added — for diagnoses. */ + readonly placement: string; + /** Inadmissible offsets TEST-SPEC names, with why, for diagnoses. */ + readonly excluded: Readonly<Record<number, string>>; + /** Inadmissible offsets whose composed forms TEST-SPEC states derive + * (S-9), judged with the premises. */ + readonly deriving?: readonly number[]; + /** What the preview's entry reports, where TEST-SPEC states it; the + * preview is taken wherever this is set or the receiver gains a + * declaration (T6.6-4(b)). */ + readonly preview?: S23PreviewExpectation; + /** `query edges` answers after the move. */ + readonly edges?: readonly S23Edge[]; + /** (c): why the file compiles clean under standard tooling (H-2). */ + readonly compiles?: string; +} + +/** The added declaration (6.5's exact spelling) binding `ident` to the + * module `specifier` designates. */ +function s23Declaration( + ident: string, + specifier: string = S23_TARGET_SPECIFIER, +): string { + return `import ${ident} from "${specifier}"`; +} + +/** The declaration `addition` adds, spelled for the identifier `ident`. */ +function s23Added(addition: S23Addition, ident: string): string { + return ( + addition.spelled?.declaration(ident) ?? + s23Declaration(ident, addition.specifier) + ); +} + +/** `addition`'s spelling in words, for diagnoses. */ +function s23Form(addition: S23Addition): string { + return ( + addition.spelled?.form ?? + `\`import <X> from "${addition.specifier}"\`, binding its module's default` + ); +} + +/** The marker's span in `text` — `O.x` unless `marker` says otherwise — + * rewritten as `spelled` spells it, `<X>.y` by default (6.4: prefix + * replacement, the chain re-rooted at the binding 6.5 chooses). */ +function s23Marker( + text: string, + key: string, + marker = "O.x", + spelled: (ident: string) => string = (ident) => `${ident}.y`, +): S23Rewrite { + const bytes = Buffer.from(text, "utf8"); + const start = bytes.indexOf(marker); + if (start < 0 || bytes.lastIndexOf(marker) !== start) { + throw new Error( + `T6.5-23 ${key}: a harness defect — the staged ${S23_APP} must hold ` + + `the marker \`${marker}\` exactly once`, + ); + } + return { start, end: start + Buffer.byteLength(marker, "utf8"), spelled }; +} + +/** + * The receiving file as the rewrite leaves it, composed in pre-operation + * coordinates (SPEC 6.5, 6.6): each rewrite's span replaced whole and, given + * an addition, its declaration inserted at the offset — before an edit + * beginning there, after one ending there — followed by U+000A and preceded + * by one exactly when the offset is not at the start of a line of the + * composed text with the insertion's own result absent (6.5; 3's + * terminators, `atLineStart`) — or, for the misplacement diagnosis alone, + * exactly when `lead` says so. `undefined` when the offset lies strictly + * inside a replaced span (no admissible offset does, 6.5). + */ +function s23Compose( + pre: Uint8Array, + rewrites: readonly S23Rewrite[], + ident: string, + addition?: { + readonly offset: number; + readonly declaration: string; + readonly lead?: boolean; + }, +): Buffer | undefined { + const sorted = [...rewrites].sort((a, b) => a.start - b.start); + const parts: Uint8Array[] = []; + let length = 0; + let cursor = 0; + let insertAt: number | undefined; + const push = (part: Uint8Array): void => { + parts.push(part); + length += part.length; + }; + const place = (): void => { + if (addition === undefined || insertAt !== undefined) return; + push(pre.subarray(cursor, addition.offset)); + cursor = addition.offset; + insertAt = length; + }; + for (const rewrite of sorted) { + if (addition !== undefined) { + if (addition.offset > rewrite.start && addition.offset < rewrite.end) { + return undefined; + } + if (addition.offset <= rewrite.start) place(); + } + push(pre.subarray(cursor, rewrite.start)); + push(Buffer.from(rewrite.spelled(ident), "utf8")); + cursor = rewrite.end; + } + place(); + push(pre.subarray(cursor)); + const base = Buffer.concat(parts); + if (addition === undefined || insertAt === undefined) return base; + const lead = addition.lead ?? !atLineStart(base, insertAt); + const terminator = lead ? "\n" : ""; + return Buffer.concat([ + base.subarray(0, insertAt), + Buffer.from(`${terminator}${addition.declaration}\n`, "utf8"), + base.subarray(insertAt), + ]); +} + +/** A placeholder identifier the premise check composes with. */ +const S23_PLACEHOLDER = "X"; + +/** + * Why `bytes` are no well-formed text of the receiver's kind — `undefined` + * when they are: a TypeScript source is text TypeScript 5.9.3 accepts both + * as module code and as script code, a spec source text the stock MDX 3 + * grammar derives (14.20; S-9's judges). + */ +function s23Malformed(arm: S23Arm, bytes: Uint8Array): string | undefined { + if (arm.kind === "spec-source") { + const verdict = deriveMdx(bytes); + return verdict.derives + ? undefined + : `not text the stock MDX 3 grammar derives (14.20; S-9): ` + + JSON.stringify(verdict); + } + const verdict = judgeTypeScript(bytes, arm.receiver); + return verdict.verdict === "well-formed" + ? undefined + : `not text TypeScript 5.9.3 accepts both as module code and as script ` + + `code (14.20; S-9): ${JSON.stringify(verdict)}`; +} + +/** + * The staging's premises, judged before any product is driven on it (a + * harness defect otherwise, never a product verdict): the staged text, each + * admissible offset's composed form — the placeholder in `<X>`'s place — or, + * where nothing is added, the form the rewrite alone composes, and each form + * TEST-SPEC states derives at an inadmissible offset are well-formed text of + * the receiver's kind (14.20; S-9's judges), and the admissible offsets + * compose pairwise distinct bytes, so the product's bytes name the one it + * used. + */ +function s23AssertPremises(arm: S23Arm): void { + const pre = Buffer.from(arm.text, "utf8"); + const forms: { readonly what: string; readonly bytes: Uint8Array }[] = [ + { what: "the staged text", bytes: pre }, + ]; + const addition = arm.addition; + if (addition === undefined) { + if (arm.deriving !== undefined) { + throw new Error( + `T6.5-23 ${arm.key}: a harness defect — a staging gaining no ` + + `declaration names no offset of one`, + ); + } + forms.push({ + what: "the form the rewrite alone composes", + bytes: s23Compose(pre, arm.rewrites, S23_PLACEHOLDER) ?? pre, + }); + } else { + for (const ident of [S23_PLACEHOLDER, ...(addition.spelled?.also ?? [])]) { + const named = + ident === S23_PLACEHOLDER ? "" : ` with the identifier \`${ident}\``; + const admissible: Buffer[] = []; + for (const [offset, judged] of [ + ...addition.offsets.map((offset) => [offset, "admissible"] as const), + ...(arm.deriving ?? []).map((offset) => [offset, "deriving"] as const), + ]) { + const composed = s23Compose(pre, arm.rewrites, ident, { + offset, + declaration: s23Added(addition, ident), + }); + if (composed === undefined) { + throw new Error( + `T6.5-23 ${arm.key}: a harness defect — the ${judged} offset ` + + `${String(offset)} lies inside a replaced span`, + ); + } + if (judged === "admissible") admissible.push(composed); + forms.push({ + what: + `the form composed at ${judged === "admissible" ? "the admissible" : "the inadmissible, deriving"} ` + + `offset ${String(offset)}${named}`, + bytes: composed, + }); + } + const distinct = new Set(admissible.map((form) => form.toString("hex"))); + if (distinct.size !== addition.offsets.length) { + throw new Error( + `T6.5-23 ${arm.key}: a harness defect — two admissible offsets ` + + `compose the same bytes${named}, so the bytes cannot name the ` + + `offset used`, + ); + } + } + } + for (const form of forms) { + const malformed = s23Malformed(arm, form.bytes); + if (malformed !== undefined) { + throw new Error( + `T6.5-23 ${arm.key}: a harness defect — ${form.what} is ` + + `${malformed}; the text reads ` + + JSON.stringify(Buffer.from(form.bytes).toString("utf8")), + ); + } + } +} + +/** The receiving file's text after the move, failing diagnosed unless it is + * a plain file of valid UTF-8 (SPEC 6.5 rewrites it in place; 1.6). */ +async function s23ReadReceiver( + workspace: TestWorkspace, + arm: S23Arm, + context: string, +): Promise<{ readonly bytes: Uint8Array; readonly text: string }> { + const kind = await workspace.kind(arm.receiver); + if (kind !== "file") { + fail( + `${context}: after the move, ${arm.receiver} must still be a plain ` + + `file — the move rewrites it in place (SPEC 6.5); found ${kind}`, + ); + } + const bytes = await workspace.readBytes(arm.receiver); + try { + const text = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + return { bytes, text }; + } catch { + fail( + `${context}: after the move, ${arm.receiver} is not valid UTF-8 — ` + + `6.5 keeps every file a move rewrites well-formed (SPEC 1.6, 14.20)`, + ); + } +} + +/** The preview's entry for the receiver, taken before the real move (SPEC + * 6.6: a preview modifies nothing). */ +async function s23PreviewEntry( + product: ProductBinding, + workspace: TestWorkspace, + arm: S23Arm, + context: string, +): Promise<PreviewFileEntry> { + const label = `${context}: \`${arm.argv.join(" ")} --preview --json\` before the real move`; + const report = decodePreviewReport( + await runJson( + product, + workspace, + [...arm.argv, "--preview", "--json"], + `${label} — a performable move previews with exit 0 (SPEC 6.6, 6.5)`, + ), + label, + ); + if (report.files === null) { + fail( + `${label}: a preview exiting 0 succeeds as the real operation would ` + + `and reports its \`files\` — \`null\` is a refused preview's form ` + + `(SPEC 6.6, 12.7); findings: ${JSON.stringify(report.findings)}`, + ); + } + const entries = report.files.filter( + (candidate) => candidate.file === arm.receiver, + ); + const entry = entries.length === 1 ? entries[0] : undefined; + if (entry === undefined) { + fail( + `${label}: \`files\` holds exactly one entry for ${arm.receiver}, a ` + + `file the operation rewrites (SPEC 6.6, 12.7); got ` + + `[${report.files.map((candidate) => JSON.stringify(candidate.file)).join(", ")}]`, + ); + } + return entry; +} + +/** The 1-based line and column of byte `offset` in `bytes` (lines by 3's + * terminators), for diagnoses. */ +function s23Where(bytes: Uint8Array, offset: number): string { + let line = 1; + let start = 0; + for (let i = 0; i < offset; i += 1) { + const byte = bytes[i]; + if (byte === 0x0a || (byte === 0x0d && bytes[i + 1] !== 0x0a)) { + line += 1; + start = i + 1; + } + } + return `offset ${String(offset)} (line ${String(line)}, byte column ${String(offset - start + 1)})`; +} + +/** + * Name the product's placement when its bytes read as the staged bytes + * composed with the declaration inserted at offsets other than the + * admissible ones (every offset of the file tried) — under 6.5's line + * discipline, else as a line of its own with or without a U+000A before it + * against that discipline — each with the reason TEST-SPEC gives where it + * names one. + */ +function s23Misplacement( + arm: S23Arm, + addition: S23Addition, + pre: Uint8Array, + actual: Uint8Array, + ident: string, +): string { + const found: string[] = []; + for (let offset = 0; offset <= pre.length; offset += 1) { + for (const lead of [undefined, true, false]) { + const composed = s23Compose(pre, arm.rewrites, ident, { + offset, + declaration: s23Added(addition, ident), + lead, + }); + if (composed === undefined || Buffer.compare(composed, actual) !== 0) { + continue; + } + const why = arm.excluded[offset]; + found.push( + s23Where(pre, offset) + + (lead === undefined + ? "" + : ` with${lead ? "" : "out"} a U+000A before it, against ` + + `6.5's line discipline (the offset is ` + + `${atLineStart(pre, offset) ? "" : "not "}at a line's start)`) + + (why === undefined ? "" : ` — ${why}`), + ); + break; + } + } + return found.length === 0 + ? `${S23_NO_READING} of the declaration under 6.5's line discipline ` + + "beside the rewrite's edits, at any offset" + : `the bytes read as the declaration inserted at ${found.join("; or at ")}`; +} + +/** How `s23Misplacement` begins when the bytes name no offset. */ +const S23_NO_READING = "the bytes read as no single insertion"; + +/** The identifiers every declaration of `addition`'s spelling in `text` + * binds, wherever it stands — a statement's body included — for diagnoses + * alone. */ +function s23SpelledIdentifiers( + addition: S23Addition, + text: string, +): readonly string[] { + const slot = "@IDENT@"; + const pattern = s23Added(addition, slot) + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(slot, "([A-Za-z_$][A-Za-z0-9_$]*)"); + return [...text.matchAll(new RegExp(pattern, "g"))].flatMap((match) => + match[1] === undefined ? [] : [match[1]], + ); +} + +/** The rewrite's edits in words, given the added binding's identifier, for + * diagnoses. */ +function s23Rewritten( + pre: Uint8Array, + rewrites: readonly S23Rewrite[], + ident: string, +): string { + return rewrites + .map((rewrite) => { + const spelled = rewrite.spelled(ident); + const replaced = Buffer.from( + pre.subarray(rewrite.start, rewrite.end), + ).toString("utf8"); + if (rewrite.start === rewrite.end) { + return ( + `${JSON.stringify(spelled)} inserted at offset ` + + String(rewrite.start) + ); + } + return spelled === "" + ? `${JSON.stringify(replaced)} removed, ` + + `[${String(rewrite.start)}, ${String(rewrite.end)})` + : `\`${replaced}\` rewritten in place to \`${spelled}\``; + }) + .join(", "); +} + +/** The receiver's text after the move, as read. */ +interface S23After { + readonly bytes: Uint8Array; + readonly text: string; +} + +/** + * The receiver gains exactly one declaration, of `addition`'s module and + * binding its default, value-blind in the identifier alone, at an + * admissible offset: the admissible offsets whose composed bytes the + * product's equal (pairwise distinct per staging, so one), failing diagnosed + * where none does. + */ +function s23AssertAddition( + arm: S23Arm, + addition: S23Addition, + pre: Uint8Array, + after: S23After, + added: readonly AddedImportDeclaration[], + context: string, +): readonly number[] { + const ident = + added.length === 1 && + added[0]?.specifier === addition.specifier && + added[0].identifiers.length === 1 + ? added[0].identifiers[0] + : undefined; + if (ident === undefined) { + // A declaration a statement nests ((n): inside a body) is no added + // import T6.5-22(a)'s judgement reads: name where it stands. + const nested = s23SpelledIdentifiers(addition, after.text) + .map((candidate) => + s23Misplacement(arm, addition, pre, after.bytes, candidate), + ) + .filter((reading) => !reading.startsWith(S23_NO_READING)); + fail( + `${context}: the move adds exactly one import declaration to ` + + `${arm.receiver}, of one binding — ${s23Form(addition)} (SPEC ` + + `6.5: ${addition.why}); the declarations added: ` + + JSON.stringify(added.map((declaration) => declaration.text)) + + nested.map((reading) => `; ${reading}`).join("") + + `; the file reads ${JSON.stringify(after.text)}`, + ); + } + const declaration = s23Added(addition, ident); + const readings = addition.offsets.filter((offset) => { + const composed = s23Compose(pre, arm.rewrites, ident, { + offset, + declaration, + }); + return ( + composed !== undefined && Buffer.compare(composed, after.bytes) === 0 + ); + }); + if (readings.length === 0) { + fail( + `${context}: ${arm.receiver} after the move is exactly the staged ` + + `bytes with ${s23Rewritten(pre, arm.rewrites, ident)} and ` + + `${JSON.stringify(declaration)} inserted under 6.5's line ` + + `discipline (U+000A after it, one before it where the offset is ` + + `not at a line's start) at ` + + addition.offsets.map((offset) => s23Where(pre, offset)).join(" or ") + + ` — ${arm.placement} (SPEC 6.5, 1.4, 3, 6.4; T6.5-8's ` + + `discipline, value-blind in the identifier alone); ` + + `${s23Misplacement(arm, addition, pre, after.bytes, ident)}; the ` + + `file reads ${JSON.stringify(after.text)}`, + ); + } + return readings; +} + +/** + * The receiver gains no declaration — T6.5-22(a)'s judgement reads none — + * and is exactly the staged bytes with the rewrite's edits alone, byte- + * composable exactly, failing diagnosed otherwise. + */ +function s23AssertNothingAdded( + arm: S23Arm, + pre: Uint8Array, + after: S23After, + added: readonly AddedImportDeclaration[], + context: string, +): void { + if (added.length > 0) { + fail( + `${context}: the move adds no import declaration to ${arm.receiver} ` + + `— ${arm.placement} (SPEC 6.5); the declarations added: ` + + JSON.stringify(added.map((declaration) => declaration.text)) + + `; the file reads ${JSON.stringify(after.text)}`, + ); + } + const expected = s23Compose(pre, arm.rewrites, "") ?? Buffer.from(pre); + if (Buffer.compare(expected, after.bytes) !== 0) { + fail( + `${context}: ${arm.receiver} after the move is exactly the staged ` + + `bytes with ${s23Rewritten(pre, arm.rewrites, "")} and no other ` + + `byte changed — ${arm.placement} (SPEC 6.5, 6.4; byte-composable ` + + `exactly); expected ${JSON.stringify(expected.toString("utf8"))}, ` + + `the file reads ${JSON.stringify(after.text)}`, + ); + } +} + +/** The spans of `edits`, in a canonical order, against `spans`. */ +function s23SameSpans( + edits: readonly PreviewEdit[], + spans: readonly S23Span[], +): boolean { + const key = (span: S23Span): string => + `${String(span.start)}:${String(span.end)}`; + const got = edits.map((edit) => key(edit.range)).sort(); + const want = spans.map(key).sort(); + return ( + got.length === want.length && + got.every((value, index) => value === want[index]) + ); +} + +/** Spans in words, for diagnoses. */ +function s23Spans(spans: readonly S23Span[]): string { + return spans + .map((span) => `[${String(span.start)}, ${String(span.end)})`) + .join(", "); +} + +/** + * The preview's entry for the receiver against the real operation (SPEC + * 6.6: a preview reports exactly the real operation's edits; T6.6-4(b)): + * the `import-addition` parity, then whatever the arm's preview expectation + * states. + */ +function s23AssertPreview( + arm: S23Arm, + pre: Uint8Array, + entry: PreviewFileEntry, + readings: readonly number[], + context: string, +): void { + const label = `${context}: the preview's entry for ${arm.receiver}`; + const of = (cls: PreviewEditClass): readonly PreviewEdit[] => + entry.edits.filter((edit) => edit.class === cls); + const additions = of("import-addition"); + if (arm.addition !== undefined) { + const addition = additions.length === 1 ? additions[0] : undefined; + if ( + addition === undefined || + addition.range.start !== addition.range.end || + !readings.includes(addition.range.start) + ) { + fail( + `${label} reports exactly one \`import-addition\`, zero-length at ` + + `the offset the real operation then used — ` + + readings.map((offset) => s23Where(pre, offset)).join(" or ") + + `, where the bytes above stand (SPEC 6.6, 6.5: the offset is ` + + `exactly the one the preview reports; T6.6-4(b)); the preview's ` + + `edits: ${JSON.stringify(entry.edits)}`, + ); + } + } else if (additions.length > 0) { + fail( + `${label} reports no \`import-addition\` — the real operation adds ` + + `no declaration to it (SPEC 6.6, 6.5; T6.6-4(b)); the preview's ` + + `edits: ${JSON.stringify(entry.edits)}`, + ); + } + const wanted = arm.preview?.rewrites; + if (wanted !== undefined) { + const rewrites = of("reference-rewrite"); + const holds = + typeof wanted === "number" + ? rewrites.length === wanted + : s23SameSpans(rewrites, wanted); + if (!holds) { + fail( + `${label} reports ` + + (typeof wanted === "number" + ? `exactly ${String(wanted)} \`reference-rewrite\` ` + + `edit${wanted === 1 ? "" : "s"}` + : `exactly the \`reference-rewrite\` edits spanning ` + + s23Spans(wanted)) + + ` (SPEC 6.6, 6.5, 12.7: the real operation's rewrites); the ` + + `preview's edits: ${JSON.stringify(entry.edits)}`, + ); + } + } + const removals = arm.preview?.removals; + if (removals !== undefined && !s23SameSpans(of("import-removal"), removals)) { + fail( + `${label} reports ` + + (removals.length === 0 + ? "no `import-removal`" + : `exactly the \`import-removal\` edits spanning ${s23Spans(removals)}`) + + ` (SPEC 6.6, 6.5, 12.7: the real operation's removals); the ` + + `preview's edits: ${JSON.stringify(entry.edits)}`, + ); + } +} + +/** `query edges` after the move answers exactly the arm's edge between its + * two graph nodes, of its kind (SPEC 11.1, 5.2, 4.6). */ +async function s23AssertEdge( + product: ProductBinding, + workspace: TestWorkspace, + edge: S23Edge, + context: string, +): Promise<void> { + const argv = [ + "query", + "edges", + "--from", + edge.from, + "--to", + edge.to, + "--kinds", + edge.kind, + ]; + const label = `${context}: \`${argv.join(" ")}\` after the move`; + assertEdgeSetEqual( + decodeEdgesReport( + await runJson( + product, + workspace, + argv, + `${label} — a valid workspace answers with exit 0 (SPEC 11.1, 13.3)`, + ), + label, + ), + [{ from: edge.from, to: edge.to, kind: edge.kind }], + `${label}: exactly ${edge.what}'s \`${edge.kind}\` edge from ` + + `${edge.from} to ${edge.to} (SPEC 6.5, 5.2, 4.6, 11.1)`, + ); +} + +/** One staging in its own fresh workspace (see the notes). */ +async function runS23Arm(product: ProductBinding, arm: S23Arm): Promise<void> { + const context = `T6.5-23 ${arm.key}`; + const moveLabel = arm.argv.join(" "); + s23AssertPremises(arm); + const workspace = await TestWorkspace.create({ + files: { ...arm.files, [arm.receiver]: arm.staged }, + }); + try { + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context}: premise \`build --json\` over the staging — clean: a ` + + `valid workspace, so a later failure is the move's (SPEC 12.1, 6.5)`, + ); + if (arm.compiles !== undefined) { + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [arm.receiver], + }), + `${context}: ${arm.receiver} compiles clean under standard tooling ` + + `before the move (H-2) — ${arm.compiles}`, + ); + } + const preview = + arm.addition !== undefined || arm.preview !== undefined + ? await s23PreviewEntry(product, workspace, arm, context) + : undefined; + decodeAppliedMappingReport( + await runJson( + product, + workspace, + [...arm.argv, "--json"], + `${context}: \`${moveLabel} --json\` — a valid move` + + (arm.addition === undefined + ? "" + : " whose receiver holds an admissible offset") + + ` is performed: exit 0 (SPEC 6.5, 12.0)`, + ), + `${context}: \`${moveLabel} --json\` — the form-exact 12.7 ` + + `performed-operation document (SPEC 6.5, 12.7)`, + ); + + const pre = Buffer.from(arm.text, "utf8"); + const after = await s23ReadReceiver(workspace, arm, context); + const malformed = s23Malformed(arm, after.bytes); + if (malformed !== undefined) { + fail( + `${context}: ${arm.receiver} after the move is well-formed — 6.5 ` + + `keeps every file a move rewrites well-formed (SPEC 14.20) — yet ` + + `it is ${malformed}; the file reads ${JSON.stringify(after.text)}`, + ); + } + const judgement = judgeAddedImportsOfFile( + arm.receiver, + arm.kind, + arm.text, + after.text, + ); + if (judgement.problems.length > 0) { + fail( + `${context}: T6.5-22(a)'s constraints on the added identifiers ` + + `(SPEC 6.5), read from the added bytes:\n` + + judgement.problems.map((problem) => ` - ${problem}`).join("\n"), + ); + } + let readings: readonly number[] = []; + if (arm.addition === undefined) { + s23AssertNothingAdded(arm, pre, after, judgement.added, context); + } else { + readings = s23AssertAddition( + arm, + arm.addition, + pre, + after, + judgement.added, + context, + ); + } + if (preview !== undefined) { + s23AssertPreview(arm, pre, preview, readings, context); + } + for (const edge of arm.edges ?? []) { + await s23AssertEdge(product, workspace, edge, context); + } + + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `${context}: \`check --json\` after the move — clean (SPEC 6.5, 6.4, ` + + `12.2, 14.10)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context}: \`build --json\` after the move — clean: the rewritten ` + + `workspace is valid (SPEC 6.5, 12.1)`, + ); + if (arm.compiles !== undefined) { + // A fresh project: the language service snapshots files on first + // access, and the move rewrote them. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [arm.receiver], + }), + `${context}: ${arm.receiver} compiles clean under standard tooling ` + + `after the move (H-2) — ${arm.compiles}; the file reads ` + + `${JSON.stringify(after.text)}`, + ); + } + } finally { + await workspace.dispose(); + } +} + +/** A staged `src/c.ts` (S-9's record, declared well-formed TypeScript). */ +function s23StagedApp(key: string, text: string): StagedTs { + return stagedTs(`T6.5-23 ${key} ${S23_APP}`, text, "well-formed", "ts"); +} + +/** Why each staging of (a) through (e) gains its declaration (SPEC 6.5). */ +const S23_O_WHY = + "one declaration per module whose bindings the rewritten spellings are " + + "rooted at and the file lacks; `O` keeps its use by `O.w`"; + +/** An arm of (a) through (e) at module load: its `src/c.ts` record, the + * common staging, the arms' move, the marker's rewrite to `<X>.y`, and the + * target module's declaration at one of `offsets`. */ +function s23Arm(spec: { + readonly key: string; + readonly text: string; + readonly extra?: Readonly<Record<string, InitialFileContents>>; + readonly offsets: readonly number[]; + readonly placement: string; + readonly excluded: Readonly<Record<number, string>>; + readonly deriving?: readonly number[]; + readonly preview?: S23PreviewExpectation; + readonly compiles?: string; +}): S23Arm { + return { + key: spec.key, + receiver: S23_APP, + kind: "typescript", + text: spec.text, + staged: s23StagedApp(spec.key, spec.text), + files: { + "xspec.config.ts": S23_CONFIG, + [S23_ORIGIN]: S23_ORIGIN_SOURCE, + [S23_TARGET]: S23_TARGET_SOURCE, + ...spec.extra, + }, + argv: S23_ARGV, + rewrites: [s23Marker(spec.text, spec.key)], + addition: { + specifier: S23_TARGET_SPECIFIER, + offsets: spec.offsets, + why: S23_O_WHY, + }, + placement: spec.placement, + excluded: spec.excluded, + ...(spec.deriving === undefined ? {} : { deriving: spec.deriving }), + ...(spec.preview === undefined ? {} : { preview: spec.preview }), + ...(spec.compiles === undefined ? {} : { compiles: spec.compiles }), + }; +} + +/** Why the file's end is excluded in every staging below. */ +const S23_UNTIMELY_END = + "the file's end — untimely, the statement `f` standing between it and " + + "`O`'s declaration (6.5: an added binding is declared at or before the " + + "binding the spelling was rooted at, or after it with only import " + + "declarations between)"; + +/** Why offset 0 is excluded in every staging below. */ +const S23_NO_STATEMENT_BEFORE = + "offset 0 — no statement's end precedes it (6.5)"; + +/** Why a mid-line admissible offset loses to a line-start one. */ +function s23MidLine(where: string): string { + return ( + `${where} — admissible, but mid-line, while the staging holds a ` + + `line-start admissible offset, taken over any other (6.5's preference)` + ); +} + +// (a) the directive prologue. +const S23_A_PROLOGUE = s23Arm({ + key: "(a)", + text: ['"use client"', S23_IMPORT_O, S23_F, ""].join("\n"), + offsets: [13, 51], + placement: + "the start of line 2 or of line 3, the line starts after the " + + "prologue's statement and after `O`'s, both timely (6.5's latitude " + + "between them), never offset 0, which would end the prologue", + excluded: { + 0: + "offset 0 — a declaration there would end the directive prologue, " + + "and no statement's end precedes it (6.5)", + 12: s23MidLine('the end of `"use client"`'), + 50: s23MidLine("the end of `O`'s declaration"), + 84: S23_UNTIMELY_END, + }, +}); + +// (a) a line start between two directives: the prologue condition alone +// decides it. +const S23_A_BETWEEN = s23Arm({ + key: "(a) between two directives", + text: [ + '"use client"', + `"use strict"; ${S23_IMPORT_O} // note`, + S23_F, + "", + ].join("\n"), + offsets: [26, 27, 64, 65], + placement: + "no line start being admissible — the start of line 2 lies before the " + + 'prologue\'s end (26, `"use strict";` being [13, 26)), the start of ' + + "line 3 follows a comment, and the file's end is untimely — the " + + "prologue's end, after the space following it, `O`'s declaration's " + + "end, or after the space before `//` (6.5's latitude among them), both " + + "directives staying directives", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 12: + 'the end of `"use client"` — before the prologue\'s end, 26 (6.5: at ' + + "or after the end of the directive prologue)", + 13: + 'the start of line 2 — it follows `"use client"`\'s end and is ' + + "timely but lies before the prologue's end, 26: a product lacking the " + + "prologue condition takes it under the line-start preference and " + + 'leaves `"use strict"` an ordinary statement after the added line ' + + "(6.5)", + 73: + "the start of line 3 — it follows the comment `// note`, no " + + "statement's end with whitespace alone between (6.5)", + 106: S23_UNTIMELY_END, + }, +}); + +// (b) file-top directives in the prologue's place. +function s23FileTop(directive: string, key: string): S23Arm { + const text = [directive, S23_IMPORT_O, S23_F, ""].join("\n"); + const line2 = Buffer.byteLength(directive, "utf8") + 1; + const line3 = line2 + Buffer.byteLength(S23_IMPORT_O, "utf8") + 1; + return s23Arm({ + key, + text, + offsets: [line3], + placement: + "the start of line 3, directly after `O`'s declaration — no " + + "statement precedes the start of line 2, so the added line never " + + "stands above the directive", + excluded: { + 0: `offset 0 — above the directive, no statement's end preceding it (6.5)`, + [line2]: + `the start of line 2 — it follows the comment \`${directive}\` ` + + `alone, no statement's end (6.5)`, + [line3 - 1]: s23MidLine("the end of `O`'s declaration"), + [Buffer.byteLength(text, "utf8")]: S23_UNTIMELY_END, + }, + }); +} + +const S23_B_NOCHECK = s23FileTop( + "// @ts-nocheck", + "(b) under `// @ts-nocheck`", +); +const S23_B_REFERENCE = s23FileTop( + '/// <reference lib="esnext" />', + '(b) under `/// <reference lib="esnext" />`', +); + +// (c) a comment governing a statement. +const S23_C_GOVERNED = s23Arm({ + key: "(c)", + text: [ + S23_IMPORT_O, + "// @ts-expect-error", + 'const n: number = "x"', + S23_F, + "", + ].join("\n"), + offsets: [38], + placement: + "the start of line 2, before the comment, never between it and the " + + "statement it governs (6.5: the added line parts no comment from the " + + "statement it precedes); the start of line 4 and every later offset " + + "are untimely", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 37: s23MidLine("the end of `O`'s declaration"), + 58: + "the start of line 3 — between the `// @ts-expect-error` comment and " + + "the statement it governs: the directive would then govern the " + + "import and leave the type error bare (6.5)", + 80: + "the start of line 4 — untimely, the statement `const n: number = " + + '"x"` standing between it and `O`\'s declaration (6.5)', + 113: S23_UNTIMELY_END, + }, + compiles: + "the `// @ts-expect-error` directive governs `const n: number = " + + '"x"`, the file\'s one type error (6.5: the added line parts no ' + + "comment from the statement it precedes)", +}); + +// (d) a trailing comment: the forced mid-line placement in a TypeScript +// source. +const S23_D_COMMENT = s23Arm({ + key: "(d)", + text: [`${S23_IMPORT_O} // note`, S23_F, ""].join("\n"), + offsets: [37, 38], + placement: + "no line start qualifying — the start of line 2 follows the comment " + + "and every later one is untimely — the declaration's end or after the " + + "space before `//`, both mid-line (6.5's latitude between them; " + + "T6.5-13(c)'s forced mid-line placement met in a TypeScript source)", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 46: + "the start of line 2 — it follows the comment `// note`, no " + + "statement's end with whitespace alone between (6.5)", + 79: S23_UNTIMELY_END, + }, +}); + +// (d) the same forced placement where a character no whitespace under 1.4, +// though ECMAScript's lexical grammar takes it for whitespace or a line +// terminator, follows the declaration. +const S23_D_NBSP = s23Arm({ + key: "(d) with U+00A0", + text: `${S23_IMPORT_O}${S23_NBSP}\n${S23_F}\n`, + offsets: [37], + placement: + "the declaration's end, 37, the only admissible offset — U+00A0 " + + "[37, 39), no whitespace under 1.4, stands between that end and both " + + "the offset after it (39) and the start of line 2 (40); offset 0 " + + "follows no statement's end, every offset inside a statement is " + + "excluded, and those after `f` are untimely — U+00A0 then leading the " + + "line after the added one (6.5's statement-end condition reads " + + "whitespace as 1.4 defines it)", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 39: + "offset 39 — U+00A0, no whitespace under 1.4, stands between the " + + "declaration's end and it (6.5)", + 40: + "the start of line 2 — U+00A0, no whitespace under 1.4, stands " + + "between the declaration's end and it: a product judging the " + + "statement-end condition with ECMAScript's whitespace (TypeScript's " + + "own trivia) takes it under the line-start preference (6.5, 1.4)", + 73: S23_UNTIMELY_END, + }, +}); + +const S23_D_LSEP = s23Arm({ + key: "(d) with U+2028", + text: `${S23_IMPORT_O}${S23_LSEP}${S23_F}\n`, + offsets: [37], + placement: + "the declaration's end, 37, the only admissible offset — U+2028 " + + "[37, 40), no whitespace under 1.4 and no line terminator under 3, " + + "stands between that end and the offset after it (40), no line start " + + "either; offset 0 follows no statement's end, every offset inside a " + + "statement is excluded, and those after `f` are untimely — U+2028 " + + "then leading the line after the added one", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 40: + "offset 40 — U+2028, no whitespace under 1.4, stands between the " + + "declaration's end and it, and it is no line start, U+2028 being no " + + "line terminator (3): a product taking U+2028 for a line terminator " + + "takes it for one under the line-start preference (6.5, 1.4, 3)", + 73: S23_UNTIMELY_END, + }, +}); + +// (e) statement splitting: the `;` terminating `O`'s declaration across a +// line, the declaration spanning [0, 39). +const S23_E_SEMICOLON = s23Arm({ + key: "(e)", + text: [S23_IMPORT_O, ";", S23_F, ""].join("\n"), + offsets: [40], + placement: + "the start of line 3, never the start of line 2, inside `O`'s " + + "declaration, which the `;` on line 2 terminates (6.5: the added line " + + "splits no statement)", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 37: + "offset 37 — inside `O`'s declaration, which the `;` on line 2 " + + "terminates (6.5: the added line splits no statement)", + 38: + "the start of line 2 — inside `O`'s declaration, which the `;` on " + + "that line terminates (6.5: the added line splits no statement)", + 39: s23MidLine("the end of `O`'s declaration"), + 73: S23_UNTIMELY_END, + }, +}); + +// (e) 6.5's own shape: its in-statement line start is the one line start a +// product judging statement ends over the composed text would find. +const S23_E_SHAPE = s23Arm({ + key: "(e) 6.5's own shape", + text: [ + 'import C, { text as textC } from "../specs/c.xspec" // c1', + "const s = textC", + "(C.c) // c3", + `${S23_IMPORT_O} // c4`, + S23_F, + "", + ].join("\n"), + extra: { [S23_C_SPEC]: S23_C_SOURCE }, + offsets: [51, 52, 79, 80, 123, 124], + placement: + "every admissible offset mid-line — at, or after the space following, " + + "the end of line 1's declaration, of the call statement on line 3, or " + + "of `O`'s declaration, each before its comment (6.5's latitude among " + + "the six) — never at the start of line 3, where automatic semicolon " + + "insertion would part the statement", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 58: + "the start of line 2 — it follows the comment `// c1`, no " + + "statement's end with whitespace alone between (6.5)", + 74: + "the start of line 3 — inside the statement `const s = textC` " + + "`(C.c)`, one call of `textC` across lines 2 and 3, which automatic " + + "semicolon insertion would let the added line part, leaving `s` the " + + "function `textC` itself and `(C.c)` a non-static bare reference " + + "(6.5, 14.8)", + 86: + "the start of line 4 — it follows the comment `// c3`, no " + + "statement's end with whitespace alone between (6.5)", + 130: + "the start of line 5 — it follows the comment `// c4`, no " + + "statement's end with whitespace alone between (6.5)", + 163: S23_UNTIMELY_END, + }, +}); + +// (f) timeliness: TEST-SPEC's `move specs/A.mdx#m specs/B.mdx#m`, over +// `specs/A.mdx` holding `m` and `k` and `specs/B.mdx` holding `b` and no `m`, +// as throughout (f). +const S23_F_ARGV = ["move", "specs/A.mdx#m", "specs/B.mdx#m"] as const; +/** `B`'s module's canonical relative specifier from `src/` (6.5). */ +const S23_F_SPECIFIER = "../specs/B.xspec"; + +const S23_F_A_SOURCE: StagedMdx = stagedMdx( + "T6.5-23 (f) specs/A.mdx (the moved m, the kept k)", + [ + '<S id="m">', + "M text.", + "</S>", + "", + '<S id="k">', + "K text.", + "</S>", + "", + ].join("\n"), +); + +const S23_F_B_SOURCE: StagedMdx = stagedMdx( + "T6.5-23 (f) specs/B.mdx (b, no m)", + ['<S id="b">', "B text.", "</S>", ""].join("\n"), +); + +/** `A`'s declaration, at [0, 32) where it heads the file. */ +const S23_IMPORT_A = 'import A from "../specs/A.xspec"'; +/** `B`'s declaration. */ +const S23_IMPORT_B = 'import B from "../specs/B.xspec"'; + +/** The moved marker's edge after the move: from `src/c.ts` — a top-level + * statement's reference is attributed to the file (4.6) — to the moved + * node's new identity. */ +const S23_F_EDGE: S23Edge = { + from: S23_APP, + to: "specs/B.mdx#m", + kind: "references", + what: "the moved marker", +}; + +/** Why an offset after `A.m` is excluded in (f): untimely. */ +function s23FUntimely(where: string, between: string): string { + return ( + `${where} — untimely, ${between} standing between it and \`A\`'s ` + + `declaration (6.5: an added binding is declared at or before the ` + + `binding the spelling was rooted at, or after it with no top-level ` + + `statement between but import declarations)` + ); +} + +/** A staging of (f) at module load: the marker `A.m` rewritten in place to + * `<X>.m` where `B`'s module gains a declaration, re-rooted to `B.m` + * where it gains none. */ +function s23FArm(spec: { + readonly key: string; + readonly lines: readonly string[]; + readonly addition?: { + readonly offsets: readonly number[]; + readonly why: string; + }; + readonly placement: string; + readonly excluded?: Readonly<Record<number, string>>; + readonly preview?: S23PreviewExpectation; + readonly edges?: readonly S23Edge[]; +}): S23Arm { + const text = [...spec.lines, ""].join("\n"); + const addition = spec.addition; + return { + key: spec.key, + receiver: S23_APP, + kind: "typescript", + text, + staged: s23StagedApp(spec.key, text), + files: { + "xspec.config.ts": S23_CONFIG, + "specs/A.mdx": S23_F_A_SOURCE, + "specs/B.mdx": S23_F_B_SOURCE, + }, + argv: S23_F_ARGV, + rewrites: [ + addition === undefined + ? s23Marker(text, spec.key, "A.m", () => "B.m") + : s23Marker(text, spec.key, "A.m", (ident) => `${ident}.m`), + ], + addition: + addition === undefined + ? undefined + : { specifier: S23_F_SPECIFIER, ...addition }, + placement: spec.placement, + excluded: spec.excluded ?? {}, + ...(spec.preview === undefined ? {} : { preview: spec.preview }), + ...(spec.edges === undefined ? {} : { edges: spec.edges }), + }; +} + +// (f) 6.5's example: `B` is untimely for the moved marker. +const S23_F_EXAMPLE = s23FArm({ + key: "(f) 6.5's example", + lines: [S23_IMPORT_A, "A.m", "A.k", S23_IMPORT_B, "B.b"], + addition: { + offsets: [33], + why: + "`B` is untimely for the moved marker — its declaration follows " + + "`A`'s with the statement `A.m` between — so a second declaration " + + "of `B`'s module is added, binding `<X>` distinct from `B`; a " + + "product rooting at `B` writes `B.m`, which TypeScript's CommonJS " + + "output reads before initializing `B`", + }, + placement: + "the start of line 2 (offset 33), the only line-start admissible " + + "offset — the end of line 1, admissible too, is mid-line, and every " + + "later line start is untimely — `A.k`, `import B …`, and `B.b` " + + "unchanged", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 32: s23MidLine("the end of line 1, `A`'s declaration's end"), + 37: s23FUntimely("the start of line 3", "the statement `A.m`"), + 41: s23FUntimely("the start of line 4", "the statements `A.m` and `A.k`"), + 74: s23FUntimely("the start of line 5", "the statements `A.m` and `A.k`"), + 78: s23FUntimely( + "the file's end", + "the statements `A.m`, `A.k`, and `B.b`", + ), + }, + edges: [S23_F_EDGE], +}); + +// (f) its control: `B`'s declaration on line 2, before `A.m`. +const S23_F_CONTROL = s23FArm({ + key: "(f) control", + lines: [S23_IMPORT_A, S23_IMPORT_B, "A.m", "A.k", "B.b"], + placement: + "`B` is timely — its declaration directly follows `A`'s (6.5: or " + + "follows it with no top-level statement between them but import " + + "declarations) — so the marker is re-rooted to `B.m` and nothing is " + + "added", +}); + +/** The control with `line` interposed between `import A …` and `import B + * …`, its lines and the byte offsets of its line starts after line 2. */ +function s23FInterposed(line: string): { + readonly lines: readonly string[]; + readonly starts: readonly number[]; +} { + const lines = [S23_IMPORT_A, line, S23_IMPORT_B, "A.m", "A.k", "B.b"]; + const starts: number[] = []; + let offset = 0; + for (const each of lines) { + offset += Buffer.byteLength(each, "utf8") + 1; + starts.push(offset); + } + return { lines, starts: starts.slice(1) }; +} + +// (f) the boundary of the import-declaration exemption: a top-level +// statement other than an import declaration interposed in the control. +function s23FBoundary(line: string, key: string, why: string): S23Arm { + const { lines, starts } = s23FInterposed(line); + const [line3, line4, line5, line6, end] = starts as [ + number, + number, + number, + number, + number, + ]; + return s23FArm({ + key, + lines, + addition: { offsets: [33], why }, + placement: + "the start of line 2 (offset 33), between `import A …` and the " + + "interposed line — the one line-start admissible offset, the start " + + "of line 3 and every later one untimely — the marker rewritten in " + + "place to `<X>.m`, every other line unchanged", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 32: s23MidLine("the end of line 1, `A`'s declaration's end"), + [line3]: s23FUntimely("the start of line 3", `\`${line}\``), + [line4]: s23FUntimely("the start of line 4", `\`${line}\``), + [line5]: s23FUntimely("the start of line 5", `\`${line}\` and \`A.m\``), + [line6]: s23FUntimely( + "the start of line 6", + `\`${line}\`, \`A.m\`, and \`A.k\``, + ), + [end]: s23FUntimely( + "the file's end", + `\`${line}\`, \`A.m\`, \`A.k\`, and \`B.b\``, + ), + }, + }); +} + +const S23_F_TYPE_ALIAS = s23FBoundary( + "type T = number", + "(f) boundary: `type T = number`", + "`type T = number` reads no `B` and emits no code, yet it is a top-level " + + "statement other than an import declaration standing between the two " + + "declarations, so `B` is untimely and a declaration of `B`'s module is " + + "added, binding `<X>` distinct from `B`; a product exempting statements " + + "that emit no code re-roots the marker at `B` and adds nothing", +); + +const S23_F_IMPORT_EQUALS = s23FBoundary( + 'import Z = require("./z")', + '(f) boundary: `import Z = require("./z")`', + '`import Z = require("./z")`, a module-linking form other than an ' + + "import declaration (4), reads no `B`, yet it is a top-level statement " + + "other than an import declaration standing between the two " + + "declarations, so `B` is untimely and a declaration of `B`'s module is " + + "added, binding `<X>` distinct from `B`; a product exempting every " + + "module-linking form re-roots the marker at `B` and adds nothing", +); + +// (f) the exempt side: an import declaration interposed instead, binding no +// value and naming no spec module. +function s23FExempt(line: string, key: string, what: string): S23Arm { + return s23FArm({ + key, + lines: s23FInterposed(line).lines, + placement: + `\`${line}\`, ${what}, is an import declaration, so \`B\` stays ` + + "timely (6.5: no top-level statement between the two declarations " + + "but import declarations), the marker is re-rooted to `B.m`, and " + + "nothing is added; a product exempting only spec module imports, or " + + "only imports binding a value, adds a declaration of `B`'s module", + preview: { rewrites: 1, removals: [] }, + }); +} + +const S23_F_TYPE_IMPORT = s23FExempt( + 'import type { T } from "./t"', + '(f) exempt: `import type { T } from "./t"`', + "a type-only import declaration binding no value and naming no spec module", +); + +const S23_F_SIDE_EFFECT = s23FExempt( + 'import "./p"', + '(f) exempt: `import "./p"`', + "a side-effect import declaration binding nothing and naming no spec module", +); + +// (f) the precedence branch at a distance: `B`'s declaration precedes +// `A`'s, the statement `B.b` between them; the marker `A.m` at [70, 73). +const S23_F_PRECEDENCE = s23FArm({ + key: "(f) the precedence branch at a distance", + lines: [S23_IMPORT_B, "B.b", S23_IMPORT_A, "A.m", "A.k"], + placement: + "`B`'s declaration precedes `A`'s, so `B` is timely for the moved " + + "marker (6.5: its declaration is or precedes that of the binding the " + + "spelling was rooted at), though the statement `B.b` stands between " + + "them — the precedence branch bounds no distance, the " + + "import-declaration condition governing a later declaration alone — " + + "so the marker is re-rooted to `B.m`, nothing is added, and `A`'s " + + "declaration stays, kept by `A.k`; a product applying the succession " + + "condition in both directions adds a declaration of `B`'s module and " + + "writes `<X>.m`", + preview: { rewrites: [{ start: 70, end: 73 }], removals: [] }, + edges: [S23_F_EDGE], +}); + +// (g) the spec-source side of the split rule: TEST-SPEC's target, its first +// two lines one declaration of one ESM block, receiving into `p.n` +// T6.5-13(h)'s moved text — T6.5-13's cross-file origin and third module — +// which needs `import <X> from "./x.xspec"`. +const S23_G_RECEIVER = "specs/target.mdx"; +const S23_G_TEXT = [ + 'import K from "./k.xspec"', + ";", + "", + '<S id="p">', + "x {text(K.a)}", + "</S>", + "", +].join("\n"); +/** The start of the `</S>` line, where the moved text and a U+000A are + * inserted (T6.5-13's composition). */ +const S23_G_CLOSE = Buffer.from(S23_G_TEXT, "utf8").lastIndexOf("</S>"); + +const S23_G: S23Arm = { + key: "(g)", + receiver: S23_G_RECEIVER, + kind: "spec-source", + text: S23_G_TEXT, + staged: stagedMdx( + "T6.5-23 (g) specs/target.mdx (K's declaration across lines 1 and 2 of one ESM block, then p)", + S23_G_TEXT, + ), + files: { + "xspec.config.ts": R16_CONFIG, + "specs/a.mdx": A13_ORIGIN_STAGED, + "specs/x.mdx": A13_THIRD_STAGED, + "specs/k.mdx": A13_THIRD_STAGED, + }, + argv: ["move", "specs/a.mdx#m", `${S23_G_RECEIVER}#p.n`], + rewrites: [ + { + start: S23_G_CLOSE, + end: S23_G_CLOSE, + spelled: (ident) => [...a13MovedLines("p.n", ident), ""].join("\n"), + }, + ], + addition: { + specifier: "./x.xspec", + offsets: [0, 28, 59], + why: + "the moved text's `{text(X.a)}` embedding is rooted at the origin's " + + "binding of `specs/x.mdx`, a module the target lacks — its `K` binds " + + "`specs/k.mdx` — so exactly one declaration of it is added " + + "(T6.5-13's shape)", + }, + placement: + "offset 0, the start of the empty line (28), or the file's end (59) — " + + "the admissible line starts, 6.5's latitude among them — never the " + + "start of line 2; the moved text inserted at the start of the `</S>` " + + "line, its embedding re-rooted at `<X>`", + excluded: { + 26: + "the start of line 2 — its form derives (S-9) yet splits `K`'s " + + 'declaration, `import K from "./k.xspec"` and the `;` on line 2 one ' + + "declaration of one ESM block (6.5: an admissible offset lies inside " + + "none of the file's statements before the edit — in a spec source, " + + "its ESM blocks' declarations)", + }, + deriving: [26], +}; + +// (h) through (j): a removed declaration's place. `f` holds the marker alone, +// so `O`'s declaration loses its last use and is removed with its line (6.5's +// import edits) while the receiver gains the target module's declaration — +// at the removal's end, the statement-end condition and the prologue judged +// over the file before the edit, the removed declaration included. + +/** `f` in (h) through (j): the marker alone, the origin import losing its + * last use. */ +const S23_F_LAST_USE = "export function f() { O.x }"; + +/** Why each staging of (h) through (j) gains its declaration (SPEC 6.5). */ +const S23_REMOVED_WHY = + "one declaration per module whose bindings the rewritten spellings are " + + "rooted at and the file lacks; `O`'s declaration, its last use gone, is " + + "removed with its line, the added line taking its place"; + +/** The marker's edge after (h)'s move: from `f`, the unit holding it (4.6), + * to the moved node's new identity. */ +const S23_H_EDGE: S23Edge = { + from: `${S23_APP}#f`, + to: "specs/target.mdx#y", + kind: "references", + what: "the moved marker", +}; + +/** Why an offset's composed bytes equal the removal's end's. */ +function s23SameAsRemovalEnd(where: string, end: number): string { + return ( + `${where}: its composed bytes equal those of the removal's end, ` + + `${String(end)}, so the preview's \`import-addition\` alone tells them ` + + "apart — a product mapping the composed position to the removal's " + + "start reports this offset (T6.6-4(b))" + ); +} + +/** + * A staging of (h) through (j) at module load: `head` above `O`'s + * declaration and `tail` below it, `f` last; `O`'s declaration removed with + * its line — TEST-SPEC's `removal` range, cross-checked against the staged + * text, a rewrite spelled `""` — the marker rewritten in place to `<X>.y`, + * and the target module's declaration at the removal's end. Where + * `statesEdits`, the preview's `reference-rewrite` spans the marker and its + * `import-removal` the removal ((h)). + */ +function s23RemovalArm(spec: { + readonly key: string; + readonly head: readonly string[]; + readonly tail: readonly string[]; + readonly removal: S23Span; + readonly placement: string; + readonly excluded: Readonly<Record<number, string>>; + readonly statesEdits?: boolean; + readonly edges?: readonly S23Edge[]; +}): S23Arm { + const text = [...spec.head, S23_IMPORT_O, ...spec.tail, ""].join("\n"); + const start = spec.head.reduce( + (sum, line) => sum + Buffer.byteLength(line, "utf8") + 1, + 0, + ); + const end = start + Buffer.byteLength(S23_IMPORT_O, "utf8") + 1; + if (start !== spec.removal.start || end !== spec.removal.end) { + throw new Error( + `T6.5-23 ${spec.key}: a harness defect — \`O\`'s declaration with ` + + `its line spans [${String(start)}, ${String(end)}) in the staged ` + + `text, not TEST-SPEC's removal range ` + + `[${String(spec.removal.start)}, ${String(spec.removal.end)})`, + ); + } + const marker = s23Marker(text, spec.key); + return { + key: spec.key, + receiver: S23_APP, + kind: "typescript", + text, + staged: s23StagedApp(spec.key, text), + files: { + "xspec.config.ts": S23_CONFIG, + [S23_ORIGIN]: S23_ORIGIN_SOURCE, + [S23_TARGET]: S23_TARGET_SOURCE, + }, + argv: S23_ARGV, + rewrites: [{ start, end, spelled: () => "" }, marker], + addition: { + specifier: S23_TARGET_SPECIFIER, + offsets: [end], + why: S23_REMOVED_WHY, + }, + placement: spec.placement, + excluded: spec.excluded, + ...(spec.statesEdits === true + ? { + preview: { + rewrites: [{ start: marker.start, end: marker.end }], + removals: [{ start, end }], + }, + } + : {}), + ...(spec.edges === undefined ? {} : { edges: spec.edges }), + }; +} + +// (h) a removed declaration's place: the basic move, `O`'s declaration +// removed over [0, 38). +const S23_H_BASIC = s23RemovalArm({ + key: "(h)", + head: [], + tail: [S23_F_LAST_USE], + removal: { start: 0, end: 38 }, + placement: + "the removal's end, offset 38, the only admissible offset — it follows " + + "the removed declaration's end with nothing but that line's terminator " + + "between, and is timely (6.5: a statement's end judged over the file " + + "before the edit, a statement the rewrite removes included); a product " + + "judging statement ends over the composed text finds no admissible " + + "offset and refuses the move (`refused-invalid-rewrite`)", + excluded: { + 0: s23SameAsRemovalEnd(S23_NO_STATEMENT_BEFORE, 38), + 37: "offset 37 — strictly inside the removal's range [0, 38)", + 66: S23_UNTIMELY_END, + }, + statesEdits: true, + edges: [S23_H_EDGE], +}); + +// (i) a comment above the removed declaration — 6.5's own example. +function s23CommentAbove(comment: string, removal: S23Span): S23Arm { + return s23RemovalArm({ + key: `(i) under \`${comment}\``, + head: [comment], + tail: [S23_F_LAST_USE], + removal, + placement: + `the removal's end, offset ${String(removal.end)}, the only ` + + "admissible offset — the removal's start, the start of line 2, " + + `follows only the comment \`${comment}\`, no statement's end — the ` + + "comment then preceding the added line (6.5: a comment that " + + "preceded the removed declaration then precedes the added line)", + excluded: { + 0: + "offset 0 — above the comment, no statement's end preceding it " + + "(6.5): a product placing the added line above the comment fails", + [removal.start]: s23SameAsRemovalEnd( + `the start of line 2, the removal's start — it follows only the ` + + `comment \`${comment}\`, no statement's end (6.5)`, + removal.end, + ), + [removal.end + Buffer.byteLength(S23_F_LAST_USE, "utf8") + 1]: + S23_UNTIMELY_END, + }, + }); +} + +const S23_I_NOTE = s23CommentAbove("// note", { start: 8, end: 46 }); +const S23_I_EXPECT_ERROR = s23CommentAbove("// @ts-expect-error", { + start: 20, + end: 58, +}); + +// (j) a string-literal statement after the removed declaration. +const S23_J_STRING = s23RemovalArm({ + key: "(j)", + head: [], + tail: ['"use client"', S23_F_LAST_USE], + removal: { start: 0, end: 38 }, + placement: + "the removal's end, offset 38, the only admissible offset — before the " + + "edit the file's directive prologue is empty, its first statement an " + + 'import declaration, so `"use client"` is no directive, and judged ' + + "over the file before the edit (6.5) the removal that would bring it " + + "to the file's head changes neither — the string-literal statement " + + "staying, as before the edit, no directive; a product judging the " + + 'prologue over the composed text admits no offset before `"use ' + + 'client"`, finds every later one untimely, and refuses the move', + excluded: { + 0: s23SameAsRemovalEnd(S23_NO_STATEMENT_BEFORE, 38), + 37: "offset 37 — strictly inside the removal's range [0, 38)", + 51: + 'the start of line 3 — untimely, the statement `"use client"` ' + + "standing between it and `O`'s declaration (6.5)", + 79: + 'the file\'s end — untimely, the statements `"use client"` and `f` ' + + "standing between it and `O`'s declaration (6.5)", + }, +}); + +// (k) a callee's timeliness: (f)'s move and spec sources; the call, its +// target carried into `B`'s file, rewritten whole (6.5). + +/** `A`'s declaration, binding its module's default and its `text` as `ta`. */ +const S23_K_IMPORT_A = 'import A, { text as ta } from "../specs/A.xspec"'; +/** A declaration binding `B`'s module's `text` as `tb`. */ +const S23_K_IMPORT_TB = 'import { text as tb } from "../specs/B.xspec"'; +/** The call the move carries into `B`'s file. */ +const S23_K_CALL = "ta(A.m)"; + +/** (k)'s added declaration: `B`'s module's `text` alone (6.5's spellings). */ +const S23_K_SPELLING: S23Spelling = { + declaration: (ident) => + ident === "text" + ? `import { text } from "${S23_F_SPECIFIER}"` + : `import { text as ${ident} } from "${S23_F_SPECIFIER}"`, + form: + `\`import { text as <Y> } from "${S23_F_SPECIFIER}"\`, or ` + + `\`import { text } from "${S23_F_SPECIFIER}"\` where the fresh ` + + "identifier is `text` itself, binding `B`'s module's `text` alone — no " + + "second default binding", + also: ["text"], +}; + +/** The call's edge after the move: from `src/c.ts` — a top-level + * statement's embedding is attributed to the file (4.6) — to the moved + * node's new identity. */ +const S23_K_EDGE: S23Edge = { + from: S23_APP, + to: "specs/B.mdx#m", + kind: "embeds", + what: "the call", +}; + +/** Why an offset is excluded in (k): untimely for the callee. */ +function s23KUntimely(where: string, between: string): string { + return ( + `${where} — untimely for the callee, ${between} standing between it ` + + "and `ta`'s declaration (6.5: an added binding is declared at or " + + "before the binding the spelling was rooted at, or after it with no " + + "top-level statement between but import declarations)" + ); +} + +/** A staging of (k) at module load: the call rewritten whole as + * `rewritten` spells it, the preview's one `reference-rewrite` spanning it + * and no `import-removal`, and the call's `embeds` edge after the move. */ +function s23KArm(spec: { + readonly key: string; + readonly lines: readonly string[]; + readonly rewritten: (ident: string) => string; + readonly addition?: { + readonly offsets: readonly number[]; + readonly why: string; + }; + readonly placement: string; + readonly excluded?: Readonly<Record<number, string>>; +}): S23Arm { + const text = [...spec.lines, ""].join("\n"); + const call = s23Marker(text, spec.key, S23_K_CALL, spec.rewritten); + return { + key: spec.key, + receiver: S23_APP, + kind: "typescript", + text, + staged: s23StagedApp(spec.key, text), + files: { + "xspec.config.ts": S23_CONFIG, + "specs/A.mdx": S23_F_A_SOURCE, + "specs/B.mdx": S23_F_B_SOURCE, + }, + argv: S23_F_ARGV, + rewrites: [call], + addition: + spec.addition === undefined + ? undefined + : { + specifier: S23_F_SPECIFIER, + spelled: S23_K_SPELLING, + ...spec.addition, + }, + placement: spec.placement, + excluded: spec.excluded ?? {}, + preview: { + rewrites: [{ start: call.start, end: call.end }], + removals: [], + }, + edges: [S23_K_EDGE], + }; +} + +const S23_K_CALLEE = s23KArm({ + key: "(k)", + lines: [ + S23_K_IMPORT_A, + S23_IMPORT_B, + S23_K_CALL, + S23_K_IMPORT_TB, + "tb(B.b)", + "A.k", + ], + rewritten: (ident) => `${ident}(B.m)`, + addition: { + offsets: [49, 82], + why: + "the call's argument is re-rooted at `B`, timely, its declaration " + + "directly following `A`'s, but its callee needs `B`'s module's " + + "`text`, and `tb`, binding it, is untimely — the statement `ta(A.m)` " + + "stands between `ta`'s declaration and `tb`'s — so a declaration " + + "binding only that `text` is added; a product judging timeliness for " + + "default bindings alone writes `tb(B.m)`, which TypeScript's CommonJS " + + "output reads before initializing `tb`'s module binding", + }, + placement: + "the start of line 2 or of line 3 (offset 49 or 82), the two " + + "line-start admissible offsets, both timely (6.5's latitude between " + + "them) — the call's occurrence span becoming exactly `<Y>(B.m)`, " + + "`A`'s declaration staying, kept by `A.k`, and no other byte changing", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 48: s23MidLine("the end of line 1, `A`'s declaration's end"), + 81: s23MidLine("the end of line 2, `B`'s declaration's end"), + 90: s23KUntimely("the start of line 4", "the statement `ta(A.m)`"), + 136: s23KUntimely("the start of line 5", "the statement `ta(A.m)`"), + 144: s23KUntimely( + "the start of line 6", + "the statements `ta(A.m)` and `tb(B.b)`", + ), + 148: s23KUntimely( + "the file's end", + "the statements `ta(A.m)`, `tb(B.b)`, and `A.k`", + ), + }, +}); + +// (k) its control: re-rooting at bindings the file holds. +const S23_K_CONTROL = s23KArm({ + key: "(k) control", + lines: [ + S23_K_IMPORT_A, + S23_K_IMPORT_TB, + S23_IMPORT_B, + S23_K_CALL, + "tb(B.b)", + "A.k", + ], + rewritten: () => "tb(B.m)", + placement: + "`tb` is timely for the callee, its declaration directly following " + + "`ta`'s, and `B` for the argument, `tb`'s import declaration alone " + + "standing between `A`'s declaration and `B`'s (6.5: or follows it with " + + "no top-level statement between them but import declarations), so the " + + "call is re-rooted at both existing bindings, `tb(B.m)`, nothing is " + + "added, and `A`'s declaration stays, kept by `A.k`; a product never " + + "re-rooting a callee at a `text` binding the file holds adds `import " + + "{ text as <Y> } …`, and one judging timeliness as precedence or " + + "direct succession alone adds a declaration binding `B`'s module's " + + "default", +}); + +/** Each line's start in `lines` joined by U+000A with a final U+000A, then + * the file's end, in bytes. */ +function s23Starts(lines: readonly string[]): readonly number[] { + const starts: number[] = []; + let offset = 0; + for (const line of lines) { + starts.push(offset); + offset += Buffer.byteLength(line, "utf8") + 1; + } + starts.push(offset); + return starts; +} + +/** `actual`, failing as a harness defect unless it is TEST-SPEC's `stated` + * offset of `what`. */ +function s23Stated( + key: string, + what: string, + actual: number | undefined, + stated: number, +): number { + if (actual !== stated) { + throw new Error( + `T6.5-23 ${key}: a harness defect — ${what} lies at ` + + `${String(actual)} in the staged text, not at TEST-SPEC's ` + + String(stated), + ); + } + return stated; +} + +// (l) and (m): (f)'s move over two declarations of `A`'s module binding +// distinct identifiers (valid, 4, T4-4), the moved `m` carrying its child +// `m.c`. + +/** `specs/A.mdx` in (l) and (m): the moved `m`, its child `m.c`, and the + * kept `k`. */ +const S23_LM_A_SOURCE: StagedMdx = stagedMdx( + "T6.5-23 (l)/(m) specs/A.mdx (the moved m and its child m.c, the kept k)", + [ + '<S id="m">', + "M text.", + "", + '<S id="m.c">', + "C text.", + "</S>", + "</S>", + "", + '<S id="k">', + "K text.", + "</S>", + "", + ].join("\n"), +); + +/** `A1`'s declaration, heading the file at [0, 33). */ +const S23_IMPORT_A1 = 'import A1 from "../specs/A.xspec"'; +/** `A2`'s declaration, of the same module. */ +const S23_IMPORT_A2 = 'import A2 from "../specs/A.xspec"'; + +/** The rewritten markers' edges after the move: from `src/c.ts` — a + * top-level statement's reference is attributed to the file (4.6) — to the + * moved nodes' new identities. */ +const S23_LM_EDGES: readonly S23Edge[] = [ + { + from: S23_APP, + to: "specs/B.mdx#m", + kind: "references", + what: "the rewritten marker `A1.m`", + }, + { + from: S23_APP, + to: "specs/B.mdx#m.c", + kind: "references", + what: "the rewritten marker `A2.m.c`", + }, +]; + +/** Why an offset of (l) or (m) is excluded: untimely for `A1.m`. */ +function s23LmUntimely(where: string, between: string, more = ""): string { + return ( + `${where} — untimely for \`A1.m\`, ${between} standing between it and ` + + `\`A1\`'s declaration${more} (6.5: an added declaration's bindings are ` + + "timely for every spelling rooted at them — declared at or before the " + + "binding each spelling was rooted at, or after it with only import " + + "declarations between)" + ); +} + +/** A staging of (l) or (m) at module load: `A1.m` rewritten in place to + * `<X>.m` and `A2.m.c` as `chained` spells it, `B`'s module's declaration at + * the start of line 2 (TEST-SPEC's offset 34, cross-checked against the + * staged text), the preview's two `reference-rewrite` edits and no + * `import-removal`, and both markers' `references` edges. */ +function s23LmArm(spec: { + readonly key: string; + readonly lines: readonly string[]; + readonly chained: (ident: string) => string; + readonly why: string; + readonly placement: string; + readonly excluded: Readonly<Record<number, string>>; +}): S23Arm { + const text = [...spec.lines, ""].join("\n"); + const line2 = s23Stated( + spec.key, + "the start of line 2", + s23Starts(spec.lines)[1], + 34, + ); + return { + key: spec.key, + receiver: S23_APP, + kind: "typescript", + text, + staged: s23StagedApp(spec.key, text), + files: { + "xspec.config.ts": S23_CONFIG, + "specs/A.mdx": S23_LM_A_SOURCE, + "specs/B.mdx": S23_F_B_SOURCE, + }, + argv: S23_F_ARGV, + rewrites: [ + s23Marker(text, spec.key, "A1.m", (ident) => `${ident}.m`), + s23Marker(text, spec.key, "A2.m.c", spec.chained), + ], + addition: { + specifier: S23_F_SPECIFIER, + offsets: [line2], + why: spec.why, + }, + placement: spec.placement, + excluded: spec.excluded, + preview: { rewrites: 2, removals: [] }, + edges: S23_LM_EDGES, + }; +} + +// (l) one added binding rooting spellings formerly rooted at different +// bindings. +const S23_L_ONE_BINDING = s23LmArm({ + key: "(l)", + lines: [S23_IMPORT_A1, "A1.m", S23_IMPORT_A2, "A2.m.c", "A1.k", "A2.k"], + chained: (ident) => `${ident}.m.c`, + why: + "both moved markers, `A1.m` and `A2.m.c`, need `B`'s module's default, " + + "which the file lacks, so one declaration of it is added, its binding " + + "rooting both — and it must be timely for each (6.5: its bindings " + + "timely for every spelling rooted at them)", + placement: + "the start of line 2 (offset 34), the only line-start admissible " + + "offset — timely for `A1.m` and for `A2.m.c`; the end of line 1, " + + "admissible too, is mid-line, and the starts of lines 3 and 4 are " + + "timely for `A2.m.c` alone — the one added binding rooting both, " + + "`<X>.m` and `<X>.m.c`, both former declarations kept by `A1.k` and " + + "`A2.k`", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 33: s23MidLine("the end of line 1, `A1`'s declaration's end"), + 39: s23LmUntimely( + "the start of line 3", + "the statement `A1.m`", + ", though timely for `A2.m.c`: a product judging the added binding's " + + "timeliness against one spelling's former binding alone — `A2`, " + + "the last found — admits it", + ), + 72: s23LmUntimely( + "the end of line 3, `A2`'s declaration's end", + "the statement `A1.m`", + ", though timely for `A2.m.c`", + ), + 73: s23LmUntimely( + "the start of line 4", + "the statement `A1.m`", + ", though timely for `A2.m.c`: a product placing the added line " + + "directly after the last-found former binding's declaration, " + + "`A2`'s, writes it here, after `<X>.m`, which TypeScript's " + + "CommonJS output then runs before initializing `<X>`", + ), + 80: s23LmUntimely( + "the start of line 5", + "the statements `A1.m` and `A2.m.c`", + ", and for `A2.m.c` as well", + ), + 85: s23LmUntimely( + "the start of line 6", + "the statements `A1.m`, `A2.m.c`, and `A1.k`", + ", and for `A2.m.c` as well", + ), + 90: s23LmUntimely( + "the file's end", + "the statements `A1.m`, `A2.m.c`, `A1.k`, and `A2.k`", + ", and for `A2.m.c` as well", + ), + }, +}); + +// (m) a held binding beside an added one, for one module in one file. +const S23_M_HELD_BESIDE = s23LmArm({ + key: "(m)", + lines: [ + S23_IMPORT_A1, + "A1.m", + S23_IMPORT_B, + S23_IMPORT_A2, + "A2.m.c", + "A1.k", + "A2.k", + "B.b", + ], + chained: () => "B.m.c", + why: + "`B` is untimely for `A1.m` — the statement `A1.m` stands between " + + "`A1`'s declaration and `B`'s — so a declaration of `B`'s module is " + + "added, binding `<X>` distinct from `B`, rooting `A1.m` alone, while " + + "`B`'s declaration precedes `A2`'s, so `B` is timely for `A2.m.c`, " + + "which is re-rooted at it (6.5: a spelling is rooted at a binding the " + + "file already holds, unshadowed and timely, and at an added " + + "declaration's binding only where the file holds none)", + placement: + "the start of line 2 (offset 34), the only line-start admissible " + + "offset — the end of line 1, admissible too, is mid-line, and every " + + "later offset is untimely for `A1.m` — `A1.m` rewritten in place to " + + "`<X>.m` and `A2.m.c` to `B.m.c`, never `<X>.m.c` (a product rooting " + + "every spelling of a module at the added binding, once one needs it, " + + "writes `<X>.m.c`), both former declarations kept by `A1.k` and " + + "`A2.k`, and `B`'s by `B.b`", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 33: s23MidLine("the end of line 1, `A1`'s declaration's end"), + 39: s23LmUntimely("the start of line 3", "the statement `A1.m`"), + 72: s23LmUntimely("the start of line 4", "the statement `A1.m`"), + 106: s23LmUntimely("the start of line 5", "the statement `A1.m`"), + 113: s23LmUntimely( + "the start of line 6", + "the statements `A1.m` and `A2.m.c`", + ), + 118: s23LmUntimely( + "the start of line 7", + "the statements `A1.m`, `A2.m.c`, and `A1.k`", + ), + 123: s23LmUntimely( + "the start of line 8", + "the statements `A1.m`, `A2.m.c`, `A1.k`, and `A2.k`", + ), + 127: s23LmUntimely( + "the file's end", + "the statements `A1.m`, `A2.m.c`, `A1.k`, `A2.k`, and `B.b`", + ), + }, +}); + +// (n) nested statement lists: (d)'s file with `f` spread over lines, and +// separately with `namespace N {` in place of `export function f() {`. +function s23Nested(spec: { + readonly key: string; + readonly opener: string; + /** The statement whose body the inner line starts lie in. */ + readonly unit: string; + /** That body in words. */ + readonly body: string; + /** What else a product taking the body's line start does, in words. */ + readonly also: string; +}): S23Arm { + const lines = [`${S23_IMPORT_O} // note`, spec.opener, " O.x", " O.w", "}"]; + const [, line2, line3, line4, line5, end] = s23Starts(lines) as [ + number, + number, + number, + number, + number, + number, + ]; + const derives = + ", though the form there derives (S-9: TypeScript 5.9.3's parser " + + `derives an import declaration in ${spec.body}, the restrictions on ` + + "where one may stand being post-parse grammar checks 14.20 excludes)"; + const inside = `inside the statement \`${spec.unit}\`, where a declaration is no top-level one (6.5)`; + return s23Arm({ + key: spec.key, + text: [...lines, ""].join("\n"), + offsets: [37, 38], + placement: + "no line start qualifying — the start of line 2 follows the comment, " + + `every line start of ${spec.body} lies inside the statement ` + + `\`${spec.unit}\`, where a declaration is no top-level one, and every ` + + "offset after it is untimely, the statement standing between it and " + + "`O`'s declaration — the declaration's end or after the space before " + + "`//`, 37 or 38, both mid-line (6.5's latitude between them), the " + + "marker rewritten in place to `<X>.y`", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + [line2]: + "the start of line 2 — it follows the comment `// note`, no " + + "statement's end with whitespace alone between (6.5)", + [line3]: `the start of line 3 — ${inside}${derives}`, + [line4]: + `the start of line 4, after \`O.x\`'s end — ${inside}${derives}: a ` + + "product judging statement boundaries within the innermost " + + "statement list alone finds it admissible and takes it under the " + + `line-start preference${spec.also}`, + [line5]: `the start of line 5, before \`}\` — ${inside}${derives}`, + [end]: + "the file's end — untimely, the statement " + + `\`${spec.unit}\` standing between it and \`O\`'s declaration (6.5)`, + }, + deriving: [line3, line4, line5], + }); +} + +const S23_N_FUNCTION = s23Nested({ + key: "(n)", + opener: "export function f() {", + unit: "f", + body: "a function body", + also: "", +}); + +const S23_N_NAMESPACE = s23Nested({ + key: "(n) with `namespace N {`", + opener: "namespace N {", + unit: "N", + body: "a namespace body", + also: + ", as does one taking a namespace body — a module block, where " + + "TypeScript also admits import-equals declarations — for a " + + "declaration site", +}); + +// (o) timeliness, a TypeScript source's condition alone: a third spec source +// under the arms' move, `T`'s ESM block following `O`'s with the section `p` +// between them. +const S23_O_RECEIVER = "specs/third.mdx"; +/** `O`'s declaration, heading the file, with its line: [0, 31). */ +const S23_O_IMPORT = 'import O from "./origin.xspec"'; +const S23_O_TEXT = [ + S23_O_IMPORT, + "", + '<S id="p" d={O.x} />', + "", + 'import T from "./target.xspec"', + "", + '<S id="q" d={T.z} />', + "", +].join("\n"); +const S23_O_REMOVAL_END = s23Stated( + "(o)", + "the end of `O`'s declaration's line", + Buffer.byteLength(S23_O_IMPORT, "utf8") + 1, + 31, +); +const S23_O_REFERENCE = s23Marker(S23_O_TEXT, "(o)", "O.x", () => "T.y"); +s23Stated("(o)", "the reference `O.x`", S23_O_REFERENCE.start, 45); + +const S23_O: S23Arm = { + key: "(o)", + receiver: S23_O_RECEIVER, + kind: "spec-source", + text: S23_O_TEXT, + staged: stagedMdx( + "T6.5-23 (o) specs/third.mdx (O's ESM block, p, T's ESM block, q)", + S23_O_TEXT, + ), + files: { + "xspec.config.ts": R16_CONFIG, + [S23_ORIGIN]: S23_ORIGIN_SOURCE, + [S23_TARGET]: S23_TARGET_SOURCE, + }, + argv: S23_ARGV, + rewrites: [ + { start: 0, end: S23_O_REMOVAL_END, spelled: () => "" }, + S23_O_REFERENCE, + ], + addition: undefined, + placement: + "`T` is a binding the file holds and no local declaration shadows, so " + + "the reference `O.x` is re-rooted at it, `T.y` — a spec source, which " + + "6.5 holds to no timeliness, re-roots wherever the file holds one " + + "unshadowed, though `T`'s ESM block follows `O`'s with the section `p` " + + "between them — nothing is added, and `O`'s declaration, its last use " + + "gone, is removed with its line, [0, 31); a product judging timeliness " + + "in a spec source too, the flow content between two ESM blocks counted " + + "as a statement standing between their declarations, adds a " + + "declaration of the target module and roots the reference at its " + + "binding", + excluded: {}, + preview: { + rewrites: [{ start: S23_O_REFERENCE.start, end: S23_O_REFERENCE.end }], + removals: [{ start: 0, end: S23_O_REMOVAL_END }], + }, + edges: [ + { + from: `${S23_O_RECEIVER}#p`, + to: "specs/target.mdx#y", + kind: "depends", + what: "the section `p`", + }, + ], +}; + +// (p) whitespace between a statement's end and its line's terminator: the +// members of 1.4's class that are no line terminator (3), each staged after +// `O`'s declaration where (d) stages characters outside the class. +function s23TrailingWhitespace(codePoint: number): S23Arm { + const name = `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`; + const key = `(p) with ${name}`; + const text = `${S23_IMPORT_O}${String.fromCodePoint(codePoint)}\n${S23_F}\n`; + const bytes = Buffer.from(text, "utf8"); + s23Stated(key, "`f`", bytes.indexOf(S23_F), 39); + const marker = s23Marker(text, key); + s23Stated(key, "the marker `O.x`", marker.start, 61); + const narrower = + codePoint === 0x0b || codePoint === 0x0c + ? `, and one judging whitespace over a set narrower than 1.4's — ` + + `space, tab, CR, and LF, say — takes 37 here, ${name} lying ` + + "outside it" + : ""; + const midLine = (where: string): string => + `${where} — admissible, but mid-line, while the start of line 2 is ` + + "admissible, taken over any other (6.5's preference): a product " + + "admitting a line start only where the line terminator before it " + + "directly follows a statement's end takes 37 or 38" + + narrower; + return s23Arm({ + key, + text, + offsets: [39], + placement: + "the start of line 2 (offset 39), the only line-start admissible " + + `offset — 37, 38, and 39 each follow the declaration's end with ` + + `nothing but whitespace (1.4: ${name} is a member of the class) ` + + "between, and are timely, while offset 0 follows no statement's end, " + + "every offset inside `O`'s declaration or `f` lies inside a " + + "statement, and every offset after `f` is untimely — " + + `${name} kept at the end of line 1, the marker rewritten in place to ` + + "`<X>.y`", + excluded: { + 0: S23_NO_STATEMENT_BEFORE, + 37: midLine("the declaration's end, 37"), + 38: midLine(`offset 38, after ${name}`), + 72: S23_UNTIMELY_END, + }, + preview: { + rewrites: [{ start: marker.start, end: marker.end }], + removals: [], + }, + }); +} + +const S23_P_SPACE = s23TrailingWhitespace(0x20); +const S23_P_TAB = s23TrailingWhitespace(0x09); +const S23_P_VT = s23TrailingWhitespace(0x0b); +const S23_P_FF = s23TrailingWhitespace(0x0c); + +/** Arms (a) through (p), in TEST-SPEC's order. */ +const S23_ARMS: readonly S23Arm[] = [ + S23_A_PROLOGUE, + S23_A_BETWEEN, + S23_B_NOCHECK, + S23_B_REFERENCE, + S23_C_GOVERNED, + S23_D_COMMENT, + S23_D_NBSP, + S23_D_LSEP, + S23_E_SEMICOLON, + S23_E_SHAPE, + S23_F_EXAMPLE, + S23_F_CONTROL, + S23_F_TYPE_ALIAS, + S23_F_IMPORT_EQUALS, + S23_F_TYPE_IMPORT, + S23_F_SIDE_EFFECT, + S23_F_PRECEDENCE, + S23_G, + S23_H_BASIC, + S23_I_NOTE, + S23_I_EXPECT_ERROR, + S23_J_STRING, + S23_K_CALLEE, + S23_K_CONTROL, + S23_L_ONE_BINDING, + S23_M_HELD_BESIDE, + S23_N_FUNCTION, + S23_N_NAMESPACE, + S23_O, + S23_P_SPACE, + S23_P_TAB, + S23_P_VT, + S23_P_FF, +]; + +const T6_5_23 = defineProductTest({ + id: "T6.5-23", + title: + 'statement boundaries, the directive prologue, and timeliness — arms (a) through (p), each a section move: in (a) through (e) `move specs/origin.mdx#x specs/target.mdx#y`, whose receiver `src/c.ts` (`import O from "../specs/origin.xspec"` beside `f` = `export function f() { O.x; O.w }`) gains exactly `import <X> from "../specs/target.xspec"`, value-blind in `<X>` alone, at an admissible offset, the marker rewritten in place to `<X>.y`, every other byte as staged: (a) after a `"use client"` prologue at the start of line 2 or 3, never offset 0, and between two directives (`"use client"`, then `"use strict"; import O … // note`), no line start admissible, at offset 26, 27, 64, or 65; (b) under `// @ts-nocheck` and under `/// <reference lib="esnext" />` at the start of line 3, never above the directive; (c) at the start of line 2, before a `// @ts-expect-error` comment, never between it and the statement it governs, the file compiling clean under standard tooling before and after the move; (d) after a trailing `// note` mid-line at 37 or 38, and after U+00A0 or U+2028 at 37 alone (1.4\'s whitespace, 3\'s terminators); (e) after a `;` terminating `O`\'s declaration across a line at the start of line 3, never inside the declaration, and in 6.5\'s own shape (one call statement across two lines) at one of six mid-line offsets, never at the start of line 3; (f) timeliness under `move specs/A.mdx#m specs/B.mdx#m`: 6.5\'s example (`import A …`, `A.m`, `A.k`, `import B …`, `B.b`), `B` untimely for the marker, gains `import <X> from "../specs/B.xspec"` at the start of line 2, the marker written `<X>.m`, and `query edges` reports the marker\'s `references` edge from `src/c.ts` to `specs/B.mdx#m`; its control (`import B …` on line 2) is re-rooted to `B.m`, nothing added; with `type T = number` or `import Z = require("./z")` interposed between the two declarations `B` is untimely and the declaration is added at offset 33; with `import type { T } from "./t"` or `import "./p"` interposed `B` stays timely, the marker re-rooted to `B.m`, nothing added, the preview one `reference-rewrite` and no `import-addition` or `import-removal`; the precedence branch at a distance (`import B …`, `B.b`, `import A …`, `A.m`, `A.k`) re-rooted to `B.m`, nothing added, the preview\'s one `reference-rewrite` spanning [70, 73), and the marker\'s edge reported; (g) the spec-source side of the split rule: a target whose first two lines are one declaration of one ESM block (`import K from "./k.xspec"`, `;`), receiving T6.5-13(h)\'s moved text into `p.n`, gains `import <X> from "./x.xspec"` at offset 0, the empty line\'s start, or the file\'s end, never at the start of line 2, which derives yet splits the declaration; (h) a removed declaration\'s place: with `f` = `export function f() { O.x }`, `O`\'s declaration losing its last use and removed with its line over [0, 38), the added line stands at the removal\'s end, offset 38, the file becoming exactly `import <X> from "../specs/target.xspec"`, U+000A, `export function f() { <X>.y }`, U+000A, the preview one `reference-rewrite` spanning the marker, one `import-removal` spanning [0, 38), and the `import-addition` at 38, never 0, and `query edges` the marker\'s `references` edge from `src/c.ts#f` to `specs/target.mdx#y`; (i) that file headed by `// note` or by `// @ts-expect-error`: the added line at the removal\'s end, 46 or 58, the comment then preceding it; (j) `"use client"` after the removed declaration: the added line at 38, the string-literal statement staying no directive; (k) a callee\'s timeliness under (f)\'s move: `ta(A.m)`, below `import A, { text as ta } …` and `import B …` and above `import { text as tb } …`, rewritten whole to `<Y>(B.m)`, the file gaining exactly `import { text as <Y> } from "../specs/B.xspec"` (or `import { text } …`) at offset 49 or 82, the preview one `reference-rewrite` spanning the call and no `import-removal`, and `query edges` the call\'s `embeds` edge from `src/c.ts` to `specs/B.mdx#m`; its control, `import { text as tb } …` on line 2, re-rooted at both existing bindings, `tb(B.m)`, nothing added; (l) under (f)\'s move, two declarations of one module (`import A1 …`, `A1.m`, `import A2 …`, `A2.m.c`, `A1.k`, `A2.k`): one added `import <X> from "../specs/B.xspec"` at offset 34, timely for both markers, which become `<X>.m` and `<X>.m.c`, the preview two `reference-rewrite` edits and no `import-removal`, and `query edges` the markers\' `references` edges from `src/c.ts` to `specs/B.mdx#m` and `specs/B.mdx#m.c`; (m) the same with `import B …` after `A1.m` and `B.b` last: the declaration added at 34 for `A1.m` alone, `A2.m.c` re-rooted at the timely `B` as `B.m.c`; (n) (d)\'s file with `f` spread over lines, or with `namespace N {` in its place: at 37 or 38, never at a line start inside the body; (o) a spec source (`import O …`, `<S id="p" d={O.x} />`, `import T …`, `<S id="q" d={T.z} />`): `O.x` re-rooted at the later-declared `T`, nothing added, `O`\'s declaration removed with its line, the preview one `reference-rewrite` spanning [45, 48) and one `import-removal` spanning [0, 31), and `query edges` `p`\'s `depends` edge to `specs/target.mdx#y`; (p) U+0020, U+0009, U+000B, or U+000C between `O`\'s declaration and its line\'s terminator: at the start of line 2, 39, the preview one `reference-rewrite` spanning [61, 64) and no `import-removal` — each move exiting 0, every receiver well-formed after it (TypeScript 5.9.3\'s module and script readings; the stock MDX 3 grammar), the preview\'s one `import-addition` at the offset the real operation used wherever a declaration is added (T6.6-4(b)), and `check` and `build` clean after it (SPEC 6.5, 1.4, 3, 4, 4.6, 5.2, 6.4, 6.6, 11.1, 12.7, 14.20)', + timeoutMs: 300_000, + run: async (product) => { + const failures: string[] = []; + for (const arm of S23_ARMS) { + try { + await runS23Arm(product, arm); + } catch (error) { + if (!(error instanceof HarnessAssertionError)) throw error; + // A failure diagnosed outside the staging's own assertions — the + // subprocess driver's T6.5-22(a) hook, say — is named by its + // staging too. + const context = `T6.5-23 ${arm.key}`; + failures.push( + error.message.startsWith(`${context}:`) + ? error.message + : `${context}: ${error.message}`, + ); + } + } + if (failures.length > 0) { + fail( + `T6.5-23: ${String(failures.length)} of ${String(S23_ARMS.length)} ` + + `stagings failed, each diagnosed:\n` + + failures.map((failure) => `* ${failure}`).join("\n"), + ); + } + }, +}); + +/** TEST-SPEC §6.5, fifth part, in canonical ID order (SUITE-25). */ +export const section65vTests: readonly ProductTestEntry[] = [T6_5_23]; diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index d8952572..aa471f60 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-9. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -28,17 +28,48 @@ // ID is a usage error (12.0). // // Conservative operationalizations (noted per H-4): -// - T6.5-1 "rewritten so everything resolves": SPEC pins resolution, not the -// rewritten specifier's spelling (several relative paths resolve to one -// file), so specifiers are asserted as: the stale quoted spelling gone, a -// spelling naming the moved module's file stem present (every resolving -// specifier ends in `Moved.xspec` / `Other.xspec`), and `check` exit 0 — -// which enforces that all imports and references actually resolve (12.2). +// - T6.5-1's specifier-rewrite byte contract (TEST-SPEC T6.5-1; SPEC 6.5): +// the `import-specifier-rewrite` ranges are read by running the preview on +// a copy of each arm's fixture (SPEC 6.6: a rewrite's range is the +// specifier literal's characters, in pre-operation coordinates), the +// preview's `mapping` and complete `files` pinned form-exact meanwhile +// (T6.6-4 (c)), and after the real move each rewritten file — the moved +// file under its new path, the importing `.mdx`, the importing `.ts` — +// must be its pre-move bytes with exactly those ranges replaced by the +// expected literal: the kept delimiters around the canonical relative +// spelling 6.5 fixes (the `..` ascents, then the descending segments, +// `/`-joined, no `.` segments, `./`-prefixed without an ascent, `.xspec`), +// compared byte for byte rather than resolved — over a move into a +// subdirectory, out of one, across sibling directories, and a relocation +// within the file's own directory, whose own specifiers still designate +// their source and so are neither rewritten nor reported (6.5). `check` +// exit 0 then enforces that everything actually resolves (12.2). A fifth +// arm stages five declarations that record no edge or occurrence — two +// unreferenced spec-source imports, a code source's type-only import +// under each modifier form and its side-effect import — each rewritten +// all the same (6.5: every specifier naming a spec module stands in an +// import declaration, 4), its pre-move edge sets pinned empty as a +// staging premise, and closes with `check` and `build` finding-free. // - "Mapping appended to the journal" uses the SUITE-21 operationalization: // the journal (absent before the first journaled operation, SPEC 6.1) is a // plain file holding exactly one line-oriented entry after the one move; // 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 form-exact +// performed-operation decoder (`decodeAppliedMappingReport`, a thin alias +// of forms.ts's `decodePerformedOperationReport`: exactly `{"findings", +// "mapping"}`, `findings` `[]`, SPEC 12.7; H-3), and the decoded pairs are +// asserted as the ordered array (`assertAppliedMapping`) — every identity +// pair the operation journaled, the preview's `mapping`, one `{"from", +// "to"}` per node ordered by `from` bytes (SPEC 6.4, 6.6, 12.7). 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 +91,117 @@ // 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; `identities` exactly the 1.5 identity +// over the target file as its sole element, and the collision's located +// bearers in location order — support.ts assertRefusalIdentities), 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. The barred-character arms +// are refusals instead: a `<new-id>` carrying a character 1.4's +// quote-and-escape bullet bars (refused-invalid-id) and a destination +// path carrying one 7.1 bars (refused-invalid-destination) are each a +// well-formed argument value that no spelling rule decides (12.0), so +// exit 1, never exit 2. +// - 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 — in each form, both of T6.4-4's spellings: absent on disk, and a +// valid `.mdx` present on disk (holding a section spelling the origin +// ID) but matched by no spec group, its absence from the discovered set +// pinned through `ids --json` on the valid workspace (a file named in an +// argument exists as a member of the discovered set, SPEC 12.0) — or +// origin ID, and a discovered code source as the origin in each +// form — both forms' origin operands name discovered spec sources +// (SPEC 6.5), so a code-source origin is a wrong-kind operand, judged +// like existence before any content question — the wrong-kind arms on +// the valid workspace inside whole-root modifies-nothing snapshot +// compares (a product accepting a code origin would relocate the file or +// act on its named unit), and the existence table inside one such +// compare in the valid-workspace and ordering arms alike (a product +// probing the filesystem for the stray origin would move it). The +// masking arm asserts exit 1 with exactly +// one 14.20 finding naming the unparseable origin file, and origin-ID +// existence is parse-local over spelled identities, as T6.4-4 +// (SPEC 6.5, 6.4, 11.2): an origin ID two sections both spell, or one +// 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; the moved subtree spells a descendant's `id` +// attribute single-quoted (SPEC 2.7), re-identified with its quotes kept +// (SPEC 6.4: minimal in-place edits bind the `id`-attribute rewrite as +// they bind references, T6.4-2). The code-source counterpart, two `.ts` +// files in the same workspace, each importing the origin, target, and +// third modules with the origin binding referenced only by markers on +// moved nodes (SPEC 4.5): the origin declaration alone on its line in one +// file and following the third module's on a shared line in the other, +// removed with 6.5's exact extent as in MDX, the moved markers re-rooted +// at the existing target binding (no import added), each file byte-equal +// to its composed expectation. A premise `build` pins the staging valid +// (the shared-line two-declaration import blocks parse, SPEC 2.1, 4) and +// a post-move `check` guards the composition's soundness: if the product's +// bytes equal the expected bytes yet something failed to resolve, the +// staging itself was defective and must fail loud. The whole workspace +// recurs with every staged terminator CRLF, and again with every one a +// lone CR (SPEC 3), composed by the same rules: emptied lines dropped +// with their whole terminators, staged terminators kept, and U+000A the +// one terminator the move inserts. // - 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 @@ -81,23 +210,44 @@ // resolve" clause is unstageable for T6.4-3's reason. import { Buffer } from "node:buffer"; -import type { GraphEdge } from "../../helpers/adapters/index.js"; +import * as fsp from "node:fs/promises"; +import { join as joinPath, posix as posixPath } from "node:path"; +import type { + AppliedMappingPair, + GraphEdge, + PreviewEdit, + PreviewFileEntry, + SourceRange, +} from "../../helpers/adapters/index.js"; import { + decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, + decodeIdsReport, decodeNodeRowsReport, + decodePreviewReport, + decodeViewReport, + renderPathValue, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; import { assertAcrossDirectoriesDeterministic } from "../../helpers/determinism.js"; +import { + assertAddedImportInsertion, + assertExactDeclarationInsertion, + canonicalSpecifier, +} from "../../helpers/import-insertion.js"; +import { deriveMdx } from "../../helpers/mdx-derivability.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import { assertDirectoriesEqual, assertLeavesUnchanged, @@ -108,33 +258,94 @@ import type { ProductBinding, RunResult, } from "../../helpers/subprocess.js"; +import { + ConsumerProject, + assertNoCompileErrors, +} from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { + BARRED_NEW_ID_CHARACTERS, + U4_ANC_SOURCE, + U4_BAD_SOURCE, + U4_BROKEN_SOURCE, + U4_DUP_SOURCE, + U4_SOLO_SOURCE, + U4_SOURCE, + U4_STRAY_SOURCE, +} from "./section-6.4.js"; +import type { + BearerLocationExpectation, + FindingSourceExpectation, +} from "./support.js"; +import { + REPLACEMENT_CHARACTER, + assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, + assertFindingConcernsPath, assertFindingLocated, + assertFindingLocatesExactly, + assertFindingMentionsLocation, + assertRefusalIdentities, assertSameJson, buildFindings, buildOk, + byteWindow, + expectErrorDocument, expectExit, + expectFindingFreeReport, + expectSyntaxClassUsageError, runJson, sortedIdentities, + stageConfigurationStateTwins, } from "./support.js"; // Exactly one spec group (SPEC 7), for the byte-exact edit and identity-terms -// fixtures. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// fixtures. A staged-source record: T6.5-2, T6.5-5, and T6.5-8 stage it in +// workspaces created after a product invocation, as T6.6-3 does through +// MOVE_SOLO_CONFIG and MOVE_IDENTITY_CONFIG (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.5-2/T6.5-5/T6.5-8/T6.6-3 xspec.config.ts — exactly one spec group, the byte-exact, usage-error, and identity-terms fixtures'", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); + +// 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). +// A staged-source record: T6.5-5's ordering arm stages it in a workspace +// created after a product invocation, as T6.6-3 does through +// MOVE_USAGE_CONFIG (S-9's timing clause). +const SPEC_AND_CODE_CONFIG = stagedTs( + "T6.5-5/T6.6-3 xspec.config.ts — one spec group and one code group, the usage-error fixtures'", + `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" +// A staged-source record: T6.5-2, T6.5-3, T6.5-9, and T6.5-10 stage it in +// workspaces created after a product invocation (S-9's timing clause). +const SPECS_MD_CONFIG = stagedTs( + "T6.5-2/T6.5-3/T6.5-9/T6.5-10 xspec.config.ts — one spec group with Markdown emission", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -142,12 +353,17 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); // Specs, code, and Markdown emission, for the T6.5-1 file-form fixture whose // rewrites span MDX and TypeScript sources and whose fresh-build compare -// covers every derived-file kind (the T6.4-7 configuration). -const FULL_CONFIG = `import { defineConfig } from "xspec" +// covers every derived-file kind (the T6.4-7 configuration). A staged-source +// record: T6.5-1 stages it after a product invocation — every arm's preview +// copy, and arms (b)–(d) (S-9's timing clause). +const FULL_CONFIG = stagedTs( + "T6.5-1 xspec.config.ts — specs, code, and Markdown emission", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -158,14 +374,20 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); // The T6.5-4 refusal configuration: the second spec glob admits `.mdx`-less // destinations under `specs/plain/` (isolating the lacking-`.mdx` refusal // from the no-spec-group one), and the code group overlaps the spec globs at // `specs/dual/` (the belonging-to-a-code-group-as-well refusal, 14.14). Both -// extra globs match no staged file, so the workspace itself stays valid. -const REFUSAL_CONFIG = `import { defineConfig } from "xspec" +// extra globs match no staged file, so the workspace itself stays valid. A +// staged-source record: T6.5-4's later workspaces stage it after a product +// invocation, as T6.6-3 and T14-7 do through MOVE_REFUSAL_CONFIG (S-9's +// timing clause). +const REFUSAL_CONFIG = stagedTs( + "T6.5-4/T6.6-3/T14-7 xspec.config.ts — the refusal configuration: a second spec glob under specs/plain/, a code group over specs/dual/", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -175,15 +397,21 @@ export default defineConfig({ dual: ["specs/dual/**"] } }) -`; +`, +); const JOURNAL_PATH = ".xspec/journal"; const LF = 0x0a; -/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +/** + * Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). + * An `.mdx` entry of a workspace created after the body's first product + * invocation is a staged-source record (the record-accepting initial + * `files`; helpers/staged-mdx.ts), staged under the record's declaration. + */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -296,6 +524,45 @@ async function queryEdgesOfKind( * Read a workspace source file as UTF-8 text, failing diagnosed (H-8) when * the path does not hold a plain file. */ +/** + * The rewritten spec file derives under the stock MDX 3 grammar (S-9, + * `deriveMdx`). 6.5 adds a declaration only at an admissible offset — one at + * which the file, as every edit leaves it, is well-formed under its grammar + * (14.20) with the added line a declaration of an ESM block — and a + * product's own `check` cannot judge that where its grammar is wider than + * 14.20's: the grammar bounds a block line-sensitively, running it to the + * next blank line, so a declaration inserted directly above a non-blank line + * holding no declaration absorbs it and derives as no block at all, the + * insertion inadmissible where the file's end after its final terminator, a + * line-start admissible offset, is taken over any other (T6.5-13). + */ +async function assertRewrittenSpecDerives( + workspace: TestWorkspace, + rel: string, + context: string, +): Promise<void> { + const bytes = await workspace.readBytes(rel); + const verdict = deriveMdx(bytes); + if (verdict.derives) return; + const where = + verdict.position === undefined + ? "" + : ` at line ${String(verdict.position.line)}, column ${String(verdict.position.column)} (offset ${String(verdict.position.offset)})`; + fail( + `${context}: ${rel} after the move is not well-formed under the stock ` + + `MDX 3 grammar (S-9)${where}: ${verdict.reason} — an added ` + + `declaration stands only at an admissible offset, one at which the ` + + `file as every edit leaves it is well-formed (14.20) with the added ` + + `line a declaration of an ESM block, which the grammar bounds ` + + `line-sensitively: a block runs to the next blank line, so a ` + + `declaration inserted directly above a non-blank line holding no ` + + `declaration absorbs it and derives as no block, an inadmissible ` + + `offset while the file's end after its final terminator, a line-start ` + + `admissible one, is taken over any other (SPEC 6.5, 14.20; T6.5-13); ` + + `the file reads ${JSON.stringify(Buffer.from(bytes).toString("utf8"))}`, + ); +} + async function readSourceText( workspace: TestWorkspace, rel: string, @@ -340,6 +607,236 @@ function assertLacks( } } +/** UTF-8 byte length of `text` — SPEC 1.7 ranges are byte offsets. */ +function utf8Length(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** + * Byte span (SPEC 1.7) of exactly one occurrence of `fragment` in `source`. + * An absent or ambiguous fragment is a staging defect — a harness error, + * never a product failure: a precomputed span must name its bytes uniquely. + */ +function uniqueSpan( + source: string, + fragment: string, + where: string, +): SourceRange { + const first = source.indexOf(fragment); + if (first === -1) { + throw new Error( + `${where}: staging locator — fragment ${JSON.stringify(fragment)} ` + + `not found`, + ); + } + if (source.indexOf(fragment, first + 1) !== -1) { + throw new Error( + `${where}: staging locator — fragment ${JSON.stringify(fragment)} ` + + `is ambiguous`, + ); + } + const start = utf8Length(source.slice(0, first)); + return { start, end: start + utf8Length(fragment) }; +} + +/** + * Byte spans (SPEC 1.7) of every occurrence of `fragment` in `source`, in + * source order — exactly `count` of them. Any other count is a staging + * defect — a harness error, never a product failure: each precomputed span + * names one specifier literal of a declaration the fixture stages. + */ +function everySpan( + source: string, + fragment: string, + count: number, + where: string, +): readonly SourceRange[] { + const spans: SourceRange[] = []; + for ( + let at = source.indexOf(fragment); + at !== -1; + at = source.indexOf(fragment, at + fragment.length) + ) { + const start = utf8Length(source.slice(0, at)); + spans.push({ start, end: start + utf8Length(fragment) }); + } + if (spans.length !== count) { + throw new Error( + `${where}: staging locator — fragment ${JSON.stringify(fragment)} ` + + `occurs ${String(spans.length)} time(s), not ${String(count)}`, + ); + } + return spans; +} + +/** + * The `import-specifier-rewrite` ranges a completed preview reports for the + * file at `rel` — its current, pre-operation path — in the report's own + * order (SPEC 6.6: every file the operation would rewrite, with every edit + * it would make there, each classed; 12.7: edits ordered by range start). + * A file the preview lists other than exactly once, or without a specifier + * rewrite, fails diagnosed: the caller's fixture stages in each such file + * at least one specifier the file-form move must rewrite. + */ +function specifierRewriteRanges( + files: readonly PreviewFileEntry[], + rel: string, + context: string, +): readonly SourceRange[] { + const entries = files.filter((entry) => entry.file === rel); + if (entries.length !== 1) { + fail( + `${context}: the preview must list ${rel} exactly once among the ` + + `files the move would rewrite (SPEC 6.6, 12.7); found it ` + + `${entries.length} time(s) in [${files + .map((entry) => renderPathValue(entry.file)) + .join(", ")}]`, + ); + } + const ranges = entries[0]!.edits + .filter((edit) => edit.class === "import-specifier-rewrite") + .map((edit) => edit.range); + if (ranges.length === 0) { + fail( + `${context}: the preview's entry for ${rel} reports no ` + + `\`import-specifier-rewrite\` edit, yet the relocation must rewrite ` + + `the specifier staged there (SPEC 6.5, 6.6)`, + ); + } + return ranges; +} + +/** The first offset at which two byte runs differ, or -1 when equal. */ +function firstDifference(a: Uint8Array, b: Uint8Array): number { + const shared = Math.min(a.length, b.length); + for (let i = 0; i < shared; i += 1) { + if (a[i] !== b[i]) return i; + } + return a.length === b.length ? -1 : shared; +} + +/** Up to 24 bytes of `bytes` from `offset`, rendered for a diagnosis. */ +function excerpt(bytes: Uint8Array, offset: number): string { + return JSON.stringify( + Buffer.from(bytes.subarray(offset, offset + 24)).toString("utf8"), + ); +} + +/** + * The file-form move's specifier-rewrite byte contract (TEST-SPEC T6.5-1; + * SPEC 6.5). `post` must be `pre` with each previewed range — read on a + * copy, in pre-operation coordinates (SPEC 6.6, 1.7) — replaced by exactly + * its expected literal and nothing else (6.5: beyond its exact edits a move + * changes no bytes): the splice walks `pre` and `post` together, requiring + * every byte outside the ranges to recur at its spliced position and each + * range's post-move content to be, byte for byte, the literal expected + * there — the pre-move delimiters (the quote style kept, 6.4) around the + * canonical relative spelling of the designated source's post-operation + * path from the importing file's post-operation directory: the `..` + * ascents, then the descending segments, joined with `/`, no `.` segments, + * `./`-prefixed when there is no ascent, `.mdx` replaced by `.xspec` (6.5, + * 2.1). The literal is compared, never resolved: a product spelling a `.` + * segment, omitting the `./` prefix, leaving a stale `..`, switching quote + * style, or rewriting anything beyond the literals fails here. + */ +function assertSpecifierRewriteByteContract( + options: { + readonly rel: string; + readonly pre: Uint8Array; + readonly post: Uint8Array; + /** Each previewed range with the literal expected there after the move. */ + readonly rewrites: readonly { + readonly range: SourceRange; + readonly literal: string; + }[]; + }, + context: string, +): void { + const { rel, pre, post, rewrites } = options; + let preCursor = 0; + let postCursor = 0; + for (const { range, literal } of rewrites) { + if ( + range.start < preCursor || + range.end <= range.start || + range.end > pre.length + ) { + fail( + `${context}: ${rel} — the previewed import-specifier-rewrite range ` + + `[${range.start}, ${range.end}) must be non-empty, lie within the ` + + `${pre.length} pre-move bytes, and follow the preceding range ` + + `(SPEC 6.6, 1.7)`, + ); + } + // Bytes outside the ranges: the pre-move run before this range recurs + // verbatim at its spliced position. + const kept = pre.subarray(preCursor, range.start); + const spliced = post.subarray(postCursor, postCursor + kept.length); + const drift = firstDifference(kept, spliced); + if (drift !== -1) { + fail( + `${context}: ${rel} — bytes outside the previewed ` + + `import-specifier-rewrite ranges changed: from pre-move byte ` + + `${preCursor + drift} the file held ${excerpt(kept, drift)}… and ` + + `now holds ${excerpt(spliced, drift)}… there — a file-form move ` + + `rewrites nothing beyond the specifier literals (SPEC 6.5, 6.6)`, + ); + } + postCursor += kept.length; + // The range's post-move content: exactly the expected literal. + const expected = Buffer.from(literal, "utf8"); + const found = post.subarray(postCursor, postCursor + expected.length); + if (firstDifference(found, expected) !== -1) { + fail( + `${context}: ${rel} — at the previewed import-specifier-rewrite ` + + `range [${range.start}, ${range.end}) the file must hold exactly ` + + `${literal}: the pre-move delimiters, the quote style kept, ` + + `around the canonical relative spelling of the designated ` + + `source's post-operation path from the file's post-operation ` + + `directory — the \`..\` ascents, then the descending segments, ` + + `joined with \`/\`, no \`.\` segments, \`./\`-prefixed when there ` + + `is no ascent, \`.xspec\` (SPEC 6.5, 6.4, 2.1); found ` + + `${literalAt(post, postCursor)}`, + ); + } + postCursor += expected.length; + preCursor = range.end; + } + // After the last range: the pre-move tail recurs and nothing follows it. + const preTail = pre.subarray(preCursor); + const postTail = post.subarray(postCursor); + const drift = firstDifference(preTail, postTail); + if (drift !== -1) { + fail( + `${context}: ${rel} — after the last previewed ` + + `import-specifier-rewrite range the file must end with its ` + + `pre-move bytes verbatim; they diverge at pre-move byte ` + + `${preCursor + drift} (pre ${excerpt(preTail, drift)}…, post ` + + `${excerpt(postTail, drift)}…) — a file-form move rewrites nothing ` + + `beyond the specifier literals (SPEC 6.5, 6.6)`, + ); + } +} + +/** + * The quoted literal opening at `offset` in `bytes`, rendered for a + * diagnosis — or an excerpt when no closed literal opens there. + */ +function literalAt(bytes: Uint8Array, offset: number): string { + const quote = bytes[offset]; + if (quote === 0x22 || quote === 0x27) { + const close = Buffer.from( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).indexOf(quote, offset + 1); + if (close !== -1) { + return Buffer.from(bytes.subarray(offset, close + 1)).toString("utf8"); + } + } + return `${excerpt(bytes, offset)}…`; +} + /** Human rendering of an argv that may carry raw-byte elements. */ function renderArgv(argv: readonly ArgvValue[]): string { return argv @@ -351,64 +848,184 @@ 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, the exact `identities` where 14 pins them, 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; + /** + * The finding's complete location set — exactly one location per listed + * bearer, none beside, index-wise in 12.7's within-finding order (SPEC 14: + * `refused-id-collision` locates every colliding bearer — the + * every-participant strictness of T6.4-3's two-bearer arm, asserted by + * its home test and by T14-7 over the shared case table; section-6.4.ts + * declares the same member). Declared beside `locatedAt`, whose + * SOME-quantified check consumers asserting it alone still apply. + */ + readonly locatedAtEach?: readonly BearerLocationExpectation[]; + /** + * The finding's exact `identities` (SPEC 12.7, 14): stated for every + * reason SPEC 14 pins — the concerned identity as the sole element, in + * 1.5's form over the operation's destination file (whether or not the ID + * is valid), or `refused-id-collision`'s located bearers' identities in + * location order — and omitted where 12.7 leaves the composition unpinned + * (support.ts assertRefusalIdentities guards both ways). + */ + readonly identities?: readonly string[]; + /** 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.locatedAtEach !== undefined) { + assertFindingLocatesExactly( + finding, + expectation.locatedAtEach, + `${context}: the ${expectation.finding} refusal's complete ` + + `located-bearer set`, + ); + } + assertRefusalIdentities( + finding, + expectation.finding, + expectation.identities, + `${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( @@ -420,327 +1037,992 @@ async function expectMoveUsageError( } // --------------------------------------------------------------------------- -// T6.5-1 — file form +// T6.5-1 — file form: the specifier-rewrite byte contract over four +// relocations // --------------------------------------------------------------------------- -// The moved file imports another spec file (its own import specifier must be -// rewritten across the directory change) and its generated module is imported -// by a spec file and a code file (their import paths rewritten); its sections -// are referenced through `d`, MDX and TS `text(...)`, and a TS marker, so the -// post-move edge sets witness that everything resolves under the new -// identities — file part changed, IDs unchanged (SPEC 6.5). -const F1_OTHER = "specs/Other.mdx"; -const F1_CORE = "specs/Core.mdx"; -const F1_MOVED = "specs/sub/Moved.mdx"; -const F1_REFS = "specs/Refs.mdx"; -const F1_APP = "src/app.ts"; - -const F1_OTHER_SOURCE = [ - '<S id="oth">', - "Outside target text.", - "</S>", - "", -].join("\n"); +// The file-form fixture, staged per arm at the paths TEST-SPEC T6.5-1 +// spells. The moved file `A.mdx` holds three nested sections and imports the +// unmoved `C.mdx` — single-quoted where the arm rewrites it, the quote style +// a rewrite keeps (SPEC 6.4, 6.5) — through a `d` reference and a +// `text(...)` embedding; the spec importer `I.mdx` references two of A's +// nodes through a double-quoted import; and the importing `.ts` file is +// T6.2-2's: a marker and a `text(...)` call through one `.xspec` import. +// Every specifier literal occurs exactly once in its file, so the previewed +// `import-specifier-rewrite` range and the expected post-move bytes are +// located by fragment (`uniqueSpan`); every source is ASCII, so byte offsets +// are character offsets (1.7). +const A_SECTION_IDS = ["a", "a.mid", "a.mid.leaf"] as const; +// T6.5-1's stagings: arm (a)'s workspace is the body's first; its preview +// copy and every later arm follow (a)'s invocations, so each `.mdx` source +// is a ledger record (S-9's before-any-product clause; helpers/staged-mdx.ts), +// (a)'s converted uniformly. The geometries share sources — all four the +// other file, (a) and (c) the moved file, (a) and (d) the importer — and +// identical bytes one test stages at several sites are ONE record staged at +// each, so `fileMoveArm` composes each source as before and registers it +// through `t651Record` under a name spelling its composition inputs, the +// record reused where a later geometry composes the same bytes. +const C_SOURCE = stagedMdx( + "T6.5-1 every arm's other file specs/C.mdx (specs/w/C.mdx in arm (c))", + ['<S id="c">', "C text.", "</S>", ""].join("\n"), +); +const CONSUMER = "src/app.ts"; +const T6_5_1_RECORDS = new Map<string, StagedMdx>(); +function t651Record(what: string, source: string): StagedMdx { + const name = `T6.5-1 ${what}`; + const known = T6_5_1_RECORDS.get(name); + if (known !== undefined) { + if (known.source !== source) { + throw new Error( + `T6.5-1 staging: ${name} is composed twice with different bytes`, + ); + } + return known; + } + const record = stagedMdx(name, source); + T6_5_1_RECORDS.set(name, record); + return record; +} -const F1_CORE_SOURCE = [ - 'import Other from "./Other.xspec"', - "", - '<S id="core">', - "Core holder text.", - "", - '<S id="core.mid" d={Other.oth}>', - "Mid text.", +/** The moved file's source: its import line(s), then the three sections. */ +function movedFileSource( + imports: readonly string[], + embedBinding: string, +): string { + return [ + ...imports, + "", + '<S id="a">', + "A holder text.", + "", + '<S id="a.mid" d={C.c}>', + "Mid text.", + "", + '<S id="a.mid.leaf">', + `Leaf embeds: {text(${embedBinding}.c)}`, + "</S>", + "</S>", + "</S>", + "", + ].join("\n"); +} + +/** The spec importer's source: one double-quoted import of A's module. */ +function importerSource(specifier: string): string { + return [ + `import A from ${specifier}`, + "", + '<S id="i" d={A.a.mid}>', + "I embeds: {text(A.a.mid.leaf)}", + "</S>", + "", + ].join("\n"); +} + +/** T6.2-2's importing `.ts` file: a marker and a `text(...)` call. */ +function consumerSource(specifier: string): string { + return [ + `import A, { text } from ${specifier};`, + "", + "A.a.mid.leaf;", + "text(A.a.mid);", + "", + ].join("\n"); +} + +/** A specifier literal, delimiters included, before and after the move. */ +interface LiteralRewrite { + readonly before: string; + /** + * The expected post-move literal: the pre-move delimiters (the quote + * style kept, 6.4) around the canonical relative spelling of the + * designated source's post-operation path from the importing file's + * post-operation directory (SPEC 6.5) — spelled here as TEST-SPEC T6.5-1 + * pins it, never computed. + */ + readonly after: string; +} + +/** One relocation geometry of T6.5-1, in the spellings TEST-SPEC pins. */ +interface FileMoveGeometry { + readonly name: string; + readonly origin: string; + readonly destination: string; + /** The unmoved module the moved file imports. */ + readonly other: string; + /** The moved file's import lines, as staged. */ + readonly ownImports: readonly string[]; + /** The binding the leaf's `text(...)` embedding is rooted at. */ + readonly embedBinding: string; + /** + * The moved file's own specifier the relocation rewrites — or `null` + * where its imports still designate their source from the destination + * directory and are neither rewritten nor reported (SPEC 6.5). + */ + readonly ownRewrite: LiteralRewrite | null; + /** The spec importer's path and its rewrite. */ + readonly importer: string; + readonly importerRewrite: LiteralRewrite; + /** The `.ts` importer's rewrite, or `null` where the arm stages none. */ + readonly consumerRewrite: LiteralRewrite | null; +} + +/** + * The specifier literals the relocation rewrites in one file (SPEC 6.5): + * every occurrence of `before` there, each to `after`. + */ +interface SpecifierRewrite { + /** The importing file's current, pre-operation path (the preview's, 6.6). */ + readonly pre: string; + /** Its post-operation path (the moved file's own import moves with it). */ + readonly post: string; + /** + * The staged source, holding `before` once per declaration the move + * rewrites there and nowhere else — once in every file but arm (e)'s code + * source, whose three declarations spell it alike. + */ + readonly source: string; + readonly before: string; + readonly after: string; +} + +/** A preview `files` entry as pinned: a plain path and class-plus-range edits. */ +interface ExpectedPreviewFile { + readonly file: string; + readonly edits: readonly PreviewEdit[]; +} + +/** The workspace's complete dependency-kind edge sets. */ +interface EdgeSets { + readonly depends: readonly GraphEdge[]; + readonly embeds: readonly GraphEdge[]; + readonly references: readonly GraphEdge[]; +} + +/** One staged arm of T6.5-1, everything the driver asserts precomputed. */ +interface FileMoveArm { + readonly name: string; + readonly origin: string; + readonly destination: string; + readonly files: Readonly<Record<string, InitialFileContents>>; + /** The rewrites the move must make, one per rewritten file. */ + readonly rewrites: readonly SpecifierRewrite[]; + /** Staged files the move rewrites nothing in (the configuration included). */ + readonly untouched: readonly string[]; + /** The preview's complete `files`, ordered by file path bytes (6.6, 12.7). */ + readonly previewFiles: readonly ExpectedPreviewFile[]; + /** The applied mapping: every node of the moved file, the root first. */ + readonly mapping: readonly AppliedMappingPair[]; + readonly preIdentities: readonly string[]; + readonly postIdentities: readonly string[]; + /** The complete edge sets with the moved file at `movedPath`. */ + readonly edges: (movedPath: string) => EdgeSets; + /** The non-derived post-move state seeding a fresh-build directory. */ + readonly seedFiles: readonly string[]; + /** + * Whether the arm closes as TEST-SPEC T6.5-1's five-declaration arm pins: + * `check` and `build` each exit 0 with no finding after the move, their + * `--json` reports decoded form-exact as `{"findings": []}` — rather than + * the post-move `check` exiting 0 alone. + */ + readonly findingFreeClose: boolean; +} + +/** Compare two plain paths by their UTF-8 bytes (12.7's `files` order). */ +function comparePathBytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +/** + * Stage one geometry: its sources, the rewrites the move must make, the + * preview plan it must report, the mapping it must journal, and the node and + * edge inventories before and after — every spelling the geometry's own, the + * factory canonicalizing nothing (H-4: the expected bytes are pinned, not + * derived by a rule the product might share). + */ +function fileMoveArm(geometry: FileMoveGeometry): FileMoveArm { + const { name, origin, destination, other, importer } = geometry; + const where = `T6.5-1 (${name}) staging`; + const movedSource = movedFileSource( + geometry.ownImports, + geometry.embedBinding, + ); + const importerText = importerSource(geometry.importerRewrite.before); + const files: Record<string, InitialFileContents> = { + [other]: C_SOURCE, + [origin]: t651Record( + `the moved file importing ${geometry.ownImports.join("; ")} and embedding ${geometry.embedBinding}`, + movedSource, + ), + [importer]: t651Record( + `the spec importer importing ${geometry.importerRewrite.before}`, + importerText, + ), + }; + const rewrites: SpecifierRewrite[] = []; + if (geometry.ownRewrite !== null) { + rewrites.push({ + pre: origin, + post: destination, + source: movedSource, + before: geometry.ownRewrite.before, + after: geometry.ownRewrite.after, + }); + } + rewrites.push({ + pre: importer, + post: importer, + source: importerText, + before: geometry.importerRewrite.before, + after: geometry.importerRewrite.after, + }); + const consumerStaged = geometry.consumerRewrite !== null; + if (geometry.consumerRewrite !== null) { + const consumerText = consumerSource(geometry.consumerRewrite.before); + files[CONSUMER] = stagedTs( + `T6.5-1 src/app.ts the .ts importer importing ${geometry.consumerRewrite.before}`, + consumerText, + ); + rewrites.push({ + pre: CONSUMER, + post: CONSUMER, + source: consumerText, + before: geometry.consumerRewrite.before, + after: geometry.consumerRewrite.after, + }); + } + // The preview's plan (SPEC 6.6, 12.7; T6.6-4 (c)): the relocation spans + // the entire moved file, its entry under the current, pre-operation path, + // and — starting at byte 0 — orders before the file's own specifier + // rewrite, whose range starts after the `import` keyword (edits by range + // start); every other rewritten file's entry holds its one specifier + // rewrite; entries in file path byte order. + const movedEdits: PreviewEdit[] = [ + { + class: "file-relocation", + range: { start: 0, end: utf8Length(movedSource) }, + }, + ]; + const previewFiles: ExpectedPreviewFile[] = [ + { file: origin, edits: movedEdits }, + ]; + for (const rewrite of rewrites) { + const edit: PreviewEdit = { + class: "import-specifier-rewrite", + range: uniqueSpan( + rewrite.source, + rewrite.before, + `${where} ${rewrite.pre}`, + ), + }; + if (rewrite.pre === origin) movedEdits.push(edit); + else previewFiles.push({ file: rewrite.pre, edits: [edit] }); + } + previewFiles.sort((x, y) => comparePathBytes(x.file, y.file)); + // Every node of the moved file, the implicit root included (its identity + // is the path alone, SPEC 1.2, 1.5), IDs kept and file parts changed, in + // `from`-byte order: the bare path is a proper prefix of every + // `<path>#<id>`, and each section ID a prefix of its descendants'. + const mapping: AppliedMappingPair[] = [ + { from: origin, to: destination }, + ...A_SECTION_IDS.map((id) => ({ + from: `${origin}#${id}`, + to: `${destination}#${id}`, + })), + ]; + const unchanged = [other, `${other}#c`, importer, `${importer}#i`]; + const identities = (movedPath: string): string[] => [ + ...unchanged, + movedPath, + ...A_SECTION_IDS.map((id) => `${movedPath}#${id}`), + ]; + const edges = (movedPath: string): EdgeSets => { + const embeds: GraphEdge[] = [ + { from: `${movedPath}#a.mid.leaf`, to: `${other}#c`, kind: "embeds" }, + { from: `${importer}#i`, to: `${movedPath}#a.mid.leaf`, kind: "embeds" }, + ]; + const references: GraphEdge[] = []; + if (consumerStaged) { + embeds.push({ from: CONSUMER, to: `${movedPath}#a.mid`, kind: "embeds" }); + references.push({ + from: CONSUMER, + to: `${movedPath}#a.mid.leaf`, + kind: "references", + }); + } + return { + depends: [ + { from: `${movedPath}#a.mid`, to: `${other}#c`, kind: "depends" }, + { from: `${importer}#i`, to: `${movedPath}#a.mid`, kind: "depends" }, + ], + embeds, + references, + }; + }; + const seedFiles = ["xspec.config.ts", other, destination, importer]; + if (consumerStaged) seedFiles.push(CONSUMER); + seedFiles.push(JOURNAL_PATH); + return { + name, + origin, + destination, + files, + rewrites, + untouched: ["xspec.config.ts", other], + previewFiles, + mapping, + preIdentities: identities(origin), + postIdentities: identities(destination), + edges, + seedFiles, + findingFreeClose: false, + }; +} + +// Arm (e): rewritten whatever uses the declaration's bindings (TEST-SPEC +// T6.5-1; SPEC 6.5: the relocation rewrites the moved file's own import +// specifiers and the paths by which other files import its module, every +// specifier naming a spec module standing in an import declaration, 4). +// Under `move specs/A.mdx specs/sub/A.mdx`, the spec glob `specs/**/*.mdx` +// reaching the destination and `specs/C.mdx` discovered, five declarations +// stand that record no edge and no occurrence: the moved file's own +// `import C`, `C` referenced nowhere, and the spec importer's `import A`, +// `A` referenced nowhere likewise (2.1: valid, recording no edge); and in a +// code source a type-only import under each modifier form (T4-4) — `T` +// spelled at type level alone, `t` nowhere — and a side-effect import +// (T4-2), none recording an edge or an occurrence (4, 4.5, 5.7). The bytes +// are TEST-SPEC's, every line terminated by U+000A. +const T651E_MOVED_SOURCE = [ + 'import C from "./C.xspec"', "", - '<S id="core.mid.leaf">', - "Leaf embeds: {text(Other.oth)}", - "</S>", - "</S>", - "</S>", + '<S id="x">x</S>', "", ].join("\n"); - -const F1_REFS_SOURCE = [ - 'import Core from "./Core.xspec"', +const T651E_IMPORTER_SOURCE = [ + 'import A from "./A.xspec"', "", - '<S id="refs" d={Core.core.mid}>', - "Refs embeds: {text(Core.core.mid.leaf)}", - "</S>", + '<S id="b">b</S>', "", ].join("\n"); - -const F1_APP_SOURCE = [ - 'import CORE, { text } from "../specs/Core.xspec";', - "", - "CORE.core.mid.leaf;", - "text(CORE.core.mid);", +const T651E_CODE_SOURCE = [ + 'import type T from "../specs/A.xspec"', + 'import { type text as t } from "../specs/A.xspec"', + 'import "../specs/A.xspec"', + "let v: typeof T.x", "", ].join("\n"); +const T651E_MOVED = stagedMdx( + "T6.5-1 (e) specs/A.mdx — the moved file, its own import of C unreferenced, and section x", + T651E_MOVED_SOURCE, +); +const T651E_IMPORTER = stagedMdx( + "T6.5-1 (e) specs/B.mdx — the spec importer, its import of A unreferenced, and section b", + T651E_IMPORTER_SOURCE, +); +const T651E_CODE = stagedTs( + "T6.5-1 (e) src/c.ts — a type-only import of A's module under each modifier form and a side-effect import of it", + T651E_CODE_SOURCE, +); -const F1_UNCHANGED_IDENTITIES = [ - F1_OTHER, - `${F1_OTHER}#oth`, - F1_REFS, - `${F1_REFS}#refs`, -]; -const F1_PRE_IDENTITIES = [ - ...F1_UNCHANGED_IDENTITIES, - F1_CORE, - `${F1_CORE}#core`, - `${F1_CORE}#core.mid`, - `${F1_CORE}#core.mid.leaf`, -]; -// Identities change only in their file part (SPEC 6.5): same IDs, new path. -const F1_POST_IDENTITIES = [ - ...F1_UNCHANGED_IDENTITIES, - F1_MOVED, - `${F1_MOVED}#core`, - `${F1_MOVED}#core.mid`, - `${F1_MOVED}#core.mid.leaf`, -]; - -/** The fixture's complete dependency-kind edge sets, per moved-file path. */ -function f1Edges(coreFile: string): { - depends: GraphEdge[]; - embeds: GraphEdge[]; - references: GraphEdge[]; -} { +/** + * Arm (e)'s staging and expectations. Each of the five declarations is + * rewritten under the byte contract, its double quotes kept — the moved + * file's to `"../C.xspec"`, the importer's to `"./sub/A.xspec"`, and each of + * the code source's three to `"../specs/sub/A.xspec"` — no other byte of the + * three files changing; the preview's `files` holds the relocation entry and + * exactly one `import-specifier-rewrite` per declaration, five in all, each + * spanning its specifier literal, and no other edit (T6.6-4 (c)); and the + * arm closes with `check` and `build` exiting 0 with no finding. A product + * collecting the specifiers it rewrites from recorded edges, occurrences, + * or used bindings — as 6.5's reference rewrites follow occurrences — + * rewrites none of the five: its preview's `files` lacks them, its real move + * leaves them designating no discovered source (14.15 at the next `check`), + * and it fails. + */ +function fiveDeclarationArm(): FileMoveArm { + const where = "T6.5-1 (five declarations recording no edge) staging"; + const origin = "specs/A.mdx"; + const destination = "specs/sub/A.mdx"; + const other = "specs/C.mdx"; + const importer = "specs/B.mdx"; + const code = "src/c.ts"; + const rewrites: SpecifierRewrite[] = [ + { + pre: origin, + post: destination, + source: T651E_MOVED_SOURCE, + before: '"./C.xspec"', + after: '"../C.xspec"', + }, + { + pre: importer, + post: importer, + source: T651E_IMPORTER_SOURCE, + before: '"./A.xspec"', + after: '"./sub/A.xspec"', + }, + { + pre: code, + post: code, + source: T651E_CODE_SOURCE, + before: '"../specs/A.xspec"', + after: '"../specs/sub/A.xspec"', + }, + ]; + // One `import-specifier-rewrite` per declaration (the code source's three + // in source order), the relocation spanning the entire moved file and + // ordering first there (range start 0); entries in file path byte order + // (SPEC 6.6, 12.7). + const declarations = new Map([ + [origin, 1], + [importer, 1], + [code, 3], + ]); + const previewFiles: ExpectedPreviewFile[] = rewrites.map((rewrite) => { + const edits: PreviewEdit[] = everySpan( + rewrite.source, + rewrite.before, + declarations.get(rewrite.pre)!, + `${where} ${rewrite.pre}`, + ).map((range) => ({ class: "import-specifier-rewrite", range })); + if (rewrite.pre === origin) { + edits.unshift({ + class: "file-relocation", + range: { start: 0, end: utf8Length(T651E_MOVED_SOURCE) }, + }); + } + return { file: rewrite.pre, edits }; + }); + previewFiles.sort((x, y) => comparePathBytes(x.file, y.file)); + const identities = (movedPath: string): string[] => [ + other, + `${other}#c`, + importer, + `${importer}#b`, + movedPath, + `${movedPath}#x`, + ]; return { - depends: [ - { - from: `${coreFile}#core.mid`, - to: `${F1_OTHER}#oth`, - kind: "depends", - }, - { - from: `${F1_REFS}#refs`, - to: `${coreFile}#core.mid`, - kind: "depends", - }, - ], - embeds: [ - { - from: `${coreFile}#core.mid.leaf`, - to: `${F1_OTHER}#oth`, - kind: "embeds", - }, - { - from: `${F1_REFS}#refs`, - to: `${coreFile}#core.mid.leaf`, - kind: "embeds", - }, - { from: F1_APP, to: `${coreFile}#core.mid`, kind: "embeds" }, + name: "five declarations recording no edge", + origin, + destination, + files: { + [other]: C_SOURCE, + [origin]: T651E_MOVED, + [importer]: T651E_IMPORTER, + [code]: T651E_CODE, + }, + rewrites, + untouched: ["xspec.config.ts", other], + previewFiles, + mapping: [ + { from: origin, to: destination }, + { from: `${origin}#x`, to: `${destination}#x` }, ], - references: [ - { from: F1_APP, to: `${coreFile}#core.mid.leaf`, kind: "references" }, + preIdentities: identities(origin), + postIdentities: identities(destination), + // No edge of any kind, before the move or after it: the five + // declarations record none (2.1, 4, 4.5, 5.7), and no file references + // another. + edges: () => ({ depends: [], embeds: [], references: [] }), + seedFiles: [ + "xspec.config.ts", + other, + destination, + importer, + code, + JOURNAL_PATH, ], + findingFreeClose: true, }; } +// The five relocations TEST-SPEC T6.5-1 pins, each literal spelled as the +// entry spells it (SPEC 6.5: the `..` ascents, then the descending segments, +// joined with `/`, no `.` segments, `./`-prefixed when there is no ascent, +// `.mdx` replaced by `.xspec`; the quote style kept, 6.4). +const FILE_MOVE_ARMS: readonly FileMoveArm[] = [ + // (a) Into a subdirectory: the importer's `"./A.xspec"` becomes + // `"./sub/A.xspec"`; the moved file's own `'./C.xspec'` becomes + // `'../C.xspec'`, single quotes kept — an ascent gained; the `.ts` + // importer's ascent-then-descent spelling gains a segment. + fileMoveArm({ + name: "into a subdirectory", + origin: "specs/A.mdx", + destination: "specs/sub/A.mdx", + other: "specs/C.mdx", + ownImports: ["import C from './C.xspec'"], + embedBinding: "C", + ownRewrite: { before: "'./C.xspec'", after: "'../C.xspec'" }, + importer: "specs/I.mdx", + importerRewrite: { before: '"./A.xspec"', after: '"./sub/A.xspec"' }, + consumerRewrite: { + before: '"../specs/A.xspec"', + after: '"../specs/sub/A.xspec"', + }, + }), + // (b) Out of a subdirectory — an ascent lost: the moved file's own + // `'../C.xspec'` becomes `'./C.xspec'` (`./`-prefixed, no ascent left), + // the importer's `"./sub/A.xspec"` `"./A.xspec"`, the `.ts` importer's + // `"../specs/sub/A.xspec"` `"../specs/A.xspec"`; a stale `..` fails. + fileMoveArm({ + name: "out of a subdirectory", + origin: "specs/sub/A.mdx", + destination: "specs/A.mdx", + other: "specs/C.mdx", + ownImports: ["import C from '../C.xspec'"], + embedBinding: "C", + ownRewrite: { before: "'../C.xspec'", after: "'./C.xspec'" }, + importer: "specs/I.mdx", + importerRewrite: { before: '"./sub/A.xspec"', after: '"./A.xspec"' }, + consumerRewrite: { + before: '"../specs/sub/A.xspec"', + after: '"../specs/A.xspec"', + }, + }), + // (c) Across sibling directories — ascents before descents: the importer + // in `specs/y/` spells `"../w/A.xspec"`, then `"../x/A.xspec"`; the moved + // file's own `'./C.xspec'`, C staying in `specs/w/`, becomes + // `'../w/C.xspec'`. + fileMoveArm({ + name: "across sibling directories", + origin: "specs/w/A.mdx", + destination: "specs/x/A.mdx", + other: "specs/w/C.mdx", + ownImports: ["import C from './C.xspec'"], + embedBinding: "C", + ownRewrite: { before: "'./C.xspec'", after: "'../w/C.xspec'" }, + importer: "specs/y/I.mdx", + importerRewrite: { before: '"../w/A.xspec"', after: '"../x/A.xspec"' }, + consumerRewrite: { + before: '"../specs/w/A.xspec"', + after: '"../specs/x/A.xspec"', + }, + }), + // (d) Relocation within the file's own directory (`specs/A.mdx` → + // `specs/A2.mdx`): the moved file's own imports — a canonical `./C.xspec` + // and a non-canonical `.//C.xspec`, each still designating `C.mdx` from + // `specs/` (SPEC 2.1) — are neither rewritten nor reported (6.5), while + // the importer's `./A.xspec` becomes `./A2.xspec`; no `.ts` importer, so + // the preview's `files` holds exactly the relocation entry and the + // importer's `import-specifier-rewrite`. + fileMoveArm({ + name: "relocation within the directory", + origin: "specs/A.mdx", + destination: "specs/A2.mdx", + other: "specs/C.mdx", + ownImports: ['import C from "./C.xspec"', 'import C2 from ".//C.xspec"'], + embedBinding: "C2", + ownRewrite: null, + importer: "specs/I.mdx", + importerRewrite: { before: '"./A.xspec"', after: '"./A2.xspec"' }, + consumerRewrite: null, + }), + // (e) Five declarations recording no edge, each rewritten whatever uses + // its bindings: the moved file's own `"./C.xspec"` becomes + // `"../C.xspec"`, the importer's `"./A.xspec"` `"./sub/A.xspec"`, and + // each of the code source's three `"../specs/A.xspec"` + // `"../specs/sub/A.xspec"`. + fiveDeclarationArm(), +]; + +/** Preview edits projected to their pinned form, for an exact compare. */ +function projectEdits(edits: readonly PreviewEdit[]): readonly PreviewEdit[] { + return edits.map((edit) => ({ + class: edit.class, + range: { start: edit.range.start, end: edit.range.end }, + })); +} + +/** + * The preview's complete `files` against the arm's pinned plan (SPEC 6.6, + * 12.7; T6.6-4 (c)): one entry per file the relocation would rewrite or + * relocate — the moved file's under its current, pre-operation path — in + * file path byte order, each edit class-plus-range only, in 12.7's order. A + * file the move rewrites nothing in has no entry: a specifier that still + * designates its source from the file's post-operation directory is neither + * rewritten nor reported (6.5). + */ +function assertPreviewFiles( + files: readonly PreviewFileEntry[], + expected: readonly ExpectedPreviewFile[], + context: string, +): void { + if (files.length !== expected.length) { + fail( + `${context}: \`files\` must hold exactly one {"file", "edits"} entry ` + + `per file the relocation would rewrite or relocate, and none for a ` + + `file it rewrites nothing in — expected ` + + `[${expected.map((entry) => entry.file).join(", ")}], got ` + + `[${files.map((entry) => renderPathValue(entry.file)).join(", ")}] ` + + `(SPEC 6.5, 6.6, 12.7)`, + ); + } + for (let i = 0; i < expected.length; i += 1) { + const want = expected[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, ordered by file ` + + `path bytes (SPEC 6.6, 12.7); got ${renderPathValue(got.file)}`, + ); + } + assertSameJson( + projectEdits(got.edits), + projectEdits(want.edits), + `${context}: ${want.file} — every edit the relocation would make ` + + `there, class-plus-range only: the \`file-relocation\` spanning the ` + + `entire moved file and each \`import-specifier-rewrite\` spanning ` + + `exactly the specifier literal's characters, quotes included, in ` + + `12.7's pinned edit order (SPEC 6.5, 6.6, 1.7)`, + ); + } +} + /** Assert the workspace-wide edge set of each dependency kind (SPEC 5.2, 11). */ -async function assertF1Edges( +async function assertEdges( product: ProductBinding, workspace: TestWorkspace, - coreFile: string, + expected: EdgeSets, context: string, ): Promise<void> { - const expected = f1Edges(coreFile); for (const kind of ["depends", "embeds", "references"] as const) { assertEdgeSetEqual( await queryEdgesOfKind(product, workspace, kind, context), expected[kind], `${context}: the workspace's complete \`${kind}\` edge set — every ` + - `reference resolves to the moved file's new identities, whose file ` + - `part alone changed (SPEC 6.5, 5.2)`, + `reference resolves to the moved file's identities, whose file ` + + `part alone changed (SPEC 6.5, 5.2), and no import declaration ` + + `records an edge of its own, its binding used or not — a type-only ` + + `binding and a side-effect import included (2.1, 4, 4.5, 5.7)`, ); } } -// The non-derived workspace state seeded into the fresh-build directory: -// configuration, every source file (post-move bytes), and the journal -// (derived files are reproducible from those, SPEC 13.4). -const F1_SEED_FILES = [ - "xspec.config.ts", - F1_OTHER, - F1_MOVED, - F1_REFS, - F1_APP, - JOURNAL_PATH, -] as const; +/** + * One arm of T6.5-1 on its own fresh workspace: the preview's plan read on a + * copy, the real move's report, the specifier-rewrite byte contract, the + * untouched files, the journal, `check`, and the node and edge inventories; + * with `fullProtocol`, the finishing regeneration as T6.4-7 (H-6); and, for + * an arm closing finding-free, a final `build` with no finding. + */ +async function runFileMoveArm( + product: ProductBinding, + arm: FileMoveArm, + fullProtocol: boolean, +): Promise<void> { + const context = `T6.5-1 (${arm.name})`; + const moveArgv = ["move", arm.origin, arm.destination] as const; + await withWorkspace(FULL_CONFIG, arm.files, async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staged workspace`, + ); -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)", - run: async (product) => { - await withWorkspace( + // Staging premises: no journal before the first journaled operation + // (SPEC 6.1); the pre-move node and edge inventories are exactly as + // staged, so the post-move assertions witness a real transition. + const journalBefore = await workspace.kind(JOURNAL_PATH); + if (journalBefore !== "absent") { + fail( + `${context}: staging premise — no journal file exists before the ` + + `first journaled operation (SPEC 6.1); found ${journalBefore} ` + + `at ${JOURNAL_PATH}`, + ); + } + await assertNodeIdentities( + product, + workspace, + arm.preIdentities, + "staging premise — the pre-move enumeration is exactly the staged " + + "node set (SPEC 11, 1.5)", + `${context} pre-move`, + ); + await assertEdges( + product, + workspace, + arm.edges(arm.origin), + `${context} pre-move`, + ); + + // The preview side (TEST-SPEC T6.5-1): the `import-specifier-rewrite` + // ranges — in current, pre-operation coordinates (SPEC 6.6, 1.7) — are + // read by running the preview on a copy of the fixture, staged and + // built like the original, whose pre-move state no preview then + // touches; the preview's `mapping` and complete `files` are pinned + // form-exact meanwhile (6.6, 12.7; T6.6-4 (c)). + const previewRanges = await withWorkspace( FULL_CONFIG, - { - [F1_OTHER]: F1_OTHER_SOURCE, - [F1_CORE]: F1_CORE_SOURCE, - [F1_REFS]: F1_REFS_SOURCE, - [F1_APP]: F1_APP_SOURCE, - }, - async (workspace) => { + arm.files, + async (copy) => { await buildOk( product, - workspace, - "T6.5-1 `build` over the staged workspace", + copy, + `${context} \`build\` over the copy staged for the preview`, ); - - // Staging premises: no journal before the first journaled operation - // (SPEC 6.1); the pre-move node and edge inventories are exactly as - // staged, so the post-move assertions witness a real transition. - const journalBefore = await workspace.kind(JOURNAL_PATH); - if (journalBefore !== "absent") { - fail( - `T6.5-1: staging premise — no journal file exists before the ` + - `first journaled operation (SPEC 6.1); found ${journalBefore} ` + - `at ${JOURNAL_PATH}`, - ); - } - await assertNodeIdentities( - product, - workspace, - F1_PRE_IDENTITIES, - "staging premise — the pre-move enumeration is exactly the staged " + - "node set (SPEC 11, 1.5)", - "T6.5-1 pre-move", + const label = `${context} \`${moveArgv.join(" ")} --preview --json\` on the copy`; + const report = decodePreviewReport( + await runJson( + product, + copy, + [...moveArgv, "--preview", "--json"], + label, + ), + label, ); - await assertF1Edges(product, workspace, F1_CORE, "T6.5-1 pre-move"); - - await expectExit( - product, - workspace, - ["move", F1_CORE, F1_MOVED], - 0, - "T6.5-1 file-form `move specs/Core.mdx specs/sub/Moved.mdx`", + assertSameJson( + report.findings, + [], + `${label}: the preview of the valid file-form move completes ` + + `with findings [] (SPEC 6.6)`, ); - - // The file was relocated. - const originKind = await workspace.kind(F1_CORE); - if (originKind !== "absent") { + if (report.mapping === null || report.files === null) { fail( - `T6.5-1: the origin file ${F1_CORE} must be gone after the ` + - `file-form move (SPEC 6.5); found ${originKind}`, + `${label}: the completed preview reports its plan — \`mapping\` ` + + `and \`files\` non-null (SPEC 6.6, 12.7)`, ); } - - // Specifier rewrites (module header, H-4): the stale quoted spelling - // is gone and a spelling naming the resolving module remains — the - // moved file's own import, a spec file's import, a code file's - // import (SPEC 6.5). - const movedText = await readSourceText( - workspace, - F1_MOVED, - "T6.5-1 rewrite check", + assertAppliedMapping( + report.mapping, + arm.mapping, + `${label}: \`mapping\` is one entry per node of the moved file — ` + + `the root's bare-path identities, \`from\` the old path and ` + + `\`to\` the new, and every section's — ordered by \`from\` ` + + `bytes (SPEC 6.6, 12.7)`, ); - assertLacks( - movedText, - F1_MOVED, - '"./Other.xspec"', - "the moved file's own import specifiers are rewritten for its new " + - "directory (SPEC 6.5); from specs/sub/ the old spelling no " + - "longer resolves", - "T6.5-1 rewrite check", - ); - assertContains( - movedText, - F1_MOVED, - "Other.xspec", - "every resolving specifier for specs/Other.mdx ends in " + - "`Other.xspec` (SPEC 2.1)", - "T6.5-1 rewrite check", + assertPreviewFiles(report.files, arm.previewFiles, label); + const files = report.files; + return new Map( + arm.rewrites.map( + (rewrite) => + [ + rewrite.pre, + specifierRewriteRanges(files, rewrite.pre, label), + ] as const, + ), ); - for (const [rel, stale] of [ - [F1_REFS, '"./Core.xspec"'], - [F1_APP, '"../specs/Core.xspec"'], - ] as const) { - const text = await readSourceText( - workspace, - rel, - "T6.5-1 rewrite check", - ); - assertLacks( - text, - rel, - stale, - "imports of the moved file's generated module are rewritten so " + - "all references continue to resolve (SPEC 6.5)", - "T6.5-1 rewrite check", - ); - assertContains( - text, - rel, - "Moved.xspec", - "every resolving specifier for the moved module ends in " + - "`Moved.xspec` (SPEC 2.1, 4)", - "T6.5-1 rewrite check", - ); - } + }, + ); - // Mapping appended to the journal (SPEC 6.5, 6.1; SUITE-21 - // operationalization, content opaque per H-4). - await assertJournalHoldsOneEntry(workspace, "T6.5-1 after the move"); + // The pre-move bytes of every staged file, taken from the original just + // before the move (staging premise: as staged — `build` rewrites no + // source, T6.1-1). + const preMoveBytes = new Map<string, Uint8Array>(); + for (const rel of ["xspec.config.ts", ...Object.keys(arm.files)]) { + preMoveBytes.set(rel, await workspace.readBytes(rel)); + } + for (const rewrite of arm.rewrites) { + assertBytesEqual( + preMoveBytes.get(rewrite.pre)!, + rewrite.source, + `${context}: staging premise — ${rewrite.pre} holds its staged ` + + `source before the move`, + ); + } - // Everything resolves and no stale output remains: `check` exit 0 - // immediately after the move (SPEC 6.5, 12.2, 14.10). - await expectExit( - product, - workspace, - ["check"], - 0, - "T6.5-1 `check` immediately after the file-form move — all " + - "rewritten imports and references resolve and the finishing " + - "regeneration left no staleness (SPEC 6.5, 12.2, 14.10)", - ); + // The command's own report is the applied mapping — every identity pair + // the operation journaled, the preview's `mapping` (SPEC 6.5: both forms + // report as rename does; 6.4, 6.6) — carried in JSON in the form-exact + // performed-operation document of 12.7 (H-3; T6.4-1's protocol: exactly + // `{"findings", "mapping"}`, pairs ordered by `from` bytes). 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, + [...moveArgv, "--json"], + `${context} file-form \`${moveArgv.join(" ")} --json\``, + ); + assertAppliedMapping( + decodeAppliedMappingReport(moveReport, context), + arm.mapping, + `${context}: 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)`, + ); - // IDs unchanged; identities change file part only (query-asserted). - await assertNodeIdentities( - product, - workspace, - F1_POST_IDENTITIES, - "after the file-form move, every moved identity keeps its ID and " + - "changes only its file part; every other identity is unchanged " + - "(SPEC 6.5, 1.5)", - "T6.5-1 post-move", + // The file was relocated. + const originKind = await workspace.kind(arm.origin); + if (originKind !== "absent") { + fail( + `${context}: the origin file ${arm.origin} must be gone after the ` + + `file-form move (SPEC 6.5); found ${originKind}`, + ); + } + const destinationKind = await workspace.kind(arm.destination); + if (destinationKind !== "file") { + fail( + `${context}: expected a plain file at ${arm.destination} after the ` + + `file-form move (SPEC 6.5, 13.4); found ${destinationKind}`, + ); + } + + // The specifier-rewrite byte contract, the operation side (TEST-SPEC + // T6.5-1; SPEC 6.5): each rewritten file — the moved file under its new + // path, the importing `.mdx`, the importing `.ts` — is its pre-move + // bytes with exactly the previewed ranges replaced by the expected + // literal: the kept delimiters around the canonical relative spelling. + for (const rewrite of arm.rewrites) { + const kind = await workspace.kind(rewrite.post); + if (kind !== "file") { + fail( + `${context} rewrite check: expected a plain file at ` + + `${rewrite.post} after the move (SPEC 6.5, 13.4); found ${kind}`, ); - await assertF1Edges(product, workspace, F1_MOVED, "T6.5-1 post-move"); + } + assertSpecifierRewriteByteContract( + { + rel: rewrite.post, + pre: preMoveBytes.get(rewrite.pre)!, + post: await workspace.readBytes(rewrite.post), + rewrites: previewRanges + .get(rewrite.pre)! + .map((range) => ({ range, literal: rewrite.after })), + }, + `${context} rewrite check`, + ); + } + // A moved file whose own specifiers still designate their source from + // its new directory lands byte-identical: neither rewritten nor + // reported (SPEC 6.5, 2.1). + if (!arm.rewrites.some((rewrite) => rewrite.pre === arm.origin)) { + await assertFileBytes( + workspace.path(arm.destination), + preMoveBytes.get(arm.origin)!, + `${context}: ${arm.destination} — relocated within its own ` + + `directory, the moved file's own specifiers (the canonical ` + + `\`./C.xspec\` and the non-canonical \`.//C.xspec\`) still ` + + `designate \`C.mdx\` from there, so the file must land ` + + `byte-identical to its pre-move bytes: a specifier is rewritten ` + + `exactly when its characters would no longer designate the ` + + `source (SPEC 6.5, 2.1)`, + ); + } + // Nothing else changed: a file the move rewrites nothing in is + // byte-identical to its pre-move bytes (SPEC 6.5). + for (const rel of arm.untouched) { + await assertFileBytes( + workspace.path(rel), + preMoveBytes.get(rel)!, + `${context}: ${rel} — a file the file-form move rewrites nothing ` + + `in must be byte-identical to its pre-move bytes (SPEC 6.5)`, + ); + } - // Finishing regeneration as T6.4-7 (H-6 two-directory protocol): - // seed a fresh workspace with the post-move sources, configuration, - // and journal; `build`; compare the whole roots byte-for-byte. - const fresh = await TestWorkspace.create(); - try { - for (const rel of F1_SEED_FILES) { - const kind = await workspace.kind(rel); - if (kind !== "file") { - fail( - `T6.5-1: expected ${rel} as a plain file in the moved ` + - `workspace to seed the fresh-build directory (SPEC 6.5, ` + - `6.1, 13.4); found ${kind}`, - ); - } - await fresh.file(rel, await workspace.readBytes(rel)); - } - await buildOk( - product, - fresh, - "T6.5-1 fresh `build` over the post-move sources", - ); - await assertDirectoriesEqual( - workspace.root, - fresh.root, - "T6.5-1: the moved workspace vs a fresh `build` of the post-move " + - "sources — generated modules, Markdown output, and graph data " + - "must be byte-identical (SPEC 6.5: a successful move " + - "regenerates derived files as rename does; 6.4, 12.0 " + - "determinism; H-4/H-6, normalizing nothing)", - ); - } finally { - await fresh.dispose(); - } - }, + // Mapping appended to the journal (SPEC 6.5, 6.1; SUITE-21 + // operationalization, content opaque per H-4). + await assertJournalHoldsOneEntry(workspace, `${context} after the move`); + + // Everything resolves and no stale output remains: `check` exit 0 + // immediately after the move (SPEC 6.5, 12.2, 14.10) — in the + // five-declaration arm with no finding, its `--json` report exactly + // `{"findings": []}` (TEST-SPEC T6.5-1; 12.7). + const checkContext = + `${context} \`check\` immediately after the file-form move — all ` + + `rewritten imports and references resolve and the finishing ` + + `regeneration left no staleness (SPEC 6.5, 12.2, 14.10, 14.15)`; + if (arm.findingFreeClose) { + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + checkContext, + ); + } else { + await expectExit(product, workspace, ["check"], 0, checkContext); + } + + // IDs unchanged; identities change file part only (query-asserted). + await assertNodeIdentities( + product, + workspace, + arm.postIdentities, + "after the file-form move, every moved identity keeps its ID and " + + "changes only its file part; every other identity is unchanged " + + "(SPEC 6.5, 1.5)", + `${context} post-move`, + ); + await assertEdges( + product, + workspace, + arm.edges(arm.destination), + `${context} post-move`, + ); + + if (fullProtocol) { + await assertFreshBuildAgrees(product, workspace, arm, context); + } + + // The five-declaration arm's close (TEST-SPEC T6.5-1): `build` exits 0 + // with no finding after the move as well — run last, so that no arm's + // fresh-build compare ever follows a `build` of the moved workspace. + if (arm.findingFreeClose) { + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `${context} \`build\` after the file-form move — every rewritten ` + + `specifier designates a discovered source (SPEC 6.5, 2.1, 4, ` + + `14.15)`, + ); + } + }); +} + +/** + * Finishing regeneration as T6.4-7 (H-6 two-directory protocol): seed a + * fresh workspace with the post-move sources, configuration, and journal; + * `build`; compare the whole roots byte-for-byte. + */ +async function assertFreshBuildAgrees( + product: ProductBinding, + workspace: TestWorkspace, + arm: FileMoveArm, + context: string, +): Promise<void> { + const fresh = await TestWorkspace.create(); + try { + for (const rel of arm.seedFiles) { + const kind = await workspace.kind(rel); + if (kind !== "file") { + fail( + `${context}: expected ${rel} as a plain file in the moved ` + + `workspace to seed the fresh-build directory (SPEC 6.5, 6.1, ` + + `13.4); found ${kind}`, + ); + } + await fresh.copyFrom(workspace, rel); + } + await buildOk( + product, + fresh, + `${context} fresh \`build\` over the post-move sources`, ); + await assertDirectoriesEqual( + workspace.root, + fresh.root, + `${context}: the moved workspace vs a fresh \`build\` of the ` + + `post-move sources — generated modules, Markdown output, and ` + + `graph data must be byte-identical (SPEC 6.5: a successful move ` + + `regenerates derived files as rename does; 6.4, 12.0 ` + + `determinism; H-4/H-6, normalizing nothing)`, + ); + } finally { + await fresh.dispose(); + } +} + +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 and reported as the form-exact performed-operation document; the finishing regeneration is byte-identical to a fresh build — the specifier-rewrite byte contract of 6.5 byte-asserted, the `import-specifier-rewrite` ranges read from a preview on a copy whose `files` is pinned form-exact, over a move into a subdirectory (the importer's `\"./A.xspec\"` becomes `\"./sub/A.xspec\"`, the moved file's own `'./C.xspec'` `'../C.xspec'` with its single quotes kept), out of one (an ascent lost), and across sibling directories (`\"../x/A.xspec\"`, ascents before descents) — each rewritten literal exactly the kept delimiters around the canonical relative spelling and every other byte unchanged, so a `.` segment, a missing `./` prefix, a stale `..`, a switched quote style, or any rewrite beyond the literals fails — and a relocation within the file's own directory (`specs/A.mdx` → `specs/A2.mdx`) that leaves its canonical `./C.xspec` and non-canonical `.//C.xspec` imports byte-untouched and unreported while the importer's `./A.xspec` becomes `./A2.xspec`, the preview's `files` exactly the relocation entry and the importer's rewrite — and, under `move specs/A.mdx specs/sub/A.mdx`, five import declarations recording no edge or occurrence (the moved file's own `import C from \"./C.xspec\"` and the spec importer's `import A from \"./A.xspec\"`, neither binding referenced, and a code source's `import type T`, `import { type text as t }`, and side-effect `import` of `\"../specs/A.xspec\"`) each rewritten under the same byte contract, its quote style kept, whatever uses its bindings — the preview's `files` the relocation entry and exactly five `import-specifier-rewrite` edits, each spanning its literal — and `check` and `build` exiting 0 with no finding afterward, so a product collecting the specifiers it rewrites from recorded edges, occurrences, or used bindings fails", + run: async (product) => { + for (let i = 0; i < FILE_MOVE_ARMS.length; i += 1) { + await runFileMoveArm(product, FILE_MOVE_ARMS[i]!, i === 0); + } }, }); @@ -756,32 +2038,143 @@ const T6_5_1 = defineProductTest({ // it is dropped with its terminator (rule of 3), while the blank line above // it — already empty in the source — is kept (SPEC 6.5, 3). const X2_ORIGIN = "specs/A.mdx"; -const X2_ORIGIN_BEFORE = [ - '<S id="a">', - "Alpha holder.", - "", - '<S id="a.mv">', - "Moved text.", - "</S>", - "</S>", - "", -].join("\n"); +const X2_ORIGIN_BEFORE = stagedMdx( + "T6.5-2 specs/A.mdx (the shared origin)", + [ + '<S id="a">', + "Alpha holder.", + "", + '<S id="a.mv">', + "Moved text.", + "</S>", + "</S>", + "", + ].join("\n"), +); const X2_ORIGIN_AFTER = ['<S id="a">', "Alpha holder.", "", "</S>", ""].join( "\n", ); +// The fourth geometry's origin: the moved construct `a.m` is a single-line +// in-line section holding prose outside its tags, alone on its line — a +// paragraph line (`x</S>` follows its opening tag on the line, so the flow +// attempt fails and both tags stand in text position). Deleting its own +// characters leaves that line empty purely by the deletion, so it drops +// with its terminator (rule of 3), leaving exactly `X2_ORIGIN_AFTER` +// (SPEC 6.5, 3). +const X2_INLINE_ORIGIN_BEFORE = stagedMdx( + "T6.5-2 mid-line insertion arm specs/A.mdx (the in-line origin)", + ['<S id="a">', "Alpha holder.", "", '<S id="a.m">x</S>', "</S>", ""].join( + "\n", + ), +); + // Uninvolved bystander, asserted byte-identical in every arm: beyond the // stated edits, the identity and reference rewrites, and the finishing // regeneration, a move changes no bytes (SPEC 6.5). const X2_ZED = "specs/Zed.mdx"; const X2_ZED_SOURCE = ['<S id="zed">', "Zed text.", "</S>", ""].join("\n"); +const X2_ZED_STAGED = stagedMdx( + "T6.5-2 specs/Zed.mdx (the uninvolved bystander)", + X2_ZED_SOURCE, +); + +// The arms' stagings: the first arm's workspace is the body's first, every +// later arm follows its invocations, so each staged `.mdx` source is a +// ledger record (S-9's before-any-product clause; helpers/staged-mdx.ts), +// the first arm's converted uniformly; the Beta holder two arms stage at +// `specs/B.mdx` — the line-start and the self-closing-section arms — is one +// record (identical bytes). The `expected` texts stay strings. +const X2_B_HOLDER = stagedMdx( + "T6.5-2 line-start and self-closing-section arms specs/B.mdx", + ['<S id="b">', "Beta holder.", "</S>", ""].join("\n"), +); /** One byte-exact arm: staged files, the move argv, expected file bytes. */ interface ByteExactArm { readonly name: string; - readonly files: Readonly<Record<string, string>>; + /** The staged configuration; `SPECS_ONLY_CONFIG` unless the arm reads compiled Markdown. */ + readonly config?: StagedTs; + readonly files: Readonly<Record<string, InitialFileContents>>; readonly argv: readonly string[]; readonly expected: Readonly<Record<string, string>>; + /** + * Compiled Markdown asserted after a post-move `build`: the moved-to + * file's output under SPEC 3, compiled over the composed source, at 7.3's + * default destination beside the source (`specs/G.mdx` → `specs/G.md`). + */ + readonly markdown?: Readonly<Record<string, string>>; +} + +// The terminator-kind arms' characters (SPEC 3), spelled from code points +// (tool-safe): a CRLF pair is one terminator, a lone CR one too, and U+000A +// is the terminator every move inserts, whatever terminators the file holds. +const X2_CR = String.fromCodePoint(0x000d); +const X2_LF = String.fromCodePoint(0x000a); +const X2_CRLF = X2_CR + X2_LF; + +/** + * T6.5-2's terminator-kind arms for one kind T (SPEC 3, 6.5), origin and + * target sharing it: the origin `specs/o.mdx` = `<S id="a">x</S>`, T, + * `<S id="m">`, T, `y`, T, `</S>`, T, moved into parent `p` of + * `specs/t.mdx` = `<S id="p">`, T, `x`, T, `</S>`, T, and to top-level `n` + * at the end of `specs/u.mdx` = `<S id="p">x</S>`, T. The deletion empties + * the moved construct's joined line, dropped with its whole terminator; the + * moved text carries its own terminators and is followed by one U+000A; the + * insertion point, following a T, is a line start (none added before it); + * every terminator the edits leave in place is kept byte-for-byte. A product + * matching the file's terminator style (T after the moved text) fails, and + * one splitting lines on U+000A alone, which in the lone-CR arms keeps the + * emptied origin line and adds a U+000A before the moved text. Called at + * module load: the records are the ledger's (S-9; later arms of the body). + */ +function x2TerminatorKindArms( + kind: string, + terminator: string, +): readonly ByteExactArm[] { + const t = terminator; + const origin = stagedMdx( + `T6.5-2 ${kind} terminator-kind arms specs/o.mdx (the shared origin)`, + ['<S id="a">x</S>', '<S id="m">', "y", "</S>", ""].join(t), + ); + const originAfter = '<S id="a">x</S>' + t; + return [ + { + name: `${kind} terminators, into parent p: the emptied origin line dropped with its terminator, U+000A after the moved text, none before it`, + files: { + "specs/o.mdx": origin, + "specs/t.mdx": stagedMdx( + `T6.5-2 ${kind} terminator-kind arm specs/t.mdx (into parent p)`, + ['<S id="p">', "x", "</S>", ""].join(t), + ), + }, + argv: ["move", "specs/o.mdx#m", "specs/t.mdx#p.m"], + expected: { + "specs/o.mdx": originAfter, + "specs/t.mdx": + ['<S id="p">', "x", '<S id="p.m">', "y", "</S>"].join(t) + + X2_LF + + "</S>" + + t, + }, + }, + { + name: `${kind} terminators, end of file at top-level n: U+000A after the moved text, none before it`, + files: { + "specs/o.mdx": origin, + "specs/u.mdx": stagedMdx( + `T6.5-2 ${kind} terminator-kind arm specs/u.mdx (end of file)`, + '<S id="p">x</S>' + t, + ), + }, + argv: ["move", "specs/o.mdx#m", "specs/u.mdx#n"], + expected: { + "specs/o.mdx": originAfter, + "specs/u.mdx": + ['<S id="p">x</S>', '<S id="n">', "y", "</S>"].join(t) + X2_LF, + }, + }, + ]; } const X2_ARMS: readonly ByteExactArm[] = [ @@ -794,7 +2187,7 @@ const X2_ARMS: readonly ByteExactArm[] = [ name: "line-start insertion + origin line-drop", files: { [X2_ORIGIN]: X2_ORIGIN_BEFORE, - "specs/B.mdx": ['<S id="b">', "Beta holder.", "</S>", ""].join("\n"), + "specs/B.mdx": X2_B_HOLDER, }, argv: ["move", "specs/A.mdx#a.mv", "specs/B.mdx#b.mv"], expected: { @@ -811,25 +2204,80 @@ const X2_ARMS: readonly ByteExactArm[] = [ }, }, { - // The target parent's closing tag is mid-line (preceded by `.`), so the - // insertion is preceded by one U+000A as well as followed by one. - name: "mid-line insertion point", + // Sibling of the line-start arm: the target parent's closing tag is + // indented on its line, ` </S>` after two spaces — a flow-position tag + // still (SPEC 14.20: the flow attempt consumes the line prefix). The + // insertion point, immediately before the tag, is preceded by the two + // spaces, so it is no line start (a terminator immediately precedes it + // exactly when it is): one U+000A is added, leaving the spaces a line of + // their own before the moved text, and the closing tag follows the + // moved text's own terminator at a line start. The composed form derives + // (S-9: the whitespace-only line ends the holder's paragraph; the moved + // text's tags are flow-position tags). In the compiled Markdown the + // two-space line is kept with its terminator: no non-whitespace stood on + // it, so 3's drop rule — a line that contained non-whitespace left + // empty or whitespace-only purely by removals — does not reach it. A + // product reading T3-3's `alone on its line` as `at a line start`, and + // so inserting at the tag's line start without a terminator, fails. + name: "indented closing tag: terminator added, the spaces left a line of their own", + config: SPECS_MD_CONFIG, files: { [X2_ORIGIN]: X2_ORIGIN_BEFORE, - "specs/C.mdx": '<S id="c">Gamma holder.</S>\n', + "specs/G.mdx": stagedMdx( + "T6.5-2 indented-closing-tag arm specs/G.mdx", + ['<S id="g">', "Golf holder.", " </S>", ""].join("\n"), + ), }, - argv: ["move", "specs/A.mdx#a.mv", "specs/C.mdx#c.mv"], + argv: ["move", "specs/A.mdx#a.mv", "specs/G.mdx#g.mv"], expected: { [X2_ORIGIN]: X2_ORIGIN_AFTER, - "specs/C.mdx": [ - '<S id="c">Gamma holder.', - '<S id="c.mv">', + "specs/G.mdx": [ + '<S id="g">', + "Golf holder.", + " ", + '<S id="g.mv">', "Moved text.", "</S>", "</S>", "", ].join("\n"), }, + markdown: { + "specs/G.md": ["Golf holder.", " ", "Moved text.", ""].join("\n"), + }, + }, + { + // The fourth geometry: before a closing tag sharing its line with + // preceding content — the text-position parent `foo <S id="p">bar</S> + // baz` receiving a single-line in-line section holding prose outside its + // tags, `<S id="a.m">x</S>`. The insertion point, preceded by `bar`, is + // no line start, so one U+000A precedes the moved text as well as + // following it; the moved text's line is then a paragraph continuation + // at the destination (the prose outside its tags denies the flow + // attempt), so the composed form derives (S-9). A flow-form section + // here — its tags alone on their lines — would interrupt the paragraph + // holding `p`'s opening tag and leave `p` unclosed (14.20, T3-3's + // constraint): T6.5-16(c)'s refused shape, which this arm's former + // staging (`<S id="c">Gamma holder.</S>` receiving `a.mv`) pinned as a + // performed move's result. + name: "mid-line insertion point: a text-position parent receiving an in-line section", + files: { + [X2_ORIGIN]: X2_INLINE_ORIGIN_BEFORE, + "specs/C.mdx": stagedMdx( + "T6.5-2 mid-line insertion arm specs/C.mdx", + 'foo <S id="p">bar</S> baz\n', + ), + }, + argv: ["move", "specs/A.mdx#a.m", "specs/C.mdx#p.m"], + expected: { + [X2_ORIGIN]: X2_ORIGIN_AFTER, + "specs/C.mdx": [ + 'foo <S id="p">bar', + '<S id="p.m">x</S>', + "</S> baz", + "", + ].join("\n"), + }, }, { // Top-level `new-id` into an absent target: the file is created, empty @@ -850,7 +2298,10 @@ const X2_ARMS: readonly ByteExactArm[] = [ name: "end-of-file insertion after a terminated final line", files: { [X2_ORIGIN]: X2_ORIGIN_BEFORE, - "specs/D.mdx": ['<S id="d">', "Delta text.", "</S>", ""].join("\n"), + "specs/D.mdx": stagedMdx( + "T6.5-2 terminated-final-line arm specs/D.mdx", + ['<S id="d">', "Delta text.", "</S>", ""].join("\n"), + ), }, argv: ["move", "specs/A.mdx#a.mv", "specs/D.mdx#dm"], expected: { @@ -873,7 +2324,10 @@ const X2_ARMS: readonly ByteExactArm[] = [ name: "end-of-file insertion after an unterminated final line", files: { [X2_ORIGIN]: X2_ORIGIN_BEFORE, - "specs/E.mdx": ['<S id="e">', "Echo text.", "</S>"].join("\n"), + "specs/E.mdx": stagedMdx( + "T6.5-2 unterminated-final-line arm specs/E.mdx", + ['<S id="e">', "Echo text.", "</S>"].join("\n"), + ), }, argv: ["move", "specs/A.mdx#a.mv", "specs/E.mdx#em"], expected: { @@ -895,14 +2349,13 @@ const X2_ARMS: readonly ByteExactArm[] = [ // destination, re-identified; its origin line is dropped (rule of 3). name: "self-closing moved section", files: { - [X2_ORIGIN]: [ - '<S id="a">', - "Alpha holder.", - '<S id="a.todo" />', - "</S>", - "", - ].join("\n"), - "specs/B.mdx": ['<S id="b">', "Beta holder.", "</S>", ""].join("\n"), + [X2_ORIGIN]: stagedMdx( + "T6.5-2 self-closing-section arm specs/A.mdx", + ['<S id="a">', "Alpha holder.", '<S id="a.todo" />', "</S>", ""].join( + "\n", + ), + ), + "specs/B.mdx": X2_B_HOLDER, }, argv: ["move", "specs/A.mdx#a.todo", "specs/B.mdx#b.todo"], expected: { @@ -926,7 +2379,10 @@ const X2_ARMS: readonly ByteExactArm[] = [ name: "self-closing target parent rewritten to paired form", files: { [X2_ORIGIN]: X2_ORIGIN_BEFORE, - "specs/P.mdx": '<Spec id="p" />\n', + "specs/P.mdx": stagedMdx( + "T6.5-2 self-closing-target-parent arm specs/P.mdx", + '<Spec id="p" />\n', + ), }, argv: ["move", "specs/A.mdx#a.mv", "specs/P.mdx#p.mv"], expected: { @@ -941,17 +2397,40 @@ const X2_ARMS: readonly ByteExactArm[] = [ ].join("\n"), }, }, + // Terminator-kind arms (SPEC 3, 6.5): one per kind, CRLF and lone CR, in + // both geometries — into parent `p` and at the end of the file at `n`. + ...x2TerminatorKindArms("CRLF", X2_CRLF), + ...x2TerminatorKindArms("lone CR", X2_CR), ]; +/** + * Every composed (post-move) MDX text T6.5-2 asserts, for the S-9 self-test + * (test/self/s9-fixture-well-formedness.test.ts): T6.5-2 stages geometries + * whose composed form derives, so an expectation here the stock MDX 3 parser + * rejects would pin a text SPEC 6.5 refuses (`refused-invalid-rewrite`, + * T6.5-16) as a performed move's result. The staged pre-move files are judged + * by the workspace builder as they are staged. + */ +export const X2_COMPOSED_FORMS: ReadonlyArray< + readonly [name: string, source: string] +> = X2_ARMS.flatMap((arm) => + Object.entries(arm.expected) + .filter(([rel]) => rel.endsWith(".mdx")) + .map(([rel, source]): readonly [string, string] => [ + `T6.5-2 (${arm.name}) ${rel} after the move`, + source, + ]), +); + const T6_5_2 = defineProductTest({ id: "T6.5-2", title: - "section form text edits, byte-exact: moved text spans the opening tag's first character through the 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; self-closing sections move as exactly their tag's characters and a self-closing target parent is first rewritten to paired form; no other byte changes (SPEC 6.5, 3, 1.1)", + "section form text edits, byte-exact: moved text spans the opening tag's first character through the 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 the insertion point is not at the start of a line, judged over the composed text — staged over four geometries: a closing tag alone on its line (no terminator added), with its sibling indented on its line, ` </S>` (one added, the two spaces left a line of their own, kept in the compiled Markdown); the end of a file with a final terminator (none added) and without one (one added); and a closing tag sharing its line with preceding content, a text-position parent receiving a single-line in-line section whose line is a paragraph continuation there (one added); target file created when absent; self-closing sections move as exactly their tag's characters and a self-closing target parent is first rewritten to paired form; terminator-kind arms, CRLF and lone CR, origin and target sharing the kind, into a parent and at the end of a file: every terminator the move inserts is U+000A, a line start and the drop rule are judged by 3's terminators (the emptied origin line dropped with its whole terminator, none added before the moved text), and every kept terminator stays byte-for-byte; no other byte changes (SPEC 6.5, 3, 1.1, 14.20)", run: async (product) => { for (const arm of X2_ARMS) { await withWorkspace( - SPECS_ONLY_CONFIG, - { ...arm.files, [X2_ZED]: X2_ZED_SOURCE }, + arm.config ?? SPECS_ONLY_CONFIG, + { ...arm.files, [X2_ZED]: X2_ZED_STAGED }, async (workspace) => { const context = `T6.5-2 (${arm.name})`; await expectExit( @@ -977,6 +2456,27 @@ const T6_5_2 = defineProductTest({ `and the finishing regeneration, a move changes no bytes ` + `(SPEC 6.5)`, ); + if (arm.markdown !== undefined) { + await expectExit( + product, + workspace, + ["build"], + 0, + `${context}: \`build\` over the moved-to workspace, every ` + + `source well-formed (SPEC 14.20) — its compiled Markdown is ` + + `asserted next`, + ); + for (const [rel, bytes] of Object.entries(arm.markdown)) { + await assertFileBytes( + workspace.path(rel), + bytes, + `${context}: ${rel}, the compiled Markdown of the composed ` + + `source — a line that held no non-whitespace is kept with ` + + `its terminator; 3's drop rule reaches only lines left ` + + `empty or whitespace-only purely by removals (SPEC 3, 7.3)`, + ); + } + } }, ); } @@ -1009,45 +2509,62 @@ const R3_SPARE = "specs/Spare.mdx"; const R3_ORIGIN = "specs/Origin.mdx"; const R3_TARGET = "specs/Target.mdx"; -const R3_KEEP_SOURCE = ['<S id="keep">', "Keep text.", "</S>", ""].join("\n"); -const R3_SPARE_SOURCE = ['<S id="sp">', "Spare text.", "</S>", ""].join("\n"); +// The determinism directories are the body's first workspaces (both created +// before the first run); the third-file arms follow, staging R3_FILES again +// with their own third file, so every `.mdx` source is a ledger record (S-9's +// before-any-product clause; helpers/staged-mdx.ts), the first workspaces' +// converted uniformly. +const R3_KEEP_SOURCE = stagedMdx( + "T6.5-3 specs/Keep.mdx", + ['<S id="keep">', "Keep text.", "</S>", ""].join("\n"), +); +const R3_SPARE_SOURCE = stagedMdx( + "T6.5-3 specs/Spare.mdx", + ['<S id="sp">', "Spare text.", "</S>", ""].join("\n"), +); -const R3_ORIGIN_SOURCE = [ - 'import Keep from "./Keep.xspec"', - "", - '<S id="org">', - "Origin holder text.", - "", - '<S id="org.mv" d={Keep.keep}>', - "Moved root text.", - "", - '<S id="org.mv.k1">', - "Moved first kid.", - "</S>", - "", - '<S id="org.mv.k2" d={"org.mv.k1"}>', - "Moved second kid.", - "</S>", - "</S>", - "", - '<S id="org.usemv" d={"org.mv"}>', - 'Uses the moved node: {text("org.mv.k1")}', - "</S>", - "</S>", - "", -].join("\n"); +const R3_ORIGIN_SOURCE = stagedMdx( + "T6.5-3 specs/Origin.mdx", + [ + 'import Keep from "./Keep.xspec"', + "", + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv" d={Keep.keep}>', + "Moved root text.", + "", + '<S id="org.mv.k1">', + "Moved first kid.", + "</S>", + "", + '<S id="org.mv.k2" d={"org.mv.k1"}>', + "Moved second kid.", + "</S>", + "</S>", + "", + '<S id="org.usemv" d={"org.mv"}>', + 'Uses the moved node: {text("org.mv.k1")}', + "</S>", + "</S>", + "", + ].join("\n"), +); -const R3_TARGET_SOURCE = [ - 'import Org from "./Origin.xspec"', - 'import Keep from "./Spare.xspec"', - "", - '<S id="tgt" d={Org.org.mv}>', - "Target text: {text(Org.org.mv.k1)}", - "</S>", - "", -].join("\n"); +const R3_TARGET_SOURCE = stagedMdx( + "T6.5-3 specs/Target.mdx", + [ + 'import Org from "./Origin.xspec"', + 'import Keep from "./Spare.xspec"', + "", + '<S id="tgt" d={Org.org.mv}>', + "Target text: {text(Org.org.mv.k1)}", + "</S>", + "", + ].join("\n"), +); -const R3_FILES: Readonly<Record<string, string>> = { +const R3_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": SPECS_MD_CONFIG, [R3_KEEP]: R3_KEEP_SOURCE, [R3_SPARE]: R3_SPARE_SOURCE, @@ -1055,10 +2572,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. @@ -1086,10 +2607,248 @@ const R3_SEED_FILES = [ JOURNAL_PATH, ] as const; +// Third-file arms (TEST-SPEC T6.5-3; SPEC 6.5: "all references across the +// workspace are rewritten"): a spec source that is neither origin nor target +// imports the origin module and references the moved node through it (a `d` +// chain). After the move that reference is rewritten to the target module +// under an import added to the third file. The added declaration's +// identifier and insertion offset are 6.5's latitude, so the file's bytes +// are asserted with T6.5-8's discipline (`assertAddedImportInsertion`): its +// expected post-move bytes are composed from the rules of 6.4/6.5 and 3 +// WITHOUT the added import, the fresh identifier read off the rewritten +// reference, and the single inserted run isolated by diff must be +// byte-exactly 6.5's spelling, `import <X> from "./Target.xspec"`, followed +// by U+000A at a line-start offset — the file holds one (its end after the +// final terminator), which 6.5 takes over any other (T6.5-8). The origin +// import's fate splits the arms (6.5: +// removed exactly when its binding had references and the rewrite leaves it +// with none): +// - (a) `Org`'s only reference was to the moved node → the own-line +// declaration's characters are deleted and its emptied line dropped with +// its terminator (6.5, 3); the blank line after it was blank before the +// deletion, so it stays — the composed file begins with that U+000A. +// - (b) `th2` keeps `d={Org.org}` through the binding → the declaration +// survives byte-for-byte; only the moved reference is rewritten. +// The rewritten reference keeps its access form — `Org.org.mv` becomes +// `<fresh>.tm`, dot access for the identifier-valid segment (6.4) — and the +// fresh binding may not collide with `Org` where it stays, nor be `S`, +// `Spec`, or `text` (2.1); `check` would fail either, but the arms name the +// collision first. +const R3_THIRD = "specs/Third.mdx"; +const R3_TARGET_MODULE = "specs/Target.xspec"; + +const R3_THIRD_A_SOURCE = stagedMdx( + "T6.5-3 third-file arm (a) specs/Third.mdx", + [ + 'import Org from "./Origin.xspec"', + "", + '<S id="th" d={Org.org.mv}>', + "Third text.", + "</S>", + "", + ].join("\n"), +); + +/** Arm (a)'s expected post-move bytes without the added import (6.5, 3). */ +const R3_THIRD_A_BASE = (root: string): string => + ["", `<S id="th" d={${root}.tm}>`, "Third text.", "</S>", ""].join("\n"); + +const R3_THIRD_B_SOURCE = stagedMdx( + "T6.5-3 third-file arm (b) specs/Third.mdx", + [ + 'import Org from "./Origin.xspec"', + "", + '<S id="th" d={Org.org.mv}>', + "Third text.", + "</S>", + "", + '<S id="th2" d={Org.org}>', + "Third keeps the origin.", + "</S>", + "", + ].join("\n"), +); + +/** Arm (b)'s expected post-move bytes without the added import (6.5). */ +const R3_THIRD_B_BASE = (root: string): string => + [ + 'import Org from "./Origin.xspec"', + "", + `<S id="th" d={${root}.tm}>`, + "Third text.", + "</S>", + "", + '<S id="th2" d={Org.org}>', + "Third keeps the origin.", + "</S>", + "", + ].join("\n"); + +/** The section form's journaled mapping: the moved subtree, nothing else. */ +const R3_MAPPING = [ + { from: `${R3_ORIGIN}#org.mv`, to: `${R3_TARGET}#tm` }, + { from: `${R3_ORIGIN}#org.mv.k1`, to: `${R3_TARGET}#tm.k1` }, + { from: `${R3_ORIGIN}#org.mv.k2`, to: `${R3_TARGET}#tm.k2` }, +] as const; + +/** The complete post-move `depends` edge set of the four-file fixture. */ +const R3_DEPENDS_EDGES: readonly GraphEdge[] = [ + { from: `${R3_ORIGIN}#org.usemv`, to: `${R3_TARGET}#tm`, kind: "depends" }, + { from: `${R3_TARGET}#tgt`, to: `${R3_TARGET}#tm`, kind: "depends" }, + { from: `${R3_TARGET}#tm`, to: `${R3_KEEP}#keep`, kind: "depends" }, + { from: `${R3_TARGET}#tm.k2`, to: `${R3_TARGET}#tm.k1`, kind: "depends" }, +]; + +/** `<S id="th" d={<root>.tm}>` — the third file's rewritten reference. */ +const R3_THIRD_REWRITTEN = /<S id="th" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.tm\}>/g; + +/** + * The identifier the third file's rewritten reference is rooted at — the + * value-unpinned fresh binding (SPEC 6.5), read off the one place 6.4's + * pinned spelling makes it observable. + */ +function thirdFileReferenceRoot(text: string, context: string): string { + const matches = [...text.matchAll(R3_THIRD_REWRITTEN)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${R3_THIRD} must hold exactly one ` + + `\`<S id="th" d={<binding>.tm}>\` — the third file's reference to ` + + `the moved node rewritten to the target module under the new ` + + `identity, its access form kept (dot access for the ` + + `identifier-valid segment; SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * One third-file arm: stage the four-file fixture plus `Third.mdx`, run the + * identical section-form move, and assert the third file's rewrite — the + * moved reference re-rooted at a fresh binding of the target module, added + * under 6.5's line discipline; the origin import removed or kept as + * `originImportKept` says — beside the applied-mapping report, the journal + * entry, the complete `depends` edge set, and a clean `check`. + */ +async function runThirdFileArm( + product: ProductBinding, + created: TestWorkspace[], + arm: { + readonly label: string; + readonly source: StagedMdx; + readonly base: (root: string) => string; + readonly originImportKept: boolean; + readonly thirdEdges: readonly GraphEdge[]; + }, +): Promise<void> { + const context = `T6.5-3 third-file arm ${arm.label}`; + const workspace = await TestWorkspace.create({ + files: { ...R3_FILES, [R3_THIRD]: arm.source }, + }); + created.push(workspace); + const result = await runProduct(product, { + cwd: workspace.root, + argv: [...R3_MOVE_ARGV], + }); + assertExitCode( + result, + 0, + `${context} \`move specs/Origin.mdx#org.mv specs/Target.mdx#tm --json\``, + ); + assertAppliedMapping( + decodeAppliedMappingReport( + parseJsonStdout(result, `${context} report (SPEC 12.0)`), + context, + ), + [...R3_MAPPING], + `${context}: the applied mapping is exactly the moved subtree's ` + + `prefix-replaced pairs — the third file's rewrite maps no identity ` + + `(SPEC 6.5, 6.4)`, + ); + + const text = await readSourceText(workspace, R3_THIRD, context); + const root = thirdFileReferenceRoot(text, context); + if (arm.originImportKept) { + assertContains( + text, + R3_THIRD, + 'import Org from "./Origin.xspec"\n', + "the `Org` binding keeps a reference (`th2`'s `d={Org.org}`) after " + + "the rewrite, so its import stays byte-for-byte (SPEC 6.5, 2.1)", + context, + ); + if (root === "Org") { + fail( + `${context}: the added import binds \`Org\`, an identifier the ` + + `file's retained origin import already binds — an added import ` + + `binds fresh identifiers colliding with no binding already in ` + + `the file (SPEC 6.5, 2.1, 14.15)`, + ); + } + } else { + assertLacks( + text, + R3_THIRD, + "Origin.xspec", + "the `Org` binding's only reference was to the moved node, so the " + + "rewrite leaves it with none and the import is removed (SPEC 6.5, " + + "2.1)", + context, + ); + } + for (const reserved of ["S", "Spec", "text"]) { + if (root === reserved) { + fail( + `${context}: the added import binds \`${reserved}\`, a ` + + `compiler-provided name no import may bind (SPEC 2.1, 14.15)`, + ); + } + } + // Composed from the rules of 6.4/6.5 and 3 up to the two unknowns — the + // fresh identifier (now known) and the insertion offset (isolated below). + assertAddedImportInsertion( + { + rel: R3_THIRD, + base: Buffer.from(arm.base(root), "utf8"), + actual: await workspace.readBytes(R3_THIRD), + importerDir: posixPath.dirname(R3_THIRD), + expectedModule: R3_TARGET_MODULE, + identifier: root, + }, + `${context}: the third file's rewrite is its composed post-move bytes ` + + `with exactly one import of the target module added in 6.5's exact ` + + `spelling as a line of its own at a line-start offset (SPEC 6.5, ` + + `2.1, 6.4, 3; T6.5-8)`, + ); + + await assertJournalHoldsOneEntry(workspace, `${context} after the move`); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "depends", context), + [...R3_DEPENDS_EDGES, ...arm.thirdEdges], + `${context}: the complete \`depends\` edge set — the third file's ` + + `edge is reported under the moved node's new identity` + + (arm.originImportKept + ? ", its other edge through the retained origin binding unchanged" + : "") + + ` (SPEC 6.5, 5.2)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the third file's ` + + `rewritten reference and added import resolve, the fresh binding ` + + `collides with nothing (14.15), and no staleness remains (SPEC 6.5, ` + + `12.2, 14.10)`, + ); +} + const T6_5_3 = defineProductTest({ id: "T6.5-3", title: - "re-identification and reference conversion: the moved subtree is re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed exactly (an import unreferenced before the move stays); rewritten content is byte-deterministic across two identical fixtures; the full mapping is appended to the journal; 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); third-file arms — a spec source neither origin nor target, importing the origin module and referencing the moved node through a `d` chain, has that reference rewritten to the target module under an import added there (bytes per T6.5-8's discipline: the single inserted run isolated by diff against bytes composed from 6.4/6.5 and 3 is byte-exactly `import <X> from \"./Target.xspec\"` followed by U+000A at a line-start offset, its identifier alone the product's), the origin import removed exactly when the moved reference was its binding's last and kept byte-for-byte when another reference through it remains, `query edges` listing the third file's `depends` edge under the new identity and `check` clean (SPEC 6.5, 2.1, 6.4, 3)", run: async (product) => { const created: TestWorkspace[] = []; try { @@ -1116,10 +2875,37 @@ 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 in the form-exact performed-operation + // document of 12.7 (H-3; T6.4-1's protocol, pairs ordered by `from` + // bytes). The fixture pins the journaled mapping completely and in + // order: 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", + ), + [...R3_MAPPING], + "T6.5-3: the successful section-form move's report is the applied " + + "mapping — exactly the identity pairs the operation journaled: " + + "the moved subtree's prefix-replaced identities, nothing else " + + "(SPEC 6.5, 6.4, 6.6, 12.0)", + ); + // Conversion and import-rewrite observables (module header, H-4). const originText = await readSourceText( workspace, @@ -1242,20 +3028,7 @@ const T6_5_3 = defineProductTest({ "depends", "T6.5-3 post-move", ), - [ - { - from: `${R3_ORIGIN}#org.usemv`, - to: `${R3_TARGET}#tm`, - kind: "depends", - }, - { from: `${R3_TARGET}#tgt`, to: `${R3_TARGET}#tm`, kind: "depends" }, - { from: `${R3_TARGET}#tm`, to: `${R3_KEEP}#keep`, kind: "depends" }, - { - from: `${R3_TARGET}#tm.k2`, - to: `${R3_TARGET}#tm.k1`, - kind: "depends", - }, - ], + R3_DEPENDS_EDGES, "T6.5-3: the complete `depends` edge set — every converted, added, " + "and re-identified reference resolves to the new identities " + "(SPEC 6.5, 5.2)", @@ -1307,7 +3080,7 @@ const T6_5_3 = defineProductTest({ `found ${kind}`, ); } - await fresh.file(rel, await workspace.readBytes(rel)); + await fresh.copyFrom(workspace, rel); } await buildOk( product, @@ -1322,8 +3095,31 @@ const T6_5_3 = defineProductTest({ "must be byte-identical (SPEC 6.5, 6.4, 12.0; H-4/H-6, " + "normalizing nothing)", ); - } finally { - for (const workspace of created) { + + // Third-file arms (SPEC 6.5: all references across the workspace): + // (a) the moved reference was the origin binding's last — import + // removed; (b) another reference through it remains — import kept. + await runThirdFileArm(product, created, { + label: "(a) origin import removed", + source: R3_THIRD_A_SOURCE, + base: R3_THIRD_A_BASE, + originImportKept: false, + thirdEdges: [ + { from: `${R3_THIRD}#th`, to: `${R3_TARGET}#tm`, kind: "depends" }, + ], + }); + await runThirdFileArm(product, created, { + label: "(b) origin import kept", + source: R3_THIRD_B_SOURCE, + base: R3_THIRD_B_BASE, + originImportKept: true, + thirdEdges: [ + { from: `${R3_THIRD}#th`, to: `${R3_TARGET}#tm`, kind: "depends" }, + { from: `${R3_THIRD}#th2`, to: `${R3_ORIGIN}#org`, kind: "depends" }, + ], + }); + } finally { + for (const workspace of created) { await workspace.dispose(); } } @@ -1339,12 +3135,29 @@ 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 directory (a product probing for a file alone +// sees none and proceeds), a symbolic link, a broken symbolic link (target +// absent; a product probing existence through link-following stat sees +// that path absent and proceeds to relocate) — and the section form on +// any occupant that is not a discovered spec source: a directory, a +// 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,11 +3194,54 @@ const V4_B_SOURCE = [ "", ].join("\n"); +// The A and B sources stay strings for the location windows below; the +// records made from them serve every set staging them — MOVE_REFUSAL_FILES +// (T6.5-4's first workspace; T6.6-3 and T14-7 stage it after their first +// invocations), MOVE_LINK_OUTSIDE_FILES, and MOVE_PRECONDITION_FILES — one +// record per byte sequence, named with every staging test (S-9's +// before-any-product clause; helpers/staged-mdx.ts); the sets' other `.mdx` +// sources are wrapped in place below. +const V4_A_STAGED = stagedMdx("T6.5-4/T6.6-3/T14-7 specs/A.mdx", V4_A_SOURCE); +const V4_B_STAGED = stagedMdx("T6.5-4/T6.6-3/T14-7 specs/B.mdx", V4_B_SOURCE); + +// 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_DEST = "specs/DirDest.mdx"; // file form: directory +const V4_DIR_TARGET = "specs/DirTarget.mdx"; // section form: directory +const V4_LINK_TARGET = "specs/LinkTarget.mdx"; // section form: symlink → B.mdx +const V4_OCC = "docs/Occ.mdx"; // section form: out-of-group `.mdx` file +const V4_OCC_SOURCE = stagedMdx( + "T6.5-4/T6.6-3/T14-7 docs/Occ.mdx", + ['<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). const V4_OTHER = "specs/Other.mdx"; -const V4_OTHER_VALID = ['<S id="oth">', "Other text.", "</S>", ""].join("\n"); +const V4_OTHER_VALID = stagedMdx( + "T6.5-4/T6.6-3 precondition arm specs/Other.mdx as staged (valid)", + ['<S id="oth">', "Other text.", "</S>", ""].join("\n"), +); const V4_OTHER_INVALID = [ '<S id="oth" d={"nope"}>', "Other text.", @@ -1393,23 +3249,647 @@ 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). +// A staged-source record: T6.5-4's derived-path workspaces are created +// after a product invocation, as T6.6-3's and T14-7's are through +// MOVE_DERIVED_PATH_CONFIG (S-9's timing clause). +const V4_OUTDIR_CONFIG = stagedTs( + "T6.5-4/T6.6-3/T14-7 xspec.config.ts — the derived-path configuration: a second spec glob under new/, Markdown emitted under mdout/", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx", "new/**/*.mdx"] + }, + markdown: { emit: true, outDir: "mdout" } +}) +`, +); +const V4_SOLO = "specs/Solo.mdx"; +// Exported: T14-7's destination-component arm (section-14.ts) stages the +// same bytes as its moved file specs/Src.mdx — one staged-source record +// (S-9) for every site. +export const V4_SOLO_SOURCE = stagedMdx( + "T6.5-4/T6.6-3/T14-7 the moved file solo (the derived-path arms' specs/Solo.mdx; T14-7's destination-component arm specs/Src.mdx)", + ['<S id="solo">', "Solo text.", "</S>", ""].join("\n"), +); +const V4_MDOUT_OCCUPANT = "mdout/new"; +const V4_MDOUT_OCCUPANT_CONTENT = "not a directory\n"; + +// The symbolic-link arms of the same clause (SPEC 6.5: a workspace-relative +// directory component of the destination path, or of a derived path the +// destination would generate, occupied by a symbolic link — whatever it +// targets: discovery never traverses one, 7, and writes never traverse or +// replace one, 13.4, 14.22). `specs/sub` is a symbolic link to a real, empty +// directory, so the file-form destination `specs/sub/b.mdx` and the section +// form's created target file `specs/sub/new.mdx` each have that link as a +// directory component. Staged twice: with the link targeting the empty +// directory `linked/` inside the workspace root (on the main refusal +// workspace, beside the occupant arms), and, on its own workspace, a real +// directory outside the root — beside the workspace in the test-owned +// temporary directory, disposed with it. These discriminate a product +// vetting components through link-following stat: it sees a directory at +// the link, proceeds, and writes the moved file — and its regenerated +// derived files — through the link, possibly outside the workspace; the +// outside-root arms therefore also compare the link's target directory +// around each refusal, since the whole-root compare cannot see a write +// landing there. The link lies under no current source's write path and is +// never a source (SPEC 7), so the premise `build` passes; through the link +// the destination path itself is absent (the target directory is empty), so +// no occupant refusal applies beside the component one — one finding, +// refused-invalid-destination concerning the destination path (14, T14-7), +// never 14.22. The derived-path arm's sibling stages `mdout/new` — the emit +// destination's directory component — as such a link (to `linked/`) instead +// of a plain file, refused identically. +const V4_LINK_COMPONENT = "specs/sub"; +const V4_LINKED_DIR = "linked"; // the real, empty inside-root target +const V4_LINKED_TARGET = "../linked"; // spelled from one level below the root +const V4_OUTSIDE_DIR = "outside"; // beside the root in the temporary directory +const V4_OUTSIDE_TARGET = "../../outside"; // work/specs/sub → tempRoot/outside +const V4_LINK_FILE_DEST = "specs/sub/b.mdx"; +const V4_LINK_SECTION_DEST = "specs/sub/new.mdx"; + +/** + * One T6.5-4 refusal case: the full move argv (without `--json`), the + * expected refusal finding — or one expectation per applicable reason where + * 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; +} + +/** + * The two link-component arms (V4_LINK_COMPONENT's note) for one staging of + * `specs/sub` — the file-form destination and the section form's created + * target file — each refused refused-invalid-destination concerning the + * destination path (SPEC 6.5, 14, T14-7), never 14.22; `target` names what + * the staged link resolves to, for diagnosis. + */ +function linkComponentCases(target: string): readonly MoveRefusalCase[] { + return [ + { + argv: ["move", "specs/A.mdx", V4_LINK_FILE_DEST], + expected: { + finding: "refused-invalid-destination", + path: V4_LINK_FILE_DEST, + }, + reason: + "file form whose destination path has its directory component " + + `specs/sub occupied by a symbolic link to ${target} — a component ` + + "occupied by anything other than a directory, a symbolic link " + + "whatever it targets, is the move's own refusal, never 14.22; a " + + "product vetting components through link-following stat sees a " + + "directory there, proceeds, and writes the moved file through the " + + "link (SPEC 6.5, 7, 13.4, 14)", + }, + { + argv: ["move", "specs/A.mdx#x", `${V4_LINK_SECTION_DEST}#tnew`], + expected: { + finding: "refused-invalid-destination", + path: V4_LINK_SECTION_DEST, + }, + reason: + "section form creating the target file specs/sub/new.mdx, whose " + + `directory component specs/sub is a symbolic link to ${target} — ` + + "the created target file's path is vetted like the file form's " + + "destination, refused never 14.22; through the link the path is " + + "absent, so no occupant reason applies beside it (SPEC 6.5, 7, " + + "13.4, 14)", + }, + ]; +} + +/** + * The inside-root staging of the link-component arms (V4_LINK_COMPONENT's + * note): `specs/sub` → `linked/`, staged by stageMoveRefusalOccupants on the + * main refusal workspace — the last entries of MOVE_REFUSAL_CASES, exported + * for T14-7, which compares the link and its target around each (TEST-SPEC + * T14-7: the link and its target byte-identical after each refusal). + */ +export const MOVE_LINK_INSIDE_CASES: readonly MoveRefusalCase[] = + linkComponentCases("the empty directory linked/ inside the workspace root"); + +/** + * The links of the link-component stagings (V4_LINK_COMPONENT's note), + * exported for T14-7's link-and-target compare: `specs/sub`, the + * destination-side component of MOVE_LINK_INSIDE_CASES' and + * MOVE_LINK_OUTSIDE_CASES' stagings, and `mdout/new`, the emit + * destination's component in MOVE_DERIVED_LINK_CASE's. + */ +export const MOVE_LINK_COMPONENT = V4_LINK_COMPONENT; +export const MOVE_DERIVED_LINK_COMPONENT = V4_MDOUT_OCCUPANT; + +/** + * 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, InitialFileContents>> = + { + [V4_A]: V4_A_STAGED, + [V4_B]: V4_B_STAGED, + [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 — plus the inside-root staging of + * the link-component arms (V4_LINK_COMPONENT's note): `specs/sub` a + * symbolic link to the real, empty directory `linked/` at the root. + */ +export async function stageMoveRefusalOccupants( + workspace: TestWorkspace, +): Promise<void> { + await workspace.dir(V4_DIR_TARGET); + await workspace.dir(V4_DIR_DEST); + await workspace.symlink(V4_SYM_DEST, "B.mdx"); + await workspace.symlink(V4_LINK_TARGET, "B.mdx"); + await workspace.symlink(V4_GONE_DEST, "missing-target.mdx"); + await workspace.dir(V4_LINKED_DIR); + await workspace.symlink(V4_LINK_COMPONENT, V4_LINKED_TARGET, "dir"); +} + +/** `U+XXXX`, naming a barred character in a case's diagnosis text. */ +function codePointName(codePoint: number): string { + return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`; +} + +// T6.5-4's barred-character `<new-id>` arms (TEST-SPEC T6.5-4; SPEC 1.4's +// quote-and-escape bullet — T6.4-3's discriminator, met in the section +// form): one per character the bullet bars (section-6.4.ts's +// BARRED_NEW_ID_CHARACTERS: the double quote, the single quote, the escape +// character, `&`, U+2028, and U+2029), each spelled between two letters in +// a one-segment `<new-id>` (as T1.4-1 spells them: the literal, validly +// encoded character) moving A.mdx's `keep` into B.mdx, as the other +// invalid-id arms do. Each destination operand `specs/B.mdx#a<c>b` is a +// well-formed argument value (12.0: valid UTF-8, no U+FFFD, one `#`) that +// no spelling rule decides, so each arm exits 1 with `refused-invalid-id` +// alone, never exit 2, its `identities` exactly `["specs/B.mdx#a<c>b"]` +// with the character verbatim (SPEC 14: refused-structural-parent and +// refused-invalid-rewrite are evaluated only over an intrinsically valid +// new ID, and no other reason applies to the staging). A product +// whose new-ID check omits a character its source validation bars (T1.4-1) +// performs the move — writing the character into an `id` attribute, a +// workspace failing validation behind a reported success — and fails the +// exit, the finding, and the modifies-nothing compare (workspace and +// journal alike). Each character is built from its code point, so no tool +// layer can normalize the spelling away. +const BARRED_CHARACTER_NEW_ID_CASES: readonly MoveRefusalCase[] = + BARRED_NEW_ID_CHARACTERS.map(([codePoint, name]): MoveRefusalCase => { + const newId = `a${String.fromCodePoint(codePoint)}b`; + return { + argv: ["move", "specs/A.mdx#keep", `${V4_B}#${newId}`], + expected: { + finding: "refused-invalid-id", + identities: [`${V4_B}#${newId}`], + }, + reason: + `section form whose one-segment <new-id> carries ${name} ` + + `(${codePointName(codePoint)}) between two letters, which 1.4's ` + + `quote-and-escape bullet bars — a well-formed argument value no ` + + `spelling rule decides, so exit 1, never exit 2, the character ` + + `verbatim in identities (SPEC 6.5, 1.4, 12.0, 14)`, + }; + }); + +// T6.5-4's barred destination-path arms (TEST-SPEC T6.5-4; SPEC 7.1, 6.5, +// 14.19): one file-form arm and one section-form arm creating the target +// per character 7.1 bars from spec-source paths — the double quote, the +// single quote, the backslash, U+000A, U+000D, U+2028, and U+2029 — each +// spelled between two letters in the destination's file name +// (`specs/a<c>b.mdx`; the section form's `specs/a<c>b.mdx#x`, nothing at +// that path), plus, for the single quote, one arm of each form placing it +// in a directory component instead (`specs/it's/b.mdx`, `specs/it's` +// absent): 7.1 bars the character anywhere in the path, so a validator +// checking the file name alone fails there. REFUSAL_CONFIG's spec glob +// `specs/**/*.mdx` reaches every destination these arms spell — under a +// glob reaching the top level alone `specs/it's/b.mdx` would belong to no +// spec group, refused under the same code concerning the same path, and +// that validator would pass. Each destination is a well-formed argument +// value (12.0: valid UTF-8, no U+FFFD, no `#` in its path part), so each +// arm is refused `refused-invalid-destination` concerning the destination +// path as spelled, the character verbatim (SPEC 6.5, 14.19, 14) — never a +// usage error — and modifies nothing; no other reason applies (A.mdx is +// referenced from no other file, and `x` moves under its own valid ID with +// nothing referencing it). The destinations are operands, never staged +// file names, so no arm needs Linux-leg gating. Each character is built +// from its code point. +const BARRED_PATH_CHARACTERS: readonly (readonly [number, string])[] = [ + [0x22, "the double quote"], + [0x27, "the single quote"], + [0x5c, "the backslash"], + [0x0a, "LINE FEED"], + [0x0d, "CARRIAGE RETURN"], + [0x2028, "LINE SEPARATOR"], + [0x2029, "PARAGRAPH SEPARATOR"], +]; + +/** + * The two arms of one barred destination path: the file form moving A.mdx + * to `path`, and the section form moving A.mdx's `x` to `path#x`, creating + * the target file — each refused `refused-invalid-destination` concerning + * `path` exactly as spelled. `shown` renders the path for diagnosis with + * the barred character named by its code point. + */ +function barredPathCases( + path: string, + shown: string, + name: string, +): readonly MoveRefusalCase[] { + return [ + { + argv: ["move", V4_A, path], + expected: { finding: "refused-invalid-destination", path }, + reason: + `file form whose destination path ${shown} contains ${name} — a ` + + `character 7.1 bars from spec-source paths; a well-formed argument ` + + `value, so refused concerning the path as spelled, never a usage ` + + `error (SPEC 6.5, 7.1, 14.19, 12.0)`, + }, + { + argv: ["move", `${V4_A}#x`, `${path}#x`], + expected: { finding: "refused-invalid-destination", path }, + reason: + `section form creating the target file ${shown}, whose path ` + + `contains ${name} — a character 7.1 bars from spec-source paths; ` + + `the created target file's path is vetted like the file form's ` + + `destination, refused concerning it as spelled, never a usage ` + + `error (SPEC 6.5, 7.1, 14.19, 12.0)`, + }, + ]; +} + +const BARRED_DESTINATION_PATH_CASES: readonly MoveRefusalCase[] = [ + ...BARRED_PATH_CHARACTERS.flatMap(([codePoint, name]) => + barredPathCases( + `specs/a${String.fromCodePoint(codePoint)}b.mdx`, + `specs/a<${codePointName(codePoint)}>b.mdx`, + `${name} (${codePointName(codePoint)}) in its file name`, + ), + ), + ...barredPathCases( + `specs/it${String.fromCodePoint(0x27)}s/b.mdx`, + `specs/it<U+0027>s/b.mdx (specs/it<U+0027>s absent)`, + "the single quote (U+0027) in a directory component, where a " + + "validator checking the file name alone sees none", + ), +]; + +// 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", V4_DIR_DEST], + expected: { finding: "refused-destination-exists", path: V4_DIR_DEST }, + reason: + "file form whose destination path is occupied by a directory — " + + "whatever kind of filesystem object occupies it; a product probing " + + "for a file alone sees none there and proceeds (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#x", `${V4_DIR_TARGET}#tdir`], + expected: { finding: "refused-destination-exists", path: V4_DIR_TARGET }, + 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", + identities: [`${V4_B}#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", + identities: [`${V4_B}#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", + identities: [`${V4_B}#`], + }, + 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)", + }, + ...BARRED_CHARACTER_NEW_ID_CASES, + { + argv: ["move", "specs/A.mdx#x", "specs/B.mdx#y"], + expected: { + finding: "refused-id-collision", + locatedAt: { file: V4_B, window: V4_Y_WINDOW }, + identities: [`${V4_B}#y`], + }, + 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", + identities: [`${V4_B}#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", + identities: [`${V4_A}#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)", + }, + ...BARRED_DESTINATION_PATH_CASES, + // The inside-root staging of the link-component arms (V4_LINK_COMPONENT's + // note): `specs/sub` → `linked/`, staged by stageMoveRefusalOccupants; the + // whole-root compare sees any write landing through the link. + ...MOVE_LINK_INSIDE_CASES, +]; + +/** + * 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, InitialFileContents> +> = { + [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 outside-root staging of the link-component arms + * (V4_LINK_COMPONENT's note), exported for T6.6-3: MOVE_LINK_OUTSIDE_FILES + * under MOVE_REFUSAL_CONFIG, `specs/sub` a symbolic link to a real, empty + * directory created beside the workspace root in the test-owned temporary + * directory (disposed with the workspace). Returns that directory's + * absolute path: the caller compares its byte state around each refusal + * (`assertLeavesUnchanged`), since the whole-root compare cannot see a + * write landing through the link outside the root. + */ +export const MOVE_LINK_OUTSIDE_FILES: Readonly< + Record<string, InitialFileContents> +> = { + [V4_A]: V4_A_STAGED, + [V4_B]: V4_B_STAGED, +}; +export async function stageMoveLinkOutsideComponent( + workspace: TestWorkspace, +): Promise<string> { + const outside = joinPath(workspace.tempRoot, V4_OUTSIDE_DIR); + await fsp.mkdir(outside); + await workspace.symlink(V4_LINK_COMPONENT, V4_OUTSIDE_TARGET, "dir"); + return outside; +} +export const MOVE_LINK_OUTSIDE_CASES: readonly MoveRefusalCase[] = + linkComponentCases("an empty directory outside the workspace root"); + +/** + * The derived-path arm's symbolic-link sibling (V4_LINK_COMPONENT's note), + * exported for T6.6-3: MOVE_DERIVED_LINK_FILES under + * MOVE_DERIVED_PATH_CONFIG, with `mdout/new` — the emit destination's + * directory component — staged as a symbolic link to the real, empty + * directory `linked/` instead of a plain file; the link lies under no + * current source's write path (specs/Solo.mdx emits mdout/specs/Solo.md), + * so the premise `build` passes, and the move is refused identically — + * refused-invalid-destination concerning the destination path, never + * 14.22, the link and its target byte-identical afterward. + */ +export const MOVE_DERIVED_LINK_FILES: Readonly< + Record<string, InitialFileContents> +> = { + [V4_SOLO]: V4_SOLO_SOURCE, +}; +export async function stageMoveDerivedLinkComponent( + workspace: TestWorkspace, +): Promise<void> { + await workspace.dir(V4_LINKED_DIR); + await workspace.symlink(V4_MDOUT_OCCUPANT, V4_LINKED_TARGET, "dir"); +} +export const MOVE_DERIVED_LINK_CASE: MoveRefusalCase = { + argv: MOVE_DERIVED_PATH_CASE.argv, + expected: MOVE_DERIVED_PATH_CASE.expected, + reason: + "derived-path arm's symbolic-link sibling — the emit destination's " + + "directory component mdout/new occupied by a symbolic link to the " + + "empty directory linked/ instead of a plain file (a component occupied " + + "by a symbolic link, whatever it targets; writes never traverse one): " + + "refused refused-invalid-destination concerning the destination path, " + + "never 14.22, the link and its target byte-identical (SPEC 6.5, 7.3, " + + "13.1, 13.2, 13.4, 14)", +}; + +/** + * T6.5-4's valid-workspace precondition arm (as T6.4-6), exported for + * T6.6-3: stage MOVE_PRECONDITION_FILES under MOVE_REFUSAL_CONFIG, `build` + * (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, InitialFileContents> +> = { + [V4_A]: V4_A_STAGED, + [V4_B]: V4_B_STAGED, + [V4_OTHER]: V4_OTHER_VALID, +}; +export const MOVE_PRECONDITION_BREAK_FILE = V4_OTHER; +const MOVE_PRECONDITION_BREAK_SOURCE = V4_OTHER_INVALID; +// The break is staged after each arm's `build` — here and in T6.6-3's +// identically staged arm — so it is a ledger record (S-9's before-any-product +// clause; helpers/staged-mdx.ts) shared by both tests: the same constant. +export const MOVE_PRECONDITION_BREAK = stagedMdx( + "T6.5-4/T6.6-3 precondition arm: specs/Other.mdx overwritten with an unresolved local d reference", + MOVE_PRECONDITION_BREAK_SOURCE, +); +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 directory, by a symbolic link, and by a broken symbolic link with its target absent, one arm each, the directory arm discriminating a product probing for a file alone and the broken-link arm discriminating a product probing existence through link-following stat (refused-destination-exists, concerning that path); section form whose target path is occupied by anything other than a discovered spec source — a directory; a symbolic link resolving to a discovered spec source (discovery never yields a symlink); and an existing `.mdx` file outside every configured spec group, the latter refusing under refused-destination-exists and refused-invalid-destination together, one finding per applicable reason; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment; the empty `<new-id>` of destination operand `specs/B.mdx#`, a well-formed 12.0 split with zero id segments, never the exit-2 generalization of 11.3's `--to` spelling rule; one arm per character 1.4's quote-and-escape bullet bars — the double quote, the single quote, the escape character, `&`, U+2028, and U+2029 — each between two letters in a one-segment `<new-id>` (destination operand `specs/B.mdx#a<c>b`), a well-formed argument value no spelling rule decides, so exit 1, never exit 2, the character verbatim in identities — 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, lacking `.mdx`, or containing a character 7.1 bars from spec-source paths — the double quote, the single quote, the backslash, U+000A, U+000D, U+2028, or U+2029 — one file-form arm and one section-form arm creating the target per character (`specs/a<c>b.mdx`, nothing at that path), and for the single quote one arm of each form placing it in a directory component instead (`specs/it's/b.mdx`, `specs/it's` absent: a validator checking the file name alone passes it), under the spec glob `specs/**/*.mdx`, which reaches every such destination, each refused concerning the destination path as spelled, never a usage error — every such spelling is a well-formed argument value (12.0) — and the derived-path arm — emission enabled under `markdown.outDir`, the otherwise-valid destination's emit-destination directory component `mdout/new` occupied by a plain file lying under no current source's write path, refused never 14.22 (refused-invalid-destination, concerning the destination path); the symbolic-link arms of the same clause — a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, `specs/sub` a symbolic link to a real, empty directory, staged with the link targeting a directory inside the workspace root and, on its own workspace, one outside it — each refused-invalid-destination concerning the destination path, never 14.22, nothing written through the link inside or outside the workspace (the link and its target directory byte-identical afterward), and the derived-path arm's sibling staging `mdout/new` as such a link instead of a plain file, refused identically — each refusal the form-exact 12.7 findings-only report holding exactly one finding per applicable reason with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 7, 7.1, 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 +3898,141 @@ 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 outside-root staging of the link-component arms + // (V4_LINK_COMPONENT's note): `specs/sub` a symbolic link to a real, + // empty directory beside the workspace root. Each refusal is compared + // over the link's target directory as well — a product writing the + // moved file, or its regenerated derived files, through the link lands + // them outside the root, where the whole-root compare cannot see them. + await withWorkspace( + MOVE_REFUSAL_CONFIG, + MOVE_LINK_OUTSIDE_FILES, + async (workspace) => { + const outside = await stageMoveLinkOutsideComponent(workspace); + await buildOk( + product, + workspace, + "T6.5-4 outside-root link staging `build` — the link specs/sub " + + "is never discovered nor traversed and lies under no current " + + "source's write path (SPEC 7, 13.4), so the workspace passes " + + "`build`'s validations and each refusal below is the move's own", + ); + for (const { argv, expected, reason } of MOVE_LINK_OUTSIDE_CASES) { + await assertLeavesUnchanged( + outside, + () => + expectRefusalModifiesNothing( + product, + workspace, + argv, + expected, + `T6.5-4 (${reason})`, + ), + `T6.5-4 (${reason}): the link's target directory outside the ` + + `workspace root stays byte-identical — nothing is written ` + + `through the link (SPEC 6.5, 13.4)`, ); } }, ); + // The derived-path arm of refused-invalid-destination, on its own + // workspace (V4_OUTDIR_CONFIG's note): the destination `new/b.mdx` is + // otherwise valid and its own directory components unobstructed (`new/` + // 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})`, + ); + }, + ); + + // The derived-path arm's symbolic-link sibling (V4_LINK_COMPONENT's + // note): `mdout/new` a symbolic link to the empty directory `linked/` + // instead of a plain file — refused identically, the link and its + // target byte-identical (both inside the root: the whole-root compare). + await withWorkspace( + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_LINK_FILES, + async (workspace) => { + await stageMoveDerivedLinkComponent(workspace); + await buildOk( + product, + workspace, + "T6.5-4 derived-path link sibling `build` — the link mdout/new " + + "lies under no current source's write path (SPEC 13.4), so the " + + "workspace passes `build`'s validations and the refusal below " + + "is the move's own", + ); + await expectRefusalModifiesNothing( + product, + workspace, + MOVE_DERIVED_LINK_CASE.argv, + MOVE_DERIVED_LINK_CASE.expected, + `T6.5-4 (${MOVE_DERIVED_LINK_CASE.reason})`, + ); + }, + ); + // Valid-workspace precondition, as T6.4-6: with a pre-existing // validation error elsewhere, the move's own arguments being valid, the - // move refuses (exit 1) before modifying anything. + // 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, + ); 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})`, ); }, ); @@ -1545,46 +4044,130 @@ const T6_5_4 = defineProductTest({ // --------------------------------------------------------------------------- const U5_A = "specs/A.mdx"; -const U5_A_SOURCE = [ - '<S id="a">', - "Alpha text.", - "", - '<S id="a.mid">', - "Mid text.", - "</S>", - "</S>", - "", -].join("\n"); +// T6.5-5's sources mirror T6.4-4's byte for byte (section-6.4.ts's U4_* +// records, exported for this module): identical bytes are ONE record (S-9's +// naming rule; helpers/staged-mdx.ts), named there with T6.5-5 too, so this +// module aliases the records rather than registering the bytes twice. The +// base arm's workspace is the body's first; the configuration-state twins +// and the ordering, masking, duplicate-spellings, undefined-ancestor, and +// solo arms follow its invocations, and T6.6-3 stages the exported sets +// after its first invocation — so every `.mdx` source here is a record, the +// base arm's converted uniformly. Only `specs/B.mdx`, the section form's +// target, is this module's own. +const U5_A_SOURCE = U4_SOURCE; const U5_B = "specs/B.mdx"; -const U5_B_SOURCE = ['<S id="b">', "Beta text.", "</S>", ""].join("\n"); +const U5_B_SOURCE = stagedMdx( + "T6.5-5/T6.6-3 specs/B.mdx", + ['<S id="b">', "Beta text.", "</S>", ""].join("\n"), +); // The ordering arm's unrelated validation error: an unresolved local `d` // reference (14.5) in a file untouched by the move arguments. const U5_BAD = "specs/Bad.mdx"; -const U5_BAD_SOURCE = [ - '<S id="bad" d={"nope"}>', - "Bad text depending on nothing that exists.", - "</S>", - "", -].join("\n"); +const U5_BAD_SOURCE = U4_BAD_SOURCE; // The masking arm's unparseable origin file: an unclosed section tag (14.20). const U5_BROKEN = "specs/Broken.mdx"; -const U5_BROKEN_SOURCE = [ - '<S id="broken">', - "Text that never closes.", - "", -].join("\n"); +const U5_BROKEN_SOURCE = U4_BROKEN_SOURCE; + +// 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). A staged-source record: T6.5-5's twins +// and ordering arm stage it after a product invocation, as T6.6-3 does +// through MOVE_USAGE_ORDERING_FILES (S-9's timing clause). +const U5_CODE = "src/app.ts"; +const U5_CODE_SOURCE = stagedTs( + "T6.5-5/T6.6-3 src/app.ts — a discovered code source bearing no requirement IDs, the wrong-kind origin operand", + "export function noop(): void {}\n", +); + +// The second nonexistent-`<file>` spelling (TEST-SPEC T6.5-5: "both of +// T6.4-4's spellings"): a valid `.mdx` present on disk, holding a section +// spelling the origin ID `a`, but outside every configured spec group +// (`specs/**/*.mdx`, SPEC 7) — both forms' origin operands name discovered +// spec sources (SPEC 6.5), and a file named in an argument exists as a +// member of the discovered set (SPEC 12.0), so this operand is as +// nonexistent as an absent path in either form. A product probing the +// filesystem for the operand finds the file (file form) and the origin ID +// it spells (section form) and proceeds — moving the stray file to +// `specs/New.mdx`, or inserting its `a` subtree into `specs/B.mdx` as `z` — +// instead of exiting 2, so the existence table runs inside whole-root +// modifies-nothing compares. Its `a` beside `specs/A.mdx`'s is no +// duplicate-ID condition even when discovered (SPEC 14 condition 3 is a +// duplicate within a file), so the base arm pins the stray file's absence +// from the discovered set directly, through `ids --json` (SPEC 12.3), as +// T6.4-4 does. +const U5_STRAY = "docs/Stray.mdx"; +const U5_STRAY_SOURCE = U4_STRAY_SOURCE; + +// 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 = U4_DUP_SOURCE; + +// 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 = U4_ANC_SOURCE; + +// 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 = U4_SOLO_SOURCE; + +// Destination operand that is not valid UTF-8: `specs/<0xFF>.mdx` (Linux-leg +// staging — argv is a byte channel there; T6.5-5, T12.0-5, T1.5-2's note). +// It contains no `#`, so only the argument-value rule makes it exit 2: a +// non-UTF-8 argument value is a usage error (SPEC 12.0), and a valid operand +// therefore never denotes a non-UTF-8 destination path — the 6.5 refusal +// clause is unreachable (T6.5-4's dead-letter note). +const U5_NON_UTF8_DESTINATION: Uint8Array = Buffer.concat([ + Buffer.from("specs/", "utf8"), + Buffer.from([0xff]), + Buffer.from(".mdx", "utf8"), +]); -const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ +// 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", + "file form, nonexistent origin file, absent on disk", + ], + [ + ["move", U5_STRAY, "specs/New.mdx"], + "file form, nonexistent origin file — an .mdx present on disk but " + + "matched by no spec group (a file named in an argument exists as a " + + "member of the discovered set, SPEC 12.0; both forms' origin " + + "operands name discovered spec sources, SPEC 6.5)", ], [ ["move", "specs/Missing.mdx#a", "specs/B.mdx#z"], - "section form, nonexistent origin file", + "section form, nonexistent origin file, absent on disk", + ], + [ + ["move", `${U5_STRAY}#a`, "specs/B.mdx#z"], + "section form, nonexistent origin file — an .mdx present on disk " + + "(holding a section spelling the origin ID) but matched by no spec " + + "group (SPEC 6.5, 12.0)", ], [ ["move", "specs/A.mdx#nope", "specs/B.mdx#z"], @@ -1592,37 +4175,292 @@ const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ ], ]; -const T6_5_5 = defineProductTest({ - id: "T6.5-5", - title: - "usage errors (exit 2): a nonexistent origin file (either form) and a nonexistent origin ID are usage errors checked before source validation — the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4) — but an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1 (SPEC 6.5, 12.0, 14, 14.20)", - run: async (product) => { - // --- Base arm: a valid workspace --- - await withWorkspace( - SPECS_ONLY_CONFIG, - { [U5_A]: U5_A_SOURCE, [U5_B]: U5_B_SOURCE }, - async (workspace) => { - const context = "T6.5-5 valid-workspace arm"; +// 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 U+FFFD destination operands (either leg): the file form's + * `specs/B<U+FFFD>.mdx` and the section form's `specs/B.mdx#x<U+FFFD>`, + * each a malformed argument value — a usage error of the syntax class, + * judged before any refusal is evaluated (SPEC 12.0, 6.5; T12.0-5) — so + * neither ever reaches `refused-invalid-destination` or `refused-invalid-id`. + * The origins exist (`specs/A.mdx`; its `a.mid`), so the malformed value is + * each invocation's only defect. The character is built from its code point + * so no tool layer can normalize the spelling away. Exported beside + * MOVE_NON_UTF8_ARGV for T6.6-3's preview variants. + */ +export const MOVE_REPLACEMENT_DESTINATION_CASES: readonly (readonly [ + readonly string[], + string, +])[] = [ + [ + ["move", U5_A, `specs/B${REPLACEMENT_CHARACTER}.mdx`], + "file-form destination containing U+FFFD", + ], + [ + ["move", `${U5_A}#a.mid`, `${U5_B}#x${REPLACEMENT_CHARACTER}`], + "section-form destination whose id part contains U+FFFD", + ], +]; + +/** The base/ordering staging shared by the usage cases (T6.4-4's mirror): + * valid sources, the discovered code source, the undiscovered stray `.mdx`, + * and — in the ordering variant — the failing Bad.mdx, exported for + * T6.6-3's preview sweep. */ +export const MOVE_USAGE_CONFIG = SPEC_AND_CODE_CONFIG; +export const MOVE_USAGE_ORDERING_FILES: Readonly< + Record<string, InitialFileContents> +> = { + [U5_A]: U5_A_SOURCE, + [U5_B]: U5_B_SOURCE, + [U5_BAD]: U5_BAD_SOURCE, + [U5_CODE]: U5_CODE_SOURCE, + [U5_STRAY]: U5_STRAY_SOURCE, +}; + +/** + * 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, InitialFileContents>> = { + [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 in either form — absent on disk, or an `.mdx` present on disk but matched by no spec group (a file named in an argument exists as a member of the discovered set), its absence from the discovered set pinned through `ids --json` — a nonexistent origin ID, and a discovered code source as the origin in each form — both forms' origin operands name discovered spec sources, so a code-source origin is a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4); an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1; origin-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2, nothing modified) — while an ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings; operand classification is by spelling alone: the three mixed-synopsis invocations — bare-file origin with pair destination, pair origin with bare-file destination, and a `#`-containing file-form destination classified as a pair — match neither synopsis (exit 2), and a destination operand containing U+FFFD (the file form's path, the section form's id part; either leg) or not valid UTF-8 (raw argv bytes, Linux leg) is a malformed argument value — a syntax-class usage error, exit 2 with the plain usage error's document (`code` null), byte-identical with the configuration file invalid or missing, never `refused-invalid-destination` or `refused-invalid-id` — the latter the stagings T6.5-4's unspellable-destination note sets aside — the existence, wrong-kind, mixed-synopsis, dead-letter, and refusal arms each proving nothing modified (SPEC 6.5, 6.4, 11.2, 12.0, 14, 14.20)", + run: async (product) => { + // --- Base arm: a valid workspace --- + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + [U5_A]: U5_A_SOURCE, + [U5_B]: U5_B_SOURCE, + [U5_CODE]: U5_CODE_SOURCE, + [U5_STRAY]: U5_STRAY_SOURCE, + }, + async (workspace) => { + const context = "T6.5-5 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); - for (const [argv, label] of U5_USAGE_CASES) { - await expectMoveUsageError( - product, - workspace, - argv, - `${context}, ${label}`, + // Staging premise: the stray file is outside the discovered set — + // `ids --json` lists the discovered spec sources (SPEC 12.3), and it + // lists `specs/A.mdx` but never `docs/Stray.mdx`. Pinning this makes + // the exit-2 assertions on the stray origin demonstrably a + // discovered-set judgement over a file present on disk, not a + // filesystem miss (as T6.4-4). + const idsLabel = `${context}: \`ids --json\` premise`; + const listed = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsLabel), + idsLabel, + ).files.map((entry) => entry.file); + if (!listed.includes(U5_A) || listed.includes(U5_STRAY)) { + fail( + `${context}: staging premise — the discovered spec sources must ` + + `include ${U5_A} and exclude the stray ${U5_STRAY} (outside ` + + `every spec group, SPEC 7; a file named in an argument exists ` + + `as a member of the discovered set, SPEC 12.0), but \`ids ` + + `--json\` listed ${JSON.stringify(listed)}`, + ); + } + // Every usage error modifies nothing (SPEC 12.0): one whole-root + // byte compare around the existence table — derived files, sources, + // and the stray file alike (a product probing the filesystem for the + // stray origin would move it, or insert its subtree into B.mdx). + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of MOVE_USAGE_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 6.5, 12.0)`, + ); + + // Wrong-kind origins (SPEC 6.5: both forms' origin operands name + // discovered spec sources), each inside a whole-root + // 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)`, + ); + } + + // Malformed destination operands — the stagings T6.5-4's + // unspellable-destination note sets aside (SPEC 6.5: a destination + // containing U+FFFD or not valid UTF-8 is unspellable as an + // argument): a destination operand containing U+FFFD (either leg; + // the file form and the section form's id part) and a non-UTF-8 + // destination operand (raw argv bytes, Linux leg only — Linux argv + // is a byte channel, the driver's trampoline; other platforms + // cannot carry the argument at all, T1.5-2's platform note) are + // each a malformed argument value, a usage error of the syntax + // class judged before any refusal is evaluated: exit 2 with the + // plain usage error's document (`code` null) — never + // `refused-invalid-destination` or `refused-invalid-id` — reported + // without loading configuration (byte-identical with the + // configuration file invalid or missing, T12.0-10's discipline) + // and modifying nothing. + const twins = await stageConfigurationStateTwins({ + [U5_A]: U5_A_SOURCE, + [U5_B]: U5_B_SOURCE, + [U5_CODE]: U5_CODE_SOURCE, + [U5_STRAY]: U5_STRAY_SOURCE, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of MOVE_REPLACEMENT_DESTINATION_CASES) { + await expectSyntaxClassUsageError( + product, + workspace, + twins, + [...argv, "--json"], + `${context}, ${label} — a malformed argument value, never ` + + `\`refused-invalid-destination\` or ` + + `\`refused-invalid-id\` (SPEC 12.0, 6.5)`, + ); + } + if (process.platform === "linux") { + await expectSyntaxClassUsageError( + product, + workspace, + twins, + [...MOVE_NON_UTF8_ARGV, "--json"], + `${context}, non-UTF-8 destination operand (raw argv ` + + `bytes, Linux leg) — a malformed argument value, never ` + + `\`refused-invalid-destination\` (SPEC 12.0, 6.5)`, + ); + } + }, + `${context}: the malformed destination operands modify nothing ` + + `(SPEC 6.5, 12.0)`, ); + } finally { + await twins.dispose(); } }, ); // --- 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,15 +4479,33 @@ const T6_5_5 = defineProductTest({ `at least one validation finding (SPEC 14)`, ); } - for (const [argv, label] of U5_USAGE_CASES) { - await expectMoveUsageError( - product, - workspace, - argv, - `${context}, ${label}, with unrelated validation errors present ` + - `— the existence checks precede source validation (SPEC 12.0)`, - ); - } + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of MOVE_USAGE_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors ` + + `present — the existence checks precede source ` + + `validation (SPEC 12.0)`, + ); + } + for (const [argv, label] of MOVE_WRONG_KIND_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors ` + + `present — the wrong-kind operand is judged like ` + + `existence, before source validation (SPEC 6.5, 6.4, ` + + `12.0)`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 6.5, 12.0)`, + ); }, ); @@ -1692,6 +4548,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)`, + ); + }, + ); }, }); @@ -1699,7 +4648,7 @@ const T6_5_5 = defineProductTest({ // T6.5-6 — identity terms // --------------------------------------------------------------------------- -// The new-identity checks read in identity terms (SPEC 6.5). Two clauses +// The new-identity checks read in identity terms (SPEC 6.5). Three clauses // admit no discriminating fixture, per TEST-SPEC T6.5-6, and are documented // rather than staged: // - The collision clause's after-the-removal qualifier: structural IDs (1.3) @@ -1711,25 +4660,257 @@ const T6_5_5 = defineProductTest({ // reason: a move rewrites only valid workspaces and retargets every // affected reference to identities that exist after the operation; it is // exercised as the always-passing side of every successful move. +// - The mirrored "structural parent rules remain satisfied" check, in both +// forms: the file form changes no ID and no within-file nesting; in the +// section form the target parent is located as the target file's section +// bearing `<new-id>` minus its final segment — absent, or lying within the +// moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix +// replacement preserves the subtree's relative nesting, a single-segment +// `<new-id>` inserts at top level (exactly one segment, 1.3), and the +// origin's removal disturbs no remaining ID; on a workspace passing the +// valid-workspace precondition 1.3 therefore holds by construction after +// every non-refused move — the check is 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). +// +// The kept-ID move's fixture: the moved subtree `x` holds a descendant `x.c` +// — its `id` attribute single-quoted, a spelling 2.7 admits, so a product +// re-emitting the unchanged attribute in a normalized form is caught at the +// byte compare — and a sibling `x.u` carrying the local reference +// `d={"x.c"}` to it. The move changes every moved node's identity in its +// file part alone: `x.c` keeps its ID, and the local spelling `"x.c"`, read +// in the target file, already resolves to `specs/B.mdx#x.c` (2.2) — a +// rewrite is made and reported exactly when it changes the construct's +// characters (SPEC 6.5), so the preview reports the origin deletion and the +// target insertion alone, with no `id-rewrite` and no `reference-rewrite` +// nested inside the deletion, and the moved text lands byte-identical. const I6_A = "specs/A.mdx"; -const I6_A_SOURCE = [ - '<S id="a">', - "Alpha text.", - "</S>", - "", +const I6_B = "specs/B.mdx"; + +/** The moved text: the `x` construct's own characters (SPEC 6.5, 1.1). */ +const I6_MOVED_TEXT = [ '<S id="x">', "Ex text.", + "", + "<S id='x.c'>", + "Ex-c text.", "</S>", "", + '<S id="x.u" d={"x.c"}>', + "Ex-u text.", + "</S>", + "</S>", ].join("\n"); -const I6_B = "specs/B.mdx"; +/** The origin's text before the construct: one section, then a blank line. */ +const I6_A_HEAD = ['<S id="a">', "Alpha text.", "</S>", "", ""].join("\n"); +const I6_A_SOURCE = I6_A_HEAD + I6_MOVED_TEXT + "\n"; + +// Expected origin bytes after the move, composed from SPEC 6.5 and 3 — not +// from any product output: the construct's own characters are deleted in +// place, and the merged line that deletion leaves — holding only the closing +// tag's terminator — is dropped with it; the blank line before the construct +// was blank in the source and is kept (3 drops only lines a removal blanked). +const I6_A_AFTER = I6_A_HEAD; + const I6_B_SOURCE = ['<S id="b">', "Bee text.", "</S>", ""].join("\n"); +// Expected target bytes: a top-level `<new-id>` inserts the moved text at +// the end of the file followed by U+000A; the file's final line is +// terminated, so the insertion point is at the start of a line and no +// preceding U+000A is added (SPEC 6.5). The moved text is byte-identical to +// the construct's own characters — no `id` attribute and no local-form +// reference rewritten to its unchanged spelling (SPEC 6.5). +const I6_B_AFTER = I6_B_SOURCE + I6_MOVED_TEXT + "\n"; + +/** + * The identity-terms workspace as the kept-ID move leaves it — the + * post-move state T6.5-6 asserts byte-exact (composed above from SPEC 6.5 + * and 3, never from product output): `specs/A.mdx` keeping `a` alone and + * `specs/B.mdx` holding `b` then the landed `x` subtree — the state + * T6.5-6's refusals are judged on. Exported with the refusal cases below + * for T6.6-3's preview twins, which stage this state directly (TEST-SPEC + * T6.6-3: "staged identically") rather than through the product's move — + * after T6.6-3's first invocation, so the entries are ledger records made + * from the strings T6.5-6 asserts (helpers/staged-mdx.ts). + */ +export const MOVE_IDENTITY_CONFIG = SPECS_ONLY_CONFIG; +export const MOVE_IDENTITY_FILES_AFTER: Readonly< + Record<string, InitialFileContents> +> = { + [I6_A]: stagedMdx("T6.6-3 identity-terms twins specs/A.mdx", I6_A_AFTER), + [I6_B]: stagedMdx("T6.6-3 identity-terms twins specs/B.mdx", I6_B_AFTER), +}; + +/** + * The exact self-move, section form — `<target-file>#<new-id>` equal to + * `<file>#<id>` — refused as `refused-identity-unchanged` alone, no + * collision reason beside it (the after-removal check collides with + * nothing), concerning the unchanged identity (SPEC 6.5, 14, T14-7). + */ +export const MOVE_IDENTITY_SELF_MOVE_CASE: MoveRefusalCase = { + argv: ["move", `${I6_B}#x`, `${I6_B}#x`], + expected: { + finding: "refused-identity-unchanged", + identities: [`${I6_B}#x`], + }, + reason: + "the exact self-move, section form — `<target-file>#<new-id>` equal " + + "to `<file>#<id>` — refused as identity-unchanged alone (SPEC 6.5)", +}; + +/** + * A same-file move whose `<new-id>` `b` collides with the ID `b` remaining + * in the target file after the removal: `refused-id-collision` locating the + * remaining bearer, its identity the sole `identities` entry (SPEC 6.5, 14). + */ +export const MOVE_IDENTITY_COLLISION_CASE: MoveRefusalCase = { + argv: ["move", `${I6_B}#x`, `${I6_B}#b`], + expected: { + finding: "refused-id-collision", + locatedAt: { file: I6_B }, + identities: [`${I6_B}#b`], + }, + reason: + "same-file move whose <new-id> `b` collides with the ID `b` remaining " + + "in the target file after the removal (SPEC 6.5)", +}; + +/** + * The exact self-move, file form — `<new-file>` equal to `<old-file>`, + * compared byte-wise (12.0) — refused as `refused-identity-unchanged` + * alone: its only occupant is the origin the relocation would remove, so + * `refused-destination-exists` is never reported beside it (SPEC 6.5, 14); + * the concerned identity is the bare `<new-file>`, the root identity (14, + * 1.5). T6.6-3's "the exact self-move of either form" stages this form. + */ +export const MOVE_IDENTITY_FILE_SELF_MOVE_CASE: MoveRefusalCase = { + argv: ["move", I6_B, I6_B], + expected: { + finding: "refused-identity-unchanged", + identities: [I6_B], + }, + reason: + "the exact self-move, file form — `<new-file>` equal to `<old-file>` " + + "— refused as identity-unchanged alone, never " + + "`refused-destination-exists` beside it, its only occupant being the " + + "origin the relocation would remove (SPEC 6.5, 14)", +}; + +/** T6.5-6's refusals on the post-move workspace, the file-form self-move beside them (T6.6-3). */ +export const MOVE_IDENTITY_REFUSAL_CASES: readonly MoveRefusalCase[] = [ + MOVE_IDENTITY_SELF_MOVE_CASE, + MOVE_IDENTITY_COLLISION_CASE, + MOVE_IDENTITY_FILE_SELF_MOVE_CASE, +]; + +/** + * The preview's complete plan for the kept-ID move (SPEC 6.6, 12.7): in the + * origin, the `origin-deletion` alone — one range spanning the construct's + * own characters extended over the terminator of the merged line the + * deletion drops (6.6, 3) — and in the target, the `target-insertion` alone, + * zero-length at the end of the file. + */ +const I6_ORIGIN_DELETION: SourceRange = { + start: utf8Length(I6_A_HEAD), + end: utf8Length(I6_A_HEAD) + utf8Length(I6_MOVED_TEXT) + 1, +}; +const I6_TARGET_INSERTION: SourceRange = { + start: utf8Length(I6_B_SOURCE), + end: utf8Length(I6_B_SOURCE), +}; +const I6_EXPECTED_FILES: readonly ExpectedPreviewFile[] = [ + { + file: I6_A, + edits: [{ class: "origin-deletion", range: I6_ORIGIN_DELETION }], + }, + { + file: I6_B, + edits: [{ class: "target-insertion", range: I6_TARGET_INSERTION }], + }, +]; + +/** The kept-ID move's mapping: three IDs kept, the file part changed. */ +const I6_MAPPING: readonly AppliedMappingPair[] = [ + { from: `${I6_A}#x`, to: `${I6_B}#x` }, + { from: `${I6_A}#x.c`, to: `${I6_B}#x.c` }, + { from: `${I6_A}#x.u`, to: `${I6_B}#x.u` }, +]; + +/** + * The kept-ID move's preview plan (TEST-SPEC T6.5-6): first the no-op + * discipline — a rewrite is made and reported exactly when it changes the + * construct's characters (SPEC 6.5), so no `id-rewrite` and no + * `reference-rewrite` may stand anywhere in the plan, the origin deletion's + * interior included — then the complete `files`, form-exact: the origin's + * one `origin-deletion` and the target's one `target-insertion`, in file + * path byte order, class-plus-range only (SPEC 6.6, 12.7). + */ +function assertKeptIdMovePlan( + files: readonly PreviewFileEntry[], + context: string, +): void { + const noOps = files.flatMap((entry) => + entry.edits + .filter( + (edit) => + edit.class === "id-rewrite" || edit.class === "reference-rewrite", + ) + .map( + (edit) => + `${renderPathValue(entry.file)} ${edit.class} ` + + `[${String(edit.range.start)}, ${String(edit.range.end)})`, + ), + ); + if (noOps.length > 0) { + fail( + `${context}: a cross-file section move keeping its ID rewrites no ` + + `\`id\` attribute and no local-form reference inside the moved ` + + `text — \`x.c\` keeps its ID, and \`"x.c"\` read in the target ` + + `file already resolves to ${I6_B}#x.c — because a rewrite is made ` + + `and reported exactly when it changes the construct's characters ` + + `(SPEC 6.5, 6.6); the plan reports ${String(noOps.length)} ` + + `no-op edit(s): ${noOps.join(", ")}`, + ); + } + if (files.length !== I6_EXPECTED_FILES.length) { + fail( + `${context}: \`files\` must hold exactly one entry per file the move ` + + `would rewrite — the origin and the target — expected ` + + `[${I6_EXPECTED_FILES.map((entry) => entry.file).join(", ")}], got ` + + `[${files.map((entry) => renderPathValue(entry.file)).join(", ")}] ` + + `(SPEC 6.6, 12.7)`, + ); + } + for (let i = 0; i < I6_EXPECTED_FILES.length; i += 1) { + const want = I6_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 pre-operation paths, ordered by file path bytes ` + + `(SPEC 6.6, 12.7); got ${renderPathValue(got.file)}`, + ); + } + assertSameJson( + projectEdits(got.edits), + projectEdits(want.edits), + `${context}: ${want.file} — exactly the edits the kept-ID move would ` + + `make there, class-plus-range only: the origin's \`origin-deletion\` ` + + `spanning the construct's own characters extended over the ` + + `terminator of the merged line the deletion drops, the target's ` + + `zero-length \`target-insertion\` at the end of the file ` + + `(SPEC 6.6, 6.5, 3, 12.7)`, + ); + } +} + 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 — and rewrites no `id` attribute and no local-form reference inside the moved text: with the moved subtree holding a descendant `x.c` (its `id` single-quoted) and the local reference " + + 'd={"x.c"}' + + ", the preview reports no `id-rewrite` and no `reference-rewrite` inside the origin deletion — its `files` exactly the origin's `origin-deletion` and the target's end-of-file `target-insertion`, its `mapping` the three kept IDs under the new file part — and the real move (its applied mapping the same) lands the moved text in `b.mdx` byte-identical (the construct's own characters, appended after U+000A discipline) with the origin's construct deleted in place and its emptied line dropped, `check` clean after it; a product rewriting attributes or references to their unchanged spellings, or reporting such no-op edits, fails; 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; the collision clause's after-the-removal qualifier, the mirrored all-rewritten-references-resolve clause, and the mirrored structural-parent check admit no discriminating fixture and are documented at the module (SPEC 6.5, 6.6, 1.5, 6.1, 3, 12.7, 14)", run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -1744,22 +4925,107 @@ const T6_5_6 = defineProductTest({ // Valid: the cross-file move keeping its ID — the new identity // specs/B.mdx#x differs from specs/A.mdx#x in its file part // (SPEC 6.5: "a cross-file section move keeping its ID is - // therefore valid"). + // therefore valid"). First its preview, whose plan makes the no-op + // discipline observable: no rewrite of an `id` attribute or a + // local-form reference whose characters the move leaves unchanged + // is reported (SPEC 6.5, 6.6). + const moveArgv = ["move", "specs/A.mdx#x", "specs/B.mdx#x"] as const; + const previewLabel = `T6.5-6 \`${moveArgv.join(" ")} --preview --json\``; + const preview = decodePreviewReport( + await runJson( + product, + workspace, + [...moveArgv, "--preview", "--json"], + previewLabel, + ), + previewLabel, + ); + assertSameJson( + preview.findings, + [], + `${previewLabel}: a cross-file section move keeping its ID is ` + + `valid — the identity check compares identities, not IDs, and ` + + `${I6_B}#x differs from ${I6_A}#x in its file part — so the ` + + `preview completes with findings [] (SPEC 6.5, 1.5, 6.6)`, + ); + if (preview.mapping === null || preview.files === null) { + fail( + `${previewLabel}: the completed preview reports its plan — ` + + `\`mapping\` and \`files\` non-null (SPEC 6.6, 12.7)`, + ); + } + assertAppliedMapping( + preview.mapping, + I6_MAPPING, + `${previewLabel}: \`mapping\` is one entry per node of the moved ` + + `subtree — \`x\`, \`x.c\`, \`x.u\` — each ID kept and its file ` + + `part changed, ordered by \`from\` bytes (SPEC 6.5, 6.6, 12.7)`, + ); + assertKeptIdMovePlan(preview.files, previewLabel); + + // The real move: its applied mapping, then the bytes it leaves — + // the moved text landing byte-identical, the origin's construct + // deleted with 6.5's exact extent — and its soundness (`check`). + const moveLabel = `T6.5-6 \`${moveArgv.join(" ")} --json\``; + assertAppliedMapping( + decodeAppliedMappingReport( + await runJson( + product, + workspace, + [...moveArgv, "--json"], + moveLabel, + ), + moveLabel, + ), + I6_MAPPING, + `${moveLabel}: the successful move's report is its applied ` + + `mapping — exactly the identity pairs the preview planned and ` + + `the operation journaled (SPEC 6.5, 6.4, 6.6, 12.0)`, + ); + await assertFileBytes( + workspace.path(I6_B), + I6_B_AFTER, + "T6.5-6: the target after the kept-ID move — the moved text lands " + + "byte-identical to the construct's own characters, its " + + "single-quoted descendant `id` attribute and its local " + + 'reference `d={"x.c"}` untouched (a rewrite is made exactly ' + + "when it changes the construct's characters), inserted at the " + + "end of the file followed by U+000A with none added before it " + + "(the final line was terminated) (SPEC 6.5, 1.1; H-4, " + + "normalizing nothing)", + ); + await assertFileBytes( + workspace.path(I6_A), + I6_A_AFTER, + "T6.5-6: the origin after the kept-ID move — the construct's own " + + "characters deleted in place, the merged line the deletion " + + "emptied dropped with its terminator, the pre-existing blank " + + "line kept, and no other byte changed (SPEC 6.5, 3; H-4, " + + "normalizing nothing)", + ); await expectExit( product, workspace, - ["move", "specs/A.mdx#x", "specs/B.mdx#x"], + ["check"], 0, - "T6.5-6 `move specs/A.mdx#x specs/B.mdx#x` — a cross-file section " + - "move keeping its ID is valid: the identity check compares " + - "identities, not IDs (SPEC 6.5, 1.5)", + "T6.5-6 `check` after the kept-ID move — the local reference the " + + "moved text carries resolves in the target file and nothing is " + + "stale (SPEC 6.5, 2.2, 12.2)", ); await assertNodeIdentities( product, workspace, - [I6_A, `${I6_A}#a`, I6_B, `${I6_B}#b`, `${I6_B}#x`], - "the kept-ID move relocated the node: same ID, new file part " + - "(SPEC 6.5, 1.5)", + [ + I6_A, + `${I6_A}#a`, + I6_B, + `${I6_B}#b`, + `${I6_B}#x`, + `${I6_B}#x.c`, + `${I6_B}#x.u`, + ], + "the kept-ID move relocated the subtree: every ID kept, the file " + + "part new (SPEC 6.5, 1.5)", "T6.5-6 post-move", ); await assertJournalHoldsOneEntry( @@ -1777,9 +5043,12 @@ const T6_5_6 = defineProductTest({ await expectRefusalModifiesNothing( product, workspace, - ["move", "specs/B.mdx#x", "specs/B.mdx#x"], - "T6.5-6 (the exact self-move — the new identity equals the old " + - "one, SPEC 6.5)", + MOVE_IDENTITY_SELF_MOVE_CASE.argv, + // Reported alone — no collision reason beside it: the + // after-removal check collides with nothing (SPEC 6.4, 14, + // T14-7) — concerning the unchanged identity. + MOVE_IDENTITY_SELF_MOVE_CASE.expected, + `T6.5-6 (${MOVE_IDENTITY_SELF_MOVE_CASE.reason})`, ); assertBytesEqual( await readJournal( @@ -1797,15 +5066,2882 @@ const T6_5_6 = defineProductTest({ await expectRefusalModifiesNothing( product, workspace, - ["move", "specs/B.mdx#x", "specs/B.mdx#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)", + MOVE_IDENTITY_COLLISION_CASE.argv, + // 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 case asserts + // the bearer's file without a byte window. + MOVE_IDENTITY_COLLISION_CASE.expected, + `T6.5-6 (${MOVE_IDENTITY_COLLISION_CASE.reason})`, ); }, ); }, }); +// --------------------------------------------------------------------------- +// 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 and spells that +// descendant's `id` attribute single-quoted (`id='org.mv.leaf'`, SPEC 2.7: +// single- or double-quoted alike); no reference to a moved node lies +// outside the subtree, and no moved reference targets a node remaining in +// the origin — so the rewrite adds no import anywhere, the one direction +// free of implementation latitude (SPEC 6.5). +// +// Every fixture file, staged and composed alike, is held as its lines (a +// final empty entry terminating the last one) and spelled by joining them +// with one terminator kind: U+000A in the first run, then CRLF and lone CR +// in the terminator-kind re-runs (`B7_KINDS` below; SPEC 3: each one +// terminator) — the lines hold no terminator character of their own. +const B7_ORIGIN = "specs/Origin.mdx"; +const B7_TARGET = "specs/Target.mdx"; +const B7_KEEP = "specs/Keep.mdx"; + +const B7_ORIGIN_BEFORE_LINES: readonly string[] = [ + '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>", + "", +]; + +const B7_TARGET_BEFORE_LINES: readonly string[] = [ + '<S id="hub">', + "Hub text.", + "</S>", + "", + '<S id="aux">', + "Aux text.", + "</S>", + "", +]; + +const B7_KEEP_LINES: readonly string[] = [ + '<S id="keep">', + "Keep text.", + "</S>", + "", +]; + +// 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 whole +// terminator — both bytes of a CRLF (SPEC 6.5, 3) — a product leaving an +// emptied line behind fails here, as one whose edit layer splits lines on +// U+000A alone does in the lone-CR re-run. +// - 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). +// - No terminator is inserted into the origin: every terminator it keeps is +// a staged one, kept byte-for-byte, so its lines join with the staged kind. +const B7_ORIGIN_AFTER_LINES: readonly string[] = [ + 'import Keep from "./Keep.xspec";' + " ", + "", + '<S id="org">', + "Origin holder text.", + "", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", +]; + +// 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 whatever terminators the file holds (a +// product matching the file's terminator style where the moved text lands +// fails the CRLF and lone-CR re-runs); the existing final line is +// terminated — a lone CR is a terminator as a CRLF is (SPEC 3) — so the +// insertion point sits at the start of a line and no preceding U+000A is +// added (SPEC 6.5). The target's own lines keep their staged terminators, +// and the moved text carries its staged ones between its lines, each kept +// byte-for-byte (`b7TargetAfter`). +// - 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); the descendant's +// single-quoted `id` attribute is re-identified the same way, its quotes +// kept: `id='mv.leaf'` (SPEC 6.4 binds the `id`-attribute rewrite as it +// binds references — the double-quoted fallback applies only where a form +// cannot be kept, and `mv.leaf` holds no quote character). A product +// re-emitting a rewritten `id` attribute double-quoted fails here. +// The moved text as it lands: the `org.mv` construct's own characters, +// re-identified and converted, its last line the closing tag's (unterminated +// here: the U+000A the move inserts after it is composed by `b7TargetAfter`). +const B7_MOVED_AFTER_LINES: readonly string[] = [ + '<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>", +]; + +/** + * The expected target bytes for the terminator `t` the fixture's files were + * staged with (SPEC 6.5, 3): the target's staged lines, each terminator + * kept, the last one included (the insertion point a line start, so none is + * added before the moved text); then the moved text, its staged terminators + * between its lines; then the one terminator the move inserts, U+000A. + */ +function b7TargetAfter(t: string): string { + return B7_TARGET_BEFORE_LINES.join(t) + B7_MOVED_AFTER_LINES.join(t) + X2_LF; +} + +// The code-source counterpart (TEST-SPEC T6.5-7), fully composed — no +// import is added, so no latitude: two `.ts` files of the configured code +// group, each importing the origin module, the target module, and the +// retained third module (SPEC 4: default bindings, `.xspec` specifiers +// resolved as 2.1), whose only references through the origin binding +// (`ORG`) are markers (SPEC 4.5) on nodes of the moved subtree — the moved +// root `org.mv` and its descendant `org.mv.leaf` — beside a marker through +// the target binding (`TGT.hub`) and one through the third (`Keep.keep`). +// The variants differ in the origin declaration's line alone: alone on its +// line (between the other two) in `src/own-line.ts`; following the third +// module's declaration on a shared line in `src/shared-line.ts`. As in the +// MDX fixture, the removed declaration carries no `;` — the declaration +// spans `import ORG from "../specs/Origin.xspec"` exactly, so its extent +// reaches no terminator character whose membership could be argued — while +// the retained declarations keep theirs (TypeScript's grammar admits both, +// a line break ending the `;`-less declaration). +const B7_TS_OWN = "src/own-line.ts"; +const B7_TS_SHARED = "src/shared-line.ts"; + +const B7_TS_OWN_BEFORE_LINES: readonly string[] = [ + 'import TGT from "../specs/Target.xspec";', + 'import ORG from "../specs/Origin.xspec"', + 'import Keep from "../specs/Keep.xspec";', + "", + "export function ownLine(): void {", + " ORG.org.mv;", + " ORG.org.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +]; + +const B7_TS_SHARED_BEFORE_LINES: readonly string[] = [ + 'import Keep from "../specs/Keep.xspec"; import ORG from "../specs/Origin.xspec"', + 'import TGT from "../specs/Target.xspec";', + "", + "export function sharedLine(): void {", + " ORG.org.mv;", + " ORG.org.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +]; + +// Expected code bytes, composed from the rules of SPEC 6.4/6.5 and 3 — not +// from any product output: +// - The moved markers are rewritten through the EXISTING target binding +// (SPEC 6.5: an import is added only where the file lacks the binding): +// re-rooted at `TGT` with the prefix `org.mv` replaced by `mv`, dot access +// kept (SPEC 6.4: minimal in-place edits preserve access form; every +// segment is an identifier), so `ORG.org.mv` → `TGT.mv` and +// `ORG.org.mv.leaf` → `TGT.mv.leaf`, each marker's `;`, indentation, and +// trailing comment untouched. +// - The origin binding is left without references, so its declaration is +// removed with 6.5's exact extent: in the own-line variant, the line left +// empty purely by the deletion is dropped with its whole terminator, both +// bytes of a CRLF (SPEC 6.5, 3); in the shared-line variant, the +// declaration's own characters ALONE are deleted, so the retained `Keep` +// import, its `;`, AND the separating U+0020 survive byte-for-byte — the +// kept line ends `"../specs/Keep.xspec"; ` with a trailing space before +// its terminator (an explicit concatenation below, so the byte is loud). +// - The `TGT.hub` and `Keep.keep` markers, the retained imports, and every +// other byte are unchanged — every terminator kept byte-for-byte, none +// inserted, so the lines join with the staged kind: a product reprinting +// the code file on removal — re-indenting, dropping the comment, or +// normalizing `;`, whitespace, or terminators — fails here while passing +// every resolution-only assertion. +const B7_TS_OWN_AFTER_LINES: readonly string[] = [ + 'import TGT from "../specs/Target.xspec";', + 'import Keep from "../specs/Keep.xspec";', + "", + "export function ownLine(): void {", + " TGT.mv;", + " TGT.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +]; + +const B7_TS_SHARED_AFTER_LINES: readonly string[] = [ + 'import Keep from "../specs/Keep.xspec";' + " ", + 'import TGT from "../specs/Target.xspec";', + "", + "export function sharedLine(): void {", + " TGT.mv;", + " TGT.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +]; + +const B7_MOVE_ARGV = [ + "move", + "specs/Origin.mdx#org.mv", + "specs/Target.mdx#mv", +] as const; + +/** + * One run of T6.5-7's fixtures — the MDX fixture and both code variants, + * staged together — with every line terminator of its staged files one kind + * (TEST-SPEC T6.5-7: each fixture recurs with every terminator CRLF, and + * again with every one a lone CR; SPEC 3: each one terminator). + */ +interface B7Kind { + /** The kind as the failure messages name it. */ + readonly kind: string; + /** The terminator ending every line of every staged file. */ + readonly terminator: string; + /** `xspec.config.ts`: one spec group and one code group (SPEC 7.2). */ + readonly config: string | StagedTs; + /** The fixture's staged files, by workspace-relative path. */ + readonly files: Readonly<Record<string, InitialFileContents>>; +} + +/** + * The shared configuration's bytes with every terminator respelled `t`: the + * configuration is one of the staged files the re-runs respell (its lines + * hold U+000A terminators alone, checked here). + */ +function b7ConfigSource(t: string): string { + const source = SPEC_AND_CODE_CONFIG.source; + if (typeof source !== "string" || source.includes(X2_CR)) { + throw new Error( + "T6.5-7: the shared configuration record is expected as text whose " + + "every terminator is U+000A", + ); + } + return source.split(X2_LF).join(t); +} + +/** + * A terminator-kind re-run's staging (SPEC 3, 6.5): every staged file — the + * configuration included — spelled with `t`. Its workspace is created after + * the body's first product invocation, so each file is a staged-source + * record (S-9's timing clause), registered here at module load. + */ +function b7TerminatorKind(kind: string, t: string): B7Kind { + const name = (what: string): string => `T6.5-7 ${kind} re-run ${what}`; + return { + kind, + terminator: t, + config: stagedTs( + name("xspec.config.ts — one spec group and one code group"), + b7ConfigSource(t), + ), + files: { + [B7_ORIGIN]: stagedMdx( + name(`${B7_ORIGIN} — the target module imported under two bindings`), + B7_ORIGIN_BEFORE_LINES.join(t), + ), + [B7_TARGET]: stagedMdx( + name(`${B7_TARGET} — the move's target`), + B7_TARGET_BEFORE_LINES.join(t), + ), + [B7_KEEP]: stagedMdx( + name(`${B7_KEEP} — the retained third module`), + B7_KEEP_LINES.join(t), + ), + [B7_TS_OWN]: stagedTs( + name(`${B7_TS_OWN} — the origin-module import alone on its line`), + B7_TS_OWN_BEFORE_LINES.join(t), + ), + [B7_TS_SHARED]: stagedTs( + name(`${B7_TS_SHARED} — the origin-module import on a shared line`), + B7_TS_SHARED_BEFORE_LINES.join(t), + ), + }, + }; +} + +// The first run stages U+000A terminators (its workspace precedes every +// product invocation, so its files are plain contents); the re-runs stage +// CRLF and lone CR. +const B7_KINDS: readonly B7Kind[] = [ + { + kind: "LF", + terminator: X2_LF, + config: SPEC_AND_CODE_CONFIG, + files: { + [B7_ORIGIN]: B7_ORIGIN_BEFORE_LINES.join(X2_LF), + [B7_TARGET]: B7_TARGET_BEFORE_LINES.join(X2_LF), + [B7_KEEP]: B7_KEEP_LINES.join(X2_LF), + [B7_TS_OWN]: B7_TS_OWN_BEFORE_LINES.join(X2_LF), + [B7_TS_SHARED]: B7_TS_SHARED_BEFORE_LINES.join(X2_LF), + }, + }, + b7TerminatorKind("CRLF", X2_CRLF), + b7TerminatorKind("lone CR", X2_CR), +]; + +/** + * One run of T6.5-7's fixtures under `k`'s terminators: the staging, its + * `build`, the move, every staged file byte-asserted against bytes composed + * for `k` from the rules of SPEC 6.4/6.5 and 3 (never from product output), + * then `check`. + */ +async function runB7Kind(product: ProductBinding, k: B7Kind): Promise<void> { + const t = k.terminator; + const at = `T6.5-7 (${k.kind} terminators)`; + await withWorkspace(k.config, k.files, async (workspace) => { + // Premise: the staging is valid — most acutely, the shared lines' two + // import declarations parse as two bindings in MDX (SPEC 2.1) and in + // TypeScript (SPEC 4) alike, and every marker resolves (SPEC 4.5) — so + // a later failure is the move's, not the staging's. + await buildOk(product, workspace, `${at} \`build\` over the staging`); + + await expectExit( + product, + workspace, + [...B7_MOVE_ARGV], + 0, + `${at} \`move specs/Origin.mdx#org.mv specs/Target.mdx#mv\``, + ); + + await assertFileBytes( + workspace.path(B7_ORIGIN), + B7_ORIGIN_AFTER_LINES.join(t), + `${at}: 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 whole terminator " + + "(both bytes of a CRLF), 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; " + + "the moved construct's emptied line dropped likewise, every other " + + "staged terminator kept byte-for-byte (SPEC 6.5, 2.1, 3; H-4, " + + "normalizing nothing: an edit layer splitting lines on U+000A " + + "alone leaves the lone-CR origin's emptied lines behind)", + ); + await assertFileBytes( + workspace.path(B7_TARGET), + b7TargetAfter(t), + `${at}: 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, its staged terminators kept, plus U+000A at end of " + + "file — never the file's own terminator style, and none before " + + "the moved text, the final line being terminated (SPEC 6.5, 6.4, " + + "3; H-4, normalizing nothing)", + ); + await assertFileBytes( + workspace.path(B7_KEEP), + B7_KEEP_LINES.join(t), + `${at}: 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)", + ); + + // The code-source counterpart: the import-removal rule binds code + // sources as it binds MDX, and the moved markers are rewritten through + // the binding the file already has. + await assertFileBytes( + workspace.path(B7_TS_OWN), + B7_TS_OWN_AFTER_LINES.join(t), + `${at}: the own-line code variant after the move — the ` + + "origin-module import, its binding left without references, is " + + "removed with 6.5's exact extent (its line dropped with its whole " + + "terminator, both bytes of a CRLF), the moved markers are " + + "rewritten through the existing target binding (`TGT.mv`, " + + "`TGT.mv.leaf`; no import added), and every other byte — the " + + "retained imports, the `TGT.hub` and `Keep.keep` markers, " + + "indentation, `;`, the trailing comment, and every staged " + + "terminator — is unchanged (SPEC 6.5, 6.4, 4.5, 3; H-4, " + + "normalizing nothing: a product reprinting the code file on " + + "removal fails here)", + ); + await assertFileBytes( + workspace.path(B7_TS_SHARED), + B7_TS_SHARED_AFTER_LINES.join(t), + `${at}: the shared-line code variant after the move — the ` + + "origin-module declaration's own characters alone are deleted " + + "from the line it shares with the retained third-module import, " + + "which is kept byte-for-byte (its `;` and the separating space " + + "included) on its kept line, the moved markers are rewritten " + + "through the existing target binding, and every other byte, " + + "every staged terminator included, is unchanged (SPEC 6.5, 6.4, " + + "4.5, 3; H-4, normalizing nothing)", + ); + + // Soundness guard on the composed expectation itself: everything + // resolves after the move — if the product's bytes matched the expected + // bytes yet a reference or import failed to resolve, the 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, + `${at} \`check\` immediately after the move — every converted, ` + + "re-identified, and re-rooted reference (MDX references and TS " + + "markers alike) resolves and no staleness remains (SPEC 6.5, " + + "12.2, 14.10)", + ); + }); +} + +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, the single-quoted local reference and the single-quoted descendant `id` attribute each re-identified by prefix replacement with their quote spellings preserved; and the code-source counterpart — two `.ts` files whose origin-module import, its binding referenced only by markers on moved nodes, is removed with the same exact extent (own-line and shared-line variants) while the moved markers are rewritten through the existing target binding, each file byte-equal to its composed expectation; every fixture, the code variants included, recurs with every terminator of its staged files CRLF and again with every one a lone CR, the expected bytes composed by the same rules — the own-line declaration's line dropped with its whole terminator, both bytes of a CRLF, every other staged terminator kept byte-for-byte, and every terminator the move inserts U+000A (SPEC 6.5, 6.4, 3, 2.1, 2.7, 4.5; H-4, normalizing nothing)", + run: async (product) => { + for (const k of B7_KINDS) { + await runB7Kind(product, k); + } + }, +}); + +// --------------------------------------------------------------------------- + +// T6.5-8 Added-import insertion discipline (TEST-SPEC T6.5-8): the +// addition-side byte contract of SPEC 6.5 — an added import is inserted as +// a line of its own, the declaration's characters followed by U+000A, +// preceded by one when the insertion point is not at the start of a line, +// judged over the composed text with the insertion's own result absent — +// and the declaration's exact spelling, asserted with the identifier choice +// alone left free and the offset confined by 6.5's preference (a line-start +// admissible offset, which each arm's receiving file holds, is taken over +// any other, so the mid-line form is never conforming here; T6.5-13 forces +// it). Each arm's receiving file has its expected post-move bytes composed +// from the rules of 6.4/6.5 and 3 up to exactly those two unknowns: the +// fresh identifier is read off the rewritten reference (the one place +// 6.4's pinned spellings make it observable), and +// `assertAddedImportInsertion` isolates the single inserted run by diff +// against the composed bytes and reads it as byte-exactly 6.5's spelling — +// `import <X> from "../specs/target.xspec"` in the TS arm (the canonical +// ascent from `src/`), `"./target.xspec"` in the MDX origin arm, +// `"./origin.xspec"` in the MDX target arm: single spaces, no statement +// terminator, the specifier double-quoted — followed by U+000A at a +// line-start offset, line starts judged by 3's terminators. Three +// section-move arms over `specs/origin.mdx`, `specs/target.mdx`, and the +// code file `src/c.ts`: +// - TS: `src/c.ts` is `import O from "../specs/origin.xspec"`, U+000A, then +// a function `f` holding the markers `O.x`, on the moved `x`, and `O.w`, +// on the unmoved `w` (`move specs/origin.mdx#x specs/target.mdx#y`), so +// the rewrite needs a target-module binding the file lacks while the +// origin import keeps its remaining reference and stays. The start of +// line 2 is the file's one line-start admissible offset — offset 0 +// follows no statement's end, and one after `f` is untimely for the moved +// marker, a non-import statement standing between it and `O`'s +// declaration (6.5) — so the run is pinned there. +// - MDX origin: the origin file holds a retained third-module import +// (`Keep`, referenced by `org.stay`; grammar-permitted offsets exist +// beside it, and freshness is live against its binding) and, outside the +// moved subtree, a local string reference to a moved descendant +// (`d={"org.mv.leaf"}`), whose conversion to imported form +// (`<fresh>.mv.leaf`, dot access) makes the origin file itself gain the +// target module's import (`move specs/origin.mdx#org.mv +// specs/target.mdx#mv`, as in the MDX target arm). +// - MDX target (the third conversion direction): the moved subtree holds a +// local string reference to an origin node outside it, `org.base-line`, +// whose second segment is not identifier-valid; the target file — an +// existing discovered source holding a retained `Keep` import but no +// import of the origin module — gains that import, the reference +// converting to imported form through the fresh binding in 6.4's pinned +// spellings (`<fresh>.org["base-line"]`: dot access, then double-quoted +// computed access), the moved text otherwise byte-identical; the origin +// loses the section and gains no import. +// Every file the two unknowns do not touch is asserted byte-equal to its +// composed expectation; the arm's edge set and a post-move `check` guard +// the compositions' soundness (every rewritten reference resolves). Each +// arm recurs with every line terminator of its staged files — the +// configuration included — CRLF, and again with every one a lone CR +// (`A8_KINDS`): the added run is still exactly the declaration followed by +// U+000A, at a line start judged by 3's terminators — in the TS arm the +// start of line 2, after the CRLF or lone CR ending line 1 — and every +// staged terminator is kept byte-for-byte, the moved text's own included, +// with U+000A after it; this fails a product matching the file's +// terminator style, writing CRLF or CR after the declaration, and one +// judging line starts by U+000A alone, for which no lone CR ends a line. +const A8_ORIGIN = "specs/origin.mdx"; +const A8_TARGET = "specs/target.mdx"; +const A8_KEEP = "specs/keep.mdx"; +const A8_CODE = "src/c.ts"; +const A8_ORIGIN_MODULE = "specs/origin.xspec"; +const A8_TARGET_MODULE = "specs/target.xspec"; + +/** The TS arm's move: the top-level `x` to the target's top-level `y`. */ +const A8_TS_ARGV = [ + "move", + "specs/origin.mdx#x", + "specs/target.mdx#y", +] as const; + +/** The MDX arms' move: `org.mv` to the target's top-level `mv`. */ +const A8_MDX_ARGV = [ + "move", + "specs/origin.mdx#org.mv", + "specs/target.mdx#mv", +] as const; + +// T6.5-8's files are held as line arrays (a final `""` for the final +// terminator) joined with each run's terminator; every staging is a +// staged-source record built at module load by `a8Kind` (the LF run's TS +// arm is the body's first workspace, every later one follows a product +// invocation: S-9's timing clause, helpers/staged-mdx.ts). + +/** A plain target file: one top-level section, no imports. */ +const A8_PLAIN_TARGET_LINES: readonly string[] = [ + '<S id="tgt">', + "Target text.", + "</S>", + "", +]; + +/** + * The plain target's LF bytes — staged by T6.5-8's LF run and T6.5-9's code + * arm at `specs/target.mdx`, and by T6.6-2's move arm and T6.6-6 at + * `specs/Target.mdx`, so section-6.6.ts aliases this record (one record + * for identical bytes across tests). + */ +export const A8_PLAIN_TARGET = stagedMdx( + "T6.5-8/T6.5-9/T6.6-2/T6.6-6 the plain target (specs/target.mdx in T6.5-8 and T6.5-9, specs/Target.mdx elsewhere)", + A8_PLAIN_TARGET_LINES.join(X2_LF), +); + +/** The retained third module of the MDX arms (`Keep`). */ +const A8_KEEP_LINES: readonly string[] = [ + '<S id="keep">', + "Keep text.", + "</S>", + "", +]; + +// TS arm. The origin's moved `x` and unmoved `w` are top-level leaves. +const A8_TS_ORIGIN_LINES: readonly string[] = [ + '<S id="x">', + "Origin x text.", + "</S>", + "", + '<S id="w">', + "Kept w text.", + "</S>", + "", +]; + +// Composed from SPEC 6.5 and 3: the moved construct's own characters are +// deleted in place; the line that deletion leaves empty is dropped with its +// whole terminator; the blank line after it was blank before the deletion +// and stays, so the file opens with that terminator. No import is gained: +// nothing left in the origin references a moved node. +const A8_TS_ORIGIN_AFTER_LINES: readonly string[] = [ + "", + '<S id="w">', + "Kept w text.", + "</S>", + "", +]; + +/** The TS arm's moved text, re-identified by prefix replacement `x` → `y`. */ +const A8_TS_MOVED_LINES: readonly string[] = [ + '<S id="y">', + "Origin x text.", + "</S>", +]; + +/** Line 1 of `src/c.ts`: the origin import, exactly as T6.5-8 pins it. */ +const A8_CODE_LINE_1 = 'import O from "../specs/origin.xspec"'; + +/** + * `src/c.ts` around its one variable part, the marker on the moved node + * (`O.x` before the move, `<fresh>.y` after): the origin import on line 1, + * then a function `f` holding that marker and `O.w` on the unmoved `w` — + * both attributed to `f` (4.6). The origin import keeps `O.w` and stays. + */ +function a8Code(marker: string): readonly string[] { + return [A8_CODE_LINE_1, "function f() {", ` ${marker};`, " O.w;", "}", ""]; +} + +// The rewritten marker: a root not preceded by an identifier character or a +// `.`, then `.y;` (`O.w;` and the declarations never match, nor does an +// unrewritten `O.x;`). +const A8_CODE_REWRITTEN = /(?<![A-Za-z0-9_$.])([A-Za-z_$][A-Za-z0-9_$]*)\.y;/g; + +// MDX origin arm. +const A8_ORG_ORIGIN_LINES: readonly string[] = [ + 'import Keep from "./keep.xspec"', + "", + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv">', + "Moved head text.", + "", + '<S id="org.mv.leaf">', + "Moved leaf text.", + "</S>", + "</S>", + "", + '<S id="org.use" d={"org.mv.leaf"}>', + "Uses the moved leaf.", + "</S>", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", +]; + +// The origin's expected post-move lines WITHOUT the added import (SPEC +// 6.4/6.5, 3): the moved construct deleted in place with its emptied merged +// line dropped (the two blank neighbours stay), the local reference to the +// moved descendant converted to imported form in 6.4's pinned spelling — +// rooted at the fresh binding, dot access for the identifier-valid segments +// — and the retained `Keep` import kept byte-for-byte. +function a8OrgOriginBase(root: string): readonly string[] { + return [ + 'import Keep from "./keep.xspec"', + "", + '<S id="org">', + "Origin holder text.", + "", + "", + `<S id="org.use" d={${root}.mv.leaf}>`, + "Uses the moved leaf.", + "</S>", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", + ]; +} + +/** The MDX origin arm's moved text, re-identified (`org.mv` → `mv`). */ +const A8_ORG_MOVED_LINES: readonly string[] = [ + '<S id="mv">', + "Moved head text.", + "", + '<S id="mv.leaf">', + "Moved leaf text.", + "</S>", + "</S>", +]; + +const A8_ORG_REWRITTEN = + /<S id="org\.use" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.mv\.leaf\}>/g; + +// MDX target arm. `org.base-line` is a valid ID (SPEC 1.4 forbids `.`, `#`, +// whitespace, and control characters alone) whose second segment is not a +// TypeScript identifier. +const A8_TGT_ORIGIN_LINES: readonly string[] = [ + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv" d={"org.base-line"}>', + "Moved text.", + "</S>", + "", + '<S id="org.base-line">', + "Base line text.", + "</S>", + "</S>", + "", +]; + +// Composed from SPEC 6.5 and 3 (as the TS arm's origin): the section gone, +// its merged line dropped, the blank neighbours kept; no import gained. +const A8_TGT_ORIGIN_AFTER_LINES: readonly string[] = [ + '<S id="org">', + "Origin holder text.", + "", + "", + '<S id="org.base-line">', + "Base line text.", + "</S>", + "</S>", + "", +]; + +const A8_TGT_TARGET_LINES: readonly string[] = [ + 'import Keep from "./keep.xspec"', + "", + '<S id="tgt" d={Keep.keep}>', + "Target text.", + "</S>", + "", +]; + +// The MDX target arm's moved text (SPEC 6.4/6.5): its `id` re-identified +// and its local reference to the origin node converted to imported form in +// 6.4's pinned spellings — the fresh root, dot access for the +// identifier-valid `org`, double-quoted computed access for `base-line` — +// otherwise byte-identical. +function a8TgtMoved(root: string): readonly string[] { + return [`<S id="mv" d={${root}.org["base-line"]}>`, "Moved text.", "</S>"]; +} + +const A8_TGT_REWRITTEN = + /<S id="mv" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.org\["base-line"\]\}>/g; + +/** + * A target file after the section move (SPEC 6.5, 3): its staged lines + * joined with `t`, then the moved text — its own terminators the staged + * ones — inserted at the end of the file (a top-level `new-id`) and + * followed by U+000A, never the file's own terminator style; the staged + * final line is terminated, so the insertion point lies at a line start + * and no terminator precedes the moved text. + */ +function a8TargetAfter( + lines: readonly string[], + moved: readonly string[], + t: string, +): string { + return lines.join(t) + moved.join(t) + X2_LF; +} + +/** Names an added import may not bind in an MDX source (SPEC 2.1, 14.15). */ +const A8_MDX_RESERVED = ["S", "Spec", "text"].map((name) => ({ + name, + why: "a compiler-provided name no import in an xspec source file may bind", +})); + +/** One T6.5-8 arm: a receiving file gaining exactly one import. */ +interface AddedImportArm { + readonly label: string; + readonly config: StagedTs; + readonly files: Readonly<Record<string, InitialFileContents>>; + /** The section-form move the arm runs. */ + readonly argv: readonly string[]; + /** Workspace-relative path of the file gaining the import. */ + readonly receiving: string; + /** Matches the one rewritten reference; group 1 is the fresh root. */ + readonly rewritten: RegExp; + /** The rewritten reference's expected spelling, for diagnoses. */ + readonly rewrittenForm: string; + /** The receiving file's composed post-move bytes without the import. */ + readonly base: (root: string) => string; + /** Workspace-relative module path the added import must designate. */ + readonly expectedModule: string; + readonly moduleLabel: string; + /** + * The receiving file's one line-start admissible offset, where the test + * pins the run (the TS arm's start of line 2); undefined where the + * choice among line-start offsets is the product's. + */ + readonly pinnedOffset: + { readonly offset: number; readonly where: string } | undefined; + /** Identifiers the fresh binding may not be, each with its reason. */ + readonly forbiddenRoots: readonly { name: string; why: string }[]; + /** Files whose post-move bytes are fully composed (no latitude). */ + readonly composed: readonly { + rel: string; + expected: string; + why: string; + }[]; + /** The workspace's complete edge set of this kind after the move. */ + readonly edgeKind: "depends" | "references"; + readonly edges: readonly GraphEdge[]; +} + +/** + * One run of T6.5-8's three arms with every line terminator of every staged + * file one kind (TEST-SPEC T6.5-8: each arm recurs with every terminator + * CRLF, and again with every one a lone CR; SPEC 3: each one terminator). + */ +interface A8Kind { + /** The kind as the failure messages name it. */ + readonly kind: string; + readonly arms: readonly AddedImportArm[]; +} + +/** + * A shared configuration record's bytes with every terminator respelled + * `t`: the configuration is one of the staged files the re-runs respell + * (its lines hold U+000A terminators alone, checked here). + */ +function a8ConfigSource(config: StagedTs, t: string): string { + const source = config.source; + if (typeof source !== "string" || source.includes(X2_CR)) { + throw new Error( + `T6.5-8: the configuration record ${JSON.stringify(config.name)} is ` + + "expected as text whose every terminator is U+000A", + ); + } + return source.split(X2_LF).join(t); +} + +/** + * T6.5-8's three arms with every staged file — the configuration included + * — spelled with terminator `t`, each file a staged-source record + * registered here at module load (the LF run reuses the shared + * configuration records and the plain target's), and every composed + * expectation built by the same rules from the same lines. + */ +function a8Kind(kind: string, t: string): A8Kind { + const lf = t === X2_LF; + const name = (what: string): string => + lf ? `T6.5-8 ${what}` : `T6.5-8 ${kind} re-run ${what}`; + const mdx = (what: string, lines: readonly string[]): StagedMdx => + stagedMdx(name(what), lines.join(t)); + const specAndCode = lf + ? SPEC_AND_CODE_CONFIG + : stagedTs( + name("xspec.config.ts — one spec group and one code group"), + a8ConfigSource(SPEC_AND_CODE_CONFIG, t), + ); + const specsOnly = lf + ? SPECS_ONLY_CONFIG + : stagedTs( + name("xspec.config.ts — exactly one spec group"), + a8ConfigSource(SPECS_ONLY_CONFIG, t), + ); + const plainTarget = lf + ? A8_PLAIN_TARGET + : mdx(`${A8_TARGET} — the plain target`, A8_PLAIN_TARGET_LINES); + const keep = mdx(`${A8_KEEP} — the retained third module`, A8_KEEP_LINES); + const keepAfter = { + rel: A8_KEEP, + expected: A8_KEEP_LINES.join(t), + why: "an uninvolved bystander, untouched, every staged terminator kept", + }; + const appended = + "the re-identified moved text appended at end of file — its own " + + "staged terminators kept — plus U+000A, never the file's own " + + "terminator style, the file otherwise byte-identical"; + const deleted = + "the moved section deleted in place with its emptied merged line " + + "dropped with its whole terminator, the blank neighbours and every " + + "other staged terminator kept, and no import gained"; + const keepRoot = { + name: "Keep", + why: "the identifier the file's retained third-module import already binds", + }; + return { + kind, + arms: [ + { + label: "TS", + config: specAndCode, + files: { + [A8_ORIGIN]: mdx( + `TS arm ${A8_ORIGIN} — the moved x and the unmoved w`, + A8_TS_ORIGIN_LINES, + ), + [A8_TARGET]: plainTarget, + [A8_CODE]: stagedTs( + name( + `TS arm ${A8_CODE} — the origin import, then f holding O.x and O.w`, + ), + a8Code("O.x").join(t), + ), + }, + argv: A8_TS_ARGV, + receiving: A8_CODE, + rewritten: A8_CODE_REWRITTEN, + rewrittenForm: "marker `<binding>.y;`", + base: (root) => a8Code(`${root}.y`).join(t), + expectedModule: A8_TARGET_MODULE, + moduleLabel: "the target module", + pinnedOffset: { + // All ASCII: the string length is the byte length. + offset: A8_CODE_LINE_1.length + t.length, + where: `the start of line 2, after the ${kind} terminator ending the origin import's line`, + }, + forbiddenRoots: [ + { + name: "O", + why: "the identifier the file's retained origin import already binds", + }, + { + name: "f", + why: "the identifier the file's function declaration already binds", + }, + ], + composed: [ + { + rel: A8_ORIGIN, + expected: A8_TS_ORIGIN_AFTER_LINES.join(t), + why: deleted, + }, + { + rel: A8_TARGET, + expected: a8TargetAfter( + A8_PLAIN_TARGET_LINES, + A8_TS_MOVED_LINES, + t, + ), + why: appended, + }, + ], + edgeKind: "references", + edges: [ + { from: `${A8_CODE}#f`, to: `${A8_TARGET}#y`, kind: "references" }, + { from: `${A8_CODE}#f`, to: `${A8_ORIGIN}#w`, kind: "references" }, + ], + }, + { + label: "MDX origin", + config: specsOnly, + files: { + [A8_KEEP]: keep, + [A8_ORIGIN]: mdx( + `MDX-origin arm ${A8_ORIGIN} — a local reference to a moved descendant`, + A8_ORG_ORIGIN_LINES, + ), + [A8_TARGET]: plainTarget, + }, + argv: A8_MDX_ARGV, + receiving: A8_ORIGIN, + rewritten: A8_ORG_REWRITTEN, + rewrittenForm: '`<S id="org.use" d={<binding>.mv.leaf}>`', + base: (root) => a8OrgOriginBase(root).join(t), + expectedModule: A8_TARGET_MODULE, + moduleLabel: "the target module", + pinnedOffset: undefined, + forbiddenRoots: [keepRoot, ...A8_MDX_RESERVED], + composed: [ + { + rel: A8_TARGET, + expected: a8TargetAfter( + A8_PLAIN_TARGET_LINES, + A8_ORG_MOVED_LINES, + t, + ), + why: appended, + }, + keepAfter, + ], + edgeKind: "depends", + edges: [ + { + from: `${A8_ORIGIN}#org.use`, + to: `${A8_TARGET}#mv.leaf`, + kind: "depends", + }, + { + from: `${A8_ORIGIN}#org.stay`, + to: `${A8_KEEP}#keep`, + kind: "depends", + }, + ], + }, + { + label: "MDX target", + config: specsOnly, + files: { + [A8_KEEP]: keep, + [A8_ORIGIN]: mdx( + `MDX-target arm ${A8_ORIGIN} — the moved section's local reference to org.base-line`, + A8_TGT_ORIGIN_LINES, + ), + [A8_TARGET]: mdx( + `MDX-target arm ${A8_TARGET} — the retained Keep import`, + A8_TGT_TARGET_LINES, + ), + }, + argv: A8_MDX_ARGV, + receiving: A8_TARGET, + rewritten: A8_TGT_REWRITTEN, + rewrittenForm: '`<S id="mv" d={<binding>.org["base-line"]}>`', + base: (root) => a8TargetAfter(A8_TGT_TARGET_LINES, a8TgtMoved(root), t), + expectedModule: A8_ORIGIN_MODULE, + moduleLabel: "the origin module", + pinnedOffset: undefined, + forbiddenRoots: [keepRoot, ...A8_MDX_RESERVED], + composed: [ + { + rel: A8_ORIGIN, + expected: A8_TGT_ORIGIN_AFTER_LINES.join(t), + why: deleted, + }, + keepAfter, + ], + edgeKind: "depends", + edges: [ + { + from: `${A8_TARGET}#mv`, + to: `${A8_ORIGIN}#org.base-line`, + kind: "depends", + }, + { from: `${A8_TARGET}#tgt`, to: `${A8_KEEP}#keep`, kind: "depends" }, + ], + }, + ], + }; +} + +// The first run stages U+000A terminators (its TS arm's workspace precedes +// every product invocation); the re-runs stage CRLF and lone CR. +const A8_KINDS: readonly A8Kind[] = [ + a8Kind("LF", X2_LF), + a8Kind("CRLF", X2_CRLF), + a8Kind("lone CR", X2_CR), +]; + +/** + * The identifier the receiving file's rewritten reference is rooted at — + * the value-unpinned fresh binding (SPEC 6.5), read off the one place 6.4's + * pinned spelling makes it observable; diagnosed when the reference is not + * spelled as 6.4 pins it (or is rewritten more or less than once). + */ +function addedImportReferenceRoot( + text: string, + arm: AddedImportArm, + context: string, +): string { + const matches = [...text.matchAll(arm.rewritten)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${arm.receiving} must hold exactly one ` + + `${arm.rewrittenForm} — the reference to the moved node rewritten ` + + `through a binding of ${arm.moduleLabel} in 6.4's pinned spelling ` + + `(dot access for identifier-valid segments, double-quoted computed ` + + `access for the others; SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * Stage one arm under one terminator kind, run the section-form move, and + * assert the receiving file is its composed post-move bytes with exactly + * one import of the needed module added under 6.5's line discipline — + * at the pinned offset where the arm pins one — binding the fresh + * identifier the rewritten reference uses; the fully composed files + * byte-equal; the edge set and a clean `check` as soundness guards. + */ +async function runAddedImportArm( + product: ProductBinding, + kind: string, + arm: AddedImportArm, +): Promise<void> { + const context = `T6.5-8 ${arm.label} arm (${kind} terminators)`; + await withWorkspace(arm.config, arm.files, async (workspace) => { + // Premise: the staging is valid (every reference and marker resolves), + // so a later failure is the move's, not the staging's. + await buildOk(product, workspace, `${context} \`build\` over the staging`); + await expectExit( + product, + workspace, + [...arm.argv], + 0, + `${context} \`${arm.argv.join(" ")}\``, + ); + + const text = await readSourceText(workspace, arm.receiving, context); + const root = addedImportReferenceRoot(text, arm, context); + for (const forbidden of arm.forbiddenRoots) { + if (root === forbidden.name) { + fail( + `${context}: the added import binds \`${forbidden.name}\`, ` + + `${forbidden.why} — an added import binds fresh identifiers ` + + `colliding with no binding already in the file (SPEC 6.5, ` + + `2.1, 4, 14.15)`, + ); + } + } + // Composed from the rules of 6.4/6.5 and 3 up to the two unknowns — + // the fresh identifier (now known) and the insertion offset (isolated + // by the helper, which reads the run at every admissible offset and + // accepts a line-start one alone, line starts judged by 3's + // terminators: each receiving file holds one; in the TS arm the one, + // the start of line 2, pinned). + const pinned = arm.pinnedOffset; + assertAddedImportInsertion( + { + rel: arm.receiving, + base: Buffer.from(arm.base(root), "utf8"), + actual: await workspace.readBytes(arm.receiving), + importerDir: posixPath.dirname(arm.receiving), + expectedModule: arm.expectedModule, + identifier: root, + ...(pinned === undefined ? {} : { pinnedOffset: pinned }), + }, + `${context}: ${arm.receiving} after the move is its composed ` + + `post-move bytes, every staged terminator kept byte-for-byte, with ` + + `exactly one import of ${arm.moduleLabel} added as a line of its ` + + `own — byte-exactly 6.5's spelling (single spaces, no statement ` + + `terminator, the specifier double-quoted in its canonical relative ` + + `spelling) followed by U+000A, never the file's own terminator ` + + `style, at a line-start offset judged by 3's terminators, which the ` + + `file holds and 6.5 takes over any other` + + (pinned === undefined ? "" : ` — here ${pinned.where}`) + + ` — binding the fresh identifier the rewritten reference is rooted ` + + `at, no other byte inserted (SPEC 6.5, 2.1, 6.4, 3; T6.5-8)`, + ); + for (const file of arm.composed) { + await assertFileBytes( + workspace.path(file.rel), + file.expected, + `${context}: ${file.rel} after the move — ${file.why} (SPEC 6.5, ` + + `6.4, 3; H-4, normalizing nothing)`, + ); + } + + // Soundness guards on the compositions: the rewritten reference resolves + // to the moved node's new identity through the added binding, and + // nothing else changed hands. + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, arm.edgeKind, context), + arm.edges, + `${context}: the complete \`${arm.edgeKind}\` edge set after the ` + + `move — the rewritten reference reported under the moved node's ` + + `new identity, every other edge unchanged (SPEC 6.5, 5.2, 4.6)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the added import ` + + `and the rewritten reference resolve, the fresh binding collides ` + + `with nothing (14.15), and no staleness remains (SPEC 6.5, 12.2, ` + + `14.10)`, + ); + }); +} + +const T6_5_8 = defineProductTest({ + id: "T6.5-8", + title: + "added-import insertion discipline: the addition-side byte contract of 6.5 asserted value-blind — in three section-move arms over `specs/origin.mdx`, `specs/target.mdx`, and `src/c.ts` (a TS arm whose `src/c.ts` is `import O from \"../specs/origin.xspec\"`, U+000A, then a function `f` holding the markers `O.x` on the moved node and `O.w` on an unmoved one, so a target-module binding is added while the origin import stays, the start of line 2 the file's one line-start admissible offset; an MDX origin holding a retained third-module import and a local string reference to a moved descendant, converted to imported form so the origin itself gains the target module's import; an MDX target gaining the origin module's import for a moved local reference to an origin node with a non-identifier segment, converted to dot then double-quoted computed access) the receiving file's post-move bytes are composed from the rules of 6.4/6.5 and 3 up to the fresh identifier (read off the rewritten reference) and the choice among the receiving file's line-start admissible offsets, and the single inserted run isolated by diff is byte-exactly 6.5's spelling of the declaration — `import <X> from \"../specs/target.xspec\"` in the TS arm, `\"./target.xspec\"` in the MDX origin arm, `\"./origin.xspec\"` in the MDX target arm: single spaces, no statement terminator, the specifier double-quoted in its canonical relative spelling — followed by U+000A at a line-start offset judged by 3's terminators (which each file holds, 6.5 taking it over any other, so the mid-line form is never conforming here), in the TS arm exactly the start of line 2, the fresh identifier its only unpinned run, no other byte inserted; the fully composed files byte-equal, the edge set exact, `check` clean; each arm recurs with every terminator of its staged files CRLF and again with every one a lone CR, the added run still exactly the declaration followed by U+000A at a line start judged by 3's terminators — in the TS arm the start of line 2, after the CRLF or lone CR ending line 1 — and every staged terminator kept byte-for-byte (SPEC 6.5, 6.4, 2.1, 3; H-4, normalizing nothing)", + run: async (product) => { + for (const k of A8_KINDS) { + for (const arm of k.arms) { + await runAddedImportArm(product, k.kind, arm); + } + } + }, +}); + +// T6.5-9 — Fresh identifiers (TEST-SPEC T6.5-9). The freshness clauses of +// 6.5 — an added import's identifiers are bound by no declaration already +// in the file, in any scope and at value or type level alike, and equal no +// name the file already references (2.1, 4) — span, in a code file, local +// declarations as well as imports, and a breach is observable to xspec only +// in part: an added import sharing its identifier with a value-level +// declaration of the module scope is the collision of 14.15 (4.5), while +// one sharing it with a `type` alias collides with nothing to xspec, and +// even the consumer's compile reports that one only where the imported +// default export has a type meaning. A product checking freshness against +// import bindings alone passes T6.5-8's TS arm, whose receiving file leaves +// every plausible identifier free. The code arm is that arm re-staged — +// T6.5-8's `specs/origin.mdx` (`A8_TS_ORIGIN_LINES`), the plain +// `specs/target.mdx`, `move specs/origin.mdx#x specs/target.mdx#y`, and +// `src/c.ts` built around `a8Code` — with a receiving `src/c.ts` that +// additionally declares, at module scope, bindings pre-empting the +// identifiers a product would plausibly derive: spelled from the target +// file's basename `target` (as written, which is its lower-cased form, +// upper-cased, `Spec`- and `SPEC`-suffixed, and capitalized besides) and +// from the origin binding `O` with a digit (the digit is unspecified, so +// the two smallest counters are both staged) and with an underscore +// appended, as a local `const`, a `function`, a `class`, a `type` alias, +// and non-spec import bindings, each used trivially; the file compiles +// clean before the move under standard tooling (a fixture self-check). Its +// import declarations — T6.5-8's origin import on line 1, the non-spec +// lures' on line 2 — head it before every other statement, with no blank +// line after them, so its line-start admissible offsets are exactly the +// starts of lines 2 and 3, each directly after one of them (6.5: offset 0 +// follows no statement's end, and an added declaration after the first +// non-import statement would be untimely for the moved marker, that +// statement standing between it and `O`'s declaration). After the section +// move, the diff-isolated added run is asserted as T6.5-8 asserts it, +// confined to those two offsets (`assertAddedImportInsertion`'s +// `pinnedOffsets`); through H-2's standard-tooling channel +// (`test/helpers/tooling.ts`) the rewritten file compiles with no +// diagnostics — a collision with the `const`, `function`, or `class` is +// TS2440 (import declaration conflicts with local declaration), one with a +// non-spec import binding TS2300 (duplicate identifier), and an unrewritten +// or misrooted marker a type error against the regenerated modules — so the +// added identifier equals none of the value-level pre-empted names; `query +// edges` reports the moved marker's `references` edge to the moved node's +// new identity and the unmoved marker's through the retained origin +// binding; and `check` is clean (T6.5-3). The `type` alias's name is left +// to T6.5-22(a), whose universal check in the subprocess driver judges the +// added identifiers of every performed move (test/helpers/subprocess.ts): +// standard tooling accepts a default import beside a same-named alias when +// the imported default export has no type meaning, so compile-cleanliness +// does not reach it, and this test asserts nothing of it itself. The +// pre-empted set is a lure, not a bound: the identifier stays the product's +// choice (6.5's latitude), and compile-cleanliness is the assertion +// whatever the choice. + +/** The code arm's non-spec module, whose bindings pre-empt `Target` and `O2`. */ +const A9_UTIL = "src/util.ts"; +const A9_UTIL_SOURCE = ["export default 1;", "export const O2 = 2;", ""].join( + "\n", +); + +/** One pre-empting module-scope binding of the receiving code file. */ +interface PreemptedBinding { + readonly name: string; + /** The declaration kind holding the name. */ + readonly kind: string; + /** The derivation it is spelled from. */ + readonly derivation: string; +} + +/** + * The value-level lures, the reach of the compile-cleanliness assertion: + * every derivation TEST-SPEC T6.5-9 enumerates but the alias's — from the + * target file's basename `target` and the origin binding `O` — and the + * capitalized basename, over the `const`, `function`, `class`, and + * non-spec import kinds. The likeliest derivation, the basename as written, + * is a local `const`, so a product checking freshness against import + * bindings alone takes it and draws TS2440. + */ +const A9_VALUE_LURES: readonly PreemptedBinding[] = [ + { + name: "target", + kind: "a module-scope `const`", + derivation: "the target file's basename as written, its lower-cased form", + }, + { + name: "TARGET", + kind: "a `function` declaration", + derivation: "the target file's basename upper-cased", + }, + { + name: "targetSpec", + kind: "a `class` declaration", + derivation: "the target file's basename `Spec`-suffixed", + }, + { + name: "Target", + kind: "a non-spec import binding (the default import of `src/util.ts`)", + derivation: "the target file's basename capitalized", + }, + { + name: "O1", + kind: "a module-scope `const`", + derivation: "the origin binding's name with a digit appended", + }, + { + name: "O2", + kind: "a non-spec import binding (a named import of `src/util.ts`)", + derivation: "the origin binding's name with a digit appended", + }, + { + name: "O_", + kind: "a `function` declaration", + derivation: "the origin binding's name with an underscore appended", + }, +]; + +/** Line 2 of `src/c.ts`: the non-spec lures' import declaration. */ +const A9_UTIL_IMPORT_LINE = 'import Target, { O2 } from "./util.js"'; + +/** + * The lure declarations between the imports and `f`, every binding used + * trivially — rooted at local declarations, so none is a spec module + * reference (4.5) and none records an edge. The `type` alias pre-empts the + * basename `SPEC`-suffixed, `targetSPEC`, left to T6.5-22(a). + */ +const A9_LURE_LINES: readonly string[] = [ + "const target = Target + O2;", + "function TARGET(): number {", + " return target * 2;", + "}", + "class targetSpec {", + " readonly value = TARGET();", + "}", + "type targetSPEC = targetSpec;", + "const O1: targetSPEC = new targetSpec();", + "function O_(): number {", + " return O1.value;", + "}", + "O_();", +]; + +/** + * `src/c.ts` around its one variable part, the marker on the moved node + * (`O.x` before the move, `<fresh>.y` after): T6.5-8's TS arm file + * (`a8Code`) with the non-spec lures' import after its origin import and + * the lure declarations before `f`. + */ +function a9Code(marker: string): readonly string[] { + const lines = a8Code(marker); + return [ + ...lines.slice(0, 1), + A9_UTIL_IMPORT_LINE, + ...A9_LURE_LINES, + ...lines.slice(1), + ]; +} + +/** The import declarations heading `src/c.ts`, lines 1 and 2. */ +const A9_IMPORT_LINES: readonly string[] = [ + A8_CODE_LINE_1, + A9_UTIL_IMPORT_LINE, +]; + +/** + * The receiving file's line-start admissible offsets (SPEC 6.5): the start + * of the line directly after each import declaration heading it, as byte + * offsets into the composed text (every line U+000A-terminated). + */ +const A9_AFTER_IMPORT_OFFSETS: readonly number[] = A9_IMPORT_LINES.map( + (_, index) => + A9_IMPORT_LINES.slice(0, index + 1).reduce( + (sum, line) => sum + Buffer.byteLength(line, "utf8") + X2_LF.length, + 0, + ), +); + +const A9_FILES: Readonly<Record<string, InitialFileContents>> = { + [A8_ORIGIN]: stagedMdx( + "T6.5-9 code arm specs/origin.mdx — T6.5-8's TS arm origin, the moved x and the unmoved w", + A8_TS_ORIGIN_LINES.join(X2_LF), + ), + [A8_TARGET]: A8_PLAIN_TARGET, + [A8_CODE]: a9Code("O.x").join(X2_LF), + [A9_UTIL]: A9_UTIL_SOURCE, +}; + +/** The workspace's complete `references` edge set after the move. */ +const A9_EDGES: readonly GraphEdge[] = [ + { from: `${A8_CODE}#f`, to: `${A8_TARGET}#y`, kind: "references" }, + { from: `${A8_CODE}#f`, to: `${A8_ORIGIN}#w`, kind: "references" }, +]; + +/** + * The identifier the rewritten marker is rooted at — the value-unpinned + * fresh binding (SPEC 6.5), read off the one place 6.4's pinned spelling + * makes it observable (T6.5-8); diagnosed when the marker is not spelled as + * 6.4 pins it, or is rewritten more or less than once. + */ +function a9RewrittenMarkerRoot(text: string, context: string): string { + const matches = [...text.matchAll(A8_CODE_REWRITTEN)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${A8_CODE} must hold exactly one \`<fresh>.y;\` — the ` + + `marker on the moved node rewritten through a binding of the ` + + `target module in 6.4's pinned spelling (dot access, \`y\` being ` + + `identifier-valid; SPEC 6.5, 6.4); found ${String(matches.length)} ` + + `in ${JSON.stringify(text)}`, + ); + } + return root; +} + +// Spec-source arm (TEST-SPEC T6.5-9): the freshness rule's other clauses — +// an added import's identifiers are distinct from the others added there +// and, in a spec source, none of the compiler-provided names `S`, `Spec`, +// `text` (SPEC 6.5, 2.1). A spec target lacking imports of two third +// modules, `specs/S.mdx` and `specs/text.mdx`, both referenced by the moved +// text through the origin's bindings (T6.5-10's shape), so that a product +// deriving identifiers from basenames would bind `S` and `text` (14.15) and +// one deriving them from a fixed stem would bind one identifier twice +// (14.15: two imports binding one identifier). After the move `check` is +// clean, `view` lists the two added declarations under `imports` with +// distinct `name`s and targets `specs/S.mdx` and `specs/text.mdx`, each +// moved reference is rooted at the binding of its own module (`query edges` +// under the new identities), and the declarations stand contiguous in one +// ESM block (T6.5-13(g)): the target's post-move bytes are composed from the +// rules of 6.4/6.5 and 3 up to the two fresh identifiers and their order — +// both read from the result — and the single inserted run is byte-exactly +// the two declarations on contiguous lines, each followed by U+000A, at a +// line-start admissible offset (SPEC 6.5: added declarations sharing one +// offset stand contiguous, in a spec source one ESM block). +const S9_ORIGIN = "specs/a.mdx"; +const S9_TARGET = "specs/b.mdx"; +/** The third module whose basename is the compiler-provided `S` (2.1). */ +const S9_S = "specs/S.mdx"; +const S9_S_MODULE = "specs/S.xspec"; +/** The third module whose basename is the compiler-provided `text` (2.1). */ +const S9_TEXT = "specs/text.mdx"; +const S9_TEXT_MODULE = "specs/text.xspec"; +const S9_MOVE_ARGV = ["move", "specs/a.mdx#a.mv", "specs/b.mdx#mv"] as const; + +const S9_S_SOURCE = ['<S id="foo">', "Foo text.", "</S>", ""].join("\n"); +const S9_TEXT_SOURCE = ['<S id="bar">', "Bar text.", "</S>", ""].join("\n"); +// The spec-source arm follows the code arm's invocations, so its four +// sources are ledger records (S-9's before-any-product clause; +// helpers/staged-mdx.ts) — S and text kept as strings for the untouched-file +// compare, their records made from them. +const S9_S_STAGED = stagedMdx( + "T6.5-9 spec-source arm specs/S.mdx", + S9_S_SOURCE, +); +const S9_TEXT_STAGED = stagedMdx( + "T6.5-9 spec-source arm specs/text.mdx", + S9_TEXT_SOURCE, +); + +/** + * The moved subtree's lines: the head's `d` reference to `S.mdx`'s `foo` + * and the leaf's embedding of `text.mdx`'s `bar`, each rooted at a binding + * of its own module (`SM` and `TM` in the origin; the two fresh identifiers + * in the target), spelled with the subtree's ID prefix. + */ +function s9MovedLines( + prefix: string, + sRoot: string, + textRoot: string, +): string[] { + return [ + `<S id="${prefix}" d={${sRoot}.foo}>`, + "Moved head text.", + "", + `<S id="${prefix}.leaf">`, + "Moved leaf text, as specified:", + "", + `{text(${textRoot}.bar)}`, + "</S>", + "</S>", + ]; +} + +// The origin binds both modules under names free in the target (`SM`, `TM`) +// and keeps a reference through each outside the moved subtree (`a.stay`), +// so both declarations stay byte-for-byte after the move (SPEC 6.5) and the +// arm turns on the target's additions alone. +const S9_ORIGIN_BEFORE = stagedMdx( + "T6.5-9 spec-source arm specs/a.mdx", + [ + 'import SM from "./S.xspec"', + 'import TM from "./text.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + ...s9MovedLines("a.mv", "SM", "TM"), + "", + '<S id="a.stay" d={SM.foo}>', + "Staying text, as specified:", + "", + "{text(TM.bar)}", + "</S>", + "</S>", + "", + ].join("\n"), +); + +// Composed from SPEC 6.5 and 3, no latitude: the moved construct deleted in +// place, its two emptied lines dropped with their terminators, the blank +// neighbours kept; both imports kept, their bindings still referenced. +const S9_ORIGIN_AFTER = [ + 'import SM from "./S.xspec"', + 'import TM from "./text.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + "", + '<S id="a.stay" d={SM.foo}>', + "Staying text, as specified:", + "", + "{text(TM.bar)}", + "</S>", + "</S>", + "", +].join("\n"); + +const S9_TARGET_BEFORE = stagedMdx( + "T6.5-9 spec-source arm specs/b.mdx", + ['<S id="b">', "Target text.", "</S>", ""].join("\n"), +); + +/** + * The target's expected post-move bytes WITHOUT the added declarations + * (SPEC 6.4/6.5, 3): the moved text appended at end of file plus U+000A, + * re-identified by prefix replacement, each third-module reference + * re-rooted at the fresh binding of its own module, otherwise byte-identical. + */ +const S9_TARGET_BASE = (sRoot: string, textRoot: string): string => + [ + '<S id="b">', + "Target text.", + "</S>", + ...s9MovedLines("mv", sRoot, textRoot), + "", + ].join("\n"); + +const S9_REWRITTEN_DEPENDS = + /<S id="mv" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.foo\}>/g; +const S9_REWRITTEN_EMBEDS = /\{text\(([A-Za-z_$][A-Za-z0-9_$]*)\.bar\)\}/g; + +/** The complete `depends` and `embeds` edge sets after the move (SPEC 6.5, 5.2). */ +const S9_DEPENDS: readonly GraphEdge[] = [ + { from: `${S9_ORIGIN}#a.stay`, to: `${S9_S}#foo`, kind: "depends" }, + { from: `${S9_TARGET}#mv`, to: `${S9_S}#foo`, kind: "depends" }, +]; +const S9_EMBEDS: readonly GraphEdge[] = [ + { from: `${S9_ORIGIN}#a.stay`, to: `${S9_TEXT}#bar`, kind: "embeds" }, + { from: `${S9_TARGET}#mv.leaf`, to: `${S9_TEXT}#bar`, kind: "embeds" }, +]; + +/** + * The identifier one of the target's rewritten third-module references is + * rooted at, read off 6.4's pinned spelling; diagnosed when the reference + * is not spelled as 6.4 pins it, or is present more or less than once. + */ +function s9ReferenceRoot( + text: string, + pattern: RegExp, + form: string, + context: string, +): string { + const matches = [...text.matchAll(pattern)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${S9_TARGET} must hold exactly one ${form} — the moved ` + + `reference rewritten through a binding of its own module in 6.4's ` + + `pinned spelling, its access form kept (SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +const T6_5_9 = defineProductTest({ + id: "T6.5-9", + title: + "fresh identifiers in code: T6.5-8's TS arm re-staged (its `specs/origin.mdx`, the plain `specs/target.mdx`, `move specs/origin.mdx#x specs/target.mdx#y`) with a receiving `src/c.ts` that also declares at module scope — as a local `const`, a `function`, a `class`, a `type` alias, and non-spec import bindings, each used trivially — the identifiers a product would plausibly derive for the added target-module import (the target file's basename as written, lower- and upper-cased, `Spec`- and `SPEC`-suffixed, and capitalized; the origin binding's name with a digit and with an underscore appended), the file compiling clean before the move under standard tooling, its import declarations — the origin's, then the non-spec lures' — heading it with no blank line after them; after the section move the rewritten file compiles with no diagnostics through H-2's standard-tooling channel (a collision with the `const`, `function`, or `class` is TS2440, with a non-spec import binding TS2300, an unrewritten or misrooted marker a type error against the regenerated modules), so the added identifier is none of the value-level pre-empted names, the `type` alias's name left to T6.5-22(a); the diff-isolated added run is asserted as T6.5-8 asserts it — byte-exactly `import <X> from \"../specs/target.xspec\"` followed by U+000A, the fresh identifier read off the rewritten marker, at the start of the line directly after one of the file's import declarations (the start of line 2 or 3, its line-start admissible offsets); `query edges` reports the moved marker's `references` edge to the moved node's new identity and the unmoved marker's through the retained origin binding, and `check` is clean (SPEC 6.5, 2.1, 4, 4.5, 6.4, 3); spec-source arm: a spec target lacking imports of two third modules, `specs/S.mdx` and `specs/text.mdx`, both referenced by the moved text through the origin's bindings, so that a product deriving identifiers from basenames would bind `S` and `text` (14.15) and one deriving them from a fixed stem would bind one identifier twice — after the move `check` is clean, `view` lists the two added declarations under `imports` with distinct `name`s and targets `specs/S.mdx` and `specs/text.mdx`, each moved reference is rooted at the binding of its own module (`query edges` under the new identities), and the declarations stand contiguous in one ESM block (T6.5-13(g)), the single inserted run being byte-exactly the two declarations on contiguous lines, each followed by U+000A, at a line-start offset, in the order the product fixed (SPEC 6.5, 2.1, 11.4)", + run: async (product) => { + const context = "T6.5-9"; + const lures = A9_VALUE_LURES.map((binding) => binding.name).join(", "); + await withWorkspace(SPEC_AND_CODE_CONFIG, A9_FILES, async (workspace) => { + // Premise: the staging is valid (every reference and marker resolves). + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + // Fixture self-check: the pre-empting declarations are valid + // TypeScript and the generated origin module resolves, so a later + // diagnostic is the move's, not the staging's. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [A8_CODE], + }), + `${context} premise: ${A8_CODE} compiles clean before the move ` + + `under standard tooling — its pre-empting module-scope ` + + `declarations (${lures}, and the \`type\` alias \`targetSPEC\`) ` + + `are valid TypeScript and the generated origin module resolves ` + + `(SPEC 4, 13.1; a fixture self-check)`, + ); + + await expectExit( + product, + workspace, + [...A8_TS_ARGV], + 0, + `${context} \`${A8_TS_ARGV.join(" ")}\` — a valid move over the ` + + `workspace the premise \`build\` accepted succeeds (SPEC 6.5); a ` + + `finding located in ${A8_CODE} at this step points at the added ` + + `target-module import binding one of the pre-empted identifiers ` + + `(${lures}), the file's pre-existing local uses of that name then ` + + `read as value-level uses of a spec binding (SPEC 6.5, 4.5, 14.18)`, + ); + + // The assertion T6.5-9 names: no diagnostics, whatever identifier the + // product chose. A fresh project — the language service snapshots + // files on first access, and the move rewrote them. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [A8_CODE], + }), + `${context}: ${A8_CODE} after the move compiles with no diagnostics ` + + `under standard tooling — the added target-module import binds an ` + + `identifier colliding with none of the file's value-level ` + + `module-scope bindings (pre-empted: ${lures}; a collision is ` + + `TS2440 "Import declaration conflicts with local declaration" or ` + + `TS2300 "Duplicate identifier"), and the rewritten marker resolves ` + + `against the regenerated modules (SPEC 6.5, 2.1, 4, 4.5)`, + ); + + // T6.5-8's discipline on the diff-isolated run, confined to the file's + // line-start admissible offsets — the start of the line directly + // after each import heading it — with the fresh identifier read off + // the rewritten marker. + const text = await readSourceText(workspace, A8_CODE, context); + const root = a9RewrittenMarkerRoot(text, context); + assertAddedImportInsertion( + { + rel: A8_CODE, + base: Buffer.from(a9Code(`${root}.y`).join(X2_LF), "utf8"), + actual: await workspace.readBytes(A8_CODE), + importerDir: posixPath.dirname(A8_CODE), + expectedModule: A8_TARGET_MODULE, + identifier: root, + pinnedOffsets: { + offsets: A9_AFTER_IMPORT_OFFSETS, + where: + "the start of the line directly after one of the import " + + "declarations heading the file", + }, + }, + `${context}: ${A8_CODE} after the move is its pre-move bytes with ` + + `the moved marker rewritten in 6.4's pinned spelling and exactly ` + + `one import of the target module added as a line of its own — ` + + `byte-exactly 6.5's spelling (single spaces, no statement ` + + `terminator, the specifier double-quoted in its canonical ` + + `relative spelling) followed by U+000A, at the start of the line ` + + `directly after one of the import declarations heading the file, ` + + `its line-start admissible offsets, which 6.5 takes over any ` + + `other — binding the fresh identifier the rewritten marker is ` + + `rooted at, no other byte inserted (SPEC 6.5, 2.1, 6.4, 3; T6.5-8)`, + ); + + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "references", context), + A9_EDGES, + `${context}: the complete \`references\` edge set after the move — ` + + `the rewritten marker reported under the moved node's new ` + + `identity through the fresh binding, the unmoved marker's edge ` + + `through the retained origin binding, both attributed to the ` + + `file's function \`f\` (SPEC 6.5, 4.5, 4.6, 5.2)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the added import ` + + `and the rewritten marker resolve and no staleness remains (SPEC ` + + `6.5, 12.2, 14.10)`, + ); + }); + { + // Spec-source arm: the freshness rule's distinctness and reserved-name + // clauses, observable to xspec (14.15) and through `view` (11.4). + const context = "T6.5-9 spec-source arm"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [S9_S]: S9_S_STAGED, + [S9_TEXT]: S9_TEXT_STAGED, + [S9_ORIGIN]: S9_ORIGIN_BEFORE, + [S9_TARGET]: S9_TARGET_BEFORE, + }, + async (workspace) => { + // Premise: the staging is valid (every reference resolves), so a + // later failure is the move's, not the staging's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await expectExit( + product, + workspace, + [...S9_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\` — a valid ` + + `move over the workspace the premise \`build\` accepted ` + + `succeeds (SPEC 6.5); a 14.15 finding at this step points at ` + + `an added import binding \`S\` or \`text\` (a ` + + `compiler-provided name, 2.1) or at two added imports binding ` + + `one identifier (SPEC 6.5, 2.1, 14.15)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the two ` + + `added imports bind distinct identifiers, none of them a ` + + `compiler-provided name (no 14.15), every rewritten reference ` + + `resolves through its binding, and no staleness remains (SPEC ` + + `6.5, 2.1, 12.2, 14.10, 14.15)`, + ); + + // The fresh identifiers, read off the rewritten references in + // 6.4's pinned spelling: one per module. + const text = await readSourceText(workspace, S9_TARGET, context); + const sRoot = s9ReferenceRoot( + text, + S9_REWRITTEN_DEPENDS, + '`<S id="mv" d={<binding>.foo}>`', + context, + ); + const textRoot = s9ReferenceRoot( + text, + S9_REWRITTEN_EMBEDS, + "`{text(<binding>.bar)}`", + context, + ); + if (sRoot === textRoot) { + fail( + `${context}: both moved references are rooted at ` + + `\`${sRoot}\` while they need bindings of two modules, ` + + `${S9_S}'s and ${S9_TEXT}'s — the identifiers of the imports ` + + `added to one file are distinct from each other (SPEC 6.5, ` + + `2.1; two imports binding one identifier is 14.15)`, + ); + } + for (const [root, module] of [ + [sRoot, S9_S], + [textRoot, S9_TEXT], + ] as const) { + const reserved = A8_MDX_RESERVED.find( + (entry) => entry.name === root, + ); + if (reserved !== undefined) { + fail( + `${context}: the import added for ${module}'s module binds ` + + `\`${reserved.name}\`, ${reserved.why} — in a spec source ` + + `an added import's identifiers are none of the ` + + `compiler-provided names, whatever the module's basename ` + + `(SPEC 6.5, 2.1, 14.15)`, + ); + } + } + + // `view`: the two added declarations, distinct names, each the + // binding the moved reference to its module is rooted at. + const viewLabel = `${context} \`view ${S9_TARGET} --json\``; + const view = decodeViewReport( + await runJson( + product, + workspace, + ["view", S9_TARGET, "--json"], + viewLabel, + ), + { text: false }, + viewLabel, + ); + assertSameJson( + view.findings, + [], + `${viewLabel}: a \`view\` of the rewritten target on the valid ` + + `workspace the move left reports findings [] (SPEC 11.4, 6.5)`, + ); + const fileView = view.views.length === 1 ? view.views[0] : undefined; + if (fileView === undefined || fileView.file !== S9_TARGET) { + fail( + `${viewLabel}: \`views\` holds exactly the one requested, ` + + `parseable file ${S9_TARGET} (SPEC 11.4); got ` + + `[${view.views.map((entry) => renderPathValue(entry.file)).join(", ")}]`, + ); + } + const listed = fileView.imports.map((entry) => ({ + name: entry.name, + target: entry.target, + })); + const names = new Map<string, string | null>(); + for (const entry of listed) { + if (typeof entry.target === "string") + names.set(entry.target, entry.name); + } + if ( + listed.length !== 2 || + names.size !== 2 || + names.get(S9_S) !== sRoot || + names.get(S9_TEXT) !== textRoot + ) { + fail( + `${viewLabel}: \`imports\` lists exactly the two added ` + + `declarations — the file having lacked any import — with ` + + `distinct \`name\`s, each the binding the moved reference ` + + `to its module is rooted at (\`${sRoot}\` for ${S9_S}, ` + + `\`${textRoot}\` for ${S9_TEXT}), and \`target\`s the two ` + + `third modules (SPEC 6.5, 2.1, 11.4); got ` + + `${JSON.stringify(listed)}`, + ); + } + + // Contiguous in one ESM block (T6.5-13(g)): the two declarations, + // in the order the product fixed (read from the result), are the + // single run inserted into the composed base — each followed by + // U+000A, no empty line between — at a line-start offset. + const importerDir = posixPath.dirname(S9_TARGET); + const sDeclaration = `import ${sRoot} from "${canonicalSpecifier(importerDir, S9_S_MODULE)}"`; + const textDeclaration = `import ${textRoot} from "${canonicalSpecifier(importerDir, S9_TEXT_MODULE)}"`; + const ordered = + text.indexOf(sDeclaration) < text.indexOf(textDeclaration) + ? [sDeclaration, textDeclaration] + : [textDeclaration, sDeclaration]; + const readings = assertExactDeclarationInsertion( + { + rel: S9_TARGET, + base: Buffer.from(S9_TARGET_BASE(sRoot, textRoot), "utf8"), + actual: await workspace.readBytes(S9_TARGET), + declaration: ordered.join("\n"), + }, + `${context}: ${S9_TARGET} after the move is its composed ` + + `post-move bytes with the two added declarations as one ` + + `inserted run — \`${ordered[0]}\`, U+000A, \`${ordered[1]}\`, ` + + `U+000A: 6.5's exact spelling, on contiguous lines with no ` + + `empty line between, one ESM block (T6.5-13(g)), in the order ` + + `the product fixed — and no other byte inserted (SPEC 6.5, ` + + `2.1, 6.4, 3)`, + ); + if (!readings.some((reading) => reading.atLineStart)) { + fail( + `${context}: the added declarations' run is inserted at no ` + + `line-start offset of ${S9_TARGET} — an admissible offset ` + + `at the start of a line, the file's end after its final ` + + `terminator included, is taken over any other (SPEC 6.5); ` + + `read at offset(s) ` + + `${readings.map((reading) => String(reading.offset)).join(", ")}`, + ); + } + + await assertRewrittenSpecDerives(workspace, S9_TARGET, context); + + await assertFileBytes( + workspace.path(S9_ORIGIN), + S9_ORIGIN_AFTER, + `${context}: ${S9_ORIGIN} after the move — the moved section ` + + `deleted in place with its emptied lines dropped, the blank ` + + `neighbours kept, both imports kept byte-for-byte, their ` + + `bindings still referenced by \`a.stay\` (SPEC 6.5, 2.1, 3; ` + + `H-4, normalizing nothing)`, + ); + for (const [rel, source] of [ + [S9_S, S9_S_SOURCE], + [S9_TEXT, S9_TEXT_SOURCE], + ] as const) { + await assertFileBytes( + workspace.path(rel), + source, + `${context}: ${rel} after the move — a third module whose ` + + `node is referenced but not moved, untouched (SPEC 6.5; H-4)`, + ); + } + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "depends", context), + S9_DEPENDS, + `${context}: the complete \`depends\` edge set after the move — ` + + `the moved head's \`d={…foo}\` reported under its new ` + + `identity to ${S9_S}#foo through the binding added for that ` + + `module, \`a.stay\`'s edge unchanged (SPEC 6.5, 5.2)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", context), + S9_EMBEDS, + `${context}: the complete \`embeds\` edge set after the move — ` + + `the moved leaf's \`{text(…bar)}\` reported under its new ` + + `identity to ${S9_TEXT}#bar through the binding added for ` + + `that module, \`a.stay\`'s edge unchanged (SPEC 6.5, 5.2, 2.3)`, + ); + await buildOk( + product, + workspace, + `${context} \`build\` after the move — the rewritten workspace ` + + `is valid, the embedding's expansion included (SPEC 6.5, ` + + `12.1, 3)`, + ); + }, + ); + } + }, +}); + +// T6.5-10 — Third-module bindings carried with moved text (TEST-SPEC +// T6.5-10). A reference inside the moved text need not target a moved node +// to need rewriting: an imported-form reference to a node of a spec module +// `X` that is neither origin nor target — `d={X.foo}`, `{text(X["bar-baz"])}` +// — is bound by the origin file's import of `X`, which the moved text leaves +// behind, so in the target file it needs a binding of `X`'s module (SPEC +// 6.5: an import is added when a rewritten reference needs a module binding +// its file lacks — and only then, the reading T6.5-7's TS arm pins for +// markers rewritten through an existing binding). T6.5-7 keeps its +// third-module reference outside the moved subtree and T6.5-8's conversions +// are local↔imported alone, so no fixture of theirs meets this shape. Four +// section-move arms over three spec sources in one directory — origin +// `specs/a.mdx`, target `specs/b.mdx`, and `specs/x.mdx`; (c) and its +// sibling adding a fourth, `specs/z.mdx` — each moving +// `a.mv` to the top-level `mv` of the target, where the moved head carries +// `d={X.foo}` and the moved leaf embeds `{text(X["bar-baz"])}` (a +// double-quoted computed segment: `bar-baz` is a valid ID, 1.4, that is not +// a TypeScript identifier, 2.4), and no reference to a moved node lies +// outside the subtree: +// - (a) value-blind, T6.5-8's discipline: the target, an existing discovered +// source, holds no import of `x.mdx`'s module, and the origin's only +// references through `X` lie inside the moved subtree. The target's +// post-move bytes are composed from the rules of 6.4/6.5 and 3 up to the +// fresh identifier — read off the rewritten `d` reference in 6.4's pinned +// spelling and cross-checked against the rewritten embedding; `X` itself +// admissible, being fresh in that file — and the insertion offset, +// `assertAddedImportInsertion` isolating the single inserted run as +// byte-exactly `import <X> from "./x.xspec"` followed by U+000A at a +// line-start offset (6.5's spelling and line discipline, T6.5-8); the +// origin loses the section and, its `X` binding left +// without references, its own-line `X` declaration with the line's +// terminator (6.5's exact extent, T6.5-7), and is otherwise +// byte-identical. +// - (b) byte-composable, no latitude: the target already imports `x.mdx`'s +// module as `Z`, referenced by a section of its own, and the origin keeps +// a reference through `X` outside the moved subtree. No import is added +// (the file lacks no binding of the module), each moved reference is +// re-rooted to `Z` with quote style and access form kept (`X.foo` → +// `Z.foo`, `X["bar-baz"]` → `Z["bar-baz"]`; 6.4: minimal in-place edits), +// the origin's `X` declaration stays byte-for-byte, and the rewritten +// origin and target are each asserted byte-equal to composed expectations. +// - (c) the identifier bound to another module, value-blind ((a)'s +// discipline): the target holds `import X from "./z.xspec"`, `z.mdx` a +// fourth source spelling `q` and `foo`, and uses that binding in a section +// of its own (`d={X.q}`) while holding no binding of `x.mdx`'s module, the +// origin staged as (a). `z.mdx`'s `foo` is the lure for a product that +// keeps a moved spelling wherever the target binds its root identifier, +// whatever module the binding designates. The single added run is +// byte-exactly `import <F> from "./x.xspec"` plus U+000A at a line-start +// offset, `<F>` read from the declaration and asserted distinct from `X` +// (6.5, 2.1: fresh), each moved spelling re-rooted to `<F>` with its +// access form kept, the target's own `X.q` and its `z` declaration +// byte-untouched, the origin as in (a). +// - (c)'s sibling: the target additionally binds `x.mdx`'s module as `Z`, +// used by a section of its own ((b)'s shape, the origin staged as (b)): +// nothing added, each moved spelling re-rooted to `Z`, `X.q` and both +// declarations byte-untouched — (b)'s whole-file contract. +// In (a) and (c) the rewritten target is additionally judged under the +// stock MDX 3 grammar (S-9): 6.5 adds a declaration only at an admissible +// offset, and a product's own `check` cannot judge that where its grammar +// is wider than 14.20's; and the preview's rewrite reporting follows the +// identifier the real run chose (6.5: a rewrite is made, and reported, +// exactly when it changes the construct's characters, read with the chosen +// binding in place): inside the origin deletion's range its +// `reference-rewrite` edits are exactly the two moved spellings' occurrence +// spans when that identifier is not `X` and none when it is, the target's +// `import-addition` reported either way. +// In every arm `x.mdx` is a bystander asserted untouched, `query edges` +// reports the moved nodes' `depends` and `embeds` edges under their new +// identities to `x.mdx`'s unchanged nodes (the complete set of each kind), +// and `check` and `build` are clean (6.5: a successful move's finishing +// regeneration runs on a valid workspace; Markdown emission is enabled so +// the embedding's expansion is regenerated as well, 3). A product converting +// only between local and imported forms leaves `X.foo` unbound in the +// target — an invalid workspace behind a reported success — and fails (a) +// and (b); one adding a second import of a module the target already binds +// fails (b)'s whole-file contract and (c)'s sibling's; one rooting by +// identifier name rather than by module passes (a), where `X` is free, and +// (b), where it is absent, and fails (c)'s byte and edge contracts. +const C10_ORIGIN = "specs/a.mdx"; +const C10_TARGET = "specs/b.mdx"; +const C10_THIRD = "specs/x.mdx"; +const C10_THIRD_MODULE = "specs/x.xspec"; + +const C10_MOVE_ARGV = ["move", "specs/a.mdx#a.mv", "specs/b.mdx#mv"] as const; + +// The third module: `foo` (identifier-valid) and `bar-baz` (a valid ID that +// is not a TypeScript identifier, reachable only by computed access; SPEC +// 1.4, 2.4). Neither is moved, so both keep their identities. +const C10_THIRD_SOURCE = [ + '<S id="foo">', + "Foo text.", + "</S>", + "", + '<S id="bar-baz">', + "Bar baz text.", + "</S>", + "", +].join("\n"); +// T6.5-10's stagings: arm (a)'s workspace is the body's first; arms (b), +// (c), and (c)'s sibling follow its invocations, so every `.mdx` source is a +// ledger record (S-9's before-any-product clause; helpers/staged-mdx.ts), +// (a)'s converted uniformly — the third and fourth sources and (a)'s origin +// kept as strings for the untouched-file compares and the occurrence spans, +// their records made from them; identical bytes staged by several arms are +// one record each. +const C10_THIRD_STAGED = stagedMdx( + "T6.5-10 every arm's third module specs/x.mdx", + C10_THIRD_SOURCE, +); + +/** + * The moved subtree's lines, spelled with its ID prefix (`a.mv` in the + * origin, `mv` after re-identification in the target) and the binding its + * third-module references are rooted at (`X` in the origin; the fresh + * identifier or the existing `Z` in the target), the access forms kept: + * dot access for `foo`, double-quoted computed access for `bar-baz` (SPEC + * 6.5, 6.4, 2.4). + */ +function c10MovedLines(prefix: string, root: string): string[] { + return [ + `<S id="${prefix}" d={${root}.foo}>`, + "Moved head text.", + "", + `<S id="${prefix}.leaf">`, + "Moved leaf text, as specified:", + "", + `{text(${root}["bar-baz"])}`, + "</S>", + "</S>", + ]; +} + +// Arm (a). The origin's `X` import is the first line, alone; its only +// references through `X` are the moved subtree's two. +const C10_A_ORIGIN_BEFORE = [ + 'import X from "./x.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + ...c10MovedLines("a.mv", "X"), + "</S>", + "", +].join("\n"); +const C10_A_ORIGIN_STAGED = stagedMdx( + "T6.5-10 arms (a) and (c) specs/a.mdx", + C10_A_ORIGIN_BEFORE, +); + +// Composed from SPEC 6.5 and 3, no latitude: the moved construct deleted in +// place, the lines holding its opening and closing tags (left empty) dropped +// with their terminators, the blank neighbours kept; the `X` binding left +// without references, so its declaration is deleted in place and its +// emptied line dropped with its U+000A — the blank line that followed it +// stays, so the file now opens with that terminator (T6.5-7's exact extent). +const C10_A_ORIGIN_AFTER = [ + "", + '<S id="a">', + "Origin holder text.", + "", + "</S>", + "", +].join("\n"); + +const C10_A_TARGET_BEFORE = stagedMdx( + "T6.5-10 arm (a) specs/b.mdx", + ['<S id="b">', "Target text.", "</S>", ""].join("\n"), +); + +// The target's expected post-move bytes WITHOUT the added import (SPEC +// 6.4/6.5): the moved text appended at end of file (the insertion point at +// the start of a line, so no preceding terminator) plus U+000A, its `id`s +// re-identified by prefix replacement, each third-module reference re-rooted +// at the fresh binding with its access form kept, otherwise byte-identical. +const C10_A_TARGET_BASE = (root: string): string => + ['<S id="b">', "Target text.", "</S>", ...c10MovedLines("mv", root), ""].join( + "\n", + ); + +const C10_REWRITTEN_DEPENDS = + /<S id="mv" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.foo\}>/g; +const C10_REWRITTEN_EMBEDS = + /\{text\(([A-Za-z_$][A-Za-z0-9_$]*)\["bar-baz"\]\)\}/g; + +// Arm (b). The origin keeps `a.stay`'s reference through `X` outside the +// moved subtree; the target already binds `x.mdx`'s module as `Z`, used by +// its own section. +const C10_B_ORIGIN_BEFORE = stagedMdx( + "T6.5-10 arms (b) and (c)-sibling specs/a.mdx", + [ + 'import X from "./x.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + ...c10MovedLines("a.mv", "X"), + "", + '<S id="a.stay" d={X.foo}>', + "Staying text.", + "</S>", + "</S>", + "", + ].join("\n"), +); + +// Composed from SPEC 6.5 and 3: the moved construct deleted in place with +// its two emptied lines dropped, the blank neighbours kept; the `X` binding +// keeps `a.stay`'s reference, so its declaration stays byte-for-byte. +const C10_B_ORIGIN_AFTER = [ + 'import X from "./x.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + "", + '<S id="a.stay" d={X.foo}>', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +const C10_B_TARGET_BEFORE = stagedMdx( + "T6.5-10 arm (b) specs/b.mdx", + [ + 'import Z from "./x.xspec"', + "", + '<S id="b" d={Z.foo}>', + "Target text.", + "</S>", + "", + ].join("\n"), +); + +// Composed from SPEC 6.5 and 6.4, no latitude: the moved text appended at +// end of file plus U+000A, re-identified, each third-module reference +// re-rooted at the existing `Z` binding with quote style and access form +// kept; no import added, the existing import and `b` byte-for-byte. +const C10_B_TARGET_AFTER = [ + 'import Z from "./x.xspec"', + "", + '<S id="b" d={Z.foo}>', + "Target text.", + "</S>", + ...c10MovedLines("mv", "Z"), + "", +].join("\n"); + +// Arm (c) and its sibling. The fourth source `z.mdx` spells `q` and `foo` — +// `foo` the lure for a product that keeps a moved spelling wherever the +// target binds its root identifier, whatever module the binding designates: +// `X.foo` left untouched then names `z.mdx`'s `foo`, a silent retarget +// behind a reported success, `check` clean. +const C10_FOURTH = "specs/z.mdx"; +const C10_FOURTH_SOURCE = [ + '<S id="q">', + "Q text.", + "</S>", + "", + '<S id="foo">', + "Foo lure text.", + "</S>", + "", +].join("\n"); +const C10_FOURTH_STAGED = stagedMdx( + "T6.5-10 arms (c) and (c)-sibling specs/z.mdx", + C10_FOURTH_SOURCE, +); + +// (c): the target binds `X` itself to `z.mdx`'s module, used by its own +// section, and holds no binding of `x.mdx`'s module; the origin is (a)'s. +const C10_C_TARGET_BEFORE = stagedMdx( + "T6.5-10 arm (c) specs/b.mdx", + [ + 'import X from "./z.xspec"', + "", + '<S id="b" d={X.q}>', + "Target text.", + "</S>", + "", + ].join("\n"), +); + +// The target's expected post-move bytes WITHOUT the added import (SPEC +// 6.4/6.5, 3): the moved text appended at end of file plus U+000A, +// re-identified, each third-module reference re-rooted at the fresh binding +// with its access form kept (`<F>.foo`, `<F>["bar-baz"]`), the target's own +// `X.q` and its `z` declaration byte-untouched. +const C10_C_TARGET_BASE = (root: string): string => + [ + 'import X from "./z.xspec"', + "", + '<S id="b" d={X.q}>', + "Target text.", + "</S>", + ...c10MovedLines("mv", root), + "", + ].join("\n"); + +// (c)'s sibling: the target additionally binds `x.mdx`'s module as `Z`, +// used by a section of its own ((b)'s shape; the origin staged as (b)), so +// nothing is added and each moved spelling is re-rooted to `Z` — (b)'s +// whole-file contract, `X.q` and both declarations byte-untouched. +const C10_CS_TARGET_BEFORE = stagedMdx( + "T6.5-10 arm (c) sibling specs/b.mdx", + [ + 'import X from "./z.xspec"', + 'import Z from "./x.xspec"', + "", + '<S id="b" d={X.q}>', + "Target text.", + "</S>", + "", + '<S id="c" d={Z.foo}>', + "Other text.", + "</S>", + "", + ].join("\n"), +); + +const C10_CS_TARGET_AFTER = [ + 'import X from "./z.xspec"', + 'import Z from "./x.xspec"', + "", + '<S id="b" d={X.q}>', + "Target text.", + "</S>", + "", + '<S id="c" d={Z.foo}>', + "Other text.", + "</S>", + ...c10MovedLines("mv", "Z"), + "", +].join("\n"); + +/** The target's own section's edge to the fourth source, standing as before. */ +const C10_TARGET_OWN_DEPENDS: GraphEdge = { + from: `${C10_TARGET}#b`, + to: `${C10_FOURTH}#q`, + kind: "depends", +}; + +/** The moved nodes' edges under their new identities (SPEC 6.5, 5.2). */ +const C10_MOVED_DEPENDS: GraphEdge = { + from: `${C10_TARGET}#mv`, + to: `${C10_THIRD}#foo`, + kind: "depends", +}; +const C10_MOVED_EMBEDS: GraphEdge = { + from: `${C10_TARGET}#mv.leaf`, + to: `${C10_THIRD}#bar-baz`, + kind: "embeds", +}; + +/** + * The identifier one of the target's rewritten third-module references is + * rooted at, read off 6.4's pinned spelling; diagnosed when the reference + * is not spelled as 6.4 pins it, or is present more or less than once. + */ +function c10ReferenceRoot( + text: string, + pattern: RegExp, + form: string, + context: string, +): string { + const matches = [...text.matchAll(pattern)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${C10_TARGET} must hold exactly one ${form} — the moved ` + + `reference to ${C10_THIRD}'s node rewritten through a binding of ` + + `its module in 6.4's pinned spelling, its access form kept (dot ` + + `access for the identifier-valid segment, double-quoted computed ` + + `access for the other; SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * The guards shared by both arms after the move: the third module untouched, + * the complete `depends` and `embeds` edge sets exact — the moved nodes' + * edges reported under their new identities to `x.mdx`'s unchanged nodes — + * and `check` and `build` clean on the rewritten workspace. + */ +async function c10AssertPostMove( + product: ProductBinding, + workspace: TestWorkspace, + edges: { depends: readonly GraphEdge[]; embeds: readonly GraphEdge[] }, + context: string, +): Promise<void> { + await assertFileBytes( + workspace.path(C10_THIRD), + C10_THIRD_SOURCE, + `${context}: ${C10_THIRD} after the move — the third module, whose ` + + `nodes are referenced but not moved, untouched (SPEC 6.5; H-4)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "depends", context), + edges.depends, + `${context}: the complete \`depends\` edge set after the move — the ` + + `moved head's \`d={…foo}\` reported under its new identity to ` + + `${C10_THIRD}#foo, every other edge unchanged (SPEC 6.5, 5.2)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", context), + edges.embeds, + `${context}: the complete \`embeds\` edge set after the move — the ` + + `moved leaf's \`{text(…["bar-baz"])}\` reported under its new ` + + `identity to ${C10_THIRD}#bar-baz (SPEC 6.5, 5.2, 2.3)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — every rewritten ` + + `reference resolves through its file's binding of ${C10_THIRD}'s ` + + `module and no staleness remains (SPEC 6.5, 12.2, 14.10)`, + ); + await buildOk( + product, + workspace, + `${context} \`build\` after the move — the rewritten workspace is ` + + `valid, the embedding's expansion included (SPEC 6.5, 12.1, 3)`, + ); +} + +/** + * The moved spellings' occurrence spans in the (a)-staged origin (SPEC 5.7: + * a `d` reference occurrence spans that one reference's own expression; an + * MDX embedding occurrence spans the entire `{text(...)}` container), in + * pre-operation byte coordinates — the origin is ASCII, so string indices + * are byte offsets — ordered by range start as 12.7 orders edits. + */ +function c10MovedOccurrenceSpans(): readonly { start: number; end: number }[] { + const origin = C10_A_ORIGIN_BEFORE; + const head = '<S id="a.mv" d={X.foo}>'; + const headAt = origin.indexOf(head); + const embed = '{text(X["bar-baz"])}'; + const embedAt = origin.indexOf(embed); + if (headAt < 0 || embedAt < 0) { + throw new Error( + "T6.5-10 fixture: the moved spellings are not in the origin", + ); + } + const dAt = headAt + head.indexOf("X.foo"); + return [ + { start: dAt, end: dAt + "X.foo".length }, + { start: embedAt, end: embedAt + embed.length }, + ]; +} + +/** + * The preview's plan for the move (SPEC 6.6, 12.7): findings [] — a preview + * succeeds exactly when the real operation would proceed — and its `files`, + * kept for the rewrite reporting, which is read against the identifier the + * real run then chooses. + */ +async function c10PreviewFiles( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<readonly PreviewFileEntry[]> { + const label = `${context} \`${C10_MOVE_ARGV.join(" ")} --preview --json\``; + const preview = decodePreviewReport( + await runJson( + product, + workspace, + [...C10_MOVE_ARGV, "--preview", "--json"], + label, + ), + label, + ); + assertSameJson( + preview.findings, + [], + `${label}: a valid move's preview completes with findings [] (SPEC 6.5, 6.6)`, + ); + if (preview.mapping === null || preview.files === null) { + fail( + `${label}: the completed preview reports its plan — \`mapping\` and ` + + `\`files\` non-null (SPEC 6.6, 12.7)`, + ); + } + return preview.files; +} + +/** + * The preview's rewrite reporting for a move carrying third-module + * references into a target lacking the module's binding (SPEC 6.5: a + * rewrite is made, and reported, exactly when it changes the construct's + * characters, read with the chosen binding in place, an added declaration's + * included; 6.6): inside the origin deletion's range (T6.6-4(b)), the + * `reference-rewrite` edits are exactly the two moved spellings' occurrence + * spans when the identifier the real run chose differs from `X`, and none + * when it is `X` — the spellings then already resolving in the form they are + * rooted at, neither rewritten nor reported — while the target's one + * `import-addition` is reported either way, a zero-length range at its + * insertion offset (6.6). Other classes (the origin deletion itself, the + * `id-rewrite`s of the re-identification, the target insertion) are T6.6-4's. + */ +function c10AssertPreviewRewrites( + files: readonly PreviewFileEntry[], + root: string, + context: string, +): void { + const label = `${context} preview`; + const origin = files.find((entry) => entry.file === C10_ORIGIN); + const target = files.find((entry) => entry.file === C10_TARGET); + if (origin === undefined || target === undefined) { + fail( + `${label}: \`files\` holds an entry for the origin ${C10_ORIGIN} (its ` + + `deletion and the moved text's rewrites) and one for the target ` + + `${C10_TARGET} (its insertion and import addition) (SPEC 6.6, 12.7); ` + + `got [${files.map((entry) => renderPathValue(entry.file)).join(", ")}]`, + ); + } + const deletions = origin.edits.filter( + (edit) => edit.class === "origin-deletion", + ); + const deletion = deletions.length === 1 ? deletions[0]?.range : undefined; + if (deletion === undefined) { + fail( + `${label}: ${C10_ORIGIN} — exactly one \`origin-deletion\` edit, the ` + + `one range spanning every byte the origin edit removes (SPEC 6.6); ` + + `got ${String(deletions.length)}`, + ); + } + const rewrites = origin.edits + .filter((edit) => edit.class === "reference-rewrite") + .map((edit) => ({ start: edit.range.start, end: edit.range.end })); + assertSameJson( + rewrites, + root === "X" ? [] : c10MovedOccurrenceSpans(), + root === "X" + ? `${label}: ${C10_ORIGIN} — the real run rooted the moved spellings ` + + `at \`X\`, the identifier they already spell, so no ` + + `\`reference-rewrite\` is reported: a rewrite is made, and ` + + `reported, exactly when it changes the construct's characters, ` + + `read with the chosen binding in place (SPEC 6.5, 6.6)` + : `${label}: ${C10_ORIGIN} — the real run rooted the moved spellings ` + + `at \`${root}\`, so its \`reference-rewrite\` edits are exactly ` + + `the two moved spellings' occurrence spans (5.7: the \`d\` ` + + `reference's own expression \`X.foo\`; the whole ` + + `\`{text(X["bar-baz"])}\` container), in pre-operation ` + + `coordinates, ordered by range start (SPEC 6.5, 6.6, 12.7)`, + ); + for (const rewrite of rewrites) { + if (rewrite.start < deletion.start || rewrite.end > deletion.end) { + fail( + `${label}: ${C10_ORIGIN} — the moved text's rewrites locate inside ` + + `the origin deletion's range [${String(deletion.start)}, ` + + `${String(deletion.end)}) (SPEC 6.6; T6.6-4(b)); ` + + `[${String(rewrite.start)}, ${String(rewrite.end)}) does not`, + ); + } + } + const additions = target.edits.filter( + (edit) => edit.class === "import-addition", + ); + if ( + additions.length !== 1 || + additions[0]?.range.start !== additions[0]?.range.end + ) { + fail( + `${label}: ${C10_TARGET} — exactly one \`import-addition\` edit, the ` + + `declaration the target needs for ${C10_THIRD}'s module, a ` + + `zero-length range at its insertion offset (SPEC 6.5, 6.6); got ` + + `${JSON.stringify(additions.map((edit) => edit.range))}`, + ); + } +} + +const T6_5_10 = defineProductTest({ + id: "T6.5-10", + title: + "third-module bindings carried with moved text: four section-move arms over three spec sources in one directory (origin `a.mdx`, target `b.mdx`, and `x.mdx`; (c) and its sibling adding a fourth, `z.mdx`), the moved subtree holding a `d={X.foo}` reference and a `{text(X[\"bar-baz\"])}` embedding through the origin's `X` binding and no reference to a moved node lying outside it — (a) value-blind: the target holds no import of `x.mdx`'s module and the origin's only `X` references lie in the moved subtree, so the target's post-move bytes are composed from the rules of 6.4/6.5 and 3 up to the fresh identifier (read off the rewritten references in 6.4's pinned spelling, `X` itself admissible) and the insertion offset, the single inserted run isolated by diff being byte-exactly `import <X> from \"./x.xspec\"` followed by U+000A at a line-start offset (6.5's spelling and line discipline, T6.5-8), each moved reference rooted at its binding with access form kept, while the origin loses the section and its own-line `X` declaration with the line's terminator and is otherwise byte-identical; (b) byte-composable: the target already imports `x.mdx`'s module as `Z`, referenced by its own section, and the origin keeps an `X` reference outside the subtree, so no import is added, `X.foo` → `Z.foo` and `X[\"bar-baz\"]` → `Z[\"bar-baz\"]`, the origin's `X` declaration stays, and both files are byte-equal to composed expectations; in both arms `x.mdx` is untouched, `query edges` reports the moved nodes' `depends` and `embeds` edges under their new identities to `x.mdx`'s unchanged nodes, and `check` and `build` are clean; (c) the identifier bound to another module, value-blind: the target holds `import X from \"./z.xspec\"`, `z.mdx` a fourth source spelling `q` and `foo` (the lure for a product rooting by identifier name rather than by module), and uses it in a section of its own (`d={X.q}`) while holding no binding of `x.mdx`'s module, the origin staged as (a) — the single added run is byte-exactly `import <F> from \"./x.xspec\"` plus U+000A at a line-start offset with `<F>` distinct from `X`, each moved spelling re-rooted to `<F>` with its access form kept, `X.q` and the `z` declaration byte-untouched, the origin as in (a); and its sibling, the target additionally binding `x.mdx`'s module as `Z` used by a section of its own (the origin staged as (b)), adding nothing and re-rooting each moved spelling to `Z` — (b)'s whole-file contract; in (a) and (c) the rewritten target derives under the stock MDX 3 grammar (the added declaration at an admissible offset, S-9) and the preview's rewrite reporting follows the identifier the real run chose — inside the origin deletion's range exactly the two moved spellings' occurrence spans as `reference-rewrite`s when it is not `X` and none when it is, the target's one `import-addition` either way — and the moved nodes' edges go to `x.mdx`'s nodes alone, the target's own edge to `specs/z.mdx#q` standing as before (SPEC 6.5, 6.4, 6.6, 2.1, 3, 5.7; H-4, normalizing nothing)", + run: async (product) => { + { + const context = "T6.5-10 arm (a) value-blind"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [C10_THIRD]: C10_THIRD_STAGED, + [C10_ORIGIN]: C10_A_ORIGIN_STAGED, + [C10_TARGET]: C10_A_TARGET_BEFORE, + }, + async (workspace) => { + // Premise: the staging is valid (every reference resolves), so a + // later failure is the move's, not the staging's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + const files = await c10PreviewFiles(product, workspace, context); + await expectExit( + product, + workspace, + [...C10_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\``, + ); + + const text = await readSourceText(workspace, C10_TARGET, context); + const root = c10ReferenceRoot( + text, + C10_REWRITTEN_DEPENDS, + '`<S id="mv" d={<binding>.foo}>`', + context, + ); + const embedRoot = c10ReferenceRoot( + text, + C10_REWRITTEN_EMBEDS, + '`{text(<binding>["bar-baz"])}`', + context, + ); + if (embedRoot !== root) { + fail( + `${context}: the moved references are rooted at different ` + + `identifiers — the \`d\` reference at ${JSON.stringify(root)}, ` + + `the embedding at ${JSON.stringify(embedRoot)} — while both ` + + `were bound by the origin's one \`X\` import and need the ` + + `one binding of ${C10_THIRD}'s module the added import ` + + `supplies (SPEC 6.5, 2.1)`, + ); + } + for (const forbidden of A8_MDX_RESERVED) { + if (root === forbidden.name) { + fail( + `${context}: the added import binds \`${forbidden.name}\`, ` + + `${forbidden.why} — an added import binds fresh ` + + `identifiers colliding with no binding already in the ` + + `file (SPEC 6.5, 2.1, 14.15)`, + ); + } + } + // Composed from the rules of 6.4/6.5 and 3 up to the two unknowns — + // the fresh identifier (now known) and the insertion offset + // (isolated by the helper, which reads the run at every + // admissible offset and accepts a line-start one alone: the + // target holds one, its end after the final terminator). + assertAddedImportInsertion( + { + rel: C10_TARGET, + base: Buffer.from(C10_A_TARGET_BASE(root), "utf8"), + actual: await workspace.readBytes(C10_TARGET), + importerDir: posixPath.dirname(C10_TARGET), + expectedModule: C10_THIRD_MODULE, + identifier: root, + }, + `${context}: ${C10_TARGET} after the move is its composed ` + + `post-move bytes with exactly one import of ${C10_THIRD}'s ` + + `module added as a line of its own — byte-exactly 6.5's ` + + `spelling followed by U+000A at a line-start offset, which ` + + `the file holds and 6.5 takes over any other — binding the ` + + `identifier the moved references are rooted at, no other ` + + `byte inserted (SPEC 6.5, 2.1, 6.4, 3; T6.5-8)`, + ); + await assertRewrittenSpecDerives(workspace, C10_TARGET, context); + c10AssertPreviewRewrites(files, root, context); + await assertFileBytes( + workspace.path(C10_ORIGIN), + C10_A_ORIGIN_AFTER, + `${context}: ${C10_ORIGIN} after the move — the moved section ` + + `deleted in place with its emptied lines dropped, the blank ` + + `neighbours kept, and the \`X\` import, its binding left ` + + `without references, deleted in place with its emptied line's ` + + `U+000A (6.5's exact extent), otherwise byte-identical (SPEC ` + + `6.5, 2.1, 3; H-4, normalizing nothing)`, + ); + await c10AssertPostMove( + product, + workspace, + { depends: [C10_MOVED_DEPENDS], embeds: [C10_MOVED_EMBEDS] }, + context, + ); + }, + ); + } + { + const context = "T6.5-10 arm (b) byte-composable"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [C10_THIRD]: C10_THIRD_STAGED, + [C10_ORIGIN]: C10_B_ORIGIN_BEFORE, + [C10_TARGET]: C10_B_TARGET_BEFORE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await expectExit( + product, + workspace, + [...C10_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\``, + ); + await assertFileBytes( + workspace.path(C10_TARGET), + C10_B_TARGET_AFTER, + `${context}: ${C10_TARGET} after the move — the re-identified ` + + `moved text appended at end of file plus U+000A, its ` + + `third-module references re-rooted at the existing \`Z\` ` + + `binding with quote style and access form kept (\`X.foo\` → ` + + `\`Z.foo\`, \`X["bar-baz"]\` → \`Z["bar-baz"]\`), no import ` + + `added (the file lacks no binding of the module), otherwise ` + + `byte-identical (SPEC 6.5, 6.4, 2.1; H-4, normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(C10_ORIGIN), + C10_B_ORIGIN_AFTER, + `${context}: ${C10_ORIGIN} after the move — the moved section ` + + `deleted in place with its emptied lines dropped, the blank ` + + `neighbours kept, and the \`X\` import kept byte-for-byte, its ` + + `binding keeping \`a.stay\`'s reference (SPEC 6.5, 2.1, 3; ` + + `H-4, normalizing nothing)`, + ); + await c10AssertPostMove( + product, + workspace, + { + depends: [ + C10_MOVED_DEPENDS, + { + from: `${C10_TARGET}#b`, + to: `${C10_THIRD}#foo`, + kind: "depends", + }, + { + from: `${C10_ORIGIN}#a.stay`, + to: `${C10_THIRD}#foo`, + kind: "depends", + }, + ], + embeds: [C10_MOVED_EMBEDS], + }, + context, + ); + }, + ); + } + { + const context = "T6.5-10 arm (c) identifier bound to another module"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [C10_THIRD]: C10_THIRD_STAGED, + [C10_FOURTH]: C10_FOURTH_STAGED, + [C10_ORIGIN]: C10_A_ORIGIN_STAGED, + [C10_TARGET]: C10_C_TARGET_BEFORE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + const files = await c10PreviewFiles(product, workspace, context); + await expectExit( + product, + workspace, + [...C10_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\``, + ); + + const text = await readSourceText(workspace, C10_TARGET, context); + const root = c10ReferenceRoot( + text, + C10_REWRITTEN_DEPENDS, + '`<S id="mv" d={<binding>.foo}>`', + context, + ); + const embedRoot = c10ReferenceRoot( + text, + C10_REWRITTEN_EMBEDS, + '`{text(<binding>["bar-baz"])}`', + context, + ); + if (embedRoot !== root) { + fail( + `${context}: the moved references are rooted at different ` + + `identifiers — the \`d\` reference at ${JSON.stringify(root)}, ` + + `the embedding at ${JSON.stringify(embedRoot)} — while both ` + + `were bound by the origin's one \`X\` import and need the ` + + `one binding of ${C10_THIRD}'s module the added import ` + + `supplies (SPEC 6.5, 2.1)`, + ); + } + if (root === "X") { + fail( + `${context}: the moved spellings stay rooted at \`X\`, which ` + + `the target binds to ${C10_FOURTH}'s module — \`X.foo\` read ` + + `there names ${C10_FOURTH}#foo, a silent retarget behind a ` + + `reported success — while the added import binds a fresh ` + + `identifier colliding with no binding already in the file ` + + `and each moved reference is re-rooted to it (SPEC 6.5, 2.1, ` + + `6.4)`, + ); + } + for (const forbidden of A8_MDX_RESERVED) { + if (root === forbidden.name) { + fail( + `${context}: the added import binds \`${forbidden.name}\`, ` + + `${forbidden.why} — an added import binds fresh ` + + `identifiers colliding with no binding already in the ` + + `file (SPEC 6.5, 2.1, 14.15)`, + ); + } + } + assertAddedImportInsertion( + { + rel: C10_TARGET, + base: Buffer.from(C10_C_TARGET_BASE(root), "utf8"), + actual: await workspace.readBytes(C10_TARGET), + importerDir: posixPath.dirname(C10_TARGET), + expectedModule: C10_THIRD_MODULE, + identifier: root, + }, + `${context}: ${C10_TARGET} after the move is its composed ` + + `post-move bytes — the target's own \`X.q\` and its \`z\` ` + + `declaration byte-untouched, each moved spelling re-rooted to ` + + `the fresh identifier with its access form kept — with ` + + `exactly one import of ${C10_THIRD}'s module added as a line ` + + `of its own, byte-exactly 6.5's spelling followed by U+000A ` + + `at a line-start offset, binding that identifier, no other ` + + `byte inserted (SPEC 6.5, 2.1, 6.4, 3; T6.5-8)`, + ); + await assertRewrittenSpecDerives(workspace, C10_TARGET, context); + c10AssertPreviewRewrites(files, root, context); + await assertFileBytes( + workspace.path(C10_ORIGIN), + C10_A_ORIGIN_AFTER, + `${context}: ${C10_ORIGIN} after the move — as in (a): the ` + + `moved section deleted in place with its emptied lines ` + + `dropped, the blank neighbours kept, and the \`X\` import, its ` + + `binding left without references, deleted with its line's ` + + `U+000A, otherwise byte-identical (SPEC 6.5, 2.1, 3; H-4, ` + + `normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(C10_FOURTH), + C10_FOURTH_SOURCE, + `${context}: ${C10_FOURTH} after the move — the fourth source, ` + + `whose \`q\` the target's own section references and whose ` + + `\`foo\` is the lure, untouched (SPEC 6.5; H-4)`, + ); + await c10AssertPostMove( + product, + workspace, + { + depends: [C10_MOVED_DEPENDS, C10_TARGET_OWN_DEPENDS], + embeds: [C10_MOVED_EMBEDS], + }, + context, + ); + }, + ); + } + { + const context = "T6.5-10 arm (c) sibling: the module also bound as `Z`"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [C10_THIRD]: C10_THIRD_STAGED, + [C10_FOURTH]: C10_FOURTH_STAGED, + [C10_ORIGIN]: C10_B_ORIGIN_BEFORE, + [C10_TARGET]: C10_CS_TARGET_BEFORE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await expectExit( + product, + workspace, + [...C10_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\``, + ); + await assertFileBytes( + workspace.path(C10_TARGET), + C10_CS_TARGET_AFTER, + `${context}: ${C10_TARGET} after the move — the re-identified ` + + `moved text appended at end of file plus U+000A, each moved ` + + `spelling re-rooted to the existing \`Z\` binding of ` + + `${C10_THIRD}'s module with quote style and access form kept, ` + + `nothing added (the file lacks no binding of the module), ` + + `\`X.q\` and both declarations byte-untouched — (b)'s ` + + `whole-file contract (SPEC 6.5, 6.4, 2.1; H-4, normalizing ` + + `nothing)`, + ); + await assertFileBytes( + workspace.path(C10_ORIGIN), + C10_B_ORIGIN_AFTER, + `${context}: ${C10_ORIGIN} after the move — as in (b): the ` + + `moved section deleted in place with its emptied lines ` + + `dropped, the blank neighbours kept, and the \`X\` import kept ` + + `byte-for-byte, its binding keeping \`a.stay\`'s reference ` + + `(SPEC 6.5, 2.1, 3; H-4, normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(C10_FOURTH), + C10_FOURTH_SOURCE, + `${context}: ${C10_FOURTH} after the move — the fourth source ` + + `untouched (SPEC 6.5; H-4)`, + ); + await c10AssertPostMove( + product, + workspace, + { + depends: [ + C10_MOVED_DEPENDS, + C10_TARGET_OWN_DEPENDS, + { + from: `${C10_TARGET}#c`, + to: `${C10_THIRD}#foo`, + kind: "depends", + }, + { + from: `${C10_ORIGIN}#a.stay`, + to: `${C10_THIRD}#foo`, + kind: "depends", + }, + ], + embeds: [C10_MOVED_EMBEDS], + }, + context, + ); + }, + ); + } + }, +}); + /** TEST-SPEC §6.5, in canonical ID order (SUITE-25). */ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_1, @@ -1814,4 +7950,8 @@ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_4, T6_5_5, T6_5_6, + T6_5_7, + T6_5_8, + T6_5_9, + T6_5_10, ]; diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index db1be165..71f49601 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -1,79 +1,404 @@ -// 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 +// performed-operation document (T6.4-1's protocol; the form-exact 12.7 +// form, exactly `{"findings", "mapping"}` with `findings` `[]`, H-3) +// carries a `mapping` equal to the preview's, pair for pair in order — both +// documents' `from`-byte order is decode-enforced (SPEC 12.7), and the raw +// decoded arrays compare as ordered arrays. 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's T6.5-6, T6.5-16, and T6.5-17 twins: the identity-terms +// refusals run on T6.5-6's post-move workspace staged directly from the +// bytes that test asserts (section-6.5.ts's MOVE_IDENTITY_* exports) — +// the exact self-move of either form, the file form refused as +// identity-unchanged alone (SPEC 6.5, 14), and the same-file +// after-removal collision — and every refused arm of T6.5-16 (its alone +// arms included) and of T6.5-17 is staged from the arm tables +// section-6.5-iii.ts exports (R16_REFUSED_ARMS, R16_ALONE_ARMS, +// M17_REFUSED_ARMS under R16_CONFIG), the premise re-pinning each arm's +// code set alone — the finding's concerned data and the S-9 premises stay +// the home test's — before the equivalence compare. T6.5-20's refused +// stagings run through section-6.5-iv.ts's own staging code +// (`d20RefusedStagings`, `runD20RefusedStaging`): the companion legs read +// as the home test reads them, each staging before any build or after its +// premise `build` as the home test stages it, the code set +// (`refused-invalid-destination` alone) re-pinned before the compare. +// T6.5-21's refused stagings — (a), its two-reason move included, and +// (b) — run likewise through `D21_REFUSED_STAGINGS` and +// `runD21RefusedStaging`, each move's code set re-pinned before the +// compare. +// The U+FFFD destination operands (T6.5-5's +// MOVE_REPLACEMENT_DESTINATION_CASES, either leg) join the usage sweep +// below. +// - 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; the rename arm's descendants `z` and +// `c` stand in document order opposite to `from`-byte order, so a mapping +// in document order fails the decoder's 12.7 order check and the exact +// list alike (TEST-SPEC T6.6-4). Judgment calls pinned here (H-4): an +// `id`-attribute rewrite spans the attribute's own characters (`id="…"`, +// name through closing quote — SPEC 6.6 "the `id` attribute's own +// characters", the construct-spelling reading its sibling clauses use for +// 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, +// and arm (e) makes its final tie-break a pinned observation: identical +// ranges arise only between zero-length insertion points, where 6.5's +// line-start preference puts an import addition at the target +// insertion's offset, so TEST-SPEC T6.6-4 names T6.5-13's (b) (a +// self-closing target parent's tag end), (d) both variants (the end of a +// paragraph-ended file), and (g) (two declarations added at one offset) +// — restaged here byte for byte from section-6.5-iii's exported +// `A13_TIE_BREAK_ARMS`, the receiving file's edit list compared entry for +// entry (`import-addition` before `target-insertion` at one offset; +// (g)'s two `import-addition` entries adjacent, their count the +// observation), then the whole plan once the real operation on the +// preview-pinned state has bound the fresh identifiers (the origin's +// deletion with the `id-rewrite` and each embedding's `reference-rewrite` +// nested inside it, the latter reported exactly when the chosen binding +// changes the spelling, SPEC 6.5), the receiving and other files +// byte-asserted against T6.5-13's composed forms. 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). The lagging-record arm builds WITHOUT emission, then enables +// `markdown.emit` in the configuration and takes the same move preview +// with no rebuild: the record holds modules and companions alone — a +// lagging record alone is never staleness (SPEC 13.3), and a preview +// never refreshes it — so `generated` is exactly the destination's +// module, companions, and Markdown together with every OTHER discovered +// source's Markdown emit destination (the paths the current configuration +// generates that the stale record lacks; the staying sources' recorded +// modules and companions regenerate in place, in neither direction; the +// origin's Markdown, never recorded and not generated post-move, in +// neither) and `removed` exactly the recorded pre-move module and +// companions, no Markdown among them — a product composing either +// direction from the configuration alone, or from presence, fails a set +// equality; whole-root compare around the invocation. +// - T6.6-6 stages the unreadable record through the H-3 corrupt-record +// adapter (record-staging.ts): shape-blind garbage — files present, their +// bytes readable as no record, not even valid UTF-8 — over every +// 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 { StagedMdx, stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.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, + decodeFindingsReport, + decodePerformedOperationReport, + decodePreviewReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + assertFileBytes, + 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, + rethrowOutputOverflow, + runProduct, + startProduct, +} from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } 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 { + A8_PLAIN_TARGET, + MOVE_DERIVED_LINK_CASE, + MOVE_DERIVED_LINK_FILES, + MOVE_DERIVED_PATH_CASE, + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + MOVE_IDENTITY_CONFIG, + MOVE_IDENTITY_FILES_AFTER, + MOVE_IDENTITY_REFUSAL_CASES, + MOVE_LINK_OUTSIDE_CASES, + MOVE_LINK_OUTSIDE_FILES, + MOVE_MIXED_SYNOPSIS_CASES, + MOVE_NON_UTF8_ARGV, + MOVE_PRECONDITION_BREAK, + MOVE_PRECONDITION_BREAK_FILE, + MOVE_PRECONDITION_CASE, + MOVE_PRECONDITION_FILES, + MOVE_REFUSAL_CASES, + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, + MOVE_REPLACEMENT_DESTINATION_CASES, + MOVE_SOLO_ARGV, + MOVE_SOLO_CONFIG, + MOVE_SOLO_FILES, + MOVE_USAGE_CASES, + MOVE_USAGE_CONFIG, + MOVE_USAGE_ORDERING_FILES, + MOVE_WRONG_KIND_CASES, + stageMoveDerivedLinkComponent, + stageMoveLinkOutsideComponent, + stageMoveRefusalOccupants, +} from "./section-6.5.js"; +import type { A13TieBreakArm } from "./section-6.5-iii.js"; +import { + A13_TIE_BREAK_ARMS, + M17_REFUSED_ARMS, + R16_ALONE_ARMS, + R16_CONFIG, + R16_REFUSED_ARMS, + a13ReadAddedIdentifiers, +} from "./section-6.5-iii.js"; +import { + D21_REFUSED_STAGINGS, + d20RefusedStagings, + runD20RefusedStaging, + runD21RefusedStaging, +} from "./section-6.5-iv.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. +// A staged-source record: T6.6-2 stages it in a workspace created after a +// product invocation, and T6.6-5 by `file()` after one (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_MD_CONFIG = stagedTs( + "T6.6-2/T6.6-5 xspec.config.ts — one spec group with Markdown emission", + `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). + * The record-accepting initial `files`: the sets section-6.4.ts, + * section-6.5.ts, and section-6.5-iii.ts export, and this module's own + * initial files of every workspace a body creates after its first product + * invocation, stage their `.mdx` entries as staged-source records + * (helpers/staged-mdx.ts; S-9's before-any-product clause). + */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, 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 +408,3413 @@ 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`, its performed-operation document decoded form-exact (SPEC 12.7: + * exactly `{"findings", "mapping"}`, `findings` `[]`; T6.4-1's protocol) + * and its `mapping` asserted equal — as the ordered array, pair for pair — + * to the preview's `mapping` (T6.6-2: byte-equal mappings). */ -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 performed = decodePerformedOperationReport( + await runJson( + product, + workspace, + argv, + `${context}: \`${argv.join(" ")}\``, + ), context, ); - return decodeImpactReport(parseJsonStdout(result, context), context); + assertSameJson( + performed.mapping, + previewMapping, + `${context}: a subsequent real run on the same state performs the ` + + `previewed plan — its performed-operation document's \`mapping\` ` + + `(T6.4-1's report, form-exact per 12.7) is byte-equal to the ` + + `preview's \`mapping\`, the raw decoded arrays compared pair for ` + + `pair in order (SPEC 6.6, 6.4, 6.5, 12.7; T6.6-2)`, + ); } -/** 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 = stagedMdx( + "T6.6-2 move arm specs/Origin.mdx", + [ + '<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"), +); +// The plain target — the bytes T6.5-8 and T6.5-9 stage at +// `specs/target.mdx`, so section-6.5.ts's record (one record for identical +// bytes across tests). +const P2_TARGET_SOURCE = A8_PLAIN_TARGET; +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 (its home test's staging, 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; the twinned refusal's ` + + `home test)`, + ); + 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 the refusal's home ` + + `test)`, ); - } - 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 twin of a T6.5-16/T6.5-17 arm (their exported arm tables, + * staged identically: the pre-move files under those arms' configuration, + * the premise `build` laying down the derived files the modifies-nothing + * compare covers): the real refusal, then its `--preview`, through + * expectRefusedPreviewEquivalence. The premise re-pins the arm's code set + * alone — `refused-invalid-rewrite` or `refused-moved-import` beside every + * other applicable reason, or an alone arm's reasons — while the finding's + * concerned data and the S-9 premises stay the home test's; the equivalence + * compare then covers every contractual member (SPEC 6.5, 6.6, 14). + */ +async function expectRefusedArmPreviewTwin( + product: ProductBinding, + files: Readonly<Record<string, InitialFileContents>>, + argv: readonly string[], + codes: readonly string[], + context: string, +): Promise<void> { + await withWorkspace(R16_CONFIG, files, async (workspace) => { + await buildOk( + product, + workspace, + `${context}: \`build\` over the staging — the pre-move workspace is ` + + `valid, every staged file well-formed (SPEC 6.4, 6.5, 14.20)`, ); + await expectRefusedPreviewEquivalence( + product, + workspace, + argv, + codes.map((finding) => ({ finding })), + context, + ); + }); +} - 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()), - ); - } - } - } +/** + * One T6.6-3 usage-error-equivalence pair (T6.4-4/T6.5-5, staged + * identically): the real invocation and then the `--preview` one, each with + * `--json` — exit 2 exactly, the single 12.7 error document as the entire + * stdout (12.0, H-5), and a usage error message on stderr (presence, not + * wording). Argument checks precede either way (SPEC 6.6, 12.0). Accepts + * raw-byte argv elements for the Linux-leg non-UTF-8 destination case. + */ +async function expectUsageErrorEitherWay( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly ArgvValue[], + context: string, +): Promise<void> { + const invocations: readonly (readonly [readonly ArgvValue[], string])[] = [ + [[...argv, "--json"], "real invocation"], + [[...argv, "--preview", "--json"], "`--preview` invocation"], + ]; + for (const [fullArgv, what] of invocations) { + const label = `${context} (${what})`; + const result = await runProduct(product, { + cwd: workspace.root, + argv: fullArgv, + }); + assertExitCode( + result, + 2, + `${label}: the usage error is exit 2 with \`--preview\` exactly as ` + + `without it — argument checks precede either way (SPEC 6.6, 12.0)`, + ); + expectErrorDocument( + result, + `${label}: under --json, the exit-2 error document is the entire ` + + `stdout — no report, no validation findings (SPEC 12.0, 12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${label}: usage error messages are standard-error content ` + + `(SPEC 12.0), but stderr is empty`, + ); } } - - 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, T6.5-4, T6.5-6, T6.5-16, T6.5-17, T6.5-20, and T6.5-21 — the invalid-workspace precondition, the exact self-move of either form (the file form refused as identity-unchanged alone, never `refused-destination-exists` beside it), the same-file after-removal collision, every `refused-invalid-rewrite` and `refused-moved-import` arm with the reasons reported beside it, T6.5-16's alone arms, and T6.5-21's two-reason move included — staged identically (T6.5-6's post-move workspace staged directly from the bytes it asserts; T6.5-16's, T6.5-17's, T6.5-20's, and T6.5-21's arms from their exported tables — T6.5-20's companion legs read as its home test reads them, each T6.5-20 and T6.5-21 staging before any build or after a `build` as its home test stages it), 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 — the U+FFFD destination operands of either leg and the Linux leg's non-UTF-8 operand included — 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)", + // The twins of T6.5-16's and T6.5-17's arms add some fifty stagings, each + // with its premise `build` and two invocations: a wider hang guard (H-8). + timeoutMs: 240_000, 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, - `${context}: \`build\` after the direct edit — nothing references ` + - `the vacated identity, so the workspace stays valid`, + "T6.6-3 move-refusal staging `build` (occupants staged before it; " + + "T6.5-4's protocol)", ); - await assertNoJournal( + 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 outside-root link-component arms, staged identically on + // their own workspace: the real operation and the preview alike leave + // the link's target directory outside the root byte-identical. + await withWorkspace( + MOVE_REFUSAL_CONFIG, + MOVE_LINK_OUTSIDE_FILES, + async (workspace) => { + const outside = await stageMoveLinkOutsideComponent(workspace); + await buildOk( + product, workspace, - "after the direct edit and the `build` over it", - context, + "T6.6-3 outside-root link staging `build` (the link staged before " + + "it lies under no current source's write path; T6.5-4's protocol)", ); + for (const { argv, expected, reason } of MOVE_LINK_OUTSIDE_CASES) { + await assertLeavesUnchanged( + outside, + () => + expectRefusedPreviewEquivalence( + product, + workspace, + argv, + expected, + `T6.6-3 move refusal (${reason})`, + ), + `T6.6-3 move refusal (${reason}): the link's target directory ` + + `outside the workspace root stays byte-identical across the ` + + `real operation and its preview (SPEC 6.5, 6.6, 13.4)`, + ); + } + }, + ); - 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 derived-path arm, staged identically on its own workspace. + await withWorkspace( + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + async (workspace) => { + await buildOk( + product, + workspace, + "T6.6-3 derived-path staging `build` — the occupant lies under no " + + "current source's write path (T6.5-4's derived-path arm), so " + + "the refusal previewed below is the move's own", + ); + await expectRefusedPreviewEquivalence( + product, + workspace, + MOVE_DERIVED_PATH_CASE.argv, + MOVE_DERIVED_PATH_CASE.expected, + `T6.6-3 move refusal (${MOVE_DERIVED_PATH_CASE.reason})`, ); - 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"); + // T6.5-4's derived-path link sibling, staged identically on its own + // workspace. await withWorkspace( - { - [V2_ORIGIN]: originSource("b.mid", "b.mid").text, - [V2_WATCH]: staleWatch.text, - }, + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_LINK_FILES, async (workspace) => { - const context = "T6.6-1 validation arm"; - await buildOk(product, workspace, `${context}: \`build\``); - await assertNoJournal( + await stageMoveDerivedLinkComponent(workspace); + await buildOk( + product, workspace, - "before any journaled operation (staging premise)", - context, + "T6.6-3 derived-path link-sibling staging `build` — the link " + + "mdout/new lies under no current source's write path (T6.5-4's " + + "sibling arm), so the refusal previewed below is the move's own", ); - - // 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}`, - ); - } - await assertNoJournal( + await expectRefusedPreviewEquivalence( + product, workspace, - "after the direct edit and the failing `build`", - context, + MOVE_DERIVED_LINK_CASE.argv, + MOVE_DERIVED_LINK_CASE.expected, + `T6.6-3 move refusal (${MOVE_DERIVED_LINK_CASE.reason})`, ); + }, + ); - // "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); + // 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, - `${context}: \`build\` after rewriting both dependents to the new ` + - `identity — the workspace validates again (SPEC 6.6, 14.5)`, + "T6.6-3 precondition staging `build` over the staged workspace", + ); + await workspace.file( + MOVE_PRECONDITION_BREAK_FILE, + MOVE_PRECONDITION_BREAK, ); - await assertNoJournal( + await expectRefusedPreviewEquivalence( + product, workspace, - "after the dependents were rewritten and the `build` over them", - context, + MOVE_PRECONDITION_CASE.argv, + MOVE_PRECONDITION_CASE.expected, + `T6.6-3 move refusal (${MOVE_PRECONDITION_CASE.reason})`, + ); + }, + ); + + // --- Refusal equivalence: T6.5-6's identity-terms refusals, staged + // identically — the post-kept-ID-move workspace that test asserts + // byte-exact, staged directly from those bytes (SPEC 6.5, 6.6): the + // exact self-move of either form (the file form refused as + // identity-unchanged alone) and the same-file after-removal collision, + // each reported alike by the real operation and its preview --- + await withWorkspace( + MOVE_IDENTITY_CONFIG, + MOVE_IDENTITY_FILES_AFTER, + async (workspace) => { + await buildOk( + product, + workspace, + "T6.6-3 identity-terms staging `build` (T6.5-6's post-move " + + "workspace, staged directly; the derived files sit under the " + + "modifies-nothing compares)", + ); + for (const { argv, expected, reason } of MOVE_IDENTITY_REFUSAL_CASES) { + await expectRefusedPreviewEquivalence( + product, + workspace, + argv, + expected, + `T6.6-3 move refusal (${reason})`, + ); + } + }, + ); + + // --- Refusal equivalence: T6.5-16's `refused-invalid-rewrite` arms — + // every refused shape beside the reasons reported with it, and the + // alone arms refused for another reason with no would-be text judged — + // staged identically from the exported arm tables (SPEC 6.5, 6.6, 14) + // --- + for (const arm of R16_REFUSED_ARMS) { + await expectRefusedArmPreviewTwin( + product, + arm.files, + arm.argv, + ["refused-invalid-rewrite", ...(arm.beside ?? [])], + `T6.6-3 move refusal (T6.5-16 ${arm.key})`, + ); + } + for (const arm of R16_ALONE_ARMS) { + await expectRefusedArmPreviewTwin( + product, + arm.files, + arm.argv, + arm.codes, + `T6.6-3 move refusal (T6.5-16 ${arm.key})`, + ); + } + + // --- Refusal equivalence: T6.5-17's `refused-moved-import` arms, + // staged identically from the exported arm table (SPEC 6.5, 6.6, 14) --- + for (const arm of M17_REFUSED_ARMS) { + await expectRefusedArmPreviewTwin( + product, + arm.files, + arm.argv, + ["refused-moved-import", ...(arm.beside ?? [])], + `T6.6-3 move refusal (T6.5-17 ${arm.key})`, + ); + } + + // --- Refusal equivalence: T6.5-20's refused stagings, staged + // identically from the exported table through the home test's own + // staging code — the companion legs read as the home test reads them, + // each staging before any build or after its premise `build` as the + // home test stages it (SPEC 6.5, 6.6, 14) --- + for (const staging of await d20RefusedStagings( + product, + "T6.6-3 (T6.5-20)", + )) { + await runD20RefusedStaging( + product, + staging, + `T6.6-3 move refusal (T6.5-20 ${staging.key})`, + (workspace, move, context) => + expectRefusedPreviewEquivalence( + product, + workspace, + move.argv, + { finding: "refused-invalid-destination" }, + context, + ), + ); + } + + // --- Refusal equivalence: T6.5-21's refused stagings — (a) with its + // two-reason move, and (b) — staged identically from the exported table + // through the home test's own staging code, (a) after its premise + // `build` and (b) before any build as the home test stages them, the + // code set re-pinned before the compare (SPEC 6.5, 6.6, 14) --- + for (const staging of D21_REFUSED_STAGINGS) { + await runD21RefusedStaging( + product, + staging, + `T6.6-3 move refusal (T6.5-21 ${staging.key})`, + (workspace, move, context) => + expectRefusedPreviewEquivalence( + product, + workspace, + move.argv, + move.findings.map((expected) => ({ finding: expected.code })), + context, + ), + ); + } + + // --- Usage-error equivalence: T6.4-4's usage errors on its + // ordering-shaped staging --- + await withWorkspace( + RENAME_USAGE_CONFIG, + RENAME_USAGE_ORDERING_FILES, + async (workspace) => { + const context = "T6.6-3 rename usage"; + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace ` + + `fails build validation (unresolved d reference, SPEC 14.5), ` + + `so exit 2 across each pair realizes "argument checks precede ` + + `either way" (T6.4-4's ordering arm)`, + ); + if (findings.length === 0) { + fail( + `${context}: staging premise — the failing \`build\` must ` + + `report at least one validation finding (SPEC 14)`, + ); + } + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectUsageErrorEitherWay( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + }, + `${context}: the usage errors modify nothing, previewed or not ` + + `(SPEC 12.0)`, + ); + }, + ); + await withWorkspace( + RENAME_REFUSAL_CONFIG, // the same specs-only configuration (T6.4-4) + RENAME_SOLO_FILES, + async (workspace) => { + await runSoloUsageArm( + product, + workspace, + RENAME_SOLO_ARGV, + "T6.6-3 rename usage, spells-no-identity arm", + ); + }, + ); + + // --- Usage-error equivalence: T6.5-5's usage errors on its + // ordering-shaped staging --- + await withWorkspace( + MOVE_USAGE_CONFIG, + MOVE_USAGE_ORDERING_FILES, + async (workspace) => { + const context = "T6.6-3 move usage"; + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace ` + + `fails build validation (unresolved d reference, SPEC 14.5), ` + + `so exit 2 across each pair realizes "argument checks precede ` + + `either way" (T6.5-5's ordering arm)`, + ); + if (findings.length === 0) { + fail( + `${context}: staging premise — the failing \`build\` must ` + + `report at least one validation finding (SPEC 14)`, + ); + } + await assertLeavesUnchanged( + workspace.root, + async () => { + // T6.5-5's usage tables, the U+FFFD destination operands of + // either leg included: malformed argument values, exit 2 + // before any refusal is evaluated (SPEC 12.0, 6.5; T12.0-5). + for (const [argv, label] of [ + ...MOVE_USAGE_CASES, + ...MOVE_WRONG_KIND_CASES, + ...MOVE_MIXED_SYNOPSIS_CASES, + ...MOVE_REPLACEMENT_DESTINATION_CASES, + ]) { + await expectUsageErrorEitherWay( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + // The non-UTF-8 destination operand (raw argv bytes) — Linux + // leg only, as staged in T6.5-5. + if (process.platform === "linux") { + await expectUsageErrorEitherWay( + product, + workspace, + MOVE_NON_UTF8_ARGV, + `${context}, non-UTF-8 destination operand (raw argv ` + + `bytes, Linux leg — T6.5-5's staging)`, + ); + } + }, + `${context}: the usage errors modify nothing, previewed or not ` + + `(SPEC 12.0)`, + ); + }, + ); + await withWorkspace( + MOVE_SOLO_CONFIG, + MOVE_SOLO_FILES, + async (workspace) => { + await runSoloUsageArm( + product, + workspace, + MOVE_SOLO_ARGV, + "T6.6-3 move usage, spells-no-identity arm", + ); + }, + ); + + // --- Scheduling: the equivalence is over workspace state, never + // scheduling (SPEC 6.6) — T13.5-2's staging and choreography --- + const workspace = await TestWorkspace.create(CORE_DECL); + try { + await buildOk(product, workspace, "T6.6-3 scheduling staging `build`"); + + const hold = holdPathFor(workspace, "hold-t663-primary.tmp"); + const context1 = + "T6.6-3 held command 1 `rename specs/A.mdx a a2 --test-hold <path>` " + + "(T13.5-2's staging)"; + const running = await startProduct(product, { + cwd: workspace.root, + argv: ["rename", "specs/A.mdx", "a", "a2", "--test-hold", hold], + }); + try { + await awaitHoldFile(running, hold, context1); + const heldBaseline = await snapshotDirectory(workspace.root); + + // The same second command T13.5-2 asserts is refused exit 2 without + // `--preview` runs to completion with it (SPEC 6.6, 13.5). + const previewArgv = [ + "rename", + "specs/A.mdx", + "g", + "g2", + "--preview", + "--json", + ]; + const context = `T6.6-3 \`${previewArgv.join(" ")}\` while command 1 is held`; + const result = await runBounded( + product, + workspace.root, + previewArgv, + context, + ); + assertExitCode( + result, + 0, + `${context}: a preview invocation is a non-mutating command under ` + + `13.5 — it takes no exclusivity, so while another mutating ` + + `command is held it runs to completion, never meeting the ` + + `mutual-exclusion refusal (exit 2) T13.5-2 asserts for the same ` + + `second command without --preview, and never blocking ` + + `(SPEC 6.6, 13.5)`, + ); + const report = decodePreviewReport( + parseJsonStdout(result, context), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the preview runs to completion with its full ` + + `successful report — findings [] (SPEC 6.6)`, + ); + if ( + report.mapping === null || + report.files === null || + report.delta === null + ) { + fail( + `${context}: the completed preview reports its plan — ` + + `\`mapping\`, \`files\`, and \`delta\` non-null (SPEC 6.6, ` + + `12.7)`, + ); + } + if (running.hasExited()) { + fail( + `${context}: command 1 must still be held when the preview ` + + `completes — otherwise the completion is not attributable to ` + + `the preview's taking no exclusivity (SPEC 6.6, 13.5) — ` + + `${await describeExit(running)}`, + ); + } + assertSnapshotsEqual( + heldBaseline, + await snapshotDirectory(workspace.root), + `${context}: the preview modifies nothing while another command ` + + `is held (SPEC 6.6)`, + ); + await releaseHoldFile(hold); + let result1: RunResult; + try { + result1 = await running.waitForExit(); + } catch (error) { + rethrowOutputOverflow(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 comparator + * assertion; arm (e) pins the tie-break's observations). + */ +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. A +// staged-source record: T6.6-4's arms (b)–(d) and T6.6-5's later workspaces +// stage it after a product invocation (S-9's timing clause). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.6-4/T6.6-5 xspec.config.ts — exactly one spec group, no code group", + `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 descendants `core.mid.z` and +// `core.mid.c`, in that document order) is renamed to `core.hub`; affected +// references, all through `core.mid.z`: two `d` entries and one MDX +// embedding in the origin file (string form), a `d` chain and an embedding +// in a second MDX file (external form), and a marker plus a `text(...)` call +// in a TS file. Controls that must produce NO edit: `d={"core.plain"}` (its +// target keeps its identity), every unaffected `id` attribute, and the +// unrelocated `./Core.xspec` import specifiers. Multi-byte text ("hölder", +// "ünicode", "Δ") precedes every located construct. +// +// The mapping-order fixture (TEST-SPEC T6.6-4): the descendants stand in +// document order (`z` before `c`) opposite to the byte order of their +// `from` identities (`…#core.mid.c` < `…#core.mid.z`), so a product listing +// the mapping in document order — the order its subtree traversal yields — +// fails both decodePreviewReport's `from`-byte order check (SPEC 12.7, H-3) +// and the exact-list comparison against the expected mapping. `c` bears no +// reference (its only edit is its `id-rewrite`), keeping the discriminator +// in the mapping alone; mappingInFromByteOrder composes the expectation and +// guards that the two orders really differ. +const A4_CORE = "specs/Core.mdx"; +const A4_OTHER = "specs/Other.mdx"; +const A4_USE = "src/use.ts"; +const A4_CORE_SOURCE = [ + '<S id="core">', + "Core hölder text.", + "", + '<S id="core.mid" d={"core.plain"}>', + "Mid text.", + "", + '<S id="core.mid.z">', + "Z text.", + "</S>", + "", + '<S id="core.mid.c">', + "C text.", + "</S>", + "</S>", + "", + '<S id="core.sib" d={["core.mid", "core.mid.z"]}>', + 'Sib embeds: {text("core.mid.z")}', + "</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.z)}", + "</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.z;", + " return text(SPEC.core.mid);", + "}", + "", +].join("\n"); +const A4_RENAME_ARGV = ["rename", A4_CORE, "core.mid", "core.hub"] as const; + +/** + * The expected mapping in `from`-byte order (SPEC 12.7), composed from the + * mapped pairs of one spec file — order of listing immaterial — with a + * staging guard: the pairs' DOCUMENT order, read off the staged bytes as + * the order of the mapped IDs' `id` attributes, must differ from the byte + * order, or the fixture could not fail a product emitting document order + * (TEST-SPEC T6.6-4) — a re-staging whose descendants happen to sort alike + * is a staging defect (harness error), never a weaker test. + */ +function mappingInFromByteOrder( + source: string, + pairs: readonly AppliedMappingPair[], + where: string, +): readonly AppliedMappingPair[] { + const documentIndex = (pair: AppliedMappingPair): number => + uniqueCharIndex( + source, + `id="${pair.from.slice(pair.from.indexOf("#") + 1)}"`, + `${where}: ${pair.from}`, + ); + const documentOrder = [...pairs].sort( + (a, b) => documentIndex(a) - documentIndex(b), + ); + const byteOrder = [...pairs].sort((a, b) => + Buffer.compare(Buffer.from(a.from, "utf8"), Buffer.from(b.from, "utf8")), + ); + if (byteOrder.every((pair, index) => pair === documentOrder[index])) { + throw new Error( + `T6.6-4 staging (${where}): the mapping's document order coincides ` + + `with its \`from\`-byte order, so the fixture cannot discriminate a ` + + `product emitting document order (TEST-SPEC T6.6-4)`, + ); + } + return byteOrder; +} + +function armAPlan(): ExpectedPreviewPlan { + const core = A4_CORE_SOURCE; + const dArray = 'd={["core.mid", "core.mid.z"]}'; + return { + // The complete mapping — the renamed ID and every descendant — in + // `from`-byte order: `…#core.mid.c` before `…#core.mid.z`, the reverse + // of the document order the pairs are listed in (SPEC 12.7, 6.4). + mapping: mappingInFromByteOrder( + core, + [ + { from: "specs/Core.mdx#core.mid", to: "specs/Core.mdx#core.hub" }, + { from: "specs/Core.mdx#core.mid.z", to: "specs/Core.mdx#core.hub.z" }, + { from: "specs/Core.mdx#core.mid.c", to: "specs/Core.mdx#core.hub.c" }, + ], + "a: mapping", + ), + files: [ + { + file: A4_CORE, + 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.z"', "a: z id"), + }, + { + class: "id-rewrite", + range: uniqueSpan(core, 'id="core.mid.c"', "a: c id"), + }, + // Each `d` array entry is its own occurrence spanning that one + // reference's own expression (SPEC 5.7) — located through the + // 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.z"', "a: d core.mid.z"), + }, + // An MDX embedding spans the entire `{text(...)}` container, + // opening brace through closing brace (SPEC 5.7). + { + class: "reference-rewrite", + range: uniqueSpan(core, '{text("core.mid.z")}', "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.z)}", + "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.z", "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"); +// Arm (b)'s initial files as staged-source records (S-9): the strings stay +// for the offsets `armBPlan` and the real-run byte assertion pin. +const B4_ORIGIN_STAGED = stagedMdx( + "T6.6-4 arm (b) specs/Origin.mdx", + B4_ORIGIN_SOURCE, +); +const B4_TARGET_STAGED = stagedMdx( + "T6.6-4 arm (b) specs/Target.mdx", + B4_TARGET_SOURCE, +); +const B4_THIRD_STAGED = stagedMdx( + "T6.6-4 arm (b) specs/Third.mdx", + B4_THIRD_SOURCE, +); +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_USER_SOURCE = [ + 'import MV from "./Mv.xspec"', + "", + '<S id="user" d={MV.mv}>', + "User text.", + "</S>", + "", +].join("\n"); +// The file-form move's initial files as staged-source records (S-9), staged +// by T6.6-4's arm (c) and both of T6.6-5's file-form arms: `Mv` and `User` +// keep their strings for the spans `armCPlan` pins. +const C4_MV_STAGED = stagedMdx("T6.6-4/T6.6-5 specs/Mv.mdx", C4_MV_SOURCE); +const C4_PAL_STAGED = stagedMdx( + "T6.6-4/T6.6-5 specs/Pal.mdx", + ['<S id="pal">', "Pal text.", "</S>", ""].join("\n"), +); +const C4_USER_STAGED = stagedMdx( + "T6.6-4/T6.6-5 specs/User.mdx", + C4_USER_SOURCE, +); +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"); +// The created-target move's origin as a staged-source record (S-9), staged +// by T6.6-4's arm (d) and T6.6-5's created-target arm: the string stays for +// the offsets `armDPlan` pins. +const D4_SOLO_STAGED = stagedMdx( + "T6.6-4/T6.6-5 specs/Solo.mdx", + D4_SOLO_SOURCE, +); +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 stagings. The comparator's final tie-break — +// class-name bytes after range start and range end — is observable only +// between zero-length insertion points, and 6.5's line-start preference +// fixes where an import addition's offset coincides with the target +// insertion's: TEST-SPEC T6.6-4 names T6.5-13's (b), (d) both variants, and +// (g), restaged here from section-6.5-iii's exported table byte for byte. +// The receiving file's edit list is the pinned observation; the origin +// entry is composed here from the same staged bytes — the deletion spanning +// the construct's own characters extended over its emptied line's +// terminator, the re-identification's `id-rewrite` and each embedding's +// `reference-rewrite` nested inside it, the latter present exactly when the +// fresh identifier the real operation binds changes the spelling's +// characters (SPEC 6.5: a spelling already resolving in the form it is +// rooted at is neither rewritten nor reported). + +/** + * The identifier-independent observation arm (e) exists for: the receiving + * file's edits exactly as pinned, in 12.7's order (SPEC 6.6, 12.7). + */ +function assertTieBreakEntry( + report: PreviewReport, + arm: A13TieBreakArm, + context: string, +): void { + const pinned = projectEdits(arm.receivingEdits); + if ( + JSON.stringify(projectEdits(editsInPinnedOrder(arm.receivingEdits))) !== + JSON.stringify(pinned) + ) { + throw new Error( + `T6.6-4 staging self-check (e ${arm.key}): the pinned receiving edits ` + + "must stand in 12.7's order", + ); + } + if (report.files === null) { + fail( + `${context}: a preview whose real operation would proceed reports its ` + + `plan — \`files\` is null exactly on refusal (SPEC 6.6, 12.7); ` + + `findings: ${JSON.stringify(report.findings)}`, + ); + } + const entry = report.files.find( + (candidate) => candidate.file === arm.receiving, + ); + if (entry === undefined) { + fail( + `${context}: \`files\` holds an entry for ${arm.receiving}, the file ` + + `the operation inserts the moved text and its added ` + + `declaration${arm.added.length > 1 ? "s" : ""} into (SPEC 6.6, ` + + `12.7); got [${report.files.map((candidate) => renderPathValue(candidate.file)).join(", ")}]`, + ); + } + const additions = arm.receivingEdits.filter( + (edit) => edit.class === "import-addition", + ).length; + assertSameJson( + projectEdits(entry.edits), + pinned, + `${context}: ${arm.receiving} — ${arm.summary}; the entry's edits are ` + + `exactly the pinned list, in 12.7's order — range start, then range ` + + `end, then class-name bytes, which puts ` + + (additions > 1 + ? `the ${String(additions)} \`import-addition\` entries, one per ` + + `added declaration and adjacent at their one offset, ` + : "the `import-addition` ") + + `before the \`target-insertion\` there: the tie-break observed ` + + `(SPEC 6.5, 6.6, 12.7; TEST-SPEC T6.5-13 ${arm.key})`, + ); +} + +/** + * The whole plan of a tie-break staging, composed from the staged bytes + * given the fresh identifiers the real operation bound (read off the added + * declarations): the mapping's one entry (the moved section holds no + * descendant), the origin entry, and the receiving file's pinned entry, the + * files in path-byte order (SPEC 6.6, 12.7). + */ +function tieBreakPlan( + arm: A13TieBreakArm, + idents: readonly string[], +): ExpectedPreviewPlan { + const where = `e ${arm.key}`; + const staged = arm.files[arm.origin]; + const origin = staged instanceof StagedMdx ? staged.source : staged; + if (typeof origin !== "string") { + throw new Error( + `T6.6-4 staging self-check (${where}): the origin ${arm.origin} is not staged as text`, + ); + } + if (arm.movedConstruct.indexOf("<S", 1) !== -1) { + throw new Error( + `T6.6-4 staging self-check (${where}): the moved section must hold no ` + + "descendant, so the mapping is its one entry", + ); + } + const constructChar = uniqueCharIndex( + origin, + arm.movedConstruct, + `${where}: moved construct`, + ); + if ( + (constructChar !== 0 && origin.charAt(constructChar - 1) !== "\n") || + origin.charAt(constructChar + arm.movedConstruct.length) !== "\n" + ) { + throw new Error( + `T6.6-4 staging self-check (${where}): the moved construct must occupy ` + + "whole lines, so the deletion's adjunct drop is exactly the emptied " + + "line's terminator", + ); + } + const construct = uniqueSpan( + origin, + arm.movedConstruct, + `${where}: construct`, + ); + const originDeletion: SourceRange = { + start: construct.start, + end: construct.end + 1, + }; + const nestedEdits: ExpectedEdit[] = [ + { + class: "id-rewrite", + range: spanWithin( + origin, + arm.movedConstruct, + arm.movedIdAttribute, + `${where}: id`, + ), + }, + ]; + arm.embeddings.forEach((embedding, index) => { + const ident = idents[index]; + if (ident === undefined) { + throw new Error( + `T6.6-4 staging self-check (${where}): one fresh identifier per embedding`, + ); + } + // Rooted at the chosen binding, the spelling keeps its characters when + // that binding is the origin's own: neither rewritten nor reported. + if (ident === embedding.binding) return; + nestedEdits.push({ + class: "reference-rewrite", + range: spanWithin( + origin, + arm.movedConstruct, + embedding.container, + `${where}: embedding ${String(index)}`, + ), + }); + }); + assertComposedWithin(originDeletion, nestedEdits, `${where}: origin`); + const from = arm.argv[1]; + const to = arm.argv[2]; + if (from === undefined || to === undefined) { + throw new Error( + `T6.6-4 staging self-check (${where}): a section-form move names two operands`, + ); + } + const files: ExpectedPreviewFile[] = [ + { + file: arm.origin, + edits: editsInPinnedOrder([ + { class: "origin-deletion", range: originDeletion }, + ...nestedEdits, + ]), + }, + { file: arm.receiving, edits: arm.receivingEdits }, + ]; + files.sort((a, b) => + Buffer.compare(Buffer.from(a.file, "utf8"), Buffer.from(b.file, "utf8")), + ); + return { mapping: [{ from, to }], files }; +} + +/** The rewritten `rel` as text — diagnosed when it is not valid UTF-8 (SPEC 1.6, 6.5). */ +async function readRewrittenText( + workspace: TestWorkspace, + rel: string, + context: string, +): Promise<string> { + const bytes = await workspace.readBytes(rel); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return fail( + `${context}: the rewritten ${rel} must remain valid UTF-8 (SPEC 1.6, 6.5)`, + ); + } +} + +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) ordered by `from` bytes — the descendants `core.mid.z` and `core.mid.c` standing in document order opposite to byte order, so a product emitting document order fails — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one contiguous range (the construct's own characters extended over the adjunct-dropped leftover whitespace and line terminator), the re-identification's `id-rewrite` edits and the moved text's reference rewrites nested inside that range, `target-insertion` zero-length at the insertion offset, `target-parent-rewrite` spanning the self-closing target parent's tag, `import-addition` zero-length at the exact offset the real operation then uses (byte-asserted by running the operation on the preview-pinned state), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file under its pre-operation path; (d) a created-target section-move preview reports exactly one `file-creation` edit at the new file's start — the insertion and import additions there subsumed — with the moved text's own rewrites inside the origin deletion; every edit class-plus-range only, every class one of the ten 12.7 names, and the full 12.7 edit comparator asserted over every emitted edit list — its final tie-break, class-name bytes between zero-length insertion points, pinned as an observation by (e), T6.5-13's stagings restaged byte for byte: the self-closing target parent's tag end (b) and the end of a paragraph-ended file, terminated or not (d), where `import-addition` orders before `target-insertion` at one offset, and (g), two `import-addition` entries adjacent at one offset, their count asserted — the real operation on the preview-pinned state then byte-asserted against the composed forms (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_STAGED, + [B4_TARGET]: B4_TARGET_STAGED, + [B4_THIRD]: B4_THIRD_STAGED, + }, + 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_STAGED, + [C4_PAL]: C4_PAL_STAGED, + [C4_USER]: C4_USER_STAGED, + }, + 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_STAGED }, + 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 tie-break stagings — T6.5-13's (b), (d) both + // variants, and (g), restaged byte for byte --- + for (const arm of A13_TIE_BREAK_ARMS) { + await withWorkspace(R16_CONFIG, arm.files, async (workspace) => { + const context = `T6.6-4(e) tie-break staging (T6.5-13 ${arm.key})`; + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` (SPEC 6.5, 6.6)`, + ); + // 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 report = await assertLeavesUnchanged( + workspace.root, + async () => runPreviewJson(product, workspace, arm.argv, context), + `${context}: the preview modifies nothing (SPEC 6.6) — pinning ` + + `the pre-operation state for the real run's byte assertion`, + ); + assertTieBreakEntry(report, arm, context); + await expectExit( + product, + workspace, + [...arm.argv], + 0, + `${context}: \`${arm.argv.join(" ")}\` — the real operation on ` + + `the preview-pinned state proceeds (SPEC 6.5; the premise build ` + + `passed and the preview above modified nothing)`, + ); + const actual = await readRewrittenText( + workspace, + arm.receiving, + context, + ); + const idents = a13ReadAddedIdentifiers(actual, arm, context); + const forms = arm.compose(idents); + if (!forms.includes(actual)) { + fail( + `${context}: ${arm.receiving} after the move — ${arm.summary}: ` + + `the moved text at the previewed insertion point and the ` + + `added declaration${arm.added.length > 1 ? "s" : ""} at the ` + + `previewed offset, composed as 6.5 and 3 fix (H-4, ` + + `normalizing nothing; value-blind in the fresh identifiers ` + + `alone)\n` + + ` actual: ${JSON.stringify(actual)}\n` + + forms + .map((form) => ` expected: ${JSON.stringify(form)}`) + .join("\n"), + ); + } + for (const other of arm.others) { + await assertFileBytes( + workspace.path(other.rel), + other.bytes, + `${context}: ${other.rel} after the move — ${other.reason} ` + + `(SPEC 6.5, 3; H-4)`, + ); + } + assertPreviewPlanContent(report, tieBreakPlan(arm, idents), context); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: \`check\` after the real move — the rewritten ` + + `workspace is valid and fresh (SPEC 6.5, 12.2)`, + ); + }); + } + }, +}); + +// --------------------------------------------------------------------------- +// 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; lagging-record counterpart: with emission enabled in the configuration after the build and no rebuild, the same preview's `generated` is exactly the destination's module, companions, and Markdown together with every other discovered spec source's Markdown emit destination — the paths the current configuration generates that the stale record lacks — and its `removed` exactly the recorded pre-move module and companions, the preview writing nothing and the record left lagging (SPEC 6.6, 12.7, 13.1, 13.2, 13.3, 7.3, 12.1; H-3, H-4)", + run: async (product) => { + // --- File-form move, rename, and the record-deleted arm: one + // Markdown-emitting workspace (arm (c)'s sources) --- + await withWorkspace( + SPECS_MD_CONFIG, + { + [C4_MV]: C4_MV_STAGED, + [C4_PAL]: C4_PAL_STAGED, + [C4_USER]: C4_USER_STAGED, + }, + 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_STAGED }, + 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)`, + ); + }, + ); + + // --- Lagging-record counterpart: arm (c)'s sources built WITHOUT + // emission, then `markdown.emit` enabled in the configuration with no + // rebuild, and the same file-form move previewed --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [C4_MV]: C4_MV_STAGED, + [C4_PAL]: C4_PAL_STAGED, + [C4_USER]: C4_USER_STAGED, + }, + async (workspace) => { + const context = "T6.6-5 file-form move (record lagging)"; + const before = await snapshotDirectory(workspace.root); + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` with emission disabled — ` + + `the record then holds modules and companions alone (SPEC ` + + `12.1, 13.3)`, + ); + const after = await snapshotDirectory(workspace.root); + assertGraphDataPresent( + after, + `${context}: staging premise — the record the delta consults`, + ); + const observed = observeDerivedWrites( + addedFiles(before, after), + [C4_MV, C4_PAL, C4_USER], + false, + `${context} staging observation`, + ); + const mv = observed.get(C4_MV)!; + + // Enable emission in the configuration, no rebuild: the record now + // lags the configuration — no Markdown path recorded — and a + // lagging record alone is never staleness (SPEC 13.3); the preview + // plans under the current configuration and consults the record as + // it stands (6.6). + await workspace.file("xspec.config.ts", SPECS_MD_CONFIG); + + // `generated`: the paths the current configuration generates that + // the stale record lacks — the destination's module, companions, + // and Markdown (nothing recorded there) together with every OTHER + // discovered source's Markdown emit destination (unrecorded, the + // record predating emission). The staying sources' recorded modules + // and companions regenerate in place, in neither direction; the + // origin's Markdown — never recorded, not generated post-move — is + // in neither direction either. + const generated = byteSortedPaths([ + ...transposeModuleCompanions(mv, C4_MV, F5_DEST), + markdownDestination(F5_DEST), + markdownDestination(C4_PAL), + markdownDestination(C4_USER), + ]); + // `removed`: exactly the recorded pre-move module and companions — + // the premise build's observed writes for the origin, no Markdown + // among them (SPEC 13.3). + const removed = byteSortedPaths(derivedPathsOf(mv)); + + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = await runPreviewJson( + product, + workspace, + C4_MOVE_ARGV, + context, + ); + assertDeltaContent(report, { generated, removed }, context); + }, + `${context}: the preview writes nothing — the record stays ` + + `lagging, never refreshed by a preview (SPEC 6.6, 13.3)`, + ); + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// 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"); +// The plain target again (T6.5-8/T6.5-9's, T6.6-2's): section-6.5.ts's record. +const R6_TARGET_SOURCE = A8_PLAIN_TARGET; +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 = decodePerformedOperationReport( + await runJson( + product, + workspace, + realArgv, + `${realContext}: \`${realArgv.join(" ")}\` — the real ` + + `operation proceeds, exit 0 (SPEC 6.6, 6.5, 12.0)`, + ), + realContext, + ); + assertAppliedMapping( + applied.mapping, + 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 00000000..69af5e3b --- /dev/null +++ b/test/suite/registry/section-6.7.ts @@ -0,0 +1,572 @@ +// 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 { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } 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. A staged-source record: T6.7-1 stages it +// in a workspace created after a product invocation (S-9's timing clause; +// test/self/s9-staged-sources.test.ts). +const SPECS_ONLY_CONFIG = stagedTs( + "T6.7-1 xspec.config.ts — exactly one spec group, no code group", + `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). An `.mdx` + * entry of a workspace created after the body's first product invocation is + * a staged-source record (the record-accepting initial `files`; + * helpers/staged-mdx.ts), staged under the record's declaration. + */ +async function withWorkspace<T>( + files: Readonly<Record<string, InitialFileContents>>, + 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 manual rename's staging follows the arm's first `build`, so it is a +// ledger record (S-9's before-any-product clause; helpers/staged-mdx.ts) — +// the same template call, moved to module level. +const T6_7_1_RENAMED = stagedMdx( + "T6.7-1 impact arm: the direct rename of a.mid to a.neo", + impactArmSource("a.neo"), +); + +// 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 validation arm's stagings after its first `build` — the manual rename +// leaving both dependents naming the vacated identity, then both dependents +// rewritten to the new one — are ledger records wrapping the builders' same +// `.text` (S-9's before-any-product clause; helpers/staged-mdx.ts). The stale +// builders' `prefix`/`construct` still pin the 14.5 findings' byte windows. +const staleOrigin = originSource("b.neo", "b.mid"); +const staleWatch = watchSource("b.mid"); +const T6_7_1_STALE_ORIGIN = stagedMdx( + "T6.7-1 validation arm: origin renamed b.mid to b.neo, its same-file dependent still naming b.mid", + staleOrigin.text, +); +const T6_7_1_REWRITTEN_ORIGIN = stagedMdx( + "T6.7-1 validation arm: origin with its same-file dependent rewritten to b.neo", + originSource("b.neo", "b.neo").text, +); +const T6_7_1_REWRITTEN_WATCH = stagedMdx( + "T6.7-1 validation arm: the cross-file dependent rewritten to b.neo", + watchSource("b.neo").text, +); + +// The validation arm's workspace is the body's second, created after the +// impact arm's invocations, so its initial `.mdx` files are ledger records +// too (the record-accepting initial `files`; helpers/staged-mdx.ts): the +// origin with its same-file dependent naming `b.mid`, and the cross-file +// dependent naming it — `staleWatch`'s text, its `prefix`/`construct` still +// pinning the 14.5 finding's byte window. +const T6_7_1_INITIAL_ORIGIN = stagedMdx( + "T6.7-1 validation arm: the initial origin, its same-file dependent naming b.mid (specs/B.mdx)", + originSource("b.mid", "b.mid").text, +); +const T6_7_1_STALE_WATCH = stagedMdx( + "T6.7-1 validation arm: the cross-file dependent naming b.mid (specs/Watch.mdx)", + staleWatch.text, +); + +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, T6_7_1_RENAMED); + + 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 --- + await withWorkspace( + { [V2_ORIGIN]: T6_7_1_INITIAL_ORIGIN, [V2_WATCH]: T6_7_1_STALE_WATCH }, + 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, T6_7_1_STALE_ORIGIN); + + 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, T6_7_1_REWRITTEN_ORIGIN); + await workspace.file(V2_WATCH, T6_7_1_REWRITTEN_WATCH); + 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 db2ff6b7..465cfff8 100644 --- a/test/suite/registry/section-7-basics.ts +++ b/test/suite/registry/section-7-basics.ts @@ -18,23 +18,83 @@ // configuration load as a usage error (exit 2), before all source analysis. // `specs` is required; `code`, `markdown`, `coverage`, and `policy` are // optional with defined omission semantics; empty `coverage`/`policy` lists -// equal omission; unknown keys anywhere in the argument are 14.14. +// equal omission; unknown keys anywhere in the argument are 14.14, as is a +// value of the wrong shape — 14.14's "otherwise invalid group shape" (7.1, +// 7.2, 7.4, 7.5: `specs` and `code` are maps of named groups, each a list +// of glob strings; `coverage` and `policy` are lists). // // Conservative operationalizations (noted per H-3/H-4): // - 14.14 contract: `expectConfigurationError` (shared, ./support.ts) — run -// 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 // search exhausts without a hit. +// - T7-1 `--config` naming a nonexistent file: run from the location +// fixture's root, whose own `xspec.config.ts` the upward search would +// find, naming `alt/missing.config.ts` — absent, beside `alt/`'s valid +// configuration — so a product falling back to the search, or to a +// configuration near the named path, builds and exits 0, and a product +// classing the failed `--config` as a plain usage error reports `code` +// and `path` null (T12.7-3). The concerned path is asserted exactly as +// the argument spells it: zero ascent segments, two descending ones, no +// `.` segment — already 11.6's canonical form (the sibling-directory +// ascent spelling is T12.7-3's arm); a whole-root snapshot compare around +// the invocation pins that nothing is written (SPEC 12.1). +// - T7-1 occupancy (SPEC 7: the upward search stops at the nearest directory +// holding an entry named `xspec.config.ts`, whatever occupies it, and the +// occupant is read only when it is a plain file; a directory or a symbolic +// link, whatever it targets, is missing or invalid configuration, 14.14, +// never read through): one workspace with a valid root configuration and +// two working directories beneath it, `dirocc/` holding an empty directory +// named `xspec.config.ts` and `linkocc/` a symbolic link of that name to +// the valid root configuration itself — so a product skipping the +// occupant, or following the link, loads a valid configuration and exits +// 0. "Every command but `version`" is operationalized as the complete +// command set of SPEC 12 with `review` and `query` by every subcommand +// (26 invocations), each spelled syntactically complete so that no +// syntax-class usage error, reported without loading configuration (12.0), +// can precede the configuration load; every invocation is pinned to exit +// 2 with the error document's finding — `configuration-error`, locations +// [], the concerned path the occupied entry itself in 11.6's anchoring +// form: `xspec.config.ts` from its own directory, never `../xspec.config.ts` +// (the valid file above) and never the link's target. The same set names +// each entry through `--config` from the root (`dirocc/xspec.config.ts`, +// `linkocc/xspec.config.ts`: the path as given, already canonical, so the +// two conventions of T12.7-3 coincide). `version` answers exit 0 from both +// working directories (12.6), a whole-root compare brackets the sweeps +// (12.1), and the premise — the root configuration valid, discovering +// `specs/A.mdx` — is driven last (`ids` regenerates graph data, 13.3) so +// the sweeps observe a tree holding no derived file or graph data. // - 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 import modifiers (SPEC 7): four arms — `import type { … }`, +// `import { type … }`, `import defer { … }`, and an import carrying +// `with { type: "json" }` — each the canonical configuration with only +// its import line changed. Every one is a text TypeScript 5.9.3 accepts +// both as module code and as script code, so each record is declared +// well-formed (S-9; the same four texts are vectors of +// test/self/s9-typescript-well-formedness.test.ts) and the refusal is the +// declarative form's (14.14), never a parse failure's; a product reading +// the binding loosely loads each and builds (exit 0), failing the +// exit-code assertion. +// - 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,37 +104,127 @@ // 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. +// - T7-3 value shapes: seven fixtures — TEST-SPEC's "one arm each" over a +// spec group and a code group valued by a single string, a glob list +// holding `true`, `coverage` and `policy` given as `{}`, and `specs` and +// `code` given as lists — each SPECS_ONLY_CONFIG with one shape deviation +// the declarative form of 7 admits, so the refusal is 14.14's +// non-conformance (an invalid group shape), never a form error (T7-2). +// The `[true]` fixture matches no staged file: a product tolerating it +// (dropping or stringifying the element) discovers no source and builds +// anyway — a group matching no files is valid (7) — so exit 0 still +// discriminates it. +// - every `expectConfigRefused` arm (T7-2, T7-3): the finding's concerned +// path is exactly `xspec.config.ts` — the file the upward search found, +// in 11.6's anchoring form relative to the invocation working directory, +// the workspace root (SPEC 14, 12.7) — its locations [] (a configuration +// condition carries the file it concerns, no source range; 14), and a +// whole-root snapshot compare around the invocation pins that nothing is +// written (12.1). +// - T7-2 verbatim literals (SPEC 7, 2.4): the escape spellings are built +// from the backslash's code point (`BACKSLASH`), never written as an +// escape in harness source, which would interpret it. "Matching no +// discovered file, discovering zero sources" is observed twice — the +// inventory's `sources` (discovery reported directly, no source parsed, +// 11.6) and `ids`'s file listing, both exactly empty — beside the +// inventory's resolved configuration view carrying the glob and, in the +// group-name arm, the group name as spelled (the six escape characters +// included), so a product interpreting the escape fails on the +// reported spelling as well as on the file it then discovers. "Resolves" +// for the escape-spelled target is the profile's reported rows: with the +// boundary the same group, the target set is the group's one leaf, +// uncovered (8.1, 8.2); the `target: "product"` twin differs in that one +// value alone and is refused as an unknown group (7.4, 14.14). +// - T7-2 encoding: the non-UTF-8 fixture carries its one invalid byte +// (0xFF) inside a trailing line comment, so the decoded text is a valid +// configuration and the encoding is the sole defect; the BOM fixture is +// the canonical text prefixed by EF BB BF. Both are staged as bytes +// (records whose source is a byte array), never as strings. +// - T7-2 repeated keys: 14.14 whatever TypeScript's own diagnosis of the +// repetition (SPEC 7) — the arms pin the 14.14 contract alone, so a +// product reporting it through a compiler diagnostic and one detecting +// it itself pass alike, while one taking the last member loads and +// builds (exit 0). +// - T7-2 comments: "inventory's `configuration` byte-identical to its +// comment-free twin's" is compared as the `configuration` member of each +// `inventory --json` document (form-decoded first), as values and as +// serializations — member order included. +// - T7-3 U+FFFD names: the character is staged as the validly encoded code +// point EF BF BD between two letters — a string-literal group key, a +// profile name, a rule name — so 14.14's name rule, never the encoding +// rule, is at stake; each fixture is otherwise valid. +// - Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): every `.mdx` file a body stages in a workspace +// created after its first product invocation — `expectConfigRefused`'s +// one staging site (serving every arm, the first included), T7-1's +// no-configuration and occupancy workspaces, T7-2's and T7-3's later +// arms — is a ledger record, judged by test/self/s9-staged-sources.test.ts +// before any product exists. The minimal `mdxSection("a")` and +// `mdxSection("b")` sources are staged byte-identically by this module, +// section-7-discovery.ts, section-7.1-7.3.ts, and (the `a`) +// section-7.4-7.5.ts, so each is ONE record, +// exported from here and named with every staging test in ID order and +// every path. T7-1's first workspace (`LOCATION_FILES`) precedes any +// invocation and stays plain. +// - TypeScript staged-source records (TEST-SPEC S-9's TypeScript and +// timing clauses; helpers/staged-ts.ts): every configuration file and +// code source a body stages in a workspace created after its first +// product invocation — `expectConfigRefused`'s one staging site (serving +// every arm of every refused-configuration table, the first included: +// `refusedConfigArms` makes one record per row at module load), T7-1's +// occupancy workspace, T7-2's and T7-3's later arms — is a ledger record +// carrying its S-9 declaration (T7-2's syntax-error and encoding arms +// unparseable, 14.20; every other well-formed), judged by +// test/self/s9-staged-sources.test.ts before any product exists. The +// canonical `SPECS_ONLY_CONFIG` is one record wherever it is staged, +// `LOCATION_FILES` included. +import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import type { GraphEdge } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, decodeEdgesReport, + decodeFindingsReport, decodeIdsReport, + decodeInventoryDocument, + decodeVersionDocument, } from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { + assertSnapshotsEqual, + snapshotDirectory, +} from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; import { + assertConditionCounts, assertEdgeSetEqual, assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, + REPLACEMENT_CHARACTER, runJson, } from "./support.js"; @@ -87,16 +237,90 @@ function mdxSection(id: string): string { return `<S id="${id}">\nText for ${id}.\n</S>\n`; } +// The minimal sources the §7 modules stage in workspaces created after a +// body's first product invocation (module header): byte-identical wherever +// they are staged — `mdxSection("a")` at `specs/A.mdx` here and in +// section-7-discovery.ts, section-7.1-7.3.ts, section-7.4-7.5.ts (T7.4-1, +// T7.5-1; T7.5-5 also at `tgt/a.mdx`), and section-12.6.ts (T12.6-1, +// T12.6-2: the version workspaces' minimal source, the same bytes); +// `mdxSection("b")` at `specs/sub/B.mdx` (T7-3, T7-6, T7.3-1) and +// `specs2/B.mdx` (T7-4) — so each is ONE staged-source record, named with +// every staging test in ID order and every path; the other modules import +// them (section-7.4-7.5.ts and section-12.6.ts the `a` alone). +export const SECTION_A_SOURCE = stagedMdx( + "T7-1/T7-2/T7-3/T7-4/T7-6/T7.1-1/T7.3-1/T7.4-1/T7.5-1/T7.5-5/T12.6-1/T12.6-2 specs/A.mdx (the minimal section a; T7.5-5's tgt/a.mdx)", + mdxSection("a"), +); +export const SECTION_B_SOURCE = stagedMdx( + "T7-3/T7-4/T7-6/T7.3-1 the minimal section b (specs/sub/B.mdx; T7-4's specs2/B.mdx)", + mdxSection("b"), +); + // The canonical valid configuration (SPEC 7): exactly one spec group, no // optional keys. Every T7-2 violation below is this file with one deviation. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// A staged-source record (module header): T7-1's occupancy workspace and +// T7-3's omission arms stage it in workspaces created after a product +// invocation; T7-1's first workspace passes the record too, and T7-2's +// encoding arms compose their bytes from its text. +const SPECS_ONLY_CONFIG = stagedTs( + "T7-1/T7-3 xspec.config.ts (the canonical configuration: exactly one spec group, no optional keys)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { main: ["specs/**/*.mdx"] } }) -`; +`, +); + +/** + * A configuration record's text: every configuration this module composes + * from is a string; anything else is a defect of the module's fixtures. + */ +function configText(record: StagedTs): string { + if (typeof record.source !== "string") { + throw new Error( + `${record.name}: the staged configuration must be a string (the ` + + "module composes text, never bytes)", + ); + } + return record.source; +} + +/** A refused-configuration arm (T7-2, T7-3): its label and its record. */ +interface RefusedConfigArm { + readonly label: string; + readonly config: StagedTs; +} + +/** + * An arm table's rows as refused-configuration arms, each configuration a + * staged-source record made at module load from its row — the expression + * moved, never re-spelled — named `<TEST-ID> xspec.config.ts (<label>)` and + * carrying the row's S-9 declaration: unparseable (14.20) where TEST-SPEC + * declares it so, else well-formed. `expectConfigRefused` stages every arm + * in a workspace created after the body's first product invocation, the + * first arm's excepted (S-9's timing clause; module header). + */ +function refusedConfigArms( + testId: string, + rows: readonly { + readonly label: string; + readonly config: string; + /** S-9: TEST-SPEC declares this configuration unparseable (14.20). */ + readonly unparseable?: true; + }[], +): readonly RefusedConfigArm[] { + return rows.map((row) => ({ + label: row.label, + config: stagedTs( + `${testId} xspec.config.ts (${row.label})`, + row.config, + row.unparseable === true ? "unparseable" : "well-formed", + ), + })); +} /** Stage a fresh workspace, run `body`, dispose (H-1). */ async function withWorkspace<T>( @@ -112,27 +336,73 @@ async function withWorkspace<T>( } /** - * Stage a workspace whose only defect is the given configuration text and - * assert `build --json` refuses it per 14.14. The staged source file is - * valid and matched by every fixture's `specs/**\/*.mdx` glob, so a product - * that wrongly accepts the configuration proceeds to a successful build - * (exit 0) and fails the exit-code assertion — never exits 2 for a - * side reason. + * Stage a workspace whose only defect is the given configuration — a + * staged-source record carrying its S-9 declaration (unparseable for + * T7-2's syntax-error and encoding arms, 14.20; else well-formed), since + * every arm but a body's first is staged after a product invocation (S-9's + * timing clause) — and assert `build --json` refuses it per 14.14. The + * staged source file is valid and matched by every fixture's + * `specs/**\/*.mdx` glob (or, where the deviation replaces that glob, by + * nothing — a group matching no files is valid, SPEC 7), so a product that + * wrongly accepts the configuration proceeds to a successful build (exit + * 0) and fails the exit-code assertion — never exits 2 for a side reason. + * Beyond the shared 14.14 contract (`expectConfigurationError`), the + * finding is pinned to the configuration file: its concerned path is + * exactly `xspec.config.ts` — the file the upward search found, in the + * anchoring form of 11.6 relative to the invocation working directory, + * here the workspace root, so the bare name (SPEC 14, 12.7) — and its + * locations are [] (a configuration condition carries the file it + * concerns, no source range; SPEC 14); a whole-root snapshot compare + * around the invocation pins that a build failing at configuration load + * writes nothing (SPEC 12.1). */ async function expectConfigRefused( product: ProductBinding, - config: string, + config: StagedTs, context: string, ): Promise<void> { await withWorkspace( { files: { "xspec.config.ts": config, - "specs/A.mdx": mdxSection("a"), + "specs/A.mdx": SECTION_A_SOURCE, }, }, async (workspace) => { - await expectConfigurationError(product, workspace, ["build"], context); + const before = await snapshotDirectory(workspace.root); + const result = await expectConfigurationError( + product, + workspace, + ["build"], + context, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: "xspec.config.ts", + locations: [], + }, + `${context}: the error document's one finding carries the stable ` + + `code "configuration-error", locations [] (a configuration ` + + `condition carries the file it concerns, never a source range), ` + + `and as its concerned path the configuration file the upward ` + + `search found, in the anchoring form of 11.6 relative to the ` + + `invocation working directory — the workspace root, so exactly ` + + `"xspec.config.ts" (SPEC 14, 12.7, 11.6)`, + ); + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `${context}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — no derived file or graph data ` + + `appears anywhere under the root`, + ); }, ); } @@ -148,7 +418,7 @@ async function expectConfigRefused( // workspace root) — discovers alt/specs/B.mdx as `specs/B.mdx`. The two // listings differ in both file and ID, so which configuration served a run // is unambiguous. -const LOCATION_FILES: Readonly<Record<string, string>> = { +const LOCATION_FILES: Readonly<Record<string, InitialFileContents>> = { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/A.mdx": mdxSection("a"), "alt/xspec.config.ts": SPECS_ONLY_CONFIG, @@ -163,6 +433,266 @@ const LOCATION_FILES: Readonly<Record<string, string>> = { const SEARCH_CWD = "nested/inner"; const OVERRIDE_CWD = "nested"; const OVERRIDE_CONFIG_ARG = "../alt/xspec.config.ts"; +// The nonexistent --config path, run from the workspace root: two +// descending segments, no `.` segment — the argument's own spelling is +// already the canonical anchoring form (SPEC 11.6), so the concerned path is +// asserted exactly as given (SPEC 14; T12.7-3). +const MISSING_CONFIG_ARG = "alt/missing.config.ts"; + +// --- T7-1 occupancy (SPEC 7, 14.14) --------------------------------------- + +// A valid configuration at the root discovers specs/A.mdx; beneath it, two +// working directories each hold an entry named `xspec.config.ts` that is not +// a plain file: OCCUPANCY_DIR_CWD's is an empty directory, OCCUPANCY_LINK_CWD's +// a symbolic link to the valid root configuration itself. SPEC 7: the upward +// search stops at the nearest directory holding an entry of that name — +// whatever occupies it — and the occupant is read only when it is a plain +// file; any other occupant is missing or invalid configuration (14.14), never +// read through. The discriminating structure: a product skipping a non-file +// entry and continuing upward finds the valid root configuration and exits 0; +// a product following the link loads that same valid configuration and exits +// 0 (rooted at the link's directory or at the root, either way exit 0) — +// both fail the exit-2 assertion, and a product reporting the file above +// (`../xspec.config.ts`) or the link's target fails the concerned-path pin. +const OCCUPANCY_DIR_CWD = "dirocc"; +const OCCUPANCY_LINK_CWD = "linkocc"; +const OCCUPANT_NAME = "xspec.config.ts"; +const OCCUPANCY_DIR_ENTRY = `${OCCUPANCY_DIR_CWD}/${OCCUPANT_NAME}`; +const OCCUPANCY_LINK_ENTRY = `${OCCUPANCY_LINK_CWD}/${OCCUPANT_NAME}`; +const OCCUPANCY_WORKSPACE: WorkspaceDecl = { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, + }, + dirs: [OCCUPANCY_DIR_ENTRY], + // The link's target is spelled relative to the link's own directory: the + // valid configuration one level up. + symlinks: { [OCCUPANCY_LINK_ENTRY]: `../${OCCUPANT_NAME}` }, +}; + +// Every command but `version` (SPEC 14 condition 14 is reported by every +// command that loads the configuration; 12.6: `version` loads none), `review` +// and `query` by every subcommand, each spelled syntactically complete — its +// required operands and flags present, every value well-formed and inside its +// fixed vocabulary — so that no syntax-class usage error, which 12.0 reports +// without loading configuration, can precede the configuration load. The +// operands name nothing that must exist: a configuration error precedes every +// other exit-2 error consulting the configuration or the workspace (12.0), so +// an unknown node, session, item, profile, or baseline is never reached; the +// mutating commands (`review create`, `review split`, `review resolve`, +// `rename`, `move`) fail before acquisition and modify nothing (12.0, 13.5). +// The driver appends `--json`. +const EVERY_LOADING_COMMAND: readonly (readonly string[])[] = [ + ["build"], + ["check"], + ["ids"], + ["show", "specs/A.mdx#a"], + ["coverage"], + ["impact", "--base", "HEAD"], + ["review", "create", "--strategy", "audit", "--name", "s"], + ["review", "list"], + ["review", "status", "s"], + ["review", "next", "s"], + ["review", "show", "s", "i1"], + ["review", "split", "s", "i1"], + ["review", "resolve", "s", "i1", "--status", "updated"], + ["review", "export", "s"], + ["query", "node", "specs/A.mdx#a"], + ["query", "nodes"], + ["query", "edges"], + ["query", "subtree", "specs/A.mdx#a"], + ["query", "ancestors", "specs/A.mdx#a"], + ["query", "reachable", "--from", "specs/A.mdx#a", "--to", "specs/A.mdx#a"], + ["occurrences"], + ["view"], + ["at", "specs/A.mdx", "0"], + ["inventory"], + ["rename", "specs/A.mdx", "a", "b"], + ["move", "specs/A.mdx", "specs/B.mdx"], +]; + +interface OccupancySweep { + /** Workspace-relative working directory, or "." for the root. */ + readonly cwd: string; + /** The `--config` value, spelled as given; absent for the upward search. */ + readonly configArg?: string; + /** The concerned path 14 pins: the occupied entry, in 11.6's form. */ + readonly expectedPath: string; + /** What occupies the entry, for the diagnosis. */ + readonly occupant: string; +} + +/** + * Drive every configuration-loading command from `sweep.cwd` (naming the + * entry through `--config` when `sweep.configArg` is given) and assert each + * reports 14.14 concerning exactly `sweep.expectedPath`: exit 2, the error + * document's one finding carrying the stable code `configuration-error`, + * locations [] (an unlocated condition), and as its concerned path the + * occupied entry itself in the anchoring form of 11.6 — for the search, the + * working directory's own `xspec.config.ts`; for `--config`, the path as + * given, which from the root is already canonical (SPEC 14, 12.7; T12.7-3) — + * never the valid file above it and never what the link targets. + */ +async function sweepOccupiedConfiguration( + product: ProductBinding, + workspace: TestWorkspace, + sweep: OccupancySweep, +): Promise<void> { + const cwd = sweep.cwd === "." ? workspace.root : workspace.path(sweep.cwd); + const where = + sweep.cwd === "." + ? "the workspace root" + : `the working directory ${sweep.cwd}`; + for (const command of EVERY_LOADING_COMMAND) { + const argv = + sweep.configArg === undefined + ? command + : [...command, "--config", sweep.configArg]; + const label = + `T7-1 \`${argv.join(" ")} --json\` run from ${where} ` + + `(${sweep.occupant})`; + const result = await expectConfigurationError( + product, + workspace, + argv, + `${label} — the ${sweep.configArg === undefined ? "upward search stops at the nearest directory holding an entry named xspec.config.ts, whatever occupies it, and the" : "named path's"} ` + + `occupant is read only when it is a plain file: a directory or a ` + + `symbolic link, whatever it targets, is missing or invalid ` + + `configuration, never read through — reported by every command but ` + + `version at configuration load (SPEC 7, 14.14, 12.0)`, + cwd, + ); + const finding = expectErrorDocument(result, label); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: sweep.expectedPath, + locations: [], + }, + `${label}: the error document's one finding carries the stable code ` + + `"configuration-error", locations [] (an unlocated condition), and ` + + `as its concerned path the occupied entry itself in the anchoring ` + + `form of 11.6 — ${JSON.stringify(sweep.expectedPath)} — never the ` + + `valid configuration above it (../xspec.config.ts) and never the ` + + `link's target (SPEC 14, 12.7, 7; T12.7-3)`, + ); + } +} + +/** + * T7-1's occupancy arms (SPEC 7, 14.14): the directory and the symbolic-link + * occupants found by the upward search from their own directories, then each + * named through `--config` from the root; `version` answering beside them + * (12.6); a whole-root compare around all of it (12.1); and, last — so the + * arms observe a tree holding no graph data or derived file — the premise + * that the root configuration is valid and discovers specs/A.mdx, without + * which every exit 2 above would be vacuous. + */ +async function runOccupancyArms(product: ProductBinding): Promise<void> { + await withWorkspace(OCCUPANCY_WORKSPACE, async (workspace) => { + // Staging self-check (H-9): an ineffective staging is a harness error, + // never a pass and never a diagnosed product failure. + const stagedKinds: readonly (readonly [string, "dir" | "symlink"])[] = [ + [OCCUPANCY_DIR_ENTRY, "dir"], + [OCCUPANCY_LINK_ENTRY, "symlink"], + ]; + for (const [rel, expected] of stagedKinds) { + const kind = await workspace.kind(rel); + if (kind !== expected) { + throw new Error( + `T7-1 harness staging: expected a ${expected} at ${rel}, found ` + + `${kind} — the occupancy arms cannot run (H-9)`, + ); + } + } + + const before = await snapshotDirectory(workspace.root); + const sweeps: readonly OccupancySweep[] = [ + { + cwd: OCCUPANCY_DIR_CWD, + expectedPath: OCCUPANT_NAME, + occupant: "the working directory's xspec.config.ts is a directory", + }, + { + cwd: OCCUPANCY_LINK_CWD, + expectedPath: OCCUPANT_NAME, + occupant: + "the working directory's xspec.config.ts is a symbolic link to " + + "the valid root configuration", + }, + { + cwd: ".", + configArg: OCCUPANCY_DIR_ENTRY, + expectedPath: OCCUPANCY_DIR_ENTRY, + occupant: "--config names a directory", + }, + { + cwd: ".", + configArg: OCCUPANCY_LINK_ENTRY, + expectedPath: OCCUPANCY_LINK_ENTRY, + occupant: + "--config names a symbolic link to the valid root configuration", + }, + ]; + for (const sweep of sweeps) { + await sweepOccupiedConfiguration(product, workspace, sweep); + } + + // `version` loads no configuration (SPEC 12.6): from either occupied + // working directory it answers, exit 0, the version document decoded + // form-exact — the occupant is met only by a configuration load. + for (const cwd of [OCCUPANCY_DIR_CWD, OCCUPANCY_LINK_CWD]) { + const versionLabel = `T7-1 \`version --json\` run from the working directory ${cwd} (its xspec.config.ts occupied)`; + const versionRun = await runProduct(product, { + cwd: workspace.path(cwd), + argv: ["version", "--json"], + }); + assertExitCode( + versionRun, + 0, + `${versionLabel} — version consults no configuration, so the ` + + `occupied entry is never met: exit 0 with its answer (SPEC 12.6, ` + + `14.14)`, + ); + decodeVersionDocument( + parseJsonStdout(versionRun, versionLabel), + versionLabel, + ); + } + + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `T7-1 occupancy sweeps: a command failing at configuration load ` + + `modifies nothing (SPEC 12.1, 12.0) — no derived file, graph data, ` + + `session, or hold file appears anywhere under the root, the ` + + `occupants and the valid configuration are byte-untouched`, + ); + + // The premise, last: the root configuration is valid and discovers + // specs/A.mdx — the configuration a product skipping the occupant, or + // reading through the link, would have loaded (module header). + const premiseLabel = + "T7-1 occupancy premise: `ids --json` run from the workspace root"; + const premise = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], premiseLabel), + premiseLabel, + ); + assertSameJson( + premise.files, + [{ file: "specs/A.mdx", ids: ["a"] }], + `${premiseLabel}: the root configuration is valid and discovers ` + + `specs/A.mdx (SPEC 7) — the occupancy arms' exit-2 answers are ` + + `meaningful only because a product reading past the occupant would ` + + `have found this configuration and exited 0`, + ); + }); +} const T7_1 = defineProductTest({ id: "T7-1", @@ -170,7 +700,16 @@ const T7_1 = defineProductTest({ "configuration location: upward search from a nested working " + "directory; --config, resolved against the working directory, " + "overrides the search; no configuration reachable is a configuration " + - "error (SPEC 7, 12.0, 14.14)", + "error — by a failed upward search, and by --config naming a " + + "nonexistent file, never a plain usage error; occupancy — the search " + + "stops at the nearest entry named xspec.config.ts whatever occupies " + + "it, and a found or named entry that is a directory or a symbolic " + + "link is 14.14 for every command but version, the concerned path that " + + "entry, never read through (SPEC 7, 12.0, 12.6, 12.7, 14.14)", + // Four sweeps of every configuration-loading command (~26 invocations + // each) join the location arms; the default budget is kept only for + // margin under suite contention. + timeoutMs: 240_000, run: async (product) => { await withWorkspace( { files: LOCATION_FILES, dirs: [SEARCH_CWD] }, @@ -232,6 +771,61 @@ const T7_1 = defineProductTest({ `workspace root — the listing carries alt/specs/B.mdx as ` + `specs/B.mdx and nothing of the root project (SPEC 7, 12.0)`, ); + + // --config naming a nonexistent file: missing configuration, a + // configuration error (SPEC 14.14) — never a plain usage error and + // never a fallback. Run from the root, whose own xspec.config.ts the + // upward search would find, naming alt/missing.config.ts — absent, + // beside alt/'s valid configuration — so a product falling back to + // the search, or to a configuration near the named path, builds and + // exits 0 (module header). The error document's finding carries the + // stable code, locations [] (an unlocated condition, SPEC 14), and + // the concerned path: the path --config names, in the anchoring + // form of 11.6 — spelled here exactly as given (SPEC 14, 12.7; the + // sibling-directory ascent spelling is T12.7-3's arm). A build + // failing at configuration load modifies nothing (SPEC 12.1): + // whole-root compare around the run. + const missingLabel = + `T7-1 \`build --config ${MISSING_CONFIG_ARG} --json\` run from ` + + `the workspace root (--config naming a nonexistent file)`; + const beforeMissing = await snapshotDirectory(workspace.root); + const missingRun = await expectConfigurationError( + product, + workspace, + ["build", "--config", MISSING_CONFIG_ARG], + missingLabel, + ); + const missingFinding = expectErrorDocument(missingRun, missingLabel); + assertSameJson( + { + code: missingFinding.code, + path: missingFinding.path, + locations: missingFinding.locations.map( + (location) => location.file, + ), + }, + { + code: "configuration-error", + path: MISSING_CONFIG_ARG, + locations: [], + }, + `${missingLabel}: --config naming a nonexistent file is missing ` + + `configuration (SPEC 14.14) — the error document's one finding ` + + `carries the stable code "configuration-error", locations [] ` + + `(an unlocated condition), and as its concerned path the path ` + + `--config names, in the anchoring form of 11.6 relative to the ` + + `invocation working directory (SPEC 14, 12.7; T12.7-3) — never ` + + `a plain usage error's null code and path, and never "." (the ` + + `failed-upward-search spelling, reserved for no --config given)`, + ); + assertSnapshotsEqual( + beforeMissing, + await snapshotDirectory(workspace.root), + `${missingLabel}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — the configurations at the root and ` + + `beside the named path are never consulted, so no derived file ` + + `or graph data appears anywhere under the root`, + ); }, ); @@ -239,7 +833,7 @@ const T7_1 = defineProductTest({ // xspec.config.ts anywhere on the upward path (module header) — a // configuration error, not a crash and not an empty success. await withWorkspace( - { files: { "specs/A.mdx": mdxSection("a") } }, + { files: { "specs/A.mdx": SECTION_A_SOURCE } }, async (workspace) => { await expectConfigurationError( product, @@ -250,6 +844,10 @@ const T7_1 = defineProductTest({ ); }, ); + + // Occupancy: a found or named xspec.config.ts that is a directory or a + // symbolic link (SPEC 7, 14.14; helpers above). + await runOccupancyArms(product); }, }); @@ -264,9 +862,10 @@ const T7_1 = defineProductTest({ // object literals with non-computed identifier or string-literal keys, array // literals, static string literals, and the boolean literals; no other // statement or expression form, no spread, no computed value. -const FORM_VIOLATIONS: readonly { label: string; config: string }[] = [ +const FORM_VIOLATIONS = refusedConfigArms("T7-2", [ { label: "not well-formed TypeScript — a syntax error (unclosed braces)", + unparseable: true, config: `import { defineConfig } from "xspec" export default defineConfig({ @@ -405,25 +1004,389 @@ export default { export default defineConfig `, }, -]; + // The import carries no `type` or `defer` modifier and no import + // attributes (SPEC 7): four well-formed texts (module header). + { + label: + 'a type-only import clause: import type { defineConfig } from "xspec"', + config: `import type { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: + 'a type-only import specifier: import { type defineConfig } from "xspec"', + config: `import { type defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: 'a deferred import: import defer { defineConfig } from "xspec"', + config: `import defer { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: + "import attributes: " + + 'import { defineConfig } from "xspec" with { type: "json" }', + config: `import { defineConfig } from "xspec" with { type: "json" } + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, + }, +]); // The valid arm: an aliased defineConfig import (SPEC 7: optionally aliased). -const ALIASED_CONFIG = `import { defineConfig as makeConfig } from "xspec" +const ALIASED_CONFIG = stagedTs( + "T7-2 xspec.config.ts (aliased defineConfig import)", + `import { defineConfig as makeConfig } from "xspec" export default makeConfig({ specs: { main: ["specs/**/*.mdx"] } }) +`, +); + +// 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 = stagedTs( + "T7-2 xspec.config.ts (string-literal keys)", + `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, InitialFileContents>> = { + "xspec.config.ts": QUOTED_KEYS_CONFIG, + "specs/A.mdx": stagedMdx( + "T7-2 string-literal keys specs/A.mdx", + `<S id="a"> +Covered leaf. +</S> + +<S id="p" d={"a"}> +Dependent leaf. +</S> +`, + ), + "src/impl.ts": stagedTs( + "T7-2 src/impl.ts (string-literal keys)", + `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" }, +]; + +// Verbatim literals (SPEC 7, 2.4: configuration literals are static string +// literals read exactly as spelled). The escape spellings below are built +// from the backslash's code point so the six characters reach the staged +// file exactly — a template literal would itself interpret a backslash-u +// escape (the header's verbatim-literal note). +const BACKSLASH = String.fromCodePoint(0x5c); + +// A glob spelled with the six-character escape of `*` (backslash, `u002A`) +// is read as those characters: it names only a file literally so called, +// so with `specs/A.mdx` the sole source the group discovers nothing — valid, +// a group matching no files being valid (7). A product interpreting the +// escape reads `specs/*.mdx`, discovers `specs/A.mdx`, and fails the empty +// listings and the inventory's verbatim glob. +const VERBATIM_GLOB = `specs/${BACKSLASH}u002A.mdx`; +const VERBATIM_GLOB_CONFIG = stagedTs( + "T7-2 xspec.config.ts (verbatim glob)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["${VERBATIM_GLOB}"] + } +}) +`, +); + +// A group name spelled with the escape of `u` (`prod`, backslash, `u0075`, +// `ct`) names a group whose spelling contains a backslash: a profile's +// `target: "product"` is then an unknown group (14.14, SPEC 7.4) while the +// same escape spelling resolves. `boundary` carries the verbatim spelling +// in both fixtures, so the profile's target is their only difference. +const VERBATIM_GROUP_NAME = `prod${BACKSLASH}u0075ct`; +function verbatimNameConfig(target: string): string { + return `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + "${VERBATIM_GROUP_NAME}": ["specs/**/*.mdx"] + }, + coverage: [ + { + name: "verbatim", + target: "${target}", + boundary: "${VERBATIM_GROUP_NAME}", + mode: "direct" + } + ] +}) `; +} + +// The two verbatim-name fixtures, records made at module load: T7-2 stages +// both after a product invocation (S-9's timing clause). +const VERBATIM_NAME_UNKNOWN_TARGET_CONFIG = stagedTs( + 'T7-2 xspec.config.ts (verbatim group name: a profile\'s target "product")', + verbatimNameConfig("product"), +); +const VERBATIM_NAME_CONFIG = stagedTs( + "T7-2 xspec.config.ts (verbatim group name: the target spelled as the group's key)", + verbatimNameConfig(VERBATIM_GROUP_NAME), +); + +// Encoding (SPEC 7: the file's bytes MUST be valid UTF-8 and MUST NOT begin +// with a byte-order mark; either violation is 14.14). The non-UTF-8 fixture +// is the canonical configuration plus one trailing line comment holding the +// byte 0xFF, valid in no UTF-8 sequence: a product decoding leniently (0xFF +// → U+FFFD) sees the valid configuration and a comment contributing nothing, +// loads, and builds (exit 0). The BOM fixture is the canonical text prefixed +// by EF BB BF, which TypeScript tooling strips silently, so a product +// reading through the compiler alone loads it too. +const NON_UTF8_CONFIG = stagedTs( + "T7-2 xspec.config.ts (encoding: the byte 0xFF inside a trailing line comment)", + Buffer.concat([ + Buffer.from(configText(SPECS_ONLY_CONFIG), "utf8"), + Buffer.from("// ", "utf8"), + Buffer.from([0xff]), + Buffer.from("\n", "utf8"), + ]), + "unparseable", +); +const BOM_CONFIG = stagedTs( + "T7-2 xspec.config.ts (encoding: a leading byte-order mark)", + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(configText(SPECS_ONLY_CONFIG), "utf8"), + ]), + "unparseable", +); + +// Object-literal keys and names (SPEC 7, 14.14): a key repeated within one +// object literal — an identifier key and a string-literal key spelling the +// same name included, whatever TypeScript's own diagnosis of the +// repetition — and an empty group, profile, or rule name (`""`). Every +// fixture is otherwise valid: each repeated member is a valid group, and +// the empty-named profile and rule reference the existing unambiguous +// group `main`, so the repetition or the empty name is the only defect. +const KEY_AND_NAME_VIOLATIONS = refusedConfigArms("T7-2", [ + { + label: "repeated key: `specs` twice within the top-level object literal", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: + "repeated key: an identifier key and a string-literal key spelling " + + "the same group name within `specs`", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + product: ["specs/**/*.mdx"], + "product": ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: 'empty name: a spec group ""', + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + "": ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: 'empty name: a coverage profile whose name is ""', + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + coverage: [ + { + name: "", + target: "main", + boundary: "main", + mode: "direct" + } + ] +}) +`, + }, + { + label: 'empty name: a policy rule whose name is ""', + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: [ + { + name: "", + type: "forbidden", + from: { group: "main" }, + to: { group: "main" } + } + ] +}) +`, + }, +]); + +// Comments (SPEC 7: permitted anywhere, contributing nothing). The commented +// fixture places line and block comments before the import, after it, +// before the first key, between keys, inside the glob list (after a glob +// and between two globs), after the list, after the export, and after +// everything; its twin is the same configuration with every comment +// removed. Two globs and a `markdown` key give the "between" positions +// something to stand between (`docs/` matches nothing: valid, 7). +const COMMENT_FREE_CONFIG = stagedTs( + "T7-2 xspec.config.ts (comment-free twin)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: [ + "specs/**/*.mdx", + "docs/**/*.mdx" + ] + }, + markdown: { emit: false } +}) +`, +); +const COMMENTED_CONFIG = stagedTs( + "T7-2 xspec.config.ts (comments)", + `// a line comment before the import +/* a block comment + before the import */ +import { defineConfig } from "xspec" // after the import + +export default defineConfig({ + // before the first key + specs: { + main: [ + "specs/**/*.mdx", // after a glob + /* between two globs */ "docs/**/*.mdx" + ] /* after the glob list */ + }, // after a key + /* between keys */ + markdown: { emit: false } +}) // after the export +/* after everything */ +`, +); 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)", + "defineConfig import, a type or defer modifier or import attributes " + + "on that 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; 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); literals are read verbatim — an escape-spelled glob matches " + + "nothing and an escape-spelled group name is named only by the same " + + "spelling (2.4); a non-UTF-8 file, a byte-order mark, a repeated key, " + + "and an empty group, profile, or rule name are configuration errors; " + + "comments anywhere contribute nothing (inventory's configuration " + + "byte-identical to the comment-free twin's)", + timeoutMs: 240_000, run: async (product) => { for (const arm of FORM_VIOLATIONS) { await expectConfigRefused(product, arm.config, `T7-2 (${arm.label})`); @@ -433,7 +1396,7 @@ const T7_2 = defineProductTest({ { files: { "xspec.config.ts": ALIASED_CONFIG, - "specs/A.mdx": mdxSection("a"), + "specs/A.mdx": SECTION_A_SOURCE, }, }, async (workspace) => { @@ -457,6 +1420,329 @@ 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`, + ); + }); + + // Verbatim literals (SPEC 7, 2.4): the glob spelled with the escape of + // `*` is read as its characters — the inventory reports it as spelled + // and its group discovers nothing. + await withWorkspace( + { + files: { + "xspec.config.ts": VERBATIM_GLOB_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T7-2 (verbatim glob): `build` — the escape-spelled glob names " + + "no file, and a group matching no files is valid, discovery " + + "yielding zero sources (SPEC 7, 2.4)", + ); + const inventoryLabel = "T7-2 (verbatim glob) `inventory --json`"; + const inventory = decodeInventoryDocument( + await runJson( + product, + workspace, + ["inventory", "--json"], + inventoryLabel, + ), + inventoryLabel, + ); + assertSameJson( + inventory.configuration.specs, + [{ name: "main", globs: [VERBATIM_GLOB] }], + `${inventoryLabel}: the configured glob is reported as spelled — ` + + `thirteen characters, the six of the escape included — never ` + + `the interpreted "specs/*.mdx" (SPEC 7, 2.4, 11.6)`, + ); + assertSameJson( + inventory.sources, + [], + `${inventoryLabel}: the verbatim glob matches no discovered ` + + `file — specs/A.mdx is not "specs/${BACKSLASH}u002A.mdx" — so ` + + `the group discovers zero sources (SPEC 7, 2.4)`, + ); + const idsLabel = "T7-2 (verbatim glob) `ids --json`"; + const ids = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsLabel), + idsLabel, + ); + assertSameJson( + ids.files, + [], + `${idsLabel}: zero discovered spec sources list zero files ` + + `(SPEC 7, 2.4, 12.3)`, + ); + }, + ); + + // A group name spelled with the escape of `u` names a group whose + // spelling contains a backslash: a profile's target spelled `product` + // is an unknown group (14.14), the escape spelling resolves. + await expectConfigRefused( + product, + VERBATIM_NAME_UNKNOWN_TARGET_CONFIG, + 'T7-2 (verbatim group name: a profile\'s target "product" names no ' + + "configured group — the group's spelling contains a backslash)", + ); + await withWorkspace( + { + files: { + "xspec.config.ts": VERBATIM_NAME_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T7-2 (verbatim group name): `build` — the profile's target and " + + "boundary, spelled as the group's key is, resolve (SPEC 7, " + + "2.4, 7.4)", + ); + const inventoryLabel = "T7-2 (verbatim group name) `inventory --json`"; + const inventory = decodeInventoryDocument( + await runJson( + product, + workspace, + ["inventory", "--json"], + inventoryLabel, + ), + inventoryLabel, + ); + assertSameJson( + inventory.configuration.specs.map((group) => group.name), + [VERBATIM_GROUP_NAME], + `${inventoryLabel}: the group is named as spelled — eleven ` + + `characters, the six of the escape included — never the ` + + `interpreted "product" (SPEC 7, 2.4, 11.6)`, + ); + const coverageLabel = "T7-2 (verbatim group name) `coverage --json`"; + const coverage = decodeCoverageReport( + await runJson( + product, + workspace, + ["coverage", "--json"], + coverageLabel, + ), + coverageLabel, + ); + assertSameJson( + coverage.profiles.map((row) => ({ + name: row.name, + covered: row.covered.map((entry) => entry.identity), + uncovered: [...row.uncovered].sort(), + })), + [{ name: "verbatim", covered: [], uncovered: ["specs/A.mdx#a"] }], + `${coverageLabel}: the profile resolved its target — the ` + + `group's one leaf is its target set, uncovered under a ` + + `boundary sourcing no edge into it (SPEC 7, 2.4, 7.4, 8.1, 8.2)`, + ); + }, + ); + + // Encoding (SPEC 7, 14.14): bytes that are not valid UTF-8; a + // byte-order mark. + await expectConfigRefused( + product, + NON_UTF8_CONFIG, + "T7-2 (encoding: the byte 0xFF inside a trailing line comment — the " + + "file's bytes are not valid UTF-8)", + ); + await expectConfigRefused( + product, + BOM_CONFIG, + "T7-2 (encoding: the file begins with a byte-order mark)", + ); + + // Object-literal keys and names (SPEC 7, 14.14): repeated keys, empty + // names — each 14.14, exit 2. + for (const arm of KEY_AND_NAME_VIOLATIONS) { + await expectConfigRefused(product, arm.config, `T7-2 (${arm.label})`); + } + + // Comments (SPEC 7): the commented configuration loads, and the + // inventory's resolved `configuration` view is byte-identical to its + // comment-free twin's — compared as the member's serialization, member + // order and spellings included. + const configurationViewOf = async ( + config: StagedTs, + label: string, + ): Promise<unknown> => + await withWorkspace( + { + files: { + "xspec.config.ts": config, + "specs/A.mdx": SECTION_A_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${label}: \`build\` — the configuration loads (SPEC 7)`, + ); + const inventoryLabel = `${label} \`inventory --json\``; + const raw = await runJson( + product, + workspace, + ["inventory", "--json"], + inventoryLabel, + ); + decodeInventoryDocument(raw, inventoryLabel); + return (raw as { readonly configuration: unknown }).configuration; + }, + ); + const commented = await configurationViewOf( + COMMENTED_CONFIG, + "T7-2 (comments)", + ); + const commentFree = await configurationViewOf( + COMMENT_FREE_CONFIG, + "T7-2 (comment-free twin)", + ); + const commentsContext = + "T7-2 (comments): `inventory`'s `configuration` member under the " + + "commented configuration is byte-identical to the comment-free " + + "twin's — line and block comments before the import, after it, " + + "between keys, inside the glob list, and after the export " + + "contribute nothing (SPEC 7, 11.6)"; + assertSameJson(commented, commentFree, commentsContext); + if (JSON.stringify(commented) !== JSON.stringify(commentFree)) { + fail( + `${commentsContext}; the members are equal as values but ` + + `serialize differently (member order): ` + + `${JSON.stringify(commented)} vs ${JSON.stringify(commentFree)}`, + ); + } }, }); @@ -469,7 +1755,7 @@ const T7_2 = defineProductTest({ // every unknown-key fixture the surrounding configuration is valid (existing // unambiguous group references, all required fields present, permitted // literal values), so the unknown key is the only defect. -const KEY_VIOLATIONS: readonly { label: string; config: string }[] = [ +const KEY_VIOLATIONS = refusedConfigArms("T7-3", [ { label: "`specs` missing", config: `import { defineConfig } from "xspec" @@ -563,18 +1849,116 @@ export default defineConfig({ }) `, }, -]; +]); + +// Value shapes (SPEC 7, 7.1, 7.2; 14.14: a configuration that does not +// conform, an otherwise invalid group shape) — TEST-SPEC T7-3's "one arm +// each": seven fixtures, every one SPECS_ONLY_CONFIG with exactly one shape +// deviation, so the refusal is attributable to it alone. Each deviating +// value is admitted by the declarative form of 7 (a static string literal, +// the boolean literal `true`, an object or array literal — never a T7-2 +// form error) and excluded by the shapes 7.1, 7.2, 7.4, and 7.5 prescribe: +// a group's value is a list of globs, a glob is a string, `coverage` and +// `policy` are lists, and `specs` and `code` are maps of named groups — +// discriminating a product that reads the declarative form loosely, +// accepting whatever its own loader tolerates. +const VALUE_SHAPE_VIOLATIONS = refusedConfigArms("T7-3", [ + { + label: "a spec group whose value is a single string rather than a list", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: "specs/**/*.mdx" + } +}) +`, + }, + { + label: "a code group whose value is a single string rather than a list", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + impl: "src/**/*.ts" + } +}) +`, + }, + { + label: "a glob list holding a non-string element ([true])", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: [true] + } +}) +`, + }, + { + label: "`coverage` given as an object rather than a list ({})", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + coverage: {} +}) +`, + }, + { + label: "`policy` given as an object rather than a list ({})", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: {} +}) +`, + }, + { + label: "`specs` given as a list rather than a map of groups", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: ["specs/**/*.mdx"] +}) +`, + }, + { + label: "`code` given as a list rather than a map of groups", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: ["src/**/*.ts"] +}) +`, + }, +]); // `code` omitted: a marker-bearing TypeScript file that WOULD be a valid // code source (a spec module import plus a marker recording a `references` // edge, SPEC 4.5) — so a product that wrongly discovers `.ts` files without // a `code` key records an edge from it and fails the edge-set equality. -const MARKER_TS = `import SPEC from "../specs/A.xspec" +const MARKER_TS = stagedTs( + "T7-3 src/impl.ts (code omitted: the marker-bearing file)", + `import SPEC from "../specs/A.xspec" export function impl(): void { SPEC.a } -`; +`, +); // The complete edge set of the `code`-omitted fixture (SPEC 5.1–5.2): one // contains edge from A.mdx's root to its only section — and nothing sourced @@ -586,7 +1970,9 @@ const CODE_OMITTED_EDGES: readonly GraphEdge[] = [ // `policy` omitted / empty lists: two spec groups joined by one depends edge // (external-form d prop, SPEC 2.2) — the edge T7.5-2's forbidden rule // (from group product to group other) would flag if the rule existed. -const TWO_GROUP_CONFIG = `import { defineConfig } from "xspec" +const TWO_GROUP_CONFIG = stagedTs( + "T7-3 xspec.config.ts (policy omitted: two spec groups)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -594,9 +1980,12 @@ export default defineConfig({ other: ["specs/other/**/*.mdx"] } }) -`; +`, +); -const EMPTY_LISTS_CONFIG = `import { defineConfig } from "xspec" +const EMPTY_LISTS_CONFIG = stagedTs( + "T7-3 xspec.config.ts (empty coverage and policy lists)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -606,18 +1995,22 @@ export default defineConfig({ coverage: [], policy: [] }) -`; +`, +); -const PRODUCT_MDX = `import O from "../other/O.xspec" +const PRODUCT_MDX = stagedMdx( + "T7-3 specs/product/P.mdx", + `import O from "../other/O.xspec" <S id="p" d={O.o}> Product behavior depending on other. </S> -`; +`, +); -const OTHER_MDX = mdxSection("o"); +const OTHER_MDX = stagedMdx("T7-3 specs/other/O.mdx", mdxSection("o")); -const VIOLATING_EDGE_FILES: Readonly<Record<string, string>> = { +const VIOLATING_EDGE_FILES: Readonly<Record<string, InitialFileContents>> = { "specs/product/P.mdx": PRODUCT_MDX, "specs/other/O.mdx": OTHER_MDX, }; @@ -676,19 +2069,95 @@ async function assertViolatingEdgePresent( ); } +// Names containing U+FFFD (SPEC 7, 14.14; 12.0: no argument value carries +// the character, and configured names are named in arguments): a spec +// group, a profile, and a rule, one arm each. The character is staged as +// its validly encoded code point (EF BF BD) between two letters, so 14.14's +// name rule — never the encoding rule — is at stake; the group key takes +// the string-literal form (U+FFFD is no identifier character), and the +// profile and rule reference the existing unambiguous group `main`, so the +// name is each fixture's only defect. A product not checking names loads +// each and builds (exit 0). +const REPLACEMENT_NAME_VIOLATIONS = refusedConfigArms("T7-3", [ + { + label: "a spec group named with U+FFFD", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + "a${REPLACEMENT_CHARACTER}b": ["specs/**/*.mdx"] + } +}) +`, + }, + { + label: "a coverage profile named with U+FFFD", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + coverage: [ + { + name: "p${REPLACEMENT_CHARACTER}q", + target: "main", + boundary: "main", + mode: "direct" + } + ] +}) +`, + }, + { + label: "a policy rule named with U+FFFD", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: [ + { + name: "r${REPLACEMENT_CHARACTER}s", + type: "forbidden", + from: { group: "main" }, + to: { group: "main" } + } + ] +}) +`, + }, +]); + const T7_3 = defineProductTest({ id: "T7-3", title: "keys: specs is required; omitted code/markdown/coverage/policy mean " + "no code groups, no emission, zero profiles, and no policy findings; " + "empty coverage/policy lists equal omission; unknown keys at every " + - "position are configuration errors (SPEC 7, 14.14)", + "position, values of the wrong shape (a group valued by a single " + + "string, a glob list holding true, coverage/policy given as objects, " + + "specs/code given as lists), and a spec group, a profile, or a rule " + + "named with U+FFFD are configuration errors (SPEC 7, 7.1, 7.2, 14.14)", run: async (product) => { // (a) `specs` missing and the unknown-key matrix — each 14.14, exit 2. for (const arm of KEY_VIOLATIONS) { await expectConfigRefused(product, arm.config, `T7-3 (${arm.label})`); } + // (a′) value shapes — each 14.14, exit 2, the configuration file named + // and nothing written (SPEC 7, 7.1, 7.2; 14.14). + for (const arm of VALUE_SHAPE_VIOLATIONS) { + await expectConfigRefused(product, arm.config, `T7-3 (${arm.label})`); + } + + // (a″) names containing U+FFFD — a spec group, a profile, a rule — + // each 14.14, exit 2 (SPEC 7, 14.14). + for (const arm of REPLACEMENT_NAME_VIOLATIONS) { + await expectConfigRefused(product, arm.config, `T7-3 (${arm.label})`); + } + // (b) `code` omitted — no code groups: the marker-bearing .ts file is // undiscovered, no edge is sourced at it, and naming it in --from is // unknown (exit 2; SPEC 7, 11). @@ -696,7 +2165,7 @@ const T7_3 = defineProductTest({ { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": mdxSection("a"), + "specs/A.mdx": SECTION_A_SOURCE, "src/impl.ts": MARKER_TS, }, }, @@ -730,11 +2199,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( @@ -751,8 +2221,8 @@ const T7_3 = defineProductTest({ { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": mdxSection("a"), - "specs/sub/B.mdx": mdxSection("b"), + "specs/A.mdx": SECTION_A_SOURCE, + "specs/sub/B.mdx": SECTION_B_SOURCE, }, }, async (workspace) => { @@ -774,7 +2244,7 @@ const T7_3 = defineProductTest({ { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/A.mdx": mdxSection("a"), + "specs/A.mdx": SECTION_A_SOURCE, }, }, async (workspace) => { diff --git a/test/suite/registry/section-7-discovery.ts b/test/suite/registry/section-7-discovery.ts index 447cf1e4..bc8ca88b 100644 --- a/test/suite/registry/section-7-discovery.ts +++ b/test/suite/registry/section-7-discovery.ts @@ -12,9 +12,17 @@ // whole segments, including none) — every other character is a literal; // matching is byte-wise (workspace-relative paths as their UTF-8 bytes) and // case-sensitive; a path segment beginning with `.` is matched only by a -// pattern segment written with a leading `.`; patterns resolve relative to -// the configuration file's directory, and one resolving outside the workspace -// root is a configuration error (14.14). Discovery never follows symbolic +// pattern segment written with a leading `.`; `**` means any segments only +// as a whole pattern segment (elsewhere each `*` of a `**` is the +// single-segment wildcard); patterns resolve relative to the configuration +// file's directory, and whether one lies outside the workspace root is +// decided by its spelling alone — reading its `/`-separated segments from a +// depth of zero, `..` lowers the depth by one, `.`, an empty segment, and +// `**` leave it unchanged, every other segment (a drive-qualified `C:` +// included) raises it by one; a glob beginning with `/`, or whose depth ever +// falls below zero, is a configuration error (14.14), and every other glob +// is inside, its `.`, `..`, and empty segments matching nothing, since a +// discovered path carries no such segment. Discovery never follows symbolic // links; derived files are never sources (13.4); imports resolve references // but never add files to the workspace (2.1, else 14.15); a no-match group // and an empty `specs`/`code` map are valid with zero sources. @@ -27,11 +35,34 @@ // ID unique in its workspace, and every decoy (a file that must NOT be // discovered) is equally valid with its own unique ID: a product that wrongly // discovers a decoy lists it cleanly instead of crashing, keeping failures -// diagnosed (H-8). T7-4 and T7-5 never run `build`, so the only -// product-written path is graph data under `.xspec/` (13.3), which no fixture -// pattern can reach: none names `.xspec/`, no staged name carries `.xspec.`, -// `markdown` is absent, and wildcards never match the dot segment — the -// CERTIFICATIONS.md CONF-DISC staging constraints for these two tests. +// diagnosed (H-8). T7-4's inside-root arms add `inventory --json` (11.6) as +// a second observation — the glob reported exactly as configured, `sources` +// exactly the control file — and its outside-root arms drive `build`, which +// fails at configuration load and writes nothing (12.1); otherwise T7-4 and +// T7-5 never run `build`, so the only product-written path is graph data +// under `.xspec/` (13.3), which no fixture pattern can reach: none names +// `.xspec/`, no staged name carries `.xspec.`, `markdown` is absent, and +// wildcards never match the dot segment — the CERTIFICATIONS.md CONF-DISC +// staging constraints for these two tests. T7-4's literal-backslash arm +// (Linux leg) declares the one code group these two tests stage, and +// observes its discovered set through `inventory --json` alone — it parses +// no source and writes nothing — with no `build` preceding it and no spec +// group declared, so `sources` is exactly the code set; its glob reaches no +// derived-classified path (CONF-DISC's constraint for that arm). +// +// T7-6's code-group exclusion arm observes the code side through `query +// edges --from <path>` (11.1), T7-3's idiom for code discovery: a discovered +// code source's whole-file location (4.6) answers exit 0 with its edge +// enumeration — empty, nothing staged here giving a code file an edge — +// while a path in no configured group, an excluded derived path above all, +// is unknown to `--from`: the usage error of 12.0, judged after +// configuration loading and before the 13.3 gate, exit 2 with the 12.7 +// error document (the CONF-DISC staging constraint for that arm). Its +// invalid-source arm and that arm's control observe through `check --json` +// (12.2) alone, each over a workspace staged from scratch that fails +// `build`'s validations and on which no `build` runs — so no record exists +// (13.3) and `check` reports exactly the validation findings (CONF-DISC's +// constraint for those two). // // Conservative operationalizations (H-3/H-4): // - Listing comparisons sort both sides bytewise by file path: these tests @@ -41,17 +72,56 @@ // text: Linux file names are byte strings, so the staged two-byte code // point reaches the matcher verbatim; other platforms' filesystems // normalize or re-case names, so the staged bytes are not portable. +// - The literal-backslash arm is gated to the Linux leg by T7-4's own text, +// where a file name can hold the byte; the gate sits inside the body (the +// Windows leg reruns only the single-casing probe, E-6). Its glob is +// written between the configuration literal's quotes with its single +// backslash, built from the code point: the literal is read verbatim +// (SPEC 2.4), so a doubled backslash would spell a different glob. // - T7-5 runs `ids` once over one workspace holding every link arm; the // invocation is wrapped so a failure to complete — a discovery hang on the // staged symlink cycle, killed by the subprocess driver's timeout (H-8) — // is reported as a diagnosed assertion failure: nontermination is exactly -// the product defect that arm tests (SPEC 7). +// the product defect that arm tests (SPEC 7). An exhausted capture limit +// is never so converted: it propagates as a harness error (H-11). // - 14.14 contract: `expectConfigurationError` (shared, ./support.ts). +// - Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): every `.mdx` file a body stages in a workspace +// created after its first product invocation — T7-4's probe workspaces +// past the semantics one (`StagedProbe.source`) and its outside-root +// arms, T7-6's arms past (a) — is a ledger record, judged by +// test/self/s9-staged-sources.test.ts before any product exists: the +// minimal `a` and `b` sources are section-7-basics.ts's shared records, +// `c`, `m`, and `n` this module's, and the sources of T7-6's +// invalid-source and import arms their own. Each body's first workspace +// (T7-4's semantics probes, T7-5's link workspace, T7-6's exclusion +// workspace) precedes any invocation and stays plain; the files T7-4 and +// T7-5 write beside the root (`stageBesideRoot`, a raw write outside the +// builder) stay strings — `x/M.mdx` the one the `m` record is made from. +// - TypeScript staged-source records (TEST-SPEC S-9's TypeScript and +// timing clauses; helpers/staged-ts.ts): every configuration file and +// code source a body stages in a workspace created after its first +// product invocation — T7-4's probe, outside-root, inside-root, and +// literal-backslash workspaces (the single-casing probe's too, a code +// path the Windows leg reruns, E-6), T7-6's arms past (a) — is a ledger +// record carrying its S-9 declaration, judged by +// test/self/s9-staged-sources.test.ts before any product exists: T7-6's +// `specs/a'b.md` holding `)` unparseable (14.20 — the record makes the +// path judged, a name the default does not reach), every other +// well-formed. Arms staged from a module-level spelling table (T7-4's +// outside-root patterns and inside-root spellings) carry one record per +// row, made at module load. The same first workspaces stay plain, and +// so does `x/M.mdx`'s raw write; the literal-backslash records' bytes +// and paths are built from the code point as before. import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import { decodeIdsReport } from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeIdsReport, + decodeInventoryDocument, +} from "../../helpers/adapters/index.js"; import { assertExitCode, fail, @@ -60,17 +130,36 @@ import { import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; -import { runProduct } from "../../helpers/subprocess.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import type { StagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; +import { + rethrowOutputOverflow, + runProduct, + summarizeResult, +} from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; +import { SECTION_A_SOURCE, SECTION_B_SOURCE } from "./section-7-basics.js"; import { assertConditionCounts, + assertEdgeSetEqual, + assertFindingConcernsPath, assertFindingLocated, assertSameJson, buildFindings, buildOk, byteWindow, expectConfigurationError, + expectErrorDocument, + expectExit, + runFindingsReport, + runJson, + stageBesideRoot, } from "./support.js"; // --------------------------------------------------------------------------- @@ -82,6 +171,29 @@ function mdxSection(id: string): string { return `<S id="${id}">\nText for ${id}.\n</S>\n`; } +// This module's own staged-source records (module header), beside +// section-7-basics.ts's shared `a` and `b`: the minimal sources T7-4's later +// probe workspaces stage — `c`, the control of the casing and inside-root +// workspaces (exported: section-7.4-7.5.ts stages the same bytes at T7.5-4's +// `specs/C.mdx` and T7.5-5's `tgt/c.mdx`); `m` and `n`, the inside-root +// decoys, `n` also T7-6's no-group source at `notes/N.mdx` — one record per +// byte sequence, named with every staging test and path. +export const SECTION_C_SOURCE = stagedMdx( + "T7-4/T7.5-4/T7.5-5 the minimal section c (T7-4's ctl/C.mdx, the control source; T7.5-4's specs/C.mdx; T7.5-5's tgt/c.mdx)", + mdxSection("c"), +); +/** `mdxSection("m")`: the `b/M.mdx` decoy's record is made from it, and + * `stageBesideRoot` writes it at `x/M.mdx` beside the root. */ +const M_SOURCE = mdxSection("m"); +const SECTION_M_SOURCE = stagedMdx( + "T7-4 b/M.mdx (the minimal section m, the ascent decoy)", + M_SOURCE, +); +const SECTION_N_SOURCE = stagedMdx( + "T7-4/T7-6 the minimal section n (T7-4's a/N.mdx; T7-6's notes/N.mdx)", + mdxSection("n"), +); + /** * A declarative configuration (SPEC 7) whose `specs` map holds exactly the * given groups. Group names are non-computed identifier keys; patterns are @@ -170,12 +282,26 @@ interface DiscoveryProbe { readonly path: string; readonly id: string; readonly discovered: boolean; + /** + * The staged-source record holding `mdxSection(id)` — required of every + * probe of a workspace created after T7-4's first product invocation + * (`StagedProbe`; module header); absent for the semantics probes, the + * body's first workspace, staged plain. + */ + readonly source?: StagedMdx; } -function probeFiles(probes: readonly DiscoveryProbe[]): Record<string, string> { - const files: Record<string, string> = {}; +/** A probe of a later workspace: its source a ledger record. */ +interface StagedProbe extends DiscoveryProbe { + readonly source: StagedMdx; +} + +function probeFiles( + probes: readonly DiscoveryProbe[], +): Record<string, InitialFileContents> { + const files: Record<string, InitialFileContents> = {}; for (const probe of probes) { - files[probe.path] = mdxSection(probe.id); + files[probe.path] = probe.source ?? mdxSection(probe.id); } return files; } @@ -213,6 +339,9 @@ const SEMANTICS_GROUPS: Readonly<Record<string, readonly string[]>> = { literalBraces: ["litbrace/b{a,c}.mdx"], literalBang: ["litbang/!x.mdx"], literalExtglob: ["litext/+(x).mdx"], + // In-segment `**` (SPEC 7: `**` means any segments only as a whole pattern + // segment; elsewhere each `*` of a `**` is the single-segment wildcard). + inSegmentDoubleStar: ["dstar/a**b.mdx"], }; const SEMANTICS_PROBES: readonly DiscoveryProbe[] = [ @@ -260,6 +389,11 @@ const SEMANTICS_PROBES: readonly DiscoveryProbe[] = [ { path: "litext/+(x).mdx", id: "l8", discovered: true }, { path: "litext/x.mdx", id: "l9", discovered: false }, { path: "litext/xx.mdx", id: "l10", discovered: false }, + // In-segment `**`: each `*` a possibly empty single-segment run, so + // `axxb.mdx` and `ab.mdx` match and the two-segment `a/b.mdx` never does. + { path: "dstar/axxb.mdx", id: "g1", discovered: true }, + { path: "dstar/ab.mdx", id: "g2", discovered: true }, + { path: "dstar/a/b.mdx", id: "g3", discovered: false }, ]; // Single-casing case-sensitivity probes (T7-4: stageable on any filesystem — @@ -274,12 +408,25 @@ const CASING_GROUPS: Readonly<Record<string, readonly string[]>> = { control: ["ctl/*.mdx"], }; -const CASING_PROBES: readonly DiscoveryProbe[] = [ - { path: "specs/A.mdx", id: "a", discovered: false }, - { path: "specs2/B.mdx", id: "b", discovered: false }, - { path: "ctl/C.mdx", id: "c", discovered: true }, +const CASING_PROBES: readonly StagedProbe[] = [ + { path: "specs/A.mdx", id: "a", discovered: false, source: SECTION_A_SOURCE }, + { + path: "specs2/B.mdx", + id: "b", + discovered: false, + source: SECTION_B_SOURCE, + }, + { path: "ctl/C.mdx", id: "c", discovered: true, source: SECTION_C_SOURCE }, ]; +// The probe workspace's configuration, a staged-source record (module +// header): T7-4 stages it after its first product invocation, and the +// Windows leg's rerun (E-6) stages the same record. +const CASING_CONFIG = stagedTs( + "T7-4 xspec.config.ts (the single-casing case-sensitivity probes)", + specGroupsConfig(CASING_GROUPS), +); + /** * T7-4's single-casing glob probe as one shared code path: called by the * registered T7-4 body on the suite leg and rerun verbatim by the Windows leg @@ -296,7 +443,7 @@ export async function runT74SingleCasingGlobProbe( await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig(CASING_GROUPS), + "xspec.config.ts": CASING_CONFIG, ...probeFiles(CASING_PROBES), }, }, @@ -322,18 +469,57 @@ export async function runT74SingleCasingGlobProbe( const BYTE_ONE_GROUPS: Readonly<Record<string, readonly string[]>> = { one: ["bytes/?.mdx"], }; -const BYTE_ONE_PROBES: readonly DiscoveryProbe[] = [ - { path: "bytes/é.mdx", id: "etwo", discovered: false }, - { path: "bytes/x.mdx", id: "xone", discovered: true }, +const BYTE_ONE_PROBES: readonly StagedProbe[] = [ + { + path: "bytes/é.mdx", + id: "etwo", + discovered: false, + source: stagedMdx( + "T7-4 byte probes bytes/é.mdx (etwo, under bytes/?.mdx)", + mdxSection("etwo"), + ), + }, + { + path: "bytes/x.mdx", + id: "xone", + discovered: true, + source: stagedMdx( + "T7-4 byte probes bytes/x.mdx (xone)", + mdxSection("xone"), + ), + }, ]; const BYTE_TWO_GROUPS: Readonly<Record<string, readonly string[]>> = { two: ["bytes/??.mdx"], anyRun: ["bytes2/*.mdx"], }; -const BYTE_TWO_PROBES: readonly DiscoveryProbe[] = [ - { path: "bytes/é.mdx", id: "e1", discovered: true }, - { path: "bytes2/é.mdx", id: "e2", discovered: true }, +const BYTE_TWO_PROBES: readonly StagedProbe[] = [ + { + path: "bytes/é.mdx", + id: "e1", + discovered: true, + source: stagedMdx( + "T7-4 byte probes bytes/é.mdx (e1, under bytes/??.mdx)", + mdxSection("e1"), + ), + }, + { + path: "bytes2/é.mdx", + id: "e2", + discovered: true, + source: stagedMdx("T7-4 byte probes bytes2/é.mdx (e2)", mdxSection("e2")), + }, ]; +// The two byte-probe workspaces' configurations, staged-source records +// (module header). +const BYTE_ONE_CONFIG = stagedTs( + "T7-4 xspec.config.ts (byte probes: bytes/?.mdx)", + specGroupsConfig(BYTE_ONE_GROUPS), +); +const BYTE_TWO_CONFIG = stagedTs( + "T7-4 xspec.config.ts (byte probes: bytes/??.mdx and bytes2/*.mdx)", + specGroupsConfig(BYTE_TWO_GROUPS), +); // Configuration-directory resolution (T7-4: all paths resolve relative to the // configuration file's directory): run from `sub/`, whose own `sub/specs/` @@ -344,30 +530,318 @@ const BYTE_TWO_PROBES: readonly DiscoveryProbe[] = [ const CONFIG_DIR_GROUPS: Readonly<Record<string, readonly string[]>> = { main: ["specs/*.mdx"], }; -const CONFIG_DIR_PROBES: readonly DiscoveryProbe[] = [ - { path: "specs/A.mdx", id: "roota", discovered: true }, - { path: "sub/specs/B.mdx", id: "nested", discovered: false }, +const CONFIG_DIR_PROBES: readonly StagedProbe[] = [ + { + path: "specs/A.mdx", + id: "roota", + discovered: true, + source: stagedMdx( + "T7-4 configuration-directory probes specs/A.mdx (roota)", + mdxSection("roota"), + ), + }, + { + path: "sub/specs/B.mdx", + id: "nested", + discovered: false, + source: stagedMdx( + "T7-4 configuration-directory probes sub/specs/B.mdx (nested)", + mdxSection("nested"), + ), + }, ]; +// The workspace's configuration, a staged-source record (module header). +const CONFIG_DIR_CONFIG = stagedTs( + "T7-4 xspec.config.ts (configuration-directory probes: specs/*.mdx)", + specGroupsConfig(CONFIG_DIR_GROUPS), +); -// Outside-root patterns (SPEC 7: a pattern that resolves outside the -// workspace root is a configuration error, 14.14) — a plain `../` escape and -// a `..` traversal buried mid-pattern. Each fixture also stages a valid group -// and source, so a product that ignores or no-match-treats the escaping -// pattern proceeds to a successful run (exit 0) and fails the exit-2 -// assertion — never exits 2 for a side reason. +// Outside-root patterns by spelling alone (SPEC 7, 14.14; module header): +// the three spellings T7-4 pins plus the plain ascent. Each fixture also +// stages a valid group and source, so a product that ignores or +// no-match-treats the escaping pattern proceeds to a successful run (exit 0) +// and fails the exit-2 assertion — never exits 2 for a side reason — and the +// root's parent holds `x/M.mdx`, the file the `x` spellings name when +// resolved: a product deciding by what it finds rather than by spelling +// discovers it and exits 0 too. (No file can be staged at the absolute +// `/specs/`, so that arm's premise is the spelling alone.) const OUTSIDE_ROOT_PATTERNS: readonly string[] = [ - "../outside/*.mdx", - "specs/../../outside/*.mdx", + "a/../../x/*.mdx", // the depth falls below zero at the second `..` + "**/../x/*.mdx", // `**` leaves the depth at zero, so `..` falls below it + "/specs/*.mdx", // a leading `/` + "../x/*.mdx", // the plain ascent +]; +const BESIDE_ROOT_MATCH: Readonly<Record<string, string>> = { + "x/M.mdx": M_SOURCE, +}; +// The outside-root arms, one staged-source record per pattern made at +// module load (module header): each arm's configuration — the valid group +// beside the escaping one — staged after T7-4's first product invocation. +const OUTSIDE_ROOT_ARMS: readonly { + readonly pattern: string; + readonly config: StagedTs; +}[] = OUTSIDE_ROOT_PATTERNS.map((pattern) => ({ + pattern, + config: stagedTs( + `T7-4 xspec.config.ts (the outside-root pattern ${pattern})`, + specGroupsConfig({ + main: ["specs/*.mdx"], + escape: [pattern], + }), + ), +})); + +// Inside-root spellings that match nothing (SPEC 7: every glob not outside +// the root is inside, its `.`, `..`, and empty segments matching nothing, +// since a discovered file's workspace-relative path — the directory-entry +// names descending from the root, `/`-joined — carries no such segment). Each +// spelling runs in its own workspace beside a control group, over the files +// a normalizing product would match through it: `specs/A.mdx` for +// `./specs/*.mdx`, `specs//*.mdx`, `specs/*.mdx/`, and `C:/specs/*.mdx` (a +// drive prefix stripped), and `b/M.mdx` for `a/../b/*.mdx` — `a/N.mdx` +// making `a/` a real directory, so a product walking `a` and then `..` +// reaches `b/` as well. +const INSIDE_NO_MATCH_SPELLINGS: readonly string[] = [ + "a/../b/*.mdx", + "./specs/*.mdx", + "specs//*.mdx", + "specs/*.mdx/", +]; +// A drive-qualified spelling is ordinary segments — inside the root and +// matching nothing — on the Linux leg (T7-4), where `C:` is a plain +// directory name; other platforms' semantics for `C:` are not staged. +const DRIVE_QUALIFIED_SPELLING = "C:/specs/*.mdx"; +const CONTROL_GLOB = "ctl/*.mdx"; +const INSIDE_NO_MATCH_PROBES: readonly StagedProbe[] = [ + { path: "ctl/C.mdx", id: "c", discovered: true, source: SECTION_C_SOURCE }, + { path: "specs/A.mdx", id: "a", discovered: false, source: SECTION_A_SOURCE }, + { path: "b/M.mdx", id: "m", discovered: false, source: SECTION_M_SOURCE }, + { path: "a/N.mdx", id: "n", discovered: false, source: SECTION_N_SOURCE }, ]; +/** An inside-root arm: the spelling and its workspace's configuration. */ +interface InsideNoMatchArm { + readonly spelling: string; + readonly config: StagedTs; +} + +/** + * An inside-root spelling's arm, its configuration — the group holding only + * the spelling beside the control group — a staged-source record made at + * module load (module header): T7-4 stages every arm after its first + * product invocation. + */ +function insideNoMatchArm(spelling: string): InsideNoMatchArm { + return { + spelling, + config: stagedTs( + `T7-4 xspec.config.ts (the inside-root spelling ${spelling} beside ` + + "the control group)", + specGroupsConfig({ + probe: [spelling], + control: [CONTROL_GLOB], + }), + ), + }; +} +const INSIDE_NO_MATCH_ARMS: readonly InsideNoMatchArm[] = + INSIDE_NO_MATCH_SPELLINGS.map(insideNoMatchArm); +const DRIVE_QUALIFIED_ARM = insideNoMatchArm(DRIVE_QUALIFIED_SPELLING); + +/** + * One inside-root spelling that matches nothing (SPEC 7): a group holding + * only the spelling discovers zero sources beside the control group — + * `ids --json` (12.3) lists exactly the control, exit 0 — and + * `inventory --json` (11.6) reports the glob exactly as configured, with + * `sources` exactly the control file under its own group. A product + * normalizing the spelling lists the decoy it then matches. + */ +async function expectInsideMatchingNothing( + product: ProductBinding, + arm: InsideNoMatchArm, +): Promise<void> { + const { spelling } = arm; + const shown = JSON.stringify(spelling); + await withWorkspace( + { + files: { + "xspec.config.ts": arm.config, + ...probeFiles(INSIDE_NO_MATCH_PROBES), + }, + }, + async (workspace) => { + await expectDiscovered( + product, + workspace, + expectedListing(INSIDE_NO_MATCH_PROBES), + `T7-4 (the inside-root spelling ${shown} matches nothing: a ` + + `discovered path carries no ".", "..", or empty segment) ` + + "`ids --json`", + ); + const label = `T7-4 (the inside-root spelling ${shown}) \`inventory --json\``; + const inventory = decodeInventoryDocument( + await runJson(product, workspace, ["inventory", "--json"], label), + label, + ); + assertSameJson( + inventory.configuration.specs, + [ + { name: "probe", globs: [spelling] }, + { name: "control", globs: [CONTROL_GLOB] }, + ], + `${label}: the glob is reported exactly as configured — never a ` + + `normalized spelling (SPEC 7, 11.6)`, + ); + assertSameJson( + inventory.sources, + [{ path: "ctl/C.mdx", groups: [{ name: "control", kind: "spec" }] }], + `${label}: the group holding only ${shown} discovers zero sources — ` + + `the control file under its own group is the whole discovered ` + + `set (SPEC 7, 11.6)`, + ); + }, + ); +} + +// The literal backslash (T7-4; Linux leg, where a file name can hold it): +// the backslash is a literal byte of a glob, never an escape (SPEC 7: every +// character outside `*`, `?`, and `**` is a literal), and the configuration +// literal spelling the glob is read verbatim (2.4: no escape sequence of any +// configuration literal is interpreted). The one code group globs `src/a`, +// a backslash, `*.ts`, spelled with that single backslash between the +// literal's quotes, so it discovers `src/a`, a backslash, `b.ts` and not the +// sibling `src/ab.ts`: a product reading the backslash as a glob escape (a +// literal `*`) discovers neither, and one interpreting the literal's escape +// (TypeScript's own reading of a backslash before `*`, which drops the +// backslash) discovers both and reports the glob without its backslash. +// The backslash is built from its code point (U+005C), never spelled as an +// escape in this source, and the glob is rendered between the quotes as is, +// never through `JSON.stringify` (which would double the backslash). +// Observed through `inventory --json` (11.6), which parses no source and +// writes nothing, with no `build` preceding it, over a configuration +// declaring no spec group (`specs: {}`, valid with zero sources, SPEC 7), so +// the code group's discovered set is the whole `sources` listing — the +// CERTIFICATIONS.md CONF-DISC staging constraint for this arm: its code set +// observed through `inventory` before any `build`, its glob reaching no +// derived-classified path, `markdown` absent. The configuration file and +// both code files are well-formed TypeScript, accepted by release 5.9.3 read +// as module code and as script code (14.20; TypeScript accepts a backslash +// before `*` in a string literal as an escape), and the code files spell no +// marker, `text` call, or module-linking form (CONF-DISC's scope). +const BACKSLASH = String.fromCharCode(0x5c); +const LITERAL_BACKSLASH_GROUP = "lit"; +const LITERAL_BACKSLASH_GLOB = `src/a${BACKSLASH}*.ts`; +const LITERAL_BACKSLASH_MATCH = `src/a${BACKSLASH}b.ts`; +const LITERAL_BACKSLASH_SIBLING = "src/ab.ts"; +// The arm's three files are staged-source records (module header), staged +// after T7-4's first product invocation; their names spell the backslash in +// words. +const LITERAL_BACKSLASH_CONFIG = stagedTs( + "T7-4 xspec.config.ts (the literal-backslash arm: the code group globbing " + + "src/a, a backslash, *.ts)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: {}, + code: { + ${LITERAL_BACKSLASH_GROUP}: ["${LITERAL_BACKSLASH_GLOB}"] + } +}) +`, +); +const LITERAL_BACKSLASH_FILES: Readonly<Record<string, StagedTs>> = { + [LITERAL_BACKSLASH_MATCH]: stagedTs( + "T7-4 the literal-backslash arm's matched code source (src/a, a " + + "backslash, b.ts)", + "export const backslashed = 1;\n", + ), + [LITERAL_BACKSLASH_SIBLING]: stagedTs( + "T7-4 src/ab.ts (the literal-backslash arm's sibling)", + "export const sibling = 2;\n", + ), +}; + +/** + * T7-4's literal-backslash arm (Linux leg; the constants above): the code + * group globbing `src/a`, a backslash, `*.ts` is reported exactly as + * configured — the configuration literal read verbatim (SPEC 2.4) — and + * discovers the backslash-named file alone, never its sibling `src/ab.ts` + * (SPEC 7), as `inventory --json` lists them (11.6). + */ +async function expectLiteralBackslashGlob( + product: ProductBinding, +): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": LITERAL_BACKSLASH_CONFIG, + ...LITERAL_BACKSLASH_FILES, + }, + }, + async (workspace) => { + const glob = JSON.stringify(LITERAL_BACKSLASH_GLOB); + const match = JSON.stringify(LITERAL_BACKSLASH_MATCH); + const sibling = JSON.stringify(LITERAL_BACKSLASH_SIBLING); + const label = + "T7-4 (the literal backslash, Linux leg: the code group globbing " + + `${glob}, JSON-spelled) \`inventory --json\``; + const inventory = decodeInventoryDocument( + await runJson(product, workspace, ["inventory", "--json"], label), + label, + ); + assertSameJson( + { + specs: inventory.configuration.specs, + code: inventory.configuration.code, + }, + { + specs: [], + code: [ + { name: LITERAL_BACKSLASH_GROUP, globs: [LITERAL_BACKSLASH_GLOB] }, + ], + }, + `${label}: the glob is reported exactly as configured — the ` + + `configuration literal read verbatim, its backslash kept (SPEC ` + + `2.4, 7, 11.6); a product interpreting the literal's escape ` + + `reports ${JSON.stringify("src/a*.ts")} instead`, + ); + assertSameJson( + inventory.sources, + [ + { + path: LITERAL_BACKSLASH_MATCH, + groups: [{ name: LITERAL_BACKSLASH_GROUP, kind: "code" }], + }, + ], + `${label}: the backslash is a literal byte, never an escape — the ` + + `group discovers ${match} alone, never the sibling ${sibling}, ` + + `and the configuration declares no other group (SPEC 7, 2.4, ` + + `11.6); a product reading the backslash as a glob escape ` + + `discovers neither file, and one interpreting the literal's ` + + `escape discovers both`, + ); + }, + ); +} + const T7_4 = defineProductTest({ id: "T7-4", title: - "glob semantics: `*`/`?`/`**` per SPEC 7, byte-wise case-sensitive " + - "matching incl. the single-casing SPECS/specs probe and the Linux-leg " + - "é.mdx byte probes, the dot-segment rule, literal metacharacters " + - "([1], {a,c}, !, +(x)), configuration-directory-relative resolution, " + - "and outside-root patterns as configuration errors (SPEC 7, 14.14)", + "glob semantics: `*`/`?`/`**` per SPEC 7 (in-segment `a**b.mdx` each " + + "`*` the single-segment wildcard), byte-wise case-sensitive matching " + + "incl. the single-casing SPECS/specs probe and the Linux-leg é.mdx " + + "byte probes, the dot-segment rule, literal metacharacters ([1], " + + "{a,c}, !, +(x), and the Linux-leg backslash: a code group globbing " + + "src/a, a backslash, *.ts — the configuration literal read verbatim, " + + "SPEC 2.4 — discovers the backslash-named file alone, never " + + "src/ab.ts, per `inventory`), configuration-directory-relative " + + "resolution, " + + "outside-root patterns by spelling alone (`a/../../x`, `**/../x`, a " + + "leading `/`) as configuration errors even with a matching file " + + "beside the root (SPEC 7, 14.14), and inside-root spellings " + + "(`a/../b`, `./specs`, `specs//`, `specs/*.mdx/`, Linux-leg `C:/`) " + + "matching nothing with the inventory reporting them as configured " + + "(SPEC 7, 11.6)", run: async (product) => { // Wildcard, dot-segment, and literal-metacharacter semantics — disjoint // per-directory groups over one workspace, asserted as one exact set. @@ -398,7 +872,7 @@ const T7_4 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig(CONFIG_DIR_GROUPS), + "xspec.config.ts": CONFIG_DIR_CONFIG, ...probeFiles(CONFIG_DIR_PROBES), }, }, @@ -419,7 +893,7 @@ const T7_4 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig(BYTE_ONE_GROUPS), + "xspec.config.ts": BYTE_ONE_CONFIG, ...probeFiles(BYTE_ONE_PROBES), }, }, @@ -436,7 +910,7 @@ const T7_4 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig(BYTE_TWO_GROUPS), + "xspec.config.ts": BYTE_TWO_CONFIG, ...probeFiles(BYTE_TWO_PROBES), }, }, @@ -452,29 +926,70 @@ const T7_4 = defineProductTest({ ); } - // A pattern resolving outside the workspace root → 14.14 (exit 2). - for (const pattern of OUTSIDE_ROOT_PATTERNS) { + // A pattern outside the workspace root by its spelling alone → 14.14 + // (exit 2), the root's parent holding the file the `x` spellings name + // when resolved; the error document's finding carries the stable code + // and the configuration file as its concerned path (SPEC 14, 12.7), and + // a build failing at configuration load writes nothing (12.1). + for (const { pattern, config } of OUTSIDE_ROOT_ARMS) { await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig({ - main: ["specs/*.mdx"], - escape: [pattern], - }), - "specs/A.mdx": mdxSection("a"), + "xspec.config.ts": config, + "specs/A.mdx": SECTION_A_SOURCE, }, }, async (workspace) => { - await expectConfigurationError( - product, - workspace, - ["build"], - `T7-4 (pattern ${JSON.stringify(pattern)} resolves outside the ` + - `workspace root) \`build --json\``, + await stageBesideRoot(workspace, BESIDE_ROOT_MATCH); + const context = + `T7-4 (the pattern ${JSON.stringify(pattern)} lies outside the ` + + `workspace root by its spelling alone, a matching file beside ` + + `the root notwithstanding) \`build --json\``; + const result = await assertLeavesUnchanged( + workspace.root, + () => + expectConfigurationError(product, workspace, ["build"], context), + context, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: "xspec.config.ts", + locations: [], + }, + `${context}: the error document's one finding carries the ` + + `stable code "configuration-error", locations [] (an ` + + `unlocated condition), and the configuration file as its ` + + `concerned path (SPEC 14.14, 14, 12.7)`, ); }, ); } + + // Inside-root spellings match nothing: a group holding only such a glob + // discovers zero sources (exit 0), the inventory reporting the glob as + // configured (SPEC 7, 11.6); the drive-qualified spelling is ordinary + // segments on the Linux leg. + for (const arm of INSIDE_NO_MATCH_ARMS) { + await expectInsideMatchingNothing(product, arm); + } + if (process.platform === "linux") { + await expectInsideMatchingNothing(product, DRIVE_QUALIFIED_ARM); + } + + // The literal backslash: a code group globbing `src/a`, a backslash, + // `*.ts` — the configuration literal read verbatim (SPEC 2.4) — + // discovers the backslash-named file alone (SPEC 7), on the Linux leg, + // where a file name can hold the byte (T7-4's own text). + if (process.platform === "linux") { + await expectLiteralBackslashGlob(product); + } }, }); @@ -559,18 +1074,21 @@ const T7_5 = defineProductTest({ const result: RunResult = await runProduct(product, { cwd: workspace.root, argv: ["ids", "--json"], - }).catch((error: unknown) => + }).catch((error: unknown) => { // Module header: a run that fails to complete — the staged symlink // cycle hanging discovery until the subprocess driver kills it — - // is the tested defect, diagnosed here (SPEC 7; H-8). - fail( + // is the tested defect, diagnosed here (SPEC 7; H-8). An exhausted + // capture limit is never converted: it propagates as the harness + // error it is (H-11). + rethrowOutputOverflow(error); + return fail( `${context}: discovery must terminate without following ` + `symbolic links — in particular, the staged symlink cycle ` + `(cyc/self -> .) must not hang it (SPEC 7); the invocation ` + `did not complete: ` + (error instanceof Error ? error.message : String(error)), - ), - ); + ); + }); assertExitCode( result, 0, @@ -601,9 +1119,9 @@ const T7_5 = defineProductTest({ // Exclusion arms (SPEC 13.4: derived files are never sources — paths whose // file name contains `.xspec.`, files under `.xspec/`, and files at the -// configured Markdown emit destinations are excluded from every group), all -// staged over spec groups (the CERTIFICATIONS.md CONF-DISC staging -// constraint; `code` appears in this test only as the empty map): +// configured Markdown emit destinations are excluded from every spec and +// code group), staged on both group sides (the CERTIFICATIONS.md CONF-DISC +// staging constraint). The spec side, observed through `ids`: // // specs/A.mdx the one real source (id `a`) // specs/A.md user-authored file at A.mdx's emit destination — @@ -636,21 +1154,246 @@ const EXCLUSION_EXPECTED: readonly ListingEntry[] = [ { file: "specs/A.mdx", ids: ["a"] }, ]; +// The code side, observed through `query edges --from <path>` (SPEC 11.1; +// module header). The spec group `specs/*.mdx` matches specs/A.mdx alone; +// the code group's globs match: +// +// src/plain.ts the one code source: well-formed TypeScript spelling +// no marker, no spec-module import, no `text` call +// (4) — discovered, its whole-file code location (4.6) +// a graph node with no edges +// specs/A.xspec.ts the module `build` generates beside specs/A.mdx +// (13.1), matched by `specs/*.ts` — a file the product +// itself wrote under an everyday code glob: excluded +// .xspec/staged.ts a staged `.ts` file under `.xspec/`, matched by +// `.xspec/*.ts` (the dot segment spelled literally): +// excluded +// specs/A.md specs/A.mdx's enabled Markdown emit destination +// (7.3) — user-authored before `build`, emitted by it +// — matched by `specs/*.md`: excluded +// +// and no spec-group file: `specs/*.md` does not match `specs/A.mdx`, so +// 14.14's both-groups rule stays dormant; `specs/*.ts` also matches +// whatever `.xspec.`-named companions the module has (13.1), derived like +// the module. No pattern carries a bracket or brace character and nothing +// is a symbolic link (the VIOL-DISC-DIALECT and VIOL-DISC-SYMLINK staging +// constraints). Each excluded path, in no configured group, is unknown to +// `--from` — the usage error of 12.0, judged after configuration loading +// and before the 13.3 gate: exit 2 with the 12.7 error document, never a +// finding, nothing modified — beside the discovered source's exit-0 control +// showing the group live. The arm's configuration and its two staged `.ts` +// files are staged-source records (module header): T7-6 stages them after +// its first product invocation. +const CODE_EXCLUSION_CONFIG = stagedTs( + "T7-6 xspec.config.ts (code-group exclusion)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + code: { + impl: ["src/**/*.ts", "specs/*.ts", ".xspec/*.ts", "specs/*.md"] + }, + markdown: { emit: true } +}) +`, +); + +/** The staged code source: well-formed, no marker, spec import, or text call. */ +const PLAIN_TS = stagedTs( + "T7-6 src/plain.ts (code-group exclusion: the discovered code source)", + "export const plain = 1;\n", +); + +/** A well-formed `.ts` file staged under `.xspec/` — derived by path alone. */ +const STAGED_UNDER_XSPEC_TS = stagedTs( + "T7-6 .xspec/staged.ts (code-group exclusion: a staged file under .xspec/)", + "export const staged = 2;\n", +); + +/** The excluded paths the code globs match: each arm's premise and rule. */ +const CODE_EXCLUDED: readonly { + readonly path: string; + readonly premise: string; + readonly why: string; +}[] = [ + { + path: "specs/A.xspec.ts", + premise: + "NAME.mdx generates NAME.xspec.ts in the source file's directory " + + "(SPEC 13.1)", + why: + "the module `build` generated beside specs/A.mdx (13.1), matched by " + + "specs/*.ts", + }, + { + path: ".xspec/staged.ts", + premise: + "the staged file under .xspec/ is a derived path of nothing and a " + + "recorded derived file of nothing, so `build` neither replaces nor " + + "removes it (SPEC 12.1, 13.4)", + why: "a file under .xspec/, matched by .xspec/*.ts", + }, + { + path: "specs/A.md", + premise: + "with emission enabled, `build` emits specs/A.mdx's Markdown at " + + "specs/A.md (SPEC 13.2)", + why: + "specs/A.mdx's enabled Markdown emit destination (7.3), matched by " + + "specs/*.md", + }, +]; + +// The invalid-source arm and its control (SPEC 13.4, 13.1, 7.3, 7.1, 14.19, +// 14.20, 12.2): an invalid source's emit destination is derived too — +// per-source derived paths follow the `NAME.mdx` name shape alone (13.1, +// 7.3), whatever the source's validity. Staged as T7-6 pins it: emission +// next to sources, the spec glob `specs/*.mdx`, a code group globbing +// `specs/*.md`, and +// +// specs/a'b.mdx holding `<S id="a">A</S>` — a spec-group file whose +// path holds `'`, which 7.1 bars (14.19; T7.1-1) +// specs/a'b.md a plain file holding `)`, no well-formed TypeScript +// (14.20) — specs/a'b.mdx's configured emit destination +// while emission is enabled +// +// `specs/*.md` does not match specs/a'b.mdx, so no file lies in both kinds +// of group (7.2's 14.14 stays dormant). With emission enabled the +// destination is a derived file (13.4), so the code group discovers nothing +// and `check` reports the condition-19 finding alone — a product deriving +// emit destinations for valid sources alone discovers specs/a'b.md as a +// code source and reports its condition 20. The control flips `emit` to +// `false`, the one bit (either spelling of disabled emission 7.3 admits +// would do; `emit: false` also catches a product classifying by the mere +// presence of `markdown`): the path is then no emit destination, the code +// group discovers it, and `check` reports its condition-20 finding beside +// the condition-19 one — showing the code glob reaches the path, so the +// arm's non-discovery is no never-matched pass. +// +// Each workspace is staged from scratch and observed by `check` alone: it +// fails `build`'s validations and no `build` runs on it, so no record exists +// (13.3) and `check` reports exactly `build`'s validation findings (12.2) — +// 14.10's mismatch forms are undetectable on a failing workspace and its +// recorded-file form meets no record (the CERTIFICATIONS.md CONF-DISC +// staging constraint for this arm). Nothing is a symbolic link and no glob +// carries a bracket or brace (the VIOL-DISC-SYMLINK and VIOL-DISC-DIALECT +// staging constraints). +const INVALID_SOURCE_PATH = "specs/a'b.mdx"; +const INVALID_SOURCE_DESTINATION = "specs/a'b.md"; + +/** T7-6's invalid-source configuration, emission enabled or disabled. */ +function invalidSourceConfig(emit: boolean): string { + return `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + code: { + impl: ["specs/*.md"] + }, + markdown: { emit: ${String(emit)} } +}) +`; +} + +// The arm's and the control's configurations, staged-source records +// (module header): T7-6 stages both after its first product invocation. +const INVALID_SOURCE_EMIT_CONFIG = stagedTs( + "T7-6 xspec.config.ts (the invalid-source arm: emission enabled)", + invalidSourceConfig(true), +); +const INVALID_SOURCE_NO_EMIT_CONFIG = stagedTs( + "T7-6 xspec.config.ts (the invalid-source control: emission disabled)", + invalidSourceConfig(false), +); + +/** specs/a'b.mdx's content, exactly as T7-6 spells it. */ +const INVALID_SOURCE = stagedMdx( + "T7-6 specs/a'b.mdx (the invalid-source arm and its control: `'` in a " + + "spec-group path, 7.1)", + '<S id="a">A</S>', +); + +/** specs/a'b.md's content, exactly as T7-6 spells it: no well-formed + * TypeScript (14.20). The control's condition-20 window is spelled from it, + * and its staged-source record below is made from it. */ +const UNPARSEABLE_DESTINATION = ")"; + +/** S-9: specs/a'b.md, a name the default does not reach, is declared + * unparseable TypeScript (14.20) — T7-6's own words — by its staged-source + * record (module header; the record makes the path judged): the arm and its + * control stage it, both after T7-6's first product invocation. */ +const UNPARSEABLE_DESTINATION_SOURCE = stagedTs( + "T7-6 specs/a'b.md (the invalid-source arm and its control: `)`, no " + + "well-formed TypeScript, 14.20)", + UNPARSEABLE_DESTINATION, + "unparseable", +); + // Import arms (SPEC 2.1/7: imports resolve references between files but // never add files to the workspace — the designated file must already be a // discovered source of a configured spec group, else 14.15). const IMPORT_NEG_LINE = 'import U from "../other/unlisted.xspec"'; -const IMPORT_NEG_SOURCE = `${IMPORT_NEG_LINE}\n\n<S id="a">\nAlpha behavior.\n</S>\n`; -const IMPORT_POS_SOURCE = `import B from "./sub/B.xspec"\n\n<S id="a">\nAlpha behavior.\n</S>\n`; +const IMPORT_NEG_SOURCE = stagedMdx( + "T7-6 specs/A.mdx importing the unmatched other/unlisted.mdx", + `${IMPORT_NEG_LINE}\n\n<S id="a">\nAlpha behavior.\n</S>\n`, +); +/** The existing but unmatched file the invalid import designates. */ +const UNLISTED_SOURCE = stagedMdx( + "T7-6 other/unlisted.mdx (existing, matched by no group)", + mdxSection("u"), +); +const IMPORT_POS_SOURCE = stagedMdx( + "T7-6 specs/A.mdx importing the discovered specs/sub/B.mdx", + `import B from "./sub/B.xspec"\n\n<S id="a">\nAlpha behavior.\n</S>\n`, +); + +// The configurations of the import arms, the no-match group, and the empty +// maps, staged-source records (module header): T7-6 stages each after its +// first product invocation. +const IMPORT_NEG_CONFIG = stagedTs( + "T7-6 xspec.config.ts (import of an existing but unmatched file: the " + + "one group main, specs/*.mdx)", + specGroupsConfig({ main: ["specs/*.mdx"] }), +); +const IMPORT_POS_CONFIG = stagedTs( + "T7-6 xspec.config.ts (import of a discovered source: the one group " + + "main, specs/**/*.mdx)", + specGroupsConfig({ main: ["specs/**/*.mdx"] }), +); +const NO_MATCH_GROUP_CONFIG = stagedTs( + "T7-6 xspec.config.ts (the no-match group vacant beside main)", + specGroupsConfig({ + main: ["specs/*.mdx"], + vacant: ["vacant/**/*.mdx"], + }), +); +const EMPTY_MAPS_CONFIG = stagedTs( + "T7-6 xspec.config.ts (empty specs and code maps)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: {}, + code: {} +}) +`, +); const T7_6 = defineProductTest({ id: "T7-6", title: "discovery boundaries: derived files (`.xspec.` names, `.xspec/` " + - "paths, enabled Markdown emit destinations) are never discovered as " + - "sources even when globs match them; an import never adds an unmatched " + + "paths, enabled Markdown emit destinations — an invalid source's " + + "included, derived paths following the NAME.mdx name shape alone) " + + "are never discovered as sources of a spec or a code group even when " + + "globs match them — the code side observed through `query edges " + + "--from`, the invalid source's destination through `check` beside " + + "its emission-disabled control; an import never adds an unmatched " + "file (14.15); a no-match group and empty specs/code maps are valid " + - "with zero sources (SPEC 7, 13.4, 2.1)", + "with zero sources (SPEC 7, 13.4, 13.1, 7.3, 2.1, 11.1, 12.2)", run: async (product) => { // (a) Derived-file exclusion, before and after a `build`. await withWorkspace( @@ -690,6 +1433,216 @@ const T7_6 = defineProductTest({ }, ); + // (a') Code-group exclusion, observed through `query edges --from` + // (11.1): after `build`, the discovered code source answers exit 0 with + // its empty edge enumeration, while each excluded path the code globs + // match is unknown — exit 2 with the 12.7 error document, no finding, + // nothing modified. + await withWorkspace( + { + files: { + "xspec.config.ts": CODE_EXCLUSION_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, + "specs/A.md": "User-authored file at the emit destination.\n", + "src/plain.ts": PLAIN_TS, + ".xspec/staged.ts": STAGED_UNDER_XSPEC_TS, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T7-6 (code-group exclusion): `build` — the workspace passes " + + "build's validations: src/plain.ts is a well-formed code " + + "source, and every other code-glob match is a derived file in " + + "no group (SPEC 7.2, 13.4)", + ); + // Premises: each excluded path exists as a plain file after the + // build — the generated module at its 13.1 path, the emitted + // Markdown at its 13.2 destination, and the staged file under + // .xspec/, which `build` has no derived path to replace and no + // record to remove (12.1, 13.4) — so the refusals below observe the + // exclusion of existing, glob-matched paths, never a merely absent + // one. + for (const excluded of CODE_EXCLUDED) { + const kind = await workspace.kind(excluded.path); + if (kind !== "file") { + fail( + `T7-6 (code-group exclusion): after \`build\`, expected a ` + + `plain file at ${excluded.path} — ${excluded.premise}; ` + + `found ${kind}`, + ); + } + } + const controlLabel = + "T7-6 (code-group exclusion) `query edges --from src/plain.ts`"; + const edges = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--from", "src/plain.ts"], + `${controlLabel} — the discovered code source's whole-file ` + + `location is a graph node, so the query answers (SPEC 7.2, ` + + `4.6, 11.1)`, + ), + controlLabel, + ); + assertEdgeSetEqual( + edges, + [], + `${controlLabel}: the edge enumeration is empty — src/plain.ts ` + + `spells no marker, spec-module import, or text call, so its ` + + `whole-file location sources no edge (SPEC 4.3, 4.5, 4.6, 5.2)`, + ); + for (const excluded of CODE_EXCLUDED) { + const label = + "T7-6 (code-group exclusion) `query edges --from " + + `${excluded.path}\``; + const result = await assertLeavesUnchanged( + workspace.root, + () => + expectExit( + product, + workspace, + ["query", "edges", "--from", excluded.path], + 2, + `${label} — ${excluded.why}: a derived file is in no ` + + `spec or code group (SPEC 13.4), so the path is unknown ` + + `to --from, a usage error judged after configuration ` + + `loading and before the 13.3 gate — never a finding ` + + `(SPEC 11.1, 12.0)`, + ), + `${label}: a refused query modifies nothing — the argument ` + + `check precedes the gate, and a built workspace's graph data ` + + `already matches its sources (SPEC 12.0, 13.3)`, + ); + expectErrorDocument( + result, + `${label} — query's single JSON document is its only output ` + + `form, so JSON output is in effect without --json and the ` + + `exit-2 error document is the entire stdout (SPEC 11, 12.0, ` + + `12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${label}: the usage error must be a standard-error ` + + `diagnostic (SPEC 12.0); stderr is empty — ` + + `${summarizeResult(result)}`, + ); + } + } + }, + ); + + // (a'') The invalid-source arm (fixture comment above): emission + // enabled, so specs/a'b.md is the invalid specs/a'b.mdx's emit + // destination — derived, in no group though the code glob matches it — + // and `check` reports exactly one finding, specs/a'b.mdx's condition 19. + await withWorkspace( + { + files: { + "xspec.config.ts": INVALID_SOURCE_EMIT_CONFIG, + [INVALID_SOURCE_PATH]: INVALID_SOURCE, + [INVALID_SOURCE_DESTINATION]: UNPARSEABLE_DESTINATION_SOURCE, + }, + }, + async (workspace) => { + const context = + "T7-6 (invalid-source arm: emission enabled, specs/a'b.md the " + + "invalid specs/a'b.mdx's emit destination) `check --json`"; + const findings = await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + `${context} — the workspace fails build's validations (14.19), ` + + `so check exits 1 with its findings report (SPEC 12.2, 12.0)`, + ); + const destinationParsed = findings.some( + (finding) => + finding.condition === "14.20" && + finding.locations.some( + (location) => location.file === INVALID_SOURCE_DESTINATION, + ), + ); + if (destinationParsed) { + fail( + `${context}: reported a condition-20 finding for ` + + `${INVALID_SOURCE_DESTINATION} — with emission enabled it is ` + + `the emit destination of ${INVALID_SOURCE_PATH} whatever that ` + + `source's validity (per-source derived paths follow the ` + + `NAME.mdx name shape alone, SPEC 13.1, 7.3), so 13.4 excludes ` + + `it from the code group and its \`)\` is never parsed; a ` + + `product deriving emit destinations for valid sources alone ` + + `discovers it as a code source`, + ); + } + assertConditionCounts( + findings, + { "14.19": 1 }, + `${context}: exactly one finding, ${INVALID_SOURCE_PATH}'s ` + + `condition 19 — \`'\` in a spec-group path (SPEC 7.1, 13.4, ` + + `14.19)`, + ); + assertFindingConcernsPath( + findings[0]!, + INVALID_SOURCE_PATH, + `${context}: the condition-19 finding`, + ); + }, + ); + + // (a''') Its control: emission disabled (`emit: false`), so specs/a'b.md + // is no emit destination (7.3) — the code group discovers it, and + // `check` reports its condition-20 finding beside specs/a'b.mdx's + // condition 19. + await withWorkspace( + { + files: { + "xspec.config.ts": INVALID_SOURCE_NO_EMIT_CONFIG, + [INVALID_SOURCE_PATH]: INVALID_SOURCE, + [INVALID_SOURCE_DESTINATION]: UNPARSEABLE_DESTINATION_SOURCE, + }, + }, + async (workspace) => { + const context = + "T7-6 (invalid-source control: emission disabled, specs/a'b.md " + + "no emit destination) `check --json`"; + const findings = await runFindingsReport( + product, + workspace, + ["check", "--json"], + 1, + `${context} — the workspace fails build's validations (14.19, ` + + `14.20), so check exits 1 with its findings report (SPEC 12.2, ` + + `12.0)`, + ); + assertConditionCounts( + findings, + { "14.19": 1, "14.20": 1 }, + `${context}: ${INVALID_SOURCE_PATH}'s condition 19 and, beside ` + + `it, ${INVALID_SOURCE_DESTINATION}'s condition 20 — with ` + + `emission disabled the path is no emit destination (7.3), so ` + + `the code glob discovers it as a code source whose \`)\` is no ` + + `well-formed TypeScript (SPEC 7.2, 14.19, 14.20)`, + ); + assertFindingConcernsPath( + findings.find((finding) => finding.condition === "14.19")!, + INVALID_SOURCE_PATH, + `${context}: the condition-19 finding`, + ); + assertFindingLocated( + findings.find((finding) => finding.condition === "14.20")!, + { + file: INVALID_SOURCE_DESTINATION, + window: byteWindow("", UNPARSEABLE_DESTINATION), + }, + `${context}: the condition-20 finding locates the parse failure ` + + `in ${INVALID_SOURCE_DESTINATION} (SPEC 14, 14.20)`, + ); + }, + ); + // (b) An import never adds an unmatched file: other/unlisted.mdx exists // on disk and the specifier resolves to it against the importing file's // directory (2.1), but no group matches it — so the import is invalid @@ -699,9 +1652,9 @@ const T7_6 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig({ main: ["specs/*.mdx"] }), + "xspec.config.ts": IMPORT_NEG_CONFIG, "specs/A.mdx": IMPORT_NEG_SOURCE, - "other/unlisted.mdx": mdxSection("u"), + "other/unlisted.mdx": UNLISTED_SOURCE, }, }, async (workspace) => { @@ -724,9 +1677,9 @@ const T7_6 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig({ main: ["specs/**/*.mdx"] }), + "xspec.config.ts": IMPORT_POS_CONFIG, "specs/A.mdx": IMPORT_POS_SOURCE, - "specs/sub/B.mdx": mdxSection("b"), + "specs/sub/B.mdx": SECTION_B_SOURCE, }, }, async (workspace) => { @@ -754,11 +1707,8 @@ const T7_6 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": specGroupsConfig({ - main: ["specs/*.mdx"], - vacant: ["vacant/**/*.mdx"], - }), - "specs/A.mdx": mdxSection("a"), + "xspec.config.ts": NO_MATCH_GROUP_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, }, }, async (workspace) => { @@ -783,14 +1733,8 @@ const T7_6 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": `import { defineConfig } from "xspec" - -export default defineConfig({ - specs: {}, - code: {} -}) -`, - "notes/N.mdx": mdxSection("n"), + "xspec.config.ts": EMPTY_MAPS_CONFIG, + "notes/N.mdx": SECTION_N_SOURCE, }, }, async (workspace) => { diff --git a/test/suite/registry/section-7.1-7.3.ts b/test/suite/registry/section-7.1-7.3.ts index 1ce56993..a5bcdec2 100644 --- a/test/suite/registry/section-7.1-7.3.ts +++ b/test/suite/registry/section-7.1-7.3.ts @@ -9,22 +9,58 @@ // and rejects a product only via diagnosed assertion failures (H-8). // // SPEC 7.1: spec groups are named glob lists; a file MAY belong to multiple -// groups; every matched file MUST have the `.mdx` extension, any other match -// being invalid (14.19). SPEC 7.2: code groups serve as coverage boundaries -// and as the impacted-code population; a file matched by both a spec and a -// code group is a configuration error (14.14). SPEC 7.3: `markdown` absent → -// no emission; when present, `emit` (boolean) is REQUIRED and controls -// emission; `outDir` redirects emitted files preserving workspace-relative -// paths, resolves against the workspace root, and MUST resolve within it -// (else 14.14); the configured emit destinations exist exactly while emission -// is enabled — with `emit: true` they are the destination paths whether or -// not emission has yet run, with `markdown` absent or `emit: false` no path -// is a destination, so the 13.4 exclusion and the import rule of 4 have no -// Markdown component. +// groups; every matched file MUST have the `.mdx` extension, and its +// workspace-relative path MUST NOT contain U+0022, U+0027, U+005C (the +// backslash), U+000A, U+000D, U+2028, or U+2029, any other match being +// invalid (14.19) — a bar binding spec groups alone, 14.19's code-source +// forms being `#`, U+FFFD, and non-UTF-8. SPEC 7.2: code groups serve as +// coverage boundaries and as the impacted-code population; a file matched +// by both a spec and a code group is a configuration error (14.14). SPEC +// 7.3: `markdown` absent → no emission; when present, `emit` (boolean) is +// REQUIRED and controls emission; `outDir` redirects emitted files +// preserving workspace-relative paths and is a directory path relative to +// the workspace root spelled as one or more non-empty `/`-separated +// segments, none `.` or `..` — the form of every workspace-relative path +// (1.5, 7) — so that each emit destination (`outDir` joined by `/` to the +// default workspace-relative path) is itself a plain workspace-relative +// path; any other spelling — empty, beginning with `/`, or carrying a `.`, +// `..`, or empty segment — is a configuration error (14.14), decided by +// spelling alone, never by where the path would resolve; so is an `outDir` +// of `.xspec` or beginning with `.xspec/` — no emit destination lies in the +// graph-data area (13.3), so graph data is the only derived file under it +// (13.1, 13.4; 11.6's claim); the configured emit destinations exist +// exactly while emission is enabled — with `emit: true` they are the +// destination paths whether or not emission has yet run, with `markdown` +// absent or `emit: false` no path is a destination, so the 13.4 exclusion +// and the import rule of 4 have no Markdown component. // // 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.3-1's own refusal arms (the local +// `expectConfigRefused`) further pin the document's `path` to exactly +// `xspec.config.ts` — the configuration file the upward search found, in +// the anchoring form of 11.6 relative to the invocation working directory, +// the workspace root — and `locations` to `[]` (a configuration condition +// carries the file it concerns, never a source range: SPEC 14, 12.7), and +// compare the workspace around the refused `build`: a build failing at +// configuration load modifies nothing (SPEC 12.1, 12.0). +// - T7.3-1 `outDir` validity is decided by spelling (SPEC 7.3): one arm per +// pinned spelling — `""`, `"/out"`, `"./out"`, `"out/../x"`, `"out//x"`, +// `"out/"` — each 14.14, beside the two `..`-bearing spellings the arm +// always drove (`"../out"`, `"docs/../../out"`), while `"out/sub"` is the +// pinned valid multi-segment spelling, asserted through the emission it +// redirects. No arm depends on where a spelling would resolve: a product +// normalizing `./out`, `out//x`, or `out/` to a path inside the root, or +// reading `""` as "next to each source", fails its arm at the exit code. +// The graph-data area is refused by segment, one arm each: `".xspec"` +// and `".xspec/md"` are 14.14 through the same refusal contract, beside +// their look-alike controls `".xspec2"` and `".xspecs/md"`, each valid +// and asserted through the emission it redirects — its absent directory +// chain created as real directories (T13.4-8) — so a product testing the +// byte prefix `.xspec` without its segment boundary fails a control at +// the exit code, and one accepting the area fails its refused arm there. // - 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 @@ -34,6 +70,23 @@ // - T7.1-1 policy findings are compared as sorted "rule :: kind: from -> to" // renderings: SPEC 7.5 fixes the information (rule name + offending edge), // not an order, and one finding per (rule, edge) pair. +// - T7.1-1 path characters (SPEC 7.1, 14.19): one arm per character 7.1 +// bars in a spec-group file name (`specs/a<c>b.mdx`) plus U+0027 in a +// directory component (`specs/it's/a.mdx`), each file alone in its own +// workspace with condition-free content, so its one 14.19 finding — the +// stable code, `locations` empty, the path as concerned path — is the +// build's exact multiset. "Still discovered and reachable as T11.2-3's +// invalid-path files are" is asserted through `view --file <glob>` +// (T11.5-3's reading of "glob-reached"): exit 1, the file's finding +// accompanying, one view whose tree keeps its ranges and raw attribute +// entries with every identity, root included, unavailable (T11.2-3's +// projection). The U+0022, backslash, U+000A, and U+000D arms — names +// other filesystems cannot hold — are staged on the Linux leg alone +// (`process.platform === "linux"`, T1.5-2's precedent), gated in the +// body; so is the code-source control `src/it's<U+005C>x.ts`, its name +// holding a backslash, asserted finding-free under `build --json` and +// `check --json` and through its workspace's exact `query edges` set. +// Every barred character is built from its code point. // - T7.3-1 emitted Markdown is byte-asserted (SPEC 3 fixes the compiled // bytes; H-4); the compilation semantics themselves are T3-*'s subject — // fixture sources are single-section files with trivially known output. @@ -52,6 +105,39 @@ // 14.15 finding (`emit: true` — the specifier designates a configured // destination) and a clean build (`emit: false` — no path is a // destination, so the ordinary import is outside xspec's validations). +// - Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): every MDX source a body stages in a workspace +// created after its first product invocation — `expectConfigRefused`'s +// one staging site, T7.1-1's non-`.mdx`-match (its `specs/notes.txt` +// included: a spec-group file not named `.mdx` is an MDX source all the +// same, invalid by its name yet judged by 14.20, and a record at that +// path declares it a well-formed one), path-character (one record staged +// at every barred path), and code-source-control workspaces, T7.3-1's +// `EMISSION_FILES` (its first workspace's too, the map being shared) and +// destination workspaces — is a ledger record, judged by +// test/self/s9-staged-sources.test.ts before any product exists: the +// minimal `a` and `b` sources are section-7-basics.ts's shared records, +// staged byte-identically by the three §7 modules. T7.1-1's two-group +// workspace and T7.2-1's overlap workspace, each its body's first, +// precede any invocation and stay plain. +// - TypeScript staged-source records (TEST-SPEC S-9's TypeScript and +// timing clauses; helpers/staged-ts.ts): every configuration file and +// code source a body stages in a workspace created after its first +// product invocation — `expectConfigRefused`'s one staging site (its +// arm tables' rows each a record made at module load), T7.1-1's +// non-`.mdx`-match and path-character configurations and its +// code-source control's configuration and code source (the record +// staged at `src/it's<U+005C>x.ts`), T7.3-1's emission-matrix variants +// (the first's too, the table being one), outDir (its graph-data-area +// look-alikes' included), destination, and configuration-alone +// workspaces — is a ledger record carrying its S-9 +// declaration, judged by test/self/s9-staged-sources.test.ts before any +// product exists: every one well-formed, T7.3-1's destination code +// source at `specs/A.md` included (its record makes the path judged, a +// name the default does not reach). The same two first workspaces stay +// plain; `specs/A.md`'s user-authored text in T7.3-1's +// configuration-alone arm lies in a spec group, no code source, and +// stays a string. import { Buffer } from "node:buffer"; import type { @@ -60,26 +146,41 @@ import type { Finding, GraphEdge, IdsFileEntry, + PathValue, + SourceRange, + ViewAttributeEntry, + ViewNode, } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, decodeEdgesReport, decodeFindingsReport, decodeIdsReport, + decodeViewReport, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, + assertExitCode, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { + assertSnapshotsEqual, + snapshotDirectory, +} from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; +import { SECTION_A_SOURCE, SECTION_B_SOURCE } from "./section-7-basics.js"; import { assertConditionCounts, assertEdgeSetEqual, @@ -89,7 +190,10 @@ import { buildOk, byteWindow, expectConfigurationError, + expectErrorDocument, expectExit, + expectFindingFreeReport, + runCli, runJson, } from "./support.js"; @@ -149,7 +253,9 @@ async function expectIdsListing( } /** - * Stage a workspace whose only defect is the given configuration text and + * Stage a workspace whose only defect is the given configuration — a + * staged-source record, well-formed (module header), since T7.3-1 stages + * every arm after its first product invocation (S-9's timing clause) — and * assert `build --json` refuses it per 14.14. The staged source is valid and * matched by every fixture configuration's spec glob, so a product that * wrongly accepts the configuration proceeds to a successful build (exit 0) @@ -157,18 +263,51 @@ async function expectIdsListing( */ async function expectConfigRefused( product: ProductBinding, - config: string, + config: StagedTs, context: string, ): Promise<void> { await withWorkspace( { files: { "xspec.config.ts": config, - "specs/A.mdx": mdxSection("a"), + "specs/A.mdx": SECTION_A_SOURCE, }, }, async (workspace) => { - await expectConfigurationError(product, workspace, ["build"], context); + const before = await snapshotDirectory(workspace.root); + const result = await expectConfigurationError( + product, + workspace, + ["build"], + context, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: "xspec.config.ts", + locations: [], + }, + `${context}: the error document's one finding carries the stable ` + + `code "configuration-error", locations [] (a configuration ` + + `condition carries the file it concerns, never a source range), ` + + `and as its concerned path the configuration file the upward ` + + `search found, in the anchoring form of 11.6 relative to the ` + + `invocation working directory — the workspace root, so exactly ` + + `"xspec.config.ts" (SPEC 14, 12.7, 11.6)`, + ); + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `${context}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — no derived file or graph data ` + + `appears anywhere under the root`, + ); }, ); } @@ -316,25 +455,411 @@ 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(); +} + +// The non-`.mdx`-match workspace's configuration: the one spec group's glob +// `specs/*` matches `specs/notes.txt`. A staged-source record (module +// header): T7.1-1 stages it after its first product invocation. +const NON_MDX_MATCH_CONFIG = stagedTs( + "T7.1-1 xspec.config.ts (the spec-group glob specs/* matching " + + "specs/notes.txt)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*"] + } +}) +`, +); + +// The non-`.mdx`-match workspace's spec-group file not named `.mdx`: an MDX +// source all the same — invalid by its name (SPEC 7.1, 14.19), its content +// still judged by 14.20 (11.2 keeps its parse-local structure) — whose +// content is well-formed, so the invalid path is the workspace's only +// condition and the arm's exact `{"14.19": 1}` has teeth. T7.1-1 stages it +// after its first product invocation, so it is a staged-source record +// (module header; S-9's before-any-product clause): a record at a path not +// named `.mdx` declares that path a well-formed MDX source for its own +// write, so the ledger self-test judges it before any product exists. +const NON_MDX_MATCH_NOTES = stagedMdx( + "T7.1-1 specs/notes.txt (the spec-group match without .mdx, its content " + + "well-formed so the 14.19 is the workspace's only condition)", + mdxSection("n"), +); + +// --- T7.1-1's path-character arms (SPEC 7.1, 14.19) -------------------------- +// +// SPEC 7.1 bars U+0022, U+0027, U+005C (the backslash), U+000A, U+000D, +// U+2028, and U+2029 from a spec-group file's workspace-relative path +// (14.19): one arm per character in the file name (`specs/a<c>b.mdx`) plus +// one with U+0027 in a directory component (`specs/it's/a.mdx`), each file +// alone in its own workspace (module header). Every barred character is +// built from its code point, never from an escape spelling. + +/** Whether the Linux-leg arms are staged (module-header note). */ +const LINUX_LEG = process.platform === "linux"; + +const APOSTROPHE = String.fromCodePoint(0x27); +const BACKSLASH = String.fromCodePoint(0x5c); + +/** One path-character arm: a barred spec-group file, alone in its workspace. */ +interface PathCharacterArm { + /** The barred character and where it stands, for diagnoses. */ + readonly what: string; + /** The spec-group file's workspace-relative path. */ + readonly path: string; + /** The `view --file` glob reaching exactly that file (SPEC 7's globs). */ + readonly glob: string; + /** Staged on the Linux leg alone: a name other filesystems cannot hold. */ + readonly linuxLeg: boolean; +} + +function fileNameArm( + codePoint: number, + name: string, + linuxLeg: boolean, +): PathCharacterArm { + return { + what: `${name} in the file name`, + path: `specs/a${String.fromCodePoint(codePoint)}b.mdx`, + glob: "specs/a*b.mdx", + linuxLeg, + }; +} + +const PATH_CHARACTER_ARMS: readonly PathCharacterArm[] = [ + fileNameArm(0x22, "U+0022 QUOTATION MARK", true), + fileNameArm(0x27, "U+0027 APOSTROPHE", false), + fileNameArm(0x5c, "U+005C REVERSE SOLIDUS (the backslash)", true), + fileNameArm(0x0a, "U+000A LINE FEED", true), + fileNameArm(0x0d, "U+000D CARRIAGE RETURN", true), + fileNameArm(0x2028, "U+2028 LINE SEPARATOR", false), + fileNameArm(0x2029, "U+2029 PARAGRAPH SEPARATOR", false), + { + what: "U+0027 APOSTROPHE in a directory component", + path: `specs/it${APOSTROPHE}s/a.mdx`, + glob: "specs/*/a.mdx", + linuxLeg: false, + }, +]; + +// The arms' configuration: one spec group whose glob reaches every arm's +// file, the directory component included. A staged-source record (module +// header): T7.1-1 stages it after its first product invocation. +const PATH_CHARACTER_CONFIG = stagedTs( + "T7.1-1 xspec.config.ts (the path-character arms: the spec-group glob " + + "specs/**/*.mdx)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`, +); + +/** + * Running byte-offset composer (the T5.7-2 discipline): `add` appends a + * segment and returns its byte range, `attr` an attribute segment as its + * expected raw view entry (SPEC 11.4: the entry's text is the attribute's + * own characters), so every expected offset is composed from the parts the + * staged file is made of. + */ +class SourceComposer { + 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 arms' one spec source, staged at each barred path: a section with a +// nested child, so "every identity unavailable" reaches the root, a +// top-level section, and a nested one. Its content is condition-free — the +// path is each arm's only defect, so the exact 14.19 count has teeth. +const PATH_ARM = new SourceComposer(); +const PATH_ARM_OUTER_START = PATH_ARM.pos; +PATH_ARM.add("<S "); +const PATH_ARM_OUTER_ID = PATH_ARM.attr("id", 'id="p"'); +PATH_ARM.add(">\nText for p.\n\n"); +const PATH_ARM_INNER_START = PATH_ARM.pos; +PATH_ARM.add("<S "); +const PATH_ARM_INNER_ID = PATH_ARM.attr("id", 'id="p.kid"'); +PATH_ARM.add(">\nText for p.kid.\n</S>"); +const PATH_ARM_INNER_RANGE: SourceRange = { + start: PATH_ARM_INNER_START, + end: PATH_ARM.pos, +}; +PATH_ARM.add("\n</S>"); +const PATH_ARM_OUTER_RANGE: SourceRange = { + start: PATH_ARM_OUTER_START, + end: PATH_ARM.pos, +}; +PATH_ARM.add("\n"); +const PATH_ARM_SOURCE = stagedMdx( + "T7.1-1 the path-character arms' spec source (staged at each barred path)", + PATH_ARM.source, +); + +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + +/** + * The tree projection T11.2-3 pins: 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. + */ +interface TreeExpectation { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly attributes: readonly ViewAttributeEntry[]; + readonly children: readonly TreeExpectation[]; +} + +function projectTree(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(projectTree), + }; +} + +// The arm source's full positional tree, every identity unavailable. +const PATH_ARM_TREE: TreeExpectation = { + identity: UNAVAILABLE, + range: { start: 0, end: PATH_ARM.pos }, + attributes: [], + children: [ + { + identity: UNAVAILABLE, + range: PATH_ARM_OUTER_RANGE, + attributes: [PATH_ARM_OUTER_ID], + children: [ + { + identity: UNAVAILABLE, + range: PATH_ARM_INNER_RANGE, + attributes: [PATH_ARM_INNER_ID], + children: [], + }, + ], + }, + ], +}; + +/** + * The asserted projection of a 14.19 finding (T11.2-3's): 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, + }; +} + +function invalidPathFinding(path: string): PathFindingExpectation { + return { code: "invalid-source-path", locations: [], path }; +} + +/** + * One path-character arm (module header): `build --json` reports exactly + * the file's condition-19 finding, concerning its path, and the + * glob-reached `view` serves its tree with every identity unavailable, the + * finding accompanying — the file still discovered and reachable as + * T11.2-3's invalid-path files are (SPEC 7.1, 14.19, 11.2, 11.4). + */ +async function runPathCharacterArm( + product: ProductBinding, + arm: PathCharacterArm, +): Promise<void> { + const label = `${JSON.stringify(arm.path)} (${arm.what})`; + const expected19 = [invalidPathFinding(arm.path)]; + await withWorkspace( + { + files: { + "xspec.config.ts": PATH_CHARACTER_CONFIG, + [arm.path]: PATH_ARM_SOURCE, + }, + }, + async (workspace) => { + const buildContext = `T7.1-1 \`build --json\` with the spec-group file ${label}`; + const findings = await buildFindings(product, workspace, buildContext); + assertConditionCounts(findings, { "14.19": 1 }, buildContext); + assertSameJson( + findings.map(projectPathFinding), + expected19, + `${buildContext} — the file is discovered and its path is invalid: ` + + `one condition-19 finding with the stable code ` + + `"invalid-source-path", no in-source locations, and the file's ` + + `workspace-relative path as its concerned path (SPEC 7.1, 14.19, ` + + `14, 12.7)`, + ); + + const viewContext = + `T7.1-1 \`view --file ${arm.glob}\` (the glob-reached view) over ` + + `the spec-group file ${label}`; + const viewResult = await runCli(product, workspace, [ + "view", + "--file", + arm.glob, + ]); + 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 report = decodeViewReport( + parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + assertSameJson( + report.findings.map(projectPathFinding), + expected19, + `${viewContext} — the file's condition-19 finding accompanies the ` + + `answer whose consulted domain includes it (SPEC 11.2, 14.19)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [arm.path], + `${viewContext} — the glob admits the discovered file: one ` + + `per-file view, its \`file\` the workspace-relative path (SPEC ` + + `11.4, 7)`, + ); + const view = report.views[0]!; + assertSameJson( + projectTree(view.root), + PATH_ARM_TREE, + `${viewContext} — the 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, 11.4, 1.5)`, + ); + assertSameJson( + [view.imports, view.occurrences, view.comments], + [[], [], []], + `${viewContext} — the file holds no imports, occurrences, or ` + + `comments: empty arrays, never null (SPEC 11.4, 12.7)`, + ); + }, + ); } +// --- T7.1-1's code-source control (SPEC 7.1, 14.19) -------------------------- +// +// 7.1's bar binds spec groups alone (14.19's code-source forms are `#`, +// U+FFFD, and non-UTF-8), so a code-group file whose path holds U+0027 and +// the backslash — `src/it's<U+005C>x.ts`, one file name: the backslash is +// no separator of a workspace-relative path (1.5) — is valid: `build` and +// `check` exit 0, and its top-level marker records its `references` edge +// from that whole-file location (4.5, 4.6), discriminating a product that +// applies the bar to every source. Staged on the Linux leg (module header): +// the name holds a backslash. +const CODE_PATH_CONTROL_FILE = `src/it${APOSTROPHE}s${BACKSLASH}x.ts`; + +// The control's configuration and code source: staged-source records +// (module header), T7.1-1 staging them after its first product invocation. +const CODE_PATH_CONTROL_CONFIG = stagedTs( + "T7.1-1 xspec.config.ts (the code-source path control: spec glob " + + "specs/*.mdx, code glob src/*.ts)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + code: { + app: ["src/*.ts"] + } +}) +`, +); +const CODE_PATH_CONTROL_SOURCE = stagedTs( + "T7.1-1 the code-source path control's code source (staged at " + + "src/it's<U+005C>x.ts: a top-level marker of specs/A.mdx#a)", + `import SPEC from "../specs/A.xspec" + +SPEC.a +`, +); + +// The control workspace's complete edge set (SPEC 5.1, 5.2): the source's +// containment edge and the marker's references edge, sourced at the code +// file's whole-file location — its path, the identity of a valid path. +const CODE_PATH_CONTROL_EDGES: readonly GraphEdge[] = [ + { from: "specs/A.mdx", to: "specs/A.mdx#a", kind: "contains" }, + { from: CODE_PATH_CONTROL_FILE, to: "specs/A.mdx#a", kind: "references" }, +]; + const T7_1_1 = defineProductTest({ id: "T7.1-1", title: "spec groups: a file in two spec groups is valid, listed once, and " + "coverage and policy see it in both groups; a spec-group match without " + - "`.mdx` is invalid (SPEC 7.1, 8, 7.5, 14.19)", + "`.mdx` is invalid; a spec-group file whose path holds U+0022, U+0027, " + + "U+005C, U+000A, U+000D, U+2028, or U+2029 — one arm per character in " + + "the file name, plus U+0027 in a directory component; the U+0022, " + + "U+005C, U+000A, and U+000D arms on the Linux leg — is invalid (14.19), " + + "still discovered and reachable: its finding concerns its path and a " + + "glob-reached `view` serves its tree with every identity unavailable; " + + "control (Linux leg): the code-group file `src/it's<U+005C>x.ts` is " + + "valid, `build` and `check` exiting 0 and its marker recording its edge " + + "from the whole-file location (SPEC 7.1, 8, 7.5, 14.19, 11.2, 11.4)", run: async (product) => { // A file in two spec groups is valid — and coverage/policy see it in // both. @@ -410,23 +935,17 @@ const T7_1_1 = defineProductTest({ }); // A spec-group match without `.mdx` → 14.19. The offending file's - // content is itself well-formed, so the invalid path is the workspace's + // content is itself well-formed (its record declares it so, and S-9's + // ledger self-test judges it), so the invalid path is the workspace's // only condition (the exact-count assertion has teeth) — and a product // that wrongly accepts the match builds cleanly and fails the exit-code // assertion. await withWorkspace( { files: { - "xspec.config.ts": `import { defineConfig } from "xspec" - -export default defineConfig({ - specs: { - main: ["specs/*"] - } -}) -`, - "specs/A.mdx": mdxSection("a"), - "specs/notes.txt": mdxSection("n"), + "xspec.config.ts": NON_MDX_MATCH_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, + "specs/notes.txt": NON_MDX_MATCH_NOTES, }, }, async (workspace) => { @@ -436,16 +955,73 @@ 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)})`, ); } }, ); + + // Path characters (SPEC 7.1, 14.19): one arm per barred character in + // the file name plus U+0027 in a directory component, each file alone + // in its own workspace; the U+0022, backslash, U+000A, and U+000D arms + // on the Linux leg alone (module header). + for (const arm of PATH_CHARACTER_ARMS) { + if (arm.linuxLeg && !LINUX_LEG) continue; + await runPathCharacterArm(product, arm); + } + + // Control (Linux leg: the name holds a backslash): the code-group file + // `src/it's<U+005C>x.ts` is valid — 7.1's bar binds spec groups alone — + // so `build` and `check` are finding-free and its top-level marker + // records its edge from the whole-file location. + if (LINUX_LEG) { + await withWorkspace( + { + files: { + "xspec.config.ts": CODE_PATH_CONTROL_CONFIG, + "specs/A.mdx": SECTION_A_SOURCE, + [CODE_PATH_CONTROL_FILE]: CODE_PATH_CONTROL_SOURCE, + }, + }, + async (workspace) => { + const label = + `the code-group file ${JSON.stringify(CODE_PATH_CONTROL_FILE)} ` + + `(U+0027 and the backslash in a code source's path)`; + await expectFindingFreeReport( + product, + workspace, + ["build", "--json"], + `T7.1-1 \`build --json\` with ${label} — valid: 7.1's bar ` + + `binds spec groups alone, 14.19's code-source forms being ` + + `\`#\`, U+FFFD, and non-UTF-8 (SPEC 7.1, 14.19)`, + ); + await expectFindingFreeReport( + product, + workspace, + ["check", "--json"], + `T7.1-1 \`check --json\` with ${label}, after the build — ` + + `valid and current (SPEC 7.1, 14.19, 12.2)`, + ); + const edgesLabel = `T7.1-1 \`query edges\` with ${label}`; + assertEdgeSetEqual( + decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], edgesLabel), + edgesLabel, + ), + CODE_PATH_CONTROL_EDGES, + `${edgesLabel}: the file's top-level marker records its ` + + `references edge from the whole-file location, whose ` + + `identity is the file's path (SPEC 4.5, 4.6, 7.1)`, + ); + }, + ); + } }, }); @@ -524,60 +1100,191 @@ export default defineConfig({ // outDir path preservation are both observable. Compiled bytes are fixed by // SPEC 3 (the tag-only lines drop with their terminators; the content line // keeps its own): byte-asserted per H-4; compilation semantics are T3-*'s. -const EMISSION_FILES: Readonly<Record<string, string>> = { - "specs/A.mdx": mdxSection("a"), - "specs/sub/B.mdx": mdxSection("b"), +const EMISSION_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/A.mdx": SECTION_A_SOURCE, + "specs/sub/B.mdx": SECTION_B_SOURCE, }; const A_COMPILED = "Text for a.\n"; const B_COMPILED = "Text for b.\n"; // The emission-scope matrix (SPEC 7.3): absent and `emit: false` mean no -// emission; `emit: true` emits next to each source. +// emission; `emit: true` emits next to each source. Each variant's +// configuration is a staged-source record (module header): T7.3-1 stages +// every variant past the first after its first product invocation. const EMISSION_VARIANTS = [ - { key: "`markdown` absent", config: specsMainConfig(""), emits: false }, + { + key: "`markdown` absent", + config: stagedTs( + "T7.3-1 xspec.config.ts (emission matrix: `markdown` absent)", + specsMainConfig(""), + ), + emits: false, + }, { key: "`markdown: { emit: false }`", - config: specsMainConfig(",\n markdown: { emit: false }"), + config: stagedTs( + "T7.3-1 xspec.config.ts (emission matrix: `markdown: { emit: false }`)", + specsMainConfig(",\n markdown: { emit: false }"), + ), emits: false, }, { key: "`markdown: { emit: true }`", - config: specsMainConfig(",\n markdown: { emit: true }"), + config: stagedTs( + "T7.3-1 xspec.config.ts (emission matrix: `markdown: { emit: true }`)", + specsMainConfig(",\n markdown: { emit: true }"), + ), emits: true, }, ] as const; +/** A refused-configuration arm (T7.3-1): its label and its record. */ +interface RefusedConfigArm { + readonly label: string; + readonly config: StagedTs; +} + // `markdown` present without `emit` → 14.14 (SPEC 7.3: `emit` is required // when `markdown` is present). The outDir-bearing arm discriminates a -// product that infers emission from any other markdown key. -const EMIT_REQUIRED_VIOLATIONS: readonly { label: string; extra: string }[] = [ +// product that infers emission from any other markdown key. Each arm's +// configuration is a staged-source record made at module load from its row +// (module header). +const EMIT_REQUIRED_VIOLATIONS: readonly RefusedConfigArm[] = [ { label: "markdown: {}", extra: ",\n markdown: {}" }, { label: 'markdown: { outDir: "docs" } (outDir given, emit still missing)', extra: ',\n markdown: { outDir: "docs" }', }, -]; +].map((row) => ({ + label: row.label, + config: stagedTs( + `T7.3-1 xspec.config.ts (${row.label})`, + specsMainConfig(row.extra), + ), +})); // `outDir` redirect (SPEC 7.3: emitted files land under outDir, preserving // workspace-relative paths; outDir resolves against the workspace root). -const OUTDIR_CONFIG = specsMainConfig( - ',\n markdown: { emit: true, outDir: "docs" }', +// A staged-source record (module header). +const OUTDIR_CONFIG = stagedTs( + 'T7.3-1 xspec.config.ts (outDir "docs")', + specsMainConfig(',\n markdown: { emit: true, outDir: "docs" }'), ); -// `outDir` resolving outside the workspace root → 14.14: a plain `../` -// escape and a `..` traversal buried mid-path. -const OUTSIDE_OUTDIRS: readonly string[] = ["../out", "docs/../../out"]; +// `outDir` not in plain workspace-relative form → 14.14 (SPEC 7.3: one or +// more non-empty `/`-separated segments, none `.` or `..`; any other +// spelling — empty, beginning with `/`, or carrying a `.`, `..`, or empty +// segment — is a configuration error, by spelling alone). One arm per +// pinned spelling (TEST-SPEC T7.3-1), then the two `..`-bearing spellings +// the arm always drove. Each is serialized into the configuration through +// `JSON.stringify`, so the literal the product reads is exactly the +// spelling listed; each arm's configuration is a staged-source record made +// at module load from its row (module header). +const INVALID_OUTDIRS: readonly { + readonly outDir: string; + readonly why: string; + readonly config: StagedTs; +}[] = [ + { outDir: "", why: "empty" }, + { outDir: "/out", why: "begins with `/`" }, + { outDir: "./out", why: "carries a `.` segment" }, + { outDir: "out/../x", why: "carries a `..` segment" }, + { outDir: "out//x", why: "carries an empty segment" }, + { outDir: "out/", why: "carries a trailing empty segment" }, + { outDir: "../out", why: "begins with a `..` segment" }, + { + outDir: "docs/../../out", + why: "carries `..` segments (resolving outside the root besides)", + }, +].map((row) => ({ + outDir: row.outDir, + why: row.why, + config: stagedTs( + `T7.3-1 xspec.config.ts (outDir ${JSON.stringify(row.outDir)} ${row.why})`, + specsMainConfig( + `,\n markdown: { emit: true, outDir: ${JSON.stringify(row.outDir)} }`, + ), + ), +})); + +// The pinned valid multi-segment spelling (SPEC 7.3, TEST-SPEC T7.3-1): +// `out/sub` redirects the emission under `out/sub/`, preserving each +// source's workspace-relative path beneath it. A staged-source record +// (module header). +const OUTDIR_SUB_CONFIG = stagedTs( + 'T7.3-1 xspec.config.ts (outDir "out/sub")', + specsMainConfig(',\n markdown: { emit: true, outDir: "out/sub" }'), +); + +// An `outDir` naming the graph-data area or a path under it → 14.14 (SPEC +// 7.3: "So is an `outDir` of `.xspec` or beginning with `.xspec/`" — no emit +// destination lies in the graph-data area, 13.3, so graph data is the only +// derived file under it, 11.6, 13.1, 13.4). Both spellings are in plain +// workspace-relative form, so each arm is refused for the area alone, one +// arm each (TEST-SPEC T7.3-1). Each arm's configuration is a staged-source +// record made at module load from its row (module header). +const GRAPH_DATA_AREA_OUTDIRS: readonly { + readonly outDir: string; + readonly why: string; + readonly config: StagedTs; +}[] = [ + { outDir: ".xspec", why: "names the graph-data area itself" }, + { outDir: ".xspec/md", why: "names a path under the graph-data area" }, +].map((row) => ({ + outDir: row.outDir, + why: row.why, + config: stagedTs( + `T7.3-1 xspec.config.ts (outDir ${JSON.stringify(row.outDir)} ${row.why})`, + specsMainConfig( + `,\n markdown: { emit: true, outDir: ${JSON.stringify(row.outDir)} }`, + ), + ), +})); + +// The graph-data area's look-alikes (TEST-SPEC T7.3-1): `.xspec2` and +// `.xspecs/md` are neither `.xspec` nor begin with `.xspec/` — a product +// testing the byte prefix `.xspec` without its segment boundary refuses +// them — so each is a valid `outDir`, and emission writes each destination +// under it, creating the missing directory chain (SPEC 7.3, 13.4; T13.4-8). +// `chain` lists every directory the destinations need, `outDir`'s own +// components first, none of which the arm's staging creates. Each arm's +// configuration is a staged-source record made at module load from its row +// (module header). +const LOOKALIKE_OUTDIRS: readonly { + readonly outDir: string; + readonly chain: readonly string[]; + readonly config: StagedTs; +}[] = [ + { outDir: ".xspec2", chain: [".xspec2"] }, + { outDir: ".xspecs/md", chain: [".xspecs", ".xspecs/md"] }, +].map((row) => ({ + outDir: row.outDir, + chain: [...row.chain, `${row.outDir}/specs`, `${row.outDir}/specs/sub`], + config: stagedTs( + `T7.3-1 xspec.config.ts (outDir ${JSON.stringify(row.outDir)}, a ` + + `look-alike of the graph-data area)`, + specsMainConfig( + `,\n markdown: { emit: true, outDir: ${JSON.stringify(row.outDir)} }`, + ), + ), +})); // Classification-follows-emit, discovery channel (module header): the // destination path `specs/A.md` staged as a *valid code source* — plain-TS // content whose top-level marker records a `references` edge attributed to // the file (SPEC 4.5, 4.6, 14.20) — in a code group whose glob matches only // it. The spec and code globs are disjoint (`*.mdx` vs `*.md` suffixes), so -// no 14.14 overlap arises. -const DESTINATION_CODE_SOURCE = `import BASE from "./A.xspec" +// no 14.14 overlap arises. S-9: the destination path's code source, a name +// the default does not reach, is declared well-formed (a valid code source, +// 14.20) by its staged-source record (module header; the record makes the +// path judged), staged in both workspaces of the arm. +const DESTINATION_CODE_SOURCE = stagedTs( + "T7.3-1 specs/A.md (the destination path staged as a valid code source)", + `import BASE from "./A.xspec" BASE.a -`; +`, +); function destinationDiscoveryConfig(emit: boolean): string { return `import { defineConfig } from "xspec" @@ -594,8 +1301,20 @@ export default defineConfig({ `; } -const DESTINATION_DISCOVERY_FILES: Readonly<Record<string, string>> = { - "specs/A.mdx": mdxSection("a"), +// The arm's two configurations, staged-source records (module header). +const DESTINATION_DISCOVERY_NO_EMIT_CONFIG = stagedTs( + "T7.3-1 xspec.config.ts (destination discovery: emission disabled)", + destinationDiscoveryConfig(false), +); +const DESTINATION_DISCOVERY_EMIT_CONFIG = stagedTs( + "T7.3-1 xspec.config.ts (destination discovery: emission enabled)", + destinationDiscoveryConfig(true), +); + +const DESTINATION_DISCOVERY_FILES: Readonly< + Record<string, InitialFileContents> +> = { + "specs/A.mdx": SECTION_A_SOURCE, "specs/A.md": DESTINATION_CODE_SOURCE, }; @@ -630,16 +1349,33 @@ export default defineConfig({ `; } -const DESTINATION_IMPORT_FILES: Readonly<Record<string, string>> = { - "specs/A.mdx": mdxSection("a"), - "src/use.ts": `${DESTINATION_IMPORT_STATEMENT}\n`, -}; +// The arm's two configurations and the importing code source, staged-source +// records (module header). +const DESTINATION_IMPORT_EMIT_CONFIG = stagedTs( + "T7.3-1 xspec.config.ts (destination import: emission enabled)", + destinationImportConfig(true), +); +const DESTINATION_IMPORT_NO_EMIT_CONFIG = stagedTs( + "T7.3-1 xspec.config.ts (destination import: emission disabled)", + destinationImportConfig(false), +); + +const DESTINATION_IMPORT_FILES: Readonly<Record<string, InitialFileContents>> = + { + "specs/A.mdx": SECTION_A_SOURCE, + "src/use.ts": stagedTs( + "T7.3-1 src/use.ts (importing the destination path ../specs/A.md)", + `${DESTINATION_IMPORT_STATEMENT}\n`, + ), + }; // Classification-by-configuration-alone arm (SPEC 7.3 "whether or not // emission has yet run"): emission enabled, no emission ever run, a // user-authored file at the destination, one spec-group glob matching both -// the source and the destination. -const CONFIG_ALONE_CONFIG = `import { defineConfig } from "xspec" +// the source and the destination. A staged-source record (module header). +const CONFIG_ALONE_CONFIG = stagedTs( + "T7.3-1 xspec.config.ts (classification by configuration alone)", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -647,7 +1383,8 @@ export default defineConfig({ }, markdown: { emit: true } }) -`; +`, +); const USER_AUTHORED_DESTINATION = "User-authored notes at the emit destination.\n"; @@ -663,8 +1400,9 @@ async function assertNotEmitted( fail( `${context}: expected nothing at ${rel} — with \`markdown\` absent or ` + `\`emit: false\` no path is a Markdown emit destination, and with ` + - `outDir the default next-to-source paths are not destinations ` + - `(SPEC 7.3) — but found: ${kind}`, + `outDir only the paths beneath outDir itself, joined by \`/\` to ` + + `each source's default workspace-relative destination, are ` + + `destinations (SPEC 7.3, 13.2) — but found: ${kind}`, ); } } @@ -674,10 +1412,15 @@ const T7_3_1 = defineProductTest({ title: "markdown configuration: absent and emit:false mean no emission, " + "emit:true emits next to each source; markdown without emit is 14.14; " + - "outDir redirects preserving workspace-relative paths and must resolve " + - "within the root (else 14.14); emit-destination classification follows " + - "emit — by configuration alone, whether or not emission has yet run " + - "(SPEC 7.3, 13.2, 13.4, 14.14)", + "outDir redirects preserving workspace-relative paths and must be " + + "spelled in plain workspace-relative form — non-empty `/`-separated " + + "segments, none `.` or `..` — decided by spelling alone (else 14.14: " + + '"", "/out", "./out", "out/../x", "out//x", "out/"; "out/sub" valid); ' + + "an outDir naming the graph-data area or a path under it is 14.14 " + + '(".xspec", ".xspec/md"), its look-alikes (".xspec2", ".xspecs/md") ' + + "valid, emission writing under them; emit-destination classification " + + "follows emit — by configuration alone, whether or not emission has " + + "yet run (SPEC 7.3, 13.2, 13.3, 13.4, 14.14)", run: async (product) => { // (a) The emission-scope matrix: absent → none, emit:false → none, // emit:true → next to each source. Fresh workspace per variant, so no @@ -724,7 +1467,7 @@ const T7_3_1 = defineProductTest({ for (const arm of EMIT_REQUIRED_VIOLATIONS) { await expectConfigRefused( product, - specsMainConfig(arm.extra), + arm.config, `T7.3-1 (${arm.label}) \`build --json\` — \`emit\` is required when ` + `\`markdown\` is present (SPEC 7.3, 14.14)`, ); @@ -762,15 +1505,123 @@ const T7_3_1 = defineProductTest({ }, ); - // (d) `outDir` resolving outside the workspace root → 14.14 (exit 2). - for (const outDir of OUTSIDE_OUTDIRS) { + // (d) `outDir` not in plain workspace-relative form → 14.14 (exit 2, + // the configuration the concerned path, nothing modified), one arm per + // spelling. + for (const arm of INVALID_OUTDIRS) { await expectConfigRefused( product, - specsMainConfig( - `,\n markdown: { emit: true, outDir: ${JSON.stringify(outDir)} }`, - ), - `T7.3-1 (outDir ${JSON.stringify(outDir)} resolves outside the ` + - `workspace root) \`build --json\` (SPEC 7.3, 14.14)`, + arm.config, + `T7.3-1 (outDir ${JSON.stringify(arm.outDir)} ${arm.why}: not in ` + + `plain workspace-relative form — one or more non-empty ` + + `\`/\`-separated segments, none \`.\` or \`..\`, decided by ` + + `spelling alone) \`build --json\` (SPEC 7.3, 14.14)`, + ); + } + + // (d') `outDir` `"out/sub"` is valid: a multi-segment plain spelling + // redirects the emission under `out/sub/`, preserving each source's + // workspace-relative path — and redirects rather than duplicates. + await withWorkspace( + { files: { "xspec.config.ts": OUTDIR_SUB_CONFIG, ...EMISSION_FILES } }, + async (workspace) => { + await buildOk( + product, + workspace, + "T7.3-1 `build` with outDir out/sub — a multi-segment plain " + + "workspace-relative spelling is valid (SPEC 7.3)", + ); + await assertFileBytes( + workspace.path("out/sub/specs/A.md"), + A_COMPILED, + "T7.3-1 (outDir out/sub): specs/A.mdx emits out/sub/specs/A.md — " + + "outDir prefixes the preserved workspace-relative path (SPEC 7.3)", + ); + await assertFileBytes( + workspace.path("out/sub/specs/sub/B.md"), + B_COMPILED, + "T7.3-1 (outDir out/sub): specs/sub/B.mdx emits " + + "out/sub/specs/sub/B.md — subdirectory structure preserved " + + "under outDir (SPEC 7.3)", + ); + for (const vacant of [ + "specs/A.md", + "specs/sub/B.md", + "out/specs/A.md", + "out/specs/sub/B.md", + ]) { + await assertNotEmitted( + workspace, + vacant, + "T7.3-1 (outDir out/sub redirects, not duplicates; every " + + "segment of the spelling is honored)", + ); + } + }, + ); + + // (d'') An `outDir` naming the graph-data area or a path under it → + // 14.14 (exit 2, the configuration the concerned path, nothing modified + // — no graph data and no emitted file appears), one arm per spelling. + for (const arm of GRAPH_DATA_AREA_OUTDIRS) { + await expectConfigRefused( + product, + arm.config, + `T7.3-1 (outDir ${JSON.stringify(arm.outDir)} ${arm.why}: no emit ` + + `destination lies in the graph-data area, graph data being the ` + + `only derived file under it) \`build --json\` (SPEC 7.3, 13.3, ` + + `11.6, 14.14)`, + ); + } + + // (d''') The look-alikes `.xspec2` and `.xspecs/md` are valid: emission + // writes each destination under them, the absent directory chain + // created as real directories (13.4; T13.4-8), workspace-relative paths + // preserved — and redirects rather than duplicates. + for (const arm of LOOKALIKE_OUTDIRS) { + const label = + `T7.3-1 (outDir ${JSON.stringify(arm.outDir)}, a look-alike of the ` + + `graph-data area)`; + await withWorkspace( + { files: { "xspec.config.ts": arm.config, ...EMISSION_FILES } }, + async (workspace) => { + await buildOk( + product, + workspace, + `${label} \`build\` — neither \`.xspec\` nor beginning with ` + + `\`.xspec/\`, the spelling is a valid outDir (SPEC 7.3)`, + ); + for (const dir of arm.chain) { + const kind = await workspace.kind(dir); + if (kind !== "dir") { + fail( + `${label}: every directory component of the emit ` + + `destinations comes into existence as a real directory ` + + `(SPEC 13.4, 7.3; T13.4-8) — expected a directory at ` + + `${dir}, found: ${kind}`, + ); + } + } + await assertFileBytes( + workspace.path(`${arm.outDir}/specs/A.md`), + A_COMPILED, + `${label}: specs/A.mdx emits ${arm.outDir}/specs/A.md — outDir ` + + `prefixes the preserved workspace-relative path (SPEC 7.3, 13.2)`, + ); + await assertFileBytes( + workspace.path(`${arm.outDir}/specs/sub/B.md`), + B_COMPILED, + `${label}: specs/sub/B.mdx emits ${arm.outDir}/specs/sub/B.md — ` + + `subdirectory structure preserved under outDir (SPEC 7.3, 13.2)`, + ); + for (const vacant of ["specs/A.md", "specs/sub/B.md"]) { + await assertNotEmitted( + workspace, + vacant, + `${label} (outDir redirects, not duplicates)`, + ); + } + }, ); } @@ -781,7 +1632,7 @@ const T7_3_1 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": destinationDiscoveryConfig(false), + "xspec.config.ts": DESTINATION_DISCOVERY_NO_EMIT_CONFIG, ...DESTINATION_DISCOVERY_FILES, }, }, @@ -821,7 +1672,7 @@ const T7_3_1 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": destinationDiscoveryConfig(true), + "xspec.config.ts": DESTINATION_DISCOVERY_EMIT_CONFIG, ...DESTINATION_DISCOVERY_FILES, }, }, @@ -849,11 +1700,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( @@ -873,7 +1725,7 @@ const T7_3_1 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": destinationImportConfig(true), + "xspec.config.ts": DESTINATION_IMPORT_EMIT_CONFIG, ...DESTINATION_IMPORT_FILES, }, }, @@ -896,7 +1748,7 @@ const T7_3_1 = defineProductTest({ await withWorkspace( { files: { - "xspec.config.ts": destinationImportConfig(false), + "xspec.config.ts": DESTINATION_IMPORT_NO_EMIT_CONFIG, ...DESTINATION_IMPORT_FILES, }, }, @@ -920,7 +1772,7 @@ const T7_3_1 = defineProductTest({ { files: { "xspec.config.ts": CONFIG_ALONE_CONFIG, - "specs/A.mdx": mdxSection("a"), + "specs/A.mdx": SECTION_A_SOURCE, "specs/A.md": USER_AUTHORED_DESTINATION, }, }, diff --git a/test/suite/registry/section-7.4-7.5.ts b/test/suite/registry/section-7.4-7.5.ts index deb0510a..8ce92969 100644 --- a/test/suite/registry/section-7.4-7.5.ts +++ b/test/suite/registry/section-7.4-7.5.ts @@ -26,15 +26,49 @@ // // 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. +// Every `expectConfigRefused` arm (T7.4-1, T7.5-1) further pins the +// finding's concerned path to exactly `xspec.config.ts` — the file the +// upward search found, in 11.6's anchoring form relative to the invocation +// working directory, the workspace root (SPEC 14, 12.7) — its locations [] +// (a configuration condition carries the file it concerns, no source +// range; 14), and, by a whole-root snapshot compare around the invocation, +// that nothing is written (12.1). +// - T7.4-1 and T7.5-1 stray-member arms (`edgeKinds` / `kinds` not a subset +// of the three dependency kinds; a non-string `targetTags` / selector +// `tags` element): each `edgeKinds` or `kinds` stray member — "contains", +// "depend", `true` — is staged last beside the valid kind "depends", so a +// product that drops unknown members (or filters them and only then +// applies the empty-list rule) keeps a valid list and builds, exit 0, +// instead of refusing by the wrong rule (`build` never evaluates policy, +// 12.1, so a tolerated rule can never fail the build by a side reason); +// `targetTags: [true]` and `tags: [true]` are staged as TEST-SPEC spells +// them. `true` is the boolean literal the declarative form of 7 admits, so +// those refusals are 14.14's invalid profile or rule shape, never a form +// error. +// - Set reading (T7.4-1, T7.5-1; SPEC 7.4, 7.5, 12.7, 11.6): `targetTags`, +// `edgeKinds`, a rule's `kinds`, and a selector's `tags` are read as sets +// — a repeated element collapses — and reported in 12.7's value forms: +// tag sets in byte order with duplicates collapsed, kind sets in 5.2's +// order however configured. Each arm stages the spelled list beside its +// collapsed twin (the same workspace under the canonical spellings) and +// asserts `inventory`'s view form-exact (`decodeInventoryResolvedMap` +// rejects a verbatim echo) and literally, then the report the sets +// govern — the profile's `coverage --json`, the rule's `check --json` — +// equal to the twin's: the decoded reports compared member for member and +// the stdout bytes identical (a product-to-itself comparison, H-4; one +// resolved configuration, one deterministic answer, 12.0). // - 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,12 +91,63 @@ // `*`, 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 // regenerated bytes deterministic). "Only check reports them" is asserted // as build exiting 0 (a reported policy finding causes exit 1, 7.5) while // the same workspace's check exits 1 with exactly the staged findings. +// - Staged-source records (TEST-SPEC S-9's before-any-product clause; +// helpers/staged-mdx.ts): every `.mdx` file a body stages in a workspace +// created after its first product invocation — T7.4-1's and T7.5-1's +// later matrix arms, ambiguous-name arms, positive arms, and set-reading +// workspaces (`MATRIX_FILES`, `DUAL_FILES`, the set-reading fixtures), +// T7.5-2's kinds-restriction workspace, T7.5-4's files- and tags-selector +// workspaces, T7.5-5's arms (b)–(j) — is a ledger record, judged by +// test/self/s9-staged-sources.test.ts before any product exists. +// Byte-identical sources across tests and paths are ONE record, named +// with every staging test in ID order and every path: `mdxSection("a")` +// is section-7-basics.ts's exported `SECTION_A_SOURCE`, `mdxSection("c")` +// section-7-discovery.ts's `SECTION_C_SOURCE`, and this module's minimal +// sections x, d, t, g, w, p, q, and r are one record each. Each body's +// first workspace (T7.4-2's, T7.5-3's, and T7.5-6's only one; T7.5-2's, +// T7.5-4's, and T7.5-5's first arm) precedes any invocation — S-7's sweep +// reaches it — and stays plain, as does T7.5-6's tampered generated +// module (no `.mdx` path). +// - TypeScript staged-source records (TEST-SPEC S-9's TypeScript and +// timing clauses; helpers/staged-ts.ts): every configuration file and +// code source a body stages in a workspace created after its first +// product invocation — `expectConfigRefused`'s one staging site (T7.4-1's +// and T7.5-1's matrix tables, every row a record made at module load, the +// first's too, the table being one), T7.4-1's ambiguous-name, inferred- +// kind, and unknown-profile-name configurations, both bodies' set-reading +// configurations, T7.5-2's kinds-restriction, T7.5-4's files- and +// tags-selector, and T7.5-5's arms (b)–(j) configurations, and the code +// sources of MATRIX_FILES, DUAL_FILES, and T7.5-5's arms (d), (e), and (g) +// — is a ledger record carrying its S-9 declaration, every one +// well-formed, judged by test/self/s9-staged-sources.test.ts before any +// product exists. Byte-identical code sources are ONE record, named with +// every path: `src/impl.ts` and `dualcode/d.ts` (OK_CODE_SOURCE), and +// CODE_MARKER_TO_P at T7.5-5's five paths, `src/end$` and `src/end` +// among them (code sources the default does not reach, judged because +// the record makes the path judged). The same first workspaces stay +// plain; T7.5-6's tampered generated module, an edit of product-written +// bytes, is staged `unchecked` (the document declares nothing of its +// well-formedness, and no harness constant equals its bytes). import type { CoverageProfileReport, @@ -72,26 +157,41 @@ import type { } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, + decodeEdgesReport, decodeFindingsReport, + decodeInventoryResolvedMap, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; import { assertRunTwiceDeterministic } from "../../helpers/determinism.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { + assertSnapshotsEqual, + snapshotDirectory, +} from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import type { + InitialFileContents, + WorkspaceDecl, +} from "../../helpers/workspace.js"; +import { SECTION_A_SOURCE } from "./section-7-basics.js"; +import { SECTION_C_SOURCE } from "./section-7-discovery.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, readGeneratedModule, runJson, @@ -107,6 +207,47 @@ function mdxSection(id: string): string { return `<S id="${id}">\nText for ${id}.\n</S>\n`; } +// The minimal sources this module stages in workspaces created after a +// body's first product invocation (module header) — one staged-source +// record per byte sequence, named with every staging test in ID order and +// every path: `mdxSection("a")` is section-7-basics.ts's `SECTION_A_SOURCE` +// (T7.4-1's and T7.5-1's `specs/A.mdx`, T7.5-5's `tgt/a.mdx`) and +// `mdxSection("c")` section-7-discovery.ts's `SECTION_C_SOURCE` (T7.5-4's +// `specs/C.mdx`, T7.5-5's `tgt/c.mdx`), both imported above; the rest are +// this module's own. +const SECTION_X_SOURCE = stagedMdx( + "T7.4-1/T7.5-1/T7.5-5 the minimal section x (aux/X.mdx; T7.5-5's tgt/abc.mdx)", + mdxSection("x"), +); +const SECTION_D_SOURCE = stagedMdx( + "T7.4-1/T7.5-1/T7.5-4 the minimal section d (dualspec/D.mdx; T7.5-4's specs/D.mdx)", + mdxSection("d"), +); +const SECTION_T_SOURCE = stagedMdx( + "T7.5-1/T7.5-4/T7.5-5 tgt/T.mdx (the minimal section t)", + mdxSection("t"), +); +const SECTION_G_SOURCE = stagedMdx( + "T7.5-5 m/good.mdx (the minimal section g)", + mdxSection("g"), +); +const SECTION_W_SOURCE = stagedMdx( + "T7.5-5 m/wrong.mdx (the minimal section w)", + mdxSection("w"), +); +const SECTION_P_SOURCE = stagedMdx( + "T7.5-5 the minimal section p (tgt/P.mdx; tgt/t$0.mdx; tgt/t$z.mdx)", + mdxSection("p"), +); +const SECTION_Q_SOURCE = stagedMdx( + "T7.5-5 the minimal section q (tgt/tb.mdx; tgt/tz.mdx)", + mdxSection("q"), +); +const SECTION_R_SOURCE = stagedMdx( + "T7.5-5 the minimal section r (tgt/t0.mdx; tgt/tQz.mdx)", + mdxSection("r"), +); + /** Stage a fresh workspace, run `body`, dispose (H-1). */ async function withWorkspace<T>( decl: WorkspaceDecl, @@ -121,20 +262,64 @@ async function withWorkspace<T>( } /** - * Stage a workspace with the given configuration and source files and assert - * `build --json` refuses it per 14.14 (module header: the deviation is the - * only defect, so wrong acceptance surfaces as a failed exit-code assertion). + * Stage a workspace with the given configuration — a staged-source record, + * well-formed (module header), since T7.4-1 and T7.5-1 stage every arm past + * their first after the body's first product invocation (S-9's timing + * clause) — and source files, and assert `build --json` refuses it per + * 14.14 (module header: the deviation is the only defect, so wrong + * acceptance surfaces as a failed exit-code assertion). + * Beyond the shared 14.14 contract (`expectConfigurationError`), the finding + * is pinned to the configuration file: its concerned path is exactly + * `xspec.config.ts` — the file the upward search found, in the anchoring + * form of 11.6 relative to the invocation working directory, here the + * workspace root, so the bare name (SPEC 14, 12.7) — and its locations are + * [] (a configuration condition carries the file it concerns, no source + * range; SPEC 14); a whole-root snapshot compare around the invocation pins + * that a build failing at configuration load writes nothing (SPEC 12.1). */ async function expectConfigRefused( product: ProductBinding, - config: string, - files: Readonly<Record<string, string>>, + config: StagedTs, + files: Readonly<Record<string, InitialFileContents>>, context: string, ): Promise<void> { await withWorkspace( { files: { "xspec.config.ts": config, ...files } }, async (workspace) => { - await expectConfigurationError(product, workspace, ["build"], context); + const before = await snapshotDirectory(workspace.root); + const result = await expectConfigurationError( + product, + workspace, + ["build"], + context, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: "xspec.config.ts", + locations: [], + }, + `${context}: the error document's one finding carries the stable ` + + `code "configuration-error", locations [] (a configuration ` + + `condition carries the file it concerns, never a source range), ` + + `and as its concerned path the configuration file the upward ` + + `search found, in the anchoring form of 11.6 relative to the ` + + `invocation working directory — the workspace root, so exactly ` + + `"xspec.config.ts" (SPEC 14, 12.7, 11.6)`, + ); + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `${context}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — no derived file or graph data ` + + `appears anywhere under the root`, + ); }, ); } @@ -157,10 +342,21 @@ function entriesBlock( return ` ${key}: [\n${rendered}\n ]`; } +// The one code source the validation-matrix and ambiguous-name layouts +// stage — `src/impl.ts` of MATRIX_FILES, `dualcode/d.ts` of DUAL_FILES, the +// same bytes — is one TypeScript staged-source record (module header): T7.4-1 +// and T7.5-1 stage both maps in workspaces created after their first product +// invocation. +const OK_CODE_SOURCE = stagedTs( + "T7.4-1/T7.5-1 the code source exporting ok (src/impl.ts; dualcode/d.ts)", + "export const ok = 1;\n", +); + // The validation-matrix group layout (T7.4-1, T7.5-1): two spec groups (so // group-typed references have a valid unambiguous referent and a second // distinct group), one code group (for the wrong-kind arms). Every group -// holds one valid staged source (MATRIX_FILES). +// holds one valid staged source (MATRIX_FILES; every one a record, the map +// serving every arm after a body's first — module header). const MATRIX_GROUPS = ` specs: { main: ["specs/**/*.mdx"], aux: ["aux/**/*.mdx"] @@ -179,10 +375,10 @@ ${block} `; } -const MATRIX_FILES: Readonly<Record<string, string>> = { - "specs/A.mdx": mdxSection("a"), - "aux/X.mdx": mdxSection("x"), - "src/impl.ts": "export const ok = 1;\n", +const MATRIX_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/A.mdx": SECTION_A_SOURCE, + "aux/X.mdx": SECTION_X_SOURCE, + "src/impl.ts": OK_CODE_SOURCE, }; // The ambiguous-name layout: `dual` exists as both a spec group and a code @@ -206,10 +402,10 @@ ${block} `; } -const DUAL_FILES: Readonly<Record<string, string>> = { - "specs/A.mdx": mdxSection("a"), - "dualspec/D.mdx": mdxSection("d"), - "dualcode/d.ts": "export const ok = 1;\n", +const DUAL_FILES: Readonly<Record<string, InitialFileContents>> = { + "specs/A.mdx": SECTION_A_SOURCE, + "dualspec/D.mdx": SECTION_D_SOURCE, + "dualcode/d.ts": OK_CODE_SOURCE, }; /** @@ -259,19 +455,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)`, ); } @@ -283,7 +492,7 @@ function assertPolicyFindings( */ async function expectPolicyFindings( product: ProductBinding, - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, expected: readonly PolicyExpectation[], contextBase: string, ): Promise<void> { @@ -321,6 +530,328 @@ function profileNamed( return profile; } +// --------------------------------------------------------------------------- +// Set reading (T7.4-1, T7.5-1) — configured lists read as sets +// --------------------------------------------------------------------------- + +// The set-reading coverage fixture's sources: the same bytes in the spelled +// workspace and its collapsed twin (the profile's two lists alone differ), +// so one staged-source record each, staged in both (module header). +const SET_READING_PROFILE_TARGET = stagedMdx( + "T7.4-1 set reading tgt/T.mdx (the spelled profile and its collapsed twin)", + `<S id="a" tags="a"> +Leaf a. +</S> + +<S id="z" tags="z"> +Leaf z. +</S> + +<S id="n"> +Leaf n. +</S> +`, +); +const SET_READING_PROFILE_BOUNDARY = stagedMdx( + "T7.4-1 set reading bnd/B.mdx (the spelled profile and its collapsed twin)", + `import T from "../tgt/T.xspec" + +<S id="dep" d={T.a}> +Depends on a. +</S> + +<S id="emb"> +Embeds z: + +{text(T.z)} +</S> +`, +); + +/** + * The set-reading coverage fixture, parametrized by the two spellings (the + * spelled profile and its collapsed twin). Three leaves under the root of + * `tgt/T.mdx`: `a` (tagged a) covered over a depends edge, `z` (tagged z) + * reachable over an embeds edge alone — a kind outside the configured + * edgeKinds — and the untagged `n`, lacking every targetTags tag (8.1/8.2), + * so both configured sets shape the report the twins must agree on. + * Called at module load only: each workspace's configuration is a + * TypeScript staged-source record named for its `spelling` (module header — + * T7.4-1 stages both workspaces after its first product invocation). + */ +function setReadingProfileFiles( + spelling: string, + targetTags: string, + edgeKinds: string, +): Readonly<Record<string, InitialFileContents>> { + return { + "xspec.config.ts": stagedTs( + `T7.4-1 set reading xspec.config.ts (${spelling})`, + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + tgt: ["tgt/**/*.mdx"], + bnd: ["bnd/**/*.mdx"] + }, + coverage: [ + { + name: "p", + target: "tgt", + targetTags: ${targetTags}, + boundary: "bnd", + mode: "direct", + edgeKinds: ${edgeKinds} + } + ] +}) +`, + ), + "tgt/T.mdx": SET_READING_PROFILE_TARGET, + "bnd/B.mdx": SET_READING_PROFILE_BOUNDARY, + }; +} + +const SET_READING_PROFILE_FILES = setReadingProfileFiles( + "the spelled profile", + '["z", "a", "a"]', + '["references", "depends", "depends"]', +); +const SET_READING_PROFILE_TWIN_FILES = setReadingProfileFiles( + "the profile's collapsed twin", + '["a", "z"]', + '["depends", "references"]', +); + +/** + * Run one set-reading coverage workspace: `build` accepts the configuration + * (the spelled lists are valid — a repeated element collapses, SPEC 7.4), + * `inventory` reports the profile with its sets in their 12.7 value forms + * (form-exact through `decodeInventoryResolvedMap`, then compared + * literally), and `coverage --json` answers the report the sets govern — + * `a` covered over its depends edge, `z` uncovered (its embeds edge lies + * outside the configured kinds), the untagged `n` required by neither + * (8.1) — returned decoded with its stdout bytes for the twin compare. + */ +async function runSetReadingProfile( + product: ProductBinding, + files: Readonly<Record<string, InitialFileContents>>, + context: string, +): Promise<{ report: CoverageReport; stdoutBytes: Uint8Array }> { + return await withWorkspace({ files }, async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` — the spelled lists are valid: a repeated ` + + `element collapses (SPEC 7.4)`, + ); + const inventoryLabel = `${context} \`inventory\``; + const view = decodeInventoryResolvedMap( + await runJson(product, workspace, ["inventory"], inventoryLabel), + inventoryLabel, + ); + assertSameJson( + view.configuration.coverage, + [ + { + name: "p", + target: "tgt", + targetTags: ["a", "z"], + targets: "leaves", + boundary: "bnd", + boundaryKind: "spec", + mode: "direct", + edgeKinds: ["depends", "references"], + }, + ], + `${inventoryLabel}: the profile's \`targetTags\` exactly ["a", "z"] ` + + `(byte order, the repeated element collapsed) and \`edgeKinds\` ` + + `exactly ["depends", "references"] (5.2's order, however ` + + `configured) — 12.7's value forms, compared literally (SPEC 7.4, ` + + `12.7, 11.6)`, + ); + const label = `${context} \`coverage --json\``; + const result = await expectExit( + product, + workspace, + ["coverage", "--json"], + 0, + label, + ); + const report = decodeCoverageReport(parseJsonStdout(result, label), label); + const profile = profileNamed(report, "p", label); + assertSameJson( + coveredWithPaths(profile), + [{ identity: "tgt/T.mdx#a", path: ["bnd/B.mdx#dep", "tgt/T.mdx#a"] }], + `${label}: exactly the a-tagged leaf is covered, over its depends ` + + `edge — both listed tags select the required set and depends is ` + + `among the configured kinds (SPEC 7.4, 8, 8.1)`, + ); + assertSameJson( + [...profile.uncovered].sort(), + ["tgt/T.mdx#z"], + `${label}: the z-tagged leaf is required (it carries a listed tag) ` + + `and uncovered — its only edge is embeds, outside the configured ` + + `kinds — while the untagged leaf is required by neither (SPEC 7.4, ` + + `8, 8.1)`, + ); + return { report, stdoutBytes: result.stdoutBytes }; + }); +} + +// The set-reading policy fixture's own source: the same bytes in the spelled +// workspace and its collapsed twin (the rule's two lists alone differ), so +// one staged-source record, staged in both; `tgt/T.mdx` is the shared `t`. +const SET_READING_RULE_POLICY = stagedMdx( + "T7.5-1 set reading pol/P.mdx (the spelled rule and its collapsed twin)", + `import T from "../tgt/T.xspec" + +<S id="pa" tags="a" d={T.t}> +Tagged a, depends. +</S> + +<S id="pb" tags="b"> +Tagged b, embeds: + +{text(T.t)} +</S> + +<S id="pu" d={T.t}> +Untagged. +</S> + +<S id="pc" tags="c" d={T.t}> +Tagged c. +</S> +`, +); + +/** + * The set-reading policy fixture, parametrized by the two spellings. One + * edge per (tag, kind) pair the sets admit — `pa` (tagged a) depends on `t`, + * `pb` (tagged b) embeds it — beside the two the rule must leave unflagged: + * the untagged `pu` and `pc`, tagged c (matching means carrying at least + * one listed tag, SPEC 7.5). Called at module load only: each workspace's + * configuration is a TypeScript staged-source record named for its + * `spelling` (module header — T7.5-1 stages both workspaces after its first + * product invocation). + */ +function setReadingRuleFiles( + spelling: string, + kinds: string, + tags: string, +): Readonly<Record<string, InitialFileContents>> { + return { + "xspec.config.ts": stagedTs( + `T7.5-1 set reading xspec.config.ts (${spelling})`, + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + pol: ["pol/**/*.mdx"], + tgt: ["tgt/**/*.mdx"] + }, + policy: [ + { + name: "r", + type: "forbidden", + from: { tags: ${tags} }, + to: { group: "tgt" }, + kinds: ${kinds} + } + ] +}) +`, + ), + "pol/P.mdx": SET_READING_RULE_POLICY, + "tgt/T.mdx": SECTION_T_SOURCE, + }; +} + +const SET_READING_RULE_FILES = setReadingRuleFiles( + "the spelled rule", + '["embeds", "depends", "embeds"]', + '["b", "a", "b"]', +); +const SET_READING_RULE_TWIN_FILES = setReadingRuleFiles( + "the rule's collapsed twin", + '["depends", "embeds"]', + '["a", "b"]', +); + +/** The rule's findings: one per (listed tag, configured kind) edge. */ +const SET_READING_RULE_EXPECTED: readonly PolicyExpectation[] = [ + { + rule: "r", + edge: { from: "pol/P.mdx#pa", to: "tgt/T.mdx#t", kind: "depends" }, + }, + { + rule: "r", + edge: { from: "pol/P.mdx#pb", to: "tgt/T.mdx#t", kind: "embeds" }, + }, +]; + +/** + * Run one set-reading policy workspace: `build` accepts the configuration + * (valid — a repeated element collapses, SPEC 7.5 — and build never + * evaluates policy, 12.1), `inventory` reports the rule with `kinds` and + * the selector's `tags` in their 12.7 value forms (form-exact, then + * literal), and `check --json` reports exactly the rule's findings — + * returned decoded with the stdout bytes for the twin compare. + */ +async function runSetReadingRule( + product: ProductBinding, + files: Readonly<Record<string, InitialFileContents>>, + context: string, +): Promise<{ findings: readonly Finding[]; stdoutBytes: Uint8Array }> { + return await withWorkspace({ files }, async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` — the spelled lists are valid: a repeated ` + + `element collapses (SPEC 7.5), and build never evaluates policy ` + + `(SPEC 12.1)`, + ); + const inventoryLabel = `${context} \`inventory\``; + const view = decodeInventoryResolvedMap( + await runJson(product, workspace, ["inventory"], inventoryLabel), + inventoryLabel, + ); + assertSameJson( + view.configuration.policy, + [ + { + name: "r", + type: "forbidden", + from: { tags: ["a", "b"] }, + to: { group: "tgt", kind: "spec" }, + kinds: ["depends", "embeds"], + }, + ], + `${inventoryLabel}: the rule's \`kinds\` exactly ["depends", ` + + `"embeds"] (5.2's order, however configured) and the selector's ` + + `\`tags\` exactly ["a", "b"] (byte order, the repeated element ` + + `collapsed) — 12.7's value forms, compared literally (SPEC 7.5, ` + + `12.7, 11.6)`, + ); + const label = `${context} \`check --json\``; + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${label} — policy violations are findings of check and cause exit 1 ` + + `(SPEC 7.5, 14.12, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, label), + label, + ).findings; + assertPolicyFindings(findings, SET_READING_RULE_EXPECTED, label); + return { findings, stdoutBytes: result.stdoutBytes }; + }); +} + // --------------------------------------------------------------------------- // T7.4-1 — profile validation // --------------------------------------------------------------------------- @@ -460,50 +991,161 @@ const PROFILE_MATRIX: readonly { ], ], }, + // `edgeKinds` not a subset of ["depends", "embeds", "references"] (7.4) — + // an otherwise invalid profile shape (14.14), one arm per kind of stray + // member. Each stray member sits last beside the valid kind "depends", so + // the list is non-empty and non-subset at once: a product that drops or + // ignores members it does not know, or that filters them out and only then + // applies the empty-list rule, keeps a well-formed ["depends"] and builds + // (exit 0) — where a lone stray member would let such a product refuse by + // the wrong rule (an emptied list) and pass by accident. + { + label: + '`edgeKinds` holding the non-dependency kind "contains" beside ' + + '"depends" — not a subset of the three dependency kinds (7.4), ' + + "discriminating a product that lets `contains` grant coverage (8) or " + + "ignores the stray member", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + 'boundary: "aux"', + 'mode: "direct"', + 'edgeKinds: ["depends", "contains"]', + ], + ], + }, + { + label: + '`edgeKinds` holding the unknown token "depend" beside "depends" — ' + + "not a subset (7.4), discriminating a product that accepts and " + + "ignores a stray member", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + 'boundary: "aux"', + 'mode: "direct"', + 'edgeKinds: ["depends", "depend"]', + ], + ], + }, + { + label: + "`edgeKinds` holding the non-string element `true` beside " + + '"depends" — the boolean literal the declarative form of 7 admits, ' + + "so the refusal is 14.14's invalid profile shape, not a form error", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + 'boundary: "aux"', + 'mode: "direct"', + 'edgeKinds: ["depends", true]', + ], + ], + }, + { + label: + "`targetTags` holding the non-string element `true` (`[true]`, as " + + "TEST-SPEC T7.4-1 spells the fixture) — the boolean literal the " + + "declarative form of 7 admits, so the refusal is 14.14's invalid " + + "profile shape; a product tolerating it (dropping or stringifying the " + + "element) accepts the configuration and builds, so exit 0 " + + "discriminates it", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + "targetTags: [true]", + 'boundary: "aux"', + 'mode: "direct"', + ], + ], + }, ]; +/** A refused-configuration arm (T7.4-1, T7.5-1): its label and its record. */ +interface RefusedConfigArm { + readonly label: string; + readonly config: StagedTs; +} + +// The matrix's rows as refused-configuration arms, each configuration a +// TypeScript staged-source record made at module load from its row — the +// expression moved, never re-spelled — named `T7.4-1 xspec.config.ts +// (<label>)`: T7.4-1 stages every arm past the first after its first product +// invocation, and the table is one (module header). +const PROFILE_MATRIX_ARMS: readonly RefusedConfigArm[] = PROFILE_MATRIX.map( + (arm) => ({ + label: arm.label, + config: stagedTs( + `T7.4-1 xspec.config.ts (${arm.label})`, + matrixConfig(entriesBlock("coverage", arm.profiles)), + ), + }), +); + // `boundary: "dual"` where dual is both a spec and a code group, boundaryKind -// absent → 14.14 (7.4: boundaryKind MUST be required when ambiguous). -const AMBIGUOUS_BOUNDARY_ABSENT_CONFIG = dualConfig( - entriesBlock("coverage", [ - ['name: "p"', 'target: "main"', 'boundary: "dual"', 'mode: "direct"'], - ]), +// absent → 14.14 (7.4: boundaryKind MUST be required when ambiguous). This +// configuration and the three below are TypeScript staged-source records +// (module header): T7.4-1 stages each after its first product invocation. +const AMBIGUOUS_BOUNDARY_ABSENT_CONFIG = stagedTs( + "T7.4-1 xspec.config.ts (the ambiguous boundary name dual, boundaryKind " + + "absent)", + dualConfig( + entriesBlock("coverage", [ + ['name: "p"', 'target: "main"', 'boundary: "dual"', 'mode: "direct"'], + ]), + ), ); // The same ambiguous name with boundaryKind given, both directions → valid. -const AMBIGUOUS_BOUNDARY_GIVEN_CONFIG = dualConfig( - entriesBlock("coverage", [ - [ - 'name: "p-spec"', - 'target: "main"', - 'boundary: "dual"', - 'boundaryKind: "spec"', - 'mode: "direct"', - ], - [ - 'name: "p-code"', - 'target: "main"', - 'boundary: "dual"', - 'boundaryKind: "code"', - 'mode: "direct"', - ], - ]), +const AMBIGUOUS_BOUNDARY_GIVEN_CONFIG = stagedTs( + "T7.4-1 xspec.config.ts (the ambiguous boundary name dual, boundaryKind " + + "given in both directions)", + dualConfig( + entriesBlock("coverage", [ + [ + 'name: "p-spec"', + 'target: "main"', + 'boundary: "dual"', + 'boundaryKind: "spec"', + 'mode: "direct"', + ], + [ + 'name: "p-code"', + 'target: "main"', + 'boundary: "dual"', + 'boundaryKind: "code"', + 'mode: "direct"', + ], + ]), + ), ); // Unambiguous boundary names without boundaryKind — one spec-only (aux), one // code-only (app) → valid: the kind MUST be inferred (7.4). -const INFERRED_KIND_CONFIG = matrixConfig( - entriesBlock("coverage", [ - ['name: "p-spec"', 'target: "main"', 'boundary: "aux"', 'mode: "direct"'], - ['name: "p-code"', 'target: "main"', 'boundary: "app"', 'mode: "direct"'], - ]), +const INFERRED_KIND_CONFIG = stagedTs( + "T7.4-1 xspec.config.ts (unambiguous spec-only and code-only boundary " + + "names, boundaryKind inferred)", + matrixConfig( + entriesBlock("coverage", [ + ['name: "p-spec"', 'target: "main"', 'boundary: "aux"', 'mode: "direct"'], + ['name: "p-code"', 'target: "main"', 'boundary: "app"', 'mode: "direct"'], + ]), + ), ); // One valid profile, for the unknown-profile-name usage-error arm. -const VALID_COVERAGE_CONFIG = matrixConfig( - entriesBlock("coverage", [ - ['name: "p"', 'target: "main"', 'boundary: "aux"', 'mode: "direct"'], - ]), +const VALID_COVERAGE_CONFIG = stagedTs( + "T7.4-1 xspec.config.ts (one valid profile, the unknown-profile-name " + + "usage-error arm)", + matrixConfig( + entriesBlock("coverage", [ + ['name: "p"', 'target: "main"', 'boundary: "aux"', 'mode: "direct"'], + ]), + ), ); const T7_4_1 = defineProductTest({ @@ -511,17 +1153,25 @@ const T7_4_1 = defineProductTest({ title: "profile validation: duplicate names, each missing required field, " + "invalid targets/mode/boundaryKind values, unknown and wrong-kind " + - "target/boundary references, empty targetTags/edgeKinds, and an " + - "ambiguous boundary without boundaryKind are configuration errors " + - "(14.14, exit 2); boundaryKind is inferred when unambiguous; an unknown " + - "profile name at `coverage <name>` is a usage error (SPEC 7.4, 14.14, " + - "12.0)", + "target/boundary references, empty targetTags/edgeKinds, edgeKinds " + + 'holding "contains", an unknown token, or a non-string element, a ' + + "non-string targetTags element, and an ambiguous boundary without " + + "boundaryKind are configuration errors (14.14, exit 2, the finding " + + "naming xspec.config.ts, nothing written); boundaryKind is inferred " + + "when unambiguous; an unknown profile name at `coverage <name>` is a " + + 'usage error (12.0); set reading: `targetTags: ["z", "a", "a"]` and ' + + '`edgeKinds: ["references", "depends", "depends"]` are valid — a ' + + "repeated element collapses — `inventory` reporting `targetTags` " + + 'exactly ["a", "z"] (byte order, collapsed) and `edgeKinds` exactly ' + + '["depends", "references"] (5.2\'s order, however configured), and the ' + + "profile's coverage report equal to its collapsed twin's (SPEC 7.4, " + + "14.14, 12.0, 12.7, 11.6)", run: async (product) => { // (a) The 14.14 matrix over the standard group layout. - for (const arm of PROFILE_MATRIX) { + for (const arm of PROFILE_MATRIX_ARMS) { await expectConfigRefused( product, - matrixConfig(entriesBlock("coverage", arm.profiles)), + arm.config, MATRIX_FILES, `T7.4-1 (${arm.label})`, ); @@ -573,8 +1223,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 +1238,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( @@ -601,6 +1251,39 @@ const T7_4_1 = defineProductTest({ } }, ); + + // (e) Set reading (7.4, 12.7): `targetTags: ["z", "a", "a"]` and + // `edgeKinds: ["references", "depends", "depends"]` are valid — a + // repeated element collapses — `inventory` reports the sets in their + // value forms, and the profile's coverage report equals its collapsed + // twin's (T11.6-2). + const spelled = await runSetReadingProfile( + product, + SET_READING_PROFILE_FILES, + 'T7.4-1 (set reading: the profile spelled `targetTags: ["z", "a", ' + + '"a"]`, `edgeKinds: ["references", "depends", "depends"]`)', + ); + const twin = await runSetReadingProfile( + product, + SET_READING_PROFILE_TWIN_FILES, + 'T7.4-1 (set reading: the collapsed twin `targetTags: ["a", "z"]`, ' + + '`edgeKinds: ["depends", "references"]`)', + ); + assertSameJson( + spelled.report, + twin.report, + "T7.4-1 (set reading): the profile's coverage report equals its " + + "collapsed twin's — the decoded reports, member for member: one " + + "resolved configuration, one report (SPEC 7.4, 12.7, 8.2)", + ); + assertBytesEqual( + spelled.stdoutBytes, + twin.stdoutBytes, + "T7.4-1 (set reading): the two `coverage --json` answers are " + + "byte-identical — the same resolved configuration answers " + + "deterministically (SPEC 7.4, 12.0; a product-to-itself " + + "comparison, H-4)", + ); }, }); @@ -915,6 +1598,61 @@ const RULE_MATRIX: readonly { ], ], }, + // `kinds` not a subset of the dependency edge kinds (7.5) — an otherwise + // invalid rule shape (14.14), one arm per kind of stray member. As in + // T7.4-1, each stray member sits last beside the valid kind "depends", so + // the list is non-empty and non-subset at once: a product that drops or + // ignores members it does not know, or that filters them out and only then + // applies the empty-list rule, keeps a well-formed ["depends"] and builds + // (exit 0 — `build` never evaluates policy, 12.1) — where a lone stray + // member would let such a product refuse by the wrong rule (an emptied + // list) and pass by accident. + { + label: + '`kinds` holding the non-dependency kind "contains" beside ' + + '"depends" — not a subset of the dependency edge kinds (7.5), ' + + "discriminating a product that evaluates policy over `contains` " + + "edges or ignores the stray member", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "main" }', + 'to: { group: "aux" }', + 'kinds: ["depends", "contains"]', + ], + ], + }, + { + label: + '`kinds` holding the unknown token "depend" beside "depends" — not ' + + "a subset (7.5), discriminating a product that accepts and ignores a " + + "stray member", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "main" }', + 'to: { group: "aux" }', + 'kinds: ["depends", "depend"]', + ], + ], + }, + { + label: + "`kinds` holding the non-string element `true` beside " + + '"depends" — the boolean literal the declarative form of 7 admits, ' + + "so the refusal is 14.14's invalid rule shape, not a form error", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "main" }', + 'to: { group: "aux" }', + 'kinds: ["depends", true]', + ], + ], + }, { label: "empty selector `tags` list", rules: [ @@ -926,6 +1664,22 @@ const RULE_MATRIX: readonly { ], ], }, + { + label: + "selector `tags` holding the non-string element `true` (`[true]`, as " + + "TEST-SPEC T7.5-1 spells the fixture) — the boolean literal the " + + "declarative form of 7 admits, so the refusal is 14.14's invalid rule " + + "shape; a product stringifying or silently tolerating the element " + + "accepts the configuration and builds, so exit 0 discriminates it", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + "from: { tags: [true] }", + 'to: { group: "aux" }', + ], + ], + }, { label: "selector with zero of group/files/tags", rules: [ @@ -1006,32 +1760,61 @@ const RULE_MATRIX: readonly { }, ]; +// The matrix's rows as refused-configuration arms, each configuration a +// TypeScript staged-source record made at module load from its row — the +// expression moved, never re-spelled — named `T7.5-1 xspec.config.ts +// (<label>)`: T7.5-1 stages every arm past the first after its first product +// invocation, and the table is one (module header). +const RULE_MATRIX_ARMS: readonly RefusedConfigArm[] = RULE_MATRIX.map( + (arm) => ({ + label: arm.label, + config: stagedTs( + `T7.5-1 xspec.config.ts (${arm.label})`, + matrixConfig(entriesBlock("policy", arm.rules)), + ), + }), +); + // An ambiguous group name in a selector without `kind` → 14.14 (7.5: the // kind MUST be given when the name exists as both a spec and a code group). -const AMBIGUOUS_SELECTOR_CONFIG = dualConfig( - entriesBlock("policy", [ - [ - 'name: "r"', - 'type: "forbidden"', - 'from: { group: "dual" }', - 'to: { group: "main" }', - ], - ]), +// A TypeScript staged-source record (module header): T7.5-1 stages it after +// its first product invocation. +const AMBIGUOUS_SELECTOR_CONFIG = stagedTs( + "T7.5-1 xspec.config.ts (a selector naming the ambiguous group dual " + + "without kind)", + dualConfig( + entriesBlock("policy", [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "dual" }', + 'to: { group: "main" }', + ], + ]), + ), ); const T7_5_1 = defineProductTest({ id: "T7.5-1", title: "rule validation: duplicate names, each missing required field, an " + - "invalid type, empty kinds/tags lists, selectors with zero or two of " + - "group/files/tags, unknown, wrong-kind, and ambiguous group references, " + - "a capture used twice in `from`, and a `to` referencing an absent " + - "capture are configuration errors (SPEC 7.5, 14.14, exit 2)", + "invalid type, empty kinds/tags lists, `kinds` not a subset of the " + + "dependency edge kinds (a `contains` member, an unknown token, a " + + "non-string element), a non-string selector `tags` element, selectors " + + "with zero or two of group/files/tags, unknown, wrong-kind, and " + + "ambiguous group references, a capture used twice in `from`, and a " + + "`to` referencing an absent capture are configuration errors (SPEC " + + '7.5, 14.14, exit 2); set reading: a rule\'s `kinds: ["embeds", ' + + '"depends", "embeds"]` and a selector\'s `tags: ["b", "a", "b"]` are ' + + 'valid, `inventory` reporting `kinds` exactly ["depends", "embeds"] ' + + "(5.2's order, however configured) and the selector's `tags` exactly " + + '["a", "b"] (byte order, collapsed), and the rule\'s `check` findings ' + + "equal to its collapsed twin's (SPEC 7.5, 12.7, 11.6)", run: async (product) => { - for (const arm of RULE_MATRIX) { + for (const arm of RULE_MATRIX_ARMS) { await expectConfigRefused( product, - matrixConfig(entriesBlock("policy", arm.rules)), + arm.config, MATRIX_FILES, `T7.5-1 (${arm.label})`, ); @@ -1043,6 +1826,37 @@ const T7_5_1 = defineProductTest({ "T7.5-1 (selector naming the ambiguous group dual — both a spec and a " + "code group — without kind)", ); + + // Set reading (7.5, 12.7): a rule's `kinds: ["embeds", "depends", + // "embeds"]` and a selector's `tags: ["b", "a", "b"]` are valid, + // `inventory` reports them in their value forms, and the rule's `check` + // findings equal its collapsed twin's. + const spelled = await runSetReadingRule( + product, + SET_READING_RULE_FILES, + 'T7.5-1 (set reading: the rule spelled `kinds: ["embeds", "depends", ' + + '"embeds"]`, `from: { tags: ["b", "a", "b"] }`)', + ); + const twin = await runSetReadingRule( + product, + SET_READING_RULE_TWIN_FILES, + 'T7.5-1 (set reading: the collapsed twin `kinds: ["depends", ' + + '"embeds"]`, `from: { tags: ["a", "b"] }`)', + ); + assertSameJson( + spelled.findings, + twin.findings, + "T7.5-1 (set reading): the rule's `check` findings equal its " + + "collapsed twin's — the decoded findings arrays, every member " + + "(SPEC 7.5, 12.7, 12.0)", + ); + assertBytesEqual( + spelled.stdoutBytes, + twin.stdoutBytes, + "T7.5-1 (set reading): the two `check --json` answers are " + + "byte-identical — one resolved configuration, one deterministic " + + "report (SPEC 7.5, 12.0; a product-to-itself comparison, H-4)", + ); }, }); @@ -1101,8 +1915,11 @@ Mid uses the low group. // `kinds` restriction: one source node with a depends edge AND an embeds edge // into the forbidden group; kinds ["embeds"] evaluates only the embeds edge. -const FORBIDDEN_KINDS_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// The configuration is a TypeScript staged-source record (module header). +const FORBIDDEN_KINDS_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T7.5-2 kinds restriction xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1120,7 +1937,10 @@ export default defineConfig({ ] }) `, - "hi/H.mdx": `import L from "../lo/L.xspec" + ), + "hi/H.mdx": stagedMdx( + "T7.5-2 kinds restriction hi/H.mdx", + `import L from "../lo/L.xspec" <S id="has" d={L.l}> Uses the low group, and embeds it: @@ -1128,7 +1948,10 @@ Uses the low group, and embeds it: {text(L.l2)} </S> `, - "lo/L.mdx": `<S id="l"> + ), + "lo/L.mdx": stagedMdx( + "T7.5-2 kinds restriction lo/L.mdx", + `<S id="l"> Low one. </S> @@ -1136,6 +1959,7 @@ Low one. Low two. </S> `, + ), }; const T7_5_2 = defineProductTest({ @@ -1315,9 +2139,12 @@ T.t // (b) `files` selectors match by glob, on both sides: only the edge whose // source file matches `from`'s glob AND whose target file matches `to`'s -// glob is flagged. -const SELECTOR_FILES_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// glob is flagged. The configuration is a TypeScript staged-source record +// (module header), as is (c)'s. +const SELECTOR_FILES_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T7.5-4 files selectors xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1333,7 +2160,10 @@ export default defineConfig({ ] }) `, - "specs/inner/A.mdx": `import C from "../C.xspec" + ), + "specs/inner/A.mdx": stagedMdx( + "T7.5-4 files selectors specs/inner/A.mdx", + `import C from "../C.xspec" import D from "../D.xspec" <S id="a" d={C.c}> @@ -1344,22 +2174,28 @@ Inner depends on C. Inner depends on D — the to glob does not match this target. </S> `, - "specs/B.mdx": `import C from "./C.xspec" + ), + "specs/B.mdx": stagedMdx( + "T7.5-4 files selectors specs/B.mdx", + `import C from "./C.xspec" <S id="b" d={C.c}> Outer depends on C — the from glob does not match this source. </S> `, - "specs/C.mdx": mdxSection("c"), - "specs/D.mdx": mdxSection("d"), + ), + "specs/C.mdx": SECTION_C_SOURCE, + "specs/D.mdx": SECTION_D_SOURCE, }; // (c) `tags` selectors match nodes carrying AT LEAST ONE listed tag: `r` // carries red only, `b` carries blue (plus the unlisted green) — both match // ["red", "blue"] (an all-tags product matches neither); untagged `u` does // not. -const SELECTOR_TAGS_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +const SELECTOR_TAGS_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T7.5-4 tags selector xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1376,7 +2212,10 @@ export default defineConfig({ ] }) `, - "pol/P.mdx": `import T from "../tgt/T.xspec" + ), + "pol/P.mdx": stagedMdx( + "T7.5-4 tags selector pol/P.mdx", + `import T from "../tgt/T.xspec" <S id="r" tags="red" d={T.t}> Red-tagged dependence. @@ -1390,7 +2229,8 @@ Blue-and-green-tagged dependence. Untagged dependence. </S> `, - "tgt/T.mdx": mdxSection("t"), + ), + "tgt/T.mdx": SECTION_T_SOURCE, }; const T7_5_4 = defineProductTest({ @@ -1497,9 +2337,12 @@ ALT.n // string, the capture takes its one-byte minimum). Targets exist for the // correct expansion (tgt/a.mdx), the greedy-capture expansion (tgt/abc.mdx), // and the greedy-leading-`*` expansion (tgt/c.mdx); exactly the first is -// flagged. -const CAPTURE_STAR_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// flagged. The configuration is a TypeScript staged-source record (module +// header), as is every configuration and code source of arms (c)-(j). +const CAPTURE_STAR_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T7.5-5 (b) *$1* against abc xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1516,7 +2359,10 @@ export default defineConfig({ ] }) `, - "grp/abc/F.mdx": `import A from "../../tgt/a.xspec" + ), + "grp/abc/F.mdx": stagedMdx( + "T7.5-5 (b) *$1* against abc grp/abc/F.mdx", + `import A from "../../tgt/a.xspec" import X from "../../tgt/abc.xspec" import C from "../../tgt/c.xspec" @@ -1524,16 +2370,19 @@ import C from "../../tgt/c.xspec" Depends on every candidate expansion's node. </S> `, - "tgt/a.mdx": mdxSection("a"), - "tgt/abc.mdx": mdxSection("x"), - "tgt/c.mdx": mdxSection("c"), + ), + "tgt/a.mdx": SECTION_A_SOURCE, + "tgt/abc.mdx": SECTION_X_SOURCE, + "tgt/c.mdx": SECTION_C_SOURCE, }; // (c) A capture never matches the empty string or `/`: `pre/$1x.mdx` matches // pre/ax.mdx ($1 = a) but neither pre/x.mdx ($1 would be empty) nor // pre/d/ex.mdx ($1 would be d/e — a whole-path-regex product matches it). -const CAPTURE_LIMITS_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +const CAPTURE_LIMITS_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T7.5-5 (c) capture limits xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1550,32 +2399,44 @@ export default defineConfig({ ] }) `, - "pre/ax.mdx": `import T from "../tgt/T.xspec" + ), + "pre/ax.mdx": stagedMdx( + "T7.5-5 (c) capture limits pre/ax.mdx", + `import T from "../tgt/T.xspec" <S id="s1" d={T.t}> Source whose file matches with a one-byte capture. </S> `, - "pre/x.mdx": `import T from "../tgt/T.xspec" + ), + "pre/x.mdx": stagedMdx( + "T7.5-5 (c) capture limits pre/x.mdx", + `import T from "../tgt/T.xspec" <S id="s2" d={T.t}> Source whose file would need an empty capture. </S> `, - "pre/d/ex.mdx": `import T from "../../tgt/T.xspec" + ), + "pre/d/ex.mdx": stagedMdx( + "T7.5-5 (c) capture limits pre/d/ex.mdx", + `import T from "../../tgt/T.xspec" <S id="s3" d={T.t}> Source whose file would need a capture spanning a slash. </S> `, - "tgt/T.mdx": mdxSection("t"), + ), + "tgt/T.mdx": SECTION_T_SOURCE, }; // (d) The mirror-structure allowedOnly fixture: every edge from src/$1.ts // must target a node of m/$1.mdx — src/good.ts → m/good.mdx#g agrees (no // finding), src/evil.ts → m/wrong.mdx#w disagrees (a finding). -const CAPTURE_MIRROR_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +const CAPTURE_MIRROR_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T7.5-5 (d) mirror-structure allowedOnly xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1594,18 +2455,301 @@ export default defineConfig({ ] }) `, - "src/good.ts": `import G from "../m/good.xspec" + ), + "src/good.ts": stagedTs( + "T7.5-5 (d) mirror-structure allowedOnly src/good.ts", + `import G from "../m/good.xspec" G.g `, - "src/evil.ts": `import W from "../m/wrong.xspec" + ), + "src/evil.ts": stagedTs( + "T7.5-5 (d) mirror-structure allowedOnly src/evil.ts", + `import W from "../m/wrong.xspec" W.w `, - "m/good.mdx": mdxSection("g"), - "m/wrong.mdx": mdxSection("w"), + ), + "m/good.mdx": SECTION_G_SOURCE, + "m/wrong.mdx": SECTION_W_SOURCE, +}; + +// (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`: one + * TypeScript staged-source record for every path (e)'s and (g)'s workspaces + * stage it at (module header), well-formed — at `src/end$` and `src/end`, + * names the default does not reach, too (the record makes the path judged). + */ +const CODE_MARKER_TO_P = stagedTs( + "T7.5-5 the code source bearing one marker into tgt/P.mdx#p (src/a$0.ts; " + + "src/ab.ts; src/a0.ts; src/end$; src/end)", + '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, InitialFileContents> +> = { + "xspec.config.ts": stagedTs( + "T7.5-5 (e) $0 in from 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": SECTION_P_SOURCE, +}; + +// (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, InitialFileContents>> = + { + "xspec.config.ts": stagedTs( + "T7.5-5 (f) $0 in to 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": stagedMdx( + "T7.5-5 (f) $0 in to 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": SECTION_P_SOURCE, + "tgt/tb.mdx": SECTION_Q_SOURCE, + "tgt/t0.mdx": SECTION_R_SOURCE, + }; + +// (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, InitialFileContents> +> = { + "xspec.config.ts": stagedTs( + "T7.5-5 (g) trailing $ in from 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": SECTION_P_SOURCE, }; +// (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, InitialFileContents>> = + { + "xspec.config.ts": stagedTs( + "T7.5-5 (h) trailing $ in to 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": stagedMdx( + "T7.5-5 (h) trailing $ in to 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": SECTION_T_SOURCE, + }; + +/** (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, InitialFileContents> +> = { + "xspec.config.ts": stagedTs( + "T7.5-5 (i) $ before a non-digit in from 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": stagedMdx( + "T7.5-5 (i) $ before a non-digit in from 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": stagedMdx( + "T7.5-5 (i) $ before a non-digit in from pre/ax.mdx", + `import T from "../tgt/T.xspec" + +<S id="s2" d={T.t}> +Dropped-dollar bait. +</S> +`, + ), + "pre/aQx.mdx": stagedMdx( + "T7.5-5 (i) $ before a non-digit in from pre/aQx.mdx", + `import T from "../tgt/T.xspec" + +<S id="s3" d={T.t}> +One-byte-wildcard bait. +</S> +`, + ), + "tgt/T.mdx": SECTION_T_SOURCE, +}; + +// (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, InitialFileContents>> = + { + "xspec.config.ts": stagedTs( + "T7.5-5 (j) $ before a non-digit in to 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": stagedMdx( + "T7.5-5 (j) $ before a non-digit in to 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": SECTION_P_SOURCE, + "tgt/tz.mdx": SECTION_Q_SOURCE, + "tgt/tQz.mdx": SECTION_R_SOURCE, + }; + const CAPTURE_PAIR_EXPECTED: readonly PolicyExpectation[] = [ { rule: "pair", @@ -1623,9 +2767,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 +2860,138 @@ 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. S-9: both code sources are discovered by the extension-free glob + // `src/*`, names the default does not reach — declared well-formed by + // their record, CODE_MARKER_TO_P, which makes each path judged. + 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)", + ); }, }); @@ -1792,7 +3071,13 @@ const T7_5_6 = defineProductTest({ // …and regenerates output: tamper with a generated module, rebuild, // and the module is byte-restored (12.1 regenerates every derived // file; 12.0 makes the regenerated bytes deterministic, so the - // product-to-itself byte comparison is exact — H-4). + // product-to-itself byte comparison is exact — H-4). The tampered + // module is an edit of product-written bytes — a derived file, no + // code source (no code group globs it), whose well-formedness the + // document does not declare — so it is staged `unchecked` (S-9): + // never a staged-source record (no harness constant equals those + // bytes), and never judged, which would turn a product's malformed + // module into a harness error (H-8). const moduleRel = "hi/H.xspec.ts"; const original = await readGeneratedModule( workspace, @@ -1800,7 +3085,9 @@ const T7_5_6 = defineProductTest({ "T7.5-6 after the first build (SPEC 13.1: hi/H.mdx generates " + "hi/H.xspec.ts in the source file's directory)", ); - await workspace.file(moduleRel, `${original}// tampered\n`); + await workspace.file(moduleRel, `${original}// tampered\n`, { + ts: "unchecked", + }); await buildOk( product, workspace, diff --git a/test/suite/registry/section-8.ts b/test/suite/registry/section-8.ts index a7f33b16..73e165e7 100644 --- a/test/suite/registry/section-8.ts +++ b/test/suite/registry/section-8.ts @@ -83,8 +83,11 @@ import { import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { assertConditionCounts, assertEdgeSetEqual, @@ -100,7 +103,7 @@ import { /** Stage a fresh workspace from files, run `body`, dispose (H-1). */ async function withWorkspace<T>( - files: Readonly<Record<string, string>>, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ files }); @@ -763,17 +766,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(); } /** @@ -890,9 +900,15 @@ function assertRootExclusionImpact( // vs "all", `coverage="none"` exclusion, and root exclusion. The boundary // group has no outgoing dependency edges, so nothing is covered and each // profile's uncovered set IS its required set (required = covered ∪ -// uncovered). -const REQUIRED_SET_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// uncovered). It follows arm (a)'s invocations, so its `.mdx` sources are +// staged-source records (helpers/staged-mdx.ts; S-9's before-any-product +// clause), wrapped in place, and so is its configuration — a TypeScript +// staged-source record (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), well-formed. +const REQUIRED_SET_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T8-5 required-set fixture xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -924,7 +940,10 @@ export default defineConfig({ ] }) `, - "tgt/T.mdx": `<S id="t"> + ), + "tgt/T.mdx": stagedMdx( + "T8-5 required-set fixture tgt/T.mdx", + `<S id="t"> Internal parent behavior. <S id="t.leaf" tags="hot"> @@ -940,16 +959,31 @@ Untagged leaf behavior. </S> </S> `, - "bnd/B.mdx": `<S id="b"> + ), + "bnd/B.mdx": stagedMdx( + "T8-5 required-set fixture bnd/B.mdx", + `<S id="b"> Boundary node with no outgoing dependency edges. </S> `, - "oth/O.mdx": `<S id="o"> + ), + "oth/O.mdx": stagedMdx( + "T8-5 required-set fixture oth/O.mdx", + `<S id="o"> A node of a group that is neither target nor boundary. </S> `, + ), }; +// The b1 edit is staged after the root-exclusion arm's `build` and queries, +// so it is a ledger record (S-9's before-any-product clause; +// helpers/staged-mdx.ts) — the same template call, moved to module level. +const T8_5_B_EDITED = stagedMdx( + "T8-5 specs/B.mdx with b1's text edited", + derivedSource("Derived leaf one, edited."), +); + const T8_5 = defineProductTest({ id: "T8-5", title: @@ -1071,10 +1105,7 @@ const T8_5 = defineProductTest({ // containment cannot explain it — and B-root's through containment, // hence a1's through the root-targeted pair: A-root and a1 are both // upstream-changed (SPEC 5.5, 5.6). - await workspace.file( - "specs/B.mdx", - derivedSource("Derived leaf one, edited."), - ); + await workspace.file("specs/B.mdx", T8_5_B_EDITED); await buildOk( product, workspace, @@ -1299,9 +1330,15 @@ function normalizedReport(report: CoverageReport): unknown { } // The fully covered workspace for `--check`'s "0 otherwise" arm: one -// profile, one required leaf, covered. -const CHECK_GREEN_FILES: Readonly<Record<string, string>> = { - "xspec.config.ts": `import { defineConfig } from "xspec" +// profile, one required leaf, covered. It follows the report workspace's +// invocations, so its `.mdx` sources are staged-source records +// (helpers/staged-mdx.ts; S-9's before-any-product clause), wrapped in place, +// and so is its configuration — a TypeScript staged-source record +// (helpers/staged-ts.ts; S-9's TypeScript and timing clauses), well-formed. +const CHECK_GREEN_FILES: Readonly<Record<string, InitialFileContents>> = { + "xspec.config.ts": stagedTs( + "T8.2-1 covered fixture xspec.config.ts", + `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1318,16 +1355,23 @@ export default defineConfig({ ] }) `, - "tgt/T.mdx": `<S id="only"> + ), + "tgt/T.mdx": stagedMdx( + "T8.2-1 covered fixture tgt/T.mdx", + `<S id="only"> The only required leaf. </S> `, - "bnd/B.mdx": `import T from "../tgt/T.xspec" + ), + "bnd/B.mdx": stagedMdx( + "T8.2-1 covered fixture bnd/B.mdx", + `import T from "../tgt/T.xspec" <S id="covers" d={T.only}> Covers the only leaf. </S> `, + ), }; const T8_2_1 = defineProductTest({ diff --git a/test/suite/registry/section-9.3.ts b/test/suite/registry/section-9.3.ts index 58e82131..ac55773f 100644 --- a/test/suite/registry/section-9.3.ts +++ b/test/suite/registry/section-9.3.ts @@ -41,7 +41,10 @@ import type { import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { type StagedTs, stagedTs } from "../../helpers/staged-ts.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; import { SPECS_ONLY_CONFIG, assertImpactCategories, @@ -54,10 +57,15 @@ import { } from "./section-9.js"; import { assertSameJson, buildOk, expectExit } from "./support.js"; -/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +/** + * Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). + * The configuration is a TypeScript staged-source record where the workspace + * follows the body's first product invocation (T9.3-3's arm 2; S-9's + * TypeScript and timing clauses), plain text elsewhere. + */ async function withWorkspace<T>( - config: string, - files: Readonly<Record<string, string>>, + config: string | StagedTs, + files: Readonly<Record<string, InitialFileContents>>, body: (workspace: TestWorkspace) => Promise<T>, ): Promise<T> { const workspace = await TestWorkspace.create({ @@ -71,12 +79,14 @@ async function withWorkspace<T>( } /** - * Apply a manual edit to a source file the product may have rewritten: - * read it, require `expected` to occur exactly once (a crisp premise - * diagnosis ahead of the impact assertions, the T9.2-5 pattern — rename - * rewrites are minimal in-place edits, SPEC 6.4, so the staged construct must - * still be present verbatim up to its rewritten identity), and replace it - * with `replacement`. + * Apply a manual edit to a source file the product has rewritten: read it, + * require `expected` to occur exactly once (a crisp premise diagnosis ahead + * of the impact assertions, the T9.2-5 pattern — rename rewrites are + * minimal in-place edits, SPEC 6.4, so the staged construct must still be + * present verbatim up to its rewritten identity), and replace it with + * `replacement` through `workspace.edit()` — an edit of bytes the product + * wrote, judged at staging time (no harness constant equals them, so it is + * no staged-source record; helpers/staged-mdx.ts). */ async function editSourceExpecting( workspace: TestWorkspace, @@ -95,7 +105,7 @@ async function editSourceExpecting( `6.4); got: ${JSON.stringify(text)}`, ); } - await workspace.file(rel, text.replace(expected, replacement)); + await workspace.edit(rel, expected, replacement); } // --------------------------------------------------------------------------- @@ -531,6 +541,14 @@ const mdepSource = (d: string): string => "</S>", "", ].join("\n"); +// The metadata-terminus edit (staged after run 1's build and `impact`): `dd`'s +// `d` list gaining MTgts.t2 — a staged-source record (helpers/staged-mdx.ts), +// the same template call moved to module level. The run-1 edits precede the +// body's first product invocation and stay plain stagings. +const T9_3_2_MDEP_EDITED = stagedMdx( + "T9.3-2 specs/MDep.mdx with dd's d list gaining MTgts.t2", + mdepSource("[MTgts.t1, MTgts.t2]"), +); const S2_MX_SOURCE = [ 'import MDep from "./MDep.xspec"', "", @@ -694,7 +712,7 @@ const T9_3_2 = defineProductTest({ // Metadata terminus: edit only `dd`'s `d` list against the second // baseline. - await workspace.file(S2_MDEP, mdepSource("[MTgts.t1, MTgts.t2]")); + await workspace.file(S2_MDEP, T9_3_2_MDEP_EDITED); await buildOk( product, workspace, @@ -782,16 +800,32 @@ const D1_RENAMED_BLOCK = ['<S id="new">', "Doomed node text.", "</S>", ""].join( const D2_FILE = "specs/Twice.mdx"; const D2_DM = "specs/Twice.mdx#dm"; const D2_KEEP = "specs/Twice.mdx#keep2"; -const D2_BASELINE = [ - '<S id="d0">', - "Doomed original text.", - "</S>", - "", - '<S id="keep2">', - "Kept sibling two text.", - "</S>", - "", -].join("\n"); +// Arm 2 follows arm 1's invocations, so its baseline is a staged-source +// record (helpers/staged-mdx.ts; S-9's before-any-product clause) — the +// same expression, wrapped in place; arm 1's `D1_BASELINE`, the body's +// first workspace, stays plain. +const D2_BASELINE = stagedMdx( + "T9.3-3 arm 2 specs/Twice.mdx (the baseline)", + [ + '<S id="d0">', + "Doomed original text.", + "</S>", + "", + '<S id="keep2">', + "Kept sibling two text.", + "</S>", + "", + ].join("\n"), +); +// Arm 2's configuration follows arm 1's invocations too, so it is a +// TypeScript staged-source record (helpers/staged-ts.ts; S-9's TypeScript and +// timing clauses), well-formed: section-5.6.ts's SPECS_ONLY_CONFIG, the same +// expression moved here. Arm 1's workspace, the body's first, stays plain, +// as do T9.3-1's and T9.3-2's (each body's only one). +const T9_3_3_ARM_2_CONFIG = stagedTs( + "T9.3-3 arm 2 xspec.config.ts — one spec group (section-5.6.ts's SPECS_ONLY_CONFIG)", + SPECS_ONLY_CONFIG, +); const D2_RENAMED_BLOCK = ['<S id="dm">', "Doomed original text.", "</S>"].join( "\n", ); @@ -870,7 +904,7 @@ const T9_3_3 = defineProductTest({ // Arm 2 — the twice-reported reintroduced identity. await withWorkspace( - SPECS_ONLY_CONFIG, + T9_3_3_ARM_2_CONFIG, { [D2_FILE]: D2_BASELINE }, async (workspace) => { await workspace.gitInit(); diff --git a/test/suite/registry/section-9.ts b/test/suite/registry/section-9.ts index 68aa00af..ebc0de7b 100644 --- a/test/suite/registry/section-9.ts +++ b/test/suite/registry/section-9.ts @@ -38,6 +38,12 @@ // informational, SPEC 9.3) and, with differences present, that the report // on stdout mentions the changed node's identity (12.0: reports are // standard-output content; H-3 robust matching, never wording). +// +// Sources staged after a body's first product invocation are staged-source +// records (helpers/staged-mdx.ts, S-9: judged before any product exists) +// or, for product-rewritten bytes, `workspace.edit()` after a diagnosed +// premise; every other staging precedes the body's first invocation, so +// S-7's sweep reaches it against the stub. import * as fsp from "node:fs/promises"; import type { @@ -48,6 +54,7 @@ import { assertReportMentions } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { @@ -177,6 +184,14 @@ const p1Source = (alphaText: string): string => "", ].join("\n"); +// The edited workspace (staged after the baseline build): `alpha`'s text +// run at v2 — a staged-source record (helpers/staged-mdx.ts), the same +// template call moved to module level. +const T9_1_ALPHA_V2 = stagedMdx( + "T9-1 specs/Main.mdx with alpha's text run at v2", + p1Source("Alpha text v2."), +); + const T9_1 = defineProductTest({ id: "T9-1", title: @@ -249,7 +264,7 @@ const T9_1 = defineProductTest({ // changed node's identity, and the JSON report the 5.6 categories — // the difference is computed against the ref's content, not the // working tree (SPEC 6.3). - await workspace.file(P1_MAIN, p1Source("Alpha text v2.")); + await workspace.file(P1_MAIN, T9_1_ALPHA_V2); await buildOk( product, workspace, @@ -297,6 +312,15 @@ const T9_1 = defineProductTest({ // T9.1-1 — categories equal 5.6 (fixtures shared with T5.6-*) // --------------------------------------------------------------------------- +// Arm B's edit (staged after arm A's build): onmid.dep's `d` list gaining +// Tree.top.other — a staged-source record (helpers/staged-mdx.ts), the same +// template call moved to module level. Arm A's leaf edit precedes the +// body's first product invocation and stays a plain staging. +const T9_1_1_DEPS_EDITED = stagedMdx( + "T9.1-1 specs/Deps.mdx with onmid.dep's d list gaining Tree.top.other", + workedExample.depsSource("[Tree.top.mid, Tree.top.other]"), +); + const T9_1_1 = defineProductTest({ id: "T9.1-1", title: @@ -340,10 +364,7 @@ const T9_1_1 = defineProductTest({ // `descendant-changed`; D's ancestors are `upstream-changed` // attributed to D; the gained target and the whole tree side stay // uncategorized (SPEC 5.6's d-target example). - await workspace.file( - wx.depsFile, - wx.depsSource("[Tree.top.mid, Tree.top.other]"), - ); + await workspace.file(wx.depsFile, T9_1_1_DEPS_EDITED); await buildOk( product, workspace, @@ -867,8 +888,12 @@ const T9_2_5 = defineProductTest({ } // The real edit: the renamed node's own text run, applied to the - // rewritten source (read-modify-write keeps the test independent of - // the rename's exact byte-level rewrite, T6.4-2's business). + // product-rewritten source through `workspace.edit()` — an edit of + // bytes the product wrote, judged at staging time (no harness + // constant equals them; helpers/staged-mdx.ts), after the diagnosed + // premise that the run is still present. The edit keeps the test + // independent of the rename's exact byte-level rewrite (T6.4-2's + // business). const specText = await readSourceText( workspace, C5_SPEC, @@ -882,9 +907,10 @@ const T9_2_5 = defineProductTest({ `(SPEC 6.2, 6.4); got: ${JSON.stringify(specText)}`, ); } - await workspace.file( + await workspace.edit( C5_SPEC, - specText.replace("Renamed node text v1.", "Renamed node text v2."), + "Renamed node text v1.", + "Renamed node text v2.", ); await buildOk( product, diff --git a/test/suite/registry/support.ts b/test/suite/registry/support.ts index 34532316..6d445ad1 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -8,23 +8,82 @@ // 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 * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import type { + AppliedMappingPair, + DecodedDatum, + Finding, + FindingLocation, + GraphEdge, + PathValue, +} from "../../helpers/adapters/index.js"; import { + decodeErrorDocument, + decodeFindingsReport, + decodeInventoryRecordedDatum, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { + assertBytesEqual, assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; -import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { + ArgvValue, + ProductBinding, + RunResult, +} from "../../helpers/subprocess.js"; import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; -import type { TestWorkspace } from "../../helpers/workspace.js"; +import type { InitialFileContents } from "../../helpers/workspace.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; + +/** + * U+FFFD (REPLACEMENT CHARACTER), built from its code point so no tool layer + * can decode or normalize the spelling on the way into this file. + */ +export const REPLACEMENT_CHARACTER = String.fromCodePoint(0xfffd); + +/** + * The U+FFFD-pathed spec source T1.5-2 and T11.5-3 both stage — TEST-SPEC's + * `specs/A\uFFFD.mdx`, one spelling shared so the two tests stage the same + * file. Stageable on every platform: the path is valid UTF-8 (U+FFFD encodes + * as EF BF BD, a name any filesystem holds), unlike the non-UTF-8 byte paths + * of the Linux leg. It is an invalid source path (SPEC 14.19) presented in + * its plain string form — never the marked byte form (12.0) — that no + * argument value names: a value containing U+FFFD is a malformed value, a + * usage error of the syntax class (12.0). + */ +export const REPLACEMENT_CHARACTER_SPEC_PATH = `specs/A${REPLACEMENT_CHARACTER}.mdx`; + +/** + * Stage plain files OUTSIDE the workspace root, at paths relative to the + * root's parent — the workspace's own temporary directory (`tempRoot`, whose + * `work/` is the root), disposed with it. For arms whose subject is what a + * product must never reach: an import specifier whose ascent passes above + * the root designates nothing whatever the root's parent holds (SPEC 2.1, + * 4), so a real file there discriminates lexical resolution from a + * filesystem lookup. + */ +export async function stageBesideRoot( + workspace: TestWorkspace, + files: Readonly<Record<string, string>>, +): Promise<void> { + for (const [rel, contents] of Object.entries(files)) { + const abs = path.join(workspace.tempRoot, rel); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, contents); + } +} /** Run one product command with the workspace root as working directory. */ export async function runCli( product: ProductBinding, workspace: TestWorkspace, - argv: readonly string[], + argv: readonly ArgvValue[], ): Promise<RunResult> { return await runProduct(product, { cwd: workspace.root, argv }); } @@ -33,7 +92,7 @@ export async function runCli( export async function expectExit( product: ProductBinding, workspace: TestWorkspace, - argv: readonly string[], + argv: readonly ArgvValue[], exitCode: number, context: string, ): Promise<RunResult> { @@ -65,14 +124,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 +178,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 ` + @@ -110,6 +205,26 @@ export async function expectConfigurationError( return result; } +/** + * Run a findings-report surface — `build --json`, `check --json`, a gated + * read or refused operation with `--json` — asserting the exact exit code + * (H-5) with exactly one JSON document as the entire stdout (SPEC.md 12.0), + * decoded form-exact as the findings-only report `{"findings": […]}` + * (SPEC.md 12.7; H-3): the one member, the literal finding form, the pinned + * order. Returns the decoded findings. + */ +export async function runFindingsReport( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + exitCode: number, + context: string, +): Promise<readonly Finding[]> { + const result = await expectExit(product, workspace, argv, exitCode, context); + return decodeFindingsReport(parseJsonStdout(result, context), context) + .findings; +} + /** * Run `build --json` over a workspace staged with validation errors: assert * exit 1 (findings are exit-1 outcomes, SPEC.md 12.0; H-5) with exactly one @@ -120,15 +235,46 @@ export async function buildFindings( workspace: TestWorkspace, context: string, ): Promise<readonly Finding[]> { - const result = await expectExit( + return await runFindingsReport( product, workspace, ["build", "--json"], 1, context, ); - return decodeFindingsReport(parseJsonStdout(result, context), context) - .findings; +} + +/** + * Run a findings-report surface on a workspace expected to be finding-free — + * a successful `build --json`, or `check --json` on a clean, freshly built + * workspace — asserting exit 0 (SPEC.md 12.0: success; a report carrying + * any finding exits 1) and exactly `{"findings": []}` as the entire stdout: + * the findings-only form with the empty array — its one member and nothing + * beside it (SPEC.md 12.7: a finding-free `findings` is `[]`, never `null`, + * never omitted). "Exactly" is the form (H-3): the document's member set + * and the array's emptiness, asserted through the form-exact decode — + * SPEC.md 12.0/12.7 pin no byte layout for the serialization, so none is + * compared. + */ +export async function expectFindingFreeReport( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<void> { + const findings = await runFindingsReport( + product, + workspace, + argv, + 0, + `${context} — a finding-free report exits 0 (SPEC 12.0)`, + ); + assertSameJson( + findings, + [], + `${context} — the report is exactly {"findings": []}: the findings-only ` + + `form's one member holding the empty array, never null (SPEC 12.7)`, + ); } /** @@ -158,11 +304,139 @@ export async function readGeneratedModule( } } +/** + * The companion paths `inventory`'s `recorded` set lists for one spec source + * (SPEC 11.6, T11.6-3), taken from the decoded record-supplied datum of an + * `inventory` run after a successful `build`: the recorded entries + * `DIR/NAME.xspec.<suffix>` other than the module `DIR/NAME.xspec.ts`, each + * attributable to its source through 13.1's naming, the suffixes the + * product's own (as T6.6-5 composes them): none for a product writing no + * companions. The datum must be the plain list (after a successful `build` + * a record exists and is readable, 13.3), and it must name the module — a + * record lacking it is no reading of the product's derived paths — and every + * entry under the name shape must be a plain file name beside the module + * (13.1: `NAME.xspec.` plus a non-empty suffix); each slip fails diagnosed + * (H-8). Returned in the record's byte order (11.6). + */ +export function recordedCompanionPaths( + recorded: DecodedDatum<readonly PathValue[]>, + sourcePath: string, + inventoryContext: string, +): readonly string[] { + if (!sourcePath.endsWith(".mdx") || sourcePath.length <= ".mdx".length) { + throw new Error( + `harness defect: ${inventoryContext} — ${JSON.stringify(sourcePath)} ` + + "is not a NAME.mdx spec source path (SPEC 13.1)", + ); + } + const prefix = `${sourcePath.slice(0, -".mdx".length)}.xspec.`; + const modulePath = `${prefix}ts`; + const prefixBytes = Buffer.from(prefix, "utf8"); + if (recorded.state !== "value") { + fail( + `${inventoryContext}: after a successful \`build\` the record-` + + `supplied datum is the plain list of recorded derived-file paths ` + + `— never unavailability, never null (SPEC 13.3, 11.6, 12.7); got ` + + `state ${JSON.stringify(recorded.state)}`, + ); + } + const rendered = recorded.value.map(renderPathValue); + let moduleRecorded = false; + const companions: string[] = []; + for (const entry of recorded.value) { + const bytes = + typeof entry === "string" + ? Buffer.from(entry, "utf8") + : Buffer.from(entry.bytes, "hex"); + if ( + bytes.length < prefixBytes.length || + !bytes.subarray(0, prefixBytes.length).equals(prefixBytes) + ) { + continue; + } + if (entry === modulePath) { + moduleRecorded = true; + continue; + } + const suffix = typeof entry === "string" ? entry.slice(prefix.length) : ""; + if (suffix.length === 0 || suffix.includes("/")) { + fail( + `${inventoryContext}: the record lists ${renderPathValue(entry)} ` + + `under ${sourcePath}'s name shape, but every companion is a ` + + `plain file beside the module named \`NAME.xspec.\` plus a ` + + `suffix, valid UTF-8 like its source's name (SPEC 13.1, 13.3); ` + + `recorded ${JSON.stringify(rendered)}`, + ); + } + companions.push(entry as string); + } + if (!moduleRecorded) { + fail( + `${inventoryContext}: the record names the derived files the build ` + + `generated — ${sourcePath}'s module ${modulePath} among them ` + + `(SPEC 13.1, 13.3, 11.6); recorded ${JSON.stringify(rendered)}`, + ); + } + return companions; +} + +/** + * The companion paths a product records for one spec source, read as + * TEST-SPEC T13.4-9(e) reads them (T6.5-20's companion legs read them the + * same way): a scratch twin holding, under the staging's configuration, the + * source's bytes at its path alone is built — `build` exit 0 — and + * `inventory`'s `recorded` set is read (SPEC 11.6, T11.6-3), the companions + * taken from it by `recordedCompanionPaths` (its diagnosed slips included). + * The twin is disposed before returning (H-1); its initial files are + * whatever the caller passes — a staged-source record wherever the twin + * follows a product invocation (S-9's timing clause). + */ +export async function readRecordedCompanionPaths( + product: ProductBinding, + config: InitialFileContents, + sourcePath: string, + source: InitialFileContents, + context: string, +): Promise<readonly string[]> { + if (!sourcePath.endsWith(".mdx") || sourcePath.length <= ".mdx".length) { + throw new Error( + `harness defect: ${context} — ${JSON.stringify(sourcePath)} is not a ` + + "NAME.mdx spec source path (SPEC 13.1)", + ); + } + const twin = await TestWorkspace.create({ + files: { "xspec.config.ts": config, [sourcePath]: source }, + }); + try { + await buildOk( + product, + twin, + `${context}: the scratch twin's \`build\` — ${sourcePath}'s bytes at ` + + `that path alone, under the staging's configuration, pass ` + + `\`build\`'s validations, exit 0 (SPEC 12.1)`, + ); + const inventoryContext = `${context}: the scratch twin's \`inventory\` after its build`; + return recordedCompanionPaths( + decodeInventoryRecordedDatum( + await runJson(product, twin, ["inventory"], inventoryContext), + inventoryContext, + ), + sourcePath, + inventoryContext, + ); + } finally { + await twin.dispose(); + } +} + /** * 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 +445,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) @@ -184,6 +459,36 @@ export function assertConditionCounts( ); } +/** + * A condition's findings in (file, range start) order — for a test staging + * one condition more than once in a single `build --json` sweep (T14-2, + * T2.4-5), where an exactly-one selection does not apply (the count is + * `assertConditionCounts`'s). A finding without a location sorts first; + * `assertFindingLocated` then rejects it. + */ +export function findingsInSourceOrder( + findings: readonly Finding[], + condition: string, +): Finding[] { + const key = (finding: Finding): readonly [string, number] => { + const first = finding.locations[0]; + if (first === undefined) return ["", -1]; + return [ + typeof first.file === "string" ? first.file : first.file.bytes, + first.range.start, + ]; + }; + return findings + .filter((finding) => finding.condition === condition) + .slice() + .sort((a, b) => { + const [fileA, startA] = key(a); + const [fileB, startB] = key(b); + if (fileA !== fileB) return fileA < fileB ? -1 : 1; + return startA - startB; + }); +} + /** * A staged construct's byte window within a `prefix + construct + suffix` * fixture whose parts are known exactly: the construct's own byte range, @@ -200,13 +505,49 @@ export function byteWindow( return { start, end: start + Buffer.byteLength(construct, "utf8") + 1 }; } +/** + * A staging TEST-SPEC declares unparseable (14.20) whose syntax-failure + * offset its home test pins and T14-11 re-asserts the same way (TEST-SPEC + * T14-11's closing clause over T2.3-3, T2.4-2, T2.7-3, T2.7-4, and T14-12): + * exported by the home module from the very bytes its own arm drives, never + * re-spelled by the consumer. `files` holds every source the arm stages — + * the unparseable `file` and whatever valid sources stand beside it — the + * configuration excluded (the consumer supplies one discovering them); + * `offset` is the byte length of the longest whole-character prefix with + * which some well-formed file begins (SPEC 14, 1.7), the one zero-length + * range `{offset, offset}` of the file's sole 14.20 finding. Every spec + * source is a staged-source record (helpers/staged-mdx.ts) the home module + * registers at load — the unparseable `file` declared unparseable, a source + * beside it under its home declaration — and stages through this very map, + * so the S-9 self-test judges each before any product exists and both + * stagings, the home arm's and T14-11's (each after its body's first + * product invocation), carry that one declaration; a code source is staged + * as plain contents, a `code-source` staging's failing `file` declared + * unparseable by its S-9 TypeScript declaration at each staging site. + */ +export interface UnparseableStaging { + /** The form under test (diagnostics). */ + readonly name: string; + /** Where the failing file lies: a spec source (`.mdx`) or a code source (`.ts`). */ + readonly kind: "spec-source" | "code-source"; + /** The unparseable file's workspace-relative path. */ + readonly file: string; + /** + * Every staged source, the configuration excluded: each `.mdx` source a + * staged-source record carrying its S-9 declaration. + */ + readonly files: Readonly<Record<string, InitialFileContents>>; + /** The failure's byte offset: SPEC 14's zero-length range `{offset, offset}`. */ + readonly offset: number; +} + /** What a finding must identify about its source (SPEC.md 14 preamble). */ 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,43 +555,301 @@ 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) { + if (finding.locations.length === 0) { 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)})`, + `${context}: the finding must carry a location (SPEC.md 14: errors identify ` + + `the file, location, and correction; 12.7 locations); got none (message: ` + + `${JSON.stringify(finding.message)})`, ); } - if (finding.location === undefined) { + 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)})`, + ); +} + +/** + * One expected bearer of a jointly located concern whose bearers may nest — + * a colliding section and its colliding child (SPEC.md 14, 6.4): its + * containing file, its whole construct's byte window (the module-header + * window convention: any in-construct precision passes), and, for a bearer + * whose construct encloses another expected bearer's, `startBefore` — the + * byte offset where the first enclosed construct begins, an exclusive bound + * the location's start must fall before. An enclosed bearer's construct + * lies within its parent's window, so windows alone cannot tell "the parent + * and the child" from "the child twice"; the bound attributes each location + * to one bearer at whatever precision the product locates — the opening + * tag, the `id` attribute, and the whole construct all start inside the + * parent's own leading bytes, before any enclosed construct. + */ +export interface BearerLocationExpectation { + /** The workspace-relative, `/`-separated source file (SPEC.md 1.5, 14). */ + readonly file: string; + /** The bearer's whole construct as a byte window (`byteWindow`). */ + readonly window: { readonly start: number; readonly end: number }; + /** Exclusive bound on the location's start: where an enclosed bearer begins. */ + readonly startBefore?: number; +} + +/** + * Assert a finding locates EVERY expected bearer and nothing else (SPEC.md + * 14's location-cardinality rule — a condition several constructs jointly + * violate is one finding carrying a location for every participating + * construct, no representative chosen; the every-participant strictness, + * with none of `assertFindingMentionsLocation`'s SOME-quantified + * tolerance): exactly one location per bearer, index-wise in 12.7's + * within-finding order (file bytes, then start, then end — an enclosing + * bearer precedes the bearers it encloses at any precision), each in its + * bearer's file within the bearer's byte window and, where a `startBefore` + * bound is declared, starting before it; and, locating in source, the + * finding concerns no path (12.7: `path` null for located conditions). + */ +export function assertFindingLocatesExactly( + finding: Finding, + bearers: readonly BearerLocationExpectation[], + context: string, +): void { + const rendered = (): string => + finding.locations + .map( + (location) => + `${renderPathValue(location.file)} [${String(location.range.start)}, ` + + `${String(location.range.end)})`, + ) + .join("; "); + if (finding.locations.length !== bearers.length) { fail( - `${context}: the finding must carry a location (SPEC.md 14: errors identify ` + - `the file, location, and correction); got none (message: ` + + `${context}: one finding carries a location for every participating ` + + `bearer and none beside — expected exactly ${String(bearers.length)} ` + + `location(s), got ${String(finding.locations.length)} ` + + `[${rendered()}] (SPEC.md 14, 12.7; message: ` + `${JSON.stringify(finding.message)})`, ); } - const { window } = expected; - if ( - window !== undefined && - (finding.location.start < window.start || finding.location.end > window.end) - ) { + bearers.forEach((bearer, index) => { + const location = finding.locations[index]!; + const inFile = location.file === bearer.file; + const inWindow = + location.range.start >= bearer.window.start && + location.range.end <= bearer.window.end; + const attributable = + bearer.startBefore === undefined || + location.range.start < bearer.startBefore; + if (!inFile || !inWindow || !attributable) { + fail( + `${context}: location #${String(index + 1)} must locate bearer ` + + `#${String(index + 1)} — in ${JSON.stringify(bearer.file)} within ` + + `its byte window [${String(bearer.window.start)}, ` + + `${String(bearer.window.end)}]` + + (bearer.startBefore === undefined + ? "" + : `, starting before byte ${String(bearer.startBefore)} (the ` + + `bearer's own leading bytes: an enclosed bearer's location ` + + `is never this one's)`) + + ` — in 12.7's within-finding order (file bytes, then start, then ` + + `end); got ${renderPathValue(location.file)} ` + + `[${String(location.range.start)}, ${String(location.range.end)}) ` + + `among [${rendered()}] (SPEC.md 14, 12.7; message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + }); + if (finding.path !== null) { fail( - `${context}: 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: ` + + `${context}: a finding locating in source concerns no path — ` + + `\`path\` is null for located conditions (SPEC.md 12.7, 14); got ` + + `${renderPathValue(finding.path)} (message: ` + `${JSON.stringify(finding.message)})`, ); } } +/** + * The refusal reasons whose `identities` content SPEC.md 14 pins: a reason + * concerning an identity — `refused-invalid-id`, `refused-identity-unchanged`, + * `refused-structural-parent`, `refused-missing-target-parent` — carries it + * as the sole element, in 1.5's form over the operation's destination file + * (`<file>#id` for a rename, `<target-file>#id` for a section move, the bare + * `<new-file>` for a file move), whether or not the ID is valid; + * `refused-id-collision` carries the located bearers' identities in location + * order; `refused-invalid-rewrite` carries the workspace-relative paths of + * the files concerned — each a spec source's root identity or a code + * source's whole-file identity, a created target file's spelled whatever + * its path's validity — in byte order; `refused-moved-import` carries + * none (its `identities` empty). `refused-exposed-derived-file`, a + * path-concerning reason naming no identity, carries none either: TEST-SPEC + * pins its `identities` `[]` (T6.5-21, T14-7) — 12.7's member "empty where + * none" the condition names. For every other reason — `refused-cycle` and + * the path-concerning `refused-destination-exists` and + * `refused-invalid-destination` — 12.7 leaves the member informational, its + * composition unpinned, so no consumer asserts it (H-4). + */ +export const IDENTITY_PINNED_REFUSAL_CODES: ReadonlySet<string> = new Set([ + "refused-invalid-id", + "refused-identity-unchanged", + "refused-id-collision", + "refused-structural-parent", + "refused-missing-target-parent", + "refused-exposed-derived-file", + "refused-invalid-rewrite", + "refused-moved-import", +]); + +/** + * Assert a finding's `identities` is exactly the expected ordered array + * (SPEC.md 12.7: the member's content is contractual exactly where 14 states + * it — a refusal reason's concerned identity as the sole element, the + * collision's located bearers in location order, 14.11's foreign module, + * 14.12's enumeration): the same entries in the same order, none beside — a + * bare ID in place of the 1.5 identity, a further informational entry, or a + * differently ordered bearer list fails (T6.4-3, T6.5-4, T14-7). + */ +export function assertFindingIdentities( + finding: Finding, + expected: readonly string[], + context: string, +): void { + const actual = finding.identities; + const equal = + actual.length === expected.length && + expected.every((entry, index) => actual[index] === entry); + if (!equal) { + fail( + `${context}: the finding's \`identities\` must be exactly ` + + `${JSON.stringify(expected)} — the same entries in the same order, ` + + `none beside (SPEC.md 14, 12.7); got ${JSON.stringify(actual)} ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } +} + +/** + * Assert a refusal finding's `identities` per SPEC.md 14's pin for its + * reason: a case states the exact array for every reason in + * IDENTITY_PINNED_REFUSAL_CODES and states none for a refusal reason whose + * composition 12.7 leaves unpinned — either slip is a harness defect (a + * plain error, never `fail`: H-8's taxonomy), so no identity-concerning + * refusal is under-asserted and no unpinned one over-asserted. A numbered + * condition (the invalid-workspace refusal's findings) is asserted exactly + * when the case states an array. + */ +export function assertRefusalIdentities( + finding: Finding, + code: string, + expected: readonly string[] | undefined, + context: string, +): void { + const pinned = IDENTITY_PINNED_REFUSAL_CODES.has(code); + if (expected === undefined) { + if (pinned) { + throw new Error( + `harness defect: ${context} — SPEC.md 14 pins the \`identities\` of ` + + `${JSON.stringify(code)} (its concerned identity as the sole ` + + `element; the located bearers for refused-id-collision; the ` + + `concerned files' paths in byte order for refused-invalid-rewrite; ` + + `none for refused-moved-import and refused-exposed-derived-file), ` + + `so the case must state the exact array`, + ); + } + return; + } + if (!pinned && code.startsWith("refused-")) { + throw new Error( + `harness defect: ${context} — SPEC.md 12.7 leaves the \`identities\` ` + + `of ${JSON.stringify(code)} informational (composition unpinned), ` + + `so the case must not state one`, + ); + } + assertFindingIdentities(finding, expected, context); +} + +/** + * Assert a finding concerns exactly the expected workspace-relative path via + * its 12.7 `path` member (SPEC.md 14: conditions and refusal reasons without + * an in-source location carry the file or path they concern). + */ +export function assertFindingConcernsPath( + finding: Finding, + expected: string, + context: string, +): void { + if (finding.path === expected) return; + fail( + `${context}: the finding must carry the concerned path ` + + `${JSON.stringify(expected)} as its 12.7 path member (SPEC.md 14); ` + + `got ${renderPathValue(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); +} + function renderJson(value: unknown): string { return value === undefined ? "undefined" : JSON.stringify(value); } @@ -297,3 +896,297 @@ 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 is exactly the + * expected ordered array of identity pairs — every identity pair the + * operation journaled, no more, in the pinned order (SPEC.md 6.4, 6.5: the + * complete identity mapping, the preview's `mapping`, 6.6; 12.7: one + * `{"from", "to"}` per mapped identity ordered by `from` bytes; T6.4-1, + * T6.5-1). The performed document is a form-exact 12.7 surface (H-3), so the + * comparison is order-sensitive: a product emitting the right pairs in + * another order fails, as does a duplicated or extra pair. Callers list the + * expected pairs in `from`-byte order. + */ +export function assertAppliedMapping( + actual: readonly AppliedMappingPair[], + expected: readonly AppliedMappingPair[], + context: string, +): void { + assertSameJson(actual, expected, context); +} + +// --------------------------------------------------------------------------- +// `--file` pattern spellings (SPEC 7, 11.1, 11.3, 11.4, 12.3, 12.0) +// --------------------------------------------------------------------------- + +/** One `--file` spelling with the reason SPEC 7's depth rule classes it. */ +export interface FilePatternSpelling { + readonly spelling: string; + readonly why: string; +} + +/** + * `--file` patterns outside the workspace root by spelling alone — decided + * as SPEC 7 decides a configured glob (T7-4): reading the `/`-separated + * segments from a depth of zero, a `..` segment lowers the depth, a `.`, + * empty, or `**` segment leaves it, every other segment raises it; a glob + * beginning with `/`, or whose depth ever falls below zero, is outside. + * Each is an invalid flag value on every `--file` surface (11.1, 11.3, + * 11.4, 12.3): a plain usage error, exit 2, whatever the workspace or the + * root's parent holds — callers stage {@link BESIDE_ROOT_FILE_PATTERN_DECOY} + * so the ascending spellings, resolved, name a real file, and exit 2 never + * comes from a side reason (T7-4's discipline); the absolute spelling's + * premise is the spelling alone. + */ +export const OUTSIDE_ROOT_FILE_PATTERNS: readonly FilePatternSpelling[] = [ + { + spelling: "../x/*.mdx", + why: "the plain ascent: the depth falls below zero at the first segment", + }, + { + spelling: "../x", + why: "the plain ascent naming a directory, no wildcard segment", + }, + { + spelling: "a/../../x", + why: + "an embedded ascent: `a` raises the depth to one, the two `..` " + + "segments take it to minus one", + }, + { spelling: "/specs/*.mdx", why: "a leading `/`" }, +]; + +/** + * The file the ascending spellings of {@link OUTSIDE_ROOT_FILE_PATTERNS} + * name when resolved against the root's parent (`stageBesideRoot`): a + * product deciding by what it finds rather than by spelling finds it. + */ +export const BESIDE_ROOT_FILE_PATTERN_DECOY: Readonly<Record<string, string>> = + { "x/M.mdx": '<S id="m">\nM text.\n</S>\n' }; + +/** + * Inside-root `--file` spellings over `dir` that match nothing (SPEC 7, + * 12.0): a `.` segment and an empty segment (a doubled `/`) leave the depth + * unchanged — the pattern is inside the root — and match no discovered + * path, which carries no such segment. Each admits the empty set on every + * `--file` surface: exit 0 with the surface's empty answer form. Sharp only + * where `dir` holds a discovered `.mdx` at its top level — the file a + * product normalizing the spelling (`./specs/*.mdx` → `specs/*.mdx`) + * would match. + */ +export function insideNoMatchFilePatterns( + dir: string, +): readonly FilePatternSpelling[] { + return [ + { spelling: `./${dir}/*.mdx`, why: "a `.` segment matches nothing" }, + { + spelling: `${dir}//*.mdx`, + why: "an empty segment (a doubled `/`) matches nothing", + }, + ]; +} + +/** TEST-SPEC's pinned inside spellings: `./specs/*.mdx`, `specs//*.mdx`. */ +export const INSIDE_NO_MATCH_FILE_PATTERNS: readonly FilePatternSpelling[] = + insideNoMatchFilePatterns("specs"); + +/** + * One `--file` spelling outside the root: the invocation (`--file` and its + * value in place; `--json` where the surface takes it, T12.3-1 and T11-2, + * omitted on the JSON-only surfaces of 11.3 and 11.4) exits 2 exactly with + * the 12.7 error document as its entire stdout — a plain usage error's + * finding, `code` null, `path` null, `locations` [] (SPEC 12.7) — and a + * message on standard error (12.0). + */ +export async function expectFilePatternUsageError( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<void> { + const result = await expectExit( + product, + workspace, + argv, + 2, + `${context} — a \`--file\` pattern outside the workspace root by its ` + + `spelling alone is an invalid flag value, a usage error, exit 2 ` + + `(SPEC 7, 11.1, 11.3, 11.4, 12.3, 12.0)`, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { code: finding.code, path: finding.path, locations: finding.locations }, + { code: null, path: null, locations: [] }, + `${context}: a plain usage error's error document carries \`code\` ` + + `null, \`path\` null, and no locations (SPEC 12.7)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${context}: usage error messages are standard-error content (SPEC ` + + `12.0), but stderr is empty`, + ); + } +} + +/** + * One invocation expected to fail as a plain usage error — the syntax class + * of SPEC 12.0, or any usage error no condition of 14 codes: exit 2 exactly + * (H-5); stdout the single 12.7 error document (the caller's argv puts JSON + * output in effect: `--json` among the arguments, or a JSON-only surface), + * its finding carrying `code` null, `path` null, and no locations — never + * a configuration error's stable code and concerned path (SPEC 12.7, 14); + * and the usage message on standard error (12.0). Returns the run for byte + * compares across workspaces (`expectSyntaxClassUsageError`). + */ +export async function expectPlainUsageError( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly ArgvValue[], + context: string, +): Promise<RunResult> { + const result = await expectExit(product, workspace, argv, 2, context); + const error = expectErrorDocument(result, context); + assertSameJson( + { code: error.code, path: error.path, locations: error.locations }, + { code: null, path: null, locations: [] }, + `${context}: the reported error must be the plain usage error — ` + + `\`code\` null, \`path\` null, no locations (SPEC 12.7, 14) — never ` + + `a configuration error's stable code and concerned path (message: ` + + `${JSON.stringify(error.message)})`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${context}: usage error messages are standard-error content (SPEC ` + + `12.0), but stderr is empty`, + ); + } + return result; +} + +/** + * A configuration whose one defect is an unknown top-level key: every + * command but `version` that loads it reports 14.14 (`configuration-error`, + * SPEC 7, 14.14), so an invocation answering the plain usage error on a + * workspace holding it demonstrably never loaded the configuration (12.0). + * A staged-source record: T6.4-3, T6.5-5, T11-2, T11-4, and T12.0-5 stage + * the twins after a product invocation (S-9's timing clause; + * test/self/s9-staged-sources.test.ts). + */ +export const UNKNOWN_KEY_CONFIG = stagedTs( + "T6.4-3/T6.5-5/T11-2/T11-4/T12.0-5 xspec.config.ts — an unknown top-level key, the invalid configuration-state twin's", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`, +); + +/** + * The two configuration states under which a syntax-class usage error must + * be reported exactly as on the configured workspace under test (SPEC 12.0: + * an error the invocation's arguments alone determine is reported without + * loading configuration — T12.0-10's discipline): a workspace whose + * `xspec.config.ts` is invalid (`UNKNOWN_KEY_CONFIG`) and one holding + * none. Both hold the caller's file set, so whatever the invocation would + * consult next — discovery, a named file — is present in each and only the + * configuration state differs. + */ +export interface ConfigurationStateTwins { + readonly invalid: TestWorkspace; + readonly missing: TestWorkspace; + dispose(): Promise<void>; +} + +/** + * Stage the twins from one file set, which stages no configuration. The + * twins are created after the body's first invocation, so an `.mdx` entry + * is a staged-source record (the record-accepting initial `files`). + */ +export async function stageConfigurationStateTwins( + files: Readonly<Record<string, InitialFileContents>>, +): Promise<ConfigurationStateTwins> { + if ("xspec.config.ts" in files) { + throw new Error( + "stageConfigurationStateTwins: the file set must not stage " + + "xspec.config.ts — the twins stage their own configuration states", + ); + } + const invalid = await TestWorkspace.create({ + files: { "xspec.config.ts": UNKNOWN_KEY_CONFIG, ...files }, + }); + let missing: TestWorkspace; + try { + missing = await TestWorkspace.create({ files }); + } catch (error) { + await invalid.dispose(); + throw error; + } + return { + invalid, + missing, + dispose: async () => { + await missing.dispose(); + await invalid.dispose(); + }, + }; +} + +/** + * The T12.0-10 discipline for one syntax-class usage error (SPEC 12.0): the + * invocation answers the plain usage error on `workspace` — the workspace + * under test, whatever its configuration and findings — and again on each + * configuration-state twin, each twin's error document byte-identical to + * the workspace's (the document depends on the invocation's syntax alone, + * never on configuration state; H-4's product-to-itself compare) and each + * twin's tree unchanged around the run. A product that loads configuration + * before judging the value answers 14.14 on the twins and fails at the + * plain-error pin. Returns the workspace's run. + */ +export async function expectSyntaxClassUsageError( + product: ProductBinding, + workspace: TestWorkspace, + twins: ConfigurationStateTwins, + argv: readonly ArgvValue[], + context: string, +): Promise<RunResult> { + const reference = await expectPlainUsageError( + product, + workspace, + argv, + `${context} — a malformed value is a usage error of the syntax class: ` + + `exit 2 with the plain usage error's document (SPEC 12.0, 12.7)`, + ); + for (const [state, twin] of [ + ["invalid", twins.invalid], + ["missing", twins.missing], + ] as const) { + const run = await assertLeavesUnchanged( + twin.root, + () => + expectPlainUsageError( + product, + twin, + argv, + `${context} (configuration file ${state}) — an error the ` + + `arguments alone determine is reported without loading ` + + `configuration: the plain usage error, never 14.14 (SPEC 12.0)`, + ), + `${context} (configuration file ${state}) — a syntax-class usage ` + + `error modifies nothing (SPEC 12.0)`, + ); + assertBytesEqual( + run.stdoutBytes, + reference.stdoutBytes, + `${context} (configuration file ${state}): reported identically, ` + + `byte for byte, to the configured workspace's answer — the error ` + + `document depends on the invocation's syntax alone, never on ` + + `configuration state (SPEC 12.0; H-4's product-to-itself compare)`, + ); + } + return reference; +} diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 7002a984..cb1f863c 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 @@ -80,12 +98,19 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T1.3-4": ["1.3", "14"], "T1.3-5": ["1.3", "14"], "T1.3-6": ["1.3", "14"], + "T1.3-7": ["1.3", "11.1", "11.4"], "T1.4-1": ["1.4", "14"], "T1.4-2": ["1.4"], "T1.4-3": ["1.4"], "T1.4-4": ["1.4", "14"], + // T1.4-5: 1.4's identifier test by characters at the release and language + // level 14.20 fixes (the "14" key), through dot access in both kinds of + // source (2.4), the generated module's dot-accessible and quoted + // properties (4.1), and a section move's conversion to imported form in + // 6.4's fallback spellings (6.4, 6.5). + "T1.4-5": ["1.4", "2.4", "4.1", "6.4", "6.5", "14"], "T1.5-1": ["1.5"], - "T1.5-2": ["1.5", "14"], + "T1.5-2": ["1.5", "12.0", "12.7", "14"], "T1.5-3": ["1.5"], "T1.6-1": ["1.6"], "T1.6-2": ["1.6"], @@ -93,11 +118,13 @@ 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", "4.6"], "T2.1-1": ["2.1"], - "T2.1-2": ["2.1", "14"], + "T2.1-2": ["2.1", "2.4", "11.4", "14"], "T2.1-3": ["2.1", "14"], "T2.1-4": ["2.1"], "T2.1-5": ["2.1", "14"], + "T2.1-6": ["2.1", "1.6", "3", "11.4", "14"], "T2.2-1": ["2.2"], "T2.2-2": ["2.2"], "T2.2-3": ["2.2"], @@ -105,29 +132,38 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T2.2-5": ["2.2"], "T2.3-1": ["2.3"], "T2.3-2": ["2.3"], + "T2.3-3": ["2.3", "2.4", "2.7", "3", "5.7", "11.2", "11.4", "14"], "T2.4-1": ["2.4"], "T2.4-2": ["2.4", "14"], "T2.4-3": ["2.4", "14"], "T2.4-4": ["2.4"], + "T2.4-5": ["2.4", "1.4", "2.1", "4.5", "5.7", "11.3", "14"], "T2.5-1": ["2.5"], "T2.5-2": ["2.5"], - "T2.5-3": ["2.5", "14"], + "T2.5-3": ["2.5", "2.4", "2.7", "14"], "T2.6-1": ["2.6"], "T2.6-2": ["2.6"], "T2.6-3": ["2.6"], - "T2.7-1": ["2.7", "14"], + "T2.7-1": ["2.7", "14", "11.2", "11.4"], "T2.7-2": ["2.7"], "T2.7-3": ["2.7", "14"], + "T2.7-4": ["2.7", "14", "3", "1.4", "11.2", "11.4"], "T3-1": ["3"], "T3-2": ["3"], "T3-3": ["3"], "T3-4": ["3"], "T3-5": ["3"], "T3-6": ["3"], + "T3-7": ["3", "2.1", "2.7", "1.6", "11.4"], "T4-1": ["4"], - "T4-2": ["4", "14"], + // T4-2: 4's import rules and module-linking forms, its import-type arms + // carrying 4.5's "an import type is no type-level reference" (T4.5-7 + // defers here), and its no-other-construct arm a file move's rewrite + // (6.5) and preview edits (6.6) over the constructs naming no module. + "T4-2": ["4", "2.1", "2.4", "4.5", "5.7", "6.5", "6.6", "13.4", "14"], "T4-3": ["4"], "T4-4": ["4"], + "T4-5": ["4", "2.1", "2.4", "4.5", "5.7", "11.2", "14"], "T4.1-1": ["4.1"], "T4.1-2": ["4.1"], "T4.1-3": ["4.1"], @@ -136,19 +172,21 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T4.2-3": ["4.2"], "T4.2-4": ["4.2"], "T4.3-1": ["4.3"], - "T4.3-2": ["4.3", "14"], - "T4.4-1": ["4.4", "14"], + "T4.3-2": ["4.3", "2.4", "5.7", "11.2", "11.3", "14"], + "T4.4-1": ["4.4", "14", "1.5", "5.7", "11.2", "11.3", "12.7"], "T4.4-2": ["4.4"], "T4.5-1": ["4.5"], "T4.5-2": ["4.5"], - "T4.5-3": ["4.5", "14"], - "T4.5-4": ["4.5"], + "T4.5-3": ["4.5", "2.4", "5.7", "11.2", "11.3", "14"], + "T4.5-4": ["4.5", "2.4", "5.7", "14"], "T4.5-5": ["4.5", "14"], "T4.5-6": ["4.5"], "T4.5-7": ["4.5"], + "T4.5-8": ["4.5", "1.7", "2.1", "2.4", "5.7", "14"], + "T4.5-9": ["4.5", "2.4", "5.7", "11.2", "14"], "T4.6-1": ["4.6"], "T4.6-2": ["4.6"], - "T4.6-3": ["4.6"], + "T4.6-3": ["4.5", "4.6", "2.4", "12.2", "14"], "T4.6-4": ["4.6"], "T5.2-1": ["5", "5.1", "5.2"], "T5.3-1": ["5.3", "14"], @@ -167,6 +205,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", "1.4", "2.7", "14"], + "T5.7-3": ["5.7"], + "T5.7-4": ["5.7", "2.4", "4", "4.4", "4.5", "11.2", "14"], "T6.1-1": ["6.1"], "T6.1-2": ["6.1"], "T6.1-3": ["6.1", "14"], @@ -178,24 +220,81 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.3-2": ["6.3"], "T6.3-3": ["6.3"], "T6.3-4": ["6.3", "12.0"], + "T6.3-5": ["6.3", "10.7", "12.0"], "T6.4-1": ["6.4"], - "T6.4-2": ["6.4"], - "T6.4-3": ["6.4"], + "T6.4-2": ["6.4", "1.4", "2.4", "2.7"], + "T6.4-3": ["6.4", "1.4", "14", "12.0", "12.7"], "T6.4-4": ["6.4", "12.0"], "T6.4-5": ["6.4"], "T6.4-6": ["6.4"], "T6.4-7": ["6.4"], - "T6.5-1": ["6.5"], - "T6.5-2": ["6.5"], + "T6.5-1": ["6.5", "6.6", "6.4", "2.1", "12.7", "4", "4.5"], + "T6.5-2": ["6.5", "3"], "T6.5-3": ["6.5"], - "T6.5-4": ["6.5"], - "T6.5-5": ["6.5", "12.0"], - "T6.5-6": ["6.5"], - "T6.6-1": ["6.6"], - "T7-1": ["7", "14"], - "T7-2": ["7", "14"], - "T7-3": ["7", "14"], - "T7-4": ["7", "14"], + "T6.5-4": ["6.5", "1.4", "7.1", "14"], + "T6.5-5": ["6.5", "12.0", "12.7"], + "T6.5-6": ["6.5", "6.6", "12.7", "14"], + "T6.5-7": ["6.5", "6.4", "3", "2.7", "4.5"], + "T6.5-8": ["6.5", "6.4", "2.1", "3"], + "T6.5-9": ["6.5", "2.1", "4", "4.5", "6.4", "3", "11.4"], + "T6.5-10": ["6.5", "6.4", "6.6", "2.1", "3", "5.7"], + "T6.5-11": ["6.5", "4.3", "4.5", "4.6", "5.7", "6.6", "12.7"], + "T6.5-12": ["6.5", "6.4", "3"], + "T6.5-13": [ + "6.5", + "6.4", + "6.6", + "6.2", + "3", + "5.5", + "5.6", + "1.6", + "11.4", + "12.7", + ], + "T6.5-14": ["6.5", "6.4", "2.1", "3", "5.5", "5.6"], + "T6.5-15": ["6.5", "3", "2.1", "6.6", "12.7"], + "T6.5-16": ["6.5", "6.2", "3", "2.1", "1.7", "5.7", "14", "12.7"], + "T6.5-17": ["6.5", "14", "11.4", "12.7", "2.1", "3"], + "T6.5-18": [ + "6.5", + "4", + "4.3", + "4.5", + "4.6", + "2.1", + "3", + "5.7", + "6.6", + "12.7", + ], + "T6.5-19": ["6.5", "6.4", "6.6", "6.2", "3", "5.5", "1.6", "12.7"], + "T6.5-20": ["6.5", "4", "13.4", "13.1", "13.2", "7.3", "14", "12.7"], + "T6.5-21": ["6.5", "13.4", "7", "7.2", "7.3", "13.2", "6.6", "14", "12.7"], + "T6.5-22": ["6.5", "2.1", "4", "4.5", "14", "12.7"], + "T6.5-23": [ + "6.5", + "1.4", + "3", + "4", + "4.6", + "5.2", + "6.4", + "6.6", + "11.1", + "12.7", + "14", + ], + "T6.6-2": ["6.6"], + "T6.6-3": ["6.6", "6.5", "12.7", "14"], + "T6.6-4": ["6.6", "6.5", "12.7"], + "T6.6-5": ["6.6", "13.2", "13.3"], + "T6.6-6": ["6.6", "14"], + "T6.7-1": ["6.7"], + "T7-1": ["7", "14", "12.6"], + "T7-2": ["7", "2.4", "7.4", "11.6", "14"], + "T7-3": ["7", "7.1", "7.2", "14"], + "T7-4": ["7", "2.4", "11.6", "14"], "T7-5": ["7"], "T7-6": ["7", "13.4", "14"], "T7.1-1": ["7.1", "14"], @@ -229,6 +328,8 @@ 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.1-6": ["10.1", "10.7", "11.6", "12.2", "13.3", "13.4", "13.5", "14"], "T10.2-1": ["10.2"], "T10.2-2": ["10.2"], "T10.2-3": ["10.2"], @@ -249,7 +350,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T10.6-1": ["10", "10.6"], "T10.6-2": ["10.6"], "T10.6-3": ["10.6"], - "T10.7-1": ["10", "10.7"], + "T10.7-1": ["10", "10.1", "10.7", "13.4", "14"], "T10.7-2": ["10.7"], "T10.7-3": ["10.7"], "T10.7-4": ["10", "10.7"], @@ -260,69 +361,275 @@ 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", "1.4", "12.0", "12.7"], + "T11-3": ["11", "11.1"], + "T11-4": ["11", "11.1", "12.0", "12.7"], + "T11-5": ["11", "11.1"], + "T11-6": ["11.1", "4.6", "12.0"], + "T11-7": ["11.1"], + "T11.2-1": ["11.2"], + "T11.2-2": ["11.2"], + "T11.2-3": ["11.2"], + "T11.2-4": ["11.2", "14"], + "T11.2-5": ["11.2"], + "T11.2-6": ["11.2", "13.3", "13.4", "14"], + "T11.3-1": ["11.3"], + "T11.3-2": ["11.3"], + "T11.3-3": ["11.3", "1.4", "12.0", "12.7"], + "T11.3-4": ["11.3"], + "T11.4-1": ["11.4"], + "T11.4-2": ["11.4"], + "T11.4-3": ["11.4", "2.6", "12.7"], + "T11.4-4": ["11.4", "11.2", "2.1", "1.6", "7.2", "12.7", "14"], + "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", "12.0", "12.7", "14"], + "T11.6-1": ["11.6"], + // T11.6-2: 7.1/7.3/7.4/7.5/13.1/12.0/12.7 are carriage context with home + // coverage at T7.1-*/T7.3-*/T7.4-*/T7.5-*/T13.1-*/T12.0-*/T12.7-* — the + // invalid-path `.mdx` arms (`specs/a'b.mdx`; Linux leg, a non-UTF-8 name + // in the marked byte form) included; every answer asserts findings [] — + // no numbered condition is asserted, so no "14". + "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"], "T12.0-4": ["12.0"], - "T12.0-5": ["12.0"], + // T12.0-5: its positive side of the backslash asserts the condition-19 + // finding over a spec-group path 7.1 bars the backslash from (so "7.1" + // and "14") and the backslash as a literal `--file` pattern byte (7); + // 11.3-11.5 are carriage context with home coverage at T11.3-*/T11.4-*/ + // T11.5-*. + "T12.0-5": ["12.0", "12.7", "13.5", "6.4", "7", "7.1", "14"], "T12.0-6": ["12.0"], "T12.0-7": ["12.0"], "T12.0-8": ["12.0"], "T12.0-9": ["12.0"], + "T12.0-10": [ + "12.0", + "1.4", + "10.1", + "10.7", + "11.1", + "11.3", + "11.4", + "11.5", + "6.6", + "7", + "12.7", + "14", + ], "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.0-14: the grammar arms assert `ids --file`'s restriction (12.3), + // `build`'s flag set (12.1), the session name `-a` and its file (10.1), + // `review create` and `resolve --note` (10.7), `--kinds` list values + // (11.1), the `--config` path's directory as the root (7), and the + // error document (12.7); no "14": the missing-configuration arm pins + // the stream contract, not the finding (T12.0-13's precedent). + "T12.0-14": ["12.0", "12.3", "12.1", "10.1", "10.7", "11.1", "7", "12.7"], "T12.1-1": ["12.1"], "T12.1-3": ["12.1"], "T12.1-4": ["12.1"], "T12.2-1": ["12.2"], "T12.2-2": ["12.2", "14"], "T12.2-3": ["12.2"], + "T12.2-4": ["12.2", "13.3", "14", "7.5", "12.1"], "T12.3-1": ["12.3"], "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", "11.6", "14"], "T13.3-3": ["13.3"], "T13.3-4": ["13.3"], "T13.4-1": ["13.4"], "T13.4-2": ["13.4"], "T13.4-3": ["13.4"], + // T13.4-4's 12.1 and 14.22 citations are carriage context: every arm is a + // success path (`build` exits 0; the directory arms' `check` clean), the + // directory arms marking where 14.22's refusal stops, with its home + // coverage at T13.4-6/T13.4-9/T13.4-10; no numbered condition is asserted. "T13.4-4": ["13.4"], "T13.4-5": ["13.4"], - "T13.4-6": ["13.4", "14"], + "T13.4-6": ["13.4", "13.3", "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.4-9 asserts 13.4's relation between derived paths through condition + // 22 (14) at `build`, `check`, and the gate's `ids` (13.3); (e)'s + // `inventory` read (11.6), 7.3's emission settings, and 13.1/13.2's + // derived paths are carriage context with home coverage at + // T11.6-3/T7.3-1/T13.1-*/T13.2-1. + "T13.4-9": ["13.4", "13.3", "14"], + // T13.4-10 asserts 13.4's rebuild-obstructing orphans through condition + // 22 and condition 10's recorded-file form with its manual-deletion + // correction (14), `build`'s refusal modifying nothing (12.1), `check`'s + // recorded-file verification (12.2), and 13.5's exception to rerunning + // `build` — the orphans 13.4 leaves for manual deletion; 7.3's emission + // settings, 13.2's emit paths, and the twin's graph-data deletion (13.3) + // are carriage context with home coverage at T7.3-1/T13.2-1/T13.3-2. + "T13.4-10": ["13.4", "12.1", "12.2", "13.5", "14"], + // T13.4-11 asserts `build`'s removal of recorded derived files no longer + // generated (12.1) and `check`'s recorded-file verification (12.2) through + // condition 10's recorded-file form (14); the record (13.3), 7.2's code + // group, and 7.3's emission settings are carriage context with home + // coverage at T13.3-*/T7-*/T7.3-1. + "T13.4-11": ["13.4", "12.1", "12.2", "14"], "T13.5-1": ["13.5"], "T13.5-2": ["13.5"], "T13.5-3": ["13.5"], "T13.5-4": ["13.5"], "T13.5-5": ["13.5"], "T13.5-6": ["13.5"], - "T13.5-7": ["13.5"], + "T13.5-7": [ + "13.5", + "14", + "12.0", + "12.7", + "6.4", + "6.5", + "6.6", + "6.7", + "6.2", + "6.1", + "13.3", + "13.4", + "10.7", + ], + "T13.5-8": ["13.5", "13.3", "6.3", "6.4", "6.6", "12.0", "14"], "T14-1": ["14"], - "T14-2": ["14"], + "T14-2": ["14", "2.4", "4.5"], "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), + // 12.7/12.0 (report carriage; 12.0's unknown-identity usage error on an + // invalid-path identity, home T11-6), 1.5 (the identity form 14 restates + // over invalid paths), and 5.7/11.4 (the occurrence span and the import + // range the located sets are read in) are context with home coverage at + // T6.4-*/T6.5-*/T5.3-1/T12.7-*/T12.0-*/T11-6/T1.5-*/T5.7-*/T11.4-*; 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"], + // T14-9: the 14.24 contract (home "14") with the exit classes and the + // error document it asserts (12.0, 12.7), the refreshing reads whose + // graph-data write is refused (13.3), and the seam-held stagings, the + // hold-file usage error, and the pinned "nothing written" states (13.5); + // 6.4/6.5/10.7/11.6 (the staged operations, the area naming) are context + // with home coverage at T6.4-*/T6.5-*/T10.7-*/T11.6-*. + "T14-9": ["14", "12.0", "12.7", "13.3", "13.5"], + // T14-10: the 14.25 contract (home "14": conditions 20, 13, 21, 14, 10, + // 23 and the read-failure usage error) with the exit classes, the read + // order, and the syntax-class precedence it asserts (12.0), the error + // document (12.7), the corrupt-session reports of `review status`/`list` + // (10.7), the per-file availability and masking of the refused source + // (11.2), `at`'s unavailable resolution on it (11.5), the inventory's + // journal, session, and record answers (11.6), and the gated and + // refreshing reads over the unreadable journal and graph data (13.3); + // 6.6/7/14.14's staged surfaces are context with home coverage at + // T6.6-6/T7-*/T12.7-3. + "T14-10": ["14", "10.7", "11.2", "11.5", "11.6", "12.0", "12.7", "13.3"], + // T14-11: the per-condition range rules (home "14") with the byte-offset + // convention they use (1.7), the `d`-entry, marker, and `text(...)` spans + // (5.7), the attribute, opening-tag, and import ranges (11.4), and the + // per-spelling resolution inside a repeated `d` with the occurrence it + // records (11.2); 2.4/2.7/4.5's staged forms, and 4's module-linking + // forms whose 14.15 ranges arms (j) and (x) pin, are context with home + // coverage at T2.4-*/T2.7-*/T4.5-* and T4-2. + "T14-11": ["14", "1.4", "1.6", "1.7", "2.4", "2.7", "5.7", "11.2", "11.4"], + // T14-12 asserts the well-formedness contract of 14.20 (the "14" key) + // through the ordinary outcomes of well-formed files: the import + // collision (2.1), the container, attribute, and export forms (2.7), the + // marker's attribution and edge in a code file (4.5, 4.6), the + // occurrence it records (5.7), and the availability contract of the + // consulted surfaces (11.2, 11.3, 11.4). + "T14-12": [ + "14", + "1.4", + "1.7", + "2.1", + "2.7", + "4.5", + "4.6", + "5.7", + "7", + "11.2", + "11.3", + "11.4", + ], "T15-1": ["15"], "P-1": ["1.4", "2.6"], - "P-2": ["3"], + "P-2": ["2.3", "2.7", "3"], "P-3": ["1.6", "3"], "P-4": ["5.5"], - "P-5": ["6.2", "6.4", "6.5"], + "P-5": ["6.2", "6.4", "6.5", "2.1"], "P-6": ["6.3", "9.1"], "P-7": ["7", "7.5"], "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/registry/write-refusal-staging.ts b/test/suite/registry/write-refusal-staging.ts new file mode 100644 index 00000000..300268cf --- /dev/null +++ b/test/suite/registry/write-refusal-staging.ts @@ -0,0 +1,2027 @@ +// TEST-SPEC T13.5-7 / T14-9 — the write-refusal stagings (a)–(f) and the +// choreography that applies them while a mutating command is held at the +// seam of SPEC 13.5, shared by T13.5-7 (the per-command states a refused +// write leaves; section-13.5.ts) and T14-9 (the 14.24 contract; section-14). +// +// Staging discipline (TEST-SPEC T14-9, E-1): an environment refusal is staged +// by permission removal alone through helpers/permissions.ts — the directory +// holding the concerned path made read-only and its occupant, where one +// exists, unwritable — so creation, replacement in place or by renaming, +// appending, and removal are all refused whatever write strategy the product +// uses; each staging verifies itself on the harness's own process before the +// product proceeds and throws `HarnessStagingError` (a harness error, never a +// diagnosed failure or a skip, H-9/H-11) when the runner is privileged. For a +// mutating command the staging is applied while the command is held at the +// seam — after acquisition and before any modification — so the product's +// exclusivity mechanism, wherever it keeps state, never meets the staging +// (seam neutrality, T13.5-1); `build` and the reads take no hold (13.5 lists +// them among the non-exclusive commands), so their staging precedes the +// invocation. Every staging is restored the moment the staged command exits. +// +// Expectations are read from twins (H-6): every rewritten-byte expectation is +// the state an identical twin workspace reaches when the same operation runs +// unrefused, and every "untouched" expectation is the workspace's own +// pre-invocation snapshot — SPEC 13.5 pins the state a stopped command +// leaves as "every earlier write complete, no later one attempted" in the +// per-command write order, which `assertPinnedState` asserts entry by entry; +// where 13.5 leaves an order unpinned (a regeneration's derived-file writes) +// `assertEachWriteComplete` asserts the weaker law — each entry is its prior +// state or its complete new content — and `check`'s expected staleness set +// is computed by comparison with the twin (`derivedPathsDifferingFrom`, +// `graphDataDiffers`; TEST-SPEC T13.5-7 (b), (f)). +// +// Every arm starts from a freshly built, valid, journal-bearing workspace +// with no refresh pending (`prepareRefusalWorkspace`): `build`, then a prior +// journaled rename (SPEC 6.1: the journal comes into existence with the first +// journaled operation — `build` need not create it), then `check` clean, +// then a git commit serving as the `impact --base` baseline of the recovery +// assertions (6.3, 6.7). The fixtures deliberately leave CONF-CORE's in-scope +// shape (imports, a code group, Markdown emission): T13.5-7 and T14-9 are +// uncertified (CERTIFICATIONS.md §Exclusions). + +import { Buffer } from "node:buffer"; +import * as path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import type { + Finding, + ImpactReport, + SessionStatusReport, +} from "../../helpers/adapters/index.js"; +import { + decodeFindingsReport, + decodeImpactReport, + decodeSessionStatusReport, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import type { PermissionStaging } from "../../helpers/permissions.js"; +import { + stageWriteRefusal, + stageWriteRefusalUnder, +} from "../../helpers/permissions.js"; +import type { + DirectorySnapshot, + SnapshotEntry, +} from "../../helpers/snapshot.js"; +import { + assertLeavesUnchanged, + assertSnapshotsEqual, + describeEntry, + snapshotDirectory, +} from "../../helpers/snapshot.js"; +import { stagedMdx } from "../../helpers/staged-mdx.js"; +import { stagedTs } from "../../helpers/staged-ts.js"; +import type { + ProductBinding, + RunGuards, + RunningProduct, + RunResult, +} from "../../helpers/subprocess.js"; +import { + releaseHoldFile, + rethrowOutputOverflow, + runProduct, + startProduct, + summarizeResult, +} from "../../helpers/subprocess.js"; +import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertFindingLocated, + buildOk, + expectErrorDocument, + expectExit, + runJson, +} from "./support.js"; + +/** The refusal arms are staged on the Linux leg only (TEST-SPEC E-1). */ +export const WRITE_REFUSALS_STAGED = process.platform === "linux"; + +// --------------------------------------------------------------------------- +// Hold-seam helpers (SPEC 13.5's `--test-hold`), shared with section-13.5.ts +// and, through its re-export, T6.6-3. +// --------------------------------------------------------------------------- + +/** + * 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. + */ +export function holdPathFor(workspace: TestWorkspace, name: string): string { + return path.join(workspace.tempRoot, name); +} + +/** + * Await the hold file's appearance, converting the driver's diagnosed + * rejection (the process exited first, or the wait timed out) into a + * diagnosed assertion failure (H-8). A run the capture limit killed is never + * converted: its `ProductRunOutputOverflowError` propagates as the harness + * error it is (H-11). + */ +export async function awaitHoldFile( + running: RunningProduct, + absPath: string, + context: string, +): Promise<void> { + try { + await running.waitForFile(absPath); + } catch (error) { + rethrowOutputOverflow(error); + fail( + `${context}: the mutating command must create the hold file at ` + + `${absPath} immediately after acquiring workspace exclusivity and ` + + `before modifying anything (SPEC 13.5) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** A fixture and the prior journaled rename that makes it journal-bearing. */ +export interface RefusalFixture { + readonly decl: WorkspaceDecl; + readonly priorRename: readonly string[]; +} + +// The rename fixture (T13.5-7 (a), (b), (e), (f), and the kill arm): one spec +// group with Markdown emitted beside each source, and a rename whose section +// lives in `specs/b/B.mdx`, referenced from `specs/a/A.mdx` (a `d` +// reference) and `specs/c/C.mdx` (an embedding) — the preview's `files` +// order by path bytes is A, B, C (SPEC 6.6, 12.7). The sources are staged +// under `b0`; the prior journaled rename `b0` → `b` leaves them under `b`, so +// the arms' rename is `b` → `b2`. +// +// The H-6 twin is always created after the original's `build`, and every arm +// after its body's first, so the `.mdx` sources of both fixtures are +// staged-source records (S-9's before-any-product clause; +// helpers/staged-mdx.ts) named with every test that prepares the fixture — +// T13.5-7, and T14-9 and T14-10 (section-14-ii.ts; their +// `PRECEDENCE_FIXTURE` and `LISTING_FIXTURE` spread the rename fixture's +// files too) — and their configurations and code source are TypeScript +// staged-source records (helpers/staged-ts.ts; S-9's TypeScript and timing +// clauses), named likewise. +const RENAME_CONFIG = stagedTs( + "T13.5-7/T14-9/T14-10 the rename fixture xspec.config.ts (one spec group, Markdown emission next to sources)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`, +); +const RENAME_A = stagedMdx( + "T13.5-7/T14-9/T14-10 the rename fixture specs/a/A.mdx (a d reference to b0)", + [ + 'import B from "../b/B.xspec"', + "", + '<S id="a" d={B.b0}>', + "Alpha text.", + "</S>", + "", + ].join("\n"), +); +const RENAME_B = stagedMdx( + "T13.5-7/T14-9/T14-10 the rename fixture specs/b/B.mdx (b0 holding b0.k)", + [ + '<S id="b0">', + "Beta text.", + '<S id="b0.k">', + "Kid text.", + "</S>", + "</S>", + "", + ].join("\n"), +); +const RENAME_C = stagedMdx( + "T13.5-7/T14-9/T14-10 the rename fixture specs/c/C.mdx (an embedding of b0)", + [ + 'import B from "../b/B.xspec"', + "", + '<S id="c">', + "Ceta embeds: {text(B.b0)}", + "</S>", + "", + ].join("\n"), +); + +export const RENAME_A_PATH = "specs/a/A.mdx"; +export const RENAME_B_PATH = "specs/b/B.mdx"; +export const RENAME_C_PATH = "specs/c/C.mdx"; +export const RENAME_B_DIR = "specs/b"; +export const RENAME_B_MODULE = "specs/b/B.xspec.ts"; +export const RENAME_OLD_IDENTITY = "specs/b/B.mdx#b"; +export const RENAME_NEW_IDENTITY = "specs/b/B.mdx#b2"; +/** The arms' rename: `b` → `b2` in `specs/b/B.mdx` (SPEC 6.4). */ +export const RENAME_ARGV: readonly string[] = [ + "rename", + RENAME_B_PATH, + "b", + "b2", +]; + +export const RENAME_FIXTURE: RefusalFixture = { + decl: { + files: { + "xspec.config.ts": RENAME_CONFIG, + [RENAME_A_PATH]: RENAME_A, + [RENAME_B_PATH]: RENAME_B, + [RENAME_C_PATH]: RENAME_C, + }, + }, + priorRename: ["rename", RENAME_B_PATH, "b0", "b"], +}; + +// The move fixture (T13.5-7 (c), (d)): a file-form move `specs/A.mdx` → +// `specs/sub/B.mdx` under `markdown: { emit: true, outDir: "out" }`, the +// moved file importing `docs/Other.mdx` (its own specifier is rewritten +// across the directory change, so the destination's bytes differ from the +// origin's) and imported by the code source `src/app.ts` (the importer whose +// specifier the move rewrites) — `files` order: the relocation's entry +// `specs/A.mdx`, then `src/app.ts` (SPEC 6.5, 6.6, 12.7). The imported file +// lives under a second glob root, `docs/`, so that `out/specs` holds exactly +// the two Markdown files the move concerns — `out/specs/A.md` to remove and +// `out/specs/sub/B.md` to create — whatever else a regeneration rewrites +// (arm (c) stages `out/specs` unwritable and admits exactly those two +// writes). The prior journaled rename `oth0` → `oth` in `docs/Other.mdx` +// makes the workspace journal-bearing. +const MOVE_CONFIG = stagedTs( + "T13.5-7/T14-9 the move fixture xspec.config.ts (spec groups specs/** and docs/**, code group src/**/*.ts, Markdown emission under outDir out)", + `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx", "docs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + }, + markdown: { emit: true, outDir: "out" } +}) +`, +); +const MOVE_OTHER = stagedMdx( + "T13.5-7/T14-9 the move fixture docs/Other.mdx (oth0)", + ['<S id="oth0">', "Other text.", "</S>", ""].join("\n"), +); +const MOVE_A = stagedMdx( + "T13.5-7/T14-9 the move fixture specs/A.mdx (importing docs/Other.mdx, a d reference to oth0)", + [ + 'import Other from "../docs/Other.xspec"', + "", + '<S id="a" d={Other.oth0}>', + "Alpha text.", + "</S>", + "", + ].join("\n"), +); +const MOVE_APP = stagedTs( + "T13.5-7/T14-9 the move fixture src/app.ts (the importer of specs/A.mdx's module whose specifier the move rewrites)", + ['import A from "../specs/A.xspec";', "", "A.a;", ""].join("\n"), +); + +export const MOVE_ORIGIN = "specs/A.mdx"; +export const MOVE_DESTINATION = "specs/sub/B.mdx"; +export const MOVE_DESTINATION_DIR = "specs/sub"; +export const MOVE_IMPORTER = "src/app.ts"; +export const MOVE_OTHER_PATH = "docs/Other.mdx"; +/** The emitted-Markdown area the regeneration writes under (T13.5-7 (c)). */ +export const MOVE_MARKDOWN_DIR = "out/specs"; +/** The two Markdown writes the move's regeneration owes under `out/specs`. */ +export const MOVE_MARKDOWN_WRITES: readonly string[] = [ + "out/specs/sub/B.md", + "out/specs/A.md", +]; +/** The arms' file-form move (SPEC 6.5). */ +export const MOVE_ARGV: readonly string[] = [ + "move", + MOVE_ORIGIN, + MOVE_DESTINATION, +]; + +export const MOVE_FIXTURE: RefusalFixture = { + decl: { + files: { + "xspec.config.ts": MOVE_CONFIG, + [MOVE_OTHER_PATH]: MOVE_OTHER, + [MOVE_ORIGIN]: MOVE_A, + [MOVE_IMPORTER]: MOVE_APP, + }, + }, + priorRename: ["rename", MOVE_OTHER_PATH, "oth0", "oth"], +}; + +export const JOURNAL_PATH = ".xspec/journal"; +export const GRAPH_DATA_AREA = ".xspec"; +export const REVIEWS_DIR = ".xspec/reviews"; + +// --------------------------------------------------------------------------- +// Workspace preparation and twins +// --------------------------------------------------------------------------- + +/** A prepared workspace, its `impact` baseline, and its pre-operation state. */ +export interface PreparedWorkspace { + readonly workspace: TestWorkspace; + /** The commit holding the prepared sources and journal (SPEC 6.3). */ + readonly baseline: string; + /** Every workspace entry at the end of preparation (`.git` excluded). */ + readonly before: DirectorySnapshot; +} + +const GIT_DIR_BYTES = Buffer.from(".git", "utf8"); + +/** Exclude exactly the top-level `.git` tree (the harness's own baseline). */ +function excludeGitTree(relPathBytes: Uint8Array): boolean { + return Buffer.compare(Buffer.from(relPathBytes), GIT_DIR_BYTES) === 0; +} + +/** The byte state of every workspace entry, `.git` excluded. */ +export async function snapshotWorkspace( + root: string, +): Promise<DirectorySnapshot> { + return await snapshotDirectory(root, { exclude: excludeGitTree }); +} + +/** + * Stage T13.5-7's common start: a freshly built, valid, journal-bearing + * workspace with no refresh pending — `build`, the fixture's prior journaled + * rename (SPEC 6.1), `check` clean — committed as the `impact --base` + * baseline of the recovery assertions (SPEC 6.3). The caller disposes the + * workspace. + */ +export async function prepareRefusalWorkspace( + product: ProductBinding, + fixture: RefusalFixture, + context: string, +): Promise<PreparedWorkspace> { + const workspace = await TestWorkspace.create(fixture.decl); + try { + await buildOk( + product, + workspace, + `${context} staging \`build\` (SPEC 12.1)`, + ); + await expectExit( + product, + workspace, + fixture.priorRename, + 0, + `${context} staging \`${fixture.priorRename.join(" ")}\` — the prior ` + + `journaled rename makes the workspace journal-bearing (SPEC 6.1, 6.4)`, + ); + const journalKind = await workspace.kind(JOURNAL_PATH); + if (journalKind !== "file") { + fail( + `${context}: after the prior journaled rename the journal exists as ` + + `a plain file at ${JOURNAL_PATH} (SPEC 6.1: the file comes into ` + + `existence with the first journaled operation); found ${journalKind}`, + ); + } + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} staging \`check\` — a freshly built, valid workspace with ` + + `no refresh pending (SPEC 12.2, 6.4)`, + ); + await workspace.gitInit(); + const baseline = await workspace.gitCommitAll("pre-operation"); + const before = await snapshotWorkspace(workspace.root); + return { workspace, baseline, before }; + } catch (error) { + await workspace.dispose(); + throw error; + } +} + +/** The states an identically prepared twin passes through (H-6). */ +export interface TwinOutcome { + /** The twin right before the operation — the common pre-state. */ + readonly before: DirectorySnapshot; + /** The twin once the operation completed unrefused. */ + readonly after: DirectorySnapshot; +} + +/** + * Run `argv` unrefused on an identically prepared twin — `prepare` applying + * the arm's own pre-invocation staging (an edit, a session) on it too — and + * capture its state before and after: every rewritten-byte expectation is + * read from here (H-6), never composed by the harness. + */ +export async function completeOnTwin( + product: ProductBinding, + fixture: RefusalFixture, + argv: readonly string[], + prepare: ((workspace: TestWorkspace) => Promise<void>) | undefined, + context: string, +): Promise<TwinOutcome> { + const twin = await prepareRefusalWorkspace( + product, + fixture, + `${context} twin`, + ); + try { + if (prepare !== undefined) await prepare(twin.workspace); + const before = await snapshotWorkspace(twin.workspace.root); + await expectExit( + product, + twin.workspace, + argv, + 0, + `${context} twin \`${argv.join(" ")}\` — the same operation completes ` + + `unrefused on the identical twin (H-6)`, + ); + const after = await snapshotWorkspace(twin.workspace.root); + return { before, after }; + } finally { + await twin.workspace.dispose(); + } +} + +/** + * The comparison premise: the workspace and its twin are byte-identical + * before the operation, so the twin's post-operation state is the + * workspace's own expectation (H-6). + */ +export function assertTwinsIdentical( + workspaceBefore: DirectorySnapshot, + twinBefore: DirectorySnapshot, + context: string, +): void { + assertSnapshotsEqual( + workspaceBefore, + twinBefore, + `${context}: the workspace vs its identically staged twin before the ` + + `operation — byte-identical staging is the premise of every ` + + `twin-read expectation (H-6; a product whose output varies across ` + + `directories cannot be compared)`, + ); +} + +// --------------------------------------------------------------------------- +// Snapshot entries and workspace-file classes (SPEC 13.4) +// --------------------------------------------------------------------------- + +function sameEntry( + a: SnapshotEntry | undefined, + b: SnapshotEntry | undefined, +): boolean { + if (a === undefined || b === undefined) return a === b; + if (a.kind !== b.kind) return false; + if (a.kind === "file" && b.kind === "file") { + return Buffer.compare(a.bytes, b.bytes) === 0; + } + if (a.kind === "symlink" && b.kind === "symlink") { + return Buffer.compare(a.target, b.target) === 0; + } + return true; +} + +function renderEntry(entry: SnapshotEntry | undefined): string { + return entry === undefined ? "absent" : describeEntry(entry); +} + +function unionKeys(...snapshots: readonly DirectorySnapshot[]): string[] { + const keys = new Set<string>(); + for (const snapshot of snapshots) { + for (const key of snapshot.entries.keys()) keys.add(key); + } + return [...keys].sort(); +} + +/** The graph-data area or anything beneath it (SPEC 11.6, 13.3). */ +export function isAreaPath(rel: string): boolean { + return rel === GRAPH_DATA_AREA || rel.startsWith(`${GRAPH_DATA_AREA}/`); +} + +/** Graph data: the area minus its durable occupants — journal and sessions. */ +export function isGraphDataPath(rel: string): boolean { + if (rel === JOURNAL_PATH) return false; + if (rel === REVIEWS_DIR || rel.startsWith(`${REVIEWS_DIR}/`)) return false; + return isAreaPath(rel); +} + +/** A source of either fixture: the configuration, spec sources, code sources. */ +export function isSourcePath(rel: string): boolean { + return ( + rel === "xspec.config.ts" || rel.endsWith(".mdx") || rel.startsWith("src/") + ); +} + +/** + * A derived file (SPEC 13.4): a plain file that is neither a source nor under + * the graph-data area — generated modules and companions, emitted Markdown. + */ +export function isDerivedFile( + rel: string, + entry: SnapshotEntry | undefined, +): boolean { + return entry?.kind === "file" && !isSourcePath(rel) && !isAreaPath(rel); +} + +/** The derived scope for whole-entry laws: everything outside sources and the area. */ +export function isDerivedScope(rel: string): boolean { + return !isSourcePath(rel) && !isAreaPath(rel); +} + +// --------------------------------------------------------------------------- +// The state a stopped command leaves (SPEC 13.5, 14.24) +// --------------------------------------------------------------------------- + +/** + * SPEC 13.5's pinned-order law, entry by entry over the union of the three + * snapshots: every entry in `completed` — the writes preceding the refused + * one — holds the twin's post-operation state (a removal's absence + * included), and every other entry holds its pre-invocation state (no later + * write attempted). + */ +export function assertPinnedState( + before: DirectorySnapshot, + after: DirectorySnapshot, + twinAfter: DirectorySnapshot, + completed: readonly string[], + context: string, +): void { + const completedSet = new Set(completed); + for (const key of unionKeys(before, after, twinAfter)) { + const isCompleted = completedSet.has(key); + const expected = isCompleted + ? twinAfter.entries.get(key) + : before.entries.get(key); + const actual = after.entries.get(key); + if (sameEntry(actual, expected)) continue; + fail( + `${context}: ${key} must hold ${ + isCompleted + ? "the complete new content — an earlier write in the pinned " + + "order, byte-equal to the twin's post-operation state" + : "its pre-invocation state — a later write in the pinned order " + + "is never attempted" + } (SPEC 13.5, 14.24); expected ${renderEntry(expected)}, found ` + + renderEntry(actual), + ); + } +} + +/** + * SPEC 13.5's weaker law where the write order is unpinned (a regeneration's + * derived-file writes): every entry in `scope` holds either its prior state + * or its complete new content — the twin's — never a partial write. + */ +export function assertEachWriteComplete( + before: DirectorySnapshot, + after: DirectorySnapshot, + twinAfter: DirectorySnapshot, + scope: (rel: string) => boolean, + context: string, +): void { + for (const key of unionKeys(before, after, twinAfter)) { + if (!scope(key)) continue; + const actual = after.entries.get(key); + if (sameEntry(actual, before.entries.get(key))) continue; + if (sameEntry(actual, twinAfter.entries.get(key))) continue; + fail( + `${context}: ${key} must hold either its prior state or its complete ` + + `new content — each write complete, never partial (SPEC 13.5); ` + + `prior ${renderEntry(before.entries.get(key))}, complete ` + + `${renderEntry(twinAfter.entries.get(key))}, found ` + + renderEntry(actual), + ); + } +} + +/** Derived paths whose occupant differs from the twin's derived state. */ +export function derivedPathsDifferingFrom( + after: DirectorySnapshot, + twinAfter: DirectorySnapshot, +): string[] { + const paths: string[] = []; + for (const key of unionKeys(after, twinAfter)) { + const actual = after.entries.get(key); + const twin = twinAfter.entries.get(key); + if (!isDerivedFile(key, actual) && !isDerivedFile(key, twin)) continue; + if (!sameEntry(actual, twin)) paths.push(key); + } + return paths; +} + +/** Whether graph data differs from the twin's (the 14.10 unit form's premise). */ +export function graphDataDiffers( + after: DirectorySnapshot, + twinAfter: DirectorySnapshot, +): boolean { + return unionKeys(after, twinAfter).some( + (key) => + isGraphDataPath(key) && + !sameEntry(after.entries.get(key), twinAfter.entries.get(key)), + ); +} + +// --------------------------------------------------------------------------- +// Choreography: a refusal staged while the command is held at the seam +// --------------------------------------------------------------------------- + +/** How a refusal is staged: the permission helper applied at a workspace root. */ +export type StagingApplier = (root: string) => Promise<PermissionStaging>; + +/** T14-9's path-form discipline at a workspace-relative path. */ +export function refusalAt(rel: string): StagingApplier { + return async (root) => await stageWriteRefusal(path.join(root, rel)); +} + +/** T14-9's area-form discipline beneath a workspace-relative directory. */ +export function refusalUnder(rel: string): StagingApplier { + return async (root) => await stageWriteRefusalUnder(path.join(root, rel)); +} + +/** + * Run a command to completion under a hang guard (never an assertion input, + * H-10), converting a rejection — a product that blocks or hangs, killed at + * the bound — into a diagnosed failure (H-8). An exhausted capture limit is + * never converted: it propagates as the harness error it is (H-11). + * `guards` lower the bound or the capture limit for S-8's vector alone. + */ +export async function runSettled( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, + guards: RunGuards = {}, +): Promise<RunResult> { + try { + return await runProduct(product, { + cwd: workspace.root, + argv, + timeoutMs: guards.timeoutMs ?? 30_000, + maxOutputBytes: guards.maxOutputBytes, + }); + } catch (error) { + rethrowOutputOverflow(error); + return fail( + `${context}: the command must terminate on its own rather than block ` + + `or hang (SPEC 12.0; H-8: hangs become diagnosed failures) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Start `argv` under `--test-hold`, wait for the hold (loud when absent), + * apply the staging at the seam, release the hold, and wait for the exit. + * The staging is restored, the hold released, and the process killed + * whatever happens (a `HarnessStagingError` from `apply` included). A + * rejected run fails diagnosed (H-8), except an exhausted capture limit, + * which propagates as the harness error it is (H-11). `guards` lower the + * hang guard or the capture limit for S-8's vector alone. + */ +export async function runHeldWithStaging( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + holdName: string, + apply: StagingApplier, + context: string, + guards: RunGuards = {}, +): Promise<RunResult> { + const hold = holdPathFor(workspace, holdName); + const running = await startProduct(product, { + cwd: workspace.root, + argv: [...argv, "--test-hold", hold], + timeoutMs: guards.timeoutMs, + maxOutputBytes: guards.maxOutputBytes, + }); + let staging: PermissionStaging | undefined; + try { + await awaitHoldFile(running, hold, context); + staging = await apply(workspace.root); + await releaseHoldFile(hold); + try { + return await running.waitForExit(); + } catch (error) { + rethrowOutputOverflow(error); + return fail( + `${context}: once the hold file is deleted the command must proceed ` + + `and terminate on its own, stopping at the refused write (SPEC ` + + `13.5, 14.24; H-8: hangs become diagnosed failures) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } finally { + running.kill(); + await releaseHoldFile(hold); + if (staging !== undefined) await staging.restore(); + } +} + +/** Stage before the invocation (commands taking no hold), run, restore. */ +export async function runStaged( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + apply: StagingApplier, + context: string, +): Promise<RunResult> { + const staging = await apply(workspace.root); + try { + return await runSettled(product, workspace, argv, context); + } finally { + await staging.restore(); + } +} + +// --------------------------------------------------------------------------- +// The 14.24 contract and the states `check` reports +// --------------------------------------------------------------------------- + +/** 14.24's stable code, carried only by the exit-2 error document (SPEC 14). */ +export const WRITE_FAILURE_CODE = "write-failure"; + +/** + * A refused write's contract (SPEC 14.24, 12.0, 12.7; T13.5-7, T14-9): exit + * 2; the error document as the entire stdout; its finding's stable code + * `write-failure`; its `path` one of the admissible concerned paths — the + * workspace-relative path of the file the write would have produced or + * removed (or the graph-data area for a graph-data write). + */ +export function expectWriteFailure( + result: RunResult, + concerned: readonly string[], + context: string, +): Finding { + assertExitCode( + result, + 2, + `${context} — a write the environment refuses is a usage error: the ` + + `command stops at that write and exits 2, never a finding, never an ` + + `internal error (SPEC 14.24, 12.0)`, + ); + const finding = expectErrorDocument(result, context); + if (finding.code !== WRITE_FAILURE_CODE) { + fail( + `${context} — the error document's finding carries the stable code ` + + `${JSON.stringify(WRITE_FAILURE_CODE)} (SPEC 14.24, 14, 12.7); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if (typeof finding.path !== "string" || !concerned.includes(finding.path)) { + fail( + `${context} — the concerned path is the workspace-relative path of ` + + `the file the refused write would have produced or removed: ` + + `${concerned.length === 1 ? JSON.stringify(concerned[0]) : `one of ${JSON.stringify(concerned)}`} ` + + `(SPEC 14.24, 12.7); got ${JSON.stringify(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + return finding; +} + +/** `check --json` on a workspace carrying findings, decoded (SPEC 12.2). */ +export async function checkFindings( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<readonly Finding[]> { + const label = `${context} \`check --json\``; + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${label} — \`check\` exits 1 on any finding (SPEC 12.2, 12.0)`, + ); + return decodeFindingsReport(parseJsonStdout(result, label), label).findings; +} + +/** The condition-10 forms `check` is expected to report (SPEC 14.10). */ +export interface StalenessExpectation { + /** Exactly the derived paths reported in the per-file form. */ + readonly perFile: readonly string[]; + /** Whether the graph-data unit form (concerning the area) is reported. */ + readonly unit: boolean; +} + +/** + * `check` reports condition 10 alone, in exactly the expected forms: one + * per-file finding per expected derived path, concerning it, and the unit + * form — concerning the graph-data area — exactly when expected (SPEC 14.10, + * 12.7, 11.6). + */ +export function assertStalenessAlone( + findings: readonly Finding[], + expected: StalenessExpectation, + context: string, +): void { + for (const finding of findings) { + if (finding.condition !== "14.10") { + fail( + `${context}: every finding must be condition 10 — the state ` + + `manifests as staleness alone (SPEC 13.5, 14.10); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + const unitCount = findings.filter( + (finding) => finding.path === GRAPH_DATA_AREA, + ).length; + const perFile = findings + .filter((finding) => finding.path !== GRAPH_DATA_AREA) + .map((finding) => + typeof finding.path === "string" + ? finding.path + : JSON.stringify(finding.path), + ) + .sort(); + const expectedPerFile = [...expected.perFile].sort(); + if ( + perFile.length !== expectedPerFile.length || + perFile.some((rel, index) => rel !== expectedPerFile[index]) + ) { + fail( + `${context}: the per-file condition-10 findings must name exactly the ` + + `derived paths whose occupant differs from what the current sources ` + + `and configuration generate — one finding per path, concerning it ` + + `(SPEC 14.10, 12.7); expected ${JSON.stringify(expectedPerFile)}, ` + + `got ${JSON.stringify(perFile)}`, + ); + } + const expectedUnit = expected.unit ? 1 : 0; + if (unitCount !== expectedUnit) { + fail( + `${context}: the graph-data unit form — one condition-10 finding ` + + `concerning the graph-data area ${GRAPH_DATA_AREA} — is reported ` + + `exactly when graph data does not match the current sources (SPEC ` + + `14.10, 11.6); expected ${String(expectedUnit)}, got ` + + `${String(unitCount)}`, + ); + } +} + +/** `impact --base <baseline> --json`, decoded (SPEC 9.3, 6.3). */ +export async function impactAgainst( + product: ProductBinding, + workspace: TestWorkspace, + baseline: string, + context: string, +): Promise<ImpactReport> { + const label = `${context} \`impact --base <pre-operation ref> --json\``; + return decodeImpactReport( + await runJson( + product, + workspace, + ["impact", "--base", baseline, "--json"], + label, + ), + label, + ); +} + +/** + * A rename stopped before its journal append left manual restructuring + * (SPEC 6.7): against the pre-rename baseline the old identity is reported + * deleted and the new one added (`changed`, not deleted). + */ +export function assertManualRestructuring( + report: ImpactReport, + oldIdentity: string, + newIdentity: string, + context: string, +): void { + const deleted = report.requirements.find( + (entry) => entry.deleted && entry.nodes.includes(oldIdentity), + ); + if (deleted === undefined) { + fail( + `${context}: the old identity ${oldIdentity} is reported deleted — no ` + + `entry having been journaled, the completed source edits stand as ` + + `manual restructuring, deletions plus additions (SPEC 6.7, 13.5, ` + + `6.3); got ${JSON.stringify(report.requirements)}`, + ); + } + const added = report.requirements.find( + (entry) => + !entry.deleted && + entry.nodes.includes(newIdentity) && + entry.categories.some((category) => category.category === "changed"), + ); + if (added === undefined) { + fail( + `${context}: the new identity ${newIdentity} is reported added — a ` + + `\`changed\` entry, not deleted (SPEC 6.7, 5.6); got ` + + JSON.stringify(report.requirements), + ); + } +} + +/** A journaled file move is pure: no change categories, no impacted code. */ +export function assertNoChangeCategories( + report: ImpactReport, + context: string, +): void { + if ( + report.requirements.length !== 0 || + report.code.direct.length !== 0 || + report.code.transitive.length !== 0 + ) { + fail( + `${context}: a journaled file move produces no change categories ` + + `relative to the pre-move baseline — the identity effect is ` + + `observable through the journal's effects alone (SPEC 6.2, 13.5); ` + + `got ${JSON.stringify(report)}`, + ); + } +} + +/** `review status <name> --json`, decoded (SPEC 10.7). */ +export async function sessionStatus( + product: ProductBinding, + workspace: TestWorkspace, + name: string, + context: string, +): Promise<SessionStatusReport> { + const label = `${context} \`review status ${name} --json\``; + return decodeSessionStatusReport( + await runJson( + product, + workspace, + ["review", "status", name, "--json"], + label, + ), + label, + ); +} + +/** The unique status row scoped at `scope` (SPEC 10.1: one audit item per node). */ +export function requireItemByScope( + report: SessionStatusReport, + scope: string, + context: string, +): SessionStatusReport["items"][number] { + const rows = report.items.filter((row) => row.scope === scope); + const row = rows[0]; + if (rows.length !== 1 || row === undefined) { + fail( + `${context}: expected exactly one item scoped at ${scope} (SPEC 10.1, ` + + `10.6); found ${String(rows.length)} among ` + + JSON.stringify(report.items.map((item) => item.scope)), + ); + } + return row; +} + +// --------------------------------------------------------------------------- +// T13.5-7's arms (TEST-SPEC T13.5-7 (a)–(f) and the kill arm) +// --------------------------------------------------------------------------- + +const RENAME_JSON: readonly string[] = [...RENAME_ARGV, "--json"]; +const MOVE_JSON: readonly string[] = [...MOVE_ARGV, "--json"]; + +/** The rename twin — arms (a), (b), and the kill arm share its outcome. */ +export async function renameTwin( + product: ProductBinding, +): Promise<TwinOutcome> { + return await completeOnTwin( + product, + RENAME_FIXTURE, + RENAME_ARGV, + undefined, + "T13.5-7 rename", + ); +} + +/** The move twin — arms (c) and (d) share its outcome. */ +export async function moveTwin(product: ProductBinding): Promise<TwinOutcome> { + return await completeOnTwin( + product, + MOVE_FIXTURE, + MOVE_ARGV, + undefined, + "T13.5-7 move", + ); +} + +/** + * The byte window of `needle`'s first occurrence in the snapshot's plain + * file at `rel` — the offending construct's own range, read from the twin's + * rewritten bytes (never composed by the harness). + */ +function bytesWindow( + snapshot: DirectorySnapshot, + rel: string, + needle: string, + context: string, +): { readonly start: number; readonly end: number } { + const entry = snapshot.entries.get(rel); + if (entry === undefined || entry.kind !== "file") { + return fail( + `${context}: the twin's ${rel} must be a plain file after the ` + + `operation (SPEC 6.4); found ${renderEntry(entry)}`, + ); + } + const start = Buffer.from(entry.bytes).indexOf(Buffer.from(needle, "utf8")); + if (start < 0) { + return fail( + `${context}: the twin's rewritten ${rel} must contain ` + + `${JSON.stringify(needle)} (SPEC 6.4: the rename rewrites the ` + + `reference spelling); found ${JSON.stringify(Buffer.from(entry.bytes).toString("utf8"))}`, + ); + } + return { start, end: start + Buffer.byteLength(needle, "utf8") }; +} + +/** + * (a) Source edits first, one write per file, in preview `files` order + * (A, B, C by path bytes): with `specs/b` staged unwritable, A is rewritten + * and byte-equal to the twin's, B and C are byte-untouched, and the journal, + * derived files, and graph data are byte-unchanged; the error document + * concerns `specs/b/B.mdx`; `check` reports exactly one finding, condition + * 5 for A's now-unresolved spelling. + */ +export async function sourceEditsArm( + product: ProductBinding, + twin: TwinOutcome, +): Promise<void> { + const context = + "T13.5-7 (a) `rename specs/b/B.mdx b b2 --json` with specs/b unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + assertTwinsIdentical(prepared.before, twin.before, context); + const result = await runHeldWithStaging( + product, + workspace, + RENAME_JSON, + "hold-a.tmp", + refusalUnder(RENAME_B_DIR), + context, + ); + expectWriteFailure(result, [RENAME_B_PATH], context); + const after = await snapshotWorkspace(workspace.root); + assertPinnedState( + prepared.before, + after, + twin.after, + [RENAME_A_PATH], + `${context}: the state left — A rewritten (the first source edit, ` + + `complete and byte-equal to the twin's), B and C byte-untouched ` + + `(the refused write and the later one), the journal byte-unchanged ` + + `(no entry: the append is the commit point, reached only once every ` + + `source edit is in place), derived files and graph data byte-unchanged`, + ); + const findings = await checkFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.5": 1 }, + `${context}: \`check\` reports exactly one finding — condition 5 for ` + + `A's now-unresolved \`d\` reference (SPEC 13.5: a partly applied ` + + `rewrite manifests as 14.5–14.7; 14.10's mismatch forms are ` + + `undetectable on a failing workspace, T12.2-4)`, + ); + const finding = findings[0]; + if (finding === undefined) { + return fail(`${context}: one condition-5 finding expected (SPEC 14.5)`); + } + assertFindingLocated( + finding, + { + file: RENAME_A_PATH, + window: bytesWindow(twin.after, RENAME_A_PATH, "d={B.b2}", context), + }, + `${context}: the condition-5 finding locates A's unresolved \`d\` ` + + `spelling (SPEC 14.5, 12.7)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** + * (b) The journal append as the commit point: with `.xspec/journal` staged + * unwritable (`.xspec` read-only, graph data untouched) every source edit is + * made (A, B, C byte-equal to the twin's), no journal entry is appended, and + * derived files and graph data are byte-unchanged; the error document + * concerns `.xspec/journal`; `check` reports condition 10 alone — per-file + * staleness for B's module and companions and the graph-data unit form; + * once restored, `impact --base <pre-rename ref>` reports the old identity + * deleted and the new one added (manual restructuring, 6.7) and the next + * `build` leaves `check` clean. + */ +export async function journalCommitPointArm( + product: ProductBinding, + twin: TwinOutcome, +): Promise<void> { + const context = + "T13.5-7 (b) `rename specs/b/B.mdx b b2 --json` with .xspec/journal unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace, baseline } = prepared; + try { + assertTwinsIdentical(prepared.before, twin.before, context); + const result = await runHeldWithStaging( + product, + workspace, + RENAME_JSON, + "hold-b.tmp", + refusalAt(JOURNAL_PATH), + context, + ); + expectWriteFailure(result, [JOURNAL_PATH], context); + const after = await snapshotWorkspace(workspace.root); + assertPinnedState( + prepared.before, + after, + twin.after, + [RENAME_A_PATH, RENAME_B_PATH, RENAME_C_PATH], + `${context}: the state left — every source edit made (A, B, C ` + + `byte-equal to the twin's), no journal entry (the file ` + + `byte-unchanged), derived files and graph data byte-unchanged (the ` + + `finishing regeneration follows the append)`, + ); + const perFile = derivedPathsDifferingFrom(after, twin.after); + if (!perFile.includes(RENAME_B_MODULE)) { + fail( + `${context}: B's generated module ${RENAME_B_MODULE} must differ ` + + `from the twin's regenerated one — the rename changed the ` + + `identities it exports (SPEC 13.1) — so it is certainly among the ` + + `stale derived paths; differing: ${JSON.stringify(perFile)}`, + ); + } + if (!graphDataDiffers(after, twin.after)) { + fail( + `${context}: graph data must differ from the twin's regenerated ` + + `graph data — it carries the identities the rename changed (SPEC ` + + `13.3) — so the unit form is certainly reported; found it ` + + `byte-identical`, + ); + } + assertStalenessAlone( + await checkFindings(product, workspace, context), + { perFile, unit: true }, + `${context}: \`check\` after the refused append — condition 10 alone: ` + + `per-file staleness for the derived paths whose occupant differs ` + + `from the twin's regenerated state (B's module and companions) and ` + + `the graph-data unit form, the workspace being valid and ` + + `consistently rewritten (SPEC 13.5, 14.10)`, + ); + // Recovery, the permissions restored (SPEC 6.7, 12.1). + assertManualRestructuring( + await impactAgainst(product, workspace, baseline, context), + RENAME_OLD_IDENTITY, + RENAME_NEW_IDENTITY, + context, + ); + await buildOk( + product, + workspace, + `${context}: the next \`build\` (SPEC 12.1)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: after the next \`build\`, \`check\` is clean (SPEC 12.2, 13.5)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** The snapshot restricted to the entries `scope` admits. */ +function restrictSnapshot( + snapshot: DirectorySnapshot, + scope: (rel: string) => boolean, +): DirectorySnapshot { + const entries = new Map<string, SnapshotEntry>(); + for (const [key, entry] of snapshot.entries) { + if (scope(key)) entries.set(key, entry); + } + return { root: snapshot.root, entries }; +} + +/** Sources and the journal: the writes 13.5 orders for a rename or move. */ +function isOrderedWritePath(rel: string): boolean { + return isSourcePath(rel) || rel === JOURNAL_PATH; +} + +/** The regeneration's scope: derived files and graph data (order unpinned). */ +function isRegenerationPath(rel: string): boolean { + return isDerivedScope(rel) || isGraphDataPath(rel); +} + +/** + * (c) Stopped after the append, the identity effect complete: the file-form + * move with `out/specs` staged unwritable — every source edit made and the + * relocation complete (origin absent, destination present, the importer + * rewritten), the journal holding exactly one new entry byte-equal to the + * twin's, the error document concerning one of the two Markdown writes the + * regeneration owes under `out/specs` (`out/specs/sub/B.md`'s creation — + * the directory it needs is part of that write — or `out/specs/A.md`'s + * removal; the order among a regeneration's derived-file writes is + * unpinned) as the entire stdout (no `mapping`); every derived-file and + * graph-data entry its prior state or the twin's; `check` reports condition + * 10 alone — the stale remainder, the destination's Markdown certainly + * missing; restored, `impact` reports no change categories and the next + * `build` leaves `check` clean. + */ +export async function afterAppendArm( + product: ProductBinding, + twin: TwinOutcome, +): Promise<void> { + const context = + "T13.5-7 (c) `move specs/A.mdx specs/sub/B.mdx --json` with out/specs unwritable"; + const prepared = await prepareRefusalWorkspace( + product, + MOVE_FIXTURE, + context, + ); + const { workspace, baseline } = prepared; + try { + assertTwinsIdentical(prepared.before, twin.before, context); + const result = await runHeldWithStaging( + product, + workspace, + MOVE_JSON, + "hold-c.tmp", + refusalUnder(MOVE_MARKDOWN_DIR), + context, + ); + expectWriteFailure(result, MOVE_MARKDOWN_WRITES, context); + const after = await snapshotWorkspace(workspace.root); + assertPinnedState( + restrictSnapshot(prepared.before, isOrderedWritePath), + restrictSnapshot(after, isOrderedWritePath), + restrictSnapshot(twin.after, isOrderedWritePath), + [MOVE_ORIGIN, MOVE_DESTINATION, MOVE_IMPORTER, JOURNAL_PATH], + `${context}: the state left — every source edit made, the relocation ` + + `complete (origin absent, destination present, the importer ` + + `rewritten, each byte-equal to the twin's), the journal holding ` + + `exactly one new entry byte-equal to the twin's (the append ` + + `precedes the finishing regeneration)`, + ); + assertEachWriteComplete( + prepared.before, + after, + twin.after, + isRegenerationPath, + `${context}: the finishing regeneration, stopped at the refused ` + + `Markdown write — every derived file and graph-data entry`, + ); + const findings = await checkFindings(product, workspace, context); + const stalePaths = findings.map((finding) => finding.path); + for (const finding of findings) { + if (finding.condition !== "14.10") { + fail( + `${context}: \`check\` reports condition 10 alone — the stale ` + + `remainder of an operation stopped after its commit point ` + + `(SPEC 13.5, 14.10); got ${JSON.stringify(finding.code)} ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } + } + const destinationMarkdown = MOVE_MARKDOWN_WRITES[0]; + if (!stalePaths.includes(destinationMarkdown)) { + fail( + `${context}: the destination's emitted Markdown ` + + `${JSON.stringify(destinationMarkdown)} — never produced, its ` + + `creation refused or not attempted — is certainly among the ` + + `stale derived paths \`check\` names (SPEC 14.10, 13.2); named: ` + + JSON.stringify(stalePaths), + ); + } + // Recovery, the permissions restored (SPEC 6.2, 12.1). + assertNoChangeCategories( + await impactAgainst(product, workspace, baseline, context), + context, + ); + await buildOk( + product, + workspace, + `${context}: the next \`build\` (SPEC 12.1)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: after the next \`build\`, \`check\` is clean (SPEC 12.2, 13.5)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** + * (d) A relocation is two writes, the destination produced and then the + * origin removed: the same move with `specs/sub` present and writable and + * `specs` staged unwritable — the destination present with the moved file's + * rewritten bytes (the twin's), the origin still present and byte-unchanged, + * the error document concerning `specs/A.mdx` (the removal), no journal + * entry, no importer rewritten (the relocation's entry precedes `src/` in + * `files` order), and `check` reporting condition 10 alone — both files + * valid, the destination's derived files missing. + */ +export async function relocationArm( + product: ProductBinding, + twin: TwinOutcome, +): Promise<void> { + const context = + "T13.5-7 (d) `move specs/A.mdx specs/sub/B.mdx --json` with specs unwritable, specs/sub present and writable"; + const prepared = await prepareRefusalWorkspace( + product, + MOVE_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + assertTwinsIdentical(prepared.before, twin.before, context); + await workspace.dir(MOVE_DESTINATION_DIR); + const before = await snapshotWorkspace(workspace.root); + const result = await runHeldWithStaging( + product, + workspace, + MOVE_JSON, + "hold-d.tmp", + refusalAt(MOVE_ORIGIN), + context, + ); + expectWriteFailure(result, [MOVE_ORIGIN], context); + const after = await snapshotWorkspace(workspace.root); + assertPinnedState( + before, + after, + twin.after, + [MOVE_DESTINATION], + `${context}: the state left — the destination present with the moved ` + + `file's rewritten bytes (the twin's), the origin still present and ` + + `byte-unchanged (its removal, the relocation's second write, ` + + `refused), no importer rewritten, no journal entry, derived files ` + + `and graph data byte-unchanged`, + ); + const destinationDerived = [...twin.after.entries.keys()].filter( + (rel) => + isDerivedFile(rel, twin.after.entries.get(rel)) && + (rel.startsWith(`${MOVE_DESTINATION_DIR}/`) || + rel.startsWith(`${MOVE_MARKDOWN_DIR}/sub/`)), + ); + if (destinationDerived.length === 0) { + fail( + `${context}: the twin's completed move generates derived files for ` + + `the destination under ${MOVE_DESTINATION_DIR}/ and ` + + `${MOVE_MARKDOWN_DIR}/sub/ (SPEC 13.1, 13.2); found none among ` + + JSON.stringify([...twin.after.entries.keys()]), + ); + } + assertStalenessAlone( + await checkFindings(product, workspace, context), + { perFile: destinationDerived, unit: true }, + `${context}: \`check\` — condition 10 alone: both files valid, the ` + + `destination's derived files missing (one per-file finding each) ` + + `and graph data not matching the current sources, the destination ` + + `being unrecorded (the unit form) (SPEC 13.5, 14.10)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** One entry of `actual` must equal the same entry of `expected`. */ +function assertSameEntry( + actual: DirectorySnapshot, + expected: DirectorySnapshot, + rel: string, + context: string, +): void { + const found = actual.entries.get(rel); + const wanted = expected.entries.get(rel); + if (sameEntry(found, wanted)) return; + fail( + `${context}: ${rel} — expected ${renderEntry(wanted)}, found ` + + renderEntry(found), + ); +} + +/** Everything outside the graph-data area: sources and derived files. */ +function isOutsideArea(rel: string): boolean { + return !isAreaPath(rel); +} + +const SESSION = "s"; +const NEW_SESSION = "n"; +const SESSION_FILE = `${REVIEWS_DIR}/${SESSION}.json`; +const NEW_SESSION_FILE = `${REVIEWS_DIR}/${NEW_SESSION}.json`; +/** An unblocked leaf item's scope (SPEC 10.6) — not the edited source's. */ +const RESOLVE_SCOPE = "specs/c/C.mdx#c"; +/** A parent item's scope, its node holding a child subtree (`split`). */ +const SPLIT_SCOPE = "specs/b/B.mdx#b"; +const E_EDIT_FROM = "Alpha text."; +const E_EDIT_TO = "Alpha text, edited."; + +/** + * (e) `review` mutators write the session file once, last: on a stale, + * valid workspace (A's text edited after `build`) with `.xspec/reviews` and + * the session file staged unwritable and `.xspec` itself writable, + * `review resolve s <item> --status no-change` exits 2 with the error + * document concerning `.xspec/reviews/s.json`, the session file + * byte-unchanged (`status`, once restored, reports the item still + * `unresolved`), and the refresh already made: `check` afterwards reports + * the edited source's per-file staleness and no unit-form finding — graph + * data matching the current sources — where a product writing the session + * before refreshing, or refusing the refresh, fails; `review create + * --strategy audit --name n` and `split` on the same staging behave alike, + * no `n.json` created and no decomposition recorded. + */ +export async function reviewMutatorsArm( + product: ProductBinding, +): Promise<void> { + const context = + "T13.5-7 (e) review mutators with .xspec/reviews and the session file unwritable"; + const editA = (workspace: TestWorkspace): Promise<void> => + workspace.edit(RENAME_A_PATH, E_EDIT_FROM, E_EDIT_TO); + // The build twin: the same fixture and edit, then `build` — the derived + // state the current sources generate (SPEC 13.3: the refresh writes what + // `build` would write). + const buildTwin = await completeOnTwin( + product, + RENAME_FIXTURE, + ["build"], + editA, + `${context} build`, + ); + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", SESSION], + 0, + `${context} staging \`review create --strategy audit --name s\` (SPEC 10.7)`, + ); + // The item lookup precedes the staleness edit: `review status` is itself + // a refreshing read (SPEC 13.3), so it runs while nothing is stale. + const status = await sessionStatus( + product, + workspace, + SESSION, + `${context} staging`, + ); + const resolveItem = requireItemByScope( + status, + RESOLVE_SCOPE, + `${context} staging`, + ); + const splitItem = requireItemByScope( + status, + SPLIT_SCOPE, + `${context} staging`, + ); + await editA(workspace); + const staged = await snapshotWorkspace(workspace.root); + assertTwinsIdentical( + restrictSnapshot(staged, isOutsideArea), + restrictSnapshot(buildTwin.before, isOutsideArea), + `${context} (sources and derived files; the session and graph data set aside)`, + ); + const sessionEntry = staged.entries.get(SESSION_FILE); + if (sessionEntry === undefined || sessionEntry.kind !== "file") { + fail( + `${context}: the session file exists as a plain file at ` + + `${SESSION_FILE} after \`review create\` (SPEC 10.1); found ` + + renderEntry(sessionEntry), + ); + } + + // `resolve`: the session write, last, refused; the refresh already made. + const resolveContext = `${context} \`review resolve s ${resolveItem.id} --status no-change --json\``; + expectWriteFailure( + await runHeldWithStaging( + product, + workspace, + [ + "review", + "resolve", + SESSION, + resolveItem.id, + "--status", + "no-change", + "--json", + ], + "hold-e-resolve.tmp", + refusalAt(SESSION_FILE), + resolveContext, + ), + [SESSION_FILE], + resolveContext, + ); + const afterResolve = await snapshotWorkspace(workspace.root); + assertSameEntry( + afterResolve, + staged, + SESSION_FILE, + `${resolveContext}: the session file byte-unchanged — the session ` + + `write is the last write, refused (SPEC 13.5, 14.24)`, + ); + assertSnapshotsEqual( + restrictSnapshot(staged, isOrderedWritePath), + restrictSnapshot(afterResolve, isOrderedWritePath), + `${resolveContext}: sources and the journal byte-unchanged (a review ` + + `mutator edits neither, SPEC 10.7, 13.5)`, + ); + const perFile = derivedPathsDifferingFrom(afterResolve, buildTwin.after); + if (perFile.length === 0) { + fail( + `${resolveContext}: the edited source's derived files must differ ` + + `from what \`build\` writes on the identically edited twin — its ` + + `emitted Markdown carries the edited text (SPEC 13.2) — so some ` + + `per-file staleness is certainly reported; found every derived ` + + `file byte-identical to the twin's`, + ); + } + assertStalenessAlone( + await checkFindings(product, workspace, resolveContext), + { perFile, unit: false }, + `${resolveContext}: \`check\` afterwards — the edited source's ` + + `per-file staleness (the derived paths differing from the build ` + + `twin's) and no unit-form finding: the refresh of 13.3 was already ` + + `made, graph data matching the current sources — a product writing ` + + `the session before refreshing, or refusing the refresh, fails here ` + + `(SPEC 13.5, 13.3, 14.10)`, + ); + const restored = requireItemByScope( + await sessionStatus( + product, + workspace, + SESSION, + `${resolveContext} restored`, + ), + RESOLVE_SCOPE, + `${resolveContext} restored`, + ); + if (restored.status !== "unresolved") { + fail( + `${resolveContext}: once the permissions are restored, \`status\` ` + + `reports the item still unresolved — nothing was recorded (SPEC ` + + `13.5, 10.7); got ${JSON.stringify(restored.status)}`, + ); + } + + // `create`: no `n.json` created. + const createContext = `${context} \`review create --strategy audit --name n --json\``; + expectWriteFailure( + await runHeldWithStaging( + product, + workspace, + [ + "review", + "create", + "--strategy", + "audit", + "--name", + NEW_SESSION, + "--json", + ], + "hold-e-create.tmp", + refusalAt(NEW_SESSION_FILE), + createContext, + ), + [NEW_SESSION_FILE], + createContext, + ); + const afterCreate = await snapshotWorkspace(workspace.root); + if (afterCreate.entries.has(NEW_SESSION_FILE)) { + fail( + `${createContext}: no ${NEW_SESSION_FILE} is created — the session ` + + `write, refused, is the command's only write (SPEC 13.5, 14.24); ` + + `found ${renderEntry(afterCreate.entries.get(NEW_SESSION_FILE))}`, + ); + } + assertSameEntry( + afterCreate, + staged, + SESSION_FILE, + `${createContext}: the existing session file byte-unchanged`, + ); + + // `split`: no decomposition recorded. + const splitContext = `${context} \`review split s ${splitItem.id} --json\``; + expectWriteFailure( + await runHeldWithStaging( + product, + workspace, + ["review", "split", SESSION, splitItem.id, "--json"], + "hold-e-split.tmp", + refusalAt(SESSION_FILE), + splitContext, + ), + [SESSION_FILE], + splitContext, + ); + assertSameEntry( + await snapshotWorkspace(workspace.root), + staged, + SESSION_FILE, + `${splitContext}: the session file byte-unchanged — no decomposition ` + + `recorded (SPEC 13.5, 10.7)`, + ); + } finally { + await workspace.dispose(); + } +} + +const F_EDIT_FROM = "Beta text."; +const F_EDIT_TO = "Beta text, edited."; + +/** + * (f) `build` and a refresh, each file complete, the order unpinned: a built + * workspace whose `specs/b/B.mdx` is edited (the workspace valid) before + * `specs/b` is staged unwritable — `build` exits 2 with the error document + * concerning a derived path under `specs/b/` (B's module or a companion, + * the first `specs/b/` write in the product's order); every derived file + * present afterwards is complete — its prior state or byte-equal to the + * twin's — and `check` reports exactly the derived paths whose occupant + * differs from the twin's derived state (computed by comparison, B's module + * and companions certainly in it), the graph-data unit form exactly when + * graph data differs; the next `build`, permissions restored, exits 0 with + * `check` clean (12.1, 13.4). + */ +export async function buildArm(product: ProductBinding): Promise<void> { + const context = + "T13.5-7 (f) `build --json` with specs/b unwritable on the B-edited workspace"; + const editB = (workspace: TestWorkspace): Promise<void> => + workspace.edit(RENAME_B_PATH, F_EDIT_FROM, F_EDIT_TO); + const buildTwin = await completeOnTwin( + product, + RENAME_FIXTURE, + ["build"], + editB, + `${context} build`, + ); + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await editB(workspace); + const staged = await snapshotWorkspace(workspace.root); + assertTwinsIdentical(staged, buildTwin.before, context); + const derivedUnderB = [...buildTwin.after.entries.keys()].filter( + (rel) => + isDerivedFile(rel, buildTwin.after.entries.get(rel)) && + rel.startsWith(`${RENAME_B_DIR}/`), + ); + if (!derivedUnderB.includes(RENAME_B_MODULE)) { + fail( + `${context}: the twin's \`build\` generates B's module ` + + `${RENAME_B_MODULE} beside its source (SPEC 13.1); found ` + + JSON.stringify(derivedUnderB), + ); + } + const result = await runStaged( + product, + workspace, + ["build", "--json"], + refusalUnder(RENAME_B_DIR), + context, + ); + expectWriteFailure( + result, + derivedUnderB, + `${context} — the first \`specs/b/\` write in the product's order, a ` + + `derived path (B's module or a companion), is the refused one`, + ); + const after = await snapshotWorkspace(workspace.root); + assertSnapshotsEqual( + restrictSnapshot(staged, isOrderedWritePath), + restrictSnapshot(after, isOrderedWritePath), + `${context}: \`build\` writes no source and no journal (SPEC 12.1, 6.1)`, + ); + assertEachWriteComplete( + staged, + after, + buildTwin.after, + isRegenerationPath, + `${context}: every derived file present afterwards is complete — its ` + + `prior state or byte-equal to the twin's — and so is graph data ` + + `(the order among a \`build\`'s derived-file writes is unpinned)`, + ); + const perFile = derivedPathsDifferingFrom(after, buildTwin.after); + if (!perFile.includes(RENAME_B_MODULE)) { + fail( + `${context}: B's module ${RENAME_B_MODULE}, whose regeneration was ` + + `refused or never attempted, certainly differs from the twin's ` + + `(the edited text changes it, SPEC 13.1); differing: ` + + JSON.stringify(perFile), + ); + } + assertStalenessAlone( + await checkFindings(product, workspace, context), + { perFile, unit: graphDataDiffers(after, buildTwin.after) }, + `${context}: \`check\` reports exactly the derived paths whose ` + + `occupant differs from the twin's derived state (B's module and ` + + `companions certainly among them) and the graph-data unit form ` + + `exactly when graph data differs (SPEC 14.10, 12.1, 13.4)`, + ); + await buildOk( + product, + workspace, + `${context}: the next \`build\`, permissions restored (SPEC 12.1)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: after the next \`build\`, \`check\` is clean (SPEC 12.2, 13.4)`, + ); + } finally { + await workspace.dispose(); + } +} + +/** + * (f), the refreshing reads: on the same edited-but-not-rebuilt workspace + * with `.xspec` staged unwritable, `query nodes` and `view specs/a/A.mdx` + * each exit 2 with the error document concerning `.xspec` (14.24: a + * graph-data write concerns the area), stdout exactly that document and no + * answer (13.3, 11.2), while `check` on the same state exits 1 reporting the + * staleness (never a 14.24 reporter) and a `move --preview` exits 0 writing + * nothing (6.6). + */ +export async function refreshingReadsArm( + product: ProductBinding, +): Promise<void> { + const context = + "T13.5-7 (f) refreshing reads with .xspec unwritable on the B-edited workspace"; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + await workspace.edit(RENAME_B_PATH, F_EDIT_FROM, F_EDIT_TO); + const staged = await snapshotWorkspace(workspace.root); + const staging = await stageWriteRefusalUnder( + path.join(workspace.root, GRAPH_DATA_AREA), + ); + try { + for (const argv of [ + ["query", "nodes", "--json"], + ["view", RENAME_A_PATH, "--json"], + ]) { + const label = `${context} \`${argv.join(" ")}\``; + expectWriteFailure( + await runSettled(product, workspace, argv, label), + [GRAPH_DATA_AREA], + `${label} — a refreshing read of 13.3 whose graph-data write the ` + + `environment refuses concerns the graph-data area, never a ` + + `path inside it, and answers nothing (SPEC 14.24, 13.3, 11.2)`, + ); + } + const checkLabel = `${context} \`check --json\``; + const checkResult = await runSettled( + product, + workspace, + ["check", "--json"], + checkLabel, + ); + assertExitCode( + checkResult, + 1, + `${checkLabel} — \`check\` reports the staleness on the same state, ` + + `exit 1: it writes nothing, so it is never a 14.24 reporter (SPEC ` + + `14.24, 12.2)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(checkResult, checkLabel), + checkLabel, + ).findings; + if (findings.length === 0) { + fail( + `${checkLabel}: exit 1 carries the staleness findings (SPEC 12.2)`, + ); + } + for (const finding of findings) { + if (finding.condition !== "14.10") { + fail( + `${checkLabel}: \`check\` reports the staleness alone — ` + + `condition 10, never a write failure (SPEC 14.24, 14.10); got ` + + `${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + const previewArgv = [ + "move", + RENAME_A_PATH, + "specs/a/A2.mdx", + "--preview", + "--json", + ]; + const previewLabel = `${context} \`${previewArgv.join(" ")}\``; + const preview = await assertLeavesUnchanged( + workspace.root, + () => runSettled(product, workspace, previewArgv, previewLabel), + `${previewLabel}: a preview writes nothing — no sources, no journal, ` + + `no derived files, no graph data (SPEC 6.6)`, + { exclude: excludeGitTree }, + ); + assertExitCode( + preview, + 0, + `${previewLabel} — a preview exits 0 on the valid, stale workspace, ` + + `refreshing nothing (SPEC 6.6, 13.3)`, + ); + } finally { + await staging.restore(); + } + assertSnapshotsEqual( + restrictSnapshot(staged, isOrderedWritePath), + restrictSnapshot( + await snapshotWorkspace(workspace.root), + isOrderedWritePath, + ), + `${context}: sources and the journal byte-unchanged around the reads ` + + `(SPEC 13.3, 6.1)`, + ); + } finally { + await workspace.dispose(); + } +} + +// Post-release kill delays in milliseconds — scheduling choreography only, +// never an assertion input (H-10): the operative assertion is +// delay-independent and disjunctive exactly as 13.5 admits. +const KILL_DELAYS_MS: readonly number[] = [0, 2, 5, 10, 20, 40, 80, 160]; + +/** + * The kill arm, a robustness check only — a kill lands nondeterministically, + * the refused write being the deterministic seam: a kill at the held point + * leaves the workspace consistent (`check` exits 0: the hold precedes all + * modification), and across a spread of post-release kill timings on (a)'s + * rename `check` never crashes and reports exactly a state 13.5 admits — + * clean, or condition 5–7 findings alone (a partly applied rewrite), or + * condition 10 findings alone (the journal appended, derived files not yet + * regenerated) — while the journal is byte-equal either to its prior bytes + * or to the twin's post-operation bytes (13.5: each write complete). + * Platform-safe: no permission staging. + */ +export async function runKillArm( + product: ProductBinding, + twin: TwinOutcome, +): Promise<void> { + const probe = async (delayMs: number | null): Promise<void> => { + const label = + delayMs === null ? "held point" : `${String(delayMs)} ms after release`; + const context = `T13.5-7 (kill, ${label}) \`rename specs/b/B.mdx b b2 --test-hold <path>\``; + const prepared = await prepareRefusalWorkspace( + product, + RENAME_FIXTURE, + context, + ); + const { workspace } = prepared; + try { + assertTwinsIdentical(prepared.before, twin.before, context); + const hold = holdPathFor(workspace, "hold-kill.tmp"); + const running = await startProduct(product, { + cwd: workspace.root, + argv: [...RENAME_ARGV, "--test-hold", hold], + }); + try { + await awaitHoldFile(running, hold, context); + if (delayMs === null) { + // Held-point kill: the hold file is never deleted. + running.kill("SIGKILL"); + } else { + await releaseHoldFile(hold); + if (delayMs > 0) await sleep(delayMs); + running.kill("SIGKILL"); + } + // The run settles for kills and for completions that beat the kill + // alike; the death's shape is not asserted (a post-release kill + // lands nondeterministically). + await running.waitForExit(); + } finally { + running.kill(); + await releaseHoldFile(hold); + } + const after = await snapshotWorkspace(workspace.root); + const journal = after.entries.get(JOURNAL_PATH); + if ( + !sameEntry(journal, prepared.before.entries.get(JOURNAL_PATH)) && + !sameEntry(journal, twin.after.entries.get(JOURNAL_PATH)) + ) { + fail( + `${context}: after the kill the journal is byte-equal either to ` + + `its prior bytes or to the twin's post-operation bytes — each ` + + `write complete, never partial (SPEC 13.5, 6.1); found ` + + renderEntry(journal), + ); + } + const checkContext = `${context} \`check --json\` after the kill`; + const result = await runSettled( + product, + workspace, + ["check", "--json"], + checkContext, + ); + if (delayMs === null) { + assertExitCode( + result, + 0, + `${checkContext}: a kill at the held point demonstrably leaves the ` + + `workspace consistent — the hold precedes all modification ` + + `(SPEC 13.5), so \`check\` passes`, + ); + return; + } + if ( + result.signal !== null || + (result.exitCode !== 0 && result.exitCode !== 1) + ) { + fail( + `${checkContext}: \`check\` never crashes and either passes on a ` + + `consistent state (exit 0) or reports findings (exit 1) — the ` + + `configuration is intact, so no other outcome is stageable ` + + `(SPEC 13.5, 14, 12.0); got ${summarizeResult(result)}`, + ); + } + if (result.exitCode === 1) { + const findings = decodeFindingsReport( + parseJsonStdout(result, checkContext), + checkContext, + ).findings; + const conditions = [...new Set(findings.map((f) => f.condition))]; + const partlyApplied = conditions.every( + (condition) => + condition === "14.5" || + condition === "14.6" || + condition === "14.7", + ); + const staleOnly = conditions.every( + (condition) => condition === "14.10", + ); + if (findings.length === 0 || !(partlyApplied || staleOnly)) { + fail( + `${checkContext}: \`check\` reports exactly a state 13.5 admits ` + + `— clean, or condition 5–7 findings alone (a partly applied ` + + `rewrite, 14.5–14.7), or condition 10 findings alone (the ` + + `journal appended, derived files not yet regenerated) (SPEC ` + + `13.5, 14); got ${JSON.stringify(findings.map((f) => f.code))}`, + ); + } + } + } finally { + await workspace.dispose(); + } + }; + + await probe(null); + for (const delayMs of KILL_DELAYS_MS) { + await probe(delayMs); + } +} + +/** + * T13.5-7's refusal arms (a)–(f) on the Linux leg, each starting from a + * freshly prepared workspace; the rename twin is the caller's (shared with + * the kill arm), the move twin is built here. + */ +export async function runWriteRefusalArms( + product: ProductBinding, + rename: TwinOutcome, +): Promise<void> { + await sourceEditsArm(product, rename); + await journalCommitPointArm(product, rename); + const move = await moveTwin(product); + await afterAppendArm(product, move); + await relocationArm(product, move); + await reviewMutatorsArm(product); + await buildArm(product); + await refreshingReadsArm(product); +} diff --git a/test/suite/section-11.2.test.ts b/test/suite/section-11.2.test.ts new file mode 100644 index 00000000..1a7b43dd --- /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 00000000..444d5501 --- /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 00000000..9ac91944 --- /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 00000000..78206a81 --- /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 00000000..960341d0 --- /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.0-iii.test.ts b/test/suite/section-12.0-iii.test.ts new file mode 100644 index 00000000..f64c46ec --- /dev/null +++ b/test/suite/section-12.0-iii.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §12.0 III (SUITE-42 continued, T12.0-14): 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 conforms (H-8). + +import { declareProductTests } from "./declare.js"; +import { section120iiiTests } from "./registry/section-12.0-iii.js"; + +declareProductTests(section120iiiTests); diff --git a/test/suite/section-12.6.test.ts b/test/suite/section-12.6.test.ts new file mode 100644 index 00000000..4757d75c --- /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 00000000..cd3a708a --- /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-14-ii.test.ts b/test/suite/section-14-ii.test.ts new file mode 100644 index 00000000..c987d080 --- /dev/null +++ b/test/suite/section-14-ii.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §14 II (SUITE-49, the environment refusals): 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 { section14iiTests } from "./registry/section-14-ii.js"; + +declareProductTests(section14iiTests); diff --git a/test/suite/section-14-iii.test.ts b/test/suite/section-14-iii.test.ts new file mode 100644 index 00000000..0dc8d5c4 --- /dev/null +++ b/test/suite/section-14-iii.test.ts @@ -0,0 +1,10 @@ +// TEST-SPEC §14 III (SUITE-49 continued, T14-12 the well-formedness +// contract): 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 conforms (H-8). + +import { declareProductTests } from "./declare.js"; +import { section14iiiTests } from "./registry/section-14-iii.js"; + +declareProductTests(section14iiiTests); diff --git a/test/suite/section-16-p11.test.ts b/test/suite/section-16-p11.test.ts new file mode 100644 index 00000000..16e73cec --- /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 00000000..505647d0 --- /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 00000000..30e4cd37 --- /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 00000000..35929ce3 --- /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.5-ii.test.ts b/test/suite/section-6.5-ii.test.ts new file mode 100644 index 00000000..a03dd528 --- /dev/null +++ b/test/suite/section-6.5-ii.test.ts @@ -0,0 +1,10 @@ +// TEST-SPEC §6.5 second half (SUITE-25 continued, T6.5-11): 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 +// conforms (H-8). + +import { declareProductTests } from "./declare.js"; +import { section65iiTests } from "./registry/section-6.5-ii.js"; + +declareProductTests(section65iiTests); diff --git a/test/suite/section-6.5-iii.test.ts b/test/suite/section-6.5-iii.test.ts new file mode 100644 index 00000000..4bf8c539 --- /dev/null +++ b/test/suite/section-6.5-iii.test.ts @@ -0,0 +1,10 @@ +// TEST-SPEC §6.5 third part (SUITE-25 continued, T6.5-12 through T6.5-19): 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 +// conforms (H-8). + +import { declareProductTests } from "./declare.js"; +import { section65iiiTests } from "./registry/section-6.5-iii.js"; + +declareProductTests(section65iiiTests); diff --git a/test/suite/section-6.5-iv.test.ts b/test/suite/section-6.5-iv.test.ts new file mode 100644 index 00000000..a72b965b --- /dev/null +++ b/test/suite/section-6.5-iv.test.ts @@ -0,0 +1,10 @@ +// TEST-SPEC §6.5 fourth part (SUITE-25 continued, T6.5-20 through T6.5-22): +// 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 +// conforms (H-8). + +import { declareProductTests } from "./declare.js"; +import { section65ivTests } from "./registry/section-6.5-iv.js"; + +declareProductTests(section65ivTests); diff --git a/test/suite/section-6.5-v.test.ts b/test/suite/section-6.5-v.test.ts new file mode 100644 index 00000000..590f55a6 --- /dev/null +++ b/test/suite/section-6.5-v.test.ts @@ -0,0 +1,10 @@ +// TEST-SPEC §6.5 fifth part (SUITE-25 continued, T6.5-23): 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 +// conforms (H-8). + +import { declareProductTests } from "./declare.js"; +import { section65vTests } from "./registry/section-6.5-v.js"; + +declareProductTests(section65vTests); diff --git a/test/suite/section-6.7.test.ts b/test/suite/section-6.7.test.ts new file mode 100644 index 00000000..c4ca7d80 --- /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/vitest.config.ts b/test/vitest.config.ts index 6d754b8c..352d305a 100644 --- a/test/vitest.config.ts +++ b/test/vitest.config.ts @@ -27,6 +27,21 @@ import { defineConfig } from "vitest/config"; // test/helpers/. Certification fixture products (CERTIFICATIONS.md) and // consumer fixture projects belong in test/fixtures/. // +// Harness-only dependencies are the devDependencies of package.json (the +// product's runtime dependencies are its `dependencies`): the stock MDX 3 +// parser S-9 judges fixture well-formedness with — `micromark` with +// `micromark-extension-mdxjs`, `mdast-util-from-markdown` with +// `mdast-util-mdx` (JSX tag matching lives in that mdast layer, not in the +// tokenizer) — is declared there in its own right so the check stays +// independent of the product's `remark-mdx` (TEST-SPEC S-9). Likewise the +// harness's TypeScript: `typescript-5.9.3`, an npm alias of +// `typescript@5.9.3` pinned exactly — the release SPEC.md 14.20 fixes — is the +// parser S-9 checks TypeScript fixtures and draws with and the standard +// tooling of section 4 (H-2; T1.4-5: never a later release). npm cannot +// declare one package name in both dependency sets, so the alias is what +// keeps that dependency the harness's own, independent of the product's +// `typescript`; harness code imports `typescript-5.9.3`, never `typescript`. +// // Paths below are relative to the repository root: the npm scripts are the // canonical entry points and always run from the package root. export default defineConfig({ diff --git a/test/windows/e6-byte-identity.test.ts b/test/windows/e6-byte-identity.test.ts index fb3aeaea..7bd60017 100644 --- a/test/windows/e6-byte-identity.test.ts +++ b/test/windows/e6-byte-identity.test.ts @@ -1,13 +1,20 @@ -// 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 -// outputs are asserted byte-identical to the Linux leg's, read from +// 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 — a journaled section-form `move` — the inserted-terminator +// probe, its moved text landing before a target parent's closing tag in an +// existing target file that gains an added import — 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 +39,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 25-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 00000000..3b1d8e7a --- /dev/null +++ b/test/windows/e6-drive-mismatch.test.ts @@ -0,0 +1,510 @@ +// 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 { ANCHOR_CONFIG, ANCHOR_SOURCE } from "../helpers/e6-drive-mismatch.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. Its configuration and spec source +// (`ANCHOR_CONFIG`, `ANCHOR_SOURCE`) are staged-source records of +// helpers/e6-drive-mismatch.ts, which the S-9 self-test judges before any +// product exists: this arm stages them at creation, outside every +// registered body and S-7's sweep (TEST-SPEC S-9's timing clause, H-8). + +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 90c2106f..ac62d736 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): // @@ -9,9 +10,12 @@ // declared here against the built product exactly as test/suite/ declares // them. A Linux runner cannot discriminate a product emitting native path // separators (`/` is native there); on Windows these same assertions do. -// T12.0-5's non-UTF-8 arm gates itself to the Linux leg inside the shared -// body ("less its Linux-leg arm", E-6), so no arm is skipped here — it is -// simply not part of this platform's staging. +// T12.0-5's Linux-leg arms — the non-UTF-8 argument value and the +// positive side of the backslash (`specs/a`, backslash, `b.mdx` and +// `src/a`, backslash, `b.ts`, names no Windows filesystem admits) — gate +// themselves to the Linux leg inside the shared body ("less its Linux-leg +// arms", E-6), so no arm is skipped here — they are simply not part of +// this platform's staging. // // - The four single-casing probes are the exact probe functions the // registered bodies call on the suite leg, re-invoked here: each stages one