skillrepo 4.17.0 → 4.17.1
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/package.json +1 -1
- package/src/commands/list-render.mjs +37 -6
- package/src/lib/list-engine.mjs +24 -1
- package/src/test/commands/list.test.mjs +139 -17
- package/src/test/integration/update-list-contract.integration.test.mjs +7 -0
- package/src/test/lib/drift.test.mjs +139 -1
- package/src/test/lib/list-engine.test.mjs +82 -28
package/package.json
CHANGED
|
@@ -46,7 +46,12 @@ export function formatLibraryJson(rows, metaByKey) {
|
|
|
46
46
|
return {
|
|
47
47
|
owner: r.owner,
|
|
48
48
|
name: r.name,
|
|
49
|
+
// #2824 R1/R4 — `version` is the INSTALLED (on-disk) version;
|
|
50
|
+
// `servedVersion` is the server's served/available version. Distinct,
|
|
51
|
+
// correctly-labeled fields (pre-#2824 the top-level `version` was the
|
|
52
|
+
// server's, with no installed-version field at all).
|
|
49
53
|
version: r.version,
|
|
54
|
+
servedVersion: r.servedVersion ?? null,
|
|
50
55
|
description: meta?.description ?? null,
|
|
51
56
|
updatedAt: meta?.updatedAt ?? null,
|
|
52
57
|
filesIncomplete: meta?.filesIncomplete ?? false,
|
|
@@ -101,7 +106,10 @@ export function formatSkillsetJson({ skillsetRef, repoName, rows, metaByKey, com
|
|
|
101
106
|
return {
|
|
102
107
|
owner: r.owner,
|
|
103
108
|
name: r.name,
|
|
109
|
+
// #2824 R1/R4 — installed (disk) version + the server's served/
|
|
110
|
+
// available version as distinct fields, mirroring the whole-library JSON.
|
|
104
111
|
version: r.version,
|
|
112
|
+
servedVersion: r.servedVersion ?? null,
|
|
105
113
|
// description/updatedAt mirror the whole-library JSON (they come
|
|
106
114
|
// from the probe's manifest response) so both views' machine
|
|
107
115
|
// output matches too, not just the tables.
|
|
@@ -472,15 +480,28 @@ export function renderSignal(row, useGlyphs, variant = "library") {
|
|
|
472
480
|
case SIGNAL.EDITED:
|
|
473
481
|
return { chip: g("✎ edited", "EDITED"), suffix: "local changes — sync won't overwrite" };
|
|
474
482
|
case SIGNAL.SERVING: {
|
|
475
|
-
// Governance rollback (#2787 SERVING)
|
|
476
|
-
//
|
|
483
|
+
// Governance rollback (#2787 SERVING). The Version column shows the
|
|
484
|
+
// INSTALLED version on disk (#2824 R1); this reason names what was
|
|
485
|
+
// recalled (`governance.recalledVersion`) and what the SERVER now serves
|
|
486
|
+
// (`row.servedVersion`). It reads `servedVersion`, NOT `row.version` —
|
|
487
|
+
// `row.version` is the disk version, not the rollback.
|
|
477
488
|
const recalled = row.governance?.recalledVersion ?? null;
|
|
478
|
-
const served = row.
|
|
489
|
+
const served = row.servedVersion ?? null;
|
|
490
|
+
// "run update" ONLY when the installed version still differs from the
|
|
491
|
+
// served rollback — i.e. you still hold the recalled version and syncing
|
|
492
|
+
// actually changes something. Once you are synced to the rollback
|
|
493
|
+
// (installed === served), SERVING persists to flag the rollback, but
|
|
494
|
+
// there is nothing to update — a "run update" CTA there is a no-op that
|
|
495
|
+
// erodes trust in the guidance (reviewer finding, #2824). This is the
|
|
496
|
+
// common steady state AFTER a user complies with a recall.
|
|
497
|
+
const onRollback = served !== null && served === (row.version ?? null);
|
|
479
498
|
return {
|
|
480
499
|
chip: g("⚠ serving", "SERVING"),
|
|
481
500
|
suffix:
|
|
482
501
|
recalled && served
|
|
483
|
-
?
|
|
502
|
+
? onRollback
|
|
503
|
+
? `${recalled} recalled — server serves ${served}`
|
|
504
|
+
: `${recalled} recalled — server serves ${served} — run update`
|
|
484
505
|
: "a newer version was recalled — serving a rollback",
|
|
485
506
|
};
|
|
486
507
|
}
|
|
@@ -495,7 +516,9 @@ export function renderSignal(row, useGlyphs, variant = "library") {
|
|
|
495
516
|
// declared repo already flags as non-compliant and re-syncs.)
|
|
496
517
|
return { chip: g("⚠ withdrawn", "WITHDRAWN"), suffix: "no longer served — remove?" };
|
|
497
518
|
case SIGNAL.BEHIND: {
|
|
498
|
-
|
|
519
|
+
// The "available" version is what the SERVER serves (#2824 R2), NOT
|
|
520
|
+
// `row.version` — that is the older installed version on disk.
|
|
521
|
+
const v = row.servedVersion ?? null;
|
|
499
522
|
return {
|
|
500
523
|
chip: g("↑ behind", "BEHIND"),
|
|
501
524
|
suffix: v ? `${v} available — run update` : "a newer version is available — run update",
|
|
@@ -565,7 +588,15 @@ function computeColWidths(terminalColumns) {
|
|
|
565
588
|
return [skillCol, versionCol, stateCol, updatedCol, descCol];
|
|
566
589
|
}
|
|
567
590
|
|
|
568
|
-
|
|
591
|
+
/**
|
|
592
|
+
* Cap a string at `n` visible chars, ellipsizing the overflow. Exported for
|
|
593
|
+
* direct unit testing (#2824 audit): the integration table test cannot guard
|
|
594
|
+
* this — cli-table3's own `wordWrap` produces an ellipsis and drops the full
|
|
595
|
+
* string regardless of whether this ran, so removing it left every table
|
|
596
|
+
* assertion green. This is a real cap (it bounds ROW HEIGHT for very long
|
|
597
|
+
* descriptions before cli-table3 wraps), so it is unit-tested on its own.
|
|
598
|
+
*/
|
|
599
|
+
export function truncate(s, n) {
|
|
569
600
|
if (s.length <= n) return s;
|
|
570
601
|
return s.slice(0, n - 1) + "…";
|
|
571
602
|
}
|
package/src/lib/list-engine.mjs
CHANGED
|
@@ -290,7 +290,11 @@ export function classifyRows({
|
|
|
290
290
|
finalizeRow({
|
|
291
291
|
owner: exp.owner,
|
|
292
292
|
name: exp.name,
|
|
293
|
+
// Served but guard-refused → nothing of ours on disk. Show the
|
|
294
|
+
// declared version (what it WOULD be), marked not-installed by the
|
|
295
|
+
// classification. No server-truth lookup happens on this branch.
|
|
293
296
|
version: exp.version ?? null,
|
|
297
|
+
servedVersion: null,
|
|
294
298
|
classification: CLASSIFICATION.NOT_DELIVERED,
|
|
295
299
|
local: LOCAL_STATE.MISSING,
|
|
296
300
|
governance: null,
|
|
@@ -313,7 +317,12 @@ export function classifyRows({
|
|
|
313
317
|
finalizeRow({
|
|
314
318
|
owner: exp.owner,
|
|
315
319
|
name: exp.name,
|
|
320
|
+
// Not on disk → there is no installed version to report. The spec's
|
|
321
|
+
// not-installed rule shows the server/expected version instead; the
|
|
322
|
+
// MISSING classification is what marks it not-installed. `version`
|
|
323
|
+
// and `servedVersion` coincide here — both are the server's.
|
|
316
324
|
version: server?.servedVersion ?? exp.version ?? baseline?.version ?? null,
|
|
325
|
+
servedVersion: server?.servedVersion ?? null,
|
|
317
326
|
classification: CLASSIFICATION.MISSING,
|
|
318
327
|
local: LOCAL_STATE.MISSING,
|
|
319
328
|
governance: server?.governance ?? null,
|
|
@@ -333,7 +342,15 @@ export function classifyRows({
|
|
|
333
342
|
finalizeRow({
|
|
334
343
|
owner: exp.owner,
|
|
335
344
|
name: exp.name,
|
|
336
|
-
|
|
345
|
+
// #2824 R1 — THE fix. The headline version is the INSTALLED version:
|
|
346
|
+
// the `.last-sync` baseline (what the last sync wrote to disk). It is
|
|
347
|
+
// NEVER the server's served version, which would read false for a
|
|
348
|
+
// recalled-not-synced / behind / restored-not-synced row (you would be
|
|
349
|
+
// told you are on a version you are not). `null` when there is no
|
|
350
|
+
// baseline (an on-disk-but-never-synced row, which `local` already
|
|
351
|
+
// reports as MISSING) — we do not fabricate a version from the server.
|
|
352
|
+
version: baseline?.version ?? null,
|
|
353
|
+
servedVersion: server?.servedVersion ?? null,
|
|
337
354
|
classification: CLASSIFICATION.TRACKED,
|
|
338
355
|
local,
|
|
339
356
|
governance: server?.governance ?? null,
|
|
@@ -364,7 +381,10 @@ export function classifyRows({
|
|
|
364
381
|
finalizeRow({
|
|
365
382
|
owner: orphan.owner,
|
|
366
383
|
name,
|
|
384
|
+
// A disowned orphan's only version truth is what's on disk (baseline);
|
|
385
|
+
// the server no longer serves it, so there is no served version.
|
|
367
386
|
version: orphan.baseline.version ?? null,
|
|
387
|
+
servedVersion: null,
|
|
368
388
|
classification: CLASSIFICATION.ORPHAN,
|
|
369
389
|
local,
|
|
370
390
|
governance: null,
|
|
@@ -384,7 +404,10 @@ export function classifyRows({
|
|
|
384
404
|
finalizeRow({
|
|
385
405
|
owner: null,
|
|
386
406
|
name,
|
|
407
|
+
// Hand-authored / never CLI-managed → no baseline, no library entry, so
|
|
408
|
+
// no known version from either side.
|
|
387
409
|
version: null,
|
|
410
|
+
servedVersion: null,
|
|
388
411
|
classification: CLASSIFICATION.FOREIGN,
|
|
389
412
|
local: LOCAL_STATE.PRESENT,
|
|
390
413
|
governance: null,
|
|
@@ -24,6 +24,7 @@ import { join } from "node:path";
|
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
|
|
26
26
|
import { runList } from "../../commands/list.mjs";
|
|
27
|
+
import { truncate } from "../../commands/list-render.mjs";
|
|
27
28
|
import { EXIT_AUTH } from "../../lib/errors.mjs";
|
|
28
29
|
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
29
30
|
import { globalLastSyncPath, globalGovernanceSeenPath } from "../../lib/paths.mjs";
|
|
@@ -121,6 +122,29 @@ function baselineEntry(version, files) {
|
|
|
121
122
|
return { version, skillMdSha256: shas.skillMdSha256, filesSha256: shas.filesSha256, syncedAt: "x" };
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
126
|
+
// truncate helper (unit) — guards the description cap directly (#2824 audit)
|
|
127
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
128
|
+
//
|
|
129
|
+
// The integration "truncates a long description" test CANNOT guard truncate():
|
|
130
|
+
// cli-table3's wordWrap emits an ellipsis and drops the contiguous long string
|
|
131
|
+
// on its own, so removing truncate left that test green. This unit test asserts
|
|
132
|
+
// truncate's actual contract, independent of the table renderer.
|
|
133
|
+
describe("truncate", () => {
|
|
134
|
+
it("returns short strings unchanged", () => {
|
|
135
|
+
assert.equal(truncate("hello", 60), "hello");
|
|
136
|
+
assert.equal(truncate("x".repeat(60), 60), "x".repeat(60));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("caps an over-long string at n chars, ending in a single ellipsis", () => {
|
|
140
|
+
const out = truncate("x".repeat(120), 60);
|
|
141
|
+
assert.equal(out.length, 60, "capped to exactly n visible chars");
|
|
142
|
+
assert.ok(out.endsWith("…"), "the overflow is marked with an ellipsis");
|
|
143
|
+
assert.equal(out, "x".repeat(59) + "…");
|
|
144
|
+
assert.ok(!out.includes("x".repeat(60)), "the full run is not present");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
124
148
|
// ════════════════════════════════════════════════════════════════════════
|
|
125
149
|
// Whole-library view
|
|
126
150
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -199,7 +223,11 @@ describe("runList — library row taxonomy", () => {
|
|
|
199
223
|
assert.match(out, /all healthy|library in sync/);
|
|
200
224
|
});
|
|
201
225
|
|
|
202
|
-
it("behind:
|
|
226
|
+
it("behind: Version column shows the INSTALLED version, the available version is context", async () => {
|
|
227
|
+
// disk/baseline = 1.0.0; the server has moved to 2.0.0. The Version column
|
|
228
|
+
// must show 1.0.0 (what you have) — the row must not read as "you're on
|
|
229
|
+
// 2.0.0". The newer 2.0.0 is named only in the attention block. (#2824
|
|
230
|
+
// R1/R2/R3; pre-fix the Version cell showed the server's 2.0.0.)
|
|
203
231
|
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("aging") }];
|
|
204
232
|
seedClaudeSkillOnDisk("aging", files); // disk matches baseline v1.0.0 → local present
|
|
205
233
|
seedLastSync({ skills: { "alice/aging": baselineEntry("1.0.0", files) } });
|
|
@@ -208,7 +236,13 @@ describe("runList — library row taxonomy", () => {
|
|
|
208
236
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
209
237
|
const out = stdout.text();
|
|
210
238
|
assert.match(out, /\bBEHIND\b/);
|
|
211
|
-
|
|
239
|
+
// R3 — the row's own Version cell (the boxed table line, not the block) shows
|
|
240
|
+
// the disk version and never the server's newer version.
|
|
241
|
+
const boxedRow = out.split("\n").find((l) => l.includes("aging") && l.includes("│"));
|
|
242
|
+
assert.ok(boxedRow, "the aging row renders in the table");
|
|
243
|
+
assert.match(boxedRow, /\b1\.0\.0\b/, "R1/R3: Version column = installed (disk) version");
|
|
244
|
+
assert.doesNotMatch(boxedRow, /\b2\.0\.0\b/, "the Version cell never shows the server's newer version");
|
|
245
|
+
assert.match(out, /2\.0\.0 available — run update/, "R2: the available version is named in the attention block");
|
|
212
246
|
assert.match(out, /need.? attention/);
|
|
213
247
|
});
|
|
214
248
|
|
|
@@ -228,10 +262,17 @@ describe("runList — library row taxonomy", () => {
|
|
|
228
262
|
assert.match(out, /OVERWRITES local edits/);
|
|
229
263
|
});
|
|
230
264
|
|
|
231
|
-
it("serving:
|
|
265
|
+
it("serving (recalled-not-synced): Version column shows the INSTALLED recalled version, the served rollback is context", async () => {
|
|
266
|
+
// THE real recall-not-synced state (#2824 R1/R2). You synced 2.0.0; the team
|
|
267
|
+
// recalled 2.0.0 and the server now serves the 1.9.0 rollback — but 2.0.0 is
|
|
268
|
+
// still on disk (the file your agent loads). The Version column must show
|
|
269
|
+
// 2.0.0 (what you have); the block names what the server serves (1.9.0) and
|
|
270
|
+
// what to do. Pre-fix the column showed 1.9.0 — the row read false. The
|
|
271
|
+
// fixture DIVERGES disk (2.0.0) from served (1.9.0); the old test set both
|
|
272
|
+
// to 1.9.0, so it could never tell "shows disk" from "shows served".
|
|
232
273
|
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("sec") }];
|
|
233
274
|
seedClaudeSkillOnDisk("sec", files);
|
|
234
|
-
seedLastSync({ skills: { "alice/sec": baselineEntry("
|
|
275
|
+
seedLastSync({ skills: { "alice/sec": baselineEntry("2.0.0", files) } });
|
|
235
276
|
server.setLibraryResponse({
|
|
236
277
|
skills: [
|
|
237
278
|
makeSkill("alice", "sec", "1.9.0", {
|
|
@@ -245,18 +286,57 @@ describe("runList — library row taxonomy", () => {
|
|
|
245
286
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
246
287
|
const out = stdout.text();
|
|
247
288
|
assert.match(out, /\bSERVING\b/);
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
289
|
+
// R3 — the row's own Version cell shows the installed 2.0.0, never the
|
|
290
|
+
// server's 1.9.0 rollback.
|
|
291
|
+
const boxedRow = out.split("\n").find((l) => l.includes("sec") && l.includes("│"));
|
|
292
|
+
assert.ok(boxedRow, "the sec row renders in the table");
|
|
293
|
+
assert.match(boxedRow, /\b2\.0\.0\b/, "R1/R3: Version column = the installed (recalled) version on disk");
|
|
294
|
+
assert.doesNotMatch(boxedRow, /\b1\.9\.0\b/, "the Version cell never shows the server's served rollback");
|
|
295
|
+
// R2 — the attention block names what was recalled, what the server serves
|
|
296
|
+
// now, and what to do.
|
|
297
|
+
assert.match(out, /2\.0\.0 recalled — server serves 1\.9\.0 — run update/);
|
|
251
298
|
assert.doesNotMatch(out, /\bLATEST\b|✓ ok/, "no green for a non-latest served version");
|
|
252
299
|
});
|
|
253
300
|
|
|
301
|
+
it("serving (recalled-SYNCED): already on the rollback → states the recall but NO no-op 'run update'", async () => {
|
|
302
|
+
// The steady state AFTER a user complies with a recall: they ran update and
|
|
303
|
+
// now hold the 1.9.0 rollback (disk == served == 1.9.0), but 2.0.0 is still
|
|
304
|
+
// recalled so governance stays `serving`. This test's fixture is disk ==
|
|
305
|
+
// served BY DESIGN — that is the sync-state condition being tested (the CTA
|
|
306
|
+
// logic), NOT the version display (covered by the recalled-not-synced test
|
|
307
|
+
// above with disk ≠ served). SERVING must still flag the rollback, but must
|
|
308
|
+
// NOT tell the user to "run update" — updating changes nothing here.
|
|
309
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("sec") }];
|
|
310
|
+
seedClaudeSkillOnDisk("sec", files);
|
|
311
|
+
seedLastSync({ skills: { "alice/sec": baselineEntry("1.9.0", files) } });
|
|
312
|
+
server.setLibraryResponse({
|
|
313
|
+
skills: [
|
|
314
|
+
makeSkill("alice", "sec", "1.9.0", {
|
|
315
|
+
governance: { status: "serving", head: "2.0.0", recalledVersion: "2.0.0" },
|
|
316
|
+
}),
|
|
317
|
+
],
|
|
318
|
+
removals: [],
|
|
319
|
+
syncedAt: "x",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
323
|
+
const out = stdout.text();
|
|
324
|
+
assert.match(out, /\bSERVING\b/, "the rollback is still flagged");
|
|
325
|
+
assert.match(out, /2\.0\.0 recalled — server serves 1\.9\.0/, "the recall + served version are stated");
|
|
326
|
+
assert.doesNotMatch(out, /run update/, "no no-op CTA: you are already on the served rollback");
|
|
327
|
+
});
|
|
328
|
+
|
|
254
329
|
it("missing: in the library, not on disk", async () => {
|
|
255
330
|
server.setLibraryResponse({ skills: [makeSkill("alice", "ghost", "1.0.0")], removals: [], syncedAt: "x" });
|
|
256
331
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
257
332
|
const out = stdout.text();
|
|
258
333
|
assert.match(out, /\bMISSING\b/);
|
|
259
334
|
assert.match(out, /in your library, not installed/);
|
|
335
|
+
// Not-installed shows the server/expected version (spec), marked
|
|
336
|
+
// not-installed by the MISSING chip — the Version cell is not left blank.
|
|
337
|
+
const boxedRow = out.split("\n").find((l) => l.includes("ghost") && l.includes("│"));
|
|
338
|
+
assert.ok(boxedRow, "the missing row renders in the table");
|
|
339
|
+
assert.match(boxedRow, /\b1\.0\.0\b/, "not-installed: the server/expected version is shown in the Version cell");
|
|
260
340
|
});
|
|
261
341
|
|
|
262
342
|
it("foreign: on disk, not in the library, no baseline — no longer hidden", async () => {
|
|
@@ -379,6 +459,13 @@ describe("runList — library row taxonomy", () => {
|
|
|
379
459
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
380
460
|
const out = stdout.text();
|
|
381
461
|
assert.match(out, /@alice\/recalled/, "the stranded orphan is finally visible");
|
|
462
|
+
// R1/R3 at the OUTPUT level (#2824 audit): the orphan is the one non-tracked
|
|
463
|
+
// row that carries a REAL on-disk baseline version (the stranded #2804 copy
|
|
464
|
+
// the agent still loads), so its Version cell must show that disk version —
|
|
465
|
+
// 1.2.0 — not blank. The engine field is unit-tested; this guards the render.
|
|
466
|
+
const boxedRow = out.split("\n").find((l) => l.includes("recalled") && l.includes("│"));
|
|
467
|
+
assert.ok(boxedRow, "the orphan row renders in the table");
|
|
468
|
+
assert.match(boxedRow, /\b1\.2\.0\b/, "R1: the orphan's Version cell shows the installed (disk) version");
|
|
382
469
|
// #2807 + owner rule 2026-08-26: a server-disowned orphan renders as
|
|
383
470
|
// cause-neutral WITHDRAWN, NEVER `recalled` — absence alone is not a
|
|
384
471
|
// verified recall (could be an unpublish / unshare / skillset re-scope).
|
|
@@ -422,6 +509,12 @@ describe("runList — library row taxonomy", () => {
|
|
|
422
509
|
assert.equal(row.signal, "behind");
|
|
423
510
|
assert.deepEqual(row.governance, { status: "latest", head: "2.0.0" });
|
|
424
511
|
assert.ok(Array.isArray(row.placements) && row.placements[0].vendor === "claudeCode");
|
|
512
|
+
// #2824 R4 — the disk version and the server's served version are DISTINCT,
|
|
513
|
+
// correctly-labeled fields. disk = 1.0.0 (baseline), served = 2.0.0. Pre-fix
|
|
514
|
+
// the top-level `version` was the server's (2.0.0) and `servedVersion` did
|
|
515
|
+
// not exist.
|
|
516
|
+
assert.equal(row.version, "1.0.0", "R1/R4: top-level version is the installed (disk) version");
|
|
517
|
+
assert.equal(row.servedVersion, "2.0.0", "R4: the server's served/available version is a distinct labeled field");
|
|
425
518
|
});
|
|
426
519
|
});
|
|
427
520
|
|
|
@@ -647,7 +740,16 @@ describe("runList — skillset-declared view", () => {
|
|
|
647
740
|
});
|
|
648
741
|
|
|
649
742
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
650
|
-
|
|
743
|
+
const out = stdout.text();
|
|
744
|
+
assert.match(out, /\bBEHIND\b/, "the restore is visible even inside the sync throttle window");
|
|
745
|
+
// #2824 R1/R2/R3 — the row shows the INSTALLED 1.0.0 (the rollback still on
|
|
746
|
+
// disk); the restored 2.0.0 is named only as context. disk (1.0.0) ≠ served
|
|
747
|
+
// (2.0.0), so the Version cell distinguishes the two.
|
|
748
|
+
const boxedRow = out.split("\n").find((l) => l.includes("svc") && l.includes("│"));
|
|
749
|
+
assert.ok(boxedRow, "the svc row renders in the table");
|
|
750
|
+
assert.match(boxedRow, /\b1\.0\.0\b/, "R1/R3: Version column = installed (disk) version");
|
|
751
|
+
assert.doesNotMatch(boxedRow, /\b2\.0\.0\b/, "the Version cell never shows the restored server version");
|
|
752
|
+
assert.match(out, /2\.0\.0 available — run update/, "R2: the restored version is named as context");
|
|
651
753
|
});
|
|
652
754
|
|
|
653
755
|
it("probe failure degrades with the SAME offline guard as the whole-library view", async () => {
|
|
@@ -676,18 +778,20 @@ describe("runList — skillset-declared view", () => {
|
|
|
676
778
|
assert.doesNotMatch(out, /needs? attention/, "a synced repo is not all-attention on a probe hiccup");
|
|
677
779
|
});
|
|
678
780
|
|
|
679
|
-
it("--json carries the freshness axis
|
|
781
|
+
it("--json carries the freshness axis + distinct installed/served versions, top-level object", async () => {
|
|
680
782
|
declareSkillset();
|
|
783
|
+
// Disk/baseline = 1.0.0; the server has moved to 2.0.0 → BEHIND. disk ≠
|
|
784
|
+
// served so the two version fields are distinguishable (#2824 R4). (A
|
|
785
|
+
// steady-state up-to-date member is covered by the delivered-set test
|
|
786
|
+
// above; this one is deliberately divergent.)
|
|
681
787
|
writeRepoSyncState(process.cwd(), {
|
|
682
788
|
repoName: "checkout",
|
|
683
789
|
skillsetRef: "acme/backend-core",
|
|
684
790
|
etag: "ss-1",
|
|
685
791
|
resolved: [deliveredMember("acme", "svc", "1.0.0", [{ path: "SKILL.md", content: SKILL_MD_BODY("svc") }])],
|
|
686
792
|
});
|
|
687
|
-
// Unconditional 200 (#2807): the member is served at its baseline
|
|
688
|
-
// version, so freshness resolves to up-to-date off the body.
|
|
689
793
|
server.setSkillsetResponse({
|
|
690
|
-
skills: [makeSkill("acme", "svc", "
|
|
794
|
+
skills: [makeSkill("acme", "svc", "2.0.0", { governance: { status: "latest", head: "2.0.0" } })],
|
|
691
795
|
removals: [],
|
|
692
796
|
syncedAt: "x",
|
|
693
797
|
skillset: { name: "backend-core", updatedAt: "x", skippedExtras: [] },
|
|
@@ -698,8 +802,12 @@ describe("runList — skillset-declared view", () => {
|
|
|
698
802
|
assert.equal(json.skillset.ref, "acme/backend-core");
|
|
699
803
|
assert.equal(json.skillset.synced, true);
|
|
700
804
|
const svc = json.skills.find((s) => s.name === "svc");
|
|
701
|
-
assert.equal(svc.freshness, "
|
|
805
|
+
assert.equal(svc.freshness, "behind");
|
|
702
806
|
assert.equal(svc.classification, "tracked");
|
|
807
|
+
// R4 — the installed (disk) version and the server's served version are
|
|
808
|
+
// distinct, correctly-labeled fields in the skillset JSON too.
|
|
809
|
+
assert.equal(svc.version, "1.0.0", "R1/R4: version is the installed (disk) version");
|
|
810
|
+
assert.equal(svc.servedVersion, "2.0.0", "R4: servedVersion is the server's served/available version");
|
|
703
811
|
// description/updatedAt now ride the skillset JSON too (probe metadata).
|
|
704
812
|
assert.equal(svc.description, "svc description");
|
|
705
813
|
});
|
|
@@ -740,7 +848,13 @@ describe("runList — skillset-declared view", () => {
|
|
|
740
848
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
741
849
|
const out = stdout.text();
|
|
742
850
|
assert.match(out, /NOT DELIVERED/);
|
|
743
|
-
|
|
851
|
+
// Row-scoped (#2824 audit): the not-delivered REASON detail must render on
|
|
852
|
+
// `locked`'s OWN attention line. A bare `/edited locally/` match is
|
|
853
|
+
// confirm-the-code — the compliance summary ALSO prints "N members edited
|
|
854
|
+
// locally" (foreign-content.mjs), so it passes even if the not-delivered
|
|
855
|
+
// reason mapping is wrong. Scope it to the member's row to actually guard it.
|
|
856
|
+
const lockedLine = out.split("\n").find((l) => l.includes("/locked") && l.includes("edited locally"));
|
|
857
|
+
assert.ok(lockedLine, "the not-delivered member's reason detail renders on its own attention row, not just in the compliance summary");
|
|
744
858
|
});
|
|
745
859
|
|
|
746
860
|
it("renders each not-delivered refusal reason with its matching detail", async () => {
|
|
@@ -786,14 +900,17 @@ describe("runList — skillset-declared view", () => {
|
|
|
786
900
|
assert.ok(!stdout.text().includes("[31m"), "raw ANSI from the state file must be escaped");
|
|
787
901
|
});
|
|
788
902
|
|
|
789
|
-
it("governance:
|
|
903
|
+
it("governance (recalled-not-synced): serving member shows the INSTALLED version + names the served rollback", async () => {
|
|
790
904
|
declareSkillset();
|
|
791
905
|
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("svc") }];
|
|
906
|
+
// Disk/baseline = 2.0.0 (the recalled version the CLI last synced); the
|
|
907
|
+
// server now serves the 1.9.0 rollback. disk ≠ served, the real Fault-A
|
|
908
|
+
// state — the old fixture set the baseline to 1.9.0 too, masking the bug.
|
|
792
909
|
writeRepoSyncState(process.cwd(), {
|
|
793
910
|
repoName: "checkout",
|
|
794
911
|
skillsetRef: "acme/backend-core",
|
|
795
912
|
etag: "ss-1",
|
|
796
|
-
resolved: [deliveredMember("acme", "svc", "
|
|
913
|
+
resolved: [deliveredMember("acme", "svc", "2.0.0", files)],
|
|
797
914
|
});
|
|
798
915
|
// The unconditional 200 carries governance (recall flag on, server-side)
|
|
799
916
|
// + description/updatedAt: svc serves 1.9.0 because 2.0.0 was recalled.
|
|
@@ -812,7 +929,12 @@ describe("runList — skillset-declared view", () => {
|
|
|
812
929
|
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
813
930
|
const out = stdout.text();
|
|
814
931
|
assert.match(out, /\bSERVING\b/, "the skillset view speaks the whole-library view's governance word");
|
|
815
|
-
|
|
932
|
+
// R3 — the Version cell shows the installed 2.0.0, never the served 1.9.0.
|
|
933
|
+
const boxedRow = out.split("\n").find((l) => l.includes("svc") && l.includes("│"));
|
|
934
|
+
assert.ok(boxedRow, "the svc row renders in the table");
|
|
935
|
+
assert.match(boxedRow, /\b2\.0\.0\b/, "R1/R3: Version column = the installed (recalled) version on disk");
|
|
936
|
+
assert.doesNotMatch(boxedRow, /\b1\.9\.0\b/, "the Version cell never shows the server's served rollback");
|
|
937
|
+
assert.match(out, /2\.0\.0 recalled — server serves 1\.9\.0 — run update/, "the recall reason renders in the shared block");
|
|
816
938
|
assert.match(out, /deploys the service/, "the Description column populates from the probe (5-column table, like library)");
|
|
817
939
|
assert.doesNotMatch(out, /all members delivered and current/, "a serving member is not treated as healthy");
|
|
818
940
|
});
|
|
@@ -613,6 +613,13 @@ describe("update → mutate → list: drift verdicts on real persisted state", (
|
|
|
613
613
|
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
614
614
|
const [item] = JSON.parse(stdout.text());
|
|
615
615
|
assert.equal(item.state, "stale", "library moved ahead → `stale`");
|
|
616
|
+
// #2824 R1/R4 at the REAL entry point (update then list, genuinely
|
|
617
|
+
// persisted .last-sync): `.last-sync` recorded 1.0.0 on disk; the server
|
|
618
|
+
// now serves 1.1.0. `version` is the INSTALLED 1.0.0 (what the agent
|
|
619
|
+
// loads), `servedVersion` the available 1.1.0. Pre-#2824 `version` was
|
|
620
|
+
// 1.1.0 (the server's) — this asserts against that exact regression.
|
|
621
|
+
assert.equal(item.version, "1.0.0", "R1: installed (disk) version from the real .last-sync");
|
|
622
|
+
assert.equal(item.servedVersion, "1.1.0", "R4: the server's served version as a distinct field");
|
|
616
623
|
});
|
|
617
624
|
|
|
618
625
|
it("MISSING after update + manual delete: removing a placement makes list show `missing`", async () => {
|
|
@@ -15,7 +15,17 @@
|
|
|
15
15
|
import { describe, it } from "node:test";
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
computeSkillState,
|
|
20
|
+
rollupState,
|
|
21
|
+
SKILL_STATE,
|
|
22
|
+
computeFreshness,
|
|
23
|
+
computeLocalIntegrity,
|
|
24
|
+
rollupLocal,
|
|
25
|
+
versionIsNewer,
|
|
26
|
+
FRESHNESS,
|
|
27
|
+
LOCAL_STATE,
|
|
28
|
+
} from "../../lib/drift.mjs";
|
|
19
29
|
|
|
20
30
|
// Test fixtures — keep them readable. SHAs use distinct repeated
|
|
21
31
|
// characters so an assertion comparing the wrong field is obvious.
|
|
@@ -287,3 +297,131 @@ describe("rollupState", () => {
|
|
|
287
297
|
);
|
|
288
298
|
});
|
|
289
299
|
});
|
|
300
|
+
|
|
301
|
+
// ── computeFreshness (#2806) ─────────────────────────────────────────────
|
|
302
|
+
//
|
|
303
|
+
// The freshness axis is baseline(disk) vs the server's SERVED version. It is
|
|
304
|
+
// load-bearing for #2824: it must fire BEHIND only when the server moved
|
|
305
|
+
// FORWARD, and stay UP_TO_DATE when the server rolled BACK (a recall
|
|
306
|
+
// fallback) — otherwise a recalled-not-synced row would read "behind" and
|
|
307
|
+
// prompt an "available" version that is actually older. These direct tests
|
|
308
|
+
// were missing (the axis had only indirect coverage via the list engine).
|
|
309
|
+
|
|
310
|
+
describe("computeFreshness", () => {
|
|
311
|
+
it("BEHIND when the served version is strictly newer than the baseline (server moved forward)", () => {
|
|
312
|
+
assert.equal(
|
|
313
|
+
computeFreshness({ baselineVersion: "1.0.0", servedVersion: "2.0.0" }),
|
|
314
|
+
FRESHNESS.BEHIND,
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("UP_TO_DATE when served equals the baseline", () => {
|
|
319
|
+
assert.equal(
|
|
320
|
+
computeFreshness({ baselineVersion: "1.0.0", servedVersion: "1.0.0" }),
|
|
321
|
+
FRESHNESS.UP_TO_DATE,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("UP_TO_DATE (never BEHIND) when the server ROLLED BACK below the baseline — the recall-fallback case", () => {
|
|
326
|
+
// Disk holds 2.0.0 (the recalled version); the server serves the 1.9.0
|
|
327
|
+
// rollback. The server did NOT move forward, so freshness is UP_TO_DATE —
|
|
328
|
+
// the SERVING governance signal, not freshness, is what surfaces the
|
|
329
|
+
// recall. If this returned BEHIND, `list` would offer "1.9.0 available",
|
|
330
|
+
// i.e. tell you to "update" DOWN to the rollback via the freshness path.
|
|
331
|
+
assert.equal(
|
|
332
|
+
computeFreshness({ baselineVersion: "2.0.0", servedVersion: "1.9.0" }),
|
|
333
|
+
FRESHNESS.UP_TO_DATE,
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("NOT_CHECKED when the served version is absent (server not consulted) — never a false UP_TO_DATE", () => {
|
|
338
|
+
assert.equal(
|
|
339
|
+
computeFreshness({ baselineVersion: "1.0.0", servedVersion: null }),
|
|
340
|
+
FRESHNESS.NOT_CHECKED,
|
|
341
|
+
);
|
|
342
|
+
assert.equal(
|
|
343
|
+
computeFreshness({ baselineVersion: "1.0.0", servedVersion: "" }),
|
|
344
|
+
FRESHNESS.NOT_CHECKED,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("BEHIND for a non-semver served label that differs from the baseline (string fallback, safe direction)", () => {
|
|
349
|
+
assert.equal(
|
|
350
|
+
computeFreshness({ baselineVersion: "beta", servedVersion: "rc" }),
|
|
351
|
+
FRESHNESS.BEHIND,
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── versionIsNewer (#2806) — shared ordering rule ────────────────────────
|
|
357
|
+
|
|
358
|
+
describe("versionIsNewer", () => {
|
|
359
|
+
it("semver: strict greater-than only", () => {
|
|
360
|
+
assert.equal(versionIsNewer("2.0.0", "1.0.0"), true);
|
|
361
|
+
assert.equal(versionIsNewer("1.0.0", "1.0.0"), false);
|
|
362
|
+
assert.equal(versionIsNewer("1.0.0", "2.0.0"), false);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("non-semver: any difference reads newer (bias toward prompting an update, never masking one)", () => {
|
|
366
|
+
assert.equal(versionIsNewer("rc", "beta"), true);
|
|
367
|
+
assert.equal(versionIsNewer("beta", "beta"), false);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("absent/empty candidate or reference is inconclusive → false", () => {
|
|
371
|
+
assert.equal(versionIsNewer(null, "1.0.0"), false);
|
|
372
|
+
assert.equal(versionIsNewer("1.0.0", null), false);
|
|
373
|
+
assert.equal(versionIsNewer("", "1.0.0"), false);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ── computeLocalIntegrity + rollupLocal (#2806) ──────────────────────────
|
|
378
|
+
|
|
379
|
+
describe("computeLocalIntegrity", () => {
|
|
380
|
+
it("PRESENT when both SHAs match the baseline", () => {
|
|
381
|
+
assert.equal(
|
|
382
|
+
computeLocalIntegrity({ baseline: BASELINE, placement: localPresence(SHA_A, SHA_B) }),
|
|
383
|
+
LOCAL_STATE.PRESENT,
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("EDITED when either SHA differs from the baseline", () => {
|
|
388
|
+
assert.equal(
|
|
389
|
+
computeLocalIntegrity({ baseline: BASELINE, placement: localPresence(SHA_C, SHA_B) }),
|
|
390
|
+
LOCAL_STATE.EDITED,
|
|
391
|
+
);
|
|
392
|
+
assert.equal(
|
|
393
|
+
computeLocalIntegrity({ baseline: BASELINE, placement: localPresence(SHA_A, SHA_D) }),
|
|
394
|
+
LOCAL_STATE.EDITED,
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("MISSING when not on disk, or on disk with no baseline to verify against", () => {
|
|
399
|
+
assert.equal(
|
|
400
|
+
computeLocalIntegrity({ baseline: BASELINE, placement: { present: false, skillMdSha256: null, filesSha256: null } }),
|
|
401
|
+
LOCAL_STATE.MISSING,
|
|
402
|
+
);
|
|
403
|
+
assert.equal(
|
|
404
|
+
computeLocalIntegrity({ baseline: null, placement: localPresence(SHA_A, SHA_B) }),
|
|
405
|
+
LOCAL_STATE.MISSING,
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("EDITED (never PRESENT) when a disk SHA is null — cannot verify → conservative", () => {
|
|
410
|
+
assert.equal(
|
|
411
|
+
computeLocalIntegrity({ baseline: BASELINE, placement: { present: true, skillMdSha256: null, filesSha256: SHA_B } }),
|
|
412
|
+
LOCAL_STATE.EDITED,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("rollupLocal", () => {
|
|
418
|
+
it("precedence missing > edited > present", () => {
|
|
419
|
+
assert.equal(rollupLocal([LOCAL_STATE.PRESENT, LOCAL_STATE.EDITED, LOCAL_STATE.MISSING]), LOCAL_STATE.MISSING);
|
|
420
|
+
assert.equal(rollupLocal([LOCAL_STATE.PRESENT, LOCAL_STATE.EDITED]), LOCAL_STATE.EDITED);
|
|
421
|
+
assert.equal(rollupLocal([LOCAL_STATE.PRESENT, LOCAL_STATE.PRESENT]), LOCAL_STATE.PRESENT);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("empty array is MISSING (nothing to vouch for) — the conservative floor", () => {
|
|
425
|
+
assert.equal(rollupLocal([]), LOCAL_STATE.MISSING);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -84,11 +84,24 @@ describe("buildDiskByName", () => {
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
// ── tracked rows (on disk ∩ expected) ───────────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// DISK-TRUTH INVARIANT (#2824 R1): `row.version` is the INSTALLED version —
|
|
89
|
+
// the `.last-sync` baseline (what the last sync wrote to disk) — NEVER the
|
|
90
|
+
// server's served version, which rides `row.servedVersion` as context (R2/R4).
|
|
91
|
+
// Every fixture below DIVERGES disk ≠ served so "show disk" and "show server"
|
|
92
|
+
// produce different output; the ONE exception is the explicitly-labelled
|
|
93
|
+
// steady-state test (a healthy LATEST row is disk == served by definition).
|
|
94
|
+
// Each `version` assertion therefore FAILS on the pre-#2824 build (which put
|
|
95
|
+
// `servedVersion` in `version`) and passes after — red-first, per
|
|
96
|
+
// docs/CLI-LIST-TEST-STANDARD.md.
|
|
87
97
|
|
|
88
98
|
describe("classifyRows — tracked", () => {
|
|
89
99
|
const detected = ["claudeCode"];
|
|
90
100
|
|
|
91
|
-
it("present + latest + up-to-date
|
|
101
|
+
it("STEADY STATE (the one allowed disk==served): present + latest + up-to-date → ok, version is installed", () => {
|
|
102
|
+
// The ONLY test permitted to set disk === served: a healthy LATEST row is
|
|
103
|
+
// disk==served BY DEFINITION (you hold exactly what is served). Labelled so
|
|
104
|
+
// it can never be mistaken for a version-distinguishing test.
|
|
92
105
|
const rows = classifyRows({
|
|
93
106
|
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_A, SHA_B)])),
|
|
94
107
|
detectedVendorKeys: detected,
|
|
@@ -103,23 +116,37 @@ describe("classifyRows — tracked", () => {
|
|
|
103
116
|
assert.equal(r.freshness, FRESHNESS.UP_TO_DATE);
|
|
104
117
|
assert.equal(r.governance.status, "latest");
|
|
105
118
|
assert.equal(r.signal, SIGNAL.OK);
|
|
119
|
+
assert.equal(r.version, "1.0.0", "installed (disk) version");
|
|
120
|
+
assert.equal(r.servedVersion, "1.0.0", "served == installed in the steady state");
|
|
106
121
|
});
|
|
107
122
|
|
|
108
|
-
it("
|
|
123
|
+
it("recalled-not-synced: disk holds the recalled version, server serves the rollback → version is DISK, servedVersion is the rollback", () => {
|
|
124
|
+
// THE canonical Fault-A state (#2824 R1/R2). You synced 2.0.0; the team
|
|
125
|
+
// then recalled 2.0.0 and the server now serves the 1.9.0 rollback — but
|
|
126
|
+
// 2.0.0 is still the file on disk (and still what the agent loads). `list`
|
|
127
|
+
// must report 2.0.0 (what you have), never 1.9.0 (what the server serves).
|
|
128
|
+
// Pre-#2824 this row reported 1.9.0 → this assertion fails on that build.
|
|
109
129
|
const rows = classifyRows({
|
|
110
|
-
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "
|
|
130
|
+
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "sec", SHA_A, SHA_B)])),
|
|
111
131
|
detectedVendorKeys: detected,
|
|
112
|
-
expected: [{ owner: "alice", name: "
|
|
113
|
-
baselineByKey: baseline({ "alice/
|
|
114
|
-
serverTruth: serverTruth200({
|
|
132
|
+
expected: [{ owner: "alice", name: "sec", version: "1.9.0" }],
|
|
133
|
+
baselineByKey: baseline({ "alice/sec": { version: "2.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
|
|
134
|
+
serverTruth: serverTruth200({
|
|
135
|
+
"alice/sec": { servedVersion: "1.9.0", governance: { status: "serving", head: "2.0.0", recalledVersion: "2.0.0" } },
|
|
136
|
+
}),
|
|
115
137
|
variant: "library",
|
|
116
138
|
});
|
|
117
|
-
const r = rowByName(rows, "
|
|
118
|
-
assert.equal(r.
|
|
119
|
-
assert.equal(r.
|
|
139
|
+
const r = rowByName(rows, "sec");
|
|
140
|
+
assert.equal(r.version, "2.0.0", "R1: the INSTALLED (disk/baseline) version, NOT the server's 1.9.0 rollback");
|
|
141
|
+
assert.equal(r.servedVersion, "1.9.0", "R2/R4: the server's served rollback is carried as distinct context");
|
|
142
|
+
assert.equal(r.local, LOCAL_STATE.PRESENT, "the on-disk 2.0.0 matches its baseline");
|
|
143
|
+
assert.equal(r.freshness, FRESHNESS.UP_TO_DATE, "the server rolled BACK, it did not move forward — never 'behind'");
|
|
144
|
+
assert.equal(r.governance.status, "serving");
|
|
145
|
+
assert.equal(r.governance.recalledVersion, "2.0.0");
|
|
146
|
+
assert.equal(r.signal, SIGNAL.SERVING);
|
|
120
147
|
});
|
|
121
148
|
|
|
122
|
-
it("server moved forward →
|
|
149
|
+
it("behind: server moved forward → version is DISK, servedVersion is the newer available version", () => {
|
|
123
150
|
const rows = classifyRows({
|
|
124
151
|
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_A, SHA_B)])),
|
|
125
152
|
detectedVendorKeys: detected,
|
|
@@ -129,27 +156,29 @@ describe("classifyRows — tracked", () => {
|
|
|
129
156
|
variant: "library",
|
|
130
157
|
});
|
|
131
158
|
const r = rowByName(rows, "pdf");
|
|
159
|
+
assert.equal(r.version, "1.0.0", "R1: installed (disk) version, NOT the newer served 2.0.0");
|
|
160
|
+
assert.equal(r.servedVersion, "2.0.0", "R2: the available version rides servedVersion as context");
|
|
132
161
|
assert.equal(r.freshness, FRESHNESS.BEHIND);
|
|
133
162
|
assert.equal(r.signal, SIGNAL.BEHIND);
|
|
134
163
|
});
|
|
135
164
|
|
|
136
|
-
it("
|
|
165
|
+
it("edited content → local edited, signal edited, version stays DISK", () => {
|
|
137
166
|
const rows = classifyRows({
|
|
138
|
-
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "
|
|
167
|
+
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_EDIT, SHA_B)])),
|
|
139
168
|
detectedVendorKeys: detected,
|
|
140
|
-
expected: [{ owner: "alice", name: "
|
|
141
|
-
baselineByKey: baseline({ "alice/
|
|
142
|
-
serverTruth: serverTruth200({
|
|
143
|
-
"alice/sec": { servedVersion: "1.9.0", governance: { status: "serving", head: "2.0.0", recalledVersion: "2.0.0" } },
|
|
144
|
-
}),
|
|
169
|
+
expected: [{ owner: "alice", name: "pdf", version: "2.0.0" }],
|
|
170
|
+
baselineByKey: baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
|
|
171
|
+
serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "2.0.0", governance: { status: "latest", head: "2.0.0" } } }),
|
|
145
172
|
variant: "library",
|
|
146
173
|
});
|
|
147
|
-
const r = rowByName(rows, "
|
|
148
|
-
assert.equal(r.
|
|
149
|
-
assert.equal(r.signal, SIGNAL.
|
|
174
|
+
const r = rowByName(rows, "pdf");
|
|
175
|
+
assert.equal(r.local, LOCAL_STATE.EDITED);
|
|
176
|
+
assert.equal(r.signal, SIGNAL.EDITED, "edited is more actionable than behind");
|
|
177
|
+
assert.equal(r.version, "1.0.0", "R1: installed (disk) version even on an edited, behind row");
|
|
178
|
+
assert.equal(r.servedVersion, "2.0.0");
|
|
150
179
|
});
|
|
151
180
|
|
|
152
|
-
it("edited outranks behind + serving in the collapsed signal", () => {
|
|
181
|
+
it("edited outranks behind + serving in the collapsed signal, version stays DISK", () => {
|
|
153
182
|
const rows = classifyRows({
|
|
154
183
|
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_EDIT, SHA_B)])),
|
|
155
184
|
detectedVendorKeys: detected,
|
|
@@ -164,21 +193,25 @@ describe("classifyRows — tracked", () => {
|
|
|
164
193
|
assert.equal(r.local, LOCAL_STATE.EDITED);
|
|
165
194
|
assert.equal(r.freshness, FRESHNESS.BEHIND);
|
|
166
195
|
assert.equal(r.signal, SIGNAL.EDITED, "edited is more actionable than behind/serving");
|
|
196
|
+
assert.equal(r.version, "1.0.0", "R1: installed (disk) version regardless of the collapsed signal");
|
|
197
|
+
assert.equal(r.servedVersion, "2.0.0");
|
|
167
198
|
});
|
|
168
199
|
|
|
169
|
-
it("present at one vendor, absent at another → local missing (worst-wins)", () => {
|
|
200
|
+
it("present at one vendor, absent at another → local missing (worst-wins), version is DISK", () => {
|
|
170
201
|
const rows = classifyRows({
|
|
171
202
|
diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_A, SHA_B)])),
|
|
172
203
|
detectedVendorKeys: ["claudeCode", "cursor"],
|
|
173
|
-
expected: [{ owner: "alice", name: "pdf", version: "
|
|
204
|
+
expected: [{ owner: "alice", name: "pdf", version: "2.0.0" }],
|
|
174
205
|
baselineByKey: baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
|
|
175
|
-
serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "
|
|
206
|
+
serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "2.0.0", governance: { status: "latest", head: "2.0.0" } } }),
|
|
176
207
|
variant: "library",
|
|
177
208
|
});
|
|
178
209
|
const r = rowByName(rows, "pdf");
|
|
179
210
|
assert.equal(r.local, LOCAL_STATE.MISSING);
|
|
180
211
|
assert.equal(r.placements.length, 2);
|
|
181
212
|
assert.equal(r.placements.find((p) => p.vendor === "cursor").present, false);
|
|
213
|
+
assert.equal(r.version, "1.0.0", "R1: installed (disk) version");
|
|
214
|
+
assert.equal(r.servedVersion, "2.0.0");
|
|
182
215
|
});
|
|
183
216
|
});
|
|
184
217
|
|
|
@@ -190,17 +223,24 @@ describe("classifyRows — server-truth modes", () => {
|
|
|
190
223
|
const expected = [{ owner: "alice", name: "pdf", version: "1.0.0" }];
|
|
191
224
|
const base = () => baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } });
|
|
192
225
|
|
|
193
|
-
it("server not consulted (null) → freshness not-checked,
|
|
226
|
+
it("server not consulted (null) → freshness not-checked, version is still DISK, servedVersion null", () => {
|
|
194
227
|
const rows = classifyRows({ diskByName: disk(), detectedVendorKeys: detected, expected, baselineByKey: base(), serverTruth: null, variant: "library" });
|
|
195
228
|
const r = rowByName(rows, "pdf");
|
|
196
229
|
assert.equal(r.freshness, FRESHNESS.NOT_CHECKED);
|
|
197
230
|
assert.equal(r.governance, null);
|
|
198
231
|
assert.equal(r.signal, SIGNAL.NOT_CHECKED);
|
|
232
|
+
// Offline still shows the on-disk version (the local axis needs no network);
|
|
233
|
+
// the server's version is unknown → null, never a fabricated value.
|
|
234
|
+
assert.equal(r.version, "1.0.0", "installed (disk) version, offline");
|
|
235
|
+
assert.equal(r.servedVersion, null, "server not consulted → no served version");
|
|
199
236
|
});
|
|
200
237
|
|
|
201
|
-
it("checked:false (offline probe) → not-checked", () => {
|
|
238
|
+
it("checked:false (offline probe) → not-checked, version DISK, servedVersion null", () => {
|
|
202
239
|
const rows = classifyRows({ diskByName: disk(), detectedVendorKeys: detected, expected, baselineByKey: base(), serverTruth: { checked: false, byKey: null }, variant: "library" });
|
|
203
|
-
|
|
240
|
+
const r = rowByName(rows, "pdf");
|
|
241
|
+
assert.equal(r.freshness, FRESHNESS.NOT_CHECKED);
|
|
242
|
+
assert.equal(r.version, "1.0.0", "installed (disk) version, offline probe");
|
|
243
|
+
assert.equal(r.servedVersion, null);
|
|
204
244
|
});
|
|
205
245
|
});
|
|
206
246
|
|
|
@@ -220,6 +260,11 @@ describe("classifyRows — missing / not-delivered", () => {
|
|
|
220
260
|
assert.equal(r.classification, CLASSIFICATION.MISSING);
|
|
221
261
|
assert.equal(r.local, LOCAL_STATE.MISSING);
|
|
222
262
|
assert.equal(r.signal, SIGNAL.MISSING);
|
|
263
|
+
// Not installed → there is no disk version. The spec's not-installed rule
|
|
264
|
+
// shows the server/expected version instead; the MISSING classification is
|
|
265
|
+
// what marks it not-installed, so this is not a "false installed version".
|
|
266
|
+
assert.equal(r.version, "1.0.0", "not-installed: the server/expected version is shown");
|
|
267
|
+
assert.equal(r.servedVersion, "1.0.0");
|
|
223
268
|
});
|
|
224
269
|
|
|
225
270
|
it("skillset served-but-unwritten → not-delivered with reason", () => {
|
|
@@ -235,6 +280,9 @@ describe("classifyRows — missing / not-delivered", () => {
|
|
|
235
280
|
assert.equal(r.classification, CLASSIFICATION.NOT_DELIVERED);
|
|
236
281
|
assert.equal(r.reason, "modified");
|
|
237
282
|
assert.equal(r.signal, SIGNAL.NOT_DELIVERED);
|
|
283
|
+
// Not on disk (guard-refused) → the declared version is shown, not installed.
|
|
284
|
+
assert.equal(r.version, "1.0.0", "the declared/expected version for a not-delivered member");
|
|
285
|
+
assert.equal(r.servedVersion, null, "no server-truth lookup on the not-delivered branch");
|
|
238
286
|
});
|
|
239
287
|
});
|
|
240
288
|
|
|
@@ -255,6 +303,9 @@ describe("classifyRows — foreign / orphan", () => {
|
|
|
255
303
|
assert.equal(r.owner, null);
|
|
256
304
|
assert.equal(r.local, LOCAL_STATE.PRESENT);
|
|
257
305
|
assert.equal(r.signal, SIGNAL.FOREIGN);
|
|
306
|
+
// No baseline and not from the library → no known version either way.
|
|
307
|
+
assert.equal(r.version, null, "a hand-authored dir has no known version");
|
|
308
|
+
assert.equal(r.servedVersion, null);
|
|
258
309
|
});
|
|
259
310
|
|
|
260
311
|
it("on disk + in baseline but NOT served → orphan (#2804)", () => {
|
|
@@ -271,7 +322,8 @@ describe("classifyRows — foreign / orphan", () => {
|
|
|
271
322
|
const r = rowByName(rows, "recalled");
|
|
272
323
|
assert.equal(r.classification, CLASSIFICATION.ORPHAN);
|
|
273
324
|
assert.equal(r.owner, "alice");
|
|
274
|
-
assert.equal(r.version, "1.2.0");
|
|
325
|
+
assert.equal(r.version, "1.2.0", "R1: the version on disk (baseline), the only truth for a disowned orphan");
|
|
326
|
+
assert.equal(r.servedVersion, null, "the server no longer serves it → no served version");
|
|
275
327
|
assert.equal(r.local, LOCAL_STATE.PRESENT);
|
|
276
328
|
assert.equal(r.signal, SIGNAL.ORPHAN);
|
|
277
329
|
});
|
|
@@ -289,6 +341,8 @@ describe("classifyRows — foreign / orphan", () => {
|
|
|
289
341
|
assert.equal(r.classification, CLASSIFICATION.ORPHAN);
|
|
290
342
|
assert.equal(r.local, LOCAL_STATE.EDITED);
|
|
291
343
|
assert.equal(r.signal, SIGNAL.ORPHAN, "orphan outranks edited");
|
|
344
|
+
assert.equal(r.version, "1.2.0", "R1: still the on-disk baseline version, even when edited");
|
|
345
|
+
assert.equal(r.servedVersion, null);
|
|
292
346
|
});
|
|
293
347
|
});
|
|
294
348
|
|