@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 +259 -2
- package/package.json +3 -3
- package/src/mapper.ts +271 -1
- package/tests/route-entrypoints.test.ts +258 -0
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.
|
|
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.
|
|
12
|
-
"@kernlang/review": "3.4.6-canary.
|
|
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(
|
|
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
|
});
|