From 56e16d9dea3a1963b9892567a1feeae950987f2b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 13:34:03 -0700 Subject: [PATCH] test(cli): pin the board glyph's terminal-lane resolve (extract seam + pin) (#3238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../task-list-terminal-glyph-lanes.test.ts | 126 ++++++++++++++++++ packages/cli/src/commands/task.ts | 57 ++++++-- 2 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/__tests__/task-list-terminal-glyph-lanes.test.ts diff --git a/packages/cli/src/__tests__/task-list-terminal-glyph-lanes.test.ts b/packages/cli/src/__tests__/task-list-terminal-glyph-lanes.test.ts new file mode 100644 index 0000000000..b9f04fa915 --- /dev/null +++ b/packages/cli/src/__tests__/task-list-terminal-glyph-lanes.test.ts @@ -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[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[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("●"); + }); +}); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index d449ca0bf4..4371fafc21 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -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[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[0], + tasks: BoardLineTask[], +): Promise { + /* + 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) {