From 191a954bf67583447dac5c71cc8d679aabd101d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 10 Oct 2026 20:41:51 +0300 Subject: [PATCH] fix(python): resolve calls through module-returning accessors (#3542) * 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) * 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) * 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) * 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) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5.5 (1M context) --- .../python-scope/baseline-fingerprint.txt | 2 +- .../bench/receiver-resolution/baseline.json | 14 +- gitnexus/bench/tool-accuracy/known-gaps.json | 18 - .../src/core/ingestion/languages/python.ts | 4 +- .../ingestion/languages/python/captures.ts | 17 + .../python/module-accessor-resolution.ts | 186 ++++++++++ .../languages/python/module-accessors.ts | 286 ++++++++++++++++ .../languages/python/scope-resolver.ts | 6 +- .../languages/python/subtype-dispatch.ts | 4 +- .../contract/scope-resolver.ts | 22 ++ .../passes/receiver-bound-calls.ts | 27 +- gitnexus/src/mcp/local/local-backend.ts | 21 +- gitnexus/src/storage/parse-cache.ts | 4 +- .../expected-captures.json | 88 ++--- .../impact-epistemic-lower-bound.test.ts | 215 +++++++++++- .../local-import-worker-parity.test.ts | 111 ++++++ .../local-import-persistence.test.ts | 202 ++++++++++- .../resolvers/python-module-accessors.test.ts | 263 ++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 7 +- .../python/python-module-accessors.test.ts | 320 ++++++++++++++++++ 20 files changed, 1717 insertions(+), 100 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/python/module-accessor-resolution.ts create mode 100644 gitnexus/src/core/ingestion/languages/python/module-accessors.ts create mode 100644 gitnexus/test/integration/resolvers/python-module-accessors.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/python/python-module-accessors.test.ts diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 690ab39c7..6a3d76c1c 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -390272fd1067ca18a91c5ec5cc1800fb38fab56d06671baf0d0a0e2bd0587658 +44277912361a8783163b4ca9c5843e4d8f8444116b768881e617f26368bb1cba diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 33396439e..8214c02ec 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -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, "<>": 11, "chain-mixed": 2, "chain-unwrap": 1 }, "callDropsByOrigin": { - "in-program": 55, + "in-program": 56, "external": 44, - "unknown": 26 + "unknown": 29 } } } diff --git a/gitnexus/bench/tool-accuracy/known-gaps.json b/gitnexus/bench/tool-accuracy/known-gaps.json index 4e5e11dc0..fa0c72f60 100644 --- a/gitnexus/bench/tool-accuracy/known-gaps.json +++ b/gitnexus/bench/tool-accuracy/known-gaps.json @@ -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"] } ] } diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index be51de940..18b030e43 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -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 = 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, diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index b7dfbde70..f3b13bfcf 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -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['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( diff --git a/gitnexus/src/core/ingestion/languages/python/module-accessor-resolution.ts b/gitnexus/src/core/ingestion/languages/python/module-accessor-resolution.ts new file mode 100644 index 000000000..d93edaa25 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/module-accessor-resolution.ts @@ -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(); + const fileScopes = new Map>(); + const namespaceCaches = new Map>(); + const assignmentIndexes = new WeakMap< + ParsedFile, + Map> + >(); + const callIndexes = new WeakMap>(); + + 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(); + 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>(); + 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) }; + }; +}; diff --git a/gitnexus/src/core/ingestion/languages/python/module-accessors.ts b/gitnexus/src/core/ingestion/languages/python/module-accessors.ts new file mode 100644 index 000000000..ab680e6a6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/module-accessors.ts @@ -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>(); +const assignmentsByFile = new Map>(); + +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(); + 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; + 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 | 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(); + 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(); + 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); +} diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 628af9a0e..5b47d92ec 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts index 5f02909e1..80a0fd4a7 100644 --- a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts +++ b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts @@ -26,7 +26,9 @@ export interface PythonSubtypeDispatchSideChannel { const simplePositionalCallsByFile = new Map>(); const positionalCapacitiesByFile = new Map>(); -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 8d2af682c..4a303b9fb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 3f5b4bf51..6fa4fa920 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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, + ), }); } } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index e5dc607f1..e8c42cfbf 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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}\` ${ diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 4e9427f0c..c4a56ed56 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 6dda80375..4714aed75 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -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, diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 458a19155..729ae82a6 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -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>); @@ -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>); + + 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>); + + 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>); + + 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>); + + 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', '', {}, 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>); + + 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>); + + 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>); + + 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>); + vi.mocked(loadMeta).mockResolvedValue(metadata as Awaited>); 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(), diff --git a/gitnexus/test/integration/local-import-worker-parity.test.ts b/gitnexus/test/integration/local-import-worker-parity.test.ts index 3fd51a540..5a435ad4b 100644 --- a/gitnexus/test/integration/local-import-worker-parity.test.ts +++ b/gitnexus/test/integration/local-import-worker-parity.test.ts @@ -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 = ;`, @@ -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')!, diff --git a/gitnexus/test/integration/resolvers/local-import-persistence.test.ts b/gitnexus/test/integration/resolvers/local-import-persistence.test.ts index b10b9e35f..e5952ec8c 100644 --- a/gitnexus/test/integration/resolvers/local-import-persistence.test.ts +++ b/gitnexus/test/integration/resolvers/local-import-persistence.test.ts @@ -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(); } diff --git a/gitnexus/test/integration/resolvers/python-module-accessors.test.ts b/gitnexus/test/integration/resolvers/python-module-accessors.test.ts new file mode 100644 index 000000000..31406c383 --- /dev/null +++ b/gitnexus/test/integration/resolvers/python-module-accessors.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 68e54b6ec..9680bfde4 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/scope-resolution/python/python-module-accessors.test.ts b/gitnexus/test/unit/scope-resolution/python/python-module-accessors.test.ts new file mode 100644 index 000000000..c8a0e9262 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-module-accessors.test.ts @@ -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); + }); +});