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>
This commit is contained in:
Tom Boucher
2026-09-06 05:26:05 -04:00
committed by GitHub
parent c95b734145
commit 0aa4202f6a
7 changed files with 933 additions and 15 deletions

View File

@@ -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');
}