diff --git a/scripts/__tests__/recover-stale-blocked-by.test.mjs b/scripts/__tests__/recover-stale-blocked-by.test.mjs index 77dd8d8b4b..fdbb4b57e3 100644 --- a/scripts/__tests__/recover-stale-blocked-by.test.mjs +++ b/scripts/__tests__/recover-stale-blocked-by.test.mjs @@ -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"]); +}); diff --git a/scripts/recover-stale-blocked-by.mjs b/scripts/recover-stale-blocked-by.mjs index 21e685cdd3..068496ecda 100644 --- a/scripts/recover-stale-blocked-by.mjs +++ b/scripts/recover-stale-blocked-by.mjs @@ -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(() => {}); }