Skip to content

Commit e30139d

Browse files
committed
test(cli): fingerprint the reference shape, cover the wizard's reload banner
Three coverage gaps from #284: - REFERENCE_VERSION was asserted only as a literal (`toBe(2)`), with nothing tying a shape change in Reference/ReferenceRule/ReferenceTests/ReferenceLayout to a version bump. Adds a structural fingerprint (sha256 over a sorted list of "path:kind" pairs, walked from the live object) asserted beside the version, so adding/removing/retyping a field changes the hash while reordering, reformatting, or content-only edits do not. - runWizard wired up getReloadNotice identically to init, but only init's path had a test for it. Mirrors init-no-interactive.test.ts's three cases (prints on an upgrade, quiet on first install, quiet when unchanged) for the wizard. - The migration matrix in migrate-install.test.ts seeds every start version with a bare taskless.json and no README, so migration 0006's rewrite has nothing stale to act on; the test only reads the final version counter. Left as documented non-coverage with a comment pointing at installed-documentation.test.ts, which already seeds a real stale README and asserts on the rewrite. Each new test was mutation-checked (see PR description for the mutation used per test and confirmation it was caught).
1 parent 87c0b74 commit e30139d

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

‎packages/cli/test/migrate-install.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,20 @@ describe("migration version matrix", () => {
207207
// forgets to handle an older starting point.
208208
// Every prior version, derived. The list used to be written out, so each new
209209
// migration silently stopped testing the version it had just made "prior".
210+
//
211+
// NOTE on migration 0006 specifically (0006-refresh-readme.ts): every start
212+
// version here is seeded with a bare `taskless.json` and no pre-existing
213+
// `README.md`, so by the time 0006 runs there is no stale README for its
214+
// rewrite to act on, and this test only reads the final `manifest.version`
215+
// counter afterward. A regression that made 0006 a no-op (e.g. it stopped
216+
// writing the file, or wrote the wrong template) would NOT be caught here --
217+
// this loop would still pass, because a missing README and a freshly
218+
// written one look identical to a test that never reads README.md. Do not
219+
// mistake this matrix for coverage of 0006's actual rewrite; that coverage
220+
// lives in `installed-documentation.test.ts` ("migration 0006, on a project
221+
// that is already current" / "rewrites a stale README that no other
222+
// migration would touch"), which seeds a real stale README and asserts on
223+
// its rewritten content.
210224
const priorVersions = Array.from(
211225
{ length: LATEST_SCHEMA_VERSION },
212226
(_, index) => index

‎packages/cli/test/reference.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import { readFile } from "node:fs/promises";
23
import { join, sep } from "node:path";
34

@@ -41,6 +42,59 @@ function ruleFor(reference: Reference, engine: string) {
4142
return rule;
4243
}
4344

45+
/**
46+
* Record every (path, kind) pair reachable in `value` into `into`.
47+
*
48+
* `path` uses `[]` for "inside some array", never an index, and an object's
49+
* keys are visited in sorted order rather than insertion order. Both choices
50+
* exist so that reordering an array, or reordering an object literal's keys,
51+
* changes nothing this records — those are the cosmetic edits the fingerprint
52+
* below is required NOT to fail on. What it does record is which keys exist at
53+
* each position and what kind of value sits there (object / array / string /
54+
* number / boolean / null) -- never the value itself, so renaming a rule's
55+
* `id` from "no-eval-call" to something else does not move the fingerprint,
56+
* but adding, removing, or retyping a field does.
57+
*/
58+
function shapeOf(value: unknown, path: string, into: Set<string>): void {
59+
if (Array.isArray(value)) {
60+
into.add(`${path}:array`);
61+
for (const item of value) shapeOf(item, `${path}[]`, into);
62+
return;
63+
}
64+
if (value !== null && typeof value === "object") {
65+
into.add(`${path}:object`);
66+
for (const key of Object.keys(
67+
value as Record<string, unknown>
68+
).toSorted()) {
69+
shapeOf((value as Record<string, unknown>)[key], `${path}.${key}`, into);
70+
}
71+
return;
72+
}
73+
into.add(`${path}:${value === null ? "null" : typeof value}`);
74+
}
75+
76+
/**
77+
* A structural fingerprint of the reference payload's SHAPE, derived from the
78+
* live object rather than hand-copied -- so it moves when `Reference` /
79+
* `ReferenceRule` / `ReferenceTests` / `ReferenceLayout` change shape, and
80+
* cannot silently rot the way a hand-maintained field list would.
81+
*
82+
* This is the check the `v1 -> v2` episode (see `REFERENCE_VERSION`'s own
83+
* comment, and CLAUDE.md's account of it) needed and didn't have: `tests`
84+
* went from an array to `{ grouping, files, cases? }` and nothing forced
85+
* `REFERENCE_VERSION` to move with it. A future change of the same kind now
86+
* changes this hash, and the fix is the same one either way -- bump
87+
* `REFERENCE_VERSION`, update the expected hash below, and say why in the
88+
* commit that touches both.
89+
*/
90+
function shapeFingerprint(value: unknown): string {
91+
const paths = new Set<string>();
92+
shapeOf(value, "$", paths);
93+
return createHash("sha256")
94+
.update([...paths].toSorted().join("\n"))
95+
.digest("hex");
96+
}
97+
4498
describe("the demo reference payload", () => {
4599
it("is current — regenerate with `pnpm --filter @taskless/cli reference`", async () => {
46100
expect(await readReference()).toEqual(buildReference(DEMO_RULES));
@@ -248,6 +302,28 @@ describe("the demo reference payload", () => {
248302
expect(reference.version).toBe(2);
249303
});
250304

305+
it("carries a structural fingerprint that moves with the corpus shape", async () => {
306+
const reference = await readReference();
307+
308+
// Nothing else in this file ties a shape change to `REFERENCE_VERSION`.
309+
// Every other test here asserts the shape it expects (a `layout` block, a
310+
// `tests.grouping`, a `signature` on `runtime` only, ...), so any one of
311+
// them would fail if a field vanished -- but NONE of them fail if a field
312+
// is ADDED, and #263 was exactly an addition of shape (`tests` gained a
313+
// structure `layout` had no way to describe) that changed what a
314+
// consumer had to parse without changing what any existing assertion
315+
// here checked.
316+
//
317+
// If this assertion is the one that broke: you changed what `Reference`
318+
// (or `ReferenceRule` / `ReferenceTests` / `ReferenceLayout`) publishes.
319+
// Bump `REFERENCE_VERSION`, update its doc comment to say what changed and
320+
// why, regenerate `assets/reference.json` (`pnpm --filter @taskless/cli
321+
// reference`), and update the hash below to match.
322+
expect(shapeFingerprint(reference)).toBe(
323+
"0add32751f84d71a2684d7586a4d1b1d2fd4dbf543efeabc3a842b57b27ad787"
324+
);
325+
});
326+
251327
it("carries the prompt each rule answers", async () => {
252328
const reference = await readReference();
253329
for (const rule of reference.rules) {

‎packages/cli/test/wizard-integration.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,88 @@ describe("runWizard with an unreadable manifest", () => {
271271
expect(captureSpy).not.toHaveBeenCalledWith("cli_installed");
272272
});
273273
});
274+
275+
/**
276+
* `init-no-interactive.test.ts` covers this same wiring for `init`, and its
277+
* own comment names the risk directly: a dropped `console.log` or a swapped
278+
* field leaves every unit test green while the user is told nothing and their
279+
* agent keeps serving the previous skills. `runWizard` (src/wizard/index.ts)
280+
* grew the identical `getReloadNotice` call, but only the `init` path got a
281+
* test for it -- this mirrors that test's shape for the wizard.
282+
*/
283+
describe("the wizard's restart-your-agents banner", () => {
284+
it("prints on a second run whose recorded version moved", async () => {
285+
clackResponses.locations = [".claude"];
286+
clackResponses.summary = true;
287+
288+
const { runWizard } = await import("../src/wizard");
289+
await runWizard({ cwd });
290+
291+
// Plant an older recorded version, the same way
292+
// `init-no-interactive.test.ts` stages an upgrade: the build under test
293+
// cannot report two versions in one process, so the move has to be staged
294+
// in the manifest between two runs.
295+
const manifestPath = join(cwd, ".taskless", "taskless.json");
296+
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
297+
install?: { cliVersion?: string };
298+
};
299+
if (manifest.install) manifest.install.cliVersion = "0.0.1-planted";
300+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
301+
302+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
303+
try {
304+
const result = await runWizard({ cwd });
305+
expect(result.status).toBe("completed");
306+
307+
const printed = logSpy.mock.calls.map((call) => String(call[0]));
308+
expect(printed.some((line) => line.includes("RESTART YOUR AGENTS"))).toBe(
309+
true
310+
);
311+
// The version it moved FROM, which is the half a reader needs to tell
312+
// an upgrade from a downgrade.
313+
expect(printed.some((line) => line.includes("0.0.1-planted"))).toBe(true);
314+
} finally {
315+
logSpy.mockRestore();
316+
}
317+
});
318+
319+
it("stays quiet on a first run", async () => {
320+
clackResponses.locations = [".claude"];
321+
clackResponses.summary = true;
322+
323+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
324+
try {
325+
const { runWizard } = await import("../src/wizard");
326+
const result = await runWizard({ cwd });
327+
expect(result.status).toBe("completed");
328+
329+
const printed = logSpy.mock.calls.map((call) => String(call[0]));
330+
expect(printed.some((line) => line.includes("RESTART YOUR AGENTS"))).toBe(
331+
false
332+
);
333+
} finally {
334+
logSpy.mockRestore();
335+
}
336+
});
337+
338+
it("stays quiet when the recorded version did not move", async () => {
339+
clackResponses.locations = [".claude"];
340+
clackResponses.summary = true;
341+
342+
const { runWizard } = await import("../src/wizard");
343+
await runWizard({ cwd });
344+
345+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
346+
try {
347+
const result = await runWizard({ cwd });
348+
expect(result.status).toBe("completed");
349+
350+
const printed = logSpy.mock.calls.map((call) => String(call[0]));
351+
expect(printed.some((line) => line.includes("RESTART YOUR AGENTS"))).toBe(
352+
false
353+
);
354+
} finally {
355+
logSpy.mockRestore();
356+
}
357+
});
358+
});

0 commit comments

Comments
 (0)