fix(python): keep uncertain decorated receivers unresolved (#3405)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-09-28 20:22:10 +01:00 • committed by GitHub
parent 4569910c79
commit 0bcddec8e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 501 additions and 29 deletions

View file

@ -649,6 +649,7 @@ export interface TypeRef {
| 'parameter-annotation'
| 'return-annotation'
| 'self'
| 'decorator-unknown'
| 'assignment-inferred'
| 'constructor-inferred'
| 'receiver-propagated';

View file

@ -1 +1 @@
317f9b2e0114172ed592435a7059dae6b278f5bab2b56f8269eae66b8275c431
dde4450f8bb763575b0464255f063fd0ded24980ecf43d7d2a7d4dc4880f2665

View file

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

View file

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

View file

@ -30,17 +30,79 @@ function classDefinitionName(classNode: SyntaxNode): string | null {
return classNode.childForFieldName('name')?.text ?? null;
}
/** Does the function carry a `@<decoratorName>` 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

View file

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

View file

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

View file

@ -1284,6 +1284,11 @@ function followChainedRef(start: TypeRef, draftById: ReadonlyMap<ScopeId, ScopeD
*/
function typeBindingStrength(source: TypeRef['source']): number {
switch (source) {
// A method decorator may change what the first parameter receives. An
// annotation describes intent, not descriptor binding, so it cannot
// override this uncertainty for that parameter.
case 'decorator-unknown':
return 4;
case 'annotation':
return 3;
case 'parameter-annotation':

View file

@ -1383,6 +1383,11 @@ export interface ScopeResolver {
model: SemanticModel,
) => 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

View file

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

View file

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

View file

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

View file

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

View file

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