@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.
- package/README.md +19 -2
- package/dist/args.d.ts +16 -0
- package/dist/args.js +54 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1195 -0
- package/dist/cli.js +217 -10
- package/dist/coverage-cli.js +8 -0
- package/dist/crud-coverage.d.ts +81 -0
- package/dist/crud-coverage.js +375 -0
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +24 -15
- package/dist/detectors/oracle-axis2.js +5 -6
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +81 -16
- package/dist/detectors/shared.js +5 -5
- package/dist/detectors/source/_scan.js +6 -1
- package/dist/detectors/source/backfill.js +9 -1
- package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
- package/dist/detectors/source/guard-helper-flow.js +1173 -0
- package/dist/detectors/source/identity.d.ts +51 -2
- package/dist/detectors/source/identity.js +815 -14
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +165 -2
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +67 -4
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +225 -7
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +45 -14
- package/dist/detectors/structural.js +14 -10
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +7 -0
- package/dist/registry.js +48 -6
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +35 -0
- package/dist/tally.js +76 -8
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- 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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
48
|
-
/** Memoized read of the
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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:
|
|
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: `${
|
|
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:
|
|
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:
|
|
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
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 = {
|