skillrepo 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Integration tests for src/lib/file-write.mjs (PR1 of #646).
3
+ *
4
+ * Differs from the unit tests in that we compose the public API against
5
+ * a real temp filesystem and assert on multi-step behaviors:
6
+ * - End-to-end skill round-trip (write → read back → verify)
7
+ * - Update path leaves NO orphan state on success
8
+ * - Sequential rewrites are atomic from the reader's perspective
9
+ * - Recovery from injected partial state (simulated mid-write crash)
10
+ * - Multi-target writes produce identical content
11
+ *
12
+ * These tests touch the real filesystem in a temp directory. They do
13
+ * not require network or a running server.
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import {
19
+ mkdtempSync,
20
+ rmSync,
21
+ mkdirSync,
22
+ writeFileSync,
23
+ existsSync,
24
+ readFileSync,
25
+ readdirSync,
26
+ } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+
30
+ import {
31
+ writeSkillDir,
32
+ removeSkillDir,
33
+ cleanupOrphans,
34
+ resolvePlacementDir,
35
+ } from "../../lib/file-write.mjs";
36
+
37
+ let sandbox;
38
+ let originalCwd;
39
+ let originalHome;
40
+
41
+ function setupSandbox() {
42
+ sandbox = mkdtempSync(join(tmpdir(), "cli-fw-int-"));
43
+ mkdirSync(join(sandbox, "project"), { recursive: true });
44
+ mkdirSync(join(sandbox, "home"), { recursive: true });
45
+ originalCwd = process.cwd();
46
+ originalHome = process.env.HOME;
47
+ process.chdir(join(sandbox, "project"));
48
+ process.env.HOME = join(sandbox, "home");
49
+ }
50
+
51
+ function teardownSandbox() {
52
+ process.chdir(originalCwd);
53
+ process.env.HOME = originalHome;
54
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
55
+ }
56
+
57
+ function multiFileSkill(name = "pdf-helper") {
58
+ return {
59
+ owner: "alice",
60
+ name,
61
+ files: [
62
+ {
63
+ path: "SKILL.md",
64
+ content: `---\nname: ${name}\ndescription: Multi-file integration test skill.\n---\n\nMain body.\n`,
65
+ },
66
+ { path: "scripts/extract.py", content: "import sys\nprint(sys.argv)\n" },
67
+ { path: "scripts/format.sh", content: "#!/bin/bash\necho ok\n" },
68
+ { path: "references/REFERENCE.md", content: "# Reference\n\nDetails.\n" },
69
+ { path: "assets/template.json", content: '{"version": 1}\n' },
70
+ // Path-preserving non-spec dir — proves Defect A fix end-to-end
71
+ { path: "lib/helper.py", content: "def helper():\n return 42\n" },
72
+ ],
73
+ };
74
+ }
75
+
76
+ describe("file-write.mjs integration — round-trip", () => {
77
+ beforeEach(setupSandbox);
78
+ afterEach(teardownSandbox);
79
+
80
+ it("writes a 6-file skill and reads back identical bytes", () => {
81
+ const skill = multiFileSkill();
82
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
83
+ assert.equal(result.written.length, 1);
84
+ const dir = result.written[0];
85
+
86
+ for (const file of skill.files) {
87
+ const onDisk = readFileSync(join(dir, file.path), "utf-8");
88
+ assert.equal(onDisk, file.content, `Mismatch for ${file.path}`);
89
+ }
90
+ });
91
+
92
+ it("writes the same skill identically to multiple targets", () => {
93
+ const skill = multiFileSkill();
94
+ const result = writeSkillDir(skill, { vendors: ["claudeCode", "cursor"] });
95
+ assert.equal(result.written.length, 2);
96
+
97
+ const [firstDir, secondDir] = result.written;
98
+ for (const file of skill.files) {
99
+ const a = readFileSync(join(firstDir, file.path), "utf-8");
100
+ const b = readFileSync(join(secondDir, file.path), "utf-8");
101
+ assert.equal(a, b, `Targets diverged for ${file.path}`);
102
+ }
103
+ });
104
+
105
+ it("writes to global dir under HOME with --global", () => {
106
+ const skill = multiFileSkill();
107
+ const result = writeSkillDir(skill, { global: true });
108
+ assert.equal(result.written.length, 1);
109
+ const dir = result.written[0];
110
+ assert.ok(dir.startsWith(process.env.HOME), "should write under HOME");
111
+ for (const file of skill.files) {
112
+ assert.ok(existsSync(join(dir, file.path)));
113
+ }
114
+ });
115
+ });
116
+
117
+ describe("file-write.mjs integration — update path", () => {
118
+ beforeEach(setupSandbox);
119
+ afterEach(teardownSandbox);
120
+
121
+ it("leaves no .tmp/ or .old/ directories after a successful write", () => {
122
+ writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
123
+ const root = join(process.cwd(), ".claude", "skills");
124
+ const entries = readdirSync(root);
125
+ for (const entry of entries) {
126
+ assert.ok(!entry.endsWith(".tmp"), `Unexpected .tmp leftover: ${entry}`);
127
+ assert.ok(!entry.endsWith(".old"), `Unexpected .old leftover: ${entry}`);
128
+ }
129
+ });
130
+
131
+ it("removes deleted files when overwriting", () => {
132
+ // First write
133
+ writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
134
+
135
+ // Second write removes the lib/ and references/ dirs
136
+ const trimmed = {
137
+ owner: "alice",
138
+ name: "pdf-helper",
139
+ files: [
140
+ { path: "SKILL.md", content: "---\nname: pdf-helper\n---\nTrimmed.\n" },
141
+ { path: "scripts/extract.py", content: "print('only this remains')\n" },
142
+ ],
143
+ };
144
+ const result = writeSkillDir(trimmed, { vendors: ["claudeCode"] });
145
+ const dir = result.written[0];
146
+
147
+ assert.ok(existsSync(join(dir, "SKILL.md")));
148
+ assert.ok(existsSync(join(dir, "scripts/extract.py")));
149
+ assert.ok(!existsSync(join(dir, "lib/helper.py")), "lib/ should be gone");
150
+ assert.ok(!existsSync(join(dir, "references/REFERENCE.md")), "references/ should be gone");
151
+ assert.ok(!existsSync(join(dir, "assets/template.json")), "assets/ should be gone");
152
+ });
153
+
154
+ it("multiple sequential updates each end in a clean state", () => {
155
+ for (let i = 0; i < 5; i++) {
156
+ const skill = {
157
+ owner: "alice",
158
+ name: "pdf-helper",
159
+ files: [
160
+ { path: "SKILL.md", content: `---\nname: pdf-helper\n---\nIteration ${i}.\n` },
161
+ { path: "scripts/iter.py", content: `# iteration ${i}\n` },
162
+ ],
163
+ };
164
+ writeSkillDir(skill, { vendors: ["claudeCode"] });
165
+
166
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
167
+ const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
168
+ assert.match(skillMd, new RegExp(`Iteration ${i}`));
169
+
170
+ // No leftovers
171
+ const root = join(process.cwd(), ".claude", "skills");
172
+ const entries = readdirSync(root);
173
+ for (const entry of entries) {
174
+ assert.ok(!entry.endsWith(".tmp"));
175
+ assert.ok(!entry.endsWith(".old"));
176
+ }
177
+ }
178
+ });
179
+ });
180
+
181
+ describe("file-write.mjs integration — orphan recovery", () => {
182
+ beforeEach(setupSandbox);
183
+ afterEach(teardownSandbox);
184
+
185
+ it("cleanupOrphans removes injected .tmp/ (with live siblings) and .old/ across all roots", () => {
186
+ // Inject orphans in claudeProject root, claudeGlobal root, and
187
+ // projectFallback root. .tmp/ entries need a live sibling so the
188
+ // safety invariant doesn't preserve them as recoverable.
189
+ const claudeProjectRoot = join(process.cwd(), ".claude", "skills");
190
+ const fallbackRoot = join(process.cwd(), "skills");
191
+ const globalRoot = join(process.env.HOME, ".claude", "skills");
192
+ mkdirSync(claudeProjectRoot, { recursive: true });
193
+ mkdirSync(fallbackRoot, { recursive: true });
194
+ mkdirSync(globalRoot, { recursive: true });
195
+
196
+ // ghost-1: live sibling + .tmp + .old (.tmp gets cleaned because live exists)
197
+ mkdirSync(join(claudeProjectRoot, "ghost-1"));
198
+ mkdirSync(join(claudeProjectRoot, "ghost-1.tmp"));
199
+ mkdirSync(join(claudeProjectRoot, "ghost-1.old"));
200
+ writeFileSync(join(claudeProjectRoot, "ghost-1.tmp", "garbage.txt"), "leftover");
201
+
202
+ // ghost-2: just a .old/ in the fallback root (.old has no invariant)
203
+ mkdirSync(join(fallbackRoot, "ghost-2.old"));
204
+
205
+ // ghost-3: live sibling + .tmp in the global root
206
+ mkdirSync(join(globalRoot, "ghost-3"));
207
+ mkdirSync(join(globalRoot, "ghost-3.tmp"));
208
+
209
+ const result = cleanupOrphans({});
210
+ assert.equal(result.cleaned.length, 4);
211
+
212
+ assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.tmp")));
213
+ assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.old")));
214
+ assert.ok(!existsSync(join(fallbackRoot, "ghost-2.old")));
215
+ assert.ok(!existsSync(join(globalRoot, "ghost-3.tmp")));
216
+ // Live siblings preserved
217
+ assert.ok(existsSync(join(claudeProjectRoot, "ghost-1")));
218
+ assert.ok(existsSync(join(globalRoot, "ghost-3")));
219
+ });
220
+
221
+ it("cleanupOrphans preserves a .tmp/ whose live target is missing (Windows recovery)", () => {
222
+ // This is the safety invariant proof — a .tmp/ with no live
223
+ // sibling is the user's only copy of a skill that crashed
224
+ // mid-rename on Windows, and must not be deleted.
225
+ const root = join(process.cwd(), ".claude", "skills");
226
+ mkdirSync(join(root, "recoverable.tmp"), { recursive: true });
227
+ writeFileSync(join(root, "recoverable.tmp", "SKILL.md"), "---\nname: recoverable\n---\nUser's only copy.\n");
228
+
229
+ const result = cleanupOrphans({});
230
+ assert.equal(result.cleaned.length, 0, "no orphans should be cleaned");
231
+ assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")), "recoverable .tmp must survive");
232
+ });
233
+
234
+ it("write recovers when a stale .tmp/ from a crashed run is present", () => {
235
+ const skill = multiFileSkill();
236
+ // Pre-populate stale .tmp/ with garbage that would conflict
237
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
238
+ mkdirSync(`${targetDir}.tmp`, { recursive: true });
239
+ writeFileSync(`${targetDir}.tmp/old-garbage.txt`, "x");
240
+
241
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
242
+ const dir = result.written[0];
243
+
244
+ // Old garbage should be gone; new files should be present
245
+ assert.ok(!existsSync(join(dir, "old-garbage.txt")));
246
+ for (const file of skill.files) {
247
+ assert.ok(existsSync(join(dir, file.path)));
248
+ }
249
+ // No .tmp/ leftover
250
+ assert.ok(!existsSync(`${targetDir}.tmp`));
251
+ });
252
+ });
253
+
254
+ describe("file-write.mjs integration — remove + .gitignore", () => {
255
+ beforeEach(setupSandbox);
256
+ afterEach(teardownSandbox);
257
+
258
+ it("write then remove leaves the skills root empty", () => {
259
+ writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
260
+ const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode"] });
261
+ assert.equal(result.removed.length, 1);
262
+ const root = join(process.cwd(), ".claude", "skills");
263
+ if (existsSync(root)) {
264
+ const entries = readdirSync(root);
265
+ assert.equal(entries.length, 0, "skills root should be empty after remove");
266
+ }
267
+ });
268
+
269
+ it("write to fallback creates .gitignore entry; remove does not delete it", () => {
270
+ writeSkillDir(multiFileSkill(), { vendors: ["cursor"] });
271
+ const giBefore = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
272
+ assert.match(giBefore, /\/skills\//);
273
+
274
+ removeSkillDir("pdf-helper", { vendors: ["cursor"] });
275
+ const giAfter = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
276
+ // remove should not touch .gitignore
277
+ assert.equal(giAfter, giBefore);
278
+ });
279
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * CI enforcement test for the artifact registry (#885).
3
+ *
4
+ * Architect tightening #1: the artifact-registry test must be
5
+ * BIDIRECTIONAL. It's not enough to iterate registry entries and
6
+ * assert each has a matching remover — that catches "descriptor
7
+ * without implementation" but misses the more dangerous direction,
8
+ * "new merger writes a file we never catalogued."
9
+ *
10
+ * The checks below:
11
+ *
12
+ * 1. Filesystem → registry:
13
+ * - Every file in `src/lib/mergers/*.mjs` (installer modules)
14
+ * must be declared in MERGER_EXPECTED, which maps installer
15
+ * names to the registry ids they produce. A new merger file
16
+ * without an expected-set entry FAILS this test.
17
+ * - Every file in `src/lib/removers/*.mjs` (uninstaller
18
+ * modules) must be declared in REMOVER_EXPECTED, which
19
+ * maps remover names to the registry ids they delete. A new
20
+ * remover file without an expected-set entry FAILS.
21
+ *
22
+ * 2. Registry → filesystem:
23
+ * - Every descriptor id in ARTIFACT_REGISTRY must appear in
24
+ * REMOVER_EXPECTED or DIRECTORY_ARTIFACT_IDS (the inline-
25
+ * handled directory removals in uninstall.mjs). A new
26
+ * descriptor without a remover mapping FAILS.
27
+ *
28
+ * 3. Mutual consistency:
29
+ * - Every registry id in MERGER_EXPECTED also appears in
30
+ * REMOVER_EXPECTED or DIRECTORY_ARTIFACT_IDS. A merger that
31
+ * writes an artifact with no remover path FAILS.
32
+ *
33
+ * When this test fails, the fix is always either (a) update the
34
+ * expected-set in this test because the change was intentional
35
+ * (adding a new merger/remover pair), or (b) update the registry
36
+ * because the implementation drifted. NEVER silence the test — that
37
+ * defeats the entire drift-protection mechanism.
38
+ */
39
+
40
+ import { describe, it } from "node:test";
41
+ import assert from "node:assert/strict";
42
+ import { readdirSync } from "node:fs";
43
+ import { join, dirname } from "node:path";
44
+ import { fileURLToPath } from "node:url";
45
+
46
+ import { ARTIFACT_REGISTRY } from "../../lib/artifact-registry.mjs";
47
+
48
+ const __dirname = dirname(fileURLToPath(import.meta.url));
49
+ const LIB_DIR = join(__dirname, "..", "..", "lib");
50
+ const MERGERS_DIR = join(LIB_DIR, "mergers");
51
+ const REMOVERS_DIR = join(LIB_DIR, "removers");
52
+
53
+ /**
54
+ * Maps each installer merger file (by basename without `.mjs`) to
55
+ * the registry descriptor ids it produces. The init flow writes
56
+ * through these mergers; every installed artifact must correspond
57
+ * to a catalogued descriptor so the uninstaller can find it.
58
+ *
59
+ * UPDATE this table in the same PR that adds a new merger. The
60
+ * "filesystem → registry" assertion below reads the directory and
61
+ * requires every file to appear here. A missing entry fails with:
62
+ *
63
+ * "merger X.mjs has no entry in MERGER_EXPECTED"
64
+ *
65
+ * which points directly at this file.
66
+ */
67
+ const MERGER_EXPECTED = Object.freeze({
68
+ "claude-mcp": ["claude-mcp-entry"],
69
+ "cursor-mcp": ["cursor-mcp-entry"],
70
+ "vscode-mcp": ["vscode-mcp-entry", "vscode-mcp-input"],
71
+ "windsurf-mcp": ["windsurf-mcp-entry"],
72
+ "env-local": ["env-local-key"],
73
+ gitignore: ["gitignore-entries"],
74
+ // Session-sync installer added by #884. Writes the hook entry that
75
+ // the `settings` remover (src/lib/removers/settings.mjs) identifies
76
+ // via SESSION_HOOK_FINGERPRINT. One installer module, two target
77
+ // paths: project-local (`.claude/settings.local.json`) when called
78
+ // without --global, user-wide (`~/.claude/settings.local.json`)
79
+ // with --global. Each path has its own registry descriptor so
80
+ // `skillrepo uninstall` and `skillrepo uninstall --global` both
81
+ // know to look in the right place. Round-trip contracts are
82
+ // verified in src/test/mergers/session-hook.test.mjs.
83
+ "session-hook": ["settings-session-hook", "settings-session-hook-global"],
84
+ });
85
+
86
+ /**
87
+ * Maps each remover file (by basename without `.mjs`) to the
88
+ * registry descriptor ids it tears down. Symmetric to
89
+ * MERGER_EXPECTED — every file in src/lib/removers/ must appear
90
+ * here.
91
+ */
92
+ const REMOVER_EXPECTED = Object.freeze({
93
+ "claude-mcp": ["claude-mcp-entry"],
94
+ "cursor-mcp": ["cursor-mcp-entry"],
95
+ "vscode-mcp": ["vscode-mcp-entry", "vscode-mcp-input"],
96
+ "windsurf-mcp": ["windsurf-mcp-entry"],
97
+ "env-local": ["env-local-key"],
98
+ gitignore: ["gitignore-entries"],
99
+ // One remover file (settings.mjs), two descriptor ids — project-
100
+ // local and global variants go through the same walk with the
101
+ // settings remover's `{ global }` option.
102
+ settings: ["settings-session-hook", "settings-session-hook-global"],
103
+ });
104
+
105
+ /**
106
+ * Registry ids handled inline by uninstall.mjs's
107
+ * `removeDirectoryArtifact` (not by a dedicated remover module).
108
+ * These are the `kind: "directory"` descriptors in the registry.
109
+ */
110
+ const DIRECTORY_ARTIFACT_IDS = Object.freeze([
111
+ "skills-dir-project",
112
+ "skills-dir-global",
113
+ "global-config-dir",
114
+ ]);
115
+
116
+ /**
117
+ * Read an mjs directory and return an array of basenames without
118
+ * extension, excluding any hidden files or subdirectories.
119
+ */
120
+ function listMjsBasenames(dir) {
121
+ return readdirSync(dir, { withFileTypes: true })
122
+ .filter((d) => d.isFile() && d.name.endsWith(".mjs"))
123
+ .map((d) => d.name.replace(/\.mjs$/, ""));
124
+ }
125
+
126
+ describe("artifact-registry: drift enforcement", () => {
127
+ it("every file in src/lib/mergers/ is declared in MERGER_EXPECTED", () => {
128
+ // Filesystem-first direction: a new merger with no expected-set
129
+ // entry fails here. This is the load-bearing check — it catches
130
+ // "engineer added a new write path without updating the catalog."
131
+ const found = listMjsBasenames(MERGERS_DIR);
132
+ for (const name of found) {
133
+ assert.ok(
134
+ MERGER_EXPECTED[name],
135
+ `merger ${name}.mjs has no entry in MERGER_EXPECTED. ` +
136
+ `Add it to src/test/lib/artifact-registry.test.mjs AND add ` +
137
+ `a matching remover + registry descriptor.`,
138
+ );
139
+ }
140
+ });
141
+
142
+ it("every entry in MERGER_EXPECTED has a corresponding mjs file", () => {
143
+ // Inverse: catches a typo or stale entry in MERGER_EXPECTED
144
+ // (e.g., someone removed a merger file but forgot to trim this
145
+ // table). Low-severity drift but still worth catching.
146
+ const found = new Set(listMjsBasenames(MERGERS_DIR));
147
+ for (const name of Object.keys(MERGER_EXPECTED)) {
148
+ assert.ok(
149
+ found.has(name),
150
+ `MERGER_EXPECTED names ${name}.mjs but the file does not exist at ${MERGERS_DIR}.`,
151
+ );
152
+ }
153
+ });
154
+
155
+ it("every file in src/lib/removers/ is declared in REMOVER_EXPECTED", () => {
156
+ const found = listMjsBasenames(REMOVERS_DIR);
157
+ for (const name of found) {
158
+ assert.ok(
159
+ REMOVER_EXPECTED[name],
160
+ `remover ${name}.mjs has no entry in REMOVER_EXPECTED. ` +
161
+ `Add it to src/test/lib/artifact-registry.test.mjs AND confirm ` +
162
+ `its target ids are in ARTIFACT_REGISTRY.`,
163
+ );
164
+ }
165
+ });
166
+
167
+ it("every entry in REMOVER_EXPECTED has a corresponding mjs file", () => {
168
+ const found = new Set(listMjsBasenames(REMOVERS_DIR));
169
+ for (const name of Object.keys(REMOVER_EXPECTED)) {
170
+ assert.ok(
171
+ found.has(name),
172
+ `REMOVER_EXPECTED names ${name}.mjs but the file does not exist at ${REMOVERS_DIR}.`,
173
+ );
174
+ }
175
+ });
176
+
177
+ it("every registry descriptor id has a remover or is an inline directory removal", () => {
178
+ // Registry-first direction: catches "descriptor added but no
179
+ // one implemented the removal."
180
+ const allImplementedIds = new Set([
181
+ ...Object.values(REMOVER_EXPECTED).flat(),
182
+ ...DIRECTORY_ARTIFACT_IDS,
183
+ ]);
184
+ for (const d of ARTIFACT_REGISTRY) {
185
+ assert.ok(
186
+ allImplementedIds.has(d.id),
187
+ `ARTIFACT_REGISTRY id "${d.id}" has no remover. Either add ` +
188
+ `it to REMOVER_EXPECTED (with a matching src/lib/removers/ ` +
189
+ `file) or to DIRECTORY_ARTIFACT_IDS if it's a whole-directory ` +
190
+ `removal handled inline by uninstall.mjs.`,
191
+ );
192
+ }
193
+ });
194
+
195
+ it("every REMOVER_EXPECTED id exists in ARTIFACT_REGISTRY", () => {
196
+ // Catches a typo in the expected-set that doesn't match a real
197
+ // descriptor.
198
+ const registryIds = new Set(ARTIFACT_REGISTRY.map((d) => d.id));
199
+ for (const [removerName, ids] of Object.entries(REMOVER_EXPECTED)) {
200
+ for (const id of ids) {
201
+ assert.ok(
202
+ registryIds.has(id),
203
+ `REMOVER_EXPECTED["${removerName}"] references id "${id}" ` +
204
+ `but ARTIFACT_REGISTRY has no descriptor with that id.`,
205
+ );
206
+ }
207
+ }
208
+ });
209
+
210
+ it("every MERGER_EXPECTED id has a matching descriptor AND a remover path", () => {
211
+ // Mutual consistency: a merger that installs an artifact must
212
+ // have both a registry descriptor AND a teardown path (direct
213
+ // remover or inline directory removal). Otherwise we're
214
+ // writing state the user can never clean up.
215
+ const registryIds = new Set(ARTIFACT_REGISTRY.map((d) => d.id));
216
+ const allImplementedIds = new Set([
217
+ ...Object.values(REMOVER_EXPECTED).flat(),
218
+ ...DIRECTORY_ARTIFACT_IDS,
219
+ ]);
220
+ for (const [mergerName, ids] of Object.entries(MERGER_EXPECTED)) {
221
+ for (const id of ids) {
222
+ assert.ok(
223
+ registryIds.has(id),
224
+ `MERGER_EXPECTED["${mergerName}"] references id "${id}" ` +
225
+ `but ARTIFACT_REGISTRY has no descriptor with that id.`,
226
+ );
227
+ assert.ok(
228
+ allImplementedIds.has(id),
229
+ `MERGER_EXPECTED["${mergerName}"] installs id "${id}" but ` +
230
+ `no remover is bound to it. A write with no teardown is ` +
231
+ `exactly the drift #885 exists to prevent.`,
232
+ );
233
+ }
234
+ }
235
+ });
236
+
237
+ it("every DIRECTORY_ARTIFACT_ID is a directory-kind descriptor in the registry", () => {
238
+ for (const id of DIRECTORY_ARTIFACT_IDS) {
239
+ const d = ARTIFACT_REGISTRY.find((x) => x.id === id);
240
+ assert.ok(d, `DIRECTORY_ARTIFACT_IDS references unknown id ${id}`);
241
+ assert.equal(
242
+ d.kind,
243
+ "directory",
244
+ `DIRECTORY_ARTIFACT_IDS contains non-directory descriptor ${id} (kind=${d.kind})`,
245
+ );
246
+ }
247
+ });
248
+
249
+ it("descriptor kind enum is limited to the known set", () => {
250
+ // Catches a descriptor that uses a `kind` the dispatch table
251
+ // doesn't understand — would produce a runtime "no remover
252
+ // bound" error at uninstall time rather than a clean test failure.
253
+ const VALID_KINDS = new Set([
254
+ "json-key",
255
+ "json-input",
256
+ "line",
257
+ "section",
258
+ "directory",
259
+ ]);
260
+ for (const d of ARTIFACT_REGISTRY) {
261
+ assert.ok(
262
+ VALID_KINDS.has(d.kind),
263
+ `descriptor ${d.id} has unknown kind "${d.kind}". Allowed: ` +
264
+ `${[...VALID_KINDS].join(", ")}`,
265
+ );
266
+ }
267
+ });
268
+ });