@vantageos/backend-doctor 0.1.13 → 0.2.0

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.
Files changed (58) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +217 -10
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/crud-coverage.d.ts +81 -0
  12. package/dist/crud-coverage.js +375 -0
  13. package/dist/detectors/oracle-axis1.d.ts +5 -3
  14. package/dist/detectors/oracle-axis1.js +24 -15
  15. package/dist/detectors/oracle-axis2.js +5 -6
  16. package/dist/detectors/predicates.d.ts +3 -0
  17. package/dist/detectors/predicates.js +81 -16
  18. package/dist/detectors/shared.js +5 -5
  19. package/dist/detectors/source/_scan.js +6 -1
  20. package/dist/detectors/source/backfill.js +9 -1
  21. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  22. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  23. package/dist/detectors/source/identity.d.ts +51 -2
  24. package/dist/detectors/source/identity.js +815 -14
  25. package/dist/detectors/source/isolation.d.ts +11 -1
  26. package/dist/detectors/source/isolation.js +106 -25
  27. package/dist/detectors/source/jobs.js +165 -2
  28. package/dist/detectors/source/readbudget.d.ts +1 -0
  29. package/dist/detectors/source/readbudget.js +67 -4
  30. package/dist/detectors/source/security.d.ts +16 -0
  31. package/dist/detectors/source/security.js +225 -7
  32. package/dist/detectors/source/write-isolation.d.ts +8 -1
  33. package/dist/detectors/source/write-isolation.js +64 -30
  34. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  35. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  36. package/dist/detectors/structural.js +14 -10
  37. package/dist/oracle.d.ts +92 -3
  38. package/dist/oracle.js +229 -12
  39. package/dist/registry.d.ts +7 -0
  40. package/dist/registry.js +48 -6
  41. package/dist/report.d.ts +11 -1
  42. package/dist/report.js +15 -2
  43. package/dist/state-rank.d.ts +5 -0
  44. package/dist/state-rank.js +9 -0
  45. package/dist/tally.d.ts +35 -0
  46. package/dist/tally.js +76 -8
  47. package/dist/target.d.ts +32 -0
  48. package/dist/target.js +173 -0
  49. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  50. package/fixtures/cr1-classes/surface.ts +206 -0
  51. package/fixtures/cr1-empty/nothing.ts +1 -0
  52. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  53. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  54. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  55. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  56. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  57. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  58. package/package.json +2 -2
@@ -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
+ }
@@ -1,8 +1,9 @@
1
1
  import type { Detector } from "../engine-types.js";
2
2
  import { type OracleRow } from "../oracle.js";
3
- /** Absolute path to the pinned SECOND-backend (VantageRegistry) oracle fixture.
3
+ /** The vendored day159 SECOND-backend (VantageRegistry) snapshot — a FIXTURE for
4
+ * this tool's tests, never a default (see oracle.ts resolveOracle).
4
5
  * Vendored at pin a74a042b, blob b53e2626954a887303d11d944b6132a11dbd34f6. */
5
- export declare const VR_ORACLE_PATH: string;
6
+ export declare const FIXTURE_VR_ORACLE_PATH: string;
6
7
  /** The vendored standard (the R-5 law) — the SAME pin coverage.ts parses. The
7
8
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
8
9
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
@@ -19,7 +20,8 @@ export interface Inventory {
19
20
  /** Load any backend inventory CSV generically from its OWN header — the shared
20
21
  * fields are DERIVED from these real headers, never a prose illustration. */
21
22
  export declare function loadInventory(path: string): Inventory;
22
- /** Memoized read of the pinned VR inventory. */
23
+ /** Memoized read of the VR inventory bound for THIS run (derived from the target).
24
+ * Throws OracleAbsentError when none was found. */
23
25
  export declare function readVrInventory(): Inventory;
24
26
  /** The VP inventory, adapted from the memoized VP oracle reader — the SAME rows
25
27
  * the axis-2 detectors read, exposed with VP's real header. */
@@ -18,13 +18,14 @@
18
18
  //
19
19
  // Both inputs are IN HAND (VP oracle + VR oracle, both vendored + hash-checked).
20
20
  import { readFileSync } from "node:fs";
