function-ref.test.ts 27 KB

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