@kernlang/review-python 3.4.6-canary.38.1.c552219e → 3.4.6-canary.44.1.a85ee2e8

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
@@ -28,6 +28,19 @@ const DB_METHODS = new Set([
28
28
  ]);
29
29
  const _FS_FUNCTIONS = new Set(['open', 'read', 'write', 'readlines', 'writelines']);
30
30
  const PY_API_ERROR_STATUS_CODES = new Set([401, 403, 404, 422, 500]);
31
+ const PY_API_SUCCESS_STATUS_CODES = new Set([200, 201, 202, 204, 206]);
32
+ // FastAPI's documented default success status is 200, regardless of HTTP method
33
+ // (Codex plan-review #1, FastAPI docs:
34
+ // https://fastapi.tiangolo.com/tutorial/response-status-code/). 201 for POST is
35
+ // a per-route opt-in via `status_code=201`, not a method-derived default.
36
+ const FASTAPI_DEFAULT_SUCCESS_STATUS = 200;
37
+ // Pagination anchor families — mirror the TS classification in
38
+ // `packages/review/src/concept-rules/cross-stack-utils.ts`. The size keys
39
+ // (`limit`, `take`, `page_size`, `per_page`) are intentionally NOT anchors
40
+ // — they're compatible with either offset or cursor pagination.
41
+ const PY_PAGE_ANCHORS = new Set(['page', 'page_number', 'pageNumber']);
42
+ const PY_OFFSET_ANCHORS = new Set(['offset', 'skip']);
43
+ const PY_CURSOR_ANCHORS = new Set(['cursor', 'after', 'before', 'next', 'previous']);
31
44
  const PY_PAGINATION_RE = /\b(limit|offset|skip|cursor|page|page_size|per_page)\b|\.limit\s*\(/i;
32
45
  const PY_DB_COLLECTION_RE = /\.(find|all|fetchall|to_list|scalars)\s*\(|\bselect\s*\(/i;
33
46
  const PY_DB_WRITE_RE = /\.(insert_one|insert_many|update_one|update_many|delete_one|delete_many|add|create|save|commit)\s*\(/i;
@@ -291,7 +304,7 @@ function extractEntrypoints(root, source, filePath, nodes) {
291
304
  continue;
292
305
  const responseModel = extractResponseModel(decText);
293
306
  const routeContainerId = getSelfContainerId(fnDef, filePath);
294
- const routeAnalysis = analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels);
307
+ const routeAnalysis = analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels, decText);
295
308
  nodes.push({
296
309
  id: conceptId(filePath, 'entrypoint', child.startIndex),
297
310
  kind: 'entrypoint',
@@ -309,6 +322,10 @@ function extractEntrypoints(root, source, filePath, nodes) {
309
322
  isAsync: isAsyncFunction(fnDef),
310
323
  routerName,
311
324
  errorStatusCodes: routeAnalysis.errorStatusCodes,
325
+ successStatusCodes: routeAnalysis.successStatusCodes,
326
+ successStatusCodesResolved: routeAnalysis.successStatusCodesResolved,
327
+ paginationStrategy: routeAnalysis.paginationStrategy,
328
+ paginationStrategyResolved: routeAnalysis.paginationStrategyResolved,
312
329
  hasUnboundedCollectionQuery: routeAnalysis.hasUnboundedCollectionQuery,
313
330
  hasDbWrite: routeAnalysis.hasDbWrite,
314
331
  hasIdempotencyProtection: routeAnalysis.hasIdempotencyProtection,
@@ -503,11 +520,17 @@ function classifyDependency(depName) {
503
520
  return 'rate-limit';
504
521
  return 'policy';
505
522
  }
506
- function analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels) {
523
+ function analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels, decText) {
507
524
  const text = source.substring(fnDef.startIndex, fnDef.endIndex);
508
525
  const validation = extractFastApiBodyValidation(fnDef, source, pydanticModels);
526
+ const success = extractFastApiSuccessStatusCodes(decText, fnDef, source);
527
+ const pagination = extractFastApiPaginationStrategy(fnDef, source);
509
528
  return {
510
529
  errorStatusCodes: extractPythonHttpExceptionStatusCodes(text),
530
+ successStatusCodes: success.codes,
531
+ successStatusCodesResolved: success.resolved,
532
+ paginationStrategy: pagination.strategy,
533
+ paginationStrategyResolved: pagination.resolved,
511
534
  hasUnboundedCollectionQuery: hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel),
512
535
  hasDbWrite: PY_DB_WRITE_RE.test(text),
513
536
  hasIdempotencyProtection: PY_IDEMPOTENCY_RE.test(text),
@@ -517,6 +540,240 @@ function analyzePythonRoute(fnDef, source, method, routePath, responseModel, pyd
517
540
  validatedBodyFieldTypes: validation.types,
518
541
  };
519
542
  }
543
+ // ── FastAPI success status codes ─────────────────────────────────────────
544
+ // Phase 2 of cross-stack `status-code-drift`. Populates the
545
+ // `successStatusCodes` / `successStatusCodesResolved` payload fields so the
546
+ // rule can flag clients checking a 2xx the FastAPI server doesn't emit.
547
+ //
548
+ // Sources of evidence (per buddy plan-review consensus):
549
+ // 1. Decorator `status_code=N` (literal) or `status_code=status.HTTP_NNN_*`.
550
+ // 2. Body-side `Response(status_code=N)` / `JSONResponse(...)` returns.
551
+ // 3. Body-side `<param>.status_code = N` mutations (FastAPI's documented
552
+ // pattern for routes that take a `Response` parameter).
553
+ // 4. When the decorator omits status_code AND the body has no explicit
554
+ // Response / mutation, default to 200 — FastAPI's documented default
555
+ // regardless of HTTP method. Codex caught Gemini's POST→201 premise as
556
+ // wrong (FastAPI docs:
557
+ // https://fastapi.tiangolo.com/tutorial/response-status-code/).
558
+ //
559
+ // Marked unresolved when:
560
+ // - Decorator status_code is set to a non-literal/non-status-constant
561
+ // expression (variable, function call).
562
+ // - Any `Response(status_code=...)` / `<x>.status_code = ...` RHS is dynamic.
563
+ function extractFastApiSuccessStatusCodes(decText, fnDef, source) {
564
+ let sawDynamic = false;
565
+ // 1. Decorator `status_code=N` — applies ONLY to plain `return data` paths.
566
+ // For routes whose return paths all use explicit Response/JSONResponse,
567
+ // the decorator code is dead (Codex impl-review #1).
568
+ const decStatusMatch = decText.match(/\bstatus_code\s*=\s*([^,)]+)/);
569
+ let decoratorCode;
570
+ if (decStatusMatch) {
571
+ const code = parseFastApiStatusValue(decStatusMatch[1].trim());
572
+ if (code === undefined)
573
+ sawDynamic = true;
574
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code))
575
+ decoratorCode = code;
576
+ }
577
+ const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((c) => c.type === 'block');
578
+ const bodyText = body ? source.substring(body.startIndex, body.endIndex) : '';
579
+ // 2. Response(status_code=N) / JSONResponse(...) etc. — applies only to
580
+ // that specific return path. Multiple Response codes contribute a
581
+ // multi-2xx route.
582
+ const responseCodes = new Set();
583
+ const responseRe = /\b(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\([^)]*?\bstatus_code\s*=\s*([^,)\n]+)/g;
584
+ for (const match of bodyText.matchAll(responseRe)) {
585
+ const code = parseFastApiStatusValue(match[1].trim());
586
+ if (code === undefined)
587
+ sawDynamic = true;
588
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code))
589
+ responseCodes.add(code);
590
+ }
591
+ // 3. `<paramName>.status_code = N` — mutation on the injected Response
592
+ // parameter. The parameter name varies (`response`, `resp`, `r`, `out`,
593
+ // custom names — Codex impl-review #2). Match any identifier prefix
594
+ // rather than a name whitelist; the API_SUCCESS_STATUS_CODES filter
595
+ // keeps the noise tax low.
596
+ const mutationCodes = new Set();
597
+ // `=(?!=)` distinguishes assignment from `==` comparison so
598
+ // `if response.status_code == 200:` doesn't masquerade as a dynamic
599
+ // mutation (forge round, Claude engine).
600
+ const mutateRe = /\b[A-Za-z_]\w*\.status_code\s*=(?!=)\s*([^\n;]+)/g;
601
+ for (const match of bodyText.matchAll(mutateRe)) {
602
+ const code = parseFastApiStatusValue(match[1].trim());
603
+ if (code === undefined)
604
+ sawDynamic = true;
605
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code))
606
+ mutationCodes.add(code);
607
+ }
608
+ if (sawDynamic)
609
+ return { codes: undefined, resolved: false };
610
+ // Plain return paths inherit the route's "primary" success code, computed
611
+ // as: mutation > decorator > FastAPI default 200. When a mutation is
612
+ // present we treat it as the plain-return code (the conditional-mutation
613
+ // case is a documented v1 false-negative — would require control-flow
614
+ // analysis to disambiguate).
615
+ const plainReturnRe = /\breturn\b(?!\s+(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\()/;
616
+ const hasPlainReturn = plainReturnRe.test(bodyText);
617
+ const final = new Set();
618
+ if (hasPlainReturn) {
619
+ if (mutationCodes.size > 0) {
620
+ for (const c of mutationCodes)
621
+ final.add(c);
622
+ }
623
+ else if (decoratorCode !== undefined) {
624
+ final.add(decoratorCode);
625
+ }
626
+ else {
627
+ final.add(FASTAPI_DEFAULT_SUCCESS_STATUS);
628
+ }
629
+ }
630
+ else if (decoratorCode !== undefined && responseCodes.size === 0 && mutationCodes.size === 0) {
631
+ // Handler with no plain return, no Response, no mutation — likely an
632
+ // implicit-None-return stub or all-raise. Decorator is the only signal.
633
+ final.add(decoratorCode);
634
+ }
635
+ // Response and mutation codes ALWAYS contribute (they're explicit choices
636
+ // for their respective return paths).
637
+ for (const c of responseCodes)
638
+ final.add(c);
639
+ for (const c of mutationCodes)
640
+ final.add(c);
641
+ return {
642
+ codes: Array.from(final).sort((a, b) => a - b),
643
+ resolved: true,
644
+ };
645
+ }
646
+ function parseFastApiStatusValue(val) {
647
+ const trimmed = val.trim();
648
+ // Literal 3-digit int.
649
+ const litMatch = trimmed.match(/^(\d{3})$/);
650
+ if (litMatch)
651
+ return Number(litMatch[1]);
652
+ // status.HTTP_NNN_NAME / starlette.status.HTTP_NNN_NAME / fastapi.status.HTTP_NNN_NAME.
653
+ const httpMatch = trimmed.match(/HTTP_(\d{3})_/);
654
+ if (httpMatch)
655
+ return Number(httpMatch[1]);
656
+ return undefined;
657
+ }
658
+ // ── FastAPI pagination strategy ──────────────────────────────────────────
659
+ // Iterates the route handler's parameters and classifies each by name (or
660
+ // `Query(alias=...)` literal alias when present) against page/offset/cursor
661
+ // anchor sets. Returns:
662
+ // - `none` / resolved=true — handler reads no anchor params (and no opaque
663
+ // paths to query data).
664
+ // - `page` / `offset` / `cursor` / resolved=true — handler reads exactly
665
+ // one family.
666
+ // - `mixed` / resolved=true — handler reads multiple families.
667
+ // - `undefined` / resolved=false — handler has a `Request` parameter,
668
+ // `**kwargs`, or a `Query(alias=<dynamic>)` we can't statically resolve.
669
+ function extractFastApiPaginationStrategy(fnDef, source) {
670
+ const paramsNode = fnDef.childForFieldName('parameters');
671
+ if (!paramsNode)
672
+ return { strategy: 'none', resolved: true };
673
+ const families = new Set();
674
+ let sawOpaque = false;
675
+ for (const child of paramsNode.namedChildren) {
676
+ // **kwargs — handler may read any query key dynamically; opaque.
677
+ if (child.type === 'dictionary_splat_pattern') {
678
+ sawOpaque = true;
679
+ continue;
680
+ }
681
+ // *args — positional spread, irrelevant for query keys but rare in
682
+ // FastAPI handlers; keep silent.
683
+ if (child.type === 'list_splat_pattern')
684
+ continue;
685
+ // Drop typing wrappers to find the param identifier.
686
+ const paramName = extractParamName(child);
687
+ if (!paramName)
688
+ continue;
689
+ // `request: Request` — handler may call `request.query_params.get(...)`
690
+ // arbitrarily; mark opaque.
691
+ const typeText = extractParamTypeText(child, source);
692
+ if (typeText && /\bRequest\b/.test(typeText)) {
693
+ sawOpaque = true;
694
+ continue;
695
+ }
696
+ // Default-value AND type expression both can carry a `Query(alias="...")`
697
+ // call. Modern FastAPI (≥0.95) puts the call inside the type annotation
698
+ // via `Annotated[int, Query(alias="page")]` (Gemini/OpenCode impl-review).
699
+ // Older / classic syntax puts it in the default: `Query(0, alias="page")`.
700
+ // Check both — default-value form takes precedence when both are present.
701
+ const defaultText = extractParamDefaultText(child, source);
702
+ const aliasFromDefault = extractQueryAlias(defaultText);
703
+ const aliasFromType = aliasFromDefault.alias === undefined ? extractQueryAlias(typeText) : aliasFromDefault;
704
+ let key = paramName;
705
+ if (aliasFromDefault.opaque || aliasFromType.opaque) {
706
+ sawOpaque = true;
707
+ continue;
708
+ }
709
+ if (aliasFromDefault.alias)
710
+ key = aliasFromDefault.alias;
711
+ else if (aliasFromType.alias)
712
+ key = aliasFromType.alias;
713
+ const family = classifyPyAnchor(key);
714
+ if (family)
715
+ families.add(family);
716
+ }
717
+ if (sawOpaque)
718
+ return { strategy: undefined, resolved: false };
719
+ if (families.size === 0)
720
+ return { strategy: 'none', resolved: true };
721
+ if (families.size === 1)
722
+ return { strategy: [...families][0], resolved: true };
723
+ return { strategy: 'mixed', resolved: true };
724
+ }
725
+ function extractParamName(node) {
726
+ if (node.type === 'identifier')
727
+ return node.text;
728
+ if (node.type === 'typed_parameter' || node.type === 'typed_default_parameter' || node.type === 'default_parameter') {
729
+ const nameChild = node.childForFieldName('name') ?? node.namedChildren.find((c) => c.type === 'identifier');
730
+ if (nameChild)
731
+ return nameChild.text;
732
+ }
733
+ return undefined;
734
+ }
735
+ function extractParamTypeText(node, source) {
736
+ if (node.type !== 'typed_parameter' && node.type !== 'typed_default_parameter')
737
+ return undefined;
738
+ const typeChild = node.childForFieldName('type');
739
+ if (typeChild)
740
+ return source.substring(typeChild.startIndex, typeChild.endIndex);
741
+ return undefined;
742
+ }
743
+ function extractParamDefaultText(node, source) {
744
+ if (node.type !== 'default_parameter' && node.type !== 'typed_default_parameter')
745
+ return undefined;
746
+ const valueChild = node.childForFieldName('value');
747
+ if (valueChild)
748
+ return source.substring(valueChild.startIndex, valueChild.endIndex);
749
+ return undefined;
750
+ }
751
+ function classifyPyAnchor(key) {
752
+ if (PY_PAGE_ANCHORS.has(key))
753
+ return 'page';
754
+ if (PY_OFFSET_ANCHORS.has(key))
755
+ return 'offset';
756
+ if (PY_CURSOR_ANCHORS.has(key))
757
+ return 'cursor';
758
+ return undefined;
759
+ }
760
+ /** Extract a `Query(..., alias="...")` literal alias from a parameter's
761
+ * default-value or type-annotation text. Used to support both classic
762
+ * (`x = Query(0, alias="p")`) and modern (`x: Annotated[int, Query(alias="p")]`)
763
+ * FastAPI patterns. Returns `{alias?, opaque}` where `opaque=true` indicates
764
+ * a `Query(alias=<non-literal>)` we cannot statically resolve. */
765
+ function extractQueryAlias(text) {
766
+ if (!text)
767
+ return { opaque: false };
768
+ if (!/\bQuery\s*\(/.test(text))
769
+ return { opaque: false };
770
+ const aliasMatch = text.match(/\balias\s*=\s*['"]([^'"]+)['"]/);
771
+ if (aliasMatch)
772
+ return { alias: aliasMatch[1], opaque: false };
773
+ if (/\balias\s*=/.test(text))
774
+ return { opaque: true };
775
+ return { opaque: false };
776
+ }
520
777
  function extractPythonHttpExceptionStatusCodes(text) {
521
778
  const codes = new Set();
522
779
  const keywordRe = /HTTPException\s*\([^)]*status_code\s*=\s*(\d{3})/g;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernlang/review-python",
3
- "version": "3.4.6-canary.38.1.c552219e",
3
+ "version": "3.4.6-canary.44.1.a85ee2e8",
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.38.1.c552219e",
12
- "@kernlang/review": "3.4.6-canary.38.1.c552219e"
11
+ "@kernlang/core": "3.4.6-canary.44.1.a85ee2e8",
12
+ "@kernlang/review": "3.4.6-canary.44.1.a85ee2e8"
13
13
  },
14
14
  "devDependencies": {
15
15
  "ts-morph": "^28.0.0",
package/src/mapper.ts CHANGED
@@ -45,6 +45,10 @@ interface PydanticModel {
45
45
 
46
46
  interface PythonRouteAnalysis {
47
47
  errorStatusCodes?: readonly number[];
48
+ successStatusCodes?: readonly number[];
49
+ successStatusCodesResolved?: boolean;
50
+ paginationStrategy?: 'page' | 'offset' | 'cursor' | 'mixed' | 'none';
51
+ paginationStrategyResolved?: boolean;
48
52
  hasUnboundedCollectionQuery?: boolean;
49
53
  hasDbWrite?: boolean;
50
54
  hasIdempotencyProtection?: boolean;
@@ -55,6 +59,19 @@ interface PythonRouteAnalysis {
55
59
  }
56
60
 
57
61
  const PY_API_ERROR_STATUS_CODES = new Set([401, 403, 404, 422, 500]);
62
+ const PY_API_SUCCESS_STATUS_CODES = new Set([200, 201, 202, 204, 206]);
63
+ // FastAPI's documented default success status is 200, regardless of HTTP method
64
+ // (Codex plan-review #1, FastAPI docs:
65
+ // https://fastapi.tiangolo.com/tutorial/response-status-code/). 201 for POST is
66
+ // a per-route opt-in via `status_code=201`, not a method-derived default.
67
+ const FASTAPI_DEFAULT_SUCCESS_STATUS = 200;
68
+ // Pagination anchor families — mirror the TS classification in
69
+ // `packages/review/src/concept-rules/cross-stack-utils.ts`. The size keys
70
+ // (`limit`, `take`, `page_size`, `per_page`) are intentionally NOT anchors
71
+ // — they're compatible with either offset or cursor pagination.
72
+ const PY_PAGE_ANCHORS = new Set(['page', 'page_number', 'pageNumber']);
73
+ const PY_OFFSET_ANCHORS = new Set(['offset', 'skip']);
74
+ const PY_CURSOR_ANCHORS = new Set(['cursor', 'after', 'before', 'next', 'previous']);
58
75
  const PY_PAGINATION_RE = /\b(limit|offset|skip|cursor|page|page_size|per_page)\b|\.limit\s*\(/i;
59
76
  const PY_DB_COLLECTION_RE = /\.(find|all|fetchall|to_list|scalars)\s*\(|\bselect\s*\(/i;
60
77
  const PY_DB_WRITE_RE =
@@ -356,7 +373,15 @@ function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: s
356
373
 
357
374
  const responseModel = extractResponseModel(decText);
358
375
  const routeContainerId = getSelfContainerId(fnDef, filePath);
359
- const routeAnalysis = analyzePythonRoute(fnDef, source, method, routePath, responseModel, pydanticModels);
376
+ const routeAnalysis = analyzePythonRoute(
377
+ fnDef,
378
+ source,
379
+ method,
380
+ routePath,
381
+ responseModel,
382
+ pydanticModels,
383
+ decText,
384
+ );
360
385
 
361
386
  nodes.push({
362
387
  id: conceptId(filePath, 'entrypoint', child.startIndex),
@@ -375,6 +400,10 @@ function extractEntrypoints(root: Parser.SyntaxNode, source: string, filePath: s
375
400
  isAsync: isAsyncFunction(fnDef),
376
401
  routerName,
377
402
  errorStatusCodes: routeAnalysis.errorStatusCodes,
403
+ successStatusCodes: routeAnalysis.successStatusCodes,
404
+ successStatusCodesResolved: routeAnalysis.successStatusCodesResolved,
405
+ paginationStrategy: routeAnalysis.paginationStrategy,
406
+ paginationStrategyResolved: routeAnalysis.paginationStrategyResolved,
378
407
  hasUnboundedCollectionQuery: routeAnalysis.hasUnboundedCollectionQuery,
379
408
  hasDbWrite: routeAnalysis.hasDbWrite,
380
409
  hasIdempotencyProtection: routeAnalysis.hasIdempotencyProtection,
@@ -576,11 +605,18 @@ function analyzePythonRoute(
576
605
  routePath: string,
577
606
  responseModel: string | undefined,
578
607
  pydanticModels: ReadonlyMap<string, PydanticModel>,
608
+ decText: string,
579
609
  ): PythonRouteAnalysis {
580
610
  const text = source.substring(fnDef.startIndex, fnDef.endIndex);
581
611
  const validation = extractFastApiBodyValidation(fnDef, source, pydanticModels);
612
+ const success = extractFastApiSuccessStatusCodes(decText, fnDef, source);
613
+ const pagination = extractFastApiPaginationStrategy(fnDef, source);
582
614
  return {
583
615
  errorStatusCodes: extractPythonHttpExceptionStatusCodes(text),
616
+ successStatusCodes: success.codes,
617
+ successStatusCodesResolved: success.resolved,
618
+ paginationStrategy: pagination.strategy,
619
+ paginationStrategyResolved: pagination.resolved,
584
620
  hasUnboundedCollectionQuery: hasUnboundedPythonCollectionQuery(text, method, routePath, responseModel),
585
621
  hasDbWrite: PY_DB_WRITE_RE.test(text),
586
622
  hasIdempotencyProtection: PY_IDEMPOTENCY_RE.test(text),
@@ -591,6 +627,240 @@ function analyzePythonRoute(
591
627
  };
592
628
  }
593
629
 
630
+ // ── FastAPI success status codes ─────────────────────────────────────────
631
+ // Phase 2 of cross-stack `status-code-drift`. Populates the
632
+ // `successStatusCodes` / `successStatusCodesResolved` payload fields so the
633
+ // rule can flag clients checking a 2xx the FastAPI server doesn't emit.
634
+ //
635
+ // Sources of evidence (per buddy plan-review consensus):
636
+ // 1. Decorator `status_code=N` (literal) or `status_code=status.HTTP_NNN_*`.
637
+ // 2. Body-side `Response(status_code=N)` / `JSONResponse(...)` returns.
638
+ // 3. Body-side `<param>.status_code = N` mutations (FastAPI's documented
639
+ // pattern for routes that take a `Response` parameter).
640
+ // 4. When the decorator omits status_code AND the body has no explicit
641
+ // Response / mutation, default to 200 — FastAPI's documented default
642
+ // regardless of HTTP method. Codex caught Gemini's POST→201 premise as
643
+ // wrong (FastAPI docs:
644
+ // https://fastapi.tiangolo.com/tutorial/response-status-code/).
645
+ //
646
+ // Marked unresolved when:
647
+ // - Decorator status_code is set to a non-literal/non-status-constant
648
+ // expression (variable, function call).
649
+ // - Any `Response(status_code=...)` / `<x>.status_code = ...` RHS is dynamic.
650
+ function extractFastApiSuccessStatusCodes(
651
+ decText: string,
652
+ fnDef: Parser.SyntaxNode,
653
+ source: string,
654
+ ): { codes: readonly number[] | undefined; resolved: boolean } {
655
+ let sawDynamic = false;
656
+
657
+ // 1. Decorator `status_code=N` — applies ONLY to plain `return data` paths.
658
+ // For routes whose return paths all use explicit Response/JSONResponse,
659
+ // the decorator code is dead (Codex impl-review #1).
660
+ const decStatusMatch = decText.match(/\bstatus_code\s*=\s*([^,)]+)/);
661
+ let decoratorCode: number | undefined;
662
+ if (decStatusMatch) {
663
+ const code = parseFastApiStatusValue(decStatusMatch[1].trim());
664
+ if (code === undefined) sawDynamic = true;
665
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code)) decoratorCode = code;
666
+ }
667
+
668
+ const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((c) => c.type === 'block');
669
+ const bodyText = body ? source.substring(body.startIndex, body.endIndex) : '';
670
+
671
+ // 2. Response(status_code=N) / JSONResponse(...) etc. — applies only to
672
+ // that specific return path. Multiple Response codes contribute a
673
+ // multi-2xx route.
674
+ const responseCodes = new Set<number>();
675
+ const responseRe =
676
+ /\b(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\([^)]*?\bstatus_code\s*=\s*([^,)\n]+)/g;
677
+ for (const match of bodyText.matchAll(responseRe)) {
678
+ const code = parseFastApiStatusValue(match[1].trim());
679
+ if (code === undefined) sawDynamic = true;
680
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code)) responseCodes.add(code);
681
+ }
682
+
683
+ // 3. `<paramName>.status_code = N` — mutation on the injected Response
684
+ // parameter. The parameter name varies (`response`, `resp`, `r`, `out`,
685
+ // custom names — Codex impl-review #2). Match any identifier prefix
686
+ // rather than a name whitelist; the API_SUCCESS_STATUS_CODES filter
687
+ // keeps the noise tax low.
688
+ const mutationCodes = new Set<number>();
689
+ // `=(?!=)` distinguishes assignment from `==` comparison so
690
+ // `if response.status_code == 200:` doesn't masquerade as a dynamic
691
+ // mutation (forge round, Claude engine).
692
+ const mutateRe = /\b[A-Za-z_]\w*\.status_code\s*=(?!=)\s*([^\n;]+)/g;
693
+ for (const match of bodyText.matchAll(mutateRe)) {
694
+ const code = parseFastApiStatusValue(match[1].trim());
695
+ if (code === undefined) sawDynamic = true;
696
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code)) mutationCodes.add(code);
697
+ }
698
+
699
+ if (sawDynamic) return { codes: undefined, resolved: false };
700
+
701
+ // Plain return paths inherit the route's "primary" success code, computed
702
+ // as: mutation > decorator > FastAPI default 200. When a mutation is
703
+ // present we treat it as the plain-return code (the conditional-mutation
704
+ // case is a documented v1 false-negative — would require control-flow
705
+ // analysis to disambiguate).
706
+ const plainReturnRe =
707
+ /\breturn\b(?!\s+(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\()/;
708
+ const hasPlainReturn = plainReturnRe.test(bodyText);
709
+
710
+ const final = new Set<number>();
711
+
712
+ if (hasPlainReturn) {
713
+ if (mutationCodes.size > 0) {
714
+ for (const c of mutationCodes) final.add(c);
715
+ } else if (decoratorCode !== undefined) {
716
+ final.add(decoratorCode);
717
+ } else {
718
+ final.add(FASTAPI_DEFAULT_SUCCESS_STATUS);
719
+ }
720
+ } else if (decoratorCode !== undefined && responseCodes.size === 0 && mutationCodes.size === 0) {
721
+ // Handler with no plain return, no Response, no mutation — likely an
722
+ // implicit-None-return stub or all-raise. Decorator is the only signal.
723
+ final.add(decoratorCode);
724
+ }
725
+
726
+ // Response and mutation codes ALWAYS contribute (they're explicit choices
727
+ // for their respective return paths).
728
+ for (const c of responseCodes) final.add(c);
729
+ for (const c of mutationCodes) final.add(c);
730
+
731
+ return {
732
+ codes: Array.from(final).sort((a, b) => a - b),
733
+ resolved: true,
734
+ };
735
+ }
736
+
737
+ function parseFastApiStatusValue(val: string): number | undefined {
738
+ const trimmed = val.trim();
739
+ // Literal 3-digit int.
740
+ const litMatch = trimmed.match(/^(\d{3})$/);
741
+ if (litMatch) return Number(litMatch[1]);
742
+ // status.HTTP_NNN_NAME / starlette.status.HTTP_NNN_NAME / fastapi.status.HTTP_NNN_NAME.
743
+ const httpMatch = trimmed.match(/HTTP_(\d{3})_/);
744
+ if (httpMatch) return Number(httpMatch[1]);
745
+ return undefined;
746
+ }
747
+
748
+ // ── FastAPI pagination strategy ──────────────────────────────────────────
749
+ // Iterates the route handler's parameters and classifies each by name (or
750
+ // `Query(alias=...)` literal alias when present) against page/offset/cursor
751
+ // anchor sets. Returns:
752
+ // - `none` / resolved=true — handler reads no anchor params (and no opaque
753
+ // paths to query data).
754
+ // - `page` / `offset` / `cursor` / resolved=true — handler reads exactly
755
+ // one family.
756
+ // - `mixed` / resolved=true — handler reads multiple families.
757
+ // - `undefined` / resolved=false — handler has a `Request` parameter,
758
+ // `**kwargs`, or a `Query(alias=<dynamic>)` we can't statically resolve.
759
+ function extractFastApiPaginationStrategy(
760
+ fnDef: Parser.SyntaxNode,
761
+ source: string,
762
+ ): {
763
+ strategy: 'page' | 'offset' | 'cursor' | 'mixed' | 'none' | undefined;
764
+ resolved: boolean;
765
+ } {
766
+ const paramsNode = fnDef.childForFieldName('parameters');
767
+ if (!paramsNode) return { strategy: 'none', resolved: true };
768
+
769
+ const families = new Set<'page' | 'offset' | 'cursor'>();
770
+ let sawOpaque = false;
771
+
772
+ for (const child of paramsNode.namedChildren) {
773
+ // **kwargs — handler may read any query key dynamically; opaque.
774
+ if (child.type === 'dictionary_splat_pattern') {
775
+ sawOpaque = true;
776
+ continue;
777
+ }
778
+ // *args — positional spread, irrelevant for query keys but rare in
779
+ // FastAPI handlers; keep silent.
780
+ if (child.type === 'list_splat_pattern') continue;
781
+
782
+ // Drop typing wrappers to find the param identifier.
783
+ const paramName = extractParamName(child);
784
+ if (!paramName) continue;
785
+
786
+ // `request: Request` — handler may call `request.query_params.get(...)`
787
+ // arbitrarily; mark opaque.
788
+ const typeText = extractParamTypeText(child, source);
789
+ if (typeText && /\bRequest\b/.test(typeText)) {
790
+ sawOpaque = true;
791
+ continue;
792
+ }
793
+
794
+ // Default-value AND type expression both can carry a `Query(alias="...")`
795
+ // call. Modern FastAPI (≥0.95) puts the call inside the type annotation
796
+ // via `Annotated[int, Query(alias="page")]` (Gemini/OpenCode impl-review).
797
+ // Older / classic syntax puts it in the default: `Query(0, alias="page")`.
798
+ // Check both — default-value form takes precedence when both are present.
799
+ const defaultText = extractParamDefaultText(child, source);
800
+ const aliasFromDefault = extractQueryAlias(defaultText);
801
+ const aliasFromType = aliasFromDefault.alias === undefined ? extractQueryAlias(typeText) : aliasFromDefault;
802
+ let key = paramName;
803
+ if (aliasFromDefault.opaque || aliasFromType.opaque) {
804
+ sawOpaque = true;
805
+ continue;
806
+ }
807
+ if (aliasFromDefault.alias) key = aliasFromDefault.alias;
808
+ else if (aliasFromType.alias) key = aliasFromType.alias;
809
+
810
+ const family = classifyPyAnchor(key);
811
+ if (family) families.add(family);
812
+ }
813
+
814
+ if (sawOpaque) return { strategy: undefined, resolved: false };
815
+ if (families.size === 0) return { strategy: 'none', resolved: true };
816
+ if (families.size === 1) return { strategy: [...families][0], resolved: true };
817
+ return { strategy: 'mixed', resolved: true };
818
+ }
819
+
820
+ function extractParamName(node: Parser.SyntaxNode): string | undefined {
821
+ if (node.type === 'identifier') return node.text;
822
+ if (node.type === 'typed_parameter' || node.type === 'typed_default_parameter' || node.type === 'default_parameter') {
823
+ const nameChild = node.childForFieldName('name') ?? node.namedChildren.find((c) => c.type === 'identifier');
824
+ if (nameChild) return nameChild.text;
825
+ }
826
+ return undefined;
827
+ }
828
+
829
+ function extractParamTypeText(node: Parser.SyntaxNode, source: string): string | undefined {
830
+ if (node.type !== 'typed_parameter' && node.type !== 'typed_default_parameter') return undefined;
831
+ const typeChild = node.childForFieldName('type');
832
+ if (typeChild) return source.substring(typeChild.startIndex, typeChild.endIndex);
833
+ return undefined;
834
+ }
835
+
836
+ function extractParamDefaultText(node: Parser.SyntaxNode, source: string): string | undefined {
837
+ if (node.type !== 'default_parameter' && node.type !== 'typed_default_parameter') return undefined;
838
+ const valueChild = node.childForFieldName('value');
839
+ if (valueChild) return source.substring(valueChild.startIndex, valueChild.endIndex);
840
+ return undefined;
841
+ }
842
+
843
+ function classifyPyAnchor(key: string): 'page' | 'offset' | 'cursor' | undefined {
844
+ if (PY_PAGE_ANCHORS.has(key)) return 'page';
845
+ if (PY_OFFSET_ANCHORS.has(key)) return 'offset';
846
+ if (PY_CURSOR_ANCHORS.has(key)) return 'cursor';
847
+ return undefined;
848
+ }
849
+
850
+ /** Extract a `Query(..., alias="...")` literal alias from a parameter's
851
+ * default-value or type-annotation text. Used to support both classic
852
+ * (`x = Query(0, alias="p")`) and modern (`x: Annotated[int, Query(alias="p")]`)
853
+ * FastAPI patterns. Returns `{alias?, opaque}` where `opaque=true` indicates
854
+ * a `Query(alias=<non-literal>)` we cannot statically resolve. */
855
+ function extractQueryAlias(text: string | undefined): { alias?: string; opaque: boolean } {
856
+ if (!text) return { opaque: false };
857
+ if (!/\bQuery\s*\(/.test(text)) return { opaque: false };
858
+ const aliasMatch = text.match(/\balias\s*=\s*['"]([^'"]+)['"]/);
859
+ if (aliasMatch) return { alias: aliasMatch[1], opaque: false };
860
+ if (/\balias\s*=/.test(text)) return { opaque: true };
861
+ return { opaque: false };
862
+ }
863
+
594
864
  function extractPythonHttpExceptionStatusCodes(text: string): readonly number[] | undefined {
595
865
  const codes = new Set<number>();
596
866
  const keywordRe = /HTTPException\s*\([^)]*status_code\s*=\s*(\d{3})/g;
@@ -115,4 +115,262 @@ def create_user(payload: UserCreate):
115
115
  expect(routes[0].payload.bodyValidationResolved).toBe(true);
116
116
  expect(routes[0].payload.validatedBodyFields).toEqual(['email', 'name']);
117
117
  });
118
+
119
+ // ── P2-A: FastAPI success status codes ─────────────────────────────────
120
+
121
+ it('extracts decorator status_code literal', () => {
122
+ const routes = routePayloads(`
123
+ @router.post("/items", status_code=201)
124
+ def create():
125
+ return {}
126
+ `);
127
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
128
+ expect(routes[0].payload.successStatusCodes).toEqual([201]);
129
+ });
130
+
131
+ it('extracts status.HTTP_NNN_NAME constant from decorator', () => {
132
+ const routes = routePayloads(`
133
+ from fastapi import status
134
+
135
+ @router.post("/items", status_code=status.HTTP_202_ACCEPTED)
136
+ def create():
137
+ return {}
138
+ `);
139
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
140
+ expect(routes[0].payload.successStatusCodes).toEqual([202]);
141
+ });
142
+
143
+ it('defaults to 200 when decorator has no status_code', () => {
144
+ // Codex plan-review #1: FastAPI default is always 200, regardless of method.
145
+ const routes = routePayloads(`
146
+ @router.post("/items")
147
+ def create():
148
+ return {"id": 1}
149
+ `);
150
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
151
+ expect(routes[0].payload.successStatusCodes).toEqual([200]);
152
+ });
153
+
154
+ it('marks unresolved when decorator status_code is dynamic', () => {
155
+ const routes = routePayloads(`
156
+ DEFAULT_CODE = 201
157
+
158
+ @router.post("/items", status_code=DEFAULT_CODE)
159
+ def create():
160
+ return {}
161
+ `);
162
+ expect(routes[0].payload.successStatusCodesResolved).toBe(false);
163
+ });
164
+
165
+ it('extracts JSONResponse(status_code=N) from handler body', () => {
166
+ const routes = routePayloads(`
167
+ from fastapi.responses import JSONResponse
168
+
169
+ @router.post("/items")
170
+ def create():
171
+ return JSONResponse(status_code=201, content={"id": 1})
172
+ `);
173
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
174
+ // No plain return path → only the explicit JSONResponse code (201) — no 200 default added.
175
+ expect(routes[0].payload.successStatusCodes).toEqual([201]);
176
+ });
177
+
178
+ it('unions decorator default 200 with body-side Response codes when plain return paths exist', () => {
179
+ // Codex plan-review #3: handler with both an explicit Response branch
180
+ // and a plain `return data` branch emits a multi-2xx route.
181
+ const routes = routePayloads(`
182
+ from fastapi.responses import JSONResponse
183
+
184
+ @router.post("/items")
185
+ def create(data: dict):
186
+ if data.get("created"):
187
+ return JSONResponse(status_code=201, content=data)
188
+ return data
189
+ `);
190
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
191
+ expect(routes[0].payload.successStatusCodes).toEqual([200, 201]);
192
+ });
193
+
194
+ it('extracts response.status_code = N mutation', () => {
195
+ const routes = routePayloads(`
196
+ from fastapi import Response
197
+
198
+ @router.post("/items")
199
+ def create(response: Response):
200
+ response.status_code = 202
201
+ return {}
202
+ `);
203
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
204
+ expect(routes[0].payload.successStatusCodes).toEqual([202]);
205
+ });
206
+
207
+ // ── P2-A: FastAPI pagination strategy ──────────────────────────────────
208
+
209
+ it('classifies offset/skip/limit handler as offset family', () => {
210
+ const routes = routePayloads(`
211
+ @router.get("/items")
212
+ def list(skip: int = 0, limit: int = 10):
213
+ return []
214
+ `);
215
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
216
+ expect(routes[0].payload.paginationStrategy).toBe('offset');
217
+ });
218
+
219
+ it('classifies page handler as page family', () => {
220
+ const routes = routePayloads(`
221
+ @router.get("/items")
222
+ def list(page: int = 1, page_size: int = 20):
223
+ return []
224
+ `);
225
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
226
+ expect(routes[0].payload.paginationStrategy).toBe('page');
227
+ });
228
+
229
+ it('classifies cursor handler as cursor family', () => {
230
+ const routes = routePayloads(`
231
+ @router.get("/items")
232
+ def list(cursor: str | None = None, limit: int = 10):
233
+ return []
234
+ `);
235
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
236
+ expect(routes[0].payload.paginationStrategy).toBe('cursor');
237
+ });
238
+
239
+ it('classifies handler with no anchor params as none', () => {
240
+ const routes = routePayloads(`
241
+ @router.get("/items")
242
+ def list(filter: str = ""):
243
+ return []
244
+ `);
245
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
246
+ expect(routes[0].payload.paginationStrategy).toBe('none');
247
+ });
248
+
249
+ it('classifies multi-family handler as mixed', () => {
250
+ const routes = routePayloads(`
251
+ @router.get("/items")
252
+ def list(page: int = 1, cursor: str | None = None):
253
+ return []
254
+ `);
255
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
256
+ expect(routes[0].payload.paginationStrategy).toBe('mixed');
257
+ });
258
+
259
+ it('marks unresolved when handler has Request parameter (opaque)', () => {
260
+ const routes = routePayloads(`
261
+ from fastapi import Request
262
+
263
+ @router.get("/items")
264
+ def list(request: Request):
265
+ return []
266
+ `);
267
+ expect(routes[0].payload.paginationStrategyResolved).toBe(false);
268
+ });
269
+
270
+ it('marks unresolved when handler has **kwargs (opaque)', () => {
271
+ const routes = routePayloads(`
272
+ @router.get("/items")
273
+ def list(**kwargs):
274
+ return []
275
+ `);
276
+ expect(routes[0].payload.paginationStrategyResolved).toBe(false);
277
+ });
278
+
279
+ it('uses Query(alias="...") literal alias over the param name', () => {
280
+ // Codex plan-review #5: wire-key may differ from param identifier.
281
+ // `skip: int = Query(0, alias="offset")` — param name `skip` is offset
282
+ // family already; verify the alias takes precedence cleanly when the
283
+ // param name itself isn't an anchor.
284
+ const routes = routePayloads(`
285
+ from fastapi import Query
286
+
287
+ @router.get("/items")
288
+ def list(start: int = Query(0, alias="offset"), limit: int = 10):
289
+ return []
290
+ `);
291
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
292
+ expect(routes[0].payload.paginationStrategy).toBe('offset');
293
+ });
294
+
295
+ it('handles modern FastAPI Annotated[T, Query(alias="...")] (Gemini/OpenCode impl-review)', () => {
296
+ // FastAPI 0.95+ idiomatic syntax: Query() lives inside the type annotation
297
+ // via Annotated[]. Without scanning typeText we'd fall back to the param
298
+ // identifier (`p` here) and miss the page anchor.
299
+ const routes = routePayloads(`
300
+ from typing import Annotated
301
+ from fastapi import Query
302
+
303
+ @router.get("/items")
304
+ def list(p: Annotated[int, Query(alias="page")] = 1, size: int = 10):
305
+ return []
306
+ `);
307
+ expect(routes[0].payload.paginationStrategyResolved).toBe(true);
308
+ expect(routes[0].payload.paginationStrategy).toBe('page');
309
+ });
310
+
311
+ it('handles multi-line decorator with status_code on subsequent line (Gemini #4)', () => {
312
+ // Long decorators wrap onto multiple lines in production code. The
313
+ // extractor must pick up `status_code=` regardless of where it falls.
314
+ const routes = routePayloads(`
315
+ @router.post(
316
+ "/items",
317
+ response_model=Item,
318
+ status_code=202,
319
+ )
320
+ def create():
321
+ return {}
322
+ `);
323
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
324
+ expect(routes[0].payload.successStatusCodes).toEqual([202]);
325
+ });
326
+
327
+ it('decorator status_code is dead when ALL returns use explicit Response (Codex impl-review #1)', () => {
328
+ // FastAPI uses the returned Response's status, not the decorator. Without
329
+ // this fix, mapper would falsely report multi-2xx [201, 202] for a route
330
+ // that only emits 202 — letting clients checking 201 pass the gate.
331
+ const routes = routePayloads(`
332
+ from fastapi.responses import JSONResponse
333
+
334
+ @router.post("/items", status_code=201)
335
+ def create():
336
+ return JSONResponse(status_code=202, content={})
337
+ `);
338
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
339
+ expect(routes[0].payload.successStatusCodes).toEqual([202]);
340
+ });
341
+
342
+ it('does NOT treat `== 200` comparison as a dynamic mutation (forge round, Claude engine)', () => {
343
+ // `if response.status_code == 200:` is a comparison, not an assignment.
344
+ // Without the `=(?!=)` lookahead, the mutation regex captured `200) ...`
345
+ // as a dynamic RHS and incorrectly marked the route resolved=false.
346
+ const routes = routePayloads(`
347
+ from fastapi import Response
348
+
349
+ @router.post("/items", status_code=201)
350
+ def create(response: Response):
351
+ if response.status_code == 200:
352
+ return {"unreachable": True}
353
+ return {"id": 1}
354
+ `);
355
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
356
+ // Decorator says 201, plain returns inherit it. The `==` comparison
357
+ // contributes nothing.
358
+ expect(routes[0].payload.successStatusCodes).toEqual([201]);
359
+ });
360
+
361
+ it('detects status_code mutation under non-conventional Response param name (Codex impl-review #2)', () => {
362
+ // Param injected as `out: Response` instead of the conventional
363
+ // `response`. Without broadened receiver matching, mutation is missed and
364
+ // route falls back to default 200, FP-firing on clients checking 201.
365
+ const routes = routePayloads(`
366
+ from fastapi import Response
367
+
368
+ @router.post("/items")
369
+ def create(out: Response):
370
+ out.status_code = 201
371
+ return {}
372
+ `);
373
+ expect(routes[0].payload.successStatusCodesResolved).toBe(true);
374
+ expect(routes[0].payload.successStatusCodes).toEqual([201]);
375
+ });
118
376
  });