skillrepo 4.9.2 → 4.10.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.
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Unit tests for src/lib/foreign-content.mjs (#2361).
3
+ *
4
+ * The sync-time governance scan: directories inside managed placement
5
+ * roots that the `.last-sync` manifest has no record of are FOREIGN and
6
+ * get surfaced (never touched). Also covers the privacy seam H7's
7
+ * receipts will consume (counts/category only, never names) and the
8
+ * gitignored-declaration probe (D6).
9
+ */
10
+
11
+ import { describe, it, beforeEach, afterEach } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import {
14
+ mkdtempSync,
15
+ mkdirSync,
16
+ writeFileSync,
17
+ rmSync,
18
+ symlinkSync,
19
+ } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { tmpdir, platform } from "node:os";
22
+ import { execFileSync } from "node:child_process";
23
+
24
+ import {
25
+ scanForeignContent,
26
+ formatForeignWarnings,
27
+ receiptViolationSummary,
28
+ checkDeclarationGitignored,
29
+ formatDeclarationIgnoredWarning,
30
+ readGovernanceSeen,
31
+ writeGovernanceSeen,
32
+ commitGovernanceSeen,
33
+ selectNewFindings,
34
+ normalizeStateKey,
35
+ UNMANAGED_CONTENT_CATEGORY,
36
+ } from "../../lib/foreign-content.mjs";
37
+ import { SKILLSET_DECLARATION_FILE } from "../../lib/constants.mjs";
38
+
39
+ let originalCwd;
40
+ let tempDir;
41
+
42
+ beforeEach(() => {
43
+ originalCwd = process.cwd;
44
+ tempDir = mkdtempSync(join(tmpdir(), "cli-foreign-"));
45
+ process.cwd = () => tempDir;
46
+ });
47
+
48
+ afterEach(() => {
49
+ process.cwd = originalCwd;
50
+ rmSync(tempDir, { recursive: true, force: true });
51
+ });
52
+
53
+ /** Seed a skill-shaped directory under a project root. */
54
+ function seedDir(root, name) {
55
+ const dir = join(tempDir, root, name);
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\n---\n`);
58
+ }
59
+
60
+ const MANAGED = {
61
+ "alice/managed-one": { version: "1.0.0" },
62
+ "alice/managed-two": { version: "2.0.0" },
63
+ };
64
+
65
+ describe("scanForeignContent — classification", () => {
66
+ it("returns empty results when roots do not exist", () => {
67
+ const scan = scanForeignContent({
68
+ vendors: ["claudeCode"],
69
+ managedSkills: MANAGED,
70
+ });
71
+ assert.equal(scan.foreignCount, 0);
72
+ assert.equal(scan.roots.length, 1);
73
+ assert.deepEqual(scan.roots[0].foreignDirs, []);
74
+ });
75
+
76
+ it("classifies a manifest-less directory as foreign", () => {
77
+ seedDir(".claude/skills", "hand-rolled");
78
+ const scan = scanForeignContent({
79
+ vendors: ["claudeCode"],
80
+ managedSkills: MANAGED,
81
+ });
82
+ assert.equal(scan.foreignCount, 1);
83
+ assert.deepEqual(scan.roots[0].foreignDirs, ["hand-rolled"]);
84
+ });
85
+
86
+ it("does NOT classify manifest-listed directories as foreign", () => {
87
+ seedDir(".claude/skills", "managed-one");
88
+ seedDir(".claude/skills", "managed-two");
89
+ const scan = scanForeignContent({
90
+ vendors: ["claudeCode"],
91
+ managedSkills: MANAGED,
92
+ });
93
+ assert.equal(scan.foreignCount, 0);
94
+ });
95
+
96
+ it("mixed root — only the unmanaged dirs are foreign, sorted", () => {
97
+ seedDir(".claude/skills", "managed-one");
98
+ seedDir(".claude/skills", "zeta");
99
+ seedDir(".claude/skills", "alpha");
100
+ const scan = scanForeignContent({
101
+ vendors: ["claudeCode"],
102
+ managedSkills: MANAGED,
103
+ });
104
+ assert.deepEqual(scan.roots[0].foreignDirs, ["alpha", "zeta"]);
105
+ assert.equal(scan.foreignCount, 2);
106
+ });
107
+
108
+ it("skips .tmp/.old crashed-write artifacts and loose files", () => {
109
+ seedDir(".claude/skills", "ghost.tmp");
110
+ seedDir(".claude/skills", "ghost.old");
111
+ writeFileSync(join(tempDir, ".claude", "skills", "stray.md"), "x");
112
+ const scan = scanForeignContent({
113
+ vendors: ["claudeCode"],
114
+ managedSkills: MANAGED,
115
+ });
116
+ assert.equal(scan.foreignCount, 0);
117
+ });
118
+
119
+ it("skips symlinked directories", { skip: platform() === "win32" }, () => {
120
+ seedDir(".claude/skills", "managed-one");
121
+ symlinkSync(
122
+ join(tempDir, ".claude", "skills", "managed-one"),
123
+ join(tempDir, ".claude", "skills", "linked"),
124
+ "dir",
125
+ );
126
+ const scan = scanForeignContent({
127
+ vendors: ["claudeCode"],
128
+ managedSkills: MANAGED,
129
+ });
130
+ assert.equal(scan.foreignCount, 0);
131
+ });
132
+
133
+ it("scans each unique target root once across cohort vendors", () => {
134
+ seedDir(".agents/skills", "rogue");
135
+ const scan = scanForeignContent({
136
+ vendors: ["cursor", "gemini", "codex"],
137
+ managedSkills: MANAGED,
138
+ });
139
+ // Three cohort vendors share agentsProject — one root, one finding.
140
+ assert.equal(scan.roots.length, 1);
141
+ assert.equal(scan.foreignCount, 1);
142
+ assert.deepEqual(scan.roots[0].foreignDirs, ["rogue"]);
143
+ });
144
+
145
+ it("scans both roots for claude + cohort vendor sets", () => {
146
+ seedDir(".claude/skills", "rogue-claude");
147
+ seedDir(".agents/skills", "rogue-agents");
148
+ const scan = scanForeignContent({
149
+ vendors: ["claudeCode", "cursor"],
150
+ managedSkills: MANAGED,
151
+ });
152
+ assert.equal(scan.roots.length, 2);
153
+ assert.equal(scan.foreignCount, 2);
154
+ });
155
+
156
+ it("treats an empty/absent manifest as everything-foreign (nothing is CLI-written)", () => {
157
+ seedDir(".claude/skills", "anything");
158
+ for (const managedSkills of [null, undefined, {}]) {
159
+ const scan = scanForeignContent({ vendors: ["claudeCode"], managedSkills });
160
+ assert.equal(scan.foreignCount, 1, `managedSkills=${managedSkills}`);
161
+ }
162
+ });
163
+
164
+ it("declines to scan on an invalid vendor set instead of throwing", () => {
165
+ const scan = scanForeignContent({
166
+ vendors: ["not-a-vendor"],
167
+ managedSkills: MANAGED,
168
+ });
169
+ assert.equal(scan.foreignCount, 0);
170
+ assert.deepEqual(scan.roots, []);
171
+ });
172
+
173
+ it("ignores malformed manifest keys without crashing", () => {
174
+ seedDir(".claude/skills", "no-slash-name");
175
+ const scan = scanForeignContent({
176
+ vendors: ["claudeCode"],
177
+ managedSkills: { "no-slash-name": { version: "1.0.0" } },
178
+ });
179
+ // A key with no `/` cannot claim a dir — the dir stays foreign.
180
+ assert.equal(scan.foreignCount, 1);
181
+ });
182
+ });
183
+
184
+ describe("formatForeignWarnings", () => {
185
+ it("emits one sentence-case line per foreign dir naming the path", () => {
186
+ seedDir(".claude/skills", "hand-rolled");
187
+ seedDir(".claude/skills", "second");
188
+ const scan = scanForeignContent({
189
+ vendors: ["claudeCode"],
190
+ managedSkills: MANAGED,
191
+ });
192
+ const lines = formatForeignWarnings(scan);
193
+ assert.equal(lines.length, 2);
194
+ assert.match(lines[0], /\.claude\/skills\/hand-rolled\//);
195
+ assert.match(lines[1], /\.claude\/skills\/second\//);
196
+ for (const line of lines) {
197
+ assert.ok(!line.includes("!"), "copy rules: no exclamation marks");
198
+ assert.match(line, /^ {2}warning: /);
199
+ }
200
+ });
201
+
202
+ it("emits nothing for a clean scan", () => {
203
+ const scan = scanForeignContent({
204
+ vendors: ["claudeCode"],
205
+ managedSkills: MANAGED,
206
+ });
207
+ assert.deepEqual(formatForeignWarnings(scan), []);
208
+ });
209
+ });
210
+
211
+ describe("receiptViolationSummary — the H7/#2111 privacy seam", () => {
212
+ it("carries category and count ONLY — never names", () => {
213
+ seedDir(".claude/skills", "secret-internal-tool");
214
+ const scan = scanForeignContent({
215
+ vendors: ["claudeCode"],
216
+ managedSkills: MANAGED,
217
+ });
218
+ const summary = receiptViolationSummary(scan);
219
+ assert.deepEqual(summary, {
220
+ category: UNMANAGED_CONTENT_CATEGORY,
221
+ count: 1,
222
+ });
223
+ // Load-bearing privacy assertion: serializing the upstream shape
224
+ // must not leak the foreign dir's name.
225
+ assert.ok(!JSON.stringify(summary).includes("secret-internal-tool"));
226
+ });
227
+
228
+ it("tolerates a null scan", () => {
229
+ assert.deepEqual(receiptViolationSummary(null), {
230
+ category: UNMANAGED_CONTENT_CATEGORY,
231
+ count: 0,
232
+ });
233
+ });
234
+ });
235
+
236
+ describe("checkDeclarationGitignored — D6 out-of-band declaration probe", () => {
237
+ function git(args, cwd) {
238
+ execFileSync("git", args, { cwd, stdio: "ignore" });
239
+ }
240
+
241
+ it("reports absent when no declaration file exists", () => {
242
+ const result = checkDeclarationGitignored({ cwd: tempDir });
243
+ assert.deepEqual(result, { present: false, ignored: false });
244
+ });
245
+
246
+ it("reports present + not ignored outside a git work tree", () => {
247
+ writeFileSync(join(tempDir, SKILLSET_DECLARATION_FILE), "{}\n");
248
+ const result = checkDeclarationGitignored({ cwd: tempDir });
249
+ assert.equal(result.present, true);
250
+ assert.equal(result.ignored, false);
251
+ });
252
+
253
+ it("reports ignored when the declaration is gitignored in a real repo", () => {
254
+ git(["init", "-q"], tempDir);
255
+ writeFileSync(join(tempDir, SKILLSET_DECLARATION_FILE), "{}\n");
256
+ writeFileSync(join(tempDir, ".gitignore"), `${SKILLSET_DECLARATION_FILE}\n`);
257
+ const result = checkDeclarationGitignored({ cwd: tempDir });
258
+ assert.deepEqual(result, { present: true, ignored: true });
259
+ });
260
+
261
+ it("reports not ignored for a committed-style declaration in a real repo", () => {
262
+ git(["init", "-q"], tempDir);
263
+ writeFileSync(join(tempDir, SKILLSET_DECLARATION_FILE), "{}\n");
264
+ const result = checkDeclarationGitignored({ cwd: tempDir });
265
+ assert.deepEqual(result, { present: true, ignored: false });
266
+ });
267
+
268
+ it("warning line is sentence case, names the file, no exclamation marks", () => {
269
+ const line = formatDeclarationIgnoredWarning();
270
+ assert.match(line, new RegExp(SKILLSET_DECLARATION_FILE.replace(".", "\\.")));
271
+ assert.ok(!line.includes("!"));
272
+ assert.match(line, /^ {2}warning: /);
273
+ });
274
+ });
275
+
276
+ describe("warn-on-new state (#2361 owner directive)", () => {
277
+ it("warning copy carries the remediation (why + what to do)", () => {
278
+ seedDir(".claude/skills", "hand-rolled");
279
+ const scan = scanForeignContent({
280
+ vendors: ["claudeCode"],
281
+ managedSkills: MANAGED,
282
+ });
283
+ const [line] = formatForeignWarnings(scan);
284
+ assert.match(line, /not from your library/);
285
+ assert.match(line, /skillrepo push \.claude\/skills\/hand-rolled/);
286
+ assert.match(line, /or remove the directory/);
287
+ });
288
+
289
+ it("selectNewFindings returns only unseen dirs (pure selection)", () => {
290
+ const scan = {
291
+ roots: [
292
+ { target: "claudeProject", root: "/abs/p/.claude/skills", displayRoot: ".claude/skills/", foreignDirs: ["a", "b"] },
293
+ ],
294
+ foreignCount: 2,
295
+ };
296
+ const seen = { schemaVersion: 1, roots: { "/abs/p/.claude/skills": ["a"] }, declarations: {} };
297
+ const newScan = selectNewFindings(scan, seen);
298
+ assert.deepEqual(newScan.roots[0].foreignDirs, ["b"]);
299
+ assert.equal(newScan.foreignCount, 1);
300
+ });
301
+ });
302
+
303
+ describe("readGovernanceSeen / writeGovernanceSeen", () => {
304
+ let homeSnapshot;
305
+
306
+ beforeEach(async () => {
307
+ const { captureHome, setSandboxHome } = await import("../helpers/sandbox-home.mjs");
308
+ homeSnapshot = captureHome();
309
+ setSandboxHome(tempDir);
310
+ });
311
+
312
+ afterEach(async () => {
313
+ const { restoreHome } = await import("../helpers/sandbox-home.mjs");
314
+ restoreHome(homeSnapshot);
315
+ });
316
+
317
+ it("round-trips state", () => {
318
+ const state = {
319
+ schemaVersion: 1,
320
+ roots: { "/p/.claude/skills": ["a"] },
321
+ declarations: { "/p": true },
322
+ };
323
+ writeGovernanceSeen(state);
324
+ assert.deepEqual(readGovernanceSeen(), state);
325
+ });
326
+
327
+ it("missing file reads as empty state", () => {
328
+ assert.deepEqual(readGovernanceSeen(), {
329
+ schemaVersion: 1,
330
+ roots: {},
331
+ declarations: {},
332
+ });
333
+ });
334
+
335
+ it("corrupt file reads as empty state (warn-everything is the safe direction)", async () => {
336
+ const { globalGovernanceSeenPath } = await import("../../lib/paths.mjs");
337
+ mkdirSync(join(tempDir, ".claude", "skillrepo"), { recursive: true });
338
+ writeFileSync(globalGovernanceSeenPath(), "{not json");
339
+ assert.deepEqual(readGovernanceSeen(), {
340
+ schemaVersion: 1,
341
+ roots: {},
342
+ declarations: {},
343
+ });
344
+ });
345
+
346
+ it("unknown schemaVersion reads as empty state", async () => {
347
+ const { globalGovernanceSeenPath } = await import("../../lib/paths.mjs");
348
+ mkdirSync(join(tempDir, ".claude", "skillrepo"), { recursive: true });
349
+ writeFileSync(
350
+ globalGovernanceSeenPath(),
351
+ JSON.stringify({ schemaVersion: 99, roots: { x: ["y"] } }),
352
+ );
353
+ assert.deepEqual(readGovernanceSeen().roots, {});
354
+ });
355
+ });
356
+
357
+
358
+ describe("warning output safety (#2361 prod-readiness)", () => {
359
+ it(
360
+ "escapes control characters in on-disk directory names",
361
+ { skip: platform() === "win32" },
362
+ () => {
363
+ // A cloned repo can carry any byte except NUL and "/" in a dir
364
+ // name - including ESC, the opener of terminal escape sequences.
365
+ const evil = "evil\u001b]0;pwned\u0007";
366
+ seedDir(".claude/skills", evil);
367
+ const scan = scanForeignContent({
368
+ vendors: ["claudeCode"],
369
+ managedSkills: MANAGED,
370
+ });
371
+ assert.equal(scan.foreignCount, 1);
372
+ const [line] = formatForeignWarnings(scan);
373
+ assert.ok(!line.includes("\u001b"), "raw ESC must never reach the terminal");
374
+ assert.ok(!line.includes("\u0007"), "raw BEL must never reach the terminal");
375
+ assert.ok(
376
+ line.includes("\\u001b"),
377
+ "control chars render as visible escapes",
378
+ );
379
+ },
380
+ );
381
+
382
+ it("global-scope findings print a runnable absolute push path (no tilde)", async () => {
383
+ const { captureHome, setSandboxHome, restoreHome } = await import(
384
+ "../helpers/sandbox-home.mjs"
385
+ );
386
+ const snapshot = captureHome();
387
+ setSandboxHome(tempDir);
388
+ try {
389
+ const dir = join(tempDir, ".claude", "skills", "rogue-global");
390
+ mkdirSync(dir, { recursive: true });
391
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: rogue-global\n---\n");
392
+ const scan = scanForeignContent({
393
+ vendors: ["claudeCode"],
394
+ global: true,
395
+ managedSkills: MANAGED,
396
+ });
397
+ assert.equal(scan.foreignCount, 1);
398
+ const [line] = formatForeignWarnings(scan);
399
+ // Human-readable sentence keeps the tilde display path...
400
+ assert.match(line, /~\/\.claude\/skills\/rogue-global\//);
401
+ // ...but the copy-pasteable command must be absolute: quotes stop
402
+ // shell tilde expansion and Windows cmd never expands "~".
403
+ assert.ok(
404
+ !/push ~/.test(line),
405
+ "the push command must not contain a tilde path",
406
+ );
407
+ // The command path is the NORMALIZED (realpath) root — on macOS
408
+ // the tmpdir's /var symlink resolves to /private/var, and a
409
+ // realpath'd absolute path is exactly as runnable.
410
+ assert.ok(
411
+ line.includes(
412
+ `push ${join(normalizeStateKey(join(tempDir, ".claude", "skills")), "rogue-global")}`,
413
+ ),
414
+ "the push command uses the absolute (normalized) path",
415
+ );
416
+ } finally {
417
+ restoreHome(snapshot);
418
+ }
419
+ });
420
+ });
421
+
422
+ describe("commitGovernanceSeen — narrow merge (prod-readiness High fix)", () => {
423
+ let homeSnapshot;
424
+
425
+ beforeEach(async () => {
426
+ const { captureHome, setSandboxHome } = await import("../helpers/sandbox-home.mjs");
427
+ homeSnapshot = captureHome();
428
+ setSandboxHome(tempDir);
429
+ });
430
+
431
+ afterEach(async () => {
432
+ const { restoreHome } = await import("../helpers/sandbox-home.mjs");
433
+ restoreHome(homeSnapshot);
434
+ });
435
+
436
+ const ROOT = "/repo/.claude/skills";
437
+ function scanWith(dirs, root = ROOT) {
438
+ return {
439
+ roots: [{ target: "claudeProject", root, displayRoot: ".claude/skills/", foreignDirs: dirs }],
440
+ foreignCount: dirs.length,
441
+ };
442
+ }
443
+
444
+ it("replaces (not merges) the scanned root, pruning removed dirs", () => {
445
+ commitGovernanceSeen({ scan: scanWith(["gone", "stays"]), repoKey: "/repo", declarationIgnored: false });
446
+ commitGovernanceSeen({ scan: scanWith(["stays"]), repoKey: "/repo", declarationIgnored: false });
447
+ assert.deepEqual(readGovernanceSeen().roots[ROOT], ["stays"]);
448
+ // Reappearance is a fresh finding again.
449
+ const newScan = selectNewFindings(scanWith(["gone", "stays"]), readGovernanceSeen());
450
+ assert.deepEqual(newScan.roots[0].foreignDirs, ["gone"]);
451
+ });
452
+
453
+ it("a clean root deletes its key", () => {
454
+ commitGovernanceSeen({ scan: scanWith(["x"]), repoKey: "/repo", declarationIgnored: false });
455
+ commitGovernanceSeen({ scan: scanWith([]), repoKey: "/repo", declarationIgnored: false });
456
+ assert.ok(!(ROOT in readGovernanceSeen().roots));
457
+ });
458
+
459
+ it("preserves a CONCURRENT writer's keys instead of restoring a stale snapshot", () => {
460
+ // This sync's scan happened; before its commit, ANOTHER process
461
+ // commits its own repo's state. The narrow merge must keep that
462
+ // other-repo entry — a full-object overwrite from this sync's
463
+ // earlier snapshot would silently drop it (the architect-traced
464
+ // suppressed-re-warn bug).
465
+ const otherRoot = "/other/.claude/skills";
466
+ writeGovernanceSeen({
467
+ schemaVersion: 1,
468
+ roots: { [otherRoot]: ["their-finding"] },
469
+ declarations: { "/other": true },
470
+ });
471
+ commitGovernanceSeen({ scan: scanWith(["mine"]), repoKey: "/repo", declarationIgnored: true });
472
+ const state = readGovernanceSeen();
473
+ assert.deepEqual(state.roots[otherRoot], ["their-finding"], "other repo's roots survive");
474
+ assert.equal(state.declarations["/other"], true, "other repo's declaration flag survives");
475
+ assert.deepEqual(state.roots[ROOT], ["mine"]);
476
+ assert.equal(state.declarations["/repo"], true);
477
+ });
478
+
479
+ it("clears this repo's declaration flag when no longer gitignored", () => {
480
+ commitGovernanceSeen({ scan: scanWith([]), repoKey: "/repo", declarationIgnored: true });
481
+ assert.equal(readGovernanceSeen().declarations["/repo"], true);
482
+ commitGovernanceSeen({ scan: scanWith([]), repoKey: "/repo", declarationIgnored: false });
483
+ assert.ok(!("/repo" in readGovernanceSeen().declarations));
484
+ });
485
+ });
486
+
487
+ describe("normalizeStateKey", () => {
488
+ it(
489
+ "two spellings of one directory (via symlink) share a key",
490
+ { skip: platform() === "win32" },
491
+ () => {
492
+ const real = join(tempDir, "real-repo");
493
+ mkdirSync(real, { recursive: true });
494
+ symlinkSync(real, join(tempDir, "linked-repo"), "dir");
495
+ assert.equal(
496
+ normalizeStateKey(join(tempDir, "linked-repo")),
497
+ normalizeStateKey(real),
498
+ );
499
+ },
500
+ );
501
+
502
+ it("case-folds on win32 (platform-stubbed — real Windows CI exercises the fs)", () => {
503
+ const desc = Object.getOwnPropertyDescriptor(process, "platform");
504
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
505
+ try {
506
+ assert.equal(
507
+ normalizeStateKey("C:\\Users\\Dev\\Repo-Does-Not-Exist"),
508
+ "c:\\users\\dev\\repo-does-not-exist",
509
+ );
510
+ } finally {
511
+ Object.defineProperty(process, "platform", desc);
512
+ }
513
+ });
514
+
515
+ it("falls back to the raw path when the target does not exist", () => {
516
+ const ghost = join(tempDir, "does-not-exist");
517
+ assert.equal(
518
+ normalizeStateKey(ghost),
519
+ process.platform === "win32" ? ghost.toLowerCase() : ghost,
520
+ );
521
+ });
522
+ });