@kernlang/review-python 3.3.5 → 3.3.7

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 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
- // 1. Route decorators: @app.route, @app.get, @router.post, etc.
257
- // tree-sitter Python wraps decorated functions in 'decorated_definition'
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(/@(app|router|bp)\.(route|get|post|put|delete|patch)\s*\(/);
267
- if (routeMatch) {
268
- const method = routeMatch[2].toUpperCase();
269
- const nameNode = fnDef.childForFieldName('name');
270
- // Try to extract path from decorator args
271
- const pathMatch = decText.match(/['"]([^'"]+)['"]/);
272
- nodes.push({
273
- id: conceptId(filePath, 'entrypoint', child.startIndex),
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
- primarySpan: nodeSpan(filePath, child),
276
- evidence: nodeText(source, child, 100),
277
- confidence: 1.0,
278
- language: 'py',
279
- containerId: getContainerId(node, filePath),
280
- payload: {
281
- kind: 'entrypoint',
282
- subtype: 'route',
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
- // 2. if __name__ == '__main__':
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. Early return/raise after auth check: if not request.user: raise/return
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
- // Check for 'async' keyword before 'def'
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.5",
3
+ "version": "3.3.7",
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.3.5",
12
- "@kernlang/review": "3.3.5"
11
+ "@kernlang/core": "3.3.7",
12
+ "@kernlang/review": "3.3.7"
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
- // 1. Route decorators: @app.route, @app.get, @router.post, etc.
299
- // tree-sitter Python wraps decorated functions in 'decorated_definition'
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(/@(app|router|bp)\.(route|get|post|put|delete|patch)\s*\(/);
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
- nodes.push({
316
- id: conceptId(filePath, 'entrypoint', child.startIndex),
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
- primarySpan: nodeSpan(filePath, child),
319
- evidence: nodeText(source, child, 100),
320
- confidence: 1.0,
321
- language: 'py',
322
- containerId: getContainerId(node, filePath),
323
- payload: {
324
- kind: 'entrypoint',
325
- subtype: 'route',
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
- // 2. if __name__ == '__main__':
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. Early return/raise after auth check: if not request.user: raise/return
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
- // Check for 'async' keyword before 'def'
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
+ });
package/tsconfig.json CHANGED
@@ -9,6 +9,7 @@
9
9
  "strict": true,
10
10
  "esModuleInterop": true,
11
11
  "skipLibCheck": true,
12
+ "types": ["node", "jest"],
12
13
  "resolveJsonModule": true
13
14
  },
14
15
  "include": ["src"],