| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- /**
- * Value-reference edges (TS/JS): same-file `references` edges from a reader
- * symbol to the file-scope const/var it reads, so impact analysis catches
- * "change this constant, affect its readers". Default on; CODEGRAPH_VALUE_REFS=0
- * disables. See TreeSitterExtractor.flushValueRefs.
- */
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
- import * as fs from 'fs';
- import * as path from 'path';
- import * as os from 'os';
- import CodeGraph from '../src';
- function valueRefReaders(cg: CodeGraph, constName: string): string[] {
- // Aggregate across ALL nodes of this name — a conditionally-defined module
- // const (`try: X=…; except: X=…`) has more than one, and the edge targets
- // whichever one ended up in the target map.
- const targets = cg.searchNodes(constName).map((r) => r.node).filter((n) => n.name === constName);
- const readers = new Set<string>();
- for (const t of targets) {
- for (const e of cg.getIncomingEdges(t.id)) {
- if (e.kind === 'references' && (e.metadata as { valueRef?: boolean } | undefined)?.valueRef) {
- const r = cg.getNode(e.source)?.name;
- if (r) readers.add(r);
- }
- }
- }
- return [...readers];
- }
- describe('value-reference edges', () => {
- let dir: string;
- let cg: CodeGraph | undefined;
- beforeEach(() => {
- dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-valueref-'));
- });
- afterEach(() => {
- cg?.destroy();
- cg = undefined;
- fs.rmSync(dir, { recursive: true, force: true });
- });
- function index(): CodeGraph {
- const g = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.tsx'], exclude: [] } });
- return g;
- }
- it('edges same-file readers to the file-scope const they read (default on)', async () => {
- fs.writeFileSync(
- path.join(dir, 'config.ts'),
- [
- 'export const TABLE_CONFIG = { rows: 10, cols: 4 };',
- 'export function rowCount() { return TABLE_CONFIG.rows; }',
- 'export function describeTable() { return `${TABLE_CONFIG.rows}x${TABLE_CONFIG.cols}`; }',
- 'export const HEADER = TABLE_CONFIG.cols;',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- const readers = valueRefReaders(cg, 'TABLE_CONFIG');
- // rowCount, describeTable, and the HEADER const all read TABLE_CONFIG.
- expect(readers).toEqual(expect.arrayContaining(['rowCount', 'describeTable', 'HEADER']));
- });
- it('surfaces those readers in the impact radius of the const', async () => {
- fs.writeFileSync(
- path.join(dir, 'palette.ts'),
- [
- 'export const COLOR_PALETTE = { red: "#f00", blue: "#00f" };',
- 'export function pickRed() { return COLOR_PALETTE.red; }',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- const target = cg.searchNodes('COLOR_PALETTE').map((r) => r.node).find((n) => n.name === 'COLOR_PALETTE')!;
- const impacted = [...cg.getImpactRadius(target.id).nodes.values()].map((n) => n.name);
- expect(impacted).toContain('pickRed');
- });
- it('does NOT edge a shadowed const — inner re-declaration makes the name ambiguous', async () => {
- // The Emscripten/bundled pattern: a file-scope `const Module` re-declared as
- // an inner `var Module` / param. Nested readers resolve to the INNER binding,
- // so a file-scope edge would be a false positive. The shadow guard drops it.
- fs.writeFileSync(
- path.join(dir, 'bundled.ts'),
- [
- 'const Module = (function () {',
- ' return function (Module) {',
- ' var Module = typeof Module !== "undefined" ? Module : {};',
- ' function locate() { return Module.path; }',
- ' function getFunc() { return Module.lookup; }',
- ' return { locate, getFunc };',
- ' };',
- '})();',
- 'export default Module;',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- // No reader should be edged to the outer `const Module`.
- expect(valueRefReaders(cg, 'Module')).toEqual([]);
- });
- it('edges readers that use the const only inside JSX (.tsx)', async () => {
- // The tsx-specific path: the const is read ONLY inside JSX expressions, so
- // the reader-scan must descend into the JSX subtree to find it.
- fs.writeFileSync(
- path.join(dir, 'widget.tsx'),
- [
- 'export const THEME_TOKENS = { color: "red", size: 12 };',
- 'export function Label() {',
- ' return <span style={{ color: THEME_TOKENS.color }}>hi</span>;',
- '}',
- 'export const Box = () => <div data-size={THEME_TOKENS.size} />;',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'THEME_TOKENS')).toEqual(expect.arrayContaining(['Label', 'Box']));
- });
- it('edges same-file readers to a module-level const/static (Rust)', async () => {
- fs.writeFileSync(
- path.join(dir, 'lib.rs'),
- [
- 'const MAX_RETRIES: u32 = 3;',
- 'static DEFAULT_LABEL: &str = "prod";',
- '',
- 'fn retry() -> u32 { MAX_RETRIES }',
- "fn label() -> &'static str { DEFAULT_LABEL }",
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MAX_RETRIES')).toEqual(expect.arrayContaining(['retry']));
- expect(valueRefReaders(cg, 'DEFAULT_LABEL')).toEqual(expect.arrayContaining(['label']));
- });
- it('does NOT edge a Rust const shadowed by a local let of the same name', async () => {
- fs.writeFileSync(
- path.join(dir, 'shadow.rs'),
- [
- 'const TIMEOUT: u32 = 30;',
- '',
- 'fn uses_const() -> u32 { TIMEOUT }',
- 'fn shadows() -> u32 {',
- ' let TIMEOUT = 5;',
- ' TIMEOUT',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
- });
- it('edges same-file readers to a package-level const/var (Go)', async () => {
- fs.writeFileSync(
- path.join(dir, 'main.go'),
- [
- 'package main',
- '',
- 'const MaxRetries = 3',
- 'var DefaultLabels = map[string]string{"env": "prod"}',
- '',
- 'func retry() int { return MaxRetries }',
- 'func labels() map[string]string { return DefaultLabels }',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MaxRetries')).toEqual(expect.arrayContaining(['retry']));
- expect(valueRefReaders(cg, 'DefaultLabels')).toEqual(expect.arrayContaining(['labels']));
- });
- 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
- // edge would be a false positive — the shadow prune drops the whole target.
- fs.writeFileSync(
- path.join(dir, 'shadow.go'),
- [
- 'package main',
- '',
- 'const Timeout = 30',
- '',
- 'func usesConst() int { return Timeout }',
- 'func shadows() int {',
- '\tTimeout := 5',
- '\treturn Timeout',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'Timeout')).toEqual([]);
- });
- it('keeps a conditionally-defined module const (try/except), not a shadow (Python)', async () => {
- // `HAS_SSL` is defined twice but BOTH at module scope (a conditional def, a
- // very common Python idiom). It is one logical const, not a shadow, so its
- // reader must stay edged — and the two halves must not edge each other.
- fs.writeFileSync(
- path.join(dir, 'cond.py'),
- [
- 'try:',
- '\tHAS_SSL = True',
- 'except ImportError:',
- '\tHAS_SSL = False',
- '',
- 'def uses_ssl():',
- '\treturn HAS_SSL',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'HAS_SSL')).toEqual(['uses_ssl']);
- });
- it('edges readers to a top-level AND a class-internal constant (Ruby)', async () => {
- // Ruby keeps almost all constants inside a class/module. Both the top-level
- // `MAX_RETRIES` and the class-internal `Config::TIMEOUT` must be targets, and
- // their same-file readers edged (TIMEOUT is read by two methods of Config).
- fs.writeFileSync(
- path.join(dir, 'app.rb'),
- [
- 'MAX_RETRIES = 3',
- '',
- 'def retry_count',
- ' MAX_RETRIES',
- 'end',
- '',
- 'class Config',
- ' TIMEOUT = 30',
- ' def self.get_timeout',
- ' TIMEOUT',
- ' end',
- ' def describe',
- ' "timeout=#{TIMEOUT}"',
- ' end',
- 'end',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MAX_RETRIES')).toEqual(expect.arrayContaining(['retry_count']));
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual(expect.arrayContaining(['get_timeout', 'describe']));
- });
- it('edges same-file readers to a file-scope const/table (C)', async () => {
- // C keeps shareable values at file scope as `static const` — scalars and,
- // very commonly, pointer/array lookup tables. Both must be extracted as
- // nodes (the generic fallback misses C's nested init_declarator name) and
- // their same-file readers edged.
- fs.writeFileSync(
- path.join(dir, 'config.c'),
- [
- 'static const int MAX_ITEMS = 100;',
- 'static const char *const STATUS_NAMES[] = { "ok", "fail", "pending" };',
- '',
- 'int capped(int n) { return n > MAX_ITEMS ? MAX_ITEMS : n; }',
- 'const char *label(int i) { return STATUS_NAMES[i]; }',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MAX_ITEMS')).toEqual(expect.arrayContaining(['capped']));
- expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['label']));
- });
- it('does NOT edge a C file const shadowed by a function-local of the same name', async () => {
- // `TIMEOUT` is a file const AND a local `int TIMEOUT = 5` (init_declarator)
- // in shadows(). The local read resolves to the inner binding, so a
- // file-scope edge would be a false positive — the shadow prune drops it.
- fs.writeFileSync(
- path.join(dir, 'shadow.c'),
- [
- 'static const int TIMEOUT = 30;',
- '',
- 'int uses_const(void) { return TIMEOUT; }',
- 'int shadows(void) {',
- ' int TIMEOUT = 5;',
- ' return TIMEOUT;',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
- });
- it('does NOT mint a value target from a macro-prefixed C prototype (return-type misparse)', async () => {
- // A prototype led by an unknown macro (`CURL_EXTERN CURLcode fn(args);`)
- // makes tree-sitter-c misparse it as a declaration whose "variable" is the
- // bare return-type identifier — which would mint a spurious `CURLcode`
- // value target read by every function of that type. The bare-identifier
- // skip prevents it, while real file-scope consts still edge their readers.
- fs.writeFileSync(
- path.join(dir, 'api.c'),
- [
- 'typedef enum { CURLE_OK, CURLE_FAIL } CURLcode;',
- 'CURL_EXTERN CURLcode curl_easy_init(int x);',
- 'CURL_EXTERN CURLcode curl_easy_setopt(int y);',
- '',
- 'static const int REAL_LIMIT = 42;',
- 'int use_real(void) { return REAL_LIMIT; }',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- // The return-type name is never extracted as a const/var, so it is not a
- // value-ref target at all.
- const curlcodeValues = cg
- .searchNodes('CURLcode')
- .map((r) => r.node)
- .filter((n) => n.name === 'CURLcode' && (n.kind === 'constant' || n.kind === 'variable'));
- expect(curlcodeValues).toEqual([]);
- // Real file-scope consts alongside the misparse-prone prototypes still work.
- expect(valueRefReaders(cg, 'REAL_LIMIT')).toEqual(expect.arrayContaining(['use_real']));
- });
- it('edges same-file methods to a class-scope static final constant (Java)', async () => {
- // Java keeps constants as `static final` fields inside a class. They extract
- // as `constant` kind (not `field`) so the value-ref gate targets them; a
- // plain instance `final` field is NOT a constant and must not be a target.
- fs.writeFileSync(
- path.join(dir, 'Limits.java'),
- [
- 'class Limits {',
- ' public static final int MAX_ITEMS = 100;',
- ' static final String[] STATUS_NAMES = { "ok", "fail" };',
- ' final int instanceId = 1;',
- ' int capped(int n) { return n > MAX_ITEMS ? MAX_ITEMS : n; }',
- ' String label(int i) { return STATUS_NAMES[i]; }',
- ' int id() { return instanceId; }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MAX_ITEMS')).toEqual(expect.arrayContaining(['capped']));
- expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['label']));
- // An instance `final` field is mutable per-object state, not a shared
- // constant — it stays `field` kind and is never a value-ref target.
- expect(valueRefReaders(cg, 'instanceId')).toEqual([]);
- });
- it('does NOT edge a Java class const shadowed by a method-local of the same name', async () => {
- fs.writeFileSync(
- path.join(dir, 'Shadow.java'),
- [
- 'class Shadow {',
- ' static final int TIMEOUT = 30;',
- ' int usesConst() { return TIMEOUT; }',
- ' int shadows() { int TIMEOUT = 5; return TIMEOUT; }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
- });
- it('edges same-file methods to a class const / static readonly (C#)', async () => {
- // C# constants are `const` (compile-time) or `static readonly` (runtime);
- // both extract as `constant`. An instance `readonly` field is per-object and
- // stays `field`.
- fs.writeFileSync(
- path.join(dir, 'Limits.cs'),
- [
- 'class Limits {',
- ' const int MAX_ITEMS = 100;',
- ' static readonly string[] STATUS_NAMES = { "ok", "fail" };',
- ' readonly int instanceId = 1;',
- ' int Capped(int n) { return n > MAX_ITEMS ? MAX_ITEMS : n; }',
- ' string Label(int i) { return STATUS_NAMES[i]; }',
- ' int Id() { return instanceId; }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MAX_ITEMS')).toEqual(expect.arrayContaining(['Capped']));
- expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['Label']));
- expect(valueRefReaders(cg, 'instanceId')).toEqual([]);
- });
- it('does NOT edge a C# class const shadowed by a method-local of the same name', async () => {
- fs.writeFileSync(
- path.join(dir, 'Shadow.cs'),
- [
- 'class Shadow {',
- ' const int TIMEOUT = 30;',
- ' int UsesConst() { return TIMEOUT; }',
- ' int Shadows() { int TIMEOUT = 5; return TIMEOUT; }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
- });
- it('edges same-file readers to a top-level and class const, incl. self:: / Class:: (PHP)', async () => {
- // PHP keeps constants at file scope (`const X`) and inside classes (`const
- // X`), both extracted as `constant`. A constant *reference* is a `name` node
- // (bare `X`, or the const half of `self::X` / `Foo::X`), so the reader-scan
- // must match `name`. A `$var` local is a different namespace and can never
- // shadow a bare constant — so there is nothing to prune.
- fs.writeFileSync(
- path.join(dir, 'Config.php'),
- [
- '<?php',
- 'const APP_VERSION = "1.0";',
- 'class Config {',
- ' const MAX_ITEMS = 100;',
- ' const STATUS_NAMES = ["ok", "fail"];',
- ' public static $counter = 0;',
- ' function capped($n) { return $n > self::MAX_ITEMS ? self::MAX_ITEMS : $n; }',
- ' function label($i) { return Config::STATUS_NAMES[$i]; }',
- ' function version() { return APP_VERSION; }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'MAX_ITEMS')).toEqual(expect.arrayContaining(['capped']));
- expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['label']));
- expect(valueRefReaders(cg, 'APP_VERSION')).toEqual(expect.arrayContaining(['version']));
- // A static property is mutable class state, not a constant — never a target.
- expect(valueRefReaders(cg, 'counter')).toEqual([]);
- });
- it('edges readers to a top-level and object-scope val, not a class instance val (Scala)', async () => {
- // Scala has no `static`: an `object` is a singleton, so its `val`s are the
- // shared-constant idiom (extracted as `constant`, like a top-level val). A
- // `class` val is a per-instance immutable field (`field`, never a target).
- fs.writeFileSync(
- path.join(dir, 'Demo.scala'),
- [
- 'val AppVersion = "1.0"',
- 'object Config {',
- ' val TIMEOUT_MS = 30',
- ' val STATUS_NAMES = List("ok", "fail")',
- ' def capped(n: Int): Int = if (n > TIMEOUT_MS) TIMEOUT_MS else n',
- ' def label(i: Int): String = STATUS_NAMES(i)',
- '}',
- 'class Widget {',
- ' val MaxItems = 100',
- ' def within(n: Int): Int = if (n < MaxItems) n else MaxItems',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT_MS')).toEqual(expect.arrayContaining(['capped']));
- expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['label']));
- // A class instance `val` is per-object state (kind `field`), not a shared
- // constant — never a value-ref target even though `within` reads it.
- expect(valueRefReaders(cg, 'MaxItems')).toEqual([]);
- });
- it('does NOT edge a Scala object val shadowed by a method-local val of the same name', async () => {
- fs.writeFileSync(
- path.join(dir, 'Shadow.scala'),
- [
- 'object Config {',
- ' val TIMEOUT = 30',
- ' def usesConst(): Int = TIMEOUT',
- ' def shadows(): Int = { val TIMEOUT = 5; TIMEOUT }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
- });
- it('edges readers to top-level, object, and companion-object constants, not a class val (Kotlin)', async () => {
- // Kotlin has no `static`: a top-level property, an `object` (singleton), and a
- // class's `companion object` all hold shared constants (`val`→constant). A
- // class instance `val` is per-object state (`field`, never a target). The
- // property name nests as variable_declaration→simple_identifier, and a const
- // reference is a `simple_identifier`.
- fs.writeFileSync(
- path.join(dir, 'Demo.kt'),
- [
- 'const val TOP_LEVEL_MAX = 100',
- 'object Config {',
- ' const val TIMEOUT_MS = 30',
- ' val STATUS_NAMES = listOf("ok", "fail")',
- ' fun capped(n: Int): Int = if (n > TIMEOUT_MS) TIMEOUT_MS else n',
- ' fun label(i: Int): String = STATUS_NAMES[i]',
- '}',
- 'class Widget {',
- ' companion object { const val MAX_RETRIES = 3 }',
- ' val instanceField = 1',
- ' fun retries(): Int = MAX_RETRIES',
- ' fun within(n: Int): Int = if (n < TOP_LEVEL_MAX) n else TOP_LEVEL_MAX',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['label']));
- expect(valueRefReaders(cg, 'MAX_RETRIES')).toEqual(expect.arrayContaining(['retries']));
- expect(valueRefReaders(cg, 'TOP_LEVEL_MAX')).toEqual(expect.arrayContaining(['within']));
- // A class instance `val` is per-object state (kind `field`), never a target.
- expect(valueRefReaders(cg, 'instanceField')).toEqual([]);
- });
- it('does NOT edge a Kotlin object const shadowed by a method-local val of the same name', async () => {
- fs.writeFileSync(
- path.join(dir, 'Shadow.kt'),
- [
- 'object Config {',
- ' const val TIMEOUT = 30',
- ' fun usesConst(): Int = TIMEOUT',
- ' fun shadows(): Int { val TIMEOUT = 5; return TIMEOUT }',
- '}',
- ].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
- });
- it('emits nothing when CODEGRAPH_VALUE_REFS=0', async () => {
- const prev = process.env.CODEGRAPH_VALUE_REFS;
- process.env.CODEGRAPH_VALUE_REFS = '0';
- try {
- fs.writeFileSync(
- path.join(dir, 'config.ts'),
- ['export const TABLE_CONFIG = { rows: 10 };', 'export function rowCount() { return TABLE_CONFIG.rows; }'].join('\n'),
- );
- cg = index();
- await cg.indexAll();
- expect(valueRefReaders(cg, 'TABLE_CONFIG')).toEqual([]);
- } finally {
- if (prev === undefined) delete process.env.CODEGRAPH_VALUE_REFS;
- else process.env.CODEGRAPH_VALUE_REFS = prev;
- }
- });
- });
|