fix(3659): applySurface prunes skill dirs on cluster disable (#3766)
* test(3659): add regression tests for applySurface skill-dir pruning on cluster disable Tests that applySurface with claude global scope correctly prunes ~/.claude/skills/gsd-STEM/ dirs for disabled clusters, preserves gsd-STEM dirs in enabled clusters, leaves non-gsd user dirs untouched, and is idempotent across two consecutive calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(3659): applySurface now prunes ~/.claude/skills/gsd-STEM/ on cluster disable Root cause: surface.md directed the AI to use RUNTIME_CONFIG_DIR=~/.claude/skills (the skills sub-directory) instead of the base Claude config dir (~/.claude). When runtimeConfigDir=~/.claude/skills and scope=global, the layout computes dest=~/.claude/skills/skills — the wrong target — so pruning never reached the actual gsd-STEM dirs in ~/.claude/skills/. Fix: - surface.md: correct RUNTIME_CONFIG_DIR to use the base config dir (~/.claude), add explicit SCOPE=global, and update all path references in execution_context. Surface state file moves from ~/.claude/skills/.gsd-surface.json to ~/.claude/.gsd-surface.json, matching install/uninstall conventions. - surface.cjs: extract pruneSkillDirs() as a shared helper (single point of truth for gsd-STEM dir removal). _syncGsdDir now delegates to it instead of having the ownership/prune logic inline. Export pruneSkillDirs for callers that need stand-alone pruning without a full applySurface pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(3659): update changeset to reference PR #3766 * fix(3659): manifest-membership gate on pruneSkillDirs prevents user gsd-* dir data loss Finding 1 (CRITICAL): the prefixed branch previously deleted any on-disk dir that matched the 'gsd-' prefix and was not in retainedNames. A user-created gsd-mything/ would be silently destroyed. Fix: deletion now requires BOTH prefix match AND manifest membership (stem present in manifest). Dirs that match the prefix but are not manifest-known are preserved with a process.stderr warning so the user knows the dir was kept. Finding 2 (type guard): the Hermes (empty-prefix) branch passed manifest directly to new Set([...manifest.keys()]) without verifying it is actually a Map. A truthy non-Map would throw. Fix: safeManifest = (manifest instanceof Map) ? manifest : null, used in both branches. Non-Map manifest triggers the same conservative no-deletions path already used when manifest is absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(3659): counter-test for all-clusters-disabled + user gsd-* dir preservation Finding 3: add test (e) that disables every cluster (Object.keys(CLUSTERS)) and asserts three things: 1. All GSD-owned skill dirs (gsd-explore/, gsd-help/) are removed. 2. Non-gsd user dir (my-custom-skill/) is preserved. 3. User-created gsd-mything/ (prefix match, not in manifest) is preserved — this is the critical regression guard for the Finding 1 data-loss fix. Also update the existing _syncGsdDir skills-kind test in surface-apply.test.cjs to pass a manifest that declares old-skill as GSD-owned. Without a manifest the new conservative path correctly preserves all unknown gsd-* dirs, which broke the pre-existing no-manifest assertion; supplying the manifest restores the expected pruning behavior and documents the required calling contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(3659): address pr-review-toolkit + codex review findings - Collapse redundant if/else in _syncGsdDir - Collapse duplicate canonicalStems branches in pruneSkillDirs - Update stale module-header comment (config-dir root) - Clarify dead isGsdOwned guard comment - Log rmSync failures to stderr - Add pruneSkillDirs to module-header Exports JSDoc - Remove unused imports in bug-3659 test file Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -196,10 +196,22 @@ describe('applySurface', () => {
|
||||
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8');
|
||||
|
||||
const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir };
|
||||
_syncGsdDir(stagedDir, destDir, skillsKind);
|
||||
|
||||
// Build a minimal manifest that includes the GSD-owned stems so that the
|
||||
// manifest-membership gate (Finding 1 fix) correctly identifies gsd-old-skill
|
||||
// as GSD-owned and prunes it. Without a manifest the new code conservatively
|
||||
// preserves all gsd-* dirs it cannot confirm are GSD-owned.
|
||||
const manifest = new Map([
|
||||
['help', []],
|
||||
['update', []],
|
||||
['old-skill', []], // GSD-owned stale stem — must be pruned when not in staged set
|
||||
]);
|
||||
|
||||
_syncGsdDir(stagedDir, destDir, skillsKind, manifest);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied');
|
||||
assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied');
|
||||
// stale gsd- dir removed (it's in the manifest so it is GSD-owned, but not in staged set)
|
||||
assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed');
|
||||
assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user