* 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>
183 lines
7.3 KiB
TypeScript
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');
|
|
}
|