@vantageos/backend-doctor 0.1.14 → 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 +91 -8
- package/dist/coverage-cli.js +8 -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/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.js +1 -7
- 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
|
@@ -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,
|
|
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
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// "
|
|
409
|
-
//
|
|
410
|
-
// so
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
// never an abstention — whether the scan is otherwise empty (a fully-unmarked
|
|
415
|
-
// backend) OR mixed (bare reads alongside gated subjects). Laurent doctrine: "an
|
|
416
|
-
// instrument that cannot go red protects nothing; CNJ is for UNREADABLE input
|
|
417
|
-
// only." A bare read is fully READABLE — its two absences (no isolation, no
|
|
418
|
-
// contract) are the defect, not a could-not-read. Push to violate; the author's
|
|
419
|
-
// exit is a resolver, a typed-empty return, or a recorded isolation-contract marker.
|
|
420
|
-
for (const u of ungated)
|
|
421
|
-
scan.violate.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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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;
|