doxa-maps/internal/check-backlog-status-enum.mjs
doxa-maps-88655c70 b9a0cb040c
Some checks are pending
build-gate / build (push) Waiting to run
Require a queryable status on every backlog card
Cards were being written with explanatory statuses -- "done — reconciled: …",
"blocked — Steps 1+3 DONE and re-verified…", "ready". The prose is valuable
and several of those strings carry measurements, but it made status
unqueryable: every count used status !== 'done', which is FALSE for
"done — reconciled: …", so finished cards kept counting as open.

Measured across the three phase backlogs: 130 of 395 cards carried a prose
status and 71 of those were already done. Reported open was 176 against a real
105. The P0 figure quoted every tick was 21; it was 14.

That is worse than an off-by-N because a backlog is a decision instrument. It
answers "what is left" and "is the rest human-gated", and inflating the open
count makes a drained board look stalled, which pushes toward manufacturing
work instead of holding honestly. The error also hides: every card reads
correctly to a human, because the prose says exactly what is true. Only the
aggregate is wrong.

Migration preserved every explanation in fields.status_detail -- 130 of 130,
none lost -- and normalised status to the enum. Six cards whose head-word was
not in the map were left alone and resolved by hand against reality rather than
guessed, since a wrong normalisation hides a card instead of counting it.

The gate does not require status_detail. Most cards need no explanation and
demanding one turns a real check into paperwork satisfied with filler.

Negative-tested: restoring one prose status fails by name and prints it.
2026-09-22 20:46:06 +00:00

102 lines
5 KiB
JavaScript

#!/usr/bin/env node
/**
* check-backlog-status-enum.mjs — a card's `status` must be a machine-readable enum, not prose.
*
* THE DEFECT THIS CATCHES, and it was MY OWN reporting error for an entire flight. Cards were being
* written with explanatory statuses:
*
* "done — reconciled: big-picture + dashboard-plus-grid are current, unified-research-dashboard…"
* "blocked — Steps 1+3 DONE and re-verified 2026-09-22 at 19/19 bundles…"
* "ready"
*
* The prose is genuinely valuable — several of those strings carry measurements — but it made
* `status` unqueryable. Every count I reported used `status !== 'done'`, which is FALSE for
* `"done — reconciled: …"`, so finished cards kept being counted as open.
*
* MEASURED 2026-09-22 across the three phase backlogs: 130 of 395 cards carried a prose status, and
* 71 of those were ALREADY DONE. Reported open was 176; the real number was 105. The P0 figure I had
* been quoting every tick was 21; it was actually 14.
*
* ★ WHY THIS IS WORSE THAN AN ORDINARY OFF-BY-N. A backlog is a decision instrument — it answers
* "what is left" and "is the remaining work human-gated". Inflating the open count makes a drained
* board look stalled, which pushes toward manufacturing work rather than holding honestly. And the
* error is self-concealing: every individual card reads correctly to a human, because the prose says
* exactly what is true. Only the AGGREGATE is wrong.
*
* THE FIX PRESERVES THE PROSE. Migration moved it to `fields.status_detail` and normalised `status`
* to the enum; 130/130 preserved, 0 lost. The explanation was never the problem — putting it in the
* field that tooling reads was.
*
* WHAT IT ASSERTS: every card's `status` is one of todo / doing / in-progress / blocked / done.
*
* It deliberately does NOT require `status_detail` to exist. Most cards need no explanation, and
* demanding one would turn a real check into paperwork that gets satisfied with filler.
*
* NEGATIVE-TESTED: setting one card's status back to "done — reconciled: …" fails by name.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const QA = join(ROOT, '../_qa-cycle')
const PHASES = ['01-ideation-research', '02-builder-cycle', '03-deployment-cycle']
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done'])
if (!existsSync(QA)) {
// _qa-cycle lives outside every repo the CI clones, so absence is EXPECTED there. Say so loudly
// rather than passing silently — a gate that reports success when it never ran is the failure
// mode this repo has already been bitten by.
console.log('BACKLOG-STATUS: SKIP — _qa-cycle not present (expected in CI; it is in no repo).')
process.exit(0)
}
const offenders = []
let total = 0
const counts = {}
for (const ph of PHASES) {
const p = join(QA, ph, 'backlog.json')
if (!existsSync(p)) continue
let cards
try {
cards = JSON.parse(readFileSync(p, 'utf8')).cards || []
} catch (e) {
console.error(`BACKLOG-STATUS: FAIL — ${ph}/backlog.json does not parse: ${e.message}`)
process.exit(1)
}
for (const c of cards) {
total++
const s = String(c.status)
counts[s] = (counts[s] || 0) + 1
if (!ALLOWED.has(s)) offenders.push({ ph, id: c.id, s })
}
}
if (!total) {
console.error('BACKLOG-STATUS: FAIL — found 0 cards across three phase backlogs.')
console.error(' A gate with nothing to assert is not passing; it is not running.')
process.exit(1)
}
if (offenders.length) {
console.error(`BACKLOG-STATUS: FAIL — ${offenders.length} of ${total} card(s) carry a non-enum status.\n`)
for (const o of offenders.slice(0, 12)) {
console.error(` ${o.ph} ${o.id}`)
console.error(` status: "${o.s.slice(0, 84)}${o.s.length > 84 ? '…' : ''}"`)
}
if (offenders.length > 12) console.error(` …and ${offenders.length - 12} more`)
console.error('\n Allowed: ' + [...ALLOWED].join(' / '))
console.error(' Keep the explanation — move it to fields.status_detail. The prose is valuable and')
console.error(' often carries measurements; it just must not live in the field tooling filters on.')
console.error(' A prose status makes every open-count wrong while each card still READS correctly,')
console.error(' so the error hides in the aggregate: 130 of 395 cards once carried one, 71 of them')
console.error(' already done, and the reported open count was 176 against a real 105.')
process.exit(1)
}
console.log(`BACKLOG-STATUS: PASS — ${total} card(s), every status is a queryable enum.`)
const order = ['todo', 'doing', 'in-progress', 'blocked', 'done']
console.log(' ' + order.filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(' · '))
console.log(' Explanations live in fields.status_detail, which is not required — most cards need')
console.log(' none, and demanding one would turn this into paperwork satisfied with filler.')