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:
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user