mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
fix(ingestion): bridge object-literal value receivers in scope-resolution (PR #1718 review)
Addresses adversarial production-readiness review on PR #1718 / issue #1358: - F1 (caller resolution) — setting `ownerId` on object-literal method symbols alone is not sufficient; the scope-resolution receiver-bound resolver only consults class-like or type-annotated bindings, so lowercase value receivers (`export const fooService = {...}; fooService.getUser(...)`) never reach the owner-indexed lookup. Adds a Case 5 value-receiver bridge in receiver-bound-calls.ts that resolves the receiver name as a Const/Variable binding, translates its def to the canonical graph node id, and emits the CALLS edge via the owner-indexed method registry. - F2 (boundary guard) — rewrites findObjectLiteralBindingInfo as an explicit two-phase AST walk: Phase A tracks object-literal depth (returns null for nested literals and pre-declarator function/class boundaries — IIFE patterns); Phase B walks the declarator's ancestors and rejects function, class, and block-statement containers (if / for / while / try / catch / switch / etc.) before reaching program/export_statement. Prevents false HAS_METHOD edges for locally-scoped or block-scoped object literals. - F4 — drops the dead `ownerName` field from ObjectLiteralBindingInfo. Constraint: TS/JS are scope-resolution migrated per RFC #909; the legacy Call-Resolution DAG (call-processor.ts) is intentionally left untouched. Tests: - test/integration/ast-helpers-object-literal-binding.test.ts (13 cases) — pins helper semantics: happy paths, function/arrow/class-ctor boundaries, nested literals, block scope (if / for-of / try), IIFE, assignment expressions without declarator. - test/integration/object-literal-owner-resolution.test.ts (9 cases) — drives the full pipeline against an on-disk fixture: sequential CALLS edge emission (issue #1358 proof), worker-mode parity, negative local binding, and nested-literal attribution boundary. Full sweep: 2958/2958 integration + 6056/6056 unit tests pass.
This commit is contained in:
parent
3960648914
commit
e9531ceede
8 changed files with 635 additions and 20 deletions
|
|
@ -790,7 +790,7 @@ const processParsingSequential = async (
|
|||
returnType: methodProps.returnType as string | undefined,
|
||||
declaredType,
|
||||
templateArguments: classTemplateArguments,
|
||||
ownerId: enclosingClassId ?? undefined,
|
||||
ownerId: enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? undefined,
|
||||
qualifiedName: qualifiedTypeName,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,12 @@ export function isLinkableLabel(label: NodeLabel): boolean {
|
|||
// ACCESSES edges target field nodes (e.g. `user.name = "x"` →
|
||||
// ACCESSES edge to User's `name` Variable/Property node).
|
||||
label === 'Variable' ||
|
||||
label === 'Property'
|
||||
label === 'Property' ||
|
||||
// Const is linkable so the value-receiver-owner bridge in
|
||||
// `receiver-bound-calls.ts` Case 5 can translate the scope-resolution
|
||||
// `Variable` def for `export const fooService = {...}` to the canonical
|
||||
// `Const:filePath:name` graph node id, against which object-literal
|
||||
// method symbols register their `ownerId` (PR #1718 / issue #1358).
|
||||
label === 'Const'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@
|
|||
* but not a namespace prefix → compound resolver
|
||||
* 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot →
|
||||
* MRO walk + `findOwnedMember`
|
||||
* 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable`
|
||||
* whose `nodeId` is referenced as an `ownerId` in `model.methods`
|
||||
* (object-literal services). Last-resort fallback for lowercase
|
||||
* receivers with no class-like or type-binding match. Mirrors
|
||||
* the legacy DAG bridge in `call-processor.ts`.
|
||||
*
|
||||
* Reordering or merging cases changes resolution semantics.
|
||||
*
|
||||
|
|
@ -46,11 +51,12 @@ import {
|
|||
findExportedDef,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
findValueBindingInScope,
|
||||
isClassLike,
|
||||
} from '../scope/walkers.js';
|
||||
import { tryEmitEdge } from '../graph-bridge/edges.js';
|
||||
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
|
||||
import { resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import {
|
||||
narrowOverloadCandidates,
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
|
|
@ -706,6 +712,64 @@ export function emitReceiverBoundCalls(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 5: value-receiver bridge (object-literal services) ──
|
||||
// When prior cases couldn't resolve the receiver as a class or
|
||||
// type binding, fall back to value-binding resolution. Covers:
|
||||
//
|
||||
// export const fooService = { getUser(id) {...} };
|
||||
// import { fooService } from './service';
|
||||
// fooService.getUser(id); // ← resolve here
|
||||
//
|
||||
// `fooService` is a `Const`/`Variable` (not class-like, no typeBinding
|
||||
// for unannotated literals), so Cases 2-4 skip it. Scope-resolution
|
||||
// defs for non-class values carry a synthetic id, so we translate to
|
||||
// the canonical graph node ID via `resolveDefGraphId` before owner-
|
||||
// indexed lookup — the parser writes the graph node ID as `ownerId`
|
||||
// on the method symbol-table entry to match.
|
||||
//
|
||||
// Object-literal methods do not carry a `qualifiedName` (no class
|
||||
// owner to seed it), so the picked def cannot round-trip through
|
||||
// `tryEmitEdge` → `resolveDefGraphId`. We emit directly using
|
||||
// `picked.nodeId` (already the canonical graph node id, written by
|
||||
// the legacy parse phase).
|
||||
const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes);
|
||||
if (valueDef !== undefined) {
|
||||
const ownerGraphId =
|
||||
resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId;
|
||||
const picked = pickOverload(ownerGraphId, memberName, site, model, provider);
|
||||
if (picked === OVERLOAD_AMBIGUOUS) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
if (picked !== undefined) {
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
|
||||
if (callerGraphId !== undefined) {
|
||||
const reason =
|
||||
site.kind === 'write' || site.kind === 'read'
|
||||
? site.kind
|
||||
: picked.filePath !== parsed.filePath
|
||||
? 'import-resolved'
|
||||
: 'global';
|
||||
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
|
||||
const dedupKey = `CALLS:${callerGraphId}->${picked.nodeId}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
if (!seen.has(dedupKey)) {
|
||||
seen.add(dedupKey);
|
||||
graph.addRelationship({
|
||||
id: `rel:${dedupKey}`,
|
||||
sourceId: callerGraphId,
|
||||
targetId: picked.nodeId,
|
||||
type: 'CALLS',
|
||||
confidence,
|
||||
reason,
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,55 @@ export function findClassBindingInScope(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a value-binding (non-class-like, non-callable) by name in
|
||||
* the given scope's chain. Used by the value-receiver-owner bridge
|
||||
* for object-literal services such as:
|
||||
*
|
||||
* export const fooService = { getUser(id) {...} };
|
||||
*
|
||||
* where `fooService` is a `Const`/`Variable` whose `nodeId` is the
|
||||
* `ownerId` of the member method but where neither `findClassBindingInScope`
|
||||
* (rejects non-class-like) nor `findReceiverTypeBinding` (no typeBinding for
|
||||
* an unannotated literal) finds it. Returns the first non-class-like,
|
||||
* non-callable binding match.
|
||||
*
|
||||
* Mirrors `findClassBindingInScope` exactly; only the accepted def-type
|
||||
* predicate differs.
|
||||
*/
|
||||
export function findValueBindingInScope(
|
||||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
const isValueLike = (t: string): boolean =>
|
||||
!isClassLike(t) && t !== 'Function' && t !== 'Method' && t !== 'Constructor';
|
||||
|
||||
const localBindings = scope.bindings.get(receiverName);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
if (isValueLike(b.def.type)) return b.def;
|
||||
}
|
||||
}
|
||||
|
||||
const importedBindings = lookupBindingsAt(currentId, receiverName, scopes);
|
||||
for (const b of importedBindings) {
|
||||
if (isValueLike(b.def.type)) return b.def;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a callable (Function/Method/Constructor) by name in the
|
||||
* given scope's chain. Uses the dual-source pattern (scope.bindings +
|
||||
|
|
|
|||
|
|
@ -414,9 +414,32 @@ export const findEnclosingClassInfo = (
|
|||
/** Object literal binding info for TS/JS shorthand methods. */
|
||||
export interface ObjectLiteralBindingInfo {
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-statement AST types that disqualify an object-literal binding from
|
||||
* carrying a HAS_METHOD edge. A `const` declared inside one of these is block-
|
||||
* scoped and cannot be imported, so attributing methods to it would create
|
||||
* false-positive cross-file edges.
|
||||
*/
|
||||
const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([
|
||||
'statement_block',
|
||||
'if_statement',
|
||||
'else_clause',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'for_of_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'try_statement',
|
||||
'catch_clause',
|
||||
'finally_clause',
|
||||
'switch_statement',
|
||||
'switch_case',
|
||||
'switch_default',
|
||||
'with_statement',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the file-scope variable that owns an object literal method definition.
|
||||
*
|
||||
|
|
@ -428,41 +451,81 @@ export interface ObjectLiteralBindingInfo {
|
|||
* not inside a class container. Without this fallback, ingestion emits a
|
||||
* top-level `Method` node but no edge from the exported `service` value to
|
||||
* that method, so impact queries cannot discover `service.load`.
|
||||
*
|
||||
* Two-phase walk:
|
||||
* Phase A walks up from `node` tracking how many `object` ancestors we
|
||||
* cross. The first `variable_declarator` reached with `objectDepth >= 1`
|
||||
* is the candidate owner — unless `objectDepth > 1` (the method belongs
|
||||
* to a nested object literal; we return null rather than misattribute
|
||||
* to the outer binding). Hitting a function/class container before the
|
||||
* declarator returns null (catches IIFE-wrapped literals).
|
||||
* Phase B walks the declarator's own ancestors. Any function or class
|
||||
* ancestor before reaching `program`/`export_statement` returns null
|
||||
* (catches `const` declared inside a function body). Any block-statement
|
||||
* ancestor also returns null (catches block-scoped declarations inside
|
||||
* top-level `if`/`for`/`try`/etc., which cannot be imported).
|
||||
*/
|
||||
export const findObjectLiteralBindingInfo = (
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): ObjectLiteralBindingInfo | null => {
|
||||
// ── Phase A: walk up from node, count `object` ancestors, find declarator
|
||||
let current: SyntaxNode | null = node;
|
||||
let sawObjectLiteral = false;
|
||||
let objectDepth = 0;
|
||||
let declarator: SyntaxNode | null = null;
|
||||
|
||||
while (current) {
|
||||
if (current.type === 'object') sawObjectLiteral = true;
|
||||
|
||||
if (sawObjectLiteral && current.type === 'variable_declarator') {
|
||||
const nameNode = current.childForFieldName?.('name');
|
||||
if (!nameNode || nameNode.type !== 'identifier') return null;
|
||||
|
||||
const declaration = current.parent;
|
||||
const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const';
|
||||
return {
|
||||
ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`),
|
||||
ownerName: nameNode.text,
|
||||
};
|
||||
if (current.type === 'object') {
|
||||
objectDepth += 1;
|
||||
}
|
||||
|
||||
if (current.type === 'variable_declarator' && objectDepth >= 1) {
|
||||
if (objectDepth > 1) {
|
||||
// Method belongs to a nested object literal; safe under-approximation.
|
||||
return null;
|
||||
}
|
||||
declarator = current;
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop at a function/class boundary before finding an owning object literal.
|
||||
if (
|
||||
current !== node &&
|
||||
(FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type))
|
||||
) {
|
||||
// Function/class container encountered before owning declarator
|
||||
// (e.g. IIFE-wrapped object literal). Bail out.
|
||||
return null;
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (!declarator) return null;
|
||||
|
||||
// ── Phase B: declarator must live at file scope (program / export_statement)
|
||||
// with no function, class, or block-statement ancestor in between.
|
||||
let anc: SyntaxNode | null = declarator.parent;
|
||||
while (anc) {
|
||||
if (anc.type === 'program' || anc.type === 'export_statement') {
|
||||
break;
|
||||
}
|
||||
if (FUNCTION_NODE_TYPES.has(anc.type) || CLASS_CONTAINER_TYPES.has(anc.type)) {
|
||||
return null;
|
||||
}
|
||||
if (BLOCK_SCOPE_BOUNDARY_TYPES.has(anc.type)) {
|
||||
return null;
|
||||
}
|
||||
anc = anc.parent;
|
||||
}
|
||||
|
||||
const nameNode = declarator.childForFieldName?.('name');
|
||||
if (!nameNode || nameNode.type !== 'identifier') return null;
|
||||
|
||||
const declaration = declarator.parent;
|
||||
const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const';
|
||||
return {
|
||||
ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`),
|
||||
};
|
||||
};
|
||||
|
||||
/** Convenience wrapper: returns just the class ID string (backward compat). */
|
||||
|
|
|
|||
|
|
@ -2327,7 +2327,9 @@ const processFileGroup = (
|
|||
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
|
||||
? { templateArguments: classTemplateArguments }
|
||||
: {}),
|
||||
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
|
||||
...((enclosingClassId ?? objectLiteralOwnerInfo?.ownerId)
|
||||
? { ownerId: (enclosingClassId ?? objectLiteralOwnerInfo?.ownerId) as string }
|
||||
: {}),
|
||||
visibility: methodProps.visibility as string | undefined,
|
||||
isStatic: methodProps.isStatic as boolean | undefined,
|
||||
isReadonly: methodProps.isReadonly as boolean | undefined,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Integration tests for `findObjectLiteralBindingInfo`.
|
||||
*
|
||||
* Drives the helper against real tree-sitter ASTs (TypeScript) and pins the
|
||||
* Phase A / Phase B boundary semantics from the PR #1718 production-readiness
|
||||
* review (U1):
|
||||
* - happy path: file-scope export const / const / export var → returns binding
|
||||
* - local-inside-function / arrow / class-constructor → null
|
||||
* - nested object literal → null (safe under-approximation)
|
||||
* - block-scoped declaration (if / for body) → null
|
||||
* - IIFE-wrapped object literal → null
|
||||
* - assignment without declarator → null (no throw)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
import { findObjectLiteralBindingInfo } from '../../src/core/ingestion/utils/ast-helpers.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
|
||||
let parser: Parser;
|
||||
|
||||
beforeAll(async () => {
|
||||
parser = await loadParser();
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'fixture.ts');
|
||||
});
|
||||
|
||||
/** Locate every method_definition AST node by name. */
|
||||
function findMethodNodes(root: Parser.SyntaxNode, methodName: string): Parser.SyntaxNode[] {
|
||||
const out: Parser.SyntaxNode[] = [];
|
||||
const visit = (node: Parser.SyntaxNode) => {
|
||||
if (node.type === 'method_definition') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name?.text === methodName) out.push(node);
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) visit(child);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseTs(code: string): Parser.Tree {
|
||||
return parser.parse(code);
|
||||
}
|
||||
|
||||
describe('findObjectLiteralBindingInfo — happy paths', () => {
|
||||
it('exported const + shorthand method → owner binding', () => {
|
||||
const tree = parseTs(`export const fooService = { async getUser(id: string) { return id; } };`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'getUser');
|
||||
expect(methodNode).toBeDefined();
|
||||
const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts');
|
||||
expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') });
|
||||
});
|
||||
|
||||
it('bare file-scope const → owner binding', () => {
|
||||
const tree = parseTs(`const fooService = { getUser(id: string) { return id; } };`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'getUser');
|
||||
const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts');
|
||||
expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') });
|
||||
});
|
||||
|
||||
it('exported var (variable_declaration) → Variable label', () => {
|
||||
const tree = parseTs(`export var legacyService = { run() {} };`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
const result = findObjectLiteralBindingInfo(methodNode, 'src/legacy.ts');
|
||||
expect(result).toEqual({ ownerId: generateId('Variable', 'src/legacy.ts:legacyService') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findObjectLiteralBindingInfo — negative: container boundaries', () => {
|
||||
it('local const inside exported function → null', () => {
|
||||
const tree = parseTs(`
|
||||
export function processAll() {
|
||||
const handler = { run(x: string) { return x; } };
|
||||
return handler;
|
||||
}
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null);
|
||||
});
|
||||
|
||||
it('local const inside exported arrow function → null', () => {
|
||||
const tree = parseTs(`
|
||||
export const make = () => {
|
||||
const h = { run() {} };
|
||||
return h;
|
||||
};
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null);
|
||||
});
|
||||
|
||||
it('local const inside class constructor → null', () => {
|
||||
const tree = parseTs(`
|
||||
export class C {
|
||||
constructor() {
|
||||
const h = { run() {} };
|
||||
void h;
|
||||
}
|
||||
}
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/c.ts')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findObjectLiteralBindingInfo — negative: nested literals', () => {
|
||||
it('inner method of nested literal → null (safe under-approximation)', () => {
|
||||
const tree = parseTs(`export const s = { nested: { method() {} } };`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'method');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/s.ts')).toBe(null);
|
||||
});
|
||||
|
||||
it('top-level method alongside nested literal still binds to outer', () => {
|
||||
const tree = parseTs(`export const s = { nested: { inner() {} }, outer() {} };`);
|
||||
const [outerNode] = findMethodNodes(tree.rootNode, 'outer');
|
||||
expect(findObjectLiteralBindingInfo(outerNode, 'src/s.ts')).toEqual({
|
||||
ownerId: generateId('Const', 'src/s.ts:s'),
|
||||
});
|
||||
const [innerNode] = findMethodNodes(tree.rootNode, 'inner');
|
||||
expect(findObjectLiteralBindingInfo(innerNode, 'src/s.ts')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findObjectLiteralBindingInfo — negative: block scope', () => {
|
||||
it('declared inside top-level if-block → null', () => {
|
||||
const tree = parseTs(`
|
||||
const cond = true;
|
||||
if (cond) {
|
||||
const handler = { run() {} };
|
||||
void handler;
|
||||
}
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null);
|
||||
});
|
||||
|
||||
it('declared inside for-of body → null', () => {
|
||||
const tree = parseTs(`
|
||||
const arr = [1, 2];
|
||||
for (const _i of arr) {
|
||||
const h = { run() {} };
|
||||
void h;
|
||||
}
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null);
|
||||
});
|
||||
|
||||
it('declared inside try-block → null', () => {
|
||||
const tree = parseTs(`
|
||||
try {
|
||||
const h = { run() {} };
|
||||
void h;
|
||||
} catch {}
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'run');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findObjectLiteralBindingInfo — negative: IIFE and assignment', () => {
|
||||
it('IIFE-wrapped object literal → null', () => {
|
||||
const tree = parseTs(`export const x = (() => ({ m() {} }))();`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'm');
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/x.ts')).toBe(null);
|
||||
});
|
||||
|
||||
it('assignment expression (no variable_declarator) → null without throwing', () => {
|
||||
const tree = parseTs(`
|
||||
let y: any;
|
||||
y = { m() {} };
|
||||
`);
|
||||
const [methodNode] = findMethodNodes(tree.rootNode, 'm');
|
||||
expect(() => findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).not.toThrow();
|
||||
expect(findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Integration tests for PR #1718 production-readiness review (U4).
|
||||
*
|
||||
* Proves the bug fix for issue #1358 end-to-end:
|
||||
*
|
||||
* export const fooService = { getUser(id: string) { return id; } };
|
||||
* // consumer.ts
|
||||
* import { fooService } from './service';
|
||||
* export function caller(id: string) { return fooService.getUser(id); }
|
||||
*
|
||||
* After this PR, the full ingestion pipeline must emit:
|
||||
* - `Const:fooService` ── HAS_METHOD ─► `Method:getUser`
|
||||
* - `Function:caller` ── CALLS ─► `Method:getUser`
|
||||
*
|
||||
* The CALLS edge is the canonical proof: `gitnexus_impact` upstream traversal
|
||||
* is a graph walk over CALLS, so if the edge exists, impact returns the
|
||||
* caller. Asserting the edge directly avoids wiring an entire `withTestLbugDB`
|
||||
* fixture for what is effectively a graph-shape assertion.
|
||||
*
|
||||
* Test set:
|
||||
* - Test A: sequential pipeline produces both edges with the right `ownerId`
|
||||
* - Test B: worker-mode pipeline produces identical edge sets (skipped when
|
||||
* `dist/parse-worker.js` is missing; CI builds it before running tests)
|
||||
* - Test C: local-scoped object literal inside a function emits no false-
|
||||
* positive HAS_METHOD (proves U1 boundary guard is load-bearing)
|
||||
* - Test D: nested object literal binds neither method to outer (safe
|
||||
* under-approximation proof)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './resolvers/helpers.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
|
||||
const DIST_WORKER = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'dist',
|
||||
'core',
|
||||
'ingestion',
|
||||
'workers',
|
||||
'parse-worker.js',
|
||||
);
|
||||
const hasDistWorker = fs.existsSync(DIST_WORKER);
|
||||
|
||||
/** Materialise a tiny fixture repo on disk. Returns the absolute repo root. */
|
||||
function writeFixture(files: Record<string, string>): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-'));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function removeFixture(root: string): void {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const SERVICE_TS = `export const fooService = {
|
||||
getUser(id: string) { return id; },
|
||||
saveUser(id: string) { return id; },
|
||||
};
|
||||
`;
|
||||
|
||||
const CONSUMER_TS = `import { fooService } from './service';
|
||||
|
||||
export function caller(id: string) {
|
||||
return fooService.getUser(id);
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Test A: sequential pipeline ──────────────────────────────────────────────
|
||||
|
||||
describe('object-literal owner resolution — sequential pipeline (PR #1718)', () => {
|
||||
let repoRoot: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoRoot = writeFixture({
|
||||
'src/service.ts': SERVICE_TS,
|
||||
'src/consumer.ts': CONSUMER_TS,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoRoot, () => undefined, {
|
||||
skipGraphPhases: true,
|
||||
skipWorkers: true,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => removeFixture(repoRoot));
|
||||
|
||||
it('emits Const:fooService, Method:getUser, Function:caller exactly once', () => {
|
||||
expect(getNodesByLabel(result, 'Const').filter((n) => n === 'fooService').length).toBe(1);
|
||||
expect(getNodesByLabel(result, 'Method').filter((n) => n === 'getUser').length).toBe(1);
|
||||
expect(getNodesByLabel(result, 'Function').filter((n) => n === 'caller').length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits exactly the expected HAS_METHOD edges from fooService', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const fromFoo = hasMethod
|
||||
.filter((e) => e.source === 'fooService')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(fromFoo).toEqual(['getUser', 'saveUser']);
|
||||
});
|
||||
|
||||
it('the fooService Const node uses the expected graph node ID', () => {
|
||||
const expectedNodeId = generateId('Const', 'src/service.ts:fooService');
|
||||
let fooServiceNode: { id: string; label: string } | undefined;
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Const' && n.properties.name === 'fooService') {
|
||||
fooServiceNode = { id: n.id, label: n.label };
|
||||
}
|
||||
});
|
||||
expect(fooServiceNode).toBeDefined();
|
||||
expect(fooServiceNode!.id).toBe(expectedNodeId);
|
||||
});
|
||||
|
||||
it('emits a CALLS edge from caller to getUser (issue #1358 fix)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const callerToGetUser = calls.filter((e) => e.source === 'caller' && e.target === 'getUser');
|
||||
expect(callerToGetUser.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test B: worker-mode parity ───────────────────────────────────────────────
|
||||
|
||||
describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker parity', () => {
|
||||
let repoRoot: string;
|
||||
let sequentialResult: PipelineResult;
|
||||
let workerResult: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoRoot = writeFixture({
|
||||
'src/service.ts': SERVICE_TS,
|
||||
'src/consumer.ts': CONSUMER_TS,
|
||||
});
|
||||
sequentialResult = await runPipelineFromRepo(repoRoot, () => undefined, {
|
||||
skipGraphPhases: true,
|
||||
skipWorkers: true,
|
||||
});
|
||||
workerResult = await runPipelineFromRepo(repoRoot, () => undefined, {
|
||||
skipGraphPhases: true,
|
||||
skipWorkers: false,
|
||||
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
|
||||
});
|
||||
}, 90000);
|
||||
|
||||
afterAll(() => removeFixture(repoRoot));
|
||||
|
||||
it('produces the same HAS_METHOD edge set as sequential', () => {
|
||||
const seqEdges = getRelationships(sequentialResult, 'HAS_METHOD')
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort();
|
||||
const workerEdges = getRelationships(workerResult, 'HAS_METHOD')
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort();
|
||||
expect(workerEdges).toEqual(seqEdges);
|
||||
});
|
||||
|
||||
it('produces the same CALLS edge set as sequential', () => {
|
||||
const seqEdges = getRelationships(sequentialResult, 'CALLS')
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort();
|
||||
const workerEdges = getRelationships(workerResult, 'CALLS')
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort();
|
||||
expect(workerEdges).toEqual(seqEdges);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test C: negative — local object literal inside a function body ──────────
|
||||
|
||||
describe('object-literal owner resolution — negative (local literal)', () => {
|
||||
let repoRoot: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoRoot = writeFixture({
|
||||
'src/p.ts': `export function processAll() {
|
||||
const handler = { run(id: string) { return id; } };
|
||||
return handler;
|
||||
}
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoRoot, () => undefined, {
|
||||
skipGraphPhases: true,
|
||||
skipWorkers: true,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => removeFixture(repoRoot));
|
||||
|
||||
it('emits no HAS_METHOD edge targeting `run` (no false-positive owner attribution)', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const targetingRun = hasMethod.filter((e) => e.target === 'run');
|
||||
expect(targetingRun.length).toBe(0);
|
||||
});
|
||||
|
||||
it('the run method node carries no ownerId property', () => {
|
||||
let runNode: { properties: { name: string; ownerId?: string }; label: string } | undefined;
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Method' && n.properties.name === 'run') {
|
||||
runNode = n as typeof runNode;
|
||||
}
|
||||
});
|
||||
expect(runNode).toBeDefined();
|
||||
expect(runNode!.properties.ownerId).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test D: negative — nested object literal ─────────────────────────────────
|
||||
|
||||
describe('object-literal owner resolution — negative (nested literal)', () => {
|
||||
let repoRoot: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoRoot = writeFixture({
|
||||
'src/n.ts': `export const s = {
|
||||
nested: { method(id: string) { return id; } },
|
||||
outer(id: string) { return id; },
|
||||
};
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoRoot, () => undefined, {
|
||||
skipGraphPhases: true,
|
||||
skipWorkers: true,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => removeFixture(repoRoot));
|
||||
|
||||
it('binds the top-level outer method to s but does NOT bind the nested method', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const fromS = hasMethod
|
||||
.filter((e) => e.source === 's')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(fromS).toEqual(['outer']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue