Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions __tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,4 +756,61 @@ describe('Sync Module', () => {
expect(callerCount('callee_two')).toBe(1);
});
});

describe('Import-aware value references survive target sync', () => {
let testDir: string;
let cg: CodeGraph;

const readers = (): string[] => {
const target = cg.searchNodes('sharedProvider')
.map((result) => result.node)
.find((node) => node.name === 'sharedProvider');
if (!target) return [];
return cg.getIncomingEdges(target.id)
.filter((edge) => edge.kind === 'references' && edge.metadata?.valueRef === true)
.map((edge) => cg.getNode(edge.source)?.name)
.filter((name): name is string => !!name);
};

beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-value-ref-sync-'));
fs.mkdirSync(path.join(testDir, 'lib', 'providers'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'lib', 'pages'), { recursive: true });
fs.writeFileSync(
path.join(testDir, 'lib', 'providers', 'shared.dart'),
'final sharedProvider = Object();\n'
);
fs.writeFileSync(
path.join(testDir, 'lib', 'pages', 'consumer.dart'),
"import '../providers/shared.dart';\nvoid usesShared() { print(sharedProvider); }\n"
);
cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.dart'], exclude: [] },
});
await cg.indexAll();
});

afterEach(() => {
cg?.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});

it('drops and restores the edge when the imported value disappears and returns', async () => {
expect(readers()).toContain('usesShared');

fs.writeFileSync(
path.join(testDir, 'lib', 'providers', 'shared.dart'),
'final renamedProvider = Object();\n'
);
await cg.sync();
expect(readers()).toEqual([]);

fs.writeFileSync(
path.join(testDir, 'lib', 'providers', 'shared.dart'),
'final sharedProvider = Object();\n'
);
await cg.sync();
expect(readers()).toContain('usesShared');
});
});
});
102 changes: 102 additions & 0 deletions __tests__/value-reference-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,41 @@ describe('value-reference edges', () => {
expect(valueRefReaders(cg, 'DefaultLabels')).toEqual(expect.arrayContaining(['labels']));
});

it('edges Go readers to an imported package value without crossing a local shadow', async () => {
fs.mkdirSync(path.join(dir, 'internal', 'realtime'), { recursive: true });
fs.mkdirSync(path.join(dir, 'p2p'), { recursive: true });
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\ngo 1.24\n');
fs.writeFileSync(
path.join(dir, 'internal', 'realtime', 'store.go'),
[
'package realtime',
'',
'type Store struct{}',
'var DefaultSessionStore = &Store{}',
].join('\n'),
);
fs.writeFileSync(
path.join(dir, 'p2p', 'service.go'),
[
'package p2p',
'',
'import "example.com/app/internal/realtime"',
'',
'type holder struct { DefaultSessionStore *realtime.Store }',
'func usesDefault() *realtime.Store { return realtime.DefaultSessionStore }',
'func shadowed(realtime holder) *realtime.Store { return realtime.DefaultSessionStore }',
'func shadowedLocal() *realtime.Store { realtime := holder{}; return realtime.DefaultSessionStore }',
].join('\n'),
);
cg = index();
await cg.indexAll();

const readers = valueRefReaders(cg, 'DefaultSessionStore');
expect(readers).toContain('usesDefault');
expect(readers).not.toContain('shadowed');
expect(readers).not.toContain('shadowedLocal');
});

it('does NOT edge a Go package const shadowed by a local := of the same name', async () => {
// `Timeout` is a package const AND a local `:=` (short_var_declaration) in
// shadows(). The local read resolves to the inner binding, so a file-scope
Expand Down Expand Up @@ -628,6 +663,73 @@ describe('value-reference edges', () => {
expect(valueRefReaders(cg, 'instanceField')).toEqual([]);
});

it('edges Dart readers to a uniquely imported top-level final without crossing a parameter shadow', async () => {
fs.mkdirSync(path.join(dir, 'lib', 'providers'), { recursive: true });
fs.mkdirSync(path.join(dir, 'lib', 'pages'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'providers', 'presence.dart'),
'final agentBridgePresenceProvider = Object();\n',
);
fs.writeFileSync(
path.join(dir, 'lib', 'pages', 'settings.dart'),
[
"import '../providers/presence.dart';",
'',
'void usesProvider() { print(agentBridgePresenceProvider); }',
'void shadowed(Object agentBridgePresenceProvider) { print(agentBridgePresenceProvider); }',
'void shadowedLocal() { final agentBridgePresenceProvider = Object(); print(agentBridgePresenceProvider); }',
].join('\n'),
);
fs.writeFileSync(
path.join(dir, 'lib', 'pages', 'prefixed.dart'),
[
"import '../providers/presence.dart' as presence;",
'void usesPrefixed() { print(presence.agentBridgePresenceProvider); }',
].join('\n'),
);
cg = index();
await cg.indexAll();

const readers = valueRefReaders(cg, 'agentBridgePresenceProvider');
expect(readers).toContain('usesProvider');
expect(readers).toContain('usesPrefixed');
expect(readers).not.toContain('shadowed');
expect(readers).not.toContain('shadowedLocal');
});

it('resolves Dart value readers through this package URI but not another package', async () => {
fs.mkdirSync(path.join(dir, 'lib', 'providers'), { recursive: true });
fs.mkdirSync(path.join(dir, 'test'), { recursive: true });
fs.writeFileSync(path.join(dir, 'pubspec.yaml'), 'name: example_app\n');
fs.writeFileSync(
path.join(dir, 'lib', 'providers', 'presence.dart'),
'final agentBridgePresenceProvider = Object();\n',
);
fs.writeFileSync(
path.join(dir, 'test', 'presence_test.dart'),
[
"import 'package:example_app/providers/presence.dart';",
'void readsOwnPackage() { print(agentBridgePresenceProvider); }',
].join('\n'),
);
fs.writeFileSync(
path.join(dir, 'test', 'external_test.dart'),
[
'/*',
"import 'package:example_app/providers/presence.dart';",
'*/',
"import 'package:another_app/providers/presence.dart';",
'void readsExternalPackage() { print(agentBridgePresenceProvider); }',
].join('\n'),
);
cg = index();
await cg.indexAll();

const readers = valueRefReaders(cg, 'agentBridgePresenceProvider');
expect(readers).toContain('readsOwnPackage');
expect(readers).not.toContain('readsExternalPackage');
});

it('does NOT edge a Dart const shadowed by a method-local const of the same name', async () => {
fs.writeFileSync(
path.join(dir, 'shadow.dart'),
Expand Down
100 changes: 98 additions & 2 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,8 @@ export class TreeSitterExtractor {
this.fileScopeValues = new Map();
this.fileScopeValueCounts = new Map();
if (!this.valueRefsEnabled || !TreeSitterExtractor.VALUE_REF_LANGS.has(this.language)) return;
if (targets.size === 0 || scopes.length === 0 || isGeneratedFile(this.filePath)) return;
const crossFileEnabled = this.language === 'go' || this.language === 'dart';
if ((targets.size === 0 && !crossFileEnabled) || scopes.length === 0 || isGeneratedFile(this.filePath)) return;

// Prune SHADOWED targets. A target re-bound in an INNER scope (a
// bundled/Emscripten `const Module` re-declared as a nested `var Module`; a
Expand Down Expand Up @@ -838,11 +839,16 @@ export class TreeSitterExtractor {
}
}
for (const [nm, c] of declCounts) if (c > (fileScopeCounts.get(nm) ?? 1)) targets.delete(nm);
if (targets.size === 0) return;
if (targets.size === 0 && !crossFileEnabled) return;
}

for (const scope of scopes) {
const seen = new Set<string>();
const localBindings = new Set<string>([scope.name]);
const crossFileCandidates = new Map<
string,
{ referenceName: string; shadowName: string; line: number; column: number }
>();
const stack: SyntaxNode[] = [scope.node];
// Dart and Pascal attach a function/method BODY as a *next sibling* of the
// signature node that is stored as the reader scope (Dart `method_signature`
Expand All @@ -858,6 +864,85 @@ export class TreeSitterExtractor {
while (stack.length > 0 && visited < TreeSitterExtractor.MAX_VALUE_REF_NODES) {
const n = stack.pop()!;
visited++;

if (this.language === 'go') {
// A package-qualified value read is precise only while the receiver
// still names the imported package. Any local declaration with the
// same name shadows that import for the reader scope, so remember it
// and conservatively drop the candidate after the walk.
if (n.type === 'parameter_declaration' || n.type === 'var_spec') {
for (const child of n.namedChildren) {
if (child.type === 'identifier') localBindings.add(getNodeText(child, this.source));
}
} else if (n.type === 'short_var_declaration' || n.type === 'range_clause') {
const left = getChildByField(n, 'left') ?? n.namedChild(0);
if (left?.type === 'identifier') localBindings.add(getNodeText(left, this.source));
else if (left) {
for (const child of left.namedChildren) {
if (child.type === 'identifier') localBindings.add(getNodeText(child, this.source));
}
}
} else if (n.type === 'selector_expression') {
const operand = getChildByField(n, 'operand');
const field = getChildByField(n, 'field');
if (operand?.type === 'identifier' && field?.type === 'field_identifier') {
const receiver = getNodeText(operand, this.source);
const member = getNodeText(field, this.source);
// Exported package values start uppercase. Calls are extracted by
// the normal call path and must not be duplicated as value refs.
const isCallTarget = n.parent?.type === 'call_expression' &&
getChildByField(n.parent, 'function')?.id === n.id;
if (/^[A-Z]/.test(member) && !isCallTarget) {
const referenceName = `${receiver}.${member}`;
crossFileCandidates.set(referenceName, {
referenceName,
shadowName: receiver,
line: field.startPosition.row + 1,
column: field.startPosition.column,
});
}
}
}
} else if (this.language === 'dart') {
if (
n.type === 'formal_parameter' ||
n.type === 'initialized_identifier' ||
n.type === 'initialized_variable_definition'
) {
const id = getChildByField(n, 'name') ??
n.namedChildren.find((child) => child.type === 'identifier');
if (id) localBindings.add(getNodeText(id, this.source));
}
if (n.type === 'identifier') {
let referenceName = getNodeText(n, this.source);
let shadowName = referenceName;
const accessor = n.parent;
const selector = accessor?.parent;
const receiver = selector?.previousNamedSibling;
if (
(accessor?.type === 'unconditional_assignable_selector' ||
accessor?.type === 'conditional_assignable_selector') &&
selector?.type === 'selector' &&
receiver?.type === 'identifier'
) {
shadowName = getNodeText(receiver, this.source);
referenceName = `${shadowName}.${referenceName}`;
}
if (
referenceName.length >= 3 &&
/[A-Z_]/.test(referenceName) &&
!targets.has(referenceName)
) {
crossFileCandidates.set(referenceName, {
referenceName,
shadowName,
line: n.startPosition.row + 1,
column: n.startPosition.column,
});
}
}
}

// `constant` covers Ruby, where both a constant's definition and its
// references are `constant`-typed nodes, not `identifier`. `name` covers
// PHP, where a constant reference — bare `MAX_ITEMS` or the const half of
Expand Down Expand Up @@ -891,6 +976,17 @@ export class TreeSitterExtractor {
if (c) stack.push(c);
}
}

for (const candidate of crossFileCandidates.values()) {
if (localBindings.has(candidate.shadowName)) continue;
this.unresolvedReferences.push({
fromNodeId: scope.id,
referenceName: candidate.referenceName,
referenceKind: 'value_ref',
line: candidate.line,
column: candidate.column,
});
}
}
}

Expand Down
Loading