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