Files
msd-core/src/installer-migration-report.cts
Tom Boucher 463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00

424 lines
16 KiB
TypeScript

/**
* Installer migration report utilities (ADR-457 build-at-publish: the
* hand-written bin/lib/installer-migration-report.cjs collapsed to a TypeScript
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*
* Resolution environment variable surface for #3541 — when the installer
* runs without a TTY (typical /gsd:update path via Claude Code or any
* scripted update), prompt-user migration actions cannot be answered
* interactively. Classification-based defaults apply; anything else falls
* through to the hard assertion with a grouped, actionable error message.
*
* docs/installer-migrations.md#prompt-user-resolution for the spec.
*/
export const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
const VALID_CHOICES: ReadonlyArray<string> = ['keep', 'remove'];
// #3628: explicit whitelist of bundled hook files shipped in the npm
// distribution under `hooks/`. The classifier-based auto-removal of these
// files at first-time-baseline scan (added in #3610) is restricted to this
// set — a shape regex like `^hooks/gsd-[^/]+\.(?:js|sh|cjs|mjs)$` also
// matches user-authored custom hooks and retired bundled hooks from prior
// versions, and auto-removing those is silent data loss.
//
// The bug-3628 regression guard asserts this Set stays aligned with the
// on-disk `hooks/` directory in both directions: whitelist-but-missing
// AND shipped-but-not-whitelisted both fail CI.
export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set([
'hooks/gsd-check-update-worker.js',
'hooks/gsd-check-update.js',
'hooks/gsd-context-monitor.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',
'hooks/gsd-prompt-guard.js',
'hooks/gsd-read-guard.js',
'hooks/gsd-read-injection-scanner.js',
'hooks/gsd-session-state.sh',
'hooks/gsd-statusline.js',
'hooks/gsd-update-banner.js',
'hooks/gsd-validate-commit.sh',
'hooks/gsd-workflow-guard.js',
'hooks/gsd-worktree-path-guard.js',
]));
// ── Internal action types ─────────────────────────────────────────────────────
interface MigrationAction {
type: string;
relPath?: string;
reason?: string;
migrationId?: string;
migrationChecksum?: string;
classification?: string;
originalHash?: string | null;
currentHash?: string | null;
requestedType?: string;
backupRelPath?: string | null;
choices?: string[];
deleteIfEmpty?: boolean;
count?: number;
actions?: MigrationAction[];
[key: string]: unknown;
}
interface MigrationPlan {
actions?: MigrationAction[];
blocked?: MigrationAction[];
[key: string]: unknown;
}
interface MigrationResult {
blocked?: MigrationAction[];
plan?: MigrationPlan;
[key: string]: unknown;
}
interface SummaryRow {
label: string;
relPath: string;
reason: string;
action: MigrationAction;
}
interface SummarizeResult {
hasReportableActions: boolean;
blocked: MigrationAction[];
rows: (SummaryRow | null)[];
}
interface Resolution {
relPath: string | undefined;
category: string;
choice: string;
reason: string | undefined;
resolvedActionType: string;
source: string;
}
interface ResolvePromptsResult {
result: MigrationResult;
resolutions: Resolution[];
}
interface ClassifyResult {
category: string;
choice: string;
}
interface ResolveOptions {
isTty?: boolean;
env?: Record<string, string | undefined>;
}
// ── Internal helpers ──────────────────────────────────────────────────────────
function installerMigrationActionLabel(action: MigrationAction | null | undefined): string {
if (!action || !action.type) return 'skipped';
if (action.type === 'backup-and-remove') return 'backed up and removed';
if (action.type === 'remove-managed') return 'removed';
if (action.type === 'rewrite-json') return action.deleteIfEmpty ? 'rewrote or removed' : 'rewrote';
if (action.type === 'record-baseline') return 'recorded';
if (action.type === 'baseline-preserve-user') return 'preserved';
if (action.type === 'preserve-user') return 'preserved';
if (action.type === 'prompt-user') return 'blocked';
return 'skipped';
}
function blockedInstallerMigrationActions(result: MigrationResult | null | undefined): MigrationAction[] {
if (result && Array.isArray(result.blocked)) return result.blocked;
const plan = result && result.plan;
if (plan && Array.isArray(plan.blocked)) return plan.blocked;
return [];
}
function baselineSummaryLabel(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
function baselineSummaryRow(type: string, actions: MigrationAction[]): SummaryRow {
const count = actions.length;
if (type === 'record-baseline') {
return {
label: 'recorded',
relPath: baselineSummaryLabel(count, 'managed baseline file'),
reason: 'first-time baseline scan',
action: { type: 'record-baseline-summary', count, actions },
};
}
return {
label: 'preserved',
relPath: baselineSummaryLabel(count, 'user baseline file'),
reason: 'first-time baseline scan',
action: { type: 'baseline-preserve-user-summary', count, actions },
};
}
export function summarizeInstallerMigrationResult(result: MigrationResult | null | undefined): SummarizeResult {
const plan = result && result.plan;
const actions: MigrationAction[] = plan && Array.isArray(plan.actions) ? plan.actions : [];
const blocked = blockedInstallerMigrationActions(result);
const blockedSet = new Set(blocked);
const rows: (SummaryRow | null)[] = [];
const baselineIndexes = new Map<string, number>();
const baselineActions = new Map<string, MigrationAction[]>();
for (const action of actions) {
const type = action && action.type;
if (type === 'record-baseline' || type === 'baseline-preserve-user') {
if (!baselineActions.has(type)) {
baselineActions.set(type, []);
baselineIndexes.set(type, rows.length);
rows.push(null);
}
baselineActions.get(type)!.push(action);
continue;
}
rows.push({
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
relPath: action.relPath ?? '',
reason: action.reason || '',
action,
});
}
// Phase 4 requires action reporting without flooding first-time baseline installs:
// docs/installer-migrations.md#phase-4-installupdate-integration.
for (const [type, baselineRows] of baselineActions) {
rows[baselineIndexes.get(type)!] = baselineSummaryRow(type, baselineRows);
}
return {
hasReportableActions: actions.length > 0 || blocked.length > 0,
blocked,
rows,
};
}
// Classify a blocked prompt-user action into one of the safe-default
// categories. Returns null when no safe default applies — caller must
// fall back to the hard assertion / interactive prompt for those.
//
// Stale SDK build artifacts live under gsd-core/sdk/{dist,src}/
// and are regenerated on every install, so removing them is lossless.
// User-facing skill anchors are the .md files that surface as commands
// to the user — these are user-owned and must be kept.
export function classifyPromptUserAction(action: MigrationAction): ClassifyResult | null {
const relPath = action && action.relPath;
if (typeof relPath !== 'string' || !relPath) return null;
if (/^gsd-core\/sdk\/(dist|src)\//.test(relPath)) {
return { category: 'stale-sdk-build-artifact', choice: 'remove' };
}
if (/^skills\/gsd-[^/]+\/SKILL\.md$/.test(relPath)) {
return { category: 'user-facing-skill', choice: 'keep' };
}
// #3610 / #3628: bundled GSD hooks shipped under `hooks/`. The whitelist
// is the explicit set of filenames in the npm distribution — files that
// match the shape but are NOT in the whitelist (user-authored hooks,
// retired hooks from prior versions) fall through to the block-or-prompt
// flow so the user retains control. On a first-time-baseline scan the
// installer can safely remove whitelisted hooks because it is about to
// write the fresh bundled versions in their place.
if (BUNDLED_GSD_HOOK_FILES.has(relPath)) {
return { category: 'bundled-gsd-hook', choice: 'remove' };
}
return null;
}
// Convert a blocked prompt-user action into a concrete plan action.
// `keep` → baseline-preserve-user (idempotent — already on disk).
// `remove` → backup-and-remove (safe: keeps a rollback copy in the
// migration journal under gsd-migration-journal/<runId>-backups/).
function materializeResolution(action: MigrationAction, choice: string): MigrationAction {
const base: MigrationAction = {
type: '', // overridden in each return branch below
migrationId: action.migrationId,
migrationChecksum: action.migrationChecksum,
relPath: action.relPath,
reason: action.reason,
classification: action.classification,
originalHash: action.originalHash || null,
currentHash: action.currentHash || null,
requestedType: 'prompt-user',
};
if (choice === 'keep') {
return { ...base, type: 'baseline-preserve-user' };
}
// 'remove'
return { ...base, type: 'backup-and-remove', backupRelPath: null };
}
function normalizeResolutionChoice(rawValue: unknown): string | null {
if (typeof rawValue !== 'string') return null;
const normalized = rawValue.trim().toLowerCase();
return VALID_CHOICES.includes(normalized) ? normalized : null;
}
function actionSupportsChoice(action: MigrationAction, choice: string): boolean {
if (!action || !choice) return false;
if (!Array.isArray(action.choices) || action.choices.length === 0) {
return VALID_CHOICES.includes(choice);
}
return action.choices.includes(choice);
}
// Resolve prompt-user actions when stdin is not a TTY. Mutates the
// passed result so:
// - resolved actions are appended to plan.actions in their concrete
// form (baseline-preserve-user / backup-and-remove);
// - result.blocked and plan.blocked are filtered to actions that
// could NOT be safely defaulted (caller must still handle those).
// Returns { result, resolutions } where `resolutions` is the structured
// log of every defaulted resolution.
export function resolveInstallerMigrationPromptsForNonTty(
result: MigrationResult,
options: ResolveOptions = {},
): ResolvePromptsResult {
if (!result || typeof result !== 'object') {
return { result, resolutions: [] };
}
const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) {
return { result, resolutions: [] };
}
const isTty = options.isTty === true;
if (isTty) {
// Honour interactive prompting paths (not implemented yet — the
// hard throw is still the right behaviour for TTY runs); resolver
// only fires when the installer cannot interactively ask.
return { result, resolutions: [] };
}
const env: Record<string, string | undefined> =
options && options.env && typeof options.env === 'object'
? options.env
: process.env;
const envChoice = normalizeResolutionChoice(env && env[RESOLUTION_ENV_VAR]);
const resolutions: Resolution[] = [];
const unresolved: MigrationAction[] = [];
for (const action of blocked) {
if (action && action.type === 'prompt-user') {
let category: string | null = null;
let choice: string | null = null;
let source: string | null = null;
if (envChoice && actionSupportsChoice(action, envChoice)) {
category = 'operator-override';
choice = envChoice;
source = RESOLUTION_ENV_VAR;
} else {
const classification = classifyPromptUserAction(action);
if (classification) {
category = classification.category;
choice = classification.choice;
source = 'non-tty-default';
}
}
if (choice) {
const resolved = materializeResolution(action, choice);
// Replace the original prompt-user action in-place when present so
// applyInstallerMigrationPlan never sees an unsupported action type.
// Fallback to append only when the blocked action did not originate
// from plan.actions (defensive).
if (result.plan && Array.isArray(result.plan.actions)) {
const idx = result.plan.actions.indexOf(action);
if (idx >= 0) {
result.plan.actions[idx] = resolved;
} else {
result.plan.actions.push(resolved);
}
}
resolutions.push({
relPath: action.relPath,
category: category ?? '',
choice: choice ?? '',
reason: action.reason,
resolvedActionType: resolved.type,
source: source ?? '',
});
continue;
}
}
unresolved.push(action);
}
// Mutate both the top-level and plan.blocked surfaces so downstream
// callers (assertInstallerMigrationsUnblocked, summarizers) see the
// post-resolution state.
if (Array.isArray(result.blocked)) {
result.blocked = unresolved;
}
if (result.plan && Array.isArray(result.plan.blocked)) {
result.plan.blocked = unresolved;
}
return { result, resolutions };
}
// Group blocked prompt-user actions by their `reason` so the operator
// sees one summary line per cause instead of N path lines for the
// same underlying issue.
function groupBlockedByReason(blocked: MigrationAction[]): Map<string, MigrationAction[]> {
const byReason = new Map<string, MigrationAction[]>();
for (const action of blocked) {
const reason = (action && action.reason) || 'no reason given';
if (!byReason.has(reason)) byReason.set(reason, []);
byReason.get(reason)!.push(action);
}
return byReason;
}
function describeChoicesForActions(blocked: MigrationAction[]): string[] {
const choiceSet = new Set<string>();
for (const action of blocked) {
if (action && Array.isArray(action.choices)) {
for (const choice of action.choices) choiceSet.add(choice);
}
}
if (choiceSet.size === 0) {
for (const fallback of VALID_CHOICES) choiceSet.add(fallback);
}
return [...choiceSet];
}
function buildBlockedErrorMessage(blocked: MigrationAction[]): string {
const byReason = groupBlockedByReason(blocked);
const totalFiles = blocked.length;
const choices = describeChoicesForActions(blocked);
const lines: string[] = [
`installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`,
` choices: [${choices.join(', ')}]`,
];
for (const [reason, actions] of byReason) {
lines.push(` - ${actions.length} file${actions.length === 1 ? '' : 's'}: ${reason}`);
// Show up to 3 sample paths so operators can spot which files are
// affected without dumping a thousand-line wall when SDK build
// artifacts leak.
const sample = actions.slice(0, 3).map((a) => a.relPath);
if (sample.length > 0) {
lines.push(` e.g. ${sample.join(', ')}${actions.length > sample.length ? `, ... (+${actions.length - sample.length} more)` : ''}`);
}
}
lines.push(
` resolve non-interactively by setting ${RESOLUTION_ENV_VAR}=<choice> ` +
`(or run the installer in a TTY to be prompted per file).`
);
return lines.join('\n');
}
export function assertInstallerMigrationsUnblocked(result: MigrationResult | null | undefined): void {
const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) return;
const message = buildBlockedErrorMessage(blocked);
const error = Object.assign(new Error(message), {
blocked,
blockedByReason: Object.fromEntries(groupBlockedByReason(blocked)),
resolutionEnvVar: RESOLUTION_ENV_VAR,
});
throw error;
}