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,213 @@
1
+ /**
2
+ * Unit tests for src/lib/gitignore-section.mjs (#2361).
3
+ *
4
+ * The single marked-section writer shared by the init merger and the
5
+ * sync-time pre-flight. Attribution-sensitive: every CLI-authored line
6
+ * must land inside the ONE marked section (never a duplicate header),
7
+ * the retired pre-4.10.0 sync-time section must be healed into it, and
8
+ * user content must survive byte-exact outside the CLI's own lines.
9
+ */
10
+
11
+ import { describe, it, beforeEach, afterEach } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ import {
18
+ ensureGitignoreEntries,
19
+ ensureManagedRootIgnores,
20
+ MANAGED_ROOT_ENTRIES,
21
+ } from "../../lib/gitignore-section.mjs";
22
+ import {
23
+ GITIGNORE_SECTION_HEADER,
24
+ LEGACY_GITIGNORE_SECTION_HEADER,
25
+ } from "../../lib/artifact-registry.mjs";
26
+
27
+ let originalCwd;
28
+ let tempDir;
29
+
30
+ beforeEach(() => {
31
+ originalCwd = process.cwd;
32
+ tempDir = mkdtempSync(join(tmpdir(), "cli-gi-section-"));
33
+ process.cwd = () => tempDir;
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.cwd = originalCwd;
38
+ rmSync(tempDir, { recursive: true, force: true });
39
+ });
40
+
41
+ function gitignore() {
42
+ return readFileSync(join(tempDir, ".gitignore"), "utf-8");
43
+ }
44
+
45
+ describe("ensureGitignoreEntries — section management", () => {
46
+ it("creates a fresh file with one marked section", () => {
47
+ const result = ensureGitignoreEntries([".claude/skills/"]);
48
+ assert.equal(result.action, "created");
49
+ assert.deepEqual(result.added, [".claude/skills/"]);
50
+ const content = gitignore();
51
+ assert.equal(
52
+ content,
53
+ `${GITIGNORE_SECTION_HEADER}\n.claude/skills/\n`,
54
+ );
55
+ });
56
+
57
+ it("inserts missing entries INTO an existing marked section — no duplicate header", () => {
58
+ writeFileSync(
59
+ join(tempDir, ".gitignore"),
60
+ `node_modules/\n\n${GITIGNORE_SECTION_HEADER}\n.env.local\n\nuser-tail/\n`,
61
+ );
62
+ const result = ensureGitignoreEntries([".agents/skills/"]);
63
+ assert.equal(result.action, "updated");
64
+ const content = gitignore();
65
+ const headers = content.match(
66
+ new RegExp(GITIGNORE_SECTION_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
67
+ );
68
+ assert.equal(headers.length, 1, "must never write a second header");
69
+ // The new entry sits inside the section: after .env.local, before
70
+ // the blank line that closes the section.
71
+ assert.match(content, /\.env\.local\n\.agents\/skills\/\n\nuser-tail\//);
72
+ });
73
+
74
+ it("appends a new section when no header exists, separated by one blank line", () => {
75
+ writeFileSync(join(tempDir, ".gitignore"), "node_modules/\n");
76
+ ensureGitignoreEntries([".claude/skills/"]);
77
+ assert.equal(
78
+ gitignore(),
79
+ `node_modules/\n\n${GITIGNORE_SECTION_HEADER}\n.claude/skills/\n`,
80
+ );
81
+ });
82
+
83
+ it("is idempotent — a second identical call reports skipped and does not rewrite", () => {
84
+ ensureGitignoreEntries([".claude/skills/"]);
85
+ const before = gitignore();
86
+ const result = ensureGitignoreEntries([".claude/skills/"]);
87
+ assert.equal(result.action, "skipped");
88
+ assert.equal(gitignore(), before);
89
+ });
90
+
91
+ it("honors an entry the user already has anywhere in the file", () => {
92
+ writeFileSync(join(tempDir, ".gitignore"), ".claude/skills/\n");
93
+ const result = ensureGitignoreEntries([".claude/skills/"]);
94
+ assert.equal(result.action, "skipped");
95
+ assert.equal(gitignore(), ".claude/skills/\n");
96
+ });
97
+
98
+ it("preserves CRLF line endings", () => {
99
+ writeFileSync(
100
+ join(tempDir, ".gitignore"),
101
+ "node_modules/\r\n.env\r\n",
102
+ );
103
+ ensureGitignoreEntries([".claude/skills/"]);
104
+ const content = gitignore();
105
+ assert.ok(content.includes("\r\n"), "CRLF must be preserved");
106
+ assert.ok(
107
+ !/[^\r]\n/.test(content.replace(/\r\n/g, "")),
108
+ "no mixed line endings introduced",
109
+ );
110
+ assert.match(content, /\.claude\/skills\//);
111
+ });
112
+
113
+ it("does not treat a comment mentioning an entry as the entry", () => {
114
+ writeFileSync(join(tempDir, ".gitignore"), "# .claude/skills/ is synced\n");
115
+ const result = ensureGitignoreEntries([".claude/skills/"]);
116
+ assert.equal(result.action, "updated");
117
+ assert.deepEqual(result.added, [".claude/skills/"]);
118
+ });
119
+ });
120
+
121
+ describe("ensureGitignoreEntries — legacy sync-time section heal", () => {
122
+ it("migrates the exact legacy shape into the marked section", () => {
123
+ writeFileSync(
124
+ join(tempDir, ".gitignore"),
125
+ `node_modules/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
126
+ );
127
+ const result = ensureGitignoreEntries([".agents/skills/"]);
128
+ assert.equal(result.action, "updated");
129
+ const content = gitignore();
130
+ assert.ok(
131
+ !content.includes(LEGACY_GITIGNORE_SECTION_HEADER),
132
+ "legacy header must be healed away",
133
+ );
134
+ assert.match(content, /\.agents\/skills\//);
135
+ assert.ok(content.includes(GITIGNORE_SECTION_HEADER));
136
+ });
137
+
138
+ it("reports updated (and heals) even when no entries are missing", () => {
139
+ writeFileSync(
140
+ join(tempDir, ".gitignore"),
141
+ `${GITIGNORE_SECTION_HEADER}\n.agents/skills/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
142
+ );
143
+ const result = ensureGitignoreEntries([".agents/skills/"]);
144
+ assert.equal(result.action, "updated");
145
+ assert.deepEqual(result.added, []);
146
+ const content = gitignore();
147
+ assert.ok(!content.includes(LEGACY_GITIGNORE_SECTION_HEADER));
148
+ // Exactly one .agents/skills/ line remains (the marked-section one).
149
+ const matches = content.match(/\.agents\/skills\//g);
150
+ assert.equal(matches.length, 1);
151
+ });
152
+
153
+ it("never removes user content sitting under the legacy header", () => {
154
+ writeFileSync(
155
+ join(tempDir, ".gitignore"),
156
+ `${LEGACY_GITIGNORE_SECTION_HEADER}\nmy-own-line/\n.agents/skills/\n`,
157
+ );
158
+ ensureGitignoreEntries([".agents/skills/"]);
159
+ const content = gitignore();
160
+ // Only the header vanished — the user line survives, and the
161
+ // `.agents/skills/` line NOT directly under the header survives
162
+ // too (it satisfies the ensure, so no insertion happened).
163
+ assert.match(content, /my-own-line\//);
164
+ assert.match(content, /\.agents\/skills\//);
165
+ assert.ok(!content.includes(LEGACY_GITIGNORE_SECTION_HEADER));
166
+ });
167
+ });
168
+
169
+ describe("ensureManagedRootIgnores — target mapping", () => {
170
+ it("writes both project-root entries for claudeProject + agentsProject", () => {
171
+ const result = ensureManagedRootIgnores(["claudeProject", "agentsProject"]);
172
+ assert.equal(result.action, "created");
173
+ assert.deepEqual(
174
+ [...result.added].sort(),
175
+ [".agents/skills/", ".claude/skills/"],
176
+ );
177
+ });
178
+
179
+ it("is a no-op for global-only targets", () => {
180
+ const result = ensureManagedRootIgnores(["claudeGlobal", "agentsGlobal", "windsurfGlobal"]);
181
+ assert.equal(result.action, "skipped");
182
+ assert.deepEqual(result.added, []);
183
+ assert.throws(() => readFileSync(join(tempDir, ".gitignore")));
184
+ });
185
+
186
+ it("is a no-op for an empty or missing target list", () => {
187
+ assert.equal(ensureManagedRootIgnores([]).action, "skipped");
188
+ assert.equal(ensureManagedRootIgnores(undefined).action, "skipped");
189
+ });
190
+
191
+ it("every managed-root entry is in the artifact registry's expected set", async () => {
192
+ // Cross-consistency: the uninstall remover strips the whole marked
193
+ // section, but GITIGNORE_REQUIRED_ENTRIES is the documented
194
+ // expected-set contract — a managed-root entry the registry does
195
+ // not know about is drift (#885 / #2361).
196
+ const { GITIGNORE_REQUIRED_ENTRIES } = await import(
197
+ "../../lib/artifact-registry.mjs"
198
+ );
199
+ for (const entry of Object.values(MANAGED_ROOT_ENTRIES)) {
200
+ assert.ok(
201
+ GITIGNORE_REQUIRED_ENTRIES.includes(entry),
202
+ `${entry} missing from GITIGNORE_REQUIRED_ENTRIES`,
203
+ );
204
+ }
205
+ });
206
+
207
+ it("covers every project-scope managed root exactly once in MANAGED_ROOT_ENTRIES", () => {
208
+ assert.deepEqual(Object.keys(MANAGED_ROOT_ENTRIES).sort(), [
209
+ "agentsProject",
210
+ "claudeProject",
211
+ ]);
212
+ });
213
+ });
@@ -2700,3 +2700,352 @@ describe("runSync — a poisoned skill is quarantined, not fatal (#2413 audit)",
2700
2700
  );
2701
2701
  });
2702
2702
  });
2703
+
2704
+ // ── Foreign-content detection + declaration probe (#2361) ──────────────
2705
+
2706
+ describe("runSync — governance warnings (#2361)", () => {
2707
+ beforeEach(setupServer);
2708
+ afterEach(teardownServer);
2709
+
2710
+ /** Seed a non-CLI-managed skill dir in the project's claude root. */
2711
+ function seedForeign(name) {
2712
+ const dir = join(process.cwd(), ".claude", "skills", name);
2713
+ mkdirSync(dir, { recursive: true });
2714
+ writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\n---\n`);
2715
+ }
2716
+
2717
+ it("warns per foreign dir on a 200 sync and counts it in the summary as unmanaged", async () => {
2718
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2719
+ const stderr = createCaptureStream();
2720
+ seedForeign("hand-rolled");
2721
+ server.setLibraryResponse({
2722
+ skills: [makeSkill("pdf-helper")],
2723
+ removals: [],
2724
+ syncedAt: "2025-06-01T00:00:00Z",
2725
+ });
2726
+
2727
+ const result = await runSync({
2728
+ serverUrl,
2729
+ apiKey: VALID_KEY,
2730
+ vendors: ["claudeCode"],
2731
+ io: { stderr },
2732
+ });
2733
+
2734
+ assert.equal(result.unmanaged, 1);
2735
+ assert.match(stderr.text(), /is not from your library/);
2736
+ assert.match(stderr.text(), /skillrepo push/);
2737
+ assert.match(stderr.text(), /\.claude\/skills\/hand-rolled\//);
2738
+ // The skill delivered THIS sync is managed — never warned about.
2739
+ assert.ok(!stderr.text().includes("pdf-helper"));
2740
+ });
2741
+
2742
+ it("a freshly-synced library with no foreign dirs warns nothing (unmanaged: 0)", async () => {
2743
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2744
+ const stderr = createCaptureStream();
2745
+ server.setLibraryResponse({
2746
+ skills: [makeSkill("pdf-helper")],
2747
+ removals: [],
2748
+ syncedAt: "2025-06-01T00:00:00Z",
2749
+ });
2750
+
2751
+ const result = await runSync({
2752
+ serverUrl,
2753
+ apiKey: VALID_KEY,
2754
+ vendors: ["claudeCode"],
2755
+ io: { stderr },
2756
+ });
2757
+
2758
+ assert.equal(result.unmanaged, 0);
2759
+ assert.ok(!stderr.text().includes("not from your library"));
2760
+ });
2761
+
2762
+ it("detection runs on the 304 short-circuit too", async () => {
2763
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2764
+ server.setEtag('"v1"');
2765
+ server.setLibraryResponse({
2766
+ skills: [makeSkill("pdf-helper")],
2767
+ removals: [],
2768
+ syncedAt: "2025-06-01T00:00:00Z",
2769
+ });
2770
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
2771
+
2772
+ // Foreign content appears AFTER the first sync; the second sync
2773
+ // 304s (etag matches, placements complete) and must still warn.
2774
+ seedForeign("rogue");
2775
+ const stderr = createCaptureStream();
2776
+ const result = await runSync({
2777
+ serverUrl,
2778
+ apiKey: VALID_KEY,
2779
+ vendors: ["claudeCode"],
2780
+ io: { stderr },
2781
+ });
2782
+
2783
+ assert.equal(result.notModified, true);
2784
+ assert.equal(result.unmanaged, 1);
2785
+ assert.match(stderr.text(), /\.claude\/skills\/rogue\//);
2786
+ });
2787
+
2788
+ it("the throttled early-exit does no detection (zero-work contract)", async () => {
2789
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2790
+ server.setEtag('"v1"');
2791
+ server.setLibraryResponse({
2792
+ skills: [makeSkill("pdf-helper")],
2793
+ removals: [],
2794
+ syncedAt: "2025-06-01T00:00:00Z",
2795
+ });
2796
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
2797
+
2798
+ seedForeign("rogue");
2799
+ const stderr = createCaptureStream();
2800
+ const result = await runSync({
2801
+ serverUrl,
2802
+ apiKey: VALID_KEY,
2803
+ vendors: ["claudeCode"],
2804
+ throttle: true,
2805
+ minSyncIntervalMs: 60 * 60 * 1000,
2806
+ io: { stderr },
2807
+ });
2808
+
2809
+ assert.equal(result.throttled, true);
2810
+ assert.equal(result.unmanaged, undefined);
2811
+ assert.ok(!stderr.text().includes("not from your library"));
2812
+ });
2813
+
2814
+ it("a skillrepo.json outside a git work tree does not warn", async () => {
2815
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2816
+ const stderr = createCaptureStream();
2817
+ // Sandbox project dir is NOT a git repo — the probe has no ignore
2818
+ // signal, so the declaration warning must not fire.
2819
+ writeFileSync(join(process.cwd(), "skillrepo.json"), "{}\n");
2820
+ server.setLibraryResponse({
2821
+ skills: [],
2822
+ removals: [],
2823
+ syncedAt: "2025-06-01T00:00:00Z",
2824
+ });
2825
+
2826
+ await runSync({
2827
+ serverUrl,
2828
+ apiKey: VALID_KEY,
2829
+ vendors: ["claudeCode"],
2830
+ io: { stderr },
2831
+ });
2832
+
2833
+ assert.ok(!stderr.text().includes("skillrepo.json is gitignored"));
2834
+ });
2835
+
2836
+ it("warns when skillrepo.json exists but is gitignored (real git repo)", async () => {
2837
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2838
+ const { execFileSync } = await import("node:child_process");
2839
+ execFileSync("git", ["init", "-q"], { cwd: process.cwd(), stdio: "ignore" });
2840
+ writeFileSync(join(process.cwd(), "skillrepo.json"), "{}\n");
2841
+ writeFileSync(join(process.cwd(), ".gitignore"), "skillrepo.json\n");
2842
+
2843
+ const stderr = createCaptureStream();
2844
+ server.setLibraryResponse({
2845
+ skills: [],
2846
+ removals: [],
2847
+ syncedAt: "2025-06-01T00:00:00Z",
2848
+ });
2849
+
2850
+ await runSync({
2851
+ serverUrl,
2852
+ apiKey: VALID_KEY,
2853
+ vendors: ["claudeCode"],
2854
+ io: { stderr },
2855
+ });
2856
+
2857
+ assert.match(stderr.text(), /skillrepo\.json is gitignored/);
2858
+ });
2859
+ });
2860
+
2861
+ describe("runSync — warn-on-new gating (#2361 owner directive)", () => {
2862
+ beforeEach(setupServer);
2863
+ afterEach(teardownServer);
2864
+
2865
+ function seedForeign(name) {
2866
+ const dir = join(process.cwd(), ".claude", "skills", name);
2867
+ mkdirSync(dir, { recursive: true });
2868
+ writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\n---\n`);
2869
+ }
2870
+
2871
+ async function syncOnce(stderr) {
2872
+ return runSync({
2873
+ serverUrl,
2874
+ apiKey: VALID_KEY,
2875
+ vendors: ["claudeCode"],
2876
+ io: { stderr },
2877
+ });
2878
+ }
2879
+
2880
+ it("repeats nothing: second sync with unchanged findings warns zero lines but still counts", async () => {
2881
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2882
+ seedForeign("hand-rolled");
2883
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
2884
+
2885
+ const first = createCaptureStream();
2886
+ const r1 = await syncOnce(first);
2887
+ assert.equal(r1.unmanaged, 1);
2888
+ assert.match(first.text(), /hand-rolled/);
2889
+
2890
+ const second = createCaptureStream();
2891
+ const r2 = await syncOnce(second);
2892
+ assert.equal(r2.unmanaged, 1, "count stays total, not just new");
2893
+ assert.ok(
2894
+ !second.text().includes("hand-rolled"),
2895
+ "unchanged finding must not re-warn",
2896
+ );
2897
+ });
2898
+
2899
+ it("warns only the NEW dir when one is added later", async () => {
2900
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2901
+ seedForeign("old-one");
2902
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
2903
+ await syncOnce(createCaptureStream());
2904
+
2905
+ seedForeign("new-one");
2906
+ const stderr = createCaptureStream();
2907
+ const r = await syncOnce(stderr);
2908
+ assert.equal(r.unmanaged, 2);
2909
+ assert.match(stderr.text(), /new-one/);
2910
+ assert.ok(!stderr.text().includes("old-one"));
2911
+ });
2912
+
2913
+ it("a removed dir that reappears warns again", async () => {
2914
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2915
+ seedForeign("phoenix");
2916
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
2917
+ await syncOnce(createCaptureStream());
2918
+
2919
+ rmSync(join(process.cwd(), ".claude", "skills", "phoenix"), {
2920
+ recursive: true,
2921
+ force: true,
2922
+ });
2923
+ await syncOnce(createCaptureStream());
2924
+
2925
+ seedForeign("phoenix");
2926
+ const stderr = createCaptureStream();
2927
+ await syncOnce(stderr);
2928
+ assert.match(stderr.text(), /phoenix/);
2929
+ });
2930
+
2931
+ it("hook-mode syncs neither warn nor consume the finding (interactive sync still warns)", async () => {
2932
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2933
+ seedForeign("hand-rolled");
2934
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
2935
+
2936
+ // Hook sync (throttle: true caller) with a fresh window: scans and
2937
+ // counts but must not warn or mark seen.
2938
+ const hookErr = createCaptureStream();
2939
+ const hookRun = await runSync({
2940
+ serverUrl,
2941
+ apiKey: VALID_KEY,
2942
+ vendors: ["claudeCode"],
2943
+ throttle: true,
2944
+ minSyncIntervalMs: 0,
2945
+ io: { stderr: hookErr },
2946
+ });
2947
+ assert.equal(hookRun.unmanaged, 1, "hook sync still counts");
2948
+ assert.ok(!hookErr.text().includes("hand-rolled"), "hook sync must not warn");
2949
+
2950
+ // The interactive sync afterwards is the FIRST to warn.
2951
+ const stderr = createCaptureStream();
2952
+ await syncOnce(stderr);
2953
+ assert.match(stderr.text(), /hand-rolled/, "hook sync must not consume the warning");
2954
+ });
2955
+
2956
+ it("gitignored-declaration warning fires once, then stays quiet while the state holds", async () => {
2957
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2958
+ const { execFileSync } = await import("node:child_process");
2959
+ execFileSync("git", ["init", "-q"], { cwd: process.cwd(), stdio: "ignore" });
2960
+ writeFileSync(join(process.cwd(), "skillrepo.json"), "{}\n");
2961
+ writeFileSync(join(process.cwd(), ".gitignore"), "skillrepo.json\n");
2962
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
2963
+
2964
+ const first = createCaptureStream();
2965
+ await syncOnce(first);
2966
+ assert.match(first.text(), /skillrepo\.json is gitignored/);
2967
+ assert.match(first.text(), /Remove it from \.gitignore and commit it/);
2968
+
2969
+ const second = createCaptureStream();
2970
+ await syncOnce(second);
2971
+ assert.ok(!second.text().includes("skillrepo.json is gitignored"));
2972
+
2973
+ // Un-ignoring clears the state; re-ignoring warns again.
2974
+ writeFileSync(join(process.cwd(), ".gitignore"), "\n");
2975
+ await syncOnce(createCaptureStream());
2976
+ writeFileSync(join(process.cwd(), ".gitignore"), "skillrepo.json\n");
2977
+ const fourth = createCaptureStream();
2978
+ await syncOnce(fourth);
2979
+ assert.match(fourth.text(), /skillrepo\.json is gitignored/);
2980
+ });
2981
+ });
2982
+
2983
+
2984
+ describe("runSync — governance state robustness (#2361 prod-readiness)", () => {
2985
+ beforeEach(setupServer);
2986
+ afterEach(teardownServer);
2987
+
2988
+ it("two repos sharing one HOME warn independently for same-named dirs", async () => {
2989
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2990
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
2991
+
2992
+ const repoA = join(sandbox, "project"); // the default cwd from setupServer
2993
+ const repoB = join(sandbox, "project-b");
2994
+ mkdirSync(repoB, { recursive: true });
2995
+
2996
+ function seedIn(repoDir) {
2997
+ const dir = join(repoDir, ".claude", "skills", "shared-name");
2998
+ mkdirSync(dir, { recursive: true });
2999
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: shared-name\n---\n");
3000
+ }
3001
+
3002
+ seedIn(repoA);
3003
+ const errA = createCaptureStream();
3004
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], io: { stderr: errA } });
3005
+ assert.match(errA.text(), /shared-name/, "repo A warns");
3006
+
3007
+ // Same machine-global HOME, different repo: the same NAME must
3008
+ // still warn — seen-state keys are absolute roots, so isolation
3009
+ // holds through the REAL path derivation, not just unit inputs.
3010
+ process.chdir(repoB);
3011
+ seedIn(repoB);
3012
+ const errB = createCaptureStream();
3013
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], io: { stderr: errB } });
3014
+ assert.match(errB.text(), /shared-name/, "repo B warns independently");
3015
+
3016
+ // And repo A stays quiet on its next sync (its own state intact).
3017
+ process.chdir(repoA);
3018
+ const errA2 = createCaptureStream();
3019
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], io: { stderr: errA2 } });
3020
+ assert.ok(!errA2.text().includes("shared-name"), "repo A does not re-warn");
3021
+ });
3022
+
3023
+ it("a corrupt .governance-seen mid-sync degrades to warn-everything, never fails the sync", async () => {
3024
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
3025
+ const { globalGovernanceSeenPath } = await import("../../lib/paths.mjs");
3026
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
3027
+
3028
+ const dir = join(process.cwd(), ".claude", "skills", "survivor");
3029
+ mkdirSync(dir, { recursive: true });
3030
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: survivor\n---\n");
3031
+
3032
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], io: { stderr: createCaptureStream() } });
3033
+
3034
+ // Corrupt the state file (truncated write, disk fault, hand edit).
3035
+ writeFileSync(globalGovernanceSeenPath(), "{ this is not json");
3036
+
3037
+ const stderr = createCaptureStream();
3038
+ const result = await runSync({
3039
+ serverUrl,
3040
+ apiKey: VALID_KEY,
3041
+ vendors: ["claudeCode"],
3042
+ io: { stderr },
3043
+ });
3044
+ // Sync succeeds; corrupt state reads as empty → the finding warns
3045
+ // again (loud-but-safe) and the file is rewritten valid.
3046
+ assert.equal(result.unmanaged, 1);
3047
+ assert.match(stderr.text(), /survivor/);
3048
+ const healed = JSON.parse(readFileSync(globalGovernanceSeenPath(), "utf-8"));
3049
+ assert.equal(healed.schemaVersion, 1);
3050
+ });
3051
+ });
@@ -207,3 +207,68 @@ describe("removeGitignore", () => {
207
207
  assert.match(after, /\n\n\ndist\/|\n\ndist\//);
208
208
  });
209
209
  });
