@vantageos/backend-doctor 0.2.0 → 0.4.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 (70) hide show
  1. package/README.md +57 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/credential-hygiene.d.ts +11 -0
  22. package/dist/detectors/source/credential-hygiene.js +619 -0
  23. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  24. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  25. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  26. package/dist/detectors/source/guard-helper-flow.js +49 -6
  27. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  28. package/dist/detectors/source/identity-by-id.js +1783 -0
  29. package/dist/detectors/source/identity.d.ts +8 -1
  30. package/dist/detectors/source/identity.js +364 -35
  31. package/dist/detectors/source/jobs.js +13 -2
  32. package/dist/detectors/source/lifecycle.js +14 -2
  33. package/dist/detectors/source/local-identity.d.ts +26 -0
  34. package/dist/detectors/source/local-identity.js +666 -0
  35. package/dist/detectors/source/narrow-then-bound.d.ts +11 -0
  36. package/dist/detectors/source/narrow-then-bound.js +571 -0
  37. package/dist/detectors/source/package-principal.d.ts +9 -0
  38. package/dist/detectors/source/package-principal.js +177 -0
  39. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  40. package/dist/detectors/source/r6-shapes.js +285 -0
  41. package/dist/detectors/source/readbudget.js +291 -0
  42. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  43. package/dist/detectors/source/recipient-by-id.js +1108 -0
  44. package/dist/detectors/source/refusal.d.ts +14 -0
  45. package/dist/detectors/source/refusal.js +75 -2
  46. package/dist/detectors/source/security.js +455 -26
  47. package/dist/detectors/source/service-account-claim.d.ts +15 -0
  48. package/dist/detectors/source/service-account-claim.js +491 -0
  49. package/dist/detectors/source/writesafety.js +27 -14
  50. package/dist/detectors/standard/_doc.d.ts +1 -1
  51. package/dist/detectors/standard/_doc.js +5 -4
  52. package/dist/detectors/standard/r41-frontier.js +3 -2
  53. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  54. package/dist/detectors/structural.d.ts +6 -7
  55. package/dist/detectors/structural.js +75 -43
  56. package/dist/not-checkable.d.ts +1 -1
  57. package/dist/not-checkable.js +12 -0
  58. package/dist/registry.d.ts +1 -1
  59. package/dist/registry.js +55 -3
  60. package/dist/rule-class.d.ts +85 -0
  61. package/dist/rule-class.js +218 -0
  62. package/dist/tally.d.ts +13 -5
  63. package/dist/tally.js +14 -6
  64. package/dist/violation-count.d.ts +48 -0
  65. package/dist/violation-count.js +94 -0
  66. package/fixtures/backend-standard-2825122.md +722 -0
  67. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  68. package/fixtures/backend-standard-bacc54a.md +785 -0
  69. package/fixtures/rule-classes-bacc54a.json +244 -0
  70. package/package.json +1 -1
@@ -80,6 +80,289 @@ function readBoundFor(all, n, end) {
80
80
  }
81
81
  return undefined;
82
82
  }
