* test(#4145): regression rows for hash-matching prefix-less pristine baselines RED skeleton: src/pristine-baseline.cts exports findPristineByHash as a null-returning stub so the new rows fail behaviorally, not at require time. Failing-first rows: verifier resolution (no_baseline must drop to 0 when an exact-hash orphan exists), findPristineByHash unit row, and the two saveLocalPatches relocation rows. Negative-space rows pin today's behavior: missing baselines still report ok_no_baseline, mismatching orphans are never adopted or deleted, canonical precedence and the #3657 drift posture are untouched. * fix(#4145): resolve gsd-pristine/ baselines by recorded hash, relocate orphans Both pristine readers joined the manifest-keyed path strictly, so a snapshot stored without the gsd-core/ prefix (an earlier release's writer) was reported as ok_no_baseline by the verifier and pushed into regeneration by saveLocalPatches — where incoming-release candidates can never satisfy the recorded outgoing hash, leaving the correct baseline permanently unconsumed. - src/pristine-baseline.cts (new, ADR-457): shared findPristineByHash — deterministic sorted scan of gsd-pristine/, exact sha-256 equality with the recorded pristine_hashes entry (the same authority the #3657 drift guard trusts), symlink-skipping, canonical path excluded via skipRel. - verify-reapply-patches.cjs verifyFile(): on canonical miss with a recorded hash, adopt byte-identical content found anywhere under gsd-pristine/ before reporting OK_NO_BASELINE. Drift posture (#3657), canonical precedence, and the frozen REASON/report shapes are untouched; the verifier stays read-only. - install.js saveLocalPatches(): preserve-check rescue — relocate a hash-matching orphan to the canonical path (copy, hash-verify, then remove the orphan) so the state self-heals on the next update instead of repeating forever. Honest accounting: new non-overlapping rescued counter. - Workflow doc: one-sentence note on hash-based snapshot resolution. - Derived ripples: INVENTORY-MANIFEST.json regen, eslint ignore + .gitignore entries for the compiled artifact, seedFixture mkdir fix in the new rows. Emitted-Drift-Ack-Growth: reapply-patches.md — one-sentence note on hash-based pristine snapshot resolution (#4145) * fix(#4145): review follow-up — orphan scan never consumes a canonical path Adversarial review finding: with two modified files sharing byte-identical outgoing content, recoverOrphanedPristine could adopt the OTHER file's canonical pristine as its rescue source — relocating it (copy + delete at its home path) and ping-ponging the single baseline between the two files across updates. findPristineByHash's skip parameter now accepts a Set, and saveLocalPatches passes the normalized manifest keys so every canonical path is excluded; only genuine non-canonical orphans are eligible for removal (no strict-join reader ever consults those). Adds the canonical-theft regression row, a Set-skip unit assertion, and tightens the workflow doc sentence the same pass flagged as overstated. * fix(#4145): INVENTORY roster row + symlink-fixture correction Two leftovers from the ab17b7a1e5 bench run, both root-caused: - docs/INVENTORY.md roster row for cli_modules/pristine-baseline.cjs (#3762 gate: every manifest entry carries a row). - The findPristineByHash symlink unit fixture placed its symlink target INSIDE the scanned root, so the walk legitimately matched the real target file. The implementation skips the symlink itself; the fixture now keeps the target outside the scanned tree so the assertion tests what it claims. * changeset(#4145): fixed fragment for pristine baseline hash resolution --------- Co-authored-by: gsd-agent <agent@gsd.local>
This commit is contained in:
@@ -4013,3 +4013,227 @@ describe('Bug #4086: saveLocalPatches resolves skills/ manifest keys at the runt
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded regression block — #4145 (self-heal side). saveLocalPatches'
|
||||
// preserve-check resolved gsd-pristine/ entries strictly by the manifest-keyed
|
||||
// path, so a hash-matching snapshot stored without the gsd-core/ prefix was
|
||||
// pushed into regeneration from the incoming release; when upstream changed
|
||||
// the file, the candidate hash-mismatched and was discarded. The correct
|
||||
// baseline was never consumed and never pruned — the state repeated on every
|
||||
// future update. The fix rescues exact-recorded-hash orphans by relocating
|
||||
// them to the canonical path.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:bug-4145-saveLocalPatches-orphan-rescue', () => {
|
||||
'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).digest('hex');
|
||||
}
|
||||
|
||||
describe('Bug #4145: saveLocalPatches rescues hash-matching orphaned pristine snapshots', () => {
|
||||
let tmpDir;
|
||||
let configDir;
|
||||
let newSrcDir;
|
||||
let pristineDir;
|
||||
|
||||
const FILE = 'gsd-core/bin/lib/frontmatter.cjs';
|
||||
const OLD_PRISTINE = '# Old Release Content\nThis is the outgoing pristine.\n';
|
||||
const NEW_RELEASE = '# New Release Content\nUpstream rewrote this file wholesale in v2.\n';
|
||||
const USER_MODIFIED = OLD_PRISTINE + '## User addition\nUser customization here.\n';
|
||||
|
||||
beforeEach((t) => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4145-slp-'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
function seedFixture({ orphanRel, orphanContent, canonicalContent, newReleaseContent }) {
|
||||
fs.mkdirSync(path.dirname(path.join(configDir, FILE)), { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, FILE), USER_MODIFIED);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [FILE]: sha256(OLD_PRISTINE) } }, null, 2),
|
||||
);
|
||||
if (canonicalContent !== undefined) {
|
||||
fs.mkdirSync(path.dirname(path.join(pristineDir, FILE)), { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, FILE), canonicalContent);
|
||||
}
|
||||
if (orphanRel !== undefined) {
|
||||
fs.mkdirSync(path.dirname(path.join(pristineDir, orphanRel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, orphanRel), orphanContent);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(path.join(newSrcDir, FILE)), { recursive: true });
|
||||
fs.writeFileSync(path.join(newSrcDir, FILE), newReleaseContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core regression (self-heal): the hash-matching snapshot sits at
|
||||
* bin/lib/frontmatter.cjs — without the gsd-core/ segment. The new release
|
||||
* changed the file upstream, so regeneration candidates are discarded.
|
||||
* After the fix the orphan is relocated to the canonical manifest-keyed
|
||||
* path and the unprefixed copy no longer lingers.
|
||||
*/
|
||||
test('#4145: saveLocalPatches relocates a hash-matching unprefixed orphan to the canonical pristine path', () => {
|
||||
const orphanRel = 'bin/lib/frontmatter.cjs';
|
||||
seedFixture({ orphanRel, orphanContent: OLD_PRISTINE, newReleaseContent: NEW_RELEASE });
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true,
|
||||
});
|
||||
|
||||
const canonical = path.join(pristineDir, FILE);
|
||||
assert.ok(fs.existsSync(canonical), 'canonical prefixed pristine must exist after the update');
|
||||
assert.equal(sha256(fs.readFileSync(canonical, 'utf8')), sha256(OLD_PRISTINE),
|
||||
'relocated baseline must carry the outgoing (recorded-hash) bytes, not new-release bytes');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, orphanRel)), false,
|
||||
'the unprefixed orphan must not linger once relocated');
|
||||
});
|
||||
|
||||
/**
|
||||
* Stale canonical (new-release bytes) + hash-matching orphan elsewhere:
|
||||
* the stale entry is removed by the #3407 path and then rescued from the
|
||||
* orphan — the file must not end in no-baseline limbo.
|
||||
*/
|
||||
test('#4145: rescues after stale-canonical removal when a hash-matching orphan exists', () => {
|
||||
seedFixture({
|
||||
orphanRel: 'legacy/frontmatter.cjs',
|
||||
orphanContent: OLD_PRISTINE,
|
||||
canonicalContent: NEW_RELEASE, // stale — hash mismatch
|
||||
newReleaseContent: NEW_RELEASE,
|
||||
});
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true,
|
||||
});
|
||||
|
||||
const canonical = path.join(pristineDir, FILE);
|
||||
assert.ok(fs.existsSync(canonical), 'canonical pristine must exist after stale removal + rescue');
|
||||
assert.equal(sha256(fs.readFileSync(canonical, 'utf8')), sha256(OLD_PRISTINE),
|
||||
'rescued baseline must carry the recorded-hash bytes');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, 'legacy', 'frontmatter.cjs')), false,
|
||||
'the orphan must be consumed by the relocation');
|
||||
});
|
||||
|
||||
/** Negative space: no orphan, upstream changed — regeneration discard (#3407) is unchanged. */
|
||||
test('#4145: leaves the baseline absent when no orphan exists and upstream changed', () => {
|
||||
seedFixture({ newReleaseContent: NEW_RELEASE });
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true,
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, FILE)), false,
|
||||
'no hash-matching source exists — the baseline must stay absent (over-broad/no-baseline fallback)');
|
||||
});
|
||||
|
||||
/** Negative space: a mismatching orphan is neither adopted nor deleted. */
|
||||
test('#4145: never adopts nor deletes a hash-mismatching orphan', () => {
|
||||
const orphanRel = 'bin/lib/frontmatter.cjs';
|
||||
seedFixture({ orphanRel, orphanContent: NEW_RELEASE, newReleaseContent: NEW_RELEASE });
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true,
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, FILE)), false,
|
||||
'mismatching bytes must not be written to the canonical pristine path');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, orphanRel)), true,
|
||||
'pruning files the recorded hashes do not vouch for is not this fix\'s job');
|
||||
});
|
||||
|
||||
/**
|
||||
* Review finding (fix follow-up): two modified files sharing byte-identical
|
||||
* outgoing content. The orphan scan must never consume a path that is
|
||||
* another manifest file's canonical pristine path — otherwise the rescue
|
||||
* would relocate a correct canonical away from its owner and the two files
|
||||
* would ping-pong it between updates. Only genuine non-canonical orphans
|
||||
* are eligible.
|
||||
*/
|
||||
test('#4145: does not steal a byte-identical canonical belonging to another modified file', () => {
|
||||
const FILE_B = 'gsd-core/bin/lib/other-file.cjs';
|
||||
const SHARED_OLD = '# Shared Old Content\nByte-identical across two manifest files.\n';
|
||||
// A and B are both user-modified on top of byte-identical outgoing stock,
|
||||
// so both manifest records carry the SAME pristine hash.
|
||||
fs.mkdirSync(path.dirname(path.join(configDir, FILE_B)), { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, FILE), SHARED_OLD + '## User addition A\nCustom A.\n');
|
||||
fs.writeFileSync(path.join(configDir, FILE_B), SHARED_OLD + '## User addition B\nCustom B.\n');
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({
|
||||
version: '1.0.0',
|
||||
files: { [FILE]: sha256(SHARED_OLD), [FILE_B]: sha256(SHARED_OLD) },
|
||||
}, null, 2),
|
||||
);
|
||||
// A (processed first) has the ALREADY-correct canonical holding the shared
|
||||
// old bytes. B has no canonical and no orphan — B's only possible hash
|
||||
// match is A's canonical. Without the canonical skip set, B's rescue would
|
||||
// copy A's canonical to B's path and then DELETE A's canonical.
|
||||
fs.mkdirSync(path.dirname(path.join(pristineDir, FILE)), { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, FILE), SHARED_OLD);
|
||||
fs.mkdirSync(path.dirname(path.join(newSrcDir, FILE)), { recursive: true });
|
||||
fs.writeFileSync(path.join(newSrcDir, FILE), NEW_RELEASE);
|
||||
fs.mkdirSync(path.dirname(path.join(newSrcDir, FILE_B)), { recursive: true });
|
||||
fs.writeFileSync(path.join(newSrcDir, FILE_B), NEW_RELEASE);
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true,
|
||||
});
|
||||
|
||||
// A's canonical must survive untouched — never stolen to become B's.
|
||||
assert.ok(fs.existsSync(path.join(pristineDir, FILE)),
|
||||
'the byte-identical canonical of the earlier-processed file must survive');
|
||||
assert.equal(sha256(fs.readFileSync(path.join(pristineDir, FILE), 'utf8')), sha256(SHARED_OLD));
|
||||
// B gains no baseline from A's canonical (falls to regeneration instead).
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, FILE_B)), false,
|
||||
'a canonical path of another file must never be relocated as the rescue source');
|
||||
});
|
||||
|
||||
/** Preserve-path lock: an already-correct canonical stays put; the preserve loop ignores the orphan. */
|
||||
test('#4145: preserves an already-correct canonical and leaves a coexisting identical orphan in place', () => {
|
||||
const orphanRel = 'bin/lib/frontmatter.cjs';
|
||||
seedFixture({
|
||||
orphanRel,
|
||||
orphanContent: OLD_PRISTINE,
|
||||
canonicalContent: OLD_PRISTINE, // already correct
|
||||
newReleaseContent: NEW_RELEASE,
|
||||
});
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true,
|
||||
});
|
||||
|
||||
const canonical = path.join(pristineDir, FILE);
|
||||
assert.ok(fs.existsSync(canonical));
|
||||
assert.equal(sha256(fs.readFileSync(canonical, 'utf8')), sha256(OLD_PRISTINE),
|
||||
'preserved canonical must be byte-identical to before the run');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, orphanRel)), true,
|
||||
'the preserve path must not disturb unrelated files');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user