Files
msd-core/tests/install-minimal-hooks.test.cjs
Tom Boucher a28dcec981 chore(#597): replace count-based ratchet guards with AST lint + named-set allowlists (#603)
The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.

Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
  EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
  maxRetries (it owns no loop), so the test asserts the option contract
  (recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
  rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
  the helper, not approximated textually at every call site.

Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
  raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
  destructured and aliased forms; escape hatch is inline
  `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
  ~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
  error-swallowing or name-colliding local teardown helpers) keep the raw call
  with an inline eslint-disable + reason.

Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
  - assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
    STALE ids (a known offender was fixed but not pruned) — identity, not count,
    and a ratchet DOWN toward zero.
  - assertTightCeiling: a size/length budget whose ceiling must stay within a
    grace band of the high-water mark, so budgets may only tighten, never creep.

Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
  the remaining six patterns moved from integer baselines to named-set
  allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
  named filename sets (closes the swap-a-file-keep-the-count blind spot); a
  module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).

Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
  current high-water mark and an assertTightCeiling anti-creep check added per
  tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
  are intentionally left as-is — they are not grandfathered creeping budgets.

No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:43:49 -04:00

774 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Installer Module — Sections 9–11 + 13.
*
* Covers: install-profiles unit tests (MINIMAL_SKILL_ALLOWLIST, isMinimalMode,
* shouldInstallSkill, stageSkillsForMode, cleanupStagedSkills),
* --minimal per-runtime E2E (spawned), --minimal manifest mode + downgrade,
* and hooks copy / manifest / uninstall settings cleanup.
*
* Consolidates (original sources from #3758):
* install-minimal.test.cjs
* install-minimal-all-runtimes.test.cjs
* install-minimal-backcompat.test.cjs
* install-hooks-copy.test.cjs
*
* Closes #3758
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach, before } = 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 { spawnSync, execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
writeManifest,
GSD_UNINSTALL_HOOKS,
} = require('../bin/install.js');
const {
MINIMAL_SKILL_ALLOWLIST,
PROFILES,
isMinimalMode,
shouldInstallSkill,
stageSkillsForMode,
cleanupStagedSkills,
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const {
INSTALL_SCRIPT,
MANIFEST_NAME,
BUILD_SCRIPT,
HOOKS_DIST,
EXPECTED_SH_HOOKS,
EXPECTED_ALL_HOOKS,
SKILL_RUNTIMES,
simulateHookCopy,
installerEnv,
runMinimalInstall,
manifestSkillSet,
manifestAgentCount,
collectSkillBasenamesOnDisk,
} = require('./helpers/install-shared.cjs');
// ─── Section 9: install-profiles — MINIMAL_SKILL_ALLOWLIST ───────────────────
describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
test('contains exactly the main-loop core (frozen)', () => {
assert.deepStrictEqual(
[...MINIMAL_SKILL_ALLOWLIST].sort(),
['discuss-phase', 'execute-phase', 'help', 'new-project', 'phase', 'plan-phase', 'surface', 'update'],
);
assert.ok(Object.isFrozen(MINIMAL_SKILL_ALLOWLIST));
});
test('every allowlisted skill exists in commands/gsd/', () => {
const commandsDir = path.join(__dirname, '..', 'commands', 'gsd');
for (const name of MINIMAL_SKILL_ALLOWLIST) {
assert.ok(
fs.existsSync(path.join(commandsDir, `${name}.md`)),
`${name} is allowlisted but commands/gsd/${name}.md does not exist`,
);
}
});
});
describe('install-profiles: isMinimalMode', () => {
test('returns true only for "minimal"', () => {
assert.strictEqual(isMinimalMode('minimal'), true);
assert.strictEqual(isMinimalMode('full'), false);
assert.strictEqual(isMinimalMode(''), false);
assert.strictEqual(isMinimalMode(undefined), false);
assert.strictEqual(isMinimalMode(null), false);
assert.strictEqual(isMinimalMode('MINIMAL'), false);
});
});
describe('install-profiles: shouldInstallSkill', () => {
test('full mode admits every skill', () => {
assert.strictEqual(shouldInstallSkill('plan-phase', 'full'), true);
assert.strictEqual(shouldInstallSkill('autonomous', 'full'), true);
assert.strictEqual(shouldInstallSkill('arbitrary-future-name', 'full'), true);
});
test('minimal mode admits only allowlisted skills', () => {
for (const name of MINIMAL_SKILL_ALLOWLIST) {
assert.strictEqual(shouldInstallSkill(name, 'minimal'), true, name);
}
for (const denied of ['autonomous', 'do', 'progress', 'next', 'fast', 'quick']) {
assert.strictEqual(shouldInstallSkill(denied, 'minimal'), false, denied);
}
});
test('minimal mode rejects .md-suffixed names (callers must strip)', () => {
assert.strictEqual(shouldInstallSkill('plan-phase.md', 'minimal'), false);
});
test('unknown mode falls through to full behavior', () => {
for (const unknownMode of ['compact', 'tier2', 'CORE', 'Minimal', 'mini']) {
assert.ok(shouldInstallSkill('autonomous', unknownMode),
`unknown mode "${unknownMode}" should admit all skills`);
}
});
});
describe('install-profiles: stageSkillsForMode', () => {
function createFixtureSkillsDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fixture-'));
for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'do', 'help',
'new-project', 'phase', 'discuss-phase', 'update', 'progress', 'surface']) {
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
}
return tmp;
}
test('full mode returns original src dir unchanged', () => {
const src = createFixtureSkillsDir();
try {
assert.strictEqual(stageSkillsForMode(src, 'full'), src);
} finally {
cleanup(src);
}
});
test('minimal mode returns new dir with only allowlisted skills', () => {
const src = createFixtureSkillsDir();
let staged;
try {
staged = stageSkillsForMode(src, 'minimal');
assert.notStrictEqual(staged, src);
assert.deepStrictEqual(
fs.readdirSync(staged).sort(),
['discuss-phase.md', 'execute-phase.md', 'help.md', 'new-project.md',
'phase.md', 'plan-phase.md', 'surface.md', 'update.md'],
);
} finally {
cleanup(src);
cleanup(staged);
}
});
test('minimal mode preserves file content byte-for-byte', () => {
const src = createFixtureSkillsDir();
let staged;
try {
staged = stageSkillsForMode(src, 'minimal');
const original = fs.readFileSync(path.join(src, 'plan-phase.md'), 'utf8');
const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8');
assert.strictEqual(copied, original);
} finally {
cleanup(src);
cleanup(staged);
}
});
test('minimal mode against non-existent source returns source path', () => {
const ghost = path.join(os.tmpdir(), 'gsd-stage-does-not-exist-' + Date.now());
assert.strictEqual(stageSkillsForMode(ghost, 'minimal'), ghost);
});
test('minimal mode skips non-md files and subdirectories', () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-mixed-'));
let staged;
try {
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
fs.writeFileSync(path.join(src, 'README.txt'), 'not a skill\n');
fs.mkdirSync(path.join(src, 'nested-dir'));
fs.writeFileSync(path.join(src, 'nested-dir', 'plan-phase.md'), '# nested\n');
staged = stageSkillsForMode(src, 'minimal');
assert.deepStrictEqual(fs.readdirSync(staged), ['plan-phase.md']);
} finally {
cleanup(src);
cleanup(staged);
}
});
});
describe('install-profiles: cleanupStagedSkills', () => {
test('removes staged dirs created during process', () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-cleanup-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
try {
const a = stageSkillsForMode(src, 'minimal');
const b = stageSkillsForMode(src, 'minimal');
assert.notStrictEqual(a, b);
assert.ok(fs.existsSync(a));
assert.ok(fs.existsSync(b));
cleanupStagedSkills();
assert.ok(!fs.existsSync(a));
assert.ok(!fs.existsSync(b));
} finally {
cleanup(src);
}
});
test('is idempotent', () => {
cleanupStagedSkills();
cleanupStagedSkills();
});
test('exit handler registers at most once across many calls', () => {
cleanupStagedSkills();
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-exit-handler-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
try {
const before = process.listenerCount('exit');
for (let i = 0; i < 5; i++) stageSkillsForMode(src, 'minimal');
const after = process.listenerCount('exit');
assert.ok(after - before <= 1, `expected <=1 new exit listener, got ${after - before}`);
} finally {
cleanup(src);
cleanupStagedSkills();
}
});
test('mid-copy failure removes partial staged dir and re-throws', () => {
cleanupStagedSkills();
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fail-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
fs.writeFileSync(path.join(src, 'execute-phase.md'), '# x\n');
const realCopy = fs.copyFileSync;
const realMkdtemp = fs.mkdtempSync;
let stagedDir = null;
fs.mkdtempSync = (prefix, ...rest) => {
const out = realMkdtemp(prefix, ...rest);
if (typeof prefix === 'string' && prefix.endsWith('gsd-minimal-skills-')) stagedDir = out;
return out;
};
let copyCount = 0;
fs.copyFileSync = (s, d) => {
copyCount++;
if (copyCount === 2) throw new Error('synthetic disk full');
return realCopy(s, d);
};
try {
assert.throws(() => stageSkillsForMode(src, 'minimal'), /synthetic disk full/);
assert.notStrictEqual(stagedDir, null);
assert.equal(fs.existsSync(stagedDir), false);
} finally {
fs.copyFileSync = realCopy;
fs.mkdtempSync = realMkdtemp;
cleanup(src);
cleanupStagedSkills();
}
});
});
describe('install-profiles: allowlist scope guards', () => {
test('every main-loop command is in the allowlist', () => {
for (const required of ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase']) {
assert.ok(shouldInstallSkill(required, 'minimal'), `"${required}" must be in allowlist`);
}
});
test('off-loop commands are NOT in the allowlist', () => {
for (const offLoop of ['autonomous', 'ship', 'do', 'progress', 'next', 'fast', 'quick', 'debug', 'code-review', 'verify-work']) {
assert.ok(!shouldInstallSkill(offLoop, 'minimal'), `"${offLoop}" must NOT be in allowlist`);
}
});
});
// ─── Section 10: --minimal install — per-runtime E2E (spawned) ───────────────
describe('install: --minimal honoured for every runtime in --global mode', () => {
for (const runtime of SKILL_RUNTIMES) {
test(`${runtime} --global --minimal: mode=minimal, correct skills, zero agents`, () => {
const { manifest, root } = runMinimalInstall({ runtime, scope: 'global', extraArgs: ['--minimal'] });
try {
assert.ok(manifest, `${runtime} global must produce manifest`);
assert.strictEqual(manifest.mode, 'minimal');
assert.deepStrictEqual(
[...manifestSkillSet(manifest)].sort(),
[...MINIMAL_SKILL_ALLOWLIST].sort(),
);
assert.strictEqual(manifestAgentCount(manifest), 0);
} finally {
cleanup(root);
}
});
}
});
describe('install: --minimal honoured for every runtime in --local mode', () => {
for (const runtime of SKILL_RUNTIMES) {
test(`${runtime} --local --minimal: mode=minimal, correct skills, zero agents`, () => {
const { manifest, root } = runMinimalInstall({ runtime, scope: 'local', extraArgs: ['--minimal'] });
try {
assert.ok(manifest, `${runtime} local must produce manifest`);
assert.strictEqual(manifest.mode, 'minimal');
assert.deepStrictEqual(
[...manifestSkillSet(manifest)].sort(),
[...MINIMAL_SKILL_ALLOWLIST].sort(),
);
assert.strictEqual(manifestAgentCount(manifest), 0);
} finally {
cleanup(root);
}
});
}
});
describe('install: Cline --minimal (rules-based, no skills/ dir)', () => {
for (const scope of ['global', 'local']) {
test(`cline --${scope} --minimal: mode=minimal, zero agents, .clinerules present`, () => {
const { manifest, configDir, root } = runMinimalInstall({
runtime: 'cline', scope, extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, 'cline must produce manifest');
assert.strictEqual(manifest.mode, 'minimal');
assert.strictEqual(manifestAgentCount(manifest), 0);
assert.ok(fs.existsSync(path.join(configDir, '.clinerules')));
} finally {
cleanup(root);
}
});
}
});
describe('install: on-disk skill files match manifest for --minimal', () => {
for (const runtime of SKILL_RUNTIMES) {
for (const scope of ['global', 'local']) {
test(`${runtime} --${scope} --minimal: on-disk matches manifest`, () => {
const { manifest, configDir, root } = runMinimalInstall({
runtime, scope, extraArgs: ['--minimal'],
});
try {
assert.ok(manifest);
const onDisk = collectSkillBasenamesOnDisk(configDir);
const inManifest = manifestSkillSet(manifest);
assert.deepStrictEqual([...onDisk].sort(), [...inManifest].sort());
const agentsDir = path.join(configDir, 'agents');
if (fs.existsSync(agentsDir)) {
const gsdAgents = fs.readdirSync(agentsDir)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.deepStrictEqual(gsdAgents, []);
}
} finally {
cleanup(root);
}
});
}
}
});
// ─── Section 11: --minimal manifest mode + downgrade ─────────────────────────
describe('install: manifest records mode for both profiles', () => {
function manifestModeAfterInstall(extraArgs) {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-mode-'));
try {
spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ encoding: 'utf8', env: installerEnv() },
);
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
).size;
const agentCount = Object.keys(m.files || {}).filter(k => k.startsWith('agents/')).length;
return { mode: m.mode, skillCount, agentCount };
} finally {
cleanup(targetDir);
}
}
test('default install records mode: "full" with full skill+agent count', () => {
const r = manifestModeAfterInstall([]);
assert.strictEqual(r.mode, 'full');
assert.ok(r.skillCount > 7);
assert.ok(r.agentCount > 0);
});
test('--minimal records mode: "minimal" with exactly 8 skills and 0 agents', () => {
const r = manifestModeAfterInstall(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 8);
assert.strictEqual(r.agentCount, 0);
});
test('--core-only is an alias for --minimal', () => {
const r = manifestModeAfterInstall(['--core-only']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 8);
assert.strictEqual(r.agentCount, 0);
});
});
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
test('PROFILES.core contains the same 8 skills as MINIMAL_SKILL_ALLOWLIST', () => {
assert.deepStrictEqual(
[...PROFILES.core].sort(),
[...MINIMAL_SKILL_ALLOWLIST].sort(),
);
});
});
describe('install-minimal-backcompat: --minimal and --profile=core produce same manifest', () => {
function installAndGetManifest(extraArgs) {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-backcompat-'));
try {
spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ encoding: 'utf8', env: installerEnv() },
);
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
).size;
const markerPath = path.join(targetDir, '.gsd-profile');
const profileMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8').trim() : null;
return { mode: m.mode, skillCount, profileMarker };
} finally {
cleanup(targetDir);
}
}
test('--minimal produces mode "minimal" with exactly 8 skills', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 8);
});
test('--minimal writes .gsd-profile marker "core"', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.profileMarker, 'core');
});
test('default install writes .gsd-profile marker "full"', () => {
const r = installAndGetManifest([]);
assert.strictEqual(r.profileMarker, 'full');
});
test('--profile=core writes .gsd-profile marker "core"', () => {
const r = installAndGetManifest(['--profile=core']);
assert.strictEqual(r.profileMarker, 'core');
});
test('--profile=standard writes .gsd-profile marker "standard"', () => {
const r = installAndGetManifest(['--profile=standard']);
assert.strictEqual(r.profileMarker, 'standard');
});
});
describe('install: Codex full → minimal downgrade cleans stale agent state', () => {
test('--minimal removes stale .toml agents and strips [agents.gsd-*] from config.toml', () => {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-downgrade-'));
try {
const agentsDir = path.join(targetDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'name = "gsd-executor"\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'name = "gsd-planner"\n');
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
const codexConfig = [
'# user-owned setting',
'model = "gpt-5"',
'',
'# GSD Agent Configuration — managed by get-shit-done installer',
'[agents.gsd-executor]',
'cmd = "stale"',
'',
'[agents.gsd-planner]',
'cmd = "stale"',
'',
].join('\n');
fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig);
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'],
{ encoding: 'utf8', env: installerEnv() },
);
assert.ok(result.stdout || result.stderr);
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
assert.ok(!remaining.includes('gsd-executor.md'));
assert.ok(!remaining.includes('gsd-planner.md'));
assert.ok(!remaining.includes('gsd-executor.toml'));
assert.ok(!remaining.includes('gsd-planner.toml'));
assert.ok(remaining.includes('my-custom-agent.md'));
const configPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(configPath)) {
const config = fs.readFileSync(configPath, 'utf8');
assert.ok(!config.includes('[agents.gsd-executor]'));
assert.ok(!config.includes('[agents.gsd-planner]'));
assert.ok(config.includes('model = "gpt-5"'));
}
assert.ok(fs.existsSync(configPath));
} finally {
cleanup(targetDir);
}
});
});
describe('install: Claude full → minimal downgrade removes stale agents', () => {
test('--minimal removes stale gsd-*.md agents but preserves user-owned agents', () => {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-downgrade-'));
try {
const agentsDir = path.join(targetDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'],
{ encoding: 'utf8', env: installerEnv() },
);
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
assert.ok(!remaining.includes('gsd-executor.md'));
assert.ok(!remaining.includes('gsd-planner.md'));
assert.ok(remaining.includes('my-custom-agent.md'));
assert.deepStrictEqual(remaining.filter(f => f.startsWith('gsd-')), []);
} finally {
cleanup(targetDir);
}
});
});
// ─── Section 13: Hooks copy, manifest, uninstall settings cleanup ─────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
});
const isWindows = process.platform === 'win32';
describe('#1755: .sh hooks are copied and executable after install', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-hook-copy-'); });
afterEach(() => { cleanup(tmpDir); });
test('all expected hooks are copied from hooks/dist/ to target', () => {
const hooksDest = path.join(tmpDir, 'hooks');
simulateHookCopy(HOOKS_DIST, hooksDest);
for (const hook of EXPECTED_ALL_HOOKS) {
assert.ok(fs.existsSync(path.join(hooksDest, hook)), `${hook} should exist`);
}
});
test('.sh hooks are executable after copy', {
skip: isWindows ? 'Windows has no POSIX file permissions' : false,
}, () => {
const hooksDest = path.join(tmpDir, 'hooks');
simulateHookCopy(HOOKS_DIST, hooksDest);
for (const sh of EXPECTED_SH_HOOKS) {
const stat = fs.statSync(path.join(hooksDest, sh));
assert.ok((stat.mode & 0o111) !== 0, `${sh} should be executable`);
}
});
test('.js hooks are executable after copy', {
skip: isWindows ? 'Windows has no POSIX file permissions' : false,
}, () => {
const hooksDest = path.join(tmpDir, 'hooks');
simulateHookCopy(HOOKS_DIST, hooksDest);
for (const js of EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'))) {
const stat = fs.statSync(path.join(hooksDest, js));
assert.ok((stat.mode & 0o111) !== 0, `${js} should be executable`);
}
});
});
// Migrated (#455): uses typed export GSD_UNINSTALL_HOOKS instead of
// source-grep assertions on bin/install.js for the uninstall hook list tests.
describe('install.js uninstall hooks registry (typed assertions)', () => {
test('GSD_UNINSTALL_HOOKS is a non-empty array', () => {
assert.ok(Array.isArray(GSD_UNINSTALL_HOOKS), 'GSD_UNINSTALL_HOOKS must be an array');
assert.ok(GSD_UNINSTALL_HOOKS.length > 0, 'GSD_UNINSTALL_HOOKS must not be empty');
});
test('gsd-workflow-guard.js is in GSD_UNINSTALL_HOOKS', () => {
assert.ok(
GSD_UNINSTALL_HOOKS.includes('gsd-workflow-guard.js'),
'GSD_UNINSTALL_HOOKS must include gsd-workflow-guard.js'
);
});
test('phantom gsd-check-update.sh is NOT in GSD_UNINSTALL_HOOKS', () => {
assert.ok(
!GSD_UNINSTALL_HOOKS.includes('gsd-check-update.sh'),
'GSD_UNINSTALL_HOOKS must not include the phantom gsd-check-update.sh entry'
);
});
test('GSD_UNINSTALL_HOOKS covers all 3 opt-in bash hooks', () => {
const required = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
for (const hook of required) {
assert.ok(
GSD_UNINSTALL_HOOKS.includes(hook),
`GSD_UNINSTALL_HOOKS must include ${hook}`
);
}
});
test('GSD_UNINSTALL_HOOKS covers core JS hooks', () => {
const coreJsHooks = [
'gsd-check-update.js', 'gsd-statusline.js', 'gsd-session-state.sh',
'gsd-context-monitor.js', 'gsd-phase-boundary.sh', 'gsd-prompt-guard.js',
'gsd-read-guard.js', 'gsd-validate-commit.sh', 'gsd-workflow-guard.js',
];
for (const hook of coreJsHooks) {
assert.ok(
GSD_UNINSTALL_HOOKS.includes(hook),
`GSD_UNINSTALL_HOOKS must include ${hook}`
);
}
});
});
describe('writeManifest includes .sh hooks', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-manifest-');
const hooksDir = path.join(tmpDir, 'hooks');
simulateHookCopy(HOOKS_DIST, hooksDir);
});
afterEach(() => { cleanup(tmpDir); });
test('manifest contains .sh hook entries', () => {
writeManifest(tmpDir, 'claude');
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
for (const sh of EXPECTED_SH_HOOKS) {
assert.ok(manifest.files['hooks/' + sh], `manifest should contain hash for ${sh}`);
}
});
test('manifest contains .js hook entries', () => {
writeManifest(tmpDir, 'claude');
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
for (const js of EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'))) {
assert.ok(manifest.files['hooks/' + js], `manifest should contain hash for ${js}`);
}
});
});
describe('uninstall settings cleanup preserves user hooks', () => {
const isGsdHook = (cmd) =>
cmd && (cmd.includes('gsd-check-update') || cmd.includes('gsd-statusline') ||
cmd.includes('gsd-session-state') || cmd.includes('gsd-context-monitor') ||
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
cmd.includes('gsd-read-guard') || cmd.includes('gsd-validate-commit') ||
cmd.includes('gsd-workflow-guard'));
function filterGsdHooks(entries) {
return entries
.map(e => {
if (!e.hooks || !Array.isArray(e.hooks)) return e;
e.hooks = e.hooks.filter(h => !isGsdHook(h.command));
return e.hooks.length > 0 ? e : null;
})
.filter(Boolean);
}
test('mixed entry preserves user hooks', () => {
const entries = [{
matcher: 'Bash',
hooks: [
{ type: 'command', command: 'node /path/gsd-prompt-guard.js' },
{ type: 'command', command: 'bash /my/custom-lint.sh' },
],
}];
const result = filterGsdHooks(entries);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].hooks.length, 1);
assert.ok(result[0].hooks[0].command.includes('custom-lint'));
});
test('entry with only GSD hooks is fully removed', () => {
const entries = [{
hooks: [
{ type: 'command', command: 'node /path/gsd-check-update.js' },
{ type: 'command', command: 'node /path/gsd-statusline.js' },
],
}];
assert.strictEqual(filterGsdHooks(entries).length, 0);
});
test('entry with only user hooks is untouched', () => {
const entries = [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'bash /my/pre-check.sh' }] }];
const result = filterGsdHooks(entries);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].hooks.length, 1);
});
test('non-array hook entries are preserved (#1825)', () => {
const entries = [
{ type: 'custom', command: 'echo hello' },
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node /path/gsd-prompt-guard.js' }] },
{ url: 'https://example.com/webhook' },
];
const result = filterGsdHooks(JSON.parse(JSON.stringify(entries)));
assert.strictEqual(result.length, 2);
assert.deepStrictEqual(result[0], { type: 'custom', command: 'echo hello' });
assert.deepStrictEqual(result[1], { url: 'https://example.com/webhook' });
});
test('all GSD hook names are recognised', () => {
const cmds = [
'node /path/gsd-check-update.js', 'node /path/gsd-statusline.js',
'bash /path/gsd-session-state.sh', 'node /path/gsd-context-monitor.js',
'bash /path/gsd-phase-boundary.sh', 'node /path/gsd-prompt-guard.js',
'node /path/gsd-read-guard.js', 'bash /path/gsd-validate-commit.sh',
'node /path/gsd-workflow-guard.js',
];
for (const cmd of cmds) {
assert.ok(isGsdHook(cmd), `should recognise: ${cmd}`);
}
});
});
describe('Codex legacy gsd-update-check migration', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
test('install.js strips legacy gsd-update-check hook blocks', () => {
assert.ok(src.includes('gsd-update-check') && src.includes('replace('));
});
test('migration regex removes LF legacy hook block', () => {
const legacyBlock = ['[features]', 'codex_hooks = true', '',
'# GSD Hooks', '[[hooks]]', 'event = "SessionStart"',
'command = "node /old/path/gsd-update-check.js"', ''].join('\n');
let content = legacyBlock.replace(
/\n# GSD Hooks\n\[\[hooks\]\]\nevent = "SessionStart"\ncommand = "node [^\n]*gsd-update-check\.js"\n/g, '\n',
);
assert.ok(!content.includes('gsd-update-check'));
assert.ok(content.includes('[features]'));
});
test('migration regex removes CRLF legacy hook block', () => {
const legacyBlock = ['[features]', 'codex_hooks = true', '',
'# GSD Hooks', '[[hooks]]', 'event = "SessionStart"',
'command = "node /old/path/gsd-update-check.js"', ''].join('\r\n');
let content = legacyBlock.replace(
/\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = "SessionStart"\r\ncommand = "node [^\r\n]*gsd-update-check\.js"\r\n/g, '\r\n',
);
assert.ok(!content.includes('gsd-update-check'));
assert.ok(content.includes('[features]'));
});
});