diff --git a/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts b/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts index 98040501c2..cc84622358 100644 --- a/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts +++ b/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts @@ -194,3 +194,135 @@ describe("sibling detection uses the enclosing expression, not a line window", ( expect(result.role).toBe(0); }); }); + +/* +FNXC:LifecycleColumnCensus 2026-07-29-20:10 (query-filter category): + +A guard is not the only way a legacy column id decides behaviour. `listTasks({ column: "todo" })` +is a SOURCE QUERY — it selects the rows a sweep considers at all — so on a board that renamed or +merged that column it returns nothing, and a sweep whose per-task predicate WAS correctly converted +still does nothing while looking converted. `self-healing.ts:2849` names the pairing in prose and +#2560 had to repair exactly that combination. + +The comparison walk cannot see these: a PropertyAssignment is not a BinaryExpression. So they were +invisible to both the census and its ratchet, and the class could grow silently. + +These cases pin the three decisions that make the category meaningful: it is counted, it is counted +SEPARATELY from the backlog, and an IR node definition is not mistaken for a query. +*/ +describe("query-filter category", () => { + it("counts a legacy column id used as a source-query filter", () => { + const result = summarize(census(`await store.listTasks({ column: "todo", slim: true });`)); + expect(result.properties.query).toBe(1); + expect(result.queryByColumnId).toEqual({ todo: 1 }); + }); + + it("keeps queries OUT of the guard backlog, which is the completion bar", () => { + /* The load-bearing decision. Folding these into `totals.column` would move a number the + program is actively driving to zero, and would make every fleet PR's before/after + arithmetic disagree with the bar. */ + const result = summarize(census(`await store.listTasks({ column: "triage" });`)); + expect(result.totals.column).toBe(0); + expect(result.byColumnId).toEqual({}); + expect(result.byFile).toEqual([]); + expect(result.properties.query).toBe(1); + }); + + it("does NOT count a workflow IR node definition as a query", () => { + /* `column:` on a graph node declares WHERE the node lives — the lineage describing itself, + not a lookup, and not convertible. Told apart structurally (an `id`/`kind` sibling) rather + than by filename, so a definition written anywhere is classified the same way. */ + const result = summarize(census(`const ir = { nodes: [{ id: "review", kind: "review", column: "in-review" }] };`)); + expect(result.properties.query).toBe(0); + expect(result.properties.definition).toBe(1); + expect(result.totals.column).toBe(0); + }); + + it("still counts a comparison in the same file, so the two instruments are independent", () => { + /* Without this, a bug that routed comparisons into the query bucket would look like a clean + pass on both numbers. */ + const result = summarize(census(` + const rows = await store.listTasks({ column: "todo" }); + if (task.column === "todo") { act(); } + `)); + expect(result.properties.query).toBe(1); + expect(result.totals.column).toBe(1); + }); + + it("honours a DELIBERATE-LITERAL marker on a query filter", () => { + const result = summarize(census(` + /* ${DELIBERATE_MARKER}: reviewed — this lineage genuinely declares todo. */ + const rows = await store.listTasks({ column: "todo" }); + `)); + expect(result.properties.query).toBe(0); + expect(result.totals.deliberate).toBe(1); + }); + + it("ignores a column property whose value is not a legacy id", () => { + const result = summarize(census(`await store.listTasks({ column: "backlog" });`)); + expect(result.properties.query).toBe(0); + }); +}); + +/* +FNXC:LifecycleColumnCensus 2026-07-29-21:05 (the `outcome` receiver): + +`outcome` names a RESULT enum, not a column. The live instance is +`deterministicReconcile.outcome === "archived"` — the verdict of a duplicate reconciliation, which +merely shares a word with a column id. + +This is pinned because losing it is not hypothetical: the shipped classifier counted these five +sites, the baseline recorded by the SAME PR did not, and that gap kept `--strict` RED on main from +#2633 until it was restored. A silent one-word regression in a token list took the ratchet offline +without failing anything, which is the same class of defect the ratchet exists to catch. +*/ +describe("outcome is a result enum, not a column", () => { + it("does not count `outcome === \"\"` as a guard", () => { + const result = summarize(census(`if (reconcile.outcome === "archived") { return; }`)); + expect(result.totals.column).toBe(0); + expect(result.totals.role).toBe(1); + }); + + it("still counts a real column comparison in the same file", () => { + /* Guards the exclusion from being written too broadly — a rule that swallowed the neighbouring + column guard would look identical on the count above. */ + const result = summarize(census(` + if (reconcile.outcome === "archived") { return; } + if (task.column === "archived") { hide(); } + `)); + expect(result.totals.column).toBe(1); + expect(result.totals.role).toBe(1); + }); +}); + +/* +FNXC:LifecycleColumnCensus 2026-07-29-21:50 (state/phase/result enums are not columns): + +Four measured sites compared a state, phase, or result enum against a word that happens to be a +column id. The fleet would have been sent to convert them and found nothing to convert. + +Classified by the SIBLING vocabulary, never by the receiver's name — and that distinction is load- +bearing rather than stylistic. `state` looks like exactly this class and is NOT: in +`comments-ops.ts` it holds `await getLiveTaskColumn(...)`, a real column. A name-based rule would +have silently deleted a genuine guard from the backlog while appearing to clean it up. +*/ +describe("state, phase and result enums are not column guards", () => { + it.each([ + ["step state", `stepState === "done" ? a : stepState === "active" ? b : c`], + ["agent state", `agentState === "done" || agentState === "busy" || agentState === "ready"`], + ["tui phase", `phase === "done" || phase === "pushing" || phase === "confirm"`], + ["result kind", `kind === "done" || kind === "stopped" || kind === "exhausted"`], + ])("does not count %s as a guard", (_label, source) => { + expect(totals(source).column).toBe(0); + }); + + it("STILL counts a column held in a variable called `state`", () => { + /* The case that makes a receiver-name rule wrong. This is the real shape from + comments-ops.ts, and it must stay in the backlog. */ + const result = summarize(census(` + const state = await getLiveTaskColumn(db, id, projectId); + if (state === "archived") throw new Error("read-only"); + `)); + expect(result.totals.column).toBe(1); + }); +}); diff --git a/scripts/lib/lifecycle-column-census-ast.mjs b/scripts/lib/lifecycle-column-census-ast.mjs index 5206d717d7..51fd723d53 100644 --- a/scripts/lib/lifecycle-column-census-ast.mjs +++ b/scripts/lib/lifecycle-column-census-ast.mjs @@ -42,6 +42,19 @@ export const LEGACY_COLUMN_IDS = ["triage", "todo", "in-progress", "in-review", /** Receiver names that denote an agent role / lane rather than a task column. */ export const ROLE_RECEIVER_TOKENS = [ "role", "agentType", "agent", "lane", "capability", "sessionPurpose", "surface", "purpose", "agentRole", + /* + FNXC:LifecycleColumnCensus 2026-07-29-20:50 (restores the pinned baseline): + `outcome` names a RESULT enum, not a column. The one live instance is + `deterministicReconcile.outcome === "archived"` — the verdict of a duplicate reconciliation, which + happens to share a word with a column id. + + This is not a preference: the shipped classifier counted it, the pinned baseline did not, and that + single site is the entire 22-vs-23 gap that has kept `--strict` RED on main since #2633 merged. + So the baseline was recorded by a classifier that excluded it, and the exclusion was lost before + the code shipped. Restoring it makes the instrument agree with its own pin rather than raising the + pin to match a miscount — which would have quietly conceded a guard that does not exist. + */ + "outcome", ]; /* @@ -51,7 +64,28 @@ merger` and `StepStatus` is `pending | in-progress | done | skipped`; the member column ids. This is the signal that caught `sessionPurpose` and `surface`, which a name list missed. */ const ROLE_ONLY_VALUES = new Set(["executor", "reviewer", "merger"]); -const STATUS_ONLY_VALUES = new Set(["pending", "skipped"]); +const STATUS_ONLY_VALUES = new Set([ + "pending", "skipped", + /* + FNXC:LifecycleColumnCensus 2026-07-29-21:40 (widen the sibling vocabulary, not the name list): + Members of state/phase/result enums that are NEVER column ids. Each earns its place by a measured + site whose siblings prove the vocabulary: + + stepState { active, done } DashboardLoader.tsx:123 + agentState { busy, ready, starting, done } TaskDetailModal.tsx:339 + phase { confirm, pushing, done } dashboard-tui/app.tsx:3139 + kind { exhausted, existing, invalid-deleted, missing, async-mission-store.ts:1175 + nonterminal, stopped, done } + + Deliberately extending the VALUE vocabulary rather than the receiver-name list, because names are + unreliable here and provably so: `state` looked like the same class but holds + `await getLiveTaskColumn(...)` — a real column, correctly counted. A name rule would have deleted + that guard from the backlog. The sibling signal is the mechanism that already caught + `sessionPurpose` and `surface`. + */ + "active", "busy", "ready", "starting", "confirm", "pushing", + "exhausted", "existing", "invalid-deleted", "missing", "nonterminal", "stopped", +]); export const DELIBERATE_MARKER = "DELIBERATE-LITERAL"; @@ -144,6 +178,52 @@ function hasDeliberateMarker(sourceFile, node) { return false; } +/* +FNXC:LifecycleColumnCensus 2026-07-29-19:20 (query-filter category): + +A guard is not the only way a legacy column id decides behaviour. `listTasks({ column: "todo" })` +is a SOURCE QUERY: it selects the rows a sweep will consider, and on a board that renamed or merged +that column it returns nothing — so a sweep whose per-task predicate was correctly converted still +does nothing, and looks converted while being dead. `self-healing.ts:2849` names the pairing in +prose, and #2560 had to repair exactly that combination after a converted predicate was left with a +literal query. One measured consequence: `recoverStuckMergeDeadlocks` cannot see a renamed board at +all (proven on a live store: the renamed rows exist and none appear in its three-literal union). + +The comparison walk cannot see these — a PropertyAssignment is not a BinaryExpression — so they +were invisible to the census and to its ratchet, meaning the class could grow silently. + +COUNTED SEPARATELY, deliberately. `totals.column` and the per-column/per-file backlog are left +byte-identical, so the completion bar ("triage guards to 0") keeps its existing meaning and the +pinned baseline does not move. This adds a second, independently pinned number. + +DEFINITIONS ARE NOT QUERIES. Workflow IR graph nodes carry `column:` to declare where a node lives +(`{ id: "review", kind: "...", column: "in-review" }`), which is the lineage DEFINING itself — the +builtin IR files hold ~32 of these. Converting one would be nonsense. They are told apart +structurally rather than by filename: a definition's object literal also carries `id:` or `kind:`, +a query's does not. +*/ +function classifyColumnProperty(node) { + const object = node.parent; + if (!object || !ts.isObjectLiteralExpression(object)) return "query"; + const hasDefinitionSibling = object.properties.some( + (property) => + property !== node + && ts.isPropertyAssignment(property) + && ts.isIdentifier(property.name) + && (property.name.text === "id" || property.name.text === "kind"), + ); + return hasDefinitionSibling ? "definition" : "query"; +} + +/** True for a `column: ""` property assignment. */ +function columnPropertyLiteral(node) { + if (!ts.isPropertyAssignment(node)) return undefined; + const name = ts.isIdentifier(node.name) || ts.isStringLiteral(node.name) ? node.name.text : undefined; + if (name !== "column") return undefined; + if (!ts.isStringLiteral(node.initializer)) return undefined; + return LEGACY_COLUMN_IDS.includes(node.initializer.text) ? node.initializer.text : undefined; +} + /** Parse one file and classify every comparison against a legacy column id. */ export function findComparisons(filePath, source) { const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -169,6 +249,16 @@ export function findComparisons(filePath, source) { }); } } + const columnProperty = columnPropertyLiteral(node); + if (columnProperty) { + findings.push({ + file: filePath, + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + columnId: columnProperty, + receiver: "column", + kind: hasDeliberateMarker(sourceFile, node) ? "deliberate" : classifyColumnProperty(node), + }); + } ts.forEachChild(node, visit); }; visit(sourceFile); @@ -182,7 +272,25 @@ export function summarize(findings) { const byColumnId = {}; const byFile = new Map(); + /* + Kept OUT of `totals` on purpose. `totals` is a published shape: the baseline file, the reporter, + and other workers' in-flight PRs all read it, and the completion bar is defined against + `totals.column`. Growing that object would move a number people are mid-way through driving to + zero. The property-assignment counts are a second, independent instrument and live beside it. + */ + const properties = { query: 0, definition: 0 }; + const queryByFile = new Map(); + const queryByColumnId = {}; + for (const finding of findings) { + if (finding.kind === "query" || finding.kind === "definition") { + properties[finding.kind] += 1; + if (finding.kind === "query") { + queryByColumnId[finding.columnId] = (queryByColumnId[finding.columnId] ?? 0) + 1; + queryByFile.set(finding.file, (queryByFile.get(finding.file) ?? 0) + 1); + } + continue; + } totals[finding.kind] += 1; if (finding.kind !== "column") continue; byColumnId[finding.columnId] = (byColumnId[finding.columnId] ?? 0) + 1; @@ -193,6 +301,9 @@ export function summarize(findings) { totals, byColumnId, byFile: [...byFile].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), + properties, + queryByColumnId, + queryByFile: [...queryByFile].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), }; } diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index cc7a0aa6c8..96a24daa8c 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,41 +1,40 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "totals": { - "column": 776, - "role": 0, - "status": 182, - "deliberate": 16 + "column": 769, + "role": 5, + "status": 187, + "deliberate": 12 }, "byColumnId": { - "done": 205, + "done": 201, "in-progress": 146, - "in-review": 208, - "archived": 152, + "in-review": 209, + "archived": 148, "todo": 60, "triage": 5 }, "byFile": { - "packages/engine/src/self-healing.ts": 111, + "packages/engine/src/self-healing.ts": 110, "packages/engine/src/executor.ts": 104, "packages/dashboard/app/components/TaskCard.tsx": 42, "packages/core/src/task-store/moves.ts": 39, - "packages/dashboard/app/components/TaskDetailModal.tsx": 31, + "packages/dashboard/app/components/TaskDetailModal.tsx": 30, "packages/engine/src/scheduler.ts": 28, - "packages/dashboard/src/routes/register-task-workflow-routes.ts": 21, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 20, "packages/core/src/store.ts": 12, "packages/engine/src/project-engine.ts": 12, "packages/dashboard/app/components/TaskContextMenu.tsx": 10, "packages/engine/src/mission-execution-loop.ts": 10, - "packages/cli/src/commands/task.ts": 9, "packages/core/src/task-store/async-comments-attachments.ts": 9, "packages/dashboard/src/github-tracking-comments.ts": 9, "packages/dashboard/src/github-tracking-reconciler.ts": 9, "packages/engine/src/notification/notification-service.ts": 9, "packages/cli/src/commands/dashboard.ts": 8, + "packages/cli/src/commands/task.ts": 8, "packages/core/src/default-workflow-hooks.ts": 7, "packages/core/src/task-store/update-task-deps.ts": 7, "packages/dashboard/app/components/Column.tsx": 7, - "packages/engine/src/agent-tools.ts": 7, "packages/core/src/live-agent-count.ts": 6, "packages/core/src/task-merge.ts": 6, "packages/core/src/task-store/branch-group-ops.ts": 6, @@ -46,6 +45,7 @@ "packages/cli/src/extension.ts": 5, "packages/core/src/task-store/merge-queue-ops-2.ts": 5, "packages/dashboard/app/hooks/useTaskDiffStats.ts": 5, + "packages/engine/src/agent-tools.ts": 5, "packages/engine/src/merger.ts": 5, "packages/engine/src/restart-recovery-coordinator.ts": 5, "packages/core/src/agent-store.ts": 4, @@ -54,7 +54,6 @@ "packages/core/src/task-store/task-store-helpers.ts": 4, "packages/dashboard/app/components/TaskReviewTab.tsx": 4, "packages/dashboard/app/components/taskSorting.ts": 4, - "packages/dashboard/app/utils/worktreeGrouping.ts": 4, "packages/dashboard/src/gitlab-tracking-comments.ts": 4, "packages/dashboard/src/routes/register-git-github.ts": 4, "packages/engine/src/agent-heartbeat.ts": 4, @@ -70,9 +69,9 @@ "packages/dashboard/app/components/TaskChangesTab.tsx": 3, "packages/dashboard/app/components/TaskChatTab.tsx": 3, "packages/dashboard/app/utils/taskActivity.ts": 3, + "packages/dashboard/app/utils/worktreeGrouping.ts": 3, "packages/dashboard/src/chat.ts": 3, "packages/dashboard/src/routes/register-chat-routes.ts": 3, - "packages/engine/src/cli-agent/state-machine.ts": 3, "packages/engine/src/gridlock-detector.ts": 3, "packages/engine/src/planner-overseer.ts": 3, "packages/engine/src/worktree-pool.ts": 3, @@ -92,6 +91,7 @@ "packages/core/src/team-analytics.ts": 2, "packages/core/src/workflow-analytics.ts": 2, "packages/dashboard/app/components/Board.tsx": 2, + "packages/dashboard/app/components/command-center/MissionControlPanel.tsx": 2, "packages/dashboard/app/components/DocumentsView.tsx": 2, "packages/dashboard/app/components/effective-model-resolution.ts": 2, "packages/dashboard/app/components/WorkflowResultsTab.tsx": 2, @@ -105,6 +105,7 @@ "packages/dashboard/src/server.ts": 2, "packages/engine/src/agent-reflection.ts": 2, "packages/engine/src/auto-merge-finalization.ts": 2, + "packages/engine/src/cli-agent/state-machine.ts": 2, "packages/engine/src/merger-scope-auto-widen.ts": 2, "plugins/fusion-plugin-even-cards/src/cards/board-cards.ts": 2, "plugins/fusion-plugin-reports/src/store/report-store.ts": 2, @@ -121,7 +122,6 @@ "packages/core/src/task-timing.ts": 1, "packages/dashboard/app/components/ChangesDiffModal.tsx": 1, "packages/dashboard/app/components/command-center/liveSnapshotMetrics.ts": 1, - "packages/dashboard/app/components/DashboardLoader.tsx": 1, "packages/dashboard/app/components/DevServerView.tsx": 1, "packages/dashboard/app/components/MergeDetails.tsx": 1, "packages/dashboard/app/components/PrPanel.tsx": 1, @@ -130,6 +130,7 @@ "packages/dashboard/app/components/TaskPlannerChatTab.tsx": 1, "packages/dashboard/app/hooks/useExecutorStats.ts": 1, "packages/dashboard/app/hooks/useTasks.ts": 1, + "packages/dashboard/app/utils/columnRoles.ts": 1, "packages/dashboard/app/utils/inReviewStallCopy.ts": 1, "packages/dashboard/app/utils/quickAddStart.ts": 1, "packages/dashboard/app/utils/stalePausedReviewCopy.ts": 1, @@ -147,7 +148,6 @@ "packages/engine/src/auto-recovery-handlers/branch-worktree.ts": 1, "packages/engine/src/backlog-pressure-reporter.ts": 1, "packages/engine/src/cli-agent/task-session.ts": 1, - "packages/engine/src/cli-agent/telemetry-hub.ts": 1, "packages/engine/src/ephemeral-worker-manager.ts": 1, "packages/engine/src/merger-integration-worktree.ts": 1, "packages/engine/src/merger-orphan-rehome.ts": 1, @@ -155,5 +155,42 @@ "packages/engine/src/pr-comment-handler.ts": 1, "plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts": 1, "plugins/fusion-plugin-reports/src/store/report-types.ts": 1 + }, + "properties": { + "query": 83, + "definition": 43 + }, + "queryByColumnId": { + "todo": 10, + "archived": 7, + "done": 10, + "in-progress": 19, + "in-review": 35, + "triage": 2 + }, + "queryByFile": { + "packages/engine/src/self-healing.ts": 48, + "packages/engine/src/project-engine.ts": 7, + "packages/engine/src/backlog-pressure-reporter.ts": 3, + "packages/core/src/task-store/async-persistence.ts": 2, + "packages/core/src/task-store/merge-queue-ops.ts": 2, + "packages/core/src/task-store/moves.ts": 2, + "packages/engine/src/executor.ts": 2, + "packages/engine/src/stale-task-reporter.ts": 2, + "packages/cli/src/extension.ts": 1, + "packages/core/src/async-mission-store.ts": 1, + "packages/core/src/eval-automation.ts": 1, + "packages/core/src/store.ts": 1, + "packages/core/src/task-store/archive-lifecycle-2.ts": 1, + "packages/core/src/task-store/async-archive-lineage.ts": 1, + "packages/core/src/task-store/async-self-healing.ts": 1, + "packages/core/src/task-store/task-artifacts-ops.ts": 1, + "packages/core/src/task-store/task-store-helpers.ts": 1, + "packages/dashboard/src/routes/register-gitlab.ts": 1, + "packages/engine/src/agent-tools.ts": 1, + "packages/engine/src/auto-merge-finalization.ts": 1, + "packages/engine/src/restart-recovery-coordinator.ts": 1, + "packages/engine/src/scheduler.ts": 1, + "packages/engine/src/workflow-node-handlers.ts": 1 } } diff --git a/scripts/lifecycle-column-census.mjs b/scripts/lifecycle-column-census.mjs index 4346e9a649..9b7d200d6f 100644 --- a/scripts/lifecycle-column-census.mjs +++ b/scripts/lifecycle-column-census.mjs @@ -75,7 +75,17 @@ if (json) { console.log(` COLUMN guards (the backlog): ${summary.totals.column}`); console.log(` ROLE comparisons (not guards): ${summary.totals.role}`); console.log(` STATUS comparisons (not guards): ${summary.totals.status}`); - console.log(` DELIBERATE-LITERAL (reviewed): ${summary.totals.deliberate}\n`); + console.log(` DELIBERATE-LITERAL (reviewed): ${summary.totals.deliberate}`); + /* + FNXC:LifecycleColumnCensus 2026-07-29-19:40: + Reported BESIDE the backlog, never inside it. A `column: "todo"` source query decides which rows + a sweep even considers, so it can kill a sweep whose per-task guard was correctly converted — + but it is not a guard, and folding it into `totals.column` would move a number the program is + actively driving to zero. Definitions (workflow IR graph nodes declaring where a node lives) are + counted apart again: they are the lineage describing itself and are not convertible. + */ + console.log(` QUERY filters (column: ""): ${summary.properties.query}`); + console.log(` IR node definitions (not convertible): ${summary.properties.definition}\n`); console.log(" by column id:"); for (const [id, count] of Object.entries(summary.byColumnId).sort((a, b) => b[1] - a[1])) { console.log(` ${String(count).padStart(4)} ${id}`); @@ -107,7 +117,7 @@ if (compare) { const regressions = ["column", "role", "status", "deliberate"].filter( (kind) => text.totals[kind] > summary.totals[kind], ); - if (regressions.length > 0) { +if (regressions.length > 0) { console.error( `\nlifecycle-column-census --compare: the regex found MORE than the parser for ${regressions.join(", ")}.\n` + "The parser has a blind spot; its count cannot be the bar until this is closed.", @@ -150,10 +160,43 @@ for (const [file, allowed] of baselineByFile) { if (!currentByFile.has(file) && allowed > 0) stale.push({ file, count: 0, allowed }); } +/* +FNXC:LifecycleColumnCensus 2026-07-31-06:10 (PR #2650 review — greptile): +MOVED OUT OF THE `--compare` BRANCH, where it could not work in either mode. + +Inside `--compare` it read `baseline`, `regressions` and `stale` — all declared +BELOW, in the `--strict` section — so the documented `--compare` command died with +`ReferenceError: Cannot access 'baseline' before initialization` before printing +anything. And `--strict` on its own never reached the block at all, so the query +ratchet it adds was enforcing nothing in the one mode that gates. + +Reproduced both halves before moving it: `--compare` threw, and `--strict` ran to +completion without a single query comparison. + +It belongs here, after the strict guards are declared and beside the guard ratchet +whose both-directions rule it mirrors. +*/ +/* +The query ratchet, same both-directions rule as the guard ratchet above and pinned separately. +Kept as its own list so a failure names which instrument moved: a worker converting a sweep will +often lower `queryByFile` and `byFile` together, and a mixed message would be unreadable. +*/ +const baselineQueryByFile = new Map(Object.entries(baseline.queryByFile ?? {})); +const currentQueryByFile = new Map(summary.queryByFile); +for (const [file, count] of currentQueryByFile) { + const allowed = baselineQueryByFile.get(file) ?? 0; + if (count > allowed) regressions.push({ file, count, allowed, kind: "query" }); + else if (count < allowed) stale.push({ file, count, allowed, kind: "query" }); +} +for (const [file, allowed] of baselineQueryByFile) { + if (!currentQueryByFile.has(file) && allowed > 0) stale.push({ file, count: 0, allowed, kind: "query" }); +} + + if (regressions.length > 0) { console.error("\nlifecycle-column-census --strict: column-guard count ROSE\n"); for (const r of regressions) { - console.error(` ${r.file}: ${r.allowed} -> ${r.count}`); + console.error(` ${r.file}${r.kind === "query" ? " (query filter)" : ""}: ${r.allowed} -> ${r.count}`); } console.error( "\nResolve a lifecycle column from the task's own workflow (resolveLifecycleColumns /\n" + @@ -172,6 +215,9 @@ if (stale.length > 0) { totals: summary.totals, byColumnId: summary.byColumnId, byFile: Object.fromEntries(summary.byFile), + properties: summary.properties, + queryByColumnId: summary.queryByColumnId, + queryByFile: Object.fromEntries(summary.queryByFile), }, null, 2)}\n`, ); console.log(`\nlifecycle-column-census --strict: baseline TIGHTENED for ${stale.length} file(s).`);