Skip to content

False indirect_call edges (INFERRED 0.8): call arguments bind as callees to an unrelated single-letter test helper across files #2241

Description

@regiusti

Summary

When a function makes a call through a function-valued variable (campoFn(r), c.get(r), pred(r)) and an argument of that call is an identifier that shares its name with an unrelated symbol elsewhere in the repo, graphify emits an indirect_call edge from the calling function to that unrelated symbol, with confidence 0.8.

Single-letter identifiers make this explode: a file-local one-letter test helper (const r = ... inside *.test.ts) became the target of fabricated indirect_call edges from every function in the repo that passes a variable named r to a callback.

Version / environment

  • graphifyy 0.9.28 (PyPI), installed via uv tool install graphifyy==0.9.28
  • Windows 11, Python 3.13
  • graphify . --code-only (pure AST path, no LLM)

Minimal repro (2 files)

src/round.test.ts:

import { describe, it, expect } from 'vitest';

// Local one-letter rounding helper, private to this test file.
const r = (v: number) => Math.round(v * 100) / 100;

describe('rounding', () => {
  it('rounds to 2 decimals', () => {
    expect(r(1.005)).toBe(1.01);
  });
});

src/report.ts:

type Row = { t: string; v: number };
type ColDef = { label: string; get: (row: Row) => number };

// None of these functions reference the test helper `r` from round.test.ts.
// The only `r` below is a loop variable / arrow parameter used as an ARGUMENT
// to calls made through function-valued variables (dispatch).

export function serieTemporal(
  rows: Row[],
  bucketFn: (t: string) => string,
  campoFn: (row: Row) => number,
): Map<string, number[]> {
  const buckets = new Map<string, number[]>();
  for (const r of rows) {
    const k = bucketFn(r.t);
    const arr = buckets.get(k) ?? [];
    arr.push(campoFn(r));
    buckets.set(k, arr);
  }
  return buckets;
}

export function buildSheet(rows: Row[], cols: ColDef[]): Record<string, number>[] {
  return rows.map((r) => {
    const o: Record<string, number> = {};
    for (const c of cols) o[c.label] = c.get(r);
    return o;
  });
}

export function filterRows(rows: Row[], pred: (row: Row) => boolean): Row[] {
  const out: Row[] = [];
  for (const r of rows) if (pred(r)) out.push(r);
  return out;
}

Run:

graphify . --code-only

Observed

serieTemporal() --indirect_call--> r()  [INFERRED 0.8]
buildSheet()    --indirect_call--> r()  [INFERRED 0.8]
filterRows()    --indirect_call--> r()  [INFERRED 0.8]

Baseline control: the same two files without the variable-dispatch calls (plain rows.map((r) => r.v) / rows.filter((r) => ...) only) produce zero false edges — the trigger is specifically the indirect-call path picking up the argument identifier.

Expected

No edge at all. r in the callers is (a) an argument, never a callee, and (b) the target r is lexically file-local to a *.test.ts and not exported — it should not be a cross-file resolution candidate.

Real-world impact

In a private ~120-file TS/React repo (where I first hit this): 8 of the graph's 18 INFERRED edges were this exact fabrication pointing at one ghost node. That node received the graph's highest betweenness centrality (0.040), so GRAPH_REPORT.md promoted it to the top "Surprising Connections" entry and the #1 "Suggested Question" — the report's headline finding was an artifact.

Note these edges carry confidence 0.8, so filtering confidence_score == 0.5 (which helps with #2236-style fabrications) does not catch this class.

Related

Suggested direction

  1. Never treat call arguments as callee candidates for indirect_call.
  2. Resolve callees respecting lexical scope and imports (a non-exported test-file local should not be bindable cross-file).
  3. If fuzzy label binding is kept, require a minimum identifier length — or drastically lower the confidence for 1–2 character labels so they are filterable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions