From f6e70016d6c2c86c01558f192dd726a782c0f801 Mon Sep 17 00:00:00 2001 From: EVA Date: Mon, 28 Sep 2026 13:12:56 +0900 Subject: [PATCH] fix(python): resolve mixin self calls to subtype implementations (#3390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(python): resolve missing mixin self members through subtypes * fix: honor Python effective MRO and static mixin targets * fix: bind Python subtype dispatch to receiver provenance * fix(python): limit mixin fanout to instance receivers * fix(python): capture call arity and invalidate stale parsed facts * fix(python): count bound receivers by method context Preserve static, free, nested and typed variadic parameters; test renamed target receivers without weakening incompatible-arity rejection. Regenerate capture goldens for receiver metadata and eight mixin fixtures. Record the deliberate missing_target coverage outcome: CI measured Python call drops 5->6 and total call drops 113->114; no shape or scaling threshold relaxed. * test(python): require a call capture before checking unknown arity * fix(python): bind subtype dispatch to receiver definition * test(python): include conditional renamed-receiver target * fix(python): prove positional mixin targets and report partial coverage Preserve upstream notebook coordinate mapping and maintainer changes. Reject incompatible/implicit-class targets, retain proven targets across ambiguous alternatives, and report capped or unresolved coverage without confusing edge deduplication. * fix(python): isolate subtype call proof and preserve lookup boundaries --------- Co-authored-by: Eva Co-authored-by: Gergő Magyar --- .../python-scope/baseline-fingerprint.txt | 2 +- .../bench/receiver-resolution/BASELINE.md | 20 ++ .../bench/receiver-resolution/baseline.json | 13 +- .../src/core/ingestion/languages/python.ts | 4 + .../languages/python/arity-metadata.ts | 9 +- .../ingestion/languages/python/captures.ts | 54 ++++ .../languages/python/receiver-binding.ts | 148 ++++++--- .../languages/python/scope-resolver.ts | 65 +++- .../languages/python/subtype-dispatch.ts | 231 ++++++++++++++ .../method-extractors/configs/python.ts | 32 +- .../contract/scope-resolver.ts | 63 +++- .../passes/receiver-bound-calls.ts | 284 +++++++++++++++++- gitnexus/src/storage/parse-cache.ts | 12 +- .../python-mixin-self-dispatch/ambiguous_a.py | 6 + .../python-mixin-self-dispatch/ambiguous_b.py | 6 + .../python-mixin-self-dispatch/conditional.py | 7 + .../python-mixin-self-dispatch/decoys.py | 10 + .../python-mixin-self-dispatch/helpers.py | 4 + .../python-mixin-self-dispatch/inherited.py | 6 + .../python-mixin-self-dispatch/mixins.py | 104 +++++++ .../python-mixin-self-dispatch/worker.py | 139 +++++++++ .../python-mixin-self-dispatch/wrong_arity.py | 11 + .../expected-captures.json | 36 +++ .../test/integration/resolvers/python.test.ts | 249 +++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 9 +- gitnexus/test/unit/method-extraction.test.ts | 172 +++++++++++ .../python/python-captures-golden.test.ts | 62 ++++ .../python/python-hooks.test.ts | 83 +++++ .../python/python-subtype-dispatch.test.ts | 141 +++++++++ 29 files changed, 1904 insertions(+), 78 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_a.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_b.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/conditional.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/decoys.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/helpers.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/inherited.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/mixins.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/worker.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/wrong_arity.py create mode 100644 gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index f7eb6a959..adc688988 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -8075fe28c703c9d27d1b1b90ae04992548b677daf61be5116511b6ca3424dde1 +317f9b2e0114172ed592435a7059dae6b278f5bab2b56f8269eae66b8275c431 diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 329270eaa..38d82dd37 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,25 @@ # Receiver-resolution baseline +## Python mixin dispatch (#3390) + +The added mixin fixture deliberately calls an absent `missing_target` on a known +in-program receiver. The resolver now records that unresolved call rather than +silently treating missing edges as complete coverage. The focused Python resolver +test asserts this outcome. CI run 36319863343 at `4034cee` measured one additional +Python call drop (113 to 114; all-kind total 159 to 160), classified as in-program +with no receiver-shape annotation. No shape-arm result or performance threshold +changed. The renamed bound-receiver correction preserves the existing method's +effective arity; the exact-head CI gate must still confirm these counts. + +The Python capture fingerprint is also intentionally regenerated: ordinary call +captures now include statically known argument counts, the corpus includes eight +new mixin fixture files, and bound method parameter counts exclude receivers by +class/decorator context rather than spelling. Unknown splat cardinalities remain +unknown. The final deterministic corpus contains 213 entries and 3,463 capture +groups. The golden test pins each fixture; only the mixin and renamed target +digests changed in the bound-receiver delta, with their group counts unchanged. +Scaling limits and all non-Python capture baselines remain unchanged. + > **`baseline.json` is the source of truth for every number.** It is what > `measure.mjs --check` enforces byte-exactly. This file is a lab notebook: > each section records what was measured AT THAT UNIT and why it changed the diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index c89c2c6e3..6656262dd 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -199,10 +199,10 @@ } }, "countArm": { - "callDrops": 113, - "totalDropsAllKinds": 159, + "callDrops": 114, + "totalDropsAllKinds": 160, "bySiteKind": { - "call": 113, + "call": 114, "read": 27, "write": 19 }, @@ -213,7 +213,7 @@ ".ts": 7, ".cpp": 7, ".tsx": 6, - ".py": 5, + ".py": 6, ".go": 5, ".php": 4, ".kt": 4, @@ -227,11 +227,12 @@ "chain-call": 27, "no-chain": 23, "chain-mixed": 2, - "chain-unwrap": 1 + "chain-unwrap": 1, + "<>": 1 }, "callDropsByOrigin": { "external": 44, - "in-program": 43, + "in-program": 44, "unknown": 26 } } diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index d07902f66..be51de940 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -46,6 +46,8 @@ import { extractDjangoRoutes } from '../route-extractors/django.js'; import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js'; import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js'; import { pythonDecoratorRouteHandlerName } from '../route-extractors/python-decorator-handler.js'; +import { assertCloneable } from '../workers/clone-safety.js'; +import { collectPythonSubtypeDispatchSideChannel } from './python/subtype-dispatch.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -152,6 +154,8 @@ export const pythonProvider = defineLanguage({ // full per-hook rationale and the canonical capture vocabulary in // ./python/query.ts (PYTHON_SCOPE_QUERY constant). emitScopeCaptures: emitPythonScopeCaptures, + collectCaptureSideChannel: (filePath) => + assertCloneable(collectPythonSubtypeDispatchSideChannel(filePath)), cfgVisitor: createPythonCfgVisitor(), interpretImport: interpretPythonImport, interpretTypeBinding: interpretPythonTypeBinding, diff --git a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts index d4da7dfd7..6d5e38af2 100644 --- a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts @@ -5,12 +5,13 @@ * * Mirrors the legacy `buildMethodProps` conversion so scope-extracted * defs carry the same arity semantics as the parse-worker path: - * - `self` / `cls` are stripped (consumed by `extractPythonParameters`). + * - A bound method's first positional receiver is stripped by class and + * decorator context, independent of spelling; static/free functions keep it. * - Defaulted params contribute to `optionalCount`, flipping * `requiredParameterCount = total − optionalCount`. - * - Variadic (`*args` / `**kwargs`) collapses `parameterCount` to - * `undefined`, which `pythonArityCompatibility` then treats as - * `'unknown'` — keeping the candidate in the registry's lookup set. + * - Variadic (`*args` / `**kwargs`) leaves both count-only bounds unknown: + * parameter kinds are not retained, so a required keyword-only argument + * cannot safely be treated as a positional minimum. * - `parameterTypes` is populated only with real type text, matching * legacy behavior. */ diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 5b212e525..37ecb8552 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -42,6 +42,11 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { pythonFunctionDefinitionLabel } from './simple-hooks.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +import { + beginPythonSubtypeDispatchCapture, + recordPythonSimplePositionalCall, + recordPythonSubtypeMethodShape, +} from './subtype-dispatch.js'; const PYTHON_CALLABLE_CAPTURE_OPTIONS = { functionNodeTypes: new Set(['function_definition', 'lambda']), @@ -84,6 +89,7 @@ export function emitPythonScopeCaptures( notebookSegments?: readonly NotebookLineSegment[]; }, ): readonly CaptureMatch[] { + beginPythonSubtypeDispatchCapture(filePath); let parseText = sourceText; let tree = cachedTree as ReturnType['parse']> | undefined; let notebookSegments: readonly NotebookLineSegment[] | undefined; @@ -94,6 +100,10 @@ export function emitPythonScopeCaptures( tree = resolved.tree; notebookSegments = resolved.notebookSegments; } + const subtypeLineMapper = + notebookSegments === undefined + ? undefined + : (line: number): number => mapExtractLine(line - 1, notebookSegments) + 1; // Skip the parse when the caller (the scope-resolution orchestrator's // `treeCache`) already produced a Tree for this source — empty under // worker-pool runs, so cache miss = re-parse. The cachedTree parameter @@ -141,6 +151,8 @@ export function emitPythonScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + recordPythonSubtypeCallShape(grouped, nodeMap, filePath, subtypeLineMapper); + if (grouped['@import.statement'] !== undefined) { // `@import.statement` is captured directly ON the `import_statement` / // `import_from_statement` node (query: `(import_statement) @import.statement` @@ -210,6 +222,7 @@ export function emitPythonScopeCaptures( if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') { delete grouped['@declaration.function']; grouped['@declaration.method'] = { ...anchorCap, name: '@declaration.method' }; + recordPythonSubtypeMethodShape(filePath, fnNode, subtypeLineMapper); } const arity = computePythonArityMetadata(fnNode); if (arity.parameterCount !== undefined) { @@ -317,6 +330,47 @@ function remapCaptureMatch( return next; } +/** + * Record fixed positional argument counts only for Python's conservative + * missing-member subtype fallback. Ordinary reference arity stays unchanged: + * count-only metadata cannot model Python keyword binding or definition order. + */ +function recordPythonSubtypeCallShape( + grouped: Record, + nodeMap: Readonly>, + filePath: string, + mapLine?: (line: number) => number, +): void { + const callTag = (['@reference.call.free', '@reference.call.member'] as const).find( + (tag) => grouped[tag] !== undefined, + ); + if (callTag === undefined) return; + + // Decorator references use the same call tags but are anchored on a + // `decorator`, not a `call`, so they intentionally retain their old shape. + const callNode = nodeMap[callTag]; + if (callNode === undefined || callNode.type !== 'call') return; + + const argumentList = callNode.childForFieldName('arguments'); + if (argumentList === null || argumentList.type !== 'argument_list') return; + + const args = argumentList.namedChildren.filter( + (child): child is SyntaxNode => child !== null && child.type !== 'comment', + ); + if ( + args.some( + (arg) => + arg.type === 'list_splat' || + arg.type === 'dictionary_splat' || + arg.type === 'keyword_argument', + ) + ) { + return; + } + + recordPythonSimplePositionalCall(filePath, callNode, args.length, mapLine); +} + /** * Synthesize `@reference.inherits` captures from Python class superclass * lists so the registry-primary scope-resolution path emits EXTENDS edges diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index 85ee381e2..8bfbbf995 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -3,7 +3,8 @@ * 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. + * defined in a class suite, including conditional suite branches" via a + * single static query. * Doing this in code keeps the embedded scope query declarative and * lets us encode the `@classmethod` / `@staticmethod` decorator * awareness that Python's runtime depends on. @@ -44,31 +45,100 @@ function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean { return false; } -function firstNamedParameter(parameters: SyntaxNode): SyntaxNode | null { +function firstBoundReceiverParameter(parameters: SyntaxNode): SyntaxNode | null { for (let i = 0; i < parameters.namedChildCount; i++) { const child = parameters.namedChild(i); if (child === null) continue; - // Skip `*` / `/` markers. - if (child.type === 'positional_separator' || child.type === 'keyword_separator') continue; - return child; + if (child.type === 'comment') continue; + + // A positional-only separator follows at least one real positional + // parameter, so encountering it before a candidate is malformed input. + // A keyword-only separator, *args, or **kwargs means there is no variable + // that directly receives Python's descriptor-injected instance. + if ( + child.type === 'positional_separator' || + child.type === 'keyword_separator' || + child.type === 'list_splat_pattern' || + child.type === 'dictionary_splat_pattern' + ) { + return null; + } + + return firstParameterName(child) === null ? null : child; } return null; } function firstParameterName(param: SyntaxNode): string | null { if (param.type === 'identifier') return param.text; - // typed_parameter / default_parameter / typed_default_parameter: - // first child holds the identifier / pattern. - const ident = param.childForFieldName('name') ?? findIdentifierChild(param); - return ident?.text ?? null; + // typed_parameter / default_parameter / typed_default_parameter must name a + // real positional variable. In particular, do not look through a typed + // list_splat_pattern or dictionary_splat_pattern for its nested identifier. + const named = param.childForFieldName('name') ?? param.firstNamedChild; + return named?.type === 'identifier' ? named.text : null; } -function findIdentifierChild(node: SyntaxNode): SyntaxNode | null { - for (let i = 0; i < node.namedChildCount; i++) { - const child = node.namedChild(i); - if (child !== null && child.type === 'identifier') return child; - } - return null; +export interface PythonBoundReceiver { + readonly kind: 'instance' | 'class'; + readonly parameter: SyntaxNode; + readonly name: string; + readonly className: string; +} + +/** + * Classify the parameter that Python's descriptor protocol binds implicitly. + * Class-suite control flow does not change descriptor ownership, while an + * intervening function does. Splat and keyword-only parameters cannot name + * the injected receiver directly and therefore fail closed. + */ +export function classifyPythonBoundReceiver(fnNode: SyntaxNode): PythonBoundReceiver | null { + const enclosingClass = findEnclosingClassDefinition(fnNode); + if (enclosingClass === null || hasDecorator(fnNode, 'staticmethod')) return null; + + const functionName = fnNode.childForFieldName('name')?.text; + // Python applies these descriptor kinds implicitly even without decorators. + // __new__ is static-like (its class argument is explicit), while + // __init_subclass__ and __class_getitem__ receive the class implicitly. + if (functionName === '__new__') return null; + + const params = fnNode.childForFieldName('parameters'); + if (params === null) return null; + const parameter = firstBoundReceiverParameter(params); + if (parameter === null) return null; + + const name = firstParameterName(parameter); + const className = classDefinitionName(enclosingClass); + if (name === null || className === null) return null; + + return { + kind: + hasDecorator(fnNode, 'classmethod') || + functionName === '__init_subclass__' || + functionName === '__class_getitem__' + ? 'class' + : 'instance', + parameter, + name, + className, + }; +} + +/** + * `__new__` is static-like for method dispatch, but Python supplies its class + * argument during construction rather than through descriptor binding. Keep + * that explicit parameter in arity metadata while still typing its local name. + */ +function classifyPythonExplicitNewReceiver(fnNode: SyntaxNode): PythonBoundReceiver | null { + if (fnNode.childForFieldName('name')?.text !== '__new__') return null; + const enclosingClass = findEnclosingClassDefinition(fnNode); + const params = fnNode.childForFieldName('parameters'); + if (enclosingClass === null || params === null) return null; + const parameter = firstBoundReceiverParameter(params); + if (parameter === null) return null; + const name = firstParameterName(parameter); + const className = classDefinitionName(enclosingClass); + if (name === null || className === null) return null; + return { kind: 'class', parameter, name, className }; } /** @@ -80,37 +150,35 @@ function findIdentifierChild(node: SyntaxNode): SyntaxNode | null { * 'function_definition'`. */ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch | null { - const enclosingClass = findEnclosingClassDefinition(fnNode); - if (enclosingClass === null) return null; - - // Skip @staticmethod-decorated methods (no implicit receiver). - if (hasDecorator(fnNode, 'staticmethod')) return null; - const isClassmethod = hasDecorator(fnNode, 'classmethod'); - - const params = fnNode.childForFieldName('parameters'); - if (params === null) return null; - const first = firstNamedParameter(params); - if (first === null) return null; - - const className = classDefinitionName(enclosingClass); - if (className === null) return null; - - const firstName = firstParameterName(first); - if (firstName === null) return null; + const receiver = classifyPythonBoundReceiver(fnNode) ?? classifyPythonExplicitNewReceiver(fnNode); + if (receiver === null) return null; // Receiver convention: instance methods get `self`, classmethods get `cls`. - // We trust the AST literal name (Python convention is strict in practice). - if (isClassmethod) { + // The capture tag records the descriptor kind; the variable may use any + // spelling. + if (receiver.kind === 'class') { return { - '@type-binding.cls': nodeToCapture('@type-binding.cls', first), - '@type-binding.name': syntheticCapture('@type-binding.name', first, firstName), - '@type-binding.type': syntheticCapture('@type-binding.type', first, className), + '@type-binding.cls': nodeToCapture('@type-binding.cls', receiver.parameter), + '@type-binding.name': syntheticCapture( + '@type-binding.name', + receiver.parameter, + receiver.name, + ), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + receiver.parameter, + receiver.className, + ), }; } return { - '@type-binding.self': nodeToCapture('@type-binding.self', first), - '@type-binding.name': syntheticCapture('@type-binding.name', first, firstName), - '@type-binding.type': syntheticCapture('@type-binding.type', first, className), + '@type-binding.self': nodeToCapture('@type-binding.self', receiver.parameter), + '@type-binding.name': syntheticCapture('@type-binding.name', receiver.parameter, receiver.name), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + receiver.parameter, + receiver.className, + ), }; } diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 25bee8f96..965cb69e8 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -12,11 +12,14 @@ * the 2 booleans, and register in `scope-resolution/pipeline/registry.ts`. */ -import type { ParsedFile } from 'gitnexus-shared'; +import type { ParsedFile, ReferenceSite, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; -import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import type { + ArityVerdict, + ScopeResolver, +} from '../../scope-resolution/contract/scope-resolver.js'; import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { pythonProvider } from '../python.js'; import { @@ -27,6 +30,48 @@ import { resolvePythonImportTarget, type PythonResolveContext, } from './index.js'; +import { + applyPythonSubtypeDispatchSideChannel, + pythonSubtypeCallPositionalCount, + pythonSubtypePositionalCapacity, +} from './subtype-dispatch.js'; + +/** + * Python subtype dispatch is deliberately limited to instance receiver facts. + * Private names are class-mangled and cannot be matched by their source + * spelling across an eventual subtype. Argument-shape proof is candidate-level + * because it needs both the call-site and target-method capture facts. + */ +export function pythonMissingReceiverSubtypeDecision( + typeRef: TypeRef, + context: { + readonly receiverBindingIsStatic: boolean | undefined; + readonly memberName: string; + readonly callArity: number | undefined; + }, +): boolean | 'suppress' { + if (typeRef.source !== 'self' || context.receiverBindingIsStatic !== false) return false; + const isPrivateName = context.memberName.startsWith('__') && !context.memberName.endsWith('__'); + if (isPrivateName) return 'suppress'; + return true; +} + +/** Additive compatibility proof for Python's missing-member subtype candidates. */ +export function pythonMissingReceiverSubtypeCandidateCompatibility( + callerFilePath: string, + callsite: Pick, + candidate: SymbolDefinition, +): ArityVerdict { + const positionalCount = pythonSubtypeCallPositionalCount(callerFilePath, callsite.atRange); + if (positionalCount === undefined) return 'unknown'; + + const capacity = pythonSubtypePositionalCapacity(candidate); + const minimum = candidate.requiredParameterCount; + const maximum = candidate.parameterCount; + if (capacity === undefined || minimum === undefined || maximum === undefined) return 'unknown'; + if (positionalCount < minimum || positionalCount > maximum) return 'incompatible'; + return positionalCount <= capacity ? 'compatible' : 'incompatible'; +} const pythonScopeResolver: ScopeResolver = { // A free call naming a class constructs it: `Service(db).do_work()` (#2708). @@ -81,8 +126,24 @@ const pythonScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + applyCaptureSideChannel: applyPythonSubtypeDispatchSideChannel, + isSuperReceiver: (text) => /^super\s*\(/.test(text), + // A mixin may call a method supplied only by its eventual concrete class. + // Resolve the callable that DEFINED the binding, rather than the innermost + // caller, so a class receiver inherited by a closure cannot masquerade as + // instance dispatch. The helper also suppresses Python shapes whose exact + // target cannot be represented by the existing call-site facts. + resolveMissingReceiverMembersFromSubtypes: pythonMissingReceiverSubtypeDecision, + missingReceiverSubtypeCandidateCompatibility: (callsite, candidate, context) => + pythonMissingReceiverSubtypeCandidateCompatibility(context.callerFilePath, callsite, candidate), + + // Python permits both @staticmethod and @classmethod access through an + // instance. The graph's generic `isStatic` bit therefore does not mean + // "unreachable by instance dispatch" for this provider. + isStaticOnly: () => false, + // Subscript route only — Python spells collection views as method calls // (`.values()`), which the compound resolver's call branch already handles. // diff --git a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts new file mode 100644 index 000000000..a7e9c9305 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts @@ -0,0 +1,231 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js'; +import { classifyPythonBoundReceiver } from './receiver-binding.js'; + +type PositionTuple = readonly [line: number, column: number]; +type CallShapeTuple = readonly [line: number, column: number, positionalCount: number]; +type CapacityTuple = readonly [line: number, column: number, capacity: number]; +type LineMapper = (line: number) => number; + +/** + * Python-private capture facts for conservative missing-member subtype dispatch. + * They stay opaque on `ParsedFile.captureSideChannel`; the public reference and + * definition schemas intentionally do not gain Python argument-binding fields. + */ +export interface PythonSubtypeDispatchSideChannel { + readonly kind: 'python-subtype-dispatch'; + readonly simplePositionalCalls: readonly CallShapeTuple[]; + readonly positionalCapacities: readonly CapacityTuple[]; +} + +const simplePositionalCallsByFile = new Map>(); +const positionalCapacitiesByFile = new Map>(); + +const positionKey = (line: number, column: number): string => `${line}:${column}`; + +const nodePosition = (node: SyntaxNode, mapLine?: LineMapper): PositionTuple => { + const line = node.startPosition.row + 1; + return [mapLine?.(line) ?? line, node.startPosition.column]; +}; + +/** Reset one file before a fresh capture or a worker snapshot restore. */ +export function beginPythonSubtypeDispatchCapture(filePath: string): void { + simplePositionalCallsByFile.delete(filePath); + positionalCapacitiesByFile.delete(filePath); +} + +/** Record calls whose arguments are all ordinary positional expressions. */ +export function recordPythonSimplePositionalCall( + filePath: string, + callNode: SyntaxNode, + positionalCount: number, + mapLine?: LineMapper, +): void { + const [line, column] = nodePosition(callNode, mapLine); + let sites = simplePositionalCallsByFile.get(filePath); + if (sites === undefined) { + sites = new Map(); + simplePositionalCallsByFile.set(filePath, sites); + } + sites.set(positionKey(line, column), positionalCount); +} + +function parameterBindingNode(parameter: SyntaxNode): SyntaxNode { + if ( + parameter.type === 'typed_parameter' || + parameter.type === 'default_parameter' || + parameter.type === 'typed_default_parameter' + ) { + return parameter.childForFieldName('name') ?? parameter.firstNamedChild ?? parameter; + } + return parameter; +} + +function sameNodePosition(left: SyntaxNode, right: SyntaxNode): boolean { + return ( + left.startPosition.row === right.startPosition.row && + left.startPosition.column === right.startPosition.column && + left.endPosition.row === right.endPosition.row && + left.endPosition.column === right.endPosition.column + ); +} + +/** + * Count parameters that can receive ordinary positional arguments after + * Python's descriptor-bound receiver is removed. `*args` and any required + * keyword-only parameter are deliberately unknown: this successor proves + * fixed positional calls only. + */ +function positionalCapacity(fnNode: SyntaxNode): number | undefined { + const parameters = fnNode.childForFieldName('parameters'); + if (parameters === null) return undefined; + const receiver = classifyPythonBoundReceiver(fnNode)?.parameter; + let capacity = 0; + let keywordOnly = false; + + for (const parameter of parameters.namedChildren) { + if (parameter === null || parameter.type === 'comment') continue; + if (receiver !== undefined && sameNodePosition(parameter, receiver)) continue; + + const binding = parameterBindingNode(parameter); + if (binding.type === 'positional_separator') continue; + if (binding.type === 'keyword_separator') { + keywordOnly = true; + continue; + } + if (binding.type === 'dictionary_splat_pattern') break; + if (binding.type === 'list_splat_pattern') return undefined; + if ( + binding.type !== 'identifier' && + parameter.type !== 'default_parameter' && + parameter.type !== 'typed_parameter' && + parameter.type !== 'typed_default_parameter' + ) { + return undefined; + } + if (keywordOnly) { + if (parameter.type !== 'default_parameter' && parameter.type !== 'typed_default_parameter') { + return undefined; + } + continue; + } + capacity++; + } + return capacity; +} + +/** Record a method's exact fixed positional capacity when the AST proves it. */ +export function recordPythonSubtypeMethodShape( + filePath: string, + fnNode: SyntaxNode, + mapLine?: LineMapper, +): void { + const capacity = positionalCapacity(fnNode); + if (capacity === undefined) return; + const [line, column] = nodePosition(fnNode, mapLine); + let capacities = positionalCapacitiesByFile.get(filePath); + if (capacities === undefined) { + capacities = new Map(); + positionalCapacitiesByFile.set(filePath, capacities); + } + capacities.set(positionKey(line, column), capacity); +} + +function parsePositionKey(key: string): PositionTuple | undefined { + const match = /^(\d+):(\d+)$/.exec(key); + if (match === null) return undefined; + return [Number(match[1]), Number(match[2])]; +} + +/** Snapshot one worker file into structured-clone-safe plain data. */ +export function collectPythonSubtypeDispatchSideChannel( + filePath: string, +): PythonSubtypeDispatchSideChannel | undefined { + const callSites: CallShapeTuple[] = []; + for (const [key, positionalCount] of simplePositionalCallsByFile.get(filePath) ?? []) { + const position = parsePositionKey(key); + if (position !== undefined) callSites.push([position[0], position[1], positionalCount]); + } + const capacities: CapacityTuple[] = []; + for (const [key, capacity] of positionalCapacitiesByFile.get(filePath) ?? []) { + const position = parsePositionKey(key); + if (position !== undefined) capacities.push([position[0], position[1], capacity]); + } + if (callSites.length === 0 && capacities.length === 0) return undefined; + return { + kind: 'python-subtype-dispatch', + simplePositionalCalls: callSites, + positionalCapacities: capacities, + }; +} + +/** Restore worker/cache facts without reparsing source on the main thread. */ +export function applyPythonSubtypeDispatchSideChannel(parsed: ParsedFile): void { + beginPythonSubtypeDispatchCapture(parsed.filePath); + const data = parsed.captureSideChannel as PythonSubtypeDispatchSideChannel | undefined; + if ( + data === undefined || + data === null || + typeof data !== 'object' || + data.kind !== 'python-subtype-dispatch' || + !Array.isArray(data.simplePositionalCalls) || + !Array.isArray(data.positionalCapacities) + ) { + return; + } + + for (const position of data.simplePositionalCalls) { + if (!validCallShape(position)) continue; + let sites = simplePositionalCallsByFile.get(parsed.filePath); + if (sites === undefined) { + sites = new Map(); + simplePositionalCallsByFile.set(parsed.filePath, sites); + } + sites.set(positionKey(position[0], position[1]), position[2]); + } + for (const entry of data.positionalCapacities) { + if (!validCapacity(entry)) continue; + let capacities = positionalCapacitiesByFile.get(parsed.filePath); + if (capacities === undefined) { + capacities = new Map(); + positionalCapacitiesByFile.set(parsed.filePath, capacities); + } + capacities.set(positionKey(entry[0], entry[1]), entry[2]); + } +} + +function validPosition(value: readonly number[]): value is PositionTuple { + return ( + value.length === 2 && + Number.isInteger(value[0]) && + value[0]! > 0 && + Number.isInteger(value[1]) && + value[1]! >= 0 + ); +} + +function validCallShape(value: readonly number[]): value is CallShapeTuple { + return validPosition(value.slice(0, 2)) && Number.isInteger(value[2]) && value[2]! >= 0; +} + +function validCapacity(value: readonly number[]): value is CapacityTuple { + return validPosition(value.slice(0, 2)) && Number.isInteger(value[2]) && value[2]! >= 0; +} + +export function pythonSubtypeCallPositionalCount( + filePath: string, + range: { readonly startLine: number; readonly startCol: number }, +): number | undefined { + return simplePositionalCallsByFile + .get(filePath) + ?.get(positionKey(range.startLine, range.startCol)); +} + +export function pythonSubtypePositionalCapacity(candidate: SymbolDefinition): number | undefined { + const position = definitionIdPosition(candidate.nodeId, candidate.filePath); + if (position === undefined) return undefined; + return positionalCapacitiesByFile + .get(candidate.filePath) + ?.get(positionKey(position.line, position.column)); +} diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/python.ts b/gitnexus/src/core/ingestion/method-extractors/configs/python.ts index 563b6f3f2..fd1d154c1 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/python.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/python.ts @@ -8,6 +8,7 @@ import type { MethodVisibility, } from '../../method-types.js'; import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { classifyPythonBoundReceiver } from '../../languages/python/receiver-binding.js'; import { extractSimpleTypeName } from '../../type-extractors/shared.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; @@ -15,9 +16,6 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; // Python helpers // --------------------------------------------------------------------------- -/** Names that represent the instance/class receiver — not real parameters. */ -const SELF_NAMES = new Set(['self', 'cls']); - /** * Unwrap a decorated_definition to its inner function_definition. * @@ -91,7 +89,9 @@ function hasDecorator(node: SyntaxNode, name: string): boolean { * * Handles: identifier, default_parameter, typed_parameter, typed_default_parameter, * list_splat_pattern (*args), dictionary_splat_pattern (**kwargs), and typed variants. - * Skips `self` and `cls` first parameters. + * Skips the first positional parameter of bound class members, independent of + * its spelling. Module functions, nested functions, and static methods retain + * their first parameter. */ function extractPythonParameters(node: SyntaxNode): ParameterInfo[] { const funcNode = unwrapDecorated(node); @@ -100,18 +100,20 @@ function extractPythonParameters(node: SyntaxNode): ParameterInfo[] { const params: ParameterInfo[] = []; let isFirst = true; + const boundReceiverId = classifyPythonBoundReceiver(funcNode)?.parameter.id; for (let i = 0; i < paramList.namedChildCount; i++) { const param = paramList.namedChild(i); if (!param) continue; + if (param.type === 'comment') continue; + if (isFirst && boundReceiverId !== undefined && param.id === boundReceiverId) { + isFirst = false; + continue; + } switch (param.type) { case 'identifier': { - // Bare parameter: `self`, `cls`, or untyped `x` - if (isFirst && SELF_NAMES.has(param.text)) { - isFirst = false; - continue; - } + // Bare parameter: untyped `x` isFirst = false; params.push({ name: param.text, @@ -143,10 +145,6 @@ function extractPythonParameters(node: SyntaxNode): ParameterInfo[] { const inner = param.firstNamedChild; if (!inner) break; - if (isFirst && inner.type === 'identifier' && SELF_NAMES.has(inner.text)) { - isFirst = false; - continue; - } isFirst = false; const typeNode = param.childForFieldName('type'); @@ -297,7 +295,13 @@ export const pythonMethodConfig: MethodExtractionConfig = { extractVisibility: extractPythonVisibility, isStatic(node) { - return hasDecorator(node, 'staticmethod') || hasDecorator(node, 'classmethod'); + const funcNode = unwrapDecorated(node); + return ( + hasDecorator(node, 'staticmethod') || + hasDecorator(node, 'classmethod') || + funcNode.childForFieldName('name')?.text === '__new__' || + classifyPythonBoundReceiver(funcNode)?.kind === 'class' + ); }, isAbstract(node, _ownerNode) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 5fed0ea15..772acad52 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -79,9 +79,9 @@ * pass running first prevents the wrong edge. * * - **I2 — `handledSites` semantics.** A site is added to - * `handledSites` IFF a `tryEmitEdge` call returned `true` for it. - * Sites a pass touched but couldn't resolve do NOT get marked — - * they still get a chance from the shared resolver. Exception: + * `handledSites` after successful emission or a definitive suppression + * that must prevent receiver-blind fallback. Ordinary unresolved misses + * remain unhandled so the shared resolver can try them. Additionally, * the free-call fallback marks the site after it decides the site, * including when it emits no edge — dedup-collapse, a visibility * veto (`fallback-refused`), or a selected callable that is deleted @@ -132,7 +132,8 @@ * 6. Case 3 dotted typeBinding for namespace prefix * 7. Case 3b chain-typebinding (compound resolver + interface-dispatch * fan-out on an Interface fold, #2832) - * 8. Case 4 simple typeBinding (MRO walk + findOwnedMember) + * 8. Case 4 simple typeBinding (MRO walk + findOwnedMember, then an + * opt-in concrete-subtype fan-out when the normal member is missing) * Reordering or merging cases changes resolution semantics. The * numbering is part of the contract — keep the comments. * @@ -288,6 +289,7 @@ import type { ScopeId, SupportedLanguages, SymbolDefinition, + TypeRef, } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; @@ -1395,6 +1397,59 @@ export interface ScopeResolver { */ readonly resolveThisViaEnclosingClass?: boolean; + /** + * Opt a receiver type fact into subtype dispatch when Case 4 resolves the + * receiver's declared class but finds no same-named member on that class or + * its ancestors. The shared pass walks the concrete subtype closure and + * emits up to the shared fan-out cap of unique, arity-compatible + * implementations it can prove. + * + * This is deliberately a predicate rather than a language check in shared + * ingestion. Dynamic languages can enable only type facts whose runtime + * class may legally supply a member absent from the declared owner (Python's + * synthesized instance-receiver binding, for example). A declined receiver + * retains the existing owner/MRO behavior byte-for-byte. + * + * `callerIsStatic` is the existing graph-node fact for the callable that + * contains the site. `receiverBindingIsStatic` is the corresponding fact + * for the callable where the receiver TypeRef was declared. The latter is + * load-bearing for closures that inherit an outer receiver binding. Either + * is undefined when its callable cannot be resolved; providers using these + * facts to distinguish dispatch kinds should fail closed. `memberName` and + * `callArity` are the already-captured site facts; providers may return + * `'suppress'` when those facts prove the language cannot safely infer a + * subtype target but receiver-blind fallback would be wrong. + * + * When enabled, a no-target or overload-ambiguous result is a definitive + * receiver-bound miss: the pass records a suppression and marks the site + * handled so receiver-blind name fallback cannot mint a false exact edge. + */ + readonly resolveMissingReceiverMembersFromSubtypes?: ( + typeRef: TypeRef, + context: { + readonly callerIsStatic: boolean | undefined; + readonly receiverBindingIsStatic: boolean | undefined; + readonly memberName: string; + readonly callArity: number | undefined; + }, + ) => boolean | 'suppress'; + + /** + * Optional language-specific compatibility check for each candidate found by + * `resolveMissingReceiverMembersFromSubtypes`. It runs in addition to ordinary + * arity filtering. `unknown` suppresses the whole inferred site rather than + * publishing a partial subtype fan-out as complete. + * + * Python uses this with private capture-side-channel facts to prove that an + * ordinary positional call fits a target's positional parameter capacity, + * without adding argument-kind fields to the public `ReferenceSite` schema. + */ + readonly missingReceiverSubtypeCandidateCompatibility?: ( + callsite: ReferenceSite, + candidate: SymbolDefinition, + context: { readonly callerFilePath: string }, + ) => ArityVerdict; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ea0168070..52af76330 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -33,7 +33,8 @@ * interface-dispatch fan-out when the folded receiver type is an * Interface (#2832) — same call Cases 0 and 4 make. * 8. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → - * MRO walk + `findOwnedMember` + * MRO walk + `findOwnedMember`, then an opt-in concrete-subtype fan-out + * when the normal member is missing * 9. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable` * whose `nodeId` is referenced as an `ownerId` in `model.methods` * (object-literal services). Last-resort fallback for lowercase @@ -59,7 +60,13 @@ * resolved to a wrong target. */ -import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { + ParsedFile, + ReferenceSite, + ScopeId, + SymbolDefinition, + TypeRef, +} from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; @@ -100,7 +107,7 @@ import { type GroundedTypeArgument, type HeritageTypeArguments, } from '../utils/generic-instantiation.js'; -import { resolveDefGraphId } from '../graph-bridge/ids.js'; +import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization, @@ -179,6 +186,9 @@ type ReceiverBoundProviderSubset = Pick< | 'namespaceReceiverPaths' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' + | 'resolveMissingReceiverMembersFromSubtypes' + | 'missingReceiverSubtypeCandidateCompatibility' + | 'arityCompatibility' | 'conversionRankFn' | 'conversionOnlyArgTypePrefixes' | 'constraintCompatibility' @@ -327,6 +337,37 @@ export const MAX_INTERFACE_DISPATCH_FANOUT = (() => { /** Bound on the sample of over-cap interface members kept for the warning. */ const MAX_REPORTED_SKIPPED_INTERFACES = 20; +/** + * Keep every proven subtype target while making omitted coverage explicit. + * `recordUnresolved` intentionally depends on candidate coverage, not edge + * emission: collapse-mode dedup can make every `tryEmitEdge` return false even + * though the site is already known and complete. + */ +export function prepareSubtypeDispatchCoverage( + targets: readonly T[], + missingCandidateIds: Iterable, + maxTargets: number, +): { + readonly targets: readonly T[]; + readonly droppedTargets: readonly T[]; + readonly missingCandidateIds: readonly string[]; + readonly partialCoverage: boolean; + readonly recordUnresolved: boolean; +} { + const keptTargets = targets.slice(0, maxTargets); + const droppedTargets = targets.slice(maxTargets); + const missing = new Set(missingCandidateIds); + for (const target of droppedTargets) missing.add(target.nodeId); + const partialCoverage = missing.size > 0; + return { + targets: keptTargets, + droppedTargets, + missingCandidateIds: [...missing], + partialCoverage, + recordUnresolved: partialCoverage || targets.length === 0, + }; +} + /** What `emitReceiverBoundCalls` reports back to the orchestrator. */ export interface ReceiverBoundResult { /** CALLS/ACCESSES edges emitted by this pass. */ @@ -664,6 +705,32 @@ export function emitReceiverBoundCalls( return graph.getNode(graphId)?.properties.isStatic === true; }; + const missingReceiverSubtypeDecision = ( + typeRef: TypeRef, + site: ReferenceSite, + ): boolean | 'suppress' => { + const predicate = provider.resolveMissingReceiverMembersFromSubtypes; + if (predicate === undefined) return false; + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange); + const callerIsStatic = + callerGraphId === undefined ? undefined : graph.getNode(callerGraphId)?.properties.isStatic; + const receiverBindingGraphId = resolveCallerGraphId( + typeRef.declaredAtScope, + scopes, + nodeLookup, + ); + const receiverBindingIsStatic = + receiverBindingGraphId === undefined + ? undefined + : graph.getNode(receiverBindingGraphId)?.properties.isStatic; + return predicate(typeRef, { + callerIsStatic, + receiverBindingIsStatic, + memberName: site.name, + callArity: site.arity, + }); + }; + /** * What does this written type argument NAME, as seen from `scopeId` (#2912)? * @@ -2241,6 +2308,217 @@ export function emitReceiverBoundCalls( handledSites.add(siteKey); continue; } + + // Dynamic subtype dispatch for a receiver whose declared owner and + // ancestors do not define the member. The provider predicate keeps + // language syntax out of this shared pass; Python opts in only for + // instance `self` calls made from mixin-style base classes. + // + // Multiple concrete subtype implementations are runtime alternatives, + // not an overload ambiguity, so emit the same bounded fan-out used by + // interface dispatch. An ambiguity *within* one subtype does not erase + // proven targets from other subtypes; it marks the site's coverage as + // partial so callers cannot mistake the emitted set for completeness. + const subtypeDecision = + site.kind === 'call' ? missingReceiverSubtypeDecision(typeRef, site) : false; + if (subtypeDecision !== false) { + if (subtypeDecision === 'suppress') { + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + receiverOrigin: 'in-program', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + siteKind: site.kind, + }); + handledSites.add(siteKey); + continue; + } + const subtypeTargets = new Map(); + const ambiguousCandidateIds = new Set(); + const unknownCompatibilityCandidateIds = new Set(); + const visitedSubtypeIds = new Set([ownerDef.nodeId]); + const subtypeQueue = [ownerDef.nodeId]; + let subtypeHead = 0; + + while (subtypeHead < subtypeQueue.length) { + const supertypeId = subtypeQueue[subtypeHead++]!; + for (const subtype of subtypesBySupertypeDefId.get(supertypeId) ?? []) { + if (visitedSubtypeIds.has(subtype.nodeId)) continue; + visitedSubtypeIds.add(subtype.nodeId); + subtypeQueue.push(subtype.nodeId); + + // Prefer a concrete override owned by this subtype. Otherwise + // accept exactly one inherited provider. The generic MRO is a + // BFS approximation rather than Python C3, so selecting the + // first of multiple inherited owners would fabricate order. + // A class-body field of the same name also blocks descriptor + // lookup and must suppress a later method candidate. + // + // class Worker(HookMixin, Helpers): ... + // + // `Helpers` is not itself a subtype of HookMixin, so the + // subtype closure cannot discover it. The already-built MRO + // supplies the inherited owner set; the conservative rule + // above deliberately does not trust its approximate order. + let subtypeAmbiguous = false; + let picked: SymbolDefinition | undefined; + const effectiveOwners = [ + subtype.nodeId, + ...scopes.methodDispatch.mroFor(subtype.nodeId), + ]; + const inheritedCandidates = new Map(); + for (let ownerIndex = 0; ownerIndex < effectiveOwners.length; ownerIndex++) { + const effectiveOwnerId = effectiveOwners[ownerIndex]!; + const overloads = model.methods.lookupAllByOwner(effectiveOwnerId, memberName); + const field = model.fields.lookupFieldByOwner(effectiveOwnerId, memberName); + if (field !== undefined) { + ambiguousCandidateIds.add(field.nodeId); + for (const overload of overloads) ambiguousCandidateIds.add(overload.nodeId); + subtypeAmbiguous = true; + break; + } + if (overloads.length === 0) continue; + const candidate = pickFirstNonStaticOnly( + effectiveOwnerId, + memberName, + site, + model, + provider, + ); + if (candidate === OVERLOAD_AMBIGUOUS) { + for (const overload of overloads) ambiguousCandidateIds.add(overload.nodeId); + subtypeAmbiguous = true; + break; + } + if (candidate === STATIC_ONLY_FILTERED) continue; + if (candidate !== undefined) { + if (isDeclarationOnly(candidate)) { + // An abstract declaration still binds the name for this + // owner. Do not expose a concrete method hidden in a base; + // concrete descendants are visited as their own subtypes. + inheritedCandidates.clear(); + ambiguousCandidateIds.add(candidate.nodeId); + subtypeAmbiguous = true; + break; + } + if (provider.arityCompatibility(site, candidate) === 'incompatible') { + // The owner bound this name. Python-style lookup cannot + // skip an incompatible override and expose a hidden base. + subtypeAmbiguous = true; + break; + } + const subtypeCompatibility = + provider.missingReceiverSubtypeCandidateCompatibility?.(site, candidate, { + callerFilePath: parsed.filePath, + }); + if (subtypeCompatibility === 'incompatible') { + subtypeAmbiguous = true; + break; + } + if (subtypeCompatibility === 'unknown') { + unknownCompatibilityCandidateIds.add(candidate.nodeId); + subtypeAmbiguous = true; + break; + } + if ( + candidate.isDeleted === true || + isUnreachableByInstanceDispatch(candidate) + ) { + continue; + } + if (ownerIndex === 0) { + picked = candidate; + break; + } + inheritedCandidates.set(candidate.nodeId, candidate); + } + } + if (!subtypeAmbiguous && picked === undefined) { + if (inheritedCandidates.size === 1) { + picked = inheritedCandidates.values().next().value; + } else if (inheritedCandidates.size > 1) { + for (const candidate of inheritedCandidates.values()) { + ambiguousCandidateIds.add(candidate.nodeId); + } + subtypeAmbiguous = true; + } + } + if (subtypeAmbiguous || picked === undefined) continue; + subtypeTargets.set(picked.nodeId, picked); + } + } + + if (ambiguousCandidateIds.size > 0) { + options.recordResolutionOutcome?.({ + kind: 'suppressed', + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + reason: 'member-lookup-ambiguous', + candidateIds: [...ambiguousCandidateIds], + }); + } + + const allTargets = [...subtypeTargets.values()]; + const coverage = prepareSubtypeDispatchCoverage( + allTargets, + new Set([...ambiguousCandidateIds, ...unknownCompatibilityCandidateIds]), + MAX_INTERFACE_DISPATCH_FANOUT, + ); + const targets = coverage.targets; + if (coverage.droppedTargets.length > 0) { + dispatchFanoutSkipped += coverage.droppedTargets.length; + if (dispatchFanoutSkippedNames.length < MAX_REPORTED_SKIPPED_INTERFACES) { + const dropped = coverage.droppedTargets + .slice(0, 5) + .map((target) => target.qualifiedName ?? target.nodeId); + const omitted = coverage.droppedTargets.length - dropped.length; + dispatchFanoutSkippedNames.push( + `${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${allTargets.length} targets; ` + + `dropped: ${dropped.join(', ')}${omitted > 0 ? `, +${omitted} more` : ''})`, + ); + } + } + + for (const target of targets) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + target, + 'interface-dispatch', + seen, + 0.85, + collapse, + calleeCapture, + ); + if (ok) { + emitted++; + } + } + + if (coverage.recordUnresolved) { + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + receiverOrigin: 'in-program', + candidateIds: coverage.missingCandidateIds, + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + siteKind: site.kind, + }); + } + handledSites.add(siteKey); + continue; + } } } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index c3e8fc1b5..59dc62d23 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -790,7 +790,17 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v114 (#2965): C and C++ angle includes are real wildcard imports with // `isSystem`. Warm shards stored those captures as absent, so incremental // analyze never asked the resolver to search include paths. 113 is #3371. -const SCHEMA_BUMP = 114; +// v115 (#3390): Python call captures now carry `@reference.arity` when the +// argument count is statically known. Warm v114 ParsedFiles lack that fact, so +// arity-aware method filtering would remain inert for every unchanged file. +// v116 (#3390 follow-up): Python's private capture side-channel now records +// simple-positional call sites and fixed positional method capacity. Warm v115 +// ParsedFiles lack those facts, so conservative mixin subtype dispatch would +// suppress unchanged one-argument callers. +// v117 (#3390 private-only successor): simple-positional call entries now carry +// their count privately, while ordinary Python references no longer receive +// synthetic arity. Warm v116 ParsedFiles have neither equivalent fact. +const SCHEMA_BUMP = 117; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_a.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_a.py new file mode 100644 index 000000000..bf40d9d36 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_a.py @@ -0,0 +1,6 @@ +from mixins import AmbiguousMixin + + +class FirstWorker(AmbiguousMixin): + def run(self) -> int: + return 1 diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_b.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_b.py new file mode 100644 index 000000000..e68fb584e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_b.py @@ -0,0 +1,6 @@ +from mixins import AmbiguousMixin + + +class SecondWorker(AmbiguousMixin): + def run(self) -> int: + return 2 diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/conditional.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/conditional.py new file mode 100644 index 000000000..ec1b3a170 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/conditional.py @@ -0,0 +1,7 @@ +from mixins import HookMixin + + +class ConditionalWorker(HookMixin): + if True: + def helper(instance) -> int: + return 2 diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/decoys.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/decoys.py new file mode 100644 index 000000000..3efd642d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/decoys.py @@ -0,0 +1,10 @@ +def helper() -> int: + return -1 + + +def run() -> int: + return -1 + + +def missing_target() -> int: + return -1 diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/helpers.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/helpers.py new file mode 100644 index 000000000..4cadc83f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/helpers.py @@ -0,0 +1,4 @@ +class Helpers: + @staticmethod + def helper() -> int: + return 2 diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/inherited.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/inherited.py new file mode 100644 index 000000000..972b45069 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/inherited.py @@ -0,0 +1,6 @@ +from helpers import Helpers +from mixins import HookMixin + + +class InheritedWorker(HookMixin, Helpers): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/mixins.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/mixins.py new file mode 100644 index 000000000..e469fad40 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/mixins.py @@ -0,0 +1,104 @@ +class HookMixin: + def first(self) -> int: + return self.helper() + + def second(self) -> int: + return self.helper() + + def renamed(instance) -> int: + return instance.helper() + + def missing(self) -> int: + return self.missing_target() + + +class AnnotatedCaller: + def call_annotated(self, other: HookMixin) -> int: + return other.helper() + + +class ClassReceiverMixin: + @classmethod + def invoke(receiver, value: int) -> int: + return receiver.class_only(value) + + +class AmbiguousMixin: + def dispatch(self) -> int: + return self.run() + + +class VariadicPseudoReceiverMixin: + def variadic_dispatch(*args) -> int: + return args.variadic_target() + + +class NestedClassReceiverMixin: + @classmethod + def invoke_nested(owner) -> int: + def inner() -> int: + return owner.instance_only() + + return inner() + + +class MroOrderMixin: + def dispatch_order(self) -> int: + return self.order_hook() + + +class FieldShadowMixin: + def dispatch_shadow(self) -> int: + return self.shadow_hook() + + +class LifecycleReceiverMixin: + def __init_subclass__(cls) -> int: + return cls.lifecycle_hook() + + def __new__(cls) -> int: + cls.allocate() + return cls.new_hook() + + @classmethod + def allocate(cls) -> int: + return 1 + + +class GenericMixin: + def __class_getitem__(cls, item: object) -> int: + return cls.class_only() + + +class ArgumentShapeMixin: + def positional_to_keyword_only(self) -> int: + return self.keyword_only_target(1) + + def keyword_to_positional_only(self) -> int: + return self.positional_only_target(value=1) + + def positional_missing_required_keyword(self) -> int: + return self.required_keyword_target(1) + + +class ArgumentForwardingMixin: + def forward_first(self, value: int) -> int: + return self.forward_target(value) + + def forward_second(self, value: int) -> int: + return self.forward_target(value) + + +class PrivateNameMixin: + def dispatch_private(self) -> int: + return self.__private_hook() + + +class AbstractBoundaryMixin: + def dispatch_abstract(self) -> int: + return self.abstract_hook() + + +class DuplicateDefinitionMixin: + def dispatch_duplicate(self) -> int: + return self.duplicate_hook(1) diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/worker.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/worker.py new file mode 100644 index 000000000..de76a6359 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/worker.py @@ -0,0 +1,139 @@ +from abc import ABC, abstractmethod + +from mixins import ( + AbstractBoundaryMixin, + ArgumentForwardingMixin, + ArgumentShapeMixin, + ClassReceiverMixin, + DuplicateDefinitionMixin, + FieldShadowMixin, + GenericMixin, + HookMixin, + LifecycleReceiverMixin, + MroOrderMixin, + NestedClassReceiverMixin, + PrivateNameMixin, + VariadicPseudoReceiverMixin, +) + + +class Worker(HookMixin): + def helper(instance) -> int: + return 1 + + +class ClassReceiverWorker(ClassReceiverMixin): + def class_only(self) -> int: + return 1 + + +class VariadicPseudoReceiverWorker(VariadicPseudoReceiverMixin): + def variadic_target(self) -> int: + return 1 + + +class NestedClassReceiverWorker(NestedClassReceiverMixin): + def instance_only(self) -> int: + return 1 + + +class OrderX: + def order_hook(self) -> int: + return 1 + + +class OrderA(OrderX): + pass + + +class OrderB: + def order_hook(self) -> int: + return 2 + + +class OrderedWorker(MroOrderMixin, OrderA, OrderB): + pass + + +class ShadowBlocker: + shadow_hook = None + + +class ShadowProvider: + def shadow_hook(self) -> int: + return 1 + + +class ShadowWorker(FieldShadowMixin, ShadowBlocker, ShadowProvider): + pass + + +class LifecycleReceiverWorker(LifecycleReceiverMixin): + def lifecycle_hook(self) -> int: + return 1 + + def new_hook(self) -> int: + return 1 + + +class GenericWorker(GenericMixin): + def class_only(self) -> int: + return 1 + + +class CompatibleArgumentBase: + def keyword_only_target(self, value: int) -> int: + return value + + +class ArgumentShapeWorker(ArgumentShapeMixin, CompatibleArgumentBase): + def keyword_only_target(self, *, value: int) -> int: + return value + + def positional_only_target(self, value: int, /) -> int: + return value + + def required_keyword_target(self, value: int = 0, *, required: int) -> int: + return value + required + + +class ArgumentForwardingWorker(ArgumentForwardingMixin): + def forward_target(self, value: int) -> int: + return value + + +class PrivateNameWorker(PrivateNameMixin): + def __private_hook(self) -> int: + return 1 + + +class AbstractBoundaryX(ABC): + @abstractmethod + def abstract_hook(self) -> int: + raise NotImplementedError + + +class AbstractBoundaryA(AbstractBoundaryX): + pass + + +class AbstractBoundaryB: + def abstract_hook(self) -> int: + return 1 + + +class AbstractBoundaryWorker(AbstractBoundaryMixin, AbstractBoundaryA, AbstractBoundaryB): + pass + + +class ConcreteAbstractWorker(AbstractBoundaryWorker): + def abstract_hook(self) -> int: + return 2 + + +class DuplicateDefinitionWorker(DuplicateDefinitionMixin): + def duplicate_hook(self, value: int) -> int: + return value + + def duplicate_hook(self, left: int, right: int) -> int: + return left + right diff --git a/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/wrong_arity.py b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/wrong_arity.py new file mode 100644 index 000000000..695a12ead --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/wrong_arity.py @@ -0,0 +1,11 @@ +from mixins import HookMixin + + +class WrongArityWorker(HookMixin): + def helper(self, value: int) -> int: + return value + + +class VariadicWrongArityWorker(HookMixin): + def helper(self, required: int, *args: int) -> int: + return required diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 78827ffdd..efcb35649 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -519,6 +519,42 @@ "captureGroups": 31, "digest": "9e3f359187a82e936cd74c59848a296d19f138865a71882e3bbd3842bb4f0704" }, + "python-mixin-self-dispatch/ambiguous_a.py": { + "captureGroups": 10, + "digest": "8ff1561b66846722c78a95f0492f4bcc67587e52a22c6d09d980b2025b1a9f53" + }, + "python-mixin-self-dispatch/ambiguous_b.py": { + "captureGroups": 10, + "digest": "0d81efdead49a51822b48916d5137699d8343e68811af7cceae37946596159c5" + }, + "python-mixin-self-dispatch/conditional.py": { + "captureGroups": 10, + "digest": "fa09cba191ae3ba4afb67db0ceef56725e2a73707c8b106b397206afe173c115" + }, + "python-mixin-self-dispatch/decoys.py": { + "captureGroups": 10, + "digest": "aefd2ce8ab50a2ddca86f277e2503b33ea825c67041e0f1ad3f3cc6f26f30534" + }, + "python-mixin-self-dispatch/helpers.py": { + "captureGroups": 7, + "digest": "21cf0a934e0bd7556082f83084d27a5ff04706f88f3d8de16811ed76646dc3cd" + }, + "python-mixin-self-dispatch/inherited.py": { + "captureGroups": 7, + "digest": "4250018e9eabafe863caedc43306c69ff28fe17f19103edf2f01ed6438d0af19" + }, + "python-mixin-self-dispatch/mixins.py": { + "captureGroups": 188, + "digest": "730fcef0260b2a8af6a6c80d2700011a82ac9d54bc842fcdb15a5907986c49ed" + }, + "python-mixin-self-dispatch/worker.py": { + "captureGroups": 211, + "digest": "82b087675071c005a6a58c84659009d180e71297f0ed7c915a1d8f745883730e" + }, + "python-mixin-self-dispatch/wrong_arity.py": { + "captureGroups": 23, + "digest": "77c19005cc204aff148f6b230d0334cda52f338f34418bf9b609c6c5c44d87a5" + }, "python-module-export-vs-method-collision/app.py": { "captureGroups": 14, "digest": "1362e9187b6a8a8223e55833c725b3f02475faaa803ac657b917d434b550356a" diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index add7b7bd0..6c61a1fe9 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -9,6 +9,7 @@ import { FIXTURES, CROSS_FILE_FIXTURES, getRelationships, + getResolutionOutcomes, getNodesByLabel, getNodesByLabelFull, edgeSet, @@ -1078,6 +1079,254 @@ describe('Python self resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Mixin self-dispatch: a method supplied only by a concrete subtype +// --------------------------------------------------------------------------- + +describe('Python mixin self-dispatch', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'python-mixin-self-dispatch'), () => {}); + }, 60000); + + it('resolves each self.helper() through direct and sibling-base implementations', () => { + const helperCalls = getRelationships(result, 'CALLS').filter( + (call) => call.target === 'helper' && ['first', 'second'].includes(call.source), + ); + expect(helperCalls.map((call) => `${call.source} → ${call.targetFilePath}`).sort()).toEqual([ + 'first → conditional.py', + 'first → helpers.py', + 'first → worker.py', + 'second → conditional.py', + 'second → helpers.py', + 'second → worker.py', + ]); + }); + + it('keeps the sibling-base @staticmethod reachable through instance self dispatch', () => { + const staticCalls = getRelationships(result, 'CALLS').filter( + (call) => + call.target === 'helper' && + call.targetFilePath === 'helpers.py' && + ['first', 'second'].includes(call.source), + ); + expect(staticCalls.map((call) => call.source).sort()).toEqual(['first', 'second']); + }); + + it('uses self provenance with renamed caller and target receiver parameters', () => { + const renamedCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'renamed' && call.target === 'helper', + ); + expect(renamedCalls.map((call) => call.targetFilePath).sort()).toEqual([ + 'conditional.py', + 'helpers.py', + 'worker.py', + ]); + }); + + it('does not fan ordinary annotated receivers out through concrete subtypes', () => { + const annotatedFanout = getRelationships(result, 'CALLS').filter( + (call) => + call.source === 'call_annotated' && + call.target === 'helper' && + call.rel.reason === 'interface-dispatch', + ); + expect(annotatedFanout).toEqual([]); + }); + + it('does not treat a renamed classmethod receiver as instance dispatch', () => { + const classReceiverFanout = getRelationships(result, 'CALLS').filter( + (call) => + call.source === 'invoke' && + call.target === 'class_only' && + call.rel.reason === 'interface-dispatch', + ); + expect(classReceiverFanout).toEqual([]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.filePath === 'mixins.py' && + outcome.name === 'class_only' && + outcome.reason === 'receiver-unresolved', + ), + ).toBe(false); + }); + + it('does not use pseudo-receivers or captured classmethod receivers for instance fan-out', () => { + const falseFanout = getRelationships(result, 'CALLS').filter( + (call) => + call.rel.reason === 'interface-dispatch' && + ((call.source === 'variadic_dispatch' && call.target === 'variadic_target') || + (call.source === 'inner' && call.target === 'instance_only')), + ); + expect(falseFanout).toEqual([]); + }); + + it('excludes required fixed arguments that precede a variadic tail', () => { + const wrongArityCalls = getRelationships(result, 'CALLS').filter( + (call) => + ['first', 'second', 'renamed'].includes(call.source) && + call.target === 'helper' && + call.targetFilePath === 'wrong_arity.py', + ); + expect(wrongArityCalls).toEqual([]); + }); + + it('fans an ambiguous runtime subtype dispatch out instead of picking one target', () => { + const runCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch' && call.target === 'run', + ); + expect(runCalls.map((call) => call.targetFilePath).sort()).toEqual([ + 'ambiguous_a.py', + 'ambiguous_b.py', + ]); + }); + + it('suppresses a missing self member instead of falling back to a same-named free function', () => { + const missingCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'missing' && call.target === 'missing_target', + ); + expect(missingCalls).toEqual([]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.kind === 'suppressed' && + outcome.filePath === 'mixins.py' && + outcome.name === 'missing_target' && + outcome.reason === 'receiver-unresolved' && + outcome.receiverOrigin === 'in-program', + ), + ).toBe(true); + }); + + it('suppresses inherited providers when the simplified MRO cannot prove Python order', () => { + const orderCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch_order' && call.target === 'order_hook', + ); + expect(orderCalls).toEqual([]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === 'order_hook' && + outcome.reason === 'member-lookup-ambiguous', + ), + ).toBe(true); + }); + + it('honors inherited field shadowing instead of skipping to a later method', () => { + const shadowCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch_shadow' && call.target === 'shadow_hook', + ); + expect(shadowCalls).toEqual([]); + }); + + it('does not treat implicit class/static lifecycle receivers as instance fan-out', () => { + const lifecycleCalls = getRelationships(result, 'CALLS').filter( + (call) => + ['__init_subclass__', '__new__'].includes(call.source) && + ['lifecycle_hook', 'new_hook'].includes(call.target), + ); + expect(lifecycleCalls).toEqual([]); + const allocationCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === '__new__' && call.target === 'allocate', + ); + expect(allocationCalls).toHaveLength(1); + expect(allocationCalls[0]!.rel.targetId).toContain('LifecycleReceiverMixin.allocate'); + }); + + it('does not treat an implicit __class_getitem__ receiver as instance fan-out', () => { + const genericCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === '__class_getitem__' && call.target === 'class_only', + ); + expect(genericCalls).toEqual([]); + }); + + it('suppresses positional/keyword binding mismatches during subtype dispatch', () => { + const shapedCalls = getRelationships(result, 'CALLS').filter( + (call) => + ['positional_to_keyword_only', 'keyword_to_positional_only'].includes(call.source) && + ['keyword_only_target', 'positional_only_target'].includes(call.target), + ); + expect(shapedCalls).toEqual([]); + }); + + it('does not expose a hidden compatible base behind an incompatible override', () => { + const hiddenBaseCalls = getRelationships(result, 'CALLS').filter( + (call) => + call.source === 'positional_to_keyword_only' && call.target === 'keyword_only_target', + ); + expect(hiddenBaseCalls).toEqual([]); + }); + + it('suppresses a positional call that leaves a required keyword-only parameter unsatisfied', () => { + const requiredKeywordCalls = getRelationships(result, 'CALLS').filter( + (call) => + call.source === 'positional_missing_required_keyword' && + call.target === 'required_keyword_target', + ); + expect(requiredKeywordCalls).toEqual([]); + }); + + it('resolves both simple one-positional-argument mixin callers', () => { + const forwardingCalls = getRelationships(result, 'CALLS').filter( + (call) => + ['forward_first', 'forward_second'].includes(call.source) && + call.target === 'forward_target', + ); + expect(forwardingCalls.map((call) => `${call.source} → ${call.targetFilePath}`).sort()).toEqual( + ['forward_first → worker.py', 'forward_second → worker.py'], + ); + }); + + it('does not cross Python private-name mangling boundaries', () => { + const privateCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch_private' && call.target === '__private_hook', + ); + expect(privateCalls).toEqual([]); + }); + + it('keeps an abstract declaration as a name boundary while resolving concrete descendants', () => { + const abstractCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch_abstract' && call.target === 'abstract_hook', + ); + expect(abstractCalls).toHaveLength(1); + expect(abstractCalls[0]!.rel.targetId).toContain('ConcreteAbstractWorker.abstract_hook'); + }); + + it('keeps duplicate same-owner definitions ambiguous without generic Python call arity', () => { + const duplicateCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch_duplicate' && call.target === 'duplicate_hook', + ); + expect(duplicateCalls).toEqual([]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.filePath === 'mixins.py' && + outcome.name === 'duplicate_hook' && + outcome.reason === 'member-lookup-ambiguous', + ), + ).toBe(true); + }); + + it('never routes mixin self-dispatch to receiver-blind decoy functions', () => { + const calls = getRelationships(result, 'CALLS').filter((call) => + [ + 'first', + 'second', + 'renamed', + 'call_annotated', + 'dispatch', + 'missing', + 'variadic_dispatch', + 'inner', + ].includes(call.source), + ); + expect(calls.some((call) => call.targetFilePath === 'decoys.py')).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // Parent class resolution: EXTENDS edge // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index d949208d1..550bf510a 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -294,8 +294,11 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 113 -> 114 for #2965: C/C++ angle includes survive interpret as // `isSystem` wildcards. Warm shards omitted them, so include-path lookup // never ran until a full reparse. 113 stays taken by #3371. - it('pins SCHEMA_BUMP to 114 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(114); + // Moved 114 -> 115 for #3390: statically known Python call arity. + // Moved 115 -> 116 for #3390's Python subtype-dispatch shape side-channel. + // Moved 116 -> 117 for #3390's private positional-count side-channel. + it('pins SCHEMA_BUMP to 117 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(117); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -304,7 +307,7 @@ describe('PARSE_CACHE_VERSION', () => { for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, - 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, + 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index 7f9a83c43..9bdfe2f12 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -12,6 +12,8 @@ import { } from '../../src/core/ingestion/method-extractors/configs/typescript-javascript.js'; import { cppMethodConfig } from '../../src/core/ingestion/method-extractors/configs/c-cpp.js'; import { pythonMethodConfig } from '../../src/core/ingestion/method-extractors/configs/python.js'; +import { computePythonArityMetadata } from '../../src/core/ingestion/languages/python/arity-metadata.js'; +import { synthesizeReceiverTypeBinding } from '../../src/core/ingestion/languages/python/receiver-binding.js'; import { rubyMethodConfig } from '../../src/core/ingestion/method-extractors/configs/ruby.js'; import { rustMethodConfig } from '../../src/core/ingestion/method-extractors/configs/rust.js'; import { dartMethodConfig } from '../../src/core/ingestion/method-extractors/configs/dart.js'; @@ -2748,6 +2750,176 @@ class UserService: }); }); + describe('bound receiver parameters', () => { + it('uses class and decorator context instead of receiver spelling', () => { + const tree = parsePython(` +class Service: + def ordinary(instance): + pass + + @trace + def decorated(receiver): + pass + + @classmethod + def factory(owner): + pass + + @staticmethod + def static(self): + pass + + def typed_args(*args: int): + pass + + def typed_kwargs(**kwargs: int): + pass + `); + const result = extractor.extract(tree.rootNode.child(0)!, pythonCtx); + const byName = new Map(result!.methods.map((method) => [method.name, method])); + + expect(byName.get('ordinary')!.parameters).toHaveLength(0); + expect(byName.get('decorated')!.parameters).toHaveLength(0); + expect(byName.get('factory')!.parameters).toHaveLength(0); + expect(byName.get('static')!.parameters.map((parameter) => parameter.name)).toEqual(['self']); + expect(byName.get('typed_args')!.parameters[0]).toMatchObject({ + name: 'args', + isVariadic: true, + }); + expect(byName.get('typed_kwargs')!.parameters[0]).toMatchObject({ + name: 'kwargs', + isVariadic: true, + }); + }); + + it('retains first parameters on module and nested functions', () => { + const tree = parsePython(` +def module(instance): + pass + +def outer(): + def nested(receiver): + pass + `); + const functions = tree.rootNode.descendantsOfType('function_definition'); + const moduleFn = functions.find((node) => node.childForFieldName('name')?.text === 'module')!; + const nestedFn = functions.find((node) => node.childForFieldName('name')?.text === 'nested')!; + + expect( + pythonMethodConfig.extractParameters(moduleFn).map((parameter) => parameter.name), + ).toEqual(['instance']); + expect( + pythonMethodConfig.extractParameters(nestedFn).map((parameter) => parameter.name), + ).toEqual(['receiver']); + }); + + it('recognizes receivers through class-suite control flow', () => { + const tree = parsePython(` +class Service: + if ENABLED: + def conditional(instance): + pass + `); + const conditional = tree.rootNode + .descendantsOfType('function_definition') + .find((node) => node.childForFieldName('name')?.text === 'conditional')!; + + expect(pythonMethodConfig.extractParameters(conditional)).toEqual([]); + expect(computePythonArityMetadata(conditional)).toMatchObject({ + parameterCount: 0, + requiredParameterCount: 0, + }); + }); + + it('recognizes implicit descriptor kinds for Python lifecycle methods', () => { + const tree = parsePython(` +class Service: + def __init_subclass__(owner, flag): + pass + + def __new__(owner, value): + pass + + def __class_getitem__(owner, item): + pass + + @classmethod + def variadic_factory(*args): + pass + `); + const functions = tree.rootNode.descendantsOfType('function_definition'); + const byName = new Map( + functions.map((node) => [node.childForFieldName('name')?.text, node] as const), + ); + const extractedByName = new Map( + extractor + .extract(tree.rootNode.child(0)!, pythonCtx)! + .methods.map((method) => [method.name, method] as const), + ); + + expect( + pythonMethodConfig + .extractParameters(byName.get('__init_subclass__')!) + .map((parameter) => parameter.name), + ).toEqual(['flag']); + expect( + pythonMethodConfig + .extractParameters(byName.get('__new__')!) + .map((parameter) => parameter.name), + ).toEqual(['owner', 'value']); + const newBinding = synthesizeReceiverTypeBinding(byName.get('__new__')!); + expect(newBinding?.['@type-binding.cls']).toBeDefined(); + expect(newBinding?.['@type-binding.self']).toBeUndefined(); + const classGetitemBinding = synthesizeReceiverTypeBinding(byName.get('__class_getitem__')!); + expect(classGetitemBinding?.['@type-binding.cls']).toBeDefined(); + expect(classGetitemBinding?.['@type-binding.self']).toBeUndefined(); + expect( + pythonMethodConfig + .extractParameters(byName.get('__class_getitem__')!) + .map((parameter) => parameter.name), + ).toEqual(['item']); + expect(extractedByName.get('__init_subclass__')!.isStatic).toBe(true); + expect(extractedByName.get('__class_getitem__')!.isStatic).toBe(true); + expect(extractedByName.get('__new__')!.isStatic).toBe(true); + expect(extractedByName.get('variadic_factory')!.isStatic).toBe(true); + expect(extractedByName.get('variadic_factory')!.parameters).toHaveLength(1); + }); + + it('preserves non-receiver splats, keyword-only parameters, and variadic minima', () => { + const tree = parsePython(` +class Service: + def variadic(*args: int): + pass + + def keyword_only(*, option: int): + pass + + def needs_value(instance, required: int, *args: int): + pass + `); + const functions = tree.rootNode.descendantsOfType('function_definition'); + const byName = new Map( + functions.map((node) => [node.childForFieldName('name')?.text, node] as const), + ); + + expect( + pythonMethodConfig + .extractParameters(byName.get('variadic')!) + .map((parameter) => parameter.name), + ).toEqual(['args']); + expect( + pythonMethodConfig + .extractParameters(byName.get('keyword_only')!) + .map((parameter) => parameter.name), + ).toEqual(['option']); + expect(computePythonArityMetadata(byName.get('needs_value')!)).toMatchObject({ + parameterCount: undefined, + requiredParameterCount: undefined, + parameterNames: ['required', 'args'], + }); + }); + }); + describe('@abstractmethod', () => { it('detects abstract method', () => { const tree = parsePython(` diff --git a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts index 7944b82d5..15ba5f8fe 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts @@ -76,6 +76,27 @@ function snapshotOf(src: string, filePath: string): FixtureSnapshot { return { captureGroups: matches.length, digest: digestCaptures(matches) }; } +function callArity(src: string, name: string): string | undefined { + const match = emitPythonScopeCaptures(src, 'arity.py').find( + (candidate) => + candidate['@reference.name']?.text === name && + (candidate['@reference.call.free'] !== undefined || + candidate['@reference.call.member'] !== undefined), + ); + if (!match) throw new Error(`Missing call capture for ${name}`); + return match['@reference.arity']?.text; +} + +function receiverBindingNames(src: string): string[] { + return emitPythonScopeCaptures(src, 'receiver.py') + .filter( + (match) => + match['@type-binding.self'] !== undefined || match['@type-binding.cls'] !== undefined, + ) + .map((match) => match['@type-binding.name']!.text) + .sort(); +} + /** All `.py` files under `lang-resolution/python-*`, as sorted repo-relative-ish keys. */ function collectPythonFixtures(): { key: string; absPath: string }[] { const out: { key: string; absPath: string }[] = []; @@ -149,6 +170,47 @@ function formatGolden(snap: Snapshot): string { } describe('Python scope captures — golden parity', () => { + it('keeps ordinary Python call arity out of the generic reference schema', () => { + const src = [ + 'zero()', + 'one(value)', + 'keyword(value=1)', + 'mixed(1, named=2)', + 'obj.member(', + ' # comments are not arguments', + ' value,', + ')', + ].join('\n'); + + expect(callArity(src, 'zero')).toBeUndefined(); + expect(callArity(src, 'one')).toBeUndefined(); + expect(callArity(src, 'keyword')).toBeUndefined(); + expect(callArity(src, 'mixed')).toBeUndefined(); + expect(callArity(src, 'member')).toBeUndefined(); + }); + + it('keeps call arity unknown when an argument splat is present', () => { + const src = ['from_list(*values)', 'from_dict(**values)', 'mixed(*values, named=1)'].join('\n'); + + expect(callArity(src, 'from_list')).toBeUndefined(); + expect(callArity(src, 'from_dict')).toBeUndefined(); + expect(callArity(src, 'mixed')).toBeUndefined(); + }); + + it('synthesizes receiver bindings only for real positional parameters', () => { + const src = [ + 'class Example:', + ' def ordinary(instance): pass', + ' @classmethod', + ' def factory(owner): pass', + ' def variadic(*args): pass', + ' def keyword_variadic(**kwargs): pass', + ' def keyword_only(*, option): pass', + ].join('\n'); + + expect(receiverBindingNames(src)).toEqual(['instance', 'owner']); + }); + it('matches the committed golden snapshot across all python-* fixtures + DAO shape', () => { const snapshot = buildSnapshot(); diff --git a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts index 1411b6548..8ffadb3f2 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts @@ -26,6 +26,8 @@ import { pythonBindingScopeFor, resolvePythonImportTarget, } from '../../../../src/core/ingestion/languages/python/index.js'; +import { pythonMissingReceiverSubtypeDecision } from '../../../../src/core/ingestion/languages/python/scope-resolver.js'; +import { prepareSubtypeDispatchCoverage } from '../../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js'; // ─── Helpers ─────────────────────────────────────────────────────────────── @@ -56,6 +58,43 @@ const binding = (origin: BindingRef['origin'], nodeId = 'd1'): BindingRef => ({ origin, }); +describe('prepareSubtypeDispatchCoverage', () => { + const target = (nodeId: string) => ({ nodeId }); + + it('retains proven targets while marking ambiguous subtype coverage incomplete', () => { + const coverage = prepareSubtypeDispatchCoverage( + [target('valid')], + ['ambiguous-a', 'ambiguous-b'], + 32, + ); + + expect(coverage.targets).toEqual([target('valid')]); + expect(coverage.missingCandidateIds).toEqual(['ambiguous-a', 'ambiguous-b']); + expect(coverage.partialCoverage).toBe(true); + expect(coverage.recordUnresolved).toBe(true); + }); + + it('keeps the bounded prefix and reports every target dropped by the cap', () => { + const coverage = prepareSubtypeDispatchCoverage( + [target('one'), target('two'), target('three')], + [], + 2, + ); + + expect(coverage.targets).toEqual([target('one'), target('two')]); + expect(coverage.droppedTargets).toEqual([target('three')]); + expect(coverage.missingCandidateIds).toEqual(['three']); + expect(coverage.recordUnresolved).toBe(true); + }); + + it('does not report complete known targets unresolved when edge emission deduplicates', () => { + const coverage = prepareSubtypeDispatchCoverage([target('already-emitted')], [], 32); + + expect(coverage.partialCoverage).toBe(false); + expect(coverage.recordUnresolved).toBe(false); + }); +}); + // ─── arityCompatibility ──────────────────────────────────────────────────── describe('pythonArityCompatibility', () => { @@ -141,6 +180,50 @@ describe('pythonReceiverBinding', () => { }); }); +describe('pythonMissingReceiverSubtypeDecision', () => { + const selfType: TypeRef = { + rawName: 'Mixin', + declaredAtScope: 'scope:mixin' as ScopeId, + source: 'self', + }; + + it('admits instance dispatch before candidate argument-shape filtering', () => { + expect( + pythonMissingReceiverSubtypeDecision(selfType, { + receiverBindingIsStatic: false, + memberName: 'hook', + callArity: 0, + }), + ).toBe(true); + expect( + pythonMissingReceiverSubtypeDecision(selfType, { + receiverBindingIsStatic: false, + memberName: 'hook', + callArity: 1, + }), + ).toBe(true); + }); + + it('suppresses private-name mangling', () => { + expect( + pythonMissingReceiverSubtypeDecision(selfType, { + receiverBindingIsStatic: false, + memberName: '__hook', + callArity: 0, + }), + ).toBe('suppress'); + }); + + it('declines class receivers without suppressing their ordinary resolution path', () => { + expect( + pythonMissingReceiverSubtypeDecision( + { ...selfType, source: 'cls' }, + { receiverBindingIsStatic: true, memberName: 'hook', callArity: 0 }, + ), + ).toBe(false); + }); +}); + // ─── mergeBindings ───────────────────────────────────────────────────────── describe('pythonMergeBindings — LEGB precedence', () => { diff --git a/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts b/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts new file mode 100644 index 000000000..97a9f75b7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { emitPythonScopeCaptures } from '../../../../src/core/ingestion/languages/python/captures.js'; +import { + applyPythonSubtypeDispatchSideChannel, + beginPythonSubtypeDispatchCapture, + collectPythonSubtypeDispatchSideChannel, +} from '../../../../src/core/ingestion/languages/python/subtype-dispatch.js'; +import { pythonMissingReceiverSubtypeCandidateCompatibility } from '../../../../src/core/ingestion/languages/python/scope-resolver.js'; + +const callerSource = [ + 'class Caller:', + ' def positional(self, value):', + ' return self.target(value)', + ' def keyword(self, value):', + ' return self.target(value=value)', + ' def too_few(self):', + ' return self.target()', + ' def too_many(self, value):', + ' return self.target(value, value)', +].join('\n'); + +const targetSource = [ + 'class Worker:', + ' def target(self, value):', + ' return value', + 'class KeywordOnly:', + ' def target(self, *, value=0):', + ' return value', + 'class PositionalOnly:', + ' def target(self, value, /):', + ' return value', + 'class RequiredKeywordOnly:', + ' def target(self, value=0, *, required):', + ' return value + required', +].join('\n'); + +const candidate = (line: number): SymbolDefinition => ({ + nodeId: `def:targets.py#${line}:4:Method:target`, + filePath: 'targets.py', + type: 'Method', + parameterCount: 1, + requiredParameterCount: 1, +}); + +const positionalSite = { + arity: 1, + atRange: { startLine: 3, startCol: 15, endLine: 3, endCol: 33 }, +}; +const keywordSite = { + arity: 1, + atRange: { startLine: 5, startCol: 15, endLine: 5, endCol: 39 }, +}; +const tooFewSite = { + atRange: { startLine: 7, startCol: 15, endLine: 7, endCol: 28 }, +}; +const tooManySite = { + atRange: { startLine: 9, startCol: 15, endLine: 9, endCol: 40 }, +}; + +describe('Python missing-member subtype argument shapes', () => { + it('preserves simple positional compatibility across capture snapshot restore', () => { + const captures = emitPythonScopeCaptures(callerSource, 'caller.py'); + emitPythonScopeCaptures(targetSource, 'targets.py'); + const callerSnapshot = collectPythonSubtypeDispatchSideChannel('caller.py'); + const targetSnapshot = collectPythonSubtypeDispatchSideChannel('targets.py'); + + expect(callerSnapshot).toBeDefined(); + expect(targetSnapshot).toBeDefined(); + expect(() => structuredClone(callerSnapshot)).not.toThrow(); + expect(() => structuredClone(targetSnapshot)).not.toThrow(); + expect(captures.every((capture) => capture['@reference.arity'] === undefined)).toBe(true); + expect(callerSnapshot?.simplePositionalCalls).toEqual([ + [3, 15, 1], + [7, 15, 0], + [9, 15, 2], + ]); + + const fresh = [ + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(2)), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(5)), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', keywordSite, candidate(8)), + pythonMissingReceiverSubtypeCandidateCompatibility( + 'caller.py', + positionalSite, + candidate(11), + ), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', tooFewSite, candidate(2)), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', tooManySite, candidate(2)), + ]; + expect(fresh).toEqual([ + 'compatible', + 'incompatible', + 'unknown', + 'unknown', + 'incompatible', + 'incompatible', + ]); + + beginPythonSubtypeDispatchCapture('caller.py'); + beginPythonSubtypeDispatchCapture('targets.py'); + expect( + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(2)), + ).toBe('unknown'); + + applyPythonSubtypeDispatchSideChannel({ + filePath: 'caller.py', + captureSideChannel: callerSnapshot, + } as ParsedFile); + applyPythonSubtypeDispatchSideChannel({ + filePath: 'targets.py', + captureSideChannel: targetSnapshot, + } as ParsedFile); + + expect([ + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(2)), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(5)), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', keywordSite, candidate(8)), + pythonMissingReceiverSubtypeCandidateCompatibility( + 'caller.py', + positionalSite, + candidate(11), + ), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', tooFewSite, candidate(2)), + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', tooManySite, candidate(2)), + ]).toEqual(fresh); + }); + + it('records notebook side-channel facts in remapped source coordinates', () => { + emitPythonScopeCaptures('target(value)', 'notebook.ipynb', undefined, { + sourceKind: 'pre-extracted-script', + notebookSegments: [ + { extractStartLine: 0, extractEndLine: 0, jsonStartLine: 20, jsonEndLine: 20 }, + ], + }); + + expect(collectPythonSubtypeDispatchSideChannel('notebook.ipynb')).toMatchObject({ + simplePositionalCalls: [[21, 0, 1]], + }); + }); +});