@maci0/dsh-cordis-review 0.17.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.
package/lib/check.js ADDED
@@ -0,0 +1,726 @@
1
+ /**
2
+ * Deterministic CORDIS tags, converged on one engine: ast-grep.
3
+ *
4
+ * Every tag (`mix-export`, `inject`, `toplevel`, `id`) is an ast-grep query.
5
+ * The engine is auto-detected: when the binary is missing, every covered file
6
+ * warns on stderr and yields an LLM-fallback handoff in the message (the
7
+ * review agent reads the message and judges the file).
8
+ *
9
+ * @module dsh-cordis-review/check
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { extname, join, relative } from 'node:path';
14
+ const SKIP_DIR = new Set([
15
+ '.git',
16
+ 'node_modules',
17
+ 'dist',
18
+ 'build',
19
+ 'venv',
20
+ '__pycache__',
21
+ 'target',
22
+ '.venv',
23
+ 'coverage',
24
+ '.next',
25
+ 'vendor',
26
+ 'out',
27
+ '.scratch', // gitignored scratch space, including this package's own test fixtures
28
+ ]);
29
+ /** Generated dirs with a variable prefix/suffix the exact set cannot name. */
30
+ function isSkippedDir(part) {
31
+ return (SKIP_DIR.has(part) || part.endsWith('.egg-info') || part.startsWith('cmake-build-')); // pip / CLion output
32
+ }
33
+ /**
34
+ * ast-grep language per file extension. One map serves both jobs: it is the
35
+ * coverage set (`check` reads a file only when its extension appears here) and
36
+ * the grouping that routes each file to the id / script / python pass.
37
+ */
38
+ const LANGUAGE = {
39
+ '.ts': 'typescript',
40
+ '.mts': 'typescript',
41
+ '.cts': 'typescript',
42
+ '.tsx': 'tsx',
43
+ '.js': 'javascript',
44
+ '.jsx': 'javascript',
45
+ '.mjs': 'javascript',
46
+ '.cjs': 'javascript',
47
+ '.yml': 'yaml',
48
+ '.yaml': 'yaml',
49
+ '.py': 'python',
50
+ '.pyi': 'python',
51
+ };
52
+ /** Languages whose files carry the JS/TS tags (mix-export plus member passes). */
53
+ const SCRIPT_LANGUAGES = new Set(['typescript', 'tsx', 'javascript']);
54
+ /** Extensions whose text is read before scanning (only to skip browser bundles). */
55
+ const SKIPPABLE = new Set(['.js', '.jsx', '.mjs', '.cjs']);
56
+ /** Mixed onto every Cordis `ctx`; not services. */
57
+ const CTX_INTRINSICS = new Set([
58
+ 'accessor',
59
+ 'bail',
60
+ 'baseUrl',
61
+ 'effect',
62
+ 'emit',
63
+ 'events',
64
+ 'fiber',
65
+ 'get',
66
+ 'inject',
67
+ 'logger',
68
+ 'mixin',
69
+ 'on',
70
+ 'once',
71
+ 'parallel',
72
+ 'plugin',
73
+ 'provide',
74
+ 'reflect',
75
+ 'registry',
76
+ 'root',
77
+ 'runtime',
78
+ 'serial',
79
+ 'set',
80
+ 'waterfall',
81
+ ]);
82
+ /** Every alias a Cordis context is bound to in the languages this checker reads. */
83
+ const CTX_ALT = '(ctx|scope|hostCtx|context)';
84
+ const CTX_ALIAS = new RegExp(`^${CTX_ALT}$`);
85
+ const TOPLEVEL_VERB = /^(effect|on|set|plugin|register)/;
86
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
87
+ /**
88
+ * Service-key shape, enforced only for Python (the `nonTs` flag).
89
+ * Snake_case or `_`-leading members (`_undos`, `do_thing`) are locals in the
90
+ * Python grammar: service keys carry no underscores. TS/JS keep the old
91
+ * behavior (Cordis TS keys are host-defined; narrowing there would trade
92
+ * false negatives for fewer false positives).
93
+ */
94
+ const NONTS_KEY = /^[A-Za-z][A-Za-z0-9]*$/;
95
+ /** `alias.member` / `alias->member` anywhere in a text; alias and member are groups 1-2. */
96
+ const MEMBER = new RegExp(`(?:^|[^\\w$])\\$?${CTX_ALT}\\s*(?:\\.|->)\\s*([A-Za-z_][A-Za-z0-9_]*)`);
97
+ /** {@link MEMBER} restricted to a call: `alias.member(`. */
98
+ const MEMBER_CALL = new RegExp(`${MEMBER.source}\\s*\\(`);
99
+ /**
100
+ * True when `line` continues `alias.KEY` with another `.` / `->`
101
+ * dereference. A service is a namespace called into (`ctx.jobs.run()`); a
102
+ * bare `ctx.snapshot()` is a method call, not a coeffect.
103
+ */
104
+ const CONTINUED = new RegExp(`(?:^|[^\\w$])\\$?${CTX_ALT}\\s*(?:\\.|->)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\.|->)`);
105
+ const CONTINUED_G = new RegExp(CONTINUED.source, 'g');
106
+ function continuedAccess(line, alias, key) {
107
+ CONTINUED_G.lastIndex = 0;
108
+ let match;
109
+ while ((match = CONTINUED_G.exec(` ${line}`)) !== null) {
110
+ if (match[1] === alias && match[2] === key)
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ /**
116
+ * `stopBy: end` on every `inside` guard is load-bearing: ast-grep's default
117
+ * `stopBy: neighbor` inspects only the immediate parent, so the guard matched
118
+ * nothing (a Python call sits under an `expression_statement`).
119
+ *
120
+ * Rule bodies every script grammar shares, as `[id base, body]`. The
121
+ * `language:` token and the `-<suffix>` every id carries both come from
122
+ * {@link SCRIPT_LANGS}, so the TypeScript, JavaScript and JSX documents render
123
+ * from this one list and a rule can never drift between them.
124
+ */
125
+ const SCRIPT_RULES = [
126
+ ['u-def', String.raw `rule:
127
+ pattern: "export default $X"`],
128
+ ['u-fn', String.raw `rule:
129
+ pattern: "export function $N($$$ARGS) { $$$BODY }"`],
130
+ // TS return-type annotation: shape-exact `$N($$$ARGS) {` misses the `: $T`
131
+ // between parameters and body, and every real plugin here writes it.
132
+ ['u-fn-typed', String.raw `rule:
133
+ pattern: "export function $N($$$ARGS): $T { $$$BODY }"`],
134
+ // Generic type parameters sit in the same slot as the return type: `apply<T>`.
135
+ ['u-fn-generic', String.raw `rule:
136
+ pattern: "export function $N<$T>($$$ARGS) { $$$BODY }"`],
137
+ ['u-fn-generic-typed', String.raw `rule:
138
+ pattern: "export function $N<$T>($$$ARGS): $U { $$$BODY }"`],
139
+ ['u-const', String.raw `rule:
140
+ pattern: "export const $N = $V"`],
141
+ ['u-const-typed', String.raw `rule:
142
+ pattern: "export const $N: $T = $V"`],
143
+ ['u-named', String.raw `rule:
144
+ pattern: "export { $X }"`],
145
+ ['u-decl', String.raw `rule:
146
+ pattern: "export const inject = $V"`],
147
+ // Same shape miss on the declaration side: `export const inject: string[] = […]`.
148
+ ['u-decl-typed', String.raw `rule:
149
+ pattern: "export const inject: $T = $V"`],
150
+ ['u-decl-bare', String.raw `rule:
151
+ pattern: "inject = $V"`],
152
+ ['m', String.raw `rule:
153
+ all:
154
+ - pattern: "$C.$K"
155
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"`],
156
+ ['t', String.raw `rule:
157
+ all:
158
+ - pattern: "$C.$M($$$ARGS)"
159
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"
160
+ - not:
161
+ inside:
162
+ any:
163
+ - kind: function_declaration
164
+ - kind: arrow_function
165
+ - kind: function_expression
166
+ - kind: method_definition
167
+ stopBy: end`],
168
+ ['t-bare', String.raw `rule:
169
+ all:
170
+ - pattern: "register($$$ARGS)"
171
+ - not:
172
+ inside:
173
+ any:
174
+ - kind: function_declaration
175
+ - kind: arrow_function
176
+ - kind: function_expression
177
+ - kind: method_definition
178
+ stopBy: end`],
179
+ ['u-get', String.raw `rule:
180
+ all:
181
+ - pattern: "$C.get($$$ARGS)"
182
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"`],
183
+ ['u-inject', String.raw `rule:
184
+ all:
185
+ - pattern: "$C.inject($$$ARGS)"
186
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"`],
187
+ ];
188
+ /** Script grammar → the id suffix its rules carry. Declaration order is the scan order. */
189
+ const SCRIPT_LANGS = { typescript: 'ts', javascript: 'js', tsx: 'tsx' };
190
+ /** One `ast-grep` document for one script grammar: every {@link SCRIPT_RULES} body under its own id. */
191
+ function scriptDoc(language) {
192
+ return SCRIPT_RULES.map(([id, body]) => `id: ${id}-${SCRIPT_LANGS[language]}\nlanguage: ${language}\n${body}`).join('\n---\n');
193
+ }
194
+ /** Rule-id suffixes of {@link SCRIPT_LANGS}. */
195
+ const SCRIPT_SUFFIXES = Object.values(SCRIPT_LANGS);
196
+ /** One rule id per script grammar. */
197
+ function scriptIds(base) {
198
+ return SCRIPT_SUFFIXES.map((suffix) => `${base}-${suffix}`);
199
+ }
200
+ /**
201
+ * One static multi-rule document per language, fed to `ast-grep scan
202
+ * --inline-rules`. One engine spawn covers every file of the language.
203
+ * Rule order is load-bearing only for readability: matches carry ruleId, so
204
+ * evaluation order never changes output. Keep the member/call/toplevel triple
205
+ * together when adding a rule.
206
+ */
207
+ const SG_DOC = {
208
+ yaml: String.raw `id: u-id
209
+ language: yaml
210
+ rule:
211
+ pattern: "id: $ID"
212
+ `,
213
+ typescript: scriptDoc('typescript'),
214
+ javascript: scriptDoc('javascript'),
215
+ tsx: scriptDoc('tsx'),
216
+ python: String.raw `id: u-decl-py
217
+ language: python
218
+ rule:
219
+ pattern: "inject = $V"
220
+ ---
221
+ id: m-py
222
+ language: python
223
+ rule:
224
+ all:
225
+ - pattern: "$C.$K"
226
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"
227
+ ---
228
+ id: t-py
229
+ language: python
230
+ rule:
231
+ all:
232
+ - pattern: "$C.$M($$$ARGS)"
233
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"
234
+ - not:
235
+ inside:
236
+ any:
237
+ - kind: function_definition
238
+ - kind: lambda
239
+ stopBy: end
240
+ ---
241
+ id: t-bare-py
242
+ language: python
243
+ rule:
244
+ all:
245
+ - pattern: "register($$$ARGS)"
246
+ - not:
247
+ inside:
248
+ any:
249
+ - kind: function_definition
250
+ - kind: lambda
251
+ stopBy: end
252
+ ---
253
+ id: u-get-py
254
+ language: python
255
+ rule:
256
+ all:
257
+ - pattern: "$C.get($$$ARGS)"
258
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"
259
+ ---
260
+ id: u-inject-py
261
+ language: python
262
+ rule:
263
+ all:
264
+ - pattern: "$C.inject($$$ARGS)"
265
+ - regex: "^\\$?(ctx|scope|hostCtx|context)\\b"
266
+ `,
267
+ };
268
+ function warn(message) {
269
+ process.stderr.write(`cordis-check: warning: ${message}\n`);
270
+ }
271
+ /** LLM-fallback handoff: the review agent judges the file against the checklist. */
272
+ function fallback(rel) {
273
+ warn(`${rel}: no ast-grep verdict. LLM fallback: judge this file against the CORDIS checklist yourself.`);
274
+ return {
275
+ tag: 'inject',
276
+ file: rel,
277
+ line: 1,
278
+ message: 'ast-grep is not on PATH (LLM fallback): judge this file against the CORDIS checklist yourself. ' +
279
+ 'ctx.* service reads need inject, module-load effects belong in apply(ctx).',
280
+ };
281
+ }
282
+ /**
283
+ * Scan `root` for the closed-form tags.
284
+ * @param root - workspace (or subdirectory) to walk.
285
+ */
286
+ export async function check(root) {
287
+ const { files, unreadable } = await listFiles(root);
288
+ const detected = astGrepOnPath();
289
+ if (!detected) {
290
+ warn('ast-grep not on PATH: every covered file takes the warning + LLM-fallback path. Install ast-grep for deterministic results.');
291
+ }
292
+ const covered = [];
293
+ for (const abs of files) {
294
+ const ext = extname(abs).toLowerCase();
295
+ if (isTestPath(relative(root, abs).split('\\').join('/')))
296
+ continue;
297
+ if (abs.endsWith('.d.ts'))
298
+ continue;
299
+ if (LANGUAGE[ext] === undefined)
300
+ continue;
301
+ const text = SKIPPABLE.has(ext) ? await readFile(abs, 'utf8') : undefined;
302
+ if (text !== undefined && text.includes('__ModuleLoader__'))
303
+ continue;
304
+ covered.push(abs);
305
+ }
306
+ // One scan spawn per language and batch (see sgScanAll); per-file grouping
307
+ // below is just bucketing matches by their `file` field.
308
+ const raw = detected ? sgScanAll(covered) : undefined;
309
+ const byFile = new Map();
310
+ for (const hit of raw ?? []) {
311
+ const abs = hit.file ?? '';
312
+ const list = byFile.get(abs) ?? [];
313
+ list.push(hit);
314
+ byFile.set(abs, list);
315
+ }
316
+ const findings = [];
317
+ const seenIds = new Map();
318
+ for (const abs of covered) {
319
+ const rel = relative(root, abs).split('\\').join('/');
320
+ const ext = extname(abs).toLowerCase();
321
+ if (raw === undefined) {
322
+ findings.push(fallback(rel));
323
+ continue;
324
+ }
325
+ const byRule = indexHits(byFile.get(abs) ?? []);
326
+ const language = LANGUAGE[ext];
327
+ if (language === 'yaml') {
328
+ findings.push(...sgIds(rel, byRule, seenIds));
329
+ continue;
330
+ }
331
+ if (language !== undefined && SCRIPT_LANGUAGES.has(language)) {
332
+ const text = await readFile(abs, 'utf8');
333
+ findings.push(...sgScript(rel, byRule, text));
334
+ continue;
335
+ }
336
+ const text = await readFile(abs, 'utf8');
337
+ findings.push(...sgMembersAndToplevel(rel, byRule, text, true, 'm-py', 't-py', 't-bare-py'));
338
+ }
339
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.tag.localeCompare(b.tag));
340
+ return { findings, unreadable };
341
+ }
342
+ async function listFiles(root) {
343
+ const out = [];
344
+ const unreadable = [];
345
+ // Walk by hand instead of `readdir({ recursive: true })`: pruning skipped
346
+ // dirs during descent keeps node_modules unvisited, and skipping entries
347
+ // that are neither plain files nor plain dirs means a symlinked dir (pnpm's
348
+ // store layout) is never followed, so no ELOOP and no double-reported files.
349
+ const walk = async (dir) => {
350
+ let entries;
351
+ try {
352
+ entries = await readdir(dir, { withFileTypes: true });
353
+ }
354
+ catch (error) {
355
+ const rel = relative(root, dir).split('\\').join('/');
356
+ unreadable.push({ dir: rel === '' ? '.' : rel, reason: error instanceof Error ? error.message : String(error) });
357
+ return;
358
+ }
359
+ for (const entry of entries) {
360
+ const abs = join(dir, entry.name);
361
+ if (entry.isDirectory()) {
362
+ if (!isSkippedDir(entry.name))
363
+ await walk(abs);
364
+ }
365
+ else if (entry.isFile()) {
366
+ out.push(abs);
367
+ }
368
+ }
369
+ };
370
+ await walk(root);
371
+ unreadable.sort((a, b) => a.dir.localeCompare(b.dir));
372
+ return { files: out, unreadable };
373
+ }
374
+ function astGrepOnPath() {
375
+ const which = spawnSync('ast-grep', ['--version'], { encoding: 'utf8' });
376
+ return which.status === 0;
377
+ }
378
+ function isTestPath(rel) {
379
+ if (rel.startsWith('tests/') || rel.startsWith('test/') || rel.includes('/tests/') || rel.includes('/test/'))
380
+ return true;
381
+ const base = rel.split('/').pop() ?? '';
382
+ return (/\.(?:test|spec)\.[^.]+$/.test(base)
383
+ || /^test_/.test(base)
384
+ || /_(?:test|spec)\.[^.]+$/.test(base)
385
+ || /Test\.[^.]+$/.test(base));
386
+ }
387
+ /** `spawnSync` stdout ceiling for one `ast-grep scan` (see {@link MAX_PATHS}). */
388
+ const MAX_OUTPUT = 32 * 1024 * 1024;
389
+ /**
390
+ * Paths per `ast-grep` spawn. Bounds argv size and keeps the batch's JSON
391
+ * stdout inside {@link MAX_OUTPUT} (measured ~14KB per file on ordinary
392
+ * sources, worst case ~0.5MB on dense ones). A single file with more than
393
+ * {@link MAX_OUTPUT} of matches still overflows its batch; that is ~100k
394
+ * matches in one file, not a real tree.
395
+ * ponytail: fixed chunks; stream stdout to disk if a real tree ever hits the ceiling.
396
+ */
397
+ const MAX_PATHS = 200;
398
+ /** `ast-grep scan` spawns over every covered file: hits, [] on no match. Throws on engine failure. */
399
+ function sgScanAll(files) {
400
+ if (files.length === 0)
401
+ return [];
402
+ // Group by language *before* chunking. The engine evaluates every rule in
403
+ // the doc against every file, so one multi-language doc costs per-file eval
404
+ // time linear in rule count; per-language docs keep each spawn's rule set
405
+ // small. Chunking first pays the engine's startup once per (chunk ×
406
+ // language present); on a mixed tree that is one spawn per language per
407
+ // 50 files instead of one per language per 200, for no gain.
408
+ const byLang = new Map();
409
+ for (const file of files) {
410
+ const lang = LANGUAGE[extname(file).toLowerCase()];
411
+ if (lang === undefined)
412
+ continue;
413
+ const list = byLang.get(lang);
414
+ if (list === undefined)
415
+ byLang.set(lang, [file]);
416
+ else
417
+ list.push(file);
418
+ }
419
+ const out = [];
420
+ for (const [lang, langFiles] of byLang) {
421
+ const doc = SG_DOC[lang];
422
+ if (doc === undefined)
423
+ continue;
424
+ for (let index = 0; index < langFiles.length; index += MAX_PATHS) {
425
+ out.push(...sgScanBatch(doc, langFiles.slice(index, index + MAX_PATHS)));
426
+ }
427
+ }
428
+ return out;
429
+ }
430
+ /** One bounded `ast-grep scan` spawn. Throws an `Error` naming the engine failure. */
431
+ function sgScanBatch(doc, files) {
432
+ const result = spawnSync('ast-grep', ['scan', '--inline-rules', doc, '--json=compact', ...files], {
433
+ encoding: 'utf8',
434
+ maxBuffer: MAX_OUTPUT,
435
+ });
436
+ if (result.error !== undefined)
437
+ throw new Error(`ast-grep scan failed: ${result.error.message}`);
438
+ if (result.status !== 0) {
439
+ const how = result.status === null ? `killed by ${result.signal ?? 'a signal'}` : `exited ${result.status}`;
440
+ const reason = result.stderr.trim().split('\n')[0] ?? '';
441
+ throw new Error(`ast-grep scan ${how}${reason === '' ? '' : `: ${reason}`}`);
442
+ }
443
+ const stdout = result.stdout.trim();
444
+ if (stdout === '')
445
+ return [];
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(stdout);
449
+ }
450
+ catch {
451
+ // A SyntaxError leaves `parsed` undefined: the non-array error below covers it.
452
+ }
453
+ if (!Array.isArray(parsed))
454
+ throw new Error('ast-grep scan printed no JSON array');
455
+ return parsed;
456
+ }
457
+ function meta(hit, name) {
458
+ return (hit.metaVariables?.single?.[name]?.text ?? '').trim();
459
+ }
460
+ function lineOf(hit) {
461
+ return (hit.range?.start?.line ?? 0) + 1;
462
+ }
463
+ function injectMessage(key) {
464
+ return `ctx.${key} without inject: ['${key}']. Declare it, or read optional services with ctx.get('${key}').`;
465
+ }
466
+ function memberKeyFromText(text) {
467
+ const match = MEMBER.exec(` ${text}`);
468
+ if (match?.[1] === undefined || match[2] === undefined)
469
+ return undefined;
470
+ return { alias: match[1], key: match[2] };
471
+ }
472
+ function keysFromValue(value) {
473
+ const out = [];
474
+ // Read the bracketed group itself, not "value minus its last ]": a trailing
475
+ // `as const` / `satisfies T` after the closing bracket otherwise poisons the
476
+ // final key (`'skills'] as const` fails IDENT and the key is lost).
477
+ const inner = /^\s*[[{]([^\]}]*)[\]}]/.exec(value)?.[1] ?? value;
478
+ for (const part of inner.split(',')) {
479
+ const token = part.trim().replace(/^['"`]|['"`]$/g, '');
480
+ if (IDENT.test(token))
481
+ out.push(token);
482
+ }
483
+ return out;
484
+ }
485
+ /** Index hits once per file: ruleId → matches. */
486
+ function indexHits(hits) {
487
+ const out = new Map();
488
+ for (const hit of hits) {
489
+ if (hit.ruleId === undefined)
490
+ continue;
491
+ const list = out.get(hit.ruleId);
492
+ if (list === undefined)
493
+ out.set(hit.ruleId, [hit]);
494
+ else
495
+ list.push(hit);
496
+ }
497
+ return out;
498
+ }
499
+ function sgIds(rel, byRule, seen) {
500
+ const findings = [];
501
+ for (const hit of byRule.get('u-id') ?? []) {
502
+ const id = meta(hit, 'ID').replace(/^['"`]|['"`]$/g, '');
503
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(id))
504
+ continue;
505
+ const line = lineOf(hit);
506
+ const prev = seen.get(id);
507
+ if (prev !== undefined) {
508
+ findings.push({
509
+ tag: 'id',
510
+ file: rel,
511
+ line,
512
+ message: `duplicate Loader id "${id}" (first at ${prev}). insert does not dedupe.`,
513
+ });
514
+ }
515
+ else {
516
+ seen.set(id, `${rel}:${line}`);
517
+ }
518
+ }
519
+ return findings;
520
+ }
521
+ /** Declared inject keys, read as data from `inject = [...]` matches. */
522
+ function declaredKeys(byRule) {
523
+ const out = new Set();
524
+ for (const [id, hits] of byRule) {
525
+ if (!id.startsWith('u-decl'))
526
+ continue;
527
+ for (const hit of hits) {
528
+ for (const key of keysFromValue(meta(hit, 'V')))
529
+ out.add(key);
530
+ }
531
+ }
532
+ return out;
533
+ }
534
+ /**
535
+ * `ctx.inject([...], scope => …)` widens the alias set: the callback's first
536
+ * parameter carries the declared keys for its body lines.
537
+ */
538
+ function injectWidens(byRule) {
539
+ const widens = new Map();
540
+ for (const [id, hits] of byRule) {
541
+ if (!id.startsWith('u-inject'))
542
+ continue;
543
+ for (const hit of hits) {
544
+ const text = hit.text ?? '';
545
+ const keys = keysFromValue(/\[([^\]]*)\]/.exec(text)?.[1] ?? '');
546
+ const alias = /\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*[,)]/.exec(text)?.[1];
547
+ if (alias === undefined || keys.length === 0)
548
+ continue;
549
+ widens.set(lineOf(hit), { alias, keys });
550
+ }
551
+ }
552
+ return widens;
553
+ }
554
+ /** `ctx.get('key')` / `scope.get('key')` reads that never need inject. */
555
+ function getReads(byRule) {
556
+ const reads = new Set();
557
+ for (const [id, hits] of byRule) {
558
+ if (!id.startsWith('u-get'))
559
+ continue;
560
+ for (const hit of hits) {
561
+ const arg = /\(\s*['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]/.exec(hit.text ?? '')?.[1];
562
+ if (arg !== undefined)
563
+ reads.add(`${lineOf(hit)}:${arg}`);
564
+ }
565
+ }
566
+ return reads;
567
+ }
568
+ function sgScript(rel, byRule, text) {
569
+ const defs = scriptIds('u-def').flatMap((id) => byRule.get(id) ?? []);
570
+ if (defs.length === 0) {
571
+ return sgMembersAndToplevel(rel, byRule, text, false, ...scriptIds('m'), ...scriptIds('t'), ...scriptIds('t-bare'));
572
+ }
573
+ const names = new Set();
574
+ // Derive from the rule ids, not a hand-kept list: every export shape added to
575
+ // SCRIPT_RULES then counts, instead of silently dropping out of mix-export.
576
+ for (const [id, hits] of byRule) {
577
+ if (!/^u-(?:fn|const|named)-/.test(id))
578
+ continue;
579
+ for (const hit of hits) {
580
+ for (const slot of ['N', 'X']) {
581
+ for (const part of meta(hit, slot).split(',')) {
582
+ const token = part.trim().split(/\s+as\s+/).pop()?.trim() ?? '';
583
+ if (token !== '')
584
+ names.add(token);
585
+ }
586
+ }
587
+ }
588
+ }
589
+ const findings = names.has('apply') || names.has('inject')
590
+ ? [
591
+ {
592
+ tag: 'mix-export',
593
+ file: rel,
594
+ line: 1,
595
+ message: 'default export plus named apply/inject. Loader keeps one form; mixing drops inject. Pick one.',
596
+ },
597
+ ]
598
+ : [];
599
+ return findings.concat(sgMembersAndToplevel(rel, byRule, text, false, ...scriptIds('m'), ...scriptIds('t'), ...scriptIds('t-bare')));
600
+ }
601
+ /** Shared inject + toplevel pass over one scan's member/call matches. */
602
+ function sgMembersAndToplevel(rel, byRule, text, nonTs, ...ids) {
603
+ const memberIds = ids.filter((id) => id.startsWith('m-'));
604
+ const callIds = ids.filter((id) => id.startsWith('t-'));
605
+ const declared = declaredKeys(byRule);
606
+ const widens = injectWidens(byRule);
607
+ const reads = getReads(byRule);
608
+ const findings = [];
609
+ const seen = new Set();
610
+ const srcLines = text.split(/\r?\n/);
611
+ for (const id of memberIds) {
612
+ for (const hit of byRule.get(id) ?? []) {
613
+ const parsed = memberKeyFromText(hit.text ?? '');
614
+ if (parsed === undefined || !CTX_ALIAS.test(parsed.alias))
615
+ continue;
616
+ if (!IDENT.test(parsed.key) || CTX_INTRINSICS.has(parsed.key) || declared.has(parsed.key))
617
+ continue;
618
+ if (nonTs && !NONTS_KEY.test(parsed.key))
619
+ continue;
620
+ // A bare call is a host method, not a coeffect: only `ctx.KEY.…`
621
+ // survived. The call itself never reads (`ctx.tools.register()` is the
622
+ // service `tools` providing `register`, already caught at its member).
623
+ const contText = continuedAccess(hit.text ?? '', parsed.alias, parsed.key)
624
+ ? hit.text ?? ''
625
+ : (srcLines[lineOf(hit) - 1] ?? '');
626
+ if (nonTs && !continuedAccess(contText, parsed.alias, parsed.key))
627
+ continue;
628
+ if (reads.has(`${lineOf(hit)}:${parsed.key}`))
629
+ continue;
630
+ if (widened(widens, lineOf(hit), parsed.alias, parsed.key))
631
+ continue;
632
+ const seenId = `${lineOf(hit)}:${parsed.key}`;
633
+ if (seen.has(seenId))
634
+ continue;
635
+ seen.add(seenId);
636
+ findings.push({ tag: 'inject', file: rel, line: lineOf(hit), message: injectMessage(parsed.key) });
637
+ }
638
+ }
639
+ const callLines = new Map();
640
+ for (const id of callIds) {
641
+ for (const hit of byRule.get(id) ?? []) {
642
+ const text = hit.text ?? '';
643
+ const ruleId = hit.ruleId ?? '';
644
+ // t-bare-*: the whole match is the call; method is its callee name.
645
+ const bareCall = ruleId.startsWith('t-bare-') ? /^register/.exec(text.trim()) !== null : false;
646
+ // Recover receiver+method from one match.
647
+ const call = MEMBER_CALL.exec(` ${text}`);
648
+ const bare = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(text);
649
+ const recv = call?.[1] ?? '';
650
+ const method = call?.[2] ?? (bareCall ? 'register' : (bare?.[1] ?? ''));
651
+ if (!IDENT.test(method))
652
+ continue;
653
+ if (recv === '' && !bareCall)
654
+ continue;
655
+ callLines.set(lineOf(hit), { recv, method });
656
+ }
657
+ }
658
+ // Toplevel = effect-shaped call at brace depth 0. ast-grep patterns cannot
659
+ // see depth, so depth stays a brace scan over ast-grep's own call lines:
660
+ // the match set is ast-grep's, not a second engine. Bare `register(…)`
661
+ // has no receiver; `registerX` methods match TOPLEVEL_VERB by prefix.
662
+ // String/comment braces would corrupt depth, so count code braces only.
663
+ let depth = 0;
664
+ for (let index = 0; index < srcLines.length; index += 1) {
665
+ const call = callLines.get(index + 1);
666
+ if (depth === 0 && call !== undefined && TOPLEVEL_VERB.test(call.method)
667
+ && (call.recv === '' || CTX_ALIAS.test(call.recv))) {
668
+ findings.push({
669
+ tag: 'toplevel',
670
+ file: rel,
671
+ line: index + 1,
672
+ message: 'effect at module load. Move it into apply(ctx) / the Service constructor.',
673
+ });
674
+ }
675
+ for (const ch of codeBraces(srcLines[index] ?? '', nonTs)) {
676
+ if (ch === '{' || ch === '(')
677
+ depth += 1;
678
+ if (ch === '}' || ch === ')')
679
+ depth -= 1;
680
+ }
681
+ if (depth < 0)
682
+ depth = 0;
683
+ }
684
+ return findings;
685
+ }
686
+ /**
687
+ * Yield the brace characters that are code, not string/comment text, so an
688
+ * unbalanced `}` inside a literal cannot corrupt the toplevel depth pass.
689
+ * Handles `'`, `"`, backtick strings (with `\` escapes) and line comments:
690
+ * `#` in Python, `//` in JS/TS (where `#` starts a private member). Block
691
+ * comments stay counted (a backstop, not a lexer).
692
+ * @param line - one source line.
693
+ * @param python - whether `#` starts a comment.
694
+ */
695
+ function* codeBraces(line, python) {
696
+ let quote;
697
+ for (let i = 0; i < line.length; i += 1) {
698
+ const ch = line[i];
699
+ if (quote !== undefined) {
700
+ if (ch === '\\')
701
+ i += 1;
702
+ else if (ch === quote)
703
+ quote = undefined;
704
+ continue;
705
+ }
706
+ if (ch === '"' || ch === "'" || ch === '`') {
707
+ quote = ch;
708
+ continue;
709
+ }
710
+ if (python ? ch === '#' : ch === '/' && line[i + 1] === '/')
711
+ return;
712
+ if (ch === '{' || ch === '(' || ch === '}' || ch === ')')
713
+ yield ch;
714
+ }
715
+ }
716
+ /**
717
+ * True when a ctx.inject call widens this alias+key. `<=` because the
718
+ * callback may open on the same line as the call itself.
719
+ */
720
+ function widened(widens, line, alias, key) {
721
+ for (const [start, widen] of widens) {
722
+ if (start <= line && widen.alias === alias && widen.keys.includes(key))
723
+ return true;
724
+ }
725
+ return false;
726
+ }