@kernlang/review-python 3.3.4 → 3.3.6
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.js +203 -27
- package/package.json +3 -3
- package/src/mapper.ts +197 -27
- package/tests/route-entrypoints.test.ts +99 -0
- package/tsconfig.json +1 -0
package/dist/mapper.js
CHANGED
|
@@ -253,8 +253,15 @@ function extractEffects(root, source, filePath, nodes) {
|
|
|
253
253
|
}
|
|
254
254
|
// ── entrypoint ──────────────────────────────────────────────────────────
|
|
255
255
|
function extractEntrypoints(root, source, filePath, nodes) {
|
|
256
|
-
//
|
|
257
|
-
//
|
|
256
|
+
// FastAPI / Flask route decorators.
|
|
257
|
+
//
|
|
258
|
+
// The route *path* (e.g. `/current`) is what cross-stack rules need to
|
|
259
|
+
// match against — not the Python function name. Prior to 2026-04-21 this
|
|
260
|
+
// emitted the function name, which `collectRoutes` then silently dropped
|
|
261
|
+
// (it filters on paths starting with `/`). The FastAPI router-prefix join
|
|
262
|
+
// in `cross-stack-utils.collectRoutes` also needs `routerName` so it can
|
|
263
|
+
// pair per-file routes with the `include_router(prefix=…)` call that
|
|
264
|
+
// mounts them.
|
|
258
265
|
walkNodes(root, 'decorated_definition', (node) => {
|
|
259
266
|
const fnDef = node.children.find((c) => c.type === 'function_definition');
|
|
260
267
|
if (!fnDef)
|
|
@@ -263,31 +270,91 @@ function extractEntrypoints(root, source, filePath, nodes) {
|
|
|
263
270
|
if (child.type !== 'decorator')
|
|
264
271
|
continue;
|
|
265
272
|
const decText = source.substring(child.startIndex, child.endIndex);
|
|
266
|
-
const routeMatch = decText.match(/@(
|
|
267
|
-
if (routeMatch)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
273
|
+
const routeMatch = decText.match(/@(\w+)\.(route|get|post|put|delete|patch)\s*\(/);
|
|
274
|
+
if (!routeMatch)
|
|
275
|
+
continue;
|
|
276
|
+
const routerName = routeMatch[1];
|
|
277
|
+
const method = routeMatch[2].toUpperCase();
|
|
278
|
+
const pathMatch = decText.match(/['"]([^'"]+)['"]/);
|
|
279
|
+
const routePath = pathMatch?.[1];
|
|
280
|
+
// Only surface the decorator as a route when we could extract a URL
|
|
281
|
+
// path literal. Mystery decorators with only kwargs (e.g. `@app.get`
|
|
282
|
+
// stub) are noise — skip them instead of filling `name` with the
|
|
283
|
+
// function name, which cross-stack routes treat as invalid.
|
|
284
|
+
if (!routePath?.startsWith('/'))
|
|
285
|
+
continue;
|
|
286
|
+
const responseModel = extractResponseModel(decText);
|
|
287
|
+
const routeContainerId = getSelfContainerId(fnDef, filePath);
|
|
288
|
+
nodes.push({
|
|
289
|
+
id: conceptId(filePath, 'entrypoint', child.startIndex),
|
|
290
|
+
kind: 'entrypoint',
|
|
291
|
+
primarySpan: nodeSpan(filePath, child),
|
|
292
|
+
evidence: nodeText(source, child, 100),
|
|
293
|
+
confidence: 1.0,
|
|
294
|
+
language: 'py',
|
|
295
|
+
containerId: routeContainerId,
|
|
296
|
+
payload: {
|
|
274
297
|
kind: 'entrypoint',
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
name: nameNode ? nameNode.text : pathMatch?.[1] || 'anonymous',
|
|
284
|
-
httpMethod: method === 'ROUTE' ? undefined : method,
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
}
|
|
298
|
+
subtype: 'route',
|
|
299
|
+
name: routePath,
|
|
300
|
+
httpMethod: method === 'ROUTE' ? undefined : method,
|
|
301
|
+
responseModel,
|
|
302
|
+
isAsync: isAsyncFunction(fnDef),
|
|
303
|
+
routerName,
|
|
304
|
+
},
|
|
305
|
+
});
|
|
288
306
|
}
|
|
289
307
|
});
|
|
290
|
-
//
|
|
308
|
+
// FastAPI `app.include_router(<module>.<router>, prefix="/api/x")`.
|
|
309
|
+
//
|
|
310
|
+
// Emitted as a route-mount concept so `collectRoutes` can join it with
|
|
311
|
+
// the per-file route nodes: a route declared on `router` in
|
|
312
|
+
// `app/api/nutrition_goals.py` and mounted in `main.py` with
|
|
313
|
+
// `app.include_router(nutrition_goals.router, prefix="/api/nutrition-goals")`
|
|
314
|
+
// should resolve to the full URL `/api/nutrition-goals/<path>`.
|
|
315
|
+
walkNodes(root, 'call', (node) => {
|
|
316
|
+
const fn = node.childForFieldName('function');
|
|
317
|
+
if (!fn)
|
|
318
|
+
return;
|
|
319
|
+
const fnText = source.substring(fn.startIndex, fn.endIndex);
|
|
320
|
+
if (!/\.include_router$/.test(fnText))
|
|
321
|
+
return;
|
|
322
|
+
const argsNode = node.childForFieldName('arguments');
|
|
323
|
+
if (!argsNode)
|
|
324
|
+
return;
|
|
325
|
+
const argsText = source.substring(argsNode.startIndex, argsNode.endIndex);
|
|
326
|
+
// First positional arg is the router. Common shapes:
|
|
327
|
+
// include_router(router) — local identifier
|
|
328
|
+
// include_router(nutrition_goals.router) — imported-module attribute
|
|
329
|
+
// include_router(auth_router) — aliased local identifier
|
|
330
|
+
const posMatch = argsText.match(/^\(\s*([A-Za-z_][\w.]*)/);
|
|
331
|
+
if (!posMatch)
|
|
332
|
+
return;
|
|
333
|
+
const routerRef = posMatch[1];
|
|
334
|
+
const dot = routerRef.lastIndexOf('.');
|
|
335
|
+
const sourceModule = dot === -1 ? undefined : routerRef.slice(0, dot);
|
|
336
|
+
const routerName = dot === -1 ? routerRef : routerRef.slice(dot + 1);
|
|
337
|
+
const prefixMatch = argsText.match(/prefix\s*=\s*['"]([^'"]*)['"]/);
|
|
338
|
+
// Prefix defaults to '' when omitted — still valid (the route keeps its
|
|
339
|
+
// declared path as-is), so emit the mount either way.
|
|
340
|
+
const prefix = prefixMatch?.[1] ?? '';
|
|
341
|
+
nodes.push({
|
|
342
|
+
id: conceptId(filePath, 'entrypoint', node.startIndex),
|
|
343
|
+
kind: 'entrypoint',
|
|
344
|
+
primarySpan: nodeSpan(filePath, node),
|
|
345
|
+
evidence: nodeText(source, node, 120),
|
|
346
|
+
confidence: 0.95,
|
|
347
|
+
language: 'py',
|
|
348
|
+
payload: {
|
|
349
|
+
kind: 'entrypoint',
|
|
350
|
+
subtype: 'route-mount',
|
|
351
|
+
name: prefix,
|
|
352
|
+
routerName,
|
|
353
|
+
sourceModule,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
// `if __name__ == '__main__':`
|
|
291
358
|
walkNodes(root, 'if_statement', (node) => {
|
|
292
359
|
const condition = node.childForFieldName('condition');
|
|
293
360
|
if (condition?.text.includes('__name__') && condition.text.includes('__main__')) {
|
|
@@ -349,7 +416,42 @@ function extractGuards(root, source, filePath, nodes) {
|
|
|
349
416
|
});
|
|
350
417
|
}
|
|
351
418
|
});
|
|
352
|
-
// 3.
|
|
419
|
+
// 3. FastAPI `Depends(...)` injection — route handler parameter with a
|
|
420
|
+
// `Depends` default is the idiomatic FastAPI auth/validation guard.
|
|
421
|
+
// Example:
|
|
422
|
+
// @router.get("/me")
|
|
423
|
+
// def me(user: User = Depends(get_current_user)):
|
|
424
|
+
// Classified by the dependency function name:
|
|
425
|
+
// - `get_current_user` / `current_user` / `require_auth` / `*_user` → 'auth'
|
|
426
|
+
// - `verify_*` / `validate_*` → 'validation'
|
|
427
|
+
// - `rate_limit_*` / `check_rate_limit` → 'rate-limit'
|
|
428
|
+
// - everything else → 'policy'
|
|
429
|
+
// Feeds the `auth-drift` cross-stack rule.
|
|
430
|
+
walkNodes(root, 'default_parameter', (node) => {
|
|
431
|
+
const val = node.childForFieldName('value');
|
|
432
|
+
if (!val || val.type !== 'call')
|
|
433
|
+
return;
|
|
434
|
+
const func = val.childForFieldName('function');
|
|
435
|
+
if (!func || func.text !== 'Depends')
|
|
436
|
+
return;
|
|
437
|
+
const args = val.childForFieldName('arguments');
|
|
438
|
+
if (!args)
|
|
439
|
+
return;
|
|
440
|
+
const posArg = args.namedChildren.find((c) => c.type === 'identifier' || c.type === 'attribute');
|
|
441
|
+
const depName = posArg ? posArg.text : 'Depends';
|
|
442
|
+
const subtype = classifyDependency(depName);
|
|
443
|
+
nodes.push({
|
|
444
|
+
id: conceptId(filePath, 'guard', node.startIndex),
|
|
445
|
+
kind: 'guard',
|
|
446
|
+
primarySpan: nodeSpan(filePath, node),
|
|
447
|
+
evidence: nodeText(source, node, 120),
|
|
448
|
+
confidence: 0.85,
|
|
449
|
+
language: 'py',
|
|
450
|
+
containerId: getContainerId(node, filePath),
|
|
451
|
+
payload: { kind: 'guard', subtype, name: depName },
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
// 4. Early return/raise after auth check: if not request.user: raise/return
|
|
353
455
|
walkNodes(root, 'if_statement', (node) => {
|
|
354
456
|
const cond = node.childForFieldName('condition');
|
|
355
457
|
if (cond && /\b(user|auth|request\.user)\b/.test(cond.text)) {
|
|
@@ -372,6 +474,20 @@ function extractGuards(root, source, filePath, nodes) {
|
|
|
372
474
|
}
|
|
373
475
|
});
|
|
374
476
|
}
|
|
477
|
+
function classifyDependency(depName) {
|
|
478
|
+
// Strip module prefix (`auth.get_current_user` → `get_current_user`) so the
|
|
479
|
+
// heuristic looks at the final identifier where intent usually lives.
|
|
480
|
+
const tail = depName.split('.').pop() ?? depName;
|
|
481
|
+
if (/^(get_current_user|current_user|require_auth|authenticated|is_authenticated)$/i.test(tail))
|
|
482
|
+
return 'auth';
|
|
483
|
+
if (/_user$|^user$|auth/i.test(tail))
|
|
484
|
+
return 'auth';
|
|
485
|
+
if (/^(verify_|validate_)/i.test(tail))
|
|
486
|
+
return 'validation';
|
|
487
|
+
if (/rate_?limit/i.test(tail))
|
|
488
|
+
return 'rate-limit';
|
|
489
|
+
return 'policy';
|
|
490
|
+
}
|
|
375
491
|
// ── state_mutation ───────────────────────────────────────────────────────
|
|
376
492
|
function extractStateMutation(root, source, filePath, nodes) {
|
|
377
493
|
// Track global keyword usage
|
|
@@ -527,6 +643,64 @@ function getContainerId(node, filePath) {
|
|
|
527
643
|
}
|
|
528
644
|
return undefined;
|
|
529
645
|
}
|
|
646
|
+
function getSelfContainerId(node, filePath) {
|
|
647
|
+
if (node.type !== 'function_definition' && node.type !== 'class_definition')
|
|
648
|
+
return undefined;
|
|
649
|
+
const nameNode = node.childForFieldName('name');
|
|
650
|
+
const name = nameNode ? nameNode.text : 'anonymous';
|
|
651
|
+
return `${filePath}#fn:${name}@${node.startIndex}`;
|
|
652
|
+
}
|
|
653
|
+
function extractResponseModel(decoratorText) {
|
|
654
|
+
const match = decoratorText.match(/\bresponse_model\s*=/);
|
|
655
|
+
if (!match || match.index === undefined)
|
|
656
|
+
return undefined;
|
|
657
|
+
let index = match.index + match[0].length;
|
|
658
|
+
while (/\s/.test(decoratorText[index] ?? ''))
|
|
659
|
+
index++;
|
|
660
|
+
const start = index;
|
|
661
|
+
let squareDepth = 0;
|
|
662
|
+
let parenDepth = 0;
|
|
663
|
+
let braceDepth = 0;
|
|
664
|
+
let quote;
|
|
665
|
+
while (index < decoratorText.length) {
|
|
666
|
+
const char = decoratorText[index];
|
|
667
|
+
const prev = decoratorText[index - 1];
|
|
668
|
+
if (quote) {
|
|
669
|
+
if (char === quote && prev !== '\\')
|
|
670
|
+
quote = undefined;
|
|
671
|
+
index++;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (char === '"' || char === "'") {
|
|
675
|
+
quote = char;
|
|
676
|
+
index++;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (char === '[')
|
|
680
|
+
squareDepth++;
|
|
681
|
+
else if (char === ']')
|
|
682
|
+
squareDepth = Math.max(0, squareDepth - 1);
|
|
683
|
+
else if (char === '(')
|
|
684
|
+
parenDepth++;
|
|
685
|
+
else if (char === ')') {
|
|
686
|
+
if (squareDepth === 0 && parenDepth === 0 && braceDepth === 0)
|
|
687
|
+
break;
|
|
688
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
689
|
+
}
|
|
690
|
+
else if (char === '{')
|
|
691
|
+
braceDepth++;
|
|
692
|
+
else if (char === '}')
|
|
693
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
694
|
+
else if (char === ',' && squareDepth === 0 && parenDepth === 0 && braceDepth === 0) {
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
index++;
|
|
698
|
+
}
|
|
699
|
+
const model = decoratorText.slice(start, index).trim();
|
|
700
|
+
if (!model || model === 'None')
|
|
701
|
+
return undefined;
|
|
702
|
+
return model;
|
|
703
|
+
}
|
|
530
704
|
function extractRaiseType(node) {
|
|
531
705
|
// raise ValueError("...") → "ValueError"
|
|
532
706
|
const callNode = node.namedChildren.find((c) => c.type === 'call');
|
|
@@ -560,10 +734,12 @@ function isInAsyncDef(node) {
|
|
|
560
734
|
let parent = node.parent;
|
|
561
735
|
while (parent) {
|
|
562
736
|
if (parent.type === 'function_definition') {
|
|
563
|
-
|
|
564
|
-
return parent.children.some((c) => c.type === 'async');
|
|
737
|
+
return isAsyncFunction(parent);
|
|
565
738
|
}
|
|
566
739
|
parent = parent.parent;
|
|
567
740
|
}
|
|
568
741
|
return false;
|
|
569
742
|
}
|
|
743
|
+
function isAsyncFunction(node) {
|
|
744
|
+
return node.children.some((c) => c.type === 'async');
|
|
745
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernlang/review-python",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.6",
|
|
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/review": "3.3.
|
|
12
|
-
"@kernlang/core": "3.3.
|
|
11
|
+
"@kernlang/review": "3.3.6",
|
|
12
|
+
"@kernlang/core": "3.3.6"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"ts-morph": "^28.0.0",
|
package/src/mapper.ts
CHANGED
|
@@ -295,8 +295,15 @@ function extractEffects(root: Parser.SyntaxNode, source: string, filePath: strin
|
|
|
295
295
|
// ── entrypoint ──────────────────────────────────────────────────────────
|
|
296
296
|
|
|
297
297
|
function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void {
|
|
298
|
-
//
|
|
299
|
-
//
|
|
298
|
+
// FastAPI / Flask route decorators.
|
|
299
|
+
//
|
|
300
|
+
// The route *path* (e.g. `/current`) is what cross-stack rules need to
|
|
301
|
+
// match against — not the Python function name. Prior to 2026-04-21 this
|
|
302
|
+
// emitted the function name, which `collectRoutes` then silently dropped
|
|
303
|
+
// (it filters on paths starting with `/`). The FastAPI router-prefix join
|
|
304
|
+
// in `cross-stack-utils.collectRoutes` also needs `routerName` so it can
|
|
305
|
+
// pair per-file routes with the `include_router(prefix=…)` call that
|
|
306
|
+
// mounts them.
|
|
300
307
|
walkNodes(root, 'decorated_definition', (node) => {
|
|
301
308
|
const fnDef = node.children.find((c) => c.type === 'function_definition');
|
|
302
309
|
if (!fnDef) return;
|
|
@@ -305,33 +312,93 @@ function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: s
|
|
|
305
312
|
if (child.type !== 'decorator') continue;
|
|
306
313
|
const decText = source.substring(child.startIndex, child.endIndex);
|
|
307
314
|
|
|
308
|
-
const routeMatch = decText.match(/@(
|
|
309
|
-
if (routeMatch)
|
|
310
|
-
const method = routeMatch[2].toUpperCase();
|
|
311
|
-
const nameNode = fnDef.childForFieldName('name');
|
|
312
|
-
// Try to extract path from decorator args
|
|
313
|
-
const pathMatch = decText.match(/['"]([^'"]+)['"]/);
|
|
315
|
+
const routeMatch = decText.match(/@(\w+)\.(route|get|post|put|delete|patch)\s*\(/);
|
|
316
|
+
if (!routeMatch) continue;
|
|
314
317
|
|
|
315
|
-
|
|
316
|
-
|
|
318
|
+
const routerName = routeMatch[1];
|
|
319
|
+
const method = routeMatch[2].toUpperCase();
|
|
320
|
+
const pathMatch = decText.match(/['"]([^'"]+)['"]/);
|
|
321
|
+
const routePath = pathMatch?.[1];
|
|
322
|
+
// Only surface the decorator as a route when we could extract a URL
|
|
323
|
+
// path literal. Mystery decorators with only kwargs (e.g. `@app.get`
|
|
324
|
+
// stub) are noise — skip them instead of filling `name` with the
|
|
325
|
+
// function name, which cross-stack routes treat as invalid.
|
|
326
|
+
if (!routePath?.startsWith('/')) continue;
|
|
327
|
+
|
|
328
|
+
const responseModel = extractResponseModel(decText);
|
|
329
|
+
const routeContainerId = getSelfContainerId(fnDef, filePath);
|
|
330
|
+
|
|
331
|
+
nodes.push({
|
|
332
|
+
id: conceptId(filePath, 'entrypoint', child.startIndex),
|
|
333
|
+
kind: 'entrypoint',
|
|
334
|
+
primarySpan: nodeSpan(filePath, child),
|
|
335
|
+
evidence: nodeText(source, child, 100),
|
|
336
|
+
confidence: 1.0,
|
|
337
|
+
language: 'py',
|
|
338
|
+
containerId: routeContainerId,
|
|
339
|
+
payload: {
|
|
317
340
|
kind: 'entrypoint',
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
name: nameNode ? nameNode.text : pathMatch?.[1] || 'anonymous',
|
|
327
|
-
httpMethod: method === 'ROUTE' ? undefined : method,
|
|
328
|
-
},
|
|
329
|
-
});
|
|
330
|
-
}
|
|
341
|
+
subtype: 'route',
|
|
342
|
+
name: routePath,
|
|
343
|
+
httpMethod: method === 'ROUTE' ? undefined : method,
|
|
344
|
+
responseModel,
|
|
345
|
+
isAsync: isAsyncFunction(fnDef),
|
|
346
|
+
routerName,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
331
349
|
}
|
|
332
350
|
});
|
|
333
351
|
|
|
334
|
-
//
|
|
352
|
+
// FastAPI `app.include_router(<module>.<router>, prefix="/api/x")`.
|
|
353
|
+
//
|
|
354
|
+
// Emitted as a route-mount concept so `collectRoutes` can join it with
|
|
355
|
+
// the per-file route nodes: a route declared on `router` in
|
|
356
|
+
// `app/api/nutrition_goals.py` and mounted in `main.py` with
|
|
357
|
+
// `app.include_router(nutrition_goals.router, prefix="/api/nutrition-goals")`
|
|
358
|
+
// should resolve to the full URL `/api/nutrition-goals/<path>`.
|
|
359
|
+
walkNodes(root, 'call', (node) => {
|
|
360
|
+
const fn = node.childForFieldName('function');
|
|
361
|
+
if (!fn) return;
|
|
362
|
+
const fnText = source.substring(fn.startIndex, fn.endIndex);
|
|
363
|
+
if (!/\.include_router$/.test(fnText)) return;
|
|
364
|
+
const argsNode = node.childForFieldName('arguments');
|
|
365
|
+
if (!argsNode) return;
|
|
366
|
+
const argsText = source.substring(argsNode.startIndex, argsNode.endIndex);
|
|
367
|
+
|
|
368
|
+
// First positional arg is the router. Common shapes:
|
|
369
|
+
// include_router(router) — local identifier
|
|
370
|
+
// include_router(nutrition_goals.router) — imported-module attribute
|
|
371
|
+
// include_router(auth_router) — aliased local identifier
|
|
372
|
+
const posMatch = argsText.match(/^\(\s*([A-Za-z_][\w.]*)/);
|
|
373
|
+
if (!posMatch) return;
|
|
374
|
+
const routerRef = posMatch[1];
|
|
375
|
+
const dot = routerRef.lastIndexOf('.');
|
|
376
|
+
const sourceModule = dot === -1 ? undefined : routerRef.slice(0, dot);
|
|
377
|
+
const routerName = dot === -1 ? routerRef : routerRef.slice(dot + 1);
|
|
378
|
+
|
|
379
|
+
const prefixMatch = argsText.match(/prefix\s*=\s*['"]([^'"]*)['"]/);
|
|
380
|
+
// Prefix defaults to '' when omitted — still valid (the route keeps its
|
|
381
|
+
// declared path as-is), so emit the mount either way.
|
|
382
|
+
const prefix = prefixMatch?.[1] ?? '';
|
|
383
|
+
|
|
384
|
+
nodes.push({
|
|
385
|
+
id: conceptId(filePath, 'entrypoint', node.startIndex),
|
|
386
|
+
kind: 'entrypoint',
|
|
387
|
+
primarySpan: nodeSpan(filePath, node),
|
|
388
|
+
evidence: nodeText(source, node, 120),
|
|
389
|
+
confidence: 0.95,
|
|
390
|
+
language: 'py',
|
|
391
|
+
payload: {
|
|
392
|
+
kind: 'entrypoint',
|
|
393
|
+
subtype: 'route-mount',
|
|
394
|
+
name: prefix,
|
|
395
|
+
routerName,
|
|
396
|
+
sourceModule,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// `if __name__ == '__main__':`
|
|
335
402
|
walkNodes(root, 'if_statement', (node) => {
|
|
336
403
|
const condition = node.childForFieldName('condition');
|
|
337
404
|
if (condition?.text.includes('__name__') && condition.text.includes('__main__')) {
|
|
@@ -396,7 +463,40 @@ function extractGuards(root: Parser.SyntaxNode, source: string, filePath: string
|
|
|
396
463
|
}
|
|
397
464
|
});
|
|
398
465
|
|
|
399
|
-
// 3.
|
|
466
|
+
// 3. FastAPI `Depends(...)` injection — route handler parameter with a
|
|
467
|
+
// `Depends` default is the idiomatic FastAPI auth/validation guard.
|
|
468
|
+
// Example:
|
|
469
|
+
// @router.get("/me")
|
|
470
|
+
// def me(user: User = Depends(get_current_user)):
|
|
471
|
+
// Classified by the dependency function name:
|
|
472
|
+
// - `get_current_user` / `current_user` / `require_auth` / `*_user` → 'auth'
|
|
473
|
+
// - `verify_*` / `validate_*` → 'validation'
|
|
474
|
+
// - `rate_limit_*` / `check_rate_limit` → 'rate-limit'
|
|
475
|
+
// - everything else → 'policy'
|
|
476
|
+
// Feeds the `auth-drift` cross-stack rule.
|
|
477
|
+
walkNodes(root, 'default_parameter', (node) => {
|
|
478
|
+
const val = node.childForFieldName('value');
|
|
479
|
+
if (!val || val.type !== 'call') return;
|
|
480
|
+
const func = val.childForFieldName('function');
|
|
481
|
+
if (!func || func.text !== 'Depends') return;
|
|
482
|
+
const args = val.childForFieldName('arguments');
|
|
483
|
+
if (!args) return;
|
|
484
|
+
const posArg = args.namedChildren.find((c) => c.type === 'identifier' || c.type === 'attribute');
|
|
485
|
+
const depName = posArg ? posArg.text : 'Depends';
|
|
486
|
+
const subtype = classifyDependency(depName);
|
|
487
|
+
nodes.push({
|
|
488
|
+
id: conceptId(filePath, 'guard', node.startIndex),
|
|
489
|
+
kind: 'guard',
|
|
490
|
+
primarySpan: nodeSpan(filePath, node),
|
|
491
|
+
evidence: nodeText(source, node, 120),
|
|
492
|
+
confidence: 0.85,
|
|
493
|
+
language: 'py',
|
|
494
|
+
containerId: getContainerId(node, filePath),
|
|
495
|
+
payload: { kind: 'guard', subtype, name: depName },
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// 4. Early return/raise after auth check: if not request.user: raise/return
|
|
400
500
|
walkNodes(root, 'if_statement', (node) => {
|
|
401
501
|
const cond = node.childForFieldName('condition');
|
|
402
502
|
if (cond && /\b(user|auth|request\.user)\b/.test(cond.text)) {
|
|
@@ -420,6 +520,17 @@ function extractGuards(root: Parser.SyntaxNode, source: string, filePath: string
|
|
|
420
520
|
});
|
|
421
521
|
}
|
|
422
522
|
|
|
523
|
+
function classifyDependency(depName: string): 'auth' | 'validation' | 'rate-limit' | 'policy' {
|
|
524
|
+
// Strip module prefix (`auth.get_current_user` → `get_current_user`) so the
|
|
525
|
+
// heuristic looks at the final identifier where intent usually lives.
|
|
526
|
+
const tail = depName.split('.').pop() ?? depName;
|
|
527
|
+
if (/^(get_current_user|current_user|require_auth|authenticated|is_authenticated)$/i.test(tail)) return 'auth';
|
|
528
|
+
if (/_user$|^user$|auth/i.test(tail)) return 'auth';
|
|
529
|
+
if (/^(verify_|validate_)/i.test(tail)) return 'validation';
|
|
530
|
+
if (/rate_?limit/i.test(tail)) return 'rate-limit';
|
|
531
|
+
return 'policy';
|
|
532
|
+
}
|
|
533
|
+
|
|
423
534
|
// ── state_mutation ───────────────────────────────────────────────────────
|
|
424
535
|
|
|
425
536
|
function extractStateMutation(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void {
|
|
@@ -587,6 +698,62 @@ function getContainerId(node: Parser.SyntaxNode, filePath: string): string | und
|
|
|
587
698
|
return undefined;
|
|
588
699
|
}
|
|
589
700
|
|
|
701
|
+
function getSelfContainerId(node: Parser.SyntaxNode, filePath: string): string | undefined {
|
|
702
|
+
if (node.type !== 'function_definition' && node.type !== 'class_definition') return undefined;
|
|
703
|
+
const nameNode = node.childForFieldName('name');
|
|
704
|
+
const name = nameNode ? nameNode.text : 'anonymous';
|
|
705
|
+
return `${filePath}#fn:${name}@${node.startIndex}`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function extractResponseModel(decoratorText: string): string | undefined {
|
|
709
|
+
const match = decoratorText.match(/\bresponse_model\s*=/);
|
|
710
|
+
if (!match || match.index === undefined) return undefined;
|
|
711
|
+
|
|
712
|
+
let index = match.index + match[0].length;
|
|
713
|
+
while (/\s/.test(decoratorText[index] ?? '')) index++;
|
|
714
|
+
|
|
715
|
+
const start = index;
|
|
716
|
+
let squareDepth = 0;
|
|
717
|
+
let parenDepth = 0;
|
|
718
|
+
let braceDepth = 0;
|
|
719
|
+
let quote: string | undefined;
|
|
720
|
+
|
|
721
|
+
while (index < decoratorText.length) {
|
|
722
|
+
const char = decoratorText[index];
|
|
723
|
+
const prev = decoratorText[index - 1];
|
|
724
|
+
|
|
725
|
+
if (quote) {
|
|
726
|
+
if (char === quote && prev !== '\\') quote = undefined;
|
|
727
|
+
index++;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (char === '"' || char === "'") {
|
|
732
|
+
quote = char;
|
|
733
|
+
index++;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (char === '[') squareDepth++;
|
|
738
|
+
else if (char === ']') squareDepth = Math.max(0, squareDepth - 1);
|
|
739
|
+
else if (char === '(') parenDepth++;
|
|
740
|
+
else if (char === ')') {
|
|
741
|
+
if (squareDepth === 0 && parenDepth === 0 && braceDepth === 0) break;
|
|
742
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
743
|
+
} else if (char === '{') braceDepth++;
|
|
744
|
+
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
|
|
745
|
+
else if (char === ',' && squareDepth === 0 && parenDepth === 0 && braceDepth === 0) {
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
index++;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const model = decoratorText.slice(start, index).trim();
|
|
753
|
+
if (!model || model === 'None') return undefined;
|
|
754
|
+
return model;
|
|
755
|
+
}
|
|
756
|
+
|
|
590
757
|
function extractRaiseType(node: Parser.SyntaxNode): string | undefined {
|
|
591
758
|
// raise ValueError("...") → "ValueError"
|
|
592
759
|
const callNode = node.namedChildren.find((c) => c.type === 'call');
|
|
@@ -619,10 +786,13 @@ function isInAsyncDef(node: Parser.SyntaxNode): boolean {
|
|
|
619
786
|
let parent = node.parent;
|
|
620
787
|
while (parent) {
|
|
621
788
|
if (parent.type === 'function_definition') {
|
|
622
|
-
|
|
623
|
-
return parent.children.some((c) => c.type === 'async');
|
|
789
|
+
return isAsyncFunction(parent);
|
|
624
790
|
}
|
|
625
791
|
parent = parent.parent;
|
|
626
792
|
}
|
|
627
793
|
return false;
|
|
628
794
|
}
|
|
795
|
+
|
|
796
|
+
function isAsyncFunction(node: Parser.SyntaxNode): boolean {
|
|
797
|
+
return node.children.some((c) => c.type === 'async');
|
|
798
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
import type { ConceptNode, EntrypointPayload } from '@kernlang/core';
|
|
3
|
+
import { extractPythonConcepts } from '../src/mapper.js';
|
|
4
|
+
|
|
5
|
+
function isEntrypointNode(node: ConceptNode): node is ConceptNode & { payload: EntrypointPayload } {
|
|
6
|
+
return node.kind === 'entrypoint' && node.payload.kind === 'entrypoint';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function routePayloads(source: string) {
|
|
10
|
+
return extractPythonConcepts(source, 'app/api/users.py')
|
|
11
|
+
.nodes.filter(isEntrypointNode)
|
|
12
|
+
.map((node) => ({ node, payload: node.payload }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Python route entrypoint payloads', () => {
|
|
16
|
+
it('extracts FastAPI response_model from route decorator kwargs', () => {
|
|
17
|
+
const routes = routePayloads(`
|
|
18
|
+
from fastapi import APIRouter
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
@router.get("/users", response_model=UserOut)
|
|
22
|
+
def list_users():
|
|
23
|
+
return []
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
expect(routes).toHaveLength(1);
|
|
27
|
+
expect(routes[0].payload.responseModel).toBe('UserOut');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('extracts bracketed response_model expressions', () => {
|
|
31
|
+
const routes = routePayloads(`
|
|
32
|
+
@router.get("/users", response_model=list[schemas.UserOut])
|
|
33
|
+
def list_users():
|
|
34
|
+
return []
|
|
35
|
+
`);
|
|
36
|
+
|
|
37
|
+
expect(routes[0].payload.responseModel).toBe('list[schemas.UserOut]');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('extracts nested response_model generic expressions', () => {
|
|
41
|
+
const routes = routePayloads(`
|
|
42
|
+
@router.get("/users", response_model=dict[str, list[schemas.UserOut]], status_code=200)
|
|
43
|
+
def list_users():
|
|
44
|
+
return {}
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
expect(routes[0].payload.responseModel).toBe('dict[str, list[schemas.UserOut]]');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('leaves responseModel undefined when response_model is absent or None', () => {
|
|
51
|
+
const routes = routePayloads(`
|
|
52
|
+
@router.get("/healthz")
|
|
53
|
+
def healthz():
|
|
54
|
+
return {"ok": True}
|
|
55
|
+
|
|
56
|
+
@router.get("/raw", response_model=None)
|
|
57
|
+
def raw():
|
|
58
|
+
return {"ok": True}
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
expect(routes.map((route) => route.payload.responseModel)).toEqual([undefined, undefined]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('marks async def route handlers as async', () => {
|
|
65
|
+
const routes = routePayloads(`
|
|
66
|
+
@router.get("/users", response_model=UserOut)
|
|
67
|
+
async def list_users():
|
|
68
|
+
return []
|
|
69
|
+
`);
|
|
70
|
+
|
|
71
|
+
expect(routes[0].payload.isAsync).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('marks sync def route handlers as not async', () => {
|
|
75
|
+
const routes = routePayloads(`
|
|
76
|
+
@router.get("/users", response_model=UserOut)
|
|
77
|
+
def list_users():
|
|
78
|
+
return []
|
|
79
|
+
`);
|
|
80
|
+
|
|
81
|
+
expect(routes[0].payload.isAsync).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('sets the route containerId to the decorated function container', () => {
|
|
85
|
+
const concepts = extractPythonConcepts(
|
|
86
|
+
`
|
|
87
|
+
@router.get("/users")
|
|
88
|
+
def list_users():
|
|
89
|
+
requests.get("https://example.com")
|
|
90
|
+
`,
|
|
91
|
+
'app/api/users.py',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const route = concepts.nodes.find((node) => node.kind === 'entrypoint');
|
|
95
|
+
const effect = concepts.nodes.find((node) => node.kind === 'effect');
|
|
96
|
+
expect(route?.containerId).toBeDefined();
|
|
97
|
+
expect(route?.containerId).toBe(effect?.containerId);
|
|
98
|
+
});
|
|
99
|
+
});
|