skillrepo 3.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.
- package/README.md +72 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +132 -14
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/test/commands/init.test.mjs +211 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +158 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
|
@@ -162,3 +162,161 @@ describe("runUpdate — flag handling", () => {
|
|
|
162
162
|
assert.ok(existsSync(dir));
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
// ── --session-hook mode (#884) ────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// Session-hook mode is what the Claude Code SessionStart hook #884's
|
|
169
|
+
// installer writes invokes: `skillrepo update --session-hook`. The
|
|
170
|
+
// contract: exit 0 on ALL errors, silent on 304, one-line summary on
|
|
171
|
+
// changes, one-line failure message on error. A sync failure must
|
|
172
|
+
// NEVER block a session start.
|
|
173
|
+
//
|
|
174
|
+
// Architect pre-flight review flagged the flag-acceptance bug as
|
|
175
|
+
// "most likely silent-failure bug" — if `resolveFlags` rejects the
|
|
176
|
+
// flag before the exit-0 contract activates, every session-start
|
|
177
|
+
// hook silently fails with a symptom indistinguishable from 304.
|
|
178
|
+
// Tests in this suite specifically guard against that.
|
|
179
|
+
|
|
180
|
+
describe("runUpdate — --session-hook contract", () => {
|
|
181
|
+
beforeEach(setup);
|
|
182
|
+
afterEach(teardown);
|
|
183
|
+
|
|
184
|
+
it("accepts the --session-hook flag without throwing a validation error", async () => {
|
|
185
|
+
// THE architect-flagged regression guard. If a future refactor
|
|
186
|
+
// removes the acceptPositional callback from update.mjs,
|
|
187
|
+
// resolveFlags throws `Unknown argument: --session-hook` before
|
|
188
|
+
// the exit-0 contract has a chance to fire. The `|| true` shell
|
|
189
|
+
// backstop would catch it, but the user would lose the failure
|
|
190
|
+
// message AND every sync would silently no-op. This test makes
|
|
191
|
+
// that class of regression fail loudly at unit-test time.
|
|
192
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
193
|
+
await assert.doesNotReject(
|
|
194
|
+
() =>
|
|
195
|
+
runUpdate(
|
|
196
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
197
|
+
{ stdout },
|
|
198
|
+
),
|
|
199
|
+
"update --session-hook must NOT throw Unknown argument",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
|
|
204
|
+
// INTENT: 304 means nothing changed. Printing "Syncing..." on
|
|
205
|
+
// every session start with no value to show would clutter the
|
|
206
|
+
// system-message surface. The contract is SILENCE on 304.
|
|
207
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
208
|
+
await runUpdate(
|
|
209
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
210
|
+
{ stdout },
|
|
211
|
+
);
|
|
212
|
+
assert.equal(
|
|
213
|
+
stdout.text(),
|
|
214
|
+
"",
|
|
215
|
+
"session-hook mode must emit zero output when there's nothing to sync",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("prints one line on successful sync with changes", async () => {
|
|
220
|
+
// INTENT: when content CHANGES, surface exactly one actionable
|
|
221
|
+
// line so the user knows their library updated. No banners, no
|
|
222
|
+
// multi-line output — a single line the hook runner can render
|
|
223
|
+
// as a system message.
|
|
224
|
+
server.setLibraryResponse({
|
|
225
|
+
skills: [makeSkill("sync-hook-test")],
|
|
226
|
+
removals: [],
|
|
227
|
+
syncedAt: "x",
|
|
228
|
+
});
|
|
229
|
+
await runUpdate(
|
|
230
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
231
|
+
{ stdout },
|
|
232
|
+
);
|
|
233
|
+
const out = stdout.text();
|
|
234
|
+
assert.match(out, /^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("exits 0 with a failure message on a server error instead of throwing", async () => {
|
|
238
|
+
// THE load-bearing contract. A sync failure must NEVER block a
|
|
239
|
+
// session start. runUpdate called with --session-hook against an
|
|
240
|
+
// unreachable server must return normally (no throw) and emit a
|
|
241
|
+
// one-line failure message.
|
|
242
|
+
//
|
|
243
|
+
// We simulate an unreachable server by pointing at an invalid
|
|
244
|
+
// port. Without the exit-0 contract, this would throw
|
|
245
|
+
// networkError from runSync; WITH the contract, the catch block
|
|
246
|
+
// swallows it and prints a failure message.
|
|
247
|
+
await assert.doesNotReject(
|
|
248
|
+
() =>
|
|
249
|
+
runUpdate(
|
|
250
|
+
["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
|
|
251
|
+
{ stdout },
|
|
252
|
+
),
|
|
253
|
+
"session-hook mode must NEVER throw — session start must proceed",
|
|
254
|
+
);
|
|
255
|
+
const out = stdout.text();
|
|
256
|
+
assert.match(out, /^\[SkillRepo\] Sync failed: .+\n$/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("exits 0 with a failure message on auth error (invalid key)", async () => {
|
|
260
|
+
// Auth error is non-retryable AND non-blocking for session start.
|
|
261
|
+
// If the user's key was rotated, we want them to see "access key
|
|
262
|
+
// invalid" in their session system message, but the session must
|
|
263
|
+
// still open normally.
|
|
264
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
265
|
+
await assert.doesNotReject(
|
|
266
|
+
() =>
|
|
267
|
+
runUpdate(
|
|
268
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
269
|
+
{ stdout },
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
const out = stdout.text();
|
|
273
|
+
assert.match(out, /Sync failed/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("exits 0 with a failure message when no access key is configured", async () => {
|
|
277
|
+
// INTENT: the VERY first session after installing skillrepo, if
|
|
278
|
+
// init hasn't run yet, has no key. The hook must not block the
|
|
279
|
+
// session. This is a genuine first-run scenario, not a synthetic
|
|
280
|
+
// edge case — users who set up Claude Code before running
|
|
281
|
+
// `skillrepo init` will hit exactly this.
|
|
282
|
+
//
|
|
283
|
+
// No --key flag, no cached config, no env var. resolveFlags
|
|
284
|
+
// normally throws authError in this case. Session-hook mode
|
|
285
|
+
// must catch that and exit 0 anyway.
|
|
286
|
+
await assert.doesNotReject(
|
|
287
|
+
() =>
|
|
288
|
+
runUpdate(["--url", serverUrl, "--session-hook"], { stdout }),
|
|
289
|
+
"session-hook mode with no key must NEVER throw",
|
|
290
|
+
);
|
|
291
|
+
const out = stdout.text();
|
|
292
|
+
assert.match(out, /Sync failed/, "failure message must still surface");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("exits 0 when the --session-hook flag appears after other flags", async () => {
|
|
296
|
+
// INTENT: flag ordering is not part of the contract. The installer
|
|
297
|
+
// writes a specific order today, but a user hand-editing their
|
|
298
|
+
// settings (or a future refactor moving flags around) must not
|
|
299
|
+
// break the exit-0 behavior.
|
|
300
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
301
|
+
await assert.doesNotReject(
|
|
302
|
+
() =>
|
|
303
|
+
runUpdate(
|
|
304
|
+
["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
|
|
305
|
+
{ stdout },
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
assert.equal(stdout.text(), "", "still silent on 304 regardless of flag order");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("exits 0 silently when the server returns zero deltas (empty success, not 304)", async () => {
|
|
312
|
+
// INTENT: 304 isn't the only silent-success case. A 200 response
|
|
313
|
+
// with zero added/updated/removed (a fresh sync that happened to
|
|
314
|
+
// produce no work) is also silent — same UX reasoning as 304.
|
|
315
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
316
|
+
await runUpdate(
|
|
317
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
318
|
+
{ stdout },
|
|
319
|
+
);
|
|
320
|
+
assert.equal(stdout.text(), "", "zero-delta 200 is silent");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -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
|
+
});
|