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"]);
});

View File

@@ -65,6 +65,44 @@ function isTerminalColumn(column) {
return column === "done" || column === "archived";
}
/*
FNXC:OperatorScriptLaneAssumptions 2026-07-30-25:30:
Refuse to look healthy on a board this script cannot reason about.
Every lane test here is a legacy id: `isTerminalColumn` is done/archived, "active" is
in-progress/in-review, and the candidate gate is `row.column !== "todo"`. On a board whose lanes are
named anything else that gate matches NOTHING, so the planner returns no findings and the script
prints "Repairs: 0" — an operator running a recovery reads that as "the board is fine" when in fact
the tool never examined a single card. A silently empty answer from a RECOVERY tool is the worst
shape available: it is indistinguishable from success and it is consulted precisely during incidents.
Detection only, deliberately. Correct classification needs the board's resolved trait vocabulary, and
this script holds a raw backend (`openBackend` -> asyncLayer + sql), not a TaskStore — resolving lanes
here would mean reimplementing IR trait resolution in a .mjs script, which is a worse bug than the one
it fixes. So the assumptions are not repaired; they are made LOUD, which 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").
Pure and exported so the warning is testable without a database, matching how `planRecoverBlockedBy`
is already structured.
*/
const LEGACY_LANES_THIS_SCRIPT_UNDERSTANDS = new Set([
"triage",
"todo",
"in-progress",
"in-review",
"done",
"archived",
]);
export function unrecognisedLanes(rows) {
return [...new Set(
rows
.map((row) => row.column)
.filter((column) => typeof column === "string" && column.length > 0 && !LEGACY_LANES_THIS_SCRIPT_UNDERSTANDS.has(column)),
)].sort();
}
/**
* Pure FN-3899 planning: rows are { id, column, blockedBy, worktree, paused }.
* Returns findings; entries with newBlocker === null are the repairs.
@@ -123,6 +161,10 @@ export async function recoverBlockedBy({ backend, tasksDir, dryRun = true }) {
);
const findings = planRecoverBlockedBy({ rows, tasksDir });
/* Attached to the returned array rather than changing the return type: `recoverBlockedBy` is
consumed as findings[] by the entry point below and by tests, and widening it to an object would
be a breaking change to a script an operator may already be scripting around. */
Object.defineProperty(findings, "unrecognisedLanes", { value: unrecognisedLanes(rows), enumerable: false });
if (dryRun) return findings;
const byId = new Map(rows.map((row) => [row.id, row]));
@@ -151,9 +193,19 @@ function resolveProjectRoot() {
return path.resolve(commonDir, "..");
}
function printFindings(findings, dryRun) {
function printFindings(findings, dryRun, unknownLanes = []) {
const changed = findings.filter((row) => row.oldBlocker !== row.newBlocker);
console.log(dryRun ? "Mode: DRY RUN" : "Mode: APPLY");
/* Printed BEFORE the results, because the results are the thing it qualifies — "Repairs: 0" under
unrecognised lanes means "not examined", not "nothing to fix". See `unrecognisedLanes`. */
if (unknownLanes.length > 0) {
console.warn(
`WARNING: this board uses lanes this script does not understand: ${unknownLanes.join(", ")}.\n`
+ " Its blocked/active/terminal tests are hardcoded to the legacy column ids, so cards in\n"
+ " those lanes were NOT examined. Treat the result below as incomplete, not as a clean bill\n"
+ " of health, and recover those cards by another route.",
);
}
console.log("taskId\toldBlocker\tnewBlocker\treason");
for (const row of findings) {
if (row.oldBlocker === row.newBlocker) continue;
@@ -170,7 +222,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
const backend = await openBackend(projectRoot);
try {
const findings = await recoverBlockedBy({ backend, tasksDir, dryRun });
printFindings(findings, dryRun);
printFindings(findings, dryRun, findings.unrecognisedLanes ?? []);
} finally {
await backend.shutdown().catch(() => {});
}