@kernlang/review-python 3.3.8 → 3.4.0

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
@@ -27,6 +27,11 @@ const DB_METHODS = new Set([
27
27
  'delete_one',
28
28
  ]);
29
29
  const _FS_FUNCTIONS = new Set(['open', 'read', 'write', 'readlines', 'writelines']);
30
+ const PY_API_ERROR_STATUS_CODES = new Set([401, 403, 404, 422, 500]);
31
+ const PY_PAGINATION_RE = /\b(limit|offset|skip|cursor|page|page_size|per_page)\b|\.limit\s*\(/i;
32
+ const PY_DB_COLLECTION_RE = /\.(find|all|fetchall|to_list|scalars)\s*\(|\bselect\s*\(/i;
33
+ const PY_DB_WRITE_RE = /\.(insert_one|insert_many|update_one|update_many|delete_one|delete_many|add|create|save|commit)\s*\(/i;
34
+ const PY_IDEMPOTENCY_RE = /\b(idempotency(?:[_-]?key)?|Idempotency-Key|transaction|unique|upsert|get_or_create|on_conflict)\b/i;
30
35
  const STDLIB_MODULES = new Set([
31
36
  'os',
32
37
  'sys',
@@ -253,6 +258,7 @@ function extractEffects(root, source, filePath, nodes) {
253
258
  }
254
259
  // ── entrypoint ──────────────────────────────────────────────────────────
255
260
  function extractEntrypoints(root, source, filePath, nodes) {
261
+ const pydanticModels = collectPydanticModels(source);
256
262
  // FastAPI / Flask route decorators.
257
263
  //
258
264
  // The route *path* (e.g. `/current`) is what cross-stack rules need to
@@ -285,6 +291,7 @@ function extractEntrypoints(root, source, filePath, nodes) {
285
291
  continue;
286
292
  const responseModel = extractResponseModel(decText);
287
293
  const routeContainerId = getSelfContainerId(fnDef, filePath);
294
+ const routeAnalysis = analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels);
288
295
  nodes.push({
289
296
  id: conceptId(filePath, 'entrypoint', child.startIndex),
290
297
  kind: 'entrypoint',
@@ -301,6 +308,13 @@ function extractEntrypoints(root, source, filePath, nodes) {
301
308
  responseModel,
302
309
  isAsync: isAsyncFunction(fnDef),
303
310
  routerName,
311
+ errorStatusCodes: routeAnalysis.errorStatusCodes,
312
+ hasUnboundedCollectionQuery: routeAnalysis.hasUnboundedCollectionQuery,
313
+ hasDbWrite: routeAnalysis.hasDbWrite,
314
+ hasIdempotencyProtection: routeAnalysis.hasIdempotencyProtection,
315
+ hasBodyValidation: routeAnalysis.hasBodyValidation,
316
+ validatedBodyFields: routeAnalysis.validatedBodyFields,
317
+ bodyValidationResolved: routeAnalysis.bodyValidationResolved,
304
318
  },
305
319
  });
306
320
  }
@@ -488,6 +502,89 @@ function classifyDependency(depName) {
488
502
  return 'rate-limit';
489
503
  return 'policy';
490
504
  }
505
+ function analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels) {
506
+ const text = source.substring(fnDef.startIndex, fnDef.endIndex);
507
+ const validation = extractFastApiBodyValidation(fnDef, source, pydanticModels);
508
+ return {
509
+ errorStatusCodes: extractPythonHttpExceptionStatusCodes(text),
510
+ hasUnboundedCollectionQuery: hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel),
511
+ hasDbWrite: PY_DB_WRITE_RE.test(text),
512
+ hasIdempotencyProtection: PY_IDEMPOTENCY_RE.test(text),
513
+ hasBodyValidation: validation.has,
514
+ validatedBodyFields: validation.fields,
515
+ bodyValidationResolved: validation.resolved,
516
+ };
517
+ }
518
+ function extractPythonHttpExceptionStatusCodes(text) {
519
+ const codes = new Set();
520
+ const keywordRe = /HTTPException\s*\([^)]*status_code\s*=\s*(\d{3})/g;
521
+ for (const match of text.matchAll(keywordRe)) {
522
+ const code = Number(match[1]);
523
+ if (PY_API_ERROR_STATUS_CODES.has(code))
524
+ codes.add(code);
525
+ }
526
+ const positionalRe = /HTTPException\s*\(\s*(\d{3})/g;
527
+ for (const match of text.matchAll(positionalRe)) {
528
+ const code = Number(match[1]);
529
+ if (PY_API_ERROR_STATUS_CODES.has(code))
530
+ codes.add(code);
531
+ }
532
+ return codes.size > 0 ? Array.from(codes).sort((a, b) => a - b) : undefined;
533
+ }
534
+ function hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel) {
535
+ if (method !== 'GET')
536
+ return false;
537
+ if (/[{:]/.test(routePath))
538
+ return false;
539
+ if (PY_PAGINATION_RE.test(text))
540
+ return false;
541
+ const responseLooksList = responseModel ? /^(list|List|Sequence|Iterable)\s*\[/.test(responseModel) : false;
542
+ return (PY_DB_COLLECTION_RE.test(text) &&
543
+ (responseLooksList || /\breturn\b[\s\S]*(\.all\s*\(|\.find\s*\(|\.fetchall\s*\()/.test(text)));
544
+ }
545
+ function collectPydanticModels(source) {
546
+ const models = new Map();
547
+ const classRe = /^class\s+([A-Za-z_]\w*)\s*\([^)]*BaseModel[^)]*\)\s*:/gm;
548
+ for (const match of source.matchAll(classRe)) {
549
+ const name = match[1];
550
+ const start = (match.index ?? 0) + match[0].length;
551
+ const rest = source.slice(start);
552
+ const nextTopLevel = rest.search(/\n\S/);
553
+ const body = nextTopLevel === -1 ? rest : rest.slice(0, nextTopLevel);
554
+ const fields = [];
555
+ const fieldRe = /^\s+([A-Za-z_]\w*)\s*:/gm;
556
+ for (const fieldMatch of body.matchAll(fieldRe)) {
557
+ const field = fieldMatch[1];
558
+ if (field === 'model_config' || field === 'Config')
559
+ continue;
560
+ fields.push(field);
561
+ }
562
+ if (fields.length > 0)
563
+ models.set(name, fields.sort());
564
+ }
565
+ return models;
566
+ }
567
+ function extractFastApiBodyValidation(fnDef, source, pydanticModels) {
568
+ const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((child) => child.type === 'block');
569
+ const headerEnd = body ? body.startIndex : fnDef.endIndex;
570
+ const header = source.substring(fnDef.startIndex, headerEnd);
571
+ const fields = new Set();
572
+ let has = false;
573
+ const annotationRe = /([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/g;
574
+ for (const match of header.matchAll(annotationRe)) {
575
+ const modelFields = pydanticModels.get(match[2]);
576
+ if (!modelFields)
577
+ continue;
578
+ has = true;
579
+ for (const field of modelFields)
580
+ fields.add(field);
581
+ }
582
+ return {
583
+ has,
584
+ fields: fields.size > 0 ? Array.from(fields).sort() : undefined,
585
+ resolved: fields.size > 0,
586
+ };
587
+ }
491
588
  // ── state_mutation ───────────────────────────────────────────────────────
492
589
  function extractStateMutation(root, source, filePath, nodes) {
493
590
  // Track global keyword usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernlang/review-python",
3
- "version": "3.3.8",
3
+ "version": "3.4.0",
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.8",
12
- "@kernlang/review": "3.3.8"
11
+ "@kernlang/review": "3.4.0",
12
+ "@kernlang/core": "3.4.0"
13
13
  },
14
14
  "devDependencies": {
15
15
  "ts-morph": "^28.0.0",
package/src/mapper.ts CHANGED
@@ -35,6 +35,24 @@ const DB_METHODS = new Set([
35
35
 
36
36
  const _FS_FUNCTIONS = new Set(['open', 'read', 'write', 'readlines', 'writelines']);
37
37
 
38
+ interface PythonRouteAnalysis {
39
+ errorStatusCodes?: readonly number[];
40
+ hasUnboundedCollectionQuery?: boolean;
41
+ hasDbWrite?: boolean;
42
+ hasIdempotencyProtection?: boolean;
43
+ hasBodyValidation?: boolean;
44
+ validatedBodyFields?: readonly string[];
45
+ bodyValidationResolved?: boolean;
46
+ }
47
+
48
+ const PY_API_ERROR_STATUS_CODES = new Set([401, 403, 404, 422, 500]);
49
+ const PY_PAGINATION_RE = /\b(limit|offset|skip|cursor|page|page_size|per_page)\b|\.limit\s*\(/i;
50
+ const PY_DB_COLLECTION_RE = /\.(find|all|fetchall|to_list|scalars)\s*\(|\bselect\s*\(/i;
51
+ const PY_DB_WRITE_RE =
52
+ /\.(insert_one|insert_many|update_one|update_many|delete_one|delete_many|add|create|save|commit)\s*\(/i;
53
+ const PY_IDEMPOTENCY_RE =
54
+ /\b(idempotency(?:[_-]?key)?|Idempotency-Key|transaction|unique|upsert|get_or_create|on_conflict)\b/i;
55
+
38
56
  const STDLIB_MODULES = new Set([
39
57
  'os',
40
58
  'sys',
@@ -295,6 +313,8 @@ function extractEffects(root: Parser.SyntaxNode, source: string, filePath: strin
295
313
  // ── entrypoint ──────────────────────────────────────────────────────────
296
314
 
297
315
  function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void {
316
+ const pydanticModels = collectPydanticModels(source);
317
+
298
318
  // FastAPI / Flask route decorators.
299
319
  //
300
320
  // The route *path* (e.g. `/current`) is what cross-stack rules need to
@@ -327,6 +347,7 @@ function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: s
327
347
 
328
348
  const responseModel = extractResponseModel(decText);
329
349
  const routeContainerId = getSelfContainerId(fnDef, filePath);
350
+ const routeAnalysis = analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels);
330
351
 
331
352
  nodes.push({
332
353
  id: conceptId(filePath, 'entrypoint', child.startIndex),
@@ -344,6 +365,13 @@ function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: s
344
365
  responseModel,
345
366
  isAsync: isAsyncFunction(fnDef),
346
367
  routerName,
368
+ errorStatusCodes: routeAnalysis.errorStatusCodes,
369
+ hasUnboundedCollectionQuery: routeAnalysis.hasUnboundedCollectionQuery,
370
+ hasDbWrite: routeAnalysis.hasDbWrite,
371
+ hasIdempotencyProtection: routeAnalysis.hasIdempotencyProtection,
372
+ hasBodyValidation: routeAnalysis.hasBodyValidation,
373
+ validatedBodyFields: routeAnalysis.validatedBodyFields,
374
+ bodyValidationResolved: routeAnalysis.bodyValidationResolved,
347
375
  },
348
376
  });
349
377
  }
@@ -531,6 +559,103 @@ function classifyDependency(depName: string): 'auth' | 'validation' | 'rate-limi
531
559
  return 'policy';
532
560
  }
533
561
 
562
+ function analyzePythonRoute(
563
+ fnDef: Parser.SyntaxNode,
564
+ source: string,
565
+ method: string,
566
+ routePath: string,
567
+ responseModel: string | undefined,
568
+ pydanticModels: ReadonlyMap<string, readonly string[]>,
569
+ ): PythonRouteAnalysis {
570
+ const text = source.substring(fnDef.startIndex, fnDef.endIndex);
571
+ const validation = extractFastApiBodyValidation(fnDef, source, pydanticModels);
572
+ return {
573
+ errorStatusCodes: extractPythonHttpExceptionStatusCodes(text),
574
+ hasUnboundedCollectionQuery: hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel),
575
+ hasDbWrite: PY_DB_WRITE_RE.test(text),
576
+ hasIdempotencyProtection: PY_IDEMPOTENCY_RE.test(text),
577
+ hasBodyValidation: validation.has,
578
+ validatedBodyFields: validation.fields,
579
+ bodyValidationResolved: validation.resolved,
580
+ };
581
+ }
582
+
583
+ function extractPythonHttpExceptionStatusCodes(text: string): readonly number[] | undefined {
584
+ const codes = new Set<number>();
585
+ const keywordRe = /HTTPException\s*\([^)]*status_code\s*=\s*(\d{3})/g;
586
+ for (const match of text.matchAll(keywordRe)) {
587
+ const code = Number(match[1]);
588
+ if (PY_API_ERROR_STATUS_CODES.has(code)) codes.add(code);
589
+ }
590
+ const positionalRe = /HTTPException\s*\(\s*(\d{3})/g;
591
+ for (const match of text.matchAll(positionalRe)) {
592
+ const code = Number(match[1]);
593
+ if (PY_API_ERROR_STATUS_CODES.has(code)) codes.add(code);
594
+ }
595
+ return codes.size > 0 ? Array.from(codes).sort((a, b) => a - b) : undefined;
596
+ }
597
+
598
+ function hasUnboundedPythonCollectionQuery(
599
+ text: string,
600
+ method: string,
601
+ routePath: string,
602
+ responseModel: string | undefined,
603
+ ): boolean {
604
+ if (method !== 'GET') return false;
605
+ if (/[{:]/.test(routePath)) return false;
606
+ if (PY_PAGINATION_RE.test(text)) return false;
607
+ const responseLooksList = responseModel ? /^(list|List|Sequence|Iterable)\s*\[/.test(responseModel) : false;
608
+ return (
609
+ PY_DB_COLLECTION_RE.test(text) &&
610
+ (responseLooksList || /\breturn\b[\s\S]*(\.all\s*\(|\.find\s*\(|\.fetchall\s*\()/.test(text))
611
+ );
612
+ }
613
+
614
+ function collectPydanticModels(source: string): Map<string, readonly string[]> {
615
+ const models = new Map<string, readonly string[]>();
616
+ const classRe = /^class\s+([A-Za-z_]\w*)\s*\([^)]*BaseModel[^)]*\)\s*:/gm;
617
+ for (const match of source.matchAll(classRe)) {
618
+ const name = match[1];
619
+ const start = (match.index ?? 0) + match[0].length;
620
+ const rest = source.slice(start);
621
+ const nextTopLevel = rest.search(/\n\S/);
622
+ const body = nextTopLevel === -1 ? rest : rest.slice(0, nextTopLevel);
623
+ const fields: string[] = [];
624
+ const fieldRe = /^\s+([A-Za-z_]\w*)\s*:/gm;
625
+ for (const fieldMatch of body.matchAll(fieldRe)) {
626
+ const field = fieldMatch[1];
627
+ if (field === 'model_config' || field === 'Config') continue;
628
+ fields.push(field);
629
+ }
630
+ if (fields.length > 0) models.set(name, fields.sort());
631
+ }
632
+ return models;
633
+ }
634
+
635
+ function extractFastApiBodyValidation(
636
+ fnDef: Parser.SyntaxNode,
637
+ source: string,
638
+ pydanticModels: ReadonlyMap<string, readonly string[]>,
639
+ ): { has: boolean; fields: readonly string[] | undefined; resolved: boolean } {
640
+ const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((child) => child.type === 'block');
641
+ const headerEnd = body ? body.startIndex : fnDef.endIndex;
642
+ const header = source.substring(fnDef.startIndex, headerEnd);
643
+ const fields = new Set<string>();
644
+ let has = false;
645
+ const annotationRe = /([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/g;
646
+ for (const match of header.matchAll(annotationRe)) {
647
+ const modelFields = pydanticModels.get(match[2]);
648
+ if (!modelFields) continue;
649
+ has = true;
650
+ for (const field of modelFields) fields.add(field);
651
+ }
652
+ return {
653
+ has,
654
+ fields: fields.size > 0 ? Array.from(fields).sort() : undefined,
655
+ resolved: fields.size > 0,
656
+ };
657
+ }
658
+
534
659
  // ── state_mutation ───────────────────────────────────────────────────────
535
660
 
536
661
  function extractStateMutation(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void {
@@ -96,4 +96,23 @@ def list_users():
96
96
  expect(route?.containerId).toBeDefined();
97
97
  expect(route?.containerId).toBe(effect?.containerId);
98
98
  });
99
+
100
+ it('extracts Pydantic request model fields from annotated route parameters', () => {
101
+ const routes = routePayloads(`
102
+ from pydantic import BaseModel
103
+
104
+ class UserCreate(BaseModel):
105
+ email: str
106
+ name: str
107
+
108
+ @router.post("/users")
109
+ def create_user(payload: UserCreate):
110
+ return payload
111
+ `);
112
+
113
+ expect(routes).toHaveLength(1);
114
+ expect(routes[0].payload.hasBodyValidation).toBe(true);
115
+ expect(routes[0].payload.bodyValidationResolved).toBe(true);
116
+ expect(routes[0].payload.validatedBodyFields).toEqual(['email', 'name']);
117
+ });
99
118
  });