carto-md 1.1.1 → 1.1.2
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 +10 -0
- package/index.js +20 -0
- package/package.json +2 -2
- package/src/cache/file-hash.js +84 -0
- package/src/cache/graph-cache.js +77 -0
- package/src/cli/check.js +124 -0
- package/src/cli/impact.js +30 -138
- package/src/cli/index.js +6 -0
- package/src/cli/watch.js +148 -15
- package/src/engine/carto.js +590 -0
- package/src/engine/incremental.js +149 -0
- package/src/engine/worker-pool.js +119 -0
- package/src/engine/worker.js +55 -0
- package/src/extractors/languages/go.js +124 -0
- package/src/extractors/languages/typescript.js +204 -200
- package/src/extractors/models.js +85 -18
- package/src/extractors/routes.js +38 -16
- package/src/mcp/server.js +360 -146
- package/src/sync.js +193 -146
- package/src/watcher/watch.js +30 -10
package/README.md
CHANGED
|
@@ -27,6 +27,16 @@ AI coding tools are blind to your actual project. Every session starts from zero
|
|
|
27
27
|
|
|
28
28
|
**Carto makes it live. And queryable.**
|
|
29
29
|
|
|
30
|
+
| | Without Carto | With Carto |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Knows blast radius before editing | Never | Always, instantly |
|
|
33
|
+
| Knows which routes break | Never | Exact list |
|
|
34
|
+
| Plans multi-file changes | Guesses | Fully informed |
|
|
35
|
+
| Hallucinates field names | Often | Never |
|
|
36
|
+
| Understands codebase on session start | 10–20 min | 0 |
|
|
37
|
+
| Works across Kiro, Cursor, Claude, Copilot | Separately | One shared graph |
|
|
38
|
+
| Stays current as code changes | Goes stale | Live on every save |
|
|
39
|
+
|
|
30
40
|
---
|
|
31
41
|
|
|
32
42
|
## Proof — cal.com (800k lines)
|
package/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* carto-md — public module API
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { Carto } = require('carto-md');
|
|
8
|
+
* const carto = new Carto();
|
|
9
|
+
* await carto.index('/path/to/project');
|
|
10
|
+
*
|
|
11
|
+
* // Get everything Kepler needs for a file
|
|
12
|
+
* const ctx = carto.getContextForFile('src/auth/auth.service.ts');
|
|
13
|
+
*
|
|
14
|
+
* // Listen for live updates
|
|
15
|
+
* carto.on('updated', ({ file, blastRadius }) => { ... });
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { Carto } = require('./src/engine/carto');
|
|
19
|
+
|
|
20
|
+
module.exports = { Carto };
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carto-md",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "The context layer for AI-native development.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"carto": "src/cli/index.js"
|
|
7
7
|
},
|
|
8
|
-
"main": "./
|
|
8
|
+
"main": "./index.js",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node test/test.js"
|
|
11
11
|
},
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
function getHashPath(projectRoot) {
|
|
8
|
+
return path.join(projectRoot, '.carto', 'hashes.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function loadHashes(projectRoot) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(getHashPath(projectRoot), 'utf-8');
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function saveHashes(projectRoot, hashes) {
|
|
21
|
+
const hashPath = getHashPath(projectRoot);
|
|
22
|
+
const tmp = hashPath + '.tmp';
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(tmp, JSON.stringify(hashes, null, 2), 'utf-8');
|
|
25
|
+
fs.renameSync(tmp, hashPath);
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hashContent(content) {
|
|
30
|
+
return crypto.createHash('sha1').update(content).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* computeChangedFiles(filePaths, storedHashes, projectRoot)
|
|
35
|
+
* Returns { changed: string[], unchanged: string[], hashes: object }
|
|
36
|
+
* changed = files whose content hash differs from stored
|
|
37
|
+
* unchanged = files whose hash matches — can skip re-parsing
|
|
38
|
+
*/
|
|
39
|
+
function computeChangedFiles(filePaths, storedHashes, projectRoot) {
|
|
40
|
+
const changed = [];
|
|
41
|
+
const unchanged = [];
|
|
42
|
+
const newHashes = { ...storedHashes };
|
|
43
|
+
|
|
44
|
+
for (const filePath of filePaths) {
|
|
45
|
+
const relPath = path.relative(projectRoot, filePath);
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const hash = hashContent(content);
|
|
53
|
+
if (storedHashes[relPath] === hash) {
|
|
54
|
+
unchanged.push(filePath);
|
|
55
|
+
} else {
|
|
56
|
+
changed.push(filePath);
|
|
57
|
+
newHashes[relPath] = hash;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { changed, unchanged, hashes: newHashes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* updateFileHash(projectRoot, relPath, content)
|
|
66
|
+
* Updates the hash for a single file after incremental re-index.
|
|
67
|
+
*/
|
|
68
|
+
function updateFileHash(projectRoot, relPath, content) {
|
|
69
|
+
const hashes = loadHashes(projectRoot);
|
|
70
|
+
hashes[relPath] = hashContent(content);
|
|
71
|
+
saveHashes(projectRoot, hashes);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* removeFileHash(projectRoot, relPath)
|
|
76
|
+
* Removes hash entry when a file is deleted.
|
|
77
|
+
*/
|
|
78
|
+
function removeFileHash(projectRoot, relPath) {
|
|
79
|
+
const hashes = loadHashes(projectRoot);
|
|
80
|
+
delete hashes[relPath];
|
|
81
|
+
saveHashes(projectRoot, hashes);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { loadHashes, saveHashes, hashContent, computeChangedFiles, updateFileHash, removeFileHash };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function getCachePath(projectRoot) {
|
|
7
|
+
return path.join(projectRoot, '.carto', 'graph-cache.json');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* loadGraphCache(projectRoot)
|
|
12
|
+
* Returns the full persisted graph or null if not found / corrupt.
|
|
13
|
+
*
|
|
14
|
+
* Cache shape:
|
|
15
|
+
* {
|
|
16
|
+
* version: '2',
|
|
17
|
+
* generated: ISO string,
|
|
18
|
+
* fileData: {
|
|
19
|
+
* [relPath]: { routes, models, functions, envVars, dbTables, fetches, storageKeys, imports }
|
|
20
|
+
* },
|
|
21
|
+
* importGraph: { [relPath]: [relPath, ...] },
|
|
22
|
+
* routesByFile: { [relPath]: ['METHOD /path', ...] },
|
|
23
|
+
* domains: { [domain]: { files, routes, models, functions, envVars, dbTables } },
|
|
24
|
+
* highImpact: [{ file, dependents }],
|
|
25
|
+
* entryPoints: [relPath, ...],
|
|
26
|
+
* stack: [...],
|
|
27
|
+
* meta: { totalFiles, totalRoutes, totalImportEdges, lastIndexed, indexDuration }
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
function loadGraphCache(projectRoot) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(getCachePath(projectRoot), 'utf-8');
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (parsed.version !== '2') return null;
|
|
35
|
+
return parsed;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveGraphCache(projectRoot, cache) {
|
|
42
|
+
const cachePath = getCachePath(projectRoot);
|
|
43
|
+
const tmp = cachePath + '.tmp';
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
46
|
+
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2) + '\n', 'utf-8');
|
|
47
|
+
fs.renameSync(tmp, cachePath);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn(`[CARTO] Warning: Could not save graph cache — ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* buildEmptyCache() — starting point for a fresh index
|
|
55
|
+
*/
|
|
56
|
+
function buildEmptyCache() {
|
|
57
|
+
return {
|
|
58
|
+
version: '2',
|
|
59
|
+
generated: new Date().toISOString(),
|
|
60
|
+
fileData: {},
|
|
61
|
+
importGraph: {},
|
|
62
|
+
routesByFile: {},
|
|
63
|
+
domains: {},
|
|
64
|
+
highImpact: [],
|
|
65
|
+
entryPoints: [],
|
|
66
|
+
stack: [],
|
|
67
|
+
meta: {
|
|
68
|
+
totalFiles: 0,
|
|
69
|
+
totalRoutes: 0,
|
|
70
|
+
totalImportEdges: 0,
|
|
71
|
+
lastIndexed: null,
|
|
72
|
+
indexDuration: 0
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { loadGraphCache, saveGraphCache, buildEmptyCache, getCachePath };
|
package/src/cli/check.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { Carto } = require('../../index.js');
|
|
6
|
+
|
|
7
|
+
async function run(projectRoot) {
|
|
8
|
+
const carto = new Carto();
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await carto.index(projectRoot, { useWorkers: false });
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(`[CARTO] Error loading index: ${err.message}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const meta = carto.getMeta();
|
|
18
|
+
const domains = carto.getDomainsList();
|
|
19
|
+
const crossDomain = carto.getCrossDomainDeps();
|
|
20
|
+
const highImpact = carto.getHighImpactFiles(20);
|
|
21
|
+
|
|
22
|
+
let hasIssues = false;
|
|
23
|
+
|
|
24
|
+
console.log('\n── Carto Check ─────────────────────────────────────────\n');
|
|
25
|
+
|
|
26
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
27
|
+
console.log(` Files indexed : ${meta.totalFiles}`);
|
|
28
|
+
console.log(` Routes found : ${meta.totalRoutes}`);
|
|
29
|
+
console.log(` Import edges : ${meta.totalImportEdges}`);
|
|
30
|
+
console.log(` Domains : ${domains.map(d => d.name).join(', ') || 'none'}`);
|
|
31
|
+
if (meta.lastIndexed) {
|
|
32
|
+
const age = Math.round((Date.now() - new Date(meta.lastIndexed).getTime()) / 1000);
|
|
33
|
+
const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.round(age / 60)}m ago` : `${Math.round(age / 3600)}h ago`;
|
|
34
|
+
console.log(` Last indexed : ${ageStr}`);
|
|
35
|
+
}
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
// ── Uncommitted changes that touch high-blast-radius files ───────────────
|
|
39
|
+
const modifiedFiles = getModifiedFiles(projectRoot);
|
|
40
|
+
if (modifiedFiles.length > 0) {
|
|
41
|
+
const highImpactSet = new Set(highImpact.map(f => f.file));
|
|
42
|
+
const riskyChanges = modifiedFiles.filter(f => highImpactSet.has(f));
|
|
43
|
+
|
|
44
|
+
if (riskyChanges.length > 0) {
|
|
45
|
+
hasIssues = true;
|
|
46
|
+
console.log(` ⚠️ High-risk uncommitted changes (${riskyChanges.length}):`);
|
|
47
|
+
for (const f of riskyChanges) {
|
|
48
|
+
const hi = highImpact.find(h => h.file === f);
|
|
49
|
+
const br = carto.getBlastRadius(f);
|
|
50
|
+
console.log(` 🔴 ${f}`);
|
|
51
|
+
console.log(` ${hi.dependents} files depend on this — blast risk: ${br ? br.risk : 'UNKNOWN'}`);
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
} else {
|
|
55
|
+
console.log(` ✅ ${modifiedFiles.length} modified file(s) — none are high-impact\n`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Cross-domain dependency violations ───────────────────────────────────
|
|
60
|
+
if (crossDomain.length > 0) {
|
|
61
|
+
hasIssues = true;
|
|
62
|
+
console.log(` ⚠️ Cross-domain dependencies (${crossDomain.length}):`);
|
|
63
|
+
console.log(' These files import across domain boundaries.\n');
|
|
64
|
+
|
|
65
|
+
// Group by fromDomain → toDomain pair
|
|
66
|
+
const grouped = {};
|
|
67
|
+
for (const d of crossDomain) {
|
|
68
|
+
const key = `${d.fromDomain} → ${d.toDomain}`;
|
|
69
|
+
if (!grouped[key]) grouped[key] = [];
|
|
70
|
+
grouped[key].push(d);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [pair, deps] of Object.entries(grouped)) {
|
|
74
|
+
console.log(` ${pair} (${deps.length})`);
|
|
75
|
+
for (const d of deps.slice(0, 3)) {
|
|
76
|
+
console.log(` ${path.basename(d.from)} imports ${path.basename(d.to)}`);
|
|
77
|
+
}
|
|
78
|
+
if (deps.length > 3) console.log(` ...and ${deps.length - 3} more`);
|
|
79
|
+
}
|
|
80
|
+
console.log('');
|
|
81
|
+
} else if (domains.length > 1) {
|
|
82
|
+
console.log(' ✅ No cross-domain dependency violations\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Top high-impact files ────────────────────────────────────────────────
|
|
86
|
+
if (highImpact.length > 0) {
|
|
87
|
+
console.log(` 🔥 Top high-impact files (changing these = highest blast radius):`);
|
|
88
|
+
for (const f of highImpact.slice(0, 5)) {
|
|
89
|
+
console.log(` ${f.dependents.toString().padStart(3)} dependents — ${f.file}`);
|
|
90
|
+
}
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Domain breakdown ─────────────────────────────────────────────────────
|
|
95
|
+
if (domains.length > 0) {
|
|
96
|
+
console.log(' Domains:');
|
|
97
|
+
for (const d of domains) {
|
|
98
|
+
console.log(` ${d.name.padEnd(16)} ${d.fileCount} files ${d.routeCount} routes ${d.modelCount} models`);
|
|
99
|
+
}
|
|
100
|
+
console.log('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('─────────────────────────────────────────────────────────\n');
|
|
104
|
+
console.log(hasIssues ? ' ⚠️ Issues found above.' : ' ✅ All clear.');
|
|
105
|
+
console.log('');
|
|
106
|
+
|
|
107
|
+
carto.terminate();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getModifiedFiles(projectRoot) {
|
|
111
|
+
try {
|
|
112
|
+
const output = execSync('git diff --name-only HEAD 2>/dev/null && git diff --name-only 2>/dev/null', {
|
|
113
|
+
cwd: projectRoot,
|
|
114
|
+
encoding: 'utf-8',
|
|
115
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
116
|
+
});
|
|
117
|
+
const files = [...new Set(output.trim().split('\n').filter(Boolean))];
|
|
118
|
+
return files;
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { run };
|
package/src/cli/impact.js
CHANGED
|
@@ -1,167 +1,59 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
2
3
|
const path = require('path');
|
|
4
|
+
const { Carto } = require('../../index.js');
|
|
3
5
|
|
|
4
|
-
function run(projectRoot, fileArg) {
|
|
6
|
+
async function run(projectRoot, fileArg) {
|
|
5
7
|
if (!fileArg) {
|
|
6
8
|
console.error('[CARTO] Usage: carto impact <file>');
|
|
7
9
|
process.exit(1);
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
if (!fs.existsSync(mapPath)) {
|
|
12
|
-
console.error('[CARTO] Run "carto init" first.');
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let map;
|
|
12
|
+
const carto = new Carto();
|
|
17
13
|
try {
|
|
18
|
-
|
|
14
|
+
await carto.index(projectRoot, { useWorkers: false });
|
|
19
15
|
} catch (err) {
|
|
20
|
-
console.error(`[CARTO] Error
|
|
16
|
+
console.error(`[CARTO] Error loading index: ${err.message}`);
|
|
21
17
|
process.exit(1);
|
|
22
18
|
}
|
|
23
19
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// Resolve file argument — match by basename or partial path
|
|
28
|
-
const matchedFile = resolveFile(fileArg, imports);
|
|
29
|
-
if (!matchedFile) {
|
|
20
|
+
const br = carto.getBlastRadius(fileArg);
|
|
21
|
+
if (!br) {
|
|
30
22
|
console.error(`[CARTO] File not found in project graph: ${fileArg}`);
|
|
31
23
|
process.exit(1);
|
|
32
24
|
}
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
const importedBy = [];
|
|
36
|
-
for (const [file, deps] of Object.entries(imports)) {
|
|
37
|
-
if (deps.includes(matchedFile)) {
|
|
38
|
-
importedBy.push(file);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
importedBy.sort();
|
|
26
|
+
console.log(`\nImpact analysis: ${br.file}\n`);
|
|
42
27
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
28
|
+
const riskBadge = { HIGH: '🔴 HIGH', MEDIUM: '🟡 MEDIUM', LOW: '🟢 LOW', SAFE: '✅ SAFE' };
|
|
29
|
+
console.log(`Risk: ${riskBadge[br.risk] || br.risk}`);
|
|
30
|
+
console.log(`Directly affected: ${br.directlyAffected.files} files across ${br.directlyAffected.domains} domain(s)`);
|
|
31
|
+
console.log(`Potentially affected: ${br.potentiallyAffected.files} files total\n`);
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.log('Imported by:');
|
|
52
|
-
if (importedBy.length > 0) {
|
|
53
|
-
for (const f of importedBy) {
|
|
54
|
-
console.log(` → ${f}`);
|
|
55
|
-
}
|
|
56
|
-
} else {
|
|
57
|
-
console.log(' (none — this file is not imported by any other file)');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
console.log('\nRoutes affected:');
|
|
61
|
-
// Use routesByFile for precise file→route mapping
|
|
62
|
-
const affectedRoutes = new Set();
|
|
63
|
-
for (const affectedFile of dependentFiles) {
|
|
64
|
-
const fileRoutes = map.routesByFile && map.routesByFile[affectedFile];
|
|
65
|
-
if (fileRoutes) {
|
|
66
|
-
for (const r of fileRoutes) affectedRoutes.add(r);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// Also check the target file itself
|
|
70
|
-
if (map.routesByFile && map.routesByFile[matchedFile]) {
|
|
71
|
-
for (const r of map.routesByFile[matchedFile]) affectedRoutes.add(r);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Fall back to all routes only if no file-specific routes found and an entry point is hit
|
|
75
|
-
if (affectedRoutes.size === 0) {
|
|
76
|
-
const entryPointsInChain = map.entryPoints
|
|
77
|
-
? map.entryPoints.filter(ep => dependentFiles.has(ep))
|
|
78
|
-
: [];
|
|
79
|
-
if (entryPointsInChain.length > 0) {
|
|
80
|
-
for (const route of routes) {
|
|
81
|
-
affectedRoutes.add(`${route.method} ${route.path}`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
33
|
+
if (br.domainsImpacted.length > 0) {
|
|
34
|
+
console.log(`Domains impacted: ${br.domainsImpacted.join(', ')}\n`);
|
|
84
35
|
}
|
|
85
36
|
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
37
|
+
if (br.dependentFiles.length > 0) {
|
|
38
|
+
console.log(`Files that depend on this (${br.dependentFiles.length}):`);
|
|
39
|
+
for (const f of br.dependentFiles) console.log(` → ${f}`);
|
|
40
|
+
console.log('');
|
|
90
41
|
} else {
|
|
91
|
-
console.log('
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Risk level
|
|
95
|
-
const depCount = importedBy.length;
|
|
96
|
-
let risk;
|
|
97
|
-
if (depCount >= 3) risk = 'HIGH';
|
|
98
|
-
else if (depCount === 2) risk = 'MEDIUM';
|
|
99
|
-
else if (depCount === 1) risk = 'LOW';
|
|
100
|
-
else risk = 'SAFE';
|
|
101
|
-
|
|
102
|
-
console.log(`\nRisk: ${risk} — ${depCount} file${depCount !== 1 ? 's' : ''} depend on this\n`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Resolve a file argument to a full relative path in the import graph.
|
|
107
|
-
* Matches by basename or partial path suffix.
|
|
108
|
-
*/
|
|
109
|
-
function resolveFile(fileArg, imports) {
|
|
110
|
-
const allFiles = new Set();
|
|
111
|
-
for (const [file, deps] of Object.entries(imports)) {
|
|
112
|
-
allFiles.add(file);
|
|
113
|
-
for (const dep of deps) allFiles.add(dep);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const normalized = fileArg.replace(/\\/g, '/');
|
|
117
|
-
const hasPathSeparator = normalized.includes('/');
|
|
118
|
-
|
|
119
|
-
// Exact match
|
|
120
|
-
if (allFiles.has(normalized)) return normalized;
|
|
121
|
-
|
|
122
|
-
// Match by suffix (partial path)
|
|
123
|
-
const matches = [...allFiles].filter(f => f.endsWith('/' + normalized) || f === normalized);
|
|
124
|
-
if (matches.length === 1) return matches[0];
|
|
125
|
-
|
|
126
|
-
// If input was a path (contains /), don't fall back to basename — it's ambiguous
|
|
127
|
-
if (hasPathSeparator) {
|
|
128
|
-
if (matches.length > 1) return null;
|
|
129
|
-
return null;
|
|
42
|
+
console.log('No files depend on this.\n');
|
|
130
43
|
}
|
|
131
44
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Collect all files that transitively depend on the target file (reverse BFS).
|
|
142
|
-
* maxHops limits the depth of the search.
|
|
143
|
-
*/
|
|
144
|
-
function collectDependents(targetFile, imports, maxHops) {
|
|
145
|
-
const dependents = new Set();
|
|
146
|
-
let frontier = new Set([targetFile]);
|
|
147
|
-
|
|
148
|
-
for (let hop = 0; hop < maxHops; hop++) {
|
|
149
|
-
const nextFrontier = new Set();
|
|
150
|
-
for (const [file, deps] of Object.entries(imports)) {
|
|
151
|
-
if (dependents.has(file)) continue;
|
|
152
|
-
for (const dep of deps) {
|
|
153
|
-
if (frontier.has(dep)) {
|
|
154
|
-
dependents.add(file);
|
|
155
|
-
nextFrontier.add(file);
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
45
|
+
if (br.routesImpacted.length > 0) {
|
|
46
|
+
console.log(`Routes at risk (${br.routesImpacted.length}):`);
|
|
47
|
+
for (const r of br.routesImpacted) {
|
|
48
|
+
const badge = r.risk === 'HIGH' ? '🔴' : r.risk === 'MEDIUM' ? '🟡' : '🟢';
|
|
49
|
+
console.log(` ${badge} ${r.method} ${r.path}`);
|
|
159
50
|
}
|
|
160
|
-
|
|
161
|
-
|
|
51
|
+
} else {
|
|
52
|
+
console.log('No routes directly traceable.');
|
|
162
53
|
}
|
|
163
54
|
|
|
164
|
-
|
|
55
|
+
console.log('');
|
|
56
|
+
carto.terminate();
|
|
165
57
|
}
|
|
166
58
|
|
|
167
59
|
module.exports = { run };
|
package/src/cli/index.js
CHANGED
|
@@ -12,6 +12,7 @@ Commands:
|
|
|
12
12
|
watch Read .carto/config.json, start file watcher
|
|
13
13
|
sync Read .carto/config.json, run one sync, exit
|
|
14
14
|
impact <file> Show which files and routes are affected by changing a file
|
|
15
|
+
check Report cross-domain deps, high-risk uncommitted changes, domain health
|
|
15
16
|
remove Remove AGENTS.md and .carto/ from this project
|
|
16
17
|
serve Start MCP server for AI tool integration
|
|
17
18
|
|
|
@@ -48,6 +49,11 @@ if (command === 'init') {
|
|
|
48
49
|
} else if (command === 'impact') {
|
|
49
50
|
const fileArg = process.argv[3];
|
|
50
51
|
require('./impact').run(process.cwd(), fileArg);
|
|
52
|
+
} else if (command === 'check') {
|
|
53
|
+
require('./check').run(process.cwd()).catch(err => {
|
|
54
|
+
console.error(`[CARTO] Fatal error: ${err.message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
51
57
|
} else if (command === 'remove') {
|
|
52
58
|
require('./remove').run(process.cwd());
|
|
53
59
|
} else if (command === 'serve') {
|