mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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
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:
parent
114ae0b411
commit
191a954bf6
20 changed files with 1717 additions and 100 deletions
|
|
@ -1 +1 @@
|
|||
390272fd1067ca18a91c5ec5cc1800fb38fab56d06671baf0d0a0e2bd0587658
|
||||
44277912361a8783163b4ca9c5843e4d8f8444116b768881e617f26368bb1cba
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
};
|
||||
};
|
||||
286
gitnexus/src/core/ingestion/languages/python/module-accessors.ts
Normal file
286
gitnexus/src/core/ingestion/languages/python/module-accessors.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}\` ${
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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')!,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue