test(cli): pin the board glyph's terminal-lane resolve (extract seam + pin) (#3238)
## What Pins the CLI board glyph's terminal-lane resolve — **the last flagged site in the repo-wide resolver audit.** Two commits: a behaviour-preserving extraction, then the test. ## I was wrong to flag this as unpinnable In #3236 I recorded this site as not pinnable, reasoning that *"extracting a pure helper and testing it would look like coverage and would not be."* That is true of a helper that **receives** the lane set — such a test passes with the resolve blinded, which is exactly the `reads.ts` trap the audit note records. It is **not** true of one that **resolves** it. Building `resolveReliabilityLanes` in #3237 made the distinction obvious: the seam has to contain the resolve, and then blinding fails a test of it. So the flag was too broad, and correcting it closes the site rather than leaving a permanent excuse. That is the same failure mode I corrected in someone else's note earlier today — a caution that hardens into a reason not to look. ## Measured ``` converted: Tests 5 passed (5) blinded: Tests 2 failed | 3 passed (5) ``` The two failures are the **renamed complete** and **renamed archive** lanes. The three survivors are the default-vocabulary control, the active-lane negative, and the degrade path — all of which should survive. ``` task-list-board-columns + bin: 82 passed typecheck clean; lint clean; fnxc-future-dates: none added ``` ## Why the sibling file did not cover it `task-list-board-columns.test.ts` pins `boardColumnsForDisplay`, which decides **which** lanes print. That function takes no lane set, so it cannot fail when this resolve is blinded — and its own header says so honestly. Two tests about the same command, one of which cannot see the other's bug. ## What breaks without the conversion On a board whose complete lane is `shipped`, a finished lane renders `●` — the same glyph as active work. The board says work is in flight when it shipped. Cosmetic next to the blank-board bug this area already fixed, but wrong in the direction an operator reads at a glance. ## Also pinned Two contracts the surrounding comments assert but nothing tested: - **Cards come from the TASKS, not a resolved IR** — a card must never depend on resolution succeeding to be *visible*. Asserted with an unreadable workflow list. - **A failed resolve degrades to the legacy pair**, with an unresolved custom lane rendering as active — the documented fail-open direction. Plus the paired negative: an ACTIVE lane keeps the active glyph under both vocabularies, so widening the terminal set cannot mark the whole board finished. ## Audit complete Every `resolveProjectColumnsForRoles` call site in the repository — `engine`, `core`, `dashboard`, `cli` — has now been blinded individually, and every uncovered one is either pinned or has a recorded reason it cannot be. Nothing is left flagged.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
FNXC:CliBoardGlyph 2026-07-31-20:23:
|
||||
THE BOARD GLYPH'S TERMINAL-LANE RESOLVE, on a RENAMED board.
|
||||
|
||||
`fn task list` marks each lane with `○` (finished) or `●` (active). That is a lane question, resolved
|
||||
via `resolveProjectColumnsForRoles(store, TERMINAL_ROLES)`.
|
||||
|
||||
WHY THIS FILE EXISTS. That resolve was unreachable by any test — it sat inline in `runTaskList`,
|
||||
which resolves a real project context and ends in `process.exit`. Blinding it left the whole CLI
|
||||
suite green (1,835 tests).
|
||||
|
||||
WHY ITS SIBLING FILE DOES NOT COVER IT. `task-list-board-columns.test.ts` pins
|
||||
`boardColumnsForDisplay`, which decides WHICH lanes print — a different question, and one that takes
|
||||
no lane set. It says so honestly in its own header. Neither it nor any helper test could fail when
|
||||
this resolve is blinded, because the uncovered thing is the RESOLVE, not the decision it feeds. The
|
||||
seam under test here resolves, so blinding fails it.
|
||||
|
||||
WHAT BREAKS WITHOUT THE CONVERSION. On a board whose complete lane is `shipped`, a finished lane
|
||||
renders `●` — the same glyph as active work. The board says work is still in flight when it shipped.
|
||||
Cosmetic next to the blank-board bug this area already fixed, but wrong in the direction an operator
|
||||
reads at a glance.
|
||||
|
||||
DIFFERENTIAL. The same cards under two vocabularies with identical traits; only the ids differ, and
|
||||
no renamed id collides with a legacy one. The default-vocabulary case is the control.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildTaskListBoardLines, type BoardLineTask } from "../commands/task.js";
|
||||
|
||||
const RENAMED_COMPLETE = "shipped";
|
||||
const RENAMED_ARCHIVE = "vaulted";
|
||||
|
||||
function ir(complete: string, archived: string) {
|
||||
return {
|
||||
version: "v2",
|
||||
id: "custom:renamed-cli-board",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
columns: [
|
||||
{ id: "todo", label: "Hold", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", label: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: complete, label: "Complete", traits: [{ trait: "complete" }] },
|
||||
{ id: archived, label: "Archived", traits: [{ trait: "archived" }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A store that can answer differently from the legacy floor — i.e. one with workflow definitions. */
|
||||
function storeWith(complete: string, archived: string) {
|
||||
return {
|
||||
listWorkflowDefinitions: vi.fn(async () => [{ ir: ir(complete, archived) }]),
|
||||
getWorkflowDefinition: vi.fn(async () => ({ ir: ir(complete, archived) })),
|
||||
} as unknown as Parameters<typeof buildTaskListBoardLines>[0];
|
||||
}
|
||||
|
||||
const card = (id: string, column: string): BoardLineTask => ({
|
||||
id,
|
||||
column,
|
||||
title: `card ${id}`,
|
||||
description: "",
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
/** The glyph on the header line for `column`, or undefined if that lane did not render. */
|
||||
function glyphFor(lines: string[], label: string): string | undefined {
|
||||
const header = lines.find((line) => line.includes(`${label} (`));
|
||||
return header?.trim().charAt(0);
|
||||
}
|
||||
|
||||
describe("buildTaskListBoardLines terminal glyph", () => {
|
||||
it("default vocabulary: a finished lane renders the terminal glyph", async () => {
|
||||
const lines = await buildTaskListBoardLines(storeWith("done", "archived"), [card("KB-1", "done")]);
|
||||
expect(glyphFor(lines, "Done")).toBe("○");
|
||||
});
|
||||
|
||||
it("renamed vocabulary: the RENAMED complete lane renders the terminal glyph", async () => {
|
||||
const lines = await buildTaskListBoardLines(
|
||||
storeWith(RENAMED_COMPLETE, RENAMED_ARCHIVE),
|
||||
[card("KB-1", RENAMED_COMPLETE)],
|
||||
);
|
||||
expect(glyphFor(lines, RENAMED_COMPLETE)).toBe("○");
|
||||
});
|
||||
|
||||
it("renamed vocabulary: the RENAMED archive lane renders the terminal glyph", async () => {
|
||||
const lines = await buildTaskListBoardLines(
|
||||
storeWith(RENAMED_COMPLETE, RENAMED_ARCHIVE),
|
||||
[card("KB-1", RENAMED_ARCHIVE)],
|
||||
);
|
||||
expect(glyphFor(lines, RENAMED_ARCHIVE)).toBe("○");
|
||||
});
|
||||
|
||||
it("an ACTIVE lane keeps the active glyph under both vocabularies", async () => {
|
||||
/*
|
||||
The paired negative. Widening the terminal set must not mark everything finished — that would
|
||||
turn a wrong-glyph bug into a board where nothing looks in flight, which is worse.
|
||||
*/
|
||||
const renamed = await buildTaskListBoardLines(
|
||||
storeWith(RENAMED_COMPLETE, RENAMED_ARCHIVE),
|
||||
[card("KB-1", "building")],
|
||||
);
|
||||
expect(glyphFor(renamed, "building")).toBe("●");
|
||||
|
||||
const legacy = await buildTaskListBoardLines(storeWith("done", "archived"), [card("KB-2", "in-progress")]);
|
||||
expect(glyphFor(legacy, "In Progress")).toBe("●");
|
||||
});
|
||||
|
||||
it("renders every card whatever its lane, and falls back to the legacy pair when the board cannot be read", async () => {
|
||||
/*
|
||||
Two contracts the surrounding code documents. Cards come from the TASKS, not from a resolved IR,
|
||||
so a card must never depend on resolution succeeding to be VISIBLE; and a failed resolve degrades
|
||||
to the legacy terminal ids rather than failing the command.
|
||||
*/
|
||||
const unreadable = {
|
||||
listWorkflowDefinitions: vi.fn(async () => {
|
||||
throw new Error("unreadable");
|
||||
}),
|
||||
} as unknown as Parameters<typeof buildTaskListBoardLines>[0];
|
||||
|
||||
const lines = await buildTaskListBoardLines(unreadable, [card("KB-1", RENAMED_COMPLETE), card("KB-2", "done")]);
|
||||
|
||||
expect(lines.some((line) => line.includes("KB-1"))).toBe(true);
|
||||
expect(glyphFor(lines, "Done")).toBe("○");
|
||||
/* An unresolved custom lane renders as active — the documented fail-open direction. */
|
||||
expect(glyphFor(lines, RENAMED_COMPLETE)).toBe("●");
|
||||
});
|
||||
});
|
||||
@@ -657,11 +657,53 @@ export async function runTaskList(projectName?: string) {
|
||||
fail-open direction used elsewhere, since showing a finished card with the wrong glyph is a far
|
||||
smaller error than the blank board this replaces.
|
||||
*/
|
||||
const terminalColumns = await resolveProjectColumnsForRoles(
|
||||
for (const line of await buildTaskListBoardLines(
|
||||
context.store as Parameters<typeof resolveProjectColumnsForRoles>[0],
|
||||
TERMINAL_ROLES,
|
||||
).catch(() => undefined);
|
||||
tasks,
|
||||
)) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
await closeBoardContextAndExit(context, 0);
|
||||
}
|
||||
|
||||
/** A card as the board renderer reads it. */
|
||||
export interface BoardLineTask {
|
||||
id: string;
|
||||
column: string;
|
||||
title?: string | null;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:CliBoardGlyph 2026-07-31-20:23:
|
||||
* The board renderer, INCLUDING its terminal-lane resolve, behind one seam.
|
||||
*
|
||||
* WHY THIS EXISTS. The resolve below was unreachable by any test: it sat inline in `runTaskList`,
|
||||
* which resolves a real project context and ends in `process.exit`, so driving it needs the
|
||||
* mock-the-world shell `docs/testing.md` forbids. Blinding it left the whole CLI suite green.
|
||||
*
|
||||
* Extracting a helper that RECEIVES the lane set would not have helped — such a test passes with the
|
||||
* resolve blinded, because the resolve is the uncovered thing, not the decision it feeds. This
|
||||
* function RESOLVES, so blinding the resolve fails a test of it. Same seam shape as
|
||||
* `resolveReliabilityLanes` in the dashboard.
|
||||
*
|
||||
* Returns the lines rather than printing them, so a test can read the glyph without capturing
|
||||
* stdout. `runTaskList` prints them unchanged.
|
||||
*/
|
||||
export async function buildTaskListBoardLines(
|
||||
store: Parameters<typeof resolveProjectColumnsForRoles>[0],
|
||||
tasks: BoardLineTask[],
|
||||
): Promise<string[]> {
|
||||
/*
|
||||
Best-effort: a failed resolve falls back to the legacy pair rather than failing the command, and an
|
||||
unresolved custom lane renders as active — showing a finished card with the wrong glyph is a far
|
||||
smaller error than failing the whole board.
|
||||
*/
|
||||
const terminalColumns = await resolveProjectColumnsForRoles(store, TERMINAL_ROLES).catch(() => undefined);
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const col of boardColumnsForDisplay(tasks)) {
|
||||
const colTasks = tasks.filter((t) => t.column === col);
|
||||
if (colTasks.length === 0) continue;
|
||||
@@ -693,16 +735,15 @@ export async function runTaskList(projectName?: string) {
|
||||
resolve failed, which is the documented unconverted-caller default. */
|
||||
const dot = (terminalColumns ? terminalColumns.has(col) : col === "done" || col === "archived") ? "○" : "●";
|
||||
|
||||
console.log(` ${dot} ${label} (${colTasks.length})`);
|
||||
lines.push(` ${dot} ${label} (${colTasks.length})`);
|
||||
for (const t of colTasks) {
|
||||
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
|
||||
const label = t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
|
||||
console.log(` ${t.id} ${label}${deps}`);
|
||||
lines.push(` ${t.id} ${label}${deps}`);
|
||||
}
|
||||
console.log();
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
await closeBoardContextAndExit(context, 0);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function runTaskUpdate(id: string, stepStr: string, status: string, projectName?: string) {
|
||||
|
||||
Reference in New Issue
Block a user