mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
ccf6b4743d
commit
f6e70016d6
29 changed files with 1904 additions and 78 deletions
|
|
@ -1 +1 @@
|
|||
8075fe28c703c9d27d1b1b90ae04992548b677daf61be5116511b6ca3424dde1
|
||||
317f9b2e0114172ed592435a7059dae6b278f5bab2b56f8269eae66b8275c431
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
231
gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts
Normal file
231
gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_a.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_a.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from mixins import AmbiguousMixin
|
||||
|
||||
|
||||
class FirstWorker(AmbiguousMixin):
|
||||
def run(self) -> int:
|
||||
return 1
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_b.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/ambiguous_b.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from mixins import AmbiguousMixin
|
||||
|
||||
|
||||
class SecondWorker(AmbiguousMixin):
|
||||
def run(self) -> int:
|
||||
return 2
|
||||
7
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/conditional.py
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/conditional.py
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from mixins import HookMixin
|
||||
|
||||
|
||||
class ConditionalWorker(HookMixin):
|
||||
if True:
|
||||
def helper(instance) -> int:
|
||||
return 2
|
||||
10
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/decoys.py
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/decoys.py
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
def helper() -> int:
|
||||
return -1
|
||||
|
||||
|
||||
def run() -> int:
|
||||
return -1
|
||||
|
||||
|
||||
def missing_target() -> int:
|
||||
return -1
|
||||
4
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/helpers.py
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/helpers.py
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class Helpers:
|
||||
@staticmethod
|
||||
def helper() -> int:
|
||||
return 2
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/inherited.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/inherited.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from helpers import Helpers
|
||||
from mixins import HookMixin
|
||||
|
||||
|
||||
class InheritedWorker(HookMixin, Helpers):
|
||||
pass
|
||||
104
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/mixins.py
vendored
Normal file
104
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/mixins.py
vendored
Normal 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)
|
||||
139
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/worker.py
vendored
Normal file
139
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/worker.py
vendored
Normal 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
|
||||
11
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/wrong_arity.py
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/python-mixin-self-dispatch/wrong_arity.py
vendored
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue