carto-md 2.0.5 → 2.0.7

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.
@@ -1,606 +0,0 @@
1
- 'use strict';
2
-
3
- const EventEmitter = require('events');
4
- const fs = require('fs');
5
- const path = require('path');
6
-
7
- const { loadGraphCache, saveGraphCache, buildEmptyCache } = require('../cache/graph-cache');
8
- const { loadHashes, saveHashes, computeChangedFiles, updateFileHash } = require('../cache/file-hash');
9
- const { applyIncrementalUpdate, removeFileFromGraph, recomputeGraphMetrics } = require('./incremental');
10
- const { WorkerPool } = require('./worker-pool');
11
- const { loadLanguagePlugins, getPluginForFile } = require('../extractors/loader');
12
- const { buildImportGraph } = require('../extractors/imports');
13
- const { buildStackLine } = require('../extractors/stack');
14
- const { getDomainForFile, buildFileAssignments, setDomainMap } = require('../agents/domains');
15
- const { extractImports } = require('../extractors/imports');
16
-
17
- const plugins = loadLanguagePlugins();
18
-
19
- const IGNORE_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', '.next', '.turbo', 'build', 'coverage', '.carto']);
20
- const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.r', '.R', '.prisma', '.html', '.go', '.rb']);
21
-
22
- /**
23
- * Carto — programmatic API for codebase intelligence.
24
- *
25
- * Events:
26
- * 'status' { state: 'indexing'|'updating'|'ready', progress?: number }
27
- * 'indexed' { meta }
28
- * 'updated' { file, blastRadius }
29
- *
30
- * Usage:
31
- * const { Carto } = require('carto-md');
32
- * const carto = new Carto();
33
- * await carto.index('/path/to/project');
34
- * const ctx = carto.getContextForFile('src/auth/auth.service.ts');
35
- */
36
- class Carto extends EventEmitter {
37
- constructor() {
38
- super();
39
- this._cache = null;
40
- this._projectRoot = null;
41
- this._pool = null;
42
- this._domainByFile = null; // reverse map: relPath → domainName
43
- }
44
-
45
- // ─── Indexing ────────────────────────────────────────────────────────────
46
-
47
- /**
48
- * index(projectRoot, { onProgress, useWorkers, force })
49
- * Full index of a project. Loads from disk cache, only re-parses changed files.
50
- * Emits 'status' and 'indexed' events.
51
- */
52
- async index(projectRoot, { onProgress, useWorkers = true, force = false } = {}) {
53
- this._projectRoot = projectRoot;
54
- const start = Date.now();
55
-
56
- this.emit('status', { state: 'indexing', progress: 0 });
57
-
58
- // Load custom domain config if present
59
- try {
60
- const config = JSON.parse(fs.readFileSync(path.join(projectRoot, 'carto.config.json'), 'utf-8'));
61
- setDomainMap(config.domains || null);
62
- } catch {
63
- setDomainMap(null); // reset to defaults
64
- }
65
-
66
- const cartoDir = path.join(projectRoot, '.carto');
67
- try { fs.mkdirSync(cartoDir, { recursive: true }); } catch {}
68
-
69
- const storedHashes = force ? {} : loadHashes(projectRoot);
70
- const existingCache = force ? buildEmptyCache() : (loadGraphCache(projectRoot) || buildEmptyCache());
71
-
72
- // Discover all code files
73
- const allFiles = this._discoverFiles(projectRoot);
74
-
75
- const { changed, hashes: newHashes } = computeChangedFiles(allFiles, storedHashes, projectRoot);
76
- const changedSet = new Set(changed.map(f => path.relative(projectRoot, f)));
77
-
78
- // Remove stale files from cache
79
- const currentRel = new Set(allFiles.map(f => path.relative(projectRoot, f)));
80
- for (const relPath of Object.keys(existingCache.fileData)) {
81
- if (!currentRel.has(relPath)) {
82
- delete existingCache.fileData[relPath];
83
- delete existingCache.importGraph[relPath];
84
- delete existingCache.routesByFile[relPath];
85
- }
86
- }
87
-
88
- this._cache = existingCache;
89
-
90
- if (changed.length > 0) {
91
- const progress = (pct) => {
92
- this.emit('status', { state: 'indexing', progress: pct });
93
- if (onProgress) onProgress(pct);
94
- };
95
-
96
- if (useWorkers && changed.length > 10) {
97
- // Parallel parsing via worker threads
98
- if (!this._pool) this._pool = new WorkerPool();
99
- const results = await this._pool.processFiles(changed, projectRoot, progress);
100
-
101
- for (const result of results) {
102
- if (!result) continue;
103
- const { relPath, imports, ...data } = result;
104
- this._cache.fileData[relPath] = {
105
- routes: data.routes,
106
- models: data.models,
107
- functions: data.functions,
108
- envVars: data.envVars,
109
- dbTables: data.dbTables,
110
- fetches: data.fetches,
111
- storageKeys: data.storageKeys,
112
- };
113
- if (data.routes.length > 0) {
114
- this._cache.routesByFile[relPath] = data.routes.map(r => `${r.method} ${r.path}`);
115
- } else {
116
- delete this._cache.routesByFile[relPath];
117
- }
118
- this._cache.importGraph[relPath] = imports;
119
- }
120
- } else {
121
- // Single-threaded fallback for small change sets
122
- let done = 0;
123
- for (const filePath of changed) {
124
- const relPath = path.relative(projectRoot, filePath);
125
- let content;
126
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { done++; continue; }
127
-
128
- const plugin = getPluginForFile(plugins, filePath);
129
- if (!plugin) { done++; continue; }
130
-
131
- const extracted = plugin.extract(content, relPath);
132
- const imports = extractImports(content, filePath, projectRoot);
133
-
134
- this._cache.fileData[relPath] = {
135
- routes: extracted.routes || [],
136
- models: extracted.models || [],
137
- functions: extracted.functions || [],
138
- envVars: extracted.envVars || [],
139
- dbTables: (extracted.dbTables || []).map(t => ({ ...t, file: relPath })),
140
- fetches: extracted.fetches || [],
141
- storageKeys: extracted.storageKeys || [],
142
- };
143
- if ((extracted.routes || []).length > 0) {
144
- this._cache.routesByFile[relPath] = extracted.routes.map(r => `${r.method} ${r.path}`);
145
- } else {
146
- delete this._cache.routesByFile[relPath];
147
- }
148
- this._cache.importGraph[relPath] = imports;
149
-
150
- done++;
151
- if (onProgress) onProgress(Math.round((done / changed.length) * 100));
152
- this.emit('status', { state: 'indexing', progress: Math.round((done / changed.length) * 100) });
153
- }
154
- }
155
- }
156
-
157
- recomputeGraphMetrics(this._cache);
158
- this._buildDomainMap();
159
- this._cache.meta.indexDuration = Date.now() - start;
160
- this._cache.meta.lastIndexed = new Date().toISOString();
161
- this._cache.generated = new Date().toISOString();
162
-
163
- saveHashes(projectRoot, newHashes);
164
- saveGraphCache(projectRoot, this._cache);
165
-
166
- this.emit('status', { state: 'ready' });
167
- this.emit('indexed', { meta: this._cache.meta });
168
-
169
- return this._cache;
170
- }
171
-
172
- /**
173
- * reindex(filePath)
174
- * Incremental update — re-parses one file, updates graph in ~100ms.
175
- * Emits 'updated' event.
176
- */
177
- async reindex(filePath) {
178
- this._assertIndexed();
179
- this.emit('status', { state: 'updating' });
180
-
181
- const relPath = path.relative(this._projectRoot, filePath);
182
- let content;
183
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch {
184
- this.emit('status', { state: 'ready' });
185
- return;
186
- }
187
-
188
- const plugin = getPluginForFile(plugins, filePath);
189
- if (!plugin) { this.emit('status', { state: 'ready' }); return; }
190
-
191
- const extracted = plugin.extract(content, relPath);
192
- applyIncrementalUpdate(this._cache, relPath, extracted, content, this._projectRoot);
193
- this._cache.generated = new Date().toISOString();
194
-
195
- updateFileHash(this._projectRoot, relPath, content);
196
- saveGraphCache(this._projectRoot, this._cache);
197
-
198
- this._buildDomainMap();
199
- const blastRadius = this.getBlastRadius(relPath);
200
- this.emit('status', { state: 'ready' });
201
- this.emit('updated', { file: relPath, blastRadius });
202
-
203
- return { file: relPath, blastRadius };
204
- }
205
-
206
- /**
207
- * removeFile(filePath)
208
- * Removes a deleted file from the graph.
209
- */
210
- removeFile(filePath) {
211
- this._assertIndexed();
212
- const relPath = path.relative(this._projectRoot, filePath);
213
- removeFileFromGraph(this._cache, relPath);
214
- saveGraphCache(this._projectRoot, this._cache);
215
- }
216
-
217
- // ─── Query API ───────────────────────────────────────────────────────────
218
-
219
- /**
220
- * getBlastRadius(file)
221
- * Returns full impact analysis for a file.
222
- * {
223
- * file, risk, directlyAffected, potentiallyAffected,
224
- * routesImpacted [{ method, path, risk }],
225
- * domainsImpacted, dependentFiles
226
- * }
227
- */
228
- getBlastRadius(file) {
229
- this._assertIndexed();
230
- const relPath = this._resolveFile(file);
231
- if (!relPath) return null;
232
-
233
- const importGraph = this._cache.importGraph;
234
- const domains = this._cache.domains || {};
235
-
236
- // Direct dependents (1 hop)
237
- const directDeps = [];
238
- for (const [f, deps] of Object.entries(importGraph)) {
239
- if (deps.includes(relPath)) directDeps.push(f);
240
- }
241
-
242
- // All transitive dependents (up to 5 hops)
243
- const allDeps = new Set(directDeps);
244
- let frontier = new Set(directDeps);
245
- for (let hop = 0; hop < 4; hop++) {
246
- const next = new Set();
247
- for (const [f, deps] of Object.entries(importGraph)) {
248
- if (allDeps.has(f)) continue;
249
- for (const dep of deps) {
250
- if (frontier.has(dep)) { allDeps.add(f); next.add(f); break; }
251
- }
252
- }
253
- if (next.size === 0) break;
254
- frontier = next;
255
- }
256
- allDeps.add(relPath);
257
-
258
- // Routes impacted
259
- const affectedRoutes = new Set();
260
- for (const f of allDeps) {
261
- const fileRoutes = this._cache.routesByFile[f];
262
- if (fileRoutes) fileRoutes.forEach(r => affectedRoutes.add(r));
263
- }
264
-
265
- // Risk per route
266
- const routesImpacted = [...affectedRoutes].sort().map(r => {
267
- const [method, ...rest] = r.split(' ');
268
- const routePath = rest.join(' ');
269
- const isAuth = routePath.includes('auth') || routePath.includes('login') || routePath.includes('session');
270
- const isPayment = routePath.includes('billing') || routePath.includes('payment') || routePath.includes('checkout');
271
- const riskLevel = isAuth || isPayment ? 'HIGH' : directDeps.length >= 3 ? 'HIGH' : directDeps.length >= 2 ? 'MEDIUM' : 'LOW';
272
- return { method, path: routePath, risk: riskLevel };
273
- });
274
-
275
- // Domains impacted
276
- const domainsImpacted = new Set();
277
- const fileDomain = this._getDomainForFile(relPath);
278
- if (fileDomain) domainsImpacted.add(fileDomain);
279
- for (const f of allDeps) {
280
- const d = this._getDomainForFile(f);
281
- if (d) domainsImpacted.add(d);
282
- }
283
-
284
- const depCount = directDeps.length;
285
- const risk = depCount >= 5 ? 'HIGH' : depCount >= 3 ? 'HIGH' : depCount >= 2 ? 'MEDIUM' : depCount >= 1 ? 'LOW' : 'SAFE';
286
-
287
- return {
288
- file: relPath,
289
- risk,
290
- directlyAffected: { files: directDeps.length, domains: domainsImpacted.size },
291
- potentiallyAffected: { files: allDeps.size - 1, domains: domainsImpacted.size },
292
- routesImpacted,
293
- domainsImpacted: [...domainsImpacted],
294
- dependentFiles: [...allDeps].filter(f => f !== relPath).sort()
295
- };
296
- }
297
-
298
- /**
299
- * getNeighbors(file, hops)
300
- * Returns import graph neighbors in React Flow compatible format.
301
- * { nodes: [{ id, label, domain }], edges: [{ id, source, target }] }
302
- */
303
- getNeighbors(file, hops = 1) {
304
- this._assertIndexed();
305
- const relPath = this._resolveFile(file);
306
- if (!relPath) return { nodes: [], edges: [] };
307
-
308
- const importGraph = this._cache.importGraph;
309
- const visited = new Set([relPath]);
310
- let frontier = new Set([relPath]);
311
-
312
- for (let h = 0; h < hops; h++) {
313
- const next = new Set();
314
- for (const f of frontier) {
315
- // Files this file imports
316
- for (const dep of (importGraph[f] || [])) {
317
- if (!visited.has(dep)) { visited.add(dep); next.add(dep); }
318
- }
319
- // Files that import this file
320
- for (const [other, deps] of Object.entries(importGraph)) {
321
- if (!visited.has(other) && deps.includes(f)) { visited.add(other); next.add(other); }
322
- }
323
- }
324
- if (next.size === 0) break;
325
- frontier = next;
326
- }
327
-
328
- const nodes = [...visited].map(f => ({
329
- id: f,
330
- label: path.basename(f),
331
- domain: this._getDomainForFile(f) || 'CORE',
332
- isRoot: f === relPath
333
- }));
334
-
335
- const edges = [];
336
- for (const f of visited) {
337
- for (const dep of (importGraph[f] || [])) {
338
- if (visited.has(dep)) {
339
- edges.push({ id: `${f}->${dep}`, source: f, target: dep });
340
- }
341
- }
342
- }
343
-
344
- return { nodes, edges };
345
- }
346
-
347
- /**
348
- * getContextForFile(file)
349
- * Single call that returns everything Kepler needs for a file.
350
- */
351
- getContextForFile(file) {
352
- this._assertIndexed();
353
- const relPath = this._resolveFile(file);
354
- if (!relPath) return null;
355
-
356
- const domain = this._getDomainForFile(relPath);
357
- const fileData = this._cache.fileData[relPath] || {};
358
- const blastRadius = this.getBlastRadius(relPath);
359
- const neighbors = this.getNeighbors(relPath, 2);
360
- const crossDomain = this.getCrossDomainDeps().filter(d => d.from === relPath || d.to === relPath);
361
-
362
- // Domain context file content
363
- let domainContext = null;
364
- if (domain) {
365
- const ctxPath = path.join(this._projectRoot, '.carto', 'context', `${domain}.md`);
366
- try { domainContext = fs.readFileSync(ctxPath, 'utf-8'); } catch {}
367
- }
368
-
369
- return {
370
- file: relPath,
371
- domain: domain || 'CORE',
372
- routes: (this._cache.routesByFile[relPath] || []),
373
- models: (fileData.models || []).map(m => m.name || m),
374
- functions: (fileData.functions || []),
375
- envVars: (fileData.envVars || []),
376
- blastRadius,
377
- neighbors,
378
- crossDomainDeps: crossDomain,
379
- domainContext,
380
- meta: {
381
- importCount: (this._cache.importGraph[relPath] || []).length,
382
- dependentCount: blastRadius ? blastRadius.directlyAffected.files : 0
383
- }
384
- };
385
- }
386
-
387
- /**
388
- * getCrossDomainDeps()
389
- * Returns all import edges that cross domain boundaries.
390
- * [{ from, fromDomain, to, toDomain }]
391
- */
392
- getCrossDomainDeps() {
393
- this._assertIndexed();
394
- const results = [];
395
- for (const [file, deps] of Object.entries(this._cache.importGraph)) {
396
- const fromDomain = this._getDomainForFile(file);
397
- for (const dep of deps) {
398
- const toDomain = this._getDomainForFile(dep);
399
- if (fromDomain && toDomain && fromDomain !== toDomain) {
400
- results.push({ from: file, fromDomain, to: dep, toDomain });
401
- }
402
- }
403
- }
404
- return results;
405
- }
406
-
407
- /**
408
- * getHighImpactFiles(n)
409
- * Top N files by number of dependents (fan-in).
410
- */
411
- getHighImpactFiles(n = 10) {
412
- this._assertIndexed();
413
- return (this._cache.highImpact || []).slice(0, n);
414
- }
415
-
416
- /**
417
- * searchRoutes(query)
418
- * Search routes by path or method. Case-insensitive.
419
- * Returns [{ method, path, file }]
420
- */
421
- searchRoutes(query) {
422
- this._assertIndexed();
423
- const q = query.toLowerCase();
424
- const results = [];
425
- for (const [file, routeStrs] of Object.entries(this._cache.routesByFile)) {
426
- for (const r of routeStrs) {
427
- if (r.toLowerCase().includes(q)) {
428
- const [method, ...rest] = r.split(' ');
429
- results.push({ method, path: rest.join(' '), file });
430
- }
431
- }
432
- }
433
- return results;
434
- }
435
-
436
- /**
437
- * getRoutes()
438
- * All API routes across the project.
439
- */
440
- getRoutes() {
441
- this._assertIndexed();
442
- const routes = [];
443
- for (const [file, routeStrs] of Object.entries(this._cache.routesByFile)) {
444
- for (const r of routeStrs) {
445
- const [method, ...rest] = r.split(' ');
446
- routes.push({ method, path: rest.join(' '), file });
447
- }
448
- }
449
- return routes.sort((a, b) => a.path.localeCompare(b.path));
450
- }
451
-
452
- /**
453
- * getStructure()
454
- * Full graph structure: importGraph, entryPoints, highImpact, stack, meta.
455
- */
456
- getStructure() {
457
- this._assertIndexed();
458
- return {
459
- importGraph: this._cache.importGraph,
460
- entryPoints: this._cache.entryPoints,
461
- highImpact: this._cache.highImpact,
462
- stack: this._cache.stack,
463
- domains: Object.keys(this._cache.domains || {}),
464
- meta: this._cache.meta
465
- };
466
- }
467
-
468
- /**
469
- * getDomain(name)
470
- * Returns domain cluster data + context file content.
471
- */
472
- getDomain(name) {
473
- this._assertIndexed();
474
- const domain = (this._cache.domains || {})[name.toUpperCase()];
475
- if (!domain) return null;
476
-
477
- let contextContent = null;
478
- const ctxPath = path.join(this._projectRoot, '.carto', 'context', `${name.toUpperCase()}.md`);
479
- try { contextContent = fs.readFileSync(ctxPath, 'utf-8'); } catch {}
480
-
481
- return { ...domain, contextContent };
482
- }
483
-
484
- /**
485
- * getDomainsList()
486
- * All detected domains with file counts.
487
- */
488
- getDomainsList() {
489
- this._assertIndexed();
490
- return Object.entries(this._cache.domains || {}).map(([name, cluster]) => ({
491
- name,
492
- fileCount: (cluster.files || []).length,
493
- routeCount: (cluster.routes || []).length,
494
- modelCount: (cluster.models || []).length,
495
- }));
496
- }
497
-
498
- /**
499
- * getModels(domain?)
500
- * All models, optionally filtered by domain.
501
- */
502
- getModels(domain) {
503
- this._assertIndexed();
504
- const all = [];
505
- for (const [relPath, data] of Object.entries(this._cache.fileData)) {
506
- for (const model of (data.models || [])) {
507
- const fileDomain = this._getDomainForFile(relPath);
508
- all.push({ ...model, file: relPath, domain: fileDomain || 'CORE' });
509
- }
510
- }
511
- if (domain) return all.filter(m => m.domain === domain.toUpperCase());
512
- return all;
513
- }
514
-
515
- /**
516
- * getEnvVars(domain?)
517
- * All environment variables, optionally grouped by domain.
518
- */
519
- getEnvVars(domain) {
520
- this._assertIndexed();
521
- const envMap = new Map();
522
- for (const [relPath, data] of Object.entries(this._cache.fileData)) {
523
- const fileDomain = this._getDomainForFile(relPath) || 'CORE';
524
- for (const v of (data.envVars || [])) {
525
- if (!envMap.has(v)) envMap.set(v, { name: v, files: [], domains: new Set() });
526
- envMap.get(v).files.push(relPath);
527
- envMap.get(v).domains.add(fileDomain);
528
- }
529
- }
530
- const result = [...envMap.values()].map(e => ({
531
- name: e.name,
532
- files: e.files,
533
- domains: [...e.domains]
534
- })).sort((a, b) => a.name.localeCompare(b.name));
535
-
536
- if (domain) return result.filter(e => e.domains.includes(domain.toUpperCase()));
537
- return result;
538
- }
539
-
540
- /**
541
- * getMeta()
542
- * Index stats.
543
- */
544
- getMeta() {
545
- this._assertIndexed();
546
- return this._cache.meta;
547
- }
548
-
549
- // ─── Internals ───────────────────────────────────────────────────────────
550
-
551
- _assertIndexed() {
552
- if (!this._cache) throw new Error('[Carto] Call index() first.');
553
- }
554
-
555
- _resolveFile(file) {
556
- const importGraph = this._cache.importGraph;
557
- const fileData = this._cache.fileData;
558
- const allFiles = new Set([...Object.keys(importGraph), ...Object.keys(fileData)]);
559
- const normalized = file.replace(/\\/g, '/');
560
-
561
- if (allFiles.has(normalized)) return normalized;
562
- const bySuffix = [...allFiles].filter(f => f.endsWith('/' + normalized) || f === normalized);
563
- if (bySuffix.length === 1) return bySuffix[0];
564
- const byBasename = [...allFiles].filter(f => path.basename(f) === path.basename(normalized));
565
- if (byBasename.length === 1) return byBasename[0];
566
- return null;
567
- }
568
-
569
- _buildDomainMap() {
570
- this._domainByFile = {};
571
- for (const [name, cluster] of Object.entries(this._cache.domains || {})) {
572
- for (const file of (cluster.files || [])) {
573
- this._domainByFile[file] = name;
574
- }
575
- }
576
- }
577
-
578
- _getDomainForFile(relPath) {
579
- return this._domainByFile ? (this._domainByFile[relPath] || null) : null;
580
- }
581
-
582
- _discoverFiles(projectRoot) {
583
- const results = [];
584
- const walk = (dir) => {
585
- let entries;
586
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
587
- for (const entry of entries) {
588
- if (IGNORE_DIRS.has(entry.name)) continue;
589
- const full = path.join(dir, entry.name);
590
- if (entry.isDirectory()) {
591
- walk(full);
592
- } else if (entry.isFile() && CODE_EXTS.has(path.extname(entry.name).toLowerCase())) {
593
- results.push(full);
594
- }
595
- }
596
- };
597
- walk(projectRoot);
598
- return results;
599
- }
600
-
601
- terminate() {
602
- if (this._pool) { this._pool.terminate(); this._pool = null; }
603
- }
604
- }
605
-
606
- module.exports = { Carto };