mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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
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:
parent
4569910c79
commit
0bcddec8e6
14 changed files with 501 additions and 29 deletions
|
|
@ -649,6 +649,7 @@ export interface TypeRef {
|
|||
| 'parameter-annotation'
|
||||
| 'return-annotation'
|
||||
| 'self'
|
||||
| 'decorator-unknown'
|
||||
| 'assignment-inferred'
|
||||
| 'constructor-inferred'
|
||||
| 'receiver-propagated';
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
317f9b2e0114172ed592435a7059dae6b278f5bab2b56f8269eae66b8275c431
|
||||
dde4450f8bb763575b0464255f063fd0ded24980ecf43d7d2a7d4dc4880f2665
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue