codesight 1.4.0 → 1.5.1
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/dist/ast/extract-go.d.ts +23 -0
- package/dist/ast/extract-go.js +331 -0
- package/dist/ast/extract-python.d.ts +25 -0
- package/dist/ast/extract-python.js +388 -0
- package/dist/detectors/routes.js +51 -55
- package/dist/detectors/schema.js +28 -2
- package/dist/formatter.js +3 -1
- package/dist/index.js +3 -3
- package/dist/scanner.js +31 -4
- package/eval/fixtures/gin-gorm/ground-truth.json +25 -0
- package/eval/fixtures/gin-gorm/repo.json +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go structured parser for routes and models.
|
|
3
|
+
* Uses brace-tracking + regex for near-AST accuracy on Go's regular syntax.
|
|
4
|
+
*
|
|
5
|
+
* Go's syntax is regular enough that structured parsing (tracking braces,
|
|
6
|
+
* extracting struct bodies, parsing field tags) achieves AST-level accuracy
|
|
7
|
+
* without needing the Go compiler.
|
|
8
|
+
*
|
|
9
|
+
* Extracts:
|
|
10
|
+
* - Gin/Fiber/Echo/Chi/net-http routes with group prefixes
|
|
11
|
+
* - GORM model structs with field types, tags (primaryKey, unique, etc.)
|
|
12
|
+
*/
|
|
13
|
+
import type { RouteInfo, SchemaModel, Framework } from "../types.js";
|
|
14
|
+
/**
|
|
15
|
+
* Extract routes from a Go file with group/prefix tracking.
|
|
16
|
+
* Works for Gin, Fiber, Echo, Chi, and net/http.
|
|
17
|
+
*/
|
|
18
|
+
export declare function extractGoRoutesStructured(filePath: string, content: string, framework: Framework, tags: string[]): RouteInfo[];
|
|
19
|
+
/**
|
|
20
|
+
* Extract GORM model structs from a Go file.
|
|
21
|
+
* Parses struct bodies, field types, and gorm tags.
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractGORMModelsStructured(_filePath: string, content: string): SchemaModel[];
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go structured parser for routes and models.
|
|
3
|
+
* Uses brace-tracking + regex for near-AST accuracy on Go's regular syntax.
|
|
4
|
+
*
|
|
5
|
+
* Go's syntax is regular enough that structured parsing (tracking braces,
|
|
6
|
+
* extracting struct bodies, parsing field tags) achieves AST-level accuracy
|
|
7
|
+
* without needing the Go compiler.
|
|
8
|
+
*
|
|
9
|
+
* Extracts:
|
|
10
|
+
* - Gin/Fiber/Echo/Chi/net-http routes with group prefixes
|
|
11
|
+
* - GORM model structs with field types, tags (primaryKey, unique, etc.)
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Extract routes from a Go file with group/prefix tracking.
|
|
15
|
+
* Works for Gin, Fiber, Echo, Chi, and net/http.
|
|
16
|
+
*/
|
|
17
|
+
export function extractGoRoutesStructured(filePath, content, framework, tags) {
|
|
18
|
+
const routes = [];
|
|
19
|
+
// Step 1: Find route groups with prefixes
|
|
20
|
+
// Gin: r.Group("/api") / g := r.Group("/v1")
|
|
21
|
+
// Echo: g := e.Group("/api")
|
|
22
|
+
// Fiber: api := app.Group("/api")
|
|
23
|
+
// Chi: r.Route("/api", func(r chi.Router) { ... })
|
|
24
|
+
const groups = extractRouteGroups(content, framework);
|
|
25
|
+
// Collect group variable names to exclude from top-level scan
|
|
26
|
+
const groupVarNames = new Set();
|
|
27
|
+
// Step 2: Extract routes from each group with prefix resolution
|
|
28
|
+
for (const group of groups) {
|
|
29
|
+
// Track which variable this group belongs to
|
|
30
|
+
if (group.varName)
|
|
31
|
+
groupVarNames.add(group.varName);
|
|
32
|
+
const groupRoutes = extractRoutesFromBlock(group.body, framework, filePath, tags);
|
|
33
|
+
for (const route of groupRoutes) {
|
|
34
|
+
route.path = normalizePath(group.prefix + "/" + route.path);
|
|
35
|
+
routes.push(route);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Step 3: Extract top-level routes (only from lines NOT belonging to group vars)
|
|
39
|
+
// Filter out lines that reference group variables to avoid duplicates
|
|
40
|
+
if (groupVarNames.size > 0) {
|
|
41
|
+
const lines = content.split("\n");
|
|
42
|
+
const topLines = lines.filter((line) => {
|
|
43
|
+
for (const v of groupVarNames) {
|
|
44
|
+
if (line.includes(v + "."))
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
const topContent = topLines.join("\n");
|
|
50
|
+
const topLevelRoutes = extractRoutesFromBlock(topContent, framework, filePath, tags);
|
|
51
|
+
routes.push(...topLevelRoutes);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const topLevelRoutes = extractRoutesFromBlock(content, framework, filePath, tags);
|
|
55
|
+
routes.push(...topLevelRoutes);
|
|
56
|
+
}
|
|
57
|
+
// Deduplicate
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
return routes.filter((r) => {
|
|
60
|
+
const key = `${r.method}:${r.path}`;
|
|
61
|
+
if (seen.has(key))
|
|
62
|
+
return false;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function extractRouteGroups(content, framework) {
|
|
68
|
+
const groups = [];
|
|
69
|
+
if (framework === "chi") {
|
|
70
|
+
// Chi: r.Route("/prefix", func(r chi.Router) { ... })
|
|
71
|
+
const chiRoutePattern = /\.Route\s*\(\s*"([^"]+)"\s*,\s*func\s*\([^)]*\)\s*\{/g;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = chiRoutePattern.exec(content)) !== null) {
|
|
74
|
+
const prefix = match[1];
|
|
75
|
+
const bodyStart = match.index + match[0].length;
|
|
76
|
+
const body = extractBraceBlock(content, bodyStart);
|
|
77
|
+
if (body)
|
|
78
|
+
groups.push({ prefix, body });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Gin/Echo/Fiber: varName := receiver.Group("/prefix")
|
|
83
|
+
// Build a prefix map to resolve chained groups: api := r.Group("/api"), users := api.Group("/users")
|
|
84
|
+
const prefixMap = new Map(); // varName -> resolved full prefix
|
|
85
|
+
const groupPattern = /(\w+)\s*:?=\s*(\w+)\.Group\s*\(\s*"([^"]*)"/g;
|
|
86
|
+
let match;
|
|
87
|
+
// First pass: build prefix chain
|
|
88
|
+
while ((match = groupPattern.exec(content)) !== null) {
|
|
89
|
+
const varName = match[1];
|
|
90
|
+
const receiver = match[2];
|
|
91
|
+
const prefix = match[3];
|
|
92
|
+
// Resolve receiver prefix (if receiver is itself a group)
|
|
93
|
+
const receiverPrefix = prefixMap.get(receiver) || "";
|
|
94
|
+
const fullPrefix = normalizePath(receiverPrefix + "/" + prefix);
|
|
95
|
+
prefixMap.set(varName, fullPrefix);
|
|
96
|
+
}
|
|
97
|
+
// Second pass: extract routes for each group variable
|
|
98
|
+
for (const [varName, fullPrefix] of prefixMap) {
|
|
99
|
+
const varRoutes = [];
|
|
100
|
+
// Match routes — allow empty path strings with ([^"]*)
|
|
101
|
+
const varPattern = new RegExp(`${escapeRegex(varName)}\\.\\s*(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Get|Post|Put|Patch|Delete|Options|Head)\\s*\\(\\s*"([^"]*)"`, "g");
|
|
102
|
+
let routeMatch;
|
|
103
|
+
while ((routeMatch = varPattern.exec(content)) !== null) {
|
|
104
|
+
varRoutes.push(`${routeMatch[1]}:${routeMatch[2]}`);
|
|
105
|
+
}
|
|
106
|
+
// Also handle HandleFunc for mixed patterns
|
|
107
|
+
const handlePattern = new RegExp(`${escapeRegex(varName)}\\.\\s*HandleFunc\\s*\\(\\s*"([^"]*)"`, "g");
|
|
108
|
+
while ((routeMatch = handlePattern.exec(content)) !== null) {
|
|
109
|
+
varRoutes.push(`ALL:${routeMatch[1]}`);
|
|
110
|
+
}
|
|
111
|
+
if (varRoutes.length > 0) {
|
|
112
|
+
groups.push({
|
|
113
|
+
prefix: fullPrefix,
|
|
114
|
+
varName,
|
|
115
|
+
body: varRoutes
|
|
116
|
+
.map((r) => {
|
|
117
|
+
const colonIdx = r.indexOf(":");
|
|
118
|
+
const m = r.slice(0, colonIdx);
|
|
119
|
+
const p = r.slice(colonIdx + 1);
|
|
120
|
+
return `FAKE.${m}("${p}")`;
|
|
121
|
+
})
|
|
122
|
+
.join("\n"),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return groups;
|
|
128
|
+
}
|
|
129
|
+
function extractRoutesFromBlock(block, framework, filePath, tags) {
|
|
130
|
+
const routes = [];
|
|
131
|
+
if (framework === "gin" || framework === "echo") {
|
|
132
|
+
// .GET("/path", handler) — uppercase methods (allow empty path)
|
|
133
|
+
const pattern = /\.(?:FAKE\.)?(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(\s*"([^"]*)"/g;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = pattern.exec(block)) !== null) {
|
|
136
|
+
routes.push({
|
|
137
|
+
method: match[1],
|
|
138
|
+
path: match[2],
|
|
139
|
+
file: filePath,
|
|
140
|
+
tags,
|
|
141
|
+
framework,
|
|
142
|
+
params: extractPathParams(match[2]),
|
|
143
|
+
confidence: "ast",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (framework === "fiber" || framework === "chi") {
|
|
148
|
+
// .Get("/path", handler) — PascalCase methods (allow empty path)
|
|
149
|
+
const pattern = /\.(?:FAKE\.)?(Get|Post|Put|Patch|Delete|Options|Head)\s*\(\s*"([^"]*)"/g;
|
|
150
|
+
let match;
|
|
151
|
+
while ((match = pattern.exec(block)) !== null) {
|
|
152
|
+
routes.push({
|
|
153
|
+
method: match[1].toUpperCase(),
|
|
154
|
+
path: match[2],
|
|
155
|
+
file: filePath,
|
|
156
|
+
tags,
|
|
157
|
+
framework,
|
|
158
|
+
params: extractPathParams(match[2]),
|
|
159
|
+
confidence: "ast",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// net/http: HandleFunc or Handle
|
|
164
|
+
if (framework === "go-net-http" || framework === "chi") {
|
|
165
|
+
const pattern = /\.(?:HandleFunc|Handle)\s*\(\s*"([^"]+)"/g;
|
|
166
|
+
let match;
|
|
167
|
+
while ((match = pattern.exec(block)) !== null) {
|
|
168
|
+
// Go 1.22+: "GET /path" patterns
|
|
169
|
+
const pathStr = match[1];
|
|
170
|
+
let method = "ALL";
|
|
171
|
+
let path = pathStr;
|
|
172
|
+
const methodMatch = pathStr.match(/^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\/.*)/);
|
|
173
|
+
if (methodMatch) {
|
|
174
|
+
method = methodMatch[1];
|
|
175
|
+
path = methodMatch[2];
|
|
176
|
+
}
|
|
177
|
+
routes.push({
|
|
178
|
+
method,
|
|
179
|
+
path,
|
|
180
|
+
file: filePath,
|
|
181
|
+
tags,
|
|
182
|
+
framework,
|
|
183
|
+
params: extractPathParams(path),
|
|
184
|
+
confidence: "ast",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Chi: r.Get, r.Post etc. (also catch Method pattern)
|
|
189
|
+
if (framework === "chi") {
|
|
190
|
+
const methodPattern = /\.Method\s*\(\s*"(\w+)"\s*,\s*"([^"]+)"/g;
|
|
191
|
+
let match;
|
|
192
|
+
while ((match = methodPattern.exec(block)) !== null) {
|
|
193
|
+
routes.push({
|
|
194
|
+
method: match[1].toUpperCase(),
|
|
195
|
+
path: match[2],
|
|
196
|
+
file: filePath,
|
|
197
|
+
tags,
|
|
198
|
+
framework,
|
|
199
|
+
params: extractPathParams(match[2]),
|
|
200
|
+
confidence: "ast",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return routes;
|
|
205
|
+
}
|
|
206
|
+
// ─── GORM Model Extraction ───
|
|
207
|
+
/**
|
|
208
|
+
* Extract GORM model structs from a Go file.
|
|
209
|
+
* Parses struct bodies, field types, and gorm tags.
|
|
210
|
+
*/
|
|
211
|
+
export function extractGORMModelsStructured(_filePath, content) {
|
|
212
|
+
const models = [];
|
|
213
|
+
// Find structs that embed gorm.Model or have gorm tags
|
|
214
|
+
const structPattern = /type\s+(\w+)\s+struct\s*\{/g;
|
|
215
|
+
let match;
|
|
216
|
+
while ((match = structPattern.exec(content)) !== null) {
|
|
217
|
+
const name = match[1];
|
|
218
|
+
const bodyStart = match.index + match[0].length;
|
|
219
|
+
const body = extractBraceBlock(content, bodyStart);
|
|
220
|
+
if (!body)
|
|
221
|
+
continue;
|
|
222
|
+
// Check if this is a GORM model
|
|
223
|
+
const isGormModel = body.includes("gorm.Model") ||
|
|
224
|
+
body.includes("gorm.DeletedAt") ||
|
|
225
|
+
body.includes("`gorm:") ||
|
|
226
|
+
body.includes("`json:");
|
|
227
|
+
if (!isGormModel)
|
|
228
|
+
continue;
|
|
229
|
+
const fields = [];
|
|
230
|
+
const relations = [];
|
|
231
|
+
const lines = body.split("\n");
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const trimmed = line.trim();
|
|
234
|
+
if (!trimmed || trimmed.startsWith("//") || trimmed === "}" || trimmed === "{")
|
|
235
|
+
continue;
|
|
236
|
+
// Embedded model: gorm.Model
|
|
237
|
+
if (trimmed === "gorm.Model") {
|
|
238
|
+
fields.push({ name: "ID", type: "uint", flags: ["pk"] });
|
|
239
|
+
fields.push({ name: "CreatedAt", type: "time.Time", flags: [] });
|
|
240
|
+
fields.push({ name: "UpdatedAt", type: "time.Time", flags: [] });
|
|
241
|
+
fields.push({ name: "DeletedAt", type: "gorm.DeletedAt", flags: ["nullable"] });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Parse field: Name Type `gorm:"..." json:"..."`
|
|
245
|
+
const fieldMatch = trimmed.match(/^(\w+)\s+([\w.*\[\]]+)\s*(?:`(.+)`)?/);
|
|
246
|
+
if (!fieldMatch)
|
|
247
|
+
continue;
|
|
248
|
+
const fieldName = fieldMatch[1];
|
|
249
|
+
const fieldType = fieldMatch[2];
|
|
250
|
+
const tagStr = fieldMatch[3] || "";
|
|
251
|
+
// Skip embedded structs that aren't fields
|
|
252
|
+
if (fieldType.startsWith("*") || fieldType.includes(".")) {
|
|
253
|
+
// Check if it's a relation
|
|
254
|
+
if (trimmed.includes("gorm:\"foreignKey") || trimmed.includes("gorm:\"many2many")) {
|
|
255
|
+
relations.push(`${fieldName}: ${fieldType.replace("*", "").replace("[]", "")}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Check for belongs_to / has_many by type
|
|
259
|
+
if (fieldType.startsWith("[]") || fieldType.startsWith("*")) {
|
|
260
|
+
const relType = fieldType.replace("*", "").replace("[]", "");
|
|
261
|
+
if (relType[0] === relType[0]?.toUpperCase() && !relType.includes(".")) {
|
|
262
|
+
relations.push(`${fieldName}: ${relType}`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const flags = [];
|
|
268
|
+
// Parse gorm tag
|
|
269
|
+
const gormTag = tagStr.match(/gorm:"([^"]+)"/)?.[1] || "";
|
|
270
|
+
if (gormTag) {
|
|
271
|
+
if (gormTag.includes("primaryKey") || gormTag.includes("primarykey"))
|
|
272
|
+
flags.push("pk");
|
|
273
|
+
if (gormTag.includes("unique"))
|
|
274
|
+
flags.push("unique");
|
|
275
|
+
if (gormTag.includes("not null"))
|
|
276
|
+
flags.push("required");
|
|
277
|
+
if (gormTag.includes("default:"))
|
|
278
|
+
flags.push("default");
|
|
279
|
+
if (gormTag.includes("index"))
|
|
280
|
+
flags.push("index");
|
|
281
|
+
if (gormTag.includes("foreignKey") || gormTag.includes("foreignkey"))
|
|
282
|
+
flags.push("fk");
|
|
283
|
+
}
|
|
284
|
+
fields.push({ name: fieldName, type: fieldType, flags });
|
|
285
|
+
}
|
|
286
|
+
if (fields.length > 0) {
|
|
287
|
+
models.push({
|
|
288
|
+
name,
|
|
289
|
+
fields,
|
|
290
|
+
relations,
|
|
291
|
+
orm: "gorm",
|
|
292
|
+
confidence: "ast",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return models;
|
|
297
|
+
}
|
|
298
|
+
// ─── Helpers ───
|
|
299
|
+
/**
|
|
300
|
+
* Extract the content between matched braces starting at position.
|
|
301
|
+
* Returns the content inside the braces (not including the opening brace).
|
|
302
|
+
*/
|
|
303
|
+
function extractBraceBlock(content, startAfterOpenBrace) {
|
|
304
|
+
let depth = 1;
|
|
305
|
+
let i = startAfterOpenBrace;
|
|
306
|
+
while (i < content.length && depth > 0) {
|
|
307
|
+
if (content[i] === "{")
|
|
308
|
+
depth++;
|
|
309
|
+
else if (content[i] === "}")
|
|
310
|
+
depth--;
|
|
311
|
+
i++;
|
|
312
|
+
}
|
|
313
|
+
if (depth !== 0)
|
|
314
|
+
return null;
|
|
315
|
+
return content.slice(startAfterOpenBrace, i - 1);
|
|
316
|
+
}
|
|
317
|
+
function extractPathParams(path) {
|
|
318
|
+
const params = [];
|
|
319
|
+
// Gin/Echo: :param, Chi: {param}, Go 1.22: {param}
|
|
320
|
+
const regex = /[:{}](\w+)/g;
|
|
321
|
+
let m;
|
|
322
|
+
while ((m = regex.exec(path)) !== null)
|
|
323
|
+
params.push(m[1]);
|
|
324
|
+
return params;
|
|
325
|
+
}
|
|
326
|
+
function normalizePath(path) {
|
|
327
|
+
return path.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
328
|
+
}
|
|
329
|
+
function escapeRegex(str) {
|
|
330
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
331
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python AST extraction via subprocess.
|
|
3
|
+
* Spawns python3 with an inline script using stdlib `ast` module.
|
|
4
|
+
* Zero dependencies — if the project uses Python, the interpreter is there.
|
|
5
|
+
*
|
|
6
|
+
* Extracts:
|
|
7
|
+
* - FastAPI/Flask route decorators with precise path + method
|
|
8
|
+
* - Django urlpatterns with path() calls
|
|
9
|
+
* - SQLAlchemy model classes with Column types, flags, and relationships
|
|
10
|
+
*/
|
|
11
|
+
import type { RouteInfo, SchemaModel, Framework } from "../types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Extract routes from a Python file using AST.
|
|
14
|
+
* Returns routes with confidence: "ast", or null if Python is unavailable.
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractPythonRoutesAST(filePath: string, content: string, framework: Framework, tags: string[]): Promise<RouteInfo[] | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Extract SQLAlchemy models from a Python file using AST.
|
|
19
|
+
* Returns models with confidence: "ast", or null if Python is unavailable.
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractSQLAlchemyAST(filePath: string, content: string): Promise<SchemaModel[] | null>;
|
|
22
|
+
/**
|
|
23
|
+
* Check if Python 3 is available on this system.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isPythonAvailable(): Promise<boolean>;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python AST extraction via subprocess.
|
|
3
|
+
* Spawns python3 with an inline script using stdlib `ast` module.
|
|
4
|
+
* Zero dependencies — if the project uses Python, the interpreter is there.
|
|
5
|
+
*
|
|
6
|
+
* Extracts:
|
|
7
|
+
* - FastAPI/Flask route decorators with precise path + method
|
|
8
|
+
* - Django urlpatterns with path() calls
|
|
9
|
+
* - SQLAlchemy model classes with Column types, flags, and relationships
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const execFileP = promisify(execFile);
|
|
14
|
+
// The Python script that does AST parsing via stdlib
|
|
15
|
+
// Outputs JSON to stdout
|
|
16
|
+
const PYTHON_ROUTE_SCRIPT = `
|
|
17
|
+
import ast, json, sys
|
|
18
|
+
|
|
19
|
+
def extract_routes(source, filename):
|
|
20
|
+
try:
|
|
21
|
+
tree = ast.parse(source, filename)
|
|
22
|
+
except SyntaxError:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
routes = []
|
|
26
|
+
|
|
27
|
+
for node in ast.walk(tree):
|
|
28
|
+
# FastAPI/Flask style: @router.get("/path") or @app.route("/path", methods=["GET","POST"])
|
|
29
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
30
|
+
for dec in node.decorator_list:
|
|
31
|
+
route = parse_route_decorator(dec)
|
|
32
|
+
if route:
|
|
33
|
+
for r in route:
|
|
34
|
+
routes.append(r)
|
|
35
|
+
|
|
36
|
+
# Django: path("url", view) in urlpatterns list
|
|
37
|
+
if isinstance(node, ast.Assign):
|
|
38
|
+
for target in node.targets:
|
|
39
|
+
if isinstance(target, ast.Name) and target.id == 'urlpatterns':
|
|
40
|
+
if isinstance(node.value, (ast.List, ast.BinOp)):
|
|
41
|
+
routes.extend(parse_urlpatterns(node.value))
|
|
42
|
+
|
|
43
|
+
return routes
|
|
44
|
+
|
|
45
|
+
def parse_route_decorator(dec):
|
|
46
|
+
results = []
|
|
47
|
+
|
|
48
|
+
# @router.get("/path") / @app.post("/path") — Attribute call
|
|
49
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
50
|
+
method_name = dec.func.attr.upper()
|
|
51
|
+
methods_map = {'GET':'GET','POST':'POST','PUT':'PUT','PATCH':'PATCH',
|
|
52
|
+
'DELETE':'DELETE','OPTIONS':'OPTIONS','HEAD':'HEAD'}
|
|
53
|
+
|
|
54
|
+
if method_name in methods_map and dec.args:
|
|
55
|
+
path = get_str(dec.args[0])
|
|
56
|
+
if path is not None:
|
|
57
|
+
results.append({'method': methods_map[method_name], 'path': path})
|
|
58
|
+
|
|
59
|
+
# @app.route("/path", methods=["GET","POST"])
|
|
60
|
+
if dec.func.attr == 'route' and dec.args:
|
|
61
|
+
path = get_str(dec.args[0])
|
|
62
|
+
if path is not None:
|
|
63
|
+
methods = ['GET']
|
|
64
|
+
for kw in dec.keywords:
|
|
65
|
+
if kw.arg == 'methods' and isinstance(kw.value, ast.List):
|
|
66
|
+
methods = [get_str(e) for e in kw.value.elts if get_str(e)]
|
|
67
|
+
for m in methods:
|
|
68
|
+
results.append({'method': m.upper(), 'path': path})
|
|
69
|
+
|
|
70
|
+
# @app.api_route("/path", methods=["GET","POST"]) — FastAPI api_route
|
|
71
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
72
|
+
if dec.func.attr == 'api_route' and dec.args:
|
|
73
|
+
path = get_str(dec.args[0])
|
|
74
|
+
if path is not None:
|
|
75
|
+
methods = ['GET']
|
|
76
|
+
for kw in dec.keywords:
|
|
77
|
+
if kw.arg == 'methods' and isinstance(kw.value, ast.List):
|
|
78
|
+
methods = [get_str(e) for e in kw.value.elts if get_str(e)]
|
|
79
|
+
for m in methods:
|
|
80
|
+
results.append({'method': m.upper(), 'path': path})
|
|
81
|
+
|
|
82
|
+
return results if results else None
|
|
83
|
+
|
|
84
|
+
def parse_urlpatterns(node):
|
|
85
|
+
routes = []
|
|
86
|
+
elements = []
|
|
87
|
+
if isinstance(node, ast.List):
|
|
88
|
+
elements = node.elts
|
|
89
|
+
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
90
|
+
# urlpatterns = [...] + [...]
|
|
91
|
+
if isinstance(node.left, ast.List):
|
|
92
|
+
elements.extend(node.left.elts)
|
|
93
|
+
if isinstance(node.right, ast.List):
|
|
94
|
+
elements.extend(node.right.elts)
|
|
95
|
+
|
|
96
|
+
for elt in elements:
|
|
97
|
+
if isinstance(elt, ast.Call):
|
|
98
|
+
func_name = ''
|
|
99
|
+
if isinstance(elt.func, ast.Name):
|
|
100
|
+
func_name = elt.func.id
|
|
101
|
+
elif isinstance(elt.func, ast.Attribute):
|
|
102
|
+
func_name = elt.func.attr
|
|
103
|
+
|
|
104
|
+
if func_name in ('path', 're_path', 'url') and elt.args:
|
|
105
|
+
path_str = get_str(elt.args[0])
|
|
106
|
+
if path_str is not None:
|
|
107
|
+
routes.append({'method': 'ALL', 'path': '/' + path_str.lstrip('/')})
|
|
108
|
+
|
|
109
|
+
return routes
|
|
110
|
+
|
|
111
|
+
def get_str(node):
|
|
112
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
113
|
+
return node.value
|
|
114
|
+
if isinstance(node, ast.Str): # Python 3.7
|
|
115
|
+
return node.s
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
source = sys.stdin.read()
|
|
119
|
+
filename = sys.argv[1] if len(sys.argv) > 1 else '<stdin>'
|
|
120
|
+
result = extract_routes(source, filename)
|
|
121
|
+
print(json.dumps(result))
|
|
122
|
+
`;
|
|
123
|
+
const PYTHON_SCHEMA_SCRIPT = `
|
|
124
|
+
import ast, json, sys
|
|
125
|
+
|
|
126
|
+
def extract_models(source, filename):
|
|
127
|
+
try:
|
|
128
|
+
tree = ast.parse(source, filename)
|
|
129
|
+
except SyntaxError:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
models = []
|
|
133
|
+
|
|
134
|
+
for node in ast.walk(tree):
|
|
135
|
+
if not isinstance(node, ast.ClassDef):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Check if inherits from Base, Model, DeclarativeBase, db.Model
|
|
139
|
+
is_model = False
|
|
140
|
+
for base in node.bases:
|
|
141
|
+
name = ''
|
|
142
|
+
if isinstance(base, ast.Name):
|
|
143
|
+
name = base.id
|
|
144
|
+
elif isinstance(base, ast.Attribute):
|
|
145
|
+
name = base.attr
|
|
146
|
+
if name in ('Base', 'Model', 'DeclarativeBase', 'AbstractBase'):
|
|
147
|
+
is_model = True
|
|
148
|
+
break
|
|
149
|
+
if not is_model:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
fields = []
|
|
153
|
+
relations = []
|
|
154
|
+
|
|
155
|
+
for item in node.body:
|
|
156
|
+
# Column assignments: field = Column(Type, ...) or field: Mapped[Type] = mapped_column(...)
|
|
157
|
+
if isinstance(item, ast.Assign):
|
|
158
|
+
for target in item.targets:
|
|
159
|
+
if isinstance(target, ast.Name):
|
|
160
|
+
field_info = parse_column_or_rel(target.id, item.value)
|
|
161
|
+
if field_info:
|
|
162
|
+
if field_info['kind'] == 'field':
|
|
163
|
+
fields.append(field_info)
|
|
164
|
+
else:
|
|
165
|
+
relations.append(field_info)
|
|
166
|
+
|
|
167
|
+
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
168
|
+
field_name = item.target.id
|
|
169
|
+
if field_name.startswith('__'):
|
|
170
|
+
continue
|
|
171
|
+
if item.value:
|
|
172
|
+
field_info = parse_column_or_rel(field_name, item.value)
|
|
173
|
+
if field_info:
|
|
174
|
+
if field_info['kind'] == 'field':
|
|
175
|
+
# Try to get type from annotation
|
|
176
|
+
ann_type = extract_mapped_type(item.annotation)
|
|
177
|
+
if ann_type and field_info['type'] == 'unknown':
|
|
178
|
+
field_info['type'] = ann_type
|
|
179
|
+
fields.append(field_info)
|
|
180
|
+
else:
|
|
181
|
+
relations.append(field_info)
|
|
182
|
+
|
|
183
|
+
if fields:
|
|
184
|
+
models.append({
|
|
185
|
+
'name': node.name,
|
|
186
|
+
'fields': fields,
|
|
187
|
+
'relations': relations,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return models
|
|
191
|
+
|
|
192
|
+
def parse_column_or_rel(name, value):
|
|
193
|
+
if name.startswith('__'):
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
# relationship("Model")
|
|
197
|
+
if isinstance(value, ast.Call):
|
|
198
|
+
func_name = get_func_name(value)
|
|
199
|
+
|
|
200
|
+
if func_name in ('relationship', 'db.relationship'):
|
|
201
|
+
target = ''
|
|
202
|
+
if value.args:
|
|
203
|
+
target = get_str(value.args[0]) or ''
|
|
204
|
+
return {'kind': 'relation', 'name': name, 'target': target}
|
|
205
|
+
|
|
206
|
+
if func_name in ('Column', 'db.Column', 'mapped_column'):
|
|
207
|
+
col_type = 'unknown'
|
|
208
|
+
flags = []
|
|
209
|
+
|
|
210
|
+
for arg in value.args:
|
|
211
|
+
t = get_type_name(arg)
|
|
212
|
+
if t:
|
|
213
|
+
col_type = t
|
|
214
|
+
if isinstance(arg, ast.Call) and get_func_name(arg) == 'ForeignKey':
|
|
215
|
+
flags.append('fk')
|
|
216
|
+
|
|
217
|
+
for kw in value.keywords:
|
|
218
|
+
if kw.arg == 'primary_key' and is_true(kw.value):
|
|
219
|
+
flags.append('pk')
|
|
220
|
+
elif kw.arg == 'unique' and is_true(kw.value):
|
|
221
|
+
flags.append('unique')
|
|
222
|
+
elif kw.arg == 'nullable' and is_true(kw.value):
|
|
223
|
+
flags.append('nullable')
|
|
224
|
+
elif kw.arg == 'default':
|
|
225
|
+
flags.append('default')
|
|
226
|
+
elif kw.arg == 'index' and is_true(kw.value):
|
|
227
|
+
flags.append('index')
|
|
228
|
+
|
|
229
|
+
return {'kind': 'field', 'name': name, 'type': col_type, 'flags': flags}
|
|
230
|
+
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def extract_mapped_type(annotation):
|
|
234
|
+
# Mapped[int] or Mapped[Optional[str]]
|
|
235
|
+
if isinstance(annotation, ast.Subscript):
|
|
236
|
+
if isinstance(annotation.value, ast.Name) and annotation.value.id == 'Mapped':
|
|
237
|
+
slice_node = annotation.slice
|
|
238
|
+
if isinstance(slice_node, ast.Name):
|
|
239
|
+
return slice_node.id
|
|
240
|
+
if isinstance(slice_node, ast.Subscript) and isinstance(slice_node.value, ast.Name):
|
|
241
|
+
if slice_node.value.id == 'Optional':
|
|
242
|
+
inner = slice_node.slice
|
|
243
|
+
if isinstance(inner, ast.Name):
|
|
244
|
+
return inner.id
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def get_func_name(node):
|
|
248
|
+
if isinstance(node.func, ast.Name):
|
|
249
|
+
return node.func.id
|
|
250
|
+
if isinstance(node.func, ast.Attribute):
|
|
251
|
+
if isinstance(node.func.value, ast.Name):
|
|
252
|
+
return node.func.value.id + '.' + node.func.attr
|
|
253
|
+
return node.func.attr
|
|
254
|
+
return ''
|
|
255
|
+
|
|
256
|
+
def get_type_name(node):
|
|
257
|
+
if isinstance(node, ast.Name):
|
|
258
|
+
known = {'String','Integer','Boolean','Float','Text','DateTime','JSON',
|
|
259
|
+
'UUID','BigInteger','SmallInteger','Numeric','Date','Time',
|
|
260
|
+
'LargeBinary','Enum','ARRAY','JSONB'}
|
|
261
|
+
if node.id in known:
|
|
262
|
+
return node.id
|
|
263
|
+
if isinstance(node, ast.Call):
|
|
264
|
+
return get_type_name(node.func)
|
|
265
|
+
if isinstance(node, ast.Attribute):
|
|
266
|
+
return node.attr
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def get_str(node):
|
|
270
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
271
|
+
return node.value
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def is_true(node):
|
|
275
|
+
if isinstance(node, ast.Constant):
|
|
276
|
+
return node.value is True
|
|
277
|
+
if isinstance(node, ast.NameConstant): # Python 3.7
|
|
278
|
+
return node.value is True
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
source = sys.stdin.read()
|
|
282
|
+
filename = sys.argv[1] if len(sys.argv) > 1 else '<stdin>'
|
|
283
|
+
result = extract_models(source, filename)
|
|
284
|
+
print(json.dumps(result))
|
|
285
|
+
`;
|
|
286
|
+
let pythonAvailable = null;
|
|
287
|
+
let pythonCmd = "python3";
|
|
288
|
+
async function findPython() {
|
|
289
|
+
if (pythonAvailable !== null)
|
|
290
|
+
return pythonAvailable;
|
|
291
|
+
for (const cmd of ["python3", "python"]) {
|
|
292
|
+
try {
|
|
293
|
+
const { stdout } = await execFileP(cmd, ["--version"], { timeout: 3000 });
|
|
294
|
+
if (stdout.includes("Python 3")) {
|
|
295
|
+
pythonCmd = cmd;
|
|
296
|
+
pythonAvailable = true;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch { }
|
|
301
|
+
}
|
|
302
|
+
pythonAvailable = false;
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
import { spawn } from "node:child_process";
|
|
306
|
+
async function runPythonWithStdin(script, source, filename) {
|
|
307
|
+
if (!(await findPython()))
|
|
308
|
+
return null;
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
const proc = spawn(pythonCmd, ["-c", script, filename], {
|
|
311
|
+
timeout: 10000,
|
|
312
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
|
|
313
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
314
|
+
});
|
|
315
|
+
let stdout = "";
|
|
316
|
+
let stderr = "";
|
|
317
|
+
proc.stdout.on("data", (data) => { stdout += data.toString(); });
|
|
318
|
+
proc.stderr.on("data", (data) => { stderr += data.toString(); });
|
|
319
|
+
proc.on("close", (code) => {
|
|
320
|
+
if (code !== 0 || !stdout.trim()) {
|
|
321
|
+
resolve(null);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
resolve(JSON.parse(stdout.trim()));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
resolve(null);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
proc.on("error", () => resolve(null));
|
|
332
|
+
proc.stdin.write(source);
|
|
333
|
+
proc.stdin.end();
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Extract routes from a Python file using AST.
|
|
338
|
+
* Returns routes with confidence: "ast", or null if Python is unavailable.
|
|
339
|
+
*/
|
|
340
|
+
export async function extractPythonRoutesAST(filePath, content, framework, tags) {
|
|
341
|
+
const result = await runPythonWithStdin(PYTHON_ROUTE_SCRIPT, content, filePath);
|
|
342
|
+
if (!result || !Array.isArray(result) || result.length === 0)
|
|
343
|
+
return null;
|
|
344
|
+
return result.map((r) => ({
|
|
345
|
+
method: r.method,
|
|
346
|
+
path: r.path,
|
|
347
|
+
file: filePath,
|
|
348
|
+
tags,
|
|
349
|
+
framework,
|
|
350
|
+
params: extractPathParams(r.path),
|
|
351
|
+
confidence: "ast",
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Extract SQLAlchemy models from a Python file using AST.
|
|
356
|
+
* Returns models with confidence: "ast", or null if Python is unavailable.
|
|
357
|
+
*/
|
|
358
|
+
export async function extractSQLAlchemyAST(filePath, content) {
|
|
359
|
+
const result = await runPythonWithStdin(PYTHON_SCHEMA_SCRIPT, content, filePath);
|
|
360
|
+
if (!result || !Array.isArray(result) || result.length === 0)
|
|
361
|
+
return null;
|
|
362
|
+
return result.map((m) => ({
|
|
363
|
+
name: m.name,
|
|
364
|
+
fields: (m.fields || []).map((f) => ({
|
|
365
|
+
name: f.name,
|
|
366
|
+
type: f.type || "unknown",
|
|
367
|
+
flags: f.flags || [],
|
|
368
|
+
})),
|
|
369
|
+
relations: (m.relations || []).map((r) => `${r.name}: ${r.target}`),
|
|
370
|
+
orm: "sqlalchemy",
|
|
371
|
+
confidence: "ast",
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Check if Python 3 is available on this system.
|
|
376
|
+
*/
|
|
377
|
+
export async function isPythonAvailable() {
|
|
378
|
+
return findPython();
|
|
379
|
+
}
|
|
380
|
+
function extractPathParams(path) {
|
|
381
|
+
const params = [];
|
|
382
|
+
// FastAPI: {param} / Flask: <param> / Django: <type:param>
|
|
383
|
+
const regex = /[{<](?:\w+:)?(\w+)[}>]/g;
|
|
384
|
+
let m;
|
|
385
|
+
while ((m = regex.exec(path)) !== null)
|
|
386
|
+
params.push(m[1]);
|
|
387
|
+
return params;
|
|
388
|
+
}
|
package/dist/detectors/routes.js
CHANGED
|
@@ -2,6 +2,8 @@ import { relative, basename } from "node:path";
|
|
|
2
2
|
import { readFileSafe } from "../scanner.js";
|
|
3
3
|
import { loadTypeScript } from "../ast/loader.js";
|
|
4
4
|
import { extractRoutesAST } from "../ast/extract-routes.js";
|
|
5
|
+
import { extractPythonRoutesAST } from "../ast/extract-python.js";
|
|
6
|
+
import { extractGoRoutesStructured } from "../ast/extract-go.js";
|
|
5
7
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
|
|
6
8
|
const TAG_PATTERNS = [
|
|
7
9
|
["auth", [/auth/i, /jwt/i, /token/i, /session/i, /bearer/i, /passport/i, /clerk/i, /betterAuth/i, /better-auth/i]],
|
|
@@ -581,6 +583,14 @@ async function detectFastAPIRoutes(files, project) {
|
|
|
581
583
|
if (!content.includes("fastapi") && !content.includes("FastAPI") && !content.includes("APIRouter"))
|
|
582
584
|
continue;
|
|
583
585
|
const rel = relative(project.root, file);
|
|
586
|
+
const tags = detectTags(content);
|
|
587
|
+
// Try Python AST first
|
|
588
|
+
const astRoutes = await extractPythonRoutesAST(rel, content, "fastapi", tags);
|
|
589
|
+
if (astRoutes && astRoutes.length > 0) {
|
|
590
|
+
routes.push(...astRoutes);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// Fallback to regex
|
|
584
594
|
const routePattern = /@\w+\s*\.\s*(get|post|put|patch|delete|options)\s*\(\s*['"]([^'"]+)['"]/gi;
|
|
585
595
|
let match;
|
|
586
596
|
while ((match = routePattern.exec(content)) !== null) {
|
|
@@ -588,7 +598,7 @@ async function detectFastAPIRoutes(files, project) {
|
|
|
588
598
|
method: match[1].toUpperCase(),
|
|
589
599
|
path: match[2],
|
|
590
600
|
file: rel,
|
|
591
|
-
tags
|
|
601
|
+
tags,
|
|
592
602
|
framework: "fastapi",
|
|
593
603
|
});
|
|
594
604
|
}
|
|
@@ -604,6 +614,14 @@ async function detectFlaskRoutes(files, project) {
|
|
|
604
614
|
if (!content.includes("flask") && !content.includes("Flask") && !content.includes("Blueprint"))
|
|
605
615
|
continue;
|
|
606
616
|
const rel = relative(project.root, file);
|
|
617
|
+
const tags = detectTags(content);
|
|
618
|
+
// Try Python AST first
|
|
619
|
+
const astRoutes = await extractPythonRoutesAST(rel, content, "flask", tags);
|
|
620
|
+
if (astRoutes && astRoutes.length > 0) {
|
|
621
|
+
routes.push(...astRoutes);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
// Fallback to regex
|
|
607
625
|
const routePattern = /@(?:app|bp|blueprint|\w+)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*methods\s*=\s*\[([^\]]+)\])?\s*\)/gi;
|
|
608
626
|
let match;
|
|
609
627
|
while ((match = routePattern.exec(content)) !== null) {
|
|
@@ -616,7 +634,7 @@ async function detectFlaskRoutes(files, project) {
|
|
|
616
634
|
method,
|
|
617
635
|
path,
|
|
618
636
|
file: rel,
|
|
619
|
-
tags
|
|
637
|
+
tags,
|
|
620
638
|
framework: "flask",
|
|
621
639
|
});
|
|
622
640
|
}
|
|
@@ -631,7 +649,14 @@ async function detectDjangoRoutes(files, project) {
|
|
|
631
649
|
for (const file of pyFiles) {
|
|
632
650
|
const content = await readFileSafe(file);
|
|
633
651
|
const rel = relative(project.root, file);
|
|
634
|
-
|
|
652
|
+
const tags = detectTags(content);
|
|
653
|
+
// Try Python AST first
|
|
654
|
+
const astRoutes = await extractPythonRoutesAST(rel, content, "django", tags);
|
|
655
|
+
if (astRoutes && astRoutes.length > 0) {
|
|
656
|
+
routes.push(...astRoutes);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
// Fallback to regex
|
|
635
660
|
const pathPattern = /path\s*\(\s*['"]([^'"]*)['"]\s*,/g;
|
|
636
661
|
let match;
|
|
637
662
|
while ((match = pathPattern.exec(content)) !== null) {
|
|
@@ -639,7 +664,7 @@ async function detectDjangoRoutes(files, project) {
|
|
|
639
664
|
method: "ALL",
|
|
640
665
|
path: "/" + match[1],
|
|
641
666
|
file: rel,
|
|
642
|
-
tags
|
|
667
|
+
tags,
|
|
643
668
|
framework: "django",
|
|
644
669
|
});
|
|
645
670
|
}
|
|
@@ -653,72 +678,43 @@ async function detectGoRoutes(files, project, fw) {
|
|
|
653
678
|
for (const file of goFiles) {
|
|
654
679
|
const content = await readFileSafe(file);
|
|
655
680
|
const rel = relative(project.root, file);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
path: match[2],
|
|
663
|
-
file: rel,
|
|
664
|
-
tags: detectTags(content),
|
|
665
|
-
framework: fw,
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
else if (fw === "fiber") {
|
|
670
|
-
const pattern = /\.\s*(Get|Post|Put|Patch|Delete|Options|Head)\s*\(\s*["']([^"']+)["']/g;
|
|
671
|
-
let match;
|
|
672
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
673
|
-
routes.push({
|
|
674
|
-
method: match[1].toUpperCase(),
|
|
675
|
-
path: match[2],
|
|
676
|
-
file: rel,
|
|
677
|
-
tags: detectTags(content),
|
|
678
|
-
framework: fw,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
+
const tags = detectTags(content);
|
|
682
|
+
// Use structured parser (brace-tracking + group prefix resolution)
|
|
683
|
+
const structuredRoutes = extractGoRoutesStructured(rel, content, fw, tags);
|
|
684
|
+
if (structuredRoutes.length > 0) {
|
|
685
|
+
routes.push(...structuredRoutes);
|
|
686
|
+
continue;
|
|
681
687
|
}
|
|
682
|
-
|
|
683
|
-
|
|
688
|
+
// Fallback to simple regex for files where structured parser found nothing
|
|
689
|
+
if (fw === "gin" || fw === "echo") {
|
|
684
690
|
const pattern = /\.\s*(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(\s*["']([^"']+)["']/g;
|
|
685
691
|
let match;
|
|
686
692
|
while ((match = pattern.exec(content)) !== null) {
|
|
687
|
-
routes.push({
|
|
688
|
-
method: match[1],
|
|
689
|
-
path: match[2],
|
|
690
|
-
file: rel,
|
|
691
|
-
tags: detectTags(content),
|
|
692
|
-
framework: fw,
|
|
693
|
-
});
|
|
693
|
+
routes.push({ method: match[1], path: match[2], file: rel, tags, framework: fw });
|
|
694
694
|
}
|
|
695
695
|
}
|
|
696
|
-
else if (fw === "chi") {
|
|
697
|
-
// r.Get("/path", handler), r.Post("/path", handler)
|
|
696
|
+
else if (fw === "fiber" || fw === "chi") {
|
|
698
697
|
const pattern = /\.\s*(Get|Post|Put|Patch|Delete|Options|Head)\s*\(\s*["']([^"']+)["']/g;
|
|
699
698
|
let match;
|
|
700
699
|
while ((match = pattern.exec(content)) !== null) {
|
|
701
|
-
routes.push({
|
|
702
|
-
method: match[1].toUpperCase(),
|
|
703
|
-
path: match[2],
|
|
704
|
-
file: rel,
|
|
705
|
-
tags: detectTags(content),
|
|
706
|
-
framework: fw,
|
|
707
|
-
});
|
|
700
|
+
routes.push({ method: match[1].toUpperCase(), path: match[2], file: rel, tags, framework: fw });
|
|
708
701
|
}
|
|
709
702
|
}
|
|
710
703
|
else {
|
|
711
|
-
// net/http
|
|
704
|
+
// net/http
|
|
712
705
|
const pattern = /(?:HandleFunc|Handle)\s*\(\s*["']([^"']+)["']/g;
|
|
713
706
|
let match;
|
|
714
707
|
while ((match = pattern.exec(content)) !== null) {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
708
|
+
// Go 1.22+: "GET /path" patterns
|
|
709
|
+
const pathStr = match[1];
|
|
710
|
+
let method = "ALL";
|
|
711
|
+
let path = pathStr;
|
|
712
|
+
const methodMatch = pathStr.match(/^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\/.*)/);
|
|
713
|
+
if (methodMatch) {
|
|
714
|
+
method = methodMatch[1];
|
|
715
|
+
path = methodMatch[2];
|
|
716
|
+
}
|
|
717
|
+
routes.push({ method, path, file: rel, tags, framework: fw });
|
|
722
718
|
}
|
|
723
719
|
}
|
|
724
720
|
}
|
package/dist/detectors/schema.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { join, relative } from "node:path";
|
|
2
2
|
import { readFileSafe } from "../scanner.js";
|
|
3
3
|
import { loadTypeScript } from "../ast/loader.js";
|
|
4
4
|
import { extractDrizzleSchemaAST, extractTypeORMSchemaAST } from "../ast/extract-schema.js";
|
|
5
|
+
import { extractSQLAlchemyAST } from "../ast/extract-python.js";
|
|
6
|
+
import { extractGORMModelsStructured } from "../ast/extract-go.js";
|
|
5
7
|
const AUDIT_FIELDS = new Set([
|
|
6
8
|
"createdAt",
|
|
7
9
|
"updatedAt",
|
|
@@ -26,6 +28,9 @@ export async function detectSchemas(files, project) {
|
|
|
26
28
|
case "sqlalchemy":
|
|
27
29
|
models.push(...(await detectSQLAlchemySchemas(files, project)));
|
|
28
30
|
break;
|
|
31
|
+
case "gorm":
|
|
32
|
+
models.push(...(await detectGORMSchemas(files, project)));
|
|
33
|
+
break;
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
return models;
|
|
@@ -256,7 +261,14 @@ async function detectSQLAlchemySchemas(files, project) {
|
|
|
256
261
|
continue;
|
|
257
262
|
if (!content.includes("Base") && !content.includes("DeclarativeBase") && !content.includes("Model"))
|
|
258
263
|
continue;
|
|
259
|
-
|
|
264
|
+
const rel = relative(project.root, file);
|
|
265
|
+
// Try Python AST first
|
|
266
|
+
const astModels = await extractSQLAlchemyAST(rel, content);
|
|
267
|
+
if (astModels && astModels.length > 0) {
|
|
268
|
+
models.push(...astModels);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// Fallback to regex
|
|
260
272
|
const classPattern = /class\s+(\w+)\s*\([^)]*(?:Base|Model|DeclarativeBase)[^)]*\)\s*:([\s\S]*?)(?=\nclass\s|\n[^\s]|$)/g;
|
|
261
273
|
let match;
|
|
262
274
|
while ((match = classPattern.exec(content)) !== null) {
|
|
@@ -301,3 +313,17 @@ async function detectSQLAlchemySchemas(files, project) {
|
|
|
301
313
|
}
|
|
302
314
|
return models;
|
|
303
315
|
}
|
|
316
|
+
// --- GORM ---
|
|
317
|
+
async function detectGORMSchemas(files, _project) {
|
|
318
|
+
const goFiles = files.filter((f) => f.endsWith(".go"));
|
|
319
|
+
const models = [];
|
|
320
|
+
for (const file of goFiles) {
|
|
321
|
+
const content = await readFileSafe(file);
|
|
322
|
+
if (!content.includes("gorm") && !content.includes("Model") && !content.includes("`json:"))
|
|
323
|
+
continue;
|
|
324
|
+
const rel = relative(_project.root, file);
|
|
325
|
+
const structModels = extractGORMModelsStructured(rel, content);
|
|
326
|
+
models.push(...structModels);
|
|
327
|
+
}
|
|
328
|
+
return models;
|
|
329
|
+
}
|
package/dist/formatter.js
CHANGED
|
@@ -239,7 +239,9 @@ function formatCombined(result, sections) {
|
|
|
239
239
|
// Token stats
|
|
240
240
|
const ts = result.tokenStats;
|
|
241
241
|
lines.push(`> ${result.routes.length} routes | ${result.schemas.length} models | ${result.components.length} components | ${result.libs.length} lib files | ${result.config.envVars.length} env vars | ${result.middleware.length} middleware | ${result.graph.edges.length} import links`);
|
|
242
|
-
|
|
242
|
+
// Round to nearest 100 to keep output deterministic across runs (avoids git conflicts in worktrees)
|
|
243
|
+
const roundTo100 = (n) => Math.round(n / 100) * 100;
|
|
244
|
+
lines.push(`> **Token savings:** this file is ~${roundTo100(ts.outputTokens).toLocaleString()} tokens. Without it, AI exploration would cost ~${roundTo100(ts.estimatedExplorationTokens).toLocaleString()} tokens. **Saves ~${roundTo100(ts.saved).toLocaleString()} tokens per conversation.**`);
|
|
243
245
|
lines.push("");
|
|
244
246
|
lines.push("---");
|
|
245
247
|
lines.push("");
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { writeOutput } from "./formatter.js";
|
|
|
15
15
|
import { generateAIConfigs } from "./generators/ai-config.js";
|
|
16
16
|
import { generateHtmlReport } from "./generators/html-report.js";
|
|
17
17
|
import { loadConfig, mergeCliConfig } from "./config.js";
|
|
18
|
-
const VERSION = "1.
|
|
18
|
+
const VERSION = "1.5.1";
|
|
19
19
|
const BRAND = "codesight";
|
|
20
20
|
function printHelp() {
|
|
21
21
|
console.log(`
|
|
@@ -396,9 +396,9 @@ async function main() {
|
|
|
396
396
|
const reportPath = await generateHtmlReport(result, outputDir);
|
|
397
397
|
console.log(` ${outputDirName}/report.html`);
|
|
398
398
|
if (doOpen) {
|
|
399
|
-
const {
|
|
399
|
+
const { execFile } = await import("node:child_process");
|
|
400
400
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
401
|
-
|
|
401
|
+
execFile(cmd, [reportPath]);
|
|
402
402
|
console.log(" Opening in browser...");
|
|
403
403
|
}
|
|
404
404
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -90,7 +90,7 @@ export async function detectProject(root) {
|
|
|
90
90
|
pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
91
91
|
}
|
|
92
92
|
catch { }
|
|
93
|
-
const name = pkg.name ||
|
|
93
|
+
const name = pkg.name || await resolveRepoName(root);
|
|
94
94
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
95
95
|
// Detect monorepo
|
|
96
96
|
const isMonorepo = !!(pkg.workspaces || await fileExists(join(root, "pnpm-workspace.yaml")));
|
|
@@ -212,11 +212,11 @@ async function detectFrameworks(root, pkg) {
|
|
|
212
212
|
frameworks.push("django");
|
|
213
213
|
// Go frameworks - check go.mod
|
|
214
214
|
const goDeps = await getGoDeps(root);
|
|
215
|
-
if (goDeps.includes("net/http"))
|
|
215
|
+
if (goDeps.some((d) => d.includes("net/http")))
|
|
216
216
|
frameworks.push("go-net-http");
|
|
217
|
-
if (goDeps.includes("gin-gonic/gin"))
|
|
217
|
+
if (goDeps.some((d) => d.includes("gin-gonic/gin")))
|
|
218
218
|
frameworks.push("gin");
|
|
219
|
-
if (goDeps.includes("gofiber/fiber"))
|
|
219
|
+
if (goDeps.some((d) => d.includes("gofiber/fiber")))
|
|
220
220
|
frameworks.push("fiber");
|
|
221
221
|
if (goDeps.some((d) => d.includes("labstack/echo")))
|
|
222
222
|
frameworks.push("echo");
|
|
@@ -429,6 +429,33 @@ async function getGoDeps(root) {
|
|
|
429
429
|
catch { }
|
|
430
430
|
return deps;
|
|
431
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Resolve the repo name, handling git worktrees.
|
|
434
|
+
* In a worktree, basename(root) is a random name — resolve the actual repo instead.
|
|
435
|
+
*/
|
|
436
|
+
async function resolveRepoName(root) {
|
|
437
|
+
try {
|
|
438
|
+
// Check if .git is a file (worktree) vs directory (normal repo)
|
|
439
|
+
const gitPath = join(root, ".git");
|
|
440
|
+
const gitStat = await stat(gitPath);
|
|
441
|
+
if (gitStat.isFile()) {
|
|
442
|
+
// Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/name"
|
|
443
|
+
const gitContent = await readFile(gitPath, "utf-8");
|
|
444
|
+
const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/);
|
|
445
|
+
if (gitdirMatch) {
|
|
446
|
+
// Resolve back to main repo: /repo/.git/worktrees/name -> /repo
|
|
447
|
+
const worktreeGitDir = gitdirMatch[1].trim();
|
|
448
|
+
// Go up from .git/worktrees/name to the repo root
|
|
449
|
+
const mainGitDir = join(worktreeGitDir, "..", "..");
|
|
450
|
+
const mainRepoRoot = join(mainGitDir, "..");
|
|
451
|
+
return basename(mainRepoRoot);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch { }
|
|
456
|
+
// Fallback: use directory name
|
|
457
|
+
return basename(root);
|
|
458
|
+
}
|
|
432
459
|
async function fileExists(path) {
|
|
433
460
|
try {
|
|
434
461
|
await stat(path);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"routes": [
|
|
3
|
+
{ "method": "GET", "path": "/health" },
|
|
4
|
+
{ "method": "GET", "path": "/api/users" },
|
|
5
|
+
{ "method": "GET", "path": "/api/users/:id" },
|
|
6
|
+
{ "method": "POST", "path": "/api/users" },
|
|
7
|
+
{ "method": "PUT", "path": "/api/users/:id" },
|
|
8
|
+
{ "method": "DELETE", "path": "/api/users/:id" },
|
|
9
|
+
{ "method": "GET", "path": "/api/projects" },
|
|
10
|
+
{ "method": "GET", "path": "/api/projects/:id" },
|
|
11
|
+
{ "method": "POST", "path": "/api/projects" }
|
|
12
|
+
],
|
|
13
|
+
"models": [
|
|
14
|
+
{
|
|
15
|
+
"name": "User",
|
|
16
|
+
"fields": ["ID", "CreatedAt", "UpdatedAt", "DeletedAt", "Email", "Password", "Name", "Role"]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "Project",
|
|
20
|
+
"fields": ["ID", "CreatedAt", "UpdatedAt", "DeletedAt", "Name", "Description", "IsPublic", "OwnerID"]
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"envVars": ["DATABASE_URL", "JWT_SECRET", "PORT", "GIN_MODE"],
|
|
24
|
+
"middleware": ["auth"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gin-gorm-api",
|
|
3
|
+
"description": "Gin REST API with GORM models and route groups",
|
|
4
|
+
"files": {
|
|
5
|
+
"go.mod": "module github.com/example/api\n\ngo 1.22\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgorm.io/gorm v1.25.5\n\tgorm.io/driver/postgres v1.5.4\n)",
|
|
6
|
+
".env.example": "DATABASE_URL=postgres://localhost:5432/ginapi\nJWT_SECRET=changeme\nPORT=8080\nGIN_MODE=release",
|
|
7
|
+
"main.go": "package main\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/example/api/handlers\"\n\t\"github.com/example/api/middleware\"\n)\n\nfunc main() {\n\tr := gin.Default()\n\n\tr.GET(\"/health\", handlers.Health)\n\n\tapi := r.Group(\"/api\")\n\tapi.Use(middleware.Auth())\n\n\tusers := api.Group(\"/users\")\n\tusers.GET(\"\", handlers.ListUsers)\n\tusers.GET(\"/:id\", handlers.GetUser)\n\tusers.POST(\"\", handlers.CreateUser)\n\tusers.PUT(\"/:id\", handlers.UpdateUser)\n\tusers.DELETE(\"/:id\", handlers.DeleteUser)\n\n\tprojects := api.Group(\"/projects\")\n\tprojects.GET(\"\", handlers.ListProjects)\n\tprojects.GET(\"/:id\", handlers.GetProject)\n\tprojects.POST(\"\", handlers.CreateProject)\n\n\tr.Run(\":8080\")\n}",
|
|
8
|
+
"models/user.go": "package models\n\nimport (\n\t\"gorm.io/gorm\"\n\t\"time\"\n)\n\ntype User struct {\n\tgorm.Model\n\tEmail string `gorm:\"uniqueIndex;not null\" json:\"email\"`\n\tPassword string `gorm:\"not null\" json:\"-\"`\n\tName string `json:\"name\"`\n\tRole string `gorm:\"default:user\" json:\"role\"`\n\tProjects []Project `gorm:\"foreignKey:OwnerID\" json:\"projects,omitempty\"`\n}",
|
|
9
|
+
"models/project.go": "package models\n\nimport (\n\t\"gorm.io/gorm\"\n)\n\ntype Project struct {\n\tgorm.Model\n\tName string `gorm:\"not null\" json:\"name\"`\n\tDescription string `json:\"description\"`\n\tIsPublic bool `gorm:\"default:true\" json:\"is_public\"`\n\tOwnerID uint `gorm:\"not null;index\" json:\"owner_id\"`\n\tOwner *User `gorm:\"foreignKey:OwnerID\" json:\"owner,omitempty\"`\n}",
|
|
10
|
+
"handlers/health.go": "package handlers\n\nimport \"github.com/gin-gonic/gin\"\n\nfunc Health(c *gin.Context) {\n\tc.JSON(200, gin.H{\"status\": \"ok\"})\n}",
|
|
11
|
+
"handlers/users.go": "package handlers\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/example/api/models\"\n)\n\nfunc ListUsers(c *gin.Context) {\n\tvar users []models.User\n\tc.JSON(200, users)\n}\n\nfunc GetUser(c *gin.Context) {\n\tc.JSON(200, gin.H{})\n}\n\nfunc CreateUser(c *gin.Context) {\n\tc.JSON(201, gin.H{})\n}\n\nfunc UpdateUser(c *gin.Context) {\n\tc.JSON(200, gin.H{})\n}\n\nfunc DeleteUser(c *gin.Context) {\n\tc.JSON(200, gin.H{\"deleted\": true})\n}",
|
|
12
|
+
"handlers/projects.go": "package handlers\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc ListProjects(c *gin.Context) {\n\tc.JSON(200, gin.H{})\n}\n\nfunc GetProject(c *gin.Context) {\n\tc.JSON(200, gin.H{})\n}\n\nfunc CreateProject(c *gin.Context) {\n\tc.JSON(201, gin.H{})\n}",
|
|
13
|
+
"middleware/auth.go": "package middleware\n\nimport \"github.com/gin-gonic/gin\"\n\nfunc Auth() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\ttoken := c.GetHeader(\"Authorization\")\n\t\tif token == \"\" {\n\t\t\tc.AbortWithStatusJSON(401, gin.H{\"error\": \"unauthorized\"})\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t}\n}",
|
|
14
|
+
"db/db.go": "package db\n\nimport (\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"os\"\n)\n\nvar DB *gorm.DB\n\nfunc Init() {\n\tdsn := os.Getenv(\"DATABASE_URL\")\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tDB = db\n}"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codesight",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "See your codebase clearly. Universal AI context generator that maps routes, schema, components, dependencies, and more for Claude Code, Cursor, Copilot, Codex, and any AI coding tool.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|