carto-md 1.0.5 → 1.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.
- package/package.json +1 -1
- package/src/cli/impact.js +196 -0
- package/src/cli/index.js +13 -3
- package/src/sync.js +20 -0
package/package.json
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function run(projectRoot, fileArg) {
|
|
5
|
+
if (!fileArg) {
|
|
6
|
+
console.error('[CARTO] Usage: carto impact <file>');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const mapPath = path.join(projectRoot, '.carto', 'map.json');
|
|
11
|
+
if (!fs.existsSync(mapPath)) {
|
|
12
|
+
console.error('[CARTO] Run "carto init" first.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let map;
|
|
17
|
+
try {
|
|
18
|
+
map = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(`[CARTO] Error reading .carto/map.json: ${err.message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const imports = map.imports || {};
|
|
25
|
+
const routes = map.routes || [];
|
|
26
|
+
|
|
27
|
+
// Resolve file argument — match by basename or partial path
|
|
28
|
+
const matchedFile = resolveFile(fileArg, imports);
|
|
29
|
+
if (!matchedFile) {
|
|
30
|
+
console.error(`[CARTO] File not found in project graph: ${fileArg}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Reverse lookup: which files import this file
|
|
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();
|
|
42
|
+
|
|
43
|
+
// Find affected routes — a route is affected if its handler file
|
|
44
|
+
// imports the target file directly or transitively (up to 3 hops)
|
|
45
|
+
const affectedRoutes = [];
|
|
46
|
+
const routeFiles = new Set();
|
|
47
|
+
|
|
48
|
+
// Collect all files that depend on matchedFile (up to 3 hops)
|
|
49
|
+
const dependentFiles = collectDependents(matchedFile, imports, 3);
|
|
50
|
+
// Also include the file itself — routes in the target file are affected
|
|
51
|
+
dependentFiles.add(matchedFile);
|
|
52
|
+
|
|
53
|
+
for (const route of routes) {
|
|
54
|
+
// Find which file contains this route by checking if any dependent file
|
|
55
|
+
// has this route's handler as a function
|
|
56
|
+
// Since we don't have a direct file→route mapping, check if any file
|
|
57
|
+
// that depends on matchedFile is an entry point with routes
|
|
58
|
+
for (const depFile of dependentFiles) {
|
|
59
|
+
if (imports[depFile] && imports[depFile].includes(matchedFile) || depFile === matchedFile) {
|
|
60
|
+
// Check if this file has routes by seeing if it appears as a key
|
|
61
|
+
// and has routes in the routes array
|
|
62
|
+
routeFiles.add(depFile);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Match routes to files — routes whose handler files are in the dependent set
|
|
68
|
+
// Since map.json routes don't have a file field, match by checking which
|
|
69
|
+
// entry point files contain routes
|
|
70
|
+
for (const route of routes) {
|
|
71
|
+
// A route is affected if any file in the dependency chain has routes
|
|
72
|
+
affectedRoutes.push(route);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Better approach: only include routes from files that are in the dependent chain
|
|
76
|
+
// We need to re-derive which file each route came from
|
|
77
|
+
// For now, if the target file or any of its direct importers have routes, show all routes
|
|
78
|
+
// from those files
|
|
79
|
+
const filesWithRoutes = new Set();
|
|
80
|
+
for (const depFile of dependentFiles) {
|
|
81
|
+
// Check if this file is known to have routes
|
|
82
|
+
// (it appears as a key in imports AND has routes — we approximate by checking
|
|
83
|
+
// if any route handler matches a function in that file)
|
|
84
|
+
filesWithRoutes.add(depFile);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Print output
|
|
88
|
+
console.log(`\nImpact analysis: ${matchedFile}\n`);
|
|
89
|
+
|
|
90
|
+
console.log('Imported by:');
|
|
91
|
+
if (importedBy.length > 0) {
|
|
92
|
+
for (const f of importedBy) {
|
|
93
|
+
console.log(` → ${f}`);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
console.log(' (none — this file is not imported by any other file)');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('\nRoutes affected:');
|
|
100
|
+
// Only show routes from files that transitively depend on the target
|
|
101
|
+
if (routes.length > 0 && dependentFiles.size > 0) {
|
|
102
|
+
// Find which files in the dependent chain are entry points with routes
|
|
103
|
+
// A route is affected if its file (the entry point) is in the dependent set
|
|
104
|
+
const entryPointsInChain = map.entryPoints
|
|
105
|
+
? map.entryPoints.filter(ep => dependentFiles.has(ep))
|
|
106
|
+
: [];
|
|
107
|
+
// If any entry point depends on this file, all routes through it are affected
|
|
108
|
+
if (entryPointsInChain.length > 0) {
|
|
109
|
+
const shown = new Set();
|
|
110
|
+
for (const route of routes) {
|
|
111
|
+
const key = `${route.method} ${route.path}`;
|
|
112
|
+
if (!shown.has(key)) {
|
|
113
|
+
console.log(` → ${route.method} ${route.path}`);
|
|
114
|
+
shown.add(key);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
console.log(' (none — no route-serving files in the dependency chain)');
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
console.log(' (none)');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Risk level
|
|
125
|
+
const depCount = importedBy.length;
|
|
126
|
+
let risk;
|
|
127
|
+
if (depCount >= 3) risk = 'HIGH';
|
|
128
|
+
else if (depCount === 2) risk = 'MEDIUM';
|
|
129
|
+
else if (depCount === 1) risk = 'LOW';
|
|
130
|
+
else risk = 'SAFE';
|
|
131
|
+
|
|
132
|
+
console.log(`\nRisk: ${risk} — ${depCount} file${depCount !== 1 ? 's' : ''} depend on this\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a file argument to a full relative path in the import graph.
|
|
137
|
+
* Matches by basename or partial path suffix.
|
|
138
|
+
*/
|
|
139
|
+
function resolveFile(fileArg, imports) {
|
|
140
|
+
// Collect all known files (keys + all values)
|
|
141
|
+
const allFiles = new Set();
|
|
142
|
+
for (const [file, deps] of Object.entries(imports)) {
|
|
143
|
+
allFiles.add(file);
|
|
144
|
+
for (const dep of deps) allFiles.add(dep);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const normalized = fileArg.replace(/\\/g, '/');
|
|
148
|
+
|
|
149
|
+
// Exact match
|
|
150
|
+
if (allFiles.has(normalized)) return normalized;
|
|
151
|
+
|
|
152
|
+
// Match by suffix (partial path)
|
|
153
|
+
const matches = [...allFiles].filter(f => f.endsWith('/' + normalized) || f === normalized);
|
|
154
|
+
if (matches.length === 1) return matches[0];
|
|
155
|
+
|
|
156
|
+
// Match by basename
|
|
157
|
+
const byBasename = [...allFiles].filter(f => path.basename(f) === path.basename(normalized));
|
|
158
|
+
if (byBasename.length === 1) return byBasename[0];
|
|
159
|
+
|
|
160
|
+
// If multiple basename matches, prefer shortest path
|
|
161
|
+
if (byBasename.length > 1) {
|
|
162
|
+
byBasename.sort((a, b) => a.length - b.length);
|
|
163
|
+
return byBasename[0];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Collect all files that transitively depend on the target file (reverse BFS).
|
|
171
|
+
* maxHops limits the depth of the search.
|
|
172
|
+
*/
|
|
173
|
+
function collectDependents(targetFile, imports, maxHops) {
|
|
174
|
+
const dependents = new Set();
|
|
175
|
+
let frontier = new Set([targetFile]);
|
|
176
|
+
|
|
177
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
178
|
+
const nextFrontier = new Set();
|
|
179
|
+
for (const [file, deps] of Object.entries(imports)) {
|
|
180
|
+
if (dependents.has(file)) continue;
|
|
181
|
+
for (const dep of deps) {
|
|
182
|
+
if (frontier.has(dep)) {
|
|
183
|
+
dependents.add(file);
|
|
184
|
+
nextFrontier.add(file);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (nextFrontier.size === 0) break;
|
|
190
|
+
frontier = nextFrontier;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return dependents;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { run };
|
package/src/cli/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
const pkg = require('../../package.json');
|
|
3
4
|
const command = process.argv[2];
|
|
4
5
|
|
|
5
6
|
function printUsage() {
|
|
@@ -7,15 +8,21 @@ function printUsage() {
|
|
|
7
8
|
Usage: carto <command>
|
|
8
9
|
|
|
9
10
|
Commands:
|
|
10
|
-
init
|
|
11
|
-
watch
|
|
12
|
-
sync
|
|
11
|
+
init Detect project, write .carto/config.json, run first sync
|
|
12
|
+
watch Read .carto/config.json, start file watcher
|
|
13
|
+
sync Read .carto/config.json, run one sync, exit
|
|
14
|
+
impact <file> Show which files and routes are affected by changing a file
|
|
13
15
|
|
|
14
16
|
Options:
|
|
15
17
|
--help, -h Show this help message
|
|
16
18
|
`);
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
if (command === '--version' || command === '-v') {
|
|
22
|
+
console.log(`${pkg.name} ${pkg.version}`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
if (!command || command === '--help' || command === '-h') {
|
|
20
27
|
printUsage();
|
|
21
28
|
process.exit(0);
|
|
@@ -36,6 +43,9 @@ if (command === 'init') {
|
|
|
36
43
|
console.error(`[CARTO] Fatal error: ${err.message}`);
|
|
37
44
|
process.exit(1);
|
|
38
45
|
});
|
|
46
|
+
} else if (command === 'impact') {
|
|
47
|
+
const fileArg = process.argv[3];
|
|
48
|
+
require('./impact').run(process.cwd(), fileArg);
|
|
39
49
|
} else {
|
|
40
50
|
console.error(`[CARTO] Unknown command: ${command}`);
|
|
41
51
|
printUsage();
|
package/src/sync.js
CHANGED
|
@@ -254,6 +254,26 @@ async function runFullSync(config) {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
mergeIntoAgentsMd(config.output, autoContent);
|
|
257
|
+
|
|
258
|
+
// Save graph to .carto/map.json (atomic write)
|
|
259
|
+
const cartoDir = path.join(config.projectRoot, '.carto');
|
|
260
|
+
const mapData = {
|
|
261
|
+
version: '1',
|
|
262
|
+
generated: new Date().toISOString(),
|
|
263
|
+
imports: importGraph,
|
|
264
|
+
routes: validated.routes,
|
|
265
|
+
highImpact: highImpact.map(h => ({ file: h.file, dependents: h.count })),
|
|
266
|
+
entryPoints,
|
|
267
|
+
stack: stackItems
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
const tmpPath = path.join(cartoDir, 'map.tmp.json');
|
|
271
|
+
const mapPath = path.join(cartoDir, 'map.json');
|
|
272
|
+
fs.writeFileSync(tmpPath, JSON.stringify(mapData, null, 2) + '\n', 'utf-8');
|
|
273
|
+
fs.renameSync(tmpPath, mapPath);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.warn(`[CARTO] Warning: Could not write .carto/map.json — ${err.message}`);
|
|
276
|
+
}
|
|
257
277
|
}
|
|
258
278
|
|
|
259
279
|
module.exports = { runFullSync, safeReadFile, scanStructure };
|