Files
fusion/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts
gsxdsm 642a4fa264 consolidate/u12 — U12 consolidation: 4 live defects, the AST ratchet fail-closed, and the moves.ts flag scoped (#2647)
One branch, one PR, per the consolidation directive. Contents
file-by-file below.

**Supersedes #2625** (its overlapping conversions landed via U11's
#2624/#2626/#2636; only the parts nobody else did are folded here).
**#2630 and #2639 stay open** — both green with zero threads, per rule
3.

## Four live defects, each measured

**1. Every planning card renders an actions menu.**
`TaskContextMenu.tsx` still had `shouldShowActionsMenu: task.column !==
"triage"` on main *after* the rest of that file was converted. Since
#2515 removed the id, the condition is TRUE for every card, so the
suppression stopped applying anywhere — including on cards whose menu is
empty, the orphaned click target the Surface Enumeration rule exists to
catch.

Found **twice independently**: by reading the guard, and again by the
invariance test below, which failed on main with `shouldShowActionsMenu`
true on one lineage and false on another. That is the argument for an
invariance property over per-site conversion — the file had already been
converted "2 → 1" and the survivor was the live one.

**2. Worktree upcoming-work list empty on renamed boards.**
`groupByWorktree` filtered `t.column === "todo"`. On the default board
the id and the role coincide so every existing test passed; renamed, it
matched nothing and a whole panel read as idle.

**3. Hold-lane FIFO ordering lost on renamed boards.**
`sortTasksForDisplayColumn` gated priority-then-FIFO on `column ===
"todo"`, degrading to the generic id-ordered sort elsewhere. Cards
simply appear in the wrong order, silently.

**4. The AST ratchet still failed open** — fourth time in that file,
third found by review. `receiverName` understood only one-level property
access and bare identifiers, so `task["column"]`, `metadataColumn(entry,
"to")`, ternaries, `(task!.column)` and backtick literals were dropped.
**Measured on main: `in-progress` 196 → 197, `in-review` 211 → 213** —
three real guards nobody counted, including `metadataColumn(entry, "to")
=== "in-review"` in `reliability-metrics.ts`. Now walks wrappers,
resolves calls to the callee name, and emits a `<SyntaxKind>`
**sentinel** for anything unnameable: counted *and* trips the
classification guard, so a human judges it instead of it vanishing.

## Per-file guard counts

| file | before | after |
|---|---:|---:|
| `app/components/TaskContextMenu.tsx` | 1 | **0** |
| `app/utils/worktreeGrouping.ts` | 1 | **0** |
| `app/components/taskSorting.ts` | 1 | **0** |

The other dashboard files I had converted reached 0 via U11's PRs; where
our work overlapped I took theirs during the rebase, including two
places where theirs was **stronger** than mine — they deleted Column's
unreachable quick-create arm outright (with fixtures migrated) where I
had converted it, and they verified the same `isPreExecutionHoldColumn`
degraded-set asymmetry I did, independently.

## Flip precondition: the moves.ts flag is scoped, not flipped

`move-target-declared-census.test.ts` answers precondition 2 with
measurement. 41 engine `moveTask` calls have literal targets — `todo`
27, `in-progress` 7, `done` 6, `archived` 1 — and **all four are
declared by the default lineage**, so the default board is not the
exposure. `triage` appears only in a comment noting `replan-target.ts`
used to hardcode it. My own grep had said `todo=29`; the AST says 27,
because grep counts comments.

The exposure is **custom** lineages: 20 of the 41 carry no
`recoveryRehome` and would reject with unknown-column post-flip; 21 are
exempt via the #1411 carve-out, which makes that carve-out load-bearing.

I did not flip the flag. It is six seams, not the `789`/`837` pair every
summary including mine described, and seam 2 turns on *new refusals*
rather than swapping equivalent implementations — a green suite says
nothing about that. #2639 pins the blast radius.

## Tests

- `column-role-id-invariance.test.tsx` — hold traits fixed, vary only
the column id across MERGED / LEGACY / RENAMED; every decision must
agree. Drives the real consumers, so a component keeping an inline
comparison fails it. Includes a unanimous-and-**false** case so it can't
be satisfied by a predicate hardwired to true. **This is the test that
caught defect 1 on main.**
- `worktreeGrouping.test.ts` — includes two cards both in a column named
`staging`, one hold and one not, asserting opposite answers. That
assertion is impossible under a board-wide column-id set, which is why
hold resolution is keyed per task via `getEffectiveTaskWorkflowId`
(#2625 review).
- `taskSorting.test.ts` — discriminates on the **tiebreak**, not
priority: both branches sort by priority, so my first version passed for
the wrong reason. Equal-priority cards whose `createdAt` order disagrees
with their id order.
- `no-hardcoded-lifecycle-columns.test.ts` — 16 detector cases: 11
shapes counted, 4 legitimate ignored, one asserting the sentinel path.

Revert checks, all run: menu suppression → diff names the field;
worktree → `expected [] to include 'FN-50'`; sort → `FN-2, FN-9` instead
of `FN-9, FN-2`; ratchet → the 3 recovered guards disappear.

## One site that should never be converted

`MissionControlPanel.tsx:46` — `{ id: "triage", match: (c) => c ===
"triage" || c === "signal" || c === "backlog" }` is a deliberate
name-similarity heuristic for the SDLC funnel; it matches synonyms and
folds unknown columns into an "other" bucket so custom columns still
contribute. Converting it changes what the funnel displays. Like the
`live-agent-count` fallbacks, it belongs in a documented floor — **the
ratchet's target is that floor, not zero.**

`DocumentsView.tsx:73` is convertible but the file has no column flags
at all, so a real fix means plumbing board-workflow metadata into a view
that doesn't fetch it — its own unit of work.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). `tsc -p
packages/dashboard/tsconfig.app.json` and `packages/core/tsconfig.json`
clean. Core ratchet + seam suites 24/24. Dashboard target suites 37/38 —
the one failure is the pre-existing `"Back to In Progress"` label
casing, confirmed identical on the base.

---

## Added after the initial push

**5. `TaskCard` lost inline editing on renamed boards; `TaskDetailModal`
kept it.** Still live on main: the modal resolved field editability from
traits in U10/R8, the card used a hardcoded `{triage, todo}` set with
**no trait path at all** — even though `taskColumnFlags` was already in
scope. On a renamed board the title was editable in the modal and the
pencil was missing from the card. Body moved unchanged into
`isFieldEditableColumnRole` so the two surfaces cannot drift again.

The veto traits are the substance: a column can legally carry `hold`
**and** a WIP or review trait, and a plain `intake || hold` check would
let an operator rewrite a description while a session executes against
it.

Coverage gap **measured, not assumed**: mutating `canEdit` back to the
hardcoded set left `TaskCard*` at the same failure count as the
unmutated run — nothing caught it. The four render cases assert the real
`aria-label`; that mutation now fails with `Unable to find an accessible
element ... name 'Edit task'`.

**6. The ratchet's target is a documented FLOOR, not zero** — and this
changes the completion bar.

Zero is not reachable, and chasing it means breaking working code. Two
categories are permanent, now protected as positive assertions so a
future sweep cannot "finish the job" by deleting them:

- `MissionControlPanel.tsx`'s `FUNNEL_STAGES` is a deliberate
**name-similarity** heuristic — it matches `signal`, `backlog`, `to-do`,
`ready`, `shipped` and folds unrecognised columns into an "other" bucket
so a custom board still contributes counts. It is not asking whether a
column has the intake trait; it buckets arbitrary column *names* for
display. Asserted on the **synonym list**, because the synonyms are what
prove it is name matching — if they disappear the site has changed
character and the exemption stops applying.
- `live-agent-count.ts`'s no-flags arm is reachable (a remote store is
deliberately given an empty flag map; a card in an undeclared column has
no flags at all) and deleting the literal makes such a card match **no**
arm, so the queued total silently under-reports a stranded card.

A count with an undocumented floor invites someone to drive it to zero.

**Not done, and why:** `DocumentsView.tsx:73` is convertible but that
file has no column flags anywhere, so a real fix means plumbing
board-workflow metadata into a view that does not fetch it — its own
unit of work, not something to smuggle into a conversion.

**Re-verified after these commits:** `pnpm lint` clean, `pnpm test:gate`
green (10 / 482 / 71), `tsc` clean on core and `tsconfig.app.json`, core
ratchet suite 26/26, `columnRoles` 10/10, `TaskCard.test.tsx` 384/386
(the 2 are pre-existing CSS assertions). `TaskDetail*` is 130 failed /
551 passed **both with and without** this change — verified by stashing,
so pre-existing and unrelated.

---

## Flag resolution: preconditions 1 and 2 are now DISCHARGED.
Precondition 3 is blocked, and by evidence.

**Precondition 1 — the side-effect equivalence proof — done.**
`moves-flag-equivalence.test.ts` runs the same journey under both flag
states against live PG and diffs the persisted row. **Result:
identical** — whole-row equality across 128 fields plus an equal timing
shape, over `todo → in-progress → in-review → todo → in-progress`.

That test was **wrong twice** before it meant anything, and both times
it was passing:

1. **It proved nothing.** `experimentalFeatures` is **global-only**, and
`moves.ts` reads `getSettingsFast()`, which filters global-only keys out
of the project layer. My `updateSettings` write was silently discarded,
`useWorkflow` was false in *both* runs, and the "proof" compared the
legacy path against itself. Found by stamping the flag-ON branch and
observing the test still passed. Now written via `updateGlobalSettings`,
and the helper **asserts the flag took effect** before the journey runs.
2. **The journey was forward-only**, so it never reached the reopen
hook's field resets (`status`, `error`, `blockedBy`, pause clearing) — a
mutation there passed. Extended with a backward move and a re-entry.

Mutation-verified after both fixes: stamping seam 3, and diverging the
reopen hook, each fail the comparison.

**Precondition 2 — done, and its answer is a blocker.** The census says
the default board is safe: all 41 literal engine move targets are
declared by the default lineage. But **20 of those 41 carry no
`recoveryRehome`**, so on a custom lineage that does not declare `todo`
/ `in-progress` / `done`, seam 2 would start rejecting them with
unknown-column. That is a user-facing break on custom boards, not a
theoretical one, and it is not fixed by the equivalence proof — seam 2
adds *new refusals* rather than swapping implementations.

**So the flip is one step away, and the step is not mine to take
alone:** those 20 call sites need to resolve their target from the
task's workflow (or justify `recoveryRehome`), and they live across
engine lanes in `moves.ts` caller territory — U2b/MAIN. Flipping before
that trades a dormant flag for broken custom boards.

What remains for precondition 3 once those land: flip both readers
**atomically** (`moves.ts` + `workflow-task-create-ops.ts`, since the
latter computes the preflight the former consumes), delete the flag-OFF
branch with its guards, and drop the settings key.

---

## CORRECTION: seam 2 is not a blocker. My earlier claim was wrong.

I stated in #2639 and above that "with the flag off there is **no**
target-column validation on the move path", so flipping would introduce
new refusals. **That is not what happens.** Reproduced against live PG:
the identical custom-lineage move rejects with the flag **OFF** as well
—

```
Error: Invalid transition: 'backlog' -> 'todo'. Valid targets: building
```

Transition validation is already in force on the flag-OFF path. So for
the shape in question — an engine move to a column the task's own
workflow does not declare — **the move already fails today**, and seam 2
introduces no new break for it. The 20 census sites lacking
`recoveryRehome` are broken on a custom lineage *now*, not broken by the
flip.

I found this because the discriminator I added to prove "the flag is the
cause" failed. Had I written the test to my assumption it would have
passed and the false claim would have shipped — the same way the
equivalence test passed while proving nothing until I tried to make it
fail.

**Revised precondition status:**

| precondition | status |
|---|---|
| 1 — side-effect equivalence | **discharged** — identical rows,
mutation-verified both directions |
| 2 — seam-2 exposure census | **discharged, and it is not a blocker** —
the rejection predates the flag |
| 3 — flip both readers atomically, delete the flag-OFF branch, drop the
settings key | **the remaining work** |

So the flip is no longer gated on fixing 20 engine call sites. What it
is still gated on is precondition 3 being done atomically across
`moves.ts` and `workflow-task-create-ops.ts` (the latter computes the
preflight the former consumes), which is `moves.ts` caller territory.

Three cases now cover seam 2: the flag-ON rejection, the flag-OFF
rejection (asserting the error *message*, so a change in which guard
rejects stays visible rather than reading as agreement), and the #1411
`recoveryRehome` carve-out succeeding — pinning why that carve-out is
load-bearing and must not be tidied away.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:13:58 -07:00

247 lines
10 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { groupByWorktree, getWorktreeLabel } from "../worktreeGrouping";
import type { Task } from "@fusion/core";
function makeTask(overrides: Partial<Task> & { id: string }): Task {
return {
description: "",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("getWorktreeLabel", () => {
it("extracts last path segment", () => {
expect(getWorktreeLabel(".worktrees/FN-001")).toBe("FN-001");
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
});
it("extracts humanized worktree names", () => {
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
expect(getWorktreeLabel("C:\\repo\\.worktrees\\quiet-falcon")).toBe("quiet-falcon");
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
});
});
describe("groupByWorktree", () => {
it("groups active in-progress tasks by worktree", () => {
const t1 = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const t2 = makeTask({ id: "FN-002", worktree: ".worktrees/quiet-robin" });
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe("swift-falcon");
expect(groups[0].activeTasks).toEqual([t1]);
expect(groups[1].label).toBe("quiet-robin");
expect(groups[1].activeTasks).toEqual([t2]);
});
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const queued = makeTask({
id: "FN-002",
column: "todo",
dependencies: [],
});
const groups = groupByWorktree([active], [active, queued], 2);
// Worktree group should have no queued tasks
const worktreeGroup = groups.find((g) => g.label === "swift-falcon");
expect(worktreeGroup).toBeDefined();
expect(worktreeGroup!.queuedTasks).toEqual([]);
// Up Next should contain the queued task
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toEqual([queued]);
expect(upNext!.activeTasks).toEqual([]);
});
it("does not create Up Next group when there are no eligible queued tasks", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const groups = groupByWorktree([active], [active], 2);
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
});
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const blocked = makeTask({
id: "FN-002",
column: "todo",
dependencies: ["FN-003"], // KB-003 doesn't exist or isn't done
});
const groups = groupByWorktree([active], [active, blocked], 2);
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
});
it("respects maxConcurrent limit on queued tasks shown", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const q1 = makeTask({ id: "FN-010", column: "todo" });
const q2 = makeTask({ id: "FN-011", column: "todo" });
const q3 = makeTask({ id: "FN-012", column: "todo" });
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toHaveLength(2);
});
it("places unassigned in-progress tasks in Unassigned group", () => {
const unassigned = makeTask({ id: "FN-001" }); // no worktree
const groups = groupByWorktree([unassigned], [unassigned], 2);
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe("Unassigned");
expect(groups[0].activeTasks).toEqual([unassigned]);
});
it("excludes paused todo tasks from Up Next", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const paused = makeTask({
id: "FN-002",
column: "todo",
dependencies: [],
paused: true,
});
const normal = makeTask({
id: "FN-003",
column: "todo",
dependencies: [],
});
const groups = groupByWorktree([active], [active, paused, normal], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["FN-003"]);
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("FN-002");
});
it("queued tasks with satisfied deps appear in Up Next", () => {
const done = makeTask({ id: "FN-001", column: "done" });
const queued = makeTask({
id: "FN-002",
column: "todo",
dependencies: ["FN-001"],
});
const groups = groupByWorktree([], [done, queued], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toEqual([queued]);
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The upcoming-work list must find the HOLD lane by trait, not by the id `todo`.
WHY THIS ONE HID. On the default board the id and the role coincide — U11 gave `todo` the
hold trait — so every existing case here passed and the site looked healthy. Rename the
hold column and the filter matched nothing: the worktree view showed no upcoming work at
all and read as idle. A whole panel silently empty, nothing thrown.
Board resolves the hold ids across ALL workflows on the board (a card in another
workflow's hold lane is still upcoming work); Lane passes nothing and keeps the legacy
fallback, which is why the default case below omits the argument entirely.
REVERT CHECK, measured: dropping the parameter back to `t.column === "todo"` fails the
renamed case with an empty queue.
*/
describe("upcoming-work queue resolves the hold lane by trait", () => {
const mkTask = (id: string, column: string, extra: Record<string, unknown> = {}) =>
({
id, title: id, description: "", column, dependencies: [], steps: [], currentStep: 0,
log: [], createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z",
...extra,
} as never);
it("finds waiting cards in a RENAMED hold column when the ids are supplied", () => {
const waiting = mkTask("FN-50", "backlog");
const groups = groupByWorktree([], [waiting], 3, new Set(["FN-50"]));
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).toContain("FN-50");
});
it("finds nothing in a renamed hold column when the ids are NOT supplied", () => {
/*
Pins the fallback's real limit rather than pretending it covers custom boards: with no
resolved ids the legacy guess is all there is, and it cannot know about `backlog`. This
is the case that used to be the ONLY behaviour, on every board.
*/
const groups = groupByWorktree([], [mkTask("FN-51", "backlog")], 3);
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).not.toContain("FN-51");
});
it("still finds legacy `todo` cards with no ids supplied, so Lane is unaffected", () => {
const groups = groupByWorktree([], [mkTask("FN-52", "todo")], 3);
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).toContain("FN-52");
});
it("does not treat a task outside the resolved set as waiting", () => {
/*
The narrowing guard: a lookup that ignored its set would pass the first case. It is keyed
on TASK id, so this also pins the per-workflow scoping — a card whose own workflow does
not mark its column `hold` is absent from the set even if another workflow reuses the id
(PR #2625 review).
*/
const groups = groupByWorktree([], [mkTask("FN-53", "building")], 3, new Set(["FN-99"]));
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).not.toContain("FN-53");
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2625 review — greptile):
TWO WORKFLOWS, ONE COLUMN NAME, DIFFERENT TRAITS — the case that killed the first design.
My first version passed a board-wide set of hold COLUMN ids. Column ids are namespaced per
workflow, so workflow A can declare `staging` as its hold lane while workflow B declares
`staging` as executing. A unioned id set answers "is `staging` a hold column?" — a question
with no single answer — and every executing card in workflow B would have been listed under
Up Next as waiting work. Confidently wrong, which is worse than the renamed-board emptiness
the change set out to fix.
Keying on TASK id moves the decision to the only place that can make it: the caller, which
knows each task's own workflow. This case is the regression test for that, expressed the way
the helper now sees it — one card in the set, one not, both in a column called `staging`.
*/
describe("hold resolution is scoped per workflow, not per column name", () => {
const mkTask = (id: string, column: string) =>
({
id, title: id, description: "", column, dependencies: [], steps: [], currentStep: 0,
log: [], createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z",
} as never);
it("lists only the card whose OWN workflow marks `staging` as hold", () => {
const waitingInA = mkTask("FN-60", "staging");
const executingInB = mkTask("FN-61", "staging");
// What Board computes: FN-60's workflow declares `staging` hold; FN-61's does not.
const groups = groupByWorktree([], [waitingInA, executingInB], 3, new Set(["FN-60"]));
const queued = groups.flatMap((group) => group.queuedTasks ?? []).map((task: { id: string }) => task.id);
expect(queued).toContain("FN-60");
// The assertion the column-id design could not satisfy: same column name, opposite answer.
expect(queued).not.toContain("FN-61");
});
});