skillrepo 4.3.0 → 4.5.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,172 @@
1
+ /**
2
+ * Unit tests for `computeSkillShas` in `src/lib/crypto-shas.mjs`.
3
+ *
4
+ * The helper is pure (no I/O), so these are direct input → output
5
+ * assertions. The properties we lock in:
6
+ *
7
+ * 1. Determinism: same input → same output, every time.
8
+ * 2. SKILL.md identification: case-sensitive, root-only.
9
+ * 3. Sort order: byte-order, not locale-aware.
10
+ * 4. Empty array → well-defined empty-string SHA.
11
+ * 5. Missing SKILL.md → `skillMdSha256: null` (callers MUST handle).
12
+ * 6. Type discipline: rejects non-array / non-string inputs.
13
+ *
14
+ * Why explicit SHA constants
15
+ * --------------------------
16
+ * The whole point of #1553 is that a v2 `.last-sync` written by one
17
+ * CLI version must be readable by another. The SHAs MUST be stable
18
+ * across Node versions, locales, and platforms. Pinning expected hex
19
+ * digests in the test catches the regression where someone "helpfully"
20
+ * swaps in `Buffer.from(content).toString("hex")` or normalizes line
21
+ * endings — both of which produce different bytes for the same
22
+ * apparent string and break drift detection silently.
23
+ */
24
+
25
+ import { describe, it } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { createHash } from "node:crypto";
28
+
29
+ import { computeSkillShas } from "../../lib/crypto-shas.mjs";
30
+
31
+ /** Test helper — computes the same SHA we expect the implementation to. */
32
+ function sha256Utf8(s) {
33
+ return createHash("sha256").update(s, "utf8").digest("hex");
34
+ }
35
+
36
+ describe("computeSkillShas", () => {
37
+ it("hashes SKILL.md content as UTF-8 bytes", () => {
38
+ const content = "# pdf-helper\n\nReads PDFs.\n";
39
+ const result = computeSkillShas([
40
+ { path: "SKILL.md", content },
41
+ ]);
42
+ assert.equal(result.skillMdSha256, sha256Utf8(content));
43
+ });
44
+
45
+ it("is deterministic across calls", () => {
46
+ const files = [
47
+ { path: "SKILL.md", content: "# a\n" },
48
+ { path: "references/x.md", content: "x\n" },
49
+ { path: "scripts/run.sh", content: "#!/bin/sh\necho hi\n" },
50
+ ];
51
+ const a = computeSkillShas(files);
52
+ const b = computeSkillShas(files);
53
+ assert.equal(a.skillMdSha256, b.skillMdSha256);
54
+ assert.equal(a.filesSha256, b.filesSha256);
55
+ });
56
+
57
+ it("filesSha256 is order-independent on input", () => {
58
+ // The same files in two different orders MUST produce the same
59
+ // filesSha256 — the helper sorts internally. If a caller passed
60
+ // server-sorted vs locally-sorted arrays, drift detection would
61
+ // false-positive on every sync.
62
+ const reordered = [
63
+ { path: "scripts/run.sh", content: "#!/bin/sh\n" },
64
+ { path: "SKILL.md", content: "# a\n" },
65
+ { path: "references/x.md", content: "x\n" },
66
+ ];
67
+ const original = [
68
+ { path: "SKILL.md", content: "# a\n" },
69
+ { path: "references/x.md", content: "x\n" },
70
+ { path: "scripts/run.sh", content: "#!/bin/sh\n" },
71
+ ];
72
+ const a = computeSkillShas(original);
73
+ const b = computeSkillShas(reordered);
74
+ assert.equal(a.filesSha256, b.filesSha256);
75
+ });
76
+
77
+ it("filesSha256 changes when any file's content changes", () => {
78
+ const before = computeSkillShas([
79
+ { path: "SKILL.md", content: "# a\n" },
80
+ { path: "references/x.md", content: "x\n" },
81
+ ]);
82
+ const after = computeSkillShas([
83
+ { path: "SKILL.md", content: "# a\n" },
84
+ { path: "references/x.md", content: "y\n" }, // changed content
85
+ ]);
86
+ assert.notEqual(before.filesSha256, after.filesSha256);
87
+ });
88
+
89
+ it("filesSha256 changes when a file is added", () => {
90
+ const before = computeSkillShas([
91
+ { path: "SKILL.md", content: "# a\n" },
92
+ ]);
93
+ const after = computeSkillShas([
94
+ { path: "SKILL.md", content: "# a\n" },
95
+ { path: "references/new.md", content: "new\n" },
96
+ ]);
97
+ assert.notEqual(before.filesSha256, after.filesSha256);
98
+ // SKILL.md itself didn't change → its sha is stable.
99
+ assert.equal(before.skillMdSha256, after.skillMdSha256);
100
+ });
101
+
102
+ it("skillMdSha256 is null when no SKILL.md is present (root, case-sensitive)", () => {
103
+ // Lowercase skill.md is NOT the canonical SKILL.md per the spec.
104
+ const result = computeSkillShas([
105
+ { path: "skill.md", content: "# a\n" },
106
+ { path: "references/x.md", content: "x\n" },
107
+ ]);
108
+ assert.equal(result.skillMdSha256, null);
109
+ // filesSha256 is still well-defined.
110
+ assert.equal(typeof result.filesSha256, "string");
111
+ assert.equal(result.filesSha256.length, 64);
112
+ });
113
+
114
+ it("skillMdSha256 is null when SKILL.md is nested (not at root)", () => {
115
+ const result = computeSkillShas([
116
+ { path: "docs/SKILL.md", content: "# a\n" },
117
+ ]);
118
+ assert.equal(result.skillMdSha256, null);
119
+ });
120
+
121
+ it("empty files array yields the SHA of the empty string", () => {
122
+ const result = computeSkillShas([]);
123
+ assert.equal(result.skillMdSha256, null);
124
+ assert.equal(result.filesSha256, sha256Utf8(""));
125
+ });
126
+
127
+ it("hashes UTF-8 content with non-ASCII characters consistently", () => {
128
+ // Regression guard: any future "fix" that encodes via a different
129
+ // charset (e.g. latin1) would silently produce different bytes for
130
+ // the same string input. The hex below is the SHA-256 of the
131
+ // UTF-8 bytes of the content string.
132
+ const content = "# café\n— résumé\n";
133
+ const result = computeSkillShas([
134
+ { path: "SKILL.md", content },
135
+ ]);
136
+ assert.equal(result.skillMdSha256, sha256Utf8(content));
137
+ });
138
+
139
+ it("uses byte-order sort, NOT locale-aware sort", () => {
140
+ // Byte-order: 'Z' (0x5A) < 'a' (0x61). Locale-aware sort
141
+ // typically puts 'a' before 'Z'. Both orderings yield the SAME
142
+ // input set, but DIFFERENT projection strings, so they hash to
143
+ // DIFFERENT filesSha256 values. We assert the byte-order
144
+ // projection wins.
145
+ const files = [
146
+ { path: "SKILL.md", content: "x\n" },
147
+ { path: "Z.md", content: "z\n" },
148
+ { path: "a.md", content: "a\n" },
149
+ ];
150
+ const result = computeSkillShas(files);
151
+ // Construct the expected projection in byte-order: SKILL.md, Z.md, a.md.
152
+ const shaSkill = sha256Utf8("x\n");
153
+ const shaZ = sha256Utf8("z\n");
154
+ const shaA = sha256Utf8("a\n");
155
+ const expectedProjection = `SKILL.md|${shaSkill}\nZ.md|${shaZ}\na.md|${shaA}`;
156
+ assert.equal(result.filesSha256, sha256Utf8(expectedProjection));
157
+ });
158
+
159
+ it("throws TypeError on non-array input", () => {
160
+ assert.throws(() => computeSkillShas(null), TypeError);
161
+ assert.throws(() => computeSkillShas(undefined), TypeError);
162
+ assert.throws(() => computeSkillShas({}), TypeError);
163
+ assert.throws(() => computeSkillShas("SKILL.md"), TypeError);
164
+ });
165
+
166
+ it("throws TypeError when a file is missing path/content as strings", () => {
167
+ assert.throws(() => computeSkillShas([{ path: 123, content: "x" }]), TypeError);
168
+ assert.throws(() => computeSkillShas([{ path: "SKILL.md", content: null }]), TypeError);
169
+ assert.throws(() => computeSkillShas([{ path: "SKILL.md" }]), TypeError);
170
+ assert.throws(() => computeSkillShas([null]), TypeError);
171
+ });
172
+ });
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Unit tests for `src/lib/drift.mjs` (#1555).
3
+ *
4
+ * The module is pure — every test is a direct input → output
5
+ * assertion. The properties locked in here are load-bearing for
6
+ * `skillrepo list`'s drift column and `--json` shape:
7
+ *
8
+ * - Every state in `SKILL_STATE` reachable from `computeSkillState`.
9
+ * - Rollup precedence (missing > edited > stale > current).
10
+ * - Semver fallback to string comparison for non-semver versions.
11
+ * - "On-disk but no baseline" treated as `missing` (not `current`).
12
+ * - Both SHA fields contribute to `edited` detection.
13
+ */
14
+
15
+ import { describe, it } from "node:test";
16
+ import assert from "node:assert/strict";
17
+
18
+ import { computeSkillState, rollupState, SKILL_STATE } from "../../lib/drift.mjs";
19
+
20
+ // Test fixtures — keep them readable. SHAs use distinct repeated
21
+ // characters so an assertion comparing the wrong field is obvious.
22
+ const SHA_A = "a".repeat(64);
23
+ const SHA_B = "b".repeat(64);
24
+ const SHA_C = "c".repeat(64);
25
+ const SHA_D = "d".repeat(64);
26
+
27
+ const BASELINE = Object.freeze({
28
+ version: "1.0.0",
29
+ skillMdSha256: SHA_A,
30
+ filesSha256: SHA_B,
31
+ });
32
+
33
+ function localPresence(skillMdSha = SHA_A, filesSha = SHA_B) {
34
+ return { present: true, skillMdSha256: skillMdSha, filesSha256: filesSha };
35
+ }
36
+
37
+ // ── computeSkillState ──────────────────────────────────────────────────
38
+
39
+ describe("computeSkillState", () => {
40
+ it("returns CURRENT when version matches and both SHAs match", () => {
41
+ const result = computeSkillState({
42
+ libraryVersion: "1.0.0",
43
+ lastSyncEntry: BASELINE,
44
+ localPlacement: localPresence(),
45
+ });
46
+ assert.equal(result, SKILL_STATE.CURRENT);
47
+ });
48
+
49
+ it("returns MISSING when localPlacement is null", () => {
50
+ const result = computeSkillState({
51
+ libraryVersion: "1.0.0",
52
+ lastSyncEntry: BASELINE,
53
+ localPlacement: null,
54
+ });
55
+ assert.equal(result, SKILL_STATE.MISSING);
56
+ });
57
+
58
+ it("returns MISSING when localPlacement.present is false", () => {
59
+ const result = computeSkillState({
60
+ libraryVersion: "1.0.0",
61
+ lastSyncEntry: BASELINE,
62
+ localPlacement: { present: false, skillMdSha256: null, filesSha256: null },
63
+ });
64
+ assert.equal(result, SKILL_STATE.MISSING);
65
+ });
66
+
67
+ it("returns MISSING when lastSyncEntry is null (no baseline)", () => {
68
+ // The on-disk file exists but we have no SHA to compare against.
69
+ // We MUST NOT report this as `current` — that would mask drift
70
+ // we cannot detect. Reporting `missing` tells the user to run
71
+ // `skillrepo update` to establish a baseline, which is the
72
+ // correct resolution.
73
+ const result = computeSkillState({
74
+ libraryVersion: "1.0.0",
75
+ lastSyncEntry: null,
76
+ localPlacement: localPresence(),
77
+ });
78
+ assert.equal(result, SKILL_STATE.MISSING);
79
+ });
80
+
81
+ it("returns STALE when library version is newer than synced version", () => {
82
+ const result = computeSkillState({
83
+ libraryVersion: "1.1.0",
84
+ lastSyncEntry: BASELINE,
85
+ localPlacement: localPresence(),
86
+ });
87
+ assert.equal(result, SKILL_STATE.STALE);
88
+ });
89
+
90
+ it("returns STALE for major version bump even with matching SHAs", () => {
91
+ // Edge case: if the user reverted their local file to match the
92
+ // OLD baseline SHAs but a major bump shipped, they're still stale.
93
+ // Stale takes precedence over SHA-match because the registry has
94
+ // strictly moved on.
95
+ const result = computeSkillState({
96
+ libraryVersion: "2.0.0",
97
+ lastSyncEntry: BASELINE,
98
+ localPlacement: localPresence(),
99
+ });
100
+ assert.equal(result, SKILL_STATE.STALE);
101
+ });
102
+
103
+ it("returns EDITED when SKILL.md SHA differs but version matches", () => {
104
+ const result = computeSkillState({
105
+ libraryVersion: "1.0.0",
106
+ lastSyncEntry: BASELINE,
107
+ localPlacement: localPresence(SHA_C, SHA_B), // SKILL.md changed
108
+ });
109
+ assert.equal(result, SKILL_STATE.EDITED);
110
+ });
111
+
112
+ it("returns EDITED when files SHA differs but SKILL.md SHA matches", () => {
113
+ // Support files (references/, scripts/, assets/) changed but
114
+ // SKILL.md didn't. Still `edited` — the user modified the skill.
115
+ // This is why crypto-shas exposes BOTH digests rather than just
116
+ // SKILL.md's: a support-file edit would otherwise read as `current`.
117
+ const result = computeSkillState({
118
+ libraryVersion: "1.0.0",
119
+ lastSyncEntry: BASELINE,
120
+ localPlacement: localPresence(SHA_A, SHA_D), // files changed
121
+ });
122
+ assert.equal(result, SKILL_STATE.EDITED);
123
+ });
124
+
125
+ it("returns EDITED when both SHAs differ but version matches", () => {
126
+ const result = computeSkillState({
127
+ libraryVersion: "1.0.0",
128
+ lastSyncEntry: BASELINE,
129
+ localPlacement: localPresence(SHA_C, SHA_D),
130
+ });
131
+ assert.equal(result, SKILL_STATE.EDITED);
132
+ });
133
+
134
+ it("STALE wins over EDITED when both apply", () => {
135
+ // Library bumped AND local edited. The user's action is `update`
136
+ // (which will overwrite their edits — they should commit first).
137
+ // Stale gives the more actionable signal: "you're behind."
138
+ const result = computeSkillState({
139
+ libraryVersion: "1.1.0",
140
+ lastSyncEntry: BASELINE,
141
+ localPlacement: localPresence(SHA_C, SHA_D),
142
+ });
143
+ assert.equal(result, SKILL_STATE.STALE);
144
+ });
145
+
146
+ it("returns CURRENT when library version is older than synced (defensive)", () => {
147
+ // This shouldn't happen in normal flow (the library is authoritative)
148
+ // but defending against the case: if the library's version is
149
+ // somehow LOWER than what we synced, don't flag stale. The SHAs
150
+ // are the source of truth for "is the on-disk content right."
151
+ const result = computeSkillState({
152
+ libraryVersion: "0.9.0",
153
+ lastSyncEntry: BASELINE, // version: "1.0.0"
154
+ localPlacement: localPresence(),
155
+ });
156
+ assert.equal(result, SKILL_STATE.CURRENT);
157
+ });
158
+
159
+ it("treats non-semver versions as different → STALE when they differ", () => {
160
+ // Future-proof: registry could adopt date-stamped or git-sha
161
+ // versioning. Any non-semver mismatch is treated as `stale`,
162
+ // which is the safe direction (user runs `update`, no drift
163
+ // gets masked).
164
+ const result = computeSkillState({
165
+ libraryVersion: "2026-05-19",
166
+ lastSyncEntry: { ...BASELINE, version: "2026-05-18" },
167
+ localPlacement: localPresence(),
168
+ });
169
+ assert.equal(result, SKILL_STATE.STALE);
170
+ });
171
+
172
+ it("treats non-semver versions as same → CURRENT when they match", () => {
173
+ const result = computeSkillState({
174
+ libraryVersion: "git-abc123",
175
+ lastSyncEntry: { ...BASELINE, version: "git-abc123" },
176
+ localPlacement: localPresence(),
177
+ });
178
+ assert.equal(result, SKILL_STATE.CURRENT);
179
+ });
180
+
181
+ it("handles null library version (no comparison, falls through to SHA check)", () => {
182
+ // libraryVersion null → can't decide stale → falls through to
183
+ // SHA comparison. With matching SHAs, we're current.
184
+ const result = computeSkillState({
185
+ libraryVersion: null,
186
+ lastSyncEntry: BASELINE,
187
+ localPlacement: localPresence(),
188
+ });
189
+ assert.equal(result, SKILL_STATE.CURRENT);
190
+ });
191
+
192
+ it("treats null SHA on disk as EDITED when baseline is present", () => {
193
+ // If the walker couldn't compute a SHA for some reason (e.g.,
194
+ // permission error, malformed file) we should NOT claim current.
195
+ // Edited is the safe verdict.
196
+ const result = computeSkillState({
197
+ libraryVersion: "1.0.0",
198
+ lastSyncEntry: BASELINE,
199
+ localPlacement: { present: true, skillMdSha256: null, filesSha256: SHA_B },
200
+ });
201
+ assert.equal(result, SKILL_STATE.EDITED);
202
+ });
203
+ });
204
+
205
+ // ── rollupState ────────────────────────────────────────────────────────
206
+
207
+ describe("rollupState", () => {
208
+ it("returns CURRENT when every vendor is current", () => {
209
+ assert.equal(
210
+ rollupState([SKILL_STATE.CURRENT, SKILL_STATE.CURRENT]),
211
+ SKILL_STATE.CURRENT,
212
+ );
213
+ });
214
+
215
+ it("returns STALE when one vendor is stale and rest are current", () => {
216
+ assert.equal(
217
+ rollupState([SKILL_STATE.CURRENT, SKILL_STATE.STALE]),
218
+ SKILL_STATE.STALE,
219
+ );
220
+ });
221
+
222
+ it("returns EDITED when one vendor is edited and rest are stale/current", () => {
223
+ assert.equal(
224
+ rollupState([SKILL_STATE.CURRENT, SKILL_STATE.STALE, SKILL_STATE.EDITED]),
225
+ SKILL_STATE.EDITED,
226
+ );
227
+ });
228
+
229
+ it("returns MISSING when any vendor is missing", () => {
230
+ // Worst-case wins. A skill that's current in Claude Code but
231
+ // missing from Cursor renders as `missing` so the user knows
232
+ // a vendor placement needs attention.
233
+ assert.equal(
234
+ rollupState([SKILL_STATE.CURRENT, SKILL_STATE.MISSING]),
235
+ SKILL_STATE.MISSING,
236
+ );
237
+ });
238
+
239
+ it("returns MISSING over EDITED, STALE, CURRENT (full precedence)", () => {
240
+ assert.equal(
241
+ rollupState([
242
+ SKILL_STATE.MISSING,
243
+ SKILL_STATE.EDITED,
244
+ SKILL_STATE.STALE,
245
+ SKILL_STATE.CURRENT,
246
+ ]),
247
+ SKILL_STATE.MISSING,
248
+ );
249
+ });
250
+
251
+ it("returns EDITED over STALE", () => {
252
+ assert.equal(
253
+ rollupState([SKILL_STATE.EDITED, SKILL_STATE.STALE]),
254
+ SKILL_STATE.EDITED,
255
+ );
256
+ });
257
+
258
+ it("returns STALE over CURRENT", () => {
259
+ assert.equal(
260
+ rollupState([SKILL_STATE.STALE, SKILL_STATE.CURRENT]),
261
+ SKILL_STATE.STALE,
262
+ );
263
+ });
264
+
265
+ it("returns MISSING on an empty array", () => {
266
+ assert.equal(rollupState([]), SKILL_STATE.MISSING);
267
+ });
268
+
269
+ it("returns MISSING when input is not an array", () => {
270
+ assert.equal(rollupState(null), SKILL_STATE.MISSING);
271
+ assert.equal(rollupState(undefined), SKILL_STATE.MISSING);
272
+ });
273
+
274
+ it("returns MISSING when input contains only unknown enum values", () => {
275
+ // Defensive fallback: a future state value or a typo shouldn't
276
+ // silently render as `current`. Bias toward the conservative
277
+ // verdict.
278
+ assert.equal(rollupState(["unknown-state", "weird"]), SKILL_STATE.MISSING);
279
+ });
280
+
281
+ it("ignores unknown values and reports the worst known state", () => {
282
+ // If the array mixes valid + unknown values, the worst valid
283
+ // state still wins.
284
+ assert.equal(
285
+ rollupState(["unknown", SKILL_STATE.STALE, "weird", SKILL_STATE.CURRENT]),
286
+ SKILL_STATE.STALE,
287
+ );
288
+ });
289
+ });