210
+
211
+ // ── Legacy sync-time section removal (#2361) ───────────────────────────
212
+
213
+ describe("removeGitignore — legacy sync-time section", () => {
214
+ beforeEach(setup);
215
+ afterEach(teardown);
216
+
217
+ const LEGACY_HEADER = "# SkillRepo (CLI-managed library skills)";
218
+
219
+ it("removes a leftover legacy section when no marked section exists", () => {
220
+ writeFileSync(
221
+ ".gitignore",
222
+ `node_modules/\n\n${LEGACY_HEADER}\n.agents/skills/\n`,
223
+ "utf-8",
224
+ );
225
+ const result = removeGitignore();
226
+ assert.equal(result.action, "removed");
227
+ assert.equal(result.removed, 2);
228
+ const content = readFileSync(".gitignore", "utf-8");
229
+ assert.ok(!content.includes(LEGACY_HEADER));
230
+ assert.ok(!content.includes(".agents/skills/"));
231
+ assert.ok(content.includes("node_modules/"));
232
+ });
233
+
234
+ it("removes both the marked section and a legacy section in one pass", () => {
235
+ writeFileSync(
236
+ ".gitignore",
237
+ "user-stuff/\n\n# SkillRepo CLI (added by `skillrepo init`)\n.env.local\n.claude/skills/\n\n" +
238
+ `${LEGACY_HEADER}\n.agents/skills/\n`,
239
+ "utf-8",
240
+ );
241
+ const result = removeGitignore();
242
+ assert.equal(result.action, "removed");
243
+ // 3 marked-section lines (header + 2 entries) + 2 legacy lines.
244
+ assert.equal(result.removed, 5);
245
+ const content = readFileSync(".gitignore", "utf-8");
246
+ assert.ok(!content.includes("SkillRepo"));
247
+ assert.ok(content.includes("user-stuff/"));
248
+ });
249
+
250
+ it("leaves user content under the legacy header untouched", () => {
251
+ writeFileSync(
252
+ ".gitignore",
253
+ `${LEGACY_HEADER}\nmy-own-line/\n.agents/skills/\n`,
254
+ "utf-8",
255
+ );
256
+ const result = removeGitignore();
257
+ assert.equal(result.action, "removed");
258
+ // Only the header itself — the directly-following line is NOT
259
+ // `.agents/skills/`, so nothing else is attributable.
260
+ assert.equal(result.removed, 1);
261
+ const content = readFileSync(".gitignore", "utf-8");
262
+ assert.ok(content.includes("my-own-line/"));
263
+ assert.ok(content.includes(".agents/skills/"));
264
+ });
265
+
266
+ it("dry-run reports the legacy section without writing", () => {
267
+ const original = `${LEGACY_HEADER}\n.agents/skills/\n`;
268
+ writeFileSync(".gitignore", original, "utf-8");
269
+ const result = removeGitignore({ dryRun: true });
270
+ assert.equal(result.action, "would-remove");
271
+ assert.equal(result.removed, 2);
272
+ assert.equal(readFileSync(".gitignore", "utf-8"), original);
273
+ });
274
+ });