diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 3853b2f93..cfd834954 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -a99e69ab2dfb897ed771c6a8e29c5b32843a7f734db701e0699afc07c090e4d5 +36e29abc0780bc857b6df6dd180a0b6036c8a28f927ccc2d4fe50eede24d0c99 diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 6b360a20d..2a196bb32 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index 1d98ee5e3..d435809be 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index ec525d398..620b19302 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -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(); + 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(); + + 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); +} diff --git a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts index aafc374a6..5784f7ad7 100644 --- a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 08d2a689d..9fe8ae8b9 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -982,13 +982,15 @@ function followChainedRef(start: TypeRef, draftById: ReadonlyMap None: + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/memory_service.py b/gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/memory_service.py new file mode 100644 index 000000000..9095229b6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/memory_service.py @@ -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) diff --git a/gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/test_fixture.py b/gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/test_fixture.py new file mode 100644 index 000000000..02c3b2894 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-constructor-field-receiver/test_fixture.py @@ -0,0 +1,6 @@ +def extract_and_store_graph(text: str) -> None: + pass + + +def exercise_decoy(text: str) -> None: + extract_and_store_graph(text) diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 254f2bb0d..616bc191b 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -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" } } diff --git a/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts b/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts new file mode 100644 index 000000000..8d6f4613c --- /dev/null +++ b/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts @@ -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([]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-constructor-field-bindings.test.ts b/gitnexus/test/unit/scope-resolution/python/python-constructor-field-bindings.test.ts new file mode 100644 index 000000000..de2a69508 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-constructor-field-bindings.test.ts @@ -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)); + }); +});