@vantageos/backend-doctor 0.1.14 → 0.3.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 (93) hide show
  1. package/README.md +66 -5
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +72 -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 +1198 -0
  9. package/dist/cli.js +249 -17
  10. package/dist/contract.d.ts +25 -7
  11. package/dist/contract.js +52 -7
  12. package/dist/coverage-cli.js +8 -0
  13. package/dist/coverage.d.ts +1 -1
  14. package/dist/coverage.js +2 -2
  15. package/dist/detectors/oracle-axis1.d.ts +5 -3
  16. package/dist/detectors/oracle-axis1.js +28 -18
  17. package/dist/detectors/oracle-axis2.d.ts +6 -0
  18. package/dist/detectors/oracle-axis2.js +37 -22
  19. package/dist/detectors/predicates.d.ts +3 -0
  20. package/dist/detectors/predicates.js +93 -19
  21. package/dist/detectors/shared.js +8 -7
  22. package/dist/detectors/source/_ast.d.ts +92 -0
  23. package/dist/detectors/source/_ast.js +353 -0
  24. package/dist/detectors/source/_scan.js +4 -3
  25. package/dist/detectors/source/backfill.js +71 -18
  26. package/dist/detectors/source/contract.js +21 -13
  27. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  28. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  29. package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
  30. package/dist/detectors/source/guard-helper-flow.js +1216 -0
  31. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  32. package/dist/detectors/source/identity-by-id.js +1783 -0
  33. package/dist/detectors/source/identity.d.ts +58 -2
  34. package/dist/detectors/source/identity.js +1158 -28
  35. package/dist/detectors/source/isolation.d.ts +11 -1
  36. package/dist/detectors/source/isolation.js +106 -25
  37. package/dist/detectors/source/jobs.js +178 -4
  38. package/dist/detectors/source/lifecycle.js +14 -2
  39. package/dist/detectors/source/local-identity.d.ts +26 -0
  40. package/dist/detectors/source/local-identity.js +661 -0
  41. package/dist/detectors/source/package-principal.d.ts +9 -0
  42. package/dist/detectors/source/package-principal.js +177 -0
  43. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  44. package/dist/detectors/source/r6-shapes.js +285 -0
  45. package/dist/detectors/source/readbudget.d.ts +1 -0
  46. package/dist/detectors/source/readbudget.js +358 -4
  47. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  48. package/dist/detectors/source/recipient-by-id.js +1108 -0
  49. package/dist/detectors/source/refusal.d.ts +14 -0
  50. package/dist/detectors/source/refusal.js +75 -2
  51. package/dist/detectors/source/security.d.ts +16 -0
  52. package/dist/detectors/source/security.js +680 -33
  53. package/dist/detectors/source/write-isolation.d.ts +8 -1
  54. package/dist/detectors/source/write-isolation.js +64 -30
  55. package/dist/detectors/source/writesafety.js +27 -14
  56. package/dist/detectors/standard/_doc.d.ts +1 -1
  57. package/dist/detectors/standard/_doc.js +5 -4
  58. package/dist/detectors/standard/r41-frontier.js +3 -2
  59. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  60. package/dist/detectors/standard/r46-returns-validator.js +48 -16
  61. package/dist/detectors/structural.d.ts +6 -7
  62. package/dist/detectors/structural.js +84 -48
  63. package/dist/not-checkable.d.ts +1 -1
  64. package/dist/not-checkable.js +11 -0
  65. package/dist/oracle.d.ts +92 -3
  66. package/dist/oracle.js +229 -12
  67. package/dist/registry.d.ts +8 -1
  68. package/dist/registry.js +74 -8
  69. package/dist/report.d.ts +11 -1
  70. package/dist/report.js +15 -2
  71. package/dist/rule-class.d.ts +85 -0
  72. package/dist/rule-class.js +218 -0
  73. package/dist/state-rank.d.ts +5 -0
  74. package/dist/state-rank.js +9 -0
  75. package/dist/tally.d.ts +13 -5
  76. package/dist/tally.js +15 -13
  77. package/dist/target.d.ts +32 -0
  78. package/dist/target.js +173 -0
  79. package/dist/violation-count.d.ts +48 -0
  80. package/dist/violation-count.js +94 -0
  81. package/fixtures/backend-standard-2825122.md +722 -0
  82. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  83. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  84. package/fixtures/cr1-classes/surface.ts +206 -0
  85. package/fixtures/cr1-empty/nothing.ts +1 -0
  86. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  87. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  88. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  89. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  90. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  91. package/fixtures/rule-classes-2825122.json +228 -0
  92. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  93. package/package.json +2 -2
@@ -0,0 +1,353 @@
1
+ // Shared TypeScript-AST primitives for the R-52 / R-53 source detectors.
2
+ //
3
+ // Both rules read the SAME delivered material — the schema's tenant columns, every
4
+ // public write handler, and the helper functions a handler calls — and both need
5
+ // the parser rather than a line regex: R-52 traces a selector's provenance across
6
+ // statements, R-53 reads the operands of an admission comparison. One parse per
7
+ // file, one function index per tree; nothing here decides a verdict.
8
+ import * as ts from "typescript";
9
+ export function sourceOf(f) {
10
+ return f.lines.map((l) => l.raw).join("\n");
11
+ }
12
+ export function parseFile(f) {
13
+ return {
14
+ rel: f.rel,
15
+ sf: ts.createSourceFile(f.rel, sourceOf(f), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
16
+ };
17
+ }
18
+ export function lineOf(n) {
19
+ const sf = n.getSourceFile();
20
+ return sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
21
+ }
22
+ /** Strip `await`, parentheses, `!` non-null, `as`/`satisfies` casts. */
23
+ export function unwrap(e) {
24
+ let x = e;
25
+ for (;;) {
26
+ if (ts.isAwaitExpression(x) ||
27
+ ts.isParenthesizedExpression(x) ||
28
+ ts.isNonNullExpression(x) ||
29
+ ts.isAsExpression(x) ||
30
+ ts.isSatisfiesExpression(x))
31
+ x = x.expression;
32
+ else
33
+ return x;
34
+ }
35
+ }
36
+ /** A property read: `x.prop`, or `x["prop"]` (a string-literal element access
37
+ * is the same read, spelled differently). A computed key is not a read of a
38
+ * known property and gives undefined. */
39
+ export function propRead(e) {
40
+ if (ts.isPropertyAccessExpression(e))
41
+ return { obj: e.expression, name: e.name.text };
42
+ if (ts.isElementAccessExpression(e) &&
43
+ ts.isStringLiteralLike(e.argumentExpression))
44
+ return { obj: e.expression, name: e.argumentExpression.text };
45
+ return undefined;
46
+ }
47
+ /** The initializer of the NEAREST `const <id> = <init>` visible from `id`, or
48
+ * undefined (a parameter or a `let` in between shadows it, so does a
49
+ * destructure). Exactly one level: the caller never follows an alias of an
50
+ * alias. */
51
+ export function constInit(id) {
52
+ for (let x = id.parent; x; x = x.parent) {
53
+ if (ts.isFunctionLike(x) &&
54
+ x.parameters.some((q) => ts.isIdentifier(q.name) && q.name.text === id.text))
55
+ return undefined;
56
+ const stmts = ts.isBlock(x) || ts.isSourceFile(x) || ts.isModuleBlock(x)
57
+ ? x.statements
58
+ : ts.isCaseClause(x) || ts.isDefaultClause(x)
59
+ ? x.statements
60
+ : undefined;
61
+ if (!stmts)
62
+ continue;
63
+ for (const st of stmts) {
64
+ if (!ts.isVariableStatement(st))
65
+ continue;
66
+ for (const d of st.declarationList.declarations)
67
+ if (ts.isIdentifier(d.name) && d.name.text === id.text)
68
+ return st.declarationList.flags & ts.NodeFlags.Const
69
+ ? d.initializer
70
+ : undefined;
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+ /** `e` as a property read, one const alias deep: `x.p`, `x["p"]`, or an
76
+ * identifier declared `const a = x.p` / `const a = x["p"]` (read as `x.p`). */
77
+ export function propReadResolved(e) {
78
+ const u = unwrap(e);
79
+ const direct = propRead(u);
80
+ if (direct)
81
+ return direct;
82
+ if (!ts.isIdentifier(u))
83
+ return undefined;
84
+ const init = constInit(u);
85
+ return init ? propRead(unwrap(init)) : undefined;
86
+ }
87
+ /** Visit every descendant of `n` (pre-order), NOT descending into nested
88
+ * function bodies when `ownOnly` is set. */
89
+ export function walk(n, visit, ownOnly = false) {
90
+ const rec = (x) => {
91
+ visit(x);
92
+ if (ownOnly &&
93
+ x !== n &&
94
+ (ts.isFunctionDeclaration(x) ||
95
+ ts.isFunctionExpression(x) ||
96
+ ts.isArrowFunction(x)))
97
+ return;
98
+ ts.forEachChild(x, rec);
99
+ };
100
+ rec(n);
101
+ }
102
+ /** `a.b.c` as text, or undefined when the expression is not a dotted chain. */
103
+ export function dotted(e) {
104
+ const x = unwrap(e);
105
+ if (ts.isIdentifier(x))
106
+ return x.text;
107
+ if (ts.isPropertyAccessExpression(x)) {
108
+ const head = dotted(x.expression);
109
+ return head === undefined ? undefined : `${head}.${x.name.text}`;
110
+ }
111
+ return undefined;
112
+ }
113
+ /** The callee's last name: `ctx.db.patch` -> `patch`, `helper` -> `helper`. */
114
+ export function calleeName(c) {
115
+ const e = unwrap(c.expression);
116
+ if (ts.isIdentifier(e))
117
+ return e.text;
118
+ if (ts.isPropertyAccessExpression(e))
119
+ return e.name.text;
120
+ return undefined;
121
+ }
122
+ const BUILDERS = new Set([
123
+ "mutation",
124
+ "action",
125
+ "query",
126
+ "internalMutation",
127
+ "internalAction",
128
+ "internalQuery",
129
+ ]);
130
+ export function idTableOf(text) {
131
+ const m = /^\s*(?:v\.optional\(\s*)?v\.id\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/.exec(text);
132
+ return m?.[1];
133
+ }
134
+ /** The ONE table a validator's text references through `v.id("T")` — through
135
+ * `v.optional`, `v.array`, or a nested `v.object` — or undefined when it names
136
+ * none or several. */
137
+ export function singleIdTable(text) {
138
+ const ts_ = new Set([...text.matchAll(/v\.id\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g)].map((m) => m[1]));
139
+ return ts_.size === 1 ? [...ts_][0] : undefined;
140
+ }
141
+ /** The object literal of an `args:` validator: inline, `v.object({…})`, or a
142
+ * same-file top-level const naming either. */
143
+ function argsObject(p, e0) {
144
+ const e = unwrap(e0);
145
+ if (ts.isObjectLiteralExpression(e))
146
+ return e;
147
+ if (ts.isCallExpression(e) &&
148
+ calleeName(e) === "object" &&
149
+ e.arguments[0] &&
150
+ ts.isObjectLiteralExpression(unwrap(e.arguments[0])))
151
+ return unwrap(e.arguments[0]);
152
+ if (ts.isIdentifier(e)) {
153
+ for (const st of p.sf.statements) {
154
+ if (!ts.isVariableStatement(st))
155
+ continue;
156
+ for (const d of st.declarationList.declarations)
157
+ if (ts.isIdentifier(d.name) && d.name.text === e.text && d.initializer)
158
+ return argsObject(p, d.initializer);
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+ /** Every `export const NAME = <builder>({ … handler … })` of a file. */
164
+ export function registrations(p) {
165
+ const out = [];
166
+ for (const st of p.sf.statements) {
167
+ if (!ts.isVariableStatement(st))
168
+ continue;
169
+ for (const d of st.declarationList.declarations) {
170
+ if (!ts.isIdentifier(d.name) || !d.initializer)
171
+ continue;
172
+ const init = unwrap(d.initializer);
173
+ if (!ts.isCallExpression(init))
174
+ continue;
175
+ const b = calleeName(init);
176
+ if (b === undefined || !BUILDERS.has(b))
177
+ continue;
178
+ const cfg = init.arguments[0];
179
+ if (!cfg || !ts.isObjectLiteralExpression(cfg))
180
+ continue;
181
+ let handler;
182
+ const argIdTable = new Map();
183
+ for (const prop of cfg.properties) {
184
+ const key = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
185
+ if (key === "handler") {
186
+ if (ts.isMethodDeclaration(prop))
187
+ handler = prop;
188
+ else if (ts.isPropertyAssignment(prop)) {
189
+ const h = unwrap(prop.initializer);
190
+ if (ts.isArrowFunction(h) || ts.isFunctionExpression(h))
191
+ handler = h;
192
+ }
193
+ }
194
+ if (key === "args" && ts.isPropertyAssignment(prop)) {
195
+ const obj = argsObject(p, prop.initializer);
196
+ for (const a of obj?.properties ?? []) {
197
+ if (ts.isPropertyAssignment(a) && ts.isIdentifier(a.name)) {
198
+ const t = singleIdTable(a.initializer.getText(p.sf));
199
+ if (t)
200
+ argIdTable.set(a.name.text, t);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ if (handler === undefined)
206
+ continue;
207
+ out.push({
208
+ name: d.name.text,
209
+ builder: b,
210
+ rel: p.rel,
211
+ line: lineOf(d),
212
+ handler,
213
+ argIdTable,
214
+ });
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+ /** Every top-level `function f` / `const f = (…) => …` across the parsed files. */
220
+ export function indexFunctions(ps) {
221
+ const idx = new Map();
222
+ const add = (d) => {
223
+ const xs = idx.get(d.name) ?? [];
224
+ xs.push(d);
225
+ idx.set(d.name, xs);
226
+ };
227
+ for (const p of ps) {
228
+ for (const st of p.sf.statements) {
229
+ if (ts.isFunctionDeclaration(st) && st.name && st.body)
230
+ add({ name: st.name.text, rel: p.rel, line: lineOf(st), fn: st });
231
+ else if (ts.isVariableStatement(st))
232
+ for (const d of st.declarationList.declarations) {
233
+ const init = d.initializer && unwrap(d.initializer);
234
+ if (ts.isIdentifier(d.name) &&
235
+ init &&
236
+ (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
237
+ add({ name: d.name.text, rel: p.rel, line: lineOf(d), fn: init });
238
+ }
239
+ }
240
+ }
241
+ return idx;
242
+ }
243
+ /** Names of the plain-identifier functions a body calls. */
244
+ export function calledNames(fn) {
245
+ const out = new Set();
246
+ walk(fn, (x) => {
247
+ if (ts.isCallExpression(x)) {
248
+ const e = unwrap(x.expression);
249
+ if (ts.isIdentifier(e))
250
+ out.add(e.text);
251
+ }
252
+ });
253
+ return out;
254
+ }
255
+ /** A per-function boolean property closed over the call graph: `own(fn)` holds,
256
+ * or the function calls (by name) another function for which it holds. */
257
+ export function closeOverCalls(idx, own) {
258
+ const holds = new Set();
259
+ for (const [n, ds] of idx)
260
+ if (ds.some(own))
261
+ holds.add(n);
262
+ let grew = true;
263
+ while (grew) {
264
+ grew = false;
265
+ for (const [n, ds] of idx) {
266
+ if (holds.has(n))
267
+ continue;
268
+ if (ds.some((d) => [...calledNames(d.fn)].some((c) => holds.has(c)))) {
269
+ holds.add(n);
270
+ grew = true;
271
+ }
272
+ }
273
+ }
274
+ return holds;
275
+ }
276
+ // ---- schema ---------------------------------------------------------------
277
+ /** The tenant columns, as standard 2825122 §4 R-52 lists them:
278
+ * `orgId`/`tenantId`/`workspaceId`/`orgSlug` (vantage-peers stamps its agents
279
+ * under `orgSlug: client_org_mapping.clerkOrgSlug`). A `namespace` is NOT in this
280
+ * set: the standard counts it only when it is "checked against the caller's
281
+ * allowed prefixes", which is a property of the code around the table, not of
282
+ * the schema. `readSchema` records it apart (`SchemaTable.namespace`) and R-52
283
+ * decides it per table (cross-tenant-write.ts `namespaceDecisions`). */
284
+ export const TENANT_COLUMNS = new Set([
285
+ "orgId",
286
+ "tenantId",
287
+ "workspaceId",
288
+ "orgSlug",
289
+ ]);
290
+ /** The column that is a tenant column only where it is prefix-checked. */
291
+ export const NAMESPACE_COLUMN = "namespace";
292
+ /** Read every `name: defineTable({...})` of a schema file. */
293
+ export function readSchema(p) {
294
+ const out = new Map();
295
+ walk(p.sf, (x) => {
296
+ if (!ts.isPropertyAssignment(x) || !ts.isIdentifier(x.name))
297
+ return;
298
+ // the initializer is `defineTable({...})` possibly chained `.index(...)`.
299
+ let e = x.initializer;
300
+ for (;;) {
301
+ const u = unwrap(e);
302
+ if (ts.isCallExpression(u) &&
303
+ ts.isPropertyAccessExpression(u.expression)) {
304
+ e = u.expression.expression;
305
+ continue;
306
+ }
307
+ e = u;
308
+ break;
309
+ }
310
+ if (!ts.isCallExpression(e) ||
311
+ calleeName(e) !== "defineTable" ||
312
+ !e.arguments[0] ||
313
+ !ts.isObjectLiteralExpression(e.arguments[0]))
314
+ return;
315
+ const fields = [];
316
+ const idRef = new Map();
317
+ let tenant = "none";
318
+ let namespace = "none";
319
+ for (const prop of e.arguments[0].properties) {
320
+ if (!ts.isPropertyAssignment(prop))
321
+ continue;
322
+ const k = ts.isIdentifier(prop.name)
323
+ ? prop.name.text
324
+ : ts.isStringLiteral(prop.name)
325
+ ? prop.name.text
326
+ : undefined;
327
+ if (k === undefined)
328
+ continue;
329
+ fields.push(k);
330
+ const text = prop.initializer.getText(p.sf);
331
+ const t = idTableOf(text);
332
+ if (t)
333
+ idRef.set(k, t);
334
+ const opt = /^\s*v\.optional\(/.test(text);
335
+ if (TENANT_COLUMNS.has(k)) {
336
+ if (!opt)
337
+ tenant = "required";
338
+ else if (tenant === "none")
339
+ tenant = "optional";
340
+ }
341
+ if (k === NAMESPACE_COLUMN)
342
+ namespace = opt ? "optional" : "required";
343
+ }
344
+ out.set(x.name.text, {
345
+ name: x.name.text,
346
+ tenant,
347
+ namespace,
348
+ fields,
349
+ idRef,
350
+ });
351
+ });
352
+ return out;
353
+ }
@@ -1,3 +1,4 @@
1
+ import { siteCount, withCount } from "../../violation-count.js";
1
2
  /** The standard both wave-2 files are pinned against (short hash a9d7877b). */
2
3
  export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
3
4
  export function emptyScan() {
@@ -45,12 +46,12 @@ export function decide(ruleId, id, check, scan, emptyReason) {
45
46
  // verdict is otherwise the only one printed without an address (Eta REVISE
46
47
  // blocker 1: R-51 CRM could-not-judge=1 was counted but never located). The
47
48
  // data is already in scan.cnj; list it under its own label whenever non-empty.
48
- return {
49
+ return withCount({
49
50
  id,
50
51
  anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
51
52
  state: "non-conforming",
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)}.` : ""}`,
53
- };
53
+ verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (not decided; each names why): ${fmt(scan.cnj)}.` : ""}`,
54
+ }, siteCount(scan.violate));
54
55
  }
55
56
  if (nJ > 0) {
56
57
  const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
@@ -1,3 +1,4 @@
1
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
1
2
  import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
3
  // A schema definition file — where defineTable declarations live.
3
4
  const SCHEMA_FILE = /schema\.ts$/;
@@ -73,7 +74,15 @@ export function derivedStructures(files) {
73
74
  if (FIELD_LINE.test(l.raw))
74
75
  fields += 1;
75
76
  const id = ID_REF.exec(l.raw);
76
- if (id && !fkTarget)
77
+ // A SYSTEM table (`_storage`, `_scheduled_functions` — Convex reserves the
78
+ // leading underscore) is not a source a migration can backfill FROM: its
79
+ // rows are platform objects, not domain rows, and there is no
80
+ // `ctx.db.query("_storage")` that enumerates what the write path would
81
+ // have bound. A table whose only foreign key points at a system table
82
+ // (a TOFU ownership binding of uploaded blobs, kbUploads) is therefore
83
+ // not a derived structure in R-44's sense. The first NON-system FK is
84
+ // the source; a table with none is skipped.
85
+ if (id && !fkTarget && !id[1].startsWith("_"))
77
86
  fkTarget = id[1];
78
87
  if (HAS_INDEX.test(l.raw))
79
88
  hasIndex = true;
@@ -96,7 +105,9 @@ export function scanR44(files) {
96
105
  let directInsert = false;
97
106
  let loopsSource = false;
98
107
  let referenced = false;
108
+ let migText = "";
99
109
  for (const f of migFiles) {
110
+ migText += `${f.lines.map((l) => l.raw).join("\n")}\n`;
100
111
  for (const l of f.lines) {
101
112
  if (!code(l))
102
113
  continue;
@@ -118,18 +129,39 @@ export function scanR44(files) {
118
129
  });
119
130
  }
120
131
  else if (directInsert) {
121
- scan.cnj.push({
122
- rel: d.rel,
123
- n: d.n,
124
- note: `derived structure ${d.table} has a migration inserting it, but no query/paginate of the source table ${d.source} is visible — coverage of existing source rows is not statically decidable (name the migration site)`,
125
- });
132
+ // judgement retired (task k179jnhx, §23): flagged (no visible query/
133
+ // paginate of the source table); decides on the migration file's written
134
+ // JUSTIFIÉ:/JUSTIFIED: token, never a person's opinion of coverage.
135
+ if (hasJustificationTokenInText(migText)) {
136
+ scan.conform.push({
137
+ rel: d.rel,
138
+ n: d.n,
139
+ note: `derived structure ${d.table} has a migration inserting it; no query/paginate of ${d.source} is visible, but the migration file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
140
+ });
141
+ }
142
+ else {
143
+ scan.violate.push({
144
+ rel: d.rel,
145
+ n: d.n,
146
+ note: `derived structure ${d.table} has a migration inserting it, but no query/paginate of the source table ${d.source} is visible, and no written JUSTIFIÉ:/JUSTIFIED: token in the migration file — coverage of existing source rows owes a fix or a recorded justification`,
147
+ });
148
+ }
126
149
  }
127
150
  else if (referenced) {
128
- scan.cnj.push({
129
- rel: d.rel,
130
- n: d.n,
131
- note: `derived structure ${d.table} has a backfill migration NAMED for it, but it writes the structure via a helper (no direct ctx.db.insert("${d.table}") in the migration) — its idempotence/coverage of existing source rows is not statically decidable (human verifies)`,
132
- });
151
+ if (hasJustificationTokenInText(migText)) {
152
+ scan.conform.push({
153
+ rel: d.rel,
154
+ n: d.n,
155
+ note: `derived structure ${d.table} has a backfill migration NAMED for it, writing the structure via a helper; the migration file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
156
+ });
157
+ }
158
+ else {
159
+ scan.violate.push({
160
+ rel: d.rel,
161
+ n: d.n,
162
+ note: `derived structure ${d.table} has a backfill migration NAMED for it, but it writes the structure via a helper (no direct ctx.db.insert("${d.table}") in the migration), and no written JUSTIFIÉ:/JUSTIFIED: token in the migration file — idempotence/coverage owes a fix or a recorded justification`,
163
+ });
164
+ }
133
165
  }
134
166
  else {
135
167
  scan.violate.push({
@@ -146,10 +178,12 @@ export const R44 = srcDetector("R-44", "R-44 a derived structure carries a same-
146
178
  // R-45 — a derived structure carries a test whose fixture bypasses the write
147
179
  // path and brackets the migration.
148
180
  // ===========================================================================
181
+ const R45_TOKEN_WINDOW = 6;
149
182
  export function scanR45(files) {
150
183
  const scan = emptyScan();
151
184
  const derived = derivedStructures(files);
152
185
  const testFiles = files.filter(isTest);
186
+ const byRel = new Map(files.map((f) => [f.rel, f]));
153
187
  for (const d of derived) {
154
188
  const insertSourceRe = INSERT_OF(d.source);
155
189
  const stem = d.table.toLowerCase();
@@ -192,13 +226,32 @@ export function scanR45(files) {
192
226
  });
193
227
  }
194
228
  else {
195
- // No test reaches the subject at all — the test-independence property
196
- // cannot be judged. A could-not-judge work-item, never a silent green.
197
- scan.cnj.push({
198
- rel: d.rel,
199
- n: d.n,
200
- note: `derived structure ${d.table}: no test constructs its source row ${d.source} without the write path (and no create-only fixture to mark red) — its fixture-independence is not statically decidable; a human must verify a test brackets the migration before AND after`,
201
- });
229
+ // No test reaches the subject at all — flagged. judgement retired
230
+ // (task k179jnhx, §23): the schema declaration's comment window is this
231
+ // site's remediation cell. No JUSTIFIÉ:/JUSTIFIED: token → non-conforming;
232
+ // token present → present, decided by the token's presence, never a
233
+ // person's opinion of whether the untested state is sound.
234
+ const declFile = byRel.get(d.rel);
235
+ const window = declFile
236
+ ? declFile.lines
237
+ .slice(Math.max(0, d.n - 1 - R45_TOKEN_WINDOW), d.n - 1 + R45_TOKEN_WINDOW)
238
+ .map((l) => l.raw)
239
+ .join("\n")
240
+ : "";
241
+ if (hasJustificationTokenInText(window)) {
242
+ scan.conform.push({
243
+ rel: d.rel,
244
+ n: d.n,
245
+ note: `derived structure ${d.table}: no test constructs its source row ${d.source} without the write path, but the schema declaration's comment window carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
246
+ });
247
+ }
248
+ else {
249
+ scan.violate.push({
250
+ rel: d.rel,
251
+ n: d.n,
252
+ note: `derived structure ${d.table}: no test constructs its source row ${d.source} without the write path (and no create-only fixture to mark red), and no written JUSTIFIÉ:/JUSTIFIED: token near its schema declaration — fixture-independence owes a test or a recorded justification`,
253
+ });
254
+ }
202
255
  }
203
256
  }
204
257
  return scan;
@@ -18,6 +18,8 @@
18
18
  // zero-applicable-site abstain that resolves to could-not-judge WITH a reason.
19
19
  import { existsSync } from "node:fs";
20
20
  import { join } from "node:path";
21
+ import { siteCount, withCount } from "../../violation-count.js";
22
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
21
23
  /** The standard all four are pinned against (short hash a9d7877b). */
22
24
  export const SOURCE_CONTRACT_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
23
25
  function emptyScan() {
@@ -73,12 +75,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
73
75
  const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
74
76
  const fmt = (s) => s.slice(0, 3).map((x) => `${x.rel}:${x.n} (${x.note})`).join("; ");
75
77
  if (nV > 0) {
76
- return {
78
+ return withCount({
77
79
  id,
78
80
  anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
79
81
  state: "non-conforming",
80
82
  verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
81
- };
83
+ }, siteCount(scan.violate));
82
84
  }
83
85
  if (nJ > 0) {
84
86
  const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`;
@@ -320,15 +322,19 @@ export function scanR25(files) {
320
322
  // authority-tier / boundary-key declaration: rag has only an import comment; the
321
323
  // agentEngine block documents durability/versioning ("long-task survival",
322
324
  // "Pinned exactly at 0.1.0-alpha.2"), not an isolation contract. Per §8 an
323
- // undeclared seam is non-conforming as an app-owned table with a blank field —
324
- // but the DISPOSITION (whether the library's tables are in fact used
325
- // fleet-internal/master only, which would justify the silence) is human. So the
326
- // detector flags an undeclared mount as could-not-judge WITH a reason (never a
327
- // silent pass); a mount whose window declares an authority tier + boundary key
328
- // is conform.
325
+ // undeclared seam is non-conforming as an app-owned table with a blank field.
326
+ // judgement retired (task k179jnhx, §23): whether the library's tables are in
327
+ // fact used fleet-internal/master only (which would justify the silence) is no
328
+ // longer a human disposition — it decides on the PRESENCE of a written
329
+ // JUSTIFIÉ:/JUSTIFIED: token in the seam's comment window (that window IS this
330
+ // site's remediation cell, the same mechanism R-7/R-8 use on an oracle row). No
331
+ // token → non-conforming; token present → present. could-not-judge is reserved
332
+ // for a seam the instrument could not read, never reached here (a parsed
333
+ // app.use() call always resolves to one of the two).
329
334
  // PATTERN: each `app.use(<component>)` line in a *.config.ts seam is conform iff its
330
335
  // surrounding window (comments included — the seam contract is documented prose)
331
- // declares an authority tier + boundary key; else could-not-judge.
336
+ // declares an authority tier + boundary key, OR carries a written justification
337
+ // token; else non-conforming.
332
338
  // ---------------------------------------------------------------------------
333
339
  const MOUNT_CALL = /app\.use\s*\(\s*(\w+)/;
334
340
  const SEAM_CONTRACT = /authority tier|boundary key|isolation contract|org-scoped|fleet-internal|master-only|namespace-bound|tenant boundary/i;
@@ -350,14 +356,16 @@ export function scanR26(files) {
350
356
  continue;
351
357
  const comp = m[1];
352
358
  // seam contract is documented as prose — scan ALL lines in the window.
353
- const declared = f.lines
354
- .slice(Math.max(0, i - R26_WINDOW), i + R26_WINDOW)
355
- .some((x) => SEAM_CONTRACT.test(x.raw));
359
+ const window = f.lines.slice(Math.max(0, i - R26_WINDOW), i + R26_WINDOW);
360
+ const declared = window.some((x) => SEAM_CONTRACT.test(x.raw));
356
361
  if (declared) {
357
362
  scan.conform.push({ rel: f.rel, n: l.n, note: `component "${comp}" mount declares an authority tier + boundary key at the seam` });
358
363
  }
364
+ else if (hasJustificationTokenInText(window.map((x) => x.raw).join("\n"))) {
365
+ scan.conform.push({ rel: f.rel, n: l.n, note: `component "${comp}" mounted with no stated authority tier / boundary key, but the seam's comment window carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed` });
366
+ }
359
367
  else {
360
- scan.cnj.push({ rel: f.rel, n: l.n, note: `component "${comp}" mounted with no stated authority tier / boundary key at the seam — usable fleet-internal/master only; org-scoped disposition needs a human ruling (§8)` });
368
+ scan.violate.push({ rel: f.rel, n: l.n, note: `component "${comp}" mounted with no stated authority tier / boundary key at the seam, and no written JUSTIFIÉ:/JUSTIFIED: token in the seam's comment window — usable fleet-internal/master only; §8 owes a fix or a recorded justification` });
361
369
  }
362
370
  }
363
371
  }
@@ -0,0 +1,46 @@
1
+ import * as ts from "typescript";
2
+ import type { Detector } from "../../engine-types.js";
3
+ import { type FnDef, type Parsed, type SchemaTable, type SrcFile } from "./_ast.js";
4
+ import { type Scan, type SourceDetectorEntry } from "./_scan.js";
5
+ export declare const R52_ID = "R-52 a write reaching a row with no tenant value through a caller-choosable value checks the target's tenant first";
6
+ export declare function fleetOnlyExpr(e0: ts.Expression, isFleetHelper: (name: string) => boolean, isFleetLocal?: (name: string) => boolean): boolean;
7
+ export interface R52Facts {
8
+ parsed: Parsed[];
9
+ schema: Map<string, SchemaTable> | undefined;
10
+ schemaRel: string | undefined;
11
+ callerChosen: Map<string, Set<string>>;
12
+ fns: Map<string, FnDef[]>;
13
+ readsTenant: Set<string>;
14
+ refusing: Set<string>;
15
+ /** helpers every truthy return of which is a fleet-scope decision. */
16
+ fleetOnly: Set<string>;
17
+ fleetRefusal: Set<string>;
18
+ singleTable: Map<string, string>;
19
+ /** helpers that hand their table argument to `db.normalizeId`. */
20
+ normalizers: Set<string>;
21
+ /** per table carrying a `namespace` and no required tenant column: whether
22
+ * that namespace is checked against the caller's allowed prefixes. */
23
+ namespace: Map<string, NamespaceDecision>;
24
+ }
25
+ /** Standard 2825122 R-52 counts a `namespace` as a tenant column only when it is
26
+ * "checked against the caller's allowed prefixes". Decided per table from the
27
+ * public doors (query/mutation/action) that read or write it:
28
+ * checked every door runs a prefix check (or refuses every non-master);
29
+ * unchecked no door does — the namespace is a label, the table is untenanted;
30
+ * undecided some do and some do not, or no public door reaches the table —
31
+ * every site targeting it is could-not-judge, never green. */
32
+ export interface NamespaceDecision {
33
+ verdict: "checked" | "unchecked" | "undecided";
34
+ why: string;
35
+ }
36
+ export declare function r52Facts(files: SrcFile[]): R52Facts;
37
+ export interface R52Detail {
38
+ scan: Scan;
39
+ /** tables carrying no tenant value (none, or optional), as read from schema. */
40
+ untenanted: string[];
41
+ schemaRel: string | undefined;
42
+ }
43
+ export declare function scanR52(files: SrcFile[]): Scan;
44
+ export declare function scanR52Detailed(files: SrcFile[]): R52Detail;
45
+ export declare const R52: Detector;
46
+ export declare const SOURCE_CROSS_TENANT_WRITE_DETECTORS: SourceDetectorEntry[];