1
0

erlang-behaviour-synthesizer.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. /**
  2. * Erlang behaviour-callback dispatch bridge.
  3. *
  4. * A behaviour module declares `-callback fn/N`, implementers declare
  5. * `-behaviour(B)` and export the callbacks, and the framework dispatches
  6. * through a variable module (`Handler:init(...)`, `Mod:handle_thing(...)`) — a
  7. * dynamic hop extraction deliberately leaves silent. This bridges each
  8. * `Var:fn(args)` site to every in-repo implementer of the ONE behaviour that
  9. * declares (fn, site-arity), and proves the precision gates: a same-named
  10. * function in a non-implementer module contributes no edge, an arity mismatch
  11. * contributes no edge, and a (fn, arity) declared by TWO behaviours bails
  12. * entirely.
  13. */
  14. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  15. import * as fs from 'node:fs';
  16. import * as path from 'node:path';
  17. import * as os from 'node:os';
  18. import { CodeGraph } from '../src';
  19. describe('erlang-behaviour synthesizer', () => {
  20. let dir: string;
  21. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'erlang-behaviour-')); });
  22. afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
  23. async function synthEdges(d: string): Promise<any[]> {
  24. const cg = await CodeGraph.init(d, { silent: true });
  25. await cg.indexAll();
  26. const db = (cg as any).db.db;
  27. const rows = db
  28. .prepare(
  29. `SELECT s.name source, s.file_path sf, t.name target, t.file_path tf,
  30. json_extract(e.metadata,'$.via') via
  31. FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
  32. WHERE json_extract(e.metadata,'$.synthesizedBy') = 'erlang-behaviour'`
  33. )
  34. .all();
  35. cg.destroy();
  36. return rows;
  37. }
  38. it('bridges Var:fn(...) dispatch to every implementer, gated on behaviour + export + arity', async () => {
  39. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  40. fs.writeFileSync(
  41. path.join(dir, 'src', 'worker_behaviour.erl'),
  42. `-module(worker_behaviour).
  43. -callback handle_thing(Arg :: term()) -> ok | {error, term()}.
  44. -callback init(list()) -> {ok, term()}.
  45. -export([dispatch/2]).
  46. dispatch(Mod, Arg) ->
  47. Mod:handle_thing(Arg).
  48. `
  49. );
  50. // Two real implementers, exporting the callback.
  51. fs.writeFileSync(
  52. path.join(dir, 'src', 'worker_a.erl'),
  53. `-module(worker_a).
  54. -behaviour(worker_behaviour).
  55. -export([handle_thing/1, init/1]).
  56. handle_thing(X) -> {ok, X}.
  57. init(_) -> {ok, state}.
  58. `
  59. );
  60. fs.writeFileSync(
  61. path.join(dir, 'src', 'worker_b.erl'),
  62. `-module(worker_b).
  63. -behaviour(worker_behaviour).
  64. -export([handle_thing/1, init/1]).
  65. handle_thing(X) -> {done, X}.
  66. init(_) -> {ok, state}.
  67. `
  68. );
  69. // Defines + exports the same function name but does NOT implement the behaviour.
  70. fs.writeFileSync(
  71. path.join(dir, 'src', 'freeloader.erl'),
  72. `-module(freeloader).
  73. -export([handle_thing/1]).
  74. handle_thing(X) -> X.
  75. `
  76. );
  77. // A second dispatcher in another module, plus an arity-mismatched site and a
  78. // macro-module site — neither of the latter two may produce edges.
  79. fs.writeFileSync(
  80. path.join(dir, 'src', 'runner.erl'),
  81. `-module(runner).
  82. -export([run/2, wrong/2, self_call/1]).
  83. run(Mod, Arg) ->
  84. Mod:handle_thing(Arg).
  85. wrong(Mod, Arg) ->
  86. Mod:handle_thing(Arg, extra).
  87. self_call(X) ->
  88. ?MODULE:handle_thing(X).
  89. `
  90. );
  91. const rows = await synthEdges(dir);
  92. const targets = (src: string) =>
  93. rows.filter((r) => r.source === src).map((r) => `${path.basename(r.tf)}:${r.target}`).sort();
  94. // Both dispatch sites link both implementers — and only them (no freeloader).
  95. expect(targets('dispatch')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
  96. expect(targets('run')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
  97. // Arity mismatch (handle_thing/2 undeclared) and ?MODULE sites: nothing.
  98. expect(targets('wrong')).toEqual([]);
  99. expect(targets('self_call')).toEqual([]);
  100. // Provenance metadata names the contract.
  101. expect(rows.every((r) => r.via === 'worker_behaviour:handle_thing/1')).toBe(true);
  102. });
  103. it('bails when two behaviours declare the same callback name and arity', async () => {
  104. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  105. for (const b of ['left_behaviour', 'right_behaviour']) {
  106. fs.writeFileSync(
  107. path.join(dir, 'src', `${b}.erl`),
  108. `-module(${b}).
  109. -callback common_cb(term()) -> ok.
  110. `
  111. );
  112. }
  113. fs.writeFileSync(
  114. path.join(dir, 'src', 'impl_left.erl'),
  115. `-module(impl_left).
  116. -behaviour(left_behaviour).
  117. -export([common_cb/1]).
  118. common_cb(X) -> X.
  119. `
  120. );
  121. fs.writeFileSync(
  122. path.join(dir, 'src', 'caller.erl'),
  123. `-module(caller).
  124. -export([go/2]).
  125. go(Mod, X) ->
  126. Mod:common_cb(X).
  127. `
  128. );
  129. const rows = await synthEdges(dir);
  130. expect(rows).toEqual([]);
  131. });
  132. it('does not link an implementer whose callback is not exported', async () => {
  133. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  134. fs.writeFileSync(
  135. path.join(dir, 'src', 'hook_behaviour.erl'),
  136. `-module(hook_behaviour).
  137. -callback on_event(term()) -> ok.
  138. -export([fire/2]).
  139. fire(Mod, Ev) ->
  140. Mod:on_event(Ev).
  141. `
  142. );
  143. fs.writeFileSync(
  144. path.join(dir, 'src', 'private_impl.erl'),
  145. `-module(private_impl).
  146. -behaviour(hook_behaviour).
  147. -export([start/0]).
  148. start() -> ok.
  149. on_event(_Ev) -> ok.
  150. `
  151. );
  152. fs.writeFileSync(
  153. path.join(dir, 'src', 'public_impl.erl'),
  154. `-module(public_impl).
  155. -behaviour(hook_behaviour).
  156. -export([on_event/1]).
  157. on_event(Ev) -> {seen, Ev}.
  158. `
  159. );
  160. const rows = await synthEdges(dir);
  161. expect(rows.map((r) => path.basename(r.tf))).toEqual(['public_impl.erl']);
  162. });
  163. });