From 0aa4202f6a401bc66e2ef80d981bacb92d60dc78 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 6 Sep 2026 05:26:05 -0400 Subject: [PATCH] fix(#4135): headline baseline coverage, opt-in strict gate, git-history widening (#4376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4135): regression rows for pristine regen coverage collapse RED skeleton: src/pristine-baseline.cts exports findPristineInGit as a null-returning stub (wired into verifyFile after the #4145 orphan tier, behavior-neutral) so the git-history rows fail behaviorally, not at require time. Failing-first rows: baseline_covered aggregate on a 1-of-13 multi-version fixture, coverageHeadline typed renderer, the opt-in --min-baseline-coverage gate (exit 3, >= threshold semantics, vacuous-pass and malformed-value boundaries), git-history baseline recovery (dropped-line catch + surviving-line verify + older-commit hop), findPristineInGit unit, Step 5a workflow headline contract, and the installer-side describeBaselineCoverage honest N-of-M summary with the collapse disk-state pinned. Negative-space rows pin today: non-git ok_no_baseline posture, no-match-no-adoption, #3657 drift never rescued, canonical precedence, and no git tier without --pristine-dir. * fix(#4135): headline baseline coverage, opt-in strict gate, git-history widening The #3407 promotion rule regenerates gsd-pristine/ baselines from the INCOMING release source and keeps only candidates byte-identical with the OUTGOING recorded hash — correct in isolation, but on a multi-version jump the surviving set is precisely the files upstream did NOT change. The verifier then reports ok_no_baseline (advisory, exit 0) for everything else, and no surface distinguishes a 12-of-13-unverified green run from a fully-verified one: the human summary printed Checked/Failures only, the JSON had no coverage aggregate, and the installer's update output gave per-bucket counts without N-of-M framing. All three issue directions, none exclusive: - Report coverage prominently: --json gains an additive baseline_covered aggregate; the human summary leads with 'Baseline coverage: N of M file(s)...' on every run plus an advisory section naming each skipped file and reason; the installer prints an honest covered-of-modified line via the exported describeBaselineCoverage helper (typed return, exact contract); workflow Step 5a computes and prints the headline before any pass/fail framing. - Fail louder on low coverage: opt-in --min-baseline-coverage <0..1> exits with new documented code 3 when coverage falls below the threshold (>= semantics; empty run vacuously passes; content failure exit 1 outranks it; malformed values are usage errors, exit 2). Default posture unchanged — no_baseline stays advisory per #934. - Widen the promotion rule (its only trustworthy form): when no baseline resolves under gsd-pristine/ and a hash is recorded, the verifier now recovers the baseline from the config dir's own git history — the workflow's documented Option A — anchored by the same authority every tier trusts, exact pristine_hashes sha-256 equality. Read-only (git log/git show, windowsHide per #685), bounded (100 commits/file, 10s/subprocess), null-on-any-failure so ok_no_baseline remains the universal fallback. Tier order: canonical join -> #4145 orphan scan -> git history -> OK_NO_BASELINE; #3657 drift and canonical precedence untouched. Hash validation in saveLocalPatches is NOT relaxed — the collapse is legitimate conservatism; hiding it was the bug. Measured on the issue's shape (13 files, 12 changed upstream, 1.10->1.12): non-git installs report baseline_covered 1/13 with the headline and can gate at exit 3; a git-managed config dir with the outgoing bytes in history verifies 13/13. Review fixes folded in: workflow headline derives the unverified count from checked - baseline_covered (not the drift+no_baseline sum), and the new site-scoped allow-test-rule annotation carries its ADR-456 see-ref on the marker line. Emitted-Drift-Ack-Growth: reapply-patches.md — #4135 — +20 lines / ~1.5 KB, prose and bash only: two additive parse lines (BASELINE_COVERED, CHECKED_COUNT), a Step 5a coverage-headline block printed BEFORE any pass/fail statement (documents the opt-in --min-baseline-coverage exit-3 gate), and one Option B sentence noting the verifier's read-only git-history fallback. No step ordering, gate, tool-invocation, or dispatch shape changed; 5a's fail/drift/advisory handling is unchanged, the headline only precedes it. * chore(#4135): backfill PR number into changeset fragment --------- Co-authored-by: agent-4135 --- .changeset/clever-badgers-gather.md | 5 + bin/install.js | 35 ++ gsd-core/bin/verify-reapply-patches.cjs | 147 ++++++- gsd-core/workflows/reapply-patches.md | 22 +- src/pristine-baseline.cts | 88 ++++ tests/install-write-confinement.test.cjs | 141 +++++++ tests/reapply-verify-hunks.test.cjs | 510 ++++++++++++++++++++++- 7 files changed, 933 insertions(+), 15 deletions(-) create mode 100644 .changeset/clever-badgers-gather.md diff --git a/.changeset/clever-badgers-gather.md b/.changeset/clever-badgers-gather.md new file mode 100644 index 000000000..f5b79edcd --- /dev/null +++ b/.changeset/clever-badgers-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4376 +--- +**The reapply verifier now headlines its baseline coverage instead of reading as fully verified when most files were skipped** — after a multi-version update, /gsd-update --reapply reports 'Baseline coverage: N of M file(s)' in the verifier summary, the reapply output, and the installer's update log; on git-managed config dirs the verifier additionally recovers pristine baselines from history by recorded hash, so files upstream heavily changed are diff-verified instead of skipped; an opt-in --min-baseline-coverage <0..1> flag lets cautious operators fail the gate (exit 3) below a coverage threshold. (#4135) diff --git a/bin/install.js b/bin/install.js index 4951e8221..a578aadcc 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10217,6 +10217,29 @@ function recoverOrphanedPristine(pristineDir, relPath, recordedHash, canonicalSk } } +/** + * #4135: honest N-of-M accounting for gsd-pristine/ baselines after an + * update. The #3407 promotion rule keeps only hash-validated candidates + * (byte-identical across the version span), so a multi-version update + * legitimately ends with near-zero baselines — the collapse itself is NOT a + * bug to hide; hiding it is. This renders the covered-of-total line the + * update output prints either way, so "1 of 13" can never present like a + * fully-covered run. Pure function (typed return) so tests lock the exact + * contract without matching console prose. + */ +function describeBaselineCoverage(totalModified, covered) { + const total = Math.max(0, totalModified); + const have = Math.min(Math.max(0, covered), total); + const uncovered = total - have; + return { + complete: uncovered === 0, + uncovered, + text: uncovered === 0 + ? `gsd-pristine/ baselines cover ${have} of ${total} modified file(s)` + : `gsd-pristine/ baselines cover ${have} of ${total} modified file(s) — ${uncovered} will be reported no_baseline by the reapply verifier`, + }; +} + /** * Detect user-modified GSD files by comparing against install manifest. * Backs up modified files to gsd-local-patches/ for reapply after update. @@ -10467,6 +10490,17 @@ function saveLocalPatches(configDir, pristineCtx) { if (removed > 0) { console.log(' ' + yellow + 'i' + reset + ' Removed ' + removed + ' stale gsd-pristine/ snapshot(s); regenerated ' + regenerated + ' of those — falls back to over-broad verify heuristic for the rest'); } + // #4135: the honest N-of-M coverage line. Preserved/rescued/regenerated + // are disjoint buckets (see their accounting comments above), so their + // sum is exactly the files that ended this update with a hash-valid + // baseline. A partial result renders as an info line, not an error: + // the collapse is legitimate (#3407), hiding it was the bug. + const coverage = describeBaselineCoverage(modified.length, preserved + rescued + regenerated); + if (coverage.complete) { + console.log(' ' + green + '✓' + reset + ' ' + coverage.text); + } else { + console.log(' ' + yellow + 'i' + reset + ' ' + coverage.text); + } } } return modified; @@ -14209,6 +14243,7 @@ module.exports = { reportLocalPatches, validateHookFields, populatePristineDir, + describeBaselineCoverage, _resolveUserArtifactStagingRoot, _tryResolveUserArtifactStagingRoot, finishInstall, diff --git a/gsd-core/bin/verify-reapply-patches.cjs b/gsd-core/bin/verify-reapply-patches.cjs index fedc65039..a1a8e0046 100755 --- a/gsd-core/bin/verify-reapply-patches.cjs +++ b/gsd-core/bin/verify-reapply-patches.cjs @@ -20,11 +20,21 @@ * [--classify] # pre-merge mode: classify each backed-up * # file as incorporated / needs_merge / * # unknown (#4136); always exits 0 + * [--min-baseline-coverage <0..1>] # OPT-IN strict gate (#4135): exit 3 + * # when the fraction of files verified + * # against a resolved pristine baseline + * # (baseline_covered / checked) falls below + * # the threshold. Default: off — a + * # low-coverage run stays green per the + * # documented #934 advisory posture, but + * # its coverage is ALWAYS headline-reported. * * Exit codes (default gate mode): * 0 — every user-added line is present in the merged file (gate passes) - * 1 — at least one missing line in at least one file (gate fails) + * 1 — at least one missing line in at least one file (gate fails); outranks + * a coverage-gate failure when both apply * 2 — usage / structural error (e.g. patches dir missing) + * 3 — opt-in coverage gate failed (--min-baseline-coverage not met; #4135) * * Bug #2969: the Step 5 gate previously trusted Claude's free-text "verified: * yes/no" reporting per hunk. The LLM was filling in `yes` even when content @@ -49,13 +59,13 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // path under gsd-pristine/ (e.g. without the gsd-core/ prefix an earlier // release's writer dropped). Same module the installer's preserve-check uses, // so the two readers cannot drift apart again. -const { findPristineByHash } = require('./lib/pristine-baseline.cjs'); +const { findPristineByHash, findPristineInGit } = require('./lib/pristine-baseline.cjs'); const SIGNIFICANT_MIN_CHARS = 12; const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i; function parseArgs(argv) { - const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false }; + const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--patches-dir') opts.patchesDir = argv[++i]; @@ -63,9 +73,19 @@ function parseArgs(argv) { else if (arg === '--pristine-dir') opts.pristineDir = argv[++i]; else if (arg === '--json') opts.json = true; else if (arg === '--classify') opts.classify = true; - else if (arg === '--help' || arg === '-h') { + else if (arg === '--min-baseline-coverage') { + const raw = argv[++i]; + if (raw === undefined) { + throw new ExitError(2, '--min-baseline-coverage requires a value between 0 and 1'); + } + const value = Number(raw); + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new ExitError(2, `--min-baseline-coverage must be a number between 0 and 1, got: ${raw}`); + } + opts.minBaselineCoverage = value; + } else if (arg === '--help' || arg === '-h') { process.stdout.write( - 'usage: verify-reapply-patches.cjs --patches-dir --config-dir [--pristine-dir ] [--json] [--classify]\n', + 'usage: verify-reapply-patches.cjs --patches-dir --config-dir [--pristine-dir ] [--json] [--classify] [--min-baseline-coverage <0..1>]\n', ); throw new ExitError(0); } else { @@ -253,7 +273,7 @@ const PRISTINE_RESOLUTION = Object.freeze({ * guard. Extracted from verifyFile's inline block so --classify reasons over * the exact same baseline semantics the post-merge gate enforces. */ -function resolvePristineBaseline({ relPath, pristineDir, pristineHashes }) { +function resolvePristineBaseline({ relPath, configDir, pristineDir, pristineHashes }) { const hashKey = relPath.replace(/\\/g, '/'); const recordedHash = pristineHashes && pristineHashes[hashKey]; if (pristineDir) { @@ -314,6 +334,27 @@ function resolvePristineBaseline({ relPath, pristineDir, pristineHashes }) { // Present but not a regular file — over-broad mode is the safe side. return { resolution: PRISTINE_RESOLUTION.OVERBROAD, content: null }; } + + // Bug #4135: still nothing under gsd-pristine/ — the multi-version + // regeneration collapse (the #3407 promotion rule only keeps files + // byte-identical across the WHOLE version span, so the surviving set is + // precisely the files upstream did not change). When the config dir is + // itself a git repository, its history may hold the outgoing bytes: this + // is the workflow's documented Option A (reapply-patches.md Step 2), + // anchored by the SAME authority every other tier trusts — exact + // pristine_hashes equality. Read-only (git log / git show); any failure + // (no git, not a repo, no matching blob) degrades to ABSENT_RECORDED. + // A recovered baseline is hash-confirmed by construction, so it VALIDATES. + if (!pristinePathExists && recordedHash) { + try { + const fromGit = findPristineInGit(configDir, hashKey, recordedHash); + if (fromGit !== null) { + return { resolution: PRISTINE_RESOLUTION.VALIDATED, content: fromGit }; + } + } catch { + // git unavailable or history walk failed — ABSENT_RECORDED posture + } + } // Bug #934: recordedHash is present (modern installer) but no // hash-matching pristine exists anywhere under gsd-pristine/ (the stat // missed and the #4145 recovery found nothing). @@ -387,6 +428,47 @@ function resolveSkillsRedirect(configDir) { } } +/** + * #4135: baseline-coverage accounting. A file counts as baseline-covered + * when its diff was actually computed against a resolved pristine baseline, + * i.e. its reason is one of the baseline-diff outcomes (null = lines + * verified present, OK_NO_USER_LINES_VS_PRISTINE = no user lines versus + * the baseline, FAIL_USER_LINES_MISSING = lines missing). Everything else + * is uncovered: the silent skips (no baseline anywhere per #934/#4135, + * drift per #3657, no-pristine over-broad with nothing significant) AND + * the structural failures (installed missing / not a file / read error), + * which never reached a baseline — they are loud blocking failures, but + * they were not baseline-verified either, and this aggregate exists to + * state exactly how much of the run the gate could reason about. When + * --pristine-dir is absent the whole run is over-broad (#2998 fallback): + * no file was verified against a baseline, covered is 0 by construction. + */ +const BASELINE_VERIFIED_REASONS = new Set([ + REASON.OK_NO_USER_LINES_VS_PRISTINE, + REASON.FAIL_USER_LINES_MISSING, +]); + +function classifyBaselineCoverage(results, pristineDirProvided) { + if (!pristineDirProvided) return 0; + let covered = 0; + for (const r of results) { + if (r.reason === null || BASELINE_VERIFIED_REASONS.has(r.reason)) covered++; + } + return covered; +} + +/** + * #4135: the headline string for the human summary. A run with 12 of 13 + * files unbaselined must not present the same way as a fully-verified one — + * the N-of-M form is the issue's own framing, and the unverified tail is + * only rendered when it is non-zero. + */ +function coverageHeadline(covered, total) { + const unverified = Math.max(0, total - covered); + const base = `Baseline coverage: ${covered} of ${total} file(s) verified against a pristine baseline`; + return unverified > 0 ? `${base} (${unverified} unverified)` : base; +} + function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) { const backupPath = path.join(patchesDir, relPath); const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect); @@ -431,7 +513,7 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe // hash-first recovery + #934 absent guard) moved into resolvePristineBaseline // so the pre-merge classifier reasons over the exact same semantics. const { resolution, content: pristineContent } = - resolvePristineBaseline({ relPath, pristineDir, pristineHashes }); + resolvePristineBaseline({ relPath, configDir, pristineDir, pristineHashes }); // Bug #3657: the resolved snapshot hash-mismatches the recorded baseline. // Skip the file with a diagnostic code rather than diffing against the @@ -531,7 +613,7 @@ function classifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHas } const { resolution, content: pristineContent } = - resolvePristineBaseline({ relPath, pristineDir, pristineHashes }); + resolvePristineBaseline({ relPath, configDir, pristineDir, pristineHashes }); if (resolution === PRISTINE_RESOLUTION.DRIFTED) { result.reason = REASON.OK_PRISTINE_DRIFT_DETECTED; @@ -669,14 +751,52 @@ function main() { const no_baseline = noBaselineResults.length; const no_baseline_files = noBaselineResults.map((r) => r.file); + // Bug #4135: baseline coverage is a first-class aggregate. The collapse + // this reports is silent by design in every other surface (no_baseline is + // advisory, exit stays 0), which is exactly why it must be counted here: + // "A clean verifier exit reads as 'hunks survived'. On this run it meant + // '12 of 13 files were not checked at all.'" + const baseline_covered = classifyBaselineCoverage(results, Boolean(opts.pristineDir)); + + // Bug #4135: the opt-in strict coverage gate. Threshold semantics are >= + // (a run exactly at the threshold passes); an empty run is vacuously + // covered (nothing checked cannot be under-covered). A content failure + // (exit 1) outranks a coverage failure — it is the louder, more specific + // signal. + let coverageGateFailed = false; + if (opts.minBaselineCoverage !== null) { + const ratio = results.length === 0 ? 1 : baseline_covered / results.length; + coverageGateFailed = ratio < opts.minBaselineCoverage; + } + if (opts.json) { process.stdout.write( - JSON.stringify({ checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, results }, null, 2) + '\n', + JSON.stringify({ checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, baseline_covered, results }, null, 2) + '\n', ); } else { process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`); process.stdout.write(`Checked: ${results.length} file(s)\n`); - process.stdout.write(`Failures: ${failures.length}\n\n`); + process.stdout.write(`Failures: ${failures.length}\n`); + // #4135: the coverage headline is printed on EVERY run, before any + // per-file detail, so a near-zero-coverage green run can never render + // identically to a fully-verified one. + process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n\n`); + if (baseline_covered < results.length) { + if (!opts.pristineDir) { + process.stdout.write(`No --pristine-dir: over-broad fallback — nothing was verified against a pristine baseline (#2998).\n\n`); + } else { + const skipped = results.filter((r) => r.reason !== null && !BASELINE_VERIFIED_REASONS.has(r.reason)); + process.stdout.write(`## Files not verified against a pristine baseline\n\n`); + process.stdout.write(`Advisory (non-blocking): their user customizations may or may not have survived the merge.\n\n`); + for (const r of skipped) { + process.stdout.write(`- ${r.file} (${r.reason})\n`); + } + process.stdout.write('\n'); + } + } + if (coverageGateFailed) { + process.stdout.write(`COVERAGE GATE FAILED: baseline coverage ${(opts.minBaselineCoverage * 100).toFixed(1)}% required, ${results.length === 0 ? 100 : ((baseline_covered / results.length) * 100).toFixed(1)}% achieved (#4135 strict mode).\n\n`); + } if (failures.length > 0) { process.stdout.write(`## Files with missing user-added content\n\n`); for (const r of failures) { @@ -692,11 +812,14 @@ function main() { } } - return failures.length > 0 ? 1 : 0; + if (failures.length > 0) return 1; + if (coverageGateFailed) return 3; + return 0; } if (require.main === module) { runMain(main); } -module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect }; +module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage }; + diff --git a/gsd-core/workflows/reapply-patches.md b/gsd-core/workflows/reapply-patches.md index a36a94e79..70692e5b8 100644 --- a/gsd-core/workflows/reapply-patches.md +++ b/gsd-core/workflows/reapply-patches.md @@ -169,7 +169,7 @@ Check if a `gsd-pristine/` directory exists alongside `gsd-local-patches/`: ```bash PRISTINE_DIR="$CONFIG_DIR/gsd-pristine" ``` -If it exists, the installer saved pristine copies at install time. Use these as the baseline. Both the deterministic verifier and the installer's preserve-check resolve each file's snapshot at its canonical path first and, when that misses, by the SHA-256 recorded in `pristine_hashes` — so a snapshot stored at a legacy path (for example, without the `gsd-core/` prefix an earlier release dropped) is still found and, on the next update, relocated to its canonical path (#4145). +If it exists, the installer saved pristine copies at install time. Use these as the baseline. Both the deterministic verifier and the installer's preserve-check resolve each file's snapshot at its canonical path first and, when that misses, by the SHA-256 recorded in `pristine_hashes` — so a snapshot stored at a legacy path (for example, without the `gsd-core/` prefix an earlier release dropped) is still found and, on the next update, relocated to its canonical path (#4145). When no snapshot resolves under `gsd-pristine/`, the deterministic verifier additionally falls back to Option A's git-history walk itself (read-only, same recorded-hash match), which is what keeps Step 5a coverage non-zero on multi-version updates where the hash-validated regeneration had nothing it could promote (#4135). ### Option C: No baseline available (two-way fallback) If neither git history nor pristine snapshots are available, fall back to two-way comparison — but with **strengthened heuristics** (see Step 3). @@ -362,9 +362,27 @@ VERIFY_STATUS=$? DRIFTED_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.drifted||0))")" DRIFTED_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.drifted_files||[]).forEach(f=>process.stdout.write(f+'\n'))")" NO_BASELINE_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.no_baseline||0))")" -NO_BASELINE_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.no_baseline_files||[]).forEach(f=>process.stdout.write(f+'\n'))")" +NO_BASELINE_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.no_baseline_files||[]).forEach(f=>process.stdout.write(f+'\n'))")" +BASELINE_COVERED="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.baseline_covered||0))")" +CHECKED_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.checked||0))")" ``` +**Baseline coverage headline (#4135)** — BEFORE any pass/fail statement, print the coverage +N-of-M line. A run where 12 of 13 files were not diff-verified must never present the same way +as a fully-verified one: + +```text +Baseline coverage: {BASELINE_COVERED} of {CHECKED_COUNT} file(s) verified against a pristine +baseline ({CHECKED_COUNT - BASELINE_COVERED} unverified) +``` + +The verifier also reports this on every run (JSON field `baseline_covered`; the human summary +leads with the same line). Operators who want the gate itself to fail on low coverage (cautious +environments) pass the opt-in strict flag when invoking the verifier: +`--min-baseline-coverage ` — below the threshold the verifier exits +with code 3 (a real content failure still exits 1 and outranks it). Without the flag the +default advisory posture is unchanged. + **If `NO_BASELINE_COUNT` is greater than 0**, emit an advisory warning (non-blocking — the gate still exits 0 for these files). Do NOT halt: ```text diff --git a/src/pristine-baseline.cts b/src/pristine-baseline.cts index 1bc5c40e6..4741c95d0 100644 --- a/src/pristine-baseline.cts +++ b/src/pristine-baseline.cts @@ -24,6 +24,7 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; /** * SHA-256 hex digest of a file's raw bytes. Byte-for-byte the same digest @@ -92,3 +93,90 @@ export function findPristineByHash( } return null; } + +/** + * #4135: recover a pristine baseline from the config dir's OWN git history, + * anchored by the recorded pristine_hashes entry. + * + * The #3407 promotion rule keeps only regeneration candidates byte-identical + * across the whole version span, so a multi-version update leaves + * gsd-pristine/ holding exactly the files upstream did NOT change — near-zero + * coverage precisely where upstream churned the most. On a git-managed config + * dir the outgoing bytes often still exist in history (the workflow's + * documented Option A), and pristine_hashes is the same authority every other + * resolution tier trusts: a blob whose SHA-256 equals the recorded hash cannot + * be the wrong baseline. This is read-only recovery (git log / git show only). + * + * Guarantees: + * - Only an EXACT sha-256 match with the recorded hash is ever returned. + * - Newest-first commit order (git log default) makes multi-match resolution + * deterministic; byte-identical matches are interchangeable anyway. + * - Any failure (git absent, not a repository, empty history, unreadable + * blob, subprocess timeout) yields null — never a throw — so the caller's + * OK_NO_BASELINE posture is the universal fallback. + * - The walk is bounded: at most GIT_MAX_COMMITS_PER_FILE commits per file. + */ +const GIT_MAX_COMMITS_PER_FILE = 100; +/** Per-subprocess bound in ms — an unbounded git call is an indefinite hang. */ +const GIT_SUBPROCESS_TIMEOUT_MS = 10_000; +/** git log --format=%H output cap; 100 full shas are ~4 KB, this is headroom. */ +const GIT_MAX_BUFFER_BYTES = 16 * 1024 * 1024; + +function isCleanRelativePosixPath(relPath: string): boolean { + if (!relPath || relPath.startsWith('/') || relPath.includes('\\') || relPath.includes('\0')) { + return false; + } + const segments = relPath.split('/'); + return segments.every((seg) => seg.length > 0 && seg !== '.' && seg !== '..'); +} + +function gitExec(gitDir: string, args: string[]): string { + return execFileSync('git', args, { + cwd: gitDir, + encoding: 'utf8', + timeout: GIT_SUBPROCESS_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER_BYTES, + // windowsHide (#685): a console-window flash per git call would spam the + // user on Windows for what is a background, read-only history walk. + windowsHide: true, + // stderr is discarded: "file absent in commit" is an expected walk outcome, + // not operator-visible diagnostics. + stdio: ['ignore', 'pipe', 'ignore'], + }); +} + +export function findPristineInGit( + gitDir: string, + relPath: string, + recordedHash: string, +): string | null { + if (!gitDir || typeof relPath !== 'string' || typeof recordedHash !== 'string' + || recordedHash.length === 0 || !isCleanRelativePosixPath(relPath)) { + return null; + } + let commits: string[]; + try { + const logOutput = gitExec(gitDir, ['log', '--format=%H', '--', relPath]).trim(); + if (!logOutput) return null; + commits = logOutput.split('\n').slice(0, GIT_MAX_COMMITS_PER_FILE); + } catch { + return null; // git absent, not a repository, or the walk failed + } + for (const commit of commits) { + if (!/^[0-9a-f]{40}$/i.test(commit)) continue; + try { + const blob = gitExec(gitDir, ['show', `${commit}:${relPath}`]); + if (sha256String(blob) === recordedHash) { + return blob; + } + } catch { + // blob absent in this commit (rename/add boundary) — keep walking + } + } + return null; +} + +/** sha256 of a utf8 string, matching how manifest hashes are recorded. */ +function sha256String(content: string): string { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index b4ceaece6..138f7d97f 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -4237,3 +4237,144 @@ describe('Bug #4145: saveLocalPatches rescues hash-matching orphaned pristine sn }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded regression block — #4135 (installer side). saveLocalPatches' +// hash-validated regeneration (the #3407 promotion rule) keeps only +// candidates byte-identical across the whole version span, so a +// multi-version update leaves gsd-pristine/ holding near-zero baselines — +// and the update output never says so in N-of-M terms. The fix keeps the +// hash validation untouched (disk behavior is pinned here) and adds an +// honest coverage summary via an exported typed helper. +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:bug-4135-saveLocalPatches-coverage-line', () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); +const { cleanup } = require('./helpers.cjs'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +function sha256(content) { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} + +function countFiles(dir) { + let n = 0; + if (!fs.existsSync(dir)) return 0; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) n += countFiles(path.join(dir, entry.name)); + else if (entry.isFile()) n += 1; + } + return n; +} + +describe('Bug #4135: saveLocalPatches reports honest gsd-pristine coverage on multi-version updates', () => { + let tmpDir; + let configDir; + let newSrcDir; + let pristineDir; + + beforeEach((t) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4135-cov-')); + configDir = path.join(tmpDir, 'config'); + newSrcDir = path.join(tmpDir, 'new-release-src'); + pristineDir = path.join(configDir, 'gsd-pristine'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(newSrcDir, { recursive: true }); + t.after(() => { + cleanup(tmpDir); + }); + }); + + /** + * Seeds a multi-version-update fixture: `total` modified files, of which + * `identical` are byte-identical across the span (the only regenerable + * baselines) and the rest changed upstream in the incoming source. + */ + function seedMultiVersionFixture(total, identical) { + const manifestFiles = {}; + for (let i = 1; i <= total; i++) { + const rel = `gsd-core/workflows/flow-${String(i).padStart(2, '0')}.md`; + const pristine = + `# Flow ${i}\nStock content of the outgoing release for file ${i}.\n` + + `Second outgoing stock line ${i} with plenty of substance.\n`; + const user = pristine + `## User customisation ${i}\nCustom section on top of the outgoing release.\n`; + const incoming = (i <= identical) + ? pristine + : `# Flow ${i} (rewritten)\nIncoming release rewrote file ${i} across the span.\n`; + manifestFiles[rel] = sha256(pristine); + fs.mkdirSync(path.dirname(path.join(configDir, rel)), { recursive: true }); + fs.writeFileSync(path.join(configDir, rel), user); + fs.mkdirSync(path.dirname(path.join(newSrcDir, rel)), { recursive: true }); + fs.writeFileSync(path.join(newSrcDir, rel), incoming); + } + fs.writeFileSync( + path.join(configDir, MANIFEST_NAME), + JSON.stringify({ version: '1.10.0', timestamp: '2026-08-01T00:00:00Z', runtime: 'claude', scope: 'global', files: manifestFiles }, null, 2), + ); + return total; + } + + /** + * Core installer regression: the multi-version collapse itself is pinned + * (hash validation untouched — only the byte-identical file survives), + * and the honest N-of-M summary is available via the typed helper. + */ + test('#4135: saveLocalPatches multi-version regen keeps hash validation and reports 1-of-13 coverage', () => { + const total = seedMultiVersionFixture(13, 1); + + INSTALL.saveLocalPatches(configDir, { + packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, + }); + + const covered = countFiles(pristineDir); + assert.equal(covered, 1, + 'the collapse is pinned: only the byte-identical file survives hash-validated regeneration'); + assert.equal(typeof INSTALL.describeBaselineCoverage, 'function', + 'the coverage summary must be rendered by an exported typed helper'); + const summary = INSTALL.describeBaselineCoverage(total, covered); + assert.equal(summary.complete, false); + assert.equal(summary.uncovered, 12); + assert.equal( + summary.text, + 'gsd-pristine/ baselines cover 1 of 13 modified file(s) — 12 will be reported no_baseline by the reapply verifier', + 'partial coverage states the N-of-M collapse and its downstream effect', + ); + }); + + /** Positive boundary: every modified file covered renders a complete summary. */ + test('#4135: describeBaselineCoverage reports complete when every modified file is covered', () => { + const total = seedMultiVersionFixture(3, 3); + + INSTALL.saveLocalPatches(configDir, { + packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, + }); + + const covered = countFiles(pristineDir); + assert.equal(covered, 3, 'a fully byte-identical span regenerates every baseline'); + const summary = INSTALL.describeBaselineCoverage(total, covered); + assert.equal(summary.complete, true); + assert.equal(summary.uncovered, 0); + assert.equal( + summary.text, + 'gsd-pristine/ baselines cover 3 of 3 modified file(s)', + 'complete coverage renders without a collapse tail', + ); + }); +}); + }); +} diff --git a/tests/reapply-verify-hunks.test.cjs b/tests/reapply-verify-hunks.test.cjs index 7aa7ab47c..3cf99b827 100644 --- a/tests/reapply-verify-hunks.test.cjs +++ b/tests/reapply-verify-hunks.test.cjs @@ -305,7 +305,8 @@ describe('Bug #2969: deterministic Step 5 verification gate', () => { // Bug #3657 (Finding 1): drifted + drifted_files are additive fields added to surface // pristine-drift skips distinctly from failures. Shape-lock updated to include them. // Bug #934: no_baseline + no_baseline_files are additive fields for missing-pristine advisory. - assert.deepEqual(Object.keys(report).sort(), ['checked', 'drifted', 'drifted_files', 'failures', 'no_baseline', 'no_baseline_files', 'results']); + // Bug #4135: baseline_covered is the additive coverage aggregate (headline reporting). + assert.deepEqual(Object.keys(report).sort(), ['baseline_covered', 'checked', 'drifted', 'drifted_files', 'failures', 'no_baseline', 'no_baseline_files', 'results']); const r0 = report.results[0]; assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']); assert.equal(typeof r0.file, 'string'); @@ -1985,3 +1986,510 @@ describe('Bug #4136: workflow consumes the classifier (contract rows)', () => { }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded regression block — #4135 (gsd-pristine/ regeneration yields +// near-zero coverage on a multi-version update). The #3407 promotion rule +// only keeps regeneration candidates byte-identical across the WHOLE +// version span, so the surviving baseline set is precisely the files +// upstream did NOT change — and no reporting surface (verifier summary, +// --json, workflow Step 5a) distinguishes a 12-of-13 unbaselined green run +// from a fully-verified one. The fix reports baseline coverage as a +// headline, adds an opt-in strict coverage gate, and widens resolution +// with a git-history tier anchored by the same pristine_hashes authority. +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:bug-4135-pristine-regen-coverage', () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); +const { REASON } = require(SCRIPT); +const { findPristineInGit } = require( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'pristine-baseline.cjs'), +); +const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'reapply-patches.md'); + +// 30000ms: same class as the folded blocks above — one deterministic +// verifier pass (plus, for the git tier rows, the in-process git log/show +// walk the verifier performs) over a small mkdtemp fixture tree. +const VERIFIER_TIMEOUT_MS = 30_000; + +let tmpRoot; +let patchesDir; +let configDir; +let pristineDir; + +function sha256(content) { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} + +function writeFile(absPath, content) { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content); +} + +function writeBackupMeta(pristine_hashes) { + writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ pristine_hashes }, null, 2)); +} + +function resetFixture() { + for (const dir of [patchesDir, configDir, pristineDir]) { + cleanup(dir); + } + fs.mkdirSync(patchesDir); + fs.mkdirSync(configDir); + fs.mkdirSync(pristineDir); +} + +/** Runs the verifier with --json plus any extra argv (strict-gate flags). */ +function runVerifier(extraArgs = []) { + const r = runNode([ + SCRIPT, + '--patches-dir', patchesDir, + '--config-dir', configDir, + '--pristine-dir', pristineDir, + '--json', + ...extraArgs, + ], { timeoutMs: VERIFIER_TIMEOUT_MS }); + return { + status: r.exitCode, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + }; +} + +/** git fixture plumbing that must abort loudly when setup breaks. */ +function gitIn(dir, args) { + gitOrThrow(args, { cwd: dir }); +} +function gitCommitAll(dir, message) { + gitIn(dir, ['add', '-A']); + gitIn(dir, ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', message]); +} + +before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4135-')); + patchesDir = path.join(tmpRoot, 'patches'); + configDir = path.join(tmpRoot, 'installed'); + pristineDir = path.join(tmpRoot, 'pristine'); + resetFixture(); +}); + +after(() => { + cleanup(tmpRoot); +}); + +describe('Bug #4135: baseline coverage is reported, gateable, and widened from git history', () => { + /** + * Core regression (headline): the issue's exact scenario — 13 backed-up + * customised files on a multi-version update, 12 changed upstream, 1 + * byte-identical. gsd-pristine/ holds only the byte-identical survivor. + * The green exit must CARRY a countable coverage aggregate instead of + * presenting like a fully-verified run. + */ + test('#4135: report aggregates baseline_covered so a 12-of-13 unbaselined run is countable', () => { + resetFixture(); + const pristineHashes = {}; + for (let i = 1; i <= 13; i++) { + const rel = `gsd-core/workflows/flow-${String(i).padStart(2, '0')}.md`; + const pristine = + `# Flow ${i}\nStock content of the outgoing release for file ${i}.\n` + + `Line two of outgoing stock content ${i} with plenty of substance.\n`; + const userLine = `## User customisation ${i}\nA custom section the user added for file ${i}.\n`; + const upstream = + `# Flow ${i} (rewritten)\nUpstream rewrote file ${i} across the multi-version span.\n` + + `New stock structure with several new lines for file ${i}.\n`; + pristineHashes[rel] = sha256(pristine); + writeFile(path.join(patchesDir, rel), pristine + userLine); + writeFile(path.join(configDir, rel), upstream + userLine); + if (i === 7) { + // The single byte-identical-across-the-span survivor. + writeFile(path.join(pristineDir, rel), pristine); + } + } + writeBackupMeta(pristineHashes); + + const { status, report } = runVerifier(); + + assert.equal(status, 0, 'no-baseline files stay advisory — the default gate must stay green'); + assert.equal(report.checked, 13); + assert.equal(report.no_baseline, 12); + assert.equal(report.failures, 0); + assert.equal(report.baseline_covered, 1, + `coverage collapse must be countable; got ${JSON.stringify(report.baseline_covered)}`); + }); + + /** Human-mode headline renderer: exact-contract unit on the typed helper. */ + test('#4135: human summary headlines baseline coverage N-of-M', () => { + const script = require(SCRIPT); + assert.equal(typeof script.coverageHeadline, 'function', + 'the human summary headline must be rendered by an exported typed helper'); + assert.equal( + script.coverageHeadline(1, 13), + 'Baseline coverage: 1 of 13 file(s) verified against a pristine baseline (12 unverified)', + 'partial coverage renders N-of-M plus the unverified count'); + assert.equal( + script.coverageHeadline(13, 13), + 'Baseline coverage: 13 of 13 file(s) verified against a pristine baseline', + 'full coverage renders without an unverified tail'); + }); + + /** + * Core regression (strict gate): the same collapsed fixture under + * `--min-baseline-coverage 0.9` must FAIL LOUDLY (new opt-in exit code 3) + * while still emitting the parseable JSON report. + */ + test('#4135: opt-in --min-baseline-coverage exits 3 below threshold', () => { + resetFixture(); + const pristineHashes = {}; + for (let i = 1; i <= 13; i++) { + const rel = `gsd-core/workflows/flow-${String(i).padStart(2, '0')}.md`; + const pristine = `# Flow ${i}\nOutgoing stock line with substantial content ${i}.\n`; + const userLine = `User customisation line that survived the merge for file ${i}.\n`; + const upstream = `# Flow ${i} new\nIncoming release rewrote this file upstream ${i}.\n`; + pristineHashes[rel] = sha256(pristine); + writeFile(path.join(patchesDir, rel), pristine + userLine); + writeFile(path.join(configDir, rel), upstream + userLine); + } + writeBackupMeta(pristineHashes); + + const { status, report } = runVerifier(['--min-baseline-coverage', '0.9']); + + assert.equal(status, 3, 'a 1-of-13 run under a 0.9 threshold must exit non-zero (coverage gate)'); + assert.ok(report, 'the JSON report must still be emitted for scripting consumers'); + assert.equal(report.failures, 0, 'the failure here is coverage, not content'); + assert.equal(report.baseline_covered, 0); + }); + + /** Precedence: a real content failure outranks the coverage failure. */ + test('#4135: strict gate yields to exit 1 when real content failed', () => { + resetFixture(); + const rel = 'gsd-core/workflows/one.md'; + const pristine = 'outgoing stock line with substantial content here\n'; + const userLine = 'user customisation line that was dropped by the merge\n'; + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + userLine); + writeFile(path.join(configDir, rel), pristine); // user line dropped + writeFile(path.join(pristineDir, rel), pristine); + + const { status, report } = runVerifier(['--min-baseline-coverage', '1']); + + assert.equal(status, 1, 'content failure is the louder, more specific signal'); + assert.equal(report.failures, 1); + assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING); + }); + + /** Ratio boundary: at exactly the threshold the gate passes (>= semantics). */ + test('#4135: strict coverage gate passes at exactly the threshold', () => { + resetFixture(); + seedTwoFilesOneCovered(); + const { status } = runVerifier(['--min-baseline-coverage', '0.5']); + assert.equal(status, 0, '1 of 2 covered satisfies a 0.5 threshold (>= passes)'); + }); + + /** Ratio boundary: just below the threshold the gate fails. */ + test('#4135: strict coverage gate fails just below the threshold', () => { + resetFixture(); + seedTwoFilesOneCovered(); + const { status } = runVerifier(['--min-baseline-coverage', '0.51']); + assert.equal(status, 3, '1 of 2 covered does not satisfy a 0.51 threshold'); + }); + + /** Vacuous input: nothing checked cannot be under-covered. */ + test('#4135: strict coverage gate is vacuously satisfied when nothing is checked', () => { + resetFixture(); + const { status, report } = runVerifier(['--min-baseline-coverage', '1']); + assert.equal(status, 0); + assert.equal(report.checked, 0); + }); + + /** Arg validation: malformed thresholds are usage errors (exit 2). */ + test('#4135: malformed --min-baseline-coverage values are usage errors', () => { + resetFixture(); + for (const bad of ['1.5', '-1', 'notanumber']) { + const r = runNode([ + SCRIPT, + '--patches-dir', patchesDir, + '--config-dir', configDir, + '--pristine-dir', pristineDir, + '--json', + '--min-baseline-coverage', bad, + ], { timeoutMs: VERIFIER_TIMEOUT_MS }); + assert.equal(r.exitCode, 2, `threshold "${bad}" must be a usage error, not silently clamped`); + } + }); + + /** + * Core regression (widening): multi-version collapse — no baseline under + * gsd-pristine/, but the config dir is a git repository whose history + * holds the outgoing bytes (recorded pristine_hashes match). The + * recovered baseline must produce a REAL diff: the dropped user line is + * caught as fail_user_lines_missing, not skipped as ok_no_baseline. + */ + test('#4135: resolves the baseline from git history by recorded hash and catches a dropped user line', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-01.md'; + const pristine = + '# Flow 1\nOutgoing release stock line one with substance.\n' + + 'Outgoing release stock line two also with substance.\n'; + const userLine = 'user customisation line that the merge dropped for flow one'; + const backup = pristine + userLine + '\n'; + const upstreamRewrite = + '# Flow 1 (rewritten)\nIncoming release replaced the stock body upstream.\n'; + + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), backup); + // Config dir IS a git repo: outgoing pristine state, then the merged state. + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), pristine); + gitCommitAll(configDir, 'gsd install of the outgoing release'); + writeFile(path.join(configDir, rel), upstreamRewrite); // user line dropped + gitCommitAll(configDir, 'post-update merged state'); + + const { status, report } = runVerifier(); + + assert.equal(status, 1, 'the git-recovered baseline must catch the dropped line'); + assert.equal(report.no_baseline, 0, 'the baseline was recoverable — no skip'); + assert.equal(report.baseline_covered, 1); + const r0 = report.results[0]; + assert.equal(r0.status, 'fail'); + assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); + assert.deepEqual(r0.missing, [userLine], + 'the diff ran against the RECOVERED baseline — upstream-removed lines are not "missing"'); + }); + + /** + * Net observable: same recovery, user line PRESENT — the file is verified + * (exit 0, covered) instead of silently skipped. + */ + test('#4135: git-recovered baseline verifies surviving user lines instead of skipping', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-02.md'; + const pristine = '# Flow 2\nOutgoing stock line with substantial content.\n'; + const userLine = 'user customisation line that survived the merge for flow two'; + const upstreamRewrite = '# Flow 2 (rewritten)\nIncoming release rewrote the stock body.\n'; + + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + userLine + '\n'); + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), pristine); + gitCommitAll(configDir, 'gsd install of the outgoing release'); + writeFile(path.join(configDir, rel), upstreamRewrite + userLine + '\n'); + gitCommitAll(configDir, 'post-update merged state'); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.no_baseline, 0); + assert.equal(report.baseline_covered, 1); + assert.equal(report.results[0].status, 'ok'); + assert.notEqual(report.results[0].reason, REASON.OK_NO_BASELINE); + }); + + /** + * Version-hop boundary (N−1 / multi-commit span): history holds TWO + * upstream versions; the recorded hash is the OLDER one, so the walk must + * pass the newer (mismatching) commit and match deeper history — the + * single-hop shape of the same recovery. + */ + test('#4135: single-version hop also recovers the baseline from git history', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-03.md'; + const vA = '# Flow 3\nVersion A stock line with substantial content.\n'; + const vB = '# Flow 3\nVersion B stock line with substantial content.\n'; + const userLine = 'user customisation line present in the merged output'; + writeBackupMeta({ [rel]: sha256(vA) }); // outgoing = the OLDER commit's bytes + writeFile(path.join(patchesDir, rel), vA + userLine + '\n'); + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), vA); + gitCommitAll(configDir, 'gsd install vA'); + writeFile(path.join(configDir, rel), vB); + gitCommitAll(configDir, 'gsd update to vB'); + writeFile(path.join(configDir, rel), vB + userLine + '\n'); + gitCommitAll(configDir, 'post-update merged state'); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.no_baseline, 0, 'the walk must reach the older vA commit'); + assert.equal(report.baseline_covered, 1); + }); + + /** Negative space: no git, no pristine — the #934 advisory posture is unchanged. */ + test('#4135: non-git config dirs keep the ok_no_baseline advisory posture', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-04.md'; + const pristine = '# Flow 4\nOutgoing stock line with substantial content.\n'; + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + 'user line that survived the merge here\n'); + writeFile(path.join(configDir, rel), 'incoming rewrite\nuser line that survived the merge here\n'); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.no_baseline, 1); + assert.equal(report.results[0].reason, REASON.OK_NO_BASELINE); + assert.equal(report.baseline_covered, 0); + }); + + /** Negative space: git present but no blob matches the recorded hash. */ + test('#4135: git tier adopts nothing when no blob matches the recorded hash', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-05.md'; + const pristine = '# Flow 5\nOutgoing stock line with substantial content.\n'; + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + 'user line that survived the merge here\n'); + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), 'history only ever held user-modified bytes\n'); + gitCommitAll(configDir, 'only user state was ever committed'); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.no_baseline, 1, 'hash equality is the authority — nothing else is trusted'); + assert.equal(report.results[0].reason, REASON.OK_NO_BASELINE); + }); + + /** Drift-path lock: #3657 is never bypassed by the git tier. */ + test('#4135: canonical drift is never rescued by the git-history tier', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-06.md'; + const pristine = '# Flow 6\nOutgoing stock line with substantial content.\n'; + const drifted = '# Flow 6\nRefreshed newer-release stock line with substance.\n'; + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + 'user line that survived the merge here\n'); + writeFile(path.join(configDir, rel), drifted + 'user line that survived the merge here\n'); + writeFile(path.join(pristineDir, rel), drifted); // canonical present, hash-mismatched + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), pristine); + gitCommitAll(configDir, 'history holds the recorded-hash bytes'); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.results[0].reason, REASON.OK_PRISTINE_DRIFT_DETECTED, + 'a present-but-drifted canonical stays drift — no rescue'); + assert.equal(report.drifted, 1); + }); + + /** Precedence lock: a matching canonical pristine beats the git tier. */ + test('#4135: canonical pristine keeps precedence over the git-history tier', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-07.md'; + const pristine = '# Flow 7\nOutgoing stock line with substantial content.\n'; + const droppedLine = 'user customisation line that the merge dropped for flow seven'; + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + droppedLine + '\n'); + writeFile(path.join(configDir, rel), pristine); // dropped — caught via canonical + writeFile(path.join(pristineDir, rel), pristine); // canonical, hash-matching + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), pristine); + gitCommitAll(configDir, 'history also holds the bytes'); + + const { status, report } = runVerifier(); + + assert.equal(status, 1); + assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok(report.results[0].missing.includes(droppedLine)); + }); + + /** Invocation-shape lock: no --pristine-dir → over-broad fallback, no git tier. */ + test('#4135: no git-history resolution when --pristine-dir is not provided', () => { + resetFixture(); + const rel = 'gsd-core/workflows/flow-08.md'; + const pristine = '# Flow 8\nOutgoing stock line with substantial content.\n'; + const userLine = 'user customisation line that survived the merge for flow eight'; + writeBackupMeta({ [rel]: sha256(pristine) }); + writeFile(path.join(patchesDir, rel), pristine + userLine + '\n'); + gitIn(configDir, ['init', '-q', '-b', 'main']); + writeFile(path.join(configDir, rel), pristine); + gitCommitAll(configDir, 'history holds the recorded-hash bytes'); + // Merge kept everything — over-broad mode passes on this shape. + writeFile(path.join(configDir, rel), pristine + userLine + '\n'); + + const r = runNode([ + SCRIPT, '--patches-dir', patchesDir, '--config-dir', configDir, '--json', + ], { timeoutMs: VERIFIER_TIMEOUT_MS }); + const report = r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null; + + assert.equal(r.exitCode, 0, 'all backup lines present — over-broad fallback passes'); + assert.equal(report.results[0].reason, null, + 'without --pristine-dir the baseline logic (incl. git tier) must not run'); + assert.equal(report.baseline_covered, 0, + 'an over-broad run verifies nothing against a pristine baseline — coverage stays 0'); + }); + + /** Module unit: findPristineInGit contract on a real fixture repo. */ + test('#4135: findPristineInGit unit — match, no-match, non-repo', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4135-git-unit-')); + const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4135-plain-')); + try { + const rel = 'gsd-core/workflows/unit.md'; + const content = '# Unit\noutgoing pristine bytes for the git walk unit test\n'; + gitIn(root, ['init', '-q', '-b', 'main']); + writeFile(path.join(root, rel), content); + gitCommitAll(root, 'seed'); + writeFile(path.join(root, rel), 'later user-modified bytes committed on top\n'); + gitCommitAll(root, 'user state'); + + assert.equal(findPristineInGit(root, rel, sha256(content)), content, + 'an exact recorded-hash blob in history is returned verbatim'); + assert.equal(findPristineInGit(root, rel, sha256('bytes that never existed anywhere\n')), null, + 'no matching blob resolves to null'); + assert.equal(findPristineInGit(plain, rel, sha256(content)), null, + 'a non-repo directory resolves to null without throwing'); + } finally { + cleanup(root); + cleanup(plain); + } + }); + + /** + * Workflow contract row (source-text-is-the-product): Step 5a must make + * coverage a headline and document the opt-in strict gate. + */ + test('#4135: Step 5a headlines baseline coverage and documents the opt-in strict gate', () => { + // allow-test-rule: source-text-is-the-product — Step 5a contract text (see #4135) + const content = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + assert.ok(content.includes('Baseline coverage:'), + 'Step 5a must print a Baseline coverage headline, not bury no_baseline in parseable fields'); + assert.ok(content.includes('--min-baseline-coverage'), + 'the opt-in strict coverage gate must be documented for operators'); + }); + + /** + * Fixture: 2 files, exactly 1 baseline-covered — the minimal exact-ratio + * fixture for the threshold boundary rows. + */ + function seedTwoFilesOneCovered() { + const specs = [ + { rel: 'gsd-core/workflows/a.md', covered: true }, + { rel: 'gsd-core/workflows/b.md', covered: false }, + ]; + const pristineHashes = {}; + for (const { rel, covered } of specs) { + const pristine = `outgoing stock line with substantial content for ${rel}\n`; + const userLine = `user customisation line that survived the merge for ${rel}\n`; + pristineHashes[rel] = sha256(pristine); + writeFile(path.join(patchesDir, rel), pristine + userLine); + writeFile(path.join(configDir, rel), `incoming rewrite for ${rel}\n` + userLine); + if (covered) writeFile(path.join(pristineDir, rel), pristine); + } + writeBackupMeta(pristineHashes); + } +}); + }); +}