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) {