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.
- package/README.md +160 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/init.mjs +60 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/config.mjs +6 -0
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
- package/src/test/lib/telemetry.test.mjs +289 -0
|
@@ -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
|
+
});
|