@vantageos/backend-doctor 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,16 +5,17 @@
5
5
  // DISTINCTLY. The target is a real directory so the engine's loadTree succeeds;
6
6
  // the first-wave detectors ignore the tree and consult the oracle.
7
7
  import { execFileSync } from "node:child_process";
8
- import { readFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
8
+ import { readdirSync, readFileSync, statSync } from "node:fs";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { renderTable, runDoctor } from "@vantageos/mcp-doctor";
12
12
  import { coverage, coverageSummary } from "./coverage.js";
13
+ import { crudCoverageSection, extractReactRouterPaths, scanCrudCoverage, } from "./crud-coverage.js";
13
14
  import { checkStandardReadable } from "./detectors/standard/_doc.js";
14
- import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults } from "./not-checkable.js";
15
+ import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults, } from "./not-checkable.js";
15
16
  import { REGISTRY } from "./registry.js";
16
17
  import { writeReport } from "./report.js";
17
- import { tally, tallyLine, verdict } from "./tally.js";
18
+ import { handlerSurfaceLine, handlerSurfaceStats, tally, tallyLine, verdict, } from "./tally.js";
18
19
  const here = dirname(fileURLToPath(import.meta.url));
19
20
  /** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
20
21
  function targetCommit(target) {
@@ -42,6 +43,110 @@ function toolVersion() {
42
43
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
43
44
  return pkg.version;
44
45
  }
46
+ /** Recursively collect files under `dir` whose basename matches `pred`, as paths
47
+ * relative to `base`. Silently skips node_modules/.git and unreadable dirs. */
48
+ function collectFiles(dir, base, pred, out = []) {
49
+ let entries;
50
+ try {
51
+ entries = readdirSync(dir);
52
+ }
53
+ catch {
54
+ return out;
55
+ }
56
+ for (const name of entries) {
57
+ if (name === "node_modules" || name === ".git")
58
+ continue;
59
+ const full = join(dir, name);
60
+ let isDir;
61
+ try {
62
+ isDir = statSync(full).isDirectory();
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ if (isDir)
68
+ collectFiles(full, base, pred, out);
69
+ else {
70
+ const rel = relative(base, full);
71
+ if (pred(rel))
72
+ out.push(rel);
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ /** Read the convex tree + the schema + probe an app routes dir, then render the
78
+ * CRUD coverage section. The target is the convex dir; the app routes dir is a
79
+ * sibling probe (`../src/app`, `../app`, `../src/routes`). */
80
+ function scanCrudCoverageForCli(target) {
81
+ const convexRels = collectFiles(target, target, (rel) => rel.endsWith(".ts") && !rel.endsWith(".test.ts"));
82
+ const files = [];
83
+ let schemaText;
84
+ for (const rel of convexRels) {
85
+ let text;
86
+ try {
87
+ text = readFileSync(join(target, rel), "utf8");
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ files.push({ rel, text });
93
+ if (/(^|\/)schema\.ts$/.test(rel))
94
+ schemaText = text;
95
+ }
96
+ return scanCrudCoverage(schemaText, files, probeRoutes(target));
97
+ }
98
+ /** Probe the app-route surface as a sibling of the convex `target` dir. Reports
99
+ * BOTH whether a candidate app/src routes dir EXISTS and the route paths found in
100
+ * it, so the scan can tell "no routes dir" (n/a) from "dir exists, no router
101
+ * convention recognised" (could-not-judge) — Eta REVISE blocker 3. Recognises
102
+ * Next/SvelteKit convention files AND a react-router `<Route path>` /
103
+ * `createBrowserRouter` router in App.tsx/main.tsx/a routes file. */
104
+ function probeRoutes(target) {
105
+ const ROUTE_FILE = /(^|\/)(page\.tsx|route\.tsx?|\+page\.svelte)$/;
106
+ const appRoot = resolve(target, "..");
107
+ let candidateDir;
108
+ const routePaths = [];
109
+ // (a) Next/SvelteKit convention dirs (a sibling of the convex target).
110
+ for (const candidate of ["src/app", "app", "src/routes"]) {
111
+ const routeRoot = join(appRoot, candidate);
112
+ let isDir = false;
113
+ try {
114
+ isDir = statSync(routeRoot).isDirectory();
115
+ }
116
+ catch {
117
+ isDir = false;
118
+ }
119
+ if (!isDir)
120
+ continue;
121
+ if (candidateDir === undefined)
122
+ candidateDir = candidate;
123
+ routePaths.push(...collectFiles(routeRoot, routeRoot, (rel) => ROUTE_FILE.test(rel)));
124
+ }
125
+ // (b) react-router (Vite SPA): parse the router source for its route paths.
126
+ for (const rel of [
127
+ "src/App.tsx",
128
+ "src/main.tsx",
129
+ "src/routes.tsx",
130
+ "src/router.tsx",
131
+ "App.tsx",
132
+ "main.tsx",
133
+ ]) {
134
+ const full = join(appRoot, rel);
135
+ let text;
136
+ try {
137
+ text = readFileSync(full, "utf8");
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ // the src dir hosting the router is a candidate app dir, even if its own
143
+ // route-path extraction turns up empty (→ could-not-judge, never n/a).
144
+ if (candidateDir === undefined)
145
+ candidateDir = dirname(rel) || rel;
146
+ routePaths.push(...extractReactRouterPaths(text));
147
+ }
148
+ return { candidateDir, routePaths };
149
+ }
45
150
  function main() {
46
151
  const target = resolve(process.argv[2] ?? process.cwd());
47
152
  // Instrument self-check, BEFORE any judging: a backend-doctor run reads its
@@ -62,6 +167,14 @@ function main() {
62
167
  ...notCheckableResults(),
63
168
  ];
64
169
  console.log(renderTable("backend-doctor (VantagePeers oracle @ day158)", results));
170
+ // CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule
171
+ // in the pinned 50-rule standard). Rows are EVERY defineTable table derived
172
+ // from the target schema at run time; the union of rows MUST equal the schema
173
+ // table set or the run is RED (fail-closed).
174
+ console.log("");
175
+ const crud = scanCrudCoverageForCli(target);
176
+ console.log(crudCoverageSection(crud));
177
+ const crudDefect = crud.status === "unreadable" || !crud.unionEqualsSchema;
65
178
  console.log("");
66
179
  const cov = coverage();
67
180
  console.log(coverageSummary(cov));
@@ -82,6 +195,10 @@ function main() {
82
195
  const v = verdict(t, results);
83
196
  console.log("");
84
197
  console.log(tallyLine(t));
198
+ // The handler-surface slice (R-50/R-51) reported APART from the doc/oracle/
199
+ // process rules, so its could-not-judge count is visible on its own and can be
200
+ // driven to 0 (Laurent: an instrument that cannot go red protects nothing).
201
+ console.log(handlerSurfaceLine(handlerSurfaceStats(results)));
85
202
  console.log(v.summary);
86
203
  if (v.criticalViolations > 0) {
87
204
  console.log(` critical violations: ${v.violations.critical.join(", ")}`);
@@ -105,6 +222,13 @@ function main() {
105
222
  console.log("\nNOTE: coverage is INCOMPLETE — a rule is in neither bucket. See the summary above.");
106
223
  process.exit(1);
107
224
  }
225
+ // The CRUD coverage self-check is fail-closed: an unreadable schema or a rows
226
+ // union that does not cover the schema tables is a DOCTOR DEFECT → RED, never a
227
+ // silent pass. (ABSENT cells are honest reported gaps, NOT a defect.)
228
+ if (crudDefect) {
229
+ console.log("\nNOTE: CRUD coverage self-check FAILED — the rows do not cover every schema table (or the schema is unreadable). See the CRUD COVERAGE section above.");
230
+ process.exit(1);
231
+ }
108
232
  process.exit(v.exitCode);
109
233
  }
110
234
  main();
@@ -0,0 +1,81 @@
1
+ /** A convex source file, reconstructed as text — the unit the scanner reads. */
2
+ export interface SourceFile {
3
+ rel: string;
4
+ text: string;
5
+ }
6
+ /** Each cell is exactly one of these. `n/a: <reason>` (a MEASURED absence — there
7
+ * is genuinely no candidate routes dir) and `could-not-judge: <reason>` (a
8
+ * candidate dir EXISTS but no router convention was recognised, so presence was
9
+ * NOT measured) are DISTINCT — `n/a` must never stand for an unmeasured cell. */
10
+ export type Cell = "present" | "ABSENT" | `n/a: ${string}` | `could-not-judge: ${string}`;
11
+ /**
12
+ * The app-route probe the CLI gathers from the filesystem and hands to the scan.
13
+ * It carries the two facts the cell disposition turns on: whether a candidate
14
+ * app/src routes dir EXISTS at all, and the normalized route paths recognised in
15
+ * it (Next/SvelteKit convention file rels AND react-router `<Route path>` /
16
+ * `createBrowserRouter` paths). The distinction closes Eta REVISE blocker 3:
17
+ * `n/a` asserts absence, but the pre-fix probe only knew its OWN matcher found
18
+ * nothing — a full react-router dir rendered n/a, a false assertion of absence.
19
+ */
20
+ export interface RouteProbe {
21
+ /** a candidate app/src routes dir that EXISTS on disk, named — undefined iff
22
+ * there is genuinely no candidate routes dir at all (the only `n/a` case). */
23
+ candidateDir?: string;
24
+ /** normalized route path strings — Next/SvelteKit file rels OR react-router
25
+ * paths. Non-empty means a router WAS recognised and cells are judged. */
26
+ routePaths: string[];
27
+ }
28
+ /** Extract react-router route paths from a router source (App.tsx / main.tsx /
29
+ * a routes file): both the `<Route path="…">` element form and the
30
+ * `createBrowserRouter([{ path: "…" }])` object form. Deduped. */
31
+ export declare function extractReactRouterPaths(text: string): string[];
32
+ export interface TableRow {
33
+ table: string;
34
+ create: Cell;
35
+ get: Cell;
36
+ update: Cell;
37
+ remove: Cell;
38
+ list: Cell;
39
+ detail: Cell;
40
+ edit: Cell;
41
+ }
42
+ export interface AbsentCell {
43
+ table: string;
44
+ column: string;
45
+ }
46
+ export interface CrudCoverage {
47
+ status: "ok" | "unreadable";
48
+ /** set when status === "unreadable" — names what could not be read. */
49
+ reason?: string;
50
+ rows: TableRow[];
51
+ rowCount: number;
52
+ schemaTableCount: number;
53
+ unionEqualsSchema: boolean;
54
+ absentCells: AbsentCell[];
55
+ }
56
+ export declare function deriveTableNames(schemaText: string): string[];
57
+ /** One public query/mutation, with the three attribution inputs: the file it
58
+ * lives in, its export name, its `args:` validator text (for `v.id("<table>")`)
59
+ * and its handler body text (for the `ctx.db.*` op it performs). */
60
+ export interface Handler {
61
+ /** module basename, lower-cased, no `.ts` (e.g. `tasks`). */
62
+ fileBase: string;
63
+ /** the export name (e.g. `get`, `update`, `remove`). */
64
+ exportName: string;
65
+ /** the `args:` object literal source text (may be ""). */
66
+ argsText: string;
67
+ /** the `handler:` function source text (may be ""). */
68
+ body: string;
69
+ }
70
+ /** Every PUBLIC query/mutation exported from one module, with its attribution
71
+ * inputs. Non-exported / internal* builders are excluded. */
72
+ export declare function publicHandlers(name: string, text: string): Handler[];
73
+ export declare function scanCrudCoverage(schemaText: string | undefined, files: SourceFile[], routeFiles: string[] | RouteProbe): CrudCoverage;
74
+ export declare function coverageUnionOk(schemaTables: string[], rows: {
75
+ table: string;
76
+ }[]): {
77
+ ok: boolean;
78
+ missing: string[];
79
+ };
80
+ export declare function crudCoverageSection(cov: CrudCoverage): string;
81
+ export declare function crudCoverageLine(cov: CrudCoverage): string;
@@ -0,0 +1,375 @@
1
+ // CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule in
2
+ // the pinned 50-rule standard). It answers, per SCHEMA TABLE derived at run time:
3
+ // does a public convex create/get/update/remove exist, and (if an app routes dir
4
+ // exists) a list/detail/edit route? Every cell is exactly one of
5
+ // `present` | `ABSENT` | `n/a: <written reason>`.
6
+ //
7
+ // DERIVE-NEVER-TYPE: the rows are EVERY `defineTable` table parsed from the
8
+ // target's schema.ts at run time (never a hand-typed subset). If the schema
9
+ // cannot be parsed / yields no table, the report is `unreadable` (fail-closed) —
10
+ // it must NEVER present a subset as a complete inventory.
11
+ //
12
+ // LOAD-BEARING: the union of the report's rows MUST equal the set of schema
13
+ // tables. A schema table ABSENT from the rows is a DOCTOR DEFECT → the run is RED
14
+ // (fail-closed), never a silent pass. `coverageUnionOk` is that assertion.
15
+ //
16
+ // Parsing reuses the R-46 approach (the TypeScript compiler API) — see
17
+ // src/detectors/standard/r46-returns-validator.ts — so the table set is the same
18
+ // truth R-46 derives, never a second, drifting parser.
19
+ import * as ts from "typescript";
20
+ import { deriveSchemaFields } from "./detectors/standard/r46-returns-validator.js";
21
+ /** Extract react-router route paths from a router source (App.tsx / main.tsx /
22
+ * a routes file): both the `<Route path="…">` element form and the
23
+ * `createBrowserRouter([{ path: "…" }])` object form. Deduped. */
24
+ export function extractReactRouterPaths(text) {
25
+ const paths = [];
26
+ const routeEl = /<Route\b[^>]*\bpath\s*=\s*["'`]([^"'`]*)["'`]/g;
27
+ const routeObj = /\bpath\s*:\s*["'`]([^"'`]*)["'`]/g;
28
+ for (const m of text.matchAll(routeEl))
29
+ paths.push(m[1]);
30
+ for (const m of text.matchAll(routeObj))
31
+ paths.push(m[1]);
32
+ return [...new Set(paths)];
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Schema → table names. Reuse the R-46 derivation so there is ONE parser of
36
+ // truth. Throwing / zero tables is surfaced by the caller as `unreadable`.
37
+ // ---------------------------------------------------------------------------
38
+ export function deriveTableNames(schemaText) {
39
+ return [...deriveSchemaFields(schemaText).keys()];
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Public-handler bodies. We only judge PUBLIC query/mutation surface — the
43
+ // internal* variants are not a user-facing CRUD path. Each public handler's
44
+ // body text is extracted so the db-op scan never trips on a `v.id("table")` in
45
+ // the args/returns validators (those live outside the handler body).
46
+ // ---------------------------------------------------------------------------
47
+ // A PUBLIC function-builder identifier: the bare `query`/`mutation`, or a repo
48
+ // custom builder that ends in `Query`/`Mutation` (Convex convention — e.g.
49
+ // `authedQuery`, `writeMutation`, `publicQuery`). `internal*` builders are NOT a
50
+ // user-facing surface and are excluded, so an internalMutation write does not
51
+ // count as a public CRUD path.
52
+ function isPublicBuilder(name) {
53
+ if (/^internal/i.test(name))
54
+ return false;
55
+ return /(^query$|^mutation$|Query$|Mutation$)/.test(name);
56
+ }
57
+ function parse(name, text) {
58
+ return ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
59
+ }
60
+ /** Every PUBLIC query/mutation exported from one module, with its attribution
61
+ * inputs. Non-exported / internal* builders are excluded. */
62
+ export function publicHandlers(name, text) {
63
+ let sf;
64
+ try {
65
+ sf = parse(name, text);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ const fileBase = (name.split("/").pop() ?? name)
71
+ .replace(/\.ts$/, "")
72
+ .toLowerCase();
73
+ const handlers = [];
74
+ function fromConfig(config, exportName) {
75
+ let argsText = "";
76
+ let body = "";
77
+ for (const prop of config.properties) {
78
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
79
+ continue;
80
+ if (prop.name.text === "args")
81
+ argsText = prop.initializer.getText(sf);
82
+ if (prop.name.text === "handler")
83
+ body = prop.initializer.getText(sf);
84
+ }
85
+ handlers.push({ fileBase, exportName, argsText, body });
86
+ }
87
+ function visit(node) {
88
+ if (ts.isVariableStatement(node) &&
89
+ node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
90
+ for (const decl of node.declarationList.declarations) {
91
+ if (!ts.isIdentifier(decl.name) || !decl.initializer)
92
+ continue;
93
+ const init = decl.initializer;
94
+ if (ts.isCallExpression(init) &&
95
+ ts.isIdentifier(init.expression) &&
96
+ isPublicBuilder(init.expression.text)) {
97
+ const arg = init.arguments[0];
98
+ if (arg && ts.isObjectLiteralExpression(arg))
99
+ fromConfig(arg, decl.name.text);
100
+ }
101
+ }
102
+ }
103
+ ts.forEachChild(node, visit);
104
+ }
105
+ visit(sf);
106
+ return handlers;
107
+ }
108
+ const CREATE_NAMES = new Set(["create", "add", "insert", "new"]);
109
+ const GET_NAMES = new Set(["get", "getbyid", "detail", "show", "byid", "find"]);
110
+ const UPDATE_NAMES = new Set(["update", "patch", "edit", "modify", "set"]);
111
+ const REMOVE_NAMES = new Set(["remove", "delete", "del", "destroy", "archive"]);
112
+ /** the schema table a file basename attributes to, singular/plural tolerant. */
113
+ function schemaTableForBase(base, tables) {
114
+ for (const t of tables) {
115
+ const tl = t.toLowerCase();
116
+ if (base === tl)
117
+ return t;
118
+ if (base === singular(t).toLowerCase())
119
+ return t;
120
+ if (singular(base) === singular(t).toLowerCase())
121
+ return t;
122
+ }
123
+ return undefined;
124
+ }
125
+ const ID_ARG = /v\.id\(\s*["']([a-zA-Z_][\w]*)["']/g;
126
+ const LITERAL_REF = /["']([a-zA-Z_][\w]*)["']/g;
127
+ function attribute(handlers, tables) {
128
+ const tableSet = new Set(tables);
129
+ const present = new Map();
130
+ const touched = new Set();
131
+ const mark = (t, col) => {
132
+ if (!tableSet.has(t))
133
+ return;
134
+ let s = present.get(t);
135
+ if (!s) {
136
+ s = new Set();
137
+ present.set(t, s);
138
+ }
139
+ s.add(col);
140
+ touched.add(t);
141
+ };
142
+ const markLiterals = (body, op, col) => {
143
+ const re = new RegExp(`\\.db\\.${op}\\(\\s*["']([a-zA-Z_][\\w]*)["']`, "g");
144
+ for (const m of body.matchAll(re))
145
+ mark(m[1], col);
146
+ };
147
+ for (const h of handlers) {
148
+ // (3) explicit table-literal ops
149
+ markLiterals(h.body, "insert", "create");
150
+ markLiterals(h.body, "get", "get");
151
+ markLiterals(h.body, "patch", "update");
152
+ markLiterals(h.body, "replace", "update");
153
+ markLiterals(h.body, "delete", "remove");
154
+ // (1) args `v.id("<t>")` + body op with no table literal
155
+ const argT = new Set();
156
+ for (const m of h.argsText.matchAll(ID_ARG))
157
+ if (tableSet.has(m[1]))
158
+ argT.add(m[1]);
159
+ const bodyGet = /\.db\.get\s*\(/.test(h.body);
160
+ const bodyUpd = /\.db\.(patch|replace)\s*\(/.test(h.body);
161
+ const bodyDel = /\.db\.delete\s*\(/.test(h.body);
162
+ for (const t of argT) {
163
+ touched.add(t);
164
+ if (bodyGet)
165
+ mark(t, "get");
166
+ if (bodyUpd)
167
+ mark(t, "update");
168
+ if (bodyDel)
169
+ mark(t, "remove");
170
+ }
171
+ // (2) file basename + export name
172
+ const ft = schemaTableForBase(h.fileBase, tables);
173
+ if (ft) {
174
+ touched.add(ft);
175
+ const n = h.exportName.toLowerCase();
176
+ if (CREATE_NAMES.has(n))
177
+ mark(ft, "create");
178
+ if (GET_NAMES.has(n))
179
+ mark(ft, "get");
180
+ if (UPDATE_NAMES.has(n))
181
+ mark(ft, "update");
182
+ if (REMOVE_NAMES.has(n))
183
+ mark(ft, "remove");
184
+ }
185
+ // touched (for the n/a distinction): any schema-table name quoted anywhere.
186
+ for (const m of `${h.body}\n${h.argsText}`.matchAll(LITERAL_REF))
187
+ if (tableSet.has(m[1]))
188
+ touched.add(m[1]);
189
+ }
190
+ return { present, touched };
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // App-route detection. A route dir "exists in this tree" iff at least one route
194
+ // file was probed. When none exists, list/detail/edit are n/a with the written
195
+ // reason. When one exists, a table's route presence is judged by a path segment
196
+ // matching the table name (or its singular), with `[..]`/detail and `edit`.
197
+ // ---------------------------------------------------------------------------
198
+ const NO_ROUTES = "n/a: no app routes dir in this tree";
199
+ /** a dynamic route segment — Next `[id]` OR react-router `:id`. */
200
+ const DYNAMIC_SEG = /\[[^\]]+\]|:[A-Za-z0-9_]+/;
201
+ const EDIT_SEG = /\/edit(\/|$|\.)/;
202
+ function singular(table) {
203
+ if (table.endsWith("ies"))
204
+ return `${table.slice(0, -3)}y`;
205
+ if (table.endsWith("s"))
206
+ return table.slice(0, -1);
207
+ return table;
208
+ }
209
+ function segMatches(path, table) {
210
+ const segs = path.toLowerCase().split("/");
211
+ const names = new Set([table.toLowerCase(), singular(table).toLowerCase()]);
212
+ return segs.some((s) => names.has(s));
213
+ }
214
+ /** Normalize the third scan argument: a bare `string[]` (legacy / Next-file list)
215
+ * becomes a probe where a non-empty list means a router WAS recognised, and an
216
+ * empty list means no candidate dir at all (`n/a`). A `RouteProbe` passes through. */
217
+ function normalizeProbe(input) {
218
+ if (Array.isArray(input)) {
219
+ return {
220
+ routePaths: input,
221
+ candidateDir: input.length > 0 ? "app routes" : undefined,
222
+ };
223
+ }
224
+ return input;
225
+ }
226
+ function routeCells(table, probe) {
227
+ // A router WAS recognised (Next/SvelteKit files or react-router paths) — judge
228
+ // each table's presence against it. An unmatched table is a real ABSENT gap.
229
+ if (probe.routePaths.length > 0) {
230
+ const forTable = probe.routePaths.filter((p) => segMatches(p, table));
231
+ const hasList = forTable.some((p) => !DYNAMIC_SEG.test(p) && !EDIT_SEG.test(p));
232
+ const hasDetail = forTable.some((p) => DYNAMIC_SEG.test(p) && !EDIT_SEG.test(p));
233
+ const hasEdit = forTable.some((p) => EDIT_SEG.test(p));
234
+ return {
235
+ list: hasList ? "present" : "ABSENT",
236
+ detail: hasDetail ? "present" : "ABSENT",
237
+ edit: hasEdit ? "present" : "ABSENT",
238
+ };
239
+ }
240
+ // A candidate app/src dir EXISTS but no known router convention was recognised
241
+ // in it — presence was NOT measured. `could-not-judge` NAMING the dir, never
242
+ // `n/a` (which would falsely assert absence of a dir that is right there).
243
+ if (probe.candidateDir !== undefined) {
244
+ const cnj = `could-not-judge: routes dir "${probe.candidateDir}" exists but no known router convention (Next page.tsx / SvelteKit +page.svelte / react-router <Route path>/createBrowserRouter) was recognised — route presence was NOT measured, so this is could-not-judge, not an asserted absence`;
245
+ return { list: cnj, detail: cnj, edit: cnj };
246
+ }
247
+ // Genuinely no candidate routes dir at all — the only honest `n/a`.
248
+ return { list: NO_ROUTES, detail: NO_ROUTES, edit: NO_ROUTES };
249
+ }
250
+ // ---------------------------------------------------------------------------
251
+ // The scan — one row per schema table, fail-closed on an unreadable schema.
252
+ // ---------------------------------------------------------------------------
253
+ export function scanCrudCoverage(schemaText, files, routeFiles) {
254
+ const probe = normalizeProbe(routeFiles);
255
+ if (schemaText === undefined) {
256
+ return unreadable("no schema.ts with a defineTable found in the target tree — the table set cannot be DERIVED (fail-closed, NOT an empty inventory)");
257
+ }
258
+ let tables;
259
+ try {
260
+ tables = deriveTableNames(schemaText);
261
+ }
262
+ catch (e) {
263
+ return unreadable(`schema.ts could not be parsed (${e.message}) — the table set cannot be DERIVED (fail-closed)`);
264
+ }
265
+ if (tables.length === 0) {
266
+ return unreadable("schema.ts parsed but yielded no defineTable tables — the inventory cannot be complete (fail-closed)");
267
+ }
268
+ // Collect every public handler across the convex tree once, then attribute
269
+ // its ops to tables via the three signals (v.id arg, filename, literal).
270
+ const handlers = [];
271
+ for (const f of files) {
272
+ if (/\.test\.ts$/.test(f.rel) || f.rel.includes("_generated/"))
273
+ continue;
274
+ if (/(^|\/)schema\.ts$/.test(f.rel))
275
+ continue;
276
+ handlers.push(...publicHandlers(f.rel, f.text));
277
+ }
278
+ const { present, touched } = attribute(handlers, tables);
279
+ const rows = [];
280
+ const absentCells = [];
281
+ for (const table of tables.slice().sort()) {
282
+ const cols = present.get(table) ?? new Set();
283
+ const rc = routeCells(table, probe);
284
+ // A table touched by NO public handler at all (no file, no literal, no v.id
285
+ // arg) is legitimately without a user-facing convex surface in this tree — an
286
+ // n/a with a written reason, not four bare ABSENT cells that read as gaps.
287
+ const naReason = `n/a: no public handler references table "${table}"`;
288
+ const isTouched = cols.size > 0 || touched.has(table);
289
+ const cell = (col) => isTouched ? (cols.has(col) ? "present" : "ABSENT") : naReason;
290
+ const row = {
291
+ table,
292
+ create: cell("create"),
293
+ get: cell("get"),
294
+ update: cell("update"),
295
+ remove: cell("remove"),
296
+ list: rc.list,
297
+ detail: rc.detail,
298
+ edit: rc.edit,
299
+ };
300
+ rows.push(row);
301
+ for (const col of [
302
+ "create",
303
+ "get",
304
+ "update",
305
+ "remove",
306
+ "list",
307
+ "detail",
308
+ "edit",
309
+ ]) {
310
+ if (row[col] === "ABSENT")
311
+ absentCells.push({ table, column: col });
312
+ }
313
+ }
314
+ const union = coverageUnionOk(tables, rows);
315
+ return {
316
+ status: "ok",
317
+ rows,
318
+ rowCount: rows.length,
319
+ schemaTableCount: tables.length,
320
+ unionEqualsSchema: union.ok,
321
+ absentCells,
322
+ };
323
+ }
324
+ function unreadable(reason) {
325
+ return {
326
+ status: "unreadable",
327
+ reason,
328
+ rows: [],
329
+ rowCount: 0,
330
+ schemaTableCount: 0,
331
+ unionEqualsSchema: false,
332
+ absentCells: [],
333
+ };
334
+ }
335
+ // ---------------------------------------------------------------------------
336
+ // The load-bearing coverage assertion: the rows' table union MUST equal the
337
+ // schema table set. A schema table missing from the rows is a DOCTOR DEFECT.
338
+ // ---------------------------------------------------------------------------
339
+ export function coverageUnionOk(schemaTables, rows) {
340
+ const have = new Set(rows.map((r) => r.table));
341
+ const missing = schemaTables.filter((t) => !have.has(t)).sort();
342
+ return { ok: missing.length === 0, missing };
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // Rendering — the CLI section + the one-line summary.
346
+ // ---------------------------------------------------------------------------
347
+ function short(cell) {
348
+ if (cell === "present")
349
+ return "present";
350
+ if (cell === "ABSENT")
351
+ return "ABSENT ";
352
+ return cell; // n/a: ...
353
+ }
354
+ export function crudCoverageSection(cov) {
355
+ const header = "=== CRUD COVERAGE (per schema table, derived at run time) ===";
356
+ if (cov.status === "unreadable") {
357
+ return `${header}\n UNREADABLE (fail-closed): ${cov.reason}\n${crudCoverageLine(cov)}`;
358
+ }
359
+ const lines = cov.rows.map((r) => {
360
+ return (` ${r.table.padEnd(28)} ` +
361
+ `create=${short(r.create)} get=${short(r.get)} ` +
362
+ `update=${short(r.update)} remove=${short(r.remove)} ` +
363
+ `| list=${short(r.list)} detail=${short(r.detail)} edit=${short(r.edit)}`);
364
+ });
365
+ return `${header}\n${lines.join("\n")}\n${crudCoverageLine(cov)}`;
366
+ }
367
+ export function crudCoverageLine(cov) {
368
+ if (cov.status === "unreadable") {
369
+ return `coverage UNREADABLE (fail-closed) — ${cov.reason}`;
370
+ }
371
+ const defect = cov.unionEqualsSchema
372
+ ? ""
373
+ : " | DOCTOR DEFECT: rows do NOT cover the schema (RED, fail-closed)";
374
+ return `coverage rows=${cov.rowCount} schema tables=${cov.schemaTableCount} | ABSENT cells=${cov.absentCells.length}${defect}`;
375
+ }
@@ -40,11 +40,16 @@ export function decide(ruleId, id, check, scan, emptyReason) {
40
40
  .map((x) => `${x.rel}:${x.n} (${x.note})`)
41
41
  .join("; ");
42
42
  if (nV > 0) {
43
+ // A non-conforming verdict must NAME its could-not-judge sites, not drop them
44
+ // to a bare integer in the breakdown — the one disposition that owes a human
45
+ // verdict is otherwise the only one printed without an address (Eta REVISE
46
+ // blocker 1: R-51 CRM could-not-judge=1 was counted but never located). The
47
+ // data is already in scan.cnj; list it under its own label whenever non-empty.
43
48
  return {
44
49
  id,
45
50
  anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
46
51
  state: "non-conforming",
47
- verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
52
+ verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (human verdict owed): ${fmt(scan.cnj)}.` : ""}`,
48
53
  };
49
54
  }
50
55
  if (nJ > 0) {
@@ -342,7 +342,7 @@ export function scanR50(files) {
342
342
  ungated.push({
343
343
  rel: f.rel,
344
344
  n: block.lines[0].n,
345
- note: `${fn}: public query with no org-throw, no fail-closed resolver, and no isolation-contract marker — no org-isolation handling is visible in convex/, and whether a subscribing client can reach it before an org exists is not statically decidable; a reviewer must confirm this read isolates by org (never a silent clean bill)`,
345
+ note: `${fn}: public query with no org-throw, no fail-closed resolver, and no isolation-contract marker — an UNMARKED public read. No org-isolation handling and no client contract is visible in convex/; an instrument that abstains here protects nothing (a fully-unmarked read is exactly the surface a subscribing client can hit before an org exists). NON-CONFORMING until the author resolves org via a fail-closed non-throwing path (\`withOrgScope\`), returns a typed empty/unauthorized shape, or records an isolation-contract marker`,
346
346
  });
347
347
  continue;
348
348
  }
@@ -410,15 +410,15 @@ export function scanR50(files) {
410
410
  // so the verdict is CNJ-with-sites, not a misleading empty abstain. Gated on "scan
411
411
  // otherwise empty" so a tree that already produced subjects (VP: 20 conform) is
412
412
  // byte-for-byte unchanged.
413
- // A bare ungated public read (no throw / resolver / marker) is NEVER a silent
414
- // clean bill — whether the scan is otherwise empty (a fully-ungated backend) OR
415
- // mixed (bare reads alongside gated subjects). Surface EVERY such read as
416
- // could-not-judge naming its site: never conformed, never dropped. (Eta mixed-tree
417
- // follow-up: the prior "scan otherwise empty" guard silently dropped bare ungated
418
- // reads whenever any gated subject also existed — a bare read hidden behind a
419
- // conforming sibling. Removed: the disposition is per-def, not per-tree.)
413
+ // An UNMARKED bare public read (no throw / resolver / marker) is NON-CONFORMING,
414
+ // never an abstention — whether the scan is otherwise empty (a fully-unmarked
415
+ // backend) OR mixed (bare reads alongside gated subjects). Laurent doctrine: "an
416
+ // instrument that cannot go red protects nothing; CNJ is for UNREADABLE input
417
+ // only." A bare read is fully READABLE — its two absences (no isolation, no
418
+ // contract) are the defect, not a could-not-read. Push to violate; the author's
419
+ // exit is a resolver, a typed-empty return, or a recorded isolation-contract marker.
420
420
  for (const u of ungated)
421
- scan.cnj.push(u);
421
+ scan.violate.push(u);
422
422
  return scan;
423
423
  }
424
424
  export const R50 = srcDetector("R-50", "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw", "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch", scanR50, "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.");
@@ -252,7 +252,7 @@ export function scanR51(files) {
252
252
  ungated.push({
253
253
  rel: f.rel,
254
254
  n: block.lines[0].n,
255
- note: `${fn}: public mutation with no org-throw, no typed-unauthorized return, and no write-contract marker — no org-isolation handling is visible in convex/, and whether a client can reach it before an org exists is not statically decidable; a reviewer must confirm this write isolates by org (never a silent clean bill)`,
255
+ note: `${fn}: public mutation with no org-throw, no typed-unauthorized return, and no write-contract marker — an UNMARKED public write. No org-isolation handling and no client contract is visible in convex/; an instrument that abstains here protects nothing (a fully-unmarked write is exactly the surface a pre-org client shell can issue). NON-CONFORMING until the author isolates org via a fail-closed path, returns a typed unauthorized result, or records a write-contract marker`,
256
256
  });
257
257
  continue; // not a gated subject
258
258
  }
@@ -321,16 +321,16 @@ export function scanR51(files) {
321
321
  // abstain. Gated on "scan otherwise empty" so a tree that already produced
322
322
  // subjects (VP: 7 marker CNJ) is byte-for-byte unchanged — the bare-write
323
323
  // disposition in a mixed tree is out of this fix's scope.
324
- // A bare ungated public write (no throw / typed-unauth / marker) is NEVER a silent
325
- // clean bill — whether the scan is otherwise empty (a fully-ungated public-write
326
- // surface) OR mixed (bare writes alongside gated/marker subjects). Surface EVERY
327
- // such write as could-not-judge naming its site: never conformed, never dropped.
328
- // (Eta mixed-tree follow-up: the prior "scan otherwise empty" guard silently
329
- // dropped bare ungated writes whenever any gated/marker subject also existed — a
330
- // bare write hidden behind a sibling that happened to produce a verdict. Removed:
331
- // the disposition is per-def, not per-tree.)
324
+ // An UNMARKED bare public write (no throw / typed-unauth / marker) is
325
+ // NON-CONFORMING, never an abstention — whether the scan is otherwise empty (a
326
+ // fully-unmarked public-write surface) OR mixed (bare writes alongside
327
+ // gated/marker subjects). Laurent doctrine: "an instrument that cannot go red
328
+ // protects nothing; CNJ is for UNREADABLE input only." A bare write is fully
329
+ // READABLE — its two absences (no isolation, no contract) are the defect, not a
330
+ // could-not-read. Push to violate; the author's exit is a fail-closed path, a
331
+ // typed unauthorized return, or a recorded write-contract marker.
332
332
  for (const u of ungated)
333
- scan.cnj.push(u);
333
+ scan.violate.push(u);
334
334
  return scan;
335
335
  }
336
336
  export const R51 = srcDetector("R-51", "R-51 a public mutation returns a typed unauthorized result on the no-org path, never an uncaught throw a pre-org client shell cannot render", "a public write either returns a typed unauthorized result / documents its client contract, or a human confirms a pre-org client cannot reach it — it never throws NO_ORG on a path an ordinary render can hit", scanR51, "R-51: no public mutation matched in the scanned tree — no write to certify. NOT a clean bill.");
@@ -179,22 +179,23 @@ const LOOP_NAMED = /\bfor\s*\(|\bfor\s+(?:const|let|var|await)\b|\.forEach\s*\(|
179
179
  const INDEPENDENCE_STATED = /no\s+invariant\s+spans|invariant\s+spans\s+no|writes?\s+are\s+independent|independently\s+atomic|no\s+cross-write\s+invariant/i;
180
180
  // Shape-(1) sub-condition (Eta REVISE #26, 2026-08-31): "a pre-I/O gate mutation
181
181
  // precedes a single atomic write" describes itself CHECKABLY — it is EXACTLY TWO
182
- // ctx.runMutation calls, the FIRST a gate (require*/assert*/ensure*/bind*). A shape-1
182
+ // ctx.runMutation calls, the FIRST a gate (require*/assert*/bind*). A shape-1
183
183
  // marker on anything else (three calls, an unbounded loop, a first call that is not a
184
184
  // gate) is INCOMPLETE and stays non-conforming, exactly as an incomplete shape-2 does
185
185
  // — otherwise eleven words in a comment remove a real violation from the #1250 prod
186
186
  // gate (abstentions do not block; mechanical_violations is the sole driver), and the
187
187
  // mechanical count that gate rests on is no longer honest.
188
- const GATE_CALLEE = /\b(require|assert|ensure|bind)\w*/i;
189
- // Shape (3) narrows the gate stems: `ensure*` is the standard prefix for an UPSERT
190
- // WRITE helper (ensureUser/ensureWorkspace/ensureRow), so on the reingest shape — whose
191
- // FIRST runMutation is the gate — accepting `ensure*` lets a first WRITE renamed the
192
- // usual way (e.g. _insertImportedMemory -> ensureImportedMemory) masquerade as the gate
193
- // and buy the exemption, removing a real violation from the #1250 prod gate (Eta REVISE
194
- // #27, probe B). `bind` STAYS (bindOrAssertStorageOwnership is kb.ts's real gate, the
195
- // case this shape exists for); `require`/`assert` are unambiguous. Shapes 1/2 keep the
196
- // broader GATE_CALLEE.
197
- const GATE_CALLEE_REINGEST = /\b(require|assert|bind)\w*/i;
188
+ // The gate stem excludes `ensure*` — the standard prefix for an UPSERT WRITE helper
189
+ // (ensureUser/ensureWorkspace/ensureRow). Both shapes whose FIRST runMutation is the gate
190
+ // (shape 1 pre-io-gate, shape 3 gate-then-idempotent-reingest) checked the callee NAME, so
191
+ // accepting `ensure*` let a first WRITE renamed the usual way (_insertImportedMemory ->
192
+ // ensureImportedMemory) masquerade as the gate and buy the exemption — removing a real
193
+ // violation from the #1250 prod gate. Fixed at the CLASS (Eta REVISE #27 probe B closed
194
+ // shape 3; this closes the same hole in shape 1, task k179fmje8vb7y0tbjdmxbzg78h8dhj3q):
195
+ // the defect was a gate sub-condition decided by a name stem that idiomatically names
196
+ // writes, so the fix is one narrowed stem set for every gate check. `bind` STAYS
197
+ // (bindOrAssertStorageOwnership is kb.ts's real gate); `require`/`assert` are unambiguous.
198
+ const GATE_CALLEE = /\b(require|assert|bind)\w*/i;
198
199
  export function scanR29(files) {
199
200
  const scan = emptyScan();
200
201
  for (const f of files) {
@@ -218,7 +219,7 @@ export function scanR29(files) {
218
219
  const shapeGate = count === 2 && GATE_CALLEE.test(firstMutCallee);
219
220
  // Shape (3) sub-conditions: a leading gate runMutation, a supersede/dedup on
220
221
  // a stable namespace, a re-insert loop, AND an explicit convergence statement.
221
- const shapeReingest = GATE_CALLEE_REINGEST.test(firstMutCallee) &&
222
+ const shapeReingest = GATE_CALLEE.test(firstMutCallee) &&
222
223
  REINGEST_SUPERSEDE.test(region) &&
223
224
  STABLE_KEY.test(region) &&
224
225
  LOOP_NAMED.test(region) &&
@@ -240,7 +241,7 @@ export function scanR29(files) {
240
241
  ? "the region names its loop and states the per-item writes are independently atomic (no invariant spans two)"
241
242
  : shape === SHAPE_GATE_REINGEST
242
243
  ? "a leading gate runMutation, a supersede/dedup keyed on a stable namespace, and a recovering re-insert loop, with the convergence stated explicitly — a re-ingest of the same stable key converges on one current set"
243
- : "exactly two runMutations, the first a gate (require*/assert*/ensure*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. R-29 refuses (a reviewer verifies the claim per site) — never a silent green (Eta ruling 2026-08-31)`,
244
+ : "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. R-29 refuses (a reviewer verifies the claim per site) — never a silent green (Eta ruling 2026-08-31)`,
244
245
  });
245
246
  }
246
247
  else if (shape === SHAPE_GATE_REINGEST) {
@@ -248,7 +249,7 @@ export function scanR29(files) {
248
249
  // NOT buy could-not-judge (Eta: a shape names a checkable sub-condition, or
249
250
  // the carve-out becomes a vocabulary).
250
251
  const missing = [
251
- !GATE_CALLEE_REINGEST.test(firstMutCallee) && "a leading gate runMutation (require*/assert*/bind* — ensure* is an upsert-write stem, excluded here)",
252
+ !GATE_CALLEE.test(firstMutCallee) && "a leading gate runMutation (require*/assert*/bind* — ensure* is an upsert-write stem, excluded here)",
252
253
  !REINGEST_SUPERSEDE.test(region) && "a supersede/dedup call",
253
254
  !STABLE_KEY.test(region) && "a stable namespace key (namespace/docId/contentHash)",
254
255
  !LOOP_NAMED.test(region) && "a re-insert loop (for/for-of/.forEach/.map)",
@@ -266,7 +267,7 @@ export function scanR29(files) {
266
267
  scan.violate.push({
267
268
  rel: f.rel,
268
269
  n: firstMut || openLine,
269
- note: `action ${inAction} orchestrates ${count} ctx.runMutation calls with an atomicity-exception "${SHAPE_PRE_IO_GATE}" marker that is INCOMPLETE — this shape is EXACTLY TWO runMutations whose FIRST is a gate (require*/assert*/ensure*/bind*); here ${count !== 2
270
+ note: `action ${inAction} orchestrates ${count} ctx.runMutation calls with an atomicity-exception "${SHAPE_PRE_IO_GATE}" marker that is INCOMPLETE — this shape is EXACTLY TWO runMutations whose FIRST is a gate (require*/assert*/bind*); here ${count !== 2
270
271
  ? `there are ${count}, not two`
271
272
  : "the first runMutation is not a gate call"}. A gate-then-single-write is checkable; a marker cannot remove a >2-call or loop site from the gate`,
272
273
  });
package/dist/tally.d.ts CHANGED
@@ -53,5 +53,40 @@ export interface Verdict {
53
53
  * on an exit that means abstention.
54
54
  */
55
55
  export declare function verdict(t: Tally, results: DetectorResult[], severity?: Map<string, Severity>): Verdict;
56
+ /**
57
+ * The HANDLER-SURFACE rules — the source/*-isolation detectors that judge the
58
+ * public query/mutation surface (R-50 read-isolation, R-51 write-isolation).
59
+ * Reported APART from the document/oracle/process rules (R-32, R-37, R-40,
60
+ * R-48, …) so the handler-surface could-not-judge count is visible on its own
61
+ * and can be driven to 0 without conflating it with honest human-gate CNJ.
62
+ * Laurent doctrine: an instrument that cannot go red protects nothing — the
63
+ * handler-surface CNJ must reach 0 except genuine parse-fail / marker-borne /
64
+ * ambiguous-domination sites.
65
+ */
66
+ export declare const HANDLER_SURFACE_RULES: readonly string[];
67
+ export interface HandlerSurfaceStats {
68
+ byState: Record<State, number>;
69
+ /** rule-level state per handler-surface ruleId (worst across its results). */
70
+ ruleState: Map<string, State>;
71
+ /** the handler-surface could-not-judge RULE count — R-50/R-51 as rules abstain
72
+ * or they don't. This is 0 whenever both rules reach a verdict, EVEN when the
73
+ * underlying sites include abstaining ones — so it must never be printed alone. */
74
+ couldNotJudge: number;
75
+ /** the handler-surface could-not-judge SITE count — the sum of every rule's
76
+ * scan.cnj length, parsed from each result's `could-not-judge=N` breakdown. A
77
+ * non-conforming rule can still carry abstaining SITES (Eta REVISE blocker 2:
78
+ * the CRM run had rules-abstaining=0 while 8 sites abstained). Both are printed. */
79
+ sitesAbstaining: number;
80
+ }
81
+ /**
82
+ * The handler-surface slice of the tally, computed over HANDLER_SURFACE_RULES
83
+ * only. Reduces each rule to its worst result state (same reduction as `tally`)
84
+ * so a rule with any non-conforming result reads non-conforming, and surfaces a
85
+ * distinct handler-surface could-not-judge count the CLI prints separately.
86
+ */
87
+ export declare function handlerSurfaceStats(results: DetectorResult[], handlerRules?: readonly string[]): HandlerSurfaceStats;
88
+ /** The one-line handler-surface tally string the CLI prints, apart from the
89
+ * document/oracle/process rules. */
90
+ export declare function handlerSurfaceLine(s: HandlerSurfaceStats): string;
56
91
  /** The one-line machine tally string the CLI prints. */
57
92
  export declare function tallyLine(t: Tally): string;
package/dist/tally.js CHANGED
@@ -19,7 +19,12 @@ const STATE_RANK = {
19
19
  absent: 1,
20
20
  present: 0,
21
21
  };
22
- const ALL_STATES = ["present", "non-conforming", "could-not-judge", "absent"];
22
+ const ALL_STATES = [
23
+ "present",
24
+ "non-conforming",
25
+ "could-not-judge",
26
+ "absent",
27
+ ];
23
28
  /** Extract the ruleId (`R-N`) a result belongs to, from its leading id token. */
24
29
  export function ruleIdOf(result) {
25
30
  const m = /^(R-\d+)\b/.exec(result.id);
@@ -143,6 +148,75 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
143
148
  summary,
144
149
  };
145
150
  }
151
+ /**
152
+ * The HANDLER-SURFACE rules — the source/*-isolation detectors that judge the
153
+ * public query/mutation surface (R-50 read-isolation, R-51 write-isolation).
154
+ * Reported APART from the document/oracle/process rules (R-32, R-37, R-40,
155
+ * R-48, …) so the handler-surface could-not-judge count is visible on its own
156
+ * and can be driven to 0 without conflating it with honest human-gate CNJ.
157
+ * Laurent doctrine: an instrument that cannot go red protects nothing — the
158
+ * handler-surface CNJ must reach 0 except genuine parse-fail / marker-borne /
159
+ * ambiguous-domination sites.
160
+ */
161
+ export const HANDLER_SURFACE_RULES = ["R-50", "R-51"];
162
+ // The per-rule breakdown token every source detect() emits: `could-not-judge=N`.
163
+ // The site figure is the sum of N across the handler-surface rules' results — the
164
+ // same N that names the sites under decide()'s CNJ label, never a re-derivation.
165
+ const CNJ_SITES_IN_VERDICT = /could-not-judge=(\d+)/;
166
+ /**
167
+ * The handler-surface slice of the tally, computed over HANDLER_SURFACE_RULES
168
+ * only. Reduces each rule to its worst result state (same reduction as `tally`)
169
+ * so a rule with any non-conforming result reads non-conforming, and surfaces a
170
+ * distinct handler-surface could-not-judge count the CLI prints separately.
171
+ */
172
+ export function handlerSurfaceStats(results, handlerRules = HANDLER_SURFACE_RULES) {
173
+ const set = new Set(handlerRules);
174
+ const ruleState = new Map();
175
+ // Sites abstaining is summed over the RAW results (before the rule reduction),
176
+ // so a non-conforming rule's abstaining sites are still counted — one entry per
177
+ // rule's single reduced result here, but robust to multi-result rules.
178
+ const cnjSitesByRule = new Map();
179
+ for (const r of results) {
180
+ const id = ruleIdOf(r);
181
+ if (!set.has(id))
182
+ continue;
183
+ const prev = ruleState.get(id);
184
+ if (prev === undefined || STATE_RANK[r.state] > STATE_RANK[prev]) {
185
+ ruleState.set(id, r.state);
186
+ }
187
+ const m = CNJ_SITES_IN_VERDICT.exec(r.verdict);
188
+ if (m)
189
+ cnjSitesByRule.set(id, (cnjSitesByRule.get(id) ?? 0) + Number(m[1]));
190
+ }
191
+ const byState = {
192
+ present: 0,
193
+ "non-conforming": 0,
194
+ "could-not-judge": 0,
195
+ absent: 0,
196
+ };
197
+ for (const s of ruleState.values())
198
+ byState[s] += 1;
199
+ let sitesAbstaining = 0;
200
+ for (const n of cnjSitesByRule.values())
201
+ sitesAbstaining += n;
202
+ return {
203
+ byState,
204
+ ruleState,
205
+ couldNotJudge: byState["could-not-judge"],
206
+ sitesAbstaining,
207
+ };
208
+ }
209
+ /** The one-line handler-surface tally string the CLI prints, apart from the
210
+ * document/oracle/process rules. */
211
+ export function handlerSurfaceLine(s) {
212
+ const ids = [...s.ruleState.entries()]
213
+ .map(([id, st]) => `${id}=${st}`)
214
+ .join(", ");
215
+ return (`HANDLER-SURFACE TALLY (${HANDLER_SURFACE_RULES.join("+")}, reported apart from doc/oracle/process rules): ` +
216
+ `present=${s.byState.present} non-conforming=${s.byState["non-conforming"]} ` +
217
+ `could-not-judge=${s.byState["could-not-judge"]} absent=${s.byState.absent}` +
218
+ `${ids ? ` [${ids}]` : ""} | rules abstaining=${s.couldNotJudge} | sites abstaining=${s.sitesAbstaining}`);
219
+ }
146
220
  /** The one-line machine tally string the CLI prints. */
147
221
  export function tallyLine(t) {
148
222
  return (`TALLY (by rule, ${t.ruleTotal} rules from ${t.resultTotal} results): ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/backend-doctor",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "A conformance instrument that measures the VantagePeers backend against the pinned backend standard. The THIRD doctor built ON the @vantageos/mcp-doctor engine — never a third engine. It consumes runDoctor/renderTable and adds only detectors.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",