@kernlang/review-python 3.4.6-canary.45.1.130ca3d2 → 3.4.6-canary.46.1.19dcfc19
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/mapper/extractors/dependency.d.ts +3 -0
- package/dist/mapper/extractors/dependency.js +52 -0
- package/dist/mapper/extractors/effect.d.ts +3 -0
- package/dist/mapper/extractors/effect.js +74 -0
- package/dist/mapper/extractors/entrypoint.d.ts +3 -0
- package/dist/mapper/extractors/entrypoint.js +225 -0
- package/dist/mapper/extractors/error.d.ts +5 -0
- package/dist/mapper/extractors/error.js +129 -0
- package/dist/mapper/extractors/fastapi-pagination.d.ts +5 -0
- package/dist/mapper/extractors/fastapi-pagination.js +119 -0
- package/dist/mapper/extractors/fastapi-status.d.ts +6 -0
- package/dist/mapper/extractors/fastapi-status.js +115 -0
- package/dist/mapper/extractors/guard.d.ts +3 -0
- package/dist/mapper/extractors/guard.js +115 -0
- package/dist/mapper/extractors/pydantic.d.ts +13 -0
- package/dist/mapper/extractors/pydantic.js +61 -0
- package/dist/mapper/extractors/state-mutation.d.ts +3 -0
- package/dist/mapper/extractors/state-mutation.js +63 -0
- package/dist/mapper/helpers/ast.d.ts +9 -0
- package/dist/mapper/helpers/ast.js +62 -0
- package/dist/mapper/helpers/types.d.ts +7 -0
- package/dist/mapper/helpers/types.js +168 -0
- package/dist/mapper/index.d.ts +8 -0
- package/dist/mapper/index.js +42 -0
- package/dist/mapper/signatures.d.ts +17 -0
- package/dist/mapper/signatures.js +87 -0
- package/dist/mapper.d.ts +1 -8
- package/dist/mapper.js +1 -1286
- package/package.json +3 -3
- package/src/mapper/extractors/dependency.ts +60 -0
- package/src/mapper/extractors/effect.ts +84 -0
- package/src/mapper/extractors/entrypoint.ts +272 -0
- package/src/mapper/extractors/error.ts +152 -0
- package/src/mapper/extractors/fastapi-pagination.ts +117 -0
- package/src/mapper/extractors/fastapi-status.ts +119 -0
- package/src/mapper/extractors/guard.ts +114 -0
- package/src/mapper/extractors/pydantic.ts +74 -0
- package/src/mapper/extractors/state-mutation.ts +72 -0
- package/src/mapper/helpers/ast.ts +72 -0
- package/src/mapper/helpers/types.ts +164 -0
- package/src/mapper/index.ts +50 -0
- package/src/mapper/signatures.ts +94 -0
- package/src/mapper.ts +1 -1388
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
|
|
2
|
+
import { STDLIB_MODULES } from '../signatures.js';
|
|
3
|
+
export function extractDependencyEdges(root, source, filePath, edges) {
|
|
4
|
+
const addDependency = (node, specifier) => {
|
|
5
|
+
let subtype = 'external';
|
|
6
|
+
if (specifier.startsWith('.')) {
|
|
7
|
+
subtype = 'internal';
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
const rootModule = specifier.split('.')[0];
|
|
11
|
+
if (STDLIB_MODULES.has(rootModule)) {
|
|
12
|
+
subtype = 'stdlib';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
edges.push({
|
|
16
|
+
id: `${filePath}#dep@${node.startIndex}`,
|
|
17
|
+
kind: 'dependency',
|
|
18
|
+
sourceId: filePath,
|
|
19
|
+
targetId: specifier,
|
|
20
|
+
primarySpan: nodeSpan(filePath, node),
|
|
21
|
+
evidence: nodeText(source, node, 100),
|
|
22
|
+
confidence: 1.0,
|
|
23
|
+
language: 'py',
|
|
24
|
+
payload: { kind: 'dependency', subtype, specifier },
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
walkNodes(root, 'import_statement', (node) => {
|
|
28
|
+
// import x, y as z
|
|
29
|
+
for (const child of node.namedChildren) {
|
|
30
|
+
if (child.type === 'dotted_name') {
|
|
31
|
+
addDependency(node, child.text);
|
|
32
|
+
}
|
|
33
|
+
else if (child.type === 'aliased_import') {
|
|
34
|
+
const name = child.childForFieldName('name');
|
|
35
|
+
if (name)
|
|
36
|
+
addDependency(node, name.text);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
walkNodes(root, 'import_from_statement', (node) => {
|
|
41
|
+
// from x import y
|
|
42
|
+
const moduleNode = node.childForFieldName('module_name');
|
|
43
|
+
const relativeMatch = node.text.match(/^from\s+(\.+)/);
|
|
44
|
+
let specifier = moduleNode ? moduleNode.text : '';
|
|
45
|
+
if (relativeMatch) {
|
|
46
|
+
specifier = relativeMatch[1] + specifier;
|
|
47
|
+
}
|
|
48
|
+
if (specifier) {
|
|
49
|
+
addDependency(node, specifier);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { conceptId } from '@kernlang/core';
|
|
2
|
+
import { getContainerId, isInAsyncDef, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
|
|
3
|
+
import { DB_METHODS, DB_MODULES, NETWORK_METHODS, NETWORK_MODULES } from '../signatures.js';
|
|
4
|
+
export function extractEffects(root, source, filePath, nodes) {
|
|
5
|
+
walkNodes(root, 'call', (node) => {
|
|
6
|
+
const funcNode = node.childForFieldName('function');
|
|
7
|
+
if (!funcNode)
|
|
8
|
+
return;
|
|
9
|
+
const funcText = source.substring(funcNode.startIndex, funcNode.endIndex);
|
|
10
|
+
// Network: requests.get(), httpx.post(), etc.
|
|
11
|
+
if (funcNode.type === 'attribute') {
|
|
12
|
+
const obj = funcNode.childForFieldName('object');
|
|
13
|
+
const attr = funcNode.childForFieldName('attribute');
|
|
14
|
+
if (obj && attr) {
|
|
15
|
+
const objName = source.substring(obj.startIndex, obj.endIndex);
|
|
16
|
+
const methodName = source.substring(attr.startIndex, attr.endIndex);
|
|
17
|
+
if (NETWORK_MODULES.has(objName) && NETWORK_METHODS.has(methodName)) {
|
|
18
|
+
nodes.push({
|
|
19
|
+
id: conceptId(filePath, 'effect', node.startIndex),
|
|
20
|
+
kind: 'effect',
|
|
21
|
+
primarySpan: nodeSpan(filePath, node),
|
|
22
|
+
evidence: nodeText(source, node, 120),
|
|
23
|
+
confidence: 0.95,
|
|
24
|
+
language: 'py',
|
|
25
|
+
containerId: getContainerId(node, filePath),
|
|
26
|
+
payload: { kind: 'effect', subtype: 'network', async: isInAsyncDef(node) },
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// DB: cursor.execute(), db.query(), etc.
|
|
31
|
+
if (DB_METHODS.has(methodName) &&
|
|
32
|
+
(DB_MODULES.has(objName) || /cursor|conn|db|session|collection/i.test(objName))) {
|
|
33
|
+
nodes.push({
|
|
34
|
+
id: conceptId(filePath, 'effect', node.startIndex),
|
|
35
|
+
kind: 'effect',
|
|
36
|
+
primarySpan: nodeSpan(filePath, node),
|
|
37
|
+
evidence: nodeText(source, node, 120),
|
|
38
|
+
confidence: 0.85,
|
|
39
|
+
language: 'py',
|
|
40
|
+
containerId: getContainerId(node, filePath),
|
|
41
|
+
payload: { kind: 'effect', subtype: 'db', async: isInAsyncDef(node) },
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// FS: open()
|
|
48
|
+
if (funcText === 'open') {
|
|
49
|
+
nodes.push({
|
|
50
|
+
id: conceptId(filePath, 'effect', node.startIndex),
|
|
51
|
+
kind: 'effect',
|
|
52
|
+
primarySpan: nodeSpan(filePath, node),
|
|
53
|
+
evidence: nodeText(source, node, 120),
|
|
54
|
+
confidence: 0.9,
|
|
55
|
+
language: 'py',
|
|
56
|
+
containerId: getContainerId(node, filePath),
|
|
57
|
+
payload: { kind: 'effect', subtype: 'fs', async: false },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// fetch() in async context (aiohttp pattern)
|
|
61
|
+
if (funcText === 'fetch' || funcText === 'aiohttp.request') {
|
|
62
|
+
nodes.push({
|
|
63
|
+
id: conceptId(filePath, 'effect', node.startIndex),
|
|
64
|
+
kind: 'effect',
|
|
65
|
+
primarySpan: nodeSpan(filePath, node),
|
|
66
|
+
evidence: nodeText(source, node, 120),
|
|
67
|
+
confidence: 0.8,
|
|
68
|
+
language: 'py',
|
|
69
|
+
containerId: getContainerId(node, filePath),
|
|
70
|
+
payload: { kind: 'effect', subtype: 'network', async: true },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { conceptId } from '@kernlang/core';
|
|
2
|
+
import { getSelfContainerId, isAsyncFunction, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
|
|
3
|
+
import { PY_DB_COLLECTION_RE, PY_DB_WRITE_RE, PY_IDEMPOTENCY_RE, PY_PAGINATION_RE } from '../signatures.js';
|
|
4
|
+
import { extractPythonHttpExceptionStatusCodes } from './error.js';
|
|
5
|
+
import { extractFastApiPaginationStrategy } from './fastapi-pagination.js';
|
|
6
|
+
import { extractFastApiSuccessStatusCodes } from './fastapi-status.js';
|
|
7
|
+
import { collectPydanticModels, extractFastApiBodyValidation } from './pydantic.js';
|
|
8
|
+
export function extractEntrypoints(root, source, filePath, nodes) {
|
|
9
|
+
const pydanticModels = collectPydanticModels(source);
|
|
10
|
+
// FastAPI / Flask route decorators.
|
|
11
|
+
//
|
|
12
|
+
// The route *path* (e.g. `/current`) is what cross-stack rules need to
|
|
13
|
+
// match against — not the Python function name. Prior to 2026-04-21 this
|
|
14
|
+
// emitted the function name, which `collectRoutes` then silently dropped
|
|
15
|
+
// (it filters on paths starting with `/`). The FastAPI router-prefix join
|
|
16
|
+
// in `cross-stack-utils.collectRoutes` also needs `routerName` so it can
|
|
17
|
+
// pair per-file routes with the `include_router(prefix=…)` call that
|
|
18
|
+
// mounts them.
|
|
19
|
+
walkNodes(root, 'decorated_definition', (node) => {
|
|
20
|
+
const fnDef = node.children.find((c) => c.type === 'function_definition');
|
|
21
|
+
if (!fnDef)
|
|
22
|
+
return;
|
|
23
|
+
for (const child of node.children) {
|
|
24
|
+
if (child.type !== 'decorator')
|
|
25
|
+
continue;
|
|
26
|
+
const decText = source.substring(child.startIndex, child.endIndex);
|
|
27
|
+
const routeMatch = decText.match(/@(\w+)\.(route|get|post|put|delete|patch)\s*\(/);
|
|
28
|
+
if (!routeMatch)
|
|
29
|
+
continue;
|
|
30
|
+
const routerName = routeMatch[1];
|
|
31
|
+
const method = routeMatch[2].toUpperCase();
|
|
32
|
+
const pathMatch = decText.match(/['"]([^'"]+)['"]/);
|
|
33
|
+
const routePath = pathMatch?.[1];
|
|
34
|
+
// Only surface the decorator as a route when we could extract a URL
|
|
35
|
+
// path literal. Mystery decorators with only kwargs (e.g. `@app.get`
|
|
36
|
+
// stub) are noise — skip them instead of filling `name` with the
|
|
37
|
+
// function name, which cross-stack routes treat as invalid.
|
|
38
|
+
if (!routePath?.startsWith('/'))
|
|
39
|
+
continue;
|
|
40
|
+
const responseModel = extractResponseModel(decText);
|
|
41
|
+
const routeContainerId = getSelfContainerId(fnDef, filePath);
|
|
42
|
+
const routeAnalysis = analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels, decText);
|
|
43
|
+
nodes.push({
|
|
44
|
+
id: conceptId(filePath, 'entrypoint', child.startIndex),
|
|
45
|
+
kind: 'entrypoint',
|
|
46
|
+
primarySpan: nodeSpan(filePath, child),
|
|
47
|
+
evidence: nodeText(source, child, 100),
|
|
48
|
+
confidence: 1.0,
|
|
49
|
+
language: 'py',
|
|
50
|
+
containerId: routeContainerId,
|
|
51
|
+
payload: {
|
|
52
|
+
kind: 'entrypoint',
|
|
53
|
+
subtype: 'route',
|
|
54
|
+
name: routePath,
|
|
55
|
+
httpMethod: method === 'ROUTE' ? undefined : method,
|
|
56
|
+
responseModel,
|
|
57
|
+
isAsync: isAsyncFunction(fnDef),
|
|
58
|
+
routerName,
|
|
59
|
+
errorStatusCodes: routeAnalysis.errorStatusCodes,
|
|
60
|
+
successStatusCodes: routeAnalysis.successStatusCodes,
|
|
61
|
+
successStatusCodesResolved: routeAnalysis.successStatusCodesResolved,
|
|
62
|
+
paginationStrategy: routeAnalysis.paginationStrategy,
|
|
63
|
+
paginationStrategyResolved: routeAnalysis.paginationStrategyResolved,
|
|
64
|
+
hasUnboundedCollectionQuery: routeAnalysis.hasUnboundedCollectionQuery,
|
|
65
|
+
hasDbWrite: routeAnalysis.hasDbWrite,
|
|
66
|
+
hasIdempotencyProtection: routeAnalysis.hasIdempotencyProtection,
|
|
67
|
+
hasBodyValidation: routeAnalysis.hasBodyValidation,
|
|
68
|
+
validatedBodyFields: routeAnalysis.validatedBodyFields,
|
|
69
|
+
bodyValidationResolved: routeAnalysis.bodyValidationResolved,
|
|
70
|
+
validatedBodyFieldTypes: routeAnalysis.validatedBodyFieldTypes,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
// FastAPI `app.include_router(<module>.<router>, prefix="/api/x")`.
|
|
76
|
+
//
|
|
77
|
+
// Emitted as a route-mount concept so `collectRoutes` can join it with
|
|
78
|
+
// the per-file route nodes: a route declared on `router` in
|
|
79
|
+
// `app/api/nutrition_goals.py` and mounted in `main.py` with
|
|
80
|
+
// `app.include_router(nutrition_goals.router, prefix="/api/nutrition-goals")`
|
|
81
|
+
// should resolve to the full URL `/api/nutrition-goals/<path>`.
|
|
82
|
+
walkNodes(root, 'call', (node) => {
|
|
83
|
+
const fn = node.childForFieldName('function');
|
|
84
|
+
if (!fn)
|
|
85
|
+
return;
|
|
86
|
+
const fnText = source.substring(fn.startIndex, fn.endIndex);
|
|
87
|
+
if (!/\.include_router$/.test(fnText))
|
|
88
|
+
return;
|
|
89
|
+
const argsNode = node.childForFieldName('arguments');
|
|
90
|
+
if (!argsNode)
|
|
91
|
+
return;
|
|
92
|
+
const argsText = source.substring(argsNode.startIndex, argsNode.endIndex);
|
|
93
|
+
// First positional arg is the router. Common shapes:
|
|
94
|
+
// include_router(router) — local identifier
|
|
95
|
+
// include_router(nutrition_goals.router) — imported-module attribute
|
|
96
|
+
// include_router(auth_router) — aliased local identifier
|
|
97
|
+
const posMatch = argsText.match(/^\(\s*([A-Za-z_][\w.]*)/);
|
|
98
|
+
if (!posMatch)
|
|
99
|
+
return;
|
|
100
|
+
const routerRef = posMatch[1];
|
|
101
|
+
const dot = routerRef.lastIndexOf('.');
|
|
102
|
+
const sourceModule = dot === -1 ? undefined : routerRef.slice(0, dot);
|
|
103
|
+
const routerName = dot === -1 ? routerRef : routerRef.slice(dot + 1);
|
|
104
|
+
const prefixMatch = argsText.match(/prefix\s*=\s*['"]([^'"]*)['"]/);
|
|
105
|
+
// Prefix defaults to '' when omitted — still valid (the route keeps its
|
|
106
|
+
// declared path as-is), so emit the mount either way.
|
|
107
|
+
const prefix = prefixMatch?.[1] ?? '';
|
|
108
|
+
nodes.push({
|
|
109
|
+
id: conceptId(filePath, 'entrypoint', node.startIndex),
|
|
110
|
+
kind: 'entrypoint',
|
|
111
|
+
primarySpan: nodeSpan(filePath, node),
|
|
112
|
+
evidence: nodeText(source, node, 120),
|
|
113
|
+
confidence: 0.95,
|
|
114
|
+
language: 'py',
|
|
115
|
+
payload: {
|
|
116
|
+
kind: 'entrypoint',
|
|
117
|
+
subtype: 'route-mount',
|
|
118
|
+
name: prefix,
|
|
119
|
+
routerName,
|
|
120
|
+
sourceModule,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
// `if __name__ == '__main__':`
|
|
125
|
+
walkNodes(root, 'if_statement', (node) => {
|
|
126
|
+
const condition = node.childForFieldName('condition');
|
|
127
|
+
if (condition?.text.includes('__name__') && condition.text.includes('__main__')) {
|
|
128
|
+
nodes.push({
|
|
129
|
+
id: conceptId(filePath, 'entrypoint', node.startIndex),
|
|
130
|
+
kind: 'entrypoint',
|
|
131
|
+
primarySpan: nodeSpan(filePath, node),
|
|
132
|
+
evidence: nodeText(source, node, 100),
|
|
133
|
+
confidence: 1.0,
|
|
134
|
+
language: 'py',
|
|
135
|
+
payload: {
|
|
136
|
+
kind: 'entrypoint',
|
|
137
|
+
subtype: 'main',
|
|
138
|
+
name: 'main',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels, decText) {
|
|
145
|
+
const text = source.substring(fnDef.startIndex, fnDef.endIndex);
|
|
146
|
+
const validation = extractFastApiBodyValidation(fnDef, source, pydanticModels);
|
|
147
|
+
const success = extractFastApiSuccessStatusCodes(decText, fnDef, source);
|
|
148
|
+
const pagination = extractFastApiPaginationStrategy(fnDef, source);
|
|
149
|
+
return {
|
|
150
|
+
errorStatusCodes: extractPythonHttpExceptionStatusCodes(text),
|
|
151
|
+
successStatusCodes: success.codes,
|
|
152
|
+
successStatusCodesResolved: success.resolved,
|
|
153
|
+
paginationStrategy: pagination.strategy,
|
|
154
|
+
paginationStrategyResolved: pagination.resolved,
|
|
155
|
+
hasUnboundedCollectionQuery: hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel),
|
|
156
|
+
hasDbWrite: PY_DB_WRITE_RE.test(text),
|
|
157
|
+
hasIdempotencyProtection: PY_IDEMPOTENCY_RE.test(text),
|
|
158
|
+
hasBodyValidation: validation.has,
|
|
159
|
+
validatedBodyFields: validation.fields,
|
|
160
|
+
bodyValidationResolved: validation.resolved,
|
|
161
|
+
validatedBodyFieldTypes: validation.types,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel) {
|
|
165
|
+
if (method !== 'GET')
|
|
166
|
+
return false;
|
|
167
|
+
if (/[{:]/.test(routePath))
|
|
168
|
+
return false;
|
|
169
|
+
if (PY_PAGINATION_RE.test(text))
|
|
170
|
+
return false;
|
|
171
|
+
const responseLooksList = responseModel ? /^(list|List|Sequence|Iterable)\s*\[/.test(responseModel) : false;
|
|
172
|
+
return (PY_DB_COLLECTION_RE.test(text) &&
|
|
173
|
+
(responseLooksList || /\breturn\b[\s\S]*(\.all\s*\(|\.find\s*\(|\.fetchall\s*\()/.test(text)));
|
|
174
|
+
}
|
|
175
|
+
function extractResponseModel(decoratorText) {
|
|
176
|
+
const match = decoratorText.match(/\bresponse_model\s*=/);
|
|
177
|
+
if (!match || match.index === undefined)
|
|
178
|
+
return undefined;
|
|
179
|
+
let index = match.index + match[0].length;
|
|
180
|
+
while (/\s/.test(decoratorText[index] ?? ''))
|
|
181
|
+
index++;
|
|
182
|
+
const start = index;
|
|
183
|
+
let squareDepth = 0;
|
|
184
|
+
let parenDepth = 0;
|
|
185
|
+
let braceDepth = 0;
|
|
186
|
+
let quote;
|
|
187
|
+
while (index < decoratorText.length) {
|
|
188
|
+
const char = decoratorText[index];
|
|
189
|
+
const prev = decoratorText[index - 1];
|
|
190
|
+
if (quote) {
|
|
191
|
+
if (char === quote && prev !== '\\')
|
|
192
|
+
quote = undefined;
|
|
193
|
+
index++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (char === '"' || char === "'") {
|
|
197
|
+
quote = char;
|
|
198
|
+
index++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (char === '[')
|
|
202
|
+
squareDepth++;
|
|
203
|
+
else if (char === ']')
|
|
204
|
+
squareDepth = Math.max(0, squareDepth - 1);
|
|
205
|
+
else if (char === '(')
|
|
206
|
+
parenDepth++;
|
|
207
|
+
else if (char === ')') {
|
|
208
|
+
if (squareDepth === 0 && parenDepth === 0 && braceDepth === 0)
|
|
209
|
+
break;
|
|
210
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
211
|
+
}
|
|
212
|
+
else if (char === '{')
|
|
213
|
+
braceDepth++;
|
|
214
|
+
else if (char === '}')
|
|
215
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
216
|
+
else if (char === ',' && squareDepth === 0 && parenDepth === 0 && braceDepth === 0) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
index++;
|
|
220
|
+
}
|
|
221
|
+
const model = decoratorText.slice(start, index).trim();
|
|
222
|
+
if (!model || model === 'None')
|
|
223
|
+
return undefined;
|
|
224
|
+
return model;
|
|
225
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ConceptNode } from '@kernlang/core';
|
|
2
|
+
import type Parser from 'tree-sitter';
|
|
3
|
+
export declare function extractErrorRaise(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void;
|
|
4
|
+
export declare function extractErrorHandle(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void;
|
|
5
|
+
export declare function extractPythonHttpExceptionStatusCodes(text: string): readonly number[] | undefined;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { conceptId } from '@kernlang/core';
|
|
2
|
+
import { getContainerId, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
|
|
3
|
+
import { PY_API_ERROR_STATUS_CODES } from '../signatures.js';
|
|
4
|
+
export function extractErrorRaise(root, source, filePath, nodes) {
|
|
5
|
+
// raise statements
|
|
6
|
+
walkNodes(root, 'raise_statement', (node) => {
|
|
7
|
+
const errorType = extractRaiseType(node);
|
|
8
|
+
nodes.push({
|
|
9
|
+
id: conceptId(filePath, 'error_raise', node.startIndex),
|
|
10
|
+
kind: 'error_raise',
|
|
11
|
+
primarySpan: nodeSpan(filePath, node),
|
|
12
|
+
evidence: nodeText(source, node, 100),
|
|
13
|
+
confidence: 1.0,
|
|
14
|
+
language: 'py',
|
|
15
|
+
containerId: getContainerId(node, filePath),
|
|
16
|
+
payload: {
|
|
17
|
+
kind: 'error_raise',
|
|
18
|
+
subtype: 'throw', // Python raise ≡ throw
|
|
19
|
+
errorType,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function extractErrorHandle(root, source, filePath, nodes) {
|
|
25
|
+
// except clauses
|
|
26
|
+
walkNodes(root, 'except_clause', (node) => {
|
|
27
|
+
const block = node.children.find((c) => c.type === 'block');
|
|
28
|
+
const disposition = classifyPythonDisposition(block, source);
|
|
29
|
+
const errorVar = extractExceptVar(node);
|
|
30
|
+
nodes.push({
|
|
31
|
+
id: conceptId(filePath, 'error_handle', node.startIndex),
|
|
32
|
+
kind: 'error_handle',
|
|
33
|
+
primarySpan: nodeSpan(filePath, node),
|
|
34
|
+
evidence: nodeText(source, node, 150),
|
|
35
|
+
confidence: disposition.confidence,
|
|
36
|
+
language: 'py',
|
|
37
|
+
containerId: getContainerId(node, filePath),
|
|
38
|
+
payload: {
|
|
39
|
+
kind: 'error_handle',
|
|
40
|
+
disposition: disposition.type,
|
|
41
|
+
errorVariable: errorVar,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function classifyPythonDisposition(block, source) {
|
|
47
|
+
if (!block)
|
|
48
|
+
return { type: 'ignored', confidence: 1.0 };
|
|
49
|
+
const children = block.namedChildren;
|
|
50
|
+
// except: pass → ignored
|
|
51
|
+
if (children.length === 1 && children[0].type === 'pass_statement') {
|
|
52
|
+
return { type: 'ignored', confidence: 1.0 };
|
|
53
|
+
}
|
|
54
|
+
// except: ... (ellipsis) → ignored
|
|
55
|
+
if (children.length === 1 && children[0].type === 'expression_statement') {
|
|
56
|
+
const text = source.substring(children[0].startIndex, children[0].endIndex).trim();
|
|
57
|
+
if (text === '...')
|
|
58
|
+
return { type: 'ignored', confidence: 1.0 };
|
|
59
|
+
}
|
|
60
|
+
// Empty block
|
|
61
|
+
if (children.length === 0) {
|
|
62
|
+
return { type: 'ignored', confidence: 1.0 };
|
|
63
|
+
}
|
|
64
|
+
const bodyText = source.substring(block.startIndex, block.endIndex);
|
|
65
|
+
// raise → rethrown or wrapped
|
|
66
|
+
if (bodyText.includes('raise')) {
|
|
67
|
+
// bare `raise` → rethrown
|
|
68
|
+
if (/\braise\s*$|\braise\s*\n/m.test(bodyText)) {
|
|
69
|
+
return { type: 'rethrown', confidence: 0.95 };
|
|
70
|
+
}
|
|
71
|
+
return { type: 'wrapped', confidence: 0.9 };
|
|
72
|
+
}
|
|
73
|
+
// return → returned
|
|
74
|
+
if (bodyText.includes('return')) {
|
|
75
|
+
return { type: 'returned', confidence: 0.85 };
|
|
76
|
+
}
|
|
77
|
+
// logging
|
|
78
|
+
if (/\b(logging|logger|log|print)\b/.test(bodyText)) {
|
|
79
|
+
if (children.length === 1)
|
|
80
|
+
return { type: 'logged', confidence: 0.9 };
|
|
81
|
+
return { type: 'logged', confidence: 0.7 };
|
|
82
|
+
}
|
|
83
|
+
return { type: 'wrapped', confidence: 0.5 };
|
|
84
|
+
}
|
|
85
|
+
function extractRaiseType(node) {
|
|
86
|
+
// raise ValueError("...") → "ValueError"
|
|
87
|
+
const callNode = node.namedChildren.find((c) => c.type === 'call');
|
|
88
|
+
if (callNode) {
|
|
89
|
+
const func = callNode.childForFieldName('function');
|
|
90
|
+
if (func)
|
|
91
|
+
return func.text;
|
|
92
|
+
}
|
|
93
|
+
// raise ValueError → just identifier
|
|
94
|
+
const ident = node.namedChildren.find((c) => c.type === 'identifier');
|
|
95
|
+
if (ident)
|
|
96
|
+
return ident.text;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
function extractExceptVar(node) {
|
|
100
|
+
// except Exception as e → "e"
|
|
101
|
+
for (const child of node.children) {
|
|
102
|
+
if (child.type === 'as_pattern') {
|
|
103
|
+
const alias = child.childForFieldName('alias');
|
|
104
|
+
if (alias)
|
|
105
|
+
return alias.text;
|
|
106
|
+
}
|
|
107
|
+
// Also try direct identifier after 'as'
|
|
108
|
+
if (child.type === 'identifier' && child.previousSibling?.text === 'as') {
|
|
109
|
+
return child.text;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
export function extractPythonHttpExceptionStatusCodes(text) {
|
|
115
|
+
const codes = new Set();
|
|
116
|
+
const keywordRe = /HTTPException\s*\([^)]*status_code\s*=\s*(\d{3})/g;
|
|
117
|
+
for (const match of text.matchAll(keywordRe)) {
|
|
118
|
+
const code = Number(match[1]);
|
|
119
|
+
if (PY_API_ERROR_STATUS_CODES.has(code))
|
|
120
|
+
codes.add(code);
|
|
121
|
+
}
|
|
122
|
+
const positionalRe = /HTTPException\s*\(\s*(\d{3})/g;
|
|
123
|
+
for (const match of text.matchAll(positionalRe)) {
|
|
124
|
+
const code = Number(match[1]);
|
|
125
|
+
if (PY_API_ERROR_STATUS_CODES.has(code))
|
|
126
|
+
codes.add(code);
|
|
127
|
+
}
|
|
128
|
+
return codes.size > 0 ? Array.from(codes).sort((a, b) => a - b) : undefined;
|
|
129
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { PY_CURSOR_ANCHORS, PY_OFFSET_ANCHORS, PY_PAGE_ANCHORS } from '../signatures.js';
|
|
2
|
+
// Iterates the route handler's parameters and classifies each by name (or
|
|
3
|
+
// `Query(alias=...)` literal alias when present) against page/offset/cursor
|
|
4
|
+
// anchor sets. Returns:
|
|
5
|
+
// - `none` / resolved=true — handler reads no anchor params (and no opaque
|
|
6
|
+
// paths to query data).
|
|
7
|
+
// - `page` / `offset` / `cursor` / resolved=true — handler reads exactly
|
|
8
|
+
// one family.
|
|
9
|
+
// - `mixed` / resolved=true — handler reads multiple families.
|
|
10
|
+
// - `undefined` / resolved=false — handler has a `Request` parameter,
|
|
11
|
+
// `**kwargs`, or a `Query(alias=<dynamic>)` we can't statically resolve.
|
|
12
|
+
export function extractFastApiPaginationStrategy(fnDef, source) {
|
|
13
|
+
const paramsNode = fnDef.childForFieldName('parameters');
|
|
14
|
+
if (!paramsNode)
|
|
15
|
+
return { strategy: 'none', resolved: true };
|
|
16
|
+
const families = new Set();
|
|
17
|
+
let sawOpaque = false;
|
|
18
|
+
for (const child of paramsNode.namedChildren) {
|
|
19
|
+
// **kwargs — handler may read any query key dynamically; opaque.
|
|
20
|
+
if (child.type === 'dictionary_splat_pattern') {
|
|
21
|
+
sawOpaque = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
// *args — positional spread, irrelevant for query keys but rare in
|
|
25
|
+
// FastAPI handlers; keep silent.
|
|
26
|
+
if (child.type === 'list_splat_pattern')
|
|
27
|
+
continue;
|
|
28
|
+
// Drop typing wrappers to find the param identifier.
|
|
29
|
+
const paramName = extractParamName(child);
|
|
30
|
+
if (!paramName)
|
|
31
|
+
continue;
|
|
32
|
+
// `request: Request` — handler may call `request.query_params.get(...)`
|
|
33
|
+
// arbitrarily; mark opaque.
|
|
34
|
+
const typeText = extractParamTypeText(child, source);
|
|
35
|
+
if (typeText && /\bRequest\b/.test(typeText)) {
|
|
36
|
+
sawOpaque = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Default-value AND type expression both can carry a `Query(alias="...")`
|
|
40
|
+
// call. Modern FastAPI (≥0.95) puts the call inside the type annotation
|
|
41
|
+
// via `Annotated[int, Query(alias="page")]` (Gemini/OpenCode impl-review).
|
|
42
|
+
// Older / classic syntax puts it in the default: `Query(0, alias="page")`.
|
|
43
|
+
// Check both — default-value form takes precedence when both are present.
|
|
44
|
+
const defaultText = extractParamDefaultText(child, source);
|
|
45
|
+
const aliasFromDefault = extractQueryAlias(defaultText);
|
|
46
|
+
const aliasFromType = aliasFromDefault.alias === undefined ? extractQueryAlias(typeText) : aliasFromDefault;
|
|
47
|
+
let key = paramName;
|
|
48
|
+
if (aliasFromDefault.opaque || aliasFromType.opaque) {
|
|
49
|
+
sawOpaque = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (aliasFromDefault.alias)
|
|
53
|
+
key = aliasFromDefault.alias;
|
|
54
|
+
else if (aliasFromType.alias)
|
|
55
|
+
key = aliasFromType.alias;
|
|
56
|
+
const family = classifyPyAnchor(key);
|
|
57
|
+
if (family)
|
|
58
|
+
families.add(family);
|
|
59
|
+
}
|
|
60
|
+
if (sawOpaque)
|
|
61
|
+
return { strategy: undefined, resolved: false };
|
|
62
|
+
if (families.size === 0)
|
|
63
|
+
return { strategy: 'none', resolved: true };
|
|
64
|
+
if (families.size === 1)
|
|
65
|
+
return { strategy: [...families][0], resolved: true };
|
|
66
|
+
return { strategy: 'mixed', resolved: true };
|
|
67
|
+
}
|
|
68
|
+
function extractParamName(node) {
|
|
69
|
+
if (node.type === 'identifier')
|
|
70
|
+
return node.text;
|
|
71
|
+
if (node.type === 'typed_parameter' || node.type === 'typed_default_parameter' || node.type === 'default_parameter') {
|
|
72
|
+
const nameChild = node.childForFieldName('name') ?? node.namedChildren.find((c) => c.type === 'identifier');
|
|
73
|
+
if (nameChild)
|
|
74
|
+
return nameChild.text;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
function extractParamTypeText(node, source) {
|
|
79
|
+
if (node.type !== 'typed_parameter' && node.type !== 'typed_default_parameter')
|
|
80
|
+
return undefined;
|
|
81
|
+
const typeChild = node.childForFieldName('type');
|
|
82
|
+
if (typeChild)
|
|
83
|
+
return source.substring(typeChild.startIndex, typeChild.endIndex);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
function extractParamDefaultText(node, source) {
|
|
87
|
+
if (node.type !== 'default_parameter' && node.type !== 'typed_default_parameter')
|
|
88
|
+
return undefined;
|
|
89
|
+
const valueChild = node.childForFieldName('value');
|
|
90
|
+
if (valueChild)
|
|
91
|
+
return source.substring(valueChild.startIndex, valueChild.endIndex);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
function classifyPyAnchor(key) {
|
|
95
|
+
if (PY_PAGE_ANCHORS.has(key))
|
|
96
|
+
return 'page';
|
|
97
|
+
if (PY_OFFSET_ANCHORS.has(key))
|
|
98
|
+
return 'offset';
|
|
99
|
+
if (PY_CURSOR_ANCHORS.has(key))
|
|
100
|
+
return 'cursor';
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
/** Extract a `Query(..., alias="...")` literal alias from a parameter's
|
|
104
|
+
* default-value or type-annotation text. Used to support both classic
|
|
105
|
+
* (`x = Query(0, alias="p")`) and modern (`x: Annotated[int, Query(alias="p")]`)
|
|
106
|
+
* FastAPI patterns. Returns `{alias?, opaque}` where `opaque=true` indicates
|
|
107
|
+
* a `Query(alias=<non-literal>)` we cannot statically resolve. */
|
|
108
|
+
function extractQueryAlias(text) {
|
|
109
|
+
if (!text)
|
|
110
|
+
return { opaque: false };
|
|
111
|
+
if (!/\bQuery\s*\(/.test(text))
|
|
112
|
+
return { opaque: false };
|
|
113
|
+
const aliasMatch = text.match(/\balias\s*=\s*['"]([^'"]+)['"]/);
|
|
114
|
+
if (aliasMatch)
|
|
115
|
+
return { alias: aliasMatch[1], opaque: false };
|
|
116
|
+
if (/\balias\s*=/.test(text))
|
|
117
|
+
return { opaque: true };
|
|
118
|
+
return { opaque: false };
|
|
119
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
export declare function extractFastApiSuccessStatusCodes(decText: string, fnDef: Parser.SyntaxNode, source: string): {
|
|
3
|
+
codes: readonly number[] | undefined;
|
|
4
|
+
resolved: boolean;
|
|
5
|
+
};
|
|
6
|
+
export declare function parseFastApiStatusValue(val: string): number | undefined;
|