From 5bb3e3b42e9a4f4ce8d11963f964d4f4e24fbd2f Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:50:58 +0300 Subject: [PATCH 1/3] feat: list callers and callees and highlight the active function on the maps Co-Authored-By: Claude Opus 5.5 --- apps/web/src/map-model.test.ts | 17 +++ apps/web/src/map-model.ts | 28 +++-- apps/web/src/overview-map.tsx | 35 +++++- apps/web/src/source-panel.tsx | 8 ++ apps/web/src/source-view.test.ts | 79 +++++++++++- apps/web/src/source-view.ts | 56 ++++++++- apps/web/src/world-app.tsx | 2 + apps/web/src/world-from-code.ts | 1 + apps/web/src/world-map-model.ts | 3 + apps/web/src/world-map-overlay.tsx | 4 + apps/web/src/world-map.test.tsx | 23 ++++ apps/web/src/world-map.tsx | 41 +++++- packages/code-graph/package.json | 1 + packages/code-graph/src/call-index.test.ts | 138 +++++++++++++++++++++ packages/code-graph/src/call-index.ts | 125 +++++++++++++++++++ 15 files changed, 539 insertions(+), 22 deletions(-) create mode 100644 packages/code-graph/src/call-index.test.ts create mode 100644 packages/code-graph/src/call-index.ts diff --git a/apps/web/src/map-model.test.ts b/apps/web/src/map-model.test.ts index 0391c6e..cf5e017 100644 --- a/apps/web/src/map-model.test.ts +++ b/apps/web/src/map-model.test.ts @@ -132,6 +132,23 @@ describe("mapModel", () => { expect(mapModel(world, new Set(), null).portals).toEqual([]); }); + it("marks the rooms of the unit the player is in", () => { + const everywhere = new Set(demo.rooms.map(({ room }) => room.id)); + const main = demo.rooms + .filter(({ room }) => room.cluster === "demo.ts::main") + .map(({ room }) => room.id); + expect(main.length).toBeGreaterThan(1); + const model = mapModel(demo, everywhere, main[0] ?? null, "demo.ts::main"); + expect( + model.rooms.filter((room) => room.active).map((room) => room.id) + ).toEqual(main); + expect(model.rooms.find((room) => room.id === "demo.ts")?.unit).toBe( + "demo.ts" + ); + const none = mapModel(demo, everywhere, main[0] ?? null); + expect(none.rooms.some((room) => room.active)).toBe(false); + }); + it("draws one area of a world of several: only its rooms", () => { const { codeGraph } = codeGraphOf(examples.repo); if (codeGraph === null) { diff --git a/apps/web/src/map-model.ts b/apps/web/src/map-model.ts index 6c3a012..5214f76 100644 --- a/apps/web/src/map-model.ts +++ b/apps/web/src/map-model.ts @@ -30,6 +30,10 @@ type MapRoom = { readonly visited: boolean; /** The room the player stands in. */ readonly current: boolean; + /** The function (cluster) it belongs to, or the room itself. */ + readonly unit: string; + /** It belongs to the unit the player is in: the function they walk through. */ + readonly active: boolean; }; type MapDoor = { @@ -95,7 +99,8 @@ const pointKey = ({ x, z }: Point): string => `${x.toFixed(3)},${z.toFixed(3)}`; const mapModel = ( world: BuiltWorld, visited: ReadonlySet, - current: string | null + current: string | null, + activeUnit: string | null = null ): MapModel => { const entered = world.rooms.filter(({ room }) => visited.has(room.id)); const seen = new Set( @@ -106,14 +111,19 @@ const mapModel = ( ); const rooms = world.rooms .filter(({ room }) => seen.has(room.id)) - .map(({ room }): MapRoom => ({ - id: room.id, - rect: roomBounds(room), - kind: room.kind, - lane: room.lane?.kind ?? null, - visited: visited.has(room.id), - current: room.id === current, - })); + .map(({ room }): MapRoom => { + const unit = room.cluster ?? room.id; + return { + id: room.id, + rect: roomBounds(room), + kind: room.kind, + lane: room.lane?.kind ?? null, + visited: visited.has(room.id), + current: room.id === current, + unit, + active: unit === activeUnit, + }; + }); const doors = new Map(); for (const { room, openings } of entered) { for (const opening of openings) { diff --git a/apps/web/src/overview-map.tsx b/apps/web/src/overview-map.tsx index b5a9be6..13df706 100644 --- a/apps/web/src/overview-map.tsx +++ b/apps/web/src/overview-map.tsx @@ -13,6 +13,8 @@ type OverviewMapProps = { readonly visited: ReadonlySet; /** The room the player stands in, or null between rooms. */ readonly current: string | null; + /** The function (or hub) the player is in: its rooms are outlined brighter. */ + readonly activeUnit: string | null; /** Where the player is; only the map re-renders when it moves. */ readonly feed: PoseFeed; }; @@ -38,6 +40,21 @@ const roomFill = (room: MapRoom): string => { return room.lane === null ? PLAIN : laneColour(room.lane); }; +/** The room stood in strongest, then the other rooms of its function. */ +const strokeOpacity = (room: MapRoom): number => { + if (room.current) { + return 0.9; + } + return room.active ? 0.7 : 0.35; +}; + +const strokeWidth = (room: MapRoom): number => { + if (room.current) { + return 1.5; + } + return room.active ? 1.25 : 1; +}; + /** A short bar on the wall, a little inside the room, along the frame. */ const portalBar = ({ point, normal }: MapPortal) => { const x = point.x + normal.x * 0.2; @@ -55,7 +72,13 @@ const portalBar = ({ point, normal }: MapPortal) => { * above round the player, north up. Screen y is world z, so north (-Z) is * already at the top. */ -const OverviewMap = ({ world, visited, current, feed }: OverviewMapProps) => { +const OverviewMap = ({ + world, + visited, + current, + activeUnit, + feed, +}: OverviewMapProps) => { const entry = world.areas.find((area) => area.id === world.entry) ?? world.areas[0]; const start = @@ -67,8 +90,10 @@ const OverviewMap = ({ world, visited, current, feed }: OverviewMapProps) => { const area = areaAt(world.areas, pose.position, null) ?? entry; const model = useMemo( () => - area === undefined ? EMPTY_MODEL : mapModel(area.built, visited, current), - [area, visited, current] + area === undefined + ? EMPTY_MODEL + : mapModel(area.built, visited, current, activeUnit), + [area, visited, current, activeUnit] ); const { x, z } = pose.position; const heading = @@ -92,8 +117,8 @@ const OverviewMap = ({ world, visited, current, feed }: OverviewMapProps) => { fill={roomFill(room)} fillOpacity={room.kind === "corridor" ? 0.2 : 0.35} stroke={PLAIN} - strokeOpacity={room.current ? 0.9 : 0.35} - strokeWidth={room.current ? 1.5 : 1} + strokeOpacity={strokeOpacity(room)} + strokeWidth={strokeWidth(room)} vectorEffect="non-scaling-stroke" /> ))} diff --git a/apps/web/src/source-panel.tsx b/apps/web/src/source-panel.tsx index 01398c4..d659cde 100644 --- a/apps/web/src/source-panel.tsx +++ b/apps/web/src/source-panel.tsx @@ -63,6 +63,14 @@ const SourcePanel = ({ view }: SourcePanelProps) => { ? `lines ${view.lines}` : `${view.region} · lines ${view.lines}`}

+ {view.callers.length > 0 && ( +

+ called by {view.callers.join(" · ")} +

+ )} + {view.callees.length > 0 && ( +

calls {view.callees.join(" · ")}

+ )}
         {view.code.map((line) => (
           
diff --git a/apps/web/src/source-view.test.ts b/apps/web/src/source-view.test.ts index e13847d..8bfac99 100644 --- a/apps/web/src/source-view.test.ts +++ b/apps/web/src/source-view.test.ts @@ -1,4 +1,5 @@ import type { CodeGraph, FlowStep } from "@repo/code-graph"; +import type { SourceFile } from "@repo/parser"; import { describe, expect, it } from "vitest"; import { examples } from "./examples"; @@ -7,15 +8,19 @@ import { sourceView } from "./source-view"; import type { SourceView } from "./source-view"; import { codeGraphOf } from "./world-from-code"; -/** Parses an example or fails the test with the parser's message. */ -const parsed = (name: "demo" | "loops") => { - const result = codeGraphOf(examples[name]); +/** Parses files or fails the test with the parser's message. */ +const parsedFiles = (files: readonly SourceFile[]) => { + const result = codeGraphOf(files); if (result.error !== null) { - throw new Error(`${name}: ${result.error}`); + throw new Error(`${files[0]?.path}: ${result.error}`); } return result; }; +/** Parses an example or fails the test with the parser's message. */ +const parsed = (name: "demo" | "loops" | "portals" | "resolution") => + parsedFiles(examples[name]); + /** The first top-level flow node of `fnId` of the given kind. */ const topLevel = ( graph: CodeGraph, @@ -123,3 +128,69 @@ describe("sourceView", () => { expectWholeLines(view, examples.loops[0].source); }); }); + +describe("sourceView calls", () => { + const demo = parsed("demo"); + + it("lists a function's callers and callees", () => { + expect( + sourceView(demo.codeGraph, demo.sources, "demo.ts::getUser") + ).toMatchObject({ callers: ["main()"], callees: ["loadSession()"] }); + }); + + it("lists the function's calls in a room of its flow too", () => { + const fork = topLevel(demo.codeGraph, "demo.ts::main", "branch"); + expect(sourceView(demo.codeGraph, demo.sources, fork.id)).toMatchObject({ + callers: [], + callees: ["getUser()", "showDashboard()", "showLogin()"], + }); + }); + + it("lists nothing for a hub", () => { + expect(sourceView(demo.codeGraph, demo.sources, "demo.ts")).toMatchObject({ + callers: [], + callees: [], + }); + }); + + it("marks a function calling itself", () => { + const portals = parsed("portals"); + expect( + sourceView(portals.codeGraph, portals.sources, "portals.ts::countdown") + ).toMatchObject({ + callers: ["countdown() ↻", "main()"], + callees: ["countdown() ↻"], + }); + }); + + it("names an ambiguous call's candidates as maybe", () => { + const resolution = parsed("resolution"); + expect( + sourceView( + resolution.codeGraph, + resolution.sources, + "resolution.ts::main" + )?.callees + ).toEqual([ + "pick()", + "settle()", + "maybe Circle.render() | Square.render()", + ]); + }); + + it("keeps five names and counts the rest", () => { + const names = ["a", "b", "c", "d", "e", "f", "g"]; + const fan = parsedFiles([ + { + path: "fan.ts", + source: [ + `function main() {\n${names.map((name) => ` ${name}();`).join("\n")}\n}`, + ...names.map((name) => `function ${name}() {}`), + ].join("\n"), + }, + ]); + expect( + sourceView(fan.codeGraph, fan.sources, "fan.ts::main")?.callees + ).toEqual(["a()", "b()", "c()", "d()", "e()", "+2"]); + }); +}); diff --git a/apps/web/src/source-view.ts b/apps/web/src/source-view.ts index 2b92061..afd8ed1 100644 --- a/apps/web/src/source-view.ts +++ b/apps/web/src/source-view.ts @@ -1,9 +1,14 @@ -import type { CodeGraph, SourceSpan } from "@repo/code-graph"; +import type { CodeGraph, FunctionNode, SourceSpan } from "@repo/code-graph"; +import { callIndexOf } from "@repo/code-graph/call-index"; +import type { CallLink } from "@repo/code-graph/call-index"; import { roomSubject } from "@repo/code-graph/subjects"; +import { labelOf } from "~/world-from-code"; + // The source panel's model, kept free of React so it runs under vitest: the // code of the room the player stands in, as whole lines of its function (or -// of its file, for a hub), with the room's own code marked. +// of its file, for a hub), with the room's own code marked, and who calls +// that function and whom it calls. /** One line of source, split around the marked part. */ type SourceLine = { @@ -24,6 +29,46 @@ type SourceView = { /** `4–8`: the marked lines, else the lines shown. */ readonly lines: string; readonly code: readonly SourceLine[]; + /** The function's callers, `count() ↻` for itself; none for a hub or a lobby. */ + readonly callers: readonly string[]; + /** Whom it calls, then `maybe a() | b()` per ambiguous call; cut at `+n`. */ + readonly callees: readonly string[]; +}; + +/** Names a call list shows before it ends in `+n`. */ +const CALL_LIST_MAX = 5; + +const NO_CALLS: readonly string[] = []; + +/** The first `CALL_LIST_MAX` entries, then how many more there are. */ +const cut = (entries: readonly string[]): readonly string[] => + entries.length > CALL_LIST_MAX + ? [...entries.slice(0, CALL_LIST_MAX), `+${entries.length - CALL_LIST_MAX}`] + : entries; + +/** `Class.method()` and, for a function calling itself, `↻`. */ +const linkLabel = (codeGraph: CodeGraph, link: CallLink): string => { + const label = labelOf(codeGraph, link.fn.id); + return link.recursive ? `${label} ↻` : label; +}; + +const callsOf = (codeGraph: CodeGraph, fn: FunctionNode) => { + const index = callIndexOf(codeGraph); + const maybe = index + .maybeCalleesOf(fn.id) + .map( + (group) => + `maybe ${group.map((candidate) => labelOf(codeGraph, candidate.id)).join(" | ")}` + ); + return { + callers: cut( + index.callersOf(fn.id).map((link) => linkLabel(codeGraph, link)) + ), + callees: cut([ + ...index.calleesOf(fn.id).map((link) => linkLabel(codeGraph, link)), + ...maybe, + ]), + }; }; type Range = { readonly start: number; readonly end: number }; @@ -92,7 +137,7 @@ const sourceView = ( const subject = found?.kind === "entrance" || found?.kind === "zone" ? null : found; const source = subject === null ? undefined : sources.get(subject.module.id); - if (subject === null || source === undefined) { + if (codeGraph === null || subject === null || source === undefined) { return null; } if (subject.kind === "module") { @@ -103,6 +148,8 @@ const sourceView = ( region: null, lines: lineRange(1, code.length), code, + callers: NO_CALLS, + callees: NO_CALLS, }; } if (subject.kind === "class") { @@ -113,12 +160,15 @@ const sourceView = ( region: null, lines: lineRange(span.startLine, span.endLine), code: linesOf(source, span.startLine, span.endLine, null), + callers: NO_CALLS, + callees: NO_CALLS, }; } const { fn } = subject; const head = { path: subject.module.path, fn: `${fn.qualifiedName}()`, + ...callsOf(codeGraph, fn), }; if (subject.kind === "function") { return { diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx index a897ed5..bbaa9f9 100644 --- a/apps/web/src/world-app.tsx +++ b/apps/web/src/world-app.tsx @@ -198,6 +198,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { world={generated.world} visited={visitedIn(visits, seed)} current={roomId} + activeUnit={navigation.roomId} feed={feed} /> )} @@ -236,6 +237,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { labelOf={labelOfArea} links={areaLinks} feed={feed} + activeUnit={navigation.roomId} locked={locked} /> )} diff --git a/apps/web/src/world-from-code.ts b/apps/web/src/world-from-code.ts index 03542e7..13c76a4 100644 --- a/apps/web/src/world-from-code.ts +++ b/apps/web/src/world-from-code.ts @@ -313,6 +313,7 @@ export { breadcrumbOf, codeGraphOf, describeRoom, + labelOf, promptOf, sourceSeedOf, worldFromCode, diff --git a/apps/web/src/world-map-model.ts b/apps/web/src/world-map-model.ts index 88e557d..3c4c53c 100644 --- a/apps/web/src/world-map-model.ts +++ b/apps/web/src/world-map-model.ts @@ -24,6 +24,8 @@ type MapAreaRoom = { readonly id: string; readonly kind: RoomKind; readonly rect: Rect; + /** The function (cluster) it belongs to, or the room itself. */ + readonly unit: string; }; type MapArea = { @@ -142,6 +144,7 @@ const generatedArea = ( id: room.id, kind: room.kind, rect: roomBounds(room), + unit: room.cluster ?? room.id, })), error: null, entry: area.id === world.entry, diff --git a/apps/web/src/world-map-overlay.tsx b/apps/web/src/world-map-overlay.tsx index 99bf947..c222d07 100644 --- a/apps/web/src/world-map-overlay.tsx +++ b/apps/web/src/world-map-overlay.tsx @@ -18,6 +18,8 @@ type WorldMapOverlayProps = { readonly links: readonly AreaLink[]; /** Where the player is; only the marker follows it. */ readonly feed: PoseFeed; + /** The function (or hub) the player is in, drawn brighter. */ + readonly activeUnit: string | null; /** Walking: the locked pointer sends the map no wheel or drag. */ readonly locked: boolean; }; @@ -39,6 +41,7 @@ const WorldMapOverlay = ({ labelOf, links, feed, + activeUnit, locked, }: WorldMapOverlayProps) => { const model = useMemo( @@ -74,6 +77,7 @@ const WorldMapOverlay = ({ svgRef={view.ref} model={model} player={player} + activeUnit={activeUnit} done={world.pending.length === 0} />
diff --git a/apps/web/src/world-map.test.tsx b/apps/web/src/world-map.test.tsx index 0a279f0..814acb8 100644 --- a/apps/web/src/world-map.test.tsx +++ b/apps/web/src/world-map.test.tsx @@ -157,6 +157,29 @@ describe("WorldMap", () => { expect(near).not.toMatch(/ { + const store = codeAreaStore(workspace(), 1); + store.ensureAll(); + const model = worldMapModel(store.snapshot().world); + const unit = "apps/web/src/main.ts::main"; + const rooms = model.areas.flatMap((area) => + area.rooms.filter((room) => room.unit === unit) + ); + expect(rooms.length).toBeGreaterThan(0); + const markup = renderToStaticMarkup( + + ); + const group = new RegExp(`(.*?)`).exec( + markup + )?.[1]; + expect(countOf(group ?? "", ") + ).not.toContain("data-active-unit"); + }); + it("draws zone names larger as the view widens", () => { const store = codeAreaStore(workspace(), 1); store.ensureAll(); diff --git a/apps/web/src/world-map.tsx b/apps/web/src/world-map.tsx index bd0f6f8..81888aa 100644 --- a/apps/web/src/world-map.tsx +++ b/apps/web/src/world-map.tsx @@ -1,5 +1,5 @@ import type { Rect } from "@repo/types"; -import { memo, useId } from "react"; +import { memo, useId, useMemo } from "react"; import type { Ref } from "react"; import { PORTAL_COLOURS } from "~/map-model"; @@ -13,6 +13,8 @@ type WorldMapProps = { readonly model: WorldMapModel; /** Drawn on its own, so following the player redraws only the marker. */ readonly player: MapPlayer | null; + /** The function (or hub) the player is in: its rooms are drawn brighter. */ + readonly activeUnit?: string | null; /** Every area generated or given up on: `data-state="done"`. */ readonly done: boolean; /** What part of the world shows; the whole of it by default. */ @@ -146,6 +148,41 @@ const Layers = ({ model, clip }: LayersProps) => { */ const MapLayers = memo(Layers); +type ActiveRoomsProps = { + readonly model: WorldMapModel; + readonly unit: string; +}; + +/** + * The rooms of the unit the player is in, over the layers. Not map detail, + * so it still shows zoomed out, and apart from the memoised layers, so + * walking into another function redraws only these few rects. + */ +const ActiveRooms = ({ model, unit }: ActiveRoomsProps) => { + const rooms = useMemo( + () => + model.areas.flatMap((area) => + area.rooms.filter((room) => room.unit === unit) + ), + [model, unit] + ); + return ( + + {rooms.map((room) => ( + + ))} + + ); +}; + type ZoneNamesProps = { readonly model: WorldMapModel; /** Width of the view in metres: names grow as the map zooms out. */ @@ -196,6 +233,7 @@ const ZoneNames = ({ model, viewWidth }: ZoneNamesProps) => { const WorldMap = ({ model, player, + activeUnit = null, done, viewBox, svgRef, @@ -230,6 +268,7 @@ const WorldMap = ({ fill={BACKGROUND} /> + {activeUnit !== null && } {player !== null && ( + links.map((link) => link.fn.qualifiedName); + +const groupNames = (groups: readonly (readonly FunctionNode[])[]) => + groups.map((group) => group.map((fn) => fn.qualifiedName)); + +/** `fnId` calls one of `candidates`, which the analysis cannot tell apart. */ +const ambiguous = ( + graph: CodeGraph, + fnId: string, + offset: number, + candidates: readonly string[] +): CallSite => { + const caller = graph.functions.find((fn) => fn.id === fnId); + if (caller === undefined) { + throw new Error(`No function "${fnId}"`); + } + return { + id: `${fnId}@${caller.span.start + offset}`, + callerId: fnId, + calleeName: "shape.render", + calleeId: null, + resolution: "ambiguous", + candidateIds: candidates.map((name) => `shapes.ts::${name}`), + kind: "call", + awaited: false, + span: caller.span, + }; +}; + +/** `draw` makes two ambiguous calls over the same shapes, `paint` one over others. */ +const shapes = (): CodeGraph => { + const graph = fixtureGraph({ + path: "shapes.ts", + functions: [ + { name: "draw" }, + { name: "paint" }, + { name: "Square.render" }, + { name: "Circle.render" }, + { name: "Line.render" }, + ], + }); + return { + ...graph, + callSites: [ + ...graph.callSites, + ambiguous(graph, "shapes.ts::draw", 1, [ + "Square.render", + "Circle.render", + ]), + ambiguous(graph, "shapes.ts::draw", 2, [ + "Circle.render", + "Square.render", + ]), + ambiguous(graph, "shapes.ts::draw", 3, ["Line.render", "Circle.render"]), + ambiguous(graph, "shapes.ts::paint", 1, ["Line.render", "missing"]), + ], + }; +}; + +describe("callIndexOf", () => { + it("lists a function's callers and callees from the call edges", () => { + const index = callIndexOf(demoGraph()); + expect(names(index.calleesOf("demo.ts::main"))).toEqual([ + "getUser", + "showDashboard", + "showLogin", + ]); + expect(names(index.callersOf("demo.ts::getUser"))).toEqual(["main"]); + expect(names(index.calleesOf("demo.ts::getUser"))).toEqual(["loadSession"]); + expect(index.callersOf("demo.ts::main")).toEqual([]); + }); + + it("sorts by qualified name, not call order", () => { + const index = callIndexOf( + fixtureGraph({ + path: "a.ts", + functions: [ + { name: "zeta", calls: ["omega"] }, + { name: "alpha", calls: ["omega"] }, + { name: "omega" }, + ], + }) + ); + expect(names(index.callersOf("a.ts::omega"))).toEqual(["alpha", "zeta"]); + }); + + it("flags a function calling itself on both sides", () => { + const index = callIndexOf( + fixtureGraph({ + path: "a.ts", + functions: [ + { name: "main", calls: ["count"] }, + { name: "count", calls: ["count"] }, + ], + }) + ); + expect(index.callersOf("a.ts::count")).toEqual([ + expect.objectContaining({ recursive: true }), + expect.objectContaining({ recursive: false }), + ]); + expect(names(index.callersOf("a.ts::count"))).toEqual(["count", "main"]); + expect(index.calleesOf("a.ts::count")).toEqual([ + expect.objectContaining({ recursive: true }), + ]); + }); + + it("groups an ambiguous call's candidates, once per set", () => { + const index = callIndexOf(shapes()); + expect(groupNames(index.maybeCalleesOf("shapes.ts::draw"))).toEqual([ + ["Circle.render", "Line.render"], + ["Circle.render", "Square.render"], + ]); + // An unknown candidate leaves one: no choice left to show. + expect(index.maybeCalleesOf("shapes.ts::paint")).toEqual([]); + expect(index.calleesOf("shapes.ts::draw")).toEqual([]); + }); + + it("knows nothing of an unknown function", () => { + const index = callIndexOf(demoGraph()); + expect(index.callersOf("nope")).toEqual([]); + expect(index.calleesOf("nope")).toEqual([]); + expect(index.maybeCalleesOf("nope")).toEqual([]); + }); + + it("builds one index per graph", () => { + const graph = demoGraph(); + expect(callIndexOf(graph)).toBe(callIndexOf(graph)); + expect(callIndexOf(demoGraph())).not.toBe(callIndexOf(graph)); + }); +}); diff --git a/packages/code-graph/src/call-index.ts b/packages/code-graph/src/call-index.ts new file mode 100644 index 0000000..e3d5abb --- /dev/null +++ b/packages/code-graph/src/call-index.ts @@ -0,0 +1,125 @@ +// Who calls a function and whom it calls, from the graph's call edges, plus +// the functions an ambiguous call of it may reach. For the source panel's +// lists; the world itself still follows resolved calls only. + +import type { CodeGraph, FunctionNode } from "./code-graph"; + +/** One function on the other end of a call; `recursive` when it is the same one. */ +type CallLink = { + readonly fn: FunctionNode; + readonly recursive: boolean; +}; + +type CallIndex = { + /** The functions calling this one, by qualified name. */ + readonly callersOf: (fnId: string) => readonly CallLink[]; + /** The functions this one calls, by qualified name. */ + readonly calleesOf: (fnId: string) => readonly CallLink[]; + /** + * One group per distinct set of candidates of this function's ambiguous + * calls, each by qualified name, the groups by their first member. + */ + readonly maybeCalleesOf: ( + fnId: string + ) => readonly (readonly FunctionNode[])[]; +}; + +const NONE: readonly never[] = []; + +const byName = (a: FunctionNode, b: FunctionNode): number => + a.qualifiedName === b.qualifiedName + ? a.id.localeCompare(b.id) + : a.qualifiedName.localeCompare(b.qualifiedName); + +/** Member by member, then the shorter first. */ +const byMembers = ( + a: readonly FunctionNode[], + b: readonly FunctionNode[] +): number => { + for (const [index, fn] of a.entries()) { + const other = b[index]; + if (other === undefined) { + return 1; + } + const order = byName(fn, other); + if (order !== 0) { + return order; + } + } + return a.length - b.length; +}; + +const pushTo = (map: Map, key: string, value: T) => { + const list = map.get(key); + if (list === undefined) { + map.set(key, [value]); + } else { + list.push(value); + } +}; + +/** One index per graph. */ +const known = new WeakMap(); + +const callIndexOf = (graph: CodeGraph): CallIndex => { + const found = known.get(graph); + if (found !== undefined) { + return found; + } + const functions = new Map(graph.functions.map((fn) => [fn.id, fn])); + const callers = new Map(); + const callees = new Map(); + for (const edge of graph.edges) { + const source = functions.get(edge.source); + const target = functions.get(edge.target); + if (edge.type !== "call" || source === undefined || target === undefined) { + continue; + } + const recursive = source === target; + pushTo(callers, target.id, { fn: source, recursive }); + pushTo(callees, source.id, { fn: target, recursive }); + } + const maybe = new Map>(); + for (const site of graph.callSites) { + if (site.resolution !== "ambiguous" || !functions.has(site.callerId)) { + continue; + } + const group = (site.candidateIds ?? []) + .flatMap((id) => { + const fn = functions.get(id); + return fn === undefined ? [] : [fn]; + }) + .toSorted(byName); + const unique = group.filter((fn, index) => group[index - 1] !== fn); + if (unique.length < 2) { + continue; + } + const groups = + maybe.get(site.callerId) ?? new Map(); + groups.set(unique.map((fn) => fn.id).join("\n"), unique); + maybe.set(site.callerId, groups); + } + const sorted = (links: readonly CallLink[]) => + links.toSorted((a, b) => byName(a.fn, b.fn)); + for (const [id, links] of callers) { + callers.set(id, sorted(links)); + } + for (const [id, links] of callees) { + callees.set(id, sorted(links)); + } + const index: CallIndex = { + callersOf: (fnId) => callers.get(fnId) ?? NONE, + calleesOf: (fnId) => callees.get(fnId) ?? NONE, + maybeCalleesOf: (fnId) => { + const groups = maybe.get(fnId); + return groups === undefined + ? NONE + : [...groups.values()].toSorted(byMembers); + }, + }; + known.set(graph, index); + return index; +}; + +export { callIndexOf }; +export type { CallIndex, CallLink }; From 3ee61c60a2e3c1b1dc4b79434d6529cfc701e25e Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:58:31 +0300 Subject: [PATCH 2/3] fix: address review findings Co-Authored-By: Claude Opus 5.5 --- apps/web/src/source-view.test.ts | 14 ++++++++ apps/web/src/source-view.ts | 62 ++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/apps/web/src/source-view.test.ts b/apps/web/src/source-view.test.ts index 8bfac99..4e17952 100644 --- a/apps/web/src/source-view.test.ts +++ b/apps/web/src/source-view.test.ts @@ -178,6 +178,20 @@ describe("sourceView calls", () => { ]); }); + it("names the file of callers that share a name", () => { + const caller = { + source: 'import { run } from "./lib";\nexport function main() { run(); }', + }; + const shared = parsedFiles([ + { path: "a.ts", ...caller }, + { path: "b.ts", ...caller }, + { path: "lib.ts", source: "export function run() {}" }, + ]); + expect( + sourceView(shared.codeGraph, shared.sources, "lib.ts::run")?.callers + ).toEqual(["a.ts · main()", "b.ts · main()"]); + }); + it("keeps five names and counts the rest", () => { const names = ["a", "b", "c", "d", "e", "f", "g"]; const fan = parsedFiles([ diff --git a/apps/web/src/source-view.ts b/apps/web/src/source-view.ts index afd8ed1..3af302d 100644 --- a/apps/web/src/source-view.ts +++ b/apps/web/src/source-view.ts @@ -40,33 +40,51 @@ const CALL_LIST_MAX = 5; const NO_CALLS: readonly string[] = []; -/** The first `CALL_LIST_MAX` entries, then how many more there are. */ -const cut = (entries: readonly string[]): readonly string[] => - entries.length > CALL_LIST_MAX - ? [...entries.slice(0, CALL_LIST_MAX), `+${entries.length - CALL_LIST_MAX}`] - : entries; - -/** `Class.method()` and, for a function calling itself, `↻`. */ -const linkLabel = (codeGraph: CodeGraph, link: CallLink): string => { - const label = labelOf(codeGraph, link.fn.id); - return link.recursive ? `${label} ↻` : label; +/** A call or, for an ambiguous one, every function it may reach. */ +type CallEntry = CallLink | readonly FunctionNode[]; + +const functionsIn = (entry: CallEntry): readonly FunctionNode[] => + "fn" in entry ? [entry.fn] : entry; + +/** + * The first `CALL_LIST_MAX` entries, then how many more there are. Only + * those shown are labelled, so a function called from hundreds of places + * costs no more than one called from five. `Class.method()`, with its file + * (`src/a.ts · main()`) when another function shown shares the name; `↻` + * for a function calling itself, `maybe a() | b()` for an ambiguous call. + */ +const callList = ( + codeGraph: CodeGraph, + entries: readonly CallEntry[] +): readonly string[] => { + const shown = entries.slice(0, CALL_LIST_MAX); + const counts = new Map(); + for (const fn of shown.flatMap(functionsIn)) { + counts.set(fn.qualifiedName, (counts.get(fn.qualifiedName) ?? 0) + 1); + } + const name = (fn: FunctionNode): string => { + const label = labelOf(codeGraph, fn.id); + return (counts.get(fn.qualifiedName) ?? 0) > 1 + ? `${fn.moduleId} · ${label}` + : label; + }; + const labels = shown.map((entry) => { + if (!("fn" in entry)) { + return `maybe ${entry.map(name).join(" | ")}`; + } + return entry.recursive ? `${name(entry.fn)} ↻` : name(entry.fn); + }); + const hidden = entries.length - shown.length; + return hidden > 0 ? [...labels, `+${hidden}`] : labels; }; const callsOf = (codeGraph: CodeGraph, fn: FunctionNode) => { const index = callIndexOf(codeGraph); - const maybe = index - .maybeCalleesOf(fn.id) - .map( - (group) => - `maybe ${group.map((candidate) => labelOf(codeGraph, candidate.id)).join(" | ")}` - ); return { - callers: cut( - index.callersOf(fn.id).map((link) => linkLabel(codeGraph, link)) - ), - callees: cut([ - ...index.calleesOf(fn.id).map((link) => linkLabel(codeGraph, link)), - ...maybe, + callers: callList(codeGraph, index.callersOf(fn.id)), + callees: callList(codeGraph, [ + ...index.calleesOf(fn.id), + ...index.maybeCalleesOf(fn.id), ]), }; }; From d838e127e108ab1a1a6089304e5ced858b487862 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:08:27 +0300 Subject: [PATCH 3/3] fix: address PR review feedback Co-Authored-By: Claude Opus 5.5 --- apps/web/src/source-view.test.ts | 18 ++++++++++++++++++ apps/web/src/source-view.ts | 18 +++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/src/source-view.test.ts b/apps/web/src/source-view.test.ts index 4e17952..4fa9564 100644 --- a/apps/web/src/source-view.test.ts +++ b/apps/web/src/source-view.test.ts @@ -192,6 +192,24 @@ describe("sourceView calls", () => { ).toEqual(["a.ts · main()", "b.ts · main()"]); }); + it("keeps five candidates of an ambiguous call and counts the rest", () => { + const shapes = ["A", "B", "C", "D", "E", "F", "G"]; + const many = parsedFiles([ + { + path: "many.ts", + source: [ + ...shapes.map((shape) => `class ${shape} {\n render() {}\n}`), + `function main(shape: ${shapes.join(" | ")}) {\n shape.render();\n}`, + ].join("\n"), + }, + ]); + expect( + sourceView(many.codeGraph, many.sources, "many.ts::main")?.callees + ).toEqual([ + "maybe A.render() | B.render() | C.render() | D.render() | E.render() | +2", + ]); + }); + it("keeps five names and counts the rest", () => { const names = ["a", "b", "c", "d", "e", "f", "g"]; const fan = parsedFiles([ diff --git a/apps/web/src/source-view.ts b/apps/web/src/source-view.ts index 3af302d..9deaac5 100644 --- a/apps/web/src/source-view.ts +++ b/apps/web/src/source-view.ts @@ -43,15 +43,17 @@ const NO_CALLS: readonly string[] = []; /** A call or, for an ambiguous one, every function it may reach. */ type CallEntry = CallLink | readonly FunctionNode[]; +/** An ambiguous call's candidates are cut at `CALL_LIST_MAX` too. */ const functionsIn = (entry: CallEntry): readonly FunctionNode[] => - "fn" in entry ? [entry.fn] : entry; + "fn" in entry ? [entry.fn] : entry.slice(0, CALL_LIST_MAX); /** - * The first `CALL_LIST_MAX` entries, then how many more there are. Only - * those shown are labelled, so a function called from hundreds of places - * costs no more than one called from five. `Class.method()`, with its file - * (`src/a.ts · main()`) when another function shown shares the name; `↻` - * for a function calling itself, `maybe a() | b()` for an ambiguous call. + * The first `CALL_LIST_MAX` entries, then how many more there are, and as + * many candidates of an ambiguous call. Only those shown are labelled, so + * a function called from hundreds of places costs no more than one called + * from five. `Class.method()`, with its file (`src/a.ts · main()`) when + * another function shown shares the name; `↻` for a function calling + * itself, `maybe a() | b() | +n` for an ambiguous call. */ const callList = ( codeGraph: CodeGraph, @@ -70,7 +72,9 @@ const callList = ( }; const labels = shown.map((entry) => { if (!("fn" in entry)) { - return `maybe ${entry.map(name).join(" | ")}`; + const candidates = functionsIn(entry).map(name); + const more = entry.length - candidates.length; + return `maybe ${[...candidates, ...(more > 0 ? [`+${more}`] : [])].join(" | ")}`; } return entry.recursive ? `${name(entry.fn)} ↻` : name(entry.fn); });