any-doctor 0.0.1

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 (74) hide show
  1. package/CONTEXT.md +128 -0
  2. package/README.md +68 -0
  3. package/bin/capabilities.d.ts +15 -0
  4. package/bin/capabilities.js +131 -0
  5. package/bin/cli.d.ts +2 -0
  6. package/bin/cli.js +426 -0
  7. package/bin/clipboard.d.ts +1 -0
  8. package/bin/clipboard.js +10 -0
  9. package/bin/contract.d.ts +134 -0
  10. package/bin/contract.js +70 -0
  11. package/bin/dashboard.d.ts +108 -0
  12. package/bin/dashboard.js +718 -0
  13. package/bin/discover.d.ts +24 -0
  14. package/bin/discover.js +87 -0
  15. package/bin/doctor-loader.d.mts +1 -0
  16. package/bin/doctor-loader.mjs +161 -0
  17. package/bin/engine.d.ts +18 -0
  18. package/bin/engine.js +22 -0
  19. package/bin/fuzzy.d.ts +2 -0
  20. package/bin/fuzzy.js +31 -0
  21. package/bin/import-guard.mjs +31 -0
  22. package/bin/keys.d.ts +2 -0
  23. package/bin/keys.js +72 -0
  24. package/bin/palette.d.ts +7 -0
  25. package/bin/palette.js +14 -0
  26. package/bin/picker.d.ts +12 -0
  27. package/bin/picker.js +82 -0
  28. package/bin/report.d.ts +18 -0
  29. package/bin/report.js +159 -0
  30. package/bin/runner.d.ts +58 -0
  31. package/bin/runner.js +271 -0
  32. package/bin/score.d.ts +13 -0
  33. package/bin/score.js +39 -0
  34. package/bin/sdk.d.ts +5 -0
  35. package/bin/sdk.js +95 -0
  36. package/bin/search-host.d.ts +6 -0
  37. package/bin/search-host.js +56 -0
  38. package/bin/select.d.ts +35 -0
  39. package/bin/select.js +45 -0
  40. package/bin/tty.d.ts +38 -0
  41. package/bin/tty.js +94 -0
  42. package/docs/REPAIR-LOG.md +45 -0
  43. package/docs/RESULTS.md +70 -0
  44. package/docs/decisions.md +450 -0
  45. package/docs/example-catalog.md +122 -0
  46. package/docs/features.md +67 -0
  47. package/docs/first-shot-results.md +18 -0
  48. package/docs/intents.md +21 -0
  49. package/docs/kill-test.md +54 -0
  50. package/docs/research.md +66 -0
  51. package/docs/vision.md +83 -0
  52. package/doctors/AGENTS.md +103 -0
  53. package/doctors/api-route-files-do-import.fixtures.mjs +61 -0
  54. package/doctors/api-route-files-do-import.mjs +26 -0
  55. package/doctors/async-doctor.fixtures.mjs +147 -0
  56. package/doctors/async-doctor.mjs +295 -0
  57. package/doctors/convex-doctor.fixtures.mjs +177 -0
  58. package/doctors/convex-doctor.mjs +223 -0
  59. package/doctors/date-now-used-inside-effect.fixtures.mjs +46 -0
  60. package/doctors/date-now-used-inside-effect.mjs +132 -0
  61. package/doctors/json-parse-calls-llm-api.fixtures.mjs +37 -0
  62. package/doctors/json-parse-calls-llm-api.mjs +85 -0
  63. package/doctors/route-handlers-touch-database-before.fixtures.mjs +58 -0
  64. package/doctors/route-handlers-touch-database-before.mjs +98 -0
  65. package/doctors/z-record-called-with-single.fixtures.mjs +28 -0
  66. package/doctors/z-record-called-with-single.mjs +19 -0
  67. package/fixtures/sample-app/src/hooks/useChat.ts +15 -0
  68. package/fixtures/sample-app/src/lib/ai/client.ts +5 -0
  69. package/fixtures/sample-app/src/schemas/user.ts +6 -0
  70. package/fixtures/sample-app/src/services/chat.ts +17 -0
  71. package/fixtures/sample-app/src/services/user.ts +10 -0
  72. package/fixtures/sample-app/src/utils/sync.ts +16 -0
  73. package/package.json +42 -0
  74. package/skill/any-doctor.skill.md +188 -0
