fix(uninstall): provenance-gate the shape-2 and cursor sweeps; document the alias-name coupling

Three ways gstack-uninstall could touch a user's own skills:

- Shape 2 (real dir + symlinked SKILL.md) matched the link target against a
  bare *gstack* substring, so a skill symlinked from ~/tools/gstack-fork/ was
  wiped on uninstall. The gate now requires "gstack" as an anchored path
  segment (gstack/*|*/gstack/*, same pattern as shape 1) AND the dir name in
  gstack's skill inventory (parity with shape 3); anything else is listed to
  stderr, never deleted.
- The new Cursor removals (~/.cursor/skills/gstack* and repo-local
  .cursor/skills/gstack*) rm -rf'd any glob match with no provenance check,
  so a hand-written ~/.cursor/skills/gstack-fork-notes was swept. Real dirs
  now require the AUTO-GENERATED banner in SKILL.md; non-matching dirs are
  kept and listed. Legacy codex/factory/kiro globs are untouched (tracked in
  TODOS as a follow-up).
- The _INVENTORY seed list hardcodes alias names created by setup's
  _install_alias_skill_md; both sites now carry mirrored keep-in-sync
  comments so a renamed alias can't silently strand its dir.

The skipped-entry report moves to the end of the run so cursor skips are
listed alongside the Claude ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 14:13:14 -07:00
co-authored by Claude Fable 5
parent 166ac2ceee
commit 4a95ce61a0
4 changed files with 143 additions and 22 deletions
+49 -2
View File
@@ -148,9 +148,56 @@ describe.skipIf(process.platform === 'win32')(
expect(fs.existsSync(dir)).toBe(false);
});
test('SKILL.md symlink into a gstack-SUBSTRING path (gstack-fork) → kept and listed', () => {
// DM5: the shape-2 gate must match "gstack" as an anchored path
// segment, not a substring — a user's own skill whose SKILL.md links
// into ~/tools/gstack-fork/ is NOT ours, even when the dir name
// collides with a real gstack skill (here: review, in the inventory).
// The anchored gate only matches a literal /gstack/ path segment, so
// the tmpdir must not carry one (shared-process shard runs can leave
// $TMPDIR pointing into a gstack worktree — same hazard as the
// "pointing elsewhere" test below). Fall back to a fixed neutral root
// and ASSERT the precondition.
let neutralRoot = os.tmpdir();
if (neutralRoot.split(path.sep).includes('gstack')) neutralRoot = '/private' + path.sep + 'tmp';
const forkRoot = fs.mkdtempSync(path.join(neutralRoot, 'tools-'));
expect(forkRoot.split(path.sep).includes('gstack')).toBe(false);
const forkSrc = path.join(forkRoot, 'gstack-fork', 'review');
fs.mkdirSync(forkSrc, { recursive: true });
fs.writeFileSync(path.join(forkSrc, 'SKILL.md'), skillMd('review'));
const dir = path.join(skillsDir, 'review');
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(path.join(forkSrc, 'SKILL.md'), path.join(dir, 'SKILL.md'));
try {
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(dir)).toBe(true);
expect(r.stderr).toContain('left in place');
expect(r.stderr).toContain(path.join('skills', 'review'));
} finally {
fs.rmSync(forkRoot, { recursive: true, force: true });
}
});
test('SKILL.md symlink into gstack but name NOT in inventory → kept and listed', () => {
// Shape 2 now carries the same inventory gate as shape 3: a dir whose
// name setup could never have created is skipped even when its
// SKILL.md target resolves into the install root.
const dir = path.join(skillsDir, 'my-custom-wrapper');
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(path.join(installRoot, 'qa', 'SKILL.md'), path.join(dir, 'SKILL.md'));
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(dir)).toBe(true);
expect(r.stderr).toContain('my-custom-wrapper');
});
test('SKILL.md symlink pointing elsewhere → kept and listed', () => {
// Target path must not contain "gstack" anywhere (the provenance match
// is a substring check, mirroring setup's cleanup helpers) — the suite
// Target path must not contain a gstack path segment (the provenance
// match is anchored: gstack/*|*/gstack/*; keeping the stricter
// no-substring precondition costs nothing) — the suite
// tmpdir prefix does, so use a separate neutral tmpdir. os.tmpdir()
// reads $TMPDIR at CALL time, and in shared-process shard runs a
// neighboring test can leave it pointing at a gstack-containing path —