fix(python): resolve mixin self calls to subtype implementations (#3390)

* 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 <eva@100yen.org>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
EVA 2026-09-28 13:12:56 +09:00 • committed by GitHub
parent ccf6b4743d
commit f6e70016d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1904 additions and 78 deletions

View file

@ -1 +1 @@
8075fe28c703c9d27d1b1b90ae04992548b677daf61be5116511b6ca3424dde1
317f9b2e0114172ed592435a7059dae6b278f5bab2b56f8269eae66b8275c431

View file

@ -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

View file

@ -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,
"<<unclassified>>": 1
},
"callDropsByOrigin": {
"external": 44,
"in-program": 43,
"in-program": 44,
"unknown": 26
}
}

View file

@ -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<string> = 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,

View file

@ -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.
*/

View file

@ -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<ReturnType<typeof getPythonParser>['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<string, Capture>,
nodeMap: Readonly<Record<string, SyntaxNode>>,
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

View file

@ -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,
),
};
}

View file

@ -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<ReferenceSite, 'atRange'>,
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.
//

View file

@ -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<string, Map<string, number>>();
const positionalCapacitiesByFile = new Map<string, Map<string, number>>();
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<string, number>();
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<string, number>();
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<string, number>();
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<string, number>();
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));
}

View file

@ -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) {

View file

@ -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

View file

@ -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<T extends { readonly nodeId: string }>(
targets: readonly T[],
missingCandidateIds: Iterable<string>,
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<string, SymbolDefinition>();
const ambiguousCandidateIds = new Set<string>();
const unknownCompatibilityCandidateIds = new Set<string>();
const visitedSubtypeIds = new Set<string>([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<string, SymbolDefinition>();
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;
}
}
}

View file

@ -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

View file

@ -0,0 +1,6 @@
from mixins import AmbiguousMixin
class FirstWorker(AmbiguousMixin):
def run(self) -> int:
return 1

View file

@ -0,0 +1,6 @@
from mixins import AmbiguousMixin
class SecondWorker(AmbiguousMixin):
def run(self) -> int:
return 2

View file

@ -0,0 +1,7 @@
from mixins import HookMixin
class ConditionalWorker(HookMixin):
if True:
def helper(instance) -> int:
return 2

View file

@ -0,0 +1,10 @@
def helper() -> int:
return -1
def run() -> int:
return -1
def missing_target() -> int:
return -1

View file

@ -0,0 +1,4 @@
class Helpers:
@staticmethod
def helper() -> int:
return 2

View file

@ -0,0 +1,6 @@
from helpers import Helpers
from mixins import HookMixin
class InheritedWorker(HookMixin, Helpers):
pass

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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);
}

View file

@ -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(`

View file

@ -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();

View file

@ -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', () => {

View file

@ -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]],
});
});
});