diff --git a/.changeset/noble-geese-climb.md b/.changeset/noble-geese-climb.md new file mode 100644 index 000000000..fab016957 --- /dev/null +++ b/.changeset/noble-geese-climb.md @@ -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) diff --git a/.gitignore b/.gitignore index ea044ca1c..62eb22987 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,8 @@ build/ /gsd-core/bin/lib/ui-consideration-probe.cjs /gsd-core/bin/lib/config-types.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-depth.cjs /gsd-core/bin/lib/context-utilization.cjs diff --git a/bin/install.js b/bin/install.js index 7d8233317..4951e8221 100755 --- a/bin/install.js +++ b/bin/install.js @@ -544,6 +544,13 @@ const { RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP, isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel, } = 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 // install-model-override-resolver.cjs's readGsdRuntimeProfileResolver // (required below) — this installer no longer needs its own bindings. @@ -10153,6 +10160,63 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP 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. * Backs up modified files to gsd-local-patches/ for reapply after update. @@ -10299,6 +10363,15 @@ function saveLocalPatches(configDir, pristineCtx) { const stalePaths = new Set(); // Track which relPaths were successfully regenerated (from either missing or stale). 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 = []; for (const relPath of modified) { const outRef = resolveInstallRelativePath(pristineDir, relPath); @@ -10320,6 +10393,17 @@ function saveLocalPatches(configDir, pristineCtx) { 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): // attempt hash-validated regeneration from new-release source. missingPaths.push(relPath); @@ -10362,13 +10446,21 @@ function saveLocalPatches(configDir, pristineCtx) { } // `regenerated` = total files successfully regenerated (from missing OR stale). 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. // Entries that were stale-deleted but then successfully regenerated are counted - // only in `regenerated` — the counts are non-overlapping. - const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p)).length; + // only in `regenerated`; stale-deleted-then-orphan-rescued entries are counted + // only in `rescued` — the counts are non-overlapping. + const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p) && !rescuedPaths.has(p)).length; if (preserved > 0) { 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) { console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'gsd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source'); } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 755c28de7..8d46675fd 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -475,6 +475,7 @@ "planning-scope.cjs", "planning-snapshot.cjs", "planning-workspace.cjs", + "pristine-baseline.cjs", "probe-core.cjs", "profile-output.cjs", "profile-pipeline-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 09baf64ef..f1e418d65 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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-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) | +| `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) | | `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 | diff --git a/eslint.config.mjs b/eslint.config.mjs index 0b6bb13f1..a017ad849 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -126,6 +126,8 @@ export default tseslint.config( 'gsd-core/bin/lib/prohibition-enforcement.cjs', // #3770: tsc-generated runtime artifact — lint the src/tdd-red-evidence.cts source. '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/code-review-flags.cjs', 'gsd-core/bin/lib/code-review-depth.cjs', diff --git a/gsd-core/bin/verify-reapply-patches.cjs b/gsd-core/bin/verify-reapply-patches.cjs index 08170a0c1..c052fc3ec 100755 --- a/gsd-core/bin/verify-reapply-patches.cjs +++ b/gsd-core/bin/verify-reapply-patches.cjs @@ -33,6 +33,11 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); 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 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. } - // Bug #934: recordedHash is present (modern installer) but the pristine - // path does not exist on disk at all (stat threw above). This means + // Bug #4145: the canonical join missed, but the recorded hash is the + // 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 // gsd-pristine/ file (the only candidate was discarded because it was from // 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 // cannot reason correctly without a baseline, the safe answer is advisory/ // non-blocking: return OK_NO_BASELINE and let the caller decide. - // NOTE: this guard fires ONLY when stat threw (path absent), not when the - // path is present but non-file — in that case over-broad mode is safer. + // NOTE: this guard fires ONLY when the baseline path is absent (stat threw + // 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) { result.reason = REASON.OK_NO_BASELINE; return result; diff --git a/gsd-core/workflows/reapply-patches.md b/gsd-core/workflows/reapply-patches.md index b4e6a493a..0b95f333f 100644 --- a/gsd-core/workflows/reapply-patches.md +++ b/gsd-core/workflows/reapply-patches.md @@ -169,7 +169,7 @@ Check if a `gsd-pristine/` directory exists alongside `gsd-local-patches/`: ```bash 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) If neither git history nor pristine snapshots are available, fall back to two-way comparison — but with **strengthened heuristics** (see Step 3). diff --git a/src/pristine-baseline.cts b/src/pristine-baseline.cts new file mode 100644 index 000000000..1bc5c40e6 --- /dev/null +++ b/src/pristine-baseline.cts @@ -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 | 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; +} diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index 4d16689e6..b4ceaece6 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -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'); + }); +}); + }); +} diff --git a/tests/reapply-verify-hunks.test.cjs b/tests/reapply-verify-hunks.test.cjs index 748e56ad5..67c719ab1 100644 --- a/tests/reapply-verify-hunks.test.cjs +++ b/tests/reapply-verify-hunks.test.cjs @@ -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); + } + }); +}); + }); +}