83
+ // ---- the CALLER-DRIVEN drain ------------------------------------------------
84
+ //
85
+ // A second drain shape R-31 can judge statically: the mutation reads exactly ONE
86
+ // page with `.paginate(...)` and returns that page's continuation to its caller —
87
+ // BOTH the cursor (`continueCursor`, under any field name) and the done flag
88
+ // (`isDone`, under any field name) — and the caller re-invokes it with the cursor
89
+ // until done. Each page is one transaction, visible and resumable from its cursor
90
+ // (vantage-peers `convex/migrations/backfill_org_stamp.ts`). It conforms only when
91
+ // the page result flows to ONE return statement carrying both fields; a dropped
92
+ // cursor, a missing done flag, or the two split across different returns stay
93
+ // could-not-judge. A `.take(n)` has no continuation and is never this shape.
94
+ const PAGINATE = /\.paginate\s*\(/;
95
+ const IDENT = "[A-Za-z_$][\\w$]*";
96
+ // `const page = await ….paginate(` — the whole page result bound to one local.
97
+ const PAGINATE_BIND = new RegExp(`\\b(?:const|let|var)\\s+(${IDENT})\\s*=\\s*(?:await\\s+)?[^;{]*\\.paginate\\s*\\(`);
98
+ // `const { page, isDone, continueCursor: cur } = await ….paginate(`.
99
+ const PAGINATE_DESTRUCT = /\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+)?[^;{]*\.paginate\s*\(/;
100
+ // `return [await] ctx.db.query(…)….paginate(` — the page result returned whole.
101
+ const PAGINATE_RETURNED = new RegExp(`\\breturn\\s+(?:await\\s+)?${IDENT}[^;{]*\\.paginate\\s*\\(`);
102
+ const escapeRe = (x) => x.replace(/[$]/g, "\\$");
103
+ /** A bare reference to local `name` in `text`: not a member (`x.name`), not part
104
+ * of a longer identifier, and not an object KEY (`{ name: …`, `, name: …`). */
105
+ function refersTo(text, name) {
106
+ const re = new RegExp(`(?<![.\\w$])${escapeRe(name)}(?![\\w$])`, "g");
107
+ for (let m = re.exec(text); m; m = re.exec(text)) {
108
+ const before = text.slice(0, m.index).trimEnd();
109
+ const after = text.slice(m.index + name.length).trimStart();
110
+ const keyPos = (before.endsWith("{") || before.endsWith(",")) &&
111
+ after.startsWith(":") &&
112
+ !after.startsWith("::");
113
+ if (!keyPos)
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ // `(…) {` opens a block, not a function body, after these keywords.
119
+ const BLOCK_PAREN_KEYWORDS = new Set([
120
+ "if",
121
+ "for",
122
+ "while",
123
+ "switch",
124
+ "catch",
125
+ "with",
126
+ ]);
127
+ /** The handler's OWN returns in a mutation block (origin: Argus REVISE #54, P1).
128
+ * A `return` inside a nested arrow function, `function`, or method body belongs to
129
+ * that function — a callback's return is not what the mutation hands its caller.
130
+ * Braces are classified while walking the block's code (strings and comments
131
+ * skipped): a `{` after `=>`, after the `)` of a parameter list (not an
132
+ * `if`/`for`/`while`/`switch`/`catch` head), or after a `function` keyword opens a
133
+ * FUNCTION body. The first function body is the handler; a return counts only when
134
+ * exactly one function body encloses it. */
135
+ function handlerReturns(lines) {
136
+ let text = "";
137
+ const lineAt = [];
138
+ for (const l of lines) {
139
+ if (!code(l))
140
+ continue;
141
+ for (const ch of `${l.raw}\n`) {
142
+ text += ch;
143
+ lineAt.push(l.n);
144
+ }
145
+ }
146
+ // blank comments; mark string bodies as non-structural.
147
+ const chars = text.split("");
148
+ const quoted = new Uint8Array(chars.length);
149
+ for (let i = 0; i < chars.length; i++) {
150
+ const ch = chars[i];
151
+ if (ch === "/" && chars[i + 1] === "/") {
152
+ while (i < chars.length && chars[i] !== "\n")
153
+ chars[i++] = " ";
154
+ }
155
+ else if (ch === "/" && chars[i + 1] === "*") {
156
+ while (i < chars.length && !(chars[i] === "*" && chars[i + 1] === "/")) {
157
+ if (chars[i] !== "\n")
158
+ chars[i] = " ";
159
+ i++;
160
+ }
161
+ if (i < chars.length)
162
+ chars[i] = " ";
163
+ if (i + 1 < chars.length)
164
+ chars[++i] = " ";
165
+ }
166
+ else if (ch === '"' || ch === "'" || ch === "`") {
167
+ for (i++; i < chars.length && chars[i] !== ch; i++) {
168
+ quoted[i] = 1;
169
+ if (chars[i] === "\\")
170
+ quoted[++i] = 1;
171
+ }
172
+ }
173
+ }
174
+ const clean = chars.join("");
175
+ /** From the `return` at `at`, its text up to the `;` closing it or the brace
176
+ * closing its block, whichever comes first. */
177
+ const sliceReturn = (at) => {
178
+ let nest = 0;
179
+ let i = at;
180
+ for (; i < clean.length; i++) {
181
+ if (quoted[i])
182
+ continue;
183
+ const ch = clean[i];
184
+ if (ch === "(" || ch === "{" || ch === "[")
185
+ nest += 1;
186
+ else if (ch === ")" || ch === "}" || ch === "]") {
187
+ if (nest === 0)
188
+ break;
189
+ nest -= 1;
190
+ }
191
+ else if (ch === ";" && nest === 0) {
192
+ i += 1;
193
+ break;
194
+ }
195
+ }
196
+ return clean.slice(at, i).replace(/\s+/g, " ").trim();
197
+ };
198
+ const out = [];
199
+ const braces = []; // true = a function body
200
+ const parens = []; // the word before each open `(`
201
+ let fnDepth = 0;
202
+ let prev = ""; // the previous significant token
203
+ let closedParenWord = "";
204
+ let pendingFunction = false;
205
+ let returnType = false; // a `)` of a parameter list followed by `:` — a typed signature
206
+ let lastWord = "";
207
+ for (let i = 0; i < clean.length; i++) {
208
+ if (quoted[i])
209
+ continue;
210
+ const ch = clean[i];
211
+ if (/\s/.test(ch))
212
+ continue;
213
+ if (/[A-Za-z_$]/.test(ch)) {
214
+ let j = i;
215
+ while (j < clean.length && !quoted[j] && /[\w$]/.test(clean[j]))
216
+ j++;
217
+ const word = clean.slice(i, j);
218
+ const isMember = i > 0 && clean[i - 1] === ".";
219
+ if (!isMember && word === "function")
220
+ pendingFunction = true;
221
+ if (!isMember && word === "return" && fnDepth === 1) {
222
+ out.push({ line: lineAt[i], text: sliceReturn(i) });
223
+ }
224
+ lastWord = word;
225
+ prev = "word";
226
+ i = j - 1;
227
+ continue;
228
+ }
229
+ if (ch === "(") {
230
+ parens.push(prev === "word" ? lastWord : "");
231
+ prev = "(";
232
+ }
233
+ else if (ch === ")") {
234
+ closedParenWord = parens.pop() ?? "";
235
+ prev = ")";
236
+ }
237
+ else if (ch === "{") {
238
+ const fn = prev === "=>" ||
239
+ pendingFunction ||
240
+ returnType ||
241
+ (prev === ")" && !BLOCK_PAREN_KEYWORDS.has(closedParenWord));
242
+ returnType = false;
243
+ if (fn) {
244
+ pendingFunction = false;
245
+ fnDepth += 1;
246
+ }
247
+ braces.push(fn);
248
+ prev = "{";
249
+ }
250
+ else if (ch === "}") {
251
+ if (braces.pop())
252
+ fnDepth -= 1;
253
+ prev = "}";
254
+ }
255
+ else if (ch === "=" && clean[i + 1] === ">") {
256
+ prev = "=>";
257
+ returnType = false;
258
+ i += 1;
259
+ }
260
+ else if (ch === ":" && prev === ")" && !BLOCK_PAREN_KEYWORDS.has(closedParenWord)) {
261
+ returnType = true;
262
+ prev = ch;
263
+ }
264
+ else if (ch === ";") {
265
+ returnType = false;
266
+ prev = ch;
267
+ }
268
+ else {
269
+ prev = ch;
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+ /** The handler's own returns that start inside the given statements. */
275
+ function returnsOf(stmts, own) {
276
+ return own
277
+ .filter((r) => stmts.some((st) => r.line >= st.n && r.line <= st.end))
278
+ .map((r) => r.text);
279
+ }
280
+ // A call that runs another function INSIDE this transaction.
281
+ // `ctx.runMutation(`, `ctx["runMutation"](`, or a destructured `runMutation(`.
282
+ const RUN_IN_TXN = /\brun(?:Mutation|Action)["'`]?\s*\]?\s*\(/;
283
+ /** True when a statement after the read hands the page's cursor to, or names the
284
+ * mutation itself in, a `ctx.runMutation` / `ctx.runAction` call (origin: Argus
285
+ * REVISE #54, P3). That walks the pages inside ONE transaction — the drain is not
286
+ * caller-driven, whatever the mutation returns afterwards. */
287
+ function selfDrains(later, self, carriesCursor) {
288
+ const selfRef = new RegExp(`\\.\\s*${escapeRe(self)}(?![\\w$])`);
289
+ // a local bound to the cursor carries it too: `const next = page.continueCursor`
290
+ const aliases = [];
291
+ for (const x of later) {
292
+ const d = /^\s*(?:const|let|var)\s+([\w$]+)\s*=/.exec(x.text);
293
+ if (d && carriesCursor(x.text.slice(d[0].length)))
294
+ aliases.push(d[1]);
295
+ }
296
+ const carries = (t) => carriesCursor(t) || aliases.some((a) => new RegExp(`(?<![\\w$.])${escapeRe(a)}(?![\\w$])`).test(t));
297
+ return later.some((x) => {
298
+ const m = RUN_IN_TXN.exec(x.text);
299
+ if (!m)
300
+ return false;
301
+ const call = x.text.slice(m.index);
302
+ return selfRef.test(call) || carries(call);
303
+ });
304
+ }
305
+ /** True when the paginated read in `st` flows to a return statement (among the
306
+ * statements after it in the mutation) carrying BOTH its continuation cursor and
307
+ * its done flag. */
308
+ function returnsContinuation(st, later, own, self) {
309
+ if (!PAGINATE.test(st.text))
310
+ return false;
311
+ if (PAGINATE_RETURNED.test(st.text)) {
312
+ // the read itself is the handler's own return, never a callback's.
313
+ return (own.some((r) => r.line >= st.n && r.line <= st.end) &&
314
+ !selfDrains(later, self, () => false));
315
+ }
316
+ // the returns this read can reach: up to the next statement that re-declares
317
+ // one of its locals (a second `const page = …` in another branch is another read).
318
+ const until = (locals) => {
319
+ const names = locals.filter((x) => x !== undefined);
320
+ const cut = later.findIndex((x) => names.some((nm) => new RegExp(`\\b(?:const|let|var)\\s*(?:\\{[^}]*)?(?<![\\w$])${escapeRe(nm)}(?![\\w$])`).test(x.text)));
321
+ return returnsOf(cut < 0 ? later : later.slice(0, cut), own);
322
+ };
323
+ const bound = PAGINATE_BIND.exec(st.text)?.[1];
324
+ if (bound !== undefined) {
325
+ const rets = until([bound]);
326
+ const b = escapeRe(bound);
327
+ const cursor = new RegExp(`(?<![.\\w$])${b}\\s*\\.\\s*continueCursor\\b`);
328
+ const done = new RegExp(`(?<![.\\w$])${b}\\s*\\.\\s*isDone\\b`);
329
+ const whole = new RegExp(`^return\\s+${b}\\s*;?\\s*$|\\.\\.\\.\\s*${b}(?![\\w$]|\\s*[.[])`);
330
+ if (selfDrains(later, self, (t) => cursor.test(t)))
331
+ return false;
332
+ return rets.some((r) => whole.test(r.trim()) || (cursor.test(r) && done.test(r)));
333
+ }
334
+ const destruct = PAGINATE_DESTRUCT.exec(st.text)?.[1];
335
+ if (destruct !== undefined) {
336
+ let cursorLocal;
337
+ let doneLocal;
338
+ let rest;
339
+ for (const part of destruct.split(",")) {
340
+ const p = part.trim();
341
+ if (p.startsWith("...")) {
342
+ rest = p.slice(3).trim();
343
+ continue;
344
+ }
345
+ const m = new RegExp(`^(${IDENT})\\s*(?::\\s*(${IDENT}))?`).exec(p);
346
+ if (!m)
347
+ continue;
348
+ const local = m[2] ?? m[1];
349
+ if (m[1] === "continueCursor")
350
+ cursorLocal = local;
351
+ if (m[1] === "isDone")
352
+ doneLocal = local;
353
+ }
354
+ const rets = until([cursorLocal, doneLocal, rest]);
355
+ const carries = (r, local, field) => local !== undefined
356
+ ? refersTo(r, local)
357
+ : rest !== undefined &&
358
+ new RegExp(`(?<![.\\w$])${escapeRe(rest)}\\s*\\.\\s*${field}\\b`).test(r);
359
+ if (selfDrains(later, self, (t) => carries(t, cursorLocal, "continueCursor")))
360
+ return false;
361
+ return rets.some((r) => carries(r, cursorLocal, "continueCursor") &&
362
+ carries(r, doneLocal, "isDone"));
363
+ }
364
+ return false;
365
+ }
83
366
  /** From the mutation opener at index `i`, delimit its body by matching braces.
84
367
  * Returns the block's lines and whether it closed cleanly. */
85
368
  function mutationBlock(lines, i) {
@@ -136,6 +419,7 @@ export function scanR31(files) {
136
419
  .map((l) => l.raw)
137
420
  .join("\n");
138
421
  const reschedules = RESCHEDULE.test(blockText);
422
+ const own = handlerReturns(block.lines);
139
423
  for (const st of stmts) {
140
424
  if (COLLECT.test(st.text) && !BOUNDED.test(st.text)) {
141
425
  const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
@@ -176,6 +460,13 @@ export function scanR31(files) {
176
460
  note: `${fn}: bounded read on "${table}" via \`.take(n)\`/\`.paginate\` with a \`ctx.scheduler.runAfter\` self-reschedule — the read stays within the per-transaction read budget across the drain (R-31 conforming)`,
177
461
  });
178
462
  }
463
+ else if (returnsContinuation(st, stmts.filter((x) => x.n > st.n), own, fn)) {
464
+ scan.conform.push({
465
+ rel: f.rel,
466
+ n: st.n,
467
+ note: `${fn}: bounded read on "${table}" via \`.paginate\` — caller-driven drain: the page's continuation (cursor + isDone) is returned to the caller, which re-invokes the mutation with the cursor until done; each page is one transaction, visible and resumable (R-31 conforming)`,
468
+ });
469
+ }
179
470
  else {
180
471
  scan.cnj.push({
181
472
  rel: f.rel,
@@ -0,0 +1,40 @@
1
+ import { type FnDef, type Parsed } from "./_ast.js";
2
+ /** Argument fields that NAME the recipient / target agent of a write. */
3
+ export declare const RECIPIENT_ARGS: ReadonlySet<string>;
4
+ /** Stored fields that ROUTE a row to its recipient. */
5
+ export declare const ROUTING_FIELDS: ReadonlySet<string>;
6
+ /** Argument fields that NAME the reader / owner whose data a door selects. */
7
+ export declare const READER_ARGS: ReadonlySet<string>;
8
+ /** Stored agent-identity fields: keying or matching one with a typed value
9
+ * SELECTS an agent's data by name, whatever the argument is called. */
10
+ export declare const SELECTOR_FIELDS: ReadonlySet<string>;
11
+ /** Fields that route (or select an agent) only on a named table — the bare
12
+ * word is an author elsewhere (`diary.orchestrator`), so it is declared here
13
+ * and never added to the global sets. */
14
+ export declare const ROUTING_BY_TABLE: ReadonlyMap<string, ReadonlySet<string>>;
15
+ export interface RecipientSite {
16
+ rel: string;
17
+ n: number;
18
+ note: string;
19
+ }
20
+ export declare class RecipientFlow {
21
+ private readonly parsed;
22
+ private readonly fns;
23
+ private readonly relOf;
24
+ private readonly done;
25
+ private readonly queue;
26
+ readonly sites: Map<string, RecipientSite>;
27
+ constructor(parsed: Parsed[], fns: Map<string, FnDef[]>);
28
+ run(): RecipientSite[];
29
+ private key;
30
+ /** Analyse one function under seeded parameters; returns its return taint. */
31
+ private analyse;
32
+ /** A field of an obj taint: a door's recipient argument, or a carried name. */
33
+ private field;
34
+ /** A function declared INSIDE `unit` (shares its environment). */
35
+ private localFn;
36
+ /** Module-level definitions a callee names: same file first, else by name. */
37
+ private defsFor;
38
+ }
39
+ /** Every recipient-by-name site reachable from a convex/ write door. */
40
+ export declare function recipientByNameSites(parsed: Parsed[], fns: Map<string, FnDef[]>): RecipientSite[];