diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index a1ac78851..8d7de2808 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -790,6 +790,34 @@ jobs: - name: Run audit-backlog-items (--enforce-duplicate-ids) run: bun tools/hygiene/audit-backlog-items.ts --enforce-duplicate-ids + lint-backlog-parent-child-status: + # Fail if any backlog row declares `status: closed` (or equivalent — + # landed/superseded/merged/done/superseded-by-*) while a declared + # `child` in its `children:` list is still `status: open`. Empirical + # anchor: PR #3518 (2026-05-15) shipped a row-status flip closing + # parent B-0442 without closing children B-0504 + B-0505. Caught by + # Codex + Copilot review (P1 + P2) but took 4 thread cycles to fully + # resolve. + # + # B-0532 hard-error slice — sibling of lint-backlog-id-uniqueness + + # lint-section-33-migration-xrefs + lint-archive-header-section33. + # Soft-warning case (all children closed but parent open) + bidirectional + # consistency (parent.children ↔ child.parent) deferred to follow-up + # slices per B-0532's full acceptance criteria. + name: lint (backlog parent-child status) + timeout-minutes: 2 + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install toolchain via three-way-parity script (GOVERNANCE §24) + run: ./tools/setup/install.sh + + - name: Run audit-backlog-items (--enforce-parent-child-status) + run: bun tools/hygiene/audit-backlog-items.ts --enforce-parent-child-status + lint-no-empty-dirs: # Fail if a committed directory has no files — almost always a # forgotten artefact (an agent-created skill folder without a diff --git a/tools/hygiene/audit-backlog-items.ts b/tools/hygiene/audit-backlog-items.ts index 0c8f3a1b4..c5693ea7d 100644 --- a/tools/hygiene/audit-backlog-items.ts +++ b/tools/hygiene/audit-backlog-items.ts @@ -32,16 +32,23 @@ // factory-wide uniqueness violation per tools/backlog/README.md. // Surfaced 2026-05-14 (Copilot caught two files claiming B-0329 on // PR #3247; PR #3249 added this audit class). +// 9. Parent-child status mismatch — parent declares `status: closed` +// while a declared `child` is still `status: open`. Surfaced 2026- +// 05-15 (PR #3518 closed B-0442 without closing children B-0504 + +// B-0505; B-0532 row + this audit class capture the failure mode). // // Usage: -// bun tools/hygiene/audit-backlog-items.ts # detect-only +// bun tools/hygiene/audit-backlog-items.ts # detect-only // bun tools/hygiene/audit-backlog-items.ts --enforce-duplicate-ids // # exit non-zero on duplicate-ID groups (B-0535 CI gate) +// bun tools/hygiene/audit-backlog-items.ts --enforce-parent-child-status +// # exit non-zero on parent-child status-mismatch groups (B-0532 CI gate) // // Exit codes: // 0 -- survey ran (findings reported in body); detect-only mode // 1 -- fatal invocation error (e.g., backlog dir missing) OR -// duplicate-ID groups found AND --enforce-duplicate-ids set +// duplicate-ID groups found AND --enforce-duplicate-ids set OR +// parent-child mismatch groups found AND --enforce-parent-child-status set import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -95,6 +102,7 @@ interface BacklogRow { readonly status: string; readonly created: string; readonly title: string; + readonly childrenRefs: readonly string[]; } interface FrontmatterFields { @@ -104,9 +112,10 @@ interface FrontmatterFields { readonly title?: string; readonly dependsOnRefs: readonly string[]; readonly composesWithRefs: readonly string[]; + readonly childrenRefs: readonly string[]; } -const B_REF_RE = /B-\d{4}/g; +const B_REF_RE = /B-\d{4}(?:\.\d+)*/g; function parseFrontmatter(text: string): FrontmatterFields { const lines = text.split("\n"); @@ -183,6 +192,7 @@ function parseFrontmatter(text: string): FrontmatterFields { ...(title !== undefined ? { title } : {}), dependsOnRefs: getListRefs("depends_on"), composesWithRefs: getListRefs("composes_with"), + childrenRefs: getListRefs("children"), }; } @@ -225,6 +235,7 @@ function loadBacklog(): BacklogRow[] { status: fm.status ?? "unknown", created: fm.created ?? "unknown", title: fm.title ?? "(no title)", + childrenRefs: fm.childrenRefs, }); } } @@ -598,6 +609,68 @@ function reportDuplicateIds(rows: readonly BacklogRow[]): number { return duplicates.length; } +interface ParentChildMismatch { + readonly parentId: string; + readonly parentPath: string; + readonly parentStatus: string; + readonly openChildren: ReadonlyArray<{ readonly id: string; readonly status: string; readonly path: string }>; +} + +function isClosedStatus(status: string): boolean { + return CLOSED_STATUSES.has(status) || status.startsWith("superseded-by-"); +} + +function reportParentChildStatusMismatch(rows: readonly BacklogRow[]): number { + console.log("## 9. Parent-child status mismatch (B-0532)"); + console.log(""); + + const byId = new Map(); + for (const r of rows) byId.set(r.id, r); + + const mismatches: ParentChildMismatch[] = []; + for (const parent of rows) { + if (!isClosedStatus(parent.status)) continue; + if (parent.childrenRefs.length === 0) continue; + const openChildren: Array<{ id: string; status: string; path: string }> = []; + for (const childRef of parent.childrenRefs) { + const child = byId.get(childRef); + if (child === undefined) continue; + if (!isClosedStatus(child.status)) { + openChildren.push({ id: child.id, status: child.status, path: child.path }); + } + } + if (openChildren.length > 0) { + mismatches.push({ + parentId: parent.id, + parentPath: parent.path, + parentStatus: parent.status, + openChildren, + }); + } + } + + mismatches.sort((a, b) => a.parentId.localeCompare(b.parentId)); + console.log(`**Parent-child status-mismatch groups: ${mismatches.length}**`); + if (mismatches.length > 0) { + console.log(""); + console.log("Closed parent row(s) with open child row(s) — graph inconsistency."); + console.log(""); + for (const m of mismatches) { + console.log(`- \`${m.parentId}\` (\`${m.parentStatus}\`) has open children:`); + for (const c of m.openChildren) { + console.log(` - \`${c.id}\` (\`${c.status}\`)`); + } + } + console.log(""); + console.log("Either:"); + console.log("- Close the open children if their work has landed, OR"); + console.log("- Re-open the parent if the children represent unfinished work, OR"); + console.log("- Remove the child refs from the parent's `children:` if they no longer apply"); + } + console.log(""); + return mismatches.length; +} + async function main(): Promise { if (!existsSync(BACKLOG_ROOT)) { process.stderr.write(`ERROR: ${BACKLOG_ROOT} not found\n`); @@ -606,8 +679,9 @@ async function main(): Promise { const argv = process.argv.slice(2); const enforceDuplicateIds = argv.includes("--enforce-duplicate-ids"); + const enforceParentChildStatus = argv.includes("--enforce-parent-child-status"); for (const arg of argv) { - if (arg !== "--enforce-duplicate-ids") { + if (arg !== "--enforce-duplicate-ids" && arg !== "--enforce-parent-child-status") { process.stderr.write(`error: unknown argument: ${arg}\n`); return 1; } @@ -646,6 +720,8 @@ async function main(): Promise { const duplicateIdGroups = reportDuplicateIds(rows); + const parentChildMismatches = reportParentChildStatusMismatch(rows); + console.log("## Summary"); console.log(""); console.log(` - Total backlog rows: ${totalRows}`); @@ -655,6 +731,7 @@ async function main(): Promise { ); console.log(` - Orphan rows (no incoming graph edge): ${orphanCount}`); console.log(` - Duplicate-ID groups: ${duplicateIdGroups}`); + console.log(` - Parent-child status-mismatch groups: ${parentChildMismatches}`); console.log(""); console.log( "Composes with: tools/hygiene/audit-lost-files.ts (sibling pattern),", @@ -671,6 +748,13 @@ async function main(): Promise { return 1; } + if (enforceParentChildStatus && parentChildMismatches > 0) { + process.stderr.write( + `\nerror: ${parentChildMismatches} parent-child status-mismatch group(s) found; --enforce-parent-child-status set (B-0532 gate)\n`, + ); + return 1; + } + return 0; }