function-ref.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  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` is deliberately NOT captured in TS/JS: class fields
  136. // extract as method-kind nodes, so `this.X` value positions (mostly data
  137. // reads in real code) produced wrong edges — see TS_JS_SPEC note.
  138. expect(fnRefEdgesInto(cg, 'handleClick')).toHaveLength(0);
  139. } finally {
  140. cg.destroy();
  141. tmpDir = undefined;
  142. }
  143. });
  144. it('resolves an imported callback across files via its import', async () => {
  145. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-'));
  146. fs.writeFileSync(
  147. path.join(tmpDir, 'handlers.ts'),
  148. 'export function onMessage(x: number): void { console.log(x); }\n'
  149. );
  150. fs.writeFileSync(
  151. path.join(tmpDir, 'wiring.ts'),
  152. [
  153. "import { onMessage } from './handlers';",
  154. 'export function wire(bus: { on(cb: (x: number) => void): void }): void {',
  155. ' bus.on(onMessage);',
  156. '}',
  157. ].join('\n')
  158. );
  159. const cg = CodeGraph.initSync(tmpDir);
  160. try {
  161. await cg.indexAll();
  162. const edges = fnRefEdgesInto(cg, 'onMessage');
  163. expect(sourceNames(cg, edges)).toContain('wire');
  164. // The edge must target the handlers.ts definition.
  165. const target = cg.getNode(edges[0]!.target);
  166. expect(target?.filePath.endsWith('handlers.ts')).toBe(true);
  167. } finally {
  168. cg.destroy();
  169. tmpDir = undefined;
  170. }
  171. });
  172. it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => {
  173. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-'));
  174. // Two same-named functions in different files…
  175. fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n');
  176. fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n');
  177. // …and a registrar that names `process` WITHOUT importing it. The name
  178. // still passes the extraction gate only if imported/defined here — it is
  179. // neither, so this asserts the gate; even if it leaked through, the
  180. // ambiguity rule (unique-only cross-file) must yield no edge.
  181. fs.writeFileSync(
  182. path.join(tmpDir, 'c.ts'),
  183. 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n'
  184. );
  185. const cg = CodeGraph.initSync(tmpDir);
  186. try {
  187. await cg.indexAll();
  188. const edges = fnRefEdgesInto(cg, 'process');
  189. expect(sourceNames(cg, edges)).not.toContain('wire');
  190. } finally {
  191. cg.destroy();
  192. tmpDir = undefined;
  193. }
  194. });
  195. it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => {
  196. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-'));
  197. fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n');
  198. fs.writeFileSync(
  199. path.join(tmpDir, 'real.c'),
  200. [
  201. 'static void my_cb(int x) { (void)x; }',
  202. 'void register_handler(void (*cb)(int)) { cb(1); }',
  203. 'void wire(void) { register_handler(my_cb); }',
  204. ].join('\n')
  205. );
  206. const cg = CodeGraph.initSync(tmpDir);
  207. try {
  208. await cg.indexAll();
  209. const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => {
  210. const src = cg.getNode(e.source);
  211. return src?.name === 'wire';
  212. });
  213. expect(wires).toHaveLength(1);
  214. const target = cg.getNode(wires[0]!.target);
  215. expect(target?.filePath.endsWith('real.c')).toBe(true);
  216. } finally {
  217. cg.destroy();
  218. tmpDir = undefined;
  219. }
  220. });
  221. it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => {
  222. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-'));
  223. fs.writeFileSync(
  224. path.join(tmpDir, 'main.ts'),
  225. [
  226. 'export class Strategy { run(): void {} }',
  227. 'export function consume(x: unknown): void { void x; }',
  228. 'export function wire(): void { consume(Strategy); }',
  229. ].join('\n')
  230. );
  231. const cg = CodeGraph.initSync(tmpDir);
  232. try {
  233. await cg.indexAll();
  234. const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!;
  235. const fnRef = cg
  236. .getIncomingEdges(strategy.id)
  237. .filter((e) => e.metadata?.fnRef === true);
  238. expect(fnRef).toHaveLength(0);
  239. } finally {
  240. cg.destroy();
  241. tmpDir = undefined;
  242. }
  243. });
  244. it('SELF: a function registering itself produces no self-loop', async () => {
  245. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-'));
  246. fs.writeFileSync(
  247. path.join(tmpDir, 'main.ts'),
  248. [
  249. 'declare function schedule(cb: () => void): void;',
  250. 'export function retry(): void { schedule(retry); }',
  251. ].join('\n')
  252. );
  253. const cg = CodeGraph.initSync(tmpDir);
  254. try {
  255. await cg.indexAll();
  256. const retry = cg.getNodesByName('retry')[0]!;
  257. const selfLoops = cg
  258. .getIncomingEdges(retry.id)
  259. .filter((e) => e.source === retry.id && e.metadata?.fnRef === true);
  260. expect(selfLoops).toHaveLength(0);
  261. } finally {
  262. cg.destroy();
  263. tmpDir = undefined;
  264. }
  265. });
  266. it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => {
  267. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-'));
  268. fs.writeFileSync(
  269. path.join(tmpDir, 'widget.cpp'),
  270. [
  271. 'struct Widget {',
  272. ' void on_click(int x);',
  273. '};',
  274. 'void Widget::on_click(int x) { (void)x; }',
  275. 'struct Decoy {',
  276. ' void on_click(int x);',
  277. '};',
  278. 'void Decoy::on_click(int x) { (void)x; }',
  279. 'void free_cb(int x) { (void)x; }',
  280. 'void bare_fn(int x) { (void)x; }',
  281. 'void reg(void* p) { (void)p; }',
  282. 'void wire() {',
  283. ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy
  284. ' reg(p);',
  285. ' reg(&free_cb);', // explicit address-of — captured
  286. ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly)
  287. '}',
  288. // A method named like a local: passing the LOCAL must not resolve to
  289. // the method (cpp args accept only explicit & forms).
  290. 'struct Buf { char* out(); };',
  291. 'void copy_to(void* out_) { (void)out_; }',
  292. 'void caller(char* out) { copy_to(out); }',
  293. ].join('\n')
  294. );
  295. const cg = CodeGraph.initSync(tmpDir);
  296. try {
  297. await cg.indexAll();
  298. // Qualified member pointer resolves to Widget::on_click specifically.
  299. const onClicks = cg.getNodesByName('on_click');
  300. const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!;
  301. const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!;
  302. const intoWidget = cg
  303. .getIncomingEdges(widgetOnClick.id)
  304. .filter((e) => e.metadata?.fnRef === true);
  305. expect(intoWidget).toHaveLength(1);
  306. expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire');
  307. expect(
  308. cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true)
  309. ).toHaveLength(0);
  310. // Explicit &fn resolves; bare identifier in C++ args does NOT (the
  311. // generic-name collision class: fmt's `begin`/`out`/`size` params).
  312. expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire');
  313. expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0);
  314. // The local `out` param must NOT produce an edge to Buf::out.
  315. const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method');
  316. if (outMethod) {
  317. expect(
  318. cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true)
  319. ).toHaveLength(0);
  320. }
  321. } finally {
  322. cg.destroy();
  323. tmpDir = undefined;
  324. }
  325. });
  326. it('Pascal: := event wiring, @addr and bare args', async () => {
  327. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-'));
  328. fs.writeFileSync(
  329. path.join(tmpDir, 'main.pas'),
  330. [
  331. 'unit Main;',
  332. 'interface',
  333. 'type',
  334. ' TCallback = procedure(X: Integer);',
  335. ' THolder = class',
  336. ' public',
  337. ' OnFire: TCallback;',
  338. ' procedure Wire;',
  339. ' end;',
  340. 'procedure TargetCb(X: Integer);',
  341. 'procedure RegisterHandler(Cb: TCallback);',
  342. 'procedure ArgRegistrar;',
  343. 'procedure AddrRegistrar;',
  344. 'implementation',
  345. 'procedure TargetCb(X: Integer);',
  346. 'begin',
  347. ' WriteLn(X);',
  348. 'end;',
  349. 'procedure RegisterHandler(Cb: TCallback);',
  350. 'begin',
  351. ' Cb(1);',
  352. 'end;',
  353. 'procedure ArgRegistrar;',
  354. 'begin',
  355. ' RegisterHandler(TargetCb);',
  356. 'end;',
  357. 'procedure AddrRegistrar;',
  358. 'begin',
  359. ' RegisterHandler(@TargetCb);',
  360. 'end;',
  361. 'procedure THolder.Wire;',
  362. 'begin',
  363. ' OnFire := TargetCb;',
  364. 'end;',
  365. 'end.',
  366. ].join('\n')
  367. );
  368. const cg = CodeGraph.initSync(tmpDir);
  369. try {
  370. await cg.indexAll();
  371. expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([
  372. 'AddrRegistrar',
  373. 'ArgRegistrar',
  374. 'Wire',
  375. ]);
  376. } finally {
  377. cg.destroy();
  378. tmpDir = undefined;
  379. }
  380. });
  381. it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => {
  382. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-'));
  383. // Handler defined in its own file…
  384. fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n');
  385. // …and registered in a table in ANOTHER file, with no import mechanism (C).
  386. fs.writeFileSync(
  387. path.join(tmpDir, 'server.c'),
  388. [
  389. 'struct cmd { const char *name; void (*proc)(int); };',
  390. 'static struct cmd commandTable[] = {',
  391. ' { "get", getCommand },',
  392. '};',
  393. ].join('\n')
  394. );
  395. // Ambiguity safety: two files define dupCmd; a third table references it →
  396. // NO edge (unique-or-drop).
  397. fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n');
  398. fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n');
  399. fs.writeFileSync(
  400. path.join(tmpDir, 'other.c'),
  401. [
  402. 'struct cmd2 { void (*proc)(int); };',
  403. 'static struct cmd2 otherTable[] = { { dupCmd } };',
  404. ].join('\n')
  405. );
  406. const cg = CodeGraph.initSync(tmpDir);
  407. try {
  408. await cg.indexAll();
  409. // Cross-file unique handler resolves from the table's file.
  410. const intoGet = fnRefEdgesInto(cg, 'getCommand');
  411. expect(sourceNames(cg, intoGet)).toEqual(['server.c']);
  412. const target = cg.getNode(intoGet[0]!.target);
  413. expect(target?.filePath.endsWith('t_string.c')).toBe(true);
  414. // Ambiguous handler resolves to NOTHING — silent beats wrong.
  415. expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0);
  416. } finally {
  417. cg.destroy();
  418. tmpDir = undefined;
  419. }
  420. });
  421. it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => {
  422. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
  423. fs.writeFileSync(
  424. path.join(tmpDir, 'main.c'),
  425. [
  426. 'static void cb_a(int x) { (void)x; }',
  427. 'void reg(void (*cb)(int)) { cb(1); }',
  428. 'void wire(void) { reg(cb_a); }',
  429. ].join('\n')
  430. );
  431. const cg = CodeGraph.initSync(tmpDir);
  432. try {
  433. await cg.indexAll();
  434. const stats1 = cg.getStats();
  435. // No function_ref rows may linger for resolvable names — the batched
  436. // resolver must have drained them (delete keyed on the ORIGINAL stored
  437. // ref; the #760 runaway came from violating that).
  438. const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db;
  439. let leftover: unknown[] = [];
  440. try {
  441. leftover = db
  442. .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'")
  443. .all();
  444. } catch {
  445. // If internals aren't reachable this guard is covered by the edge
  446. // assertions below.
  447. }
  448. expect(leftover).toHaveLength(0);
  449. // Re-index: identical node/edge counts (idempotent, no accumulation).
  450. await cg.indexAll();
  451. const stats2 = cg.getStats();
  452. expect(stats2.totalNodes).toBe(stats1.totalNodes);
  453. expect(stats2.totalEdges).toBe(stats1.totalEdges);
  454. expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']);
  455. } finally {
  456. cg.destroy();
  457. tmpDir = undefined;
  458. }
  459. });
  460. });