@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.
Files changed (44) hide show
  1. package/dist/mapper/extractors/dependency.d.ts +3 -0
  2. package/dist/mapper/extractors/dependency.js +52 -0
  3. package/dist/mapper/extractors/effect.d.ts +3 -0
  4. package/dist/mapper/extractors/effect.js +74 -0
  5. package/dist/mapper/extractors/entrypoint.d.ts +3 -0
  6. package/dist/mapper/extractors/entrypoint.js +225 -0
  7. package/dist/mapper/extractors/error.d.ts +5 -0
  8. package/dist/mapper/extractors/error.js +129 -0
  9. package/dist/mapper/extractors/fastapi-pagination.d.ts +5 -0
  10. package/dist/mapper/extractors/fastapi-pagination.js +119 -0
  11. package/dist/mapper/extractors/fastapi-status.d.ts +6 -0
  12. package/dist/mapper/extractors/fastapi-status.js +115 -0
  13. package/dist/mapper/extractors/guard.d.ts +3 -0
  14. package/dist/mapper/extractors/guard.js +115 -0
  15. package/dist/mapper/extractors/pydantic.d.ts +13 -0
  16. package/dist/mapper/extractors/pydantic.js +61 -0
  17. package/dist/mapper/extractors/state-mutation.d.ts +3 -0
  18. package/dist/mapper/extractors/state-mutation.js +63 -0
  19. package/dist/mapper/helpers/ast.d.ts +9 -0
  20. package/dist/mapper/helpers/ast.js +62 -0
  21. package/dist/mapper/helpers/types.d.ts +7 -0
  22. package/dist/mapper/helpers/types.js +168 -0
  23. package/dist/mapper/index.d.ts +8 -0
  24. package/dist/mapper/index.js +42 -0
  25. package/dist/mapper/signatures.d.ts +17 -0
  26. package/dist/mapper/signatures.js +87 -0
  27. package/dist/mapper.d.ts +1 -8
  28. package/dist/mapper.js +1 -1286
  29. package/package.json +3 -3
  30. package/src/mapper/extractors/dependency.ts +60 -0
  31. package/src/mapper/extractors/effect.ts +84 -0
  32. package/src/mapper/extractors/entrypoint.ts +272 -0
  33. package/src/mapper/extractors/error.ts +152 -0
  34. package/src/mapper/extractors/fastapi-pagination.ts +117 -0
  35. package/src/mapper/extractors/fastapi-status.ts +119 -0
  36. package/src/mapper/extractors/guard.ts +114 -0
  37. package/src/mapper/extractors/pydantic.ts +74 -0
  38. package/src/mapper/extractors/state-mutation.ts +72 -0
  39. package/src/mapper/helpers/ast.ts +72 -0
  40. package/src/mapper/helpers/types.ts +164 -0
  41. package/src/mapper/index.ts +50 -0
  42. package/src/mapper/signatures.ts +94 -0
  43. package/src/mapper.ts +1 -1388
  44. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,115 @@
