* 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:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user