value-reference-edges.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. /**
  2. * Value-reference edges (TS/JS): same-file `references` edges from a reader
  3. * symbol to the file-scope const/var it reads, so impact analysis catches
  4. * "change this constant, affect its readers". Default on; CODEGRAPH_VALUE_REFS=0
  5. * disables. See TreeSitterExtractor.flushValueRefs.
  6. */
  7. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  8. import * as fs from 'fs';
  9. import * as path from 'path';
  10. import * as os from 'os';
  11. import CodeGraph from '../src';
  12. function valueRefReaders(cg: CodeGraph, constName: string): string[] {
  13. const target = cg.searchNodes(constName).map((r) => r.node).find((n) => n.name === constName);
  14. if (!target) return [];
  15. return cg
  16. .getIncomingEdges(target.id)
  17. .filter((e) => e.kind === 'references' && (e.metadata as { valueRef?: boolean } | undefined)?.valueRef)
  18. .map((e) => cg.getNode(e.source)?.name)
  19. .filter((n): n is string => Boolean(n));
  20. }
  21. describe('value-reference edges', () => {
  22. let dir: string;
  23. let cg: CodeGraph | undefined;
  24. beforeEach(() => {
  25. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-valueref-'));
  26. });
  27. afterEach(() => {
  28. cg?.destroy();
  29. cg = undefined;
  30. fs.rmSync(dir, { recursive: true, force: true });
  31. });
  32. function index(): CodeGraph {
  33. const g = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.tsx'], exclude: [] } });
  34. return g;
  35. }
  36. it('edges same-file readers to the file-scope const they read (default on)', async () => {
  37. fs.writeFileSync(
  38. path.join(dir, 'config.ts'),
  39. [
  40. 'export const TABLE_CONFIG = { rows: 10, cols: 4 };',
  41. 'export function rowCount() { return TABLE_CONFIG.rows; }',
  42. 'export function describeTable() { return `${TABLE_CONFIG.rows}x${TABLE_CONFIG.cols}`; }',
  43. 'export const HEADER = TABLE_CONFIG.cols;',
  44. ].join('\n'),
  45. );
  46. cg = index();
  47. await cg.indexAll();
  48. const readers = valueRefReaders(cg, 'TABLE_CONFIG');
  49. // rowCount, describeTable, and the HEADER const all read TABLE_CONFIG.
  50. expect(readers).toEqual(expect.arrayContaining(['rowCount', 'describeTable', 'HEADER']));
  51. });
  52. it('surfaces those readers in the impact radius of the const', async () => {
  53. fs.writeFileSync(
  54. path.join(dir, 'palette.ts'),
  55. [
  56. 'export const COLOR_PALETTE = { red: "#f00", blue: "#00f" };',
  57. 'export function pickRed() { return COLOR_PALETTE.red; }',
  58. ].join('\n'),
  59. );
  60. cg = index();
  61. await cg.indexAll();
  62. const target = cg.searchNodes('COLOR_PALETTE').map((r) => r.node).find((n) => n.name === 'COLOR_PALETTE')!;
  63. const impacted = [...cg.getImpactRadius(target.id).nodes.values()].map((n) => n.name);
  64. expect(impacted).toContain('pickRed');
  65. });
  66. it('does NOT edge a shadowed const — inner re-declaration makes the name ambiguous', async () => {
  67. // The Emscripten/bundled pattern: a file-scope `const Module` re-declared as
  68. // an inner `var Module` / param. Nested readers resolve to the INNER binding,
  69. // so a file-scope edge would be a false positive. The shadow guard drops it.
  70. fs.writeFileSync(
  71. path.join(dir, 'bundled.ts'),
  72. [
  73. 'const Module = (function () {',
  74. ' return function (Module) {',
  75. ' var Module = typeof Module !== "undefined" ? Module : {};',
  76. ' function locate() { return Module.path; }',
  77. ' function getFunc() { return Module.lookup; }',
  78. ' return { locate, getFunc };',
  79. ' };',
  80. '})();',
  81. 'export default Module;',
  82. ].join('\n'),
  83. );
  84. cg = index();
  85. await cg.indexAll();
  86. // No reader should be edged to the outer `const Module`.
  87. expect(valueRefReaders(cg, 'Module')).toEqual([]);
  88. });
  89. it('edges readers that use the const only inside JSX (.tsx)', async () => {
  90. // The tsx-specific path: the const is read ONLY inside JSX expressions, so
  91. // the reader-scan must descend into the JSX subtree to find it.
  92. fs.writeFileSync(
  93. path.join(dir, 'widget.tsx'),
  94. [
  95. 'export const THEME_TOKENS = { color: "red", size: 12 };',
  96. 'export function Label() {',
  97. ' return <span style={{ color: THEME_TOKENS.color }}>hi</span>;',
  98. '}',
  99. 'export const Box = () => <div data-size={THEME_TOKENS.size} />;',
  100. ].join('\n'),
  101. );
  102. cg = index();
  103. await cg.indexAll();
  104. expect(valueRefReaders(cg, 'THEME_TOKENS')).toEqual(expect.arrayContaining(['Label', 'Box']));
  105. });
  106. it('edges same-file readers to a package-level const/var (Go)', async () => {
  107. fs.writeFileSync(
  108. path.join(dir, 'main.go'),
  109. [
  110. 'package main',
  111. '',
  112. 'const MaxRetries = 3',
  113. 'var DefaultLabels = map[string]string{"env": "prod"}',
  114. '',
  115. 'func retry() int { return MaxRetries }',
  116. 'func labels() map[string]string { return DefaultLabels }',
  117. ].join('\n'),
  118. );
  119. cg = index();
  120. await cg.indexAll();
  121. expect(valueRefReaders(cg, 'MaxRetries')).toEqual(expect.arrayContaining(['retry']));
  122. expect(valueRefReaders(cg, 'DefaultLabels')).toEqual(expect.arrayContaining(['labels']));
  123. });
  124. it('does NOT edge a Go package const shadowed by a local := of the same name', async () => {
  125. // `Timeout` is a package const AND a local `:=` (short_var_declaration) in
  126. // shadows(). The local read resolves to the inner binding, so a file-scope
  127. // edge would be a false positive — the shadow prune drops the whole target.
  128. fs.writeFileSync(
  129. path.join(dir, 'shadow.go'),
  130. [
  131. 'package main',
  132. '',
  133. 'const Timeout = 30',
  134. '',
  135. 'func usesConst() int { return Timeout }',
  136. 'func shadows() int {',
  137. '\tTimeout := 5',
  138. '\treturn Timeout',
  139. '}',
  140. ].join('\n'),
  141. );
  142. cg = index();
  143. await cg.indexAll();
  144. expect(valueRefReaders(cg, 'Timeout')).toEqual([]);
  145. });
  146. it('emits nothing when CODEGRAPH_VALUE_REFS=0', async () => {
  147. const prev = process.env.CODEGRAPH_VALUE_REFS;
  148. process.env.CODEGRAPH_VALUE_REFS = '0';
  149. try {
  150. fs.writeFileSync(
  151. path.join(dir, 'config.ts'),
  152. ['export const TABLE_CONFIG = { rows: 10 };', 'export function rowCount() { return TABLE_CONFIG.rows; }'].join('\n'),
  153. );
  154. cg = index();
  155. await cg.indexAll();
  156. expect(valueRefReaders(cg, 'TABLE_CONFIG')).toEqual([]);
  157. } finally {
  158. if (prev === undefined) delete process.env.CODEGRAPH_VALUE_REFS;
  159. else process.env.CODEGRAPH_VALUE_REFS = prev;
  160. }
  161. });
  162. });