Files
msd-core/src/pristine-baseline.cts
Tom Boucher 0aa4202f6a fix(#4135): headline baseline coverage, opt-in strict gate, git-history widening (#4376)
* 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 <agent-4135@gsd.local>
2026-09-06 05:26:05 -04:00

183 lines
7.3 KiB
TypeScript

/**
* #4145: hash-first recovery for gsd-pristine/ baselines stored at an
* unexpected path.
*
* Some installs hold a pristine snapshot whose SHA-256 equals the hash recorded
* in backup-meta.json.pristine_hashes for a manifest-keyed file, but at a path
* that is not `path.join(pristineDir, relPath)` — e.g. stored without the
* `gsd-core/` top-level segment by an earlier release's writer. Both readers
* (verify-reapply-patches.cjs verifyFile and install.js saveLocalPatches)
* resolved strictly by that join, missed the snapshot, and reported
* ok_no_baseline / fell into regeneration that can never satisfy the recorded
* outgoing hash — a self-perpetuating gap.
*
* Hash equality with the recorded pristine_hashes entry is the same authority
* the #3657 drift guard already trusts, so a match cannot be the wrong
* baseline regardless of which release wrote it or where under gsd-pristine/
* it lives. This module owns the shared scan so the two readers cannot drift
* apart again (two private strict joins drifting is exactly the bug class).
*
* ADR-457: runtime module in src/*.cts, compiled to
* gsd-core/bin/lib/pristine-baseline.cjs.
*/
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
* install.js fileHash() records into manifests and backup-meta.json.
*/
export function sha256File(absPath: string): string {
return crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex');
}
function walkSorted(dir: string, relPrefix: string, results: string[]): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // absent or unreadable — nothing to scan here
}
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
// Never follow symlinks: gsd-pristine/ is installer-authored plain files;
// a link here is not a baseline and must not redirect the walk out of the
// tree (same posture as migration 004's walker).
if (entry.isSymbolicLink()) continue;
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walkSorted(path.join(dir, entry.name), rel, results);
} else if (entry.isFile()) {
results.push(rel);
}
}
}
/**
* Find the first file under `pristineDir` (deterministic sorted walk) whose
* SHA-256 equals `recordedHash`, as a pristineDir-relative POSIX path.
*
* - `skip` is never returned — a single POSIX relPath string or a Set of them.
* Callers pass the canonical path(s) they (or other files in the same run)
* already own, so a file sitting at a canonical path is never adopted
* through the scan. For the installer's relocation this is what prevents a
* byte-identical canonical belonging to ANOTHER modified file from being
* "rescued" away (relocated and deleted at its home path).
* - Multiple matches are byte-identical by sha-256 authority; sorted order
* makes the choice deterministic.
* - Returns null when pristineDir is absent/unreadable or nothing matches.
*/
export function findPristineByHash(
pristineDir: string,
recordedHash: string,
skip?: string | ReadonlySet<string>,
): string | null {
if (!pristineDir || typeof recordedHash !== 'string' || recordedHash.length === 0) {
return null;
}
const skipSet = skip instanceof Set ? skip : new Set(skip !== undefined ? [skip] : []);
const rels: string[] = [];
walkSorted(pristineDir, '', rels);
for (const rel of rels) {
if (skipSet.has(rel)) continue;
try {
if (sha256File(path.join(pristineDir, rel)) === recordedHash) {
return rel;
}
} catch {
// unreadable candidate — keep scanning
}
}
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');
}