diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index 4d66d2cd4..a8fa3019e 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -103,8 +103,10 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi // `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())); + // non-nullable ones. Finally strip single-arg generic wrappers so + // `list[User]` / `Iterable[User]` behave like `User` for iterable + // for-loop chain propagation. + const rawType = stripGeneric(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 @@ -119,6 +121,7 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi else if (captures['@type-binding.cls'] !== undefined) source = 'self'; 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'; return { boundName: nameCap.text, rawTypeName: rawType, source }; } @@ -133,6 +136,24 @@ function stripForwardRefQuotes(text: string): string { return text; } +/** + * Unwrap a single-arg generic collection wrapper — `list[User]`, + * `set[User]`, `Iterable[User]`, `Sequence[User]`, `Iterator[User]`, + * `Generator[User, ...]` — to its element type. + * + * Point: for-loop and cross-file chain propagation need the element + * type, not the container. Multi-arg generics (`dict[str, User]`, + * `Callable[[int], User]`) are left alone — the element semantics + * aren't unambiguous and the scope-chain fallback handles them at + * resolution time. + */ +function stripGeneric(text: string): string { + const match = text.match( + /^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:list|List|set|Set|tuple|Tuple|Iterable|Iterator|Sequence|Generator|AsyncIterable|AsyncIterator)\[([^,\]]+)\]$/, + ); + return match !== null ? match[1].trim() : text; +} + /** * Unwrap nullable type annotations so downstream resolution treats * `User | None`, `None | User`, and `Optional[User]` identically to diff --git a/gitnexus/src/core/ingestion/languages/python/query.ts b/gitnexus/src/core/ingestion/languages/python/query.ts index e641285eb..4c61b7e28 100644 --- a/gitnexus/src/core/ingestion/languages/python/query.ts +++ b/gitnexus/src/core/ingestion/languages/python/query.ts @@ -90,6 +90,24 @@ export const PYTHON_SCOPE_QUERY = ` (dotted_name) @type-binding.type)) (identifier) @type-binding.name) @type-binding.constructor +;; Assignment chain: \`alias = user\` — the new name inherits the +;; RHS-identifier's type. The pattern emits a TypeRef whose rawName is +;; the RHS identifier's text; the scope-extractor's post-pass follows +;; the chain so \`alias\` ends up pointing at whatever type \`user\` has. +(assignment + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; For-loop iterable of an already-typed variable: +;; def f(users: list[User]): +;; for u in users: # u: users (chained via post-pass) +;; u.save() +;; The chain post-pass resolves \`users\` → its own type \`User\` via +;; the generic-arg stripping in \`interpret.ts\`. +(for_statement + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + ;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`) (assignment left: (identifier) @type-binding.name diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 39cb0f590..f0fbc93b5 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -647,6 +647,59 @@ function pass4CollectTypeBindings( host.typeBindings.set(parsed.boundName, typeRef); } } + + // ── Transitive closure over identifier-chain type bindings ───────── + // Captures like `(assignment left: (ident) right: (ident))` emit a + // TypeRef whose `rawName` is the RHS identifier. When the RHS name is + // itself a bound variable with a known type in the same scope (or a + // parent scope), follow the chain so `alias` ultimately points at the + // class type — not at another local variable name. Without this, + // `resolveTypeRef` hits the chained name, sees it's a local Variable + // (non-type kind), and strict-returns null. + for (const draft of drafts) { + for (const [name, ref] of draft.typeBindings) { + const resolved = followChainedRef(ref, draftById); + if (resolved !== ref) draft.typeBindings.set(name, resolved); + } + } +} + +/** Max chain depth: practical programs rarely exceed 4-5 re-bindings; + * the cap just prevents runaway loops when providers emit cycles. */ +const CHAIN_MAX_DEPTH = 16; + +/** + * Follow an identifier-chain TypeRef through successive typeBindings + * lookups in the declaring scope and its ancestors. Returns the terminal + * TypeRef (or the original if the chain dead-ends or cycles). + */ +function followChainedRef(start: TypeRef, draftById: ReadonlyMap): TypeRef { + let current = start; + const visited = new Set(); + for (let depth = 0; depth < CHAIN_MAX_DEPTH; depth++) { + // A rawName containing a dot (`models.User`) goes through + // `QualifiedNameIndex` at resolution time — don't follow it here. + if (current.rawName.includes('.')) return current; + + // Look up the current rawName in the declaring scope and walk up + // the chain until we hit a scope that has a binding for it. + let scopeId: ScopeId | null = current.declaredAtScope; + let next: TypeRef | undefined; + while (scopeId !== null) { + const scope = draftById.get(scopeId); + if (scope === undefined) break; + next = scope.typeBindings.get(current.rawName); + if (next !== undefined) break; + scopeId = scope.parent; + } + + if (next === undefined) return current; // dead end — nothing to chain to + if (next === current) return current; // self-ref + if (visited.has(next.rawName)) return current; // cycle guard + visited.add(next.rawName); + current = next; + } + return current; } /**