* fix(#934): reapply verifier handles missing pristine baseline post-rename Gap 1 (verify-reapply-patches.cjs): when backup-meta.json records a pristine_hash for a file but gsd-pristine/ has no corresponding snapshot on disk, the verifier fell to over-broad mode and produced false FAIL_USER_LINES_MISSING. Fix: return advisory OK_NO_BASELINE (non-blocking, exit 0) so the verifier does not block on files it cannot reason about. Gap 2 (new migration 004): migration 003 removed legacy get-shit-done/ runtime files but left gsd-pristine/get-shit-done/ orphan snapshots in place. Those stale snapshots referenced get-shit-done/... key paths that no longer match the active gsd-core/... layout. Fix: add migration 004-prune-stale-pristine-get-shit-done (NOT editing 003, preserving its checksum — ref #670 guard) to remove all files under gsd-pristine/get-shit-done/ as GSD-managed pristine snapshots. Includes tests: bug-934 OK_NO_BASELINE assertions in the verifier test, new installer-migration-prune-stale-pristine.test.cjs, updated installer-migrations baseline-lock checksum for 004. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#934): rename migration to satisfy legacy-name guard + mark intentional path refs Rename src/installer-migrations/004-prune-stale-pristine-get-shit-done.cts → 004-prune-stale-pristine-snapshots.cts so the filename no longer contains the forbidden token. Update .gitignore and eslint.config.mjs to track the new built path. Add gsd-allow-legacy-name markers to the remaining intentional uses of the legacy path string in the migration body (lines 3 and 100) and in tests (installer-migration-prune-stale-pristine.test.cjs lines 202 and 226; and the baseline-lock key in installer-migrations.test.cjs:1469). Update the baseline checksum for migration 2026-06-09-prune-stale-pristine-get-shit-done to reflect the two new marker comments added to its body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
145
src/installer-migrations/004-prune-stale-pristine-snapshots.cts
Normal file
145
src/installer-migrations/004-prune-stale-pristine-snapshots.cts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Installer migration 004: remove stale gsd-pristine/get-shit-done/ snapshot // gsd-allow-legacy-name
|
||||
* files after the get-shit-done → gsd-core rename (#604, #934). // gsd-allow-legacy-name
|
||||
*
|
||||
* Background: migration 003 removed legacy runtime files from
|
||||
* get-shit-done/ but did not touch gsd-pristine/get-shit-done/, the // gsd-allow-legacy-name
|
||||
* parallel directory that holds pristine snapshots captured before the rename.
|
||||
* These snapshot files are GSD-managed (written by the installer, never by the
|
||||
* user) and reference stale get-shit-done/... key paths that no longer exist // gsd-allow-legacy-name
|
||||
* in the active layout. When verify-reapply-patches.cjs looks up a backup entry
|
||||
* keyed under gsd-core/... it finds no matching gsd-pristine/ snapshot, falls
|
||||
* to over-broad mode, and reports false FAIL_INSTALLED_MISSING / // gsd-allow-legacy-name
|
||||
* FAIL_USER_LINES_MISSING for every backed-up pre-rename file (#934).
|
||||
*
|
||||
* Fix: walk gsd-pristine/get-shit-done/ and emit remove-managed for each file. // gsd-allow-legacy-name
|
||||
* These files are always GSD-written snapshots — users never place their own
|
||||
* files inside gsd-pristine/ — so the classification override
|
||||
* (managed-pristine) is safe: there is no user content to protect.
|
||||
*
|
||||
* Checksum safety: migration 003's body is left untouched. Adding this
|
||||
* separate migration avoids modifying 003's checksum, which would break
|
||||
* upgrade state for any user who already applied 003 (root cause of #670).
|
||||
*
|
||||
* Per-file approach: the migration framework has no recursive directory-removal
|
||||
* primitive — all actions operate on individual files. Empty directory shells
|
||||
* left after removal can be cleaned up manually; this is the intentional ADR-0008
|
||||
* limitation.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface MigrationAction {
|
||||
type: string;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
ownershipEvidence: string;
|
||||
classification?: string;
|
||||
}
|
||||
|
||||
interface MigrationPlanContext {
|
||||
configDir: string;
|
||||
classifyArtifact(relPath: string): { classification: string; [key: string]: unknown };
|
||||
}
|
||||
|
||||
interface InstallerMigration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
introducedIn: string;
|
||||
scopes: string[];
|
||||
destructive: boolean;
|
||||
plan(ctx: MigrationPlanContext): MigrationAction[];
|
||||
}
|
||||
|
||||
function walkPristineFiles(root: string, relDir: string, baseResolved: string, results: string[]): void {
|
||||
const dir = path.join(root, relDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // directory absent or unreadable — nothing to do
|
||||
}
|
||||
for (const entry of entries) {
|
||||
// Do not follow symlinks — skip to avoid out-of-tree traversal.
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
const relPath = path.posix.join(relDir, entry.name);
|
||||
// Bounds check: ensure the resolved path stays under configDir.
|
||||
const resolved = path.resolve(root, relPath);
|
||||
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
walkPristineFiles(root, relPath, baseResolved, results);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(relPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REASON = 'stale pristine snapshot from legacy get-shit-done/ dir, orphaned by rename migration 003 (#604, #934)'; // gsd-allow-legacy-name
|
||||
|
||||
const migration: InstallerMigration = {
|
||||
id: '2026-06-09-prune-stale-pristine-get-shit-done', // gsd-allow-legacy-name
|
||||
title: 'Remove stale gsd-pristine/get-shit-done/ snapshot files (#934)', // gsd-allow-legacy-name
|
||||
description:
|
||||
'Migration 003 removed runtime files from get-shit-done/ but left the matching pristine snapshot ' + // gsd-allow-legacy-name
|
||||
'directory gsd-pristine/get-shit-done/ intact. Those snapshots reference stale key paths and cause ' + // gsd-allow-legacy-name
|
||||
'verify-reapply-patches false positives (#934). Remove all files under gsd-pristine/get-shit-done/ ' + // gsd-allow-legacy-name
|
||||
'as they are GSD-managed snapshots, never user content.',
|
||||
introducedIn: '1.4.3',
|
||||
scopes: ['global', 'local'],
|
||||
destructive: true,
|
||||
plan(ctx: MigrationPlanContext): MigrationAction[] {
|
||||
const pristineGsdRoot = path.join(ctx.configDir, 'gsd-pristine', 'get-shit-done'); // gsd-allow-legacy-name
|
||||
|
||||
// Idempotency: if the stale pristine subdir doesn't exist, nothing to do.
|
||||
if (!fs.existsSync(pristineGsdRoot)) return [];
|
||||
|
||||
// Safety: reject symlinks in ANY ancestor component of the path we will walk
|
||||
// to prevent following a symlink out of configDir. Check both gsd-pristine/
|
||||
// and gsd-pristine/get-shit-done/ — either being a symlink could redirect // gsd-allow-legacy-name
|
||||
// the walk to an out-of-tree location.
|
||||
const pristineParent = path.join(ctx.configDir, 'gsd-pristine');
|
||||
try {
|
||||
if (fs.lstatSync(pristineParent).isSymbolicLink()) return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
if (fs.lstatSync(pristineGsdRoot).isSymbolicLink()) return []; // gsd-allow-legacy-name
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseResolved = path.resolve(ctx.configDir);
|
||||
const relPaths: string[] = [];
|
||||
walkPristineFiles(ctx.configDir, path.posix.join('gsd-pristine', 'get-shit-done'), baseResolved, relPaths); // gsd-allow-legacy-name
|
||||
|
||||
const actions: MigrationAction[] = [];
|
||||
for (const relPath of relPaths) {
|
||||
// Bounds-check each relPath before emitting any action.
|
||||
const resolved = path.resolve(ctx.configDir, relPath);
|
||||
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) continue;
|
||||
|
||||
// These files are GSD-managed pristine snapshots — the installer writes
|
||||
// them during install/upgrade; users never place personal files inside
|
||||
// gsd-pristine/. Pass classification: 'managed-pristine' explicitly so
|
||||
// the framework does not downgrade remove-managed to preserve-user when
|
||||
// the manifest has no entry (these paths were never in the manifest since
|
||||
// they live under gsd-pristine/, not the tracked runtime dir).
|
||||
actions.push({
|
||||
type: 'remove-managed',
|
||||
relPath,
|
||||
reason: REASON,
|
||||
ownershipEvidence:
|
||||
'GSD-written pristine snapshot under gsd-pristine/get-shit-done/; ' + // gsd-allow-legacy-name
|
||||
'installer is the sole author of gsd-pristine/ contents; no user content lives here',
|
||||
classification: 'managed-pristine',
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
Reference in New Issue
Block a user