@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.
- package/README.md +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -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 +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +71 -18
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -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/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -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,327 @@ 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
|
+
}
|
|
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
|
+
}
|
|
37
366
|
/** From the mutation opener at index `i`, delimit its body by matching braces.
|
|
38
367
|
* Returns the block's lines and whether it closed cleanly. */
|
|
39
368
|
function mutationBlock(lines, i) {
|
|
@@ -90,16 +419,34 @@ export function scanR31(files) {
|
|
|
90
419
|
.map((l) => l.raw)
|
|
91
420
|
.join("\n");
|
|
92
421
|
const reschedules = RESCHEDULE.test(blockText);
|
|
422
|
+
const own = handlerReturns(block.lines);
|
|
93
423
|
for (const st of stmts) {
|
|
94
424
|
if (COLLECT.test(st.text) && !BOUNDED.test(st.text)) {
|
|
425
|
+
const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
|
|
426
|
+
const indexRange = INDEX_EQ_RANGE.test(st.text);
|
|
427
|
+
const bound = readBoundFor(lines, st.n, st.end);
|
|
428
|
+
if (indexRange && bound !== undefined) {
|
|
429
|
+
// A partition read whose bound the author DECLARED in source (§23
|
|
430
|
+
// token control) — not a bulk operation over an unbounded set.
|
|
431
|
+
scan.conform.push({
|
|
432
|
+
rel: f.rel,
|
|
433
|
+
n: st.n,
|
|
434
|
+
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)`,
|
|
435
|
+
});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
95
438
|
// An UNBOUNDED read inside a mutation. It aborts on the read cap
|
|
96
439
|
// whether or not the loop writes — the read budget binds
|
|
97
440
|
// INDEPENDENTLY of the write budget (refined R-31). Non-conforming.
|
|
98
|
-
const
|
|
441
|
+
const exit = indexRange
|
|
442
|
+
? " — or, for a partition whose size is bounded by construction, declare it with a `// read-bound: <reason>` comment on the read"
|
|
443
|
+
: bound !== undefined
|
|
444
|
+
? " — 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)"
|
|
445
|
+
: "";
|
|
99
446
|
scan.violate.push({
|
|
100
447
|
rel: f.rel,
|
|
101
448
|
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)
|
|
449
|
+
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
450
|
});
|
|
104
451
|
}
|
|
105
452
|
else if (BOUNDED.test(st.text)) {
|
|
@@ -113,6 +460,13 @@ export function scanR31(files) {
|
|
|
113
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)`,
|
|
114
461
|
});
|
|
115
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
|
+
}
|
|
116
470
|
else {
|
|
117
471
|
scan.cnj.push({
|
|
118
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[];
|