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:
Gergo Magyar 2026-05-21 08:02:06 +01:00
parent 3960648914
commit e9531ceede
8 changed files with 635 additions and 20 deletions

View file

@ -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,
});

View file

@ -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'
);
}

View file

@ -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;
}
}
}
}
}

View file

@ -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 +

View file

@ -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). */

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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']);
});
});