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