carto-md 2.0.7 → 2.0.9

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 (43) hide show
  1. package/README.md +290 -26
  2. package/docs/anci/v0.1-DRAFT.md +420 -0
  3. package/docs/scale.md +129 -0
  4. package/package.json +10 -5
  5. package/scripts/postinstall.js +413 -0
  6. package/src/acp/agent.js +5 -5
  7. package/src/acp/providers/index.js +2 -2
  8. package/src/agents/leiden.js +11 -17
  9. package/src/agents/scan-structure.js +1 -1
  10. package/src/anci/consumer.js +305 -0
  11. package/src/anci/deserialize.js +160 -0
  12. package/src/anci/emit.js +85 -0
  13. package/src/anci/serialize.js +264 -0
  14. package/src/anci/yaml.js +401 -0
  15. package/src/bitmap/bitset.js +190 -0
  16. package/src/bitmap/index.js +121 -0
  17. package/src/bitmap/sidecar.js +545 -0
  18. package/src/bitmap/tools.js +310 -0
  19. package/src/cli/anci.js +237 -0
  20. package/src/cli/check.js +57 -0
  21. package/src/cli/index.js +28 -2
  22. package/src/cli/init.js +297 -65
  23. package/src/cli/inspect.js +295 -0
  24. package/src/cli/pr-impact.js +497 -0
  25. package/src/cli/serve.js +1 -1
  26. package/src/cli/watch.js +6 -0
  27. package/src/engine/worker.js +24 -4
  28. package/src/extractors/imports.js +176 -0
  29. package/src/extractors/languages/html.js +4 -1
  30. package/src/extractors/languages/javascript.js +5 -0
  31. package/src/extractors/languages/prisma.js +4 -1
  32. package/src/extractors/languages/python.js +5 -1
  33. package/src/extractors/languages/r.js +4 -1
  34. package/src/extractors/languages/typescript.js +2 -0
  35. package/src/extractors/tree-sitter-parser.js +15 -0
  36. package/src/mcp/change-plan.js +8 -8
  37. package/src/mcp/diff-parser.js +246 -0
  38. package/src/mcp/server-v2.js +489 -8
  39. package/src/mcp/validate.js +304 -0
  40. package/src/store/config-loader.js +77 -0
  41. package/src/store/sqlite-store.js +389 -4
  42. package/src/store/sync-v2.js +472 -97
  43. package/BENCHMARK_RESULTS.md +0 -34
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * `carto pr-impact` — pull-request-shaped impact report.
6
+ *
7
+ * Computes the impact of a pull request's diff against the current
8
+ * project graph and renders either:
9
+ * - Markdown wrapped in a `<!-- carto-impact-report -->` HTML marker
10
+ * so the GitHub Action can detect-and-update sticky comments.
11
+ * - JSON for programmatic consumers (CI dashboards, custom workflows).
12
+ *
13
+ * Composition:
14
+ * 1. `git diff --unified=3 <base>...<head>` → unified diff text.
15
+ * 2. `validateDiff(store, sidecar, diff)` → violations, blast radius,
16
+ * risk roll-up. (Same engine the MCP `validate_diff` tool uses.)
17
+ * 3. Per-file `StoreAdapter.getBlastRadius(file)` → routes affected,
18
+ * domains impacted (validateDiff returns counts; the comment
19
+ * template wants the actual route list).
20
+ * 4. Render.
21
+ *
22
+ * Exit code:
23
+ * - 0 by default (the comment surfaces the risk; failing the build
24
+ * is opt-in).
25
+ * - Non-zero when `--fail-on HIGH|MEDIUM` is supplied AND the rolled-up
26
+ * risk meets or exceeds the threshold.
27
+ *
28
+ * The command is read-only: it does not write to the SQLite store and
29
+ * does not record episodic-memory rows — those persistence concerns
30
+ * belong to the MCP `validate_diff` tool, not the CI surface.
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const { execFileSync } = require('child_process');
36
+ const { SQLiteStore } = require('../store/sqlite-store');
37
+ const { StoreAdapter } = require('../store/store-adapter');
38
+ const { ensureBitmapFresh } = require('../bitmap/index');
39
+ const { validateDiff } = require('../mcp/validate');
40
+
41
+ const MARKER = '<!-- carto-impact-report -->';
42
+
43
+ function parseArgs(argv) {
44
+ const args = {
45
+ base: null,
46
+ head: 'HEAD',
47
+ format: 'markdown',
48
+ failOn: null, // null | 'HIGH' | 'MEDIUM' | 'LOW'
49
+ diffFile: null, // for tests: read diff from a file instead of git
50
+ projectRoot: process.cwd(),
51
+ help: false,
52
+ };
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ switch (a) {
56
+ case '--base': args.base = argv[++i]; break;
57
+ case '--head': args.head = argv[++i]; break;
58
+ case '--format': args.format = argv[++i]; break;
59
+ case '--fail-on': args.failOn = (argv[++i] || '').toUpperCase(); break;
60
+ case '--diff-file': args.diffFile = argv[++i]; break;
61
+ case '--project': args.projectRoot = path.resolve(argv[++i]); break;
62
+ case '--help':
63
+ case '-h': args.help = true; break;
64
+ default:
65
+ // Unknown flag — fail loudly so typos in CI are visible.
66
+ if (a.startsWith('--')) {
67
+ throw new Error(`unknown flag: ${a}`);
68
+ }
69
+ }
70
+ }
71
+ if (!['markdown', 'json'].includes(args.format)) {
72
+ throw new Error(`--format must be 'markdown' or 'json' (got '${args.format}')`);
73
+ }
74
+ if (args.failOn && !['HIGH', 'MEDIUM', 'LOW'].includes(args.failOn)) {
75
+ throw new Error(`--fail-on must be HIGH | MEDIUM | LOW (got '${args.failOn}')`);
76
+ }
77
+ return args;
78
+ }
79
+
80
+ function printUsage() {
81
+ process.stdout.write(`
82
+ Usage: carto pr-impact [options]
83
+
84
+ Computes the impact of a PR's diff against the current carto index and
85
+ renders a markdown or JSON report. Designed for the carto GitHub Action
86
+ but usable standalone.
87
+
88
+ Options:
89
+ --base <ref> Git ref the PR branched from (e.g. origin/main)
90
+ --head <ref> Git ref of the PR head (default: HEAD)
91
+ --format <fmt> markdown (default) | json
92
+ --fail-on <severity> Exit non-zero if risk >= severity (HIGH | MEDIUM | LOW)
93
+ --diff-file <path> Read diff text from a file instead of running git diff
94
+ (primarily for testing)
95
+ --project <path> Project root (default: cwd)
96
+ --help, -h Show this help
97
+
98
+ Exit codes:
99
+ 0 Normal — comment rendered.
100
+ 1 Misuse, missing index, or git failure.
101
+ 2 --fail-on threshold tripped.
102
+
103
+ `);
104
+ }
105
+
106
+ /**
107
+ * runGitDiff(projectRoot, base, head) → diffText
108
+ *
109
+ * Captures the unified diff between two refs. Three-dot syntax (`base...head`)
110
+ * matches what GitHub uses for PR diffs — only the changes introduced on
111
+ * the head branch since it diverged from base, not changes that landed on
112
+ * base in the meantime.
113
+ */
114
+ function runGitDiff(projectRoot, base, head) {
115
+ if (!base) {
116
+ throw new Error('--base is required (or use --diff-file to supply a diff manually)');
117
+ }
118
+ let out;
119
+ try {
120
+ out = execFileSync('git', ['diff', '--unified=3', `${base}...${head}`], {
121
+ cwd: projectRoot,
122
+ encoding: 'utf8',
123
+ maxBuffer: 64 * 1024 * 1024, // 64 MB — large PRs are rare but real
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ });
126
+ } catch (err) {
127
+ const stderr = (err.stderr || '').toString().trim();
128
+ throw new Error(
129
+ `git diff ${base}...${head} failed${stderr ? `: ${stderr}` : ''}`
130
+ );
131
+ }
132
+ return out;
133
+ }
134
+
135
+ const RISK_BADGE = {
136
+ HIGH: '🔴 HIGH',
137
+ MEDIUM: '🟡 MEDIUM',
138
+ LOW: '🟢 LOW',
139
+ SAFE: '✅ SAFE',
140
+ };
141
+
142
+ const RISK_RANK = { SAFE: 0, LOW: 1, MEDIUM: 2, HIGH: 3 };
143
+
144
+ /**
145
+ * collectImpact(projectRoot, diffText) → { result, perFile, domains, highImpact }
146
+ *
147
+ * - `result` — full validateDiff(...) output (violations + blast
148
+ * radius + risk).
149
+ * - `perFile` — Map<path, { blastRadius, routes, domains }> for files
150
+ * in the diff. Skipped for files not in the index
151
+ * (pure adds, renames where the new path didn't exist
152
+ * at sync time).
153
+ * - `domains` — sorted Array<string> of all domains touched by the
154
+ * changed files (used for the headline sentence).
155
+ * - `highImpact` — { file, dependents } for the changed file with the
156
+ * most direct dependents, or null if all are 0.
157
+ */
158
+ function collectImpact(projectRoot, diffText) {
159
+ const cartoDir = path.join(projectRoot, '.carto');
160
+ const dbPath = path.join(cartoDir, 'carto.db');
161
+ if (!fs.existsSync(dbPath)) {
162
+ throw new Error(
163
+ `No carto index at ${dbPath}. Run \`carto init\` (or \`carto sync\` ` +
164
+ `if the index already exists) before \`carto pr-impact\`.`
165
+ );
166
+ }
167
+
168
+ const store = new SQLiteStore(projectRoot);
169
+ store.open({ readonly: true });
170
+ let sidecar = null;
171
+ try {
172
+ sidecar = ensureBitmapFresh(cartoDir, store);
173
+ } catch (err) {
174
+ process.stderr.write(
175
+ `[CARTO] bitmap unavailable, validation will use SQLite-only path: ` +
176
+ `${err.message || err}\n`
177
+ );
178
+ }
179
+
180
+ const result = validateDiff(store, sidecar, diffText);
181
+
182
+ // Per-file rich detail via StoreAdapter (matches what `carto impact <file>`
183
+ // returns — routes, domain). We open a second adapter pointed at the same
184
+ // DB rather than re-implementing the formatter here.
185
+ const adapter = new StoreAdapter();
186
+ // Skip the indexer — we know the DB exists. Open the store directly.
187
+ adapter._store = store;
188
+ adapter._projectRoot = projectRoot;
189
+
190
+ const perFile = new Map();
191
+ const domainSet = new Set();
192
+ let highImpact = null;
193
+ for (const f of result.diff) {
194
+ if (f.kind === 'delete') continue;
195
+ let br = null;
196
+ try {
197
+ br = adapter.getBlastRadius(f.path);
198
+ } catch {
199
+ // File not in index — most often a pure add. Leave perFile entry
200
+ // off the map; renderer skips files it doesn't have detail for.
201
+ }
202
+ if (!br) continue;
203
+ perFile.set(f.path, {
204
+ blastRadius: br.dependentFiles.length,
205
+ directlyAffected: br.directlyAffected.files,
206
+ routes: br.routesImpacted,
207
+ domains: br.domainsImpacted,
208
+ });
209
+ for (const d of br.domainsImpacted) domainSet.add(d);
210
+ if (
211
+ !highImpact ||
212
+ br.directlyAffected.files > highImpact.dependents
213
+ ) {
214
+ highImpact = {
215
+ file: f.path,
216
+ dependents: br.directlyAffected.files,
217
+ };
218
+ }
219
+ }
220
+
221
+ store.close();
222
+
223
+ return {
224
+ result,
225
+ perFile,
226
+ domains: [...domainSet].sort(),
227
+ highImpact: highImpact && highImpact.dependents > 0 ? highImpact : null,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * renderMarkdown(impact) → string
233
+ *
234
+ * Renders the PR comment body. Wraps the entire body in a
235
+ * `<!-- carto-impact-report -->` HTML comment marker so the GitHub
236
+ * Action can find-and-update its previous comment instead of posting
237
+ * a duplicate every commit.
238
+ *
239
+ * Sections:
240
+ * - Headline sentence ("This PR touches X and Y domains.")
241
+ * - Metric table (Risk · Blast radius · Files changed · Violations
242
+ * introduced · High-impact file)
243
+ * - Affected routes (collapsible) — only if any
244
+ * - Cross-domain violations (collapsible) — only if any
245
+ * - Suggestions (collapsible) — only if any
246
+ */
247
+ function renderMarkdown(impact) {
248
+ const { result, perFile, domains, highImpact } = impact;
249
+ const out = [];
250
+ out.push(MARKER);
251
+ out.push('## 🗺️ Carto Impact Report');
252
+ out.push('');
253
+
254
+ if (result.diff.length === 0) {
255
+ out.push('_No file changes detected in this diff._');
256
+ out.push('');
257
+ return out.join('\n');
258
+ }
259
+
260
+ // Headline sentence.
261
+ if (domains.length === 0) {
262
+ out.push(`This PR touches **${result.diff.length} file(s)** in unmapped domains.`);
263
+ } else if (domains.length === 1) {
264
+ out.push(`This PR touches the **${domains[0]}** domain.`);
265
+ } else if (domains.length === 2) {
266
+ out.push(`This PR touches **${domains[0]}** and **${domains[1]}** domains.`);
267
+ } else {
268
+ const head = domains.slice(0, -1).map((d) => `**${d}**`).join(', ');
269
+ const tail = `**${domains[domains.length - 1]}**`;
270
+ out.push(`This PR touches ${head}, and ${tail} domains.`);
271
+ }
272
+ out.push('');
273
+
274
+ // Metric table.
275
+ const crossDomainCount = result.violations.filter(
276
+ (v) => v.kind === 'cross_domain'
277
+ ).length;
278
+ out.push('| Metric | Value |');
279
+ out.push('|--------|-------|');
280
+ out.push(`| **Risk** | ${RISK_BADGE[result.risk] || result.risk} |`);
281
+ out.push(`| Blast radius (union) | ${result.blast_radius.union} files |`);
282
+ out.push(`| Files changed | ${result.diff.length} |`);
283
+ out.push(`| Cross-domain violations introduced | ${crossDomainCount} |`);
284
+ if (highImpact) {
285
+ out.push(
286
+ `| High-impact file changed | \`${highImpact.file}\` (${highImpact.dependents} direct dependents) |`
287
+ );
288
+ }
289
+ out.push('');
290
+
291
+ // Routes section — flatten per-file routes, dedupe by method+path.
292
+ const routeRows = [];
293
+ const seenRoutes = new Set();
294
+ for (const detail of perFile.values()) {
295
+ for (const r of detail.routes || []) {
296
+ const key = `${r.method} ${r.path}`;
297
+ if (seenRoutes.has(key)) continue;
298
+ seenRoutes.add(key);
299
+ routeRows.push(r);
300
+ }
301
+ }
302
+ if (routeRows.length > 0) {
303
+ out.push(`<details>`);
304
+ out.push(`<summary>Affected routes (${routeRows.length})</summary>`);
305
+ out.push('');
306
+ for (const r of routeRows) {
307
+ out.push(`- \`${r.method} ${r.path}\` — risk: ${r.risk}`);
308
+ }
309
+ out.push('');
310
+ out.push(`</details>`);
311
+ out.push('');
312
+ }
313
+
314
+ // Cross-domain violations.
315
+ const crossViolations = result.violations.filter(
316
+ (v) => v.kind === 'cross_domain'
317
+ );
318
+ if (crossViolations.length > 0) {
319
+ out.push(`<details>`);
320
+ out.push(
321
+ `<summary>Cross-domain violations (${crossViolations.length})</summary>`
322
+ );
323
+ out.push('');
324
+ for (const v of crossViolations) {
325
+ out.push(
326
+ `- \`${v.file}\` now imports from \`${v.toFile}\` (${v.fromDomain}→${v.toDomain})`
327
+ );
328
+ }
329
+ out.push('');
330
+ out.push(`</details>`);
331
+ out.push('');
332
+ }
333
+
334
+ // High-blast violations as a separate section so reviewers see them
335
+ // even when no cross-domain edges exist.
336
+ const blastViolations = result.violations.filter(
337
+ (v) => v.kind === 'high_blast'
338
+ );
339
+ if (blastViolations.length > 0) {
340
+ out.push(`<details>`);
341
+ out.push(
342
+ `<summary>High-blast files modified (${blastViolations.length})</summary>`
343
+ );
344
+ out.push('');
345
+ for (const v of blastViolations) {
346
+ out.push(`- \`${v.file}\` — ${v.blast_radius} dependents (${v.severity})`);
347
+ }
348
+ out.push('');
349
+ out.push(`</details>`);
350
+ out.push('');
351
+ }
352
+
353
+ // Suggestions.
354
+ if (result.suggestions.length > 0) {
355
+ out.push(`<details>`);
356
+ out.push(`<summary>Suggestions (${result.suggestions.length})</summary>`);
357
+ out.push('');
358
+ for (const s of result.suggestions) {
359
+ out.push(`- ${s.message}`);
360
+ }
361
+ out.push('');
362
+ out.push(`</details>`);
363
+ out.push('');
364
+ }
365
+
366
+ out.push(`<sub>Generated by [carto-md](https://www.npmjs.com/package/carto-md).</sub>`);
367
+ return out.join('\n');
368
+ }
369
+
370
+ /**
371
+ * renderJson(impact) → object
372
+ *
373
+ * Stable contract for programmatic consumers. Locked-in shape (any
374
+ * additions go at the end of the object, no key removals before a
375
+ * major version bump):
376
+ *
377
+ * {
378
+ * marker: string,
379
+ * risk: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH',
380
+ * files_changed: number,
381
+ * blast_radius_union: number,
382
+ * domains_touched: string[],
383
+ * high_impact_file: { file, dependents } | null,
384
+ * violations: [{ kind, severity, file, message, ... }],
385
+ * suggestions: [{ kind, file, message, ... }],
386
+ * per_file: {
387
+ * [path]: {
388
+ * blast_radius: number,
389
+ * directly_affected: number,
390
+ * domains: string[],
391
+ * routes: [{ method, path, risk }],
392
+ * }
393
+ * }
394
+ * }
395
+ */
396
+ function renderJson(impact) {
397
+ const { result, perFile, domains, highImpact } = impact;
398
+ const per_file = {};
399
+ for (const [path_, detail] of perFile) {
400
+ per_file[path_] = {
401
+ blast_radius: detail.blastRadius,
402
+ directly_affected: detail.directlyAffected,
403
+ domains: detail.domains,
404
+ routes: detail.routes,
405
+ };
406
+ }
407
+ return {
408
+ marker: MARKER,
409
+ risk: result.risk,
410
+ files_changed: result.diff.length,
411
+ blast_radius_union: result.blast_radius.union,
412
+ domains_touched: domains,
413
+ high_impact_file: highImpact,
414
+ violations: result.violations,
415
+ suggestions: result.suggestions,
416
+ per_file,
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Decide exit code based on --fail-on threshold.
422
+ * - failOn null → always 0
423
+ * - failOn set → 2 if risk >= threshold, else 0
424
+ */
425
+ function decideExitCode(risk, failOn) {
426
+ if (!failOn) return 0;
427
+ return RISK_RANK[risk] >= RISK_RANK[failOn] ? 2 : 0;
428
+ }
429
+
430
+ /**
431
+ * run({ argv, stdout, stderr }) → exitCode
432
+ *
433
+ * Pure function — no side effects on `process`. Tests pass an in-memory
434
+ * argv + capture stdout via a writable stream.
435
+ */
436
+ function run({ argv, stdout, stderr } = {}) {
437
+ argv = argv || process.argv.slice(3);
438
+ stdout = stdout || process.stdout;
439
+ stderr = stderr || process.stderr;
440
+
441
+ let args;
442
+ try {
443
+ args = parseArgs(argv);
444
+ } catch (err) {
445
+ stderr.write(`[CARTO] ${err.message}\n`);
446
+ return 1;
447
+ }
448
+
449
+ if (args.help) {
450
+ printUsage();
451
+ return 0;
452
+ }
453
+
454
+ let diffText;
455
+ try {
456
+ if (args.diffFile) {
457
+ diffText = fs.readFileSync(args.diffFile, 'utf8');
458
+ } else {
459
+ diffText = runGitDiff(args.projectRoot, args.base, args.head);
460
+ }
461
+ } catch (err) {
462
+ stderr.write(`[CARTO] ${err.message}\n`);
463
+ return 1;
464
+ }
465
+
466
+ let impact;
467
+ try {
468
+ impact = collectImpact(args.projectRoot, diffText);
469
+ } catch (err) {
470
+ stderr.write(`[CARTO] ${err.message}\n`);
471
+ return 1;
472
+ }
473
+
474
+ if (args.format === 'json') {
475
+ stdout.write(JSON.stringify(renderJson(impact), null, 2) + '\n');
476
+ } else {
477
+ stdout.write(renderMarkdown(impact) + '\n');
478
+ }
479
+
480
+ return decideExitCode(impact.result.risk, args.failOn);
481
+ }
482
+
483
+ module.exports = {
484
+ run,
485
+ // Exported for tests:
486
+ parseArgs,
487
+ collectImpact,
488
+ renderMarkdown,
489
+ renderJson,
490
+ decideExitCode,
491
+ MARKER,
492
+ };
493
+
494
+ // CLI entry — when invoked directly (not required for tests):
495
+ if (require.main === module) {
496
+ process.exit(run());
497
+ }
package/src/cli/serve.js CHANGED
@@ -7,7 +7,7 @@ function run(projectRoot) {
7
7
  const dbPath = path.join(projectRoot, '.carto', 'carto.db');
8
8
 
9
9
  if (!fs.existsSync(dbPath)) {
10
- // Could be pre-2.0.4 install with only map.json, or never initialized.
10
+ // Could be an old install with only map.json, or never initialized.
11
11
  const mapPath = path.join(projectRoot, '.carto', 'map.json');
12
12
  if (fs.existsSync(mapPath)) {
13
13
  console.error('[CARTO] Legacy index found (map.json) but no SQLite DB. Run `carto init` to upgrade your index.');
package/src/cli/watch.js CHANGED
@@ -169,6 +169,12 @@ async function run(projectRoot) {
169
169
  process.exit(1);
170
170
  }
171
171
 
172
+ // `carto watch` is opt-in. Git hooks + lazy MCP re-parse
173
+ // handle the 90% case automatically with no background process. Surface
174
+ // that to anyone who runs this command so they know it's not required.
175
+ console.log('[CARTO] Note: `carto watch` is opt-in. Git hooks + lazy MCP re-parse (installed by `carto init`) keep the index fresh without a background process.');
176
+ console.log('[CARTO] Use `carto watch` for AI-heavy workflows where many files are edited between commits and you want sub-second freshness.');
177
+
172
178
  // Initial full sync using V2
173
179
  console.log('[CARTO] Starting initial sync...');
174
180
  await runSyncV2({
@@ -15,6 +15,9 @@ parentPort.on('message', (task) => {
15
15
  try {
16
16
  content = fs.readFileSync(filePath, 'utf-8');
17
17
  } catch (err) {
18
+ // Real I/O failure (file deleted between discovery and extraction,
19
+ // permission denied, etc.) — no point trying to extract. Caller
20
+ // turns this into a skip; nothing reaches the index.
18
21
  parentPort.postMessage({ id, error: err.message, result: null });
19
22
  return;
20
23
  }
@@ -27,17 +30,33 @@ parentPort.on('message', (task) => {
27
30
  return;
28
31
  }
29
32
 
30
- let extracted;
33
+ // Capture extractor failures as breadcrumbs instead of
34
+ // dropping the file. The file still gets indexed (with empty
35
+ // extraction arrays) so its existence and imports are visible — and
36
+ // the failure shows up in `carto check` instead of vanishing.
37
+ const errors = [];
38
+
39
+ let extracted = { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
31
40
  try {
32
41
  extracted = plugin.extract(content, relPath);
42
+ // Plugin-internal failure breadcrumbs — see extractFile().
43
+ if (Array.isArray(extracted._errors) && extracted._errors.length > 0) {
44
+ for (const e of extracted._errors) {
45
+ if (e && e.phase && e.message) errors.push({ phase: e.phase, message: e.message });
46
+ }
47
+ }
33
48
  } catch (err) {
34
- parentPort.postMessage({ id, error: err.message, result: null });
35
- return;
49
+ errors.push({ phase: 'extract', message: err.message || String(err) });
36
50
  }
37
51
 
38
52
  // Use tree-sitter imports if the plugin produced them (faster, no file I/O)
39
53
  // Fall back to the regex-based extractImports for resolution
40
- const imports = extractImports(content, filePath, projectRoot);
54
+ let imports = [];
55
+ try {
56
+ imports = extractImports(content, filePath, projectRoot);
57
+ } catch (err) {
58
+ errors.push({ phase: 'imports', message: err.message || String(err) });
59
+ }
41
60
 
42
61
  // Attach tree-sitter symbols if available (richer than legacy functions array)
43
62
  const tsSymbols = extracted._tsSymbols || null;
@@ -56,6 +75,7 @@ parentPort.on('message', (task) => {
56
75
  storageKeys: extracted.storageKeys || [],
57
76
  imports,
58
77
  tsSymbols,
78
+ errors,
59
79
  }
60
80
  });
61
81
  });