diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index c89121d5f..4d66d2cd4 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -100,8 +100,11 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi if (nameCap === undefined || typeCap === undefined) return null; // Strip surrounding quotes for PEP 484 forward references: - // `def f(x: "User")`. - const rawType = stripForwardRefQuotes(typeCap.text.trim()); + // `def f(x: "User")`. Then unwrap nullable unions — `User | None`, + // `None | User`, `Optional[User]` — to the concrete class name so + // receiver-typed resolution treats nullable receivers identically to + // non-nullable ones. + const rawType = stripNullable(stripForwardRefQuotes(typeCap.text.trim())); // Order matters: more specific anchor captures take precedence. `self` // and `cls` are synthesized with their own marker captures; the SCM @@ -129,3 +132,28 @@ function stripForwardRefQuotes(text: string): string { } return text; } + +/** + * Unwrap nullable type annotations so downstream resolution treats + * `User | None`, `None | User`, and `Optional[User]` identically to + * `User`. A missing/unknown variant returns the input unchanged. + * + * This is a syntactic strip, not a semantic parse — it handles the + * canonical PEP-604 and `typing.Optional` shapes that cover the + * overwhelming majority of real-world Python annotations and punts on + * exotic unions (e.g. `User | Error`, which is ambiguous and should not + * auto-bind to one arm). + */ +function stripNullable(text: string): string { + // `Optional[X]` / `typing.Optional[X]` / `t.Optional[X]` + const optMatch = text.match(/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?Optional\[(.+)\]$/); + if (optMatch !== null) return optMatch[1].trim(); + + // Binary union forms. A three-arm or larger union (`User | None | Error`) + // is ambiguous for single-receiver inference, so we leave it alone. + const parts = text.split('|').map((p) => p.trim()); + if (parts.length !== 2) return text; + if (parts[0] === 'None') return parts[1]; + if (parts[1] === 'None') return parts[0]; + return text; +} diff --git a/gitnexus/src/core/ingestion/languages/python/query.ts b/gitnexus/src/core/ingestion/languages/python/query.ts index d6ba56740..4be0b1ea0 100644 --- a/gitnexus/src/core/ingestion/languages/python/query.ts +++ b/gitnexus/src/core/ingestion/languages/python/query.ts @@ -48,17 +48,21 @@ export const PYTHON_SCOPE_QUERY = ` name: (identifier) @type-binding.name type: (type) @type-binding.type) @type-binding.parameter -;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`) -(assignment - left: (identifier) @type-binding.name - type: (type) @type-binding.type) @type-binding.annotation - ;; Type bindings (constructor-inferred: \`u = User(...)\`) +;; Listed BEFORE the annotation pattern so \`u: User = find()\` — which +;; matches BOTH patterns — has the annotation (later match) overwrite +;; the constructor-inferred guess (earlier match) in the typeBindings +;; map. Explicit user intent beats inference. (assignment left: (identifier) @type-binding.name right: (call function: (identifier) @type-binding.type)) @type-binding.constructor +;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`) +(assignment + left: (identifier) @type-binding.name + type: (type) @type-binding.type) @type-binding.annotation + ;; References — calls (call function: (identifier) @reference.name) @reference.call.free diff --git a/gitnexus/src/core/ingestion/languages/python/scopes.scm b/gitnexus/src/core/ingestion/languages/python/scopes.scm index 935a51e35..553e32ddf 100644 --- a/gitnexus/src/core/ingestion/languages/python/scopes.scm +++ b/gitnexus/src/core/ingestion/languages/python/scopes.scm @@ -91,6 +91,26 @@ name: (identifier) @type-binding.name type: (type) @type-binding.type) @type-binding.parameter +; ─── Type bindings: constructor-inferred assignments ─────────────────────── +; +; `u = User("alice")` — `u`'s type is inferred from the RHS call's target. +; Python has no `new` keyword, so the pattern matches any `assignment` +; whose RHS is a `call` with a bare-identifier function (constructor- +; shaped). The registry resolves the raw name through the scope chain at +; lookup time, so imported classes, local classes, and aliased imports +; all work without query-time knowledge. +; +; Emits `source: 'constructor-inferred'`. +; +; Listed BEFORE the annotation pattern so `u: User = find()` — which +; matches both patterns — has the annotation (later-processed match) +; overwrite the constructor-inferred guess. Explicit user intent wins. + +(assignment + left: (identifier) @type-binding.name + right: (call + function: (identifier) @type-binding.type)) @type-binding.constructor + ; ─── Type bindings: variable annotations ─────────────────────────────────── ; ; `u: User` or `u: User = some_value` — `u` is explicitly annotated. Both @@ -105,22 +125,6 @@ left: (identifier) @type-binding.name type: (type) @type-binding.type) @type-binding.annotation -; ─── Type bindings: constructor-inferred assignments ─────────────────────── -; -; `u = User("alice")` — `u`'s type is inferred from the RHS call's target. -; Python has no `new` keyword, so the pattern matches any `assignment` -; whose RHS is a `call` with a bare-identifier function (constructor- -; shaped). The registry resolves the raw name through the scope chain at -; lookup time, so imported classes, local classes, and aliased imports -; all work without query-time knowledge. -; -; Emits `source: 'constructor-inferred'`. - -(assignment - left: (identifier) @type-binding.name - right: (call - function: (identifier) @type-binding.type)) @type-binding.constructor - ; ─── References: calls ───────────────────────────────────────────────────── ; ; Free call: `print(x)` — function is a bare identifier diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 4ab0a6257..39cb0f590 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -634,7 +634,43 @@ function pass4CollectTypeBindings( declaredAtScope: host.id, source: parsed.source, }; - host.typeBindings.set(parsed.boundName, typeRef); + // Prefer stronger sources when multiple matches fire for the same + // bound name in the same scope. Example: `u: User = find()` matches + // both the annotation and constructor-inferred patterns; the explicit + // annotation (stronger source) must win over the call-site guess + // regardless of query-match arrival order. + const existing = host.typeBindings.get(parsed.boundName); + if ( + existing === undefined || + typeBindingStrength(typeRef.source) >= typeBindingStrength(existing.source) + ) { + host.typeBindings.set(parsed.boundName, typeRef); + } + } +} + +/** + * Priority ordering when multiple `TypeRef`s compete for the same bound + * 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. + */ +function typeBindingStrength(source: TypeRef['source']): number { + switch (source) { + case 'annotation': + case 'parameter-annotation': + case 'return-annotation': + case 'self': + return 2; + case 'assignment-inferred': + case 'constructor-inferred': + case 'receiver-propagated': + return 1; + default: + return 0; } }