function-ref.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  1. /**
  2. * Function-as-value capture tests (#756) — registration-linking for callbacks.
  3. *
  4. * A function name used as a VALUE (passed as an argument, assigned to a
  5. * field/function pointer, placed in a struct/object initializer or function
  6. * table) must produce a `references` edge from the registration site to the
  7. * function, so `callers`/`impact` surface where a callback is wired up.
  8. *
  9. * Safety properties verified here, per the dynamic-dispatch discipline
  10. * ("a wrong edge is worse than none"):
  11. * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge
  12. * - same-file priority: a same-file definition beats a same-named decoy
  13. * - kind filter: a class/variable passed as a value never gets a
  14. * function-ref edge — except Python, where class-as-value is a core
  15. * idiom and bare ids ALSO resolve to classes (#1478); methods stay
  16. * excluded for bare ids everywhere
  17. * - self: a function passing itself → no self-loop
  18. * - drain: all resolvable function_ref rows leave unresolved_refs (no
  19. * batched-resolver runaway), and re-index is idempotent
  20. */
  21. import { describe, it, expect, beforeAll, afterEach } from 'vitest';
  22. import * as fs from 'fs';
  23. import * as path from 'path';
  24. import * as os from 'os';
  25. import { CodeGraph } from '../src';
  26. import type { Edge } from '../src/types';
  27. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  28. beforeAll(async () => {
  29. await initGrammars();
  30. await loadAllGrammars();
  31. });
  32. /** Incoming edges to `name`'s node that came from function-as-value capture. */
  33. function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] {
  34. const targets = cg.getNodesByName(name);
  35. const edges: Edge[] = [];
  36. for (const t of targets) {
  37. for (const e of cg.getIncomingEdges(t.id)) {
  38. if (e.kind === 'references' && e.metadata?.fnRef === true) {
  39. edges.push(e);
  40. }
  41. }
  42. }
  43. return edges;
  44. }
  45. /** Names of the source nodes of the given edges, sorted. */
  46. function sourceNames(cg: CodeGraph, edges: Edge[]): string[] {
  47. const names: string[] = [];
  48. for (const e of edges) {
  49. const n = cg.getNode(e.source);
  50. if (n) names.push(n.name);
  51. }
  52. return names.sort();
  53. }
  54. describe('Function-as-value capture (#756)', () => {
  55. let tmpDir: string | undefined;
  56. afterEach(() => {
  57. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  58. tmpDir = undefined;
  59. });
  60. it('C: registration sites produce references edges (the #756 scenario)', async () => {
  61. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-'));
  62. fs.writeFileSync(
  63. path.join(tmpDir, 'driver.c'),
  64. [
  65. 'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };',
  66. 'typedef void (*cb_t)(int);',
  67. '',
  68. 'static void my_recv_cb(int x) { (void)x; }',
  69. 'static void my_send_cb(int x) { (void)x; }',
  70. '',
  71. 'void register_handler(void (*cb)(int)) { cb(1); }',
  72. '',
  73. 'void direct_caller(void) { my_recv_cb(5); }',
  74. '',
  75. 'void arg_registrar(void) { register_handler(my_recv_cb); }',
  76. 'void addr_registrar(void) { register_handler(&my_recv_cb); }',
  77. 'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }',
  78. '',
  79. 'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };',
  80. 'static cb_t cb_table[] = { my_recv_cb, my_send_cb };',
  81. ].join('\n')
  82. );
  83. const cg = CodeGraph.initSync(tmpDir);
  84. try {
  85. await cg.indexAll();
  86. const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb');
  87. expect(sourceNames(cg, intoRecv)).toEqual([
  88. 'addr_registrar',
  89. 'arg_registrar',
  90. 'assign_registrar',
  91. 'driver.c', // file-scope: designated init + positional table (deduped per source)
  92. ]);
  93. // The direct call is still a `calls` edge — unchanged by this feature.
  94. const recv = cg.getNodesByName('my_recv_cb')[0]!;
  95. const callEdges = cg
  96. .getIncomingEdges(recv.id)
  97. .filter((e) => e.kind === 'calls');
  98. expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']);
  99. } finally {
  100. cg.destroy();
  101. tmpDir = undefined;
  102. }
  103. });
  104. it('TypeScript: arg / object / array / member / assignment forms', async () => {
  105. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-'));
  106. fs.writeFileSync(
  107. path.join(tmpDir, 'main.ts'),
  108. [
  109. 'export function targetCb(x: number): void { console.log(x); }',
  110. 'function registerHandler(cb: (x: number) => void): void { cb(1); }',
  111. '',
  112. 'export function argRegistrar(): void { registerHandler(targetCb); }',
  113. 'export function timerRegistrar(): void { setTimeout(targetCb, 100); }',
  114. 'export function objRegistrar(): unknown { return { recv: targetCb }; }',
  115. 'export function arrRegistrar(): unknown { return [targetCb]; }',
  116. '',
  117. 'class Emitter { cb: ((x: number) => void) | null = null; }',
  118. 'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }',
  119. '',
  120. 'interface Btn { on(ev: string, cb: () => void): void; }',
  121. 'export class Comp {',
  122. ' handleClick(): void {}',
  123. ' wire(btn: Btn): void { btn.on("click", this.handleClick); }',
  124. '}',
  125. ].join('\n')
  126. );
  127. const cg = CodeGraph.initSync(tmpDir);
  128. try {
  129. await cg.indexAll();
  130. expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([
  131. 'argRegistrar',
  132. 'arrRegistrar',
  133. 'assignRegistrar',
  134. 'objRegistrar',
  135. 'timerRegistrar',
  136. ]);
  137. // `this.handleClick` resolves class-scoped (#808): the target must be a
  138. // method of the ENCLOSING class, in the same file.
  139. expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']);
  140. } finally {
  141. cg.destroy();
  142. tmpDir = undefined;
  143. }
  144. });
  145. it('resolves an imported callback across files via its import', async () => {
  146. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-'));
  147. fs.writeFileSync(
  148. path.join(tmpDir, 'handlers.ts'),
  149. 'export function onMessage(x: number): void { console.log(x); }\n'
  150. );
  151. fs.writeFileSync(
  152. path.join(tmpDir, 'wiring.ts'),
  153. [
  154. "import { onMessage } from './handlers';",
  155. 'export function wire(bus: { on(cb: (x: number) => void): void }): void {',
  156. ' bus.on(onMessage);',
  157. '}',
  158. ].join('\n')
  159. );
  160. const cg = CodeGraph.initSync(tmpDir);
  161. try {
  162. await cg.indexAll();
  163. const edges = fnRefEdgesInto(cg, 'onMessage');
  164. expect(sourceNames(cg, edges)).toContain('wire');
  165. // The edge must target the handlers.ts definition.
  166. const target = cg.getNode(edges[0]!.target);
  167. expect(target?.filePath.endsWith('handlers.ts')).toBe(true);
  168. } finally {
  169. cg.destroy();
  170. tmpDir = undefined;
  171. }
  172. });
  173. it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => {
  174. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-'));
  175. // Two same-named functions in different files…
  176. fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n');
  177. fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n');
  178. // …and a registrar that names `process` WITHOUT importing it. The name
  179. // still passes the extraction gate only if imported/defined here — it is
  180. // neither, so this asserts the gate; even if it leaked through, the
  181. // ambiguity rule (unique-only cross-file) must yield no edge.
  182. fs.writeFileSync(
  183. path.join(tmpDir, 'c.ts'),
  184. 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n'
  185. );
  186. const cg = CodeGraph.initSync(tmpDir);
  187. try {
  188. await cg.indexAll();
  189. const edges = fnRefEdgesInto(cg, 'process');
  190. expect(sourceNames(cg, edges)).not.toContain('wire');
  191. } finally {
  192. cg.destroy();
  193. tmpDir = undefined;
  194. }
  195. });
  196. it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => {
  197. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-'));
  198. fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n');
  199. fs.writeFileSync(
  200. path.join(tmpDir, 'real.c'),
  201. [
  202. 'static void my_cb(int x) { (void)x; }',
  203. 'void register_handler(void (*cb)(int)) { cb(1); }',
  204. 'void wire(void) { register_handler(my_cb); }',
  205. ].join('\n')
  206. );
  207. const cg = CodeGraph.initSync(tmpDir);
  208. try {
  209. await cg.indexAll();
  210. const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => {
  211. const src = cg.getNode(e.source);
  212. return src?.name === 'wire';
  213. });
  214. expect(wires).toHaveLength(1);
  215. const target = cg.getNode(wires[0]!.target);
  216. expect(target?.filePath.endsWith('real.c')).toBe(true);
  217. } finally {
  218. cg.destroy();
  219. tmpDir = undefined;
  220. }
  221. });
  222. it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => {
  223. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-'));
  224. fs.writeFileSync(
  225. path.join(tmpDir, 'main.ts'),
  226. [
  227. 'export class Strategy { run(): void {} }',
  228. 'export function consume(x: unknown): void { void x; }',
  229. 'export function wire(): void { consume(Strategy); }',
  230. ].join('\n')
  231. );
  232. const cg = CodeGraph.initSync(tmpDir);
  233. try {
  234. await cg.indexAll();
  235. const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!;
  236. const fnRef = cg
  237. .getIncomingEdges(strategy.id)
  238. .filter((e) => e.metadata?.fnRef === true);
  239. expect(fnRef).toHaveLength(0);
  240. } finally {
  241. cg.destroy();
  242. tmpDir = undefined;
  243. }
  244. });
  245. it('SELF: a function registering itself produces no self-loop', async () => {
  246. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-'));
  247. fs.writeFileSync(
  248. path.join(tmpDir, 'main.ts'),
  249. [
  250. 'declare function schedule(cb: () => void): void;',
  251. 'export function retry(): void { schedule(retry); }',
  252. ].join('\n')
  253. );
  254. const cg = CodeGraph.initSync(tmpDir);
  255. try {
  256. await cg.indexAll();
  257. const retry = cg.getNodesByName('retry')[0]!;
  258. const selfLoops = cg
  259. .getIncomingEdges(retry.id)
  260. .filter((e) => e.source === retry.id && e.metadata?.fnRef === true);
  261. expect(selfLoops).toHaveLength(0);
  262. } finally {
  263. cg.destroy();
  264. tmpDir = undefined;
  265. }
  266. });
  267. it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => {
  268. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-'));
  269. fs.writeFileSync(
  270. path.join(tmpDir, 'widget.cpp'),
  271. [
  272. 'struct Widget {',
  273. ' void on_click(int x);',
  274. '};',
  275. 'void Widget::on_click(int x) { (void)x; }',
  276. 'struct Decoy {',
  277. ' void on_click(int x);',
  278. '};',
  279. 'void Decoy::on_click(int x) { (void)x; }',
  280. 'void free_cb(int x) { (void)x; }',
  281. 'void bare_fn(int x) { (void)x; }',
  282. 'void reg(void* p) { (void)p; }',
  283. 'void wire() {',
  284. ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy
  285. ' reg(p);',
  286. ' reg(&free_cb);', // explicit address-of — captured
  287. ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly)
  288. '}',
  289. // A method named like a local: passing the LOCAL must not resolve to
  290. // the method (cpp args accept only explicit & forms).
  291. 'struct Buf { char* out(); };',
  292. 'void copy_to(void* out_) { (void)out_; }',
  293. 'void caller(char* out) { copy_to(out); }',
  294. ].join('\n')
  295. );
  296. const cg = CodeGraph.initSync(tmpDir);
  297. try {
  298. await cg.indexAll();
  299. // Qualified member pointer resolves to Widget::on_click specifically.
  300. const onClicks = cg.getNodesByName('on_click');
  301. const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!;
  302. const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!;
  303. const intoWidget = cg
  304. .getIncomingEdges(widgetOnClick.id)
  305. .filter((e) => e.metadata?.fnRef === true);
  306. expect(intoWidget).toHaveLength(1);
  307. expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire');
  308. expect(
  309. cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true)
  310. ).toHaveLength(0);
  311. // Explicit &fn resolves; bare identifier in C++ args does NOT (the
  312. // generic-name collision class: fmt's `begin`/`out`/`size` params).
  313. expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire');
  314. expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0);
  315. // The local `out` param must NOT produce an edge to Buf::out.
  316. const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method');
  317. if (outMethod) {
  318. expect(
  319. cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true)
  320. ).toHaveLength(0);
  321. }
  322. } finally {
  323. cg.destroy();
  324. tmpDir = undefined;
  325. }
  326. });
  327. it('Pascal: := event wiring, @addr and bare args', async () => {
  328. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-'));
  329. fs.writeFileSync(
  330. path.join(tmpDir, 'main.pas'),
  331. [
  332. 'unit Main;',
  333. 'interface',
  334. 'type',
  335. ' TCallback = procedure(X: Integer);',
  336. ' THolder = class',
  337. ' public',
  338. ' OnFire: TCallback;',
  339. ' procedure Wire;',
  340. ' end;',
  341. 'procedure TargetCb(X: Integer);',
  342. 'procedure RegisterHandler(Cb: TCallback);',
  343. 'procedure ArgRegistrar;',
  344. 'procedure AddrRegistrar;',
  345. 'implementation',
  346. 'procedure TargetCb(X: Integer);',
  347. 'begin',
  348. ' WriteLn(X);',
  349. 'end;',
  350. 'procedure RegisterHandler(Cb: TCallback);',
  351. 'begin',
  352. ' Cb(1);',
  353. 'end;',
  354. 'procedure ArgRegistrar;',
  355. 'begin',
  356. ' RegisterHandler(TargetCb);',
  357. 'end;',
  358. 'procedure AddrRegistrar;',
  359. 'begin',
  360. ' RegisterHandler(@TargetCb);',
  361. 'end;',
  362. 'procedure THolder.Wire;',
  363. 'begin',
  364. ' OnFire := TargetCb;',
  365. 'end;',
  366. 'end.',
  367. ].join('\n')
  368. );
  369. const cg = CodeGraph.initSync(tmpDir);
  370. try {
  371. await cg.indexAll();
  372. expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([
  373. 'AddrRegistrar',
  374. 'ArgRegistrar',
  375. 'Wire',
  376. ]);
  377. } finally {
  378. cg.destroy();
  379. tmpDir = undefined;
  380. }
  381. });
  382. it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => {
  383. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-'));
  384. fs.writeFileSync(
  385. path.join(tmpDir, 'main.ts'),
  386. [
  387. 'declare const bus: { on(ev: string, cb: () => void): void };',
  388. // Decoy: a same-named method on an UNRELATED class.
  389. 'export class Decoy { refresh(): void {} }',
  390. 'export class Panel {',
  391. ' views: number[] = [];', // property (post-#808), shares no name
  392. ' refresh(): void {}',
  393. ' wire(): void {',
  394. ' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh
  395. ' bus.on("data", this.views as never);', // property → NO edge
  396. ' bus.on("gone", this.missing as never);', // unknown member → NO edge
  397. ' }',
  398. '}',
  399. ].join('\n')
  400. );
  401. const cg = CodeGraph.initSync(tmpDir);
  402. try {
  403. await cg.indexAll();
  404. const refreshes = cg.getNodesByName('refresh');
  405. const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!;
  406. const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!;
  407. const intoPanel = cg
  408. .getIncomingEdges(panelRefresh.id)
  409. .filter((e) => e.metadata?.fnRef === true);
  410. expect(intoPanel).toHaveLength(1);
  411. expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire');
  412. expect(
  413. cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true)
  414. ).toHaveLength(0);
  415. // The property and the unknown member produce nothing.
  416. const views = cg.getNodesByName('views').find((n) => n.kind === 'property');
  417. if (views) {
  418. expect(
  419. cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true)
  420. ).toHaveLength(0);
  421. }
  422. } finally {
  423. cg.destroy();
  424. tmpDir = undefined;
  425. }
  426. });
  427. it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => {
  428. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-'));
  429. fs.writeFileSync(
  430. path.join(tmpDir, 'base.ts'),
  431. 'export class FormBase { handleSubmit(): void {} }\n'
  432. );
  433. fs.writeFileSync(
  434. path.join(tmpDir, 'unrelated.ts'),
  435. 'export class Unrelated { handleSubmit(): void {} }\n'
  436. );
  437. fs.writeFileSync(
  438. path.join(tmpDir, 'login.ts'),
  439. [
  440. "import { FormBase } from './base';",
  441. 'declare const bus: { on(ev: string, cb: () => void): void };',
  442. 'export class LoginForm extends FormBase {',
  443. ' wire(): void { bus.on("submit", this.handleSubmit); }',
  444. '}',
  445. ].join('\n')
  446. );
  447. const cg = CodeGraph.initSync(tmpDir);
  448. try {
  449. await cg.indexAll();
  450. const handleSubmits = cg.getNodesByName('handleSubmit');
  451. const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!;
  452. const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!;
  453. const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true);
  454. expect(intoBase).toHaveLength(1);
  455. expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire');
  456. expect(
  457. cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true)
  458. ).toHaveLength(0);
  459. } finally {
  460. cg.destroy();
  461. tmpDir = undefined;
  462. }
  463. });
  464. it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => {
  465. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-'));
  466. fs.writeFileSync(
  467. path.join(tmpDir, 'Handlers.java'),
  468. [
  469. 'package com.example;',
  470. 'public class Handlers {',
  471. ' public static void onMessage(int x) { System.out.println(x); }',
  472. '}',
  473. ].join('\n')
  474. );
  475. fs.writeFileSync(
  476. path.join(tmpDir, 'BaseForm.java'),
  477. ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n')
  478. );
  479. fs.writeFileSync(
  480. path.join(tmpDir, 'Main.java'),
  481. [
  482. 'package com.example;',
  483. 'import com.example.Handlers;',
  484. 'import java.util.function.IntConsumer;',
  485. 'public class Main extends BaseForm {',
  486. ' static void registerHandler(IntConsumer cb) { cb.accept(1); }',
  487. ' void run0() {}',
  488. ' void crossFile() { registerHandler(Handlers::onMessage); }',
  489. ' void thisRef() { registerHandler(this::run0); }',
  490. ' void superRef() { registerHandler(super::baseHandler); }',
  491. ' void varRef(Main m) { registerHandler(m::run0); }',
  492. '}',
  493. ].join('\n')
  494. );
  495. const cg = CodeGraph.initSync(tmpDir);
  496. try {
  497. await cg.indexAll();
  498. expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']);
  499. expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']);
  500. // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT
  501. // add a second edge — exactly one source.
  502. expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']);
  503. } finally {
  504. cg.destroy();
  505. tmpDir = undefined;
  506. }
  507. });
  508. it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => {
  509. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-'));
  510. // Same package, no imports — the Java/Kotlin reality the name gate can't
  511. // see, which is why qualified `Type::member` candidates skip it.
  512. fs.writeFileSync(
  513. path.join(tmpDir, 'Handlers.kt'),
  514. [
  515. 'class KtHandlers {',
  516. ' companion object {',
  517. ' fun handle(x: Int) {}',
  518. ' }',
  519. '}',
  520. 'class Decoy {',
  521. ' companion object {',
  522. ' fun handle(x: Int) {}',
  523. ' }',
  524. '}',
  525. ].join('\n')
  526. );
  527. fs.writeFileSync(
  528. path.join(tmpDir, 'Wirer.kt'),
  529. [
  530. 'fun register(cb: Any) {}',
  531. 'class Wirer {',
  532. ' fun wire() { register(KtHandlers::handle) }',
  533. '}',
  534. ].join('\n')
  535. );
  536. const cg = CodeGraph.initSync(tmpDir);
  537. try {
  538. await cg.indexAll();
  539. const handles = cg.getNodesByName('handle');
  540. const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!;
  541. const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!;
  542. const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true);
  543. expect(into).toHaveLength(1);
  544. expect(cg.getNode(into[0]!.source)?.name).toBe('wire');
  545. expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0);
  546. } finally {
  547. cg.destroy();
  548. tmpDir = undefined;
  549. }
  550. });
  551. it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => {
  552. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-'));
  553. fs.writeFileSync(
  554. path.join(tmpDir, 'main.swift'),
  555. [
  556. 'func register(_ cb: (Int) -> Void) { cb(1) }',
  557. 'class Monitor {',
  558. ' func report(_ x: Int) {}',
  559. ' func wire() { register(report) }', // implicit self → Monitor::report
  560. '}',
  561. 'class Other {',
  562. // `report` here is a PARAMETER; Monitor::report must not win.
  563. ' func use(report: (Int) -> Void) { register(report) }',
  564. '}',
  565. 'func topLevel() { register(report) }', // no implicit self → no method target
  566. ].join('\n')
  567. );
  568. const cg = CodeGraph.initSync(tmpDir);
  569. try {
  570. await cg.indexAll();
  571. const edges = fnRefEdgesInto(cg, 'report');
  572. expect(sourceNames(cg, edges)).toEqual(['wire']);
  573. } finally {
  574. cg.destroy();
  575. tmpDir = undefined;
  576. }
  577. });
  578. it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => {
  579. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-'));
  580. // Handler defined in its own file…
  581. fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n');
  582. // …and registered in a table in ANOTHER file, with no import mechanism (C).
  583. fs.writeFileSync(
  584. path.join(tmpDir, 'server.c'),
  585. [
  586. 'struct cmd { const char *name; void (*proc)(int); };',
  587. 'static struct cmd commandTable[] = {',
  588. ' { "get", getCommand },',
  589. '};',
  590. ].join('\n')
  591. );
  592. // Ambiguity safety: two files define dupCmd; a third table references it →
  593. // NO edge (unique-or-drop).
  594. fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n');
  595. fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n');
  596. fs.writeFileSync(
  597. path.join(tmpDir, 'other.c'),
  598. [
  599. 'struct cmd2 { void (*proc)(int); };',
  600. 'static struct cmd2 otherTable[] = { { dupCmd } };',
  601. ].join('\n')
  602. );
  603. const cg = CodeGraph.initSync(tmpDir);
  604. try {
  605. await cg.indexAll();
  606. // Cross-file unique handler resolves from the table's file.
  607. const intoGet = fnRefEdgesInto(cg, 'getCommand');
  608. expect(sourceNames(cg, intoGet)).toEqual(['server.c']);
  609. const target = cg.getNode(intoGet[0]!.target);
  610. expect(target?.filePath.endsWith('t_string.c')).toBe(true);
  611. // Ambiguous handler resolves to NOTHING — silent beats wrong.
  612. expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0);
  613. } finally {
  614. cg.destroy();
  615. tmpDir = undefined;
  616. }
  617. });
  618. it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => {
  619. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-'));
  620. fs.writeFileSync(
  621. path.join(tmpDir, 'handlers.php'),
  622. "<?php\nfunction cmp_items($a, $b) { return $a <=> $b; }\n"
  623. );
  624. fs.writeFileSync(
  625. path.join(tmpDir, 'main.php'),
  626. [
  627. '<?php',
  628. 'class Saver {',
  629. ' public function onSave($x) {}',
  630. ' public function wire() {',
  631. " register_shutdown_function([$this, 'onSave']);",
  632. ' }',
  633. '}',
  634. 'class Loader {',
  635. ' public static function load($cls) {}',
  636. '}',
  637. 'function sorter($items) {',
  638. " usort($items, 'cmp_items');", // known HOF, cross-file string → edge
  639. " spl_autoload_register([Loader::class, 'load']);",
  640. " some_random_fn('cmp_items');", // NOT a known HOF → no edge
  641. ' return $items;',
  642. '}',
  643. ].join('\n')
  644. );
  645. const cg = CodeGraph.initSync(tmpDir);
  646. try {
  647. await cg.indexAll();
  648. // Exactly ONE source for cmp_items: the usort site, not some_random_fn.
  649. expect(sourceNames(cg, fnRefEdgesInto(cg, 'cmp_items'))).toEqual(['sorter']);
  650. expect(sourceNames(cg, fnRefEdgesInto(cg, 'onSave'))).toEqual(['wire']);
  651. expect(sourceNames(cg, fnRefEdgesInto(cg, 'load'))).toEqual(['sorter']);
  652. } finally {
  653. cg.destroy();
  654. tmpDir = undefined;
  655. }
  656. });
  657. it('RUBY HOOKS: before_action/rescue_from symbols resolve class-scoped incl. inherited; validates is excluded', async () => {
  658. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-'));
  659. fs.writeFileSync(
  660. path.join(tmpDir, 'posts_controller.rb'),
  661. [
  662. 'class ApplicationController',
  663. ' def authenticate; end',
  664. 'end',
  665. '',
  666. 'class PostsController < ApplicationController',
  667. ' before_action :authenticate', // inherited → ApplicationController
  668. ' after_save :reindex',
  669. ' validates :title, presence: true', // attributes, NOT methods → no edge
  670. ' rescue_from StandardError, with: :render_500',
  671. '',
  672. ' def reindex; end',
  673. ' def render_500; end',
  674. ' def title; end',
  675. 'end',
  676. ].join('\n')
  677. );
  678. const cg = CodeGraph.initSync(tmpDir);
  679. try {
  680. await cg.indexAll();
  681. const auth = fnRefEdgesInto(cg, 'authenticate');
  682. expect(auth).toHaveLength(1);
  683. expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController');
  684. expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1);
  685. expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1);
  686. // `validates :title` names an attribute — the same-named METHOD must
  687. // get no registration edge.
  688. expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0);
  689. } finally {
  690. cg.destroy();
  691. tmpDir = undefined;
  692. }
  693. });
  694. it('PYTHON CLASSES: return / alias / registry dict / arg positions produce references edges (#1478)', async () => {
  695. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pycls-'));
  696. fs.writeFileSync(
  697. path.join(tmpDir, 'serializers.py'),
  698. [
  699. 'class OrgSerializerFull:',
  700. ' pass',
  701. '',
  702. 'class OrgSerializerBrief:',
  703. ' pass',
  704. ].join('\n')
  705. );
  706. fs.writeFileSync(
  707. path.join(tmpDir, 'views.py'),
  708. [
  709. 'from serializers import OrgSerializerFull, OrgSerializerBrief',
  710. '',
  711. 'def register(cls):',
  712. ' pass',
  713. '',
  714. 'class OrgViewSet:',
  715. ' def get_serializer_class(self):',
  716. ' if True:',
  717. ' return OrgSerializerFull',
  718. ' return OrgSerializerBrief',
  719. '',
  720. 'SERIALIZER_REGISTRY = {"org": OrgSerializerFull}',
  721. 'register(OrgSerializerBrief)',
  722. ].join('\n')
  723. );
  724. fs.writeFileSync(
  725. path.join(tmpDir, 'models.py'),
  726. [
  727. 'class Config:',
  728. ' pass',
  729. '',
  730. 'def make_config_cls():',
  731. ' return Config',
  732. '',
  733. 'ActiveConfig = Config',
  734. ].join('\n')
  735. );
  736. const cg = CodeGraph.initSync(tmpDir);
  737. try {
  738. await cg.indexAll();
  739. // The DRF wiring: get_serializer_class → the imported serializer class,
  740. // via `return` — the issue's headline gap. The module-level registry
  741. // dict rides the file node.
  742. expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([
  743. 'get_serializer_class',
  744. 'views.py',
  745. ]);
  746. // Second branch return + a module-level call argument.
  747. expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerBrief'))).toEqual([
  748. 'get_serializer_class',
  749. 'views.py',
  750. ]);
  751. // Same-file: factory return + module-level alias assignment.
  752. expect(sourceNames(cg, fnRefEdgesInto(cg, 'Config'))).toEqual([
  753. 'make_config_cls',
  754. 'models.py',
  755. ]);
  756. // callers() must now surface the view as a consumer of the serializer.
  757. const serializer = cg
  758. .getNodesByName('OrgSerializerFull')
  759. .find((n) => n.kind === 'class')!;
  760. const callers = cg.getCallers(serializer.id);
  761. expect(callers.some((c) => c.node.name === 'get_serializer_class')).toBe(true);
  762. } finally {
  763. cg.destroy();
  764. tmpDir = undefined;
  765. }
  766. });
  767. it('PYTHON KIND FILTER: bare ids still never resolve to methods; unknown names stay silent', async () => {
  768. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pyneg-'));
  769. fs.writeFileSync(
  770. path.join(tmpDir, 'svc.py'),
  771. [
  772. 'class Svc:',
  773. ' def refresh(self):',
  774. ' pass',
  775. '',
  776. 'def wire(cb):',
  777. ' pass',
  778. '',
  779. 'def setup(refresh):',
  780. // A local/parameter sharing a same-file METHOD name: the gate lets it
  781. // through (methods are in definedHere) but resolution must refuse —
  782. // a bare id can never be a method value in Python.
  783. ' wire(refresh)',
  784. // A name with no matching class/function anywhere: no edge, silently.
  785. ' return unknown_thing',
  786. ].join('\n')
  787. );
  788. const cg = CodeGraph.initSync(tmpDir);
  789. try {
  790. await cg.indexAll();
  791. expect(fnRefEdgesInto(cg, 'refresh')).toHaveLength(0);
  792. expect(fnRefEdgesInto(cg, 'unknown_thing')).toHaveLength(0);
  793. } finally {
  794. cg.destroy();
  795. tmpDir = undefined;
  796. }
  797. });
  798. it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => {
  799. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
  800. fs.writeFileSync(
  801. path.join(tmpDir, 'main.c'),
  802. [
  803. 'static void cb_a(int x) { (void)x; }',
  804. 'void reg(void (*cb)(int)) { cb(1); }',
  805. 'void wire(void) { reg(cb_a); }',
  806. ].join('\n')
  807. );
  808. const cg = CodeGraph.initSync(tmpDir);
  809. try {
  810. await cg.indexAll();
  811. const stats1 = cg.getStats();
  812. // No function_ref rows may linger for resolvable names — the batched
  813. // resolver must have drained them (delete keyed on the ORIGINAL stored
  814. // ref; the #760 runaway came from violating that).
  815. const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db;
  816. let leftover: unknown[] = [];
  817. try {
  818. leftover = db
  819. .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'")
  820. .all();
  821. } catch {
  822. // If internals aren't reachable this guard is covered by the edge
  823. // assertions below.
  824. }
  825. expect(leftover).toHaveLength(0);
  826. // Re-index: identical node/edge counts (idempotent, no accumulation).
  827. await cg.indexAll();
  828. const stats2 = cg.getStats();
  829. expect(stats2.totalNodes).toBe(stats1.totalNodes);
  830. expect(stats2.totalEdges).toBe(stats1.totalEdges);
  831. expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']);
  832. } finally {
  833. cg.destroy();
  834. tmpDir = undefined;
  835. }
  836. });
  837. });