fix(scripts): the blocked-by recovery reported "Repairs: 0" on a board it never examined (#2992)

## A recovery tool that reports "Repairs: 0" without having examined
anything

Every lane test in `recover-stale-blocked-by.mjs` is a legacy id:

```js
function isTerminalColumn(column) { return column === "done" || column === "archived"; }
const isActive = row.column === "in-progress" || (row.column === "in-review" && row.worktree && !row.paused);
if (row.column !== "todo" || !row.blockedBy) continue;          // ← the candidate gate
```

On a board whose lanes are named anything else, that gate matches
**nothing**. The planner returns no findings and the script prints
`Repairs: 0`.

An operator running a recovery reads that as *"the board is fine"* when
the tool never examined a single card. **A silently empty answer from a
recovery tool is the worst shape available** — indistinguishable from
success, and consulted precisely during an incident.

This is not dead code: `docs/soft-delete-verification-matrix.md` cites
it as the GREEN backstop for FN-5528, and it has its own test file.

## Detection only — and why I did not "fix" the classification

Correct classification needs the board's resolved trait vocabulary. This
script holds a **raw backend** (`openBackend` → `asyncLayer` + `sql`),
not a `TaskStore`, so resolving lanes here would mean reimplementing IR
trait resolution inside a `.mjs` script — a worse bug than the one it
fixes, and precisely the kind of second, drifting copy this migration
keeps deleting.

So the assumptions are not repaired; they are made **loud**. That is the
same principle the lane-wiring gate applies to itself:

> a gate whose errors land on "nothing to report" is the one failure
mode a ratchet must not have

The unknown-lane list rides on the returned array as a
**non-enumerable** property rather than widening the return type —
`recoverBlockedBy` is consumed as `findings[]` by the entry point and by
tests, and an operator may be scripting around that shape.

## The first test pins the gap rather than papering over it

```js
assert.deepEqual(unrecognisedLanes(rows), ["backlog", "checking"]);
// The gap this warns about, pinned rather than claimed fixed: the planner still sees nothing.
assert.deepEqual(planRecoverBlockedBy({ rows, tasksDir }), []);
```

I would rather the next reader find that assertion than discover it
themselves during an incident.

## Revert proof

With `unrecognisedLanes` returning `[]` (the pre-fix behaviour):

```
✖ names lanes the planner does not understand, so an empty result cannot read as healthy
✔ stays quiet on a legacy board, so the warning means something when it appears
✖ reports each unknown lane once, ignoring rows with no column at all
ℹ pass 5   ℹ fail 2
```

The legacy-board case passes **both ways by design** — it guards against
the warning firing spuriously, so I am not counting it as coverage of
the defect.

## Verification (measured)

- `node --test` — **7 passed** (4 pre-existing + 3 new), 0 failed
- `node --check`, `eslint` — clean
- `check-sql-column-literals`, `lifecycle-column-census --strict`,
`check-lane-wiring`, `check-fnxc-future-dates` — green

No changeset: root `scripts/` is repo tooling, not part of the published
package.

## How this was found, since the method matters more than the fix

My batch is "cli + plugins + anything left", and I had been reading
*"anything left"* as nothing. Eight packages and all of `scripts/` sit
outside the four named batches. This is the first thing I found there;
sibling one-shot scripts (`reconcile-task-state-consistency.mjs`,
`reconcile-leaked-soft-deletes.mjs` — which contains a raw `UPDATE … SET
"column" = 'archived'`) carry the same hardcoded assumptions and are
**not** addressed here.
This commit is contained in:
gsxdsm
2026-07-30 23:41:03 -07:00
committed by GitHub
parent fb53a96eaa
commit bb6c08d9d6
2 changed files with 103 additions and 3 deletions

View File

@@ -12,7 +12,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { planRecoverBlockedBy } from "../recover-stale-blocked-by.mjs";
import { planRecoverBlockedBy, unrecognisedLanes } from "../recover-stale-blocked-by.mjs";
function setupTasksDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fn-3899-"));
@@ -117,3 +117,51 @@ test("treats soft-deleted blockers as missing and never plans for deleted depend
fs.rmSync(dir, { recursive: true, force: true });
}
});
/*
FNXC:OperatorScriptLaneAssumptions 2026-07-30-25:30:
THE INVARIANT: a board this script cannot reason about is REPORTED, never silently skipped.
Every lane test in the planner is a legacy id, and the candidate gate is `row.column !== "todo"`, so a
renamed board matches nothing and the planner returns []. That is indistinguishable from "no repairs
needed" — the worst possible answer from a recovery tool, which an operator consults during an
incident and reads as a clean bill of health.
The first case below documents that gap directly: the planner still finds nothing, and that is NOT
fixed here (correct classification needs the board's trait vocabulary, which this script has no store
to resolve). What is fixed is that the condition is now detectable and printed.
Reverted (`unrecognisedLanes` removed), these fail to import.
*/
test("names lanes the planner does not understand, so an empty result cannot read as healthy", () => {
const rows = [
{ id: "FN-1", column: "backlog", blockedBy: "FN-2" },
{ id: "FN-2", column: "checking", blockedBy: null },
];
assert.deepEqual(unrecognisedLanes(rows), ["backlog", "checking"]);
// The gap this warns about, pinned rather than claimed fixed: the planner still sees nothing.
const { tasksDir } = setupTasksDir();
assert.deepEqual(planRecoverBlockedBy({ rows, tasksDir }), []);
});
test("stays quiet on a legacy board, so the warning means something when it appears", () => {
const rows = [
{ id: "FN-1", column: "todo", blockedBy: "FN-2" },
{ id: "FN-2", column: "in-review", blockedBy: null },
{ id: "FN-3", column: "done", blockedBy: null },
];
assert.deepEqual(unrecognisedLanes(rows), []);
});
test("reports each unknown lane once, ignoring rows with no column at all", () => {
const rows = [
{ id: "FN-1", column: "building" },
{ id: "FN-2", column: "building" },
{ id: "FN-3", column: null },
{ id: "FN-4", column: "" },
];
assert.deepEqual(unrecognisedLanes(rows), ["building"]);
});