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--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--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--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-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.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.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.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--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--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.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.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.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.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/specs/REQ-010-check-changed.md b/specs/REQ-010-check-changed.md new file mode 100644 index 0000000..1b967fb --- /dev/null +++ b/specs/REQ-010-check-changed.md @@ -0,0 +1,39 @@ +# REQ-010: 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-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-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. +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-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/src/changed.ts b/src/changed.ts new file mode 100644 index 0000000..87b90a7 --- /dev/null +++ b/src/changed.ts @@ -0,0 +1,367 @@ +import { execFileSync, spawnSync } from "node:child_process"; +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 { 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"; + +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 }); + 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}`); + } + 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}`); + } + assertNoIgnoredDependencies(root, current); + 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}"`, + true, + ).trim(); + 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", "--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([...committed, ...staged, ...unstaged, ...untracked]) }; +} + +const NO_NETWORK_ENV = { ...process.env, GIT_NO_LAZY_FETCH: "1" }; + +function gitText(root: string, args: string[], action: string, rejectStderr = false): 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 || (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) : []; +} + +/** 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"], + env: NO_NETWORK_ENV, + }); + } 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; + 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"], + env: NO_NETWORK_ENV, + }); + } 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 }); + 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) { + 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 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 (configChanged) { + 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); + } + + for (const id of currentRequirements.keys()) { + 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(`${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 (configChanged || changedPaths.has(relativePath(root, v.file))) return true; + const referenced = quotedRequirementId(v.message); + return Boolean(referenced && baselineKnownIds.has(referenced) && !currentKnownIds.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 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))); + 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, + notInitialized: current.notInitialized && baseline.notInitialized, + scopedRequirementIds: affected, + }; +} + +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, + 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 verdictReview(path: string): { reviewId: string; requirementId: string } | undefined { + const name = basename(path).replace(/\.json$/, ""); + const parsed = splitReviewId(name); + return parsed ? { reviewId: name, requirementId: parsed.requirementId } : undefined; +} diff --git a/src/check.ts b/src/check.ts index a6b514b..3fb7096 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 { @@ -47,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); @@ -63,7 +76,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 +115,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 +142,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..7b94738 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) { + console.error(`check --changed: ${err.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/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/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 { diff --git a/tests/check-changed.test.ts b/tests/check-changed.test.ts new file mode 100644 index 0000000..07a921c --- /dev/null +++ b/tests/check-changed.test.ts @@ -0,0 +1,758 @@ +import { execFileSync } from "node:child_process"; +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"; + +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-010)", () => { + // 2119: REQ-010.1.1 + 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"), + }); + + for (const args of [ + ["check", "--changed"], + ["check", "--changed", base, "extra-ref"], + ["check", "--changed", base, "--changed", base], + ]) { + const result = run(root, args); + expect(result.status).not.toBe(0); + 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.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")); + 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.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 = () => { + 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', '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 rev-parse|merge-base|diff|ls-files|ls-tree|cat-file) ;; *) 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, + 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 + 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); + } + + 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 + 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 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"), + }); + 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); + + 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 + 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-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', + "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"); + } + + 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 + 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-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', + "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", + }; + 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; + 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.requirementCount).toBe(1); + 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"); + + 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 + 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", + ]); + + 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 + 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 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] +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-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', + "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 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, + ); + + 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 + 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// 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"])); + expect(countReport.requirementCount).toBe(1); + 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] +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).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..2e81d7f 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -191,13 +191,86 @@ 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"); + 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(() => + 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); + 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(); + } }); // 2119: REQ-004.1.1, REQ-004.1.2