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:
gsxdsm
2026-07-31 13:34:03 -07:00
committed by GitHub
parent 0698ce6f9c
commit 56e16d9dea
2 changed files with 175 additions and 8 deletions

View File

@@ -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("●");
});
});

View File

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