fix(cli): fn task list never printed cards in renamed columns (#2986)
## `fn task list` never printed cards in renamed columns
```ts
for (const col of COLUMNS) { // the legacy six ids
const colTasks = tasks.filter((t) => t.column === col);
```
A task in a workflow-defined column matches **no iteration**, so it is
not printed. This is not a wrong label or a wrong glyph — **the card is
absent**, and the output reads as a shorter, healthy board rather than
as a bug. On a fully renamed board the command prints nothing but the
header. `COLUMNS` also still contains `triage`, which U11 (#2515)
deleted.
## Found where the previous author left it
The `DELIBERATE-LITERAL` note directly above this loop is correct about
its own glyph, and it named the deeper bug rather than hiding it:
> NOT claimed as trait-resolved, and the deeper bug is left alone:
because the loop iterates the legacy enum, a card in a workflow-renamed
column is not rendered AT ALL. That is the R8/U10 surface change […] and
a far bigger fix than this glyph.
It also predicted the coupling: *"If this ever iterates
workflow-resolved columns, that difference becomes live and the right
answer is a trait lookup, not this."* So both move together — once the
loop can yield a custom id, the terminal test **must** stop being an id
comparison. Fixing only the loop would leave a renamed done-lane
rendering as active work.
## Two deliberate choices
**Lanes come from the tasks, not from a resolved IR.** A board can span
several workflows and therefore has no single column list, and a card
must never depend on a resolution succeeding in order to be *visible*.
Legacy ids keep their familiar order and labels; anything else follows
alphabetically, so output is deterministic.
**Terminal lanes are resolved**, via
`resolveProjectColumnsForRoles(TERMINAL_ROLES)` — that is a display
question with a real answer, and this function is async with a store in
hand. 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 the blank board this replaces.
## Coverage, and its limit stated plainly
The lane-selection decision is extracted to an exported seam and tested
there. It is **not** end-to-end: `runTaskList` resolves a real project
context and ends in `process.exit`, so driving it would need the
mock-the-world shell `docs/testing.md` tells us to avoid when a narrower
seam exists. The call site is held by the compiler instead — the loop's
only source of lanes is that function. I have written this in the test
file rather than leaving it implied, because "5 passed" on a helper
could otherwise read as proof of the command's behaviour.
Reverted — the seam returning `[...COLUMNS]`, which is exactly what the
loop did — **all 5 cases fail**:
```
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'backlog', 'building', 'checking' ]
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'todo', 'shipped' ]
Tests 5 failed (5)
```
## Verification (measured)
- **86 passed / 3 files** — new suite plus `bin.test.ts` and
`pr-merge-review-lane.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (26, none
added), `check-fnxc-future-dates` — green
- `pnpm check:changesets` — clean; changeset included (`patch`), since
`packages/cli` is the published `@runfusion/fusion` and this is
user-facing
This commit is contained in:
7
.changeset/cli-task-list-renamed-columns.md
Normal file
7
.changeset/cli-task-list-renamed-columns.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix `fn task list` silently omitting cards in renamed or custom workflow columns.
|
||||
category: fix
|
||||
dev: `runTaskList` iterated the legacy six-id `COLUMNS` constant and filtered `t.column === col`, so a card in a workflow-defined column matched no iteration and was never printed. Lanes now come from the tasks themselves via the exported `boardColumnsForDisplay`, and the terminal glyph resolves via `resolveProjectColumnsForRoles(TERMINAL_ROLES)` with the legacy pair as a fail-soft fallback.
|
||||
53
packages/cli/src/__tests__/task-list-board-columns.test.ts
Normal file
53
packages/cli/src/__tests__/task-list-board-columns.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
FNXC:CliBoardVocabulary 2026-07-30-24:40:
|
||||
THE INVARIANT: `fn task list` prints every card, whatever its board calls the lane.
|
||||
|
||||
`runTaskList` iterated the six-id `COLUMNS` constant and filtered `t.column === col`, so a card in a
|
||||
workflow-defined column matched no iteration and was never printed — the board looked shorter and
|
||||
healthy rather than broken, and a fully renamed board printed nothing but the header.
|
||||
|
||||
SCOPE OF THIS COVERAGE, stated rather than implied: these cases pin the lane-selection decision,
|
||||
which is the entire content of the fix. They do NOT prove `runTaskList` calls it — that function
|
||||
resolves a real project context and ends in `process.exit`, so driving it needs a mock-the-world
|
||||
shell, the shape `docs/testing.md` says to avoid when a narrower seam exists. The call site is held
|
||||
by the compiler instead: the loop's only source of lanes is this function.
|
||||
|
||||
Reverted — this function returning `[...COLUMNS]`, which is what the loop did — the first two cases
|
||||
fail: renamed lanes vanish entirely, and `shipped` never appears.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { boardColumnsForDisplay } from "../commands/task.js";
|
||||
|
||||
const at = (...columns: string[]) => columns.map((column) => ({ column }));
|
||||
|
||||
describe("boardColumnsForDisplay", () => {
|
||||
it("includes workflow-defined lanes the legacy enum has never heard of", () => {
|
||||
expect(boardColumnsForDisplay(at("backlog", "building", "checking"))).toEqual([
|
||||
"backlog",
|
||||
"building",
|
||||
"checking",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a renamed terminal lane, which the legacy filter dropped silently", () => {
|
||||
expect(boardColumnsForDisplay(at("todo", "shipped"))).toEqual(["todo", "shipped"]);
|
||||
});
|
||||
|
||||
it("orders legacy lanes in their familiar board order, whatever order the cards arrive in", () => {
|
||||
expect(boardColumnsForDisplay(at("done", "todo", "in-review", "in-progress"))).toEqual([
|
||||
"todo",
|
||||
"in-progress",
|
||||
"in-review",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts custom lanes after legacy ones and sorts them deterministically", () => {
|
||||
expect(boardColumnsForDisplay(at("zeta", "todo", "alpha"))).toEqual(["todo", "alpha", "zeta"]);
|
||||
});
|
||||
|
||||
it("emits each lane once however many cards sit in it, and nothing for an empty board", () => {
|
||||
expect(boardColumnsForDisplay(at("building", "building", "building"))).toEqual(["building"]);
|
||||
expect(boardColumnsForDisplay([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, resolveProjectColumnsForRoles, TERMINAL_ROLES, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
|
||||
import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
@@ -25,6 +25,34 @@ function columnLabel(column: ColumnId): string {
|
||||
return (COLUMN_LABELS as Record<string, string>)[column] ?? column;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CliBoardVocabulary 2026-07-30-24:40:
|
||||
The lanes `fn task list` prints, derived from the CARDS rather than from the legacy enum.
|
||||
|
||||
`runTaskList` iterated the six-id `COLUMNS` constant and filtered `t.column === col`, so a task in a
|
||||
workflow-defined column matched no iteration and was NOT PRINTED. Not a wrong label — the card is
|
||||
absent, and the output reads as a shorter, healthy board rather than as a bug. A fully renamed board
|
||||
prints nothing but the header.
|
||||
|
||||
Derived from the tasks, not from a resolved IR, deliberately: a board can span several workflows and
|
||||
therefore has no single column list, and a card must never depend on a resolution succeeding in order
|
||||
to be VISIBLE. Legacy ids keep their familiar order; anything else follows alphabetically, so output
|
||||
is deterministic.
|
||||
|
||||
Exported for test: `runTaskList` itself resolves a real project context and ends in `process.exit`,
|
||||
so covering it end-to-end would need a mock-the-world shell — the shape `docs/testing.md` tells us to
|
||||
avoid when a narrower seam exists. This IS the seam: it is the whole decision about which lanes appear.
|
||||
*/
|
||||
export function boardColumnsForDisplay(tasks: ReadonlyArray<{ column: ColumnId }>): ColumnId[] {
|
||||
const legacyOrder = (id: string) => {
|
||||
const index = (COLUMNS as readonly string[]).indexOf(id);
|
||||
return index === -1 ? COLUMNS.length : index;
|
||||
};
|
||||
return [...new Set(tasks.map((t) => t.column))].sort((a, b) =>
|
||||
legacyOrder(a) === legacyOrder(b) ? String(a).localeCompare(String(b)) : legacyOrder(a) - legacyOrder(b),
|
||||
);
|
||||
}
|
||||
|
||||
// Register GitHub tracking hook so CLI task creation paths (add, duplicate,
|
||||
// refine, import, delegate) trigger tracking issue creation.
|
||||
try {
|
||||
@@ -604,11 +632,41 @@ export async function runTaskList(projectName?: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
for (const col of COLUMNS) {
|
||||
/*
|
||||
FNXC:CliBoardVocabulary 2026-07-30-24:40:
|
||||
Iterate the columns the BOARD has, not the legacy six — a renamed card was not printed AT ALL.
|
||||
|
||||
This loop ran `for (const col of COLUMNS)` and filtered `t.column === col`, so any task in a
|
||||
workflow-defined column matched no iteration and `fn task list` silently omitted it. Not a wrong
|
||||
label or a wrong glyph: the card is absent, and the output looks like a shorter, healthy board.
|
||||
On a fully renamed board the command prints nothing but the header.
|
||||
|
||||
The glyph note directly above predicted exactly this ("If this ever iterates workflow-resolved
|
||||
columns, that difference becomes live and the right answer is a trait lookup, not this") and left
|
||||
the deeper bug named as R8/U10 surface work. It is fixed here because the two cannot be separated:
|
||||
once the loop can yield a custom id, the terminal test below MUST stop being an id comparison.
|
||||
|
||||
Columns come from the TASKS rather than from a resolved IR, so every card is rendered whatever its
|
||||
workflow — a board spanning several workflows has no single column list, and a card must never
|
||||
depend on resolution succeeding to be visible. Legacy ids keep their familiar order and labels;
|
||||
anything else follows, alphabetically, so the output is deterministic.
|
||||
|
||||
Terminal lanes ARE resolved, because that is a display question with a real answer and this
|
||||
function is async with a store in hand. Best-effort: a failed resolve falls back to the legacy
|
||||
pair rather than failing the command, and an unresolved custom lane renders as active — the same
|
||||
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(
|
||||
context.store as Parameters<typeof resolveProjectColumnsForRoles>[0],
|
||||
TERMINAL_ROLES,
|
||||
).catch(() => undefined);
|
||||
|
||||
for (const col of boardColumnsForDisplay(tasks)) {
|
||||
const colTasks = tasks.filter((t) => t.column === col);
|
||||
if (colTasks.length === 0) continue;
|
||||
|
||||
const label = COLUMN_LABELS[col];
|
||||
const label = columnLabel(col);
|
||||
/*
|
||||
FNXC:CliBoardGlyph 2026-07-29-22:40 (lifecycle-column vocabulary):
|
||||
All four non-terminal columns rendered the SAME glyph, so the four id comparisons
|
||||
@@ -630,11 +688,10 @@ export async function runTaskList(projectName?: string) {
|
||||
ALL. That is the R8/U10 surface change (no surface derives its column set from the
|
||||
legacy enum) and a far bigger fix than this glyph.
|
||||
*/
|
||||
/* DELIBERATE-LITERAL: `col` comes from the legacy `COLUMNS` enum this loop iterates, so the literal
|
||||
matches its own receiver by construction. The real defect is named in the comment above — a card in a
|
||||
workflow-renamed column is not rendered at all — and converting this glyph would hide that behind a
|
||||
trait lookup while the loop still cannot see the card. Retires with the loop. */
|
||||
const dot = col === "done" || col === "archived" ? "○" : "●";
|
||||
/* The "retires with the loop" condition above is now met: `col` can be a custom id, so the terminal
|
||||
test is a resolved-lane membership check. DELIBERATE-LITERAL only as the degraded fallback when the
|
||||
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})`);
|
||||
for (const t of colTasks) {
|
||||
|
||||
Reference in New Issue
Block a user