diff --git a/.claude/commands/pharn-plan.md b/.claude/commands/pharn-plan.md index 34c1b2dd..e423b6ea 100644 --- a/.claude/commands/pharn-plan.md +++ b/.claude/commands/pharn-plan.md @@ -21,7 +21,7 @@ reads: ] writes: ["pharn/features//PLAN.md", "pharn/features//AC-TESTS.md"] constitution_refs: ["P0", "P2", "P4", "P5", "P6", "P7"] -version: "0.5.1" +version: "0.5.2" --- # /pharn-plan — plan from Approved, un-drifted intent @@ -270,15 +270,17 @@ for such a SPEC is a `spec-kind` RED. Exit **0** → continue. Exit **2** → th - Paths are plain repo-relative, never under `.pharn/` or `pharn/features/`, and never a placeholder or glob. - Another feature's AC test file is theirs. Name a new file. - **The test infrastructure stays out of PLAN.md's `## Files` too** (6.21.0). `/pharn-test` pins it before the - build — the root runner configs, and the level gates' `package.json` scripts and `testResults` formats - (`pharn/pharn-contracts/ac-tests.md`, "The test-infrastructure pin") — so a build that changes it reads + build (`pharn/pharn-contracts/ac-tests.md`, "The test-infrastructure pin"), so a build that changes it reads `test-infra-changed` at `/pharn-verify`, and `/pharn-loop` stops (S13) with no rebuild that clears it. A root - runner config (`vite.config.ts`, `vitest.config.mjs`, …) in PLAN.md is a **`test-infra-in-plan`** RED. When - the feature genuinely needs a runner, config or test-script change, **split it**: spec that change as a + runner or package-manager config (`vite.config.ts`, `.npmrc`, …) or a file a level gate's script names (a + `pharn-json` reporter, a runner script — 6.31.0) in PLAN.md is a **`test-infra-in-plan`** RED. When the + feature genuinely needs a runner, config or test-script change, **split it**: spec that change as a `spec_kind: test-infra` increment first (through `/pharn-ship` — `/pharn-loop` never approves one), then plan this feature without it. `package.json` / `pharn.config.json` may stay (a dependency is an ordinary build change); the checker prints an **advisory** `NOTE —` line for them and never changes its exit code, because it cannot see which part of the file the build will change. + - **Never name this feature's `AC-TESTS.md` or `AC-TESTS.lock.json` in PLAN.md** (6.31.0): the build is judged + against the lock, so a build scoped to it could re-pin its own change — an **`ac-artifact-in-plan`** RED. 3. **Check it (FLOOR)** and branch only on the exit code: @@ -287,7 +289,7 @@ for such a SPEC is a `spec-kind` RED. Exit **0** → continue. Exit **2** → th ``` - **0** → GREEN. **1** → the `RED — ` lines name each problem. Fix AC-TESTS.md and re-run. If the fix is in - PLAN.md's `## Files` (an `in-plan-files` or `test-infra-in-plan` RED, for instance), first re-scope to PLAN.md with the Step 0 setter + PLAN.md's `## Files` (an `in-plan-files`, `test-infra-in-plan` or `ac-artifact-in-plan` RED, for instance), first re-scope to PLAN.md with the Step 0 setter line. Then edit it, re-run Step 4b, re-scope to AC-TESTS.md (step 1 above), and re-run this check. **2** → a file is missing or unreadable, or (6.21.1) the chain check it shells crashed (`UNUSABLE child-crashed — …`): no verdict — HALT and report it. @@ -319,11 +321,12 @@ which reduces to a floor primitive (`pharn/ARCHITECTURE.md §2`). - **Floor:** it writes only `pharn/features//PLAN.md` and `pharn/features//AC-TESTS.md` — the fix #7 hook, one declared path per `--target`. - **Floor:** every Acceptance Criterion is mapped once, at its level, to a test file the build is not scoped to, - and no root runner config the lock pins is in the build's scope — `check-ac-tests.mjs` (enum/regex/set - membership; `test-infra-in-plan` over the folded name). NOT that each target is a good public interface (follow-up + and no root config the lock pins, no file a level gate's script names (when the tree can be read — else a `NOTE —`), + and neither this feature's AC-TESTS.md nor its lock is in the build's scope — `check-ac-tests.mjs` (enum/regex/set membership over the folded name; + `test-infra-in-plan`, `ac-artifact-in-plan`). NOT that each target is a good public interface (follow-up `grill-ac-targets`), nor that a mapped file is new (a stated bound, not a check), nor - whether the build changes the pinned `package.json` scripts or `testResults` formats — the `NOTE —` line is - **advisory**, and the pin itself compares them at `/pharn-verify` (late). + whether the build changes the pinned `package.json` scripts, the scripts they chain to, the `jest` key or the + `testResults` formats — the `NOTE —` line is **advisory**, and the pin itself compares them at `/pharn-verify` (late). - **Advisory:** invoking each checker and obeying its exit code (the verdict is floor; the act is orchestration); the plan's content; and whether the cited lessons were genuinely applied or a `none` is justified — `/pharn-grill` re-verifies the declaration (the same four checks), never the application. `spec_content_hash` diff --git a/.claude/commands/pharn-test.md b/.claude/commands/pharn-test.md index ef5cc6a4..50c1b73b 100644 --- a/.claude/commands/pharn-test.md +++ b/.claude/commands/pharn-test.md @@ -22,7 +22,7 @@ reads: ] writes: ["", "pharn/features//AC-TESTS.lock.json"] constitution_refs: ["P0", "P1", "P2", "P3", "P5", "P6", "P7"] -version: "0.4.1" +version: "0.4.2" --- # /pharn-test — write the Acceptance Criteria's tests before the build, and show they fail @@ -180,11 +180,12 @@ node pharn/floor/ac-tests-lock.mjs --check `--write` records every test file's sha256, AC-TESTS.md's digest and the SPEC pin (`pharn/pharn-contracts/ac-tests.md`, "The lock"), with `red_run: null` — and, since 6.20.0, the **test-infrastructure pin** (`test_infra`, lock schema -`ac-tests-lock/3`): the `package.json` scripts of the gates your levels map to (with their `pre`/`post` scripts), their -`testResults` formats, and the root runner configs in a closed name set ("The test-infrastructure pin"). Set up the -runner and its per-test results BEFORE this step, never after it. `--write` refuses -an infrastructure it cannot pin (an unparseable `package.json`, a symlinked runner config) — HALT on it. `--check` must -print GREEN. +`ac-tests-lock/4` since 6.31.0): the `package.json` scripts of the gates your levels map to (with their `pre`/`post` +scripts) and the scripts they chain to, their `testResults` formats, the files those scripts name (a `pharn-json` +reporter, a runner script), `package.json`'s `jest` key, and the root runner and package-manager configs in a closed +name set ("The test-infrastructure pin"). Set up the runner and its per-test results BEFORE this step, never after it. +`--write` refuses an infrastructure it cannot pin (an unparseable `package.json`, a symlinked config or script-named +file, a chain past the hop bound) — HALT on it. `--check` must print GREEN. ## Step 5 — The red run (FLOOR — the runner picks the gates and the files, the checker decides) @@ -209,7 +210,8 @@ A gate exiting non-zero is **expected** here — the tests are meant to fail — node pharn/floor/check-red-run.mjs --verdict --ac-tests pharn/features//AC-TESTS.md --out .pharn/pharn-test/gates --root . ``` -- exit **0** → every AC's test was collected and failed. Go to Step 6. +- exit **0** → every AC's test was collected and failed. Go to Step 6. A `NOTE —` line names a per-test anomaly in + a file no AC maps (6.31.0): it decides nothing here — quote it in your report as data. - exit **2** → **`red-run-unusable`**: no finished run, or a run not bound to this mapping and tree. Stop. - exit **1** → **`red-run-red`**: quote each `RED — : AC-` line. The reasons are defined in `pharn/pharn-contracts/ac-tests.md`, "The red run" (cited, not restated — P4). What decides your next move: diff --git a/.dev/features/ac-gate-plan-scope/BUILD.md b/.dev/features/ac-gate-plan-scope/BUILD.md new file mode 100644 index 00000000..f3af2022 --- /dev/null +++ b/.dev/features/ac-gate-plan-scope/BUILD.md @@ -0,0 +1,186 @@ +# BUILD — ac-gate-plan-scope + +- spec_content_hash: `d831d30d399a37dc403080072763d13383de6f6f31875e7e8cb4eadeb642f4f4` — re-hashed from + `pharn/ARCHITECTURE.md` at Step 1, equal to the PLAN's pin; PLAN.md has no open question. +- Scope: `set-writes-scope.cjs --from-plan .dev/features/ac-gate-plan-scope/PLAN.md` → 34 paths (= the 34 `## Files` + bullets); reconcile epoch anchored after the setter (`--by pharn-dev-build`). +- Model: this run is Claude Opus 5.5 (`claude-opus-5-5`), the maintainer's batch direction — recorded here as a + fact about the run, never as a `pharn.config.json` route. +- Gates: GATE 1 was a decision DELEGATED by the maintainer to the orchestrating model (approved with the A3 + amendment and the A9 statement) — never recorded as a human approval. GATE 2 is delegated the same way. + +## What landed + +**Part A (H2) — the build cannot be scoped to what it is judged by, and what a level gate runs is pinned.** + +- `pharn/floor/check-ac-tests.mjs` — new kind `ac-artifact-in-plan` (this feature's AC-TESTS.md / lock in PLAN.md, + folded; `LOCK_NAME` a literal, parity-tested); `test-infra-in-plan` widened to root package-manager configs and to + every file a level gate's script names (`scriptNamedFiles`, absent included); `checkMapping` takes `acArtifacts` and + `scriptFiles` as REQUIRED inputs; `ownArtifacts()` spells the pair relative to the invoking directory; the NOTE names + the chained scripts and the `jest` key; a tree the pin cannot read is a NOTE (the lock refuses it). +- `pharn/floor/test-infra-core.mjs` — rewritten: the ONE closed literal token pass (`scriptTokens`, + `scriptPathCandidates`, `chainedIds`, `scriptWalk`), `PACKAGE_MANAGER_CONFIGS`, `EXECUTED_EXTENSIONS`, the `/4` pin + (`chained`, `script_files`, `jest`), `pinShapeError(pin, {version})` per schema, `testInfraReds → {changed, unpinned}`, + and the header's NOT-caught list (the one copy) headed by the in-process bound, with the costs stated. +- `pharn/floor/ac-tests-lock.mjs` — schema `ac-tests-lock/4` (`/3`, `/2`, `/1` still read); `pinReds → {changed, +unpinned}`; `--record-red-run` only on `/4`; the rollback direction in the header. +- `pharn/floor/ac-gate-core.mjs` — `test-infra-changed` / `test-infra-unpinned` from the two fields (L6); the header + states the in-process bound and the anomaly scope. +- `.claude/commands/pharn-plan.md` (0.5.2), `.claude/commands/pharn-test.md` (0.4.2) — Step 4c's two bullets, the + check's remedy line and claims block; Step 4's pin sentence and the red run's NOTE line. Both within their byte + ceilings (22 090 / 24 064 and 18 961 / 20 480). +- Contracts: `ac-tests.md` (the two kinds, the lock example and schema bullets, the pin section with the token pass, + the restated NOT-caught list, the costs, migration and rollback, the anomaly rule, reconcile, what it proves, the + struck claims); `test-results-record.md` (the record's `anomalies`, the refusal/anomaly split of the reasons); + `verify-report.md` (`ac_gate.unmapped_anomalies`). +- `LIMITS.md §9` — human-only, NOT edited: `proposed/human-only.patch` + `human-only.sha256` were produced by a scratch + runner inside a throwaway detached worktree at HEAD (the replacement applied there, `check-specified-markers.mjs` + exit 0, `git diff` taken, the worktree removed; `git apply --check` clean against this worktree). `apply.sh` and + `APPLY.md` were written for the human. Nothing in the build depends on it. + +**Part B (M6) — an anomaly outside the AC-mapped files is reported, never verdict-bearing.** + +- `pharn/floor/test-results-formats.mjs` — an unmapped status is a per-entry `anomaly` (`ENTRY_ANOMALIES`), not a + refusal of the document; a malformed Jest field still refuses the document whatever the status. +- `pharn/floor/test-results-core.mjs` — `ANOMALY_REASONS`; `buildRecord` lists `anomalies` (one `duplicate-test-id` + per shared id, the adapter's own otherwise) apart from `tests`; the contradiction check counts every failed entry. +- `pharn/floor/red-run-core.mjs` — `observeAc` refuses an AC over an anomaly in a mapped file; + `unmappedAnomalies()` (`MAX_ANOMALY_EXAMPLES = 3`); the verdict carries `unmapped_anomalies`. +- `pharn/floor/check-red-run.mjs` — one `NOTE —` line per unmapped anomaly group, never the exit. +- `pharn/floor/render-verify.mjs`, `pharn/floor/render-run-report.mjs` — the unmapped anomalies as fenced DATA. + +**Docs:** `CLAUDE.md` (PER-TEST RESULTS, AC TESTS, THE RED RUN, THE AC GATE), `README.md` (badge, per-test results, +pharn-json guidance, what verify proves, the costs), `CHANGELOG.md` (`[6.31.0]`), `SKILLS_VERSION` 6.31.0. + +**Tests** (every `*.test.mjs` the plan names): the token pass per clause and per closed-set member with controls, the +L59 path-kind table, the jest canonical form, `/3` and `/4` shapes both ways, the migration's `{changed, unpinned}`, the +stated bounds PROBED, the ★ HOOK measured-spellings table and the composed `package.json` proof, the parity test, the +required inputs, the anomaly model (record, red run, AC gate, CLI NOTE, both renderers), and `/3` locks at the lock, +the test-stage gate and the AC gate. + +The grill's eight concerns and where the build differs from the plan are recorded in PLAN.md ("Grill resolutions +(applied at build)", "As built"). + +## Measurements — before (PLAN.md) and after (this build) + +Every world is an install-like scratch project built by `.pharn/pharn-dev-plan/mkworld.mjs` from THIS worktree's floor +(the "after" runs are under the session scratchpad's `after/`). Each "after" run forces its way past every RED the way +the reviewer's did, so a later stage's verdict is visible too; a command that obeys the gates stops at the first RED. + +| world | before (6.28.2) | after (6.31.0) | +| --------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| wE — PLAN names the lock | plan GREEN (NOTE); test-stage READY; verify **PASS** | plan **RED** `ac-artifact-in-plan`; test-stage **RED** before the build; forced past both: verify PASS (A5's bound — `/pharn-verify` does not re-run the mapping check; "As built") | +| wB — PLAN names the reporter | plan GREEN; verify **PASS** (the pin never read the file) | plan **RED** `test-infra-in-plan`; test-stage **RED**; forced past: `--check` **RED**, verify **FAIL** `ac-evidence` | +| (b) chained script | `--check` GREEN; verify **PASS**; `f()` = 0 | `--check` **RED** (3 lines: the chained value changed, a named file added…); test-stage **RED**; verify **FAIL** `test-infra-changed` | +| (c) root `.npmrc` `script-shell` | verify **PASS**; `f()` = 0 | plan **RED** `test-infra-in-plan`; test-stage **RED**; forced past: `--check` **RED** (`.npmrc` added), verify **FAIL** `test-infra-changed` | +| (a) `jest` key | `--check` **GREEN** (the pin never read the key) | `--check` **RED** "package.json's jest key changed"; verify **FAIL** (`test-infra-changed`; this world has no real Jest, so the forge itself is inert and the `test` gate fails honestly) | +| wA — in-process | verify **PASS**; `f()` = 0 | verify **PASS**; `f()` = 0 — **unchanged, by design**: the stated in-process bound (A9) | +| M6 wF — suite glob | red run **RED** for both ACs (`duplicate-test-id`) | red run **GREEN** + one `NOTE — … duplicate-test-id …`; recorded; verify **PASS** | +| M6 wF2 — handed files only | red run GREEN; verify **INCONCLUSIVE** | verify **PASS**; `ac_gate.unmapped_anomalies` = `[{test, duplicate-test-id, 1, ["tests/other/dup.test.js::handles 1"]}]`; `VERIFY.md` lists it fenced | +| cost — `pretest` names `src/x.js` | n/a | plan **RED** `test-infra-in-plan` for the feature's own `src/x.js`; `--check` **RED** after an edit of it — the stated source-entry cost, measured | + +## Step 2b — format (advisory) + +Run by a scratch node runner over the live `.pharn/writes-scope.json` (the worktree isolation refuses the pinned +xargs pipeline, so the same three commands run as argv arrays — advisory orchestration, stated): prettier +`--ignore-unknown --write` over the scoped paths that exist, markdownlint-cli2 `--no-globs --fix` over their `.md` +subset, eslint read-only over their JS subset. **34 scoped paths, all present:** prettier exit 0 (it reformatted this +build's own test files and three contracts/modules; `human-only.patch`, `.sha256` and `apply.sh` are unknown to it and +untouched); markdownlint 11 `.md` files, 0 issues; eslint 19 JS files, exit 0. The whole-repo read-only gates were then +confirmed clean: `format:check` ("All matched files use Prettier code style!"), `lint:md` (1557 files, 0 issues), +`lint` (exit 0), after the scratch runners were moved out of `.pharn/`. + +## Pre-review corrections (made in the build, before regress) + +Re-reading the diff before handing it on found three defects of the build's first cut, each fixed and tested in the +build rather than left for review (PLAN.md, "As built"): the GREEN line of `check-ac-tests.mjs` claimed "no file a level +gate's script names" even when those files could not be read — it now says they were NOT checked; `unmappedAnomalies` +dropped an anomaly in a mapped file that only ANOTHER level's gate reported (observeAc never reads it there), so it was +neither decided nor reported — it now keys on the row's level; and the token pass skipped the target of an INPUT +redirect, although `node < tools/x.mjs` executes it — only output redirects are skipped now. A chain to a script whose +name is over 1024 characters now refuses the pin, and the NOT-caught list names `package.json`'s `config` field and a +path over 1024 characters. + +## The aggregate gate + +`npm run check` over the first cut (before those corrections), run while other sessions' suites loaded the machine: +every style/docs/changelog/reconcile gate GREEN; `npm test` **4098 / 4099**, the one failure the timing-sensitive +`stage-verify.test.mjs` "★ budget clock" (its old-clock mutant ran out a 32 s budget under load — `5 !== 0`), which +passes in isolation (34 s, 1/1). The corrected tree's full suite runs as `/pharn-dev-verify`'s `test` gate. + +## Step 3 — the floor + +`node pharn/floor/validate.mjs .` → **`FLOOR: GREEN — 36 capabilities checked in "."`**. + +## Build-time amendments to the approved plan (accepted at GATE 2) + +GATE 2 (the orchestrator's decision under the maintainer's delegation — never a human approval) accepted every +refinement made after GATE 1; they are amendments to PLAN.md's design, recorded in full in its "Grill resolutions" and +"As built" sections: + +1. the executed-extension set for script-named files (`EXECUTED_EXTENSIONS`), widened at GATE 2 by `.bash` and `.zsh`; +2. only OUTPUT redirects (`>`, `>>`, `2>`), `-o` and `--out…` targets are skipped — an input redirect's target executes; +3. `start` / `stop` / `restart`, `node --run`, and `.yarnrc` / `.yarnrc.yml` inside the closed chain and config sets; +4. `yarn t` / `pnpm tst` pin both readings (the `test` script and a script of that name); +5. the plan-time check covers ABSENT named files too (stricter than A2 as approved); +6. the `jest` nesting refusal is the engine's own serialization limit, not the planned 256-level cap; +7. the canonical JSON is a key-sorting `JSON.stringify` replacer (a second small copy, the grill's G6 resolution); +8. `unmapped_anomalies` is keyed on each gate's level, so every anomaly is either decided or reported; +9. `check-ac-tests.mjs`'s GREEN line says the named files were NOT checked when the tree cannot be read; +10. a chained script name over 1024 characters refuses the pin. + +## GATE 2 — the FIX round + +GATE 2 = FIX (the orchestrator, delegated). Fixed, each at every site it appeared: + +- **F1** — the rollback verdict: a pre-6.31.0 AC gate reads a `/4` lock as `ac-tests-modified` (verify FAIL), not + INCONCLUSIVE (`ac-tests-lock.mjs`'s header, `ac-tests.md` "Rolling back", `PLAN.md` G5). +- **F2** — README: the plan exclusion names the files, configs and lock; `package.json` stays plannable, and a change + there to anything pinned fails verify. +- **F3** — CLAUDE.md: the NOTE is advisory because every part of `package.json` the pin READS is compared and cannot be + re-pinned through the build's scope; the rest is the NOT-caught list's. +- **Minors** — `.bash` / `.zsh` added (tested); the `/3` pin over a tree the `/4` pin cannot be taken on reads + `changed`, by decision (the refusal cannot tell a `/3`-covered change from a `/4`-only one) — stated in the module + doc, the contract, the README and the CHANGELOG, and tested; "cannot re-pin" / "no build starts" narrowed to the + write scope and to builds that read the test-stage gate; the Floor claims about script-named files say "when the + tree can be read"; `test-results-record.md` names the per-level-gate rule; the CHANGELOG's MINOR rationale names the + in-flight `/3` locks that read RED. +- **F4** — not fixed, by decision: stated as a bound in `ac-tests.md` ("What it proves", Bounded) and the CHANGELOG, + with the follow-up `verify-rechecks-test-stage`. +- **The human-only `LIMITS.md §9` patch** stays in `proposed/` as an OPTIONAL staged edit, applied (if at all) after the + merge on its own branch; `APPLY.md` says so. No gate waits on it. + +## After the PR opened — the measured table assumed a case-insensitive volume (CI fix) + +CI on PR #291 went red on Linux in one test: `check-ac-tests.test.mjs`, "★ HOOK — every PLAN spelling that opens the +lock, AC-TESTS.md or a script-named reporter to the build is RED (measured table)". Its "opens" column had been +measured on APFS, where a case variant of the lock IS the lock, so the guard denies it. On a case-sensitive volume +(ext4) the variant is a different, new file, which the guard allows. The row read `true`, but that file opens nothing. + +The checker was not changed, only the test: + +- **The table now keeps two facts apart.** + - `opens` — the build may write a file it is judged by. This is decided by file identity (dev + inode), is the same + on every volume, and is what "every spelling that opens is RED" rests on. + - `probed` — the raw guard reading, which depends on a run-time case-sensitivity probe of the test's temp volume. +- **The rows that depend on the probe** are the four case variants, and a comment names them. `isRed` stays + unconditional, because the fold is deliberately fail-closed. +- **A pure test injects both probe results**, so each branch is exercised on either kind of volume. + +Measured, not only simulated: + +- The file was run on the default APFS temp volume and on a case-sensitive APFS disk image mounted in the scratchpad + (`TMPDIR` pointed at it). The old test reproduced CI's exact failure there (58/59); the new one reads 60/60 on both. +- The other eight test files this increment changed passed on the case-sensitive image (339/339). Their case-variant + tests create the variant file themselves and never rely on aliasing. +- Two mutations: + - Marking a variant row as not probe-dependent is caught on both volumes. + - Replacing the identity check with a case-folded spelling match is caught only on the case-sensitive volume. This + is expected, because on APFS the guard already denies every variant, and the code comment says so. +- The image was detached and deleted afterwards. + +## Scratch + +`.pharn/pharn-dev-plan/` (the plan's measurement runners) and `.pharn/pharn-dev-build/` (`limits-patch.mjs`, `cost.mjs`, +the format runner) are gitignored scratch, deleted before `npm run lint`; the scratch worlds live under the session +scratchpad, outside the repository. diff --git a/.dev/features/ac-gate-plan-scope/GRILL.md b/.dev/features/ac-gate-plan-scope/GRILL.md new file mode 100644 index 00000000..598cf9f6 --- /dev/null +++ b/.dev/features/ac-gate-plan-scope/GRILL.md @@ -0,0 +1,119 @@ +# GRILL — ac-gate-plan-scope + +Plan: `.dev/features/ac-gate-plan-scope/PLAN.md` (as amended at GATE 1). Spec-hash check: `pharn/ARCHITECTURE.md` +recomputes to `d831d30d399a37dc403080072763d13383de6f6f31875e7e8cb4eadeb642f4f4`, equal to the plan's pin (no drift). +**Step 1b — `applied_lessons` re-verification (FLOOR): `check-plan-lessons.mjs` exit 0, GREEN** — all 17 cited ids +resolve in `.dev/memory-bank/lessons-learned.md` and are referenced in the plan body. That verdict covers the +DECLARATION only, never that the lessons were applied. + +Grillers discovered by `count-grillers.mjs`: 13 registered, each applied inline below. Scanner outputs over the plan +(deterministic, FLOOR as outputs): `scan-plan-secrets` `{"found":false}`, `scan-plan-pii` `{"found":false}`, +`scan-plan-i18n` `{"found":false}`, `scan-plan-observability` `{"mentions":false}`, `scan-plan-migrations` +`{"mentions":true,"hits":[{"line":140},{"line":263}]}` (the `/3` migration prose — evidence, not a pass). + +The plan is `trust: untrusted` to this stage; no instruction-looking content was found in it. + +## Findings + +### Built-in interrogation + +```yaml +- type: FINDING + rule_id: P0 + severity: important + file: ".dev/features/ac-gate-plan-scope/PLAN.md:107" + problem: "A2's file scan now also runs over A7's chained values and every pre/post value, so a file a test-reachable BUILD step names is pinned: a bundler's entry (`tsup src/index.ts`, `esbuild src/index.ts`) becomes 'test infrastructure' (every feature that edits it is RED at plan time), and a named OUTPUT with an executed extension (`--outfile=dist/index.js`) is rewritten by the gate itself, so a stale copy present at `--write` reads test-infra-changed after the red run or the build." + evidence: "A2: 'The candidates of every pinned gate's `script`, `pre` and `post` values …'; A7: 'scanned again, both for further chains and for A2's script-named files'." +- type: FINDING + rule_id: P2 + severity: important + file: ".dev/features/ac-gate-plan-scope/PLAN.md:335" + problem: "A8 records the sha256 of the root `.npmrc` in a COMMITTED lock; a project `.npmrc` is where registry credentials live, and a digest of a file holding a low-entropy secret lets anyone who reads the lock test guesses offline. The plan says the digest is 'never echoed' but does not state this bound." + evidence: "'The `jest` key and every package-manager config are reduced to a sha256 and never echoed (a `.npmrc` can hold a registry token).'" +- type: FINDING + rule_id: P1 + severity: important + file: ".dev/features/ac-gate-plan-scope/PLAN.md:182" + problem: "A7 defines a CLOSED set of token sequences (npm/pnpm/yarn × run/run-script/rum/urn, test/t/tst, start/stop/restart, the pnpm/yarn shorthand, `node --run`) and an own-property rule, but the test list names only 'the candidate rule table'; per L52 the suite must iterate EVERY member of the sequence set with a control, plus the own-property negative (`toString`), a cycle, and the hop bound at 8 and 9." + evidence: "'`run` / `run-script` / `rum` / `urn` → the next non-flag token is the id; `test` / `t` / `tst` → `test`; `start` / `stop` / `restart` …'" +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/ac-gate-plan-scope/PLAN.md:107" + problem: "The plan does not say what the token pass does with a chained script whose value is not a string, a `scripts` field that is not an object, or an `lstat` error other than absence (EACCES) on a named path; each must refuse the pin (fail-closed), as a non-string level-gate script already does." + evidence: "A2: 'an absent path, a directory, or a path under a non-directory … is skipped; a symlink or any other kind REFUSES the pin'." +``` + +### Griller: migrations (P7) + +```yaml +- type: FINDING + rule_id: P7 + severity: important + file: ".dev/features/ac-gate-plan-scope/PLAN.md:140" + problem: "The lock schema moves to /4 and the plan declares the forward migration (/3 still read) but not the way BACK: a 6.28.x floor's shape check refuses a /4 schema, so after a rollback every feature locked under 6.29 reads lock-unusable until /pharn-test is re-run under the older floor. That irreversibility is not labelled." + evidence: "A4: '`/3` (6.20.0–6.28.x) is still read … `--record-red-run` writes only on a `/4` test-first lock.' — no rollback direction." +``` + +### Griller: architecture (P3) + +```yaml +- type: FINDING + rule_id: P3 + severity: minor + file: ".dev/features/ac-gate-plan-scope/PLAN.md:173" + problem: "A6 introduces key-sorted canonical JSON while `pharn/floor/loop-fresh-core.mjs:265` already has a private `canonical()`; importing that one would invert the dependency (loop-fresh-core imports test-infra-core), so a second small implementation is defensible, but the plan does not say so." + evidence: "A6: 'the sha256 of the key's value in CANONICAL JSON (keys sorted at every level, serialized directly …)'." +``` + +### Griller: comprehension (P7) + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/ac-gate-plan-scope/PLAN.md:188" + problem: "Three values carry no captured WHY: `MAX_CHAIN_HOPS = 8`, the 256-level nesting cap in A6, and 'examples ≤ 3' in B3; the next maintainer cannot tell a measured bound from an arbitrary one." + evidence: "'REFUSES the pin past 8 hops (`MAX_CHAIN_HOPS`)'; 'Nesting deeper than 256 levels refuses the pin'; '(examples ≤ 3 ids, sorted)'." +``` + +### Griller: documentation (P7) + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/ac-gate-plan-scope/PLAN.md:278" + problem: "The README edits listed cover the badge, the anomaly rule, the pin list and pharn-json guidance, but not the two costs a user will actually hit — a chained build step is pinned (`test-infra-changed` when a feature edits it) and a file a script names literally is pinned — nor their remedies." + evidence: '''`README.md` — EDIT. The badge 6.29.0; the two sentences stating the whole-record rule; the pin list in "What verify proves"; the pharn-json reporter guidance''.' +``` + +### Grillers with no finding + +- **testability (P1)** — presence recognized: `## Evals to write`, the per-file test list, the before/after world + measurements and the ★ HOOK probes. Adequacy concern raised above (the chain-sequence set, L52). +- **security (P2)** — scanner clean; the concerns this plan raises are its own subject (H2) and are designed against; + the `.npmrc` digest bound is raised above. +- **error-handling (P7)** — declaration recognized (refusals for symlinks, depth, unreadable manifests); the gaps are + raised above as minor. +- **observability (P6)** — the change adds no runtime service or operation; floor CLIs report through their exit codes + and printed lines. No finding. +- **performance (P7)** — the token pass is linear in script length with an 8-hop bound; the record's anomaly split is + linear; `unmapped_anomalies` is a bounded summary. No scaling risk recognized. +- **privacy (P2)** — scanner clean; no personal data. (The `.npmrc` credential bound is filed under security above.) +- **coupling (P3)** — clean seams: the token rule lives in one module and both consumers import it; the anomaly + scope lives beside the one match rule. No entanglement. +- **a11y (P7)**, **i18n (P7)** — no UI surface and no user-facing strings (`scan-plan-i18n` clean). No finding. + +## Summary + +The plan closes both review findings and the GATE-1 amendment with deterministic rules and states its bounds. The one +design problem the interrogation surfaced is the A2/A7 interaction: once chained and pre/post values are scanned for +named files, build steps reached from the test gate leak into the pin — a bundler's entry becomes infrastructure, and +a literally named OUTPUT is rewritten by the gate. Excluding the targets of output flags (`--out…`, `-o`) and of shell +redirects from the candidate rule keeps the closed literal form and removes the output half; the entry half remains a +stated fail-closed cost for the human at GATE 2. The other important items are the unlabelled `/4` rollback, the +`.npmrc` digest bound, and the L52 enumeration the chain rule's tests owe. + +ADVISORY VERDICT: 8 concerns raised (4 important-severity, 4 minor-severity) — for the human to weigh before +/pharn-dev-build. The Step 1b floor verdict (GREEN) is reported in the header and is not part of this tally. diff --git a/.dev/features/ac-gate-plan-scope/PLAN.md b/.dev/features/ac-gate-plan-scope/PLAN.md new file mode 100644 index 00000000..4ebce4b3 --- /dev/null +++ b/.dev/features/ac-gate-plan-scope/PLAN.md @@ -0,0 +1,444 @@ +# PLAN — the AC gate: a PLAN can no longer scope the test infrastructure it is judged by, and an unrelated test anomaly no longer voids the AC record + +- spec_content_hash: d831d30d399a37dc403080072763d13383de6f6f31875e7e8cb4eadeb642f4f4 +- applied_lessons: [L6, L15, L19, L29, L35, L36, L37, L41, L43, L47, L50, L52, L56, L59, L60, L62, L64] +- increment: close two findings of a read-only review of the AC test-first pipeline, as two separated parts of one + PR — (A, H2) the build can no longer be scoped to this feature's `AC-TESTS.lock.json` / `AC-TESTS.md`, to a file a + level gate's script names or to a root package-manager config, and the pin now also covers those files, the scripts + a gate chains to and package.json's `jest` key (the GATE-1 amendment); (B, M6) a per-test anomaly (duplicate id, a flaky test or + expected failure the report marks) in a file no AC maps is reported, never verdict-bearing +- layer(s): product floor (`pharn/floor/`), pharn-contracts, product commands, repo-meta +- constitution_refs: [P0, P2, P3, P4, P5, P6, P7] +- model: this run is on Opus by the maintainer's instruction for the batch (recorded here, not a `pharn.config.json` + route); GATE 1 and GATE 2 are delegated by the maintainer to the orchestrating model + +## Applied lessons + +- **L6** — the AC gate tells a CHANGED pin from an UNPINNED one by a structured field (`pinReds` returns + `{changed, unpinned}`), never by matching the prefix of a printed line. +- **L15** — every script read in the new script-file rule goes through `Object.hasOwn` (the existing `computeTestInfra` + discipline); no `scripts[name] ||` fallback. +- **L19** — the build's route to the lock after this fix is Bash only, which reconcile reports (the lock is + `pre_anchor_artifacts`, never exempt); my own verification runners live under `.pharn/pharn-dev-plan/` and are + deleted before `npm run lint`. +- **L29** — the enumerations are the deliverable: every renderer of the `ac_gate` block (VERIFY.md, RUN-REPORT.md), + every site that states the old whole-record rule (swept by referent, L50), and `ANOMALY_REASONS` iterated by tests. +- **L35** — ONE script-file rule (`test-infra-core.mjs`) serves both the pin and the plan-time check; the lock's file + name is not imported into `check-ac-tests.mjs` (it would pull `red-run-core.mjs` into the mapping child and break + the crash-routing test's premise), so its one literal there is held to `LOCK_NAME` by a ✧ parity test. +- **L36** — `test_infra` stays closed at every level: `/4` requires `script_files`, `/3` forbids it; an anomaly entry is + exactly `{id, file, title, reason_code, reason}`; the new kind and reason sets get closure tests both ways. +- **L37** — each new "the build cannot write X" claim is PROBED through the real setter and write guard (★ HOOK rows), + never read off them. The plan-time probe already corrected one reading: the lock and the reporter EXIST when the + build runs, so the guard judges a case-variant entry at the file's on-disk spelling and DENIES it — only the exact + and the annotated spelling open the file (measured, `.pharn/pharn-dev-plan/hookprobe.mjs`). The folded RED therefore + over-reports case variants (fail-closed), and the test asserts "every spelling that opens is RED" plus the measured + table, never RED ⟺ opens. +- **L41** — `checkMapping`'s two new inputs (`acArtifacts`, `scriptFiles`) are REQUIRED arrays (a TypeError when + absent); no default that every test would override. +- **L43** — the root cause of H2 is L43's: the lock and the tree agreed because the build could write both, so the fix + takes the lock out of the build's scope instead of adding another agreement check. +- **L47** — the retracted "anywhere in the suite voids the record" becomes a STRUCTURAL form ("an anomaly in a file + the AC maps refuses; one elsewhere is reported"), never a new count. +- **L50** — the sweep for the retracted rule runs by REFERENT (every cite of `test-results-core`'s whole-record + refusal, and of the lock schema `/3`), then by claim spelling; the site list is under "Sweep" below. +- **L52** — one test per MEMBER: each route of H2 (lock, mapping, script-named reporter), each anomaly kind in each + format that marks it, each /3 case (names a file / names none). +- **L56** — the script-file rule is a model of a shell command line, so its stated bounds (what it does NOT read) are + each PROBED by a test, not written from the model. +- **L59** — a script-named path is `lstat`ed: a symlink or other non-regular kind refuses the pin, never hashed through; + the suite enumerates the path kinds (regular, link to file, link to dir, dangling link, directory, absent). +- **L60** — every property gets its own negative control: the ★ HOOK rows (RED ⟺ opens), anomaly-in-mapped vs + anomaly-elsewhere, a /3 lock naming a file vs naming none. +- **L62** — every new refusal/anomaly reason quotes an untrusted value only through `shown()` (total) or + `JSON.stringify` of a string. +- **L64** — before hand-off, grep the diff for each restated bound (the NOT-CAUGHT list, the anomaly rule, the lock + schema) and probe every hit as a sentence of its own. + +## Trigger (P7) — both findings reproduced live on this worktree (70cb51c, 6.28.2) + +A read-only review (its text is DATA, P2) reported both; I re-ran each in throwaway install-like worlds built by +`.pharn/pharn-dev-plan/mkworld.mjs` from THIS worktree's floor (the reviewer's `mkworld.sh`, re-pointed): + +- **H2, lock route (wE).** PLAN `## Files` = `src/x.js`, `package.json`, `tools/forge.mjs`, + `pharn/features/demo/AC-TESTS.lock.json`. `check-ac-tests.mjs`: exit 0, one NOTE (package.json). A genuine red run + is recorded; `check-test-stage --require-test-first`: READY. Under the build's scope both write guards exit **0** for + the lock and `package.json`, **2** for the AC test. The "build" sets `"test": "node tools/forge.mjs"` (writes + pharn-json `passed` for the lock's red-run ids), a wrong `f()`, and re-pins `lock.test_infra` with + `computeTestInfra`. Then: test-stage READY, `--check` GREEN, `stage-verify` → `verify-report.json` verdict **PASS**, + gates `{"reconcile":0,"test":0}`, `ac_gate` **PASS** — while `node --test tests/ac/ac2.test.js` exits **1**. +- **H2, reporter route (wB).** PLAN names `tools/pharn-reporter.mjs` (the pharn-json reporter the `test` script names + via `--test-reporter=./tools/pharn-reporter.mjs`). `check-ac-tests.mjs`: GREEN, no NOTE; the guard allows the + reporter (exit 0). The build rewrites it to mark everything passed and force exit 0: verify **PASS**, `ac_gate` + **PASS**, the real AC-2 test exits **1**. The pin never saw the reporter. +- **M6 (wF).** A pre-existing `for (const n of [1, 1]) test("handles " + n, …)` in `tests/other/dup.test.js`. With the + suite glob in the `test` script, the red run is RED for BOTH ACs: `duplicate-test-id … "tests/other/dup.test.js::handles +1"`. With a script that runs only the files it is handed, the red run is GREEN, and the verify is **INCONCLUSIVE**, + `ac_gate` reason `AC-1: duplicate-test-id — … tests/other/dup.test.js::handles 1`. `/pharn-loop` would stop + `STOP_TERMINAL` (unmeasured) on every iteration. + +## Design — Part A (H2): the build cannot be scoped to what it is judged by, and what the gates run is pinned + +**A1. A new `check-ac-tests.mjs` RED kind, `ac-artifact-in-plan`.** A PLAN.md `## Files` entry that, as the setter +scopes it (`clean`, `isConcrete`, then the write guard's fold — `scopeKey`), names THIS feature's `AC-TESTS.md` or +`AC-TESTS.lock.json`. The two paths are the repo-relative spelling of the mapping path on argv (relative to the invoking +directory, the project root the setter resolves scope entries against) and the lock beside it. `checkMapping` takes +them as a new REQUIRED input `acArtifacts` (L41). Why both files: the lock pins AC-TESTS.md's bytes, so with the lock out +of scope a build-window change to the mapping reads `ac-tests-modified`; with both in scope the build could rewrite the +two together. Only THIS feature's pair (P7: another feature's lock decides nothing in this feature's verdict). The fold +over-reports: a case-variant entry is RED although the guard, judging the existing file at its on-disk spelling, would +deny it anyway (measured — see L37 above); `./`-led and glob entries open nothing and are not RED. + +**A2. The files a level gate's script NAMES are pinned (`test_infra.script_files`, lock schema `ac-tests-lock/4`) and +kept out of the build's scope (`test-infra-in-plan`).** One rule in `test-infra-core.mjs` (L35), CLOSED and literal — +never a shell parse (the reconcile precedent: a verb list would be a heuristic, P0): + +- `scriptTokens(value)` — the ONE tokenizer the pin's token pass uses (A2 here, A7's chains below): split on whitespace + and the shell control characters `; & | ( )`, drop empties, strip one pair of matching surrounding quotes. +- `scriptPathCandidates(value)`: for each token, split on `=` (so `--flag=path` and `VAR=path` yield `path`); strip + leading `./`; keep a part that is a clean relative POSIX path — not empty, not led by `-` or `/`, no empty / `.` / `..` + segment, none of ``$ ` \ ' " * ? [ ] { } ( ) < > | & ; ! # ~`` or a control character — whose first segment is not + `node_modules` (dependencies stay out, as before) or `.pharn` (runtime scratch), and **whose extension is one a gate + EXECUTES** (`.js .mjs .cjs .jsx .ts .mts .cts .tsx .sh` — `EXECUTED_EXTENSIONS`, closed). The extension set was added + after GATE 1 (a refinement, reported at GATE 2): a gate's script can name its OWN OUTPUT literally (`--outputFile= +report.json`, `--test-reporter-destination=junit.xml`), and pinning that file would make the red run itself rewrite a + pinned file, so `--record-red-run` could never record. Without it every such project is blocked at `/pharn-test`; with + it, a JSON/YAML config a script names outside the root is not pinned — stated in the NOT-CAUGHT list. +- The candidates of every pinned gate's `script`, `pre` and `post` values, minus a root runner config name (already in + `configs`) and a root manifest (`package.json` / `pharn.config.json` — pinned by VALUE, not by bytes), are `lstat`ed + at the root: a regular file is pinned `{path, sha256}`; an absent path, a directory, or a path under a non-directory + is not a file the script names and is skipped; a symlink or any other kind REFUSES the pin, as a symlinked runner + config does (L59). +- `/4` recompute compares `script_files` exactly: a file gone, changed or added (a named path that became a file) is + `test infrastructure changed — …` → `lock-red` at the test-stage gate, `test-infra-changed` at the AC gate. +- The plan-time check: `check-ac-tests.mjs` REDs `test-infra-in-plan` for a PLAN entry equal (folded) to a candidate that + exists at the root as a non-directory — the reporter in the wB repro. `checkMapping` takes these as a new REQUIRED + input `scriptFiles`; the CLI computes them from the live `package.json` for the SPEC's and the mapping's levels. An + unreadable `package.json` prints an advisory `NOTE —` and checks nothing more (the pin, at `--write`, refuses the same + manifest; the test-stage gate re-runs this check before every build). +- The cost, stated: a project whose test script names a TEST file literally (`"test": "node test.js"`) pins that file + too, so a feature that edits it reads `test-infra-changed` — fail-closed; the remedy is a directory or glob in the + script, as its own `spec_kind: test-infra` increment. + +**A3. `package.json` / `pharn.config.json` in PLAN.md stay an ADVISORY `NOTE —`, decided with evidence — AMENDED at +GATE 1.** The build may legitimately change a dependency; the checker cannot see which part of the file will change. +My first premise — "what made the NOTE unsafe was the lock, not the manifest" — was INCOMPLETE (the orchestrator's +amendment): with the lock unwritable, `package.json` stays plannable and still carries test-running content the pin did +not cover — a `jest` key and chained scripts — so A6 and A7 below pin both, and A8 adds the root package-manager +configs; with those, what `package.json` can still change without a pin reading it is the NOT-CAUGHT list's. After A1 +the lock is outside the scope of every PLAN the checker passes, so the pin compares the build's script against the one +`/pharn-test` recorded. Proved by a composed test (check-ac-tests suite): a GREEN plan naming `package.json` → the REAL setter + write +guard allow `package.json` and DENY the lock and AC-TESTS.md; the control plan naming the lock → RED and the guard +allows it. The verify side is `ac-gate-core.test.mjs`'s existing "the pinned `test` script changed after /pharn-test" +→ `test-infra-changed` (cited, and extended to a script-named file). A Bash rewrite of the lock is a reconcile escape +(`pre_anchor_artifacts`, not exempt) — detected, non-adversarially, never prevented (`LIMITS.md §6`). Measured at plan +time (`.pharn/pharn-dev-plan/bashlock.mjs`, a world whose PLAN names `package.json` only): after the anchor, an `fs` +rewrite of the lock's `test_infra` plus the `test` script → `check-bash-reconcile.mjs --require-baseline` exit **1**, +`ESCAPE` naming `pharn/features/demo/AC-TESTS.lock.json` ("the write guards would have DENIED a write to it"), and no +line for `package.json`. + +**A4. Migration — `/3` locks, stated.** `/3` (6.20.0–6.29.x) is still read; its pin has no `script_files` (the shape check +forbids the key under `/3` and requires it under `/4`). `pinReds` returns `{changed, unpinned}` (L6): for a `/3` lock +whose level gates' scripts name a regular file, `unpinned` lists them → `--check` RED (`test infrastructure unpinned — +…`, the test-stage gate's `lock-red`) and the AC gate's `test-infra-unpinned` (its existing EVIDENCE reason, extended from +"no pin at all"); a `/3` lock whose scripts name no file reads exactly as before. `--record-red-run` writes only on a +`/4` test-first lock. Remedy: re-run `/pharn-test` (before the build it is cheap; after it, set the build aside first). + +**A5. The setter-side refusal was weighed and not chosen (no human-only patch).** The checker holds under the same +obedience model as every other gate: `check-test-stage.mjs` shells `check-ac-tests.mjs` in full mode and is read by +`/pharn-ship` and `/pharn-loop` BEFORE the build (orchestrator-run, not build-agent-run), by `/pharn-build` Step 0 BEFORE +its setter, and by `check-loop-fresh.mjs` check I after every build and at the commit gate — so a plan naming the lock +never reaches a set scope when those calls are obeyed, and in the loop it cannot pass the commit gate. A setter refusal +would be a second copy of the rule in a human-only hook (L35) for no failure the checker leaves open (P7). Bound, named: +a run that skips every one of those calls and sets the scope itself is not stopped by this checker. + +## Amendment at GATE 1 (the orchestrator's, under the maintainer's delegation) + +GATE 1 was approved with A3 amended and one P0 addition. Each vector was MEASURED "before" on this worktree's floor +with `.pharn/pharn-dev-plan/scenario.mjs` (worlds under `agp/before-*`), and is measured again "after" at build: + +- **(b) chained script, before:** `"test": "npm run test:unit"`, PLAN names `package.json` + `tools/forge.mjs`; the build + rewrites `test:unit` to the forger → `--check` GREEN, verify **PASS**, `ac_gate` **PASS**, while `f()` returns 0. +- **(c) root `.npmrc`, before:** PLAN names `.npmrc` + two helpers; the build writes `script-shell=/tools/forge-shell.sh` + → verify **PASS**, `ac_gate` **PASS**, `f()` returns 0. (npm 11.12.1 also hands a project `.npmrc`'s `node-options` to + every script as `NODE_OPTIONS` — measured; my first forge through it lost a race to node:test's own exit-time + reporter flush, which is why the measured route is `script-shell`.) +- **(a) the `jest` key, before:** the build adds `"testResultsProcessor"` to package.json's `jest` key → + `ac-tests-lock --check` stays **GREEN** (the pin never reads the key). The forge itself needs a real Jest, which this + sandbox does not have (an offline install from the npm cache failed; nothing was downloaded), so Jest applying + `testResultsProcessor` before `--json` writes is cited as Jest's documented behaviour, not measured here. +- **In-process (the review's wA), before:** `assert.equal = () => {}` in the only in-scope file → verify **PASS**, + `ac_gate` **PASS**, `f()` returns 0. No pin changes this (A9). + +**A6. package.json's `jest` key is pinned.** `test_infra.jest`: the sha256 of the key's value in CANONICAL JSON (keys +sorted at every level, serialized directly — no object is rebuilt, so a `__proto__` key stays a key, L15), or `null` when +the key is absent. A digest, never the value: a Jest config can carry paths and tokens, and the lock is committed. +Changed / added / gone reads `test-infra-changed`. Nesting deeper than 256 levels refuses the pin (the red run cannot +start), never a truncated digest. Only the `jest` key: other runners' package.json keys (`mocha`, `ava`, …) are listed +as NOT caught (P7 — the recorded route is Jest's `testResultsProcessor`). + +**A7. Chained scripts are pinned, transitively, by the same token pass (L35).** Over `scriptTokens` of every pinned +value, a package-manager token `npm`, `pnpm` or `yarn` (flag tokens after it skipped), then: +`run` / `run-script` / `rum` / `urn` → the next non-flag token is the id; `test` / `t` / `tst` → `test`; `start` / `stop` / +`restart` → that script (`restart` also `stop` and `start`); and, for `pnpm` / `yarn` only, any other word is the id +(their `yarn