fix(python): resolve calls through module-returning accessors (#3542)
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

* test(accuracy): retire repaired Python accessor gaps

* fix(python): connect module accessor resolution

* fix(python): resolve calls through module-returning accessors

Bind `_pc().target()` and `m = _pc(); m.target()` to the module's
function when a local accessor returns an imported module, and count
capped-out dropped names in the impact lower bound.

Closes #3497

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(python): share module-accessor position key (#3542)

- Reuse the capture-side positionKey in the receiver namespace resolver
  instead of a second line:col helper plus an inline template.
- Note why the synthetic { moduleScope, scopes: [] } input is sufficient
  for createNamespaceTargetCache (sealed disk-backed ParsedFile shape).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(python): reuse subtype-dispatch positionKey (#3542)

- Export the existing line:col positionKey from subtype-dispatch instead of
  defining an identical copy in module-accessors, so both Python capture
  side channels key positions through one helper.
- Apply prettier to the Function-scope indexing line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ci): unique test titles and rebaseline Python benches (#3542)

- impact-epistemic-lower-bound: `%j` rendered null, NaN and Infinity all as
  `null`, so the test-completeness audit rejected the duplicate identities.
  Use `%s` so every parameterized case has a distinct title.
- python-scope: capture fingerprint changes with the new module-accessor
  capture facts (golden captures were already updated).
- receiver-resolution: +4 Python call drops (125 -> 129). The two-step
  `x = f(); x.m()` form is now counted as an unresolved call instead of
  vanishing: fastapi-depends `router.get/post` (unknown, x3) and
  python-method-chain-binding `user.get_city()` (in-program; `User` has no
  `get_city`, so the site is genuinely unresolvable).

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-10-10 20:41:51 +03:00 • committed by GitHub
parent 114ae0b411
commit 191a954bf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1717 additions and 100 deletions

View file

@ -1 +1 @@
390272fd1067ca18a91c5ec5cc1800fb38fab56d06671baf0d0a0e2bd0587658
44277912361a8783163b4ca9c5843e4d8f8444116b768881e617f26368bb1cba

View file

@ -199,16 +199,16 @@
}
},
"countArm": {
"callDrops": 125,
"totalDropsAllKinds": 163,
"callDrops": 129,
"totalDropsAllKinds": 167,
"bySiteKind": {
"call": 125,
"call": 129,
"read": 19,
"write": 19
},
"callDropsByExtension": {
".java": 49,
".py": 16,
".py": 20,
".ts": 15,
".zig": 11,
".cs": 8,
@ -224,15 +224,15 @@
"callDropsByShape": {
"chain-field": 56,
"chain-call": 32,
"no-chain": 23,
"no-chain": 27,
"<<unclassified>>": 11,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"in-program": 55,
"in-program": 56,
"external": 44,
"unknown": 26
"unknown": 29
}
}
}

View file

@ -56,18 +56,6 @@
"source handleUnsafe@13"
]
},
{
"id": "python.module-chain",
"issue": 3497,
"reason": "Compound receiver resolution does not follow module-return helpers (#3497).",
"baselineActual": []
},
{
"id": "python.module-local",
"issue": 3497,
"reason": "Module-return helper result is not bound as a namespace alias (#3497).",
"baselineActual": []
},
{
"id": "python.factory-conditional",
"issue": 3498,
@ -85,12 +73,6 @@
"issue": 3498,
"reason": "Boolean RHS loses annotated factory return type (#3498).",
"baselineActual": []
},
{
"id": "summary.omitted-lower-bound",
"issue": 3497,
"reason": "Capped-out dropped names ignore omittedNames and claim exact (#3497).",
"baselineActual": ["exact"]
}
]
}

View file

@ -47,7 +47,7 @@ import { discoverDjangoRootUrls } from '../route-extractors/django-root-discover
import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js';
import { pythonDecoratorRouteHandlerName } from '../route-extractors/python-decorator-handler.js';
import { assertCloneable } from '../workers/clone-safety.js';
import { collectPythonSubtypeDispatchSideChannel } from './python/subtype-dispatch.js';
import { collectPythonCaptureSideChannel } from './python/module-accessors.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@ -155,7 +155,7 @@ export const pythonProvider = defineLanguage({
// ./python/query.ts (PYTHON_SCOPE_QUERY constant).
emitScopeCaptures: emitPythonScopeCaptures,
collectCaptureSideChannel: (filePath) =>
assertCloneable(collectPythonSubtypeDispatchSideChannel(filePath)),
assertCloneable(collectPythonCaptureSideChannel(filePath)),
cfgVisitor: createPythonCfgVisitor(),
interpretImport: interpretPythonImport,
interpretTypeBinding: interpretPythonTypeBinding,

View file