1
+ import { FASTAPI_DEFAULT_SUCCESS_STATUS, PY_API_SUCCESS_STATUS_CODES } from '../signatures.js';
2
+ // Phase 2 of cross-stack `status-code-drift`. Populates the
3
+ // `successStatusCodes` / `successStatusCodesResolved` payload fields so the
4
+ // rule can flag clients checking a 2xx the FastAPI server doesn't emit.
5
+ //
6
+ // Sources of evidence (per buddy plan-review consensus):
7
+ // 1. Decorator `status_code=N` (literal) or `status_code=status.HTTP_NNN_*`.
8
+ // 2. Body-side `Response(status_code=N)` / `JSONResponse(...)` returns.
9
+ // 3. Body-side `<param>.status_code = N` mutations (FastAPI's documented
10
+ // pattern for routes that take a `Response` parameter).
11
+ // 4. When the decorator omits status_code AND the body has no explicit
12
+ // Response / mutation, default to 200 — FastAPI's documented default
13
+ // regardless of HTTP method. Codex caught Gemini's POST→201 premise as
14
+ // wrong (FastAPI docs:
15
+ // https://fastapi.tiangolo.com/tutorial/response-status-code/).
16
+ //
17
+ // Marked unresolved when:
18
+ // - Decorator status_code is set to a non-literal/non-status-constant
19
+ // expression (variable, function call).
20
+ // - Any `Response(status_code=...)` / `<x>.status_code = ...` RHS is dynamic.
21
+ export function extractFastApiSuccessStatusCodes(decText, fnDef, source) {
22
+ let sawDynamic = false;
23
+ // 1. Decorator `status_code=N` — applies ONLY to plain `return data` paths.
24
+ // For routes whose return paths all use explicit Response/JSONResponse,
25
+ // the decorator code is dead (Codex impl-review #1).
26
+ const decStatusMatch = decText.match(/\bstatus_code\s*=\s*([^,)]+)/);
27
+ let decoratorCode;
28
+ if (decStatusMatch) {
29
+ const code = parseFastApiStatusValue(decStatusMatch[1].trim());
30
+ if (code === undefined)
31
+ sawDynamic = true;
32
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code))
33
+ decoratorCode = code;
34
+ }
35
+ const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((c) => c.type === 'block');
36
+ const bodyText = body ? source.substring(body.startIndex, body.endIndex) : '';
37
+ // 2. Response(status_code=N) / JSONResponse(...) etc. — applies only to
38
+ // that specific return path. Multiple Response codes contribute a
39
+ // multi-2xx route.
40
+ const responseCodes = new Set();
41
+ const responseRe = /\b(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\([^)]*?\bstatus_code\s*=\s*([^,)\n]+)/g;
42
+ for (const match of bodyText.matchAll(responseRe)) {
43
+ const code = parseFastApiStatusValue(match[1].trim());
44
+ if (code === undefined)
45
+ sawDynamic = true;
46
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code))
47
+ responseCodes.add(code);
48
+ }
49
+ // 3. `<paramName>.status_code = N` — mutation on the injected Response
50
+ // parameter. The parameter name varies (`response`, `resp`, `r`, `out`,
51
+ // custom names — Codex impl-review #2). Match any identifier prefix
52
+ // rather than a name whitelist; the API_SUCCESS_STATUS_CODES filter
53
+ // keeps the noise tax low.
54
+ const mutationCodes = new Set();
55
+ // `=(?!=)` distinguishes assignment from `==` comparison so
56
+ // `if response.status_code == 200:` doesn't masquerade as a dynamic
57
+ // mutation (forge round, Claude engine).
58
+ const mutateRe = /\b[A-Za-z_]\w*\.status_code\s*=(?!=)\s*([^\n;]+)/g;
59
+ for (const match of bodyText.matchAll(mutateRe)) {
60
+ const code = parseFastApiStatusValue(match[1].trim());
61
+ if (code === undefined)
62
+ sawDynamic = true;
63
+ else if (PY_API_SUCCESS_STATUS_CODES.has(code))
64
+ mutationCodes.add(code);
65
+ }
66
+ if (sawDynamic)
67
+ return { codes: undefined, resolved: false };
68
+ // Plain return paths inherit the route's "primary" success code, computed
69
+ // as: mutation > decorator > FastAPI default 200. When a mutation is
70
+ // present we treat it as the plain-return code (the conditional-mutation
71
+ // case is a documented v1 false-negative — would require control-flow
72
+ // analysis to disambiguate).
73
+ const plainReturnRe = /\breturn\b(?!\s+(?:Response|JSONResponse|HTMLResponse|PlainTextResponse|RedirectResponse|StreamingResponse|FileResponse|ORJSONResponse|UJSONResponse)\s*\()/;
74
+ const hasPlainReturn = plainReturnRe.test(bodyText);
75
+ const final = new Set();
76
+ if (hasPlainReturn) {
77
+ if (mutationCodes.size > 0) {
78
+ for (const c of mutationCodes)
79
+ final.add(c);
80
+ }
81
+ else if (decoratorCode !== undefined) {
82
+ final.add(decoratorCode);
83
+ }
84
+ else {
85
+ final.add(FASTAPI_DEFAULT_SUCCESS_STATUS);
86
+ }
87
+ }
88
+ else if (decoratorCode !== undefined && responseCodes.size === 0 && mutationCodes.size === 0) {
89
+ // Handler with no plain return, no Response, no mutation — likely an
90
+ // implicit-None-return stub or all-raise. Decorator is the only signal.
91
+ final.add(decoratorCode);
92
+ }
93
+ // Response and mutation codes ALWAYS contribute (they're explicit choices
94
+ // for their respective return paths).
95
+ for (const c of responseCodes)
96
+ final.add(c);
97
+ for (const c of mutationCodes)
98
+ final.add(c);
99
+ return {
100
+ codes: Array.from(final).sort((a, b) => a - b),
101
+ resolved: true,
102
+ };
103
+ }
104
+ export function parseFastApiStatusValue(val) {
105
+ const trimmed = val.trim();
106
+ // Literal 3-digit int.
107
+ const litMatch = trimmed.match(/^(\d{3})$/);
108
+ if (litMatch)
109
+ return Number(litMatch[1]);
110
+ // status.HTTP_NNN_NAME / starlette.status.HTTP_NNN_NAME / fastapi.status.HTTP_NNN_NAME.
111
+ const httpMatch = trimmed.match(/HTTP_(\d{3})_/);
112
+ if (httpMatch)
113
+ return Number(httpMatch[1]);
114
+ return undefined;
115
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConceptNode } from '@kernlang/core';
2
+ import type Parser from 'tree-sitter';
3
+ export declare function extractGuards(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void;
@@ -0,0 +1,115 @@
1
+ import { conceptId } from '@kernlang/core';
2
+ import { getContainerId, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
3
+ export function extractGuards(root, source, filePath, nodes) {
4
+ // 1. Auth decorators (tree-sitter: decorated_definition → decorator + function_definition)
5
+ walkNodes(root, 'decorated_definition', (node) => {
6
+ for (const child of node.children) {
7
+ if (child.type !== 'decorator')
8
+ continue;
9
+ const decText = source.substring(child.startIndex, child.endIndex);
10
+ if (/@(login_required|requires_auth|permission_required|auth_required|authenticated)/.test(decText)) {
11
+ nodes.push({
12
+ id: conceptId(filePath, 'guard', child.startIndex),
13
+ kind: 'guard',
14
+ primarySpan: nodeSpan(filePath, child),
15
+ evidence: nodeText(source, child, 100),
16
+ confidence: 1.0,
17
+ language: 'py',
18
+ containerId: getContainerId(node, filePath),
19
+ payload: {
20
+ kind: 'guard',
21
+ subtype: 'auth',
22
+ name: decText.replace('@', '').split('(')[0].trim(),
23
+ },
24
+ });
25
+ }
26
+ }
27
+ });
28
+ // 2. Pydantic validation: BaseModel.model_validate()
29
+ walkNodes(root, 'call', (node) => {
30
+ const func = node.childForFieldName('function');
31
+ if (func?.text.includes('model_validate')) {
32
+ nodes.push({
33
+ id: conceptId(filePath, 'guard', node.startIndex),
34
+ kind: 'guard',
35
+ primarySpan: nodeSpan(filePath, node),
36
+ evidence: nodeText(source, node, 100),
37
+ confidence: 0.9,
38
+ language: 'py',
39
+ containerId: getContainerId(node, filePath),
40
+ payload: { kind: 'guard', subtype: 'validation', name: 'pydantic' },
41
+ });
42
+ }
43
+ });
44
+ // 3. FastAPI `Depends(...)` injection — route handler parameter with a
45
+ // `Depends` default is the idiomatic FastAPI auth/validation guard.
46
+ // Example:
47
+ // @router.get("/me")
48
+ // def me(user: User = Depends(get_current_user)):
49
+ // Classified by the dependency function name:
50
+ // - `get_current_user` / `current_user` / `require_auth` / `*_user` → 'auth'
51
+ // - `verify_*` / `validate_*` → 'validation'
52
+ // - `rate_limit_*` / `check_rate_limit` → 'rate-limit'
53
+ // - everything else → 'policy'
54
+ // Feeds the `auth-drift` cross-stack rule.
55
+ walkNodes(root, 'default_parameter', (node) => {
56
+ const val = node.childForFieldName('value');
57
+ if (!val || val.type !== 'call')
58
+ return;
59
+ const func = val.childForFieldName('function');
60
+ if (!func || func.text !== 'Depends')
61
+ return;
62
+ const args = val.childForFieldName('arguments');
63
+ if (!args)
64
+ return;
65
+ const posArg = args.namedChildren.find((c) => c.type === 'identifier' || c.type === 'attribute');
66
+ const depName = posArg ? posArg.text : 'Depends';
67
+ const subtype = classifyDependency(depName);
68
+ nodes.push({
69
+ id: conceptId(filePath, 'guard', node.startIndex),
70
+ kind: 'guard',
71
+ primarySpan: nodeSpan(filePath, node),
72
+ evidence: nodeText(source, node, 120),
73
+ confidence: 0.85,
74
+ language: 'py',
75
+ containerId: getContainerId(node, filePath),
76
+ payload: { kind: 'guard', subtype, name: depName },
77
+ });
78
+ });
79
+ // 4. Early return/raise after auth check: if not request.user: raise/return
80
+ walkNodes(root, 'if_statement', (node) => {
81
+ const cond = node.childForFieldName('condition');
82
+ if (cond && /\b(user|auth|request\.user)\b/.test(cond.text)) {
83
+ const block = node.namedChildren.find((c) => c.type === 'block');
84
+ if (block) {
85
+ const firstStmt = block.namedChildren[0];
86
+ if (firstStmt && (firstStmt.type === 'return_statement' || firstStmt.type === 'raise_statement')) {
87
+ nodes.push({
88
+ id: conceptId(filePath, 'guard', node.startIndex),
89
+ kind: 'guard',
90
+ primarySpan: nodeSpan(filePath, node),
91
+ evidence: nodeText(source, node, 100),
92
+ confidence: 0.8,
93
+ language: 'py',
94
+ containerId: getContainerId(node, filePath),
95
+ payload: { kind: 'guard', subtype: 'auth' },
96
+ });
97
+ }
98
+ }
99
+ }
100
+ });
101
+ }
102
+ function classifyDependency(depName) {
103
+ // Strip module prefix (`auth.get_current_user` → `get_current_user`) so the
104
+ // heuristic looks at the final identifier where intent usually lives.
105
+ const tail = depName.split('.').pop() ?? depName;
106
+ if (/^(get_current_user|current_user|require_auth|authenticated|is_authenticated)$/i.test(tail))
107
+ return 'auth';
108
+ if (/_user$|^user$|auth/i.test(tail))
109
+ return 'auth';
110
+ if (/^(verify_|validate_)/i.test(tail))
111
+ return 'validation';
112
+ if (/rate_?limit/i.test(tail))
113
+ return 'rate-limit';
114
+ return 'policy';
115
+ }
@@ -0,0 +1,13 @@
1
+ import type Parser from 'tree-sitter';
2
+ import { type FieldTypeMap } from '../helpers/types.js';
3
+ export interface PydanticModel {
4
+ fields: readonly string[];
5
+ types: FieldTypeMap;
6
+ }
7
+ export declare function collectPydanticModels(source: string): Map<string, PydanticModel>;
8
+ export declare function extractFastApiBodyValidation(fnDef: Parser.SyntaxNode, source: string, pydanticModels: ReadonlyMap<string, PydanticModel>): {
9
+ has: boolean;
10
+ fields: readonly string[] | undefined;
11
+ resolved: boolean;
12
+ types: FieldTypeMap | undefined;
13
+ };
@@ -0,0 +1,61 @@
1
+ import { coarsenPythonTypeAnnotation } from '../helpers/types.js';
2
+ export function collectPydanticModels(source) {
3
+ const models = new Map();
4
+ const classRe = /^class\s+([A-Za-z_]\w*)\s*\([^)]*BaseModel[^)]*\)\s*:/gm;
5
+ for (const match of source.matchAll(classRe)) {
6
+ const name = match[1];
7
+ const start = (match.index ?? 0) + match[0].length;
8
+ const rest = source.slice(start);
9
+ const nextTopLevel = rest.search(/\n\S/);
10
+ const body = nextTopLevel === -1 ? rest : rest.slice(0, nextTopLevel);
11
+ const fields = [];
12
+ const types = {};
13
+ // Capture annotations alongside names. The annotation runs until either
14
+ // an `=` (default value) or end-of-line / inline comment. Multiline
15
+ // annotations (`x: Annotated[\n str, Field(...)\n]`) are not handled —
16
+ // false-negative on the type tag, never false-positive.
17
+ const fieldRe = /^[ \t]+([A-Za-z_]\w*)[ \t]*:[ \t]*([^=#\n]+?)(?:[ \t]*=[^\n]*|[ \t]*#[^\n]*)?$/gm;
18
+ for (const fieldMatch of body.matchAll(fieldRe)) {
19
+ const field = fieldMatch[1];
20
+ if (field === 'model_config' || field === 'Config')
21
+ continue;
22
+ fields.push(field);
23
+ const annotation = fieldMatch[2].trim();
24
+ types[field] = coarsenPythonTypeAnnotation(annotation);
25
+ }
26
+ if (fields.length > 0) {
27
+ models.set(name, { fields: fields.sort(), types: Object.freeze({ ...types }) });
28
+ }
29
+ }
30
+ return models;
31
+ }
32
+ export function extractFastApiBodyValidation(fnDef, source, pydanticModels) {
33
+ const body = fnDef.childForFieldName('body') ?? fnDef.namedChildren.find((child) => child.type === 'block');
34
+ const headerEnd = body ? body.startIndex : fnDef.endIndex;
35
+ const header = source.substring(fnDef.startIndex, headerEnd);
36
+ const fields = new Set();
37
+ const types = {};
38
+ let has = false;
39
+ const annotationRe = /([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/g;
40
+ for (const match of header.matchAll(annotationRe)) {
41
+ const model = pydanticModels.get(match[2]);
42
+ if (!model)
43
+ continue;
44
+ has = true;
45
+ for (const field of model.fields)
46
+ fields.add(field);
47
+ for (const [name, tag] of Object.entries(model.types)) {
48
+ // Only record concrete tags. 'unknown' for a key would shadow a
49
+ // concrete tag from another model parameter on the same handler
50
+ // (rare, but multi-arg handlers do exist), so skip them.
51
+ if (tag !== 'unknown')
52
+ types[name] = tag;
53
+ }
54
+ }
55
+ return {
56
+ has,
57
+ fields: fields.size > 0 ? Array.from(fields).sort() : undefined,
58
+ resolved: fields.size > 0,
59
+ types: Object.keys(types).length > 0 ? Object.freeze({ ...types }) : undefined,
60
+ };
61
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConceptNode } from '@kernlang/core';
2
+ import type Parser from 'tree-sitter';
3
+ export declare function extractStateMutation(root: Parser.SyntaxNode, source: string, filePath: string, nodes: ConceptNode[]): void;
@@ -0,0 +1,63 @@
1
+ import { conceptId } from '@kernlang/core';
2
+ import { getContainerId, nodeSpan, nodeText, walkNodes } from '../helpers/ast.js';
3
+ export function extractStateMutation(root, source, filePath, nodes) {
4
+ // Track global keyword usage
5
+ const globalVarsInFile = new Set();
6
+ walkNodes(root, 'global_statement', (node) => {
7
+ for (const child of node.namedChildren) {
8
+ if (child.type === 'identifier')
9
+ globalVarsInFile.add(child.text);
10
+ }
11
+ });
12
+ walkNodes(root, 'assignment', (node) => {
13
+ const left = node.childForFieldName('left');
14
+ if (!left)
15
+ return;
16
+ // self.x = ... → scope 'module' (as requested)
17
+ if (left.type === 'attribute') {
18
+ const obj = left.childForFieldName('object');
19
+ if (obj && obj.text === 'self') {
20
+ nodes.push({
21
+ id: conceptId(filePath, 'state_mutation', node.startIndex),
22
+ kind: 'state_mutation',
23
+ primarySpan: nodeSpan(filePath, node),
24
+ evidence: nodeText(source, node, 100),
25
+ confidence: 0.9,
26
+ language: 'py',
27
+ containerId: getContainerId(node, filePath),
28
+ payload: { kind: 'state_mutation', target: left.text, scope: 'module' },
29
+ });
30
+ return;
31
+ }
32
+ }
33
+ // Global or Module level assignment
34
+ if (left.type === 'identifier') {
35
+ const name = left.text;
36
+ const containerId = getContainerId(node, filePath);
37
+ if (globalVarsInFile.has(name)) {
38
+ nodes.push({
39
+ id: conceptId(filePath, 'state_mutation', node.startIndex),
40
+ kind: 'state_mutation',
41
+ primarySpan: nodeSpan(filePath, node),
42
+ evidence: nodeText(source, node, 100),
43
+ confidence: 1.0,
44
+ language: 'py',
45
+ containerId,
46
+ payload: { kind: 'state_mutation', target: name, scope: 'global' },
47
+ });
48
+ }
49
+ else if (!containerId) {
50
+ // Module level (top level)
51
+ nodes.push({
52
+ id: conceptId(filePath, 'state_mutation', node.startIndex),
53
+ kind: 'state_mutation',
54
+ primarySpan: nodeSpan(filePath, node),
55
+ evidence: nodeText(source, node, 100),
56
+ confidence: 0.8,
57
+ language: 'py',
58
+ payload: { kind: 'state_mutation', target: name, scope: 'module' },
59
+ });
60
+ }
61
+ }
62
+ });
63
+ }
@@ -0,0 +1,9 @@
1
+ import type { ConceptSpan } from '@kernlang/core';
2
+ import type Parser from 'tree-sitter';
3
+ export declare function walkNodes(root: Parser.SyntaxNode, type: string, callback: (node: Parser.SyntaxNode) => void): void;
4
+ export declare function nodeSpan(filePath: string, node: Parser.SyntaxNode): ConceptSpan;
5
+ export declare function nodeText(source: string, node: Parser.SyntaxNode, maxLen: number): string;
6
+ export declare function getContainerId(node: Parser.SyntaxNode, filePath: string): string | undefined;
7
+ export declare function getSelfContainerId(node: Parser.SyntaxNode, filePath: string): string | undefined;
8
+ export declare function isInAsyncDef(node: Parser.SyntaxNode): boolean;
9
+ export declare function isAsyncFunction(node: Parser.SyntaxNode): boolean;
@@ -0,0 +1,62 @@
1
+ import { conceptSpan } from '@kernlang/core';
2
+ export function walkNodes(root, type, callback) {
3
+ const cursor = root.walk();
4
+ let reachedRoot = false;
5
+ while (true) {
6
+ if (cursor.nodeType === type) {
7
+ callback(cursor.currentNode);
8
+ }
9
+ if (cursor.gotoFirstChild())
10
+ continue;
11
+ if (cursor.gotoNextSibling())
12
+ continue;
13
+ while (true) {
14
+ if (!cursor.gotoParent()) {
15
+ reachedRoot = true;
16
+ break;
17
+ }
18
+ if (cursor.gotoNextSibling())
19
+ break;
20
+ }
21
+ if (reachedRoot)
22
+ break;
23
+ }
24
+ }
25
+ export function nodeSpan(filePath, node) {
26
+ return conceptSpan(filePath, node.startPosition.row + 1, node.startPosition.column + 1, node.endPosition.row + 1, node.endPosition.column + 1);
27
+ }
28
+ export function nodeText(source, node, maxLen) {
29
+ return source.substring(node.startIndex, Math.min(node.endIndex, node.startIndex + maxLen));
30
+ }
31
+ export function getContainerId(node, filePath) {
32
+ let parent = node.parent;
33
+ while (parent) {
34
+ if (parent.type === 'function_definition' || parent.type === 'class_definition') {
35
+ const nameNode = parent.childForFieldName('name');
36
+ const name = nameNode ? nameNode.text : 'anonymous';
37
+ return `${filePath}#fn:${name}@${parent.startIndex}`;
38
+ }
39
+ parent = parent.parent;
40
+ }
41
+ return undefined;
42
+ }
43
+ export function getSelfContainerId(node, filePath) {
44
+ if (node.type !== 'function_definition' && node.type !== 'class_definition')
45
+ return undefined;
46
+ const nameNode = node.childForFieldName('name');
47
+ const name = nameNode ? nameNode.text : 'anonymous';
48
+ return `${filePath}#fn:${name}@${node.startIndex}`;
49
+ }
50
+ export function isInAsyncDef(node) {
51
+ let parent = node.parent;
52
+ while (parent) {
53
+ if (parent.type === 'function_definition') {
54
+ return isAsyncFunction(parent);
55
+ }
56
+ parent = parent.parent;
57
+ }
58
+ return false;
59
+ }
60
+ export function isAsyncFunction(node) {
61
+ return node.children.some((c) => c.type === 'async');
62
+ }
@@ -0,0 +1,7 @@
1
+ export type FieldTypeTag = 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array' | 'unknown';
2
+ export type FieldTypeMap = Readonly<Record<string, FieldTypeTag>>;
3
+ export declare function coarsenPythonTypeAnnotation(ann: string): FieldTypeTag;
4
+ export declare function coarsenLiteralValue(v: string): FieldTypeTag;
5
+ export declare function coarsenUnionParts(parts: readonly string[]): FieldTypeTag;
6
+ export declare function splitTopLevelTypeArgs(s: string, delim: ',' | '|'): string[];
7
+ export declare function containsTopLevelChar(s: string, ch: string): boolean;
@@ -0,0 +1,168 @@
1
+ // Coarsen a Pydantic field type annotation to the same FieldTypeTag union
2
+ // the TS mapper uses, so cross-stack rules can compare client TS types
3
+ // against server Pydantic types symmetrically. Handles the common shapes:
4
+ //
5
+ // str / int / float / bool / None / Decimal / UUID / EmailStr
6
+ // Optional[T] / Annotated[T, ...] → coarsen T (drop wrapper)
7
+ // Union[A, B] / `A | B` (PEP 604) → only stable if all agree
8
+ // List[T] / list[T] / Sequence[T] / Tuple[...] → 'array'
9
+ // Dict[K, V] / dict[K, V] / Mapping[K, V] → 'object'
10
+ // Literal['admin'] / Literal[1] / Literal[True] → primitive of literal
11
+ // <CapitalIdent> → 'object' (BaseModel sub)
12
+ //
13
+ // Anything we don't recognise → 'unknown'. Conservative on purpose:
14
+ // /type rules skip 'unknown' tags.
15
+ export function coarsenPythonTypeAnnotation(ann) {
16
+ const t = ann.trim();
17
+ if (t === '')
18
+ return 'unknown';
19
+ // Optional[T] / typing.Optional[T] — strip and recurse.
20
+ const optMatch = t.match(/^(?:typing\.)?Optional\[([\s\S]+)\]$/);
21
+ if (optMatch)
22
+ return coarsenPythonTypeAnnotation(optMatch[1]);
23
+ // Annotated[T, ...] — first arg is the underlying type.
24
+ const annoMatch = t.match(/^(?:typing\.)?Annotated\[([\s\S]+)\]$/);
25
+ if (annoMatch) {
26
+ const parts = splitTopLevelTypeArgs(annoMatch[1], ',');
27
+ if (parts.length >= 1)
28
+ return coarsenPythonTypeAnnotation(parts[0]);
29
+ return 'unknown';
30
+ }
31
+ // Union[A, B, ...] — only stable if every non-null branch agrees.
32
+ // ANY 'unknown' branch poisons the result.
33
+ const unionMatch = t.match(/^(?:typing\.)?Union\[([\s\S]+)\]$/);
34
+ if (unionMatch) {
35
+ return coarsenUnionParts(splitTopLevelTypeArgs(unionMatch[1], ','));
36
+ }
37
+ // PEP 604 `int | None | str`. Only treat `|` as a union separator when
38
+ // it appears OUTSIDE of any `[...]` — otherwise `Dict[str, int | None]`
39
+ // would be split incorrectly.
40
+ if (containsTopLevelChar(t, '|')) {
41
+ return coarsenUnionParts(splitTopLevelTypeArgs(t, '|'));
42
+ }
43
+ // Container types — coarsen to wire shape.
44
+ if (/^(?:typing\.)?(?:List|list|Sequence|Iterable|Tuple|tuple|Set|set|FrozenSet|frozenset)\[/.test(t))
45
+ return 'array';
46
+ if (/^(?:typing\.)?(?:Dict|dict|Mapping|MutableMapping)\[/.test(t))
47
+ return 'object';
48
+ // Literal[X, Y, ...] — coarsen every literal arg, return the shared tag
49
+ // ONLY when all literals agree. Mixed-primitive literals like
50
+ // `Literal['a', 1]` accept either string or number on the wire, so
51
+ // tagging it 'string' (first-only) would FP-flag a number client.
52
+ // OpenCode caught this in the v1 review.
53
+ const litMatch = t.match(/^(?:typing\.)?Literal\[([\s\S]+)\]$/);
54
+ if (litMatch) {
55
+ const parts = splitTopLevelTypeArgs(litMatch[1], ',');
56
+ if (parts.length === 0)
57
+ return 'unknown';
58
+ const tags = parts.map((p) => coarsenLiteralValue(p.trim()));
59
+ if (tags.includes('unknown'))
60
+ return 'unknown';
61
+ const set = new Set(tags);
62
+ return set.size === 1 ? [...set][0] : 'unknown';
63
+ }
64
+ // Plain primitives + common Pydantic-string newtypes. `bytes` intentionally
65
+ // stays 'unknown' — it's binary on the wire and not a JSON primitive.
66
+ switch (t) {
67
+ case 'str':
68
+ case 'EmailStr':
69
+ case 'HttpUrl':
70
+ case 'AnyUrl':
71
+ case 'AnyHttpUrl':
72
+ case 'UUID':
73
+ case 'UUID1':
74
+ case 'UUID3':
75
+ case 'UUID4':
76
+ case 'UUID5':
77
+ case 'SecretStr':
78
+ return 'string';
79
+ case 'int':
80
+ case 'float':
81
+ case 'Decimal':
82
+ case 'PositiveInt':
83
+ case 'NegativeInt':
84
+ case 'NonNegativeInt':
85
+ case 'NonPositiveInt':
86
+ case 'PositiveFloat':
87
+ case 'NegativeFloat':
88
+ return 'number';
89
+ case 'bool':
90
+ case 'StrictBool':
91
+ return 'boolean';
92
+ case 'None':
93
+ case 'NoneType':
94
+ return 'null';
95
+ }
96
+ // Capitalized bare identifier could be:
97
+ // - A nested BaseModel ('object' on the wire)
98
+ // - A `class Status(str, Enum)` ('string' on the wire)
99
+ // - A `Status = Literal['a','b']` type alias ('string' on the wire)
100
+ // - A custom newtype like StrictStr / IPvAnyAddress
101
+ // We can't disambiguate without symbol resolution. Tagging 'object'
102
+ // FP'd Enum/Literal aliases against string clients (Codex flag); tag
103
+ // 'unknown' instead — the rule will skip and we trade FN for FP.
104
+ if (/^[A-Z][\w]*$/.test(t))
105
+ return 'unknown';
106
+ return 'unknown';
107
+ }
108
+ // Coarsen a single literal-value source token (e.g. `'admin'`, `42`, `True`)
109
+ // to its primitive tag. Anything we don't recognise as one of the four JSON
110
+ // primitives → 'unknown'.
111
+ export function coarsenLiteralValue(v) {
112
+ if (/^['"]/.test(v))
113
+ return 'string';
114
+ if (/^-?\d/.test(v))
115
+ return 'number';
116
+ if (v === 'True' || v === 'False')
117
+ return 'boolean';
118
+ if (v === 'None')
119
+ return 'null';
120
+ return 'unknown';
121
+ }
122
+ export function coarsenUnionParts(parts) {
123
+ const tags = parts.map(coarsenPythonTypeAnnotation);
124
+ if (tags.includes('unknown'))
125
+ return 'unknown';
126
+ const noNull = tags.filter((tag) => tag !== 'null');
127
+ if (noNull.length === 0)
128
+ return 'null';
129
+ const set = new Set(noNull);
130
+ return set.size === 1 ? [...set][0] : 'unknown';
131
+ }
132
+ // Split a type-annotation string at top-level commas / pipes — respecting
133
+ // nested `[...]` brackets — so `Union[A, B[C, D]]` splits into `[A, B[C, D]]`
134
+ // not `[A, B[C, D]]`.
135
+ export function splitTopLevelTypeArgs(s, delim) {
136
+ const parts = [];
137
+ let depth = 0;
138
+ let cur = '';
139
+ for (let i = 0; i < s.length; i++) {
140
+ const c = s[i];
141
+ if (c === '[' || c === '(')
142
+ depth++;
143
+ else if (c === ']' || c === ')')
144
+ depth--;
145
+ else if (c === delim && depth === 0) {
146
+ parts.push(cur.trim());
147
+ cur = '';
148
+ continue;
149
+ }
150
+ cur += c;
151
+ }
152
+ if (cur.trim())
153
+ parts.push(cur.trim());
154
+ return parts;
155
+ }
156
+ export function containsTopLevelChar(s, ch) {
157
+ let depth = 0;
158
+ for (let i = 0; i < s.length; i++) {
159
+ const c = s[i];
160
+ if (c === '[' || c === '(')
161
+ depth++;
162
+ else if (c === ']' || c === ')')
163
+ depth--;
164
+ else if (c === ch && depth === 0)
165
+ return true;
166
+ }
167
+ return false;
168
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Python Concept Mapper — tree-sitter based.
3
+ *
4
+ * Maps Python syntax → universal KERN concepts.
5
+ * Phase 1: error_raise, error_handle, effect
6
+ */
7
+ import type { ConceptMap } from '@kernlang/core';
8
+ export declare function extractPythonConcepts(source: string, filePath: string): ConceptMap;