From 70f1dc8895858149b9cc314c50b11e752581c4f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 04:44:38 +0000 Subject: [PATCH 1/7] spec: define requirement-scoped check --changed --- .2119/verdicts/REQ-009.1.1--9e58f09dcddc.json | 8 + .2119/verdicts/REQ-009.1.2--1774c02e3d8d.json | 8 + .2119/verdicts/REQ-009.1.3--b5aebcf0370e.json | 8 + .2119/verdicts/REQ-009.2.1--6464ff888fa1.json | 8 + .2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json | 8 + .2119/verdicts/REQ-009.2.3--3d61356231ca.json | 8 + .2119/verdicts/REQ-009.2.4--a4f3a99bf269.json | 8 + .2119/verdicts/REQ-009.2.5--547f4a0ce4db.json | 8 + .2119/verdicts/REQ-009.2.6--344fdb4be17c.json | 8 + .2119/verdicts/REQ-009.3.1--e5e99fdac548.json | 8 + .2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json | 8 + .2119/verdicts/REQ-009.3.3--8ab901cda03f.json | 8 + specs/REQ-009-check-changed.md | 40 ++ tests/check-changed.test.ts | 493 ++++++++++++++++++ 14 files changed, 629 insertions(+) create mode 100644 .2119/verdicts/REQ-009.1.1--9e58f09dcddc.json create mode 100644 .2119/verdicts/REQ-009.1.2--1774c02e3d8d.json create mode 100644 .2119/verdicts/REQ-009.1.3--b5aebcf0370e.json create mode 100644 .2119/verdicts/REQ-009.2.1--6464ff888fa1.json create mode 100644 .2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json create mode 100644 .2119/verdicts/REQ-009.2.3--3d61356231ca.json create mode 100644 .2119/verdicts/REQ-009.2.4--a4f3a99bf269.json create mode 100644 .2119/verdicts/REQ-009.2.5--547f4a0ce4db.json create mode 100644 .2119/verdicts/REQ-009.2.6--344fdb4be17c.json create mode 100644 .2119/verdicts/REQ-009.3.1--e5e99fdac548.json create mode 100644 .2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json create mode 100644 .2119/verdicts/REQ-009.3.3--8ab901cda03f.json create mode 100644 specs/REQ-009-check-changed.md create mode 100644 tests/check-changed.test.ts diff --git a/.2119/verdicts/REQ-009.1.1--9e58f09dcddc.json b/.2119/verdicts/REQ-009.1.1--9e58f09dcddc.json new file mode 100644 index 0000000..0141b06 --- /dev/null +++ b/.2119/verdicts/REQ-009.1.1--9e58f09dcddc.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.1.1--9e58f09dcddc", + "requirementId": "REQ-009.1.1", + "hash": "9e58f09dcddc", + "verdict": "pass", + "summary": "Arity, unresolvable, ambiguous, and no-merge-base args all exit non-zero with diagnostics; a diverged-branch fixture passes only under merge-base (not ref-tip) semantics; PATH git shim logs subcommands and hard-fails fetch/pull/ls-remote to prove offline operation.", + "timestamp": "2026-07-21T23:53:09.075Z" +} diff --git a/.2119/verdicts/REQ-009.1.2--1774c02e3d8d.json b/.2119/verdicts/REQ-009.1.2--1774c02e3d8d.json new file mode 100644 index 0000000..067faba --- /dev/null +++ b/.2119/verdicts/REQ-009.1.2--1774c02e3d8d.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.1.2--1774c02e3d8d", + "requirementId": "REQ-009.1.2", + "hash": "1774c02e3d8d", + "verdict": "pass", + "summary": "Committed, staged, unstaged, and untracked changes each independently surface a violation from the changed file while an identical pre-existing violation stays suppressed; deleting a covering test must uncover exactly its requirement, so dropped deletions would fail.", + "timestamp": "2026-07-21T23:53:09.309Z" +} diff --git a/.2119/verdicts/REQ-009.1.3--b5aebcf0370e.json b/.2119/verdicts/REQ-009.1.3--b5aebcf0370e.json new file mode 100644 index 0000000..a979cb2 --- /dev/null +++ b/.2119/verdicts/REQ-009.1.3--b5aebcf0370e.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.1.3--b5aebcf0370e", + "requirementId": "REQ-009.1.3", + "hash": "b5aebcf0370e", + "verdict": "pass", + "summary": "Each failure source (deleted .git, unparseable committed baseline config, corrupted baseline blob) is tested against a fixture that would pass under any silent full-repo or incomplete substitute, so the non-zero-exit assertions genuinely discriminate fail-closed behavior; real git + subprocess, no mocks.", + "timestamp": "2026-07-22T04:43:56.541Z" +} diff --git a/.2119/verdicts/REQ-009.2.1--6464ff888fa1.json b/.2119/verdicts/REQ-009.2.1--6464ff888fa1.json new file mode 100644 index 0000000..d06abe6 --- /dev/null +++ b/.2119/verdicts/REQ-009.2.1--6464ff888fa1.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.2.1--6464ff888fa1", + "requirementId": "REQ-009.2.1", + "hash": "6464ff888fa1", + "verdict": "pass", + "summary": "Each contract field (statement, keyword, manual mode, verify command, review evidence path, review-instructions path) is mutated in isolation and must yield requirementCount 1 with the untouched sibling excluded; the baseline-absent conjunct is covered by the added-requirement case.", + "timestamp": "2026-07-21T23:53:15.484Z" +} diff --git a/.2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json b/.2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json new file mode 100644 index 0000000..a3ca631 --- /dev/null +++ b/.2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.2.2--a8e70a8a6a44", + "requirementId": "REQ-009.2.2", + "hash": "a8e70a8a6a44", + "verdict": "pass", + "summary": "Annotation add/remove flips coverage for exactly the target requirement, and an in-block edit stales only that block's verdict — whole-file hashing or a missed rehash would each break a named negative assertion.", + "timestamp": "2026-07-21T23:53:15.773Z" +} diff --git a/.2119/verdicts/REQ-009.2.3--3d61356231ca.json b/.2119/verdicts/REQ-009.2.3--3d61356231ca.json new file mode 100644 index 0000000..374f4af --- /dev/null +++ b/.2119/verdicts/REQ-009.2.3--3d61356231ca.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.2.3--3d61356231ca", + "requirementId": "REQ-009.2.3", + "hash": "3d61356231ca", + "verdict": "pass", + "summary": "An edit confined to a sibling annotation block stales only that requirement while the same-file neighbor and other-file block stay fresh, and a prelude-only edit stales both same-file requirements — pinning REQ-003.1.7 boundaries in both directions.", + "timestamp": "2026-07-21T23:53:16.071Z" +} diff --git a/.2119/verdicts/REQ-009.2.4--a4f3a99bf269.json b/.2119/verdicts/REQ-009.2.4--a4f3a99bf269.json new file mode 100644 index 0000000..5eaa8fd --- /dev/null +++ b/.2119/verdicts/REQ-009.2.4--a4f3a99bf269.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.2.4--a4f3a99bf269", + "requirementId": "REQ-009.2.4", + "hash": "a4f3a99bf269", + "verdict": "pass", + "summary": "Each evidence kind (explicit [review:] file, [review: instructions:] file, shared_evidence glob member) is edited in a real repo and the target requirement goes stale while a sibling with a fresh verdict stays clean; assertions run against --changed JSON output, no mocks", + "timestamp": "2026-07-21T23:52:23.981Z" +} diff --git a/.2119/verdicts/REQ-009.2.5--547f4a0ce4db.json b/.2119/verdicts/REQ-009.2.5--547f4a0ce4db.json new file mode 100644 index 0000000..cd2ccc7 --- /dev/null +++ b/.2119/verdicts/REQ-009.2.5--547f4a0ce4db.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.2.5--547f4a0ce4db", + "requirementId": "REQ-009.2.5", + "hash": "547f4a0ce4db", + "verdict": "pass", + "summary": "Added, removed, replaced (pass-to-fail on same review ID), and malformed verdicts each drive affectedness with an unaffected-sibling control, and an unassignable corrupt verdicts file (not-a-verdict.json) still surfaces as a scoped violation", + "timestamp": "2026-07-21T23:52:29.392Z" +} diff --git a/.2119/verdicts/REQ-009.2.6--344fdb4be17c.json b/.2119/verdicts/REQ-009.2.6--344fdb4be17c.json new file mode 100644 index 0000000..2873a45 --- /dev/null +++ b/.2119/verdicts/REQ-009.2.6--344fdb4be17c.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.2.6--344fdb4be17c", + "requirementId": "REQ-009.2.6", + "hash": "344fdb4be17c", + "verdict": "pass", + "summary": "A comment-only .2119.yml diff with an untouched spec must yield uncoveredRequirements exactly equal to the full requirement set, rejecting both ignored-config and partial-affectedness implementations; 'differs' boundary probed by a semantic no-op edit", + "timestamp": "2026-07-21T23:52:33.340Z" +} diff --git a/.2119/verdicts/REQ-009.3.1--e5e99fdac548.json b/.2119/verdicts/REQ-009.3.1--e5e99fdac548.json new file mode 100644 index 0000000..ac1b6af --- /dev/null +++ b/.2119/verdicts/REQ-009.3.1--e5e99fdac548.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.3.1--e5e99fdac548", + "requirementId": "REQ-009.3.1", + "hash": "e5e99fdac548", + "verdict": "pass", + "summary": "Every report category has a positive and a pre-existing negative: changed-spec lint vs untouched keyword-less spec, dangling annotations in changed vs unchanged test files across all four change states, coverage loss scoped to deleted evidence, and a failing [verify] command reported only for the edited requirement", + "timestamp": "2026-07-21T23:52:37.435Z" +} diff --git a/.2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json b/.2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json new file mode 100644 index 0000000..d6fc91a --- /dev/null +++ b/.2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.3.2--ff6894e0e8ea", + "requirementId": "REQ-009.3.2", + "hash": "ff6894e0e8ea", + "verdict": "pass", + "summary": "Deleting one requirement from a retained spec surfaces the untouched annotation as an invalid-annotation violation while a pre-existing dangling annotation in an unchanged test file stays unreported, proving the deletion trigger rather than a full-repo scan", + "timestamp": "2026-07-21T23:52:43.763Z" +} diff --git a/.2119/verdicts/REQ-009.3.3--8ab901cda03f.json b/.2119/verdicts/REQ-009.3.3--8ab901cda03f.json new file mode 100644 index 0000000..f1655ff --- /dev/null +++ b/.2119/verdicts/REQ-009.3.3--8ab901cda03f.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.3.3--8ab901cda03f", + "requirementId": "REQ-009.3.3", + "hash": "8ab901cda03f", + "verdict": "pass", + "summary": "Exact-equality assertions: --changed --json counts describe only 1 of 2 requirements, and --changed --no-verify --json exits 0 (the [verify: false] command would fail if actually run) with manualRequirements equal to exactly the affected verify requirement", + "timestamp": "2026-07-21T23:52:48.026Z" +} diff --git a/specs/REQ-009-check-changed.md b/specs/REQ-009-check-changed.md new file mode 100644 index 0000000..8a2f6cd --- /dev/null +++ b/specs/REQ-009-check-changed.md @@ -0,0 +1,40 @@ +# REQ-009: Incremental Check + +## Overview + +The former REQ-002.3.4 proposed limiting collection to changed files and was +tombstoned before implementation because coverage is a repository-wide +relationship: an unchanged test can cover a changed requirement. The revived +flag instead builds both repository views and narrows the report only after it +has identified the requirements whose inputs changed. + +The comparison deliberately follows the existing evidence granularity. +Annotated tests use their review evidence blocks, so an edit to one test does +not invalidate a neighboring test. Explicit review evidence and shared evidence +remain whole-file inputs. This makes the incremental command agree with the +same hashes that determine verdict freshness rather than inventing a second +dependency model. + +## Requirements + +### REQ-009.1: Base and change set + +1. `2119 check --changed ` MUST accept exactly one locally resolvable commit-ish, use the merge-base of that commit and `HEAD` as the baseline, perform no network access, and exit non-zero with a clear diagnostic when the argument is absent, ambiguous, unresolvable, or has no merge-base with `HEAD`. +2. The changed-path set MUST include differences between the baseline tree and `HEAD`, staged and unstaged tracked differences, and untracked non-ignored files, with deletions retained as changed paths. +3. Incremental checking MUST exit non-zero with a diagnostic instead of silently running an incomplete or full-repository substitute when Git metadata, baseline content, or baseline configuration needed for sound scoping cannot be read or parsed. + +### REQ-009.2: Affected requirements + +1. A current requirement MUST be affected when it is absent from the baseline or its statement, normative keyword, coverage mode, coverage command, evidence globs, or review-instruction path differs from the baseline requirement with the same ID. +2. A current requirement MUST be affected when a covering annotation is added or removed between the baseline and current views, or when the content hash of one of its covering annotation evidence blocks differs between those views. +3. Test evidence comparison MUST use the REQ-003.1.7 block boundaries in each view, including the shared file prelude, so an edit confined to another annotation block in the same file does not affect the requirement. +4. A current requirement MUST be affected when content selected as its explicit review evidence, review instructions, or configured shared evidence differs by whole-file hash between the two views. +5. A current requirement MUST be affected when its current review verdict is added, removed, replaced, or malformed relative to the baseline, and a changed malformed verdict that cannot be assigned to a current requirement must remain a scoped violation. +6. Every current requirement MUST be affected when `.2119.yml` differs from the baseline, because configuration changes can alter discovery, enforcement, evidence, or review behavior. + +### REQ-009.3: Scoped validation and output + +1. Incremental checking MUST report lint violations from changed current spec files, coverage and verdict-freshness violations for affected current requirements, invalid annotations from changed test evidence, and verification failures only for affected current requirements, while ignoring pre-existing violations outside that scope. +2. An unchanged annotation whose referenced requirement was deleted from a changed spec MUST be treated as changed test evidence and reported as an invalid annotation, preventing requirement deletion from hiding a dangling reference. +3. `--changed` MUST compose with `--json` and `--no-verify`, with JSON counts and requirement lists describing only the incremental scope and skipped affected verification requirements surfaced by `--no-verify` in the existing manual-requirements form. + diff --git a/tests/check-changed.test.ts b/tests/check-changed.test.ts new file mode 100644 index 0000000..4816a70 --- /dev/null +++ b/tests/check-changed.test.ts @@ -0,0 +1,493 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const CLI = resolve(import.meta.dirname, "../dist/cli.js"); + +type Result = { status: number; stdout: string; stderr: string }; + +function run(cwd: string, args: string[], env: Record = {}): Result { + try { + return { + status: 0, + stdout: execFileSync("node", [CLI, ...args], { cwd, encoding: "utf8", env: { ...process.env, ...env } }), + stderr: "", + }; + } catch (err) { + const e = err as { status: number; stdout: string; stderr: string }; + return { status: e.status, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +function git(root: string, ...args: string[]): string { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +function write(root: string, path: string, body: string): void { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), body); +} + +function initRepo(files: Record): { root: string; base: string } { + const root = realpathSync(mkdtempSync(join(tmpdir(), "2119-changed-"))); + git(root, "init", "-b", "main"); + git(root, "config", "user.name", "2119 tests"); + git(root, "config", "user.email", "tests@2119.invalid"); + for (const [path, body] of Object.entries(files)) write(root, path, body); + git(root, "add", "."); + git(root, "commit", "-m", "baseline"); + return { root, base: git(root, "rev-parse", "HEAD") }; +} + +function spec(items: string, doc = "FIX-001", title = "Widgets"): string { + return `# ${doc}: ${title} + +## Overview + +Fixture requirements. + +## Requirements + +### ${doc}.1: Behavior + +${items}`; +} + +function json(result: Result): { + violations: Array<{ file: string; message: string; rule: string }>; + uncoveredRequirements: string[]; + staleReviews: string[]; + manualRequirements: Array<{ id: string; text: string }>; + requirementCount: number; + coveredCount: number; +} { + return JSON.parse(result.stdout); +} + +function passReviews(root: string, requirementIds: string[]): void { + const pending = run(root, ["review"]); + for (const requirementId of requirementIds) { + const reviewId = pending.stdout.match(new RegExp(`${requirementId.replaceAll(".", "\\.")}--[0-9a-f]{12}`))?.[0]; + expect(reviewId, `pending review for ${requirementId}`).toBeTruthy(); + expect(run(root, ["pass", reviewId!, "--summary", `honest coverage for ${requirementId}`]).status).toBe(0); + } +} + +function commitCurrent(root: string, message = "record reviewed baseline"): string { + git(root, "add", "."); + git(root, "commit", "-m", message); + return git(root, "rev-parse", "HEAD"); +} + +const TWO_REQUIREMENTS = spec(`1. The widget MUST spin. +2. The widget MUST stop. +`); + +describe("check --changed (REQ-009)", () => { + // 2119: REQ-009.1.1 + it("requires one local base ref, uses its merge-base, and never invokes network Git commands", () => { + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin.\n"), + }); + + for (const args of [ + ["check", "--changed"], + ["check", "--changed", base, "extra-ref"], + ["check", "--changed", "not-a-local-ref"], + ]) { + const result = run(root, args); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toMatch(/changed|base.ref|commit|usage/i); + } + + git(root, "branch", "ambiguous", base); + git(root, "tag", "ambiguous", base); + const ambiguous = run(root, ["check", "--changed", "ambiguous"]); + expect(ambiguous.status).not.toBe(0); + expect(`${ambiguous.stdout}\n${ambiguous.stderr}`).toMatch(/ambiguous|base.ref|commit/i); + + git(root, "checkout", "-b", "base-side", base); + write(root, "specs/FIX-001-widgets.md", spec("1. The widget MUST spin. [manual]\n")); + commitCurrent(root, "fix only on base side"); + git(root, "checkout", "main"); + write(root, "README.md", "main-side change\n"); + commitCurrent(root, "advance main independently"); + // The merge-base still contains the pre-existing uncovered requirement; + // diffing directly against base-side would incorrectly make it affected. + expect(run(root, ["check", "--changed", "base-side"]).status).toBe(0); + + git(root, "checkout", "--orphan", "unrelated"); + git(root, "rm", "-rf", "."); + write(root, "orphan.txt", "no common history\n"); + commitCurrent(root, "unrelated root"); + const unrelated = git(root, "rev-parse", "HEAD"); + git(root, "checkout", "main"); + const noMergeBase = run(root, ["check", "--changed", unrelated]); + expect(noMergeBase.status).not.toBe(0); + expect(`${noMergeBase.stdout}\n${noMergeBase.stderr}`).toMatch(/merge.base|history|ancestor/i); + + const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); + const bin = join(root, "fake-bin"); + const log = join(root, "git-calls.log"); + write( + root, + "fake-bin/git", + `#!/bin/sh\nprintf '%s\\n' "$1" >> "$GIT_CALL_LOG"\ncase "$1" in fetch|pull|ls-remote) exit 97;; esac\nexec ${realGit} "$@"\n`, + ); + chmodSync(join(bin, "git"), 0o755); + expect( + run(root, ["check", "--changed", base], { PATH: `${bin}:${process.env.PATH}`, GIT_CALL_LOG: log }).status, + ).toBe(0); + expect(readFileSync(log, "utf8")).not.toMatch(/^(fetch|pull|ls-remote)$/m); + }); + + // 2119: REQ-009.1.2, REQ-009.3.1 + it("includes committed, staged, unstaged, untracked, and deleted paths since the merge-base", () => { + for (const mode of ["committed", "staged", "unstaged", "untracked"] as const) { + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + "tests/old.test.js": "// 2119: FIX-800.1.1\n", + "tests/state.test.js": "// baseline placeholder\n", + }); + const path = mode === "untracked" ? "tests/new.test.js" : "tests/state.test.js"; + write(root, path, `// 2119: FIX-90${mode.length}.1.1\n`); + if (mode === "staged" || mode === "committed") git(root, "add", path); + if (mode === "committed") git(root, "commit", "-m", "changed test"); + + const report = json(run(root, ["check", "--changed", base, "--json"])); + expect(report.violations.some((v) => v.message.includes(`FIX-90${mode.length}.1.1`))).toBe(true); + expect(report.violations.some((v) => v.message.includes("FIX-800.1.1"))).toBe(false); + } + + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", + }); + unlinkSync(join(root, "tests/widget.test.js")); + const report = json(run(root, ["check", "--changed", base, "--json"])); + expect(report.uncoveredRequirements).toContain("FIX-001.1.1"); + expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); + }); + + // 2119: REQ-009.1.3 + it("fails closed when Git metadata or the baseline configuration cannot be read", () => { + const noGit = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + }); + rmSync(join(noGit.root, ".git"), { recursive: true, force: true }); + const gitFailure = run(noGit.root, ["check", "--changed", noGit.base]); + expect(gitFailure.status).not.toBe(0); + expect(`${gitFailure.stdout}\n${gitFailure.stderr}`).toMatch(/git|metadata|repository/i); + + const brokenBase = initRepo({ + ".2119.yml": "prefix: [unterminated\n", + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + }); + write(brokenBase.root, ".2119.yml", 'prefix: "FIX"\nreviews: false\n'); + const result = run(brokenBase.root, ["check", "--changed", brokenBase.base]); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toMatch(/baseline|config|yaml|parse/i); + + const corruptBlob = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + }); + const blob = git(corruptBlob.root, "rev-parse", `${corruptBlob.base}:specs/FIX-001-widgets.md`); + const objectPath = join(corruptBlob.root, ".git/objects", blob.slice(0, 2), blob.slice(2)); + chmodSync(objectPath, 0o644); + writeFileSync(objectPath, "corrupt object\n"); + const unreadable = run(corruptBlob.root, ["check", "--changed", corruptBlob.base]); + expect(unreadable.status).not.toBe(0); + expect(`${unreadable.stdout}\n${unreadable.stderr}`).toMatch(/baseline|object|content|read|git/i); + }); + + // 2119: REQ-009.2.1 + it("detects each requirement-contract field independently without affecting its sibling", () => { + const variants = [ + ["1. The widget MUST spin.\n", "1. The widget MUST rotate.\n"], + ["1. The widget MUST spin.\n", "1. The widget SHALL spin.\n"], + ["1. The widget MUST spin. [manual]\n", "1. The widget MUST spin.\n"], + ["1. The widget MUST spin. [verify: true]\n", "1. The widget MUST spin. [verify: false]\n"], + ["1. The widget MUST spin. [review: docs/a.md]\n", "1. The widget MUST spin. [review: docs/b.md]\n"], + [ + "1. The widget MUST spin. [review: instructions: docs/instructions-a.md]\n", + "1. The widget MUST spin. [review: instructions: docs/instructions-b.md]\n", + ], + ]; + for (const [before, after] of variants) { + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec(`${before}2. The widget MUST stop.\n`), + "docs/a.md": "a\n", + "docs/b.md": "b\n", + "docs/instructions-a.md": "a instructions\n", + "docs/instructions-b.md": "b instructions\n", + }); + write(root, "specs/FIX-001-widgets.md", spec(`${after}2. The widget MUST stop.\n`)); + const report = json(run(root, ["check", "--changed", base, "--no-verify", "--json"])); + expect(report.requirementCount).toBe(1); + expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); + } + + const added = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + }); + write(added.root, "specs/FIX-001-widgets.md", spec("1. The widget MUST spin. [manual]\n2. The widget MUST stop.\n")); + const addedReport = json(run(added.root, ["check", "--changed", added.base, "--json"])); + expect(addedReport.requirementCount).toBe(1); + expect(addedReport.uncoveredRequirements).toEqual(["FIX-001.1.2"]); + }); + + // 2119: REQ-009.2.2, REQ-009.2.3, REQ-009.3.1 + it("uses annotation blocks and their shared prelude when selecting affected requirements", () => { + const files = { + ".2119.yml": 'prefix: "FIX"\n', + "specs/FIX-001-widgets.md": spec(`1. The widget MUST spin. +2. The widget MUST stop. +3. The widget MUST reverse. +`), + "tests/widget.test.js": `import { widget } from './widget.js' + +// 2119: FIX-001.1.1 +test('spin', () => expect(widget.spin()).toBe(true)) +// 2119: FIX-001.1.2 +test('stop', () => expect(widget.stop()).toBe(true)) +`, + "tests/reverse.test.js": "// 2119: FIX-001.1.3\ntest('reverse', () => {})\n", + }; + + const blockEdit = initRepo(files); + passReviews(blockEdit.root, ["FIX-001.1.1", "FIX-001.1.2"]); + const blockBase = commitCurrent(blockEdit.root); + const body = readFileSync(join(blockEdit.root, "tests/widget.test.js"), "utf8"); + write(blockEdit.root, "tests/widget.test.js", body.replace("widget.stop()).toBe(true)", "widget.stop()).toBe('stopped')")); + const blockReport = json(run(blockEdit.root, ["check", "--changed", blockBase, "--json"])); + expect(blockReport.staleReviews.join("\n")).toContain("FIX-001.1.2"); + expect(blockReport.staleReviews.join("\n")).not.toContain("FIX-001.1.1"); + expect(blockReport.staleReviews.join("\n")).not.toContain("FIX-001.1.3"); + + const preludeEdit = initRepo(files); + passReviews(preludeEdit.root, ["FIX-001.1.1", "FIX-001.1.2"]); + const preludeBase = commitCurrent(preludeEdit.root); + write( + preludeEdit.root, + "tests/widget.test.js", + readFileSync(join(preludeEdit.root, "tests/widget.test.js"), "utf8").replace("./widget.js", "./mock-widget.js"), + ); + const preludeReport = json(run(preludeEdit.root, ["check", "--changed", preludeBase, "--json"])); + expect(preludeReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(preludeReport.staleReviews.join("\n")).toContain("FIX-001.1.2"); + expect(preludeReport.staleReviews.join("\n")).not.toContain("FIX-001.1.3"); + + for (const mode of ["added", "removed"] as const) { + const annotation = "// 2119: FIX-001.1.1\ntest('spin', () => {})\n"; + const annotations = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": mode === "removed" ? annotation : "// no annotation yet\n", + }); + write(annotations.root, "tests/widget.test.js", mode === "added" ? annotation : "// annotation removed\n"); + const report = json(run(annotations.root, ["check", "--changed", annotations.base, "--json"])); + expect(report.requirementCount).toBe(1); + expect(report.coveredCount).toBe(mode === "added" ? 1 : 0); + expect(report.uncoveredRequirements).toEqual(mode === "removed" ? ["FIX-001.1.1"] : []); + expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); + } + }); + + // 2119: REQ-009.2.4 + it("uses whole-file comparison for explicit review and shared evidence", () => { + const files = { + ".2119.yml": 'prefix: "FIX"\nshared_evidence: ["tests/shared/**"]\n', + "specs/FIX-001-widgets.md": spec(`1. Policy MUST stay current. [review: docs/policy.md] +2. The widget MUST spin. +3. Other policy MUST stay current. [review: docs/other.md] +`), + "docs/policy.md": "policy v1\n", + "docs/other.md": "other v1\n", + "tests/shared/helper.js": "export const expected = true\n", + "tests/widget.test.js": "// 2119: FIX-001.1.2\ntest('spin', () => {})\n", + }; + + const explicit = initRepo(files); + passReviews(explicit.root, ["FIX-001.1.1", "FIX-001.1.2"]); + const explicitBase = commitCurrent(explicit.root); + write(explicit.root, "docs/policy.md", "policy v2\n"); + const explicitReport = json(run(explicit.root, ["check", "--changed", explicitBase, "--json"])); + expect(explicitReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(explicitReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + expect(explicitReport.staleReviews.join("\n")).not.toContain("FIX-001.1.3"); + + const shared = initRepo(files); + passReviews(shared.root, ["FIX-001.1.1", "FIX-001.1.2"]); + const sharedBase = commitCurrent(shared.root); + write(shared.root, "tests/shared/helper.js", "export const expected = false\n"); + const sharedReport = json(run(shared.root, ["check", "--changed", sharedBase, "--json"])); + expect(sharedReport.staleReviews.join("\n")).toContain("FIX-001.1.2"); + expect(sharedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.1"); + expect(sharedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.3"); + + const instructionsFiles = { + ".2119.yml": 'prefix: "FIX"\n', + "specs/FIX-001-widgets.md": spec(`1. Policy MUST stay current. [review: instructions: docs/review.md] +2. Other policy MUST stay current. [review: docs/other.md] +`), + "docs/review.md": "review policy v1\n", + "docs/other.md": "other v1\n", + }; + const instructions = initRepo(instructionsFiles); + passReviews(instructions.root, ["FIX-001.1.1"]); + const instructionsBase = commitCurrent(instructions.root); + write(instructions.root, "docs/review.md", "review policy v2\n"); + const instructionsReport = json(run(instructions.root, ["check", "--changed", instructionsBase, "--json"])); + expect(instructionsReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(instructionsReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + }); + + // 2119: REQ-009.2.5 + it("scopes changed and malformed verdict records without hiding unassigned corruption", () => { + const files = { + ".2119.yml": 'prefix: "FIX"\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n// 2119: FIX-001.1.2\ntest('stop', () => {})\n", + }; + const removed = initRepo(files); + passReviews(removed.root, ["FIX-001.1.1"]); + const removedBase = commitCurrent(removed.root); + const verdict = git(removed.root, "ls-files", ".2119/verdicts/FIX-001.1.1*.json"); + unlinkSync(join(removed.root, verdict)); + const removedReport = json(run(removed.root, ["check", "--changed", removedBase, "--json"])); + expect(removedReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(removedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + + const malformed = initRepo(files); + const malformedBase = malformed.base; + write(malformed.root, ".2119/verdicts/not-a-verdict.json", "{ broken json"); + const malformedReport = json(run(malformed.root, ["check", "--changed", malformedBase, "--json"])); + expect(malformedReport.violations.some((v) => v.file.includes("not-a-verdict.json"))).toBe(true); + + const assignedMalformed = initRepo(files); + passReviews(assignedMalformed.root, ["FIX-001.1.1"]); + const assignedMalformedBase = commitCurrent(assignedMalformed.root); + const assignedPath = git(assignedMalformed.root, "ls-files", ".2119/verdicts/FIX-001.1.1*.json"); + write(assignedMalformed.root, assignedPath, "{ broken json"); + const assignedReport = json(run(assignedMalformed.root, ["check", "--changed", assignedMalformedBase, "--json"])); + expect(assignedReport.violations.some((v) => v.file.includes(assignedPath))).toBe(true); + expect(assignedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + + const added = initRepo(files); + const addedBase = added.base; + passReviews(added.root, ["FIX-001.1.1"]); + const addedReport = json(run(added.root, ["check", "--changed", addedBase, "--json"])); + expect(addedReport.requirementCount).toBe(1); + expect(addedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + + const replaced = initRepo(files); + passReviews(replaced.root, ["FIX-001.1.1"]); + const replacedBase = commitCurrent(replaced.root); + const currentId = git(replaced.root, "ls-files", ".2119/verdicts/FIX-001.1.1*.json").match(/FIX-001\.1\.1--[0-9a-f]{12}/)![0]; + expect(run(replaced.root, ["fail", currentId, "--summary", "replacement rejection"]).status).toBe(0); + const replacedReport = json(run(replaced.root, ["check", "--changed", replacedBase, "--json"])); + expect(replacedReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(replacedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + }); + + // 2119: REQ-009.2.6 + it("treats every current requirement as affected when configuration changes", () => { + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + }); + write(root, ".2119.yml", 'prefix: "FIX"\nreviews: false\n# policy changed\n'); + expect(json(run(root, ["check", "--changed", base, "--json"])).uncoveredRequirements).toEqual([ + "FIX-001.1.1", + "FIX-001.1.2", + ]); + }); + + // 2119: REQ-009.3.1 + it("reports only changed lint and affected verification failures", () => { + const lintCase = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-old.md": spec("1. The old widget spins.\n"), + "specs/FIX-002-new.md": spec("1. The new widget MUST stop. [manual]\n", "FIX-002", "New widgets"), + }); + write(lintCase.root, "specs/FIX-002-new.md", spec("1. The new widget stops.\n", "FIX-002", "New widgets")); + const lintReport = json(run(lintCase.root, ["check", "--changed", lintCase.base, "--json"])); + expect(lintReport.violations.some((v) => v.file.includes("FIX-002-new.md"))).toBe(true); + expect(lintReport.violations.some((v) => v.file.includes("FIX-001-old.md"))).toBe(false); + + const verifyCase = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec(`1. First check MUST pass. [verify: false] +2. Second check MUST pass. [verify: false] +`), + }); + write( + verifyCase.root, + "specs/FIX-001-widgets.md", + spec(`1. First changed check MUST pass. [verify: false] +2. Second check MUST pass. [verify: false] +`), + ); + const verifyReport = json(run(verifyCase.root, ["check", "--changed", verifyCase.base, "--json"])); + expect(verifyReport.violations.some((v) => v.message.includes("FIX-001.1.1"))).toBe(true); + expect(verifyReport.violations.some((v) => v.message.includes("FIX-001.1.2"))).toBe(false); + }); + + // 2119: REQ-009.3.2 + it("reports an unchanged annotation when its requirement is removed from a retained spec file", () => { + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", + "tests/old.test.js": "// 2119: FIX-999.1.1\n", + }); + write(root, "specs/FIX-001-widgets.md", spec("2. The widget MUST stop. [manual]\n")); + const report = json(run(root, ["check", "--changed", base, "--json"])); + expect(report.violations.some((v) => v.message.includes("FIX-001.1.1"))).toBe(true); + expect(report.violations.some((v) => v.message.includes("FIX-999.1.1"))).toBe(false); + }); + + // 2119: REQ-009.3.3 + it("composes with JSON and no-verify using incremental counts and manual output", () => { + const counts = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", + }); + write(counts.root, "specs/FIX-001-widgets.md", TWO_REQUIREMENTS.replace("MUST spin", "MUST rotate")); + const countReport = json(run(counts.root, ["check", "--changed", counts.base, "--json"])); + expect(countReport.requirementCount).toBe(1); + expect(countReport.coveredCount).toBe(1); + expect(countReport.uncoveredRequirements).toEqual([]); + + const skipped = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec(`1. The changed command MUST pass. [verify: false] +2. An unchanged command MUST pass. [verify: false] +3. An unrelated widget MUST stop. +`), + }); + write( + skipped.root, + "specs/FIX-001-widgets.md", + spec(`1. The newly changed command MUST pass. [verify: false] +2. An unchanged command MUST pass. [verify: false] +3. An unrelated widget MUST stop. +`), + ); + const result = run(skipped.root, ["check", "--changed", skipped.base, "--no-verify", "--json"]); + expect(result.status).toBe(0); + const skippedReport = json(result); + expect(skippedReport.manualRequirements.map((r) => r.id)).toEqual(["FIX-001.1.1"]); + expect(skippedReport.uncoveredRequirements).toEqual([]); + }); +}); From 15d14078ecb923a93e3a4e940ddf3ec31272313b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 04:44:44 +0000 Subject: [PATCH 2/7] feat: add requirement-scoped check --changed --- .2119/verdicts/REQ-003.2.2--10f5fc089cd3.json | 8 + src/changed.ts | 262 ++++++++++++++++++ src/check.ts | 16 +- src/cli.ts | 37 ++- src/verify.ts | 2 + 5 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 .2119/verdicts/REQ-003.2.2--10f5fc089cd3.json create mode 100644 src/changed.ts diff --git a/.2119/verdicts/REQ-003.2.2--10f5fc089cd3.json b/.2119/verdicts/REQ-003.2.2--10f5fc089cd3.json new file mode 100644 index 0000000..92f2484 --- /dev/null +++ b/.2119/verdicts/REQ-003.2.2--10f5fc089cd3.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.2.2--10f5fc089cd3", + "requirementId": "REQ-003.2.2", + "hash": "10f5fc089cd3", + "verdict": "pass", + "summary": "writeVerdict emits plain pretty-printed JSON to .2119/verdicts/; init.ts gitignores only .2119/reviews/ (comment cites REQ-003.2.2); git check-ignore confirms verdicts not ignored and 114 verdict files are tracked", + "timestamp": "2026-07-22T04:43:21.947Z" +} diff --git a/src/changed.ts b/src/changed.ts new file mode 100644 index 0000000..994f4bd --- /dev/null +++ b/src/changed.ts @@ -0,0 +1,262 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; +import { buildContext, type CheckContext } from "./check.js"; +import { allRequirements } from "./spec.js"; +import { splitReviewId } from "./hash.js"; +import { runVerifyCommands, VERIFY_TIMEOUT_MS } from "./verify.js"; +import type { Requirement, Violation } from "./model.js"; + +export class IncrementalCheckError extends Error { + constructor(message: string) { + super(message); + this.name = "IncrementalCheckError"; + } +} + +interface ChangeSet { + mergeBase: string; + paths: Set; +} + +export interface ChangedBuildOptions { + runVerify?: boolean; +} + +/** Build a normal current context, then conservatively narrow it by comparing a Git baseline. */ +export function buildChangedContext( + root: string, + baseRef: string, + options: ChangedBuildOptions = {}, +): CheckContext { + const changes = readChangeSet(root, baseRef); + const baselineRoot = materializeBaseline(root, changes.mergeBase); + try { + let baseline: CheckContext; + let current: CheckContext; + try { + baseline = buildContext(baselineRoot, { runVerify: false }); + } catch (err) { + throw new IncrementalCheckError(`Cannot parse baseline configuration or content: ${(err as Error).message}`); + } + try { + // Verification is run only after affected requirements are known. + current = buildContext(root, { runVerify: false }); + } catch (err) { + throw new IncrementalCheckError(`Cannot parse current configuration or content: ${(err as Error).message}`); + } + return scopeContext(root, current, baseline, changes.paths, options.runVerify !== false); + } finally { + rmSync(baselineRoot, { recursive: true, force: true }); + } +} + +function readChangeSet(root: string, baseRef: string): ChangeSet { + const resolved = gitText( + root, + ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], + `resolve base ref "${baseRef}"`, + ).trim(); + const mergeBase = gitText(root, ["merge-base", resolved, "HEAD"], `find a merge-base for "${baseRef}" and HEAD`).trim(); + if (!mergeBase) { + throw new IncrementalCheckError(`Cannot find a merge-base for "${baseRef}" and HEAD; the histories may be unrelated.`); + } + const tracked = gitNulPaths( + root, + ["diff", "--name-only", "-z", "--no-renames", mergeBase, "--"], + "read tracked changes from the merge-base", + ); + const untracked = gitNulPaths( + root, + ["ls-files", "--others", "--exclude-standard", "-z"], + "read untracked non-ignored files", + ); + return { mergeBase, paths: new Set([...tracked, ...untracked]) }; +} + +function gitText(root: string, args: string[], action: string): string { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + const warning = result.stderr?.trim(); + if (result.error || result.status !== 0 || warning) { + const detail = warning || result.error?.message || `Git exited ${result.status ?? "without a status"}`; + throw new IncrementalCheckError(`Cannot ${action}: ${detail}`); + } + return result.stdout; +} + +function gitNulPaths(root: string, args: string[], action: string): string[] { + const raw = gitText(root, args, action); + return raw ? raw.split("\0").filter(Boolean) : []; +} + +/** Materialize the exact baseline tree without checking it out or contacting a remote. */ +function materializeBaseline(root: string, commit: string): string { + const target = mkdtempSync(join(tmpdir(), "2119-baseline-")); + try { + let listing: Buffer; + try { + listing = execFileSync("git", ["ls-tree", "-rz", "--full-tree", commit], { + cwd: root, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + const e = err as { stderr?: Buffer; message?: string }; + throw new IncrementalCheckError( + `Cannot read baseline tree ${commit}: ${e.stderr?.toString().trim() || e.message || "unknown Git error"}`, + ); + } + for (const record of listing.toString("utf8").split("\0").filter(Boolean)) { + const tab = record.indexOf("\t"); + const header = tab === -1 ? "" : record.slice(0, tab); + const path = tab === -1 ? "" : record.slice(tab + 1); + const [mode, type, object] = header.split(" "); + if (!path || !object) throw new IncrementalCheckError(`Cannot parse baseline tree entry: ${record}`); + if (type === "commit") { + throw new IncrementalCheckError(`Cannot read baseline content for submodule path "${path}" without a checkout.`); + } + if (type !== "blob") continue; + // Match the repository walker: symbolic links are not scanned as files. + if (mode === "120000") continue; + const destination = resolve(target, path); + if (isAbsolute(path) || path.split("/").includes("..") || !destination.startsWith(`${target}${sep}`)) { + throw new IncrementalCheckError(`Unsafe path in baseline tree: "${path}"`); + } + let content: Buffer; + try { + content = execFileSync("git", ["cat-file", "blob", object], { + cwd: root, + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + const e = err as { stderr?: Buffer; message?: string }; + throw new IncrementalCheckError( + `Cannot read baseline content for "${path}": ${e.stderr?.toString().trim() || e.message || "unknown Git error"}`, + ); + } + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, content); + } + return target; + } catch (err) { + rmSync(target, { recursive: true, force: true }); + if (err instanceof IncrementalCheckError) throw err; + throw new IncrementalCheckError(`Cannot materialize baseline content: ${(err as Error).message}`); + } +} + +function scopeContext( + root: string, + current: CheckContext, + baseline: CheckContext, + changedPaths: Set, + runVerify: boolean, +): CheckContext { + const currentRequirements = new Map(allRequirements(current.specs).filter((r) => !r.removed).map((r) => [r.id, r])); + const baselineRequirements = new Map(allRequirements(baseline.specs).filter((r) => !r.removed).map((r) => [r.id, r])); + const affected = new Set(); + + if (changedPaths.has(".2119.yml")) { + for (const id of currentRequirements.keys()) affected.add(id); + } else { + for (const [id, requirement] of currentRequirements) { + const prior = baselineRequirements.get(id); + if (!prior || contractKey(requirement) !== contractKey(prior)) affected.add(id); + } + + const currentEvidence = new Map(current.allReviewTargets.map((t) => [t.requirement.id, t.reviewId])); + const baselineEvidence = new Map(baseline.allReviewTargets.map((t) => [t.requirement.id, t.reviewId])); + for (const id of currentRequirements.keys()) { + if (currentEvidence.get(id) !== baselineEvidence.get(id)) affected.add(id); + } + } + + for (const path of changedPaths) { + if (!path.startsWith(".2119/verdicts/") || !path.endsWith(".json")) continue; + const requirementId = verdictRequirementId(path); + if (requirementId && currentRequirements.has(requirementId)) affected.add(requirementId); + } + + const lintViolations = current.lintViolations.filter((v) => changedPaths.has(relativePath(root, v.file))); + const coverViolations = current.coverViolations.filter((v) => { + if (v.rule === "REQ-002.2.4") return affected.has(messageRequirementId(v, currentRequirements) ?? ""); + if (v.rule === "REQ-002.2.3") { + if (changedPaths.has(relativePath(root, v.file))) return true; + const referenced = quotedRequirementId(v.message); + return Boolean(referenced && baselineRequirements.has(referenced) && !currentRequirements.has(referenced)); + } + return affected.has(messageRequirementId(v, currentRequirements) ?? ""); + }); + + const malformed = new Set(current.malformedVerdictViolations); + const reviewViolations = current.reviewViolations.filter((v) => { + if (!malformed.has(v)) return affected.has(messageRequirementId(v, currentRequirements) ?? ""); + const path = relativePath(root, v.file); + const requirementId = verdictRequirementId(path); + return requirementId && currentRequirements.has(requirementId) + ? affected.has(requirementId) + : changedPaths.has(path); + }); + + const covered = new Map([...current.coverage.covered].filter(([id]) => affected.has(id))); + const coverage = { + violations: coverViolations, + covered, + uncovered: current.coverage.uncovered.filter((r) => affected.has(r.id)), + manual: current.coverage.manual.filter((r) => affected.has(r.id)), + }; + const reviewTargets = current.reviewTargets.filter((t) => affected.has(t.requirement.id)); + const verifyViolations = runVerify + ? runVerifyCommands(current.config, current.specs, VERIFY_TIMEOUT_MS, affected) + : []; + + return { + ...current, + coverage, + reviewTargets, + lintViolations, + coverViolations, + reviewViolations, + verifyViolations, + scopedRequirementIds: affected, + }; +} + +function contractKey(requirement: Requirement): string { + return JSON.stringify({ + text: requirement.text, + keywords: requirement.keywords, + kind: requirement.coverage.kind, + command: requirement.coverage.command ?? null, + globs: requirement.coverage.globs ?? [], + instructions: requirement.coverage.instructions ?? null, + }); +} + +function relativePath(root: string, path: string): string { + if (!isAbsolute(path)) return path.split(sep).join("/"); + return relative(root, path).split(sep).join("/"); +} + +function messageRequirementId(violation: Violation, requirements: Map): string | undefined { + for (const id of requirements.keys()) { + if (violation.message.startsWith(`${id} `)) return id; + } + return undefined; +} + +function quotedRequirementId(message: string): string | undefined { + return message.match(/requirement ID "([^"]+)"/)?.[1]; +} + +function verdictRequirementId(path: string): string | undefined { + const name = basename(path).replace(/\.json$/, ""); + return splitReviewId(name)?.requirementId; +} diff --git a/src/check.ts b/src/check.ts index a6b514b..d81df85 100644 --- a/src/check.ts +++ b/src/check.ts @@ -9,20 +9,26 @@ import { computeReviewTargets, verdictViolations, type ReviewTask } from "./revi import { scanVerdicts } from "./verdict.js"; import { runVerifyCommands } from "./verify.js"; import { allRequirements } from "./spec.js"; -import type { SpecFile, Verdict, Violation } from "./model.js"; +import type { Annotation, SpecFile, Verdict, Violation } from "./model.js"; export interface CheckContext { config: Config; repoFiles: string[]; specs: SpecFile[]; coverage: CoverageResult; + annotations: Annotation[]; + /** Review hashes computed even when reviews are disabled, for incremental dependency comparison. */ + allReviewTargets: Omit[]; reviewTargets: Omit[]; verdicts: Map; lintViolations: Violation[]; coverViolations: Violation[]; reviewViolations: Violation[]; + malformedVerdictViolations: Violation[]; verifyViolations: Violation[]; notInitialized: boolean; + /** Present for `check --changed`; limits report counts and manual output to affected requirements. */ + scopedRequirementIds?: Set; } export interface BuildOptions { @@ -63,7 +69,8 @@ export function buildContext(root: string, options: BuildOptions = {}): CheckCon const annotations = scanAnnotations(root, testFiles, config.prefix, config.commentLeaders); const coverage = computeCoverage(specs, annotations, config.enforce); - const reviewTargets = config.reviews ? computeReviewTargets(config, specs, coverage, repoFiles, annotations) : []; + const allReviewTargets = computeReviewTargets(config, specs, coverage, repoFiles, annotations); + const reviewTargets = config.reviews ? allReviewTargets : []; // Malformed verdict files are loud violations, not silent passes or skips (REQ-003.7.2). const { verdicts, violations: malformedVerdicts } = scanVerdicts(root); const reviewViolations = [...malformedVerdicts, ...verdictViolations(reviewTargets, verdicts)]; @@ -101,11 +108,14 @@ export function buildContext(root: string, options: BuildOptions = {}): CheckCon repoFiles, specs, coverage, + annotations, + allReviewTargets, reviewTargets, verdicts, lintViolations, coverViolations: coverage.violations, reviewViolations, + malformedVerdictViolations: malformedVerdicts, verifyViolations, notInitialized, }; @@ -125,7 +135,7 @@ export function buildReport(ctx: CheckContext): CheckReport { const violations = [...ctx.lintViolations, ...ctx.coverViolations, ...ctx.reviewViolations, ...ctx.verifyViolations]; const enforcedTestReqs = ctx.specs .flatMap((s) => s.sections.flatMap((sec) => sec.items)) - .filter((r) => !r.removed); + .filter((r) => !r.removed && (!ctx.scopedRequirementIds || ctx.scopedRequirementIds.has(r.id))); return { ok: violations.length === 0, violations, diff --git a/src/cli.ts b/src/cli.ts index 12dc61f..2fb3f72 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { buildContext, buildReport } from "./check.js"; +import { buildChangedContext, IncrementalCheckError } from "./changed.js"; import { generateAuditInstructions, generateInstructions, renderDispatchPrompt } from "./review.js"; import { pruneVerdicts, writeVerdict } from "./verdict.js"; import { splitReviewId } from "./hash.js"; @@ -51,7 +52,8 @@ usage: 2119 pass Record a passing review verdict: 2119 pass --summary "..." fail Record a failing review verdict: 2119 fail --summary "..." check lint + cover + review-verdict freshness; non-zero exit on any failure - (--json machine output; --no-verify skips [verify] shell commands) + (--changed scopes to affected requirements; --json machine + output; --no-verify skips [verify] shell commands) prune Delete verdicts whose review ID matches no current requirement content hook Agent hook entry point: 2119 hook --platform

