| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790 |
- /**
- * Function-as-value capture tests (#756) — registration-linking for callbacks.
- *
- * A function name used as a VALUE (passed as an argument, assigned to a
- * field/function pointer, placed in a struct/object initializer or function
- * table) must produce a `references` edge from the registration site to the
- * function, so `callers`/`impact` surface where a callback is wired up.
- *
- * Safety properties verified here, per the dynamic-dispatch discipline
- * ("a wrong edge is worse than none"):
- * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge
- * - same-file priority: a same-file definition beats a same-named decoy
- * - kind filter: a class/variable passed as a value never gets a
- * function-ref edge
- * - self: a function passing itself → no self-loop
- * - drain: all resolvable function_ref rows leave unresolved_refs (no
- * batched-resolver runaway), and re-index is idempotent
- */
- import { describe, it, expect, beforeAll, afterEach } from 'vitest';
- import * as fs from 'fs';
- import * as path from 'path';
- import * as os from 'os';
- import { CodeGraph } from '../src';
- import type { Edge } from '../src/types';
- import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
- beforeAll(async () => {
- await initGrammars();
- await loadAllGrammars();
- });
- /** Incoming edges to `name`'s node that came from function-as-value capture. */
- function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] {
- const targets = cg.getNodesByName(name);
- const edges: Edge[] = [];
- for (const t of targets) {
- for (const e of cg.getIncomingEdges(t.id)) {
- if (e.kind === 'references' && e.metadata?.fnRef === true) {
- edges.push(e);
- }
- }
- }
- return edges;
- }
- /** Names of the source nodes of the given edges, sorted. */
- function sourceNames(cg: CodeGraph, edges: Edge[]): string[] {
- const names: string[] = [];
- for (const e of edges) {
- const n = cg.getNode(e.source);
- if (n) names.push(n.name);
- }
- return names.sort();
- }
- describe('Function-as-value capture (#756)', () => {
- let tmpDir: string | undefined;
- afterEach(() => {
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
- tmpDir = undefined;
- });
- it('C: registration sites produce references edges (the #756 scenario)', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-'));
- fs.writeFileSync(
- path.join(tmpDir, 'driver.c'),
- [
- 'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };',
- 'typedef void (*cb_t)(int);',
- '',
- 'static void my_recv_cb(int x) { (void)x; }',
- 'static void my_send_cb(int x) { (void)x; }',
- '',
- 'void register_handler(void (*cb)(int)) { cb(1); }',
- '',
- 'void direct_caller(void) { my_recv_cb(5); }',
- '',
- 'void arg_registrar(void) { register_handler(my_recv_cb); }',
- 'void addr_registrar(void) { register_handler(&my_recv_cb); }',
- 'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }',
- '',
- 'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };',
- 'static cb_t cb_table[] = { my_recv_cb, my_send_cb };',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb');
- expect(sourceNames(cg, intoRecv)).toEqual([
- 'addr_registrar',
- 'arg_registrar',
- 'assign_registrar',
- 'driver.c', // file-scope: designated init + positional table (deduped per source)
- ]);
- // The direct call is still a `calls` edge — unchanged by this feature.
- const recv = cg.getNodesByName('my_recv_cb')[0]!;
- const callEdges = cg
- .getIncomingEdges(recv.id)
- .filter((e) => e.kind === 'calls');
- expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('TypeScript: arg / object / array / member / assignment forms', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.ts'),
- [
- 'export function targetCb(x: number): void { console.log(x); }',
- 'function registerHandler(cb: (x: number) => void): void { cb(1); }',
- '',
- 'export function argRegistrar(): void { registerHandler(targetCb); }',
- 'export function timerRegistrar(): void { setTimeout(targetCb, 100); }',
- 'export function objRegistrar(): unknown { return { recv: targetCb }; }',
- 'export function arrRegistrar(): unknown { return [targetCb]; }',
- '',
- 'class Emitter { cb: ((x: number) => void) | null = null; }',
- 'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }',
- '',
- 'interface Btn { on(ev: string, cb: () => void): void; }',
- 'export class Comp {',
- ' handleClick(): void {}',
- ' wire(btn: Btn): void { btn.on("click", this.handleClick); }',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([
- 'argRegistrar',
- 'arrRegistrar',
- 'assignRegistrar',
- 'objRegistrar',
- 'timerRegistrar',
- ]);
- // `this.handleClick` resolves class-scoped (#808): the target must be a
- // method of the ENCLOSING class, in the same file.
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('resolves an imported callback across files via its import', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-'));
- fs.writeFileSync(
- path.join(tmpDir, 'handlers.ts'),
- 'export function onMessage(x: number): void { console.log(x); }\n'
- );
- fs.writeFileSync(
- path.join(tmpDir, 'wiring.ts'),
- [
- "import { onMessage } from './handlers';",
- 'export function wire(bus: { on(cb: (x: number) => void): void }): void {',
- ' bus.on(onMessage);',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const edges = fnRefEdgesInto(cg, 'onMessage');
- expect(sourceNames(cg, edges)).toContain('wire');
- // The edge must target the handlers.ts definition.
- const target = cg.getNode(edges[0]!.target);
- expect(target?.filePath.endsWith('handlers.ts')).toBe(true);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-'));
- // Two same-named functions in different files…
- fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n');
- fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n');
- // …and a registrar that names `process` WITHOUT importing it. The name
- // still passes the extraction gate only if imported/defined here — it is
- // neither, so this asserts the gate; even if it leaked through, the
- // ambiguity rule (unique-only cross-file) must yield no edge.
- fs.writeFileSync(
- path.join(tmpDir, 'c.ts'),
- 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n'
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const edges = fnRefEdgesInto(cg, 'process');
- expect(sourceNames(cg, edges)).not.toContain('wire');
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-'));
- fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n');
- fs.writeFileSync(
- path.join(tmpDir, 'real.c'),
- [
- 'static void my_cb(int x) { (void)x; }',
- 'void register_handler(void (*cb)(int)) { cb(1); }',
- 'void wire(void) { register_handler(my_cb); }',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => {
- const src = cg.getNode(e.source);
- return src?.name === 'wire';
- });
- expect(wires).toHaveLength(1);
- const target = cg.getNode(wires[0]!.target);
- expect(target?.filePath.endsWith('real.c')).toBe(true);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.ts'),
- [
- 'export class Strategy { run(): void {} }',
- 'export function consume(x: unknown): void { void x; }',
- 'export function wire(): void { consume(Strategy); }',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!;
- const fnRef = cg
- .getIncomingEdges(strategy.id)
- .filter((e) => e.metadata?.fnRef === true);
- expect(fnRef).toHaveLength(0);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('SELF: a function registering itself produces no self-loop', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.ts'),
- [
- 'declare function schedule(cb: () => void): void;',
- 'export function retry(): void { schedule(retry); }',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const retry = cg.getNodesByName('retry')[0]!;
- const selfLoops = cg
- .getIncomingEdges(retry.id)
- .filter((e) => e.source === retry.id && e.metadata?.fnRef === true);
- expect(selfLoops).toHaveLength(0);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-'));
- fs.writeFileSync(
- path.join(tmpDir, 'widget.cpp'),
- [
- 'struct Widget {',
- ' void on_click(int x);',
- '};',
- 'void Widget::on_click(int x) { (void)x; }',
- 'struct Decoy {',
- ' void on_click(int x);',
- '};',
- 'void Decoy::on_click(int x) { (void)x; }',
- 'void free_cb(int x) { (void)x; }',
- 'void bare_fn(int x) { (void)x; }',
- 'void reg(void* p) { (void)p; }',
- 'void wire() {',
- ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy
- ' reg(p);',
- ' reg(&free_cb);', // explicit address-of — captured
- ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly)
- '}',
- // A method named like a local: passing the LOCAL must not resolve to
- // the method (cpp args accept only explicit & forms).
- 'struct Buf { char* out(); };',
- 'void copy_to(void* out_) { (void)out_; }',
- 'void caller(char* out) { copy_to(out); }',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- // Qualified member pointer resolves to Widget::on_click specifically.
- const onClicks = cg.getNodesByName('on_click');
- const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!;
- const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!;
- const intoWidget = cg
- .getIncomingEdges(widgetOnClick.id)
- .filter((e) => e.metadata?.fnRef === true);
- expect(intoWidget).toHaveLength(1);
- expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire');
- expect(
- cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true)
- ).toHaveLength(0);
- // Explicit &fn resolves; bare identifier in C++ args does NOT (the
- // generic-name collision class: fmt's `begin`/`out`/`size` params).
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire');
- expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0);
- // The local `out` param must NOT produce an edge to Buf::out.
- const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method');
- if (outMethod) {
- expect(
- cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true)
- ).toHaveLength(0);
- }
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('Pascal: := event wiring, @addr and bare args', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.pas'),
- [
- 'unit Main;',
- 'interface',
- 'type',
- ' TCallback = procedure(X: Integer);',
- ' THolder = class',
- ' public',
- ' OnFire: TCallback;',
- ' procedure Wire;',
- ' end;',
- 'procedure TargetCb(X: Integer);',
- 'procedure RegisterHandler(Cb: TCallback);',
- 'procedure ArgRegistrar;',
- 'procedure AddrRegistrar;',
- 'implementation',
- 'procedure TargetCb(X: Integer);',
- 'begin',
- ' WriteLn(X);',
- 'end;',
- 'procedure RegisterHandler(Cb: TCallback);',
- 'begin',
- ' Cb(1);',
- 'end;',
- 'procedure ArgRegistrar;',
- 'begin',
- ' RegisterHandler(TargetCb);',
- 'end;',
- 'procedure AddrRegistrar;',
- 'begin',
- ' RegisterHandler(@TargetCb);',
- 'end;',
- 'procedure THolder.Wire;',
- 'begin',
- ' OnFire := TargetCb;',
- 'end;',
- 'end.',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([
- 'AddrRegistrar',
- 'ArgRegistrar',
- 'Wire',
- ]);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.ts'),
- [
- 'declare const bus: { on(ev: string, cb: () => void): void };',
- // Decoy: a same-named method on an UNRELATED class.
- 'export class Decoy { refresh(): void {} }',
- 'export class Panel {',
- ' views: number[] = [];', // property (post-#808), shares no name
- ' refresh(): void {}',
- ' wire(): void {',
- ' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh
- ' bus.on("data", this.views as never);', // property → NO edge
- ' bus.on("gone", this.missing as never);', // unknown member → NO edge
- ' }',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const refreshes = cg.getNodesByName('refresh');
- const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!;
- const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!;
- const intoPanel = cg
- .getIncomingEdges(panelRefresh.id)
- .filter((e) => e.metadata?.fnRef === true);
- expect(intoPanel).toHaveLength(1);
- expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire');
- expect(
- cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true)
- ).toHaveLength(0);
- // The property and the unknown member produce nothing.
- const views = cg.getNodesByName('views').find((n) => n.kind === 'property');
- if (views) {
- expect(
- cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true)
- ).toHaveLength(0);
- }
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-'));
- fs.writeFileSync(
- path.join(tmpDir, 'base.ts'),
- 'export class FormBase { handleSubmit(): void {} }\n'
- );
- fs.writeFileSync(
- path.join(tmpDir, 'unrelated.ts'),
- 'export class Unrelated { handleSubmit(): void {} }\n'
- );
- fs.writeFileSync(
- path.join(tmpDir, 'login.ts'),
- [
- "import { FormBase } from './base';",
- 'declare const bus: { on(ev: string, cb: () => void): void };',
- 'export class LoginForm extends FormBase {',
- ' wire(): void { bus.on("submit", this.handleSubmit); }',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const handleSubmits = cg.getNodesByName('handleSubmit');
- const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!;
- const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!;
- const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true);
- expect(intoBase).toHaveLength(1);
- expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire');
- expect(
- cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true)
- ).toHaveLength(0);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-'));
- fs.writeFileSync(
- path.join(tmpDir, 'Handlers.java'),
- [
- 'package com.example;',
- 'public class Handlers {',
- ' public static void onMessage(int x) { System.out.println(x); }',
- '}',
- ].join('\n')
- );
- fs.writeFileSync(
- path.join(tmpDir, 'BaseForm.java'),
- ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n')
- );
- fs.writeFileSync(
- path.join(tmpDir, 'Main.java'),
- [
- 'package com.example;',
- 'import com.example.Handlers;',
- 'import java.util.function.IntConsumer;',
- 'public class Main extends BaseForm {',
- ' static void registerHandler(IntConsumer cb) { cb.accept(1); }',
- ' void run0() {}',
- ' void crossFile() { registerHandler(Handlers::onMessage); }',
- ' void thisRef() { registerHandler(this::run0); }',
- ' void superRef() { registerHandler(super::baseHandler); }',
- ' void varRef(Main m) { registerHandler(m::run0); }',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']);
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']);
- // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT
- // add a second edge — exactly one source.
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-'));
- // Same package, no imports — the Java/Kotlin reality the name gate can't
- // see, which is why qualified `Type::member` candidates skip it.
- fs.writeFileSync(
- path.join(tmpDir, 'Handlers.kt'),
- [
- 'class KtHandlers {',
- ' companion object {',
- ' fun handle(x: Int) {}',
- ' }',
- '}',
- 'class Decoy {',
- ' companion object {',
- ' fun handle(x: Int) {}',
- ' }',
- '}',
- ].join('\n')
- );
- fs.writeFileSync(
- path.join(tmpDir, 'Wirer.kt'),
- [
- 'fun register(cb: Any) {}',
- 'class Wirer {',
- ' fun wire() { register(KtHandlers::handle) }',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const handles = cg.getNodesByName('handle');
- const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!;
- const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!;
- const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true);
- expect(into).toHaveLength(1);
- expect(cg.getNode(into[0]!.source)?.name).toBe('wire');
- expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.swift'),
- [
- 'func register(_ cb: (Int) -> Void) { cb(1) }',
- 'class Monitor {',
- ' func report(_ x: Int) {}',
- ' func wire() { register(report) }', // implicit self → Monitor::report
- '}',
- 'class Other {',
- // `report` here is a PARAMETER; Monitor::report must not win.
- ' func use(report: (Int) -> Void) { register(report) }',
- '}',
- 'func topLevel() { register(report) }', // no implicit self → no method target
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const edges = fnRefEdgesInto(cg, 'report');
- expect(sourceNames(cg, edges)).toEqual(['wire']);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-'));
- // Handler defined in its own file…
- fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n');
- // …and registered in a table in ANOTHER file, with no import mechanism (C).
- fs.writeFileSync(
- path.join(tmpDir, 'server.c'),
- [
- 'struct cmd { const char *name; void (*proc)(int); };',
- 'static struct cmd commandTable[] = {',
- ' { "get", getCommand },',
- '};',
- ].join('\n')
- );
- // Ambiguity safety: two files define dupCmd; a third table references it →
- // NO edge (unique-or-drop).
- fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n');
- fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n');
- fs.writeFileSync(
- path.join(tmpDir, 'other.c'),
- [
- 'struct cmd2 { void (*proc)(int); };',
- 'static struct cmd2 otherTable[] = { { dupCmd } };',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- // Cross-file unique handler resolves from the table's file.
- const intoGet = fnRefEdgesInto(cg, 'getCommand');
- expect(sourceNames(cg, intoGet)).toEqual(['server.c']);
- const target = cg.getNode(intoGet[0]!.target);
- expect(target?.filePath.endsWith('t_string.c')).toBe(true);
- // Ambiguous handler resolves to NOTHING — silent beats wrong.
- expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-'));
- fs.writeFileSync(
- path.join(tmpDir, 'handlers.php'),
- "<?php\nfunction cmp_items($a, $b) { return $a <=> $b; }\n"
- );
- fs.writeFileSync(
- path.join(tmpDir, 'main.php'),
- [
- '<?php',
- 'class Saver {',
- ' public function onSave($x) {}',
- ' public function wire() {',
- " register_shutdown_function([$this, 'onSave']);",
- ' }',
- '}',
- 'class Loader {',
- ' public static function load($cls) {}',
- '}',
- 'function sorter($items) {',
- " usort($items, 'cmp_items');", // known HOF, cross-file string → edge
- " spl_autoload_register([Loader::class, 'load']);",
- " some_random_fn('cmp_items');", // NOT a known HOF → no edge
- ' return $items;',
- '}',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- // Exactly ONE source for cmp_items: the usort site, not some_random_fn.
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'cmp_items'))).toEqual(['sorter']);
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'onSave'))).toEqual(['wire']);
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'load'))).toEqual(['sorter']);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('RUBY HOOKS: before_action/rescue_from symbols resolve class-scoped incl. inherited; validates is excluded', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-'));
- fs.writeFileSync(
- path.join(tmpDir, 'posts_controller.rb'),
- [
- 'class ApplicationController',
- ' def authenticate; end',
- 'end',
- '',
- 'class PostsController < ApplicationController',
- ' before_action :authenticate', // inherited → ApplicationController
- ' after_save :reindex',
- ' validates :title, presence: true', // attributes, NOT methods → no edge
- ' rescue_from StandardError, with: :render_500',
- '',
- ' def reindex; end',
- ' def render_500; end',
- ' def title; end',
- 'end',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const auth = fnRefEdgesInto(cg, 'authenticate');
- expect(auth).toHaveLength(1);
- expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController');
- expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1);
- expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1);
- // `validates :title` names an attribute — the same-named METHOD must
- // get no registration edge.
- expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
- fs.writeFileSync(
- path.join(tmpDir, 'main.c'),
- [
- 'static void cb_a(int x) { (void)x; }',
- 'void reg(void (*cb)(int)) { cb(1); }',
- 'void wire(void) { reg(cb_a); }',
- ].join('\n')
- );
- const cg = CodeGraph.initSync(tmpDir);
- try {
- await cg.indexAll();
- const stats1 = cg.getStats();
- // No function_ref rows may linger for resolvable names — the batched
- // resolver must have drained them (delete keyed on the ORIGINAL stored
- // ref; the #760 runaway came from violating that).
- const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db;
- let leftover: unknown[] = [];
- try {
- leftover = db
- .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'")
- .all();
- } catch {
- // If internals aren't reachable this guard is covered by the edge
- // assertions below.
- }
- expect(leftover).toHaveLength(0);
- // Re-index: identical node/edge counts (idempotent, no accumulation).
- await cg.indexAll();
- const stats2 = cg.getStats();
- expect(stats2.totalNodes).toBe(stats1.totalNodes);
- expect(stats2.totalEdges).toBe(stats1.totalEdges);
- expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']);
- } finally {
- cg.destroy();
- tmpDir = undefined;
- }
- });
- });
|