From 0bcddec8e682c9c5fd999ca54fe6c4fd5d73d34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 28 Sep 2026 20:22:10 +0100 Subject: [PATCH] fix(python): keep uncertain decorated receivers unresolved (#3405) * fix(python): suppress uncertain decorated receivers * fix(ci): keep uncertain Python receivers out of method arity Unrecognized decorators now leave the receiver kind unproven, but the first parameter is still the implicit receiver slot for ordinary bound calls. Method extraction stopped stripping it, so decorated methods reported one extra parameter and shifted capture arity metadata. Share the uncertain-receiver classification between type-binding synthesis and parameter extraction, then refresh the Python capture golden and benchmark fingerprint for the intended capture change. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5.5 (1M context) --- gitnexus-shared/src/scope-resolution/types.ts | 1 + .../python-scope/baseline-fingerprint.txt | 2 +- .../core/ingestion/languages/python/index.ts | 12 +- .../ingestion/languages/python/interpret.ts | 3 +- .../languages/python/receiver-binding.ts | 127 ++++++- .../languages/python/scope-resolver.ts | 1 + .../method-extractors/configs/python.ts | 13 +- .../src/core/ingestion/scope-extractor.ts | 5 + .../contract/scope-resolver.ts | 5 + .../passes/receiver-bound-calls.ts | 25 ++ gitnexus/src/storage/parse-cache.ts | 5 +- .../expected-captures.json | 6 +- .../test/integration/resolvers/python.test.ts | 317 ++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 8 +- 14 files changed, 501 insertions(+), 29 deletions(-) diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 5bad9673b..c5c1b4438 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -649,6 +649,7 @@ export interface TypeRef { | 'parameter-annotation' | 'return-annotation' | 'self' + | 'decorator-unknown' | 'assignment-inferred' | 'constructor-inferred' | 'receiver-propagated'; diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index adc688988..19cfd625e 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -317f9b2e0114172ed592435a7059dae6b278f5bab2b56f8269eae66b8275c431 +dde4450f8bb763575b0464255f063fd0ded24980ecf43d7d2a7d4dc4880f2665 diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index dafddf357..18f9da132 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -43,13 +43,11 @@ * and emit a single edge rather than branching. `List[T]` / * `Dict[K, V]` strip the outer generic for receiver typing (see * `interpret.ts`). - * 5. **Decorators that rewrite signatures** — `@dataclass`, - * `@property`, `@classmethod`, `@staticmethod` are recognized - * by `receiver-binding.ts`. Arbitrary decorators (e.g. - * `functools.wraps`, custom retry wrappers) preserve the wrapped - * function's declared signature; a decorator that returns a - * different callable is followed only through the declared - * return type. + * 5. **Decorators that rewrite signatures** — Known descriptor + * spellings (`@property`, `@classmethod`, `@staticmethod`) are + * recognized by `receiver-binding.ts`. Unknown decorator expressions + * leave implicit receiver binding unresolved. Calls through wrappers + * are followed only through their declared return type. * 6. **`typing.TYPE_CHECKING`-guarded imports** — treated like any * other `import` for reference resolution. We do not distinguish * runtime-visible from type-checker-only imports; this is diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index 8c36f5c41..0f00babcd 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -128,7 +128,8 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi // the variable-annotation and constructor-inferred forms from the // classic parameter annotation. let source: TypeRef['source'] = 'parameter-annotation'; - if (captures['@type-binding.self'] !== undefined) source = 'self'; + if (captures['@type-binding.uncertain-receiver'] !== undefined) source = 'decorator-unknown'; + else if (captures['@type-binding.self'] !== undefined) source = 'self'; // `cls` is a self-like receiver; share the source label so downstream // `Registry.lookup` Step 2 treats them identically. else if (captures['@type-binding.cls'] !== undefined) source = 'self'; diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index bdf335f17..e1e93ae11 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -30,17 +30,79 @@ function classDefinitionName(classNode: SyntaxNode): string | null { return classNode.childForFieldName('name')?.text ?? null; } -/** Does the function carry a `@` decorator? Matches both - * bare `@classmethod` and module-qualified `@functools.classmethod`. */ -function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean { +/** Syntactic decorator expressions; aliases cannot be identified by spelling. */ +function decoratorNames(fnNode: SyntaxNode): string[] { const parent = fnNode.parent; - if (parent === null || parent.type !== 'decorated_definition') return false; + if (parent === null || parent.type !== 'decorated_definition') return []; + const names: string[] = []; for (let i = 0; i < parent.namedChildCount; i++) { const child = parent.namedChild(i); if (child === null || child.type !== 'decorator') continue; - const text = child.text.replace(/^@/, '').split('(')[0]!.trim(); - const tail = text.split('.').pop(); - if (tail === decoratorName) return true; + const text = child.text.replace(/^@/, '').trim(); + names.push(text); + } + return names; +} + +/** Matches bare and module-qualified decorator spellings. */ +function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean { + return decoratorNames(fnNode).some((expression) => { + const name = expression.split('(')[0]!.trim(); + return name === decoratorName || name.endsWith(`.${decoratorName}`); + }); +} + +// These spellings have a known descriptor contract in ordinary Python code. +// Arbitrary dotted tails, aliases and decorator calls do not. +const KNOWN_RECEIVER_DECORATORS = new Set(['classmethod', 'staticmethod', 'property']); + +/** Accept a local `@property` accessor chain, skipping only plain unrelated + * methods. Other intervening class-suite statements may rebind the descriptor, + * including tuple assignment or control flow. */ +function isLocalPropertyAccessor(fnNode: SyntaxNode, expression: string): boolean { + const methodName = fnNode.childForFieldName('name')?.text; + if ( + methodName === undefined || + !['getter', 'setter', 'deleter'].some((kind) => expression === `${methodName}.${kind}`) + ) + return false; + const wrapper = fnNode.parent; + const classBody = findEnclosingClassDefinition(fnNode)?.childForFieldName('body'); + if (wrapper?.type !== 'decorated_definition' || classBody === null || classBody === undefined) { + return false; + } + let wrapperIndex = -1; + for (let i = 0; i < classBody.namedChildCount; i++) { + const sibling = classBody.namedChild(i); + if (sibling?.id === wrapper.id) { + wrapperIndex = i; + break; + } + } + for (let i = wrapperIndex - 1; i >= 0; i--) { + const sibling = classBody.namedChild(i); + if ( + sibling?.type === 'function_definition' && + sibling.childForFieldName('name')?.text !== methodName + ) { + // A plain function definition binds only its own name in the class suite. + continue; + } + if (sibling?.type !== 'decorated_definition') return false; + let candidate: SyntaxNode | null = null; + for (let j = 0; j < sibling.namedChildCount; j++) { + const child = sibling.namedChild(j); + if (child?.type === 'function_definition') candidate = child; + } + if (candidate?.childForFieldName('name')?.text !== methodName) return false; + const decorators = decoratorNames(candidate); + if (decorators.length !== 1) return false; + if (decorators[0] === 'property') return true; + if ( + !['getter', 'setter', 'deleter'].some((kind) => decorators[0] === `${methodName}.${kind}`) + ) { + return false; + } } return false; } @@ -52,6 +114,10 @@ export function isPythonStaticLikeMethod(fnNode: SyntaxNode): boolean { ); } +function isKnownReceiverDecorator(fnNode: SyntaxNode, expression: string): boolean { + return KNOWN_RECEIVER_DECORATORS.has(expression) || isLocalPropertyAccessor(fnNode, expression); +} + function firstBoundReceiverParameter(parameters: SyntaxNode): SyntaxNode | null { for (let i = 0; i < parameters.namedChildCount; i++) { const child = parameters.namedChild(i); @@ -100,7 +166,17 @@ export interface PythonBoundReceiver { */ export function classifyPythonBoundReceiver(fnNode: SyntaxNode): PythonBoundReceiver | null { const enclosingClass = findEnclosingClassDefinition(fnNode); - if (enclosingClass === null || isPythonStaticLikeMethod(fnNode)) return null; + if (enclosingClass === null) return null; + const decorators = decoratorNames(fnNode); + if ( + isPythonStaticLikeMethod(fnNode) || + decorators.some((name) => !isKnownReceiverDecorator(fnNode, name)) + ) { + // Python evaluates decorators as expressions. An unrecognized one may + // replace the function with a class/static descriptor, so its first + // parameter is not proven to receive an instance. + return null; + } const functionName = fnNode.childForFieldName('name')?.text; // Python applies these descriptor kinds implicitly even without decorators. @@ -117,7 +193,7 @@ export function classifyPythonBoundReceiver(fnNode: SyntaxNode): PythonBoundRece return { kind: - hasDecorator(fnNode, 'classmethod') || + decorators.includes('classmethod') || functionName === '__init_subclass__' || functionName === '__class_getitem__' ? 'class' @@ -154,9 +230,40 @@ function classifyPythonExplicitNewReceiver(fnNode: SyntaxNode): PythonBoundRecei * The caller is responsible for guaranteeing `fnNode.type === * 'function_definition'`. */ +/** + * The first parameter of a class method under an unrecognized decorator. + * Python may still bind it implicitly, but its receiver kind is unproven. + */ +export function classifyPythonUncertainReceiver(fnNode: SyntaxNode): PythonBoundReceiver | null { + const decorators = decoratorNames(fnNode); + // Python applies decorators bottom-up. An outer built-in staticmethod + // guarantees no implicit receiver even when an inner decorator is opaque. + // The first parameter remains explicit and can keep its annotation. + if (decorators[0] === 'staticmethod') return null; + if (decorators.every((name) => isKnownReceiverDecorator(fnNode, name))) return null; + const enclosingClass = findEnclosingClassDefinition(fnNode); + const parameters = fnNode.childForFieldName('parameters'); + const parameter = parameters === null ? null : firstBoundReceiverParameter(parameters); + const name = parameter === null ? null : firstParameterName(parameter); + const className = enclosingClass === null ? null : classDefinitionName(enclosingClass); + if (parameter === null || name === null || className === null) return null; + return { kind: 'instance', parameter, name, className }; +} + export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch | null { const receiver = classifyPythonBoundReceiver(fnNode) ?? classifyPythonExplicitNewReceiver(fnNode); - if (receiver === null) return null; + if (receiver === null) { + // Keep an uncertain receiver anchored to its enclosing class so the + // resolver can report the missing member without treating it as `self`. + const uncertain = classifyPythonUncertainReceiver(fnNode); + if (uncertain === null) return null; + const { parameter: first, name, className } = uncertain; + return { + '@type-binding.uncertain-receiver': nodeToCapture('@type-binding.uncertain-receiver', first), + '@type-binding.name': syntheticCapture('@type-binding.name', first, name), + '@type-binding.type': syntheticCapture('@type-binding.type', first, className), + }; + } // Receiver convention: instance methods get `self`, classmethods get `cls`. // The capture tag records the descriptor kind; the variable may use any diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 0d5bbe1fe..1a62f5335 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -77,6 +77,7 @@ const pythonScopeResolver: ScopeResolver = { // A free call naming a class constructs it: `Service(db).do_work()` (#2708). constructionSyntax: { bare: true }, language: SupportedLanguages.Python, + suppressReceiverLookup: (typeRef) => typeRef.source === 'decorator-unknown', languageProvider: pythonProvider, importEdgeReason: 'python-scope: import', diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/python.ts b/gitnexus/src/core/ingestion/method-extractors/configs/python.ts index fd1d154c1..0540956b1 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/python.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/python.ts @@ -8,7 +8,11 @@ import type { MethodVisibility, } from '../../method-types.js'; import { hasKeyword } from '../../field-extractors/configs/helpers.js'; -import { classifyPythonBoundReceiver } from '../../languages/python/receiver-binding.js'; +import { + classifyPythonBoundReceiver, + classifyPythonUncertainReceiver, + isPythonStaticLikeMethod, +} from '../../languages/python/receiver-binding.js'; import { extractSimpleTypeName } from '../../type-extractors/shared.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; @@ -100,7 +104,12 @@ function extractPythonParameters(node: SyntaxNode): ParameterInfo[] { const params: ParameterInfo[] = []; let isFirst = true; - const boundReceiverId = classifyPythonBoundReceiver(funcNode)?.parameter.id; + // An opaque decorator leaves the receiver kind unproven, not the slot: keep + // stripping it so arity matches ordinary bound calls. + const boundReceiverId = ( + classifyPythonBoundReceiver(funcNode) ?? + (isPythonStaticLikeMethod(funcNode) ? null : classifyPythonUncertainReceiver(funcNode)) + )?.parameter.id; for (let i = 0; i < paramList.namedChildCount; i++) { const param = paramList.namedChild(i); diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 22d1f2572..2c426bd1e 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1284,6 +1284,11 @@ function followChainedRef(start: TypeRef, draftById: ReadonlyMap ReceiverMemberResolution | undefined; + /** Suppress all receiver dispatch when a captured type cannot prove its + * runtime binding. Runs before compound and simple receiver lookup; the + * caller records a receiver-unresolved outcome. */ + readonly suppressReceiverLookup?: (typeRef: TypeRef) => boolean; + /** * Enable the receiver-bound Case 0.5 fallback for explicit `this` * receivers (`this->m()` / `this.m()`) that resolves against the diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index eeec56d18..f4fec6a0c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -185,6 +185,7 @@ type ReceiverBoundProviderSubset = Pick< | 'resolveQualifiedReceiverMember' | 'namespaceReceiverPaths' | 'resolveReceiverMember' + | 'suppressReceiverLookup' | 'resolveThisViaEnclosingClass' | 'resolveMissingReceiverMembersFromSubtypes' | 'missingReceiverSubtypeCandidateCompatibility' @@ -1062,6 +1063,30 @@ export function emitReceiverBoundCalls( const memberName = site.name; const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (provider.suppressReceiverLookup !== undefined) { + const baseName = + decodeReceiverChain(site.receiverChain)?.baseReceiverName ?? + receiverName.split(/[.([\s]/, 1)[0]; + const baseTypeRef = + baseName === undefined + ? undefined + : findReceiverTypeBinding(site.inScope, baseName, scopes); + if (baseTypeRef !== undefined && provider.suppressReceiverLookup(baseTypeRef)) { + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + siteKind: site.kind, + }); + handledSites.add(siteKey); + continue; + } + } + // ── owned-but-unbound receiver ─────────────────────────────── // The language declared this scope REBINDS the receiver and gave // it no type — a JS/TS ordinary `function`, whose `this` comes diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index c35108aef..908aadb36 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -805,7 +805,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // would retain that capacity and could emit a false concrete call target. // v119 (#3396): Python subtype method capacities now omit receiverless ordinary // methods. Warm v118 ParsedFiles would replay a false compatible target. -const SCHEMA_BUMP = 119; +// v120 (#3394): decorated Python method receiver bindings now distinguish +// unproven decorators from instance receivers. Warm v119 ParsedFiles would +// replay a fabricated `self` binding or lack the uncertainty marker entirely. +const SCHEMA_BUMP = 120; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index efcb35649..6af547b14 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -5,7 +5,7 @@ }, "python-abstract-dispatch/base.py": { "captureGroups": 22, - "digest": "94900994d4bae8c55660e21694afba50d0edd6e22b71bcc897982aa860bbb07e" + "digest": "96d3ad96798ff20036cca85bcedf2eb64cc7636143d30a5af8bab65e03b08dc2" }, "python-abstract-dispatch/impl.py": { "captureGroups": 19, @@ -517,7 +517,7 @@ }, "python-method-enrichment/models.py": { "captureGroups": 31, - "digest": "9e3f359187a82e936cd74c59848a296d19f138865a71882e3bbd3842bb4f0704" + "digest": "ae9408b9692b231a2fcd09ec2f82097f7fca76c677a7cb66c02f84ec91f7198e" }, "python-mixin-self-dispatch/ambiguous_a.py": { "captureGroups": 10, @@ -549,7 +549,7 @@ }, "python-mixin-self-dispatch/worker.py": { "captureGroups": 211, - "digest": "82b087675071c005a6a58c84659009d180e71297f0ed7c915a1d8f745883730e" + "digest": "729328fa43d852b4da8b040a87a23d49e95ce87639a36146529041161cb62693" }, "python-mixin-self-dispatch/wrong_arity.py": { "captureGroups": 23, diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index d8b9ff133..a510571a2 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1462,6 +1462,323 @@ class MissingHook(Mixin): } }, 60000); + it('retains explicit annotated parameters under an outer staticmethod', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-stacked-staticmethod-')); + try { + writeFixtureRepo(repoDir, { + 'case.py': `class Service: + def work(self): + pass + +def identity(fn): + return fn + +class Mixin: + @staticmethod + @identity + def dispatch(obj: Service): + return obj.work() +`, + }); + const result = await runPipelineFromRepo(repoDir, () => {}); + expect( + getRelationships(result, 'CALLS').some( + (edge) => edge.source === 'dispatch' && edge.target === 'work', + ), + ).toBe(true); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }, 60000); + + it('does not trust shadowed decorator spellings or receiver annotations', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-decorator-shadow-')); + try { + writeFixtureRepo(repoDir, { + 'case.py': `from builtins import classmethod as override +from builtins import classmethod as cm +from builtins import classmethod as abstractmethod + +class FakeTyping: + override = override + +typing = FakeTyping() + +class Mixin: + @override + def bare(owner): + return owner.bare_hook() + + @abstractmethod + def abstract_alias(owner): + return owner.abstract_alias_hook() + + @typing.override + def qualified(owner): + return owner.qualified_hook() + + @cm + def annotated(owner: 'Mixin'): + return owner.annotated_hook() + + def bare_hook(self): pass + def abstract_alias_hook(self): pass + def qualified_hook(self): pass + def annotated_hook(self): pass +`, + }); + const result = await runPipelineFromRepo(repoDir, () => {}); + const calls = getRelationships(result, 'CALLS'); + for (const [source, target] of [ + ['bare', 'bare_hook'], + ['abstract_alias', 'abstract_alias_hook'], + ['qualified', 'qualified_hook'], + ['annotated', 'annotated_hook'], + ]) { + expect(calls.filter((edge) => edge.source === source && edge.target === target)).toEqual( + [], + ); + expect( + getResolutionOutcomes(result).some( + (outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + } + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }, 60000); + + it('keeps proven property accessors while declining unverified decorator names', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-known-decorators-')); + try { + writeFixtureRepo(repoDir, { + 'case.py': `import abc +import typing + +class Standard: + @abc.abstractmethod + def abstract(self): + return self.abstract_helper() + + @typing.override + def overridden(self): + return self.override_helper() + + def abstract_helper(self): pass + def override_helper(self): pass + +class Setter: + @property + def value(self): + return 0 + + def unrelated(self): + pass + + @value.setter + def value(self, replacement): + self.setter_helper() + + def setter_helper(self): pass + +class Deleter: + @property + def entry(self): + return 0 + + @entry.deleter + def entry(self): + self.deleter_helper() + + def deleter_helper(self): pass + +class WrappedGetter: + @custom + @property + def field(self): + return 0 + + @field.setter + def field(owner, replacement): + owner.wrapped_helper() + + def wrapped_helper(self): pass + +class CustomDescriptor: + def setter(self, fn): + return classmethod(fn) + +class ReboundProperty: + @property + def rebound(self): + return 0 + + rebound, ignored = CustomDescriptor(), None + + @rebound.setter + def rebound(owner, replacement): + owner.rebound_helper() + + def rebound_helper(self): pass +`, + }); + const result = await runPipelineFromRepo(repoDir, () => {}); + const calls = getRelationships(result, 'CALLS'); + for (const [source, target] of [ + ['value', 'setter_helper'], + ['entry', 'deleter_helper'], + ]) { + expect(calls.some((edge) => edge.source === source && edge.target === target)).toBe(true); + } + for (const [source, target] of [ + ['abstract', 'abstract_helper'], + ['overridden', 'override_helper'], + ]) { + expect(calls.filter((edge) => edge.source === source && edge.target === target)).toEqual( + [], + ); + expect( + getResolutionOutcomes(result).some( + (outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + } + expect( + calls.filter((edge) => edge.source === 'field' && edge.target === 'wrapped_helper'), + ).toEqual([]); + expect( + calls.filter((edge) => edge.source === 'rebound' && edge.target === 'rebound_helper'), + ).toEqual([]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.name === 'wrapped_helper' && outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.name === 'rebound_helper' && outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }, 60000); + + it('does not infer instance dispatch through an aliased classmethod decorator', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-classmethod-alias-')); + try { + writeFixtureRepo(repoDir, { + 'case.py': `from builtins import classmethod as cm + +class Child: + def nested_hook(self): + pass + +class Mixin: + def __init__(self): + self.child = Child() + + @cm + def dispatch(owner): + return owner.hook() + + @cm + def direct_false(owner): + return owner.own_hook() + + @cm + def unicode_false(é): + return é.unicode_helper() + + @cm + def compound_false(owner): + return owner.child.nested_hook() + + def own_hook(self): + pass + + def unicode_helper(self): + pass + + @classmethod + def direct(cls): + return cls.class_hook() + + @classmethod + def class_hook(cls): + pass + +class Worker(Mixin): + def hook(self): + pass +`, + }); + const result = await runPipelineFromRepo(repoDir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'dispatch' && edge.target === 'hook', + ), + ).toEqual([]); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'compound_false' && edge.target === 'nested_hook', + ), + ).toEqual([]); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'direct_false' && edge.target === 'own_hook', + ), + ).toEqual([]); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'unicode_false' && edge.target === 'unicode_helper', + ), + ).toEqual([]); + expect( + getRelationships(result, 'CALLS').some( + (edge) => edge.source === 'direct' && edge.target === 'class_hook', + ), + ).toBe(true); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.filePath === 'case.py' && + outcome.name === 'hook' && + outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.filePath === 'case.py' && + outcome.name === 'own_hook' && + outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.filePath === 'case.py' && + outcome.name === 'unicode_helper' && + outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.filePath === 'case.py' && + outcome.name === 'nested_hook' && + outcome.reason === 'receiver-unresolved', + ), + ).toBe(true); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }, 60000); + it('ignores a self-named external base without losing the class for its children', async () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-self-parent-')); try { diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index a6166ec58..d514a8a27 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -297,9 +297,9 @@ describe('PARSE_CACHE_VERSION', () => { // 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. - // Moved 117 -> 118 for #3398 and 118 -> 119 for #3396's subtype capacity correction. - it('pins SCHEMA_BUMP to 119 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, #3398, #3396)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(119); + // Moved 117 -> 118 for #3398, 118 -> 119 for #3396, and 119 -> 120 for #3394. + it('pins SCHEMA_BUMP to 120 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, #3398, #3396, #3394)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(120); 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 @@ -308,7 +308,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, 114, 115, 116, 117, 118, + 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); }