@@ -0,0 +1,295 @@
1
+ export const meta = {
2
+ id: "async-doctor",
3
+ description: "Async and concurrency discipline: fetch hygiene, promise handling, timer cleanup.",
4
+ severity: "warning",
5
+ category: "async",
6
+ blindSpots: [
7
+ "Fetch: cannot determine whether an options variable, spread, or helper supplies a signal at runtime; aliased or member-expression fetch functions are not recognized.",
8
+ "Promises: consumption inside template strings or dynamic property access, reassignable bindings (let) with conditional awaits, and results passed to a helper that awaits internally are not tracked.",
9
+ "Timers: only directly named useEffect/setTimeout/clearTimeout are recognized; handles must be a simple local identifier cleared in the same effect.",
10
+ ],
11
+ checks: [
12
+ {
13
+ id: "fetch-calls-without-abortsignal",
14
+ description: "Fetch call does not provide an AbortSignal.",
15
+ severity: "warning",
16
+ impact: "Requests cannot be cancelled: navigating away, unmounting, or superseding leaves fetches running to completion.",
17
+ why: "A fetch without a signal has no path to cancellation, so component-driven requests outlive the components that issued them.",
18
+ fix: "Pass an AbortController's signal via the request options and abort it on cleanup or supersede.",
19
+ },
20
+ {
21
+ id: "unawaited-async-map",
22
+ description: ".map(async ...) result is never awaited — the promises are dropped.",
23
+ severity: "warning",
24
+ impact: "The async work starts but nothing waits for it: errors vanish silently and the results are lost mid-flight.",
25
+ why: "Array.map returns a new array of promises. Without Promise.all or an await on the result, the async callbacks run fire-and-forget.",
26
+ fix: "Wrap the mapped array in Promise.all and await it — or drop the async if the work should actually be sequential.",
27
+ },
28
+ {
29
+ id: "uncleared-settimeout-in-effect",
30
+ description: "setTimeout inside useEffect is not cleared with clearTimeout.",
31
+ severity: "warning",
32
+ impact: "The callback fires after the component is gone: state updates on unmounted components, work the user cancelled, and hard-to-trace bugs.",
33
+ why: "Every timer started inside an effect must be cleared in that effect's cleanup; an uncleared setTimeout outlives the render that created it.",
34
+ fix: "Assign the timer and return a cleanup that calls clearTimeout with the same identifier.",
35
+ },
36
+ ],
37
+ };
38
+
39
+ export async function doctor(ctx) {
40
+ await checkFetch(ctx);
41
+ await checkUnawaitedMap(ctx);
42
+ await checkSetTimeout(ctx);
43
+ }
44
+
45
+ // --- fetch-calls-without-abortsignal ------------------------------------
46
+
47
+ async function checkFetch(ctx) {
48
+ const calls = await ctx.search.pattern("fetch($$$ARGS)");
49
+ for (const call of calls) {
50
+ if (!hasInlineSignal(call.text)) {
51
+ ctx.report.finding({ ...call, rule: "fetch-calls-without-abortsignal" });
52
+ }
53
+ }
54
+ }
55
+
56
+ function hasInlineSignal(call) {
57
+ const open = call.indexOf("(");
58
+ if (open < 0) return false;
59
+
60
+ const argumentsText = call.slice(open + 1, -1);
61
+ const argumentsList = splitTopLevel(argumentsText);
62
+ const options = argumentsList[1]?.trim();
63
+ if (!options?.startsWith("{")) return false;
64
+
65
+ const close = matchingBrace(options);
66
+ if (close < 0) return false;
67
+
68
+ return splitTopLevel(options.slice(1, close)).some((property) =>
69
+ /^(?:signal|["']signal["'])\s*(?::|$)/.test(property.trim()),
70
+ );
71
+ }
72
+
73
+ function splitTopLevel(text) {
74
+ const parts = [];
75
+ let start = 0;
76
+ let depth = 0;
77
+ let quote = null;
78
+
79
+ for (let index = 0; index < text.length; index += 1) {
80
+ const character = text[index];
81
+ if (quote) {
82
+ if (character === "\\") index += 1;
83
+ else if (character === quote) quote = null;
84
+ } else if (character === "'" || character === '"' || character === "`") {
85
+ quote = character;
86
+ } else if (character === "(" || character === "[" || character === "{") {
87
+ depth += 1;
88
+ } else if (character === ")" || character === "]" || character === "}") {
89
+ depth -= 1;
90
+ } else if (character === "," && depth === 0) {
91
+ parts.push(text.slice(start, index));
92
+ start = index + 1;
93
+ }
94
+ }
95
+ parts.push(text.slice(start));
96
+ return parts;
97
+ }
98
+
99
+ function matchingBrace(text) {
100
+ let depth = 0;
101
+ let quote = null;
102
+
103
+ for (let index = 0; index < text.length; index += 1) {
104
+ const character = text[index];
105
+ if (quote) {
106
+ if (character === "\\") index += 1;
107
+ else if (character === quote) quote = null;
108
+ } else if (character === "'" || character === '"' || character === "`") {
109
+ quote = character;
110
+ } else if (character === "{") {
111
+ depth += 1;
112
+ } else if (character === "}" && --depth === 0) {
113
+ return index;
114
+ }
115
+ }
116
+ return -1;
117
+ }
118
+
119
+ // --- unawaited-async-map -------------------------------------------------
120
+
121
+ async function checkUnawaitedMap(ctx) {
122
+ const CONSUMERS = /(Promise\s*\.\s*(all|allSettled|race|any)\s*\((?:[^()]|\([^()]*\))*\bNAME\b|await\s+(?:[\w.$]+\s*=\s*)?\s*\bNAME\b)/;
123
+
124
+ for (const file of ctx.files.list([".ts", ".tsx", ".js", ".jsx", ".mjs"])) {
125
+ const src = ctx.files.read(file);
126
+ const lines = src.split("\n");
127
+
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const decl = lines[i].match(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*[\w.$\]]+\s*\.\s*map\(\s*async\b/);
130
+ if (!decl) continue;
131
+ const name = decl[1];
132
+
133
+ const rest = lines.slice(i + 1).join("\n");
134
+ const consumer = new RegExp(CONSUMERS.source.replace(/NAME/g, escapeRe(name)));
135
+ if (consumer.test(rest)) continue;
136
+
137
+ ctx.report.finding({
138
+ rule: "unawaited-async-map",
139
+ file: file,
140
+ line: i + 1,
141
+ column: decl.index + 1,
142
+ });
143
+ }
144
+ }
145
+ }
146
+
147
+ function escapeRe(s) {
148
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+
151
+ // --- uncleared-settimeout-in-effect ---------------------------
152
+
153
+ async function checkSetTimeout(ctx) {
154
+ const files = await ctx.files.list();
155
+
156
+ for (const file of files) {
157
+ const source = await ctx.files.read(file);
158
+ const masked = maskNonCode(source);
159
+ const effects = [];
160
+ const useEffect = /\buseEffect\b/g;
161
+ let match;
162
+
163
+ while ((match = useEffect.exec(masked))) {
164
+ const open = nextNonSpace(masked, match.index + match[0].length);
165
+ if (masked[open] !== "(") continue;
166
+ const close = matchingDelimiter(masked, open, "(", ")");
167
+ if (close === -1) continue;
168
+ const body = effectBody(masked, open + 1, firstArgumentEnd(masked, open, close));
169
+ if (body) effects.push(body);
170
+ }
171
+
172
+ for (const effect of effects) {
173
+ const cleared = new Set();
174
+ const clearTimeout = /\bclearTimeout\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
175
+ const body = masked.slice(effect.start, effect.end);
176
+ while ((match = clearTimeout.exec(body))) cleared.add(match[1]);
177
+
178
+ const timeout = /\bsetTimeout\s*\(/g;
179
+ while ((match = timeout.exec(body))) {
180
+ const index = effect.start + match.index;
181
+ const insideNestedEffect = effects.some(
182
+ (other) => other !== effect && other.start < index && index < other.end,
183
+ );
184
+ if (insideNestedEffect) continue;
185
+
186
+ const before = masked.slice(effect.start, index);
187
+ const assignment = /(?:(?:\bconst|\blet|\bvar)\s+)?([A-Za-z_$][\w$]*)\s*=\s*$/.exec(before);
188
+ if (!assignment || !cleared.has(assignment[1])) {
189
+ ctx.report.finding({ rule: "uncleared-settimeout-in-effect", file, line: lineAt(source, index) });
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ function maskNonCode(source) {
197
+ const chars = source.split("");
198
+ let index = 0;
199
+
200
+ while (index < source.length) {
201
+ const char = source[index];
202
+ const next = source[index + 1];
203
+
204
+ if (char === "/" && next === "/") {
205
+ const end = source.indexOf("\n", index + 2);
206
+ const stop = end === -1 ? source.length : end;
207
+ for (let cursor = index; cursor < stop; cursor += 1) chars[cursor] = " ";
208
+ index = stop;
209
+ } else if (char === "/" && next === "*") {
210
+ const end = source.indexOf("*/", index + 2);
211
+ const stop = end === -1 ? source.length : end + 2;
212
+ for (let cursor = index; cursor < stop; cursor += 1) {
213
+ if (chars[cursor] !== "\n") chars[cursor] = " ";
214
+ }
215
+ index = stop;
216
+ } else if (char === "'" || char === '"' || char === "`") {
217
+ const quote = char;
218
+ let cursor = index + 1;
219
+ while (cursor < source.length) {
220
+ if (source[cursor] === "\\") {
221
+ cursor += 2;
222
+ } else if (source[cursor] === quote) {
223
+ cursor += 1;
224
+ break;
225
+ } else {
226
+ cursor += 1;
227
+ }
228
+ }
229
+ for (let position = index; position < cursor; position += 1) {
230
+ if (chars[position] !== "\n") chars[position] = " ";
231
+ }
232
+ index = cursor;
233
+ } else {
234
+ index += 1;
235
+ }
236
+ }
237
+
238
+ return chars.join("");
239
+ }
240
+
241
+ function nextNonSpace(source, index) {
242
+ while (index < source.length && /\s/.test(source[index])) index += 1;
243
+ return index;
244
+ }
245
+
246
+ function matchingDelimiter(source, open, opening, closing) {
247
+ let depth = 0;
248
+ for (let index = open; index < source.length; index += 1) {
249
+ if (source[index] === opening) depth += 1;
250
+ if (source[index] === closing) {
251
+ depth -= 1;
252
+ if (depth === 0) return index;
253
+ }
254
+ }
255
+ return -1;
256
+ }
257
+
258
+ function firstArgumentEnd(source, open, close) {
259
+ let parens = 0;
260
+ let brackets = 0;
261
+ let braces = 0;
262
+ for (let index = open + 1; index < close; index += 1) {
263
+ const char = source[index];
264
+ if (char === "(") parens += 1;
265
+ if (char === ")") parens -= 1;
266
+ if (char === "[") brackets += 1;
267
+ if (char === "]") brackets -= 1;
268
+ if (char === "{") braces += 1;
269
+ if (char === "}") braces -= 1;
270
+ if (char === "," && parens === 0 && brackets === 0 && braces === 0) return index;
271
+ }
272
+ return close;
273
+ }
274
+
275
+ function effectBody(masked, start, end) {
276
+ const argument = masked.slice(start, end);
277
+ const arrow = argument.indexOf("=>");
278
+ const functionMatch = /\bfunction\b/.exec(argument);
279
+ const callbackStart = arrow === -1 ? functionMatch?.index : arrow + 2;
280
+ if (callbackStart === undefined || callbackStart === -1) return null;
281
+
282
+ const brace = masked.indexOf("{", start + callbackStart);
283
+ if (brace === -1 || brace >= end) return null;
284
+ const bodyEnd = matchingDelimiter(masked, brace, "{", "}");
285
+ if (bodyEnd === -1 || bodyEnd > end) return null;
286
+ return { start: brace + 1, end: bodyEnd };
287
+ }
288
+
289
+ function lineAt(source, index) {
290
+ let line = 1;
291
+ for (let cursor = 0; cursor < index; cursor += 1) {
292
+ if (source[cursor] === "\n") line += 1;
293
+ }
294
+ return line;
295
+ }
@@ -0,0 +1,177 @@
1
+ export const fixtures = [
2
+ // --- filter-table-scan ---
3
+ {
4
+ name: "flags .filter() with no .withIndex()",
5
+ seed: {
6
+ "convex/messages.ts": [
7
+ "export const listByRoom = query({ args: { room: v.string() }, handler: async (ctx, { room }) => {",
8
+ " return await ctx.db.query(\"messages\").filter(q => q.eq(q.field(\"room\"), room)).collect();",
9
+ "}});",
10
+ ].join("\n"),
11
+ },
12
+ expected: [{ file: "convex/messages.ts", line: 2 }],
13
+ },
14
+ {
15
+ name: "accepts .withIndex() with a range expression",
16
+ seed: {
17
+ "convex/messages.ts": [
18
+ "export const listByRoom = query({ args: { room: v.string() }, handler: async (ctx, { room }) => {",
19
+ " return await ctx.db.query(\"messages\").withIndex(\"by_room\", q => q.eq(\"room\", room)).collect();",
20
+ "}});",
21
+ ].join("\n"),
22
+ },
23
+ expected: [],
24
+ },
25
+ {
26
+ name: "accepts .filter() chained AFTER .withIndex() (filter narrows the indexed read)",
27
+ seed: {
28
+ "convex/messages.ts": [
29
+ "export const recent = query({ args: { room: v.string() }, handler: async (ctx, { room }) => {",
30
+ " return await ctx.db.query(\"messages\")",
31
+ " .withIndex(\"by_room\", q => q.eq(\"room\", room))",
32
+ " .filter(q => q.eq(q.field(\"pinned\"), true))",
33
+ " .take(20);",
34
+ "}});",
35
+ ].join("\n"),
36
+ },
37
+ expected: [],
38
+ },
39
+
40
+ // --- index-without-range ---
41
+ {
42
+ name: "flags .withIndex() with no range expression",
43
+ seed: {
44
+ "convex/messages.ts": [
45
+ "export const all = query({ handler: async (ctx) => {",
46
+ " return await ctx.db.query(\"messages\").withIndex(\"by_room\").collect();",
47
+ "}});",
48
+ ].join("\n"),
49
+ },
50
+ expected: [{ file: "convex/messages.ts", line: 2 }],
51
+ },
52
+ {
53
+ name: "flags .withIndex(name, q => ...) whose callback never calls q.eq",
54
+ seed: {
55
+ "convex/messages.ts": [
56
+ "export const all = query({ handler: async (ctx) => {",
57
+ " return await ctx.db.query(\"messages\").withIndex(\"by_room\", q => q).collect();",
58
+ "}});",
59
+ ].join("\n"),
60
+ },
61
+ expected: [{ file: "convex/messages.ts", line: 2 }],
62
+ },
63
+ {
64
+ name: "accepts .withIndex() with q.gt range",
65
+ seed: {
66
+ "convex/messages.ts": [
67
+ "export const since = query({ handler: async (ctx) => {",
68
+ " return await ctx.db.query(\"messages\").withIndex(\"by_time\", q => q.gt(\"time\", 100)).collect();",
69
+ "}});",
70
+ ].join("\n"),
71
+ },
72
+ expected: [],
73
+ },
74
+
75
+ // --- unbounded-subscription ---
76
+ {
77
+ name: "flags useQuery on a whole-table query",
78
+ seed: {
79
+ "src/App.tsx": [
80
+ "import { useQuery } from \"convex/react\";",
81
+ "import { api } from \"../convex/_generated/api\";",
82
+ "export function Messages() {",
83
+ " const messages = useQuery(api.messages.list);",
84
+ " return <ul>{messages?.map(m => <li key={m._id}>{m.body}</li>)}</ul>;",
85
+ "}",
86
+ ].join("\n"),
87
+ },
88
+ expected: [{ file: "src/App.tsx", line: 4 }],
89
+ },
90
+ {
91
+ name: "accepts usePaginatedQuery",
92
+ seed: {
93
+ "src/App.tsx": [
94
+ "import { usePaginatedQuery } from \"convex/react\";",
95
+ "import { api } from \"../convex/_generated/api\";",
96
+ "export function Messages() {",
97
+ " const { results, loadMore } = usePaginatedQuery(api.messages.list, {}, { initialNumItems: 25 });",
98
+ " return <ul>{results.map(m => <li key={m._id}>{m.body}</li>)}</ul>;",
99
+ "}",
100
+ ].join("\n"),
101
+ },
102
+ expected: [],
103
+ },
104
+
105
+ // --- nondeterministic-clock-in-transaction ---
106
+ {
107
+ name: "flags Date.now() inside a mutation handler",
108
+ seed: {
109
+ "convex/timers.ts": [
110
+ "export const touch = mutation({ handler: async (ctx) => {",
111
+ " const now = Date.now();",
112
+ " await ctx.db.patch(id, { updatedAt: now });",
113
+ "}});",
114
+ ].join("\n"),
115
+ },
116
+ expected: [{ file: "convex/timers.ts", line: 2 }],
117
+ },
118
+ {
119
+ name: "flags Math.random() inside a query handler",
120
+ seed: {
121
+ "convex/roll.ts": [
122
+ "export const sample = query({ handler: async (ctx) => {",
123
+ " const pick = Math.random();",
124
+ " return pick;",
125
+ "}});",
126
+ ].join("\n"),
127
+ },
128
+ expected: [{ file: "convex/roll.ts", line: 2 }],
129
+ },
130
+ {
131
+ name: "flags elapsed-time math comparing two Date.now() calls in one transaction",
132
+ seed: {
133
+ "convex/sessions.ts": [
134
+ "export const expire = mutation({ handler: async (ctx, s) => {",
135
+ " const elapsed = Date.now() - s.startedAt;",
136
+ " if (elapsed > 5000) await ctx.db.delete(s._id);",
137
+ "}});",
138
+ ].join("\n"),
139
+ },
140
+ expected: [{ file: "convex/sessions.ts", line: 2 }],
141
+ },
142
+ {
143
+ name: "accepts Date.now() in a plain (non-Convex) function",
144
+ seed: {
145
+ "src/lib/time.ts": [
146
+ "export function nowMs() {",
147
+ " return Date.now();",
148
+ "}",
149
+ ].join("\n"),
150
+ },
151
+ expected: [],
152
+ },
153
+ {
154
+ name: "accepts Date.now() passed in as a mutation argument",
155
+ seed: {
156
+ "convex/timers.ts": [
157
+ "export const touch = mutation({ args: { now: v.number() }, handler: async (ctx, { now }) => {",
158
+ " await ctx.db.patch(id, { updatedAt: now });",
159
+ "}});",
160
+ ].join("\n"),
161
+ },
162
+ expected: [],
163
+ },
164
+ {
165
+ name: "ignores clock lookalikes inside comments and strings",
166
+ seed: {
167
+ "convex/timers.ts": [
168
+ "export const touch = mutation({ handler: async (ctx) => {",
169
+ " // const now = Date.now();",
170
+ " const note = 'call Date.now() from the client';",
171
+ " await ctx.db.patch(id, { note });",
172
+ "}});",
173
+ ].join("\n"),
174
+ },
175
+ expected: [],
176
+ },
177
+ ];
@@ -0,0 +1,223 @@
1
+ export const meta = {
2
+ id: "convex-doctor",
3
+ description: "Convex discipline: indexed reads, bounded subscriptions, deterministic transactions.",
4
+ severity: "warning",
5
+ category: "convex",
6
+ blindSpots: [
7
+ "Queries: the schema is never cross-referenced - a .filter() on a table the doctor cannot see indexed is still flagged only at the query site; withIndex is trusted to name a real index.",
8
+ "Subscriptions: useQuery is flagged at the call site without resolving the referenced function, so a paginated cursor returned from a helper is not recognized.",
9
+ "Clock: only direct Date.now()/Math.random() calls inside query/mutation/action bodies are detected; calls hidden behind wrappers or imported aliases are not.",
10
+ "Chains split across multiple statements (const q = ctx.db.query(t); q.filter(...)) are not tracked - only single-statement chains.",
11
+ ],
12
+ checks: [
13
+ {
14
+ id: "filter-table-scan",
15
+ description: ".filter() query without .withIndex() reads every document in the table.",
16
+ severity: "warning",
17
+ impact: "Convex bills and measures performance by documents read: a .filter() chain scans the whole table on every call, so a query that is fast at demo scale degrades linearly with data and can hit function limits in production.",
18
+ why: ".filter() runs after documents are read - it cannot reduce reads. Only an index range (.withIndex with q.eq/q.gt/...) restricts how many documents the query touches.",
19
+ fix: "Define an index covering the filtered fields in schema.ts and use .withIndex(\"by_field\", q => q.eq(\"field\", value)) instead of .filter().",
20
+ },
21
+ {
22
+ id: "index-without-range",
23
+ description: ".withIndex() with no range expression still scans the whole table through the index.",
24
+ severity: "warning",
25
+ impact: "The index is selected but never narrowed: every document is read via the index in full order, which costs the same as a table scan while looking optimized.",
26
+ why: "withIndex(\"by_x\") with no second argument (or a callback that never calls q.eq/q.gt/q.lt/q.range) leaves the read range unbounded - the docs call this out as a scan in disguise.",
27
+ fix: "Pass a range expression: .withIndex(\"by_x\", q => q.eq(\"x\", value)).",
28
+ },
29
+ {
30
+ id: "unbounded-subscription",
31
+ description: "useQuery subscribes to a whole table with no pagination.",
32
+ severity: "warning",
33
+ impact: "Every document re-syncs to the client on any change to the table; as the table grows, the subscription's payload and re-render cost grow without bound.",
34
+ why: "useQuery keeps the result live. Without .paginate() on the query side or usePaginatedQuery on the client side, the subscription reads and ships the entire table.",
35
+ fix: "Use .paginate(opts) in the query and usePaginatedQuery on the client; or bound the result with .withIndex(...).order(\"desc\").take(n) when the table is provably small.",
36
+ },
37
+ {
38
+ id: "nondeterministic-clock-in-transaction",
39
+ description: "Date.now() or Math.random() called inside a Convex transaction function.",
40
+ severity: "warning",
41
+ impact: "The value is snapshotted per transaction: elapsed-time math inside one mutation always sees zero, and repeated calls return the same value - timeouts, jitter, and expiry logic silently misbehave.",
42
+ why: "Convex executes query/mutation bodies deterministically within a transaction: Date.now() is pinned to the transaction's start and Math.random() is seeded from it. Both are safe to store but wrong for measuring or branching.",
43
+ fix: "Pass timestamps/randomness in as arguments from the client or an action (where they are genuinely live), or rely on _creationTime for ordering.",
44
+ },
45
+ ],
46
+ };
47
+
48
+ export async function doctor(ctx) {
49
+ const files = await ctx.files.list([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
50
+ for (const file of files) {
51
+ const source = await ctx.files.read(file);
52
+ const masked = maskNonCode(source);
53
+ const lines = masked.split("\n");
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const line = lines[i];
57
+ if (!line.includes(".filter(") && !line.includes(".withIndex(")) continue;
58
+ const chain = statementAt(lines, i);
59
+ if (!chain.includes("ctx.db.query(") && !chain.includes("db.query(")) continue;
60
+
61
+ const hasWithIndex = chain.includes(".withIndex(");
62
+ const hasFilter = chain.includes(".filter(");
63
+ if (hasFilter && !hasWithIndex) {
64
+ ctx.report.finding({ rule: "filter-table-scan", file, line: i + 1 });
65
+ } else if (hasWithIndex && !hasRangeExpression(chain)) {
66
+ ctx.report.finding({ rule: "index-without-range", file, line: i + 1 });
67
+ }
68
+ }
69
+
70
+ checkSubscriptions(ctx, file, masked);
71
+ checkClock(ctx, file, masked);
72
+ }
73
+ }
74
+
75
+ // --- query chain checks ----------------------------------------------------
76
+
77
+ // Gather the full statement containing line i (chains wrap across lines).
78
+ function statementAt(lines, i) {
79
+ let start = i;
80
+ while (start > 0 && !statementEnds(lines[start - 1])) start--;
81
+ let end = i;
82
+ while (end < lines.length - 1 && !statementEnds(lines[end])) end++;
83
+ return lines.slice(start, end + 1).join("\n");
84
+ }
85
+
86
+ function statementEnds(line) {
87
+ const bare = line.trim();
88
+ if (bare.startsWith("//") || bare.startsWith("*") || bare.startsWith("/*")) return true;
89
+ if (/\b(?:return|await|const|let|var|export|throw)\b.*[;)]\s*$/.test(bare) && !/[.(,+]$/.test(bare)) return true;
90
+ return /;\s*(\/\/.*)?$/.test(bare);
91
+ }
92
+
93
+ // withIndex("by_x") or withIndex("by_x", q => q.eq(...)) - the range is the
94
+ // second argument; detect it by finding an eq/gt/gtQuiet/lt/ltQuiet/range
95
+ // call within the withIndex argument span.
96
+ function hasRangeExpression(chain) {
97
+ const idx = chain.indexOf(".withIndex(");
98
+ if (idx === -1) return false;
99
+ const open = chain.indexOf("(", idx);
100
+ const close = matchingParen(chain, open);
101
+ if (close === -1) return false;
102
+ const args = chain.slice(open + 1, close);
103
+ // No second argument at all: withIndex("by_x") or withIndex("by_x")
104
+ if (args.trim().length > 0 && !args.includes(",")) return false;
105
+ return /\bq\s*\.\s*(eq|neq|gt|gtQuiet|lt|ltQuiet|range)\s*\(/.test(args);
106
+ }
107
+
108
+ function matchingParen(text, open) {
109
+ let depth = 0;
110
+ for (let i = open; i < text.length; i++) {
111
+ if (text[i] === "(") depth++;
112
+ if (text[i] === ")") {
113
+ depth--;
114
+ if (depth === 0) return i;
115
+ }
116
+ }
117
+ return -1;
118
+ }
119
+
120
+ // --- subscription check ----------------------------------------------------
121
+
122
+ function checkSubscriptions(ctx, file, masked) {
123
+ const lines = masked.split("\n");
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (!/useQuery\s*\(/.test(lines[i])) continue;
126
+ // A paginated sibling anywhere in the statement opts out.
127
+ const chain = statementAt(lines, i);
128
+ if (chain.includes("usePaginatedQuery")) continue;
129
+ ctx.report.finding({
130
+ rule: "unbounded-subscription",
131
+ file,
132
+ line: i + 1,
133
+ });
134
+ }
135
+ }
136
+
137
+ // --- clock/random check ----------------------------------------------------
138
+
139
+ // Track function boundaries: an export wired through query()/mutation()/
140
+ // action()/internalQuery()/internalMutation()/internalAction() starts a
141
+ // transaction body; direct Date.now()/Math.random() inside it is flagged.
142
+ const CONVEX_FNS = /\b(?:query|mutation|action|internalQuery|internalMutation|internalAction)\s*\(\s*(?:\([^)]*\)\s*=>|function\b|\{)/;
143
+
144
+ function checkClock(ctx, file, masked) {
145
+ const lines = masked.split("\n");
146
+ let insideConvexFn = false;
147
+ let braceDepth = 0;
148
+
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const line = lines[i];
151
+ if (!insideConvexFn && CONVEX_FNS.test(line)) {
152
+ insideConvexFn = true;
153
+ braceDepth = countChars(line, "{") - countChars(line, "}");
154
+ continue;
155
+ }
156
+ if (!insideConvexFn) continue;
157
+
158
+ braceDepth += countChars(line, "{") - countChars(line, "}");
159
+ if (/\b(?:Date\s*\.\s*now|Math\s*\.\s*random)\s*\(/.test(line)) {
160
+ ctx.report.finding({
161
+ rule: "nondeterministic-clock-in-transaction",
162
+ file,
163
+ line: i + 1,
164
+ });
165
+ }
166
+ if (braceDepth <= 0) {
167
+ insideConvexFn = false;
168
+ }
169
+ }
170
+ }
171
+
172
+ function countChars(line, ch) {
173
+ let n = 0;
174
+ for (const c of line) if (c === ch) n++;
175
+ return n;
176
+ }
177
+
178
+ // --- shared masking (comments and strings blanked, code shape kept) --------
179
+
180
+ function maskNonCode(source) {
181
+ const chars = source.split("");
182
+ let index = 0;
183
+
184
+ while (index < source.length) {
185
+ const char = source[index];
186
+ const next = source[index + 1];
187
+
188
+ if (char === "/" && next === "/") {
189
+ const end = source.indexOf("\n", index + 2);
190
+ const stop = end === -1 ? source.length : end;
191
+ for (let cursor = index; cursor < stop; cursor += 1) chars[cursor] = " ";
192
+ index = stop;
193
+ } else if (char === "/" && next === "*") {
194
+ const end = source.indexOf("*/", index + 2);
195
+ const stop = end === -1 ? source.length : end + 2;
196
+ for (let cursor = index; cursor < stop; cursor += 1) {
197
+ if (chars[cursor] !== "\n") chars[cursor] = " ";
198
+ }
199
+ index = stop;
200
+ } else if (char === "'" || char === '"' || char === "`") {
201
+ const quote = char;
202
+ let cursor = index + 1;
203
+ while (cursor < source.length) {
204
+ if (source[cursor] === "\\") {
205
+ cursor += 2;
206
+ } else if (source[cursor] === quote) {
207
+ cursor += 1;
208
+ break;
209
+ } else {
210
+ cursor += 1;
211
+ }
212
+ }
213
+ for (let position = index; position < cursor; position += 1) {
214
+ if (chars[position] !== "\n") chars[position] = " ";
215
+ }
216
+ index = cursor;
217
+ } else {
218
+ index += 1;
219
+ }
220
+ }
221
+
222
+ return chars.join("");
223
+ }