@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
@@ -1,6 +1,16 @@
1
1
  import type { Detector } from "../../engine-types.js";
2
- import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
2
+ import { type Scan, type Site, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare const SCOPE_REFUSED_RETURN: RegExp;
4
+ export declare function noOrgEarlyReturn(text: string): boolean;
5
+ /** R-50's scan plus the public queries it found OUTSIDE its scope (no org-throw,
6
+ * no resolver, no marker — standard 4bcd605 :653). The out-of-scope list never
7
+ * reaches a verdict; it only words the empty-subject abstain truthfully. */
8
+ export interface R50Scan {
9
+ scan: Scan;
10
+ outOfScope: Site[];
11
+ }
3
12
  export declare function scanR50(files: SrcFile[]): Scan;
13
+ export declare function scanR50Detailed(files: SrcFile[]): R50Scan;
4
14
  export declare const R50: Detector;
5
15
  /** In the form the orchestrator spreads into REGISTRY. */
6
16
  export declare const SOURCE_ISOLATION_DETECTORS: SourceDetectorEntry[];
@@ -1,4 +1,4 @@
1
- import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
1
+ import { code, decide, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
2
2
  // A public reactive read is DEFINED, not read. R-50 governs a query DEFINITION —
3
3
  // `export const NAME = <builder>({ ... })` — never a `ctx.db.query(...)` table READ
4
4
  // inside a handler (the pre-fix opener `/\bquery\(/` matched `.query(` reads
@@ -194,6 +194,54 @@ function namedValueDomination(before) {
194
194
  }
195
195
  return false;
196
196
  }
197
+ // Form C (VP shape — the typed-empty PRE-ORG path). `withOrgScope(ctx, {
198
+ // refuseWithoutThrow: true })` marks a signed-in caller with no organisation as
199
+ // `scope.refused` instead of throwing; the handler then answers that caller with a
200
+ // typed value BEFORE any org-throw:
201
+ // const scope = await withOrgScope(ctx, { refuseWithoutThrow: true });
202
+ // if (scope.refused) { return null; } // or `return [];` / `return { … }`
203
+ // if (!scope.isMaster && scope.orgSlug === null) throw new ConvexError("RBAC_DENIED…");
204
+ // The throw below the guard is then unreachable for the no-org caller R-50 protects
205
+ // (a mounted, subscribed shell); it fires only for a caller with no identity at all.
206
+ // NARROW on purpose: the guard must test a `.refused` flag (bare or on a binding)
207
+ // and its body must be a bare `return` of `null`, an empty array, or an object
208
+ // literal — never `return <identifier>`, never a call, never a throw.
209
+ export const SCOPE_REFUSED_RETURN = /if\s*\(\s*(?:[A-Za-z_$][\w$]*\.)?refused\s*\)\s*(?:\{\s*)?return\s+(?:null\b|\[\s*\]|\{)/;
210
+ // Form D (vCRM shape — an EARLY GUARD that answers the no-org caller with a typed
211
+ // value before the org resolver/throw is reached):
212
+ // if (!ctx.workspaceId) { return emptySummary(args.presetKey, CURRENCY); } reports.ts
213
+ // const workspaceId = requireWorkspaceId(ctx.workspaceId); (now unreachable on no-org)
214
+ // if (!ctx.workspaceId || !company || company.workspaceId !== ctx.workspaceId) { return []; } activities.ts
215
+ // NARROW on purpose: the condition must contain a no-org NEGATION as a disjunct and
216
+ // NO `&&` anywhere (a conjunction could leave the no-org caller to fall through),
217
+ // and the guarded statement must be a `return` of `null`, `[]`, an object literal,
218
+ // or a CALL whose name marks the empty/unauthorised case — never `return <ident>`,
219
+ // never an arbitrary call, never a throw.
220
+ const NO_ORG_NEGATION = /!\s*(?:scope\.|ctx\.[A-Za-z0-9_.]*)?(?:org|orgId|orgSlug|workspaceId|workspace)\b|\b(?:org|orgId|orgSlug|workspaceId)\s*(?:===|==)\s*(?:null|undefined)\b/;
221
+ const EARLY_TYPED_RETURN = /^\s*\{?\s*return\s+(?:null\b|\[\s*\]|\{|(?:[A-Za-z0-9_$]+\.)*[A-Za-z0-9_$]*(?:[Ee]mpty|[Uu]nauthori[sz]ed|NoOrg|NoWorkspace)[A-Za-z0-9_$]*\s*\()/;
222
+ export function noOrgEarlyReturn(text) {
223
+ const opener = /\bif\s*\(/g;
224
+ let m;
225
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
226
+ while ((m = opener.exec(text)) !== null) {
227
+ let depth = 1;
228
+ let j = m.index + m[0].length;
229
+ for (; j < text.length && depth > 0; j++) {
230
+ if (text[j] === "(")
231
+ depth += 1;
232
+ else if (text[j] === ")")
233
+ depth -= 1;
234
+ }
235
+ if (depth !== 0)
236
+ return false;
237
+ const cond = text.slice(m.index + m[0].length, j - 1);
238
+ if (cond.includes("&&") || !NO_ORG_NEGATION.test(cond))
239
+ continue;
240
+ if (EARLY_TYPED_RETURN.test(text.slice(j)))
241
+ return true;
242
+ }
243
+ return false;
244
+ }
197
245
  function throwDomination(blockText) {
198
246
  const t1 = ORG_THROW.exec(blockText);
199
247
  const t2 = ORG_THROW_HELPER.exec(blockText);
@@ -204,6 +252,12 @@ function throwDomination(blockText) {
204
252
  // Form B (CRM helper-returned value, guard bound to the value) — decisive on its own.
205
253
  if (namedValueDomination(before))
206
254
  return "dominated";
255
+ // Form C (VP pre-org `scope.refused` typed-empty return) — decisive on its own.
256
+ if (SCOPE_REFUSED_RETURN.test(before))
257
+ return "dominated";
258
+ // Form D (early no-org guard returning a typed value) — decisive on its own.
259
+ if (noOrgEarlyReturn(before))
260
+ return "dominated";
207
261
  // Form A (inline typed-empty object literal + a no-org guard).
208
262
  if (!TYPED_EMPTY_RETURN.test(before))
209
263
  return "none";
@@ -232,6 +286,9 @@ function queryBlock(lines, i) {
232
286
  return { lines: body, unterminated: seenOpen };
233
287
  }
234
288
  export function scanR50(files) {
289
+ return scanR50Detailed(files).scan;
290
+ }
291
+ export function scanR50Detailed(files) {
235
292
  const scan = emptyScan();
236
293
  // Pass 1: discover the tree's query/mutation builders so a customQuery wrapper
237
294
  // (authedQuery, orgQuery, …) is recognised, not just the bare `query` base.
@@ -333,16 +390,37 @@ export function scanR50(files) {
333
390
  const throwsOnNoOrg = ORG_THROW.test(blockText) || ORG_THROW_HELPER.test(blockText);
334
391
  const hasResolver = SAFE_RESOLVER.test(blockText);
335
392
  const hasMarker = CONTRACT_MARKER.test(blockTextWithComments);
393
+ if (!throwsOnNoOrg &&
394
+ !hasResolver &&
395
+ !hasMarker &&
396
+ noOrgEarlyReturn(blockText)) {
397
+ // No throw at all, and the no-org caller is answered by an early guard
398
+ // with a typed value: the standard's green verbatim (4bcd605 :653, "returns
399
+ // a typed empty/unauthorised context on the no-org path"). An R-50
400
+ // subject that conforms — not an out-of-scope read.
401
+ scan.conform.push({
402
+ rel: f.rel,
403
+ n: block.lines[0].n,
404
+ note: `${fn}: public query answers the no-org caller with a typed value from an early no-org guard (no throw anywhere in the handler) — the subscribing client receives a shape`,
405
+ });
406
+ continue;
407
+ }
336
408
  if (!throwsOnNoOrg && !hasResolver && !hasMarker) {
337
- // query does neither org-isolation throw nor fail-closed resolve nor
338
- // declare a contract — not R-50's subject (too ambiguous to judge
339
- // mechanically; never a violation). Recorded so a tree whose ENTIRE
340
- // public-read surface is bare is judged (CNJ-with-sites) rather than
341
- // falsely abstained as "no public query matched".
409
+ // A query that does neither an org-isolation throw nor a fail-closed
410
+ // resolve nor declares a contract is OUT OF R-50's SCOPE — the pinned
411
+ // standard says so in its detector spec (backend-standard-4bcd605.md
412
+ // :653, "*out of scope:* … a query that does neither org-throw nor
413
+ // fail-closed resolve (not this rule's subject)"). R-50 asks how a
414
+ // REFUSAL is shaped; a read with no guard has no refusal to shape.
415
+ // Whether such a read resolves its caller AT ALL is CR-1's question
416
+ // (src/caller-resolution.ts — an unresolved public read is a CR-1
417
+ // NO-IDENTITY violation and drives the exit code), so the instrument
418
+ // still goes red on it, under the control that owns it. Counted only
419
+ // to name the empty-subject abstain truthfully.
342
420
  ungated.push({
343
421
  rel: f.rel,
344
422
  n: block.lines[0].n,
345
- note: `${fn}: public query with no org-throw, no fail-closed resolver, and no isolation-contract marker — no org-isolation handling is visible in convex/, and whether a subscribing client can reach it before an org exists is not statically decidable; a reviewer must confirm this read isolates by org (never a silent clean bill)`,
423
+ note: `${fn}: public query with no org-throw, no fail-closed resolver, no isolation-contract marker — not an R-50 subject (standard 4bcd605 :653); caller resolution is CR-1's control`,
346
424
  });
347
425
  continue;
348
426
  }
@@ -403,25 +481,28 @@ export function scanR50(files) {
403
481
  }
404
482
  }
405
483
  }
406
- // A tree whose ENTIRE public-read surface is ungated (no throw / resolver / marker
407
- // on ANY query) leaves the scan otherwise empty. The legacy abstain then claimed
408
- // "no public query matched" — FALSE when bare public queries exist, and a silent
409
- // pass over a fully-ungated backend. Surface those reads as could-not-judge sites
410
- // so the verdict is CNJ-with-sites, not a misleading empty abstain. Gated on "scan
411
- // otherwise empty" so a tree that already produced subjects (VP: 20 conform) is
412
- // byte-for-byte unchanged.
413
- // A bare ungated public read (no throw / resolver / marker) is NEVER a silent
414
- // clean bill — whether the scan is otherwise empty (a fully-ungated backend) OR
415
- // mixed (bare reads alongside gated subjects). Surface EVERY such read as
416
- // could-not-judge naming its site: never conformed, never dropped. (Eta mixed-tree
417
- // follow-up: the prior "scan otherwise empty" guard silently dropped bare ungated
418
- // reads whenever any gated subject also existed — a bare read hidden behind a
419
- // conforming sibling. Removed: the disposition is per-def, not per-tree.)
420
- for (const u of ungated)
421
- scan.cnj.push(u);
422
- return scan;
484
+ // Unmarked public reads are NOT pushed anywhere: they are outside R-50's scope
485
+ // (standard 4bcd605 :653). Until 0.1.16 they were pushed to `violate` (the Day-158
486
+ // "an instrument that cannot go red protects nothing" correction), which predates
487
+ // CR-1; CR-1 now carries exactly that obligation for a read that resolves no
488
+ // caller, so keeping it here too double-counted it under a rule whose text
489
+ // excludes it. A tree whose ONLY public reads are unmarked abstains with the
490
+ // empty-subject reason below, naming the count — never a clean bill.
491
+ return { scan, outOfScope: ungated };
423
492
  }
424
- export const R50 = srcDetector("R-50", "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw", "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch", scanR50, "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.");
493
+ const R50_ID = "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw";
494
+ const R50_CHECK = "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch";
495
+ export const R50 = {
496
+ id: R50_ID,
497
+ run: (tree) => {
498
+ const { scan, outOfScope } = scanR50Detailed(tree.files);
499
+ const n = outOfScope.length;
500
+ const empty = n > 0
501
+ ? `R-50: no R-50 subject in the scanned tree — ${n} public query(ies) matched, none org-throws, resolves via withOrgScope, or carries an isolation-contract marker (out of scope per standard 4bcd605 :653; caller resolution is CR-1's control). NOT a clean bill.`
502
+ : "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.";
503
+ return decide("R-50", R50_ID, R50_CHECK, scan, empty);
504
+ },
505
+ };
425
506
  /** In the form the orchestrator spreads into REGISTRY. */
426
507
  export const SOURCE_ISOLATION_DETECTORS = [
427
508
  { detector: R50, ruleId: "R-50", standardPin: SOURCE_STANDARD_PIN },
@@ -92,7 +92,9 @@ export function scanR19(files) {
92
92
  // file that collects unbounded or logs nothing is the violation.
93
93
  // ---------------------------------------------------------------------------
94
94
  const JOB_FILE = /recurring|monitor|sweep|poll|resolver|cleanup|digest|cron/i;
95
- const RUN_BOUND = /\.take\s*\(|_cap\b|\blimit\b/i;
95
+ // `.paginate(` is a per-run bound too: a page of `numItems` + a self-reschedule is
96
+ // the platform's continuation pattern the standard names for R-20 (4bcd605 :256).
97
+ const RUN_BOUND = /\.take\s*\(|\.paginate\s*\(|_cap\b|\blimit\b/i;
96
98
  const OUTCOME_LOG = /console\.(log|error|warn|info)\s*\(/;
97
99
  // ACTUAL job content — a Convex data operation, a scheduler/cron registration, or
98
100
  // a scheduled-function handler. Eta ruling (2026-08-31): a file is a background-job
@@ -108,10 +110,171 @@ function isJobFile(f) {
108
110
  // require job CONTENT on an executing line, not a filename match alone.
109
111
  return f.lines.some((l) => code(l) && JOB_CONTENT.test(l.raw));
110
112
  }
113
+ // ---- REGISTRATION files are followed to their handlers ---------------------
114
+ //
115
+ // The misread: R-20 judged FILE BY FILE, so `convex/crons.ts` — which only
116
+ // REGISTERS schedules (`crons.interval("…", { minutes: 15 },
117
+ // internal.recurringTasks.processDueTasks, {})`) and does no work — was scored as an
118
+ // unbounded job because the registration file itself carries no `.take(`. The job
119
+ // is the HANDLER the registration names. A file whose job content is only cron
120
+ // registrations is therefore not judged as a job; each registration becomes a site
121
+ // judged on its handler's own body, following `ctx.runMutation/runAction/runQuery
122
+ // (internal.…)` delegations up to R20_MAX_HOP hops (a cron action that delegates
123
+ // its bounded drain to an internalMutation is bounded). A registration whose
124
+ // handler (or a delegate needed for the verdict) cannot be found in the tree is
125
+ // could-not-judge naming it — never green, never an accusation on a guess.
126
+ const CRON_FILE = /\bcronJobs\s*\(/;
127
+ const CRON_REGISTRATION = /\bcrons\.(?:interval|cron|hourly|daily|weekly|monthly)\s*\(/g;
128
+ const INTERNAL_REF = /\binternal\.([A-Za-z0-9_$.]+)/;
129
+ const DELEGATION = /ctx\.run(?:Mutation|Action|Query)\s*\(\s*internal\.([A-Za-z0-9_$.]+)/g;
130
+ const R20_MAX_HOP = 2;
131
+ /** A file whose executing job content is ONLY schedule registration. */
132
+ function isRegistrationOnly(f) {
133
+ const text = f.lines
134
+ .filter(code)
135
+ .map((l) => l.raw)
136
+ .join("\n");
137
+ if (!CRON_FILE.test(text))
138
+ return false;
139
+ return !/ctx\.db\.|\.collect\s*\(|\.paginate\s*\(|ctx\.scheduler\./.test(text);
140
+ }
141
+ /** Resolve `a.b.fn` (an `internal.` path) to the handler block's text + site. */
142
+ function findHandler(files, path) {
143
+ const parts = path.split(".");
144
+ if (parts.length < 2)
145
+ return undefined;
146
+ const fn = parts[parts.length - 1];
147
+ const mod = parts.slice(0, -1).join("/");
148
+ const file = files.find((f) => f.rel === `${mod}.ts` || f.rel.endsWith(`/${mod}.ts`));
149
+ if (!file)
150
+ return undefined;
151
+ const open = new RegExp(`export\\s+const\\s+${fn}\\s*=`);
152
+ const lines = file.lines;
153
+ const i = lines.findIndex((l) => code(l) && open.test(l.raw));
154
+ if (i < 0)
155
+ return undefined;
156
+ let depth = 0;
157
+ let started = false;
158
+ const body = [];
159
+ for (let j = i; j < lines.length; j++) {
160
+ if (code(lines[j]))
161
+ body.push(lines[j].raw);
162
+ for (const ch of lines[j].raw) {
163
+ if (ch === "{") {
164
+ depth++;
165
+ started = true;
166
+ }
167
+ else if (ch === "}")
168
+ depth--;
169
+ }
170
+ if (started && depth <= 0)
171
+ break;
172
+ }
173
+ return { rel: file.rel, n: lines[i].n, text: body.join("\n") };
174
+ }
175
+ function judgeHandler(files, path, depth, seen) {
176
+ const h = findHandler(files, path);
177
+ if (!h)
178
+ return { bound: false, log: false, unresolved: [path] };
179
+ const out = {
180
+ bound: RUN_BOUND.test(h.text),
181
+ log: OUTCOME_LOG.test(h.text),
182
+ unresolved: [],
183
+ };
184
+ if ((out.bound && out.log) || depth >= R20_MAX_HOP)
185
+ return out;
186
+ DELEGATION.lastIndex = 0;
187
+ let m = DELEGATION.exec(h.text);
188
+ while (m !== null) {
189
+ const next = m[1];
190
+ if (!seen.has(next)) {
191
+ seen.add(next);
192
+ const sub = judgeHandler(files, next, depth + 1, seen);
193
+ out.bound ||= sub.bound;
194
+ out.log ||= sub.log;
195
+ out.unresolved.push(...sub.unresolved);
196
+ }
197
+ m = DELEGATION.exec(h.text);
198
+ }
199
+ return out;
200
+ }
201
+ /** Each `crons.<kind>(…, internal.x.fn, …)` registration in a registration file,
202
+ * judged on its handler. */
203
+ function scanRegistrations(f, files, scan) {
204
+ const codeLines = f.lines.filter(code);
205
+ const text = codeLines.map((l) => l.raw).join("\n");
206
+ // map a text offset back to its source line number.
207
+ const starts = [];
208
+ let off = 0;
209
+ for (const l of codeLines) {
210
+ starts.push(off);
211
+ off += l.raw.length + 1;
212
+ }
213
+ const lineAt = (o) => {
214
+ let k = 0;
215
+ while (k + 1 < starts.length && starts[k + 1] <= o)
216
+ k++;
217
+ return codeLines[k].n;
218
+ };
219
+ CRON_REGISTRATION.lastIndex = 0;
220
+ let m = CRON_REGISTRATION.exec(text);
221
+ while (m !== null) {
222
+ const n = lineAt(m.index);
223
+ const ref = INTERNAL_REF.exec(text.slice(m.index, m.index + 400));
224
+ if (!ref) {
225
+ scan.cnj.push({
226
+ rel: f.rel,
227
+ n,
228
+ note: "schedule registration names no `internal.<module>.<fn>` handler readable at the site — its bound and observability cannot be followed (could-not-judge)",
229
+ });
230
+ }
231
+ else {
232
+ const path = ref[1];
233
+ const j = judgeHandler(files, path, 0, new Set([path]));
234
+ const h = findHandler(files, path);
235
+ const at = h ? `${h.rel}:${h.n}` : "not found";
236
+ if (j.bound && j.log) {
237
+ scan.conform.push({
238
+ rel: f.rel,
239
+ n,
240
+ note: `scheduled handler internal.${path} (${at}) bounds its run and logs its outcome (followed from the registration, delegations included)`,
241
+ });
242
+ }
243
+ else if (j.unresolved.length > 0) {
244
+ scan.cnj.push({
245
+ rel: f.rel,
246
+ n,
247
+ note: `scheduled handler internal.${path} (${at}): ${j.bound ? "bounded" : "no bound found"}${j.log ? ", logs" : ", no log found"} — but ${j.unresolved.map((u) => `internal.${u}`).join(", ")} could not be found in the tree, so the verdict cannot be completed (could-not-judge)`,
248
+ });
249
+ }
250
+ else if (!j.bound) {
251
+ scan.violate.push({
252
+ rel: f.rel,
253
+ n,
254
+ note: `scheduled handler internal.${path} (${at}) declares no per-run bound (.take/_CAP/limit) in its body or its delegates — an unbounded run does not ship`,
255
+ });
256
+ }
257
+ else {
258
+ scan.cnj.push({
259
+ rel: f.rel,
260
+ n,
261
+ note: `scheduled handler internal.${path} (${at}) bounds its run but no outcome log (console.*) found — observability not statically confirmed`,
262
+ });
263
+ }
264
+ }
265
+ m = CRON_REGISTRATION.exec(text);
266
+ }
267
+ }
111
268
  export function scanR20(files) {
112
269
  const scan = emptyScan();
113
270
  for (const f of files) {
114
- if (!isJudgeable(f) || !isJobFile(f))
271
+ if (!isJudgeable(f))
272
+ continue;
273
+ if (isRegistrationOnly(f)) {
274
+ scanRegistrations(f, files, scan);
275
+ continue;
276
+ }
277
+ if (!isJobFile(f))
115
278
  continue;
116
279
  let boundLine = 0;
117
280
  let logLine = 0;
@@ -1,5 +1,6 @@
1
1
  import type { Detector } from "../../engine-types.js";
2
2
  import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare const READ_BOUND_TOKEN: RegExp;
3
4
  export declare function scanR31(files: SrcFile[]): Scan;
4
5
  export declare const R31: Detector;
5
6
  /** In the form the orchestrator spreads into REGISTRY. */
@@ -12,6 +12,29 @@ const QUERY_TABLE = /\.query\s*\(\s*["'`]([A-Za-z0-9_]+)["'`]/;
12
12
  const RESCHEDULE = /ctx\.scheduler\.runAfter\s*\(/;
13
13
  // the exported function name the mutation opener belongs to — `export const NAME =`.
14
14
  const EXPORT_NAME = /export\s+const\s+([A-Za-z0-9_]+)\s*=/;
15
+ // ---- the DECLARED BOUND (the misread: every `.collect()` was flagged) -------
16
+ //
17
+ // R-31 governs "a bulk operation over an UNBOUNDED set" (standard 4bcd605 :266).
18
+ // A `.collect()` over an index range keyed on equality — the receipts of ONE
19
+ // message, the tokens of ONE client — reads a partition, not the table, and is not
20
+ // a bulk operation in that sense. But the partition's size is not readable from
21
+ // source (Convex has no unique index; `by_namespace` can hold thousands), so the
22
+ // detector does not GUESS that it is small. Per §23 (the disposition control: a
23
+ // flagged site carries a written justification token, and the token's PRESENCE is
24
+ // the control), the author declares the bound in the source:
25
+ //
26
+ // // read-bound: one row per recipient of a single message (≤ fleet size)
27
+ // const receipts = await ctx.db.query("messageReceipts")
28
+ // .withIndex("by_message", (q) => q.eq("messageId", id)).collect();
29
+ //
30
+ // The token is honoured ONLY on an index-range read (`.withIndex(…, q => q.eq(…))`).
31
+ // A FULL-TABLE `.query("t").collect()` is never rescued by it: there the set is the
32
+ // table, and a written "harmless at N" is, per R-32 (standard :270), "the trigger for
33
+ // that proof, never the waiver". The token must carry a reason of ≥ 10 characters.
34
+ // It is read on the statement's own lines or the comment lines directly above it.
35
+ export const READ_BOUND_TOKEN = /read-bound\s*:\s*(\S.{8,}\S)/i;
36
+ // an index-range read keyed on at least one equality — a partition, not the table.
37
+ const INDEX_EQ_RANGE = /\.withIndex\s*\(\s*["'`][A-Za-z0-9_]+["'`]\s*,(?:\([^()]*\)|[^()])*?\.eq\s*\(/;
15
38
  /** Group a block's CODE lines into logical statements (joined until a `;`), each
16
39
  * keeping its start line. Robust to the multi-line `.query(...).collect()` chain
17
40
  * the real migration uses. */
@@ -19,21 +42,44 @@ function statements(lines) {
19
42
  const out = [];
20
43
  let buf = "";
21
44
  let start = 0;
45
+ let last = 0;
22
46
  for (const l of lines) {
23
47
  if (!code(l))
24
48
  continue;
25
49
  if (buf === "")
26
50
  start = l.n;
27
51
  buf += (buf ? " " : "") + l.raw.trim();
52
+ last = l.n;
28
53
  if (l.raw.includes(";")) {
29
- out.push({ n: start, text: buf });
54
+ out.push({ n: start, end: l.n, text: buf });
30
55
  buf = "";
31
56
  }
32
57
  }
33
58
  if (buf)
34
- out.push({ n: start, text: buf });
59
+ out.push({ n: start, end: last, text: buf });
35
60
  return out;
36
61
  }
62
+ /** The `read-bound:` reason written for the statement spanning [n, end] — on its
63
+ * own lines (a trailing comment) or the contiguous comment lines directly above it.
64
+ * Returns undefined when no token is written. */
65
+ function readBoundFor(all, n, end) {
66
+ const idx = all.findIndex((l) => l.n === n);
67
+ if (idx < 0)
68
+ return undefined;
69
+ for (const l of all) {
70
+ if (l.n < n || l.n > end)
71
+ continue;
72
+ const m = READ_BOUND_TOKEN.exec(l.raw);
73
+ if (m)
74
+ return m[1];
75
+ }
76
+ for (let j = idx - 1; j >= 0 && all[j].comment; j--) {
77
+ const m = READ_BOUND_TOKEN.exec(all[j].raw);
78
+ if (m)
79
+ return m[1];
80
+ }
81
+ return undefined;
82
+ }
37
83
  /** From the mutation opener at index `i`, delimit its body by matching braces.
38
84
  * Returns the block's lines and whether it closed cleanly. */
39
85
  function mutationBlock(lines, i) {
@@ -92,14 +138,31 @@ export function scanR31(files) {
92
138
  const reschedules = RESCHEDULE.test(blockText);
93
139
  for (const st of stmts) {
94
140
  if (COLLECT.test(st.text) && !BOUNDED.test(st.text)) {
141
+ const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
142
+ const indexRange = INDEX_EQ_RANGE.test(st.text);
143
+ const bound = readBoundFor(lines, st.n, st.end);
144
+ if (indexRange && bound !== undefined) {
145
+ // A partition read whose bound the author DECLARED in source (§23
146
+ // token control) — not a bulk operation over an unbounded set.
147
+ scan.conform.push({
148
+ rel: f.rel,
149
+ n: st.n,
150
+ note: `${fn}: \`.collect()\` over an index-equality range on "${table}" with a declared \`read-bound:\` (${bound.slice(0, 80)}) — a partition read whose bound is written in source, not a bulk read over an unbounded set (R-31 conforming by the §23 token)`,
151
+ });
152
+ continue;
153
+ }
95
154
  // An UNBOUNDED read inside a mutation. It aborts on the read cap
96
155
  // whether or not the loop writes — the read budget binds
97
156
  // INDEPENDENTLY of the write budget (refined R-31). Non-conforming.
98
- const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
157
+ const exit = indexRange
158
+ ? " — or, for a partition whose size is bounded by construction, declare it with a `// read-bound: <reason>` comment on the read"
159
+ : bound !== undefined
160
+ ? " — a `read-bound:` token is NOT accepted on a full-table read (the set IS the table; R-32 makes a stated bound the trigger for proof, never the waiver)"
161
+ : "";
99
162
  scan.violate.push({
100
163
  rel: f.rel,
101
164
  n: st.n,
102
- note: `${fn}: unbounded read \`.query("${table}").collect()\` inside a mutation — loads every row (and every field, R-4) in ONE transaction; exceeds the per-execution read-byte cap independently of the write budget (an unbounded read that writes NOTHING still aborts on the read cap). Bound it with \`.take(n)\`/\`.paginate\` + \`ctx.scheduler.runAfter(0, self)\``,
165
+ note: `${fn}: unbounded read \`.query("${table}").collect()\` inside a mutation — loads every row (and every field, R-4) in ONE transaction; exceeds the per-execution read-byte cap independently of the write budget (an unbounded read that writes NOTHING still aborts on the read cap). Bound it with \`.take(n)\`/\`.paginate\` + \`ctx.scheduler.runAfter(0, self)\`${exit}`,
103
166
  });
104
167
  }
105
168
  else if (BOUNDED.test(st.text)) {
@@ -25,6 +25,22 @@ export declare function scanR22(files: SrcFile[]): Scan;
25
25
  export declare function scanR33(files: SrcFile[]): Scan;
26
26
  export declare function scanR34(files: SrcFile[]): Scan;
27
27
  export declare function scanR35(files: SrcFile[]): Scan;
28
+ /**
29
+ * The directories R-36 searches, nearest first: the scanned root, then each
30
+ * ancestor up to and INCLUDING the git root (the first ancestor holding `.git`).
31
+ *
32
+ * The misread this closes: the documented invocation points the doctor at
33
+ * `<repo>/convex`, while the lockfile and `.github/workflows` are REPOSITORY
34
+ * artifacts living at the repo root. Reading only `tree.root` reported "no lockfile
35
+ * — dependencies are not pinned" on a repository with a committed `bun.lock`.
36
+ *
37
+ * The walk stops at the git root and never goes above it: a lockfile in some
38
+ * unrelated parent directory (a home directory, a monorepo host that is not this
39
+ * repository) does not pin this repository's dependencies. With no `.git` ancestor
40
+ * at all, only the scanned root itself is read (the pre-fix behaviour) — the walk
41
+ * is never widened on a guess.
42
+ */
43
+ export declare function r36SearchDirs(root: string): string[];
28
44
  export declare function evalR36(root: string): Scan;
29
45
  export declare const R21: Detector;
30
46
  export declare const R22: Detector;