Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 88 additions & 4 deletions tools/hygiene/audit-backlog-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,6 +102,7 @@ interface BacklogRow {
readonly status: string;
readonly created: string;
readonly title: string;
readonly childrenRefs: readonly string[];
}

interface FrontmatterFields {
Expand All @@ -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");
Expand Down Expand Up @@ -183,6 +192,7 @@ function parseFrontmatter(text: string): FrontmatterFields {
...(title !== undefined ? { title } : {}),
dependsOnRefs: getListRefs("depends_on"),
composesWithRefs: getListRefs("composes_with"),
childrenRefs: getListRefs("children"),
Comment thread
AceHack marked this conversation as resolved.
};
Comment thread
AceHack marked this conversation as resolved.
}

Expand Down Expand Up @@ -225,6 +235,7 @@ function loadBacklog(): BacklogRow[] {
status: fm.status ?? "unknown",
created: fm.created ?? "unknown",
title: fm.title ?? "(no title)",
childrenRefs: fm.childrenRefs,
});
}
}
Expand Down Expand Up @@ -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<string, BacklogRow>();
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<number> {
if (!existsSync(BACKLOG_ROOT)) {
process.stderr.write(`ERROR: ${BACKLOG_ROOT} not found\n`);
Expand All @@ -606,8 +679,9 @@ async function main(): Promise<number> {

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;
}
Expand Down Expand Up @@ -646,6 +720,8 @@ async function main(): Promise<number> {

const duplicateIdGroups = reportDuplicateIds(rows);

const parentChildMismatches = reportParentChildStatusMismatch(rows);

console.log("## Summary");
console.log("");
console.log(` - Total backlog rows: ${totalRows}`);
Expand All @@ -655,6 +731,7 @@ async function main(): Promise<number> {
);
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),",
Expand All @@ -671,6 +748,13 @@ async function main(): Promise<number> {
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;
}

Expand Down
Loading