carto-md 2.0.7 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -23
- package/package.json +8 -4
- package/scripts/postinstall.js +46 -0
- package/src/acp/agent.js +5 -5
- package/src/acp/providers/index.js +2 -2
- package/src/agents/leiden.js +7 -13
- package/src/bitmap/bitset.js +190 -0
- package/src/bitmap/index.js +121 -0
- package/src/bitmap/sidecar.js +545 -0
- package/src/bitmap/tools.js +310 -0
- package/src/cli/check.js +57 -0
- package/src/cli/index.js +14 -2
- package/src/cli/init.js +297 -65
- package/src/cli/inspect.js +295 -0
- package/src/cli/serve.js +1 -1
- package/src/cli/watch.js +6 -0
- package/src/engine/worker.js +24 -4
- package/src/extractors/imports.js +176 -0
- package/src/extractors/languages/html.js +4 -1
- package/src/extractors/languages/javascript.js +5 -0
- package/src/extractors/languages/prisma.js +4 -1
- package/src/extractors/languages/python.js +5 -1
- package/src/extractors/languages/r.js +4 -1
- package/src/extractors/languages/typescript.js +2 -0
- package/src/extractors/tree-sitter-parser.js +15 -0
- package/src/mcp/diff-parser.js +246 -0
- package/src/mcp/server-v2.js +489 -8
- package/src/mcp/validate.js +304 -0
- package/src/store/config-loader.js +77 -0
- package/src/store/sqlite-store.js +389 -4
- package/src/store/sync-v2.js +422 -96
- package/BENCHMARK_RESULTS.md +0 -34
package/src/store/sync-v2.js
CHANGED
|
@@ -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
|
|
|
@@ -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 (
|
|
313
|
-
//
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
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;
|
|
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);
|
|
396
380
|
}
|
|
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 [
|
|
409
|
-
|
|
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,128 @@ 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
|
+
try {
|
|
465
|
+
const cartoDir = path.join(projectRoot, '.carto');
|
|
466
|
+
const sidecar = buildBitmap(store);
|
|
467
|
+
saveBitmap(cartoDir, sidecar);
|
|
468
|
+
// Drop any in-memory cache held by the same Node process — e.g. when
|
|
469
|
+
// sync runs from inside `carto serve`'s lazy reparse path. Next MCP
|
|
470
|
+
// query loads the freshly-saved file.
|
|
471
|
+
invalidateBitmap();
|
|
472
|
+
} catch (err) {
|
|
473
|
+
process.stderr.write(
|
|
474
|
+
`[CARTO] bitmap sidecar build failed (queries will use SQLite): ` +
|
|
475
|
+
`${err && err.message ? err.message : err}\n`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
460
479
|
console.log(`[CARTO] Indexed ${toProcess.length} files (${cached} cached) in ${elapsed}ms`);
|
|
461
480
|
console.log(`[CARTO] Total: ${allFiles.length} files, ${structure.meta.totalRoutes} routes, ${structure.meta.totalImportEdges} import edges`);
|
|
462
481
|
|
|
482
|
+
// Surface extraction failures so users notice them
|
|
483
|
+
// immediately, not after they ask the AI a question and get bad
|
|
484
|
+
// answers. Pointed at `carto check` for the full breakdown.
|
|
485
|
+
if (extractionErrorCount > 0) {
|
|
486
|
+
console.log(`[CARTO] ⚠️ ${extractionErrorCount} extraction error${extractionErrorCount === 1 ? '' : 's'} (run \`carto check\` for details)`);
|
|
487
|
+
}
|
|
488
|
+
|
|
463
489
|
store.close();
|
|
464
|
-
return { filesProcessed: toProcess.length, totalFiles: allFiles.length, elapsed };
|
|
490
|
+
return { filesProcessed: toProcess.length, totalFiles: allFiles.length, elapsed, extractionErrorCount };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* selectClusteringStrategy(fileCount, edgeCount) → { method, gamma?, minSize? }
|
|
495
|
+
*
|
|
496
|
+
* Adaptive clustering strategy selection:
|
|
497
|
+
* - <100 files → keyword (kills over-fragmentation for small repos)
|
|
498
|
+
* - density <1.5 → keyword (sparse graph / monorepo)
|
|
499
|
+
* - else → graph with continuous gamma = min(0.10, 0.02 + 0.02·log10(fileCount/10))
|
|
500
|
+
* minSize = clamp(sqrt(fileCount), 5, 20)
|
|
501
|
+
*/
|
|
502
|
+
function selectClusteringStrategy(fileCount, edgeCount) {
|
|
503
|
+
if (fileCount < 100) return { method: 'keyword' };
|
|
504
|
+
const density = edgeCount / Math.max(fileCount, 1);
|
|
505
|
+
if (density < 1.5) return { method: 'keyword' };
|
|
506
|
+
const gamma = Math.min(0.10, 0.02 + 0.02 * Math.log10(fileCount / 10));
|
|
507
|
+
const minSize = Math.max(5, Math.min(20, Math.round(Math.sqrt(fileCount))));
|
|
508
|
+
return { method: 'graph', gamma, minSize };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* runKeywordClustering(store, importGraph) → Map<filePath, domainName>
|
|
513
|
+
* Consolidated keyword-based clustering used for small/sparse repos.
|
|
514
|
+
*/
|
|
515
|
+
function runKeywordClustering(store, importGraph) {
|
|
516
|
+
const routes = store.getRoutes();
|
|
517
|
+
const models = store.getModels();
|
|
518
|
+
const envVars = store.getEnvVars();
|
|
519
|
+
const routesByFile = buildRoutesByFile(store);
|
|
520
|
+
const functions = {};
|
|
521
|
+
const allFilesForDomain = store.getAllFiles();
|
|
522
|
+
for (const f of allFilesForDomain) {
|
|
523
|
+
const syms = store.db.prepare(
|
|
524
|
+
'SELECT name FROM symbols WHERE file_id = ? AND kind = ?'
|
|
525
|
+
).all(f.id, 'function').map(r => r.name);
|
|
526
|
+
if (syms.length > 0) functions[f.path] = syms;
|
|
527
|
+
}
|
|
528
|
+
const dbTables = store.db.prepare(`
|
|
529
|
+
SELECT dt.table_name, dt.operation, f.path as file
|
|
530
|
+
FROM db_tables dt JOIN files f ON dt.file_id = f.id
|
|
531
|
+
`).all().map(r => ({ table: r.table_name, operation: r.operation, file: r.file }));
|
|
532
|
+
|
|
533
|
+
const domainResult = clusterByDomain({
|
|
534
|
+
routes, models: models.filter(m => m.name).map(m => ({ ...m, className: m.name })),
|
|
535
|
+
functions, envVars, dbTables, fileMap: [], routesByFile, importGraph
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const fileAssignments = new Map();
|
|
539
|
+
for (const [domainName, cluster] of Object.entries(domainResult)) {
|
|
540
|
+
for (const fp of (cluster.files || [])) fileAssignments.set(fp, domainName);
|
|
541
|
+
}
|
|
542
|
+
return fileAssignments;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* computeDomainStability(store, fileAssignments)
|
|
547
|
+
* Compares current assignments to previous snapshot, stores drift %.
|
|
548
|
+
* Warns if total>=10 && drift>5%.
|
|
549
|
+
*/
|
|
550
|
+
function computeDomainStability(store, fileAssignments) {
|
|
551
|
+
const prevRaw = store.getMeta('previous_domain_snapshot');
|
|
552
|
+
const currentSnapshot = {};
|
|
553
|
+
for (const [fp, domain] of fileAssignments) currentSnapshot[fp] = domain;
|
|
554
|
+
|
|
555
|
+
let driftPct = 0;
|
|
556
|
+
const reassignments = [];
|
|
557
|
+
|
|
558
|
+
if (prevRaw) {
|
|
559
|
+
try {
|
|
560
|
+
const prev = JSON.parse(prevRaw);
|
|
561
|
+
const total = fileAssignments.size;
|
|
562
|
+
let changed = 0;
|
|
563
|
+
for (const [fp, domain] of fileAssignments) {
|
|
564
|
+
if (prev[fp] && prev[fp] !== domain) {
|
|
565
|
+
changed++;
|
|
566
|
+
if (reassignments.length < 20) {
|
|
567
|
+
reassignments.push({ file: fp, from: prev[fp], to: domain });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
driftPct = total > 0 ? (changed / total) * 100 : 0;
|
|
572
|
+
if (total >= 10 && driftPct > 5) {
|
|
573
|
+
console.warn(`[CARTO] ⚠️ Domain stability: ${driftPct.toFixed(1)}% files changed domain (${changed}/${total})`);
|
|
574
|
+
}
|
|
575
|
+
} catch { /* malformed snapshot — treat as first run */ }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
store.setMeta('previous_domain_snapshot', JSON.stringify(currentSnapshot));
|
|
579
|
+
store.setMeta('domain_stability_drift_pct', driftPct.toFixed(2));
|
|
580
|
+
store.setMeta('last_reassignments_json', JSON.stringify(reassignments));
|
|
465
581
|
}
|
|
466
582
|
|
|
467
583
|
/**
|
|
@@ -618,4 +734,214 @@ function detectLanguage(ext) {
|
|
|
618
734
|
return map[ext] || 'unknown';
|
|
619
735
|
}
|
|
620
736
|
|
|
621
|
-
|
|
737
|
+
/**
|
|
738
|
+
* syncFiles(projectRoot, paths, opts?) → { reparsed, skipped, removed, elapsed }
|
|
739
|
+
*
|
|
740
|
+
* Hot re-parse a small set of files. Used by:
|
|
741
|
+
* - The MCP server's lazy mtime check at query time
|
|
742
|
+
* - The optional `carto watch` command (incremental save handler)
|
|
743
|
+
*
|
|
744
|
+
* Each path is mtime+size-checked against the SQLite row. Unchanged files
|
|
745
|
+
* are skipped. Changed files are extracted and written, and reverse_deps
|
|
746
|
+
* are updated incrementally for just the file's neighbors (NOT the whole
|
|
747
|
+
* graph). No discovery pass, no domain reclustering, no AGENTS.md
|
|
748
|
+
* regeneration — this is the freshness equivalent of `git status` and
|
|
749
|
+
* finishes in <50ms for a single file.
|
|
750
|
+
*
|
|
751
|
+
* Files that no longer exist on disk are removed from the index.
|
|
752
|
+
*
|
|
753
|
+
* Opts:
|
|
754
|
+
* store — Optional pre-opened writable SQLiteStore. If omitted, a new
|
|
755
|
+
* connection is opened and closed. Pass an existing store when
|
|
756
|
+
* batching many calls (e.g. the watcher debounce loop).
|
|
757
|
+
*/
|
|
758
|
+
function syncFiles(projectRoot, paths, opts = {}) {
|
|
759
|
+
const startTime = Date.now();
|
|
760
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
761
|
+
return { reparsed: 0, skipped: 0, removed: 0, elapsed: 0 };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const ownsStore = !opts.store;
|
|
765
|
+
let store = opts.store;
|
|
766
|
+
if (ownsStore) {
|
|
767
|
+
store = new SQLiteStore(projectRoot);
|
|
768
|
+
store.open();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
let reparsed = 0;
|
|
772
|
+
let skipped = 0;
|
|
773
|
+
let removed = 0;
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
for (const relPath of paths) {
|
|
777
|
+
if (!relPath || typeof relPath !== 'string') { skipped++; continue; }
|
|
778
|
+
|
|
779
|
+
const fullPath = path.resolve(projectRoot, relPath);
|
|
780
|
+
|
|
781
|
+
// Stat — if missing, treat as deletion
|
|
782
|
+
let stat;
|
|
783
|
+
try {
|
|
784
|
+
stat = fs.statSync(fullPath);
|
|
785
|
+
} catch {
|
|
786
|
+
const existing = store.getFileByPath(relPath);
|
|
787
|
+
if (existing) {
|
|
788
|
+
store.removeFile(relPath);
|
|
789
|
+
updateReverseDepsPartial(store, existing.id);
|
|
790
|
+
removed++;
|
|
791
|
+
}
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const mtime = Math.floor(stat.mtimeMs);
|
|
796
|
+
const size = stat.size;
|
|
797
|
+
const existing = store.getFileByPath(relPath);
|
|
798
|
+
|
|
799
|
+
// Fast path: mtime+size unchanged → DB row is current
|
|
800
|
+
if (existing && existing.mtime === mtime && existing.size === size) {
|
|
801
|
+
skipped++;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Skip large files (extractor cap matches runSyncV2 / applyIncrementalV2)
|
|
806
|
+
if (size > 1024 * 1024) { skipped++; continue; }
|
|
807
|
+
|
|
808
|
+
// Extract — single source of truth, same path runSyncV2 uses
|
|
809
|
+
const result = extractFile(relPath, projectRoot);
|
|
810
|
+
if (!result) { skipped++; continue; }
|
|
811
|
+
|
|
812
|
+
// Hash-equal but mtime drifted (e.g. `touch foo.ts`) — just refresh
|
|
813
|
+
// the cached mtime/size so future stat checks short-circuit.
|
|
814
|
+
if (existing && existing.hash === result.hash) {
|
|
815
|
+
store.updateFileMtime(relPath, mtime, size);
|
|
816
|
+
skipped++;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const fileId = store.upsertFile(relPath, {
|
|
821
|
+
language: result.language,
|
|
822
|
+
hash: result.hash,
|
|
823
|
+
mtime: result.mtime,
|
|
824
|
+
size: result.size
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const resolvedImports = (result.imports || []).map(impPath => {
|
|
828
|
+
const resolved = store.getFileByPath(impPath);
|
|
829
|
+
return { path: impPath, resolvedFileId: resolved ? resolved.id : null };
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const symbols = (result.extracted.functions || []).map(name => ({
|
|
833
|
+
name: typeof name === 'string' ? name : name.name || 'unknown',
|
|
834
|
+
kind: 'function',
|
|
835
|
+
exported: true
|
|
836
|
+
}));
|
|
837
|
+
|
|
838
|
+
store.storeExtraction(fileId, {
|
|
839
|
+
imports: resolvedImports,
|
|
840
|
+
symbols,
|
|
841
|
+
routes: result.extracted.routes || [],
|
|
842
|
+
models: result.extracted.models || [],
|
|
843
|
+
envVars: result.extracted.envVars || [],
|
|
844
|
+
dbTables: (result.extracted.dbTables || []).map(t => ({
|
|
845
|
+
table: t.table || t.table_name || t.name,
|
|
846
|
+
operation: t.operation
|
|
847
|
+
})),
|
|
848
|
+
errors: result.errors || []
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// Repair any prior unresolved imports that point AT this file —
|
|
852
|
+
// e.g. another file imported foo.ts before foo.ts was indexed.
|
|
853
|
+
// Now foo.ts exists, so those imports become resolvable. Cheap:
|
|
854
|
+
// single UPDATE WHERE to_path = relPath.
|
|
855
|
+
store.resolveUnresolvedImports();
|
|
856
|
+
|
|
857
|
+
updateReverseDepsPartial(store, fileId);
|
|
858
|
+
reparsed++;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Track partial-sync timestamp so observability tools can tell apart a
|
|
862
|
+
// full reindex from a hot reparse.
|
|
863
|
+
if (reparsed > 0 || removed > 0) {
|
|
864
|
+
try { store.setMeta('last_partial_sync', new Date().toISOString()); } catch {}
|
|
865
|
+
// Keep extraction_error_count fresh on partial syncs too
|
|
866
|
+
try { store.setMeta('extraction_error_count', String(store.getExtractionErrorCount())); } catch {}
|
|
867
|
+
// Invalidate the bitmap sidecar so the next MCP query
|
|
868
|
+
// rebuilds it from the now-fresh SQLite state. Removes both the
|
|
869
|
+
// in-memory singleton and the on-disk `bitmap.bin` so a different
|
|
870
|
+
// process (e.g. the MCP server when watch.js triggered the
|
|
871
|
+
// partial sync) also picks up the change.
|
|
872
|
+
try { invalidateBitmap(path.join(projectRoot, '.carto')); } catch {}
|
|
873
|
+
}
|
|
874
|
+
} finally {
|
|
875
|
+
if (ownsStore) {
|
|
876
|
+
try { store.close(); } catch {}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return { reparsed, skipped, removed, elapsed: Date.now() - startTime };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* updateReverseDepsPartial(store, fileId)
|
|
885
|
+
*
|
|
886
|
+
* Recompute reverse_deps for `fileId` plus its direct import neighbors only.
|
|
887
|
+
* O(neighbors) instead of O(all files). Used by syncFiles() and watch.js.
|
|
888
|
+
*
|
|
889
|
+
* Lifted out of the original watch.js implementation so MCP-side lazy
|
|
890
|
+
* reparse and the watcher share one code path.
|
|
891
|
+
*/
|
|
892
|
+
function updateReverseDepsPartial(store, fileId) {
|
|
893
|
+
const outgoing = store.db.prepare(
|
|
894
|
+
'SELECT to_file_id FROM imports WHERE from_file_id = ? AND to_file_id IS NOT NULL'
|
|
895
|
+
).all(fileId).map(r => r.to_file_id);
|
|
896
|
+
|
|
897
|
+
const incoming = store.db.prepare(
|
|
898
|
+
'SELECT from_file_id FROM imports WHERE to_file_id = ?'
|
|
899
|
+
).all(fileId).map(r => r.from_file_id);
|
|
900
|
+
|
|
901
|
+
const affectedIds = new Set([fileId, ...outgoing, ...incoming]);
|
|
902
|
+
|
|
903
|
+
const del = store.db.prepare('DELETE FROM reverse_deps WHERE file_id = ? OR dependent_file_id = ?');
|
|
904
|
+
const ins = store.db.prepare(
|
|
905
|
+
'INSERT OR IGNORE INTO reverse_deps (file_id, dependent_file_id, hop_distance) VALUES (?,?,?)'
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
// Build local reverse-edge map ONCE, reuse across affected ids.
|
|
909
|
+
const allEdges = store.db.prepare(
|
|
910
|
+
'SELECT from_file_id, to_file_id FROM imports WHERE to_file_id IS NOT NULL'
|
|
911
|
+
).all();
|
|
912
|
+
|
|
913
|
+
const reverseDirect = new Map();
|
|
914
|
+
for (const e of allEdges) {
|
|
915
|
+
if (!reverseDirect.has(e.to_file_id)) reverseDirect.set(e.to_file_id, []);
|
|
916
|
+
reverseDirect.get(e.to_file_id).push(e.from_file_id);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const tx = store.db.transaction(() => {
|
|
920
|
+
for (const fid of affectedIds) {
|
|
921
|
+
del.run(fid, fid);
|
|
922
|
+
let frontier = new Set(reverseDirect.get(fid) || []);
|
|
923
|
+
const visited = new Set();
|
|
924
|
+
for (let hop = 1; hop <= 5; hop++) {
|
|
925
|
+
const next = new Set();
|
|
926
|
+
for (const depId of frontier) {
|
|
927
|
+
if (visited.has(depId) || depId === fid) continue;
|
|
928
|
+
visited.add(depId);
|
|
929
|
+
ins.run(fid, depId, hop);
|
|
930
|
+
for (const nd of (reverseDirect.get(depId) || [])) {
|
|
931
|
+
if (!visited.has(nd) && nd !== fid) next.add(nd);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (next.size === 0) break;
|
|
935
|
+
frontier = next;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
tx();
|
|
940
|
+
|
|
941
|
+
const updateCentrality = store.db.prepare(
|
|
942
|
+
'UPDATE files SET centrality = (SELECT COUNT(DISTINCT dependent_file_id) FROM reverse_deps WHERE file_id = files.id) WHERE id = ?'
|
|
943
|
+
);
|
|
944
|
+
for (const fid of affectedIds) updateCentrality.run(fid);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
module.exports = { runSyncV2, syncFiles, updateReverseDepsPartial, discoverFiles, extractFile, buildFileDataFromStore, generateOutputs, buildRoutesByFile, detectLanguage, selectClusteringStrategy };
|
package/BENCHMARK_RESULTS.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# Carto V2 Benchmark Results
|
|
2
|
-
|
|
3
|
-
Generated: 2026-05-28T17:58:28.019Z
|
|
4
|
-
Platform: Node v20.20.1 · 8 CPUs · 8192.0 MB RAM · darwin arm64
|
|
5
|
-
|
|
6
|
-
| Repo | Source Files | Indexed | First Run | Second Run | DB Size | Routes | Import Edges |
|
|
7
|
-
|------|-------------|---------|-----------|------------|---------|--------|--------------|
|
|
8
|
-
| prisma | 3303 | 3303 | 1.6s | 178ms | 2.2 MB | 10 | 3590 |
|
|
9
|
-
| supabase | 6818 | 6746 | 4.9s | 725ms | 4.3 MB | 90 | 5754 |
|
|
10
|
-
| vscode | 10565 | 10565 | 9.7s | 1.2s | 10.6 MB | 11 | 19769 |
|
|
11
|
-
| zed | 1837 | 1837 | 2.7s | 83ms | 4.7 MB | 12 | 2176 |
|
|
12
|
-
|
|
13
|
-
## Domains Detected
|
|
14
|
-
|
|
15
|
-
**prisma:** DATABASE(1044) · CORE(752) · EVENTS(36) · AUTH(8)
|
|
16
|
-
**supabase:** CORE(4243) · AUTH(412) · DATABASE(387) · PAYMENTS(51) · EVENTS(17) · NOTIFICATIONS(12) · TRPC(3)
|
|
17
|
-
**vscode:** CORE(5330) · AUTH(850) · EVENTS(446) · DATABASE(419) · NOTIFICATIONS(5)
|
|
18
|
-
**zed:** CORE(1424) · DATABASE(110) · AUTH(75) · EVENTS(55) · PAYMENTS(53) · NOTIFICATIONS(10) · TRPC(2)
|
|
19
|
-
|
|
20
|
-
## MCP Query Latency
|
|
21
|
-
|
|
22
|
-
| Repo | get_structure | get_routes | get_domains_list |
|
|
23
|
-
|------|--------------|------------|-----------------|
|
|
24
|
-
| prisma | 0ms | 0ms | 1ms |
|
|
25
|
-
| supabase | 1ms | 0ms | 3ms |
|
|
26
|
-
| vscode | 1ms | 0ms | 3ms |
|
|
27
|
-
| zed | 0ms | 0ms | 1ms |
|
|
28
|
-
|
|
29
|
-
## Target Assessment
|
|
30
|
-
|
|
31
|
-
- **prisma**: ✅ All targets met
|
|
32
|
-
- **supabase**: ✅ All targets met
|
|
33
|
-
- **vscode**: ✅ All targets met
|
|
34
|
-
- **zed**: ✅ All targets met
|