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
@@ -9,13 +9,16 @@ const { detectChangedFiles, hashFile, hashContent } = require('./change-detector
9
9
  const { loadLanguagePlugins, getPluginForFile } = require('../extractors/loader');
10
10
  const { extractImports } = require('../extractors/imports');
11
11
  const { buildStackLine } = require('../extractors/stack');
12
- const { clusterByDomain } = require('../agents/domains');
12
+ const { clusterByDomain, setDomainMap } = require('../agents/domains');
13
13
  const { clusterByGraph } = require('../agents/leiden');
14
14
  const { formatSections, formatDomainFile } = require('../agents/formatter');
15
15
  const { mergeIntoAgentsMd } = require('../agents/merger');
16
16
  const { scanStructure } = require('../agents/scan-structure');
17
17
  const { WorkerPool, POOL_SIZE } = require('../engine/worker-pool');
18
18
  const { parseCartoIgnore } = require('../security/ignore');
19
+ const { loadCartoConfig, applyAnchors } = require('./config-loader');
20
+ const { buildFromStore: buildBitmap, saveToDisk: saveBitmap } = require('../bitmap/sidecar');
21
+ const { invalidate: invalidateBitmap } = require('../bitmap/index');
19
22
 
20
23
  const plugins = loadLanguagePlugins();
21
24
 
@@ -40,7 +43,7 @@ const CODE_EXTS = new Set([
40
43
  const JS_LIKE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
41
44
 
42
45
  /**
43
- * isTestFile(relPath) → true if the file is a test/spec/stories file
46
+ * isTestFile(relPath) → true if the file is a test or stories file.
44
47
  * Ported from V1 detector/files.js exclusion patterns.
45
48
  * R: test_*, test-*, *_test.r (case-insensitive)
46
49
  * Python: test_*.py, *_test.py
@@ -101,6 +104,14 @@ function discoverFiles(projectRoot) {
101
104
  /**
102
105
  * extractFile(relPath, projectRoot)
103
106
  * Extracts all data from a single file. Returns extraction result or null.
107
+ *
108
+ * Instead of just `console.warn`-ing on extractor failures, the
109
+ * returned result includes an `errors` array of `{ phase, message }` so
110
+ * the caller can persist them in `extraction_errors`. Phases:
111
+ * - 'extract' → plugin.extract threw (parse error, plugin bug, ...)
112
+ * - 'imports' → extractImports threw
113
+ * On a plugin.extract throw the file is still indexed with empty
114
+ * routes/models/etc. — visibility beats silent skipping.
104
115
  */
105
116
  function extractFile(relPath, projectRoot) {
106
117
  const fullPath = path.resolve(projectRoot, relPath);
@@ -110,12 +121,23 @@ function extractFile(relPath, projectRoot) {
110
121
  const { content, hash } = fileResult;
111
122
  const plugin = getPluginForFile(plugins, fullPath);
112
123
 
124
+ const errors = [];
113
125
  let extracted = { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
114
126
  if (plugin) {
115
127
  try {
116
128
  extracted = plugin.extract(content, relPath);
129
+ // Plugins may surface their own internal failures (e.g. Babel parse
130
+ // errors that they recover from with tree-sitter) via `_errors`.
131
+ // Promote those into our breadcrumb stream so they show up in
132
+ // `carto check` rather than only on stderr.
133
+ if (Array.isArray(extracted._errors) && extracted._errors.length > 0) {
134
+ for (const e of extracted._errors) {
135
+ if (e && e.phase && e.message) errors.push({ phase: e.phase, message: e.message });
136
+ }
137
+ }
117
138
  } catch (err) {
118
139
  console.warn(`[CARTO] Extraction error for ${relPath}: ${err.message}`);
140
+ errors.push({ phase: 'extract', message: err.message || String(err) });
119
141
  }
120
142
  }
121
143
 
@@ -123,7 +145,9 @@ function extractFile(relPath, projectRoot) {
123
145
  let imports = [];
124
146
  try {
125
147
  imports = extractImports(content, fullPath, projectRoot);
126
- } catch {}
148
+ } catch (err) {
149
+ errors.push({ phase: 'imports', message: err.message || String(err) });
150
+ }
127
151
 
128
152
  const stat = fs.statSync(fullPath);
129
153
 
@@ -135,7 +159,8 @@ function extractFile(relPath, projectRoot) {
135
159
  language: detectLanguage(path.extname(relPath).toLowerCase()),
136
160
  content,
137
161
  extracted,
138
- imports
162
+ imports,
163
+ errors
139
164
  };
140
165
  }
141
166
 
@@ -206,7 +231,7 @@ async function runSyncV2(config) {
206
231
  const batchTx = store.db.transaction(() => {
207
232
  for (const result of results) {
208
233
  if (!result) continue;
209
- const { relPath, routes, models, functions, envVars, dbTables, imports } = result;
234
+ const { relPath, routes, models, functions, envVars, dbTables, imports, errors } = result;
210
235
 
211
236
  const stat = (() => {
212
237
  try { return fs.statSync(path.resolve(projectRoot, relPath)); } catch { return null; }
@@ -241,7 +266,8 @@ async function runSyncV2(config) {
241
266
  routes: routes || [],
242
267
  models: models || [],
243
268
  envVars: envVars || [],
244
- dbTables: (dbTables || []).map(t => ({ table: t.table || t.table_name || t.name, operation: t.operation }))
269
+ dbTables: (dbTables || []).map(t => ({ table: t.table || t.table_name || t.name, operation: t.operation })),
270
+ errors: errors || []
245
271
  });
246
272
  }
247
273
  });
@@ -278,7 +304,8 @@ async function runSyncV2(config) {
278
304
  routes: result.extracted.routes || [],
279
305
  models: result.extracted.models || [],
280
306
  envVars: result.extracted.envVars || [],
281
- dbTables: result.extracted.dbTables || []
307
+ dbTables: result.extracted.dbTables || [],
308
+ errors: result.errors || []
282
309
  });
283
310
 
284
311
  done++;
@@ -293,6 +320,13 @@ async function runSyncV2(config) {
293
320
 
294
321
  // 7. Compute reverse deps (only if imports changed)
295
322
  if (toProcess.length > 0) {
323
+ // Repair imports whose `to_file_id` was null because the target
324
+ // file hadn't been upserted yet during this file's extraction
325
+ // (alphabetical-order chicken-and-egg). Cheap one-shot UPDATE.
326
+ const repaired = store.resolveUnresolvedImports();
327
+ if (repaired > 0) {
328
+ console.log(`[CARTO] Resolved ${repaired} previously-unresolved imports`);
329
+ }
296
330
  console.log('[CARTO] Computing reverse dependencies...');
297
331
  store.computeReverseDeps(5);
298
332
  } else {
@@ -302,15 +336,25 @@ async function runSyncV2(config) {
302
336
  // 7b. Domain clustering (only if files changed)
303
337
  if (toProcess.length > 0) {
304
338
  const importGraph = store.getImportGraph();
305
-
306
- // Use graph-based Leiden+CPM clustering for any-repo domain detection
307
- // Falls back to keyword clustering if graph is too sparse (< 10 edges)
308
339
  const edgeCount = Object.values(importGraph).reduce((s, d) => s + d.length, 0);
340
+ const fileCount = allFiles.length;
341
+
342
+ // 10b — Load carto.config.json for custom domain keywords + anchors
343
+ const cartoConfig = loadCartoConfig(projectRoot);
344
+ if (cartoConfig) {
345
+ // Feed custom keywords into the keyword-based domain map
346
+ const customKeywords = {};
347
+ for (const [name, cfg] of Object.entries(cartoConfig.domains)) {
348
+ if (cfg.keywords && cfg.keywords.length > 0) customKeywords[name] = cfg.keywords;
349
+ }
350
+ if (Object.keys(customKeywords).length > 0) setDomainMap(customKeywords);
351
+ }
309
352
 
353
+ const strategy = selectClusteringStrategy(fileCount, edgeCount);
310
354
  let fileAssignments; // Map<filePath, domainName>
311
355
 
312
- if (edgeCount >= 10) {
313
- // Graph-based: works on any repo (vscode, zed, game engines, etc.)
356
+ if (strategy.method === 'graph') {
357
+ // Merge default + config keywords for graph naming
314
358
  const keywordSeeds = {
315
359
  AUTH: ['auth', 'login', 'session', 'oauth', 'jwt', 'password'],
316
360
  PAYMENTS: ['payment', 'billing', 'stripe', 'invoice', 'subscription'],
@@ -319,97 +363,38 @@ async function runSyncV2(config) {
319
363
  EVENTS: ['webhook', 'event', 'queue', 'job', 'worker', 'cron'],
320
364
  NOTIFICATIONS: ['email', 'notification', 'mail', 'sms', 'alert'],
321
365
  };
322
-
323
- // Compute graph density to decide clustering strategy
324
- // Monorepos have sparse cross-package edges → graph clustering produces
325
- // hundreds of micro-communities. Use keyword fallback for sparse graphs.
326
- const fileCount = allFiles.length;
327
- const density = edgeCount / Math.max(fileCount, 1); // avg edges per file
328
-
329
- let rawAssignments;
330
-
331
- if (density >= 1.5) {
332
- // Dense enough for graph clustering (typical app repos)
333
- // Scale gamma: larger repos need coarser resolution
334
- let gamma = 0.03;
335
- if (fileCount > 5000) gamma = 0.08;
336
- else if (fileCount > 2000) gamma = 0.05;
337
- else if (fileCount > 500) gamma = 0.04;
338
-
339
- rawAssignments = clusterByGraph(importGraph, gamma, keywordSeeds);
340
-
341
- // Merge micro-communities (< minSize files) into CORE
342
- const minSize = Math.max(3, Math.floor(fileCount / 100));
343
- const domainCounts = new Map();
344
- for (const domain of rawAssignments.values()) {
345
- domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
366
+ if (cartoConfig) {
367
+ for (const [name, cfg] of Object.entries(cartoConfig.domains)) {
368
+ if (cfg.keywords && cfg.keywords.length > 0) {
369
+ keywordSeeds[name] = [...(keywordSeeds[name] || []), ...cfg.keywords];
370
+ }
346
371
  }
372
+ }
347
373
 
348
- fileAssignments = new Map();
349
- for (const [fp, domain] of rawAssignments) {
350
- const count = domainCounts.get(domain) || 0;
351
- fileAssignments.set(fp, count >= minSize ? domain : 'CORE');
352
- }
353
- } else {
354
- // Sparse graph (monorepo, Rust workspace, etc.)
355
- // Fall back to keyword-based clustering which works better here
356
- const routes = store.getRoutes();
357
- const models = store.getModels();
358
- const envVars = store.getEnvVars();
359
- const routesByFile = buildRoutesByFile(store);
360
- const functions = {};
361
- const allFilesForDomain = store.getAllFiles();
362
- for (const f of allFilesForDomain) {
363
- const syms = store.db.prepare(
364
- 'SELECT name FROM symbols WHERE file_id = ? AND kind = ?'
365
- ).all(f.id, 'function').map(r => r.name);
366
- if (syms.length > 0) functions[f.path] = syms;
367
- }
368
- const dbTables = store.db.prepare(`
369
- SELECT dt.table_name, dt.operation, f.path as file
370
- FROM db_tables dt JOIN files f ON dt.file_id = f.id
371
- `).all().map(r => ({ table: r.table_name, operation: r.operation, file: r.file }));
372
-
373
- const domainResult = clusterByDomain({
374
- routes, models: models.filter(m => m.name).map(m => ({ ...m, className: m.name })),
375
- functions, envVars, dbTables, fileMap: [], routesByFile, importGraph
376
- });
374
+ const rawAssignments = clusterByGraph(importGraph, strategy.gamma, keywordSeeds);
377
375
 
378
- fileAssignments = new Map();
379
- for (const [domainName, cluster] of Object.entries(domainResult)) {
380
- for (const fp of (cluster.files || [])) fileAssignments.set(fp, domainName);
381
- }
376
+ // Merge micro-communities (< minSize files) into CORE
377
+ const domainCounts = new Map();
378
+ for (const domain of rawAssignments.values()) {
379
+ domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
382
380
  }
383
- } else {
384
- // Sparse graph — fall back to keyword-based clustering
385
- const routes = store.getRoutes();
386
- const models = store.getModels();
387
- const envVars = store.getEnvVars();
388
- const routesByFile = buildRoutesByFile(store);
389
- const functions = {};
390
- const allFilesForDomain = store.getAllFiles();
391
- for (const f of allFilesForDomain) {
392
- const syms = store.db.prepare(
393
- 'SELECT name FROM symbols WHERE file_id = ? AND kind = ?'
394
- ).all(f.id, 'function').map(r => r.name);
395
- if (syms.length > 0) functions[f.path] = syms;
396
- }
397
- const dbTables = store.db.prepare(`
398
- SELECT dt.table_name, dt.operation, f.path as file
399
- FROM db_tables dt JOIN files f ON dt.file_id = f.id
400
- `).all().map(r => ({ table: r.table_name, operation: r.operation, file: r.file }));
401
-
402
- const domainResult = clusterByDomain({
403
- routes, models: models.filter(m => m.name).map(m => ({ ...m, className: m.name })),
404
- functions, envVars, dbTables, fileMap: [], routesByFile, importGraph
405
- });
406
-
407
381
  fileAssignments = new Map();
408
- for (const [domainName, cluster] of Object.entries(domainResult)) {
409
- for (const fp of (cluster.files || [])) fileAssignments.set(fp, domainName);
382
+ for (const [fp, domain] of rawAssignments) {
383
+ fileAssignments.set(fp, (domainCounts.get(domain) || 0) >= strategy.minSize ? domain : 'CORE');
410
384
  }
385
+ } else {
386
+ fileAssignments = runKeywordClustering(store, importGraph);
411
387
  }
412
388
 
389
+ // 10b — Apply anchor pinning from config
390
+ if (cartoConfig) applyAnchors(fileAssignments, cartoConfig);
391
+
392
+ // Reset domain map to defaults after use (avoid polluting subsequent runs)
393
+ if (cartoConfig) setDomainMap(null);
394
+
395
+ // 10c — Domain stability metric: compare to previous snapshot
396
+ computeDomainStability(store, fileAssignments);
397
+
413
398
  // Write domain assignments to SQLite
414
399
  store.clearDomainAssignments();
415
400
  // Group by domain name
@@ -436,6 +421,20 @@ async function runSyncV2(config) {
436
421
  store.setMeta('index_duration_ms', String(elapsed));
437
422
  store.setMeta('last_full_sync', new Date().toISOString());
438
423
 
424
+ // Extraction error count for fast access by `carto check`,
425
+ // `carto init` summary, and MCP get_architecture.
426
+ const extractionErrorCount = store.getExtractionErrorCount();
427
+ store.setMeta('extraction_error_count', String(extractionErrorCount));
428
+
429
+ // Record unavailable grammars so `carto check` and MCP
430
+ // `get_architecture` can surface language coverage gaps.
431
+ const { getUnavailableLanguages } = require('../extractors/tree-sitter-parser');
432
+ const unavailableLangs = getUnavailableLanguages();
433
+ store.setMeta('unavailable_languages_json', JSON.stringify(unavailableLangs));
434
+ if (unavailableLangs.length > 0) {
435
+ console.log(`[CARTO] ⚠️ ${unavailableLangs.length} language grammar${unavailableLangs.length === 1 ? '' : 's'} unavailable (${unavailableLangs.join(', ')}) — using regex fallback`);
436
+ }
437
+
439
438
  // Detect stack
440
439
  if (toProcess.length > 0) {
441
440
  const changedContents = [];
@@ -457,11 +456,177 @@ async function runSyncV2(config) {
457
456
  await generateOutputs(store, config, projectRoot, store.getImportGraph());
458
457
  }
459
458
 
459
+ // Build bitmap sidecar. Always run — the bitmap layer is
460
+ // derived + disposable, so we rebuild on every sync to keep
461
+ // `.carto/bitmap.bin` aligned with the SQLite source of truth.
462
+ // Best-effort: a build/persist failure is logged but never fails the
463
+ // sync (MCP tools fall back to SQLite when bitmap is unavailable).
464
+ //
465
+ // The same `sidecar` object is reused below to emit the public
466
+ // ANCI v0.1 export (`.carto/anci.{yaml,bin}`) — building it twice
467
+ // would be wasteful, so the build/save sequence shares scope.
468
+ let sidecarForAnci = null;
469
+ try {
470
+ const cartoDir = path.join(projectRoot, '.carto');
471
+ const sidecar = buildBitmap(store);
472
+ saveBitmap(cartoDir, sidecar);
473
+ sidecarForAnci = sidecar;
474
+ // Drop any in-memory cache held by the same Node process — e.g. when
475
+ // sync runs from inside `carto serve`'s lazy reparse path. Next MCP
476
+ // query loads the freshly-saved file.
477
+ invalidateBitmap();
478
+ } catch (err) {
479
+ process.stderr.write(
480
+ `[CARTO] bitmap sidecar build failed (queries will use SQLite): ` +
481
+ `${err && err.message ? err.message : err}\n`
482
+ );
483
+ }
484
+
485
+ // Emit ANCI v0.1 public export.
486
+ // Best-effort: failure logs to stderr, never fails the sync.
487
+ // Skipped when the bitmap build above failed (no sidecar to emit).
488
+ if (sidecarForAnci) {
489
+ try {
490
+ const cartoDir = path.join(projectRoot, '.carto');
491
+ const { emitToCartoDir } = require('../anci/emit');
492
+ emitToCartoDir({ cartoDir, sidecar: sidecarForAnci, store });
493
+ } catch (err) {
494
+ process.stderr.write(
495
+ `[CARTO] ANCI publish failed (anci.{yaml,bin} not written): ` +
496
+ `${err && err.message ? err.message : err}\n`
497
+ );
498
+ }
499
+ }
500
+
460
501
  console.log(`[CARTO] Indexed ${toProcess.length} files (${cached} cached) in ${elapsed}ms`);
461
502
  console.log(`[CARTO] Total: ${allFiles.length} files, ${structure.meta.totalRoutes} routes, ${structure.meta.totalImportEdges} import edges`);
462
503
 
504
+ // Surface extraction failures so users notice them
505
+ // immediately, not after they ask the AI a question and get bad
506
+ // answers. Pointed at `carto check` for the full breakdown.
507
+ if (extractionErrorCount > 0) {
508
+ console.log(`[CARTO] ⚠️ ${extractionErrorCount} extraction error${extractionErrorCount === 1 ? '' : 's'} (run \`carto check\` for details)`);
509
+ }
510
+
463
511
  store.close();
464
- return { filesProcessed: toProcess.length, totalFiles: allFiles.length, elapsed };
512
+
513
+ // store.close() runs `PRAGMA wal_checkpoint(TRUNCATE)` which bumps
514
+ // carto.db's mtime to *after* we wrote bitmap.bin and anci.{yaml,bin}.
515
+ // bitmapIsFresh() (src/bitmap/index.js) and `carto inspect` both rely
516
+ // on the invariant `bitmapMtime >= dbMtime` to decide whether the
517
+ // sidecar can be reused or has to be rebuilt — so re-stamp the
518
+ // derived files explicitly past the DB's now-final mtime. Without
519
+ // this, every cold MCP query after a sync sees the bitmap as "stale"
520
+ // and pays an unnecessary rebuild from SQLite.
521
+ //
522
+ // We stat the DB and stamp bitmap/ANCI to (dbMtime + 1ms) rather than
523
+ // using `new Date()` because the wall clock + filesystem timestamp
524
+ // pipeline has sub-millisecond jitter on APFS that can leave the
525
+ // explicit-set mtime *just* under the DB's real mtime. Reading the
526
+ // DB's actual mtime and adding a fixed offset is race-free.
527
+ try {
528
+ const cartoDir = path.join(projectRoot, '.carto');
529
+ const dbPath = path.join(cartoDir, 'carto.db');
530
+ const dbStat = fs.statSync(dbPath);
531
+ const bumpedMs = dbStat.mtimeMs + 1;
532
+ const bumpedDate = new Date(bumpedMs);
533
+ for (const name of ['bitmap.bin', 'anci.bin', 'anci.yaml']) {
534
+ const p = path.join(cartoDir, name);
535
+ try { fs.utimesSync(p, bumpedDate, bumpedDate); } catch {}
536
+ }
537
+ } catch {}
538
+
539
+ return { filesProcessed: toProcess.length, totalFiles: allFiles.length, elapsed, extractionErrorCount };
540
+ }
541
+
542
+ /**
543
+ * selectClusteringStrategy(fileCount, edgeCount) → { method, gamma?, minSize? }
544
+ *
545
+ * Adaptive clustering strategy selection:
546
+ * - <100 files → keyword (kills over-fragmentation for small repos)
547
+ * - density <1.5 → keyword (sparse graph / monorepo)
548
+ * - else → graph with continuous gamma = min(0.10, 0.02 + 0.02·log10(fileCount/10))
549
+ * minSize = clamp(sqrt(fileCount), 5, 20)
550
+ */
551
+ function selectClusteringStrategy(fileCount, edgeCount) {
552
+ if (fileCount < 100) return { method: 'keyword' };
553
+ const density = edgeCount / Math.max(fileCount, 1);
554
+ if (density < 1.5) return { method: 'keyword' };
555
+ const gamma = Math.min(0.10, 0.02 + 0.02 * Math.log10(fileCount / 10));
556
+ const minSize = Math.max(5, Math.min(20, Math.round(Math.sqrt(fileCount))));
557
+ return { method: 'graph', gamma, minSize };
558
+ }
559
+
560
+ /**
561
+ * runKeywordClustering(store, importGraph) → Map<filePath, domainName>
562
+ * Consolidated keyword-based clustering used for small/sparse repos.
563
+ */
564
+ function runKeywordClustering(store, importGraph) {
565
+ const routes = store.getRoutes();
566
+ const models = store.getModels();
567
+ const envVars = store.getEnvVars();
568
+ const routesByFile = buildRoutesByFile(store);
569
+ const functions = {};
570
+ const allFilesForDomain = store.getAllFiles();
571
+ for (const f of allFilesForDomain) {
572
+ const syms = store.db.prepare(
573
+ 'SELECT name FROM symbols WHERE file_id = ? AND kind = ?'
574
+ ).all(f.id, 'function').map(r => r.name);
575
+ if (syms.length > 0) functions[f.path] = syms;
576
+ }
577
+ const dbTables = store.db.prepare(`
578
+ SELECT dt.table_name, dt.operation, f.path as file
579
+ FROM db_tables dt JOIN files f ON dt.file_id = f.id
580
+ `).all().map(r => ({ table: r.table_name, operation: r.operation, file: r.file }));
581
+
582
+ const domainResult = clusterByDomain({
583
+ routes, models: models.filter(m => m.name).map(m => ({ ...m, className: m.name })),
584
+ functions, envVars, dbTables, fileMap: [], routesByFile, importGraph
585
+ });
586
+
587
+ const fileAssignments = new Map();
588
+ for (const [domainName, cluster] of Object.entries(domainResult)) {
589
+ for (const fp of (cluster.files || [])) fileAssignments.set(fp, domainName);
590
+ }
591
+ return fileAssignments;
592
+ }
593
+
594
+ /**
595
+ * computeDomainStability(store, fileAssignments)
596
+ * Compares current assignments to previous snapshot, stores reassignment %.
597
+ * Warns if total>=10 and the reassignment rate exceeds 5%.
598
+ */
599
+ function computeDomainStability(store, fileAssignments) {
600
+ const prevRaw = store.getMeta('previous_domain_snapshot');
601
+ const currentSnapshot = {};
602
+ for (const [fp, domain] of fileAssignments) currentSnapshot[fp] = domain;
603
+
604
+ let driftPct = 0;
605
+ const reassignments = [];
606
+
607
+ if (prevRaw) {
608
+ try {
609
+ const prev = JSON.parse(prevRaw);
610
+ const total = fileAssignments.size;
611
+ let changed = 0;
612
+ for (const [fp, domain] of fileAssignments) {
613
+ if (prev[fp] && prev[fp] !== domain) {
614
+ changed++;
615
+ if (reassignments.length < 20) {
616
+ reassignments.push({ file: fp, from: prev[fp], to: domain });
617
+ }
618
+ }
619
+ }
620
+ driftPct = total > 0 ? (changed / total) * 100 : 0;
621
+ if (total >= 10 && driftPct > 5) {
622
+ console.warn(`[CARTO] ⚠️ Domain stability: ${driftPct.toFixed(1)}% files changed domain (${changed}/${total})`);
623
+ }
624
+ } catch { /* malformed snapshot — treat as first run */ }
625
+ }
626
+
627
+ store.setMeta('previous_domain_snapshot', JSON.stringify(currentSnapshot));
628
+ store.setMeta('domain_stability_drift_pct', driftPct.toFixed(2));
629
+ store.setMeta('last_reassignments_json', JSON.stringify(reassignments));
465
630
  }
466
631
 
467
632
  /**
@@ -618,4 +783,214 @@ function detectLanguage(ext) {
618
783
  return map[ext] || 'unknown';
619
784
  }
620
785
 
621
- module.exports = { runSyncV2, discoverFiles, extractFile, buildFileDataFromStore, generateOutputs, buildRoutesByFile, detectLanguage };
786
+ /**
787
+ * syncFiles(projectRoot, paths, opts?) → { reparsed, skipped, removed, elapsed }
788
+ *
789
+ * Hot re-parse a small set of files. Used by:
790
+ * - The MCP server's lazy mtime check at query time
791
+ * - The optional `carto watch` command (incremental save handler)
792
+ *
793
+ * Each path is mtime+size-checked against the SQLite row. Unchanged files
794
+ * are skipped. Changed files are extracted and written, and reverse_deps
795
+ * are updated incrementally for just the file's neighbors (NOT the whole
796
+ * graph). No discovery pass, no domain reclustering, no AGENTS.md
797
+ * regeneration — this is the freshness equivalent of `git status` and
798
+ * finishes in <50ms for a single file.
799
+ *
800
+ * Files that no longer exist on disk are removed from the index.
801
+ *
802
+ * Opts:
803
+ * store — Optional pre-opened writable SQLiteStore. If omitted, a new
804
+ * connection is opened and closed. Pass an existing store when
805
+ * batching many calls (e.g. the watcher debounce loop).
806
+ */
807
+ function syncFiles(projectRoot, paths, opts = {}) {
808
+ const startTime = Date.now();
809
+ if (!Array.isArray(paths) || paths.length === 0) {
810
+ return { reparsed: 0, skipped: 0, removed: 0, elapsed: 0 };
811
+ }
812
+
813
+ const ownsStore = !opts.store;
814
+ let store = opts.store;
815
+ if (ownsStore) {
816
+ store = new SQLiteStore(projectRoot);
817
+ store.open();
818
+ }
819
+
820
+ let reparsed = 0;
821
+ let skipped = 0;
822
+ let removed = 0;
823
+
824
+ try {
825
+ for (const relPath of paths) {
826
+ if (!relPath || typeof relPath !== 'string') { skipped++; continue; }
827
+
828
+ const fullPath = path.resolve(projectRoot, relPath);
829
+
830
+ // Stat — if missing, treat as deletion
831
+ let stat;
832
+ try {
833
+ stat = fs.statSync(fullPath);
834
+ } catch {
835
+ const existing = store.getFileByPath(relPath);
836
+ if (existing) {
837
+ store.removeFile(relPath);
838
+ updateReverseDepsPartial(store, existing.id);
839
+ removed++;
840
+ }
841
+ continue;
842
+ }
843
+
844
+ const mtime = Math.floor(stat.mtimeMs);
845
+ const size = stat.size;
846
+ const existing = store.getFileByPath(relPath);
847
+
848
+ // Fast path: mtime+size unchanged → DB row is current
849
+ if (existing && existing.mtime === mtime && existing.size === size) {
850
+ skipped++;
851
+ continue;
852
+ }
853
+
854
+ // Skip large files (extractor cap matches runSyncV2 / applyIncrementalV2)
855
+ if (size > 1024 * 1024) { skipped++; continue; }
856
+
857
+ // Extract — single source of truth, same path runSyncV2 uses
858
+ const result = extractFile(relPath, projectRoot);
859
+ if (!result) { skipped++; continue; }
860
+
861
+ // Hash-equal but mtime drifted (e.g. `touch foo.ts`) — just refresh
862
+ // the cached mtime/size so future stat checks short-circuit.
863
+ if (existing && existing.hash === result.hash) {
864
+ store.updateFileMtime(relPath, mtime, size);
865
+ skipped++;
866
+ continue;
867
+ }
868
+
869
+ const fileId = store.upsertFile(relPath, {
870
+ language: result.language,
871
+ hash: result.hash,
872
+ mtime: result.mtime,
873
+ size: result.size
874
+ });
875
+
876
+ const resolvedImports = (result.imports || []).map(impPath => {
877
+ const resolved = store.getFileByPath(impPath);
878
+ return { path: impPath, resolvedFileId: resolved ? resolved.id : null };
879
+ });
880
+
881
+ const symbols = (result.extracted.functions || []).map(name => ({
882
+ name: typeof name === 'string' ? name : name.name || 'unknown',
883
+ kind: 'function',
884
+ exported: true
885
+ }));
886
+
887
+ store.storeExtraction(fileId, {
888
+ imports: resolvedImports,
889
+ symbols,
890
+ routes: result.extracted.routes || [],
891
+ models: result.extracted.models || [],
892
+ envVars: result.extracted.envVars || [],
893
+ dbTables: (result.extracted.dbTables || []).map(t => ({
894
+ table: t.table || t.table_name || t.name,
895
+ operation: t.operation
896
+ })),
897
+ errors: result.errors || []
898
+ });
899
+
900
+ // Repair any prior unresolved imports that point AT this file —
901
+ // e.g. another file imported foo.ts before foo.ts was indexed.
902
+ // Now foo.ts exists, so those imports become resolvable. Cheap:
903
+ // single UPDATE WHERE to_path = relPath.
904
+ store.resolveUnresolvedImports();
905
+
906
+ updateReverseDepsPartial(store, fileId);
907
+ reparsed++;
908
+ }
909
+
910
+ // Track partial-sync timestamp so observability tools can tell apart a
911
+ // full reindex from a hot reparse.
912
+ if (reparsed > 0 || removed > 0) {
913
+ try { store.setMeta('last_partial_sync', new Date().toISOString()); } catch {}
914
+ // Keep extraction_error_count fresh on partial syncs too
915
+ try { store.setMeta('extraction_error_count', String(store.getExtractionErrorCount())); } catch {}
916
+ // Invalidate the bitmap sidecar so the next MCP query
917
+ // rebuilds it from the now-fresh SQLite state. Removes both the
918
+ // in-memory singleton and the on-disk `bitmap.bin` so a different
919
+ // process (e.g. the MCP server when watch.js triggered the
920
+ // partial sync) also picks up the change.
921
+ try { invalidateBitmap(path.join(projectRoot, '.carto')); } catch {}
922
+ }
923
+ } finally {
924
+ if (ownsStore) {
925
+ try { store.close(); } catch {}
926
+ }
927
+ }
928
+
929
+ return { reparsed, skipped, removed, elapsed: Date.now() - startTime };
930
+ }
931
+
932
+ /**
933
+ * updateReverseDepsPartial(store, fileId)
934
+ *
935
+ * Recompute reverse_deps for `fileId` plus its direct import neighbors only.
936
+ * O(neighbors) instead of O(all files). Used by syncFiles() and watch.js.
937
+ *
938
+ * Lifted out of the original watch.js implementation so MCP-side lazy
939
+ * reparse and the watcher share one code path.
940
+ */
941
+ function updateReverseDepsPartial(store, fileId) {
942
+ const outgoing = store.db.prepare(
943
+ 'SELECT to_file_id FROM imports WHERE from_file_id = ? AND to_file_id IS NOT NULL'
944
+ ).all(fileId).map(r => r.to_file_id);
945
+
946
+ const incoming = store.db.prepare(
947
+ 'SELECT from_file_id FROM imports WHERE to_file_id = ?'
948
+ ).all(fileId).map(r => r.from_file_id);
949
+
950
+ const affectedIds = new Set([fileId, ...outgoing, ...incoming]);
951
+
952
+ const del = store.db.prepare('DELETE FROM reverse_deps WHERE file_id = ? OR dependent_file_id = ?');
953
+ const ins = store.db.prepare(
954
+ 'INSERT OR IGNORE INTO reverse_deps (file_id, dependent_file_id, hop_distance) VALUES (?,?,?)'
955
+ );
956
+
957
+ // Build local reverse-edge map ONCE, reuse across affected ids.
958
+ const allEdges = store.db.prepare(
959
+ 'SELECT from_file_id, to_file_id FROM imports WHERE to_file_id IS NOT NULL'
960
+ ).all();
961
+
962
+ const reverseDirect = new Map();
963
+ for (const e of allEdges) {
964
+ if (!reverseDirect.has(e.to_file_id)) reverseDirect.set(e.to_file_id, []);
965
+ reverseDirect.get(e.to_file_id).push(e.from_file_id);
966
+ }
967
+
968
+ const tx = store.db.transaction(() => {
969
+ for (const fid of affectedIds) {
970
+ del.run(fid, fid);
971
+ let frontier = new Set(reverseDirect.get(fid) || []);
972
+ const visited = new Set();
973
+ for (let hop = 1; hop <= 5; hop++) {
974
+ const next = new Set();
975
+ for (const depId of frontier) {
976
+ if (visited.has(depId) || depId === fid) continue;
977
+ visited.add(depId);
978
+ ins.run(fid, depId, hop);
979
+ for (const nd of (reverseDirect.get(depId) || [])) {
980
+ if (!visited.has(nd) && nd !== fid) next.add(nd);
981
+ }
982
+ }
983
+ if (next.size === 0) break;
984
+ frontier = next;
985
+ }
986
+ }
987
+ });
988
+ tx();
989
+
990
+ const updateCentrality = store.db.prepare(
991
+ 'UPDATE files SET centrality = (SELECT COUNT(DISTINCT dependent_file_id) FROM reverse_deps WHERE file_id = files.id) WHERE id = ?'
992
+ );
993
+ for (const fid of affectedIds) updateCentrality.run(fid);
994
+ }
995
+
996
+ module.exports = { runSyncV2, syncFiles, updateReverseDepsPartial, discoverFiles, extractFile, buildFileDataFromStore, generateOutputs, buildRoutesByFile, detectLanguage, selectClusteringStrategy };