* 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:
5
.changeset/noble-geese-climb.md
Normal file
5
.changeset/noble-geese-climb.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 4364
|
||||||
|
---
|
||||||
|
**`/gsd-update --reapply` no longer reports no_baseline when a hash-matching gsd-pristine/ snapshot is stored without the gsd-core/ prefix** — the verifier and the installer now resolve the baseline by the recorded SHA-256 and relocate the orphaned snapshot to its canonical path on the next update, so the correct baseline is finally consumed instead of sitting unusable forever. (#4145)
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -138,6 +138,8 @@ build/
|
|||||||
/gsd-core/bin/lib/ui-consideration-probe.cjs
|
/gsd-core/bin/lib/ui-consideration-probe.cjs
|
||||||
/gsd-core/bin/lib/config-types.cjs
|
/gsd-core/bin/lib/config-types.cjs
|
||||||
/gsd-core/bin/lib/cli-exit.cjs
|
/gsd-core/bin/lib/cli-exit.cjs
|
||||||
|
# #4145: emitted artifact of src/pristine-baseline.cts — never edited.
|
||||||
|
/gsd-core/bin/lib/pristine-baseline.cjs
|
||||||
/gsd-core/bin/lib/code-review-flags.cjs
|
/gsd-core/bin/lib/code-review-flags.cjs
|
||||||
/gsd-core/bin/lib/code-review-depth.cjs
|
/gsd-core/bin/lib/code-review-depth.cjs
|
||||||
/gsd-core/bin/lib/context-utilization.cjs
|
/gsd-core/bin/lib/context-utilization.cjs
|
||||||
|
|||||||
@@ -544,6 +544,13 @@ const {
|
|||||||
RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
|
RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
|
||||||
isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel,
|
isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel,
|
||||||
} = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
|
} = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
|
||||||
|
// #4145: shared hash-first recovery for gsd-pristine/ baselines stored at an
|
||||||
|
// unexpected path (e.g. without the gsd-core/ prefix an earlier release's
|
||||||
|
// writer dropped). Same module the reapply verifier uses, so the two readers
|
||||||
|
// cannot drift apart again.
|
||||||
|
const {
|
||||||
|
findPristineByHash: gsdFindPristineByHash,
|
||||||
|
} = require(path.join(_gsdLibDir, 'pristine-baseline.cjs'));
|
||||||
// #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
|
// #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
|
||||||
// install-model-override-resolver.cjs's readGsdRuntimeProfileResolver
|
// install-model-override-resolver.cjs's readGsdRuntimeProfileResolver
|
||||||
// (required below) — this installer no longer needs its own bindings.
|
// (required below) — this installer no longer needs its own bindings.
|
||||||
@@ -10153,6 +10160,63 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP
|
|||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #4145: recover a pristine baseline from a hash-matching orphan stored at an
|
||||||
|
* unexpected path under gsd-pristine/ (e.g. without the gsd-core/ prefix an
|
||||||
|
* earlier release's writer dropped).
|
||||||
|
*
|
||||||
|
* The preserve-check's strict join (pristineDir + manifest-keyed relPath)
|
||||||
|
* misses such snapshots, so they were pushed into regeneration from the
|
||||||
|
* incoming release — and when the file changed upstream, the candidate's hash
|
||||||
|
* could never satisfy the recorded outgoing hash, leaving the correct
|
||||||
|
* baseline permanently unconsumed and unpruned (the self-perpetuating state
|
||||||
|
* #4145 reports). Hash equality with pristine_hashes is the same authority
|
||||||
|
* the #3657 drift guard trusts, so an exact match cannot be the wrong
|
||||||
|
* baseline no matter where under gsd-pristine/ it lives.
|
||||||
|
*
|
||||||
|
* Recovery = relocation: copy the orphan to the canonical manifest-keyed path
|
||||||
|
* (hash-verified after the copy) and remove the orphan only once the
|
||||||
|
* canonical copy is verified in place. Returns true when the canonical path
|
||||||
|
* ended up holding recorded-hash bytes. Never deletes anything it cannot
|
||||||
|
* vouch for by hash, and never consumes a path that is the canonical path of
|
||||||
|
* ANY manifest file (see canonicalSkip below) — only genuine orphans, which
|
||||||
|
* no strict-join reader ever consults, are eligible for removal.
|
||||||
|
*/
|
||||||
|
function recoverOrphanedPristine(pristineDir, relPath, recordedHash, canonicalSkip) {
|
||||||
|
if (!recordedHash) return false;
|
||||||
|
let orphanRel;
|
||||||
|
try {
|
||||||
|
// canonicalSkip = the normalized manifest keys: a file already sitting at
|
||||||
|
// any canonical path can never be (re-)adopted through the scan. Without
|
||||||
|
// this, two modified files sharing byte-identical outgoing content would
|
||||||
|
// repeatedly "rescue" each other's canonical away (relocate + delete at
|
||||||
|
// its home path) in alternating updates — bytes identical, state unstable.
|
||||||
|
// It also keeps drift (#3657) / stale (#3407) territory with the caller.
|
||||||
|
orphanRel = gsdFindPristineByHash(pristineDir, recordedHash, canonicalSkip);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!orphanRel) return false;
|
||||||
|
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||||||
|
if (!outRef) return false;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(pristineDir, orphanRel), outRef.fullPath);
|
||||||
|
// Verify the relocated copy before removing the orphan — only a
|
||||||
|
// hash-matching canonical counts as recovered.
|
||||||
|
if (fileHash(outRef.fullPath) !== recordedHash) {
|
||||||
|
try { fs.rmSync(outRef.fullPath, { force: true }); } catch { /* best-effort */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Orphan removal is best-effort: the canonical copy is already verified,
|
||||||
|
// so a failed unlink leaves a harmless duplicate, never data loss.
|
||||||
|
try { fs.rmSync(path.join(pristineDir, orphanRel), { force: true }); } catch { /* best-effort */ }
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect user-modified GSD files by comparing against install manifest.
|
* Detect user-modified GSD files by comparing against install manifest.
|
||||||
* Backs up modified files to gsd-local-patches/ for reapply after update.
|
* Backs up modified files to gsd-local-patches/ for reapply after update.
|
||||||
@@ -10299,6 +10363,15 @@ function saveLocalPatches(configDir, pristineCtx) {
|
|||||||
const stalePaths = new Set();
|
const stalePaths = new Set();
|
||||||
// Track which relPaths were successfully regenerated (from either missing or stale).
|
// Track which relPaths were successfully regenerated (from either missing or stale).
|
||||||
const regeneratedPaths = new Set();
|
const regeneratedPaths = new Set();
|
||||||
|
// #4145: track which relPaths were recovered by relocating a hash-matching
|
||||||
|
// orphan (stored at an unexpected path, e.g. without the gsd-core/ prefix).
|
||||||
|
const rescuedPaths = new Set();
|
||||||
|
// #4145: the set of paths that are SOME file's canonical pristine path
|
||||||
|
// (every normalized manifest key). The orphan scan must never consume
|
||||||
|
// these — see recoverOrphanedPristine.
|
||||||
|
const canonicalSkip = new Set(
|
||||||
|
Object.keys(manifest.files || {}).map((k) => normalizeInstallRelativePath(k)).filter(Boolean),
|
||||||
|
);
|
||||||
const missingPaths = [];
|
const missingPaths = [];
|
||||||
for (const relPath of modified) {
|
for (const relPath of modified) {
|
||||||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||||||
@@ -10320,6 +10393,17 @@ function saveLocalPatches(configDir, pristineCtx) {
|
|||||||
stalePaths.add(relPath);
|
stalePaths.add(relPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #4145: canonical absent (or just removed as stale) — before falling
|
||||||
|
// into regeneration, try to recover the baseline from a hash-matching
|
||||||
|
// orphan elsewhere under gsd-pristine/ and relocate it to the canonical
|
||||||
|
// path. This is the self-heal for snapshots an earlier release stored
|
||||||
|
// without the gsd-core/ prefix: without it the state repeats forever
|
||||||
|
// (regeneration candidates from the incoming release can never satisfy
|
||||||
|
// the recorded outgoing hash when upstream changed the file).
|
||||||
|
if (recoverOrphanedPristine(pristineDir, relPath, pristineHashes[relPath], canonicalSkip)) {
|
||||||
|
rescuedPaths.add(relPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// File absent from gsd-pristine/ (or just removed above as stale):
|
// File absent from gsd-pristine/ (or just removed above as stale):
|
||||||
// attempt hash-validated regeneration from new-release source.
|
// attempt hash-validated regeneration from new-release source.
|
||||||
missingPaths.push(relPath);
|
missingPaths.push(relPath);
|
||||||
@@ -10362,13 +10446,21 @@ function saveLocalPatches(configDir, pristineCtx) {
|
|||||||
}
|
}
|
||||||
// `regenerated` = total files successfully regenerated (from missing OR stale).
|
// `regenerated` = total files successfully regenerated (from missing OR stale).
|
||||||
const regenerated = regeneratedPaths.size;
|
const regenerated = regeneratedPaths.size;
|
||||||
|
// `rescued` = files recovered by relocating a hash-matching orphan to its
|
||||||
|
// canonical path (#4145) — distinct from preservation (canonical already
|
||||||
|
// correct) and regeneration (bytes re-derived from new-release source).
|
||||||
|
const rescued = rescuedPaths.size;
|
||||||
// `removed` = stale entries that were deleted and NOT subsequently regenerated.
|
// `removed` = stale entries that were deleted and NOT subsequently regenerated.
|
||||||
// Entries that were stale-deleted but then successfully regenerated are counted
|
// Entries that were stale-deleted but then successfully regenerated are counted
|
||||||
// only in `regenerated` — the counts are non-overlapping.
|
// only in `regenerated`; stale-deleted-then-orphan-rescued entries are counted
|
||||||
const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p)).length;
|
// only in `rescued` — the counts are non-overlapping.
|
||||||
|
const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p) && !rescuedPaths.has(p)).length;
|
||||||
if (preserved > 0) {
|
if (preserved > 0) {
|
||||||
console.log(' ' + green + '✓' + reset + ' Preserved ' + cyan + 'gsd-pristine/' + reset + ' (' + preserved + ' file(s)) for three-way merge');
|
console.log(' ' + green + '✓' + reset + ' Preserved ' + cyan + 'gsd-pristine/' + reset + ' (' + preserved + ' file(s)) for three-way merge');
|
||||||
}
|
}
|
||||||
|
if (rescued > 0) {
|
||||||
|
console.log(' ' + green + '✓' + reset + ' Recovered ' + cyan + 'gsd-pristine/' + reset + ' (' + rescued + ' file(s)) by recorded hash from a legacy-path snapshot and relocated them (#4145)');
|
||||||
|
}
|
||||||
if (regenerated > 0) {
|
if (regenerated > 0) {
|
||||||
console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'gsd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source');
|
console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'gsd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -475,6 +475,7 @@
|
|||||||
"planning-scope.cjs",
|
"planning-scope.cjs",
|
||||||
"planning-snapshot.cjs",
|
"planning-snapshot.cjs",
|
||||||
"planning-workspace.cjs",
|
"planning-workspace.cjs",
|
||||||
|
"pristine-baseline.cjs",
|
||||||
"probe-core.cjs",
|
"probe-core.cjs",
|
||||||
"profile-output.cjs",
|
"profile-output.cjs",
|
||||||
"profile-pipeline-command-router.cjs",
|
"profile-pipeline-command-router.cjs",
|
||||||
|
|||||||
@@ -603,6 +603,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
|||||||
| `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) |
|
| `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) |
|
||||||
| `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) |
|
| `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) |
|
||||||
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |
|
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |
|
||||||
|
| `pristine-baseline.cjs` | Hash-first recovery for `gsd-pristine/` baselines stored at an unexpected path (compiled from `src/pristine-baseline.cts`, gitignored; #4145) — `findPristineByHash(pristineDir, recordedHash, skip?)` walks `gsd-pristine/` in deterministic sorted order, skips symlinks, and returns the first file whose SHA-256 equals the recorded `backup-meta.json.pristine_hashes` entry (the same authority the #3657 drift guard trusts); the `skip` set excludes canonical manifest-keyed paths so a relocation never consumes another file's canonical baseline. Shared by `verify-reapply-patches.cjs`'s `verifyFile` (read-only adoption when the strict join misses) and `install.js`'s `saveLocalPatches` (orphan relocation self-heal) so the two readers cannot drift apart again |
|
||||||
| `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) |
|
| `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) |
|
||||||
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
|
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
|
||||||
| `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover |
|
| `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover |
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export default tseslint.config(
|
|||||||
'gsd-core/bin/lib/prohibition-enforcement.cjs',
|
'gsd-core/bin/lib/prohibition-enforcement.cjs',
|
||||||
// #3770: tsc-generated runtime artifact — lint the src/tdd-red-evidence.cts source.
|
// #3770: tsc-generated runtime artifact — lint the src/tdd-red-evidence.cts source.
|
||||||
'gsd-core/bin/lib/tdd-red-evidence.cjs',
|
'gsd-core/bin/lib/tdd-red-evidence.cjs',
|
||||||
|
// #4145: tsc-generated runtime artifact — lint the src/pristine-baseline.cts source.
|
||||||
|
'gsd-core/bin/lib/pristine-baseline.cjs',
|
||||||
'gsd-core/bin/lib/ui-consideration-probe.cjs',
|
'gsd-core/bin/lib/ui-consideration-probe.cjs',
|
||||||
'gsd-core/bin/lib/code-review-flags.cjs',
|
'gsd-core/bin/lib/code-review-flags.cjs',
|
||||||
'gsd-core/bin/lib/code-review-depth.cjs',
|
'gsd-core/bin/lib/code-review-depth.cjs',
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||||
|
// #4145: shared hash-first recovery for baselines stored at an unexpected
|
||||||
|
// path under gsd-pristine/ (e.g. without the gsd-core/ prefix an earlier
|
||||||
|
// release's writer dropped). Same module the installer's preserve-check uses,
|
||||||
|
// so the two readers cannot drift apart again.
|
||||||
|
const { findPristineByHash } = require('./lib/pristine-baseline.cjs');
|
||||||
|
|
||||||
const SIGNIFICANT_MIN_CHARS = 12;
|
const SIGNIFICANT_MIN_CHARS = 12;
|
||||||
const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
|
const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
|
||||||
@@ -344,8 +349,29 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
|
|||||||
// pristinePathExists stays false.
|
// pristinePathExists stays false.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bug #934: recordedHash is present (modern installer) but the pristine
|
// Bug #4145: the canonical join missed, but the recorded hash is the
|
||||||
// path does not exist on disk at all (stat threw above). This means
|
// baseline authority the #3657 drift guard already trusts. Before
|
||||||
|
// reporting OK_NO_BASELINE, scan gsd-pristine/ for byte-identical content
|
||||||
|
// (an earlier release may have stored the snapshot without the gsd-core/
|
||||||
|
// prefix). An exact sha-256 match cannot be the wrong baseline, and
|
||||||
|
// gsd-pristine/ holds only backed-up files, so the scan is small. The
|
||||||
|
// canonical path itself is excluded — a mismatching file at the joined
|
||||||
|
// path is drift (#3657), never re-adopted through the scan.
|
||||||
|
if (!pristinePathExists && recordedHash) {
|
||||||
|
try {
|
||||||
|
const recoveredRel = findPristineByHash(pristineDir, recordedHash, hashKey);
|
||||||
|
if (recoveredRel) {
|
||||||
|
pristineContent = fs.readFileSync(path.join(pristineDir, recoveredRel), 'utf8');
|
||||||
|
pristinePathExists = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// scan or read failure — fall through to the OK_NO_BASELINE posture
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug #934: recordedHash is present (modern installer) but no hash-matching
|
||||||
|
// pristine exists anywhere under gsd-pristine/ (stat threw above AND the
|
||||||
|
// #4145 scan found nothing). This means
|
||||||
// saveLocalPatches recorded a hash but could not write the corresponding
|
// saveLocalPatches recorded a hash but could not write the corresponding
|
||||||
// gsd-pristine/ file (the only candidate was discarded because it was from
|
// gsd-pristine/ file (the only candidate was discarded because it was from
|
||||||
// a newer release). Falling to over-broad mode here would treat every
|
// a newer release). Falling to over-broad mode here would treat every
|
||||||
@@ -353,8 +379,9 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
|
|||||||
// false FAIL_USER_LINES_MISSING for each upstream removal. Since we
|
// false FAIL_USER_LINES_MISSING for each upstream removal. Since we
|
||||||
// cannot reason correctly without a baseline, the safe answer is advisory/
|
// cannot reason correctly without a baseline, the safe answer is advisory/
|
||||||
// non-blocking: return OK_NO_BASELINE and let the caller decide.
|
// non-blocking: return OK_NO_BASELINE and let the caller decide.
|
||||||
// NOTE: this guard fires ONLY when stat threw (path absent), not when the
|
// NOTE: this guard fires ONLY when the baseline path is absent (stat threw
|
||||||
// path is present but non-file — in that case over-broad mode is safer.
|
// and nothing matched by hash), not when the path is present but non-file —
|
||||||
|
// in that case over-broad mode is safer.
|
||||||
if (!pristinePathExists && recordedHash) {
|
if (!pristinePathExists && recordedHash) {
|
||||||
result.reason = REASON.OK_NO_BASELINE;
|
result.reason = REASON.OK_NO_BASELINE;
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ Check if a `gsd-pristine/` directory exists alongside `gsd-local-patches/`:
|
|||||||
```bash
|
```bash
|
||||||
PRISTINE_DIR="$CONFIG_DIR/gsd-pristine"
|
PRISTINE_DIR="$CONFIG_DIR/gsd-pristine"
|
||||||
```
|
```
|
||||||
If it exists, the installer saved pristine copies at install time. Use these as the baseline.
|
If it exists, the installer saved pristine copies at install time. Use these as the baseline. Both the deterministic verifier and the installer's preserve-check resolve each file's snapshot at its canonical path first and, when that misses, by the SHA-256 recorded in `pristine_hashes` — so a snapshot stored at a legacy path (for example, without the `gsd-core/` prefix an earlier release dropped) is still found and, on the next update, relocated to its canonical path (#4145).
|
||||||
|
|
||||||
### Option C: No baseline available (two-way fallback)
|
### Option C: No baseline available (two-way fallback)
|
||||||
If neither git history nor pristine snapshots are available, fall back to two-way comparison — but with **strengthened heuristics** (see Step 3).
|
If neither git history nor pristine snapshots are available, fall back to two-way comparison — but with **strengthened heuristics** (see Step 3).
|
||||||
|
|||||||
94
src/pristine-baseline.cts
Normal file
94
src/pristine-baseline.cts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* #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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1170,3 +1170,272 @@ describe('Bug #4086: verifyFile resolves skills entries at the runtime skills ro
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded regression block — #4145 (a hash-matching gsd-pristine/ baseline
|
||||||
|
// stored without the gsd-core/ prefix is never resolved). verifyFile() joined
|
||||||
|
// the manifest-keyed path strictly; when stat missed and a hash was recorded
|
||||||
|
// it reported OK_NO_BASELINE even though byte-correct content sat elsewhere
|
||||||
|
// under gsd-pristine/. The fix consults the recorded pristine_hashes — the
|
||||||
|
// same authority the #3657 drift guard trusts — and adopts an exact-hash
|
||||||
|
// match found anywhere in the tree.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe('folded:bug-4145-pristine-prefix-resolution', () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
process.env.GSD_TEST_MODE = '1';
|
||||||
|
|
||||||
|
const { test, describe, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const { cleanup } = require('./helpers.cjs');
|
||||||
|
const { runNode } = require('./helpers/process-seam.cjs');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs');
|
||||||
|
const { REASON } = require(SCRIPT);
|
||||||
|
const { findPristineByHash } = require(
|
||||||
|
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'pristine-baseline.cjs'),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tmpRoot;
|
||||||
|
let patchesDir;
|
||||||
|
let configDir;
|
||||||
|
let pristineDir;
|
||||||
|
|
||||||
|
function sha256(content) {
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(absPath, content) {
|
||||||
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||||
|
fs.writeFileSync(absPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBackupMeta(pristine_hashes) {
|
||||||
|
writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ pristine_hashes }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFixture() {
|
||||||
|
for (const dir of [patchesDir, configDir, pristineDir]) {
|
||||||
|
cleanup(dir);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(patchesDir);
|
||||||
|
fs.mkdirSync(configDir);
|
||||||
|
fs.mkdirSync(pristineDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runVerifier() {
|
||||||
|
const r = runNode([
|
||||||
|
SCRIPT,
|
||||||
|
'--patches-dir', patchesDir,
|
||||||
|
'--config-dir', configDir,
|
||||||
|
'--pristine-dir', pristineDir,
|
||||||
|
'--json',
|
||||||
|
], { timeoutMs: 30_000 });
|
||||||
|
return {
|
||||||
|
status: r.exitCode,
|
||||||
|
report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4145-'));
|
||||||
|
patchesDir = path.join(tmpRoot, 'patches');
|
||||||
|
configDir = path.join(tmpRoot, 'installed');
|
||||||
|
pristineDir = path.join(tmpRoot, 'pristine');
|
||||||
|
resetFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cleanup(tmpRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bug #4145: hash-matching prefix-less pristine baseline is resolved', () => {
|
||||||
|
/**
|
||||||
|
* Core regression. The manifest key is `gsd-core/bin/lib/frontmatter.cjs`
|
||||||
|
* but the snapshot sits at `bin/lib/frontmatter.cjs` — one segment away
|
||||||
|
* from the joined path. Its SHA-256 equals the recorded pristine_hashes
|
||||||
|
* entry. The upstream release replaced the file wholesale and only the
|
||||||
|
* user's line survived the merge, so a verifier that recovered the
|
||||||
|
* baseline computes exactly one user-added line (present → exit 0,
|
||||||
|
* no_baseline 0), while the pre-fix run reported ok_no_baseline.
|
||||||
|
*/
|
||||||
|
test('#4145: resolves a hash-matching prefix-less pristine baseline instead of reporting ok_no_baseline', () => {
|
||||||
|
resetFixture();
|
||||||
|
const FILE = 'gsd-core/bin/lib/frontmatter.cjs';
|
||||||
|
const OLD_PRISTINE =
|
||||||
|
'outgoing pristine stock line one with substantial content\n' +
|
||||||
|
'outgoing pristine stock line two also substantial content\n';
|
||||||
|
const USER_LINE = 'user customization line that must survive the reapply merge';
|
||||||
|
const backupContent = OLD_PRISTINE + USER_LINE + '\n';
|
||||||
|
const installedContent =
|
||||||
|
'incoming upstream replacement line with substantial content\n' + USER_LINE + '\n';
|
||||||
|
|
||||||
|
writeBackupMeta({ [FILE]: sha256(OLD_PRISTINE) });
|
||||||
|
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||||
|
writeFile(path.join(configDir, FILE), installedContent);
|
||||||
|
// The orphan: same bytes, stored WITHOUT the gsd-core/ prefix.
|
||||||
|
writeFile(path.join(pristineDir, 'bin', 'lib', 'frontmatter.cjs'), OLD_PRISTINE);
|
||||||
|
|
||||||
|
const { status, report } = runVerifier();
|
||||||
|
|
||||||
|
assert.equal(status, 0, `expected exit 0; report=${JSON.stringify(report)}`);
|
||||||
|
assert.equal(report.no_baseline, 0, 'a hash-matching baseline was on disk — it must be resolved');
|
||||||
|
assert.deepEqual(report.no_baseline_files, []);
|
||||||
|
assert.equal(report.failures, 0);
|
||||||
|
const r0 = report.results[0];
|
||||||
|
assert.equal(r0.status, 'ok');
|
||||||
|
assert.notEqual(r0.reason, REASON.OK_NO_BASELINE);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Negative space: nothing anywhere under gsd-pristine/ matches the record. */
|
||||||
|
test('#4145: still reports ok_no_baseline when the recorded hash matches nothing under gsd-pristine', () => {
|
||||||
|
resetFixture();
|
||||||
|
const FILE = 'gsd-core/bin/lib/frontmatter.cjs';
|
||||||
|
const backupContent =
|
||||||
|
'upstream line present in the backup of the outgoing release\n' +
|
||||||
|
'model: sonnet — the user customisation line in the backup file\n';
|
||||||
|
const installedContent =
|
||||||
|
'replacement upstream line in the newer release version\n' +
|
||||||
|
'model: sonnet — the user customisation line in the backup file\n';
|
||||||
|
|
||||||
|
writeBackupMeta({ [FILE]: 'deadbeef00000000000000000000000000000000000000000000000000000001' });
|
||||||
|
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||||
|
writeFile(path.join(configDir, FILE), installedContent);
|
||||||
|
// No pristine file anywhere.
|
||||||
|
|
||||||
|
const { status, report } = runVerifier();
|
||||||
|
|
||||||
|
assert.equal(status, 0, 'no-baseline is advisory, never a failure');
|
||||||
|
assert.equal(report.no_baseline, 1);
|
||||||
|
assert.equal(report.results[0].reason, REASON.OK_NO_BASELINE);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negative space: an orphan whose bytes do NOT hash to the record is never
|
||||||
|
* adopted — only exact recorded-hash matches are accepted.
|
||||||
|
*/
|
||||||
|
test('#4145: never adopts a hash-mismatching orphan — only exact recorded-hash matches', () => {
|
||||||
|
resetFixture();
|
||||||
|
const FILE = 'gsd-core/bin/lib/frontmatter.cjs';
|
||||||
|
const OLD_PRISTINE = 'outgoing pristine bytes that the record hashes\n';
|
||||||
|
const OTHER_CONTENT = 'some other release snapshot with different bytes\n';
|
||||||
|
|
||||||
|
writeBackupMeta({ [FILE]: sha256(OLD_PRISTINE) });
|
||||||
|
writeFile(path.join(patchesDir, FILE), 'outgoing pristine bytes that the record hashes\nuser line\n');
|
||||||
|
writeFile(path.join(configDir, FILE), 'user line\n');
|
||||||
|
// Orphan exists but hashes to something else.
|
||||||
|
writeFile(path.join(pristineDir, 'bin', 'lib', 'frontmatter.cjs'), OTHER_CONTENT);
|
||||||
|
|
||||||
|
const { status, report } = runVerifier();
|
||||||
|
|
||||||
|
assert.equal(status, 0);
|
||||||
|
assert.equal(report.no_baseline, 1, 'a mismatching orphan is not a baseline');
|
||||||
|
assert.equal(report.results[0].reason, REASON.OK_NO_BASELINE);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precedence lock: the canonical prefixed path still resolves exactly as
|
||||||
|
* today even when an identical-content orphan also exists — the strict join
|
||||||
|
* stays first, and the verifier (read-only) leaves the orphan untouched.
|
||||||
|
*/
|
||||||
|
test('#4145: prefixed canonical baseline resolves exactly as today when an identical orphan also exists', () => {
|
||||||
|
resetFixture();
|
||||||
|
const FILE = 'gsd-core/workflows/execute-phase.md';
|
||||||
|
const pristineContent = 'stock workflow line long enough to pass the significance threshold\n';
|
||||||
|
const droppedLine = 'user workflow customisation that was lost in the merge operation';
|
||||||
|
const backupContent = pristineContent + droppedLine + '\n';
|
||||||
|
const installedContent = pristineContent; // user line dropped — real failure
|
||||||
|
|
||||||
|
writeBackupMeta({ [FILE]: sha256(pristineContent) });
|
||||||
|
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||||
|
writeFile(path.join(configDir, FILE), installedContent);
|
||||||
|
writeFile(path.join(pristineDir, FILE), pristineContent);
|
||||||
|
const orphanPath = path.join(pristineDir, 'workflows', 'execute-phase.md');
|
||||||
|
writeFile(orphanPath, pristineContent);
|
||||||
|
|
||||||
|
const { status, report } = runVerifier();
|
||||||
|
|
||||||
|
assert.equal(status, 1, 'the dropped user line must still be caught via the canonical baseline');
|
||||||
|
const r0 = report.results[0];
|
||||||
|
assert.equal(r0.status, 'fail');
|
||||||
|
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
|
||||||
|
assert.ok(r0.missing.includes(droppedLine));
|
||||||
|
// Read-only verifier: the orphan is never relocated or pruned by a verify run.
|
||||||
|
assert.equal(fs.existsSync(orphanPath), true, 'verifier must not mutate gsd-pristine/');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drift-path lock: a hash-MISMATCHING canonical snapshot still reports
|
||||||
|
* OK_PRISTINE_DRIFT_DETECTED (#3657) — the recovery scan must not reach the
|
||||||
|
* drift case.
|
||||||
|
*/
|
||||||
|
test('#4145: canonical-path drift still reports ok_pristine_drift_detected even when a hash-matching orphan exists', () => {
|
||||||
|
resetFixture();
|
||||||
|
const FILE = 'gsd-core/agents/gsd-executor.md';
|
||||||
|
const oldPristine = 'old pristine line that was present when backup was captured\n';
|
||||||
|
const newPristine = 'refreshed upstream line in the newer pristine snapshot\n';
|
||||||
|
const userLine = 'user customisation line that should be preserved across updates';
|
||||||
|
|
||||||
|
writeBackupMeta({ [FILE]: sha256(oldPristine) });
|
||||||
|
writeFile(path.join(patchesDir, FILE), oldPristine + userLine + '\n');
|
||||||
|
writeFile(path.join(configDir, FILE), newPristine + userLine + '\n');
|
||||||
|
writeFile(path.join(pristineDir, FILE), newPristine); // canonical drifted
|
||||||
|
// A hash-matching orphan exists elsewhere — drift must still win.
|
||||||
|
writeFile(path.join(pristineDir, 'agents', 'gsd-executor.md'), oldPristine);
|
||||||
|
|
||||||
|
const { status, report } = runVerifier();
|
||||||
|
|
||||||
|
assert.equal(status, 0);
|
||||||
|
const r0 = report.results[0];
|
||||||
|
assert.equal(r0.reason, REASON.OK_PRISTINE_DRIFT_DETECTED,
|
||||||
|
`expected the untouched #3657 drift posture; got ${r0.reason}`);
|
||||||
|
assert.equal(report.drifted, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Module unit: deterministic sorted-first match, symlink skip, absent dir. */
|
||||||
|
test('#4145: findPristineByHash returns the sorted-first match, skips symlinks, and null on an absent dir', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4145-unit-'));
|
||||||
|
const symRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4145-sym-'));
|
||||||
|
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4145-out-'));
|
||||||
|
try {
|
||||||
|
const contentA = 'identical bytes that two snapshots happen to share\n';
|
||||||
|
const hashA = sha256(contentA);
|
||||||
|
writeFile(path.join(root, 'zz-dir', 'late-match.md'), contentA);
|
||||||
|
writeFile(path.join(root, 'aa.txt'), contentA);
|
||||||
|
|
||||||
|
assert.equal(findPristineByHash(root, hashA), 'aa.txt',
|
||||||
|
'sorted-first match wins deterministically');
|
||||||
|
assert.equal(findPristineByHash(root, hashA, 'aa.txt'), 'zz-dir/late-match.md',
|
||||||
|
'skipRel is never returned');
|
||||||
|
assert.equal(findPristineByHash(root, hashA, new Set(['aa.txt', 'zz-dir/late-match.md'])), null,
|
||||||
|
'every member of a skip Set is excluded (canonical-path protection)');
|
||||||
|
assert.equal(findPristineByHash(root, sha256('no such content anywhere here\n')), null,
|
||||||
|
'no match resolves to null');
|
||||||
|
assert.equal(findPristineByHash(path.join(root, 'absent'), hashA), null,
|
||||||
|
'absent dir resolves to null');
|
||||||
|
|
||||||
|
// A symlink is never followed, even when its target would hash-match.
|
||||||
|
// The target lives OUTSIDE symRoot so the only hashable entry inside the
|
||||||
|
// scanned tree is the symlink itself.
|
||||||
|
const outsideTarget = path.join(outsideRoot, 'outside-target.md');
|
||||||
|
fs.writeFileSync(outsideTarget, contentA);
|
||||||
|
fs.symlinkSync(outsideTarget, path.join(symRoot, 'sym.md'));
|
||||||
|
assert.equal(findPristineByHash(symRoot, hashA), null,
|
||||||
|
'symlinked candidates are skipped, not followed');
|
||||||
|
} finally {
|
||||||
|
cleanup(root);
|
||||||
|
cleanup(symRoot);
|
||||||
|
cleanup(outsideRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user