mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(python): resolve calls through constructor-injected fields (#2628)
* fix(python): resolve calls through injected fields * fix(ci): update python capture benchmark fingerprint * fix(python): make constructor field inference conservative --------- Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
e814e28f1f
commit
0eeecb37f3
12 changed files with 502 additions and 41 deletions
|
|
@ -1 +1 @@
|
|||
a99e69ab2dfb897ed771c6a8e29c5b32843a7f734db701e0699afc07c090e4d5
|
||||
36e29abc0780bc857b6df6dd180a0b6036c8a28f927ccc2d4fe50eede24d0c99
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
* 1. **Per-name import statements** — `import a, b` and
|
||||
* `from m import x, y` decompose to one match per imported name
|
||||
* (see `import-decomposer.ts`).
|
||||
* 2. **Receiver type bindings** — each `function_definition` inside a
|
||||
* class body emits a `@type-binding.self` (or `@type-binding.cls`
|
||||
* for `@classmethod`) capture so Pass-4 attaches the implicit
|
||||
* receiver (see `receiver-binding.ts`).
|
||||
* 2. **Receiver type bindings** — methods emit an implicit `self` / `cls`
|
||||
* binding, and `__init__` assignments from annotated parameters emit
|
||||
* class-scoped instance-field bindings (see `receiver-binding.ts`).
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
*/
|
||||
|
|
@ -25,7 +24,10 @@ import {
|
|||
} from '../../utils/ast-helpers.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getPythonParser, getPythonScopeQuery } from './query.js';
|
||||
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import {
|
||||
synthesizeConstructorFieldTypeBindings,
|
||||
synthesizeReceiverTypeBinding,
|
||||
} from './receiver-binding.js';
|
||||
import { synthesizeDependsReferences } from './depends-references.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
|
|
@ -133,6 +135,7 @@ export function emitPythonScopeCaptures(
|
|||
if (fnNode !== null) {
|
||||
const synth = synthesizeReceiverTypeBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
out.push(...synthesizeConstructorFieldTypeBindings(fnNode));
|
||||
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
|
||||
}
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -119,7 +119,10 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
|
|||
// `cls` is a self-like receiver; share the source label so downstream
|
||||
// `Registry.lookup` Step 2 treats them identically.
|
||||
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.instance-field'] !== undefined) {
|
||||
source =
|
||||
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
|
||||
} else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Synthesize `@type-binding.self` / `@type-binding.cls` captures for
|
||||
* methods.
|
||||
* Synthesize implicit receiver and constructor-assigned field type bindings
|
||||
* for methods.
|
||||
*
|
||||
* Tree-sitter can't easily express "the first parameter of a function
|
||||
* defined directly inside a class body" via a single static query.
|
||||
|
|
@ -113,3 +113,114 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
|
|||
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize class-scope field bindings for the common Python constructor
|
||||
* injection pattern:
|
||||
*
|
||||
* def __init__(self, service: Service):
|
||||
* self.service = service
|
||||
*
|
||||
* An explicit field annotation (`self.service: Service = ...`) is also
|
||||
* accepted and takes precedence over a parameter annotation. Deliberately do
|
||||
* not infer from arbitrary unannotated RHS expressions: the receiver resolver
|
||||
* needs a declared type, not a name-only guess.
|
||||
*/
|
||||
export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (fnNode.childForFieldName('name')?.text !== '__init__') return [];
|
||||
if (findEnclosingClassDefinition(fnNode) === null) return [];
|
||||
if (hasDecorator(fnNode, 'staticmethod') || hasDecorator(fnNode, 'classmethod')) return [];
|
||||
|
||||
const receiver = synthesizeReceiverTypeBinding(fnNode);
|
||||
const receiverName = receiver?.['@type-binding.self']?.text;
|
||||
if (receiverName === undefined) return [];
|
||||
|
||||
const parameters = fnNode.childForFieldName('parameters');
|
||||
const body = fnNode.childForFieldName('body');
|
||||
if (parameters === null || body === null) return [];
|
||||
|
||||
const parameterTypes = new Map<string, string>();
|
||||
for (let i = 0; i < parameters.namedChildCount; i++) {
|
||||
const parameter = parameters.namedChild(i);
|
||||
if (parameter === null) continue;
|
||||
const name = firstParameterName(parameter);
|
||||
const annotation = parameter.childForFieldName('type');
|
||||
if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text);
|
||||
}
|
||||
|
||||
type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean };
|
||||
const candidates = new Map<string, Candidate>();
|
||||
|
||||
const stack: SyntaxNode[] = [body];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (
|
||||
node !== body &&
|
||||
(node.type === 'function_definition' ||
|
||||
node.type === 'lambda' ||
|
||||
node.type === 'class_definition' ||
|
||||
node.type === 'if_statement' ||
|
||||
node.type === 'for_statement' ||
|
||||
node.type === 'while_statement' ||
|
||||
node.type === 'try_statement' ||
|
||||
node.type === 'match_statement')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'assignment') {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (left?.type === 'attribute') {
|
||||
const object = left.childForFieldName('object');
|
||||
const field = left.childForFieldName('attribute');
|
||||
if (object?.type === 'identifier' && object.text === receiverName && field !== null) {
|
||||
const explicitType = node.childForFieldName('type');
|
||||
const parameterType =
|
||||
right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined;
|
||||
const typeName = explicitType?.text ?? parameterType;
|
||||
if (typeName !== undefined) {
|
||||
const explicit = explicitType !== null;
|
||||
const existing = candidates.get(field.text);
|
||||
if (existing === undefined || explicit || !existing.explicit) {
|
||||
candidates.set(field.text, {
|
||||
explicit,
|
||||
match: {
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', field, field.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
explicitType ?? right ?? field,
|
||||
typeName,
|
||||
),
|
||||
...(explicit
|
||||
? {}
|
||||
: {
|
||||
'@type-binding.parameter': syntheticCapture(
|
||||
'@type-binding.parameter',
|
||||
right ?? field,
|
||||
'1',
|
||||
),
|
||||
}),
|
||||
'@type-binding.instance-field': syntheticCapture(
|
||||
'@type-binding.instance-field',
|
||||
node,
|
||||
'1',
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push in reverse so the LIFO walk visits source order. That keeps Map
|
||||
// insertion order (and therefore emitted capture order) deterministic.
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates.values()].map(({ match }) => match);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,15 +36,23 @@ export function pythonFunctionDefinitionLabel(
|
|||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
/** Python has no block scope, so the central extractor's "innermost
|
||||
* enclosing scope" default is already correct: `for x in …` creates
|
||||
* `x` in the enclosing function/module scope (because we never emit a
|
||||
* `@scope.block` for the for-loop body), comprehension variables stay
|
||||
* in their expression context, etc. Returns `null` to delegate. */
|
||||
* enclosing scope" default is already correct for ordinary bindings.
|
||||
* Constructor-injected instance fields are the exception: their marker is
|
||||
* anchored inside `__init__`, but compound receiver resolution needs the
|
||||
* field type on the enclosing Class scope. */
|
||||
export function pythonBindingScopeFor(
|
||||
_decl: CaptureMatch,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (decl['@type-binding.instance-field'] !== undefined) {
|
||||
let current: Scope | undefined = innermost;
|
||||
while (current !== undefined) {
|
||||
if (current.kind === 'Class') return current.id;
|
||||
if (current.parent === null) break;
|
||||
current = tree.getScope(current.parent);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -982,13 +982,15 @@ function followChainedRef(start: TypeRef, draftById: ReadonlyMap<ScopeId, ScopeD
|
|||
* name in the same scope. Higher number wins; ties keep the later match
|
||||
* (last-write-wins preserves historical order within a tier).
|
||||
*
|
||||
* Rationale: explicit annotations always beat inferred ones because they
|
||||
* reflect user intent. `self`/`cls` are treated as strongly as annotations
|
||||
* because they are language-required receiver types.
|
||||
* Rationale: explicit variable and field annotations always beat bindings
|
||||
* derived from parameter annotations or inference because they reflect the
|
||||
* most specific user intent. `self`/`cls` are treated as strongly as other
|
||||
* declared types because they are language-required receiver types.
|
||||
*/
|
||||
function typeBindingStrength(source: TypeRef['source']): number {
|
||||
switch (source) {
|
||||
case 'annotation':
|
||||
return 3;
|
||||
case 'parameter-annotation':
|
||||
case 'return-annotation':
|
||||
case 'self':
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
class KnowledgeGraphService:
|
||||
def extract_and_store_graph(self, text: str) -> None:
|
||||
pass
|
||||
23
gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/memory_service.py
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/memory_service.py
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from knowledge_graph_service import KnowledgeGraphService
|
||||
|
||||
|
||||
class MemoryService:
|
||||
def __init__(self, knowledge_graph_service: KnowledgeGraphService):
|
||||
self.knowledge_graph_service = knowledge_graph_service
|
||||
|
||||
def store_memory(self, text: str) -> None:
|
||||
self.knowledge_graph_service.extract_and_store_graph(text)
|
||||
|
||||
def archive_memory(self, text: str) -> None:
|
||||
self.knowledge_graph_service.extract_and_store_graph(text)
|
||||
|
||||
def restore_memory(self, text: str) -> None:
|
||||
self.knowledge_graph_service.extract_and_store_graph(text)
|
||||
|
||||
|
||||
class ExplicitFieldMemoryService:
|
||||
def __init__(self, knowledge_graph_service):
|
||||
self.knowledge_graph_service: KnowledgeGraphService = knowledge_graph_service
|
||||
|
||||
def ingest_memory(self, text: str) -> None:
|
||||
self.knowledge_graph_service.extract_and_store_graph(text)
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/test_fixture.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/test_fixture.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def extract_and_store_graph(text: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def exercise_decoy(text: str) -> None:
|
||||
extract_and_store_graph(text)
|
||||
|
|
@ -88,8 +88,8 @@
|
|||
"digest": "338c3922981604e71ddfc60ad61eba4b17f68ca654644e01add942c729b422cf"
|
||||
},
|
||||
"python-call-result-binding/models.py": {
|
||||
"captureGroups": 16,
|
||||
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
|
||||
"captureGroups": 17,
|
||||
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
|
||||
},
|
||||
"python-call-result-binding/service.py": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -171,13 +171,25 @@
|
|||
"captureGroups": 15,
|
||||
"digest": "201ce01b83b21d729aca89c6299570df55393a95f1899a2eaacd989873950177"
|
||||
},
|
||||
"python-constructor-field-receiver/knowledge_graph_service.py": {
|
||||
"captureGroups": 10,
|
||||
"digest": "83dcf9f81ac7d0a9e9ed5e467e41acd07e2ec581926d85eea5d4b5e1e4157744"
|
||||
},
|
||||
"python-constructor-field-receiver/memory_service.py": {
|
||||
"captureGroups": 59,
|
||||
"digest": "ad9c3be5a7c10e112bb20eac20b8603196fc2e739b7567159c58a607639ce192"
|
||||
},
|
||||
"python-constructor-field-receiver/test_fixture.py": {
|
||||
"captureGroups": 13,
|
||||
"digest": "6f642e752086a5e9337d21accb65ca90ef5af2b3e139239ea12c5cd031844dd2"
|
||||
},
|
||||
"python-constructor-type-inference/models/repo.py": {
|
||||
"captureGroups": 16,
|
||||
"digest": "ad11823ee187cc3e1efab34a67a5013119b4ada87c5701b080921c0b0be09e62"
|
||||
"captureGroups": 17,
|
||||
"digest": "3c400c7a331d7796a730e1ba53b91c4f5ec4799121044b0c160844988fca8662"
|
||||
},
|
||||
"python-constructor-type-inference/models/user.py": {
|
||||
"captureGroups": 16,
|
||||
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
|
||||
"captureGroups": 17,
|
||||
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
|
||||
},
|
||||
"python-constructor-type-inference/services/app.py": {
|
||||
"captureGroups": 13,
|
||||
|
|
@ -192,12 +204,12 @@
|
|||
"digest": "dd51c32d705934b1384991ad2291869f446327752481abc20600d4ad9f553ea3"
|
||||
},
|
||||
"python-dict-items-loop/repo.py": {
|
||||
"captureGroups": 15,
|
||||
"digest": "8116cf4cbf4dca377e88f97ca645f40fab648a4e9a8e790b5b3761e4a3e17d7c"
|
||||
"captureGroups": 16,
|
||||
"digest": "2d283b4acbc71e318a4520cb7557508b9084213ba53fbdac07457e348a8b24c6"
|
||||
},
|
||||
"python-dict-items-loop/user.py": {
|
||||
"captureGroups": 15,
|
||||
"digest": "15984fa30be4603f3e47c27342352dd602d104b33ac5224dc911d78a82d87926"
|
||||
"captureGroups": 16,
|
||||
"digest": "6568834a7f228e78a11b08282196e138795980aed6c55b9953cc89e87c998e52"
|
||||
},
|
||||
"python-django-app-imports/accounts/__init__.py": {
|
||||
"captureGroups": 0,
|
||||
|
|
@ -288,8 +300,8 @@
|
|||
"digest": "d1e23831dcae38034b278bfefa2b8c4e21ca722ab2c79bfb3126744338a2a401"
|
||||
},
|
||||
"python-enumerate-loop/user.py": {
|
||||
"captureGroups": 16,
|
||||
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
|
||||
"captureGroups": 17,
|
||||
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
|
||||
},
|
||||
"python-field-type-disambig/address.py": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -316,8 +328,8 @@
|
|||
"digest": "5c290b3b34f3f5e9dcdd6ee3ae72ba4223337cd64592330f9a8c6c6f13b2fd2d"
|
||||
},
|
||||
"python-for-call-expr/models.py": {
|
||||
"captureGroups": 39,
|
||||
"digest": "133a14c0543a41412d9d4fd5485d6270e1f4b0cd247f0ec0d71974850e4918c1"
|
||||
"captureGroups": 41,
|
||||
"digest": "e8807a9969732197feb04204d200d5810b895c006f1424a7a6f5f0d5762ef49a"
|
||||
},
|
||||
"python-function-local-import-chain/app.py": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -464,8 +476,8 @@
|
|||
"digest": "01fe4805f59723a5f163d26b7be3ed3e456eb3a093e3df3a8f034cecee22ebb9"
|
||||
},
|
||||
"python-method-chain-binding/models.py": {
|
||||
"captureGroups": 45,
|
||||
"digest": "ea3f745514a330d86447796734faff29f0c88ea49a7ef8781087c537426d29bf"
|
||||
"captureGroups": 48,
|
||||
"digest": "f049817034428759193fce03b417ca23c133f6ead87f960e173afba9b79b88e8"
|
||||
},
|
||||
"python-method-enrichment/app.py": {
|
||||
"captureGroups": 13,
|
||||
|
|
@ -680,8 +692,8 @@
|
|||
"digest": "741f690b6330491303b9b58cb31027a33600973265b59428facfefbabf0cf7e1"
|
||||
},
|
||||
"python-return-type-inference/models.py": {
|
||||
"captureGroups": 16,
|
||||
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
|
||||
"captureGroups": 17,
|
||||
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
|
||||
},
|
||||
"python-return-type-inference/service.py": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -760,8 +772,8 @@
|
|||
"digest": "4df7ea089c43552ca4ea5a51f8e985d11d351b86949efb2f7ebefdf6a9ffd689"
|
||||
},
|
||||
"python-walrus-operator/models.py": {
|
||||
"captureGroups": 21,
|
||||
"digest": "cf014d1bad66ea61e327c146fd1a52159a96faaf264555bb164230a5218e6948"
|
||||
"captureGroups": 22,
|
||||
"digest": "9ab1a8a69970e8c875bd103251ecf9c8af2c1464a6bdc1213fe7560c4fa34063"
|
||||
},
|
||||
"python-write-access/models.py": {
|
||||
"captureGroups": 11,
|
||||
|
|
@ -772,7 +784,7 @@
|
|||
"digest": "6e3690ec68d8de54f376bb6f5f7a29da829a8a6a24001eabae9ec66a327f3409"
|
||||
},
|
||||
"synthetic:dao-20": {
|
||||
"captureGroups": 733,
|
||||
"digest": "c540f2143882137e6c6f7996bf9b87a0117f41915b1d0989d3d6bb9db5a5a1ab"
|
||||
"captureGroups": 773,
|
||||
"digest": "37e047eda37477bbc33f4dd8ba259c3f876580378566c0c14dfe580795a952af"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
|
||||
describe('Python calls through constructor-assigned receiver fields', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'python-constructor-field-receiver'),
|
||||
() => {},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it('resolves all production callers to the receiver-constrained method', () => {
|
||||
const productionCalls = getRelationships(result, 'CALLS').filter(
|
||||
(edge) =>
|
||||
edge.target === 'extract_and_store_graph' &&
|
||||
edge.targetFilePath === 'knowledge_graph_service.py',
|
||||
);
|
||||
|
||||
expect(productionCalls.map((edge) => `${edge.sourceFilePath}:${edge.source}`).sort()).toEqual([
|
||||
'memory_service.py:archive_memory',
|
||||
'memory_service.py:ingest_memory',
|
||||
'memory_service.py:restore_memory',
|
||||
'memory_service.py:store_memory',
|
||||
]);
|
||||
expect(productionCalls.every((edge) => edge.rel.confidence >= 0.85)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not redirect production calls to the same-named decoy', () => {
|
||||
const misresolved = getRelationships(result, 'CALLS').filter(
|
||||
(edge) =>
|
||||
edge.sourceFilePath === 'memory_service.py' &&
|
||||
edge.target === 'extract_and_store_graph' &&
|
||||
edge.targetFilePath === 'test_fixture.py',
|
||||
);
|
||||
|
||||
expect(misresolved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
emitPythonScopeCaptures,
|
||||
interpretPythonTypeBinding,
|
||||
} from '../../../../src/core/ingestion/languages/python/index.js';
|
||||
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js';
|
||||
|
||||
function constructorFieldBindings(source: string): CaptureMatch[] {
|
||||
return emitPythonScopeCaptures(source, 'fixture.py').filter(
|
||||
(match) => match['@type-binding.instance-field'] !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function interpretedBindings(source: string): Array<{
|
||||
boundName: string;
|
||||
rawTypeName: string;
|
||||
source: string;
|
||||
}> {
|
||||
return constructorFieldBindings(source).map((match) => {
|
||||
const binding = interpretPythonTypeBinding(match);
|
||||
expect(binding).not.toBeNull();
|
||||
return binding!;
|
||||
});
|
||||
}
|
||||
|
||||
describe('Python constructor field type bindings', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'annotated constructor parameter',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service: Service):
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'typed default parameter with a nullable forward reference',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service: "Service | None" = None):
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'custom receiver name',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(this, service: Service):
|
||||
this.service = service
|
||||
`,
|
||||
},
|
||||
])('synthesizes a parameter-derived class field for $name', ({ source }) => {
|
||||
expect(interpretedBindings(source)).toEqual([
|
||||
{ boundName: 'service', rawTypeName: 'Service', source: 'parameter-annotation' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit field-annotation provenance', () => {
|
||||
const source = `
|
||||
class Facade:
|
||||
def __init__(self, service):
|
||||
self.service: Service = service
|
||||
`;
|
||||
|
||||
expect(interpretedBindings(source)).toEqual([
|
||||
{ boundName: 'service', rawTypeName: 'Service', source: 'annotation' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'an unannotated constructor parameter',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an assignment on a different receiver',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service: Service):
|
||||
other.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'a non-constructor method',
|
||||
source: `
|
||||
class Facade:
|
||||
def configure(self, service: Service):
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'a static constructor-shaped method',
|
||||
source: `
|
||||
class Facade:
|
||||
@staticmethod
|
||||
def __init__(self, service: Service):
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an annotation inside a nested function',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, value):
|
||||
def configure():
|
||||
self.service: Service = value
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an annotation inside a nested class',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, value):
|
||||
class Nested:
|
||||
self.service: Service = value
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an assignment inside an if branch',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service: Service, enabled: bool):
|
||||
if enabled:
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an assignment inside a for loop',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, services: list[Service]):
|
||||
for service in services:
|
||||
self.service: Service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an assignment inside a while loop',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service: Service, enabled: bool):
|
||||
while enabled:
|
||||
self.service = service
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'an assignment inside a try statement',
|
||||
source: `
|
||||
class Facade:
|
||||
def __init__(self, service: Service):
|
||||
try:
|
||||
self.service = service
|
||||
except RuntimeError:
|
||||
pass
|
||||
`,
|
||||
},
|
||||
])('does not synthesize a binding for $name', ({ source }) => {
|
||||
expect(constructorFieldBindings(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the final inferred assignment for a repeatedly assigned field', () => {
|
||||
const source = `
|
||||
class Facade:
|
||||
def __init__(self, primary: PrimaryService, fallback: FallbackService):
|
||||
self.service = primary
|
||||
self.service = fallback
|
||||
`;
|
||||
|
||||
expect(interpretedBindings(source)).toEqual([
|
||||
{
|
||||
boundName: 'service',
|
||||
rawTypeName: 'FallbackService',
|
||||
source: 'parameter-annotation',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers an explicit field annotation over the parameter annotation', () => {
|
||||
const source = `
|
||||
class Facade:
|
||||
def __init__(self, service: Protocol):
|
||||
self.service: ConcreteService = service
|
||||
`;
|
||||
|
||||
expect(interpretedBindings(source)).toEqual([
|
||||
{ boundName: 'service', rawTypeName: 'ConcreteService', source: 'annotation' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hoists only the synthesized field binding to the enclosing class scope', () => {
|
||||
const parsed = extractParsedFile(
|
||||
pythonProvider,
|
||||
`
|
||||
class Facade:
|
||||
def __init__(self, service: Service):
|
||||
self.service = service
|
||||
`,
|
||||
'fixture.py',
|
||||
);
|
||||
expect(parsed).toBeDefined();
|
||||
|
||||
const classScope = parsed!.scopes.find((scope) => scope.kind === 'Class');
|
||||
const constructorScope = parsed!.scopes.find((scope) => scope.kind === 'Function');
|
||||
expect(classScope?.typeBindings.get('service')).toMatchObject({
|
||||
rawName: 'Service',
|
||||
source: 'parameter-annotation',
|
||||
});
|
||||
expect(constructorScope?.typeBindings.get('service')).toMatchObject({
|
||||
rawName: 'Service',
|
||||
source: 'parameter-annotation',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not override a class-body field annotation with constructor inference', () => {
|
||||
const parsed = extractParsedFile(
|
||||
pythonProvider,
|
||||
`
|
||||
class Facade:
|
||||
service: ServiceProtocol
|
||||
|
||||
def __init__(self, service: ConcreteService):
|
||||
self.service = service
|
||||
`,
|
||||
'fixture.py',
|
||||
);
|
||||
expect(parsed).toBeDefined();
|
||||
|
||||
const classScope = parsed!.scopes.find((scope) => scope.kind === 'Class');
|
||||
expect(classScope?.typeBindings.get('service')).toMatchObject({
|
||||
rawName: 'ServiceProtocol',
|
||||
source: 'annotation',
|
||||
});
|
||||
});
|
||||
|
||||
it('is deterministic across repeated capture runs', () => {
|
||||
const source = `
|
||||
class Facade:
|
||||
def __init__(self, service: Service):
|
||||
self.service = service
|
||||
`;
|
||||
|
||||
expect(constructorFieldBindings(source)).toEqual(constructorFieldBindings(source));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue