// Class guard for the ADR-0065 terminal-edge reader migration (issues #503, #704). // // Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a // writer: on cancel/terminate it feeds urban's instance projection and the terminal edge // (`onTerminated`) is RECOMPUTED ON READ as `__tracking.derived_status` — it NO LONGER writes // the terminal (`abandoned`/`failed`/`reviewed`) onto the base `status` column. A classifying reader // that inspects the BASE `status` column of a DERIVE-ONLY tracked table therefore sees a row frozen at // its last worker-owned transient after the instance ends → phantom-active / wedged-idempotency bugs // (the #497 / #503 / #704 class). // // This is a SOURCE-SCAN guard over the defect CLASS, not a single instance. It is PARAMETRIZED over // the three derive-only tracked tables and their admission/idempotency/classifier readers // (derivation-over-duplication in the test itself, so the whole class is unrepresentable): // - `pull_requests` → service.ts, `TERMINAL_STATUSES` (submitPr idempotency, activePrs, incidents, // merge lanes, wave gates) // - `plans` → plan.ts, `PLAN_TERMINAL_STATUSES` (startPlan re-admission, active-by-base) // - `feature_runs` → feature.ts, `FEATURE_TERMINAL_STATUSES` (startFeature INTAKE idempotency — the // reader #503 omitted, closed by #704) // Every terminal-set classification (both the `.includes(x)` and `.some((s) => s === x)` forms) MUST // read the DERIVED effective status (`.derived_status`), never the frozen base `.status`. A future // reader — on ANY of the three tables — that silently re-drifts onto the base column fails here; the // pre-#704 feature intake (`FEATURE_TERMINAL_STATUSES.includes(existing.status)`) would have been red. // // Worker-owned terminals that PASS THROUGH the derive edge unchanged (`merged`) are exempt: a base // `=== "merged"` read is legitimate (see `isDepMerged` / `classifyWaveTarget` / `mergeLaneDecisionForPr` // in app/service.ts). Only the DERIVE-ONLY terminals (`abandoned`/`failed`/`reviewed`) must route // through the derived accessor. Writers (`data.table(
).update({ status: … })`) are unaffected — // they still write the base column. import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { assert, assertEquals } from "#test-assert"; import { EFFECTIVE_STATUS_COLUMN } from "./featureReadModel.ts"; const SRC = (name: string): string => readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), "utf8"); /** Strip line (`//`) and block (`/* … *​/`) comments so the scan only inspects executable code — a * doc comment may legitimately mention `.status` in prose without being a classification. The line * stripper skips a `//` preceded by `:` so a URL scheme inside a string/template literal (e.g. * `https://…` in app/service.ts) is not mistaken for a comment start and does not corrupt the scan. */ function stripComments(src: string): string { return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); } /** Every expression a `_TERMINAL_STATUSES` classifies, across BOTH idioms the app uses: * `SET.includes()` and `SET.some((s) => s === )` (and the mirrored ` === s`). One * extractor for both forms so a reader can't dodge the guard by switching idiom. For the `.some` * form we capture the CLASSIFIED OPERAND — the side of `===` that is NOT the arrow parameter — on * either orientation, and assert against that operand rather than the whole arrow body, so an * incidental `.derived_status` reference elsewhere in the body can't mask a base-`.status` * classification. */ function classifiedExprs(code: string, setName: string): string[] { const exprs: string[] = []; for (const m of code.matchAll(new RegExp(`${setName}\\.includes\\(([^)]*)\\)`, "g"))) { exprs.push(m[1]); } for (const m of code.matchAll(new RegExp(`${setName}\\.some\\(\\(\\s*(\\w+)\\s*\\)\\s*=>\\s*([^)]*)\\)`, "g"))) { const param = m[1]; // the arrow parameter, e.g. `s` const body = m[2]; // `s === ` or the mirrored ` === s` const sides = body.split("===").map((x) => x.trim()); // Capture the operand compared against the loop parameter, on either side of `===`; fall back to // the whole body for any shape we don't recognise so the guard errs toward stricter, not looser. if (sides.length === 2 && (sides[0] === param || sides[1] === param)) { exprs.push(sides[0] === param ? sides[1] : sides[0]); } else { exprs.push(body); } } return exprs; } /** The derive-only tracked tables and the reader module + terminal-status set that classifies each. * ONE registry drives the whole guard so adding a fourth derive-only admission reader is a one-line * change, never a copy-pasted test. */ const DERIVE_ONLY_READERS = [ { table: "pull_requests", file: "service.ts", set: "TERMINAL_STATUSES" }, { table: "plans", file: "plan.ts", set: "PLAN_TERMINAL_STATUSES" }, { table: "feature_runs", file: "feature.ts", set: "FEATURE_TERMINAL_STATUSES" }, ] as const; for (const { table, file, set } of DERIVE_ONLY_READERS) { test(`class guard: every ${set} classification (${table} admission/idempotency) reads derived_status, not base status`, () => { const code = stripComments(SRC(file)); const exprs = classifiedExprs(code, set); assert(exprs.length > 0, `expected ${set} classifications in ${file}`); for (const expr of exprs) { assert( /\.derived_status\b/.test(expr), `${set} classifies on \`${expr}\` — the BASE status of the derive-only tracked table \`${table}\`. ` + `Route it through the derived tracking view and read \`.derived_status\` (ADR-0065, #503/#704)`, ); assert( !/[A-Za-z0-9_)\]]\.status\b/.test(expr), `${set} classifies on \`${expr}\` which still reads a base \`.status\` — the terminal edge is ` + `derive-only for \`${table}\` (ADR-0065, #503/#704)`, ); } }); } test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"abandoned\"` read classification in service.ts", () => { const code = stripComments(SRC("service.ts")); // A READ classification against the derive-only `abandoned` terminal must use `.derived_status`. A // base `.status === ABANDONED_STATUS`/`"abandoned"` would miss a derive-only-terminated PR. (Writers // use the object-literal form `{ status: ABANDONED_STATUS }`, which this pattern never matches.) const bad = [ ...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*ABANDONED_STATUS/g), ...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*["']abandoned["']/g), ]; assertEquals( bad.length, 0, `a base \`.status\` is compared to the derive-only \`abandoned\` terminal — read \`.derived_status\` off ` + `prsTracking instead (ADR-0065, #503): ${bad.map((m) => m[0]).join(", ")}`, ); }); test("class guard: the feature read model classifies on derived_status, never the base status column", () => { // The feature history read model (app/featureReadModel.ts) buckets a run's pipeline `stage`/ // `list_bucket` off its status. Under ADR-0065 that must be the terminal-folded `derived_status` // (off `feature_runs__tracking`), or a terminated run renders "Implementing" forever. Assert the DSL // never references the base `col("status")` for classification and that the effective-status column // is the derived one. assertEquals(EFFECTIVE_STATUS_COLUMN, "derived_status", "the feature read model's effective status must be the derived column"); const code = stripComments(SRC("featureReadModel.ts")); assert( !/col\(\s*["']status["']\s*\)/.test(code), 'app/featureReadModel.ts still references col("status") — the status-classifying derivations must ' + 'read col("derived_status") off feature_runs__tracking (ADR-0065, #503)', ); assert( /baseTable:\s*FEATURE_READ_MODEL_BASE_TABLE/.test(code) || /feature_runs__tracking/.test(code), "the feature read model must be based on the feature_runs__tracking derived VIEW (ADR-0065, #503)", ); });