@ -48,6 +48,11 @@ import {
recordPythonSimplePositionalCall,
recordPythonSubtypeMethodShape,
} from './subtype-dispatch.js';
import {
beginPythonModuleAccessorCapture,
recordPythonModuleAccessor,
synthesizePythonCallResultAssignment,
} from './module-accessors.js';
const PYTHON_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_definition', 'lambda']),
@ -91,6 +96,7 @@ export function emitPythonScopeCaptures(
},
): readonly CaptureMatch[] {
beginPythonSubtypeDispatchCapture(filePath);
beginPythonModuleAccessorCapture(filePath);
let parseText = sourceText;
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
let notebookSegments: readonly NotebookLineSegment[] | undefined;
@ -154,6 +160,16 @@ export function emitPythonScopeCaptures(
recordPythonSubtypeCallShape(grouped, nodeMap, filePath, subtypeLineMapper);
const assignedNode = nodeMap['@declaration.variable'];
if (assignedNode !== undefined) {
const assignment = synthesizePythonCallResultAssignment(
assignedNode,
filePath,
subtypeLineMapper,
);
if (assignment !== undefined) out.push(assignment);
}
const declarationNode = nodeMap['@declaration.function'] ?? nodeMap['@declaration.class'];
const declarationName = grouped['@declaration.name']?.text;
if (
@ -206,6 +222,7 @@ export function emitPythonScopeCaptures(
const scopeNode = nodeMap['@scope.function']!;
const fnNode = scopeNode.type === 'function_definition' ? scopeNode : null;
if (fnNode !== null) {
recordPythonModuleAccessor(filePath, fnNode, subtypeLineMapper);
const parameterNames = computePythonArityMetadata(fnNode).parameterNames;
if (parameterNames.length > 0) {
grouped['@scope.lexical-names'] = syntheticCapture(

View file

@ -0,0 +1,186 @@
import type {
CallResultAssignmentSite,
ParsedFile,
Range,
ReferenceSite,
Scope,
SymbolDefinition,
} from 'gitnexus-shared';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { createNamespaceTargetCache } from '../../scope-resolution/scope/namespace-target-cache.js';
import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js';
import { pythonNamespaceBindingIdentity, pythonNamespaceReceiverPaths } from './import-target.js';
import {
pythonModuleAccessorFact,
pythonCallResultAssignmentIsStraightLine,
} from './module-accessors.js';
import { positionKey, pythonSubtypeCallPositionalCount } from './subtype-dispatch.js';
const contains = (outer: Range, inner: Range): boolean =>
(outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol)) &&
(outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol));
/** Namespace proof stays separate from class-return inference. No name-only
* workspace fallback or caller-local import can establish a returned module. */
export const createPythonReceiverNamespaceResolver: NonNullable<
ScopeResolver['createReceiverNamespaceResolver']
> = (scopes, index) => {
const callableNamespaces = new Map<string, readonly string[] | undefined>();
const fileScopes = new Map<string, Map<string, Scope>>();
const namespaceCaches = new Map<string, ReturnType<typeof createNamespaceTargetCache>>();
const assignmentIndexes = new WeakMap<
ParsedFile,
Map<string, Map<string, readonly CallResultAssignmentSite[]>>
>();
const callIndexes = new WeakMap<ParsedFile, Map<string, ReferenceSite>>();
const namespaceOf = (candidate: SymbolDefinition): readonly string[] | undefined => {
if (callableNamespaces.has(candidate.nodeId)) return callableNamespaces.get(candidate.nodeId);
// A declined result is memoized as well: unsupported factories can occur at
// thousands of call sites, but their capture proof is constant for this pass.
callableNamespaces.set(candidate.nodeId, undefined);
const position = definitionIdPosition(candidate.nodeId, candidate.filePath);
if (position === undefined || candidate.type !== 'Function') return undefined;
const fact = pythonModuleAccessorFact(candidate.filePath, position);
if (fact?.status !== 'accepted') return undefined;
const moduleScope = index.moduleScopeByFile.get(candidate.filePath);
if (moduleScope === undefined) return undefined;
let byPosition = fileScopes.get(candidate.filePath);
if (byPosition === undefined) {
byPosition = new Map();
const pending = [moduleScope.id];
const visited = new Set<string>();
while (pending.length > 0) {
const id = pending.pop()!;
if (visited.has(id)) continue;
visited.add(id);
const scope = scopes.scopeTree.getScope(id);
if (scope === undefined) continue;
if (scope.kind === 'Function')
byPosition.set(positionKey(scope.range.startLine, scope.range.startCol), scope);
pending.push(...scopes.scopeTree.getChildren(id));
}
fileScopes.set(candidate.filePath, byPosition);
}
const functionScope = byPosition.get(positionKey(position.line, position.column));
if (functionScope === undefined) return undefined;
let cache = namespaceCaches.get(candidate.filePath);
if (cache === undefined) {
// Same shape as a sealed disk-backed ParsedFile: the cache walks every
// scope from moduleScope through scopeTree.getChildren.
cache = createNamespaceTargetCache(
{ moduleScope: moduleScope.id, scopes: [] },
scopes,
{
receiverPaths: pythonNamespaceReceiverPaths,
bindingIdentity: pythonNamespaceBindingIdentity,
skipEnclosingClasses: true,
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
},
true,
);
namespaceCaches.set(candidate.filePath, cache);
}
const targets = cache
.at(functionScope.id, {
startLine: fact.returnLine,
startCol: fact.returnColumn,
})
.get(fact.returnedName);
// Multiple module identities are not a union proof of a deterministic
// accessor. Keep uncertainty instead of guessing an arbitrary first target.
if (targets?.length !== 1) return undefined;
callableNamespaces.set(candidate.nodeId, targets);
return targets;
};
const freeCallAt = (parsed: ParsedFile, range: Range): ReferenceSite | undefined => {
let calls = callIndexes.get(parsed);
if (calls === undefined) {
calls = new Map(
parsed.referenceSites
.filter((site) => site.kind === 'call' && site.explicitReceiver === undefined)
.map((site) => [positionKey(site.atRange.startLine, site.atRange.startCol), site]),
);
callIndexes.set(parsed, calls);
}
return calls.get(positionKey(range.startLine, range.startCol));
};
const assignedCall = (
site: ReferenceSite,
parsed: ParsedFile,
name: string,
): ReferenceSite | undefined => {
let assignments = assignmentIndexes.get(parsed);
if (assignments === undefined) {
const grouped = new Map<string, Map<string, CallResultAssignmentSite[]>>();
for (const assignment of parsed.callResultAssignmentSites ?? []) {
let byScope = grouped.get(assignment.lhs);
if (byScope === undefined) {
byScope = new Map();
grouped.set(assignment.lhs, byScope);
}
const bucket = byScope.get(assignment.inScope);
if (bucket === undefined) byScope.set(assignment.inScope, [assignment]);
else bucket.push(assignment);
}
assignments = grouped;
assignmentIndexes.set(parsed, assignments);
}
const byScope = assignments.get(name);
if (byScope === undefined) return undefined;
const claim = lookupNameClaim(site.inScope, name, scopes, {
position: site.atRange,
purpose: 'value',
skipEnclosingClasses: true,
});
if (
claim.status !== 'resolved' ||
claim.claims.length !== 1 ||
claim.claims[0]!.kind !== 'binding' ||
claim.scope === undefined
)
return undefined;
const candidates = byScope.get(claim.scope.id);
if (candidates === undefined) return undefined;
const owned = candidates.filter((assignment) =>
contains(claim.claims[0]!.range, assignment.callSite),
);
return owned.length === 1 ? freeCallAt(parsed, owned[0]!.callSite) : undefined;
};
return (site, parsed) => {
if (site.kind !== 'call') return undefined;
const receiverName = site.explicitReceiver?.name;
if (receiverName === undefined) return undefined;
const direct = /^([\p{ID_Start}_][\p{ID_Continue}]*)\s*\(\s*\)$/u.exec(receiverName);
const call =
direct !== null ? freeCallAt(parsed, site.atRange) : assignedCall(site, parsed, receiverName);
if (call === undefined || (direct !== null && call.name !== direct[1])) return undefined;
const callResultOrigin = { name: call.name, inScope: call.inScope };
// Textual claim order cannot prove which control-flow branch assigned this
// receiver. Keep the attempted call, but do not treat its origin as definite.
if (
direct === null &&
!pythonCallResultAssignmentIsStraightLine(parsed.filePath, call.atRange)
) {
return { callResultOrigin: { ...callResultOrigin, isDefinite: false } };
}
const declined = { callResultOrigin };
if (pythonSubtypeCallPositionalCount(parsed.filePath, call.atRange) !== 0) return declined;
const claim = lookupNameClaim(call.inScope, call.name, scopes, {
position: call.atRange,
purpose: 'value',
skipEnclosingClasses: true,
});
if (claim.status !== 'resolved') return declined;
const candidates = new Map(claim.bindings.map((binding) => [binding.def.nodeId, binding.def]));
if (candidates.size !== 1) return declined;
const candidate = candidates.values().next().value!;
return { callResultOrigin, targetFiles: namespaceOf(candidate) };
};
};

View file

@ -0,0 +1,286 @@
import type { CaptureMatch, ParsedFile } from 'gitnexus-shared';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import {
applyPythonSubtypeDispatchSideChannel,
collectPythonSubtypeDispatchSideChannel,
positionKey,
type PythonSubtypeDispatchSideChannel,
} from './subtype-dispatch.js';
interface CallablePosition {
readonly definitionLine: number;
readonly definitionColumn: number;
}
/** Syntax proof only: the returned import must still resolve to a namespace. */
export type PythonModuleAccessorFact = CallablePosition &
(
| {
readonly status: 'accepted';
readonly returnedName: string;
readonly returnLine: number;
readonly returnColumn: number;
}
| { readonly status: 'declined' }
);
export interface PythonCallResultAssignmentFact {
readonly callLine: number;
readonly callColumn: number;
readonly straightLine: boolean;
}
/** One plain-data snapshot preserves both Python capture consumers. */
export interface PythonCaptureSideChannel {
readonly kind: 'python-capture';
readonly subtypeDispatch?: PythonSubtypeDispatchSideChannel;
readonly moduleAccessors: readonly PythonModuleAccessorFact[];
readonly callResultAssignments: readonly PythonCallResultAssignmentFact[];
}
const accessorsByFile = new Map<string, Map<string, PythonModuleAccessorFact>>();
const assignmentsByFile = new Map<string, Map<string, PythonCallResultAssignmentFact>>();
export function beginPythonModuleAccessorCapture(filePath: string): void {
accessorsByFile.delete(filePath);
assignmentsByFile.delete(filePath);
}
/** Read an exact callable, never a workspace-wide same-name candidate. */
export function pythonModuleAccessorFact(
filePath: string,
position: { readonly line: number; readonly column: number },
): PythonModuleAccessorFact | undefined {
return accessorsByFile.get(filePath)?.get(positionKey(position.line, position.column));
}
/** Missing or malformed replay facts cannot establish a positive assignment proof. */
export function pythonCallResultAssignmentIsStraightLine(
filePath: string,
position: { readonly startLine: number; readonly startCol: number },
): boolean {
return (
assignmentsByFile.get(filePath)?.get(positionKey(position.startLine, position.startCol))
?.straightLine === true
);
}
function namedChildren(node: SyntaxNode): SyntaxNode[] {
return node.namedChildren.filter(
(child): child is SyntaxNode => child !== null && child.type !== 'comment',
);
}
function isDocstring(statement: SyntaxNode): boolean {
if (statement.type !== 'expression_statement') return false;
const children = namedChildren(statement);
return (
children.length === 1 &&
children[0]!.type === 'string' &&
/^[rRuU]*(?:"|')/.test(children[0]!.text)
);
}
/**
* Accept only imports followed by one returned imported identifier. Inspecting
* direct body statements makes nested returns/yields belong to their own
* callable; any outer body containing a nested definition simply declines.
*/
function returnedImport(fnNode: SyntaxNode): SyntaxNode | undefined {
if (
fnNode.hasError ||
fnNode.parent?.type === 'decorated_definition' ||
fnNode.children.some((child) => child.type === 'async')
)
return undefined;
const parameters = fnNode.childForFieldName('parameters');
const body = fnNode.childForFieldName('body');
if (parameters === null || namedChildren(parameters).length > 0 || body === null)
return undefined;
const statements = namedChildren(body);
if (statements[0] !== undefined && isDocstring(statements[0])) statements.shift();
const returned = statements.pop();
if (returned?.type !== 'return_statement' || statements.length === 0) return undefined;
const values = namedChildren(returned);
const value = values[0];
if (values.length !== 1 || value?.type !== 'identifier') return undefined;
const importedNames = new Set<string>();
for (const statement of statements) {
if (statement.type !== 'import_statement' && statement.type !== 'import_from_statement') {
return undefined;
}
const imports = splitImportStatement(statement);
if (imports.length === 0) return undefined;
for (const imported of imports) {
const name = (imported['@import.alias'] ?? imported['@import.name'])?.text;
if (name === undefined || name === '*' || importedNames.has(name)) return undefined;
importedNames.add(name);
}
}
return importedNames.has(value.text) ? value : undefined;
}
export function recordPythonModuleAccessor(
filePath: string,
fnNode: SyntaxNode,
mapLine?: (line: number) => number,
): void {
const definitionLine = mapLine?.(fnNode.startPosition.row + 1) ?? fnNode.startPosition.row + 1;
const definitionColumn = fnNode.startPosition.column;
const returned = returnedImport(fnNode);
const fact: PythonModuleAccessorFact =
returned === undefined
? { definitionLine, definitionColumn, status: 'declined' }
: {
definitionLine,
definitionColumn,
status: 'accepted',
returnedName: returned.text,
returnLine: mapLine?.(returned.startPosition.row + 1) ?? returned.startPosition.row + 1,
returnColumn: returned.startPosition.column,
};
let facts = accessorsByFile.get(filePath);
if (facts === undefined) {
facts = new Map();
accessorsByFile.set(filePath, facts);
}
facts.set(positionKey(definitionLine, definitionColumn), fact);
}
const conditionalAssignmentAncestors = new Set([
'if_statement',
'for_statement',
'while_statement',
'try_statement',
'match_statement',
'with_statement',
]);
function isStraightLineAssignment(node: SyntaxNode): boolean {
for (let ancestor = node.parent; ancestor !== null; ancestor = ancestor.parent) {
if (
ancestor.type === 'function_definition' ||
ancestor.type === 'lambda' ||
ancestor.type === 'module'
)
break;
if (conditionalAssignmentAncestors.has(ancestor.type)) return false;
}
return true;
}
/** Preserve generic provenance even when control flow prevents a namespace proof. */
export function synthesizePythonCallResultAssignment(
node: SyntaxNode,
filePath: string,
mapLine?: (line: number) => number,
): CaptureMatch | undefined {
if (node.type !== 'assignment' || node.hasError) return undefined;
const lhs = node.childForFieldName('left');
const call = node.childForFieldName('right');
if (lhs?.type !== 'identifier' || call?.type !== 'call') return undefined;
const callee = call.childForFieldName('function');
const argumentsNode = call.childForFieldName('arguments');
if (
callee?.type !== 'identifier' ||
argumentsNode === null ||
namedChildren(argumentsNode).length !== 0
) {
return undefined;
}
const callLine = mapLine?.(call.startPosition.row + 1) ?? call.startPosition.row + 1;
const callColumn = call.startPosition.column;
let facts = assignmentsByFile.get(filePath);
if (facts === undefined) {
facts = new Map();
assignmentsByFile.set(filePath, facts);
}
facts.set(positionKey(callLine, callColumn), {
callLine,
callColumn,
straightLine: isStraightLineAssignment(node),
});
return {
'@call-result-assignment.call': nodeToCapture('@call-result-assignment.call', call),
'@call-result-assignment.lhs': nodeToCapture('@call-result-assignment.lhs', lhs),
};
}
export function collectPythonCaptureSideChannel(
filePath: string,
): PythonCaptureSideChannel | undefined {
const subtypeDispatch = collectPythonSubtypeDispatchSideChannel(filePath);
const moduleAccessors = [...(accessorsByFile.get(filePath)?.values() ?? [])];
const callResultAssignments = [...(assignmentsByFile.get(filePath)?.values() ?? [])];
if (
subtypeDispatch === undefined &&
moduleAccessors.length === 0 &&
callResultAssignments.length === 0
)
return undefined;
return {
kind: 'python-capture',
...(subtypeDispatch === undefined ? {} : { subtypeDispatch }),
moduleAccessors,
callResultAssignments,
};
}
function validPosition(line: unknown, column: unknown): boolean {
return (
typeof line === 'number' &&
Number.isInteger(line) &&
line > 0 &&
typeof column === 'number' &&
Number.isInteger(column) &&
column >= 0
);
}
function isAccessorFact(value: unknown): value is PythonModuleAccessorFact {
if (value === null || typeof value !== 'object') return false;
const fact = value as Partial<PythonModuleAccessorFact>;
if (!validPosition(fact.definitionLine, fact.definitionColumn)) return false;
if (fact.status === 'declined') return true;
return (
fact.status === 'accepted' &&
typeof fact.returnedName === 'string' &&
fact.returnedName.length > 0 &&
validPosition(fact.returnLine, fact.returnColumn)
);
}
/** Worker/cache restore resets both stores, including when either is absent. */
export function applyPythonCaptureSideChannel(parsed: ParsedFile): void {
beginPythonModuleAccessorCapture(parsed.filePath);
const data = parsed.captureSideChannel as Partial<PythonCaptureSideChannel> | undefined;
if (data === null || typeof data !== 'object' || data.kind !== 'python-capture') {
// The subtype helper also accepts its original standalone payload.
applyPythonSubtypeDispatchSideChannel(parsed);
return;
}
applyPythonSubtypeDispatchSideChannel({ ...parsed, captureSideChannel: data.subtypeDispatch });
const facts = new Map<string, PythonModuleAccessorFact>();
for (const fact of Array.isArray(data.moduleAccessors) ? data.moduleAccessors : []) {
if (isAccessorFact(fact)) {
facts.set(positionKey(fact.definitionLine, fact.definitionColumn), fact);
}
}
if (facts.size > 0) accessorsByFile.set(parsed.filePath, facts);
const assignments = new Map<string, PythonCallResultAssignmentFact>();
for (const fact of Array.isArray(data.callResultAssignments) ? data.callResultAssignments : []) {
if (
fact !== null &&
typeof fact === 'object' &&
validPosition(fact.callLine, fact.callColumn) &&
typeof fact.straightLine === 'boolean'
) {
assignments.set(positionKey(fact.callLine, fact.callColumn), fact);
}
}
if (assignments.size > 0) assignmentsByFile.set(parsed.filePath, assignments);
}

View file

@ -33,10 +33,11 @@ import {
type PythonResolveContext,
} from './index.js';
import {
applyPythonSubtypeDispatchSideChannel,
pythonSubtypeCallPositionalCount,
pythonSubtypePositionalCapacity,
} from './subtype-dispatch.js';
import { applyPythonCaptureSideChannel } from './module-accessors.js';
import { createPythonReceiverNamespaceResolver } from './module-accessor-resolution.js';
/**
* Python subtype dispatch is deliberately limited to instance receiver facts.
@ -86,6 +87,7 @@ const pythonScopeResolver: ScopeResolver = {
filterWildcardNames: (scope, names, files) => expandPythonWildcardNames(scope, files, names),
namespaceBindingIdentity: pythonNamespaceBindingIdentity,
namespaceSkipsEnclosingClasses: true,
createReceiverNamespaceResolver: createPythonReceiverNamespaceResolver,
ownedMembersBindAtModuleScope: false,
suppressReceiverLookup: (typeRef) => typeRef.source === 'decorator-unknown',
languageProvider: pythonProvider,
@ -137,7 +139,7 @@ const pythonScopeResolver: ScopeResolver = {
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
applyCaptureSideChannel: applyPythonSubtypeDispatchSideChannel,
applyCaptureSideChannel: applyPythonCaptureSideChannel,
isSuperReceiver: (text) => /^super\s*\(/.test(text),

View file

@ -26,7 +26,9 @@ export interface PythonSubtypeDispatchSideChannel {
const simplePositionalCallsByFile = new Map<string, Map<string, number>>();
const positionalCapacitiesByFile = new Map<string, Map<string, number>>();
const positionKey = (line: number, column: number): string => `${line}:${column}`;
/** Shared by every Python capture side channel so capture and replay key
* positions identically. */
export const positionKey = (line: number, column: number): string => `${line}:${column}`;
const nodePosition = (node: SyntaxNode, mapLine?: LineMapper): PositionTuple => {
const line = node.startPosition.row + 1;

View file

@ -1447,6 +1447,28 @@ export interface ScopeResolver {
* The reference's own class scope remains visible for class-body expressions. */
readonly namespaceSkipsEnclosingClasses?: boolean;
/** Prove namespace-valued receiver expressions using provider capture facts.
* Created once per receiver pass so providers can index local facts and memoize
* callable results. Targets feed the ordinary namespace-member path; declined
* call results retain their origin for final unresolved-call accounting. */
readonly createReceiverNamespaceResolver?: (
scopes: ScopeResolutionIndexes,
index: WorkspaceResolutionIndex,
) => (
site: ReferenceSite,
parsed: ParsedFile,
) =>
| {
readonly targetFiles?: readonly string[];
readonly callResultOrigin: {
readonly name: string;
readonly inScope: ScopeId;
/** False when this call is only one possible assignment origin. */
readonly isDefinite?: boolean;
};
}
| undefined;
/**
* Optional language-specific member-lattice lookup. Runs for a resolved
* simple receiver type before the generic flattened-MRO walk. Languages

View file

@ -188,6 +188,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'namespaceReceiverPaths'
| 'namespaceBindingIdentity'
| 'namespaceSkipsEnclosingClasses'
| 'createReceiverNamespaceResolver'
| 'resolveReceiverMember'
| 'suppressReceiverLookup'
| 'resolveThisViaEnclosingClass'
@ -1064,6 +1065,7 @@ export function emitReceiverBoundCalls(
return undefined;
};
const resolveReceiverNamespace = provider.createReceiverNamespaceResolver?.(scopes, index);
for (const parsed of parsedFiles) {
const namespaceCache = createNamespaceTargetCache(
parsed,
@ -1633,6 +1635,9 @@ export function emitReceiverBoundCalls(
)
? namespaceCandidates
: undefined;
const receiverNamespace =
targetFiles === undefined ? resolveReceiverNamespace?.(site, parsed) : undefined;
targetFiles ??= receiverNamespace?.targetFiles;
// Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is
// no handle of this file, but its segments reach a module (see
// `resolveNamespaceChain`). A prefix that ends in a CLASS is Case 2's.
@ -2949,7 +2954,10 @@ export function emitReceiverBoundCalls(
// keyed by the MEMBER name, which is the only thing still known about a
// dropped site (its callee is unknown by definition, so the drop cannot
// be attributed to any target symbol).
if (compoundReceiverUnresolved && !handledSites.has(siteKey)) {
if (
(compoundReceiverUnresolved || receiverNamespace !== undefined) &&
!handledSites.has(siteKey)
) {
// Decoded once: both the shape census and the origin classifier read the
// same chain, and this is inside the drop guard so a resolved site pays
// nothing.
@ -2976,13 +2984,16 @@ export function emitReceiverBoundCalls(
// to point at, so its absence is completeness, not uncertainty — but
// ONLY a positive built-in match may say so. Everything the index
// cannot demonstrate stays `unknown` and keeps hedging.
receiverOrigin: classifyReceiverOrigin(
decodedChain,
site.inScope,
receiverName,
scopes,
receiverOriginOpts,
),
receiverOrigin:
receiverNamespace?.callResultOrigin.isDefinite === false
? 'unknown'
: classifyReceiverOrigin(
receiverNamespace === undefined ? decodedChain : undefined,
receiverNamespace?.callResultOrigin.inScope ?? site.inScope,
receiverNamespace?.callResultOrigin.name ?? receiverName,
scopes,
receiverOriginOpts,
),
});
}
}

View file

@ -964,9 +964,9 @@ function scopeExtractionBoundaries(
* Boundary notes for call sites the analyzer dropped because it could not type
* their receiver, when the queried symbol's name is among them (#2744).
*
* Empty when the index records no drops for this name — including every index
* written before the summary existed, which is why the schema version was
* bumped rather than treating "absent" as "none".
* An absent name is uncertain when the in-program summary was truncated. Its
* omitted-name count cannot establish a dropped-call count for that name.
* Uncapped summaries and indexes predating the summary add no boundary.
*/
function unresolvedReceiverBoundaries(
summary: UnresolvedReceiverSummary | undefined,
@ -975,7 +975,20 @@ function unresolvedReceiverBoundaries(
if (symName.length === 0) return { notes: [], sites: 0, external: 0 };
const sites = lookupUnresolvedCallCount(summary, symName);
const external = lookupExternalCallCount(summary, symName) ?? 0;
if (sites === undefined) return { notes: [], sites: 0, external };
if (sites === undefined) {
const omittedNames = summary?.omittedNames ?? 0;
return {
notes:
Number.isSafeInteger(omittedNames) && omittedNames > 0
? [
`The unresolved-receiver member summary was truncated, so the dropped-call count ` +
`for \`${symName}\` cannot be determined. Actual impact may be higher.`,
]
: [],
sites: 0,
external,
};
}
return {
notes: [
`${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${

View file

@ -856,7 +856,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v136: Python parameter annotations retain enclosing lookup ownership; Vue
// JSX/TSX captures and multi-block declarations retain their source grammar
// and original graph positions. Both stores must re-extract v135 captures.
const SCHEMA_BUMP = 136;
// v137: Python module-accessor syntax proofs and exact assigned-call provenance
// join the existing subtype capture payload. Re-extract v136 in both stores.
const SCHEMA_BUMP = 137;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -1,7 +1,7 @@
{
"python-abstract-dispatch/app.py": {
"captureGroups": 12,
"digest": "43c8bfb463c6384aeec03754d61b402f273137548ac6f3c860f9b559624831c2"
"captureGroups": 13,
"digest": "649a0bb876fc5aadd0f5d62232133a089d18f53771f642495e13f420d53da13d"
},
"python-abstract-dispatch/base.py": {
"captureGroups": 22,
@ -12,8 +12,8 @@
"digest": "38892e74626e45c0c29f16fdf5ea8b433045e965cdac09ec0dc8d2808a8d97a1"
},
"python-alias-imports/app.py": {
"captureGroups": 13,
"digest": "ff2b2ffe8ec9ee93c8a1318e95bfd83b899f4a667feaf8f7434b61e8ceff3629"
"captureGroups": 15,
"digest": "5d7dfbe8afdde8463ac5c4e59aebea2014f017212bd453e5160460ec0caa5197"
},
"python-alias-imports/models.py": {
"captureGroups": 13,
@ -84,8 +84,8 @@
"digest": "8efdc686879ddf1c2ae6fd1e6d86027e1b80e69cb67245f7a02c1577fa83a548"
},
"python-assignment-chain/app.py": {
"captureGroups": 29,
"digest": "7b32bc9c8da7691d3c55dc09aabcedc864b11e1154bc623297a797b91203605c"
"captureGroups": 31,
"digest": "cc7171c4f5ce4c57468e8ce66e1108576d92e7100c3a787a333dd8ff95852501"
},
"python-assignment-chain/repo.py": {
"captureGroups": 7,
@ -136,8 +136,8 @@
"digest": "49330572d87d1cbfa7db11351b18734bf23fecd686b46e55a788e6b82eca306f"
},
"python-chain-call/app.py": {
"captureGroups": 9,
"digest": "4a1b8afad8fe4c1c4260856b9013b0bf7c12271e86871c0feb60f02784020ff8"
"captureGroups": 10,
"digest": "54de3a236948db3b9321bfee1239e770751dfe3bbdaf400943c9b019ed651ef6"
},
"python-chain-call/models/repo.py": {
"captureGroups": 7,
@ -152,8 +152,8 @@
"digest": "661e322408ef754d73a89997de80d517fe649514d7f256e817f54d2bcbc6e0c9"
},
"python-child-extends-parent/app.py": {
"captureGroups": 9,
"digest": "456cb9fee3f54ce867da1caa3a6f9ad3c97d43e71337b8bb463edd07a6723f64"
"captureGroups": 10,
"digest": "f5b1e20e277b681e2229e3a699f43a0e993a0f3728e98888664a0a87aa623276"
},
"python-child-extends-parent/child.py": {
"captureGroups": 5,
@ -300,8 +300,8 @@
"digest": "c218c1a1106c1dafd85e74c3ed12292e0958fc86a245e078afd896cf190d6407"
},
"python-django-app-imports/config/asgi.py": {
"captureGroups": 7,
"digest": "cb79e02122dbb855128522604d6a02b4036c7c3a1b33399e74e74ccf0a4b2d91"
"captureGroups": 8,
"digest": "86c5142df55e0bf259440154452fcc79372513ce39707b790660d50171002390"
},
"python-django-app-imports/config/settings.py": {
"captureGroups": 21,
@ -312,8 +312,8 @@
"digest": "fafd174ed34c43ad90cb16849bb8ae8cac25014e3ca28a6abab4e9efc2babe0f"
},
"python-django-app-imports/config/wsgi.py": {
"captureGroups": 7,
"digest": "30f4d926def67232c2e50cd313984838ce08ac6ff4f6cc8de33c787381b37dac"
"captureGroups": 8,
"digest": "fa7ddb50d9b1c843ee43c07721b4053d09707e4c432f89042adbaea1220e6325"
},
"python-django-app-imports/manage.py": {
"captureGroups": 11,
@ -376,8 +376,8 @@
"digest": "fe52542a27d98c57d7acd48212dc301bdb448ad5868afb68c98cede9299dd1e5"
},
"python-function-local-import-chain/app.py": {
"captureGroups": 9,
"digest": "ad4d45976ca10c3fc3b7bf498ee23397797368a5d2817311d0e95e943af89199"
"captureGroups": 10,
"digest": "405010bd462436169e09fa9fea52c76c330da98ac0045b479f9e402c43006b3d"
},
"python-function-local-import-chain/svc.py": {
"captureGroups": 12,
@ -392,16 +392,16 @@
"digest": "efe8b8af2d4e7f547a659aecc00399091af2597b47adc7b296bddb10cab2da69"
},
"python-global-nested-def/main.py": {
"captureGroups": 63,
"digest": "9695170149861f98e96553bf319caf88098f9c764e4896367bbd91db68f279dd"
"captureGroups": 65,
"digest": "1238e3ddf3611ccc5e6216720d3deb434514364ee74fe3600ce738393ce0f283"
},
"python-global-nested-def/other.py": {
"captureGroups": 23,
"digest": "a288c41f6cb01923496422ad53b2299ff9034d16c48ef4e9b90a59826205787c"
},
"python-grandparent-resolution/app.py": {
"captureGroups": 9,
"digest": "dda8b7578e22c44f2defdaa5d7bb76c0b04b03cd7017506ef7df6810d6116ade"
"captureGroups": 10,
"digest": "9c3a16c905d36a708dcf72d235952131c876c6e2981f2dda760083c15ce2579b"
},
"python-grandparent-resolution/models/__init__.py": {
"captureGroups": 1,
@ -528,24 +528,24 @@
"digest": "e023fa6d98558619733e75005e4c6dc4079967b4d43c2fc2ecd24136a4de6e1d"
},
"python-member-calls/app.py": {
"captureGroups": 8,
"digest": "14e60b556e8dac569ef11770738da2c68dd808291413aa33973815f1102503f3"
"captureGroups": 9,
"digest": "d51ed17bf175de125cb8fccc5f93baf9bc740b73c1f8b351448a342cab2da442"
},
"python-member-calls/user.py": {
"captureGroups": 12,
"digest": "19f6f21b1d5d25dc3e1653537e986f65fdd3697efd48affa9325fe2ecffe188d"
},
"python-method-chain-binding/app.py": {
"captureGroups": 19,
"digest": "01e37b98dc27472d1a8e49ae716c4d3998da58e8b4b3075321a2a28b017a0f5c"
"captureGroups": 20,
"digest": "80a9bd98589085986083404fb7ec100c6cebfb54f06d59f1a02fcf323adf8515"
},
"python-method-chain-binding/models.py": {
"captureGroups": 48,
"digest": "f049817034428759193fce03b417ca23c133f6ead87f960e173afba9b79b88e8"
},
"python-method-enrichment/app.py": {
"captureGroups": 13,
"digest": "a116c45ac776c24ac1e2dafb511e7ab06de52027b7c6c94ea4a5016d42adc676"
"captureGroups": 14,
"digest": "b7d9e1dfb1e66742274be0427255ae6599b6130b0bd36f361fd90501e7405b37"
},
"python-method-enrichment/models.py": {
"captureGroups": 31,
@ -588,8 +588,8 @@
"digest": "77c19005cc204aff148f6b230d0334cda52f338f34418bf9b609c6c5c44d87a5"
},
"python-module-export-vs-method-collision/app.py": {
"captureGroups": 14,
"digest": "1362e9187b6a8a8223e55833c725b3f02475faaa803ac657b917d434b550356a"
"captureGroups": 15,
"digest": "f80e167c3791572b2254756d5d3df35e93bd41ee92f1e6045edf802ce60b6b02"
},
"python-module-export-vs-method-collision/mod.py": {
"captureGroups": 13,
@ -608,8 +608,8 @@
"digest": "e023fa6d98558619733e75005e4c6dc4079967b4d43c2fc2ecd24136a4de6e1d"
},
"python-multi-level-mro/app.py": {
"captureGroups": 9,
"digest": "ee7c02ef3588dc5c8d9f3932f05d8287a0c79a922551246e4fa1df10c92a81df"
"captureGroups": 10,
"digest": "c1ce4221e6d2d98128c715fd0b08981908abfc7424e0d4992a29999c557496b9"
},
"python-multi-level-mro/child.py": {
"captureGroups": 5,
@ -692,8 +692,8 @@
"digest": "9bd9cb68bf573c9da663409c72233f99b21b922c77b95b4d7cd1af94f106d105"
},
"python-nullable-chain/app.py": {
"captureGroups": 33,
"digest": "b8fc05256ec1460896cec13f7c8675389aae795b2138b3fe8f4f352b9aea360f"
"captureGroups": 35,
"digest": "046178f1a2325461b15a40cc96e6061a5279e8b0179e1865c635d472330b0bba"
},
"python-nullable-chain/repo.py": {
"captureGroups": 8,
@ -704,8 +704,8 @@
"digest": "72f02563ba72a1a1a595623a81341c694124054698afeb606e65662915b66a22"
},
"python-nullable-receiver/app.py": {
"captureGroups": 23,
"digest": "7b00548c67067d49ed69c057f102915642c29ec3d7ba5ea30cd7034c4374e82f"
"captureGroups": 25,
"digest": "0aca86d8104b10370a5459bee188018bd0b07fe6bc19c0bfde672f8bed77e068"
},
"python-nullable-receiver/repo.py": {
"captureGroups": 7,
@ -716,8 +716,8 @@
"digest": "3fa157d0e0576fe87c6abf5ce9a3509c82c04b2ecf1fb6d53290ddc1326af8e4"
},
"python-overload-dispatch/app.py": {
"captureGroups": 21,
"digest": "3ad870d0f3c152f74a1b0c7d80be2352d7cad006e09ceef00705001aeaf6ad92"
"captureGroups": 22,
"digest": "7383d0e11431f87f7a581e512496957964bc680ebd4ccad1d7fc8e7c5ae0b269"
},
"python-overload-dispatch/service.py": {
"captureGroups": 37,
@ -792,8 +792,8 @@
"digest": "6b350aa1579d58e036053a16e8e94b91d8a8f6906f78d4f44ef675887a48bf5d"
},
"python-receiver-resolution/app.py": {
"captureGroups": 15,
"digest": "68a0a495d3dcdc2372295d035369c6c2a3cdb39ffe0b74eb4b0717f6a2994c85"
"captureGroups": 17,
"digest": "a674e182f9100d43f039fefd886198978dbec052f1b7b559c140e9beaf4eebf3"
},
"python-receiver-resolution/repo.py": {
"captureGroups": 7,
@ -804,8 +804,8 @@
"digest": "3fa157d0e0576fe87c6abf5ce9a3509c82c04b2ecf1fb6d53290ddc1326af8e4"
},
"python-reexport-chain/app.py": {
"captureGroups": 13,
"digest": "733114addc76c5bee5054960bba49ea372d8e3f8ca587a1d1a5eed35fcd995a2"
"captureGroups": 15,
"digest": "163bb144284218a5041e77ccfdd54ec761b01bc7a6d0f193a99f0862737ef893"
},
"python-reexport-chain/models/__init__.py": {
"captureGroups": 3,
@ -828,8 +828,8 @@
"digest": "9c7f1c54a9b0ba3bfa4410e1843bf5edb570777789ee659a00d7dd6771f3670f"
},
"python-same-file-method-collision/app.py": {
"captureGroups": 17,
"digest": "5248d59b03f40d11e4cb70c3caa91352356ad8fccc36c0b75f25cabae92e452a"
"captureGroups": 19,
"digest": "fa6fd9162e7656694601a967096adfa2da3c7c5268a8cb36a332b7f1fe858e8e"
},
"python-same-file-method-collision/models.py": {
"captureGroups": 25,
@ -884,8 +884,8 @@
"digest": "e539f618f46b19d1f7674321c6b994c773b57753c19d7764da83900143d71c0e"
},
"python-walrus-chain/app.py": {
"captureGroups": 37,
"digest": "2f2f237a7fc9fece184820a3136aaf496991f1093e5462fc199f1b46f84655ff"
"captureGroups": 39,
"digest": "c567a40d3f8d8270eba4bd91e489ae4d4c496e6baecd592e35f720bd3cb4f50f"
},
"python-walrus-chain/repo.py": {
"captureGroups": 8,

View file

@ -16,9 +16,14 @@
* container, so impact("EmailLogger", upstream) finds no direct caller — but
* must flag that the true blast radius is higher.
*/
import { it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
import {
MAX_UNRESOLVED_RECEIVER_MEMBERS,
summarizeUnresolvedReceivers,
} from '../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import type { ResolutionOutcome } from '../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
@ -43,6 +48,10 @@ const SEED = [
`CREATE (leaf:Function {id: 'Function:src/util.ts:formatDate', name: 'formatDate', filePath: 'src/util.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
`CREATE (caller:Function {id: 'Function:src/page.ts:renderHeader', name: 'renderHeader', filePath: 'src/page.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
`MATCH (a:Function {id:'Function:src/page.ts:renderHeader'}), (b:Function {id:'Function:src/util.ts:formatDate'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`,
...['noKnownCallers', 'constructor', 'toString', '__proto__'].map(
(name) =>
`CREATE (:Function {id: 'Function:src/util.ts:${name}', name: '${name}', filePath: 'src/util.ts', startLine: 5, endLine: 7, isExported: true, content: '', description: ''})`,
),
...[
['listOrders', 'query'],
@ -68,6 +77,8 @@ withTestLbugDB(
backend = (handle as any)._backend;
});
beforeEach(() => {
// One query can read metadata for both freshness and epistemic boundaries.
// Keep each fixture snapshot stable for every read in that test.
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
} as Awaited<ReturnType<typeof loadMeta>>);
@ -122,8 +133,200 @@ withTestLbugDB(
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
});
describe.each(['impact', 'context'] as const)('%s() receiver summary', (tool) => {
const query = (name: string) =>
backend.callTool(
tool,
tool === 'impact'
? { target: name, file_path: 'src/util.ts', direction: 'upstream' }
: { name, file_path: 'src/util.ts' },
);
it.each([
['noKnownCallers', 0],
['formatDate', 1],
] as const)('hedges an omitted %s with %i known callers', async (name, callers) => {
vi.mocked(loadMeta).mockResolvedValue({
repoPath: '',
lastCommit: '',
indexedAt: '',
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: {
counts: { anotherMember: 3 },
totalSites: 10,
omittedNames: 2,
},
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query(name);
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('lower-bound');
expect(result.boundaries.join(' ')).toContain('truncated');
expect(result.boundaries.join(' ')).toContain(`\`${name}\``);
expect(result.boundaries.join(' ')).toContain('dropped-call count');
expect(result.boundaries.join(' ')).toContain('cannot be determined');
expect(result.causes).toMatchObject({ receiverTyping: 0, externalBoundary: 0 });
if (tool === 'impact') {
expect(result.impactedCount).toBe(callers);
} else {
expect(result.status).toBe('found');
expect(result.incoming.calls?.length ?? 0).toBe(callers);
}
});
it('keeps actual retained call-site counts when other names were omitted', async () => {
vi.mocked(loadMeta).mockResolvedValue({
repoPath: '',
lastCommit: '',
indexedAt: '',
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: {
counts: { formatDate: 3 },
totalSites: 10,
omittedNames: 2,
externalCounts: { formatDate: 4 },
externalSites: 4,
},
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query('formatDate');
expect(result.epistemic).toBe('lower-bound');
expect(result.causes).toMatchObject({ receiverTyping: 3, externalBoundary: 4 });
expect(result.boundaries).toHaveLength(1);
expect(result.boundaries[0]).toContain('3 call sites invoking `formatDate` were dropped');
expect(result.boundaries[0]).not.toContain('truncated');
});
it.each([
['uncapped', { counts: { anotherMember: 1 }, totalSites: 1 }],
[
'external-only capped',
{
counts: {},
totalSites: 0,
externalCounts: { formatDate: 4 },
externalSites: 10,
externalOmittedNames: 2,
},
],
])('leaves an absent name exact with an %s summary', async (_label, summary) => {
vi.mocked(loadMeta).mockResolvedValue({
repoPath: '',
lastCommit: '',
indexedAt: '',
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: summary,
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query('formatDate');
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
expect(result.causes?.receiverTyping ?? 0).toBe(0);
expect(result.causes?.externalBoundary ?? 0).toBe(
'externalCounts' in summary ? summary.externalCounts.formatDate : 0,
);
});
it.each(['in-program', 'external'] as const)(
'consumes a persisted summary actually capped after 501 %s names',
async (receiverOrigin) => {
const outcomes: ResolutionOutcome[] = [
...Array.from({ length: MAX_UNRESOLVED_RECEIVER_MEMBERS }, (_, i) => `aaa_member${i}`),
'noKnownCallers',
].map((name, i) => ({
kind: 'suppressed',
reason: 'receiver-unresolved',
candidateIds: [],
phase: 'receiver-bound-calls',
filePath: 'src/caller.py',
name,
range: { startLine: i + 1, endLine: i + 1, startCol: 0, endCol: 30 },
siteKind: 'call',
receiverOrigin,
}));
const summary = JSON.parse(JSON.stringify(summarizeUnresolvedReceivers(outcomes)));
const external = receiverOrigin === 'external';
expect(Object.keys(external ? summary.externalCounts : summary.counts)).toHaveLength(
MAX_UNRESOLVED_RECEIVER_MEMBERS,
);
expect(external ? summary.externalSites : summary.totalSites).toBe(501);
expect(external ? summary.externalOmittedNames : summary.omittedNames).toBe(1);
expect(external ? summary.externalCounts : summary.counts).not.toHaveProperty(
'noKnownCallers',
);
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: summary,
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query('noKnownCallers');
expect(result.epistemic).toBe(external ? 'exact' : 'lower-bound');
expect(result.causes?.receiverTyping ?? 0).toBe(0);
expect(result.causes?.externalBoundary ?? 0).toBe(0);
if (external) expect(result.boundaries).toBeUndefined();
else expect(result.boundaries.join(' ')).toContain('truncated');
},
);
it.each([undefined, null, 0, -1, NaN, Infinity, 1.5, '2', '<invalid>', {}, true])(
'does not infer truncation from malformed or nonpositive omittedNames=%s',
async (omittedNames) => {
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: { counts: {}, totalSites: 0, omittedNames },
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query('formatDate');
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
expect(result.causes).toBeUndefined();
},
);
it.each(['constructor', 'toString', '__proto__'])(
'never invents a count for omitted prototype-like member %s',
async (name) => {
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: { counts: {}, totalSites: 3, omittedNames: 2 },
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query(name);
expect(result.epistemic).toBe('lower-bound');
expect(result.causes).toMatchObject({ receiverTyping: 0, externalBoundary: 0 });
expect(result.boundaries.join(' ')).toContain('truncated');
expect(result.boundaries.join(' ')).not.toMatch(/native code|NaN|undefined|\[object/);
},
);
it.each(['constructor', 'toString', '__proto__'])(
'preserves a genuinely recorded prototype-like member %s',
async (name) => {
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedReceiverMembers: JSON.parse(
JSON.stringify({ counts: { [name]: 3 }, totalSites: 5, omittedNames: 2 }),
),
} as Awaited<ReturnType<typeof loadMeta>>);
const result = await query(name);
expect(result.epistemic).toBe('lower-bound');
expect(result.causes.receiverTyping).toBe(3);
expect(result.boundaries.join(' ')).toContain(`3 call sites invoking \`${name}\``);
expect(result.boundaries.join(' ')).not.toContain('truncated');
},
);
});
it('marks impact as a lower bound when scope extraction omitted files', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce({
vi.mocked(loadMeta).mockResolvedValue({
repoPath: '/test/repo',
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
@ -145,7 +348,7 @@ withTestLbugDB(
});
it('never renders repository-controlled failure paths in boundary prose', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce({
vi.mocked(loadMeta).mockResolvedValue({
repoPath: '/test/repo',
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
@ -172,7 +375,7 @@ withTestLbugDB(
['missing metadata', null],
['malformed summary', { scopeExtractionReceipt: 1, scopeExtractionFailures: 'invalid' }],
])('treats %s as an unknown lower bound', async (_label, metadata) => {
vi.mocked(loadMeta).mockResolvedValueOnce(metadata as Awaited<ReturnType<typeof loadMeta>>);
vi.mocked(loadMeta).mockResolvedValue(metadata as Awaited<ReturnType<typeof loadMeta>>);
const result = await backend.callTool('impact', {
target: 'formatDate',
@ -187,7 +390,7 @@ withTestLbugDB(
});
it('treats a metadata read failure as an unknown lower bound', async () => {
vi.mocked(loadMeta).mockRejectedValueOnce(new Error('metadata unavailable'));
vi.mocked(loadMeta).mockRejectedValue(new Error('metadata unavailable'));
const result = await backend.callTool('impact', {
target: 'formatDate',
@ -244,7 +447,7 @@ withTestLbugDB(
});
it('context() reports persisted scope extraction omissions as a lower bound', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce({
vi.mocked(loadMeta).mockResolvedValue({
repoPath: '/test/repo',
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),

View file

@ -52,6 +52,43 @@ def enclosing():
return [run() for value in values]
class Parent: pass
class Child(Parent): pass
`,
'accessor.py': `def lazy_module():
import target as namespace
return namespace
def direct_accessor():
return lazy_module().run()
def assigned_accessor():
module = lazy_module()
return module.run()
class Receiver:
def run(self): return 1
`,
'accessor_caller.py': `from accessor import lazy_module as imported_module
def imported_direct():
return imported_module().run()
def imported_assigned():
module = imported_module()
return module.run()
`,
'assignment_branches.py': `from accessor import lazy_module as first
from other import lazy_module as second
def conditional_module(flag):
if flag:
module = first()
else:
module = second()
return module.run()
def conditional_builtin(flag):
if flag:
module = first()
else:
module = list()
return module.run()
def sequential():
module = first()
module = second()
return module.run()
`,
'typescript.ts': `${loaderSource}\nfunction typed(value: Child): Parent { return value; }`,
'typescript.tsx': `${loaderSource}\nconst element = <Child />;`,
@ -221,6 +258,80 @@ await import(${JSON.stringify(realWorker.href)});
expect(parsed.scopes.find((scope) => scope.id === site!.callerScope)?.kind).toBe('Function');
});
it('transports accessor proofs and exact assigned calls alongside subtype-dispatch facts', () => {
const accessor = workerFiles.get('accessor.py')!;
expect(accessor.captureSideChannel).toEqual(
expect.objectContaining({
kind: 'python-capture',
moduleAccessors: expect.arrayContaining([
{
definitionLine: 1,
definitionColumn: 0,
status: 'accepted',
returnedName: 'namespace',
returnLine: 3,
returnColumn: 11,
},
expect.objectContaining({ status: 'declined' }),
]),
subtypeDispatch: expect.objectContaining({
kind: 'python-subtype-dispatch',
simplePositionalCalls: expect.arrayContaining([expect.any(Array)]),
positionalCapacities: expect.arrayContaining([expect.any(Array)]),
}),
}),
);
for (const [filePath, callee, assignmentLine] of [
['accessor.py', 'lazy_module', 7],
['accessor_caller.py', 'imported_module', 5],
] as const) {
const parsed = workerFiles.get(filePath)!;
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments).toHaveLength(1);
const assignment = assignments[0]!;
expect(assignment.lhs).toBe('module');
expect(assignment.callSite.startLine).toBe(assignmentLine);
expect(parsed.captureSideChannel).toEqual(
expect.objectContaining({
callResultAssignments: [
{
callLine: assignment.callSite.startLine,
callColumn: assignment.callSite.startCol,
straightLine: true,
},
],
}),
);
expect(parsed.referenceSites).toContainEqual(
expect.objectContaining({
kind: 'call',
name: callee,
inScope: assignment.inScope,
atRange: assignment.callSite,
}),
);
}
});
it('preserves branch provenance without dropping generic call-result assignments', () => {
const parsed = workerFiles.get('assignment_branches.py')!;
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments.map((assignment) => assignment.callSite.startLine)).toEqual([
5, 7, 11, 13, 16, 17,
]);
expect(assignments.every((assignment) => assignment.lhs === 'module')).toBe(true);
expect(parsed.captureSideChannel).toEqual(
expect.objectContaining({
kind: 'python-capture',
callResultAssignments: assignments.map((assignment, index) => ({
callLine: assignment.callSite.startLine,
callColumn: assignment.callSite.startCol,
straightLine: index >= 4,
})),
}),
);
});
it('derives identical finalized ImportEdge positions from transported facts', () => {
const target = extractParsedFile(
getProviderForFile('target.py')!,

View file

@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
import * as scopeBridge from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { getProviderForFile } from '../../../src/core/ingestion/languages/index.js';
import { summarizeUnresolvedReceivers } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import {
loadParseCache,
loadParseCacheChunk,
@ -84,6 +85,50 @@ def denied():
`,
'py/a.py': 'def run(): return 1\n',
'py/b.py': 'def run(): return 2\n',
'py/accessors.py': `def lazy_module():
import a as namespace
return namespace
def direct_accessor():
return lazy_module().run()
def assigned_accessor():
module = lazy_module()
return module.run()
`,
'py/accessor_caller.py': `from accessors import lazy_module as imported_module
def imported_direct():
return imported_module().run()
def imported_assigned():
module = imported_module()
return module.run()
`,
'py/alternate_accessors.py': `def other_module():
import b as namespace
return namespace
`,
'py/branch_modules.py': `from accessors import lazy_module
from alternate_accessors import other_module
def conditional_modules(flag):
if flag:
module = lazy_module()
else:
module = other_module()
return module.run()
`,
'py/branch_builtin.py': `from accessors import lazy_module
def conditional_builtin(flag):
if flag:
module = lazy_module()
else:
module = list()
return module.run()
`,
'py/sequential_accessors.py': `from accessors import lazy_module
from alternate_accessors import other_module
def sequential():
module = lazy_module()
module = other_module()
return module.run()
`,
'py/metadata.py': `from a import run
from fastapi import Depends
def dependency(): return 1
@ -122,6 +167,38 @@ int outside() { { using target::run; } return run(); }
{ file: 'src/lib.rs', caller: 'allowed', targetFile: 'src/target.rs', target: 'run' },
{ file: 'cpp/main.cpp', caller: 'allowed', targetFile: 'cpp/target.hpp', target: 'run' },
);
for (const [file, callers] of [
['py/accessors.py', ['direct_accessor', 'assigned_accessor']],
['py/accessor_caller.py', ['imported_direct', 'imported_assigned']],
] as const) {
for (const caller of callers) {
expected.push(
{ file, caller, targetFile: 'py/a.py', target: 'run' },
{ file, caller, targetFile: 'py/accessors.py', target: 'lazy_module' },
);
}
}
for (const [file, caller] of [
['py/branch_modules.py', 'conditional_modules'],
['py/branch_builtin.py', 'conditional_builtin'],
['py/sequential_accessors.py', 'sequential'],
] as const) {
expected.push({ file, caller, targetFile: 'py/accessors.py', target: 'lazy_module' });
if (file !== 'py/branch_builtin.py') {
expected.push({
file,
caller,
targetFile: 'py/alternate_accessors.py',
target: 'other_module',
});
}
}
expected.push({
file: 'py/sequential_accessors.py',
caller: 'sequential',
targetFile: 'py/b.py',
target: 'run',
});
for (const extension of ['ts', 'tsx', 'js', 'jsx']) {
const dir = extension;
const targetExtension = extension.startsWith('ts') ? 'ts' : 'js';
@ -242,6 +319,21 @@ pub fn denied() void { ns.run(); const ns = @import("target.zig"); }
.sort(),
);
// The exact set rejects both sibling-target leakage and every denied call.
for (const filePath of ['py/branch_modules.py', 'py/branch_builtin.py']) {
expect(
summarizeUnresolvedReceivers(
result.resolutionOutcomes.filter((outcome) => outcome.filePath === filePath),
),
`one in-program/unknown branch call for ${filePath}`,
).toEqual({ counts: { run: 1 }, totalSites: 1 });
}
expect(
summarizeUnresolvedReceivers(
result.resolutionOutcomes.filter(
(outcome) => outcome.filePath === 'py/sequential_accessors.py',
),
),
).toBeUndefined();
}
it('reuses genuine cold output, retains exact targets, and invalidates changed ownership', async () => {
@ -277,6 +369,43 @@ pub fn denied() void { ns.run(); const ns = @import("target.zig"); }
usingDeclarations: expect.arrayContaining([expect.any(Object)]),
}),
);
expect(coldRecords.get('py/accessors.py')!.captureSideChannel).toEqual(
expect.objectContaining({
kind: 'python-capture',
moduleAccessors: expect.arrayContaining([
expect.objectContaining({ status: 'accepted', returnedName: 'namespace' }),
expect.objectContaining({ status: 'declined' }),
]),
subtypeDispatch: expect.objectContaining({
kind: 'python-subtype-dispatch',
simplePositionalCalls: expect.arrayContaining([expect.any(Array)]),
}),
}),
);
for (const filePath of ['py/accessors.py', 'py/accessor_caller.py']) {
expect(coldRecords.get(filePath)!.callResultAssignmentSites).toEqual([
expect.objectContaining({ lhs: 'module', callSite: expect.any(Object) }),
]);
}
for (const [filePath, straightLine] of [
['py/branch_modules.py', false],
['py/branch_builtin.py', false],
['py/sequential_accessors.py', true],
] as const) {
const parsed = coldRecords.get(filePath)!;
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments).toHaveLength(2);
expect(parsed.captureSideChannel).toEqual(
expect.objectContaining({
kind: 'python-capture',
callResultAssignments: assignments.map((assignment) => ({
callLine: assignment.callSite.startLine,
callColumn: assignment.callSite.startCol,
straightLine,
})),
}),
);
}
expect(
coldRecords
.get('py/main.py')!
@ -384,22 +513,43 @@ pub fn denied() void { ns.run(); const ns = @import("target.zig"); }
await persist(warmCache);
expect(await durableRecords()).toEqual(coldRecords);
// One changed local target plus one loader that ceases to be an import.
// Change the accessor's returned module while its importing caller is unchanged.
// Also change one local target and one loader that ceases to be an import.
writeFixtureRepo(repoDir, {
'ts/main.ts': files['ts/main.ts']!.replace("require('./a')", "require('./b')"),
'js/main.js': files['js/main.js']!.replace('allowed()', 'allowed(require)'),
'py/accessors.py': files['py/accessors.py']!.replace(
'import a as namespace',
'import b as namespace',
),
'py/alternate_accessors.py': files['py/alternate_accessors.py']!.replace(
'import b as namespace',
'import a as namespace',
),
});
const editedCache = await loadParseCache(storageDir);
const edited = await run(editedCache);
expect(edited.usedWorkerPool).toBe(true);
expect(edited.reparsedFileCount).toBeGreaterThanOrEqual(2);
expect(edited.reparsedFileCount).toBeGreaterThanOrEqual(4);
expect(edited.parseCacheHitFileCount).toBeGreaterThan(0);
expect(edited.reparsedFileCount! + edited.parseCacheHitFileCount!).toBe(parsedPaths.length);
expect(fs.existsSync(marker)).toBe(true);
expect(extract).not.toHaveBeenCalled();
const changedExpected = expected
.filter((item) => item.file !== 'js/main.js')
.map((item) => (item.file === 'ts/main.ts' ? { ...item, targetFile: 'ts/b.ts' } : item));
.map((item) => {
if (item.file === 'ts/main.ts') return { ...item, targetFile: 'ts/b.ts' };
if (item.file === 'py/sequential_accessors.py' && item.target === 'run') {
return { ...item, targetFile: 'py/a.py' };
}
if (
(item.file === 'py/accessors.py' || item.file === 'py/accessor_caller.py') &&
item.target === 'run'
) {
return { ...item, targetFile: 'py/b.py' };
}
return item;
});
assertExactCalls(edited, changedExpected);
expect(graphEdges(edited)).not.toEqual(graphEdges(cold));
await persist(editedCache);
@ -409,6 +559,23 @@ pub fn denied() void { ns.run(); const ns = @import("target.zig"); }
'./b',
]);
expect(editedRecords.get('py/main.py')).toEqual(coldRecords.get('py/main.py'));
expect(
editedRecords.get('py/accessors.py')!.parsedImports.map((imp) => imp.targetRaw),
).toEqual(['b']);
expect(editedRecords.get('py/accessor_caller.py')).toEqual(
coldRecords.get('py/accessor_caller.py'),
);
expect(fs.readFileSync(path.join(repoDir, 'py/accessor_caller.py'), 'utf8')).toBe(
files['py/accessor_caller.py'],
);
for (const filePath of [
'py/branch_modules.py',
'py/branch_builtin.py',
'py/sequential_accessors.py',
]) {
expect(editedRecords.get(filePath)).toEqual(coldRecords.get(filePath));
expect(fs.readFileSync(path.join(repoDir, filePath), 'utf8')).toBe(files[filePath]);
}
fs.rmSync(marker);
const editedWarm = await run(await loadParseCache(storageDir));
@ -419,6 +586,35 @@ pub fn denied() void { ns.run(); const ns = @import("target.zig"); }
expect(extract).not.toHaveBeenCalled();
assertExactCalls(editedWarm, changedExpected);
expect(graphEdges(editedWarm)).toEqual(graphEdges(edited));
// Both stores reject the pre-accessor schema. run() constructs a fresh
// graph, proving the full-rebuild upgrade path with unchanged sources;
// cache invalidation alone is not evidence of persisted graph-row updates.
const previousVersion = PARSE_CACHE_VERSION.replace(/^\d+\+/, '136+');
expect(previousVersion).not.toBe(PARSE_CACHE_VERSION);
for (const indexPath of [
path.join(storageDir, 'parse-cache', 'index.json'),
path.join(getDurableParsedFileDir(storageDir), 'index.json'),
]) {
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
fs.writeFileSync(indexPath, JSON.stringify({ ...index, version: previousVersion }));
}
await clearParsedFileStore(storageDir);
expect(
await loadDurableParsedFileIndex(getDurableParsedFileDir(storageDir), PARSE_CACHE_VERSION),
).toEqual(new Map());
const upgradedCache = await loadParseCache(storageDir);
expect(upgradedCache.entries.size).toBe(0);
expect(upgradedCache.onDiskKeys?.size ?? 0).toBe(0);
const rebuilt = await run(upgradedCache);
expect(rebuilt.usedWorkerPool).toBe(true);
expect(rebuilt.reparsedFileCount).toBe(parsedPaths.length);
expect(rebuilt.parseCacheHitFileCount).toBe(0);
expect(extract).not.toHaveBeenCalled();
assertExactCalls(rebuilt, changedExpected);
expect(graphEdges(rebuilt)).toEqual(graphEdges(edited));
await persist(upgradedCache);
expect(await durableRecords()).toEqual(editedRecords);
} finally {
extract.mockRestore();
}

View file

@ -0,0 +1,263 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { summarizeUnresolvedReceivers } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
describe('Python module accessor receivers', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-module-accessors-'));
writeFixtureRepo(repoDir, {
'pkg/__init__.py': '',
'pkg/facade.py': 'def target(): return 1\n',
'pkg/decoy.py': 'def target(): return 2\n',
'pkg/values.py': 'facade = object()\n',
'pkg/user.py': `def _pc():
from pkg import facade
return facade
def caller():
_pc().target()
def caller_via_local():
m = _pc()
m.target()
def spaced_caller():
_pc (
).target()
`,
'pkg/control.py': 'from pkg import facade\ndef direct_caller():\n facade.target()\n',
'pkg/accessors.py': `def lazy():
from . import facade as module
return module
`,
'pkg/imported.py': `from pkg.accessors import lazy as renamed
from pkg import decoy as facade
def imported_direct():
renamed().target()
def imported_local():
m = renamed()
m.target()
`,
'pkg/shadows.py': `from pkg.user import _pc
def parameter(_pc):
_pc().target()
def before_assignment():
m.target()
m = _pc()
def reassigned():
m = _pc()
m = object()
m.target()
def deleted():
m = _pc()
del m
m.target()
def sibling():
m.target()
def local_name():
def _pc():
from pkg import decoy
return decoy
_pc().target()
def later_local():
_pc().target()
_pc = object()
def two_assignments():
m = _pc()
m.target()
m = object()
m.target()
def only_sibling_accessor():
def unrelated():
def missing():
from pkg import facade
return facade
missing().target()
`,
'pkg/rejected.py': `def rebound():
from pkg import facade
facade = object()
return facade
def ordinary_export():
from pkg.values import facade
return facade
def parameterized(facade):
from pkg import facade
return facade
def asynchronous():
async def lazy():
from pkg import facade
return facade
lazy().target()
def rejected_callers():
rebound().target()
ordinary_export().target()
parameterized().target()
m = rebound()
m.target()
def positive_external():
m = list()
m.target()
`,
'pkg/unsupported.py': `def mixed():
from pkg import facade
if flag:
return facade
return None
def unsupported_direct():
mixed().target()
def unsupported_local():
m = mixed()
m.target()
def property_only():
m = mixed()
value = m.target
m.target = value
`,
'pkg/conditional.py': `def first():
from pkg import facade
return facade
def second():
from pkg import decoy
return decoy
def conditional_modules(flag):
if flag:
m = first()
else:
m = second()
m.target()
def conditional_builtin(flag):
if flag:
m = first()
else:
m = list()
m.target()
def suppressed_assignment():
from contextlib import suppress
m = first()
with suppress(Exception):
raise RuntimeError()
m = second()
m.target()
def sequential():
m = first()
m = second()
m.target()
`,
'pkg/classes.py': `class Service:
def work(self): return 1
def factory() -> Service:
return Service()
def existing():
Service().work()
factory().work()
item = factory()
item.work()
`,
});
result = await runPipelineFromRepo(repoDir, () => {}, { skipGraphPhases: true });
}, 120_000);
afterAll(() => fs.rmSync(repoDir, { recursive: true, force: true }));
const calls = () => getRelationships(result, 'CALLS');
const targetCalls = () => calls().filter((edge) => edge.target === 'target');
it('resolves direct, assigned, imported and aliased module accessors by lexical identity', () => {
expect(
targetCalls()
.map((edge) => `${edge.source} -> ${edge.targetFilePath}`)
.sort(),
).toEqual([
'caller -> pkg/facade.py',
'caller_via_local -> pkg/facade.py',
'direct_caller -> pkg/facade.py',
'imported_direct -> pkg/facade.py',
'imported_local -> pkg/facade.py',
'local_name -> pkg/decoy.py',
'sequential -> pkg/decoy.py',
'spaced_caller -> pkg/facade.py',
'two_assignments -> pkg/facade.py',
]);
expect(
calls()
.filter((edge) => edge.target === '_pc' && edge.sourceFilePath === 'pkg/user.py')
.map((edge) => edge.source)
.sort(),
).toEqual(['caller', 'caller_via_local', 'spaced_caller']);
});
it('records each unsupported direct and assigned call once, excluding property accesses', () => {
const outcomes = result.resolutionOutcomes.filter(
(outcome) => outcome.filePath === 'pkg/unsupported.py',
);
expect(summarizeUnresolvedReceivers(outcomes)).toMatchObject({
counts: { target: 2 },
totalSites: 2,
});
});
it('declines conditional assignment proofs without treating a last-branch built-in as external', () => {
expect(
targetCalls()
.filter((edge) => edge.sourceFilePath === 'pkg/conditional.py')
.map((edge) => `${edge.source} -> ${edge.targetFilePath}`),
).toEqual(['sequential -> pkg/decoy.py']);
const outcomes = result.resolutionOutcomes.filter(
(outcome) => outcome.filePath === 'pkg/conditional.py',
);
expect(
outcomes.map((outcome) =>
outcome.kind === 'suppressed'
? [outcome.range.startLine, outcome.receiverOrigin]
: undefined,
),
).toEqual([
[12, 'unknown'],
[18, 'unknown'],
[25, 'unknown'],
]);
expect(summarizeUnresolvedReceivers(outcomes)).toEqual({
counts: { target: 3 },
totalSites: 3,
});
});
it('preserves constructor and class-returning factory dispatch without dropped calls', () => {
expect(
calls().filter((edge) => edge.source === 'existing' && edge.target === 'work'),
).toHaveLength(3);
expect(
summarizeUnresolvedReceivers(
result.resolutionOutcomes.filter((outcome) => outcome.filePath === 'pkg/classes.py'),
),
).toBeUndefined();
});
it('keeps rejected namespace proofs observable and positively external calls separate', () => {
const summary = summarizeUnresolvedReceivers(
result.resolutionOutcomes.filter((outcome) => outcome.filePath === 'pkg/rejected.py'),
);
expect(summary).toMatchObject({
counts: { target: 5 },
totalSites: 5,
externalCounts: { target: 1 },
externalSites: 1,
});
expect(
summarizeUnresolvedReceivers(
result.resolutionOutcomes.filter((outcome) =>
['pkg/user.py', 'pkg/imported.py'].includes(outcome.filePath),
),
),
).toBeUndefined();
});
});

View file

@ -318,8 +318,9 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 133 -> 134 for lexical local-import facts and real-worker parity.
// Moved 134 -> 135 for guarded-import and mutable binding review fixes (#3532).
// Moved 135 -> 136 for parameter annotations and Vue embedded script fixes (#3532).
it('pins SCHEMA_BUMP to 136 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, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(136);
// Moved 136 -> 137 for Python module-accessor and call-result assignment captures (#3497).
it('pins SCHEMA_BUMP to 137 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, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(137);
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
@ -329,7 +330,7 @@ describe('PARSE_CACHE_VERSION', () => {
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, 119, 120, 121, 122,
123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135,
123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -0,0 +1,320 @@
import { describe, expect, it } from 'vitest';
import type { ParsedFile } from 'gitnexus-shared';
import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js';
import { emitPythonScopeCaptures } from '../../../../src/core/ingestion/languages/python/captures.js';
import type { PythonCaptureSideChannel } from '../../../../src/core/ingestion/languages/python/module-accessors.js';
import { pythonScopeResolver } from '../../../../src/core/ingestion/languages/python/scope-resolver.js';
import { collectPythonSubtypeDispatchSideChannel } from '../../../../src/core/ingestion/languages/python/subtype-dispatch.js';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
function snapshot(filePath: string) {
return pythonProvider.collectCaptureSideChannel?.(filePath) as
| PythonCaptureSideChannel
| undefined;
}
function capture(source: string, filePath = 'accessors.py') {
emitPythonScopeCaptures(source, filePath);
const result = snapshot(filePath);
expect(result?.kind).toBe('python-capture');
return result!.moduleAccessors;
}
describe('Python module accessor capture facts', () => {
it.each([
['import facade', 'facade'],
['import package.facade as lazy', 'lazy'],
['from . import facade as lazy', 'lazy'],
['from package import facade', 'facade'],
['import unused, facade', 'facade'],
])('retains exact callable and returned binding positions for %s', (statement, name) => {
expect(capture(`def lazy_module():\n ${statement}\n return ${name}\n`)).toEqual([
{
definitionLine: 1,
definitionColumn: 0,
status: 'accepted',
returnedName: name,
returnLine: 3,
returnColumn: 11,
},
]);
});
it('allows a docstring and comments without changing return ownership', () => {
expect(
capture(
'def lazy_module():\n # documentation\n """Load lazily."""\n import facade\n # after import\n return facade\n',
),
).toEqual([
{
definitionLine: 1,
definitionColumn: 0,
status: 'accepted',
returnedName: 'facade',
returnLine: 6,
returnColumn: 11,
},
]);
});
it.each([
[
'conditional return',
'def lazy_module():\n import facade\n if flag:\n return facade',
],
[
'mixed returns',
'def lazy_module():\n import facade\n if flag:\n return other\n return facade',
],
['implicit fallthrough', 'def lazy_module():\n import facade'],
['bare return', 'def lazy_module():\n import facade\n return'],
['decorator', '@decorate\ndef lazy_module():\n import facade\n return facade'],
['generator', 'def lazy_module():\n import facade\n yield 1\n return facade'],
['coroutine', 'async def lazy_module():\n import facade\n return facade'],
[
'finally override',
'def lazy_module():\n import facade\n try:\n return facade\n finally:\n return other',
],
['parameter', 'def lazy_module(facade):\n import facade\n return facade'],
['optional parameter', 'def lazy_module(flag=False):\n import facade\n return facade'],
[
'reassignment',
'def lazy_module():\n import facade\n facade = other\n return facade',
],
[
'later assignment',
'def lazy_module():\n import facade\n return facade\n facade = other',
],
['deletion', 'def lazy_module():\n import facade\n del facade\n return facade'],
[
'global redirection',
'def lazy_module():\n global facade\n import facade\n return facade',
],
[
'nonlocal redirection',
'def lazy_module():\n nonlocal facade\n import facade\n return facade',
],
['unimported return', 'def lazy_module():\n import facade\n return other'],
['outer import only', 'import facade\ndef lazy_module():\n return facade'],
['returned attribute', 'def lazy_module():\n import package\n return package.facade'],
[
'import rebinding',
'def lazy_module():\n import facade\n import other as facade\n return facade',
],
[
'interpolated string',
'def lazy_module():\n f"{side_effect()}"\n import facade\n return facade',
],
])('records a declined candidate for %s', (_label, source) => {
const facts = capture(source);
expect(facts).toHaveLength(1);
expect(facts[0]).toEqual({
definitionLine: source.startsWith('@') || source.startsWith('import ') ? 2 : 1,
definitionColumn: 0,
status: 'declined',
});
});
it('keeps nested callable returns and yields separate from the outer callable', () => {
expect(
capture(
[
'def outer():',
' def nested():',
' import facade',
' return facade',
' def generator():',
' yield 1',
' return nested()',
].join('\n'),
),
).toEqual([
{ definitionLine: 1, definitionColumn: 0, status: 'declined' },
{
definitionLine: 2,
definitionColumn: 4,
status: 'accepted',
returnedName: 'facade',
returnLine: 4,
returnColumn: 15,
},
{ definitionLine: 5, definitionColumn: 4, status: 'declined' },
]);
});
it('records assigned bare no-argument calls at their exact reference and lexical scope', () => {
const parsed = extractParsedFile(
pythonProvider,
[
'def first():',
' module = lazy_module()',
' other = lazy_module()',
' argument = lazy_module(1)',
' keyword = lazy_module(flag=True)',
' qualified = package.lazy_module()',
' obj.field = lazy_module()',
'def second():',
' module = lazy_module()',
].join('\n'),
'assigned.py',
)!;
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments.map(({ lhs }) => lhs)).toEqual(['module', 'other', 'module']);
expect(assignments.map(({ callSite }) => callSite.startLine)).toEqual([2, 3, 9]);
for (const assignment of assignments) {
const reference = parsed.referenceSites.find(
(site) =>
site.atRange.startLine === assignment.callSite.startLine &&
site.atRange.startCol === assignment.callSite.startCol &&
site.name === 'lazy_module',
);
expect(reference?.atRange).toEqual(assignment.callSite);
expect(reference?.inScope).toBe(assignment.inScope);
}
expect(assignments[0]?.inScope).toBe(assignments[1]?.inScope);
expect(assignments[0]?.inScope).not.toBe(assignments[2]?.inScope);
});
it.each([
['if', 'if flag:\n module = lazy_module()'],
['else', 'if flag:\n pass\n else:\n module = lazy_module()'],
['for', 'for value in values:\n module = lazy_module()'],
['with', 'with manager():\n module = lazy_module()'],
['while', 'while flag:\n module = lazy_module()'],
['try', 'try:\n module = lazy_module()\n except Exception:\n pass'],
['except', 'try:\n pass\n except Exception:\n module = lazy_module()'],
['finally', 'try:\n pass\n finally:\n module = lazy_module()'],
['match', 'match value:\n case 1:\n module = lazy_module()'],
])('retains generic provenance but declines %s assignment eligibility', (_label, body) => {
const parsed = extractParsedFile(
pythonProvider,
`def caller():\n ${body}\n`,
'conditional.py',
)!;
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments).toHaveLength(1);
expect(snapshot('conditional.py')).toHaveProperty('callResultAssignments', [
{
callLine: assignments[0]!.callSite.startLine,
callColumn: assignments[0]!.callSite.startCol,
straightLine: false,
},
]);
});
it('stops conditional ancestry at the owning callable and records straight-line module assignments', () => {
const parsed = extractParsedFile(
pythonProvider,
[
'module = lazy_module()',
'if flag:',
' def nested():',
' module = lazy_module()',
].join('\n'),
'ownership.py',
)!;
expect(parsed.callResultAssignmentSites).toHaveLength(2);
expect(snapshot('ownership.py')).toHaveProperty('callResultAssignments', [
{ callLine: 1, callColumn: 9, straightLine: true },
{ callLine: 4, callColumn: 17, straightLine: true },
]);
});
it('composes clone-safe accessor and subtype facts and restores both after reset', () => {
const source = [
'def lazy_module():',
' import facade',
' return facade',
'class Worker:',
' def target(self, value):',
' return self.target(value)',
'module = lazy_module()',
].join('\n');
capture(source);
const before = snapshot('accessors.py')!;
const subtypeBefore = collectPythonSubtypeDispatchSideChannel('accessors.py');
expect(before.subtypeDispatch).toEqual(subtypeBefore);
expect(subtypeBefore?.simplePositionalCalls).toHaveLength(2);
expect(subtypeBefore?.positionalCapacities).toHaveLength(1);
const clone = structuredClone(before);
expect(JSON.parse(JSON.stringify(before))).toEqual(clone);
emitPythonScopeCaptures('', 'accessors.py');
expect(snapshot('accessors.py')).toBeUndefined();
expect(collectPythonSubtypeDispatchSideChannel('accessors.py')).toBeUndefined();
pythonScopeResolver.applyCaptureSideChannel!({
filePath: 'accessors.py',
captureSideChannel: clone,
} as ParsedFile);
expect(snapshot('accessors.py')).toEqual(before);
expect(collectPythonSubtypeDispatchSideChannel('accessors.py')).toEqual(subtypeBefore);
pythonScopeResolver.applyCaptureSideChannel!({ filePath: 'accessors.py' } as ParsedFile);
expect(snapshot('accessors.py')).toBeUndefined();
expect(collectPythonSubtypeDispatchSideChannel('accessors.py')).toBeUndefined();
});
it.each([
undefined,
null,
{},
[{ callLine: 1, callColumn: 9, straightLine: 'yes' }],
[{ callLine: 0, callColumn: 9, straightLine: true }],
])(
'clears stale assignment eligibility when restored facts are absent or malformed: %j',
(facts) => {
emitPythonScopeCaptures('module = lazy_module()', 'restore.py');
expect(snapshot('restore.py')).toHaveProperty('callResultAssignments', [
{ callLine: 1, callColumn: 9, straightLine: true },
]);
pythonScopeResolver.applyCaptureSideChannel!({
filePath: 'restore.py',
captureSideChannel: {
kind: 'python-capture',
moduleAccessors: [],
callResultAssignments: facts,
},
} as unknown as ParsedFile);
expect(snapshot('restore.py')).toBeUndefined();
},
);
it('does not leak facts between files or reuse stale facts on recapture', () => {
capture('def lazy_module():\n import facade\n return facade', 'first.py');
capture('def lazy_module():\n return other', 'second.py');
expect(snapshot('first.py')!.moduleAccessors[0]?.status).toBe('accepted');
expect(snapshot('second.py')!.moduleAccessors[0]?.status).toBe('declined');
emitPythonScopeCaptures('', 'first.py');
expect(snapshot('first.py')).toBeUndefined();
expect(snapshot('second.py')!.moduleAccessors[0]?.status).toBe('declined');
});
it('remaps accessor and assignment facts to original notebook coordinates', () => {
const source =
'def lazy_module():\n import facade\n return facade\nmodule = lazy_module()';
const captures = emitPythonScopeCaptures(source, 'notebook.ipynb', undefined, {
sourceKind: 'pre-extracted-script',
notebookSegments: [
{ extractStartLine: 0, extractEndLine: 3, jsonStartLine: 20, jsonEndLine: 23 },
],
});
expect(snapshot('notebook.ipynb')?.moduleAccessors).toEqual([
{
definitionLine: 21,
definitionColumn: 0,
status: 'accepted',
returnedName: 'facade',
returnLine: 23,
returnColumn: 11,
},
]);
expect(snapshot('notebook.ipynb')).toHaveProperty('callResultAssignments', [
{ callLine: 24, callColumn: 9, straightLine: true },
]);
expect(
captures.find((match) => match['@call-result-assignment.call'])?.[
'@call-result-assignment.call'
]?.range.startLine,
).toBe(24);
});
});