carto-md 1.1.3 → 1.1.4
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/CONTRIBUTING.md +19 -4
- package/README.md +25 -6
- package/package.json +1 -1
- package/src/agents/domains.js +31 -3
- package/src/detector/framework.js +24 -12
- package/src/engine/carto.js +22 -6
- package/src/extractors/imports.js +75 -0
- package/src/extractors/routes.js +41 -1
package/CONTRIBUTING.md
CHANGED
|
@@ -20,13 +20,13 @@ Framework-specific route and model extraction lives in `src/extractors/`. Each f
|
|
|
20
20
|
|
|
21
21
|
Currently supported:
|
|
22
22
|
- **JS/TS**: Express, Next.js (App + Pages Router), tRPC, Drizzle, Zod
|
|
23
|
-
- **Python**: FastAPI, Pydantic, SQLAlchemy, Django (models + URLs)
|
|
24
|
-
- **Go**: Gin, Echo, Chi, net/http
|
|
23
|
+
- **Python**: FastAPI, Flask, Pydantic, SQLAlchemy, Django (models + URLs)
|
|
24
|
+
- **Go**: Gin, Echo, Chi, Fiber, net/http — routes, structs, import graph
|
|
25
25
|
- **Schema**: Prisma
|
|
26
26
|
- **Frontend**: HTML fetch()
|
|
27
27
|
- **R**: Plumber, Shiny, R6, S7
|
|
28
28
|
|
|
29
|
-
Wanted: Rails, Laravel, NestJS, Hono, Spring,
|
|
29
|
+
Wanted: Rails, Laravel, NestJS, Hono, Spring, Fastify.
|
|
30
30
|
|
|
31
31
|
### Tier 3 — Core (review carefully before merging)
|
|
32
32
|
|
|
@@ -83,12 +83,27 @@ module.exports = {
|
|
|
83
83
|
|
|
84
84
|
## How to add a domain keyword
|
|
85
85
|
|
|
86
|
-
Domain clustering lives in `src/agents/domains.js`. The `
|
|
86
|
+
Domain clustering lives in `src/agents/domains.js`. The `DEFAULT_DOMAIN_MAP` array maps keywords to domain names. If your framework creates a new domain category, add it:
|
|
87
87
|
|
|
88
88
|
```js
|
|
89
89
|
{ keywords: ['graphql', 'resolver', 'mutation'], domain: 'GRAPHQL' },
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
### Project-level custom domains
|
|
93
|
+
|
|
94
|
+
For non-web repos (CLIs, desktop apps, compilers), users can define their own domains in `carto.config.json` at the project root without touching `domains.js`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"domains": {
|
|
99
|
+
"EDITOR": ["editor", "monaco", "text"],
|
|
100
|
+
"PLATFORM": ["platform", "service", "registry"]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Custom config overrides the default domain map entirely for that project.
|
|
106
|
+
|
|
92
107
|
---
|
|
93
108
|
|
|
94
109
|
## Ground rules
|
package/README.md
CHANGED
|
@@ -138,11 +138,11 @@ File saved → re-parse 1 file → update graph → ~130ms
|
|
|
138
138
|
|
|
139
139
|
| Category | What Carto finds |
|
|
140
140
|
|----------|-----------------|
|
|
141
|
-
| **Routes** | FastAPI, Express, Next.js App/Pages Router, React Router (JSX + createBrowserRouter), tRPC procedures, Django URLs, Gin/Echo (Go) |
|
|
142
|
-
| **Models** | Prisma, Pydantic, SQLAlchemy, Django ORM, TypeScript interfaces/types, Zod schemas, Drizzle tables |
|
|
143
|
-
| **Graph** | Full import graph: who imports what, transitive dependencies up to 5 hops |
|
|
141
|
+
| **Routes** | FastAPI, Flask, Express, Next.js App/Pages Router, React Router (JSX + createBrowserRouter), tRPC procedures, Django URLs, Gin/Echo/Chi/Fiber (Go) |
|
|
142
|
+
| **Models** | Prisma, Pydantic, SQLAlchemy, Django ORM, TypeScript interfaces/types, Zod schemas, Drizzle tables, Go structs |
|
|
143
|
+
| **Graph** | Full import graph: who imports what, transitive dependencies up to 5 hops — JS/TS, Python, Go, R |
|
|
144
144
|
| **Blast radius** | Risk level (HIGH/MEDIUM/LOW) per file and per route |
|
|
145
|
-
| **Domains** | AUTH, PAYMENTS, DATABASE, EVENTS, TRPC, NOTIFICATIONS, CORE
|
|
145
|
+
| **Domains** | AUTO-clustered from imports. Defaults: AUTH, PAYMENTS, DATABASE, EVENTS, TRPC, NOTIFICATIONS, CORE. Custom domains via `carto.config.json`. |
|
|
146
146
|
| **Events** | EventEmitter listeners, webhook handlers, queue jobs, cron schedules |
|
|
147
147
|
| **Env vars** | Every `process.env` / `os.Getenv` call (names only, never values) |
|
|
148
148
|
| **Functions** | Signatures with param names and return types |
|
|
@@ -154,8 +154,8 @@ File saved → re-parse 1 file → update graph → ~130ms
|
|
|
154
154
|
| Language | Frameworks |
|
|
155
155
|
|----------|------------|
|
|
156
156
|
| TypeScript / JavaScript | Express, Next.js (App + Pages Router), React Router, tRPC, Drizzle, Zod |
|
|
157
|
-
| Python | FastAPI, Pydantic, SQLAlchemy, Django |
|
|
158
|
-
| Go | Gin, Echo, Chi, net/http |
|
|
157
|
+
| Python | FastAPI, Flask, Pydantic, SQLAlchemy, Django |
|
|
158
|
+
| Go | Gin, Echo, Chi, Fiber, net/http — including full import graph |
|
|
159
159
|
| R | Plumber, Shiny, R6, S7 |
|
|
160
160
|
| Schema | Prisma |
|
|
161
161
|
| HTML | fetch() calls |
|
|
@@ -164,6 +164,25 @@ More via community. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
164
164
|
|
|
165
165
|
---
|
|
166
166
|
|
|
167
|
+
## Custom domains (`carto.config.json`)
|
|
168
|
+
|
|
169
|
+
By default Carto clusters into web-app domains (AUTH, PAYMENTS, etc.). For any other architecture — desktop apps, CLIs, compilers, monorepos — define your own:
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"domains": {
|
|
174
|
+
"EDITOR": ["editor", "monaco", "text", "cursor"],
|
|
175
|
+
"WORKBENCH": ["workbench", "layout", "panel", "sidebar"],
|
|
176
|
+
"PLATFORM": ["platform", "service", "registry"],
|
|
177
|
+
"BASE": ["base", "common", "util"]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Drop `carto.config.json` in your project root. Carto picks it up on the next `carto init` or `carto sync`. The import graph and blast radius always work regardless — custom domains only affect how files are clustered and labeled.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
167
186
|
## Commands
|
|
168
187
|
|
|
169
188
|
| Command | What it does |
|
package/package.json
CHANGED
package/src/agents/domains.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const DEFAULT_DOMAIN_MAP = [
|
|
4
4
|
{ keywords: ['auth', 'login', 'session', 'oauth', 'token', 'jwt', 'password', 'credential'], domain: 'AUTH' },
|
|
5
5
|
{ keywords: ['payment', 'billing', 'stripe', 'invoice', 'charge', 'subscription', 'checkout'], domain: 'PAYMENTS' },
|
|
6
6
|
{ keywords: ['trpc', 'router', 'routers', 'procedure'], domain: 'TRPC' },
|
|
@@ -9,6 +9,29 @@ const DOMAIN_MAP = [
|
|
|
9
9
|
{ keywords: ['email', 'notification', 'mail', 'sms', 'alert'], domain: 'NOTIFICATIONS' },
|
|
10
10
|
];
|
|
11
11
|
|
|
12
|
+
// Active domain map — replaced by setDomainMap() when carto.config.json is present
|
|
13
|
+
let DOMAIN_MAP = DEFAULT_DOMAIN_MAP;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* setDomainMap(customDomains)
|
|
17
|
+
* Override the domain map from carto.config.json.
|
|
18
|
+
*
|
|
19
|
+
* customDomains format:
|
|
20
|
+
* { "EDITOR": ["editor", "monaco"], "WORKBENCH": ["workbench", "panel"] }
|
|
21
|
+
*
|
|
22
|
+
* Pass null to reset to defaults.
|
|
23
|
+
*/
|
|
24
|
+
function setDomainMap(customDomains) {
|
|
25
|
+
if (!customDomains || typeof customDomains !== 'object' || Array.isArray(customDomains)) {
|
|
26
|
+
DOMAIN_MAP = DEFAULT_DOMAIN_MAP;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
DOMAIN_MAP = Object.entries(customDomains).map(([domain, keywords]) => ({
|
|
30
|
+
domain: domain.toUpperCase(),
|
|
31
|
+
keywords: keywords.map(k => String(k).toLowerCase()),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
/**
|
|
13
36
|
* getDomainForFile(relPath) → domain string or null
|
|
14
37
|
* Returns domain if path matches a keyword, null if no match.
|
|
@@ -112,7 +135,7 @@ function clusterByDomain(data) {
|
|
|
112
135
|
|
|
113
136
|
function getCluster(domain) {
|
|
114
137
|
if (!clusters[domain]) {
|
|
115
|
-
clusters[domain] = { routes: [], models: [], functions: {}, envVars: [], dbTables: [], fileMap: [] };
|
|
138
|
+
clusters[domain] = { routes: [], models: [], functions: {}, envVars: [], dbTables: [], fileMap: [], files: [] };
|
|
116
139
|
}
|
|
117
140
|
return clusters[domain];
|
|
118
141
|
}
|
|
@@ -182,7 +205,12 @@ function clusterByDomain(data) {
|
|
|
182
205
|
getCluster(domain).fileMap.push(entry);
|
|
183
206
|
}
|
|
184
207
|
|
|
208
|
+
// Files — populate from assignments so cluster.files is always set
|
|
209
|
+
for (const [file, domain] of assignments.entries()) {
|
|
210
|
+
getCluster(domain).files.push(file);
|
|
211
|
+
}
|
|
212
|
+
|
|
185
213
|
return clusters;
|
|
186
214
|
}
|
|
187
215
|
|
|
188
|
-
module.exports = { clusterByDomain, getDomainForFile, buildFileAssignments };
|
|
216
|
+
module.exports = { clusterByDomain, getDomainForFile, buildFileAssignments, setDomainMap };
|
|
@@ -7,6 +7,7 @@ const IGNORE_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 've
|
|
|
7
7
|
const PYTHON_PRIORITY = ['fastapi', 'django', 'flask', 'python-generic'];
|
|
8
8
|
const JS_PRIORITY = ['nextjs', 'express', 'react', 'node-generic'];
|
|
9
9
|
const R_PRIORITY = ['plumber', 'shiny', 'r-generic'];
|
|
10
|
+
const GO_PRIORITY = ['gin', 'echo', 'chi', 'fiber', 'go-generic'];
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* detectFramework(projectRoot) → { framework, language, confidence, secondaryFramework?, secondaryLanguage? }
|
|
@@ -17,7 +18,7 @@ const R_PRIORITY = ['plumber', 'shiny', 'r-generic'];
|
|
|
17
18
|
* (primary = highest priority overall, secondary = the other language).
|
|
18
19
|
*/
|
|
19
20
|
function detectFramework(projectRoot) {
|
|
20
|
-
const candidates = findFile(projectRoot, ['requirements.txt', 'package.json', 'pyproject.toml', 'DESCRIPTION'], 3);
|
|
21
|
+
const candidates = findFile(projectRoot, ['requirements.txt', 'package.json', 'pyproject.toml', 'DESCRIPTION', 'go.mod'], 3);
|
|
21
22
|
|
|
22
23
|
const pythonDetections = new Set();
|
|
23
24
|
const jsDetections = new Set();
|
|
@@ -43,9 +44,15 @@ function detectFramework(projectRoot) {
|
|
|
43
44
|
for (const r of detectAllFromRFiles(projectRoot)) rDetections.add(r);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
const goDetections = new Set();
|
|
48
|
+
for (const f of candidates.filter(f => path.basename(f) === 'go.mod')) {
|
|
49
|
+
for (const r of detectAllFromGoMod(f)) goDetections.add(r);
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
const bestPython = PYTHON_PRIORITY.find(fw => pythonDetections.has(fw)) || null;
|
|
47
53
|
const bestJS = JS_PRIORITY.find(fw => jsDetections.has(fw)) || null;
|
|
48
54
|
const bestR = R_PRIORITY.find(fw => rDetections.has(fw)) || null;
|
|
55
|
+
const bestGo = GO_PRIORITY.find(fw => goDetections.has(fw)) || null;
|
|
49
56
|
|
|
50
57
|
if (bestPython && bestJS) {
|
|
51
58
|
return {
|
|
@@ -57,17 +64,10 @@ function detectFramework(projectRoot) {
|
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
if (bestPython) {
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (bestJS) {
|
|
65
|
-
return { framework: bestJS, language: 'javascript', confidence: 'high' };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (bestR) {
|
|
69
|
-
return { framework: bestR, language: 'r', confidence: 'high' };
|
|
70
|
-
}
|
|
67
|
+
if (bestPython) return { framework: bestPython, language: 'python', confidence: 'high' };
|
|
68
|
+
if (bestJS) return { framework: bestJS, language: 'javascript', confidence: 'high' };
|
|
69
|
+
if (bestGo) return { framework: bestGo, language: 'go', confidence: 'high' };
|
|
70
|
+
if (bestR) return { framework: bestR, language: 'r', confidence: 'high' };
|
|
71
71
|
|
|
72
72
|
return { framework: 'unknown', language: 'unknown', confidence: 'none' };
|
|
73
73
|
}
|
|
@@ -141,6 +141,18 @@ function detectAllFromPackageJson(filePath) {
|
|
|
141
141
|
return detected;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function detectAllFromGoMod(filePath) {
|
|
145
|
+
const detected = [];
|
|
146
|
+
let content;
|
|
147
|
+
try { content = fs.readFileSync(filePath, 'utf-8').toLowerCase(); } catch { return detected; }
|
|
148
|
+
if (content.includes('gin-gonic/gin')) detected.push('gin');
|
|
149
|
+
if (content.includes('labstack/echo')) detected.push('echo');
|
|
150
|
+
if (content.includes('go-chi/chi')) detected.push('chi');
|
|
151
|
+
if (content.includes('gofiber/fiber')) detected.push('fiber');
|
|
152
|
+
if (!detected.length) detected.push('go-generic');
|
|
153
|
+
return detected;
|
|
154
|
+
}
|
|
155
|
+
|
|
144
156
|
function detectAllFromRDescription(filePath) {
|
|
145
157
|
const detected = [];
|
|
146
158
|
let content;
|
package/src/engine/carto.js
CHANGED
|
@@ -11,7 +11,7 @@ const { WorkerPool } = require('./worker-pool');
|
|
|
11
11
|
const { loadLanguagePlugins, getPluginForFile } = require('../extractors/loader');
|
|
12
12
|
const { buildImportGraph } = require('../extractors/imports');
|
|
13
13
|
const { buildStackLine } = require('../extractors/stack');
|
|
14
|
-
const { getDomainForFile, buildFileAssignments } = require('../agents/domains');
|
|
14
|
+
const { getDomainForFile, buildFileAssignments, setDomainMap } = require('../agents/domains');
|
|
15
15
|
const { extractImports } = require('../extractors/imports');
|
|
16
16
|
|
|
17
17
|
const plugins = loadLanguagePlugins();
|
|
@@ -39,6 +39,7 @@ class Carto extends EventEmitter {
|
|
|
39
39
|
this._cache = null;
|
|
40
40
|
this._projectRoot = null;
|
|
41
41
|
this._pool = null;
|
|
42
|
+
this._domainByFile = null; // reverse map: relPath → domainName
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
// ─── Indexing ────────────────────────────────────────────────────────────
|
|
@@ -54,6 +55,14 @@ class Carto extends EventEmitter {
|
|
|
54
55
|
|
|
55
56
|
this.emit('status', { state: 'indexing', progress: 0 });
|
|
56
57
|
|
|
58
|
+
// Load custom domain config if present
|
|
59
|
+
try {
|
|
60
|
+
const config = JSON.parse(fs.readFileSync(path.join(projectRoot, 'carto.config.json'), 'utf-8'));
|
|
61
|
+
setDomainMap(config.domains || null);
|
|
62
|
+
} catch {
|
|
63
|
+
setDomainMap(null); // reset to defaults
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
const cartoDir = path.join(projectRoot, '.carto');
|
|
58
67
|
try { fs.mkdirSync(cartoDir, { recursive: true }); } catch {}
|
|
59
68
|
|
|
@@ -146,6 +155,7 @@ class Carto extends EventEmitter {
|
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
recomputeGraphMetrics(this._cache);
|
|
158
|
+
this._buildDomainMap();
|
|
149
159
|
this._cache.meta.indexDuration = Date.now() - start;
|
|
150
160
|
this._cache.meta.lastIndexed = new Date().toISOString();
|
|
151
161
|
this._cache.generated = new Date().toISOString();
|
|
@@ -185,6 +195,7 @@ class Carto extends EventEmitter {
|
|
|
185
195
|
updateFileHash(this._projectRoot, relPath, content);
|
|
186
196
|
saveGraphCache(this._projectRoot, this._cache);
|
|
187
197
|
|
|
198
|
+
this._buildDomainMap();
|
|
188
199
|
const blastRadius = this.getBlastRadius(relPath);
|
|
189
200
|
this.emit('status', { state: 'ready' });
|
|
190
201
|
this.emit('updated', { file: relPath, blastRadius });
|
|
@@ -555,12 +566,17 @@ class Carto extends EventEmitter {
|
|
|
555
566
|
return null;
|
|
556
567
|
}
|
|
557
568
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
for (const [name, cluster] of Object.entries(domains)) {
|
|
561
|
-
|
|
569
|
+
_buildDomainMap() {
|
|
570
|
+
this._domainByFile = {};
|
|
571
|
+
for (const [name, cluster] of Object.entries(this._cache.domains || {})) {
|
|
572
|
+
for (const file of (cluster.files || [])) {
|
|
573
|
+
this._domainByFile[file] = name;
|
|
574
|
+
}
|
|
562
575
|
}
|
|
563
|
-
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
_getDomainForFile(relPath) {
|
|
579
|
+
return this._domainByFile ? (this._domainByFile[relPath] || null) : null;
|
|
564
580
|
}
|
|
565
581
|
|
|
566
582
|
_discoverFiles(projectRoot) {
|
|
@@ -39,6 +39,8 @@ function extractImports(content, filePath, projectRoot) {
|
|
|
39
39
|
rawImports = extractPythonImports(content, filePath, projectRoot);
|
|
40
40
|
} else if (ext === '.r') {
|
|
41
41
|
return extractRImports(content, filePath, projectRoot);
|
|
42
|
+
} else if (ext === '.go') {
|
|
43
|
+
return extractGoImports(content, filePath, projectRoot);
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
// Resolve and deduplicate
|
|
@@ -220,6 +222,79 @@ function resolveImportPath(importPath, fileDir, projectRoot, sourceExt) {
|
|
|
220
222
|
return null;
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
// ─── Go imports ──────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
// Cache go.mod module name per projectRoot — read once, reuse
|
|
228
|
+
const _goModCache = new Map();
|
|
229
|
+
|
|
230
|
+
function _getGoModuleName(projectRoot) {
|
|
231
|
+
if (_goModCache.has(projectRoot)) return _goModCache.get(projectRoot);
|
|
232
|
+
try {
|
|
233
|
+
const content = fs.readFileSync(path.join(projectRoot, 'go.mod'), 'utf-8');
|
|
234
|
+
const m = content.match(/^module\s+(\S+)/m);
|
|
235
|
+
const name = m ? m[1] : null;
|
|
236
|
+
_goModCache.set(projectRoot, name);
|
|
237
|
+
return name;
|
|
238
|
+
} catch {
|
|
239
|
+
_goModCache.set(projectRoot, null);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* extractGoImports(content, filePath, projectRoot) → Array<string>
|
|
246
|
+
*
|
|
247
|
+
* Resolves local Go imports (same module) to actual .go files.
|
|
248
|
+
* Requires go.mod at projectRoot to determine the module name.
|
|
249
|
+
*
|
|
250
|
+
* Handles:
|
|
251
|
+
* import "module/path/pkg"
|
|
252
|
+
* import ( "module/path/pkg1" \n "module/path/pkg2" )
|
|
253
|
+
* import alias "module/path/pkg"
|
|
254
|
+
*/
|
|
255
|
+
function extractGoImports(content, filePath, projectRoot) {
|
|
256
|
+
const moduleName = _getGoModuleName(projectRoot);
|
|
257
|
+
if (!moduleName) return [];
|
|
258
|
+
|
|
259
|
+
const results = new Set();
|
|
260
|
+
|
|
261
|
+
// Collect all import paths from both single and block imports
|
|
262
|
+
const importPaths = [];
|
|
263
|
+
|
|
264
|
+
// Single-line: import "path" or import alias "path"
|
|
265
|
+
const singleRe = /^import\s+(?:\w+\s+)?"([^"]+)"/gm;
|
|
266
|
+
let m;
|
|
267
|
+
while ((m = singleRe.exec(content)) !== null) importPaths.push(m[1]);
|
|
268
|
+
|
|
269
|
+
// Block: import ( ... )
|
|
270
|
+
const blockRe = /import\s*\(([^)]+)\)/g;
|
|
271
|
+
while ((m = blockRe.exec(content)) !== null) {
|
|
272
|
+
const block = m[1];
|
|
273
|
+
const lineRe = /"([^"]+)"/g;
|
|
274
|
+
let lm;
|
|
275
|
+
while ((lm = lineRe.exec(block)) !== null) importPaths.push(lm[1]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Resolve local imports only (starts with this module's name)
|
|
279
|
+
const prefix = moduleName + '/';
|
|
280
|
+
for (const imp of importPaths) {
|
|
281
|
+
if (!imp.startsWith(prefix)) continue;
|
|
282
|
+
const localPkg = imp.slice(prefix.length); // e.g. "internal/auth"
|
|
283
|
+
const pkgDir = path.join(projectRoot, localPkg);
|
|
284
|
+
|
|
285
|
+
// Find first non-test .go file in the package directory
|
|
286
|
+
try {
|
|
287
|
+
const entries = fs.readdirSync(pkgDir);
|
|
288
|
+
const goFile = entries.find(e => e.endsWith('.go') && !e.endsWith('_test.go'));
|
|
289
|
+
if (goFile) {
|
|
290
|
+
results.add(path.relative(projectRoot, path.join(pkgDir, goFile)));
|
|
291
|
+
}
|
|
292
|
+
} catch { /* directory doesn't exist or can't be read */ }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return [...results].sort();
|
|
296
|
+
}
|
|
297
|
+
|
|
223
298
|
/**
|
|
224
299
|
* buildImportGraph(fileContents, projectRoot) → { 'relative/path.js': ['relative/dep.js', ...] }
|
|
225
300
|
*
|
package/src/extractors/routes.js
CHANGED
|
@@ -25,11 +25,12 @@ function collapseMultilineDecorators(content) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Extracts HTTP routes from FastAPI and Django files.
|
|
28
|
+
* Extracts HTTP routes from FastAPI, Flask, and Django files.
|
|
29
29
|
*/
|
|
30
30
|
function extractRoutes(content) {
|
|
31
31
|
return [
|
|
32
32
|
...extractFastAPIRoutes(content),
|
|
33
|
+
...extractFlaskRoutes(content),
|
|
33
34
|
...extractDjangoRoutes(content),
|
|
34
35
|
];
|
|
35
36
|
}
|
|
@@ -61,6 +62,45 @@ function extractFastAPIRoutes(content) {
|
|
|
61
62
|
return routes;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// ─── Flask ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function extractFlaskRoutes(content) {
|
|
68
|
+
const routes = [];
|
|
69
|
+
|
|
70
|
+
if (!content.includes('.route(') && !content.includes('from flask')) return routes;
|
|
71
|
+
|
|
72
|
+
// @app.route('/path') or @bp.route('/path', methods=['GET', 'POST'])
|
|
73
|
+
// Also handles: @api.route, @blueprint.route, any @x.route pattern
|
|
74
|
+
const decoratorRe = /@\w+\.route\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*methods\s*=\s*\[([^\]]+)\])?\s*\)/g;
|
|
75
|
+
const funcRe = /(?:async\s+)?def\s+(\w+)/;
|
|
76
|
+
|
|
77
|
+
const lines = content.split('\n');
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < lines.length; i++) {
|
|
80
|
+
decoratorRe.lastIndex = 0;
|
|
81
|
+
const match = decoratorRe.exec(lines[i]);
|
|
82
|
+
if (!match) continue;
|
|
83
|
+
|
|
84
|
+
const routePath = match[1];
|
|
85
|
+
const methodsRaw = match[2];
|
|
86
|
+
const methods = methodsRaw
|
|
87
|
+
? methodsRaw.split(',').map(m => m.trim().replace(/['"]/g, '').toUpperCase()).filter(Boolean)
|
|
88
|
+
: ['GET'];
|
|
89
|
+
|
|
90
|
+
let functionName = '[anonymous]';
|
|
91
|
+
for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
|
|
92
|
+
const fm = lines[j].match(funcRe);
|
|
93
|
+
if (fm) { functionName = fm[1]; break; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const method of methods) {
|
|
97
|
+
routes.push({ method, path: routePath, functionName });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return routes;
|
|
102
|
+
}
|
|
103
|
+
|
|
64
104
|
// ─── Django ───────────────────────────────────────────────────────────────────
|
|
65
105
|
|
|
66
106
|
function extractDjangoRoutes(content) {
|