carto-md 1.0.3 → 1.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carto-md",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "The context layer for AI-native development.",
5
5
  "bin": {
6
6
  "carto": "src/cli/index.js"
@@ -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) {
@@ -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*$/;
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;
@@ -55,11 +55,14 @@ async function run(projectRoot) {
55
55
  'utf-8'
56
56
  );
57
57
 
58
+ // Install pre-commit hook
59
+ installGitHook(projectRoot);
60
+
58
61
  // Run first sync
59
62
  const syncConfig = resolveConfig(projectRoot, config);
60
63
  await runFullSync(syncConfig);
61
64
 
62
- console.log('[CARTO] AGENTS.md generated. Run "carto watch" to keep it live.');
65
+ console.log('[CARTO] AGENTS.md generated. Carto will sync on every git commit.');
63
66
  }
64
67
 
65
68
  /**
@@ -77,4 +80,29 @@ function resolveConfig(projectRoot, config) {
77
80
  };
78
81
  }
79
82
 
83
+ function installGitHook(projectRoot) {
84
+ const gitDir = path.join(projectRoot, '.git');
85
+ if (!fs.existsSync(gitDir)) return;
86
+
87
+ const hooksDir = path.join(gitDir, 'hooks');
88
+ if (!fs.existsSync(hooksDir)) fs.mkdirSync(hooksDir, { recursive: true });
89
+
90
+ const hookPath = path.join(hooksDir, 'pre-commit');
91
+ const hookLine = 'carto sync\n';
92
+
93
+ if (fs.existsSync(hookPath)) {
94
+ const existing = fs.readFileSync(hookPath, 'utf-8');
95
+ if (existing.includes('carto sync')) {
96
+ console.log('[CARTO] Git hook already installed.');
97
+ return;
98
+ }
99
+ fs.appendFileSync(hookPath, '\n' + hookLine);
100
+ } else {
101
+ fs.writeFileSync(hookPath, '#!/bin/sh\n' + hookLine);
102
+ }
103
+
104
+ fs.chmodSync(hookPath, '755');
105
+ console.log('[CARTO] Git pre-commit hook installed.');
106
+ }
107
+
80
108
  module.exports = { run, resolveConfig };
@@ -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
- if (files.length > MAX_FILES_PER_CATEGORY) {
86
- console.warn(`[CARTO] Warning: Found ${files.length} files, capping at ${MAX_FILES_PER_CATEGORY}`);
87
- return files.slice(0, MAX_FILES_PER_CATEGORY);
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 files;
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
- * Returns first match found. Does not combine multiple detections.
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
- // 1. Check requirements.txt
22
- const reqFile = candidates.find(f => path.basename(f) === 'requirements.txt');
23
- if (reqFile) {
24
- const result = detectFromPythonDeps(reqFile);
25
- if (result) return result;
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
- // 2. Check package.json
29
- const pkgFile = candidates.find(f => path.basename(f) === 'package.json');
30
- if (pkgFile) {
31
- const result = detectFromPackageJson(pkgFile);
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
- // 3. Check pyproject.toml
36
- const pyprojectFile = candidates.find(f => path.basename(f) === 'pyproject.toml');
37
- if (pyprojectFile) {
38
- const result = detectFromPythonDeps(pyprojectFile);
39
- if (result) return result;
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
- function detectFromPythonDeps(filePath) {
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 null;
105
+ return detected;
78
106
  }
79
107
 
80
- if (content.includes('fastapi')) {
81
- return { framework: 'fastapi', language: 'python', confidence: 'high' };
82
- }
83
- if (content.includes('django')) {
84
- return { framework: 'django', language: 'python', confidence: 'high' };
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
- function detectFromPackageJson(filePath) {
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 null;
125
+ return detected;
101
126
  }
102
127
 
103
128
  const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
104
129
 
105
- if (deps['next']) {
106
- return { framework: 'nextjs', language: 'javascript', confidence: 'high' };
107
- }
108
- if (deps['express']) {
109
- return { framework: 'express', language: 'javascript', confidence: 'high' };
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 route file pattern
77
- const isNextRoute = isNextJSRouteFile(filename);
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
- return routes;
132
- }
133
-
134
- function isNextJSRouteFile(filename) {
135
- const base = path.basename(filename, path.extname(filename));
136
- return (base === 'route' || base === 'Route');
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
- function extractNextJSRoutes(ast, filename) {
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 httpExports = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
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
- // Infer path from filename: app/api/users/route.js → /api/users
144
- // We only have the basename, so we can't infer the full path
145
- // This would need the full file path — for now use the filename
146
- const routePath = '[inferred]';
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
- walk(ast, (node) => {
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 && httpExports.has(decl.id.name)) {
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 declarator of decl.declarations) {
162
- if (declarator.id && declarator.id.name && httpExports.has(declarator.id.name)) {
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, basename);
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[varName]) envVarMap[varName] = new Set();
116
- envVarMap[varName].add(basename);
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: basename, responsibility });
219
+ fileMap.push({ file: relPath, responsibility });
193
220
  }
194
221
  }
195
222
 
196
223
  // Aggregate env vars into sorted array
197
- const envVars = Object.keys(envVarMap)
224
+ const envVars = [...envVarMap.keys()]
198
225
  .sort()
199
- .map(name => ({ name, files: [...envVarMap[name]].sort() }));
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,7 +247,10 @@ 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);