@kernlang/review-python 3.4.6-canary.45.1.130ca3d2 → 3.4.6-canary.46.1.19dcfc19
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/extractors/dependency.d.ts +3 -0
- package/dist/mapper/extractors/dependency.js +52 -0
- package/dist/mapper/extractors/effect.d.ts +3 -0
- package/dist/mapper/extractors/effect.js +74 -0
- package/dist/mapper/extractors/entrypoint.d.ts +3 -0
- package/dist/mapper/extractors/entrypoint.js +225 -0
- package/dist/mapper/extractors/error.d.ts +5 -0
- package/dist/mapper/extractors/error.js +129 -0
- package/dist/mapper/extractors/fastapi-pagination.d.ts +5 -0
- package/dist/mapper/extractors/fastapi-pagination.js +119 -0
- package/dist/mapper/extractors/fastapi-status.d.ts +6 -0
- package/dist/mapper/extractors/fastapi-status.js +115 -0
- package/dist/mapper/extractors/guard.d.ts +3 -0
- package/dist/mapper/extractors/guard.js +115 -0
- package/dist/mapper/extractors/pydantic.d.ts +13 -0
- package/dist/mapper/extractors/pydantic.js +61 -0
- package/dist/mapper/extractors/state-mutation.d.ts +3 -0
- package/dist/mapper/extractors/state-mutation.js +63 -0
- package/dist/mapper/helpers/ast.d.ts +9 -0
- package/dist/mapper/helpers/ast.js +62 -0
- package/dist/mapper/helpers/types.d.ts +7 -0
- package/dist/mapper/helpers/types.js +168 -0
- package/dist/mapper/index.d.ts +8 -0
- package/dist/mapper/index.js +42 -0
- package/dist/mapper/signatures.d.ts +17 -0
- package/dist/mapper/signatures.js +87 -0
- package/dist/mapper.d.ts +1 -8
- package/dist/mapper.js +1 -1286
- package/package.json +3 -3
- package/src/mapper/extractors/dependency.ts +60 -0
- package/src/mapper/extractors/effect.ts +84 -0
- package/src/mapper/extractors/entrypoint.ts +272 -0
- package/src/mapper/extractors/error.ts +152 -0
- package/src/mapper/extractors/fastapi-pagination.ts +117 -0
- package/src/mapper/extractors/fastapi-status.ts +119 -0
- package/src/mapper/extractors/guard.ts +114 -0
- package/src/mapper/extractors/pydantic.ts +74 -0
- package/src/mapper/extractors/state-mutation.ts +72 -0
- package/src/mapper/helpers/ast.ts +72 -0
- package/src/mapper/helpers/types.ts +164 -0
- package/src/mapper/index.ts +50 -0
- package/src/mapper/signatures.ts +94 -0
- package/src/mapper.ts +1 -1388
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { PY_CURSOR_ANCHORS, PY_OFFSET_ANCHORS, PY_PAGE_ANCHORS } from '../signatures.js';
|
|
3
|
+
|
|
4
|
+
// Iterates the route handler's parameters and classifies each by name (or
|
|
5
|
+
// `Query(alias=...)` literal alias when present) against page/offset/cursor
|
|
6
|
+
// anchor sets. Returns:
|
|
7
|
+
// - `none` / resolved=true — handler reads no anchor params (and no opaque
|
|
8
|
+
// paths to query data).
|
|
9
|
+
// - `page` / `offset` / `cursor` / resolved=true — handler reads exactly
|
|
10
|
+
// one family.
|
|
11
|
+
// - `mixed` / resolved=true — handler reads multiple families.
|
|
12
|
+
// - `undefined` / resolved=false — handler has a `Request` parameter,
|
|
13
|
+
// `**kwargs`, or a `Query(alias=<dynamic>)` we can't statically resolve.
|
|
14
|
+
export function extractFastApiPaginationStrategy(
|
|
15
|
+
fnDef: Parser.SyntaxNode,
|
|
16
|
+
source: string,
|
|
17
|
+
): {
|
|
18
|
+
strategy: 'page' | 'offset' | 'cursor' | 'mixed' | 'none' | undefined;
|
|
19
|
+
resolved: boolean;
|
|
20
|
+
} {
|
|
21
|
+
const paramsNode = fnDef.childForFieldName('parameters');
|
|
22
|
+
if (!paramsNode) return { strategy: 'none', resolved: true };
|
|
23
|
+
|
|
24
|
+
const families = new Set<'page' | 'offset' | 'cursor'>();
|
|
25
|
+
let sawOpaque = false;
|
|
26
|
+
|
|
27
|
+
for (const child of paramsNode.namedChildren) {
|
|
28
|
+
// **kwargs — handler may read any query key dynamically; opaque.
|
|
29
|
+
if (child.type === 'dictionary_splat_pattern') {
|
|
30
|
+
sawOpaque = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// *args — positional spread, irrelevant for query keys but rare in
|
|
34
|
+
// FastAPI handlers; keep silent.
|
|
35
|
+
if (child.type === 'list_splat_pattern') continue;
|
|
36
|
+
|
|
37
|
+
// Drop typing wrappers to find the param identifier.
|
|
38
|
+
const paramName = extractParamName(child);
|
|
39
|
+
if (!paramName) continue;
|
|
40
|
+
|
|
41
|
+
// `request: Request` — handler may call `request.query_params.get(...)`
|
|
42
|
+
// arbitrarily; mark opaque.
|
|
43
|
+
const typeText = extractParamTypeText(child, source);
|
|
44
|
+
if (typeText && /\bRequest\b/.test(typeText)) {
|
|
45
|
+
sawOpaque = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default-value AND type expression both can carry a `Query(alias="...")`
|
|
50
|
+
// call. Modern FastAPI (≥0.95) puts the call inside the type annotation
|
|
51
|
+
// via `Annotated[int, Query(alias="page")]` (Gemini/OpenCode impl-review).
|
|
52
|
+
// Older / classic syntax puts it in the default: `Query(0, alias="page")`.
|
|
53
|
+
// Check both — default-value form takes precedence when both are present.
|
|
54
|
+
const defaultText = extractParamDefaultText(child, source);
|
|
55
|
+
const aliasFromDefault = extractQueryAlias(defaultText);
|
|
56
|
+
const aliasFromType = aliasFromDefault.alias === undefined ? extractQueryAlias(typeText) : aliasFromDefault;
|
|
57
|
+
let key = paramName;
|
|
58
|
+
if (aliasFromDefault.opaque || aliasFromType.opaque) {
|
|
59
|
+
sawOpaque = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (aliasFromDefault.alias) key = aliasFromDefault.alias;
|
|
63
|
+
else if (aliasFromType.alias) key = aliasFromType.alias;
|
|
64
|
+
|
|
65
|
+
const family = classifyPyAnchor(key);
|
|
66
|
+
if (family) families.add(family);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (sawOpaque) return { strategy: undefined, resolved: false };
|
|
70
|
+
if (families.size === 0) return { strategy: 'none', resolved: true };
|
|
71
|
+
if (families.size === 1) return { strategy: [...families][0], resolved: true };
|
|
72
|
+
return { strategy: 'mixed', resolved: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractParamName(node: Parser.SyntaxNode): string | undefined {
|
|
76
|
+
if (node.type === 'identifier') return node.text;
|
|
77
|
+
if (node.type === 'typed_parameter' || node.type === 'typed_default_parameter' || node.type === 'default_parameter') {
|
|
78
|
+
const nameChild = node.childForFieldName('name') ?? node.namedChildren.find((c) => c.type === 'identifier');
|
|
79
|
+
if (nameChild) return nameChild.text;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractParamTypeText(node: Parser.SyntaxNode, source: string): string | undefined {
|
|
85
|
+
if (node.type !== 'typed_parameter' && node.type !== 'typed_default_parameter') return undefined;
|
|
86
|
+
const typeChild = node.childForFieldName('type');
|
|
87
|
+
if (typeChild) return source.substring(typeChild.startIndex, typeChild.endIndex);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractParamDefaultText(node: Parser.SyntaxNode, source: string): string | undefined {
|
|
92
|
+
if (node.type !== 'default_parameter' && node.type !== 'typed_default_parameter') return undefined;
|
|
93
|
+
const valueChild = node.childForFieldName('value');
|
|
94
|
+
if (valueChild) return source.substring(valueChild.startIndex, valueChild.endIndex);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function classifyPyAnchor(key: string): 'page' | 'offset' | 'cursor' | undefined {
|
|
99
|
+
if (PY_PAGE_ANCHORS.has(key)) return 'page';
|
|
100
|
+
if (PY_OFFSET_ANCHORS.has(key)) return 'offset';
|
|
101
|
+
if (PY_CURSOR_ANCHORS.has(key)) return 'cursor';
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extract a `Query(..., alias="...")` literal alias from a parameter's
|
|
106
|
+
* default-value or type-annotation text. Used to support both classic
|
|
107
|
+
* (`x = Query(0, alias="p")`) and modern (`x: Annotated[int, Query(alias="p")]`)
|
|
108
|
+
* FastAPI patterns. Returns `{alias?, opaque}` where `opaque=true` indicates
|
|
109
|
+
* a `Query(alias=<non-literal>)` we cannot statically resolve. */
|
|
110
|
+
function extractQueryAlias(text: string | undefined): { alias?: string; opaque: boolean } {
|
|
111
|
+
if (!text) return { opaque: false };
|
|
112
|
+
if (!/\bQuery\s*\(/.test(text)) return { opaque: false };
|
|
113
|
+
const aliasMatch = text.match(/\balias\s*=\s*['"]([^'"]+)['"]/);
|
|
114
|
+
if (aliasMatch) return { alias: aliasMatch[1], opaque: false };
|
|
115
|
+
if (/\balias\s*=/.test(text)) return { opaque: true };
|
|
116
|
+
return { opaque: false };
|
|
117
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { FASTAPI_DEFAULT_SUCCESS_STATUS, PY_API_SUCCESS_STATUS_CODES } from '../signatures.js';
|
|
3
|
+
|
|
4
|
+
// Phase 2 of cross-stack `status-code-drift`. Populates the
|
|
5
|
+
// `successStatusCodes` / `successStatusCodesResolved` payload fields so the
|
|
6
|
+
// rule can flag clients checking a 2xx the FastAPI server doesn't emit.
|
|
7
|
+
//
|
|
8
|
+
// Sources of evidence (per buddy plan-review consensus):
|
|
9
|
+
// 1. Decorator `status_code=N` (literal) or `status_code=status.HTTP_NNN_*`.
|
|
10
|
+
// 2. Body-side `Response(status_code=N)` / `JSONResponse(...)` returns.
|
|
11
|
+
// 3. Body-side `<param>.status_code = N` mutations (FastAPI's documented
|
|
12
|
+
// pattern for routes that take a `Response` parameter).
|
|
13
|
+
// 4. When the decorator omits status_code AND the body has no explicit
|
|
14
|
+
// Response / mutation, default to 200 — FastAPI's documented default
|
|
15
|
+
// regardless of HTTP method. Codex caught Gemini's POST→201 premise as
|
|
16
|
+
// wrong (FastAPI docs:
|
|
17
|
+
// https://fastapi.tiangolo.com/tutorial/response-status-code/).
|
|
18
|
+
//
|
|
19
|
+
// Marked unresolved when:
|
|
20
|
+
// - Decorator status_code is set to a non-literal/non-status-constant
|
|
21
|
+
// expression (variable, function call).
|
|
22
|
+
// - Any `Response(status_code=...)` / `<x>.status_code = ...` RHS is dynamic.
|
|
23
|
+
export function extractFastApiSuccessStatusCodes(
|
|
24
|
+
decText: string,
|
|
25
|
+
fnDef: Parser.SyntaxNode,
|
|
26
|
+
source: string,
|
|
27
|
+
): { codes: readonly number[] | undefined; resolved: boolean } {
|
|
28
|
+
let sawDynamic = false;
|
|
29
|
+
|
|
30
|
+
// 1. Decorator `status_code=N` — applies ONLY to plain `return data` paths.
|
|
31
|
+
// For routes whose return paths all use explicit Response/JSONResponse,
|
|
32
|
+
// the decorator code is dead (Codex impl-review #1).
|
|
33
|
+
const decStatusMatch = decText.match(/\bstatus_code\s*=\s*([^,)]+)/);
|
|
34
|
+
let decoratorCode: number | undefined;
|
|
35
|
+
if (decStatusMatch) {
|
|
36
|
+
const code = parseFastApiStatusValue(decStatusMatch[1].trim());
|
|
37
|
+
if (code === undefined) sawDynamic = true;
|
|
38
|
+
else if (PY_API_SUCCESS_STATUS_CODES.has(code)) decoratorCode = code;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((c) => c.type === 'block');
|
|
42
|
+
const bodyText = body ? source.substring(body.startIndex, body.endIndex) : '';
|
|
43
|
+
|
|
44
|
+
// 2. Response(status_code=N) / JSONResponse(...) etc. — applies only to
|
|
45
|
+
// that specific return path. Multiple Response codes contribute a
|
|
46
|
+
// multi-2xx route.
|
|
47
|
+
const responseCodes = new Set<number>();
|
|
48
|
+
const responseRe =
|
|
49
|
+
/\b(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\([^)]*?\bstatus_code\s*=\s*([^,)\n]+)/g;
|
|
50
|
+
for (const match of bodyText.matchAll(responseRe)) {
|
|
51
|
+
const code = parseFastApiStatusValue(match[1].trim());
|
|
52
|
+
if (code === undefined) sawDynamic = true;
|
|
53
|
+
else if (PY_API_SUCCESS_STATUS_CODES.has(code)) responseCodes.add(code);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. `<paramName>.status_code = N` — mutation on the injected Response
|
|
57
|
+
// parameter. The parameter name varies (`response`, `resp`, `r`, `out`,
|
|
58
|
+
// custom names — Codex impl-review #2). Match any identifier prefix
|
|
59
|
+
// rather than a name whitelist; the API_SUCCESS_STATUS_CODES filter
|
|
60
|
+
// keeps the noise tax low.
|
|
61
|
+
const mutationCodes = new Set<number>();
|
|
62
|
+
// `=(?!=)` distinguishes assignment from `==` comparison so
|
|
63
|
+
// `if response.status_code == 200:` doesn't masquerade as a dynamic
|
|
64
|
+
// mutation (forge round, Claude engine).
|
|
65
|
+
const mutateRe = /\b[A-Za-z_]\w*\.status_code\s*=(?!=)\s*([^\n;]+)/g;
|
|
66
|
+
for (const match of bodyText.matchAll(mutateRe)) {
|
|
67
|
+
const code = parseFastApiStatusValue(match[1].trim());
|
|
68
|
+
if (code === undefined) sawDynamic = true;
|
|
69
|
+
else if (PY_API_SUCCESS_STATUS_CODES.has(code)) mutationCodes.add(code);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (sawDynamic) return { codes: undefined, resolved: false };
|
|
73
|
+
|
|
74
|
+
// Plain return paths inherit the route's "primary" success code, computed
|
|
75
|
+
// as: mutation > decorator > FastAPI default 200. When a mutation is
|
|
76
|
+
// present we treat it as the plain-return code (the conditional-mutation
|
|
77
|
+
// case is a documented v1 false-negative — would require control-flow
|
|
78
|
+
// analysis to disambiguate).
|
|
79
|
+
const plainReturnRe =
|
|
80
|
+
/\breturn\b(?!\s+(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\()/;
|
|
81
|
+
const hasPlainReturn = plainReturnRe.test(bodyText);
|
|
82
|
+
|
|
83
|
+
const final = new Set<number>();
|
|
84
|
+
|
|
85
|
+
if (hasPlainReturn) {
|
|
86
|
+
if (mutationCodes.size > 0) {
|
|
87
|
+
for (const c of mutationCodes) final.add(c);
|
|
88
|
+
} else if (decoratorCode !== undefined) {
|
|
89
|
+
final.add(decoratorCode);
|
|
90
|
+
} else {
|
|
91
|
+
final.add(FASTAPI_DEFAULT_SUCCESS_STATUS);
|
|
92
|
+
}
|
|
93
|
+
} else if (decoratorCode !== undefined && responseCodes.size === 0 && mutationCodes.size === 0) {
|
|
94
|
+
// Handler with no plain return, no Response, no mutation — likely an
|
|
95
|
+
// implicit-None-return stub or all-raise. Decorator is the only signal.
|
|
96
|
+
final.add(decoratorCode);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Response and mutation codes ALWAYS contribute (they're explicit choices
|
|
100
|
+
// for their respective return paths).
|
|
101
|
+
for (const c of responseCodes) final.add(c);
|
|
102
|
+
for (const c of mutationCodes) final.add(c);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
codes: Array.from(final).sort((a, b) => a - b),
|
|
106
|
+
resolved: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseFastApiStatusValue(val: string): number | undefined {
|
|
111
|
+
const trimmed = val.trim();
|
|
112
|
+
// Literal 3-digit int.
|
|
113
|
+
const litMatch = trimmed.match(/^(\d{3})$/);
|
|
114
|
+
if (litMatch) return Number(litMatch[1]);
|
|
115
|
+
// status.HTTP_NNN_NAME / starlette.status.HTTP_NNN_NAME / fastapi.status.HTTP_NNN_NAME.
|
|
116
|
+
const httpMatch = trimmed.match(/HTTP_(\d{3})_/);
|
|
117
|
+
if (httpMatch) return Number(httpMatch[1]);
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ConceptNode } from '@kernlang/core';
|
|
2
|
+
import { conceptId } from '@kernlang/core';
|
|
3
|
+
import type Parser from 'tree-sitter';
|
|
4
|
+
import { getContainerId, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
|
|
5
|
+
|
|
6
|
+
export function extractGuards(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void {
|
|
7
|
+
// 1. Auth decorators (tree-sitter: decorated_definition → decorator + function_definition)
|
|
8
|
+
walkNodes(root, 'decorated_definition', (node) => {
|
|
9
|
+
for (const child of node.children) {
|
|
10
|
+
if (child.type !== 'decorator') continue;
|
|
11
|
+
const decText = source.substring(child.startIndex, child.endIndex);
|
|
12
|
+
if (/@(login_required|requires_auth|permission_required|auth_required|authenticated)/.test(decText)) {
|
|
13
|
+
nodes.push({
|
|
14
|
+
id: conceptId(filePath, 'guard', child.startIndex),
|
|
15
|
+
kind: 'guard',
|
|
16
|
+
primarySpan: nodeSpan(filePath, child),
|
|
17
|
+
evidence: nodeText(source, child, 100),
|
|
18
|
+
confidence: 1.0,
|
|
19
|
+
language: 'py',
|
|
20
|
+
containerId: getContainerId(node, filePath),
|
|
21
|
+
payload: {
|
|
22
|
+
kind: 'guard',
|
|
23
|
+
subtype: 'auth',
|
|
24
|
+
name: decText.replace('@', '').split('(')[0].trim(),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 2. Pydantic validation: BaseModel.model_validate()
|
|
32
|
+
walkNodes(root, 'call', (node) => {
|
|
33
|
+
const func = node.childForFieldName('function');
|
|
34
|
+
if (func?.text.includes('model_validate')) {
|
|
35
|
+
nodes.push({
|
|
36
|
+
id: conceptId(filePath, 'guard', node.startIndex),
|
|
37
|
+
kind: 'guard',
|
|
38
|
+
primarySpan: nodeSpan(filePath, node),
|
|
39
|
+
evidence: nodeText(source, node, 100),
|
|
40
|
+
confidence: 0.9,
|
|
41
|
+
language: 'py',
|
|
42
|
+
containerId: getContainerId(node, filePath),
|
|
43
|
+
payload: { kind: 'guard', subtype: 'validation', name: 'pydantic' },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// 3. FastAPI `Depends(...)` injection — route handler parameter with a
|
|
49
|
+
// `Depends` default is the idiomatic FastAPI auth/validation guard.
|
|
50
|
+
// Example:
|
|
51
|
+
// @router.get("/me")
|
|
52
|
+
// def me(user: User = Depends(get_current_user)):
|
|
53
|
+
// Classified by the dependency function name:
|
|
54
|
+
// - `get_current_user` / `current_user` / `require_auth` / `*_user` → 'auth'
|
|
55
|
+
// - `verify_*` / `validate_*` → 'validation'
|
|
56
|
+
// - `rate_limit_*` / `check_rate_limit` → 'rate-limit'
|
|
57
|
+
// - everything else → 'policy'
|
|
58
|
+
// Feeds the `auth-drift` cross-stack rule.
|
|
59
|
+
walkNodes(root, 'default_parameter', (node) => {
|
|
60
|
+
const val = node.childForFieldName('value');
|
|
61
|
+
if (!val || val.type !== 'call') return;
|
|
62
|
+
const func = val.childForFieldName('function');
|
|
63
|
+
if (!func || func.text !== 'Depends') return;
|
|
64
|
+
const args = val.childForFieldName('arguments');
|
|
65
|
+
if (!args) return;
|
|
66
|
+
const posArg = args.namedChildren.find((c) => c.type === 'identifier' || c.type === 'attribute');
|
|
67
|
+
const depName = posArg ? posArg.text : 'Depends';
|
|
68
|
+
const subtype = classifyDependency(depName);
|
|
69
|
+
nodes.push({
|
|
70
|
+
id: conceptId(filePath, 'guard', node.startIndex),
|
|
71
|
+
kind: 'guard',
|
|
72
|
+
primarySpan: nodeSpan(filePath, node),
|
|
73
|
+
evidence: nodeText(source, node, 120),
|
|
74
|
+
confidence: 0.85,
|
|
75
|
+
language: 'py',
|
|
76
|
+
containerId: getContainerId(node, filePath),
|
|
77
|
+
payload: { kind: 'guard', subtype, name: depName },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 4. Early return/raise after auth check: if not request.user: raise/return
|
|
82
|
+
walkNodes(root, 'if_statement', (node) => {
|
|
83
|
+
const cond = node.childForFieldName('condition');
|
|
84
|
+
if (cond && /\b(user|auth|request\.user)\b/.test(cond.text)) {
|
|
85
|
+
const block = node.namedChildren.find((c) => c.type === 'block');
|
|
86
|
+
if (block) {
|
|
87
|
+
const firstStmt = block.namedChildren[0];
|
|
88
|
+
if (firstStmt && (firstStmt.type === 'return_statement' || firstStmt.type === 'raise_statement')) {
|
|
89
|
+
nodes.push({
|
|
90
|
+
id: conceptId(filePath, 'guard', node.startIndex),
|
|
91
|
+
kind: 'guard',
|
|
92
|
+
primarySpan: nodeSpan(filePath, node),
|
|
93
|
+
evidence: nodeText(source, node, 100),
|
|
94
|
+
confidence: 0.8,
|
|
95
|
+
language: 'py',
|
|
96
|
+
containerId: getContainerId(node, filePath),
|
|
97
|
+
payload: { kind: 'guard', subtype: 'auth' },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function classifyDependency(depName: string): 'auth' | 'validation' | 'rate-limit' | 'policy' {
|
|
106
|
+
// Strip module prefix (`auth.get_current_user` → `get_current_user`) so the
|
|
107
|
+
// heuristic looks at the final identifier where intent usually lives.
|
|
108
|
+
const tail = depName.split('.').pop() ?? depName;
|
|
109
|
+
if (/^(get_current_user|current_user|require_auth|authenticated|is_authenticated)$/i.test(tail)) return 'auth';
|
|
110
|
+
if (/_user$|^user$|auth/i.test(tail)) return 'auth';
|
|
111
|
+
if (/^(verify_|validate_)/i.test(tail)) return 'validation';
|
|
112
|
+
if (/rate_?limit/i.test(tail)) return 'rate-limit';
|
|
113
|
+
return 'policy';
|
|
114
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { coarsenPythonTypeAnnotation, type FieldTypeMap, type FieldTypeTag } from '../helpers/types.js';
|
|
3
|
+
|
|
4
|
+
export interface PydanticModel {
|
|
5
|
+
fields: readonly string[];
|
|
6
|
+
types: FieldTypeMap;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function collectPydanticModels(source: string): Map<string, PydanticModel> {
|
|
10
|
+
const models = new Map<string, PydanticModel>();
|
|
11
|
+
const classRe = /^class\s+([A-Za-z_]\w*)\s*\([^)]*BaseModel[^)]*\)\s*:/gm;
|
|
12
|
+
for (const match of source.matchAll(classRe)) {
|
|
13
|
+
const name = match[1];
|
|
14
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
15
|
+
const rest = source.slice(start);
|
|
16
|
+
const nextTopLevel = rest.search(/\n\S/);
|
|
17
|
+
const body = nextTopLevel === -1 ? rest : rest.slice(0, nextTopLevel);
|
|
18
|
+
const fields: string[] = [];
|
|
19
|
+
const types: Record<string, FieldTypeTag> = {};
|
|
20
|
+
// Capture annotations alongside names. The annotation runs until either
|
|
21
|
+
// an `=` (default value) or end-of-line / inline comment. Multiline
|
|
22
|
+
// annotations (`x: Annotated[\n str, Field(...)\n]`) are not handled —
|
|
23
|
+
// false-negative on the type tag, never false-positive.
|
|
24
|
+
const fieldRe = /^[ \t]+([A-Za-z_]\w*)[ \t]*:[ \t]*([^=#\n]+?)(?:[ \t]*=[^\n]*|[ \t]*#[^\n]*)?$/gm;
|
|
25
|
+
for (const fieldMatch of body.matchAll(fieldRe)) {
|
|
26
|
+
const field = fieldMatch[1];
|
|
27
|
+
if (field === 'model_config' || field === 'Config') continue;
|
|
28
|
+
fields.push(field);
|
|
29
|
+
const annotation = fieldMatch[2].trim();
|
|
30
|
+
types[field] = coarsenPythonTypeAnnotation(annotation);
|
|
31
|
+
}
|
|
32
|
+
if (fields.length > 0) {
|
|
33
|
+
models.set(name, { fields: fields.sort(), types: Object.freeze({ ...types }) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return models;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractFastApiBodyValidation(
|
|
40
|
+
fnDef: Parser.SyntaxNode,
|
|
41
|
+
source: string,
|
|
42
|
+
pydanticModels: ReadonlyMap<string, PydanticModel>,
|
|
43
|
+
): {
|
|
44
|
+
has: boolean;
|
|
45
|
+
fields: readonly string[] | undefined;
|
|
46
|
+
resolved: boolean;
|
|
47
|
+
types: FieldTypeMap | undefined;
|
|
48
|
+
} {
|
|
49
|
+
const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((child) => child.type === 'block');
|
|
50
|
+
const headerEnd = body ? body.startIndex : fnDef.endIndex;
|
|
51
|
+
const header = source.substring(fnDef.startIndex, headerEnd);
|
|
52
|
+
const fields = new Set<string>();
|
|
53
|
+
const types: Record<string, FieldTypeTag> = {};
|
|
54
|
+
let has = false;
|
|
55
|
+
const annotationRe = /([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/g;
|
|
56
|
+
for (const match of header.matchAll(annotationRe)) {
|
|
57
|
+
const model = pydanticModels.get(match[2]);
|
|
58
|
+
if (!model) continue;
|
|
59
|
+
has = true;
|
|
60
|
+
for (const field of model.fields) fields.add(field);
|
|
61
|
+
for (const [name, tag] of Object.entries(model.types)) {
|
|
62
|
+
// Only record concrete tags. 'unknown' for a key would shadow a
|
|
63
|
+
// concrete tag from another model parameter on the same handler
|
|
64
|
+
// (rare, but multi-arg handlers do exist), so skip them.
|
|
65
|
+
if (tag !== 'unknown') types[name] = tag;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
has,
|
|
70
|
+
fields: fields.size > 0 ? Array.from(fields).sort() : undefined,
|
|
71
|
+
resolved: fields.size > 0,
|
|
72
|
+
types: Object.keys(types).length > 0 ? Object.freeze({ ...types }) : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ConceptNode } from '@kernlang/core';
|
|
2
|
+
import { conceptId } from '@kernlang/core';
|
|
3
|
+
import type Parser from 'tree-sitter';
|
|
4
|
+
import { getContainerId, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
|
|
5
|
+
|
|
6
|
+
export function extractStateMutation(
|
|
7
|
+
root: Parser.SyntaxNode,
|
|
8
|
+
source: string,
|
|
9
|
+
filePath: string,
|
|
10
|
+
nodes: ConceptNode[],
|
|
11
|
+
): void {
|
|
12
|
+
// Track global keyword usage
|
|
13
|
+
const globalVarsInFile = new Set<string>();
|
|
14
|
+
walkNodes(root, 'global_statement', (node) => {
|
|
15
|
+
for (const child of node.namedChildren) {
|
|
16
|
+
if (child.type === 'identifier') globalVarsInFile.add(child.text);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
walkNodes(root, 'assignment', (node) => {
|
|
21
|
+
const left = node.childForFieldName('left');
|
|
22
|
+
if (!left) return;
|
|
23
|
+
|
|
24
|
+
// self.x = ... → scope 'module' (as requested)
|
|
25
|
+
if (left.type === 'attribute') {
|
|
26
|
+
const obj = left.childForFieldName('object');
|
|
27
|
+
if (obj && obj.text === 'self') {
|
|
28
|
+
nodes.push({
|
|
29
|
+
id: conceptId(filePath, 'state_mutation', node.startIndex),
|
|
30
|
+
kind: 'state_mutation',
|
|
31
|
+
primarySpan: nodeSpan(filePath, node),
|
|
32
|
+
evidence: nodeText(source, node, 100),
|
|
33
|
+
confidence: 0.9,
|
|
34
|
+
language: 'py',
|
|
35
|
+
containerId: getContainerId(node, filePath),
|
|
36
|
+
payload: { kind: 'state_mutation', target: left.text, scope: 'module' },
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Global or Module level assignment
|
|
43
|
+
if (left.type === 'identifier') {
|
|
44
|
+
const name = left.text;
|
|
45
|
+
const containerId = getContainerId(node, filePath);
|
|
46
|
+
|
|
47
|
+
if (globalVarsInFile.has(name)) {
|
|
48
|
+
nodes.push({
|
|
49
|
+
id: conceptId(filePath, 'state_mutation', node.startIndex),
|
|
50
|
+
kind: 'state_mutation',
|
|
51
|
+
primarySpan: nodeSpan(filePath, node),
|
|
52
|
+
evidence: nodeText(source, node, 100),
|
|
53
|
+
confidence: 1.0,
|
|
54
|
+
language: 'py',
|
|
55
|
+
containerId,
|
|
56
|
+
payload: { kind: 'state_mutation', target: name, scope: 'global' },
|
|
57
|
+
});
|
|
58
|
+
} else if (!containerId) {
|
|
59
|
+
// Module level (top level)
|
|
60
|
+
nodes.push({
|
|
61
|
+
id: conceptId(filePath, 'state_mutation', node.startIndex),
|
|
62
|
+
kind: 'state_mutation',
|
|
63
|
+
primarySpan: nodeSpan(filePath, node),
|
|
64
|
+
evidence: nodeText(source, node, 100),
|
|
65
|
+
confidence: 0.8,
|
|
66
|
+
language: 'py',
|
|
67
|
+
payload: { kind: 'state_mutation', target: name, scope: 'module' },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ConceptSpan } from '@kernlang/core';
|
|
2
|
+
import { conceptSpan } from '@kernlang/core';
|
|
3
|
+
import type Parser from 'tree-sitter';
|
|
4
|
+
|
|
5
|
+
export function walkNodes(root: Parser.SyntaxNode, type: string, callback: (node: Parser.SyntaxNode) => void): void {
|
|
6
|
+
const cursor = root.walk();
|
|
7
|
+
let reachedRoot = false;
|
|
8
|
+
while (true) {
|
|
9
|
+
if (cursor.nodeType === type) {
|
|
10
|
+
callback(cursor.currentNode);
|
|
11
|
+
}
|
|
12
|
+
if (cursor.gotoFirstChild()) continue;
|
|
13
|
+
if (cursor.gotoNextSibling()) continue;
|
|
14
|
+
while (true) {
|
|
15
|
+
if (!cursor.gotoParent()) {
|
|
16
|
+
reachedRoot = true;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
if (cursor.gotoNextSibling()) break;
|
|
20
|
+
}
|
|
21
|
+
if (reachedRoot) break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nodeSpan(filePath: string, node: Parser.SyntaxNode): ConceptSpan {
|
|
26
|
+
return conceptSpan(
|
|
27
|
+
filePath,
|
|
28
|
+
node.startPosition.row + 1,
|
|
29
|
+
node.startPosition.column + 1,
|
|
30
|
+
node.endPosition.row + 1,
|
|
31
|
+
node.endPosition.column + 1,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function nodeText(source: string, node: Parser.SyntaxNode, maxLen: number): string {
|
|
36
|
+
return source.substring(node.startIndex, Math.min(node.endIndex, node.startIndex + maxLen));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getContainerId(node: Parser.SyntaxNode, filePath: string): string | undefined {
|
|
40
|
+
let parent = node.parent;
|
|
41
|
+
while (parent) {
|
|
42
|
+
if (parent.type === 'function_definition' || parent.type === 'class_definition') {
|
|
43
|
+
const nameNode = parent.childForFieldName('name');
|
|
44
|
+
const name = nameNode ? nameNode.text : 'anonymous';
|
|
45
|
+
return `${filePath}#fn:${name}@${parent.startIndex}`;
|
|
46
|
+
}
|
|
47
|
+
parent = parent.parent;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getSelfContainerId(node: Parser.SyntaxNode, filePath: string): string | undefined {
|
|
53
|
+
if (node.type !== 'function_definition' && node.type !== 'class_definition') return undefined;
|
|
54
|
+
const nameNode = node.childForFieldName('name');
|
|
55
|
+
const name = nameNode ? nameNode.text : 'anonymous';
|
|
56
|
+
return `${filePath}#fn:${name}@${node.startIndex}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isInAsyncDef(node: Parser.SyntaxNode): boolean {
|
|
60
|
+
let parent = node.parent;
|
|
61
|
+
while (parent) {
|
|
62
|
+
if (parent.type === 'function_definition') {
|
|
63
|
+
return isAsyncFunction(parent);
|
|
64
|
+
}
|
|
65
|
+
parent = parent.parent;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isAsyncFunction(node: Parser.SyntaxNode): boolean {
|
|
71
|
+
return node.children.some((c) => c.type === 'async');
|
|
72
|
+
}
|