carto-md 1.0.4 → 1.0.6
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/agents/formatter.js +19 -1
- package/src/agents/validator.js +1 -1
- package/src/cli/impact.js +196 -0
- package/src/cli/index.js +7 -3
- package/src/cli/init.js +1 -1
- package/src/detector/files.js +43 -7
- package/src/detector/framework.js +72 -51
- package/src/extractors/filemap.js +9 -0
- package/src/extractors/languages/javascript.js +56 -36
- package/src/extractors/languages/typescript.js +95 -0
- package/src/extractors/stack.js +172 -0
- package/src/sync.js +58 -8
package/package.json
CHANGED
package/src/agents/formatter.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Converts extracted data into markdown sections for AGENTS.md.
|
|
3
5
|
*
|
|
@@ -13,7 +15,7 @@
|
|
|
13
15
|
* 9. Frontend API Calls (auto)
|
|
14
16
|
* 10. Frontend Storage Keys (auto)
|
|
15
17
|
*/
|
|
16
|
-
function formatSections({ routes, models, frontend, structure, warnings, fileMap, functions, dbTables, envVars, importGraph }) {
|
|
18
|
+
function formatSections({ routes, models, frontend, structure, warnings, fileMap, functions, dbTables, envVars, importGraph, stackItems, entryPoints, highImpact }) {
|
|
17
19
|
const sections = [];
|
|
18
20
|
|
|
19
21
|
// 1. Project Structure
|
|
@@ -28,6 +30,22 @@ function formatSections({ routes, models, frontend, structure, warnings, fileMap
|
|
|
28
30
|
sections.push('_No structure data available._');
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// Stack line — right after structure
|
|
34
|
+
if (stackItems && stackItems.length > 0) {
|
|
35
|
+
sections.push(`\n**Stack:** ${stackItems.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Entry points
|
|
39
|
+
if (entryPoints && entryPoints.length > 0) {
|
|
40
|
+
sections.push(`**Entry points:** ${entryPoints.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// High impact files
|
|
44
|
+
if (highImpact && highImpact.length > 0) {
|
|
45
|
+
const items = highImpact.map(h => `${h.file} (${h.count} dependents)`);
|
|
46
|
+
sections.push(`**High impact:** ${items.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
// 2. File Map
|
|
32
50
|
sections.push('\n## File Map (auto)\n');
|
|
33
51
|
if (fileMap && fileMap.length > 0) {
|
package/src/agents/validator.js
CHANGED
|
@@ -14,7 +14,7 @@ function validateExtracted({ routes, models, functions, envVars, dbTables }) {
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
|
|
17
|
+
const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ALL']);
|
|
18
18
|
const VALID_HANDLER = /^[a-zA-Z_]\w*$|^\[anonymous\]$/;
|
|
19
19
|
const VALID_CLASS_NAME = /^[A-Z][a-zA-Z0-9]*$/;
|
|
20
20
|
const VALID_FIELD_NAME = /^[a-z_]\w*$/;
|
|
@@ -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
|
@@ -7,9 +7,10 @@ function printUsage() {
|
|
|
7
7
|
Usage: carto <command>
|
|
8
8
|
|
|
9
9
|
Commands:
|
|
10
|
-
init
|
|
11
|
-
watch
|
|
12
|
-
sync
|
|
10
|
+
init Detect project, write .carto/config.json, run first sync
|
|
11
|
+
watch Read .carto/config.json, start file watcher
|
|
12
|
+
sync Read .carto/config.json, run one sync, exit
|
|
13
|
+
impact <file> Show which files and routes are affected by changing a file
|
|
13
14
|
|
|
14
15
|
Options:
|
|
15
16
|
--help, -h Show this help message
|
|
@@ -36,6 +37,9 @@ if (command === 'init') {
|
|
|
36
37
|
console.error(`[CARTO] Fatal error: ${err.message}`);
|
|
37
38
|
process.exit(1);
|
|
38
39
|
});
|
|
40
|
+
} else if (command === 'impact') {
|
|
41
|
+
const fileArg = process.argv[3];
|
|
42
|
+
require('./impact').run(process.cwd(), fileArg);
|
|
39
43
|
} else {
|
|
40
44
|
console.error(`[CARTO] Unknown command: ${command}`);
|
|
41
45
|
printUsage();
|
package/src/cli/init.js
CHANGED
|
@@ -12,7 +12,7 @@ async function run(projectRoot) {
|
|
|
12
12
|
console.log(`[CARTO] Detected: ${detection.framework} (${detection.language})`);
|
|
13
13
|
|
|
14
14
|
const isIgnored = parseCartoIgnore(projectRoot);
|
|
15
|
-
const files = discoverFiles(projectRoot, detection.framework, isIgnored);
|
|
15
|
+
const files = discoverFiles(projectRoot, detection.framework, isIgnored, detection.secondaryFramework);
|
|
16
16
|
|
|
17
17
|
// Count files for reporting
|
|
18
18
|
const pyCount = files.routeFiles.filter(f => f.endsWith('.py')).length;
|
package/src/detector/files.js
CHANGED
|
@@ -4,17 +4,38 @@ const path = require('path');
|
|
|
4
4
|
const MAX_FILES_PER_CATEGORY = 50;
|
|
5
5
|
|
|
6
6
|
const PYTHON_IGNORE = new Set(['__pycache__', '.venv', 'venv', 'migrations', 'node_modules', '.git', '.carto']);
|
|
7
|
-
const JS_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', '.carto']);
|
|
7
|
+
const JS_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', '.carto', '.next', '.turbo', 'coverage', 'out', '.cache', 'generated', '__generated__', 'storybook-static', 'public', 'static']);
|
|
8
8
|
const HTML_IGNORE = new Set(['node_modules', '.git', '.carto']);
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* discoverFiles(projectRoot, framework, isIgnored) → { routeFiles, modelFiles, frontendFiles }
|
|
11
|
+
* discoverFiles(projectRoot, framework, isIgnored, secondaryFramework) → { routeFiles, modelFiles, frontendFiles }
|
|
12
12
|
*
|
|
13
13
|
* isIgnored is an optional function (filePath) → boolean from the .cartoignore parser.
|
|
14
|
+
* If secondaryFramework is provided, discovers files for both and merges.
|
|
14
15
|
*/
|
|
15
|
-
function discoverFiles(projectRoot, framework, isIgnored) {
|
|
16
|
+
function discoverFiles(projectRoot, framework, isIgnored, secondaryFramework) {
|
|
16
17
|
const ignoreFn = isIgnored || (() => false);
|
|
17
18
|
|
|
19
|
+
const primary = discoverForFramework(projectRoot, framework, ignoreFn);
|
|
20
|
+
|
|
21
|
+
if (secondaryFramework) {
|
|
22
|
+
const secondary = discoverForFramework(projectRoot, secondaryFramework, ignoreFn);
|
|
23
|
+
// Merge and deduplicate
|
|
24
|
+
const routeFiles = [...new Set([...primary.routeFiles, ...secondary.routeFiles])];
|
|
25
|
+
const modelFiles = [...new Set([...primary.modelFiles, ...secondary.modelFiles])];
|
|
26
|
+
const frontendFiles = [...new Set([...primary.frontendFiles, ...secondary.frontendFiles])];
|
|
27
|
+
return {
|
|
28
|
+
routeFiles: cap(routeFiles),
|
|
29
|
+
modelFiles: cap(modelFiles),
|
|
30
|
+
frontendFiles: cap(frontendFiles)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return primary;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function discoverForFramework(projectRoot, framework, ignoreFn) {
|
|
38
|
+
|
|
18
39
|
if (['fastapi', 'django', 'flask', 'python-generic'].includes(framework)) {
|
|
19
40
|
const pyFiles = findFilesRecursive(projectRoot, ['.py'], PYTHON_IGNORE, ignoreFn)
|
|
20
41
|
.filter(f => {
|
|
@@ -81,12 +102,27 @@ function findFilesRecursive(dir, extensions, ignoreDirs, isIgnored, results = []
|
|
|
81
102
|
return results;
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
function scoreFile(filePath) {
|
|
106
|
+
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
|
107
|
+
const base = path.basename(p);
|
|
108
|
+
if (base === 'main.py' || base === 'app.py' || base === 'server.ts' ||
|
|
109
|
+
base === 'server.js' || base === 'app.ts' || base === 'index.ts' ||
|
|
110
|
+
base === 'route.ts' || base === 'routes.ts' || base === 'routes.js') return 10;
|
|
111
|
+
if (p.includes('/api/') || p.includes('/routes/') || p.includes('/route/')) return 9;
|
|
112
|
+
if (p.includes('/models/') || p.includes('/schemas/') || p.includes('/schema/')) return 8;
|
|
113
|
+
if (p.includes('/services/') || p.includes('/controllers/') || p.includes('/handlers/')) return 6;
|
|
114
|
+
if (p.includes('/lib/') || p.includes('/utils/') || p.includes('/helpers/')) return 2;
|
|
115
|
+
return 4;
|
|
116
|
+
}
|
|
117
|
+
|
|
84
118
|
function cap(files) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
const scored = files.map(f => ({ f, score: scoreFile(f) }))
|
|
120
|
+
.sort((a, b) => b.score - a.score);
|
|
121
|
+
if (scored.length > MAX_FILES_PER_CATEGORY) {
|
|
122
|
+
console.warn(`[CARTO] Warning: Found ${scored.length} files, capping at ${MAX_FILES_PER_CATEGORY}`);
|
|
123
|
+
return scored.slice(0, MAX_FILES_PER_CATEGORY).map(x => x.f);
|
|
88
124
|
}
|
|
89
|
-
return
|
|
125
|
+
return scored.map(x => x.f);
|
|
90
126
|
}
|
|
91
127
|
|
|
92
128
|
module.exports = { discoverFiles };
|
|
@@ -3,40 +3,64 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
const IGNORE_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv', '.idea', '.vscode', '.carto']);
|
|
5
5
|
|
|
6
|
+
// Priority order: lower index = higher priority
|
|
7
|
+
const PYTHON_PRIORITY = ['fastapi', 'django', 'flask', 'python-generic'];
|
|
8
|
+
const JS_PRIORITY = ['nextjs', 'express', 'react', 'node-generic'];
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
|
-
* detectFramework(projectRoot) → { framework, language, confidence }
|
|
8
|
-
*
|
|
9
|
-
* Search order (recursive up to 3 levels deep):
|
|
10
|
-
* 1. requirements.txt → fastapi / django / flask / python-generic
|
|
11
|
-
* 2. package.json → nextjs / express / react / node-generic
|
|
12
|
-
* 3. pyproject.toml → same logic as requirements.txt
|
|
13
|
-
* 4. Nothing found → { framework: 'unknown', language: 'unknown' }
|
|
11
|
+
* detectFramework(projectRoot) → { framework, language, confidence, secondaryFramework?, secondaryLanguage? }
|
|
14
12
|
*
|
|
15
|
-
*
|
|
13
|
+
* Collects all matches from requirements.txt, package.json, pyproject.toml.
|
|
14
|
+
* Picks the most specific Python and JS framework by priority.
|
|
15
|
+
* If both a Python and JS framework are detected, returns both
|
|
16
|
+
* (primary = highest priority overall, secondary = the other language).
|
|
16
17
|
*/
|
|
17
18
|
function detectFramework(projectRoot) {
|
|
18
|
-
// Search for files up to 3 levels deep
|
|
19
19
|
const candidates = findFile(projectRoot, ['requirements.txt', 'package.json', 'pyproject.toml'], 3);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
const pythonDetections = new Set();
|
|
22
|
+
const jsDetections = new Set();
|
|
23
|
+
|
|
24
|
+
// Check requirements.txt
|
|
25
|
+
for (const f of candidates.filter(f => path.basename(f) === 'requirements.txt')) {
|
|
26
|
+
const results = detectAllFromPythonDeps(f);
|
|
27
|
+
for (const r of results) pythonDetections.add(r);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
//
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
if (result) return result;
|
|
30
|
+
// Check pyproject.toml
|
|
31
|
+
for (const f of candidates.filter(f => path.basename(f) === 'pyproject.toml')) {
|
|
32
|
+
const results = detectAllFromPythonDeps(f);
|
|
33
|
+
for (const r of results) pythonDetections.add(r);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
//
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
36
|
+
// Check package.json
|
|
37
|
+
for (const f of candidates.filter(f => path.basename(f) === 'package.json')) {
|
|
38
|
+
const results = detectAllFromPackageJson(f);
|
|
39
|
+
for (const r of results) jsDetections.add(r);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Pick best Python framework by priority
|
|
43
|
+
const bestPython = PYTHON_PRIORITY.find(fw => pythonDetections.has(fw)) || null;
|
|
44
|
+
// Pick best JS framework by priority
|
|
45
|
+
const bestJS = JS_PRIORITY.find(fw => jsDetections.has(fw)) || null;
|
|
46
|
+
|
|
47
|
+
if (bestPython && bestJS) {
|
|
48
|
+
// Both detected — Python is primary (higher priority in the global list)
|
|
49
|
+
return {
|
|
50
|
+
framework: bestPython,
|
|
51
|
+
language: 'python',
|
|
52
|
+
confidence: 'high',
|
|
53
|
+
secondaryFramework: bestJS,
|
|
54
|
+
secondaryLanguage: 'javascript'
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (bestPython) {
|
|
59
|
+
return { framework: bestPython, language: 'python', confidence: 'high' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (bestJS) {
|
|
63
|
+
return { framework: bestJS, language: 'javascript', confidence: 'high' };
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
return { framework: 'unknown', language: 'unknown', confidence: 'none' };
|
|
@@ -69,49 +93,46 @@ function findFile(dir, fileNames, maxDepth, currentDepth = 0) {
|
|
|
69
93
|
return results;
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Returns all matching Python frameworks from a deps file.
|
|
98
|
+
*/
|
|
99
|
+
function detectAllFromPythonDeps(filePath) {
|
|
100
|
+
const detected = [];
|
|
73
101
|
let content;
|
|
74
102
|
try {
|
|
75
103
|
content = fs.readFileSync(filePath, 'utf-8').toLowerCase();
|
|
76
104
|
} catch {
|
|
77
|
-
return
|
|
105
|
+
return detected;
|
|
78
106
|
}
|
|
79
107
|
|
|
80
|
-
if (content.includes('fastapi'))
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (content.includes('
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (content.includes('flask')) {
|
|
87
|
-
return { framework: 'flask', language: 'python', confidence: 'high' };
|
|
88
|
-
}
|
|
89
|
-
if (content.includes('pydantic')) {
|
|
90
|
-
return { framework: 'python-generic', language: 'python', confidence: 'medium' };
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
108
|
+
if (content.includes('fastapi')) detected.push('fastapi');
|
|
109
|
+
if (content.includes('django')) detected.push('django');
|
|
110
|
+
if (content.includes('flask')) detected.push('flask');
|
|
111
|
+
if (content.includes('pydantic') && !detected.length) detected.push('python-generic');
|
|
112
|
+
|
|
113
|
+
return detected;
|
|
93
114
|
}
|
|
94
115
|
|
|
95
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Returns all matching JS frameworks from a package.json.
|
|
118
|
+
*/
|
|
119
|
+
function detectAllFromPackageJson(filePath) {
|
|
120
|
+
const detected = [];
|
|
96
121
|
let pkg;
|
|
97
122
|
try {
|
|
98
123
|
pkg = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
99
124
|
} catch {
|
|
100
|
-
return
|
|
125
|
+
return detected;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
|
|
104
129
|
|
|
105
|
-
if (deps['next'])
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (deps['react']) {
|
|
112
|
-
return { framework: 'react', language: 'javascript', confidence: 'high' };
|
|
113
|
-
}
|
|
114
|
-
return { framework: 'node-generic', language: 'javascript', confidence: 'medium' };
|
|
130
|
+
if (deps['next']) detected.push('nextjs');
|
|
131
|
+
if (deps['express']) detected.push('express');
|
|
132
|
+
if (deps['react'] && !deps['next']) detected.push('react');
|
|
133
|
+
if (!detected.length) detected.push('node-generic');
|
|
134
|
+
|
|
135
|
+
return detected;
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
module.exports = { detectFramework };
|
|
@@ -9,6 +9,15 @@ const path = require('path');
|
|
|
9
9
|
function inferResponsibility(filename, functionCount, routeCount) {
|
|
10
10
|
const base = path.basename(filename).toLowerCase();
|
|
11
11
|
|
|
12
|
+
// Skip barrel exports — index files with no routes and no functions
|
|
13
|
+
if (
|
|
14
|
+
(base === 'index.ts' || base === 'index.js' || base === 'index.tsx' || base === 'index.jsx') &&
|
|
15
|
+
functionCount === 0 &&
|
|
16
|
+
routeCount === 0
|
|
17
|
+
) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
// Skip __init__.py entirely
|
|
13
22
|
if (base === '__init__.py') return null;
|
|
14
23
|
|
|
@@ -73,13 +73,9 @@ function walk(node, visitor) {
|
|
|
73
73
|
function extractExpressRoutes(ast, filename) {
|
|
74
74
|
const routes = [];
|
|
75
75
|
|
|
76
|
-
// Check for Next.js
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
if (isNextRoute) {
|
|
80
|
-
const nextRoutes = extractNextJSRoutes(ast, filename);
|
|
81
|
-
routes.push(...nextRoutes);
|
|
82
|
-
}
|
|
76
|
+
// Check for Next.js Pages/App Router patterns (pages/api/... or app/api/...)
|
|
77
|
+
const nextRoutes = extractNextJSPagesRoutes(ast, filename);
|
|
78
|
+
routes.push(...nextRoutes);
|
|
83
79
|
|
|
84
80
|
walk(ast, (node) => {
|
|
85
81
|
if (node.type !== 'CallExpression') return;
|
|
@@ -128,48 +124,72 @@ function extractExpressRoutes(ast, filename) {
|
|
|
128
124
|
});
|
|
129
125
|
});
|
|
130
126
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
// Deduplicate by method + path
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
return routes.filter(r => {
|
|
130
|
+
const key = `${r.method}::${r.path}`;
|
|
131
|
+
if (seen.has(key)) return false;
|
|
132
|
+
seen.add(key);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Detects Next.js Pages Router pattern:
|
|
139
|
+
* export default function handler(req, res) in files under pages/api/
|
|
140
|
+
* Also handles App Router export default in app/api/ files
|
|
141
|
+
*/
|
|
142
|
+
function extractNextJSPagesRoutes(ast, filename) {
|
|
140
143
|
const routes = [];
|
|
141
|
-
const
|
|
144
|
+
const normalizedPath = ('/' + filename).replace(/\\/g, '/');
|
|
145
|
+
const isApiFile = normalizedPath.includes('/pages/api/') || normalizedPath.includes('/app/api/');
|
|
146
|
+
|
|
147
|
+
if (!isApiFile) return routes;
|
|
148
|
+
if (!ast.program || !ast.program.body) return routes;
|
|
149
|
+
|
|
150
|
+
const HTTP_METHODS_UPPER = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
|
|
151
|
+
|
|
152
|
+
// Infer route path from filename
|
|
153
|
+
let routePath = '[inferred]';
|
|
154
|
+
const pagesMatch = normalizedPath.match(/\/pages(\/api\/.+)/);
|
|
155
|
+
const appMatch = normalizedPath.match(/\/app(\/api\/.+)/);
|
|
156
|
+
if (pagesMatch) {
|
|
157
|
+
routePath = pagesMatch[1].replace(/\/[^/]+$/, '').replace(/\/index$/, '');
|
|
158
|
+
if (!routePath || routePath === '/api') routePath = '/api';
|
|
159
|
+
} else if (appMatch) {
|
|
160
|
+
routePath = appMatch[1].replace(/\/[^/]+$/, '').replace(/\/route$/, '');
|
|
161
|
+
if (!routePath || routePath === '/api') routePath = '/api';
|
|
162
|
+
}
|
|
142
163
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
for (const node of ast.program.body) {
|
|
165
|
+
// export default function handler(req, res)
|
|
166
|
+
if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
|
|
167
|
+
const decl = node.declaration;
|
|
168
|
+
if (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression') {
|
|
169
|
+
const funcName = (decl.id && decl.id.name) || 'handler';
|
|
170
|
+
if (HTTP_METHODS_UPPER.has(funcName.toUpperCase())) {
|
|
171
|
+
routes.push({ method: funcName.toUpperCase(), path: routePath, functionName: funcName });
|
|
172
|
+
} else {
|
|
173
|
+
routes.push({ method: 'ALL', path: routePath, functionName: funcName });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
147
177
|
|
|
148
|
-
|
|
149
|
-
// export async function GET(req) { ... }
|
|
178
|
+
// export function GET/POST/... (named exports in api files)
|
|
150
179
|
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
|
|
151
180
|
const decl = node.declaration;
|
|
152
|
-
if (decl.type === 'FunctionDeclaration' && decl.id &&
|
|
153
|
-
routes.push({
|
|
154
|
-
method: decl.id.name,
|
|
155
|
-
path: routePath,
|
|
156
|
-
functionName: decl.id.name
|
|
157
|
-
});
|
|
181
|
+
if (decl.type === 'FunctionDeclaration' && decl.id && HTTP_METHODS_UPPER.has(decl.id.name.toUpperCase())) {
|
|
182
|
+
routes.push({ method: decl.id.name.toUpperCase(), path: routePath, functionName: decl.id.name });
|
|
158
183
|
}
|
|
159
|
-
// export const GET = async (req) => { ... }
|
|
160
184
|
if (decl.type === 'VariableDeclaration') {
|
|
161
|
-
for (const
|
|
162
|
-
if (
|
|
163
|
-
routes.push({
|
|
164
|
-
method: declarator.id.name,
|
|
165
|
-
path: routePath,
|
|
166
|
-
functionName: declarator.id.name
|
|
167
|
-
});
|
|
185
|
+
for (const vDecl of decl.declarations) {
|
|
186
|
+
if (vDecl.id && vDecl.id.name && HTTP_METHODS_UPPER.has(vDecl.id.name.toUpperCase())) {
|
|
187
|
+
routes.push({ method: vDecl.id.name.toUpperCase(), path: routePath, functionName: vDecl.id.name });
|
|
168
188
|
}
|
|
169
189
|
}
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
|
-
}
|
|
192
|
+
}
|
|
173
193
|
|
|
174
194
|
return routes;
|
|
175
195
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const parser = require('@babel/parser');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const jsPlugin = require('./javascript');
|
|
3
4
|
|
|
4
5
|
const TS_PARSE_OPTIONS = {
|
|
@@ -32,6 +33,100 @@ module.exports = {
|
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Next.js Pages Router route detection
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detects Next.js route patterns:
|
|
44
|
+
*
|
|
45
|
+
* Pages Router (pages/api/...):
|
|
46
|
+
* export default function handler(req, res) — treated as ALL methods
|
|
47
|
+
* export default async function handler(req, res)
|
|
48
|
+
*
|
|
49
|
+
* App Router (app/api/.../route.ts):
|
|
50
|
+
* export function GET/POST/PUT/DELETE/PATCH
|
|
51
|
+
* export async function GET/POST/PUT/DELETE/PATCH
|
|
52
|
+
* export const GET/POST = ...
|
|
53
|
+
*/
|
|
54
|
+
function extractNextJSPagesRoutes(ast, filename) {
|
|
55
|
+
const routes = [];
|
|
56
|
+
const normalizedPath = ('/' + filename).replace(/\\/g, '/');
|
|
57
|
+
const isApiFile = normalizedPath.includes('/pages/api/') || normalizedPath.includes('/app/api/');
|
|
58
|
+
|
|
59
|
+
if (!isApiFile) return routes;
|
|
60
|
+
if (!ast.program || !ast.program.body) return routes;
|
|
61
|
+
|
|
62
|
+
// Infer route path from filename
|
|
63
|
+
// pages/api/users/[id].ts → /api/users/[id]
|
|
64
|
+
// app/api/users/route.ts → /api/users
|
|
65
|
+
let routePath = '[inferred]';
|
|
66
|
+
const pagesMatch = normalizedPath.match(/\/pages(\/api\/.+)/);
|
|
67
|
+
const appMatch = normalizedPath.match(/\/app(\/api\/.+)/);
|
|
68
|
+
if (pagesMatch) {
|
|
69
|
+
routePath = pagesMatch[1].replace(/\/[^/]+$/, '').replace(/\/index$/, '');
|
|
70
|
+
if (!routePath || routePath === '/api') routePath = '/api';
|
|
71
|
+
} else if (appMatch) {
|
|
72
|
+
routePath = appMatch[1].replace(/\/[^/]+$/, '').replace(/\/route$/, '');
|
|
73
|
+
if (!routePath || routePath === '/api') routePath = '/api';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const node of ast.program.body) {
|
|
77
|
+
// export default function handler(req, res) — Pages Router pattern
|
|
78
|
+
if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
|
|
79
|
+
const decl = node.declaration;
|
|
80
|
+
if (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression') {
|
|
81
|
+
const funcName = (decl.id && decl.id.name) || 'handler';
|
|
82
|
+
|
|
83
|
+
// If the function name is an HTTP method, use it
|
|
84
|
+
if (HTTP_METHODS.has(funcName.toUpperCase())) {
|
|
85
|
+
routes.push({
|
|
86
|
+
method: funcName.toUpperCase(),
|
|
87
|
+
path: routePath,
|
|
88
|
+
functionName: funcName
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
// Pages Router default export — handles all methods
|
|
92
|
+
routes.push({
|
|
93
|
+
method: 'ALL',
|
|
94
|
+
path: routePath,
|
|
95
|
+
functionName: funcName
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// export function GET/POST/... or export const GET = ... — App Router pattern
|
|
102
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
|
|
103
|
+
const decl = node.declaration;
|
|
104
|
+
|
|
105
|
+
if (decl.type === 'FunctionDeclaration' && decl.id && HTTP_METHODS.has(decl.id.name.toUpperCase())) {
|
|
106
|
+
routes.push({
|
|
107
|
+
method: decl.id.name.toUpperCase(),
|
|
108
|
+
path: routePath,
|
|
109
|
+
functionName: decl.id.name
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (decl.type === 'VariableDeclaration') {
|
|
114
|
+
for (const vDecl of decl.declarations) {
|
|
115
|
+
if (vDecl.id && vDecl.id.name && HTTP_METHODS.has(vDecl.id.name.toUpperCase())) {
|
|
116
|
+
routes.push({
|
|
117
|
+
method: vDecl.id.name.toUpperCase(),
|
|
118
|
+
path: routePath,
|
|
119
|
+
functionName: vDecl.id.name
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return routes;
|
|
128
|
+
}
|
|
129
|
+
|
|
35
130
|
// ---------------------------------------------------------------------------
|
|
36
131
|
// AST traversal helper
|
|
37
132
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects the tech stack from file contents and package manifests.
|
|
6
|
+
* Scans only files Carto already watches — no new file scanning.
|
|
7
|
+
* Returns deduplicated array of stack items, max 6.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Python: import/from patterns → stack name
|
|
11
|
+
const PYTHON_STACK = {
|
|
12
|
+
'fastapi': 'FastAPI',
|
|
13
|
+
'django': 'Django',
|
|
14
|
+
'flask': 'Flask',
|
|
15
|
+
'sqlalchemy': 'SQLAlchemy',
|
|
16
|
+
'boto3': 'AWS',
|
|
17
|
+
'openai': 'OpenAI',
|
|
18
|
+
'anthropic': 'Claude API',
|
|
19
|
+
'google.generativeai': 'Gemini',
|
|
20
|
+
'google-generativeai': 'Gemini',
|
|
21
|
+
'redis': 'Redis',
|
|
22
|
+
'celery': 'Celery',
|
|
23
|
+
'pydantic': 'Pydantic',
|
|
24
|
+
'stripe': 'Stripe',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// JS/TS: import/require patterns → stack name
|
|
28
|
+
const JS_STACK = {
|
|
29
|
+
'next': 'Next.js',
|
|
30
|
+
'express': 'Express',
|
|
31
|
+
'fastify': 'Fastify',
|
|
32
|
+
'react': 'React',
|
|
33
|
+
'@prisma/client': 'Prisma',
|
|
34
|
+
'prisma': 'Prisma',
|
|
35
|
+
'mongoose': 'MongoDB',
|
|
36
|
+
'sequelize': 'Sequelize',
|
|
37
|
+
'drizzle-orm': 'Drizzle',
|
|
38
|
+
'drizzle': 'Drizzle',
|
|
39
|
+
'redis': 'Redis',
|
|
40
|
+
'ioredis': 'Redis',
|
|
41
|
+
'openai': 'OpenAI',
|
|
42
|
+
'@anthropic-ai/sdk': 'Claude API',
|
|
43
|
+
'anthropic': 'Claude API',
|
|
44
|
+
'stripe': 'Stripe',
|
|
45
|
+
'firebase': 'Firebase',
|
|
46
|
+
'firebase-admin': 'Firebase',
|
|
47
|
+
'@supabase/supabase-js': 'Supabase',
|
|
48
|
+
'supabase': 'Supabase',
|
|
49
|
+
'@clerk/nextjs': 'Clerk',
|
|
50
|
+
'@clerk/clerk-sdk-node': 'Clerk',
|
|
51
|
+
'clerk': 'Clerk',
|
|
52
|
+
'next-auth': 'NextAuth',
|
|
53
|
+
'nextauth': 'NextAuth',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const MAX_STACK_ITEMS = 6;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* detectStackFromContent(content, filename) → Array<string>
|
|
60
|
+
* Scans a single file's content for known stack imports.
|
|
61
|
+
*/
|
|
62
|
+
function detectStackFromContent(content, filename) {
|
|
63
|
+
const ext = path.extname(filename).toLowerCase();
|
|
64
|
+
const detected = new Set();
|
|
65
|
+
|
|
66
|
+
if (ext === '.py') {
|
|
67
|
+
// Python imports: import X, from X import Y
|
|
68
|
+
for (const pkg of Object.keys(PYTHON_STACK)) {
|
|
69
|
+
// Match: import fastapi, from fastapi import ..., from fastapi.xxx import ...
|
|
70
|
+
const pattern = new RegExp(`(?:^|\\n)\\s*(?:import\\s+${escapeRegex(pkg)}|from\\s+${escapeRegex(pkg)}(?:\\.|\\s))`, 'm');
|
|
71
|
+
if (pattern.test(content)) {
|
|
72
|
+
detected.add(PYTHON_STACK[pkg]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
|
|
76
|
+
// JS/TS imports: import ... from 'pkg', require('pkg')
|
|
77
|
+
for (const pkg of Object.keys(JS_STACK)) {
|
|
78
|
+
const escaped = escapeRegex(pkg);
|
|
79
|
+
// import ... from 'pkg' or import 'pkg'
|
|
80
|
+
const importPattern = new RegExp(`(?:from|import)\\s+['"]${escaped}(?:[/'"])`);
|
|
81
|
+
// require('pkg')
|
|
82
|
+
const requirePattern = new RegExp(`require\\s*\\(\\s*['"]${escaped}(?:[/'"])`);
|
|
83
|
+
if (importPattern.test(content) || requirePattern.test(content)) {
|
|
84
|
+
detected.add(JS_STACK[pkg]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [...detected];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* detectStackFromPackageJson(projectRoot) → Array<string>
|
|
94
|
+
* Reads package.json dependencies for known stack packages.
|
|
95
|
+
*/
|
|
96
|
+
function detectStackFromPackageJson(projectRoot) {
|
|
97
|
+
const detected = new Set();
|
|
98
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
102
|
+
const allDeps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
|
|
103
|
+
|
|
104
|
+
for (const dep of Object.keys(allDeps)) {
|
|
105
|
+
const depLower = dep.toLowerCase();
|
|
106
|
+
if (JS_STACK[depLower]) {
|
|
107
|
+
detected.add(JS_STACK[depLower]);
|
|
108
|
+
}
|
|
109
|
+
// Also check without scope for scoped packages
|
|
110
|
+
if (JS_STACK[dep]) {
|
|
111
|
+
detected.add(JS_STACK[dep]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// No package.json or can't read — that's fine
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [...detected];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* detectStackFromRequirements(projectRoot) → Array<string>
|
|
123
|
+
* Reads requirements.txt for known Python packages.
|
|
124
|
+
*/
|
|
125
|
+
function detectStackFromRequirements(projectRoot) {
|
|
126
|
+
const detected = new Set();
|
|
127
|
+
const reqPath = path.join(projectRoot, 'requirements.txt');
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const content = fs.readFileSync(reqPath, 'utf-8').toLowerCase();
|
|
131
|
+
for (const pkg of Object.keys(PYTHON_STACK)) {
|
|
132
|
+
// Match package name at start of line, possibly followed by ==, >=, etc.
|
|
133
|
+
const pattern = new RegExp(`^${escapeRegex(pkg.replace('.', '-'))}\\s*[=><~!\\[]`, 'm');
|
|
134
|
+
const simplePattern = new RegExp(`^${escapeRegex(pkg.replace('.', '-'))}\\s*$`, 'm');
|
|
135
|
+
if (pattern.test(content) || simplePattern.test(content)) {
|
|
136
|
+
detected.add(PYTHON_STACK[pkg]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// No requirements.txt — that's fine
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...detected];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* buildStackLine(fileContents, projectRoot) → string
|
|
148
|
+
* Aggregates stack detection across all watched files + manifests.
|
|
149
|
+
* Returns a comma-separated string of max 6 items, or empty string.
|
|
150
|
+
*/
|
|
151
|
+
function buildStackLine(fileContents, projectRoot) {
|
|
152
|
+
const allDetected = new Set();
|
|
153
|
+
|
|
154
|
+
// Scan manifests
|
|
155
|
+
for (const item of detectStackFromPackageJson(projectRoot)) allDetected.add(item);
|
|
156
|
+
for (const item of detectStackFromRequirements(projectRoot)) allDetected.add(item);
|
|
157
|
+
|
|
158
|
+
// Scan file contents
|
|
159
|
+
for (const { filePath, content } of fileContents) {
|
|
160
|
+
const items = detectStackFromContent(content, filePath);
|
|
161
|
+
for (const item of items) allDetected.add(item);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sorted = [...allDetected].sort();
|
|
165
|
+
return sorted.slice(0, MAX_STACK_ITEMS);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function escapeRegex(str) {
|
|
169
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { buildStackLine, detectStackFromContent };
|
package/src/sync.js
CHANGED
|
@@ -6,6 +6,7 @@ const { mergeIntoAgentsMd } = require('./agents/merger');
|
|
|
6
6
|
const { inferResponsibility } = require('./extractors/filemap');
|
|
7
7
|
const { validateExtracted } = require('./agents/validator');
|
|
8
8
|
const { buildImportGraph } = require('./extractors/imports');
|
|
9
|
+
const { buildStackLine } = require('./extractors/stack');
|
|
9
10
|
|
|
10
11
|
const IGNORE_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv', '.idea', '.vscode', '.carto', 'AGENTS.md']);
|
|
11
12
|
|
|
@@ -71,7 +72,7 @@ async function runFullSync(config) {
|
|
|
71
72
|
// Routes per file for file map
|
|
72
73
|
const routeCountMap = {};
|
|
73
74
|
// Env vars: { varName: Set([filename, ...]) }
|
|
74
|
-
const envVarMap =
|
|
75
|
+
const envVarMap = new Map();
|
|
75
76
|
// DB tables: [{ tableName, modelName, file }]
|
|
76
77
|
const dbTableList = [];
|
|
77
78
|
|
|
@@ -89,6 +90,7 @@ async function runFullSync(config) {
|
|
|
89
90
|
if (!content) continue;
|
|
90
91
|
|
|
91
92
|
const basename = path.basename(filePath);
|
|
93
|
+
const relPath = path.relative(config.projectRoot, filePath);
|
|
92
94
|
const plugin = getPluginForFile(plugins, filePath);
|
|
93
95
|
|
|
94
96
|
if (!plugin) {
|
|
@@ -96,7 +98,7 @@ async function runFullSync(config) {
|
|
|
96
98
|
continue;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
const result = plugin.extract(content,
|
|
101
|
+
const result = plugin.extract(content, relPath);
|
|
100
102
|
|
|
101
103
|
// Routes
|
|
102
104
|
allRoutes = allRoutes.concat(result.routes);
|
|
@@ -112,8 +114,8 @@ async function runFullSync(config) {
|
|
|
112
114
|
|
|
113
115
|
// Env vars
|
|
114
116
|
for (const varName of result.envVars) {
|
|
115
|
-
if (!envVarMap
|
|
116
|
-
envVarMap
|
|
117
|
+
if (!envVarMap.has(varName)) envVarMap.set(varName, new Set());
|
|
118
|
+
envVarMap.get(varName).add(basename);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// DB tables
|
|
@@ -181,22 +183,47 @@ async function runFullSync(config) {
|
|
|
181
183
|
}
|
|
182
184
|
const importGraph = buildImportGraph(fileContentsForImports, config.projectRoot);
|
|
183
185
|
|
|
186
|
+
// Detect tech stack from watched files + manifests
|
|
187
|
+
const stackItems = buildStackLine(fileContentsForImports, config.projectRoot);
|
|
188
|
+
|
|
189
|
+
// Compute entry points and high impact files from import graph
|
|
190
|
+
const allValues = new Set();
|
|
191
|
+
for (const deps of Object.values(importGraph)) {
|
|
192
|
+
for (const dep of deps) allValues.add(dep);
|
|
193
|
+
}
|
|
194
|
+
// Entry points: files that import 3+ others but nothing imports them
|
|
195
|
+
const entryPoints = Object.keys(importGraph)
|
|
196
|
+
.filter(f => !allValues.has(f) && importGraph[f].length >= 3)
|
|
197
|
+
.sort();
|
|
198
|
+
// High impact: files imported by 3+ others, sorted descending by count
|
|
199
|
+
const depCount = {};
|
|
200
|
+
for (const deps of Object.values(importGraph)) {
|
|
201
|
+
for (const dep of deps) {
|
|
202
|
+
depCount[dep] = (depCount[dep] || 0) + 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const highImpact = Object.entries(depCount)
|
|
206
|
+
.filter(([, count]) => count >= 2)
|
|
207
|
+
.sort((a, b) => b[1] - a[1])
|
|
208
|
+
.map(([file, count]) => ({ file, count }));
|
|
209
|
+
|
|
184
210
|
// Build file map
|
|
185
211
|
const fileMap = [];
|
|
186
212
|
for (const filePath of allCodeFiles) {
|
|
187
213
|
const basename = path.basename(filePath);
|
|
214
|
+
const relPath = path.relative(config.projectRoot, filePath);
|
|
188
215
|
const funcCount = (functionsMap[basename] || []).length;
|
|
189
216
|
const routeCount = routeCountMap[filePath] || 0;
|
|
190
217
|
const responsibility = inferResponsibility(basename, funcCount, routeCount);
|
|
191
218
|
if (responsibility && responsibility !== '\u2014') {
|
|
192
|
-
fileMap.push({ file:
|
|
219
|
+
fileMap.push({ file: relPath, responsibility });
|
|
193
220
|
}
|
|
194
221
|
}
|
|
195
222
|
|
|
196
223
|
// Aggregate env vars into sorted array
|
|
197
|
-
const envVars =
|
|
224
|
+
const envVars = [...envVarMap.keys()]
|
|
198
225
|
.sort()
|
|
199
|
-
.map(name => ({ name, files: [...envVarMap
|
|
226
|
+
.map(name => ({ name, files: [...envVarMap.get(name)].sort() }));
|
|
200
227
|
|
|
201
228
|
// Scan project structure
|
|
202
229
|
const structure = await scanStructure(config.projectRoot);
|
|
@@ -220,10 +247,33 @@ async function runFullSync(config) {
|
|
|
220
247
|
functions: validated.functions,
|
|
221
248
|
dbTables: validated.dbTables,
|
|
222
249
|
envVars: validated.envVars,
|
|
223
|
-
importGraph
|
|
250
|
+
importGraph,
|
|
251
|
+
stackItems,
|
|
252
|
+
entryPoints,
|
|
253
|
+
highImpact
|
|
224
254
|
});
|
|
225
255
|
|
|
226
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
|
+
}
|
|
227
277
|
}
|
|
228
278
|
|
|
229
279
|
module.exports = { runFullSync, safeReadFile, scanStructure };
|