`; @@ -184,12 +186,41 @@ switch (command) { // spec-supplied shell; the requirements surface like [manual] instead of // silently dropping (REQ-002.3.5). const noVerify = args.includes("--no-verify"); - const ctx = buildContext(root, { runVerify: !noVerify }); + const changedFlags = args.filter((arg) => arg === "--changed"); + if (changedFlags.length > 1) { + console.error("usage: 2119 check --changed [--json] [--no-verify]"); + process.exit(2); + } + const changedIndex = args.indexOf("--changed"); + const baseRef = changedIndex === -1 ? undefined : args[changedIndex + 1]; + if (changedIndex !== -1) { + const extraValues = args.filter((arg, index) => !arg.startsWith("--") && index !== changedIndex + 1); + if (!baseRef || baseRef.startsWith("--") || extraValues.length > 0) { + console.error("usage: 2119 check --changed [--json] [--no-verify]"); + process.exit(2); + } + } + let ctx: ReturnType; + try { + ctx = baseRef + ? buildChangedContext(root, baseRef, { runVerify: !noVerify }) + : buildContext(root, { runVerify: !noVerify }); + } catch (err) { + if (changedIndex !== -1 && (err instanceof IncrementalCheckError || err instanceof Error)) { + console.error(`check --changed: ${(err as Error).message}`); + process.exit(2); + } + throw err; + } requireInitialized(ctx); const report = buildReport(ctx); if (noVerify) { for (const req of allRequirements(ctx.specs)) { - if (!req.removed && req.coverage.kind === "verify") { + if ( + !req.removed && + req.coverage.kind === "verify" && + (!ctx.scopedRequirementIds || ctx.scopedRequirementIds.has(req.id)) + ) { report.manualRequirements.push({ id: req.id, text: `${req.text} [verify skipped: --no-verify]` }); } } diff --git a/src/verify.ts b/src/verify.ts index bf40cc1..20d9c6b 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -21,11 +21,13 @@ export function runVerifyCommands( config: Config, specs: SpecFile[], timeoutMs: number = VERIFY_TIMEOUT_MS, + requirementIds?: Set, ): Violation[] { const out: Violation[] = []; for (const spec of specs) { for (const section of spec.sections) { for (const req of section.items) { + if (requirementIds && !requirementIds.has(req.id)) continue; if (req.removed || req.coverage.kind !== "verify" || !req.coverage.command) continue; if (req.keywords.length !== 1 || !config.enforce.includes(req.keywords[0])) continue; try { From 320cf43039eb91984329a47949645564cb21428e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 05:05:39 +0000 Subject: [PATCH 3/7] spec: renumber check-changed as REQ-010 --- .2119/verdicts/REQ-009.1.1--9e58f09dcddc.json | 8 ------- .2119/verdicts/REQ-009.1.2--1774c02e3d8d.json | 8 ------- .2119/verdicts/REQ-009.1.3--b5aebcf0370e.json | 8 ------- .2119/verdicts/REQ-009.2.1--6464ff888fa1.json | 8 ------- .2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json | 8 ------- .2119/verdicts/REQ-009.2.3--3d61356231ca.json | 8 ------- .2119/verdicts/REQ-009.2.4--a4f3a99bf269.json | 8 ------- .2119/verdicts/REQ-009.2.5--547f4a0ce4db.json | 8 ------- .2119/verdicts/REQ-009.2.6--344fdb4be17c.json | 8 ------- .2119/verdicts/REQ-009.3.1--e5e99fdac548.json | 8 ------- .2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json | 8 ------- .2119/verdicts/REQ-009.3.3--8ab901cda03f.json | 8 ------- .2119/verdicts/REQ-010.1.1--0479b51cdaab.json | 8 +++++++ .2119/verdicts/REQ-010.1.2--f4dce0d350ab.json | 8 +++++++ .2119/verdicts/REQ-010.1.3--1559f073935a.json | 8 +++++++ .2119/verdicts/REQ-010.2.1--cbc5ab184881.json | 8 +++++++ .2119/verdicts/REQ-010.2.2--25aca4d78caa.json | 8 +++++++ .2119/verdicts/REQ-010.2.3--38fc49d4bc18.json | 8 +++++++ .2119/verdicts/REQ-010.2.4--eb50a492aca6.json | 8 +++++++ .2119/verdicts/REQ-010.2.5--b4326d354dcb.json | 8 +++++++ .2119/verdicts/REQ-010.2.6--e0dbb7708490.json | 8 +++++++ .2119/verdicts/REQ-010.3.1--0f8578c717ed.json | 8 +++++++ .2119/verdicts/REQ-010.3.2--6cd573498e47.json | 8 +++++++ .2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json | 8 +++++++ ...ck-changed.md => REQ-010-check-changed.md} | 9 ++++--- tests/check-changed.test.ts | 24 +++++++++---------- 26 files changed, 112 insertions(+), 113 deletions(-) delete mode 100644 .2119/verdicts/REQ-009.1.1--9e58f09dcddc.json delete mode 100644 .2119/verdicts/REQ-009.1.2--1774c02e3d8d.json delete mode 100644 .2119/verdicts/REQ-009.1.3--b5aebcf0370e.json delete mode 100644 .2119/verdicts/REQ-009.2.1--6464ff888fa1.json delete mode 100644 .2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json delete mode 100644 .2119/verdicts/REQ-009.2.3--3d61356231ca.json delete mode 100644 .2119/verdicts/REQ-009.2.4--a4f3a99bf269.json delete mode 100644 .2119/verdicts/REQ-009.2.5--547f4a0ce4db.json delete mode 100644 .2119/verdicts/REQ-009.2.6--344fdb4be17c.json delete mode 100644 .2119/verdicts/REQ-009.3.1--e5e99fdac548.json delete mode 100644 .2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json delete mode 100644 .2119/verdicts/REQ-009.3.3--8ab901cda03f.json create mode 100644 .2119/verdicts/REQ-010.1.1--0479b51cdaab.json create mode 100644 .2119/verdicts/REQ-010.1.2--f4dce0d350ab.json create mode 100644 .2119/verdicts/REQ-010.1.3--1559f073935a.json create mode 100644 .2119/verdicts/REQ-010.2.1--cbc5ab184881.json create mode 100644 .2119/verdicts/REQ-010.2.2--25aca4d78caa.json create mode 100644 .2119/verdicts/REQ-010.2.3--38fc49d4bc18.json create mode 100644 .2119/verdicts/REQ-010.2.4--eb50a492aca6.json create mode 100644 .2119/verdicts/REQ-010.2.5--b4326d354dcb.json create mode 100644 .2119/verdicts/REQ-010.2.6--e0dbb7708490.json create mode 100644 .2119/verdicts/REQ-010.3.1--0f8578c717ed.json create mode 100644 .2119/verdicts/REQ-010.3.2--6cd573498e47.json create mode 100644 .2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json rename specs/{REQ-009-check-changed.md => REQ-010-check-changed.md} (96%) diff --git a/.2119/verdicts/REQ-009.1.1--9e58f09dcddc.json b/.2119/verdicts/REQ-009.1.1--9e58f09dcddc.json deleted file mode 100644 index 0141b06..0000000 --- a/.2119/verdicts/REQ-009.1.1--9e58f09dcddc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.1.1--9e58f09dcddc", - "requirementId": "REQ-009.1.1", - "hash": "9e58f09dcddc", - "verdict": "pass", - "summary": "Arity, unresolvable, ambiguous, and no-merge-base args all exit non-zero with diagnostics; a diverged-branch fixture passes only under merge-base (not ref-tip) semantics; PATH git shim logs subcommands and hard-fails fetch/pull/ls-remote to prove offline operation.", - "timestamp": "2026-07-21T23:53:09.075Z" -} diff --git a/.2119/verdicts/REQ-009.1.2--1774c02e3d8d.json b/.2119/verdicts/REQ-009.1.2--1774c02e3d8d.json deleted file mode 100644 index 067faba..0000000 --- a/.2119/verdicts/REQ-009.1.2--1774c02e3d8d.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.1.2--1774c02e3d8d", - "requirementId": "REQ-009.1.2", - "hash": "1774c02e3d8d", - "verdict": "pass", - "summary": "Committed, staged, unstaged, and untracked changes each independently surface a violation from the changed file while an identical pre-existing violation stays suppressed; deleting a covering test must uncover exactly its requirement, so dropped deletions would fail.", - "timestamp": "2026-07-21T23:53:09.309Z" -} diff --git a/.2119/verdicts/REQ-009.1.3--b5aebcf0370e.json b/.2119/verdicts/REQ-009.1.3--b5aebcf0370e.json deleted file mode 100644 index a979cb2..0000000 --- a/.2119/verdicts/REQ-009.1.3--b5aebcf0370e.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.1.3--b5aebcf0370e", - "requirementId": "REQ-009.1.3", - "hash": "b5aebcf0370e", - "verdict": "pass", - "summary": "Each failure source (deleted .git, unparseable committed baseline config, corrupted baseline blob) is tested against a fixture that would pass under any silent full-repo or incomplete substitute, so the non-zero-exit assertions genuinely discriminate fail-closed behavior; real git + subprocess, no mocks.", - "timestamp": "2026-07-22T04:43:56.541Z" -} diff --git a/.2119/verdicts/REQ-009.2.1--6464ff888fa1.json b/.2119/verdicts/REQ-009.2.1--6464ff888fa1.json deleted file mode 100644 index d06abe6..0000000 --- a/.2119/verdicts/REQ-009.2.1--6464ff888fa1.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.2.1--6464ff888fa1", - "requirementId": "REQ-009.2.1", - "hash": "6464ff888fa1", - "verdict": "pass", - "summary": "Each contract field (statement, keyword, manual mode, verify command, review evidence path, review-instructions path) is mutated in isolation and must yield requirementCount 1 with the untouched sibling excluded; the baseline-absent conjunct is covered by the added-requirement case.", - "timestamp": "2026-07-21T23:53:15.484Z" -} diff --git a/.2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json b/.2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json deleted file mode 100644 index a3ca631..0000000 --- a/.2119/verdicts/REQ-009.2.2--a8e70a8a6a44.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.2.2--a8e70a8a6a44", - "requirementId": "REQ-009.2.2", - "hash": "a8e70a8a6a44", - "verdict": "pass", - "summary": "Annotation add/remove flips coverage for exactly the target requirement, and an in-block edit stales only that block's verdict — whole-file hashing or a missed rehash would each break a named negative assertion.", - "timestamp": "2026-07-21T23:53:15.773Z" -} diff --git a/.2119/verdicts/REQ-009.2.3--3d61356231ca.json b/.2119/verdicts/REQ-009.2.3--3d61356231ca.json deleted file mode 100644 index 374f4af..0000000 --- a/.2119/verdicts/REQ-009.2.3--3d61356231ca.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.2.3--3d61356231ca", - "requirementId": "REQ-009.2.3", - "hash": "3d61356231ca", - "verdict": "pass", - "summary": "An edit confined to a sibling annotation block stales only that requirement while the same-file neighbor and other-file block stay fresh, and a prelude-only edit stales both same-file requirements — pinning REQ-003.1.7 boundaries in both directions.", - "timestamp": "2026-07-21T23:53:16.071Z" -} diff --git a/.2119/verdicts/REQ-009.2.4--a4f3a99bf269.json b/.2119/verdicts/REQ-009.2.4--a4f3a99bf269.json deleted file mode 100644 index 5eaa8fd..0000000 --- a/.2119/verdicts/REQ-009.2.4--a4f3a99bf269.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.2.4--a4f3a99bf269", - "requirementId": "REQ-009.2.4", - "hash": "a4f3a99bf269", - "verdict": "pass", - "summary": "Each evidence kind (explicit [review:] file, [review: instructions:] file, shared_evidence glob member) is edited in a real repo and the target requirement goes stale while a sibling with a fresh verdict stays clean; assertions run against --changed JSON output, no mocks", - "timestamp": "2026-07-21T23:52:23.981Z" -} diff --git a/.2119/verdicts/REQ-009.2.5--547f4a0ce4db.json b/.2119/verdicts/REQ-009.2.5--547f4a0ce4db.json deleted file mode 100644 index cd2ccc7..0000000 --- a/.2119/verdicts/REQ-009.2.5--547f4a0ce4db.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.2.5--547f4a0ce4db", - "requirementId": "REQ-009.2.5", - "hash": "547f4a0ce4db", - "verdict": "pass", - "summary": "Added, removed, replaced (pass-to-fail on same review ID), and malformed verdicts each drive affectedness with an unaffected-sibling control, and an unassignable corrupt verdicts file (not-a-verdict.json) still surfaces as a scoped violation", - "timestamp": "2026-07-21T23:52:29.392Z" -} diff --git a/.2119/verdicts/REQ-009.2.6--344fdb4be17c.json b/.2119/verdicts/REQ-009.2.6--344fdb4be17c.json deleted file mode 100644 index 2873a45..0000000 --- a/.2119/verdicts/REQ-009.2.6--344fdb4be17c.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.2.6--344fdb4be17c", - "requirementId": "REQ-009.2.6", - "hash": "344fdb4be17c", - "verdict": "pass", - "summary": "A comment-only .2119.yml diff with an untouched spec must yield uncoveredRequirements exactly equal to the full requirement set, rejecting both ignored-config and partial-affectedness implementations; 'differs' boundary probed by a semantic no-op edit", - "timestamp": "2026-07-21T23:52:33.340Z" -} diff --git a/.2119/verdicts/REQ-009.3.1--e5e99fdac548.json b/.2119/verdicts/REQ-009.3.1--e5e99fdac548.json deleted file mode 100644 index ac1b6af..0000000 --- a/.2119/verdicts/REQ-009.3.1--e5e99fdac548.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.3.1--e5e99fdac548", - "requirementId": "REQ-009.3.1", - "hash": "e5e99fdac548", - "verdict": "pass", - "summary": "Every report category has a positive and a pre-existing negative: changed-spec lint vs untouched keyword-less spec, dangling annotations in changed vs unchanged test files across all four change states, coverage loss scoped to deleted evidence, and a failing [verify] command reported only for the edited requirement", - "timestamp": "2026-07-21T23:52:37.435Z" -} diff --git a/.2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json b/.2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json deleted file mode 100644 index d6fc91a..0000000 --- a/.2119/verdicts/REQ-009.3.2--ff6894e0e8ea.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.3.2--ff6894e0e8ea", - "requirementId": "REQ-009.3.2", - "hash": "ff6894e0e8ea", - "verdict": "pass", - "summary": "Deleting one requirement from a retained spec surfaces the untouched annotation as an invalid-annotation violation while a pre-existing dangling annotation in an unchanged test file stays unreported, proving the deletion trigger rather than a full-repo scan", - "timestamp": "2026-07-21T23:52:43.763Z" -} diff --git a/.2119/verdicts/REQ-009.3.3--8ab901cda03f.json b/.2119/verdicts/REQ-009.3.3--8ab901cda03f.json deleted file mode 100644 index f1655ff..0000000 --- a/.2119/verdicts/REQ-009.3.3--8ab901cda03f.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-009.3.3--8ab901cda03f", - "requirementId": "REQ-009.3.3", - "hash": "8ab901cda03f", - "verdict": "pass", - "summary": "Exact-equality assertions: --changed --json counts describe only 1 of 2 requirements, and --changed --no-verify --json exits 0 (the [verify: false] command would fail if actually run) with manualRequirements equal to exactly the affected verify requirement", - "timestamp": "2026-07-21T23:52:48.026Z" -} diff --git a/.2119/verdicts/REQ-010.1.1--0479b51cdaab.json b/.2119/verdicts/REQ-010.1.1--0479b51cdaab.json new file mode 100644 index 0000000..ba6a7a8 --- /dev/null +++ b/.2119/verdicts/REQ-010.1.1--0479b51cdaab.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.1--0479b51cdaab", + "requirementId": "REQ-010.1.1", + "hash": "0479b51cdaab", + "verdict": "pass", + "summary": "E2E git-repo tests reject absent/extra/unresolvable/ambiguous refs and orphan no-merge-base with diagnostics; diverged-branch fixture proves merge-base (not tip diff) is the baseline; PATH-shimmed git log proves no fetch/pull/ls-remote", + "timestamp": "2026-07-22T04:53:34.884Z" +} diff --git a/.2119/verdicts/REQ-010.1.2--f4dce0d350ab.json b/.2119/verdicts/REQ-010.1.2--f4dce0d350ab.json new file mode 100644 index 0000000..0ba6d00 --- /dev/null +++ b/.2119/verdicts/REQ-010.1.2--f4dce0d350ab.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.2--f4dce0d350ab", + "requirementId": "REQ-010.1.2", + "hash": "f4dce0d350ab", + "verdict": "pass", + "summary": "Each state (committed/staged/unstaged/untracked) exercised in a real repo with a scoped-out unchanged control (FIX-800 not reported); unstaged deletion of a covering test surfaces the uncovered requirement, proving deletions stay in the changed set", + "timestamp": "2026-07-22T04:53:38.563Z" +} diff --git a/.2119/verdicts/REQ-010.1.3--1559f073935a.json b/.2119/verdicts/REQ-010.1.3--1559f073935a.json new file mode 100644 index 0000000..6aa73c8 --- /dev/null +++ b/.2119/verdicts/REQ-010.1.3--1559f073935a.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.3--1559f073935a", + "requirementId": "REQ-010.1.3", + "hash": "1559f073935a", + "verdict": "pass", + "summary": "Deleted .git, unparseable baseline .2119.yml (current fixed), and a corrupted baseline blob each force non-zero exit with an on-topic diagnostic; in each fixture a silent full-repo fallback would have exited 0, so fail-closed is genuinely distinguished", + "timestamp": "2026-07-22T04:53:43.198Z" +} diff --git a/.2119/verdicts/REQ-010.2.1--cbc5ab184881.json b/.2119/verdicts/REQ-010.2.1--cbc5ab184881.json new file mode 100644 index 0000000..c76315d --- /dev/null +++ b/.2119/verdicts/REQ-010.2.1--cbc5ab184881.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.1--cbc5ab184881", + "requirementId": "REQ-010.2.1", + "hash": "cbc5ab184881", + "verdict": "pass", + "summary": "Six field-isolated variants (statement, keyword, manual mode, verify value, review evidence path, review-instructions path) each yield requirementCount 1 with the unchanged sibling excluded; new-requirement case proves absent-from-baseline is affected and uncovered", + "timestamp": "2026-07-22T04:53:47.808Z" +} diff --git a/.2119/verdicts/REQ-010.2.2--25aca4d78caa.json b/.2119/verdicts/REQ-010.2.2--25aca4d78caa.json new file mode 100644 index 0000000..74dcb87 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.2--25aca4d78caa.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.2--25aca4d78caa", + "requirementId": "REQ-010.2.2", + "hash": "25aca4d78caa", + "verdict": "pass", + "summary": "Block-content edit marks only the edited annotation's requirement stale (same-file and other-file controls stay clean); add/remove annotation loop shows the requirement enters scope with coverage flipping 1/0 while the untouched sibling stays out", + "timestamp": "2026-07-22T04:53:53.204Z" +} diff --git a/.2119/verdicts/REQ-010.2.3--38fc49d4bc18.json b/.2119/verdicts/REQ-010.2.3--38fc49d4bc18.json new file mode 100644 index 0000000..06545ed --- /dev/null +++ b/.2119/verdicts/REQ-010.2.3--38fc49d4bc18.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.3--38fc49d4bc18", + "requirementId": "REQ-010.2.3", + "hash": "38fc49d4bc18", + "verdict": "pass", + "summary": "The exact counterexample is tested: an edit confined to a neighboring block in the same file leaves FIX-001.1.1 unaffected, while a shared-prelude (import) edit marks both same-file requirements stale and leaves the other file's requirement untouched", + "timestamp": "2026-07-22T04:53:57.146Z" +} diff --git a/.2119/verdicts/REQ-010.2.4--eb50a492aca6.json b/.2119/verdicts/REQ-010.2.4--eb50a492aca6.json new file mode 100644 index 0000000..6267f30 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.4--eb50a492aca6.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.4--eb50a492aca6", + "requirementId": "REQ-010.2.4", + "hash": "eb50a492aca6", + "verdict": "pass", + "summary": "Explicit review evidence, review-instructions, and shared-evidence file edits each mark exactly the selecting requirement stale, with unedited sibling evidence files as negative controls proving per-file (not per-repo) granularity", + "timestamp": "2026-07-22T04:54:01.090Z" +} diff --git a/.2119/verdicts/REQ-010.2.5--b4326d354dcb.json b/.2119/verdicts/REQ-010.2.5--b4326d354dcb.json new file mode 100644 index 0000000..a3b537f --- /dev/null +++ b/.2119/verdicts/REQ-010.2.5--b4326d354dcb.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.5--b4326d354dcb", + "requirementId": "REQ-010.2.5", + "hash": "b4326d354dcb", + "verdict": "pass", + "summary": "Removed, added, replaced (pass->fail), and corrupted verdicts each surface for FIX-001.1.1 with the sibling scoped out, and an unassignable broken verdicts JSON file is still reported as a violation rather than silently dropped", + "timestamp": "2026-07-22T04:54:04.897Z" +} diff --git a/.2119/verdicts/REQ-010.2.6--e0dbb7708490.json b/.2119/verdicts/REQ-010.2.6--e0dbb7708490.json new file mode 100644 index 0000000..ec3dc2d --- /dev/null +++ b/.2119/verdicts/REQ-010.2.6--e0dbb7708490.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.6--e0dbb7708490", + "requirementId": "REQ-010.2.6", + "hash": "e0dbb7708490", + "verdict": "pass", + "summary": "A comment-only .2119.yml edit with no other changes makes both otherwise-untouched requirements uncovered in the incremental report; without config-triggered universal affectedness the scoped run would have reported nothing", + "timestamp": "2026-07-22T04:54:09.177Z" +} diff --git a/.2119/verdicts/REQ-010.3.1--0f8578c717ed.json b/.2119/verdicts/REQ-010.3.1--0f8578c717ed.json new file mode 100644 index 0000000..6ca1701 --- /dev/null +++ b/.2119/verdicts/REQ-010.3.1--0f8578c717ed.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.1--0f8578c717ed", + "requirementId": "REQ-010.3.1", + "hash": "0f8578c717ed", + "verdict": "pass", + "summary": "All four categories tested with pre-existing out-of-scope controls: changed-spec lint reported while an untouched lint-violating spec is ignored, verify failure only for the changed requirement, invalid annotations only from changed test files, and freshness/coverage scoping covered by the annotated 010.1.2/010.2.2 tests", + "timestamp": "2026-07-22T04:54:13.504Z" +} diff --git a/.2119/verdicts/REQ-010.3.2--6cd573498e47.json b/.2119/verdicts/REQ-010.3.2--6cd573498e47.json new file mode 100644 index 0000000..e98e250 --- /dev/null +++ b/.2119/verdicts/REQ-010.3.2--6cd573498e47.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.2--6cd573498e47", + "requirementId": "REQ-010.3.2", + "hash": "6cd573498e47", + "verdict": "pass", + "summary": "Test deletes req 1 from a retained spec and asserts the unchanged annotation is reported (verified live: rule REQ-002.2.3 on unchanged tests/widget.test.js), with FIX-999.1.1 as a scoping control proving pre-existing dangling refs outside changed specs stay unreported", + "timestamp": "2026-07-22T04:53:24.680Z" +} diff --git a/.2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json b/.2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json new file mode 100644 index 0000000..4b60cd2 --- /dev/null +++ b/.2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.3--8e02d7f7aa9b", + "requirementId": "REQ-010.3.3", + "hash": "8e02d7f7aa9b", + "verdict": "pass", + "summary": "JSON counts verified incremental (requirementCount 1 of 2, unaffected uncovered req excluded from lists); --no-verify skipping is behavioral since the affected [verify: false] command would fail the run, and manualRequirements asserts exactly the affected verification req in the existing {id,text} form", + "timestamp": "2026-07-22T04:53:28.714Z" +} diff --git a/specs/REQ-009-check-changed.md b/specs/REQ-010-check-changed.md similarity index 96% rename from specs/REQ-009-check-changed.md rename to specs/REQ-010-check-changed.md index 8a2f6cd..1b967fb 100644 --- a/specs/REQ-009-check-changed.md +++ b/specs/REQ-010-check-changed.md @@ -1,4 +1,4 @@ -# REQ-009: Incremental Check +# REQ-010: Incremental Check ## Overview @@ -17,13 +17,13 @@ dependency model. ## Requirements -### REQ-009.1: Base and change set +### REQ-010.1: Base and change set 1. `2119 check --changed ` MUST accept exactly one locally resolvable commit-ish, use the merge-base of that commit and `HEAD` as the baseline, perform no network access, and exit non-zero with a clear diagnostic when the argument is absent, ambiguous, unresolvable, or has no merge-base with `HEAD`. 2. The changed-path set MUST include differences between the baseline tree and `HEAD`, staged and unstaged tracked differences, and untracked non-ignored files, with deletions retained as changed paths. 3. Incremental checking MUST exit non-zero with a diagnostic instead of silently running an incomplete or full-repository substitute when Git metadata, baseline content, or baseline configuration needed for sound scoping cannot be read or parsed. -### REQ-009.2: Affected requirements +### REQ-010.2: Affected requirements 1. A current requirement MUST be affected when it is absent from the baseline or its statement, normative keyword, coverage mode, coverage command, evidence globs, or review-instruction path differs from the baseline requirement with the same ID. 2. A current requirement MUST be affected when a covering annotation is added or removed between the baseline and current views, or when the content hash of one of its covering annotation evidence blocks differs between those views. @@ -32,9 +32,8 @@ dependency model. 5. A current requirement MUST be affected when its current review verdict is added, removed, replaced, or malformed relative to the baseline, and a changed malformed verdict that cannot be assigned to a current requirement must remain a scoped violation. 6. Every current requirement MUST be affected when `.2119.yml` differs from the baseline, because configuration changes can alter discovery, enforcement, evidence, or review behavior. -### REQ-009.3: Scoped validation and output +### REQ-010.3: Scoped validation and output 1. Incremental checking MUST report lint violations from changed current spec files, coverage and verdict-freshness violations for affected current requirements, invalid annotations from changed test evidence, and verification failures only for affected current requirements, while ignoring pre-existing violations outside that scope. 2. An unchanged annotation whose referenced requirement was deleted from a changed spec MUST be treated as changed test evidence and reported as an invalid annotation, preventing requirement deletion from hiding a dangling reference. 3. `--changed` MUST compose with `--json` and `--no-verify`, with JSON counts and requirement lists describing only the incremental scope and skipped affected verification requirements surfaced by `--no-verify` in the existing manual-requirements form. - diff --git a/tests/check-changed.test.ts b/tests/check-changed.test.ts index 4816a70..7c1e5ab 100644 --- a/tests/check-changed.test.ts +++ b/tests/check-changed.test.ts @@ -85,8 +85,8 @@ const TWO_REQUIREMENTS = spec(`1. The widget MUST spin. 2. The widget MUST stop. `); -describe("check --changed (REQ-009)", () => { - // 2119: REQ-009.1.1 +describe("check --changed (REQ-010)", () => { + // 2119: REQ-010.1.1 it("requires one local base ref, uses its merge-base, and never invokes network Git commands", () => { const { root, base } = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', @@ -144,7 +144,7 @@ describe("check --changed (REQ-009)", () => { expect(readFileSync(log, "utf8")).not.toMatch(/^(fetch|pull|ls-remote)$/m); }); - // 2119: REQ-009.1.2, REQ-009.3.1 + // 2119: REQ-010.1.2, REQ-010.3.1 it("includes committed, staged, unstaged, untracked, and deleted paths since the merge-base", () => { for (const mode of ["committed", "staged", "unstaged", "untracked"] as const) { const { root, base } = initRepo({ @@ -174,7 +174,7 @@ describe("check --changed (REQ-009)", () => { expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); }); - // 2119: REQ-009.1.3 + // 2119: REQ-010.1.3 it("fails closed when Git metadata or the baseline configuration cannot be read", () => { const noGit = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', @@ -207,7 +207,7 @@ describe("check --changed (REQ-009)", () => { expect(`${unreadable.stdout}\n${unreadable.stderr}`).toMatch(/baseline|object|content|read|git/i); }); - // 2119: REQ-009.2.1 + // 2119: REQ-010.2.1 it("detects each requirement-contract field independently without affecting its sibling", () => { const variants = [ ["1. The widget MUST spin.\n", "1. The widget MUST rotate.\n"], @@ -245,7 +245,7 @@ describe("check --changed (REQ-009)", () => { expect(addedReport.uncoveredRequirements).toEqual(["FIX-001.1.2"]); }); - // 2119: REQ-009.2.2, REQ-009.2.3, REQ-009.3.1 + // 2119: REQ-010.2.2, REQ-010.2.3, REQ-010.3.1 it("uses annotation blocks and their shared prelude when selecting affected requirements", () => { const files = { ".2119.yml": 'prefix: "FIX"\n', @@ -302,7 +302,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) } }); - // 2119: REQ-009.2.4 + // 2119: REQ-010.2.4 it("uses whole-file comparison for explicit review and shared evidence", () => { const files = { ".2119.yml": 'prefix: "FIX"\nshared_evidence: ["tests/shared/**"]\n', @@ -351,7 +351,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(instructionsReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); }); - // 2119: REQ-009.2.5 + // 2119: REQ-010.2.5 it("scopes changed and malformed verdict records without hiding unassigned corruption", () => { const files = { ".2119.yml": 'prefix: "FIX"\n', @@ -399,7 +399,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(replacedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); }); - // 2119: REQ-009.2.6 + // 2119: REQ-010.2.6 it("treats every current requirement as affected when configuration changes", () => { const { root, base } = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', @@ -412,7 +412,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) ]); }); - // 2119: REQ-009.3.1 + // 2119: REQ-010.3.1 it("reports only changed lint and affected verification failures", () => { const lintCase = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', @@ -442,7 +442,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(verifyReport.violations.some((v) => v.message.includes("FIX-001.1.2"))).toBe(false); }); - // 2119: REQ-009.3.2 + // 2119: REQ-010.3.2 it("reports an unchanged annotation when its requirement is removed from a retained spec file", () => { const { root, base } = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', @@ -456,7 +456,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(report.violations.some((v) => v.message.includes("FIX-999.1.1"))).toBe(false); }); - // 2119: REQ-009.3.3 + // 2119: REQ-010.3.3 it("composes with JSON and no-verify using incremental counts and manual output", () => { const counts = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', From 3c2d2653a2d6df927261e4fdd3fcbfa6e866c20f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 05:27:10 +0000 Subject: [PATCH 4/7] chore: refresh review verdict after implementation --- .2119/verdicts/REQ-003.2.2--10f5fc089cd3.json | 8 -------- .2119/verdicts/REQ-003.2.2--204eb12e5588.json | 8 -------- .2119/verdicts/REQ-003.2.2--37cca72f7559.json | 8 ++++++++ 3 files changed, 8 insertions(+), 16 deletions(-) delete mode 100644 .2119/verdicts/REQ-003.2.2--10f5fc089cd3.json delete mode 100644 .2119/verdicts/REQ-003.2.2--204eb12e5588.json create mode 100644 .2119/verdicts/REQ-003.2.2--37cca72f7559.json diff --git a/.2119/verdicts/REQ-003.2.2--10f5fc089cd3.json b/.2119/verdicts/REQ-003.2.2--10f5fc089cd3.json deleted file mode 100644 index 92f2484..0000000 --- a/.2119/verdicts/REQ-003.2.2--10f5fc089cd3.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-003.2.2--10f5fc089cd3", - "requirementId": "REQ-003.2.2", - "hash": "10f5fc089cd3", - "verdict": "pass", - "summary": "writeVerdict emits plain pretty-printed JSON to .2119/verdicts/; init.ts gitignores only .2119/reviews/ (comment cites REQ-003.2.2); git check-ignore confirms verdicts not ignored and 114 verdict files are tracked", - "timestamp": "2026-07-22T04:43:21.947Z" -} diff --git a/.2119/verdicts/REQ-003.2.2--204eb12e5588.json b/.2119/verdicts/REQ-003.2.2--204eb12e5588.json deleted file mode 100644 index 0d30d60..0000000 --- a/.2119/verdicts/REQ-003.2.2--204eb12e5588.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-003.2.2--204eb12e5588", - "requirementId": "REQ-003.2.2", - "hash": "204eb12e5588", - "verdict": "pass", - "summary": "writeVerdict emits readable JSON under .2119/verdicts; init ignores only .2119/reviews, and Git confirms verdict JSON is tracked and not ignored for PR audit", - "timestamp": "2026-07-21T23:53:11.020Z" -} diff --git a/.2119/verdicts/REQ-003.2.2--37cca72f7559.json b/.2119/verdicts/REQ-003.2.2--37cca72f7559.json new file mode 100644 index 0000000..50d3620 --- /dev/null +++ b/.2119/verdicts/REQ-003.2.2--37cca72f7559.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.2.2--37cca72f7559", + "requirementId": "REQ-003.2.2", + "hash": "37cca72f7559", + "verdict": "pass", + "summary": "writeVerdict emits plain JSON to .2119/verdicts/; init gitignores only .2119/reviews/ (never verdicts), git check-ignore confirms verdicts unignored, and 131 verdict files are tracked/committed so they surface in PR diffs.", + "timestamp": "2026-07-22T05:25:10.950Z" +} From bebd9d1b4795477c3c7d046adb15bd22fc978e75 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 05:29:03 +0000 Subject: [PATCH 5/7] chore: refine review verdict rationale --- .2119/verdicts/REQ-003.2.2--37cca72f7559.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.2119/verdicts/REQ-003.2.2--37cca72f7559.json b/.2119/verdicts/REQ-003.2.2--37cca72f7559.json index 50d3620..5d55858 100644 --- a/.2119/verdicts/REQ-003.2.2--37cca72f7559.json +++ b/.2119/verdicts/REQ-003.2.2--37cca72f7559.json @@ -3,6 +3,6 @@ "requirementId": "REQ-003.2.2", "hash": "37cca72f7559", "verdict": "pass", - "summary": "writeVerdict emits plain JSON to .2119/verdicts/; init gitignores only .2119/reviews/ (never verdicts), git check-ignore confirms verdicts unignored, and 131 verdict files are tracked/committed so they surface in PR diffs.", - "timestamp": "2026-07-22T05:25:10.950Z" + "summary": "writeVerdict emits parseable JSON under .2119/verdicts; init ignores only .2119/reviews, Git confirms verdicts are unignored, and all 128 present verdict JSON files are tracked for PR diffs.", + "timestamp": "2026-07-22T05:28:25.050Z" } From e622741c959f3893656b44aef605d5c39d0fe9c8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 14:26:23 +0000 Subject: [PATCH 6/7] fix: address adversarial check-changed review --- .2119/verdicts/REQ-003.1.6--8905a787958e.json | 8 - .2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json | 8 + .2119/verdicts/REQ-003.2.2--37cca72f7559.json | 8 - .2119/verdicts/REQ-003.2.2--f704c217b548.json | 8 + .2119/verdicts/REQ-010.1.1--0479b51cdaab.json | 8 - .2119/verdicts/REQ-010.1.1--50440336fee1.json | 8 + .2119/verdicts/REQ-010.1.2--27708b83e5de.json | 8 + .2119/verdicts/REQ-010.1.2--f4dce0d350ab.json | 8 - .2119/verdicts/REQ-010.1.3--1559f073935a.json | 8 - .2119/verdicts/REQ-010.1.3--de7826b99f0e.json | 8 + .2119/verdicts/REQ-010.2.1--cbc5ab184881.json | 8 - .2119/verdicts/REQ-010.2.1--d99f97668eda.json | 8 + .2119/verdicts/REQ-010.2.2--25aca4d78caa.json | 8 - .2119/verdicts/REQ-010.2.2--ae5127a2584c.json | 8 + .2119/verdicts/REQ-010.2.3--38fc49d4bc18.json | 8 - .2119/verdicts/REQ-010.2.3--a070770701c2.json | 8 + .2119/verdicts/REQ-010.2.4--b123c4b2b7b6.json | 8 + .2119/verdicts/REQ-010.2.4--eb50a492aca6.json | 8 - .2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json | 8 + .2119/verdicts/REQ-010.2.5--b4326d354dcb.json | 8 - .2119/verdicts/REQ-010.2.6--3ce6fbc03427.json | 8 + .2119/verdicts/REQ-010.2.6--e0dbb7708490.json | 8 - .2119/verdicts/REQ-010.3.1--0f8578c717ed.json | 8 - .2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json | 8 + .2119/verdicts/REQ-010.3.2--6cd573498e47.json | 8 - .2119/verdicts/REQ-010.3.2--8b8adf907340.json | 8 + .2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json | 8 - .2119/verdicts/REQ-010.3.3--a6efe8f2ed68.json | 8 + .gitignore | 7 + specs/REQ-003-judgment-reviews.md | 2 +- src/changed.ts | 125 +++++++-- src/cli.ts | 4 +- src/init.ts | 9 +- src/verdict.ts | 62 +++++ tests/check-changed.test.ts | 258 +++++++++++++++--- tests/cli.test.ts | 67 ++++- 36 files changed, 574 insertions(+), 184 deletions(-) delete mode 100644 .2119/verdicts/REQ-003.1.6--8905a787958e.json create mode 100644 .2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json delete mode 100644 .2119/verdicts/REQ-003.2.2--37cca72f7559.json create mode 100644 .2119/verdicts/REQ-003.2.2--f704c217b548.json delete mode 100644 .2119/verdicts/REQ-010.1.1--0479b51cdaab.json create mode 100644 .2119/verdicts/REQ-010.1.1--50440336fee1.json create mode 100644 .2119/verdicts/REQ-010.1.2--27708b83e5de.json delete mode 100644 .2119/verdicts/REQ-010.1.2--f4dce0d350ab.json delete mode 100644 .2119/verdicts/REQ-010.1.3--1559f073935a.json create mode 100644 .2119/verdicts/REQ-010.1.3--de7826b99f0e.json delete mode 100644 .2119/verdicts/REQ-010.2.1--cbc5ab184881.json create mode 100644 .2119/verdicts/REQ-010.2.1--d99f97668eda.json delete mode 100644 .2119/verdicts/REQ-010.2.2--25aca4d78caa.json create mode 100644 .2119/verdicts/REQ-010.2.2--ae5127a2584c.json delete mode 100644 .2119/verdicts/REQ-010.2.3--38fc49d4bc18.json create mode 100644 .2119/verdicts/REQ-010.2.3--a070770701c2.json create mode 100644 .2119/verdicts/REQ-010.2.4--b123c4b2b7b6.json delete mode 100644 .2119/verdicts/REQ-010.2.4--eb50a492aca6.json create mode 100644 .2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json delete mode 100644 .2119/verdicts/REQ-010.2.5--b4326d354dcb.json create mode 100644 .2119/verdicts/REQ-010.2.6--3ce6fbc03427.json delete mode 100644 .2119/verdicts/REQ-010.2.6--e0dbb7708490.json delete mode 100644 .2119/verdicts/REQ-010.3.1--0f8578c717ed.json create mode 100644 .2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json delete mode 100644 .2119/verdicts/REQ-010.3.2--6cd573498e47.json create mode 100644 .2119/verdicts/REQ-010.3.2--8b8adf907340.json delete mode 100644 .2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json create mode 100644 .2119/verdicts/REQ-010.3.3--a6efe8f2ed68.json diff --git a/.2119/verdicts/REQ-003.1.6--8905a787958e.json b/.2119/verdicts/REQ-003.1.6--8905a787958e.json deleted file mode 100644 index 32a6764..0000000 --- a/.2119/verdicts/REQ-003.1.6--8905a787958e.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-003.1.6--8905a787958e", - "requirementId": "REQ-003.1.6", - "hash": "8905a787958e", - "verdict": "pass", - "summary": "Runs init end-to-end and asserts .gitignore contains .2119/reviews/ and does not contain .2119/verdicts, verifying the scratch-vs-audit distinction the requirement mandates.", - "timestamp": "2026-07-09T23:07:21.190Z" -} diff --git a/.2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json b/.2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json new file mode 100644 index 0000000..7dfa56a --- /dev/null +++ b/.2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.1.6--9f2dfb5cc3db", + "requirementId": "REQ-003.1.6", + "hash": "9f2dfb5cc3db", + "verdict": "pass", + "summary": "Real CLI test rejects review output outside .2119/reviews and verifies init's rule actually makes a pending instruction file Git-ignored.", + "timestamp": "2026-07-22T14:24:15.213Z" +} diff --git a/.2119/verdicts/REQ-003.2.2--37cca72f7559.json b/.2119/verdicts/REQ-003.2.2--37cca72f7559.json deleted file mode 100644 index 5d55858..0000000 --- a/.2119/verdicts/REQ-003.2.2--37cca72f7559.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-003.2.2--37cca72f7559", - "requirementId": "REQ-003.2.2", - "hash": "37cca72f7559", - "verdict": "pass", - "summary": "writeVerdict emits parseable JSON under .2119/verdicts; init ignores only .2119/reviews, Git confirms verdicts are unignored, and all 128 present verdict JSON files are tracked for PR diffs.", - "timestamp": "2026-07-22T05:28:25.050Z" -} diff --git a/.2119/verdicts/REQ-003.2.2--f704c217b548.json b/.2119/verdicts/REQ-003.2.2--f704c217b548.json new file mode 100644 index 0000000..3044008 --- /dev/null +++ b/.2119/verdicts/REQ-003.2.2--f704c217b548.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.2.2--f704c217b548", + "requirementId": "REQ-003.2.2", + "hash": "f704c217b548", + "verdict": "pass", + "summary": "init and pass/fail move managed exceptions after conflicting root, .2119, and verdict-directory ignore rules; pass/fail then write direct, pretty-printed JSON verdict files under .2119/verdicts", + "timestamp": "2026-07-22T14:25:00.591Z" +} diff --git a/.2119/verdicts/REQ-010.1.1--0479b51cdaab.json b/.2119/verdicts/REQ-010.1.1--0479b51cdaab.json deleted file mode 100644 index ba6a7a8..0000000 --- a/.2119/verdicts/REQ-010.1.1--0479b51cdaab.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.1.1--0479b51cdaab", - "requirementId": "REQ-010.1.1", - "hash": "0479b51cdaab", - "verdict": "pass", - "summary": "E2E git-repo tests reject absent/extra/unresolvable/ambiguous refs and orphan no-merge-base with diagnostics; diverged-branch fixture proves merge-base (not tip diff) is the baseline; PATH-shimmed git log proves no fetch/pull/ls-remote", - "timestamp": "2026-07-22T04:53:34.884Z" -} diff --git a/.2119/verdicts/REQ-010.1.1--50440336fee1.json b/.2119/verdicts/REQ-010.1.1--50440336fee1.json new file mode 100644 index 0000000..0a9d265 --- /dev/null +++ b/.2119/verdicts/REQ-010.1.1--50440336fee1.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.1--50440336fee1", + "requirementId": "REQ-010.1.1", + "hash": "50440336fee1", + "verdict": "pass", + "summary": "Covers zero/multiple refs, non-commit and unresolved or ambiguous refs, true merge-base selection, unrelated histories, and blocks observable Git/Node network access with clear failure diagnostics.", + "timestamp": "2026-07-22T14:09:35.409Z" +} diff --git a/.2119/verdicts/REQ-010.1.2--27708b83e5de.json b/.2119/verdicts/REQ-010.1.2--27708b83e5de.json new file mode 100644 index 0000000..62f5172 --- /dev/null +++ b/.2119/verdicts/REQ-010.1.2--27708b83e5de.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.2--27708b83e5de", + "requirementId": "REQ-010.1.2", + "hash": "27708b83e5de", + "verdict": "pass", + "summary": "Real-repository tests reject omission of baseline-to-HEAD, staged, unstaged, and non-ignored untracked paths; committed, staged, and unstaged deletion cases plus an unchanged-file control verify deletion retention and scoped selection.", + "timestamp": "2026-07-22T14:11:49.545Z" +} diff --git a/.2119/verdicts/REQ-010.1.2--f4dce0d350ab.json b/.2119/verdicts/REQ-010.1.2--f4dce0d350ab.json deleted file mode 100644 index 0ba6d00..0000000 --- a/.2119/verdicts/REQ-010.1.2--f4dce0d350ab.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.1.2--f4dce0d350ab", - "requirementId": "REQ-010.1.2", - "hash": "f4dce0d350ab", - "verdict": "pass", - "summary": "Each state (committed/staged/unstaged/untracked) exercised in a real repo with a scoped-out unchanged control (FIX-800 not reported); unstaged deletion of a covering test surfaces the uncovered requirement, proving deletions stay in the changed set", - "timestamp": "2026-07-22T04:53:38.563Z" -} diff --git a/.2119/verdicts/REQ-010.1.3--1559f073935a.json b/.2119/verdicts/REQ-010.1.3--1559f073935a.json deleted file mode 100644 index 6aa73c8..0000000 --- a/.2119/verdicts/REQ-010.1.3--1559f073935a.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.1.3--1559f073935a", - "requirementId": "REQ-010.1.3", - "hash": "1559f073935a", - "verdict": "pass", - "summary": "Deleted .git, unparseable baseline .2119.yml (current fixed), and a corrupted baseline blob each force non-zero exit with an on-topic diagnostic; in each fixture a silent full-repo fallback would have exited 0, so fail-closed is genuinely distinguished", - "timestamp": "2026-07-22T04:53:43.198Z" -} diff --git a/.2119/verdicts/REQ-010.1.3--de7826b99f0e.json b/.2119/verdicts/REQ-010.1.3--de7826b99f0e.json new file mode 100644 index 0000000..55d865e --- /dev/null +++ b/.2119/verdicts/REQ-010.1.3--de7826b99f0e.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.3--de7826b99f0e", + "requirementId": "REQ-010.1.3", + "hash": "de7826b99f0e", + "verdict": "pass", + "summary": "Missing Git metadata, malformed baseline config/specs, and corrupt baseline blobs each require a non-zero exit plus a relevant diagnostic, rejecting silent full or incomplete fallback behavior.", + "timestamp": "2026-07-22T14:13:42.226Z" +} diff --git a/.2119/verdicts/REQ-010.2.1--cbc5ab184881.json b/.2119/verdicts/REQ-010.2.1--cbc5ab184881.json deleted file mode 100644 index c76315d..0000000 --- a/.2119/verdicts/REQ-010.2.1--cbc5ab184881.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.1--cbc5ab184881", - "requirementId": "REQ-010.2.1", - "hash": "cbc5ab184881", - "verdict": "pass", - "summary": "Six field-isolated variants (statement, keyword, manual mode, verify value, review evidence path, review-instructions path) each yield requirementCount 1 with the unchanged sibling excluded; new-requirement case proves absent-from-baseline is affected and uncovered", - "timestamp": "2026-07-22T04:53:47.808Z" -} diff --git a/.2119/verdicts/REQ-010.2.1--d99f97668eda.json b/.2119/verdicts/REQ-010.2.1--d99f97668eda.json new file mode 100644 index 0000000..6fccd66 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.1--d99f97668eda.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.1--d99f97668eda", + "requirementId": "REQ-010.2.1", + "hash": "d99f97668eda", + "verdict": "pass", + "summary": "Pass: isolated cases reject an added requirement and changes to statement, keyword, coverage mode, verify command, evidence globs, and review-instruction path, while an identical sibling remains unaffected.", + "timestamp": "2026-07-22T05:49:14.818Z" +} diff --git a/.2119/verdicts/REQ-010.2.2--25aca4d78caa.json b/.2119/verdicts/REQ-010.2.2--25aca4d78caa.json deleted file mode 100644 index 74dcb87..0000000 --- a/.2119/verdicts/REQ-010.2.2--25aca4d78caa.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.2--25aca4d78caa", - "requirementId": "REQ-010.2.2", - "hash": "25aca4d78caa", - "verdict": "pass", - "summary": "Block-content edit marks only the edited annotation's requirement stale (same-file and other-file controls stay clean); add/remove annotation loop shows the requirement enters scope with coverage flipping 1/0 while the untouched sibling stays out", - "timestamp": "2026-07-22T04:53:53.204Z" -} diff --git a/.2119/verdicts/REQ-010.2.2--ae5127a2584c.json b/.2119/verdicts/REQ-010.2.2--ae5127a2584c.json new file mode 100644 index 0000000..f147ac8 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.2--ae5127a2584c.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.2--ae5127a2584c", + "requirementId": "REQ-010.2.2", + "hash": "ae5127a2584c", + "verdict": "pass", + "summary": "Tests independently reject missed annotation addition/removal and missed evidence-block hash changes, including sibling exclusions and a one-of-multiple-blocks case.", + "timestamp": "2026-07-22T14:15:48.181Z" +} diff --git a/.2119/verdicts/REQ-010.2.3--38fc49d4bc18.json b/.2119/verdicts/REQ-010.2.3--38fc49d4bc18.json deleted file mode 100644 index 06545ed..0000000 --- a/.2119/verdicts/REQ-010.2.3--38fc49d4bc18.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.3--38fc49d4bc18", - "requirementId": "REQ-010.2.3", - "hash": "38fc49d4bc18", - "verdict": "pass", - "summary": "The exact counterexample is tested: an edit confined to a neighboring block in the same file leaves FIX-001.1.1 unaffected, while a shared-prelude (import) edit marks both same-file requirements stale and leaves the other file's requirement untouched", - "timestamp": "2026-07-22T04:53:57.146Z" -} diff --git a/.2119/verdicts/REQ-010.2.3--a070770701c2.json b/.2119/verdicts/REQ-010.2.3--a070770701c2.json new file mode 100644 index 0000000..da58bd0 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.3--a070770701c2.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.3--a070770701c2", + "requirementId": "REQ-010.2.3", + "hash": "a070770701c2", + "verdict": "pass", + "summary": "Real CLI tests reject target-block and shared-prelude changes, exclude same-file neighboring-block edits, and detect a boundary inserted between baseline and current views.", + "timestamp": "2026-07-22T14:15:44.562Z" +} diff --git a/.2119/verdicts/REQ-010.2.4--b123c4b2b7b6.json b/.2119/verdicts/REQ-010.2.4--b123c4b2b7b6.json new file mode 100644 index 0000000..33b9b73 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.4--b123c4b2b7b6.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.4--b123c4b2b7b6", + "requirementId": "REQ-010.2.4", + "hash": "b123c4b2b7b6", + "verdict": "pass", + "summary": "Byte changes at the same selected path make the exact current requirement stale for explicit evidence, review instructions, and shared evidence, while unchanged sibling selections remain unaffected", + "timestamp": "2026-07-22T05:51:17.933Z" +} diff --git a/.2119/verdicts/REQ-010.2.4--eb50a492aca6.json b/.2119/verdicts/REQ-010.2.4--eb50a492aca6.json deleted file mode 100644 index 6267f30..0000000 --- a/.2119/verdicts/REQ-010.2.4--eb50a492aca6.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.4--eb50a492aca6", - "requirementId": "REQ-010.2.4", - "hash": "eb50a492aca6", - "verdict": "pass", - "summary": "Explicit review evidence, review-instructions, and shared-evidence file edits each mark exactly the selecting requirement stale, with unedited sibling evidence files as negative controls proving per-file (not per-repo) granularity", - "timestamp": "2026-07-22T04:54:01.090Z" -} diff --git a/.2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json b/.2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json new file mode 100644 index 0000000..bf1f856 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.5--0fe7b5cfe913", + "requirementId": "REQ-010.2.5", + "hash": "0fe7b5cfe913", + "verdict": "pass", + "summary": "Covers added, removed, replaced, and current-path malformed verdicts; excludes a changed historical verdict and preserves changed unassignable malformed JSON as a scoped violation.", + "timestamp": "2026-07-22T14:16:33.917Z" +} diff --git a/.2119/verdicts/REQ-010.2.5--b4326d354dcb.json b/.2119/verdicts/REQ-010.2.5--b4326d354dcb.json deleted file mode 100644 index a3b537f..0000000 --- a/.2119/verdicts/REQ-010.2.5--b4326d354dcb.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.5--b4326d354dcb", - "requirementId": "REQ-010.2.5", - "hash": "b4326d354dcb", - "verdict": "pass", - "summary": "Removed, added, replaced (pass->fail), and corrupted verdicts each surface for FIX-001.1.1 with the sibling scoped out, and an unassignable broken verdicts JSON file is still reported as a violation rather than silently dropped", - "timestamp": "2026-07-22T04:54:04.897Z" -} diff --git a/.2119/verdicts/REQ-010.2.6--3ce6fbc03427.json b/.2119/verdicts/REQ-010.2.6--3ce6fbc03427.json new file mode 100644 index 0000000..f97b1dd --- /dev/null +++ b/.2119/verdicts/REQ-010.2.6--3ce6fbc03427.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.6--3ce6fbc03427", + "requirementId": "REQ-010.2.6", + "hash": "3ce6fbc03427", + "verdict": "pass", + "summary": "A comment-only config diff scopes both current requirements, and a changed test-discovery glob exposes newly selected invalid evidence via the real CLI.", + "timestamp": "2026-07-22T05:52:23.598Z" +} diff --git a/.2119/verdicts/REQ-010.2.6--e0dbb7708490.json b/.2119/verdicts/REQ-010.2.6--e0dbb7708490.json deleted file mode 100644 index ec3dc2d..0000000 --- a/.2119/verdicts/REQ-010.2.6--e0dbb7708490.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.6--e0dbb7708490", - "requirementId": "REQ-010.2.6", - "hash": "e0dbb7708490", - "verdict": "pass", - "summary": "A comment-only .2119.yml edit with no other changes makes both otherwise-untouched requirements uncovered in the incremental report; without config-triggered universal affectedness the scoped run would have reported nothing", - "timestamp": "2026-07-22T04:54:09.177Z" -} diff --git a/.2119/verdicts/REQ-010.3.1--0f8578c717ed.json b/.2119/verdicts/REQ-010.3.1--0f8578c717ed.json deleted file mode 100644 index 6ca1701..0000000 --- a/.2119/verdicts/REQ-010.3.1--0f8578c717ed.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.3.1--0f8578c717ed", - "requirementId": "REQ-010.3.1", - "hash": "0f8578c717ed", - "verdict": "pass", - "summary": "All four categories tested with pre-existing out-of-scope controls: changed-spec lint reported while an untouched lint-violating spec is ignored, verify failure only for the changed requirement, invalid annotations only from changed test files, and freshness/coverage scoping covered by the annotated 010.1.2/010.2.2 tests", - "timestamp": "2026-07-22T04:54:13.504Z" -} diff --git a/.2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json b/.2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json new file mode 100644 index 0000000..cbe9bd7 --- /dev/null +++ b/.2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.1--ae0c5c9016ec", + "requirementId": "REQ-010.3.1", + "hash": "ae0c5c9016ec", + "verdict": "pass", + "summary": "End-to-end Git fixtures report changed invalid annotations, affected coverage and stale/malformed verdicts, changed-spec lint, and affected verification while excluding unchanged, sibling, and historical violations.", + "timestamp": "2026-07-22T14:19:19.677Z" +} diff --git a/.2119/verdicts/REQ-010.3.2--6cd573498e47.json b/.2119/verdicts/REQ-010.3.2--6cd573498e47.json deleted file mode 100644 index e98e250..0000000 --- a/.2119/verdicts/REQ-010.3.2--6cd573498e47.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.3.2--6cd573498e47", - "requirementId": "REQ-010.3.2", - "hash": "6cd573498e47", - "verdict": "pass", - "summary": "Test deletes req 1 from a retained spec and asserts the unchanged annotation is reported (verified live: rule REQ-002.2.3 on unchanged tests/widget.test.js), with FIX-999.1.1 as a scoping control proving pre-existing dangling refs outside changed specs stay unreported", - "timestamp": "2026-07-22T04:53:24.680Z" -} diff --git a/.2119/verdicts/REQ-010.3.2--8b8adf907340.json b/.2119/verdicts/REQ-010.3.2--8b8adf907340.json new file mode 100644 index 0000000..b9ea69a --- /dev/null +++ b/.2119/verdicts/REQ-010.3.2--8b8adf907340.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.2--8b8adf907340", + "requirementId": "REQ-010.3.2", + "hash": "8b8adf907340", + "verdict": "pass", + "summary": "CLI integration tests keep annotations unchanged while deleting their requirement or section from a changed spec, assert REQ-002.2.3 plus nonzero exit, and exclude an unrelated unchanged dangling annotation.", + "timestamp": "2026-07-22T14:17:46.363Z" +} diff --git a/.2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json b/.2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json deleted file mode 100644 index 4b60cd2..0000000 --- a/.2119/verdicts/REQ-010.3.3--8e02d7f7aa9b.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.3.3--8e02d7f7aa9b", - "requirementId": "REQ-010.3.3", - "hash": "8e02d7f7aa9b", - "verdict": "pass", - "summary": "JSON counts verified incremental (requirementCount 1 of 2, unaffected uncovered req excluded from lists); --no-verify skipping is behavioral since the affected [verify: false] command would fail the run, and manualRequirements asserts exactly the affected verification req in the existing {id,text} form", - "timestamp": "2026-07-22T04:53:28.714Z" -} diff --git a/.2119/verdicts/REQ-010.3.3--a6efe8f2ed68.json b/.2119/verdicts/REQ-010.3.3--a6efe8f2ed68.json new file mode 100644 index 0000000..2b24853 --- /dev/null +++ b/.2119/verdicts/REQ-010.3.3--a6efe8f2ed68.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.3--a6efe8f2ed68", + "requirementId": "REQ-010.3.3", + "hash": "a6efe8f2ed68", + "verdict": "pass", + "summary": "Real-CLI fixtures reject full-scope counts/stale/uncovered/manual lists and require --no-verify to emit only the affected skipped verification in exact manualRequirements JSON form.", + "timestamp": "2026-07-22T14:18:20.653Z" +} diff --git a/.gitignore b/.gitignore index 332fc6a..3147058 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ docs/explainer.html .claude/settings.json .DS_Store docs/calibration-ratchet.html +# 2119:gitignore-begin +# Review packets are scratch; verdicts are committed audit history. +!.2119/ +.2119/reviews/ +!.2119/verdicts/ +!.2119/verdicts/** +# 2119:gitignore-end diff --git a/specs/REQ-003-judgment-reviews.md b/specs/REQ-003-judgment-reviews.md index c2f01f5..5b4162f 100644 --- a/specs/REQ-003-judgment-reviews.md +++ b/specs/REQ-003-judgment-reviews.md @@ -35,7 +35,7 @@ their findings (not empty markers), and they live in version control. ### REQ-003.2: Verdict recording 1. `2119 pass --summary ` MUST write a verdict file to `.2119/verdicts/` containing the review ID, requirement ID, content hash, a findings summary, and an ISO 8601 timestamp. -2. Verdict files MUST be plain committed JSON — never gitignored — so verdicts appear in PR diffs for human audit. [review: src/**] +2. `2119 pass`, `2119 fail`, and `2119 init` MUST leave verdicts as plain JSON under an unignored `.2119/verdicts/` path, so users can commit them and audit them in PR diffs. [review: src/**] 3. `2119 pass` MUST refuse to record a verdict whose hash component does not match the current content hash for that requirement, preventing pre-computed or replayed passes. 4. `2119 fail --summary ` MUST record a failing verdict the same way, causing `2119 check` to fail until it is superseded by a passing verdict. diff --git a/src/changed.ts b/src/changed.ts index 994f4bd..a612184 100644 --- a/src/changed.ts +++ b/src/changed.ts @@ -1,10 +1,14 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { tmpdir } from "node:os"; import { buildContext, type CheckContext } from "./check.js"; +import { CONFIG_FILENAME } from "./config.js"; +import { evidenceBlockParts } from "./annotations.js"; +import { matchGlobs } from "./files.js"; import { allRequirements } from "./spec.js"; -import { splitReviewId } from "./hash.js"; +import { fileParts, splitReviewId } from "./hash.js"; +import { VERDICTS_DIR } from "./verdict.js"; import { runVerifyCommands, VERIFY_TIMEOUT_MS } from "./verify.js"; import type { Requirement, Violation } from "./model.js"; @@ -37,6 +41,12 @@ export function buildChangedContext( let current: CheckContext; try { baseline = buildContext(baselineRoot, { runVerify: false }); + const structural = baseline.lintViolations.filter( + (violation) => violation.rule.startsWith("REQ-001.1.") || violation.rule === "REQ-001.2.3", + ); + if (structural.length > 0) { + throw new Error(`${structural.length} structural baseline specification violation(s)`); + } } catch (err) { throw new IncrementalCheckError(`Cannot parse baseline configuration or content: ${(err as Error).message}`); } @@ -58,29 +68,43 @@ function readChangeSet(root: string, baseRef: string): ChangeSet { ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], `resolve base ref "${baseRef}"`, ).trim(); - const mergeBase = gitText(root, ["merge-base", resolved, "HEAD"], `find a merge-base for "${baseRef}" and HEAD`).trim(); - if (!mergeBase) { - throw new IncrementalCheckError(`Cannot find a merge-base for "${baseRef}" and HEAD; the histories may be unrelated.`); - } - const tracked = gitNulPaths( + const mergeBase = gitText( + root, + ["merge-base", resolved, "HEAD"], + `find a merge-base for "${baseRef}" and HEAD (the histories may be unrelated)`, + ).trim(); + const committed = gitNulPaths( + root, + ["diff", "--name-only", "-z", "--no-renames", mergeBase, "HEAD", "--"], + "read committed changes from the merge-base", + ); + const staged = gitNulPaths( root, - ["diff", "--name-only", "-z", "--no-renames", mergeBase, "--"], - "read tracked changes from the merge-base", + ["diff", "--cached", "--name-only", "-z", "--no-renames", "HEAD", "--"], + "read staged changes", + ); + const unstaged = gitNulPaths( + root, + ["diff", "--name-only", "-z", "--no-renames", "--"], + "read unstaged changes", ); const untracked = gitNulPaths( root, ["ls-files", "--others", "--exclude-standard", "-z"], "read untracked non-ignored files", ); - return { mergeBase, paths: new Set([...tracked, ...untracked]) }; + return { mergeBase, paths: new Set([...committed, ...staged, ...unstaged, ...untracked]) }; } +const NO_NETWORK_ENV = { ...process.env, GIT_NO_LAZY_FETCH: "1" }; + function gitText(root: string, args: string[], action: string): string { const result = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], + env: NO_NETWORK_ENV, }); const warning = result.stderr?.trim(); if (result.error || result.status !== 0 || warning) { @@ -105,6 +129,7 @@ function materializeBaseline(root: string, commit: string): string { cwd: root, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], + env: NO_NETWORK_ENV, }); } catch (err) { const e = err as { stderr?: Buffer; message?: string }; @@ -122,8 +147,6 @@ function materializeBaseline(root: string, commit: string): string { throw new IncrementalCheckError(`Cannot read baseline content for submodule path "${path}" without a checkout.`); } if (type !== "blob") continue; - // Match the repository walker: symbolic links are not scanned as files. - if (mode === "120000") continue; const destination = resolve(target, path); if (isAbsolute(path) || path.split("/").includes("..") || !destination.startsWith(`${target}${sep}`)) { throw new IncrementalCheckError(`Unsafe path in baseline tree: "${path}"`); @@ -134,6 +157,7 @@ function materializeBaseline(root: string, commit: string): string { cwd: root, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], + env: NO_NETWORK_ENV, }); } catch (err) { const e = err as { stderr?: Buffer; message?: string }; @@ -142,7 +166,16 @@ function materializeBaseline(root: string, commit: string): string { ); } mkdirSync(dirname(destination), { recursive: true }); - writeFileSync(destination, content); + if (mode === "120000") { + const linkTarget = content.toString("utf8"); + const resolvedTarget = resolve(dirname(destination), linkTarget); + if (isAbsolute(linkTarget) || !resolvedTarget.startsWith(`${target}${sep}`)) { + throw new IncrementalCheckError(`Unsafe symbolic link in baseline tree: "${path}" -> "${linkTarget}"`); + } + symlinkSync(linkTarget, destination); + } else { + writeFileSync(destination, content); + } } return target; } catch (err) { @@ -161,9 +194,18 @@ function scopeContext( ): CheckContext { const currentRequirements = new Map(allRequirements(current.specs).filter((r) => !r.removed).map((r) => [r.id, r])); const baselineRequirements = new Map(allRequirements(baseline.specs).filter((r) => !r.removed).map((r) => [r.id, r])); + const currentKnownIds = new Set([ + ...currentRequirements.keys(), + ...current.specs.flatMap((spec) => spec.sections.map((section) => section.id)), + ]); + const baselineKnownIds = new Set([ + ...baselineRequirements.keys(), + ...baseline.specs.flatMap((spec) => spec.sections.map((section) => section.id)), + ]); const affected = new Set(); + const configChanged = changedPaths.has(CONFIG_FILENAME); - if (changedPaths.has(".2119.yml")) { + if (configChanged) { for (const id of currentRequirements.keys()) affected.add(id); } else { for (const [id, requirement] of currentRequirements) { @@ -171,26 +213,33 @@ function scopeContext( if (!prior || contractKey(requirement) !== contractKey(prior)) affected.add(id); } - const currentEvidence = new Map(current.allReviewTargets.map((t) => [t.requirement.id, t.reviewId])); - const baselineEvidence = new Map(baseline.allReviewTargets.map((t) => [t.requirement.id, t.reviewId])); for (const id of currentRequirements.keys()) { - if (currentEvidence.get(id) !== baselineEvidence.get(id)) affected.add(id); + if (evidenceKey(current, id) !== evidenceKey(baseline, id)) affected.add(id); } } + const currentReviewIds = new Map(current.allReviewTargets.map((target) => [target.requirement.id, target.reviewId])); + const baselineReviewIds = new Map(baseline.allReviewTargets.map((target) => [target.requirement.id, target.reviewId])); for (const path of changedPaths) { - if (!path.startsWith(".2119/verdicts/") || !path.endsWith(".json")) continue; - const requirementId = verdictRequirementId(path); - if (requirementId && currentRequirements.has(requirementId)) affected.add(requirementId); + if (!path.startsWith(`${VERDICTS_DIR}/`) || !path.endsWith(".json")) continue; + const parsed = verdictReview(path); + if ( + parsed && + currentRequirements.has(parsed.requirementId) && + (currentReviewIds.get(parsed.requirementId) === parsed.reviewId || + baselineReviewIds.get(parsed.requirementId) === parsed.reviewId) + ) { + affected.add(parsed.requirementId); + } } const lintViolations = current.lintViolations.filter((v) => changedPaths.has(relativePath(root, v.file))); const coverViolations = current.coverViolations.filter((v) => { if (v.rule === "REQ-002.2.4") return affected.has(messageRequirementId(v, currentRequirements) ?? ""); if (v.rule === "REQ-002.2.3") { - if (changedPaths.has(relativePath(root, v.file))) return true; + if (configChanged || changedPaths.has(relativePath(root, v.file))) return true; const referenced = quotedRequirementId(v.message); - return Boolean(referenced && baselineRequirements.has(referenced) && !currentRequirements.has(referenced)); + return Boolean(referenced && baselineKnownIds.has(referenced) && !currentKnownIds.has(referenced)); } return affected.has(messageRequirementId(v, currentRequirements) ?? ""); }); @@ -199,10 +248,14 @@ function scopeContext( const reviewViolations = current.reviewViolations.filter((v) => { if (!malformed.has(v)) return affected.has(messageRequirementId(v, currentRequirements) ?? ""); const path = relativePath(root, v.file); - const requirementId = verdictRequirementId(path); - return requirementId && currentRequirements.has(requirementId) - ? affected.has(requirementId) - : changedPaths.has(path); + const parsed = verdictReview(path); + const assigned = Boolean( + parsed && + currentRequirements.has(parsed.requirementId) && + (currentReviewIds.get(parsed.requirementId) === parsed.reviewId || + baselineReviewIds.get(parsed.requirementId) === parsed.reviewId), + ); + return assigned ? affected.has(parsed!.requirementId) : changedPaths.has(path); }); const covered = new Map([...current.coverage.covered].filter(([id]) => affected.has(id))); @@ -229,6 +282,21 @@ function scopeContext( }; } +function evidenceKey(ctx: CheckContext, requirementId: string): string | undefined { + const requirement = allRequirements(ctx.specs).find((candidate) => !candidate.removed && candidate.id === requirementId); + if (!requirement) return undefined; + const covering = ctx.coverage.covered.get(requirementId) ?? []; + const parts = evidenceBlockParts(ctx.config.root, covering, ctx.annotations); + if (requirement.coverage.kind === "test") { + parts.push(...fileParts(ctx.config.root, matchGlobs(ctx.repoFiles, ctx.config.sharedEvidence))); + } else if (requirement.coverage.kind === "review") { + const evidence = requirement.coverage.globs ? matchGlobs(ctx.repoFiles, requirement.coverage.globs) : []; + const selected = requirement.coverage.instructions ? [requirement.coverage.instructions, ...evidence] : evidence; + parts.push(...fileParts(ctx.config.root, selected)); + } + return JSON.stringify(parts); +} + function contractKey(requirement: Requirement): string { return JSON.stringify({ text: requirement.text, @@ -256,7 +324,8 @@ function quotedRequirementId(message: string): string | undefined { return message.match(/requirement ID "([^"]+)"/)?.[1]; } -function verdictRequirementId(path: string): string | undefined { +function verdictReview(path: string): { reviewId: string; requirementId: string } | undefined { const name = basename(path).replace(/\.json$/, ""); - return splitReviewId(name)?.requirementId; + const parsed = splitReviewId(name); + return parsed ? { reviewId: name, requirementId: parsed.requirementId } : undefined; } diff --git a/src/cli.ts b/src/cli.ts index 2fb3f72..7b94738 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -206,8 +206,8 @@ switch (command) { ? buildChangedContext(root, baseRef, { runVerify: !noVerify }) : buildContext(root, { runVerify: !noVerify }); } catch (err) { - if (changedIndex !== -1 && (err instanceof IncrementalCheckError || err instanceof Error)) { - console.error(`check --changed: ${(err as Error).message}`); + if (changedIndex !== -1 && err instanceof IncrementalCheckError) { + console.error(`check --changed: ${err.message}`); process.exit(2); } throw err; diff --git a/src/init.ts b/src/init.ts index e79c317..8a944d6 100644 --- a/src/init.ts +++ b/src/init.ts @@ -9,6 +9,7 @@ import { refreshPinnedArtifacts, type AgentName, } from "./adapters.js"; +import { ensureReviewStorageRules } from "./verdict.js"; const CONFIG_TEMPLATE = `# 2119 configuration — https://github.com/Unsupervisedcom/2119 # All fields optional; these are the defaults unless noted. @@ -159,12 +160,8 @@ export function runInit(root: string, args: string[]): void { // .2119/reviews is scratch (gitignored); .2119/verdicts is committed audit // history and must never be ignored (REQ-003.1.6, REQ-003.2.2). - const gitignorePath = join(root, ".gitignore"); - const ignoreEntry = ".2119/reviews/"; - const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : ""; - if (!existing.includes(ignoreEntry)) { - appendFileSync(gitignorePath, `${existing.endsWith("\n") || existing === "" ? "" : "\n"}${ignoreEntry}\n`); - created.push(".gitignore (+ .2119/reviews/)"); + if (ensureReviewStorageRules(root)) { + created.push(".gitignore (2119 review/verdict rules)"); } const agentsResult = upsertSection(join(root, "AGENTS.md"), refresh); diff --git a/src/verdict.ts b/src/verdict.ts index c693107..493ca51 100644 --- a/src/verdict.ts +++ b/src/verdict.ts @@ -5,6 +5,67 @@ import { splitReviewId } from "./hash.js"; export const VERDICTS_DIR = ".2119/verdicts"; +const GITIGNORE_SECTION = `# 2119:gitignore-begin +# Review packets are scratch; verdicts are committed audit history. +!.2119/ +.2119/reviews/ +!.2119/verdicts/ +!.2119/verdicts/** +# 2119:gitignore-end +`; + +const NESTED_GITIGNORE_SECTION = `# 2119:storage-begin +reviews/ +!verdicts/ +!verdicts/** +# 2119:storage-end +`; + +const VERDICTS_GITIGNORE_SECTION = `# 2119:verdicts-begin +!.gitignore +!*.json +# 2119:verdicts-end +`; + +function putLast(path: string, section: string, pattern: RegExp): boolean { + const existing = readFileSync(path, "utf8"); + const withoutSection = existing.replace(pattern, "").trimEnd(); + const updated = `${withoutSection}${withoutSection ? "\n" : ""}${section}`; + if (updated === existing) return false; + writeFileSync(path, updated); + return true; +} + +/** Keep scratch review packets ignored while verdict audit records remain trackable. */ +export function ensureReviewStorageRules(root: string): boolean { + const path = join(root, ".gitignore"); + const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + const without2119 = existing + .replace(/# 2119:gitignore-begin\n[\s\S]*?# 2119:gitignore-end\n?/g, "") + .replace( + /# 2119: review packets are scratch; verdicts are committed audit history\n!\.2119\/\n\.2119\/reviews\/\n!\.2119\/verdicts\/\n!\.2119\/verdicts\/\*\*\n?/g, + "", + ) + .trimEnd(); + const updated = `${without2119}${without2119 ? "\n" : ""}${GITIGNORE_SECTION}`; + let changed = false; + if (updated !== existing) { + writeFileSync(path, updated); + changed = true; + } + const nested = join(root, ".2119/.gitignore"); + if (existsSync(nested)) { + changed = putLast(nested, NESTED_GITIGNORE_SECTION, /# 2119:storage-begin\n[\s\S]*?# 2119:storage-end\n?/g) || changed; + } + const verdicts = join(root, `${VERDICTS_DIR}/.gitignore`); + if (existsSync(verdicts)) { + changed = + putLast(verdicts, VERDICTS_GITIGNORE_SECTION, /# 2119:verdicts-begin\n[\s\S]*?# 2119:verdicts-end\n?/g) || + changed; + } + return changed; +} + const SAFE_ID = /^[A-Za-z0-9.-]+--[0-9a-f]{12}$/; function verdictPath(root: string, reviewId: string): string { @@ -119,6 +180,7 @@ export function writeVerdict( verdict: VerdictKind, summary: string, ): Verdict { + ensureReviewStorageRules(root); const record: Verdict = { reviewId, requirementId, diff --git a/tests/check-changed.test.ts b/tests/check-changed.test.ts index 7c1e5ab..49311ad 100644 --- a/tests/check-changed.test.ts +++ b/tests/check-changed.test.ts @@ -1,5 +1,15 @@ import { execFileSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -87,7 +97,7 @@ const TWO_REQUIREMENTS = spec(`1. The widget MUST spin. describe("check --changed (REQ-010)", () => { // 2119: REQ-010.1.1 - it("requires one local base ref, uses its merge-base, and never invokes network Git commands", () => { + it("requires one local commit-ish, uses its merge-base, and performs no network access", () => { const { root, base } = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', "specs/FIX-001-widgets.md": spec("1. The widget MUST spin.\n"), @@ -96,18 +106,35 @@ describe("check --changed (REQ-010)", () => { for (const args of [ ["check", "--changed"], ["check", "--changed", base, "extra-ref"], - ["check", "--changed", "not-a-local-ref"], + ["check", "--changed", base, "--changed", base], ]) { const result = run(root, args); expect(result.status).not.toBe(0); - expect(`${result.stdout}\n${result.stderr}`).toMatch(/changed|base.ref|commit|usage/i); + expect(result.stderr).toContain("usage: 2119 check --changed "); } + const unresolved = run(root, ["check", "--changed", "not-a-local-ref"]); + expect(unresolved.status).not.toBe(0); + expect(unresolved.stderr).toContain('Cannot resolve base ref "not-a-local-ref"'); + + for (const object of [ + git(root, "rev-parse", `${base}:specs/FIX-001-widgets.md`), + git(root, "rev-parse", `${base}:specs`), + ]) { + const result = run(root, ["check", "--changed", object]); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Cannot resolve base ref"); + expect(result.stderr).toMatch(/expected commit type|commit-ish/i); + } + + git(root, "tag", "local-base", base); + expect(run(root, ["check", "--changed", "local-base"]).status).toBe(0); + git(root, "branch", "ambiguous", base); git(root, "tag", "ambiguous", base); const ambiguous = run(root, ["check", "--changed", "ambiguous"]); expect(ambiguous.status).not.toBe(0); - expect(`${ambiguous.stdout}\n${ambiguous.stderr}`).toMatch(/ambiguous|base.ref|commit/i); + expect(ambiguous.stderr).toContain("ambiguous"); git(root, "checkout", "-b", "base-side", base); write(root, "specs/FIX-001-widgets.md", spec("1. The widget MUST spin. [manual]\n")); @@ -127,21 +154,45 @@ describe("check --changed (REQ-010)", () => { git(root, "checkout", "main"); const noMergeBase = run(root, ["check", "--changed", unrelated]); expect(noMergeBase.status).not.toBe(0); - expect(`${noMergeBase.stdout}\n${noMergeBase.stderr}`).toMatch(/merge.base|history|ancestor/i); + expect(noMergeBase.stderr).toContain("Cannot find a merge-base"); + expect(noMergeBase.stderr).toContain("histories may be unrelated"); const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); const bin = join(root, "fake-bin"); const log = join(root, "git-calls.log"); + const networkBlocker = join(root, "network-blocker.cjs"); + write( + root, + "network-blocker.cjs", + `const blocked = () => { throw new Error('network access attempted') }; +for (const name of ['node:net', 'node:http', 'node:https']) { + const module = require(name); + for (const method of ['connect', 'createConnection', 'request', 'get']) { + if (typeof module[method] === 'function') module[method] = blocked; + } +} +globalThis.fetch = blocked; +`, + ); write( root, "fake-bin/git", - `#!/bin/sh\nprintf '%s\\n' "$1" >> "$GIT_CALL_LOG"\ncase "$1" in fetch|pull|ls-remote) exit 97;; esac\nexec ${realGit} "$@"\n`, + `#!/bin/sh\nprintf '%s|%s\\n' "$GIT_NO_LAZY_FETCH" "$*" >> "$GIT_CALL_LOG"\ntest "$GIT_NO_LAZY_FETCH" = 1 || exit 98\ncase "$1" in fetch|pull|ls-remote) exit 97;; esac\nexec ${realGit} "$@"\n`, ); chmodSync(join(bin, "git"), 0o755); expect( - run(root, ["check", "--changed", base], { PATH: `${bin}:${process.env.PATH}`, GIT_CALL_LOG: log }).status, + run(root, ["check", "--changed", base], { + PATH: `${bin}:${process.env.PATH}`, + GIT_CALL_LOG: log, + NODE_OPTIONS: `--require=${networkBlocker}`, + }).status, ).toBe(0); - expect(readFileSync(log, "utf8")).not.toMatch(/^(fetch|pull|ls-remote)$/m); + const calls = readFileSync(log, "utf8"); + expect(calls.split("\n").filter(Boolean).every((line) => line.startsWith("1|"))).toBe(true); + expect(calls).not.toMatch(/\|(fetch|pull|ls-remote)( |$)/m); + expect(calls).toMatch(/\|diff .*HEAD --/); + expect(calls).toMatch(/\|diff --cached .*HEAD --/); + expect(calls).toMatch(/\|diff --name-only .*--$/m); }); // 2119: REQ-010.1.2, REQ-010.3.1 @@ -163,15 +214,19 @@ describe("check --changed (REQ-010)", () => { expect(report.violations.some((v) => v.message.includes("FIX-800.1.1"))).toBe(false); } - const { root, base } = initRepo({ - ".2119.yml": 'prefix: "FIX"\nreviews: false\n', - "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, - "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", - }); - unlinkSync(join(root, "tests/widget.test.js")); - const report = json(run(root, ["check", "--changed", base, "--json"])); - expect(report.uncoveredRequirements).toContain("FIX-001.1.1"); - expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); + for (const mode of ["committed", "staged", "unstaged"] as const) { + const { root, base } = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", + }); + unlinkSync(join(root, "tests/widget.test.js")); + if (mode === "staged" || mode === "committed") git(root, "add", "-u"); + if (mode === "committed") git(root, "commit", "-m", "delete test evidence"); + const report = json(run(root, ["check", "--changed", base, "--json"])); + expect(report.uncoveredRequirements).toContain("FIX-001.1.1"); + expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); + } }); // 2119: REQ-010.1.3 @@ -194,17 +249,42 @@ describe("check --changed (REQ-010)", () => { expect(result.status).not.toBe(0); expect(`${result.stdout}\n${result.stderr}`).toMatch(/baseline|config|yaml|parse/i); - const corruptBlob = initRepo({ + const malformedSpec = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n").replace( + "### FIX-001.1: Behavior", + "### malformed section", + ), + }); + write(malformedSpec.root, "specs/FIX-001-widgets.md", spec("1. The widget MUST spin. [manual]\n")); + const malformedSpecResult = run(malformedSpec.root, ["check", "--changed", malformedSpec.base]); + expect(malformedSpecResult.status).not.toBe(0); + expect(`${malformedSpecResult.stdout}\n${malformedSpecResult.stderr}`).toMatch(/baseline|specification|lint|content/i); + + for (const path of [".2119.yml", "specs/FIX-001-widgets.md"]) { + const corruptBlob = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + }); + const blob = git(corruptBlob.root, "rev-parse", `${corruptBlob.base}:${path}`); + const objectPath = join(corruptBlob.root, ".git/objects", blob.slice(0, 2), blob.slice(2)); + chmodSync(objectPath, 0o644); + writeFileSync(objectPath, "corrupt object\n"); + const unreadable = run(corruptBlob.root, ["check", "--changed", corruptBlob.base]); + expect(unreadable.status).not.toBe(0); + expect(`${unreadable.stdout}\n${unreadable.stderr}`).toMatch(/baseline|object|content|read|git/i); + } + + const symlinkConfig = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), }); - const blob = git(corruptBlob.root, "rev-parse", `${corruptBlob.base}:specs/FIX-001-widgets.md`); - const objectPath = join(corruptBlob.root, ".git/objects", blob.slice(0, 2), blob.slice(2)); - chmodSync(objectPath, 0o644); - writeFileSync(objectPath, "corrupt object\n"); - const unreadable = run(corruptBlob.root, ["check", "--changed", corruptBlob.base]); - expect(unreadable.status).not.toBe(0); - expect(`${unreadable.stdout}\n${unreadable.stderr}`).toMatch(/baseline|object|content|read|git/i); + write(symlinkConfig.root, "config/2119.yml", 'prefix: "FIX"\nreviews: false\n'); + unlinkSync(join(symlinkConfig.root, ".2119.yml")); + symlinkSync("config/2119.yml", join(symlinkConfig.root, ".2119.yml")); + const symlinkBase = commitCurrent(symlinkConfig.root, "use tracked config symlink"); + const symlinkReport = json(run(symlinkConfig.root, ["check", "--changed", symlinkBase, "--json"])); + expect(symlinkReport.requirementCount).toBe(0); }); // 2119: REQ-010.2.1 @@ -300,6 +380,47 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(report.uncoveredRequirements).toEqual(mode === "removed" ? ["FIX-001.1.1"] : []); expect(report.uncoveredRequirements).not.toContain("FIX-001.1.2"); } + + const nonEnforced = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\nenforce: ["MUST"]\n', + "specs/FIX-001-widgets.md": spec("1. The widget SHOULD spin.\n"), + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", + }); + write(nonEnforced.root, "tests/widget.test.js", "// annotation removed\n"); + const nonEnforcedReport = json(run(nonEnforced.root, ["check", "--changed", nonEnforced.base, "--json"])); + expect(nonEnforcedReport.requirementCount).toBe(1); + expect(nonEnforcedReport.coveredCount).toBe(0); + + const boundaryInsertion = initRepo({ + ".2119.yml": 'prefix: "FIX"\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\ntest('stop', () => {})\n", + }); + passReviews(boundaryInsertion.root, ["FIX-001.1.1"]); + const boundaryBase = commitCurrent(boundaryInsertion.root, "record one-block baseline"); + write( + boundaryInsertion.root, + "tests/widget.test.js", + "// 2119: FIX-001.1.1\ntest('spin', () => {})\n// 2119: FIX-001.1.2\ntest('stop', () => {})\n", + ); + const boundaryReport = json(run(boundaryInsertion.root, ["check", "--changed", boundaryBase, "--json"])); + expect(boundaryReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + + const multipleBlocks = initRepo({ + ".2119.yml": 'prefix: "FIX"\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin.\n"), + "tests/widget-a.test.js": "// 2119: FIX-001.1.1\ntest('spin a', () => expect(true).toBe(true))\n", + "tests/widget-b.test.js": "// 2119: FIX-001.1.1\ntest('spin b', () => expect(true).toBe(true))\n", + }); + passReviews(multipleBlocks.root, ["FIX-001.1.1"]); + const multipleBase = commitCurrent(multipleBlocks.root, "record two-block baseline"); + write( + multipleBlocks.root, + "tests/widget-a.test.js", + "// 2119: FIX-001.1.1\ntest('spin a', () => expect(false).toBe(false))\n", + ); + const multipleReport = json(run(multipleBlocks.root, ["check", "--changed", multipleBase, "--json"])); + expect(multipleReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); }); // 2119: REQ-010.2.4 @@ -351,7 +472,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(instructionsReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); }); - // 2119: REQ-010.2.5 + // 2119: REQ-010.2.5, REQ-010.3.1 it("scopes changed and malformed verdict records without hiding unassigned corruption", () => { const files = { ".2119.yml": 'prefix: "FIX"\n', @@ -380,6 +501,7 @@ test('stop', () => expect(widget.stop()).toBe(true)) write(assignedMalformed.root, assignedPath, "{ broken json"); const assignedReport = json(run(assignedMalformed.root, ["check", "--changed", assignedMalformedBase, "--json"])); expect(assignedReport.violations.some((v) => v.file.includes(assignedPath))).toBe(true); + expect(assignedReport.requirementCount).toBe(1); expect(assignedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); const added = initRepo(files); @@ -397,6 +519,33 @@ test('stop', () => expect(widget.stop()).toBe(true)) const replacedReport = json(run(replaced.root, ["check", "--changed", replacedBase, "--json"])); expect(replacedReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); expect(replacedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + + const historical = initRepo(files); + passReviews(historical.root, ["FIX-001.1.1"]); + write( + historical.root, + ".2119/verdicts/FIX-001.1.1--aaaaaaaaaaaa.json", + `${JSON.stringify({ + reviewId: "FIX-001.1.1--aaaaaaaaaaaa", + requirementId: "FIX-001.1.1", + hash: "aaaaaaaaaaaa", + verdict: "pass", + summary: "historical verdict", + timestamp: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + const historicalBase = commitCurrent(historical.root, "record historical verdict"); + write( + historical.root, + ".2119/verdicts/FIX-001.1.1--aaaaaaaaaaaa.json", + readFileSync(join(historical.root, ".2119/verdicts/FIX-001.1.1--aaaaaaaaaaaa.json"), "utf8").replace( + "historical verdict", + "edited historical verdict", + ), + ); + const historicalReport = json(run(historical.root, ["check", "--changed", historicalBase, "--json"])); + expect(historicalReport.requirementCount).toBe(0); + expect(historicalReport.violations).toEqual([]); }); // 2119: REQ-010.2.6 @@ -410,6 +559,19 @@ test('stop', () => expect(widget.stop()).toBe(true)) "FIX-001.1.1", "FIX-001.1.2", ]); + + const discovery = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\ntests: ["tests/**"]\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + "fixtures/new.test.js": "// 2119: FIX-999.1.1\n", + }); + write( + discovery.root, + ".2119.yml", + 'prefix: "FIX"\nreviews: false\ntests: ["tests/**", "fixtures/**"]\n', + ); + const discoveryReport = json(run(discovery.root, ["check", "--changed", discovery.base, "--json"])); + expect(discoveryReport.violations.some((v) => v.message.includes("FIX-999.1.1"))).toBe(true); }); // 2119: REQ-010.3.1 @@ -451,9 +613,24 @@ test('stop', () => expect(widget.stop()).toBe(true)) "tests/old.test.js": "// 2119: FIX-999.1.1\n", }); write(root, "specs/FIX-001-widgets.md", spec("2. The widget MUST stop. [manual]\n")); - const report = json(run(root, ["check", "--changed", base, "--json"])); - expect(report.violations.some((v) => v.message.includes("FIX-001.1.1"))).toBe(true); + const result = run(root, ["check", "--changed", base, "--json"]); + const report = json(result); + expect(result.status).not.toBe(0); + expect(report.violations.some((v) => v.rule === "REQ-002.2.3" && v.message.includes("FIX-001.1.1"))).toBe(true); expect(report.violations.some((v) => v.message.includes("FIX-999.1.1"))).toBe(false); + + const section = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": `${spec("1. The widget MUST spin. [manual]\n")}\n### FIX-001.2: Legacy\n\n1. Legacy mode MUST work.\n`, + "tests/legacy.test.js": "// 2119: FIX-001.2\ntest('legacy', () => {})\n", + }); + write(section.root, "specs/FIX-001-widgets.md", spec("1. The widget MUST spin. [manual]\n")); + const sectionResult = run(section.root, ["check", "--changed", section.base, "--json"]); + const sectionReport = json(sectionResult); + expect(sectionResult.status).not.toBe(0); + expect(sectionReport.violations.some((v) => v.rule === "REQ-002.2.3" && v.message.includes("FIX-001.2"))).toBe( + true, + ); }); // 2119: REQ-010.3.3 @@ -461,7 +638,8 @@ test('stop', () => expect(widget.stop()).toBe(true)) const counts = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, - "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n", + "tests/widget.test.js": + "// 2119: FIX-001.1.1\ntest('spin', () => {})\n// 2119: FIX-001.1.2\ntest('stop', () => {})\n", }); write(counts.root, "specs/FIX-001-widgets.md", TWO_REQUIREMENTS.replace("MUST spin", "MUST rotate")); const countReport = json(run(counts.root, ["check", "--changed", counts.base, "--json"])); @@ -469,6 +647,19 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(countReport.coveredCount).toBe(1); expect(countReport.uncoveredRequirements).toEqual([]); + const stale = initRepo({ + ".2119.yml": 'prefix: "FIX"\n', + "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, + "tests/widget.test.js": + "// 2119: FIX-001.1.1\ntest('spin', () => {})\n// 2119: FIX-001.1.2\ntest('stop', () => {})\n", + }); + passReviews(stale.root, ["FIX-001.1.1", "FIX-001.1.2"]); + const staleBase = commitCurrent(stale.root, "record reviewed baseline"); + write(stale.root, "specs/FIX-001-widgets.md", TWO_REQUIREMENTS.replace("MUST spin", "MUST rotate")); + const staleReport = json(run(stale.root, ["check", "--changed", staleBase, "--json"])); + expect(staleReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(staleReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + const skipped = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', "specs/FIX-001-widgets.md": spec(`1. The changed command MUST pass. [verify: false] @@ -487,7 +678,12 @@ test('stop', () => expect(widget.stop()).toBe(true)) const result = run(skipped.root, ["check", "--changed", skipped.base, "--no-verify", "--json"]); expect(result.status).toBe(0); const skippedReport = json(result); - expect(skippedReport.manualRequirements.map((r) => r.id)).toEqual(["FIX-001.1.1"]); + expect(skippedReport.manualRequirements).toEqual([ + { + id: "FIX-001.1.1", + text: "The newly changed command MUST pass. [verify skipped: --no-verify]", + }, + ]); expect(skippedReport.uncoveredRequirements).toEqual([]); }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b20f990..4e3f281 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -191,13 +191,72 @@ describe("cli end-to-end", () => { expect(result.stdout).toContain(".codex/hooks.json"); }); - // 2119: REQ-003.1.6 + // 2119: REQ-003.1.6, REQ-003.2.2 it("init gitignores .2119/reviews/ but never .2119/verdicts/", () => { const root = mkdtempSync(join(tmpdir(), "2119-ign-")); + execFileSync("git", ["init"], { cwd: root }); run(root, ["init"]); - const ignore = readFileSync(join(root, ".gitignore"), "utf8"); - expect(ignore).toContain(".2119/reviews/"); - expect(ignore).not.toContain(".2119/verdicts"); + expect(readFileSync(join(root, ".gitignore"), "utf8")).toContain(".2119/reviews/"); + mkdirSync(join(root, ".2119/reviews"), { recursive: true }); + mkdirSync(join(root, ".2119/verdicts"), { recursive: true }); + writeFileSync(join(root, ".2119/reviews/pending.md"), "scratch\n"); + writeFileSync(join(root, ".2119/verdicts/REQ-001.1.1--aaaaaaaaaaaa.json"), "{}\n"); + expect(() => execFileSync("git", ["check-ignore", "-q", ".2119/reviews/pending.md"], { cwd: root })).not.toThrow(); + expect(() => + execFileSync("git", ["check-ignore", "-q", ".2119/verdicts/REQ-001.1.1--aaaaaaaaaaaa.json"], { + cwd: root, + }), + ).toThrow(); + + writeFileSync(join(root, ".gitignore"), `${readFileSync(join(root, ".gitignore"), "utf8")}.2119/\n`); + run(root, ["init"]); + expect(() => + execFileSync("git", ["check-ignore", "-q", ".2119/verdicts/REQ-001.1.1--aaaaaaaaaaaa.json"], { + cwd: root, + }), + ).toThrow(); + + const parentIgnored = mkdtempSync(join(tmpdir(), "2119-ign-parent-")); + execFileSync("git", ["init"], { cwd: parentIgnored }); + writeFileSync(join(parentIgnored, ".gitignore"), ".2119/\n"); + mkdirSync(join(parentIgnored, ".2119"), { recursive: true }); + writeFileSync(join(parentIgnored, ".2119/.gitignore"), "verdicts/\n"); + run(parentIgnored, ["init"]); + mkdirSync(join(parentIgnored, ".2119/verdicts"), { recursive: true }); + writeFileSync(join(parentIgnored, ".2119/verdicts/REQ-001.1.1--aaaaaaaaaaaa.json"), "{}\n"); + expect(() => + execFileSync("git", ["check-ignore", "-q", ".2119/verdicts/REQ-001.1.1--aaaaaaaaaaaa.json"], { + cwd: parentIgnored, + }), + ).toThrow(); + + const dispatchRoot = fixture(); + run(dispatchRoot, ["init"]); + const pending = run(dispatchRoot, ["review"]); + expect(pending.status).toBe(1); + const reviewId = pending.stdout.match(/FIX-001\.1\.1--[0-9a-f]{12}/)?.[0]; + expect(reviewId).toBeTruthy(); + expect(readdirSync(join(dispatchRoot, ".2119/reviews"))).toEqual([`${reviewId}.md`]); + + for (const command of ["pass", "fail"]) { + const verdictRoot = fixture(); + execFileSync("git", ["init"], { cwd: verdictRoot }); + run(verdictRoot, ["init"]); + const verdictPending = run(verdictRoot, ["review"]); + const verdictId = verdictPending.stdout.match(/FIX-001\.1\.1--[0-9a-f]{12}/)?.[0]; + expect(verdictId).toBeTruthy(); + writeFileSync( + join(verdictRoot, ".gitignore"), + `${readFileSync(join(verdictRoot, ".gitignore"), "utf8")}.2119/\n`, + ); + writeFileSync(join(verdictRoot, ".2119/.gitignore"), "verdicts/\n"); + mkdirSync(join(verdictRoot, ".2119/verdicts"), { recursive: true }); + writeFileSync(join(verdictRoot, ".2119/verdicts/.gitignore"), "*.json\n"); + expect(run(verdictRoot, [command, verdictId!, "--summary", `${command} remains trackable`]).status).toBe(0); + expect(() => + execFileSync("git", ["check-ignore", "-q", `.2119/verdicts/${verdictId}.json`], { cwd: verdictRoot }), + ).toThrow(); + } }); // 2119: REQ-004.1.1, REQ-004.1.2 From c22d933469a6eb41543f9b64de09419b1ff79aae Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 14:51:33 +0000 Subject: [PATCH 7/7] fix: close round-two review findings --- .2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json | 8 -- .2119/verdicts/REQ-003.1.6--de6caae0b7d5.json | 8 ++ .2119/verdicts/REQ-003.2.2--6a2f2a3eaa08.json | 8 ++ .2119/verdicts/REQ-003.2.2--f704c217b548.json | 8 -- .2119/verdicts/REQ-010.1.1--50440336fee1.json | 8 -- .2119/verdicts/REQ-010.1.1--a0e0226f1ee4.json | 8 ++ .2119/verdicts/REQ-010.1.3--9a6de1d27590.json | 8 ++ .2119/verdicts/REQ-010.1.3--de7826b99f0e.json | 8 -- .2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json | 8 -- .2119/verdicts/REQ-010.2.5--35d4b4549f82.json | 8 ++ .2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json | 8 -- .2119/verdicts/REQ-010.3.1--df3aa1a2d7d5.json | 8 ++ .2119/verdicts/REQ-010.3.2--36dd5545b843.json | 8 ++ .2119/verdicts/REQ-010.3.2--8b8adf907340.json | 8 -- src/changed.ts | 40 +++++++- src/check.ts | 9 +- tests/check-changed.test.ts | 93 ++++++++++++++++--- tests/cli.test.ts | 14 +++ 18 files changed, 197 insertions(+), 71 deletions(-) delete mode 100644 .2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json create mode 100644 .2119/verdicts/REQ-003.1.6--de6caae0b7d5.json create mode 100644 .2119/verdicts/REQ-003.2.2--6a2f2a3eaa08.json delete mode 100644 .2119/verdicts/REQ-003.2.2--f704c217b548.json delete mode 100644 .2119/verdicts/REQ-010.1.1--50440336fee1.json create mode 100644 .2119/verdicts/REQ-010.1.1--a0e0226f1ee4.json create mode 100644 .2119/verdicts/REQ-010.1.3--9a6de1d27590.json delete mode 100644 .2119/verdicts/REQ-010.1.3--de7826b99f0e.json delete mode 100644 .2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json create mode 100644 .2119/verdicts/REQ-010.2.5--35d4b4549f82.json delete mode 100644 .2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json create mode 100644 .2119/verdicts/REQ-010.3.1--df3aa1a2d7d5.json create mode 100644 .2119/verdicts/REQ-010.3.2--36dd5545b843.json delete mode 100644 .2119/verdicts/REQ-010.3.2--8b8adf907340.json diff --git a/.2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json b/.2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json deleted file mode 100644 index 7dfa56a..0000000 --- a/.2119/verdicts/REQ-003.1.6--9f2dfb5cc3db.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-003.1.6--9f2dfb5cc3db", - "requirementId": "REQ-003.1.6", - "hash": "9f2dfb5cc3db", - "verdict": "pass", - "summary": "Real CLI test rejects review output outside .2119/reviews and verifies init's rule actually makes a pending instruction file Git-ignored.", - "timestamp": "2026-07-22T14:24:15.213Z" -} diff --git a/.2119/verdicts/REQ-003.1.6--de6caae0b7d5.json b/.2119/verdicts/REQ-003.1.6--de6caae0b7d5.json new file mode 100644 index 0000000..0f04539 --- /dev/null +++ b/.2119/verdicts/REQ-003.1.6--de6caae0b7d5.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.1.6--de6caae0b7d5", + "requirementId": "REQ-003.1.6", + "hash": "de6caae0b7d5", + "verdict": "pass", + "summary": "Review must create the identified instruction packet directly in .2119/reviews, and init is verified to add an effective .gitignore rule for files there; missing or adjacent-directory outputs fail.", + "timestamp": "2026-07-22T14:42:10.024Z" +} diff --git a/.2119/verdicts/REQ-003.2.2--6a2f2a3eaa08.json b/.2119/verdicts/REQ-003.2.2--6a2f2a3eaa08.json new file mode 100644 index 0000000..f60adac --- /dev/null +++ b/.2119/verdicts/REQ-003.2.2--6a2f2a3eaa08.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.2.2--6a2f2a3eaa08", + "requirementId": "REQ-003.2.2", + "hash": "6a2f2a3eaa08", + "verdict": "pass", + "summary": "pass/fail write parseable JSON records under .2119/verdicts, while init and verdict writes repair root and nested ignore rules without altering existing verdicts", + "timestamp": "2026-07-22T14:42:45.273Z" +} diff --git a/.2119/verdicts/REQ-003.2.2--f704c217b548.json b/.2119/verdicts/REQ-003.2.2--f704c217b548.json deleted file mode 100644 index 3044008..0000000 --- a/.2119/verdicts/REQ-003.2.2--f704c217b548.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-003.2.2--f704c217b548", - "requirementId": "REQ-003.2.2", - "hash": "f704c217b548", - "verdict": "pass", - "summary": "init and pass/fail move managed exceptions after conflicting root, .2119, and verdict-directory ignore rules; pass/fail then write direct, pretty-printed JSON verdict files under .2119/verdicts", - "timestamp": "2026-07-22T14:25:00.591Z" -} diff --git a/.2119/verdicts/REQ-010.1.1--50440336fee1.json b/.2119/verdicts/REQ-010.1.1--50440336fee1.json deleted file mode 100644 index 0a9d265..0000000 --- a/.2119/verdicts/REQ-010.1.1--50440336fee1.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.1.1--50440336fee1", - "requirementId": "REQ-010.1.1", - "hash": "50440336fee1", - "verdict": "pass", - "summary": "Covers zero/multiple refs, non-commit and unresolved or ambiguous refs, true merge-base selection, unrelated histories, and blocks observable Git/Node network access with clear failure diagnostics.", - "timestamp": "2026-07-22T14:09:35.409Z" -} diff --git a/.2119/verdicts/REQ-010.1.1--a0e0226f1ee4.json b/.2119/verdicts/REQ-010.1.1--a0e0226f1ee4.json new file mode 100644 index 0000000..1a5aa85 --- /dev/null +++ b/.2119/verdicts/REQ-010.1.1--a0e0226f1ee4.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.1--a0e0226f1ee4", + "requirementId": "REQ-010.1.1", + "hash": "a0e0226f1ee4", + "verdict": "pass", + "summary": "Covers exact arity, local commit-ish success and rejection, ambiguity, true merge-base behavior, unrelated histories, and blocks/logs network attempts for both resolved and unresolved local refs.", + "timestamp": "2026-07-22T14:50:36.997Z" +} diff --git a/.2119/verdicts/REQ-010.1.3--9a6de1d27590.json b/.2119/verdicts/REQ-010.1.3--9a6de1d27590.json new file mode 100644 index 0000000..770b67f --- /dev/null +++ b/.2119/verdicts/REQ-010.1.3--9a6de1d27590.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.1.3--9a6de1d27590", + "requirementId": "REQ-010.1.3", + "hash": "9a6de1d27590", + "verdict": "pass", + "summary": "Tests reject silent fallback with non-zero, diagnostic assertions for missing Git metadata, malformed or unreadable baseline config/spec content, and unscopable ignored dependencies, while accepting a readable config symlink boundary.", + "timestamp": "2026-07-22T14:45:08.164Z" +} diff --git a/.2119/verdicts/REQ-010.1.3--de7826b99f0e.json b/.2119/verdicts/REQ-010.1.3--de7826b99f0e.json deleted file mode 100644 index 55d865e..0000000 --- a/.2119/verdicts/REQ-010.1.3--de7826b99f0e.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.1.3--de7826b99f0e", - "requirementId": "REQ-010.1.3", - "hash": "de7826b99f0e", - "verdict": "pass", - "summary": "Missing Git metadata, malformed baseline config/specs, and corrupt baseline blobs each require a non-zero exit plus a relevant diagnostic, rejecting silent full or incomplete fallback behavior.", - "timestamp": "2026-07-22T14:13:42.226Z" -} diff --git a/.2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json b/.2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json deleted file mode 100644 index bf1f856..0000000 --- a/.2119/verdicts/REQ-010.2.5--0fe7b5cfe913.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.2.5--0fe7b5cfe913", - "requirementId": "REQ-010.2.5", - "hash": "0fe7b5cfe913", - "verdict": "pass", - "summary": "Covers added, removed, replaced, and current-path malformed verdicts; excludes a changed historical verdict and preserves changed unassignable malformed JSON as a scoped violation.", - "timestamp": "2026-07-22T14:16:33.917Z" -} diff --git a/.2119/verdicts/REQ-010.2.5--35d4b4549f82.json b/.2119/verdicts/REQ-010.2.5--35d4b4549f82.json new file mode 100644 index 0000000..42429e6 --- /dev/null +++ b/.2119/verdicts/REQ-010.2.5--35d4b4549f82.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.2.5--35d4b4549f82", + "requirementId": "REQ-010.2.5", + "hash": "35d4b4549f82", + "verdict": "pass", + "summary": "Covers added, removed, replaced, assigned-malformed, and unassigned-malformed current verdicts, while rejecting historical verdict edits as outside current scope.", + "timestamp": "2026-07-22T14:45:42.554Z" +} diff --git a/.2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json b/.2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json deleted file mode 100644 index cbe9bd7..0000000 --- a/.2119/verdicts/REQ-010.3.1--ae0c5c9016ec.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.3.1--ae0c5c9016ec", - "requirementId": "REQ-010.3.1", - "hash": "ae0c5c9016ec", - "verdict": "pass", - "summary": "End-to-end Git fixtures report changed invalid annotations, affected coverage and stale/malformed verdicts, changed-spec lint, and affected verification while excluding unchanged, sibling, and historical violations.", - "timestamp": "2026-07-22T14:19:19.677Z" -} diff --git a/.2119/verdicts/REQ-010.3.1--df3aa1a2d7d5.json b/.2119/verdicts/REQ-010.3.1--df3aa1a2d7d5.json new file mode 100644 index 0000000..96b36ee --- /dev/null +++ b/.2119/verdicts/REQ-010.3.1--df3aa1a2d7d5.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.1--df3aa1a2d7d5", + "requirementId": "REQ-010.3.1", + "hash": "df3aa1a2d7d5", + "verdict": "pass", + "summary": "Behavioral fixtures reject each scoped boundary: changed versus unchanged lint, affected versus sibling coverage/freshness/verification, and changed versus pre-existing invalid annotations.", + "timestamp": "2026-07-22T14:48:09.013Z" +} diff --git a/.2119/verdicts/REQ-010.3.2--36dd5545b843.json b/.2119/verdicts/REQ-010.3.2--36dd5545b843.json new file mode 100644 index 0000000..e40ea29 --- /dev/null +++ b/.2119/verdicts/REQ-010.3.2--36dd5545b843.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-010.3.2--36dd5545b843", + "requirementId": "REQ-010.3.2", + "hash": "36dd5545b843", + "verdict": "pass", + "summary": "Covers unchanged annotations newly dangling after requirement, section, or whole-spec deletion, rejects with REQ-002.2.3, and excludes an unrelated pre-existing dangling annotation.", + "timestamp": "2026-07-22T14:48:10.023Z" +} diff --git a/.2119/verdicts/REQ-010.3.2--8b8adf907340.json b/.2119/verdicts/REQ-010.3.2--8b8adf907340.json deleted file mode 100644 index b9ea69a..0000000 --- a/.2119/verdicts/REQ-010.3.2--8b8adf907340.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reviewId": "REQ-010.3.2--8b8adf907340", - "requirementId": "REQ-010.3.2", - "hash": "8b8adf907340", - "verdict": "pass", - "summary": "CLI integration tests keep annotations unchanged while deleting their requirement or section from a changed spec, assert REQ-002.2.3 plus nonzero exit, and exclude an unrelated unchanged dangling annotation.", - "timestamp": "2026-07-22T14:17:46.363Z" -} diff --git a/src/changed.ts b/src/changed.ts index a612184..87b90a7 100644 --- a/src/changed.ts +++ b/src/changed.ts @@ -56,6 +56,7 @@ export function buildChangedContext( } catch (err) { throw new IncrementalCheckError(`Cannot parse current configuration or content: ${(err as Error).message}`); } + assertNoIgnoredDependencies(root, current); return scopeContext(root, current, baseline, changes.paths, options.runVerify !== false); } finally { rmSync(baselineRoot, { recursive: true, force: true }); @@ -67,6 +68,7 @@ function readChangeSet(root: string, baseRef: string): ChangeSet { root, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], `resolve base ref "${baseRef}"`, + true, ).trim(); const mergeBase = gitText( root, @@ -98,7 +100,7 @@ function readChangeSet(root: string, baseRef: string): ChangeSet { const NO_NETWORK_ENV = { ...process.env, GIT_NO_LAZY_FETCH: "1" }; -function gitText(root: string, args: string[], action: string): string { +function gitText(root: string, args: string[], action: string, rejectStderr = false): string { const result = spawnSync("git", args, { cwd: root, encoding: "utf8", @@ -107,13 +109,46 @@ function gitText(root: string, args: string[], action: string): string { env: NO_NETWORK_ENV, }); const warning = result.stderr?.trim(); - if (result.error || result.status !== 0 || warning) { + if (result.error || result.status !== 0 || (rejectStderr && warning)) { const detail = warning || result.error?.message || `Git exited ${result.status ?? "without a status"}`; throw new IncrementalCheckError(`Cannot ${action}: ${detail}`); } return result.stdout; } +function assertNoIgnoredDependencies(root: string, current: CheckContext): void { + const ignored = new Set( + gitNulPaths( + root, + ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], + "identify ignored files that cannot be compared with the baseline", + ), + ); + if (ignored.size === 0) return; + + const selected = new Set(); + for (const globs of [current.config.specs, current.config.tests, current.config.sharedEvidence]) { + for (const path of matchGlobs(current.repoFiles, globs)) selected.add(path); + } + for (const requirement of allRequirements(current.specs)) { + if (requirement.coverage.instructions) selected.add(requirement.coverage.instructions); + if (requirement.coverage.globs) { + for (const path of matchGlobs(current.repoFiles, requirement.coverage.globs)) selected.add(path); + } + } + selected.add(CONFIG_FILENAME); + for (const path of ignored) { + if (path.startsWith(`${VERDICTS_DIR}/`) && path.endsWith(".json")) selected.add(path); + } + + const ambiguous = [...selected].filter((path) => ignored.has(path)).sort(); + if (ambiguous.length > 0) { + throw new IncrementalCheckError( + `Cannot compare ignored requirement dependency "${ambiguous[0]}" with the Git baseline; track it or exclude it from 2119 discovery/evidence globs.`, + ); + } +} + function gitNulPaths(root: string, args: string[], action: string): string[] { const raw = gitText(root, args, action); return raw ? raw.split("\0").filter(Boolean) : []; @@ -278,6 +313,7 @@ function scopeContext( coverViolations, reviewViolations, verifyViolations, + notInitialized: current.notInitialized && baseline.notInitialized, scopedRequirementIds: affected, }; } diff --git a/src/check.ts b/src/check.ts index d81df85..3fb7096 100644 --- a/src/check.ts +++ b/src/check.ts @@ -53,11 +53,18 @@ export function buildContext(root: string, options: BuildOptions = {}): CheckCon if (!s.docId) continue; const prior = byDocId.get(s.docId); if (prior) { + const message = `Document ID ${s.docId} is also declared by ${prior}`; lintViolations.push({ file: s.path, line: 1, rule: "REQ-001.1.7", - message: `Document ID ${s.docId} is also declared by ${prior}`, + message, + }); + lintViolations.push({ + file: prior, + line: 1, + rule: "REQ-001.1.7", + message: `Document ID ${s.docId} is also declared by ${s.path}`, }); } else { byDocId.set(s.docId, s.path); diff --git a/tests/check-changed.test.ts b/tests/check-changed.test.ts index 49311ad..07a921c 100644 --- a/tests/check-changed.test.ts +++ b/tests/check-changed.test.ts @@ -164,20 +164,31 @@ describe("check --changed (REQ-010)", () => { write( root, "network-blocker.cjs", - `const blocked = () => { throw new Error('network access attempted') }; -for (const name of ['node:net', 'node:http', 'node:https']) { + `const blocked = () => { + require('node:fs').appendFileSync(process.env.GIT_CALL_LOG, 'NETWORK|attempt\\n'); + throw new Error('network access attempted'); +}; +for (const name of ['node:net', 'node:http', 'node:https', 'node:dns', 'node:dgram']) { const module = require(name); - for (const method of ['connect', 'createConnection', 'request', 'get']) { + for (const method of ['connect', 'createConnection', 'request', 'get', 'lookup', 'resolve', 'resolve4', 'resolve6', 'createSocket']) { if (typeof module[method] === 'function') module[method] = blocked; } } +const childProcess = require('node:child_process'); +for (const method of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync']) { + const original = childProcess[method]; + childProcess[method] = function(command, ...args) { + if (command !== 'git') blocked(); + return original.call(this, command, ...args); + }; +} globalThis.fetch = blocked; `, ); write( root, "fake-bin/git", - `#!/bin/sh\nprintf '%s|%s\\n' "$GIT_NO_LAZY_FETCH" "$*" >> "$GIT_CALL_LOG"\ntest "$GIT_NO_LAZY_FETCH" = 1 || exit 98\ncase "$1" in fetch|pull|ls-remote) exit 97;; esac\nexec ${realGit} "$@"\n`, + `#!/bin/sh\nprintf '%s|%s\\n' "$GIT_NO_LAZY_FETCH" "$*" >> "$GIT_CALL_LOG"\ntest "$GIT_NO_LAZY_FETCH" = 1 || exit 98\ncase "$1" in rev-parse|merge-base|diff|ls-files|ls-tree|cat-file) ;; *) exit 97;; esac\nexec ${realGit} "$@"\n`, ); chmodSync(join(bin, "git"), 0o755); expect( @@ -187,12 +198,29 @@ globalThis.fetch = blocked; NODE_OPTIONS: `--require=${networkBlocker}`, }).status, ).toBe(0); + const missingLocal = run(root, ["check", "--changed", "not-a-local-ref"], { + PATH: `${bin}:${process.env.PATH}`, + GIT_CALL_LOG: log, + NODE_OPTIONS: `--require=${networkBlocker}`, + }); + expect(missingLocal.status).not.toBe(0); + expect(missingLocal.stderr).toContain('Cannot resolve base ref "not-a-local-ref"'); const calls = readFileSync(log, "utf8"); expect(calls.split("\n").filter(Boolean).every((line) => line.startsWith("1|"))).toBe(true); + expect(calls.split("\n").filter(Boolean).every((line) => /^1\|(rev-parse|merge-base|diff|ls-files|ls-tree|cat-file)( |$)/.test(line))).toBe(true); expect(calls).not.toMatch(/\|(fetch|pull|ls-remote)( |$)/m); expect(calls).toMatch(/\|diff .*HEAD --/); expect(calls).toMatch(/\|diff --cached .*HEAD --/); expect(calls).toMatch(/\|diff --name-only .*--$/m); + + const warningRepo = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + "data.txt": "baseline\n", + }); + git(warningRepo.root, "config", "core.autocrlf", "input"); + writeFileSync(join(warningRepo.root, "data.txt"), "changed\r\nwith crlf\r\n"); + expect(run(warningRepo.root, ["check", "--changed", warningRepo.base]).status).toBe(0); }); // 2119: REQ-010.1.2, REQ-010.3.1 @@ -285,6 +313,17 @@ globalThis.fetch = blocked; const symlinkBase = commitCurrent(symlinkConfig.root, "use tracked config symlink"); const symlinkReport = json(run(symlinkConfig.root, ["check", "--changed", symlinkBase, "--json"])); expect(symlinkReport.requirementCount).toBe(0); + + const ignoredEvidence = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\nshared_evidence: ["tests/helpers/**"]\n', + ".gitignore": "tests/helpers/cache.js\n", + "specs/FIX-001-widgets.md": spec("1. The widget MUST spin. [manual]\n"), + }); + write(ignoredEvidence.root, "tests/helpers/cache.js", "ignored cache\n"); + const ignoredResult = run(ignoredEvidence.root, ["check", "--changed", ignoredEvidence.base]); + expect(ignoredResult.status).not.toBe(0); + expect(ignoredResult.stderr).toContain("Cannot compare ignored requirement dependency"); + expect(ignoredResult.stderr).toContain("tests/helpers/cache.js"); }); // 2119: REQ-010.2.1 @@ -479,14 +518,18 @@ test('stop', () => expect(widget.stop()).toBe(true)) "specs/FIX-001-widgets.md": TWO_REQUIREMENTS, "tests/widget.test.js": "// 2119: FIX-001.1.1\ntest('spin', () => {})\n// 2119: FIX-001.1.2\ntest('stop', () => {})\n", }; - const removed = initRepo(files); - passReviews(removed.root, ["FIX-001.1.1"]); - const removedBase = commitCurrent(removed.root); - const verdict = git(removed.root, "ls-files", ".2119/verdicts/FIX-001.1.1*.json"); - unlinkSync(join(removed.root, verdict)); - const removedReport = json(run(removed.root, ["check", "--changed", removedBase, "--json"])); - expect(removedReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); - expect(removedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + for (const mode of ["committed", "staged", "unstaged"] as const) { + const removed = initRepo(files); + passReviews(removed.root, ["FIX-001.1.1"]); + const removedBase = commitCurrent(removed.root); + const verdict = git(removed.root, "ls-files", ".2119/verdicts/FIX-001.1.1*.json"); + unlinkSync(join(removed.root, verdict)); + if (mode === "staged" || mode === "committed") git(removed.root, "add", "-u"); + if (mode === "committed") git(removed.root, "commit", "-m", "remove verdict"); + const removedReport = json(run(removed.root, ["check", "--changed", removedBase, "--json"])); + expect(removedReport.staleReviews.join("\n")).toContain("FIX-001.1.1"); + expect(removedReport.staleReviews.join("\n")).not.toContain("FIX-001.1.2"); + } const malformed = initRepo(files); const malformedBase = malformed.base; @@ -586,6 +629,20 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(lintReport.violations.some((v) => v.file.includes("FIX-002-new.md"))).toBe(true); expect(lintReport.violations.some((v) => v.file.includes("FIX-001-old.md"))).toBe(false); + const duplicate = initRepo({ + ".2119.yml": 'prefix: "FIX"\nreviews: false\n', + "specs/FIX-001-z.md": spec("1. Existing behavior MUST remain. [manual]\n"), + }); + write( + duplicate.root, + "specs/FIX-001-a.md", + spec("1. Added duplicate behavior MUST remain. [manual]\n", "FIX-001", "Duplicate"), + ); + const duplicateResult = run(duplicate.root, ["check", "--changed", duplicate.base, "--json"]); + const duplicateReport = json(duplicateResult); + expect(duplicateResult.status).not.toBe(0); + expect(duplicateReport.violations.some((violation) => violation.rule === "REQ-001.1.7")).toBe(true); + const verifyCase = initRepo({ ".2119.yml": 'prefix: "FIX"\nreviews: false\n', "specs/FIX-001-widgets.md": spec(`1. First check MUST pass. [verify: false] @@ -631,6 +688,18 @@ test('stop', () => expect(widget.stop()).toBe(true)) expect(sectionReport.violations.some((v) => v.rule === "REQ-002.2.3" && v.message.includes("FIX-001.2"))).toBe( true, ); + + const onlySpec = initRepo({ + "specs/REQ-001-only.md": spec("1. The widget MUST spin.\n", "REQ-001", "Only"), + "tests/widget.test.js": "// 2119: REQ-001.1.1\ntest('spin', () => {})\n", + }); + unlinkSync(join(onlySpec.root, "specs/REQ-001-only.md")); + const onlySpecResult = run(onlySpec.root, ["check", "--changed", onlySpec.base, "--json"]); + const onlySpecReport = json(onlySpecResult); + expect(onlySpecResult.status).not.toBe(0); + expect(onlySpecReport.violations.some((v) => v.rule === "REQ-002.2.3" && v.message.includes("REQ-001.1.1"))).toBe( + true, + ); }); // 2119: REQ-010.3.3 diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4e3f281..2e81d7f 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -221,7 +221,11 @@ describe("cli end-to-end", () => { writeFileSync(join(parentIgnored, ".gitignore"), ".2119/\n"); mkdirSync(join(parentIgnored, ".2119"), { recursive: true }); writeFileSync(join(parentIgnored, ".2119/.gitignore"), "verdicts/\n"); + mkdirSync(join(parentIgnored, ".2119/verdicts"), { recursive: true }); + const preservedVerdict = join(parentIgnored, ".2119/verdicts/REQ-999.1.1--aaaaaaaaaaaa.json"); + writeFileSync(preservedVerdict, '{"existing":"verdict"}\n'); run(parentIgnored, ["init"]); + expect(readFileSync(preservedVerdict, "utf8")).toBe('{"existing":"verdict"}\n'); mkdirSync(join(parentIgnored, ".2119/verdicts"), { recursive: true }); writeFileSync(join(parentIgnored, ".2119/verdicts/REQ-001.1.1--aaaaaaaaaaaa.json"), "{}\n"); expect(() => @@ -253,6 +257,16 @@ describe("cli end-to-end", () => { mkdirSync(join(verdictRoot, ".2119/verdicts"), { recursive: true }); writeFileSync(join(verdictRoot, ".2119/verdicts/.gitignore"), "*.json\n"); expect(run(verdictRoot, [command, verdictId!, "--summary", `${command} remains trackable`]).status).toBe(0); + const verdictPath = join(verdictRoot, `.2119/verdicts/${verdictId}.json`); + const record = JSON.parse(readFileSync(verdictPath, "utf8")); + expect(record).toMatchObject({ + reviewId: verdictId, + requirementId: "FIX-001.1.1", + verdict: command, + summary: `${command} remains trackable`, + }); + expect(record.hash).toBe(verdictId!.slice(-12)); + expect(Number.isNaN(Date.parse(record.timestamp))).toBe(false); expect(() => execFileSync("git", ["check-ignore", "-q", `.2119/verdicts/${verdictId}.json`], { cwd: verdictRoot }), ).toThrow();