21
- import { dirname, join } from "node:path";
21
+ import { basename, dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
- import { ORACLE_COLUMNS, parseCsv, readOracle, } from "../oracle.js";
23
+ import { ORACLE_COLUMNS, parseCsv, activeOracle, oracleLabel, readOracle, refuseWithoutOracle, requireVrOracle, } from "../oracle.js";
24
24
  const here = dirname(fileURLToPath(import.meta.url));
25
- /** Absolute path to the pinned SECOND-backend (VantageRegistry) oracle fixture.
25
+ /** The vendored day159 SECOND-backend (VantageRegistry) snapshot — a FIXTURE for
26
+ * this tool's tests, never a default (see oracle.ts resolveOracle).
26
27
  * Vendored at pin a74a042b, blob b53e2626954a887303d11d944b6132a11dbd34f6. */
27
- export const VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
28
+ export const FIXTURE_VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
28
29
  /** The vendored standard (the R-5 law) — the SAME pin coverage.ts parses. The
29
30
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
30
31
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
@@ -44,13 +45,17 @@ export function loadInventory(path) {
44
45
  });
45
46
  return { header, rows };
46
47
  }
47
- let vrCache = null;
48
- /** Memoized read of the pinned VR inventory. */
48
+ const vrCache = new Map();
49
+ /** Memoized read of the VR inventory bound for THIS run (derived from the target).
50
+ * Throws OracleAbsentError when none was found. */
49
51
  export function readVrInventory() {
50
- if (vrCache)
51
- return vrCache;
52
- vrCache = loadInventory(VR_ORACLE_PATH);
53
- return vrCache;
52
+ const path = requireVrOracle().path;
53
+ const hit = vrCache.get(path);
54
+ if (hit)
55
+ return hit;
56
+ const inv = loadInventory(path);
57
+ vrCache.set(path, inv);
58
+ return inv;
54
59
  }
55
60
  /** The VP inventory, adapted from the memoized VP oracle reader — the SAME rows
56
61
  * the axis-2 detectors read, exposed with VP's real header. */
@@ -161,13 +166,17 @@ export function compareBackends(vpProvider = readOracle, vrProvider = readVrInve
161
166
  };
162
167
  }
163
168
  const R40_ID = "R-40 between-backend divergence";
164
- const R40_ANCHOR = "oracle:vp-by-tool-day158.csv vs oracle:vr-by-tool-day159.csv";
169
+ function r40Anchor() {
170
+ const vr = activeOracle()?.vr;
171
+ const vrName = vr && vr.status === "found" ? basename(vr.path) : "ABSENT";
172
+ return `${oracleLabel()} vs oracle:${vrName}`;
173
+ }
165
174
  /** Wrap the Axis-1 compare into a Detector. Providers are injectable for tests;
166
175
  * production reads the two memoized real oracles. */
167
176
  export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
168
177
  return {
169
178
  id: R40_ID,
170
- run: () => {
179
+ run: () => refuseWithoutOracle(R40_ID, () => {
171
180
  const cmp = compareBackends(vpProvider, vrProvider);
172
181
  const { schema, sites } = cmp;
173
182
  // the schema (column) divergence is itself a site — surfaced, never dropped.
@@ -188,7 +197,7 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
188
197
  const reason = `R-40: the VP and VR inventories agree on every shared field's closed vocabulary and share every column; the mechanical between-backend compare is exhausted with no divergence. Present — decided by the compare, not a human verdict. ${breakdown}`;
189
198
  return {
190
199
  id: R40_ID,
191
- anchor: R40_ANCHOR,
200
+ anchor: r40Anchor(),
192
201
  state: "present",
193
202
  verdict: reason,
194
203
  };
@@ -210,10 +219,10 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
210
219
  const reason = `R-40: ${sites.length} shared field(s) diverge BETWEEN VP and VR on their closed vocabulary — the mechanical compare names WHICH FIELD and WHICH value on VP vs VR, and none carries a written between-backend justification; non-conforming until each divergence is reconciled or carries a recorded justification. ${schemaFinding} ${breakdown} Field sites: ${examples}.`;
211
220
  return {
212
221
  id: R40_ID,
213
- anchor: `${R40_ANCHOR} field=${sites[0]?.field ?? "schema"}`,
222
+ anchor: `${r40Anchor()} field=${sites[0]?.field ?? "schema"}`,
214
223
  state: "non-conforming",
215
224
  verdict: reason,
216
225
  };
217
- },
226
+ }),
218
227
  };
219
228
  }
@@ -1,4 +1,4 @@
1
- import { isInventoryRow, readOracle, } from "../oracle.js";
1
+ import { isInventoryRow, oracleLabel, readOracle, refuseWithoutOracle, } from "../oracle.js";
2
2
  // A written justification token turns a flagged divergence from a DEFECT the
3
3
  // author owes into a recorded, accepted divergence. It is the mechanical pole
4
4
  // that replaced "a human decides whether the divergence is sound": the control
@@ -152,13 +152,12 @@ export function findDivergences(rows) {
152
152
  return sites;
153
153
  }
154
154
  const R37_ID = "R-37 filled-neighbour divergence";
155
- const R37_ANCHOR = "oracle:vp-by-tool-day158.csv";
156
155
  /** Wrap the filled-neighbour compare into a Detector. `rowsProvider` is
157
156
  * injectable for tests; production reads the memoized real oracle. */
158
157
  export function makeR37(rowsProvider = readOracle) {
159
158
  return {
160
159
  id: R37_ID,
161
- run: () => {
160
+ run: () => refuseWithoutOracle(R37_ID, () => {
162
161
  const all = findDivergences(rowsProvider());
163
162
  // MECHANICAL disposition: an unjustified divergence is the defect; a
164
163
  // divergence carrying a written per-tool justification token is accepted.
@@ -173,7 +172,7 @@ export function makeR37(rowsProvider = readOracle) {
173
172
  const reason = `R-37: no UNJUSTIFIED filled-neighbour divergence across the oracle's per-table groups (${all.length} divergence(s), all carrying a written per-tool justification token, or none present). The mechanical compare is exhaustive and the disposition is decided by the token's presence, not a human verdict. ${breakdown}`;
174
173
  return {
175
174
  id: R37_ID,
176
- anchor: R37_ANCHOR,
175
+ anchor: oracleLabel(),
177
176
  state: "present",
178
177
  verdict: reason,
179
178
  };
@@ -185,10 +184,10 @@ export function makeR37(rowsProvider = readOracle) {
185
184
  const reason = `R-37: ${sites.length} UNJUSTIFIED filled-neighbour divergence(s) across ${tables.size} tables — the mechanical compare names WHICH TWO TOOLS diverge on a shared contract field, and none carries a written per-tool justification token (JUSTIFIÉ:/JUSTIFIED: in rbac_adjustment_needed); each owes a fix or a recorded justification. This IS a violation, not an abstention. ${breakdown} Examples: ${examples}.`;
186
185
  return {
187
186
  id: R37_ID,
188
- anchor: `oracle:vp-by-tool-day158.csv table=${sites[0].table}`,
187
+ anchor: `${oracleLabel()} table=${sites[0].table}`,
189
188
  state: "non-conforming",
190
189
  verdict: reason,
191
190
  };
192
- },
191
+ }),
193
192
  };
194
193
  }
@@ -1,3 +1,4 @@
1
+ import { type WriterTierTable } from "../oracle.js";
1
2
  import { type RulePredicate } from "./shared.js";
2
3
  /** Collapse a raw `surface` to one of the three canonical exposures, or null. */
3
4
  export declare function collapseSurface(raw: string): string | null;
@@ -11,6 +12,8 @@ export declare const r4: RulePredicate;
11
12
  export declare function collapseTier(raw: string): string | null;
12
13
  export declare const r5: RulePredicate;
13
14
  export declare const r9: RulePredicate;
15
+ export declare const WRITER_TIERS: ReadonlySet<string>;
16
+ export declare function makeR10(sideCar: () => WriterTierTable): RulePredicate;
14
17
  export declare const r10: RulePredicate;
15
18
  export declare const r12: RulePredicate;
16
19
  export declare const r13: RulePredicate;
@@ -1,3 +1,9 @@
1
+ // The eleven per-row first-wave predicates. Each transcribes its §15 "The
2
+ // check" row and its §2 field mapping from the pinned standard, then applies
3
+ // that predicate to the oracle rows. Structural R-14 and R-38 live in
4
+ // structural.ts (they judge the inventory SCHEMA, not a row).
5
+ import { basename } from "node:path";
6
+ import { WRITER_TIER_NAME, readWriterTier } from "../oracle.js";
1
7
  import { LIST_VERBS, WRITE_VERBS, isMcpPublic } from "./shared.js";
2
8
  // ---- R-1 exposure declared ------------------------------------------------
3
9
  // §2 line 32: exposure -> `surface` = `public` (MCP surface) / `internal` /
@@ -200,22 +206,81 @@ export const r9 = {
200
206
  },
201
207
  };
202
208
  // ---- R-10 writer authority, a distinct resolved tier ----------------------
203
- // §2 writer authority [shared] -> `rbac_qui`. §15: writer tier present, DISTINCT,
204
- // one of five. The oracle has a SINGLE `rbac_qui` column, so no write can carry
205
- // a writer tier distinct from its reader tier — the headline merge (R-14).
206
- export const r10 = {
207
- ruleId: "R-10",
208
- id: "R-10 writer authority, distinct tier",
209
- check: "writer tier present, distinct from reader, one of five",
210
- classify: (r) => {
211
- if (!WRITE_VERBS.has(r.by.crud))
212
- return { kind: "na" };
213
- return {
214
- kind: "violate",
215
- note: "writer authority is not recorded as a field distinct from reader authority — both merged into the single `rbac_qui` column",
216
- };
217
- },
218
- };
209
+ // §2 line 36: writer authority is "a **distinct** tier from the same set, resolved
210
+ // from a verified identity"; §15 line 413: "writer tier present, distinct, one of
211
+ // five"; line 63: "Reader and writer are two fields". DISTINCT is a property of
212
+ // the FIELD, not of the value: the standard requires the writer tier to be
213
+ // RECORDED apart from the reader tier so a `read=public write=admin` divergence
214
+ // can be written — it nowhere forbids a tool whose writer tier happens to equal
215
+ // its reader tier (an org member reading and writing its own org's rows). So an
216
+ // equal VALUE conforms; a MERGED field (no separate record, or one cell naming
217
+ // several tiers) violates.
218
+ //
219
+ // Material: the writer-tier side-car (src/oracle.ts WRITER_TIER_NAME), joined on
220
+ // `outil`. Branches, per write row:
221
+ // side-car absent / unreadable -> cnj, naming the file(s) looked at
222
+ // no side-car row for the tool -> violate (tier absent)
223
+ // two rows with different tiers -> violate (merged)
224
+ // tier empty / `?` -> violate (unknown)
225
+ // tier naming several tiers (a/b, a+b) -> violate (merged)
226
+ // tier outside the five -> violate
227
+ // writer_gate empty or `mcp-scope:<t>` -> cnj: the tier was not derived from
228
+ // a gate on a write door, so "resolved from a verified identity" is not
229
+ // evidenced by the side-car (never a silent pass)
230
+ // otherwise -> conform
231
+ export const WRITER_TIERS = new Set([
232
+ "public",
233
+ "org-member",
234
+ "org-admin",
235
+ "fleet-internal",
236
+ "master",
237
+ ]);
238
+ const UNKNOWN_TIER = new Set(["", "?", "unknown", "n/a", "tbd"]);
239
+ const MERGED_TIER = /[/+,|&]|\s(and|or|et|ou)\s/i;
240
+ export function makeR10(sideCar) {
241
+ return {
242
+ ruleId: "R-10",
243
+ id: "R-10 writer authority, distinct tier",
244
+ check: "writer tier recorded in its own field (side-car joined on `outil`), one of five, gated on a write door; equal-to-reader values conform (the field must be distinct, §2 line 36)",
245
+ classify: (r) => {
246
+ if (!WRITE_VERBS.has(r.by.crud))
247
+ return { kind: "na" };
248
+ const t = sideCar();
249
+ if (t.status === "absent")
250
+ return {
251
+ kind: "cnj",
252
+ note: `writer-tier side-car ${WRITER_TIER_NAME} ABSENT (${t.origin}; searched: ${t.searched.join(", ") || "<none>"}) — this oracle has write rows, so writer authority cannot be judged`,
253
+ };
254
+ if (t.status === "unreadable")
255
+ return { kind: "cnj", note: `writer-tier side-car ${t.path} unreadable — ${t.reason}` };
256
+ const file = basename(t.path);
257
+ const entries = t.byTool.get(r.by.outil) ?? [];
258
+ if (entries.length === 0)
259
+ return { kind: "violate", note: `no writer tier recorded for "${r.by.outil}" in ${file} — writer authority absent` };
260
+ const tiers = [...new Set(entries.map((e) => e.writer_tier))];
261
+ if (tiers.length > 1)
262
+ return {
263
+ kind: "violate",
264
+ note: `${file} records ${tiers.length} writer tiers for "${r.by.outil}" (${tiers.join(", ")}) — merged, not one tier`,
265
+ };
266
+ const tier = tiers[0];
267
+ if (UNKNOWN_TIER.has(tier.toLowerCase()))
268
+ return { kind: "violate", note: `writer_tier="${tier || "<empty>"}" for "${r.by.outil}" — unknown, not a tier` };
269
+ if (MERGED_TIER.test(tier))
270
+ return { kind: "violate", note: `writer_tier="${tier}" names several tiers — merged, not one tier` };
271
+ if (!WRITER_TIERS.has(tier))
272
+ return { kind: "violate", note: `writer_tier="${tier}" is none of the five tiers {${[...WRITER_TIERS].join(", ")}}` };
273
+ const gate = entries[0].writer_gate;
274
+ if (gate === "" || gate.startsWith("mcp-scope:"))
275
+ return {
276
+ kind: "cnj",
277
+ note: `writer_tier="${tier}" for "${r.by.outil}" rests on ${gate === "" ? "no writer_gate" : `the MCP-scope fallback "${gate}"`} — no write-door gate evidences resolution from a verified identity`,
278
+ };
279
+ return { kind: "conform" };
280
+ },
281
+ };
282
+ }
283
+ export const r10 = makeR10(readWriterTier);
219
284
  // ---- R-12 vector search declares namespace + topK -------------------------
220
285
  // §2 vector boundary -> `search_mecanisme`. §15: real namespace + numeric topK.
221
286
  export const r12 = {