doxa-maps/internal/check-backlog-status-enum.mjs
doxa-maps-88655c70 510f3d30d4
Some checks are pending
build-gate / build (push) Waiting to run
The backlog-status enum was too narrow, not the nine cards wrong
This gate keeps the AGGREGATE honest — a prose status makes every open-count wrong
while each card still READS correctly, so the error hides in the total. That matters
to this loop directly: every tick starts by reading status counts to pick a card.

MEASURED across 633 cards: EIGHT distinct status values are in use and EVERY ONE IS
A SINGLE WORD. No prose remains anywhere in the field — which is exactly what the
original 130-card migration achieved. The nine cards this gate was failing carried
legitimate TERMINAL states the enum simply predated: superseded (5), deferred (2),
decided, verified-already-true.

★ REWRITING THEM TO "done" WOULD HAVE DESTROYED REAL DISTINCTIONS to satisfy a list.
A SUPERSEDED card was replaced by another; a DEFERRED one was consciously postponed;
neither was finished. Losing that information to make a checker green is the
opposite of what this gate is for.

OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
open = todo + doing + in-progress (66), closed = done + these four (507),
blocked (60).

MUTATION-TESTED BOTH WAYS, so widening did not weaken it: a prose status
("done — reconciled against main, see notes") still FAILS by name — the original
defect — AND an unagreed one-word status ("wontfix") ALSO fails, so the set stays
CLOSED rather than becoming "any single word".

Wired into gates:post (32 -> 33) after confirming it reads only backlog JSON. The
doc that quotes gates:post is re-transcribed in the same commit.

Build: 7/7 pre, 23/23 bundles, 33/33 post, exit 0.
2026-09-24 11:58:39 +00:00

121 lines
6.4 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 WORD from a closed set — the five workflow
* states plus four terminal ones (superseded / deferred / decided / verified-already-true).
* The point is not the particular words: it is that `status` stays machine-readable so the
* open-count is right. Prose belongs in fields.status_detail.
*
* 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']
// ★ THE ENUM WAS TOO NARROW, not the cards wrong. MEASURED 2026-09-24 across 633 cards:
// EIGHT distinct status values are in use and EVERY ONE IS A SINGLE WORD — there is no
// prose left anywhere in the field. The nine this gate was failing carried legitimate
// TERMINAL states the original enum simply predated: superseded (5), deferred (2),
// decided, verified-already-true.
//
// This gate exists to keep the AGGREGATE honest — "a prose status makes every open-count
// wrong while each card still READS correctly". A one-word terminal state serves that
// purpose perfectly; it is exactly what the 130-card migration was FOR. Rewriting nine
// accurate statuses to 'done' would have destroyed real distinctions (a SUPERSEDED card
// was replaced by another; a DEFERRED one was consciously postponed; neither was
// finished) to satisfy a list — losing information to make a checker green.
//
// OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
// open = todo + doing + in-progress (66), closed = done + these four (507), blocked (60).
const TERMINAL_EXTRA = ['superseded', 'deferred', 'decided', 'verified-already-true']
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done', ...TERMINAL_EXTRA])
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.')