From 4178db7c05c8fc38efa0f2b91e61bef5b5881362 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:24:59 +0300 Subject: [PATCH 1/3] feat: hang top-level class methods off a class lobby Co-Authored-By: Claude Opus 5.5 --- apps/web/AGENTS.md | 7 +- apps/web/src/source-view.ts | 14 +- apps/web/src/world-from-code-lobbies.test.ts | 97 ++++++++ apps/web/src/world-from-code.ts | 11 +- packages/code-graph/README.md | 14 +- packages/code-graph/src/code-graph.ts | 21 ++ packages/code-graph/src/entrance.ts | 2 +- packages/code-graph/src/fixture.ts | 57 ++++- packages/code-graph/src/flow-markers.test.ts | 1 + packages/code-graph/src/hub-plan.ts | 214 ++++++++++++++++++ packages/code-graph/src/ids.test.ts | 47 ++++ packages/code-graph/src/ids.ts | 46 +++- packages/code-graph/src/module-areas.test.ts | 2 +- packages/code-graph/src/subjects.test.ts | 24 +- packages/code-graph/src/subjects.ts | 41 +++- .../src/world-graph-lobbies.test.ts | 198 ++++++++++++++++ packages/code-graph/src/world-graph.ts | 108 ++------- packages/parser/README.md | 7 + packages/parser/src/functions.ts | 25 +- packages/parser/src/parser.test.ts | 52 ++++- packages/parser/src/parser.ts | 5 + 21 files changed, 875 insertions(+), 118 deletions(-) create mode 100644 apps/web/src/world-from-code-lobbies.test.ts create mode 100644 packages/code-graph/src/hub-plan.ts create mode 100644 packages/code-graph/src/world-graph-lobbies.test.ts diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index ca8332b..0fd868d 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -113,7 +113,7 @@ external, x.render(…) ambiguous: A.render() | B.render()`). Choosing (`→ src/server.ts`), which empties the breadcrumb like walking into a hub. The HUD counts the modules. Areas are still built all at once and merged for the renderer; generating them on approach comes later. -- Milestone 16 (current): repository entrance. Every code world starts in +- Milestone 16: repository entrance. Every code world starts in an entrance hall named after the repository's directory (`src`), facing a module portal to its first entry module: every file nobody imports, ranked by name (`index`, `main`, `server`, `app`, `cli`), unused exports @@ -121,6 +121,11 @@ external, x.render(…) ambiguous: A.render() | B.render()`). Choosing every file can be walked to. `repo` has one, `→ src/index.ts`; a single-file example has its file. R returns to the entrance; the source panel shows nothing there. +- Milestone 17 (current): class lobbies. The methods of a top-level class + that hang off the hub open off a lobby of their own instead (`class +UserService`), one door of the module hub; methods reached by a call door + keep it. The HUD reads `service.ts · class UserService` and the source + panel shows the whole class. --- diff --git a/apps/web/src/source-view.ts b/apps/web/src/source-view.ts index 943a561..3474013 100644 --- a/apps/web/src/source-view.ts +++ b/apps/web/src/source-view.ts @@ -17,7 +17,7 @@ type SourceLine = { type SourceView = { readonly path: string; - /** `Class.method()`; null for a hub. */ + /** `Class.method()`, `class Svc` for a lobby; null for a hub. */ readonly fn: string | null; /** The room's HUD words (`await fetch(…)`); null for a hub or a function. */ readonly region: string | null; @@ -76,7 +76,7 @@ const clamp = (span: SourceSpan, within: SourceSpan): Range | null => { /** * What the source panel shows for a room: the whole file for a hub, the - * whole function for a function room, and the function with the room's + * whole class for a lobby, the whole function for a function room, and the function with the room's * code marked for a flow room. Null for the entrance (no file of its * own), corridors and ids not from this graph. */ @@ -104,6 +104,16 @@ const sourceView = ( code, }; } + if (subject.kind === "class") { + const { span } = subject.node; + return { + path: subject.module.path, + fn: `class ${subject.node.name}`, + region: null, + lines: lineRange(span.startLine, span.endLine), + code: linesOf(source, span.startLine, span.endLine, null), + }; + } const { fn } = subject; const head = { path: subject.module.path, diff --git a/apps/web/src/world-from-code-lobbies.test.ts b/apps/web/src/world-from-code-lobbies.test.ts new file mode 100644 index 0000000..ba82850 --- /dev/null +++ b/apps/web/src/world-from-code-lobbies.test.ts @@ -0,0 +1,97 @@ +import type { GeneratedWorld } from "@repo/types"; +import { describe, expect, it } from "vitest"; + +import type { ExampleName } from "./examples"; +import { sourceView } from "./source-view"; +import { + breadcrumbOf, + codeGraphOf, + describeRoom, + promptOf, + worldFromCode, +} from "./world-from-code"; + +/** Parses an example or fails the test with the parser's message. */ +const parsed = (name: ExampleName) => { + const result = codeGraphOf(name); + if (result.error !== null) { + throw new Error(`${name}: ${result.error}`); + } + return result; +}; + +const lobbiesOf = (world: GeneratedWorld) => + world.graph.rooms + .filter((room) => room.id.includes("//class:")) + .map((room) => room.id); + +/** What the room opens onto through plain doors. */ +const doorsOf = (world: GeneratedWorld, roomId: string) => + world.graph.connections + .filter(({ from, kind }) => from === roomId && kind === undefined) + .map(({ to }) => to); + +describe("class lobbies", () => { + it("opens the service example's hub onto main and the UserService lobby", () => { + const { codeGraph, sources } = parsed("service"); + const world = worldFromCode(codeGraph, 1); + const lobby = "service.ts//class:UserService"; + expect(lobbiesOf(world)).toEqual([lobby]); + expect(doorsOf(world, "service.ts")).toEqual([lobby, "service.ts::main"]); + // create and the constructor are reached by doors from main; load by nobody. + expect(doorsOf(world, lobby)).toEqual(["service.ts::UserService.load"]); + expect(describeRoom(codeGraph, lobby)).toBe( + "service.ts · class UserService" + ); + expect( + promptOf(codeGraph, [], { + kind: "door", + roomId: "service.ts", + targetRoomId: lobby, + }) + ).toBe("→ class UserService"); + expect(breadcrumbOf(codeGraph, [], lobby)).toBe("class UserService"); + const view = sourceView(codeGraph, sources, lobby); + const classLines = (sources.get("service.ts") ?? "") + .split("\n") + .findIndex((line) => line === "}"); + expect(view).toMatchObject({ + path: "service.ts", + fn: "class UserService", + region: null, + lines: `1–${classLines + 1}`, + }); + const first = view?.code[0]; + expect(`${first?.before}${first?.marked}${first?.after}`).toBe( + "class UserService {" + ); + expect(view?.code.every((line) => line.marked === "")).toBe(true); + }); + + it("gives the repo example's Session class a lobby", () => { + const { codeGraph } = parsed("repo"); + const world = worldFromCode(codeGraph, 1); + const lobby = "src/handlers/auth.ts//class:Session"; + expect(lobbiesOf(world)).toEqual([lobby]); + // handleRequest is called from server.ts only, so it is no root: the + // leftover pass attaches what it reaches in source order, the + // constructor and open first. + expect(doorsOf(world, lobby)).toEqual([ + "src/handlers/auth.ts::Session.constructor", + "src/handlers/auth.ts::Session.open", + "src/handlers/auth.ts::Session.refresh", + ]); + }); + + it("gives each class of the resolution example its own lobby", () => { + const { codeGraph } = parsed("resolution"); + const world = worldFromCode(codeGraph, 1); + expect(lobbiesOf(world)).toEqual([ + "resolution.ts//class:Circle", + "resolution.ts//class:Square", + ]); + expect(doorsOf(world, "resolution.ts//class:Square")).toEqual([ + "resolution.ts::Square.render", + ]); + }); +}); diff --git a/apps/web/src/world-from-code.ts b/apps/web/src/world-from-code.ts index 15c8478..65cf8ae 100644 --- a/apps/web/src/world-from-code.ts +++ b/apps/web/src/world-from-code.ts @@ -71,7 +71,8 @@ const subjectOf = ( /** * `Class.method()` for a function or one of its rooms, the path for a hub, - * the repository's name for the entrance, the id otherwise. + * `class Svc` for a lobby, the repository's name for the entrance, the id + * otherwise. */ const labelOf = (codeGraph: CodeGraph | null, roomId: string): string => { const subject = subjectOf(codeGraph, roomId); @@ -81,6 +82,9 @@ const labelOf = (codeGraph: CodeGraph | null, roomId: string): string => { if (subject.kind === "entrance") { return subject.name; } + if (subject.kind === "class") { + return `class ${subject.node.name}`; + } return subject.kind === "module" ? subject.module.path : `${subject.fn.qualifiedName}()`; @@ -88,7 +92,7 @@ const labelOf = (codeGraph: CodeGraph | null, roomId: string): string => { /** * The HUD line for a room: the repository's name in the entrance, the file - * for a hub, `file · Class.method()` for a function, `file · Class.method() · await fetch(…)` for a room of its + * for a hub, `file · class Svc` for a lobby, `file · Class.method()` for a function, `file · Class.method() · await fetch(…)` for a room of its * flow, and the raw id for anything else (corridors, preset rooms). */ const describeRoom = (codeGraph: CodeGraph | null, roomId: string): string => { @@ -102,6 +106,9 @@ const describeRoom = (codeGraph: CodeGraph | null, roomId: string): string => { if (subject.kind === "module") { return subject.module.path; } + if (subject.kind === "class") { + return `${subject.module.path} · class ${subject.node.name}`; + } const head = `${subject.module.path} · ${subject.fn.qualifiedName}()`; return subject.kind === "flow" ? `${head} · ${subject.text}` : head; }; diff --git a/packages/code-graph/README.md b/packages/code-graph/README.md index 4dc5289..80f9325 100644 --- a/packages/code-graph/README.md +++ b/packages/code-graph/README.md @@ -181,12 +181,22 @@ directed walk from the roots cannot reach (mutual recursion with no outside caller) are attached to the hub in source order. A hub has at most five doors, links to the neighbouring hubs included (module portals are not doors but widen the first hub), so a module whose roots need more chains further hubs -(`demo.ts`, `demo.ts#2`, ...). Portals sit on flow rooms (`from`), lead to +(`demo.ts`, `demo.ts#2`, ...). The hubs are planned in `src/hub-plan.ts`: an +attached method of a top-level class (`ClassNode.topLevel`) hangs off its +class's lobby instead, a hub room labelled `class Svc` with id +`demo.ts//class:Svc` (the class's id; `//` keeps it apart from any module +path), which takes one door of the module hub at the place of the class's +first attached method and chains on the same way (`demo.ts//class:Svc#2`). +A method reached by a call door keeps that door, a class with no attached +methods has no lobby, and returns still land at the module hub. +`parseHubId(id)` reads any hub id back as `{ module, classId, index }`. +Portals sit on flow rooms (`from`), lead to units (`to`), and are listed calls first, then returns, jumps, markers and module portals. `./subjects` maps world ids back to the code: `roomSubject` gives a module, a -function, or a flow room (`{ kind: "flow", fn, module, node, ancestors, text, +class for a lobby (`{ kind: "class", module, node, methods }`), a function, +or a flow room (`{ kind: "flow", fn, module, node, ancestors, text, span }`, `text` being the HUD's words such as `await fetch(…)` or `if (user) · 2 statements · 2 calls`, and `span` the room's code: a tagged room's composite, and for a room the budget folded from several, its first node to its last); diff --git a/packages/code-graph/src/code-graph.ts b/packages/code-graph/src/code-graph.ts index 260d587..e8ff8d0 100644 --- a/packages/code-graph/src/code-graph.ts +++ b/packages/code-graph/src/code-graph.ts @@ -216,6 +216,24 @@ type FunctionNode = { readonly flow: SequenceNode; }; +/** + * A class declaration (`class Svc {}`); class expressions are not recorded. + * Its members are the FunctionNodes inside its span whose `className` is + * its name (the innermost such class). + */ +type ClassNode = { + /** `${moduleId}//class:${name}`, suffixed `~2`, `~3` on repeats. */ + readonly id: string; + readonly moduleId: string; + /** `"default"` for `export default class {}`. */ + readonly name: string; + /** The whole declaration. */ + readonly span: SourceSpan; + readonly exported: boolean; + /** Declared in module scope (blocks included), not inside any function. */ + readonly topLevel: boolean; +}; + /** * resolved: one function in the graph, possibly in another module (linked * through relative imports); ambiguous: the name may refer to several @@ -291,6 +309,8 @@ type GraphEdge = CallEdge | ContainmentEdge; type CodeGraph = { readonly modules: readonly ModuleNode[]; readonly functions: readonly FunctionNode[]; + /** Class declarations, per module in source order. */ + readonly classes: readonly ClassNode[]; readonly callSites: readonly CallSite[]; readonly edges: readonly GraphEdge[]; }; @@ -304,6 +324,7 @@ export type { CallNode, CallResolution, CallSite, + ClassNode, CodeGraph, ContainmentEdge, ContinueNode, diff --git a/packages/code-graph/src/entrance.ts b/packages/code-graph/src/entrance.ts index 18c86d7..b3a4c23 100644 --- a/packages/code-graph/src/entrance.ts +++ b/packages/code-graph/src/entrance.ts @@ -4,9 +4,9 @@ import type { Connection, GraphRoom, Portal, WorldGraph } from "@repo/types"; import type { CodeGraph, ModuleNode } from "./code-graph"; +import { linksOn, MODULE_PORTALS_PER_HUB } from "./hub-plan"; import { ENTRANCE_ID, modulePortalId } from "./ids"; import { hubDimensions } from "./room-size"; -import { linksOn, MODULE_PORTALS_PER_HUB } from "./world-graph"; /** * The repository's name: the directory every module lies in (`src` for diff --git a/packages/code-graph/src/fixture.ts b/packages/code-graph/src/fixture.ts index 0c24cfc..82764ef 100644 --- a/packages/code-graph/src/fixture.ts +++ b/packages/code-graph/src/fixture.ts @@ -5,6 +5,7 @@ import type { CallEdge, CallSite, + ClassNode, CodeGraph, ExportRecord, FlowStep, @@ -13,19 +14,25 @@ import type { ModuleNode, SourceSpan, } from "./code-graph"; -import { callSiteId, flowNodeId, functionId } from "./ids"; +import { callSiteId, classId, flowNodeId, functionId } from "./ids"; type FixtureFunction = { readonly name: string; /** Lines the function spans; defaults to 3. */ readonly lines?: number; /** - * Callee names in this module, in source order, or function ids + * Callee names (or qualified names) in this module, in source order, or function ids * (`b.ts::two`) of any module of the graph. Repeats allowed. */ readonly calls?: readonly string[]; /** Whether the module exports it; defaults to false. */ readonly exported?: boolean; + /** + * The top-level class it is a method of: its qualified name becomes + * `Class.name` and the class spans its first method to its last. + */ + readonly className?: string; + readonly isStatic?: boolean; }; type FixtureModule = { @@ -87,20 +94,23 @@ const functionsOf = (module: FixtureModule): readonly FunctionNode[] => { let line = 1; for (const fn of module.functions) { const lines = fn.lines ?? 3; - const id = functionId(module.path, fn.name); + const className = fn.className ?? null; + const qualifiedName = + className === null ? fn.name : `${className}.${fn.name}`; + const id = functionId(module.path, qualifiedName); const span = spanAt(line, lines); functions.push({ id, moduleId: module.path, name: fn.name, - qualifiedName: fn.name, - kind: "declaration", + qualifiedName, + kind: className === null ? "declaration" : "method", span, exported: fn.exported ?? false, async: false, - isStatic: false, + isStatic: fn.isStatic ?? false, parentId: null, - className: null, + className, flow: flowOf(id, span, line, lines, fn.calls ?? []), }); line += lines + 1; @@ -108,6 +118,34 @@ const functionsOf = (module: FixtureModule): readonly FunctionNode[] => { return functions; }; +/** One top-level class per class name, from its first method to its last. */ +const classesOf = ( + path: string, + functions: readonly FunctionNode[] +): readonly ClassNode[] => { + const classes = new Map(); + for (const fn of functions) { + if (fn.className === null) { + continue; + } + const found = classes.get(fn.className); + classes.set(fn.className, { + id: classId(path, fn.className), + moduleId: path, + name: fn.className, + span: { + ...(found?.span ?? fn.span), + end: fn.span.end, + endLine: fn.span.endLine, + endColumn: fn.span.endColumn, + }, + exported: false, + topLevel: true, + }); + } + return [...classes.values()]; +}; + /** `functions`: this module's; `all`: every module's, for calls by id. */ const fixtureModule = ( module: FixtureModule, @@ -124,7 +162,8 @@ const fixtureModule = ( (fn.calls ?? []).forEach((callee, offset) => { const target = callee.includes("::") ? all.find((candidate) => candidate.id === callee) - : functions.find((candidate) => candidate.name === callee); + : (functions.find((candidate) => candidate.qualifiedName === callee) ?? + functions.find((candidate) => candidate.name === callee)); if (target === undefined) { throw new Error(`Fixture calls unknown function "${callee}"`); } @@ -163,6 +202,7 @@ const fixtureModule = ( return { modules: [node], functions, + classes: classesOf(module.path, functions), callSites, edges: [ ...functions.map((fn) => ({ @@ -185,6 +225,7 @@ const fixtureGraph = (...modules: readonly FixtureModule[]): CodeGraph => { return { modules: graphs.flatMap((graph) => graph.modules), functions: graphs.flatMap((graph) => graph.functions), + classes: graphs.flatMap((graph) => graph.classes), callSites: graphs.flatMap((graph) => graph.callSites), edges: graphs.flatMap((graph) => graph.edges), }; diff --git a/packages/code-graph/src/flow-markers.test.ts b/packages/code-graph/src/flow-markers.test.ts index d918141..1dc844b 100644 --- a/packages/code-graph/src/flow-markers.test.ts +++ b/packages/code-graph/src/flow-markers.test.ts @@ -228,6 +228,7 @@ describe("markers", () => { }, ], functions: [fn], + classes: [], callSites: [open(105)], edges: [], }; diff --git a/packages/code-graph/src/hub-plan.ts b/packages/code-graph/src/hub-plan.ts new file mode 100644 index 0000000..5b8f0a0 --- /dev/null +++ b/packages/code-graph/src/hub-plan.ts @@ -0,0 +1,214 @@ +// The hubs of a module's area: the module's hub chain and a lobby (a chain +// too, when it must) for each top-level class whose methods hang off it, +// with the plain connections that join them to each other and to the +// functions they open onto. + +import type { Connection, GraphRoom } from "@repo/types"; + +import type { ClassNode, FunctionNode, ModuleNode } from "./code-graph"; +import { lobbyId } from "./ids"; +import { hubDimensions } from "./room-size"; +import { hubRatio } from "./variation"; + +/** + * Doors per hub, links to other hubs included: what the layout places + * reliably (the random generator's MAX_DEGREE). A module with more roots + * chains further hubs (`demo.ts`, `demo.ts#2`, ...) instead of asking one + * for walls it cannot have. Lobbies chain the same way. + */ +const MAX_HUB_DOORS = 5; + +/** + * Module portals per hub: a hub stops growing at 20 x 20 m, about eight + * openings of wall, so a module importing more files spreads its module + * portals along the hub chain, chaining further hubs when it runs out. + */ +const MODULE_PORTALS_PER_HUB = 8; + +/** How many of `links` module portals stand on the hub at `index`. */ +const linksOn = (index: number, links: number): number => + Math.max( + 0, + Math.min(MODULE_PORTALS_PER_HUB, links - index * MODULE_PORTALS_PER_HUB) + ); + +/** + * Splits what hangs off a hub chain over its hubs so that no hub exceeds + * MAX_HUB_DOORS once its links to the previous and next hub count; + * `incoming` is the door into the first one from outside the chain (a + * lobby's from its module hub). Module portals are not doors, so they do + * not count. Always at least one group, possibly empty. + */ +const hubGroups = ( + items: readonly T[], + incoming = 0 +): readonly (readonly T[])[] => { + const groups: (readonly T[])[] = []; + let remaining = items; + do { + const links = groups.length > 0 ? 1 : incoming; + let take = Math.max(1, MAX_HUB_DOORS - links); + if (remaining.length > take) { + take = Math.max(1, take - 1); + } + groups.push(remaining.slice(0, take)); + remaining = remaining.slice(take); + } while (remaining.length > 0); + return groups; +}; + +/** + * The class a function is a member of: the innermost declaration of its + * `className` whose span holds it. Null for anything but a class member. + */ +const classOf = ( + fn: FunctionNode, + classes: readonly ClassNode[] +): ClassNode | null => { + let found: ClassNode | null = null; + for (const candidate of classes) { + if ( + candidate.name === fn.className && + candidate.span.start <= fn.span.start && + candidate.span.end >= fn.span.end && + (found === null || candidate.span.start >= found.span.start) + ) { + found = candidate; + } + } + return found; +}; + +type HubPlanInput = { + readonly module: ModuleNode; + /** The functions that hang off the hub rather than a door, in source order. */ + readonly attached: readonly FunctionNode[]; + /** The module's classes. */ + readonly classes: readonly ClassNode[]; + /** Module portals on the hubs, MODULE_PORTALS_PER_HUB to each from the first on. */ + readonly links: number; + readonly seed: number | null; +}; + +type HubPlan = { + /** The module's hubs, chained in order; the first is its entrance. */ + readonly hubs: readonly GraphRoom[]; + /** Every class's lobby chain, class after class. */ + readonly lobbies: readonly GraphRoom[]; + readonly connections: readonly Connection[]; +}; + +/** A class's lobby chain and its connections, methods in source order. */ +const lobbyChain = ( + cls: ClassNode, + methods: readonly FunctionNode[], + seed: number | null +): { readonly rooms: GraphRoom[]; readonly connections: Connection[] } => { + const groups = hubGroups(methods, 1); + const rooms = groups.map((group, index): GraphRoom => { + const id = lobbyId(cls.id, index); + // The door in (from the module hub or the previous lobby), and on. + const chain = 1 + (index < groups.length - 1 ? 1 : 0); + return { + id, + label: `class ${cls.name}`, + hub: true, + ...hubDimensions(group.length + chain, hubRatio(seed, id)), + }; + }); + const connections: Connection[] = []; + groups.forEach((group, index) => { + const room = rooms[index]; + const next = rooms[index + 1]; + if (room === undefined) { + return; + } + if (next !== undefined) { + connections.push({ from: room.id, to: next.id }); + } + for (const fn of group) { + connections.push({ from: room.id, to: fn.id }); + } + }); + return { rooms, connections }; +}; + +/** + * A member of a top-level class hangs off its class's lobby, which takes + * one door of the module hub at the place of its first such member; every + * other attached function hangs off the hub itself. A class with no + * attached members has no lobby; nested classes have none either. + */ +const planHubs = ({ + module, + attached, + classes, + links, + seed, +}: HubPlanInput): HubPlan => { + const members = new Map(); + const items: string[] = []; + for (const fn of attached) { + const cls = fn.parentId === null ? classOf(fn, classes) : null; + if (cls === null || !cls.topLevel) { + items.push(fn.id); + continue; + } + const methods = members.get(cls); + if (methods === undefined) { + members.set(cls, [fn]); + items.push(lobbyId(cls.id, 0)); + } else { + methods.push(fn); + } + } + const rootGroups = hubGroups(items); + const groups = [ + ...rootGroups, + ...Array.from( + { + length: Math.max( + 0, + Math.ceil(links / MODULE_PORTALS_PER_HUB) - rootGroups.length + ), + }, + (): readonly string[] => [] + ), + ]; + const hubs = groups.map((group, index): GraphRoom => { + const chain = (index > 0 ? 1 : 0) + (index < groups.length - 1 ? 1 : 0); + const id = index === 0 ? module.id : `${module.id}#${index + 1}`; + return { + id, + label: module.path, + hub: true, + ...hubDimensions( + group.length + chain + linksOn(index, links), + hubRatio(seed, id) + ), + }; + }); + const connections: Connection[] = []; + groups.forEach((group, index) => { + const hub = hubs[index]; + const next = hubs[index + 1]; + if (hub === undefined) { + return; + } + if (next !== undefined) { + connections.push({ from: hub.id, to: next.id }); + } + for (const id of group) { + connections.push({ from: hub.id, to: id }); + } + }); + const lobbies: GraphRoom[] = []; + for (const [cls, methods] of members) { + const chain = lobbyChain(cls, methods, seed); + lobbies.push(...chain.rooms); + connections.push(...chain.connections); + } + return { hubs, lobbies, connections }; +}; + +export { classOf, linksOn, MODULE_PORTALS_PER_HUB, planHubs }; diff --git a/packages/code-graph/src/ids.test.ts b/packages/code-graph/src/ids.test.ts index ebc9305..87ece93 100644 --- a/packages/code-graph/src/ids.test.ts +++ b/packages/code-graph/src/ids.test.ts @@ -3,18 +3,22 @@ import { describe, expect, it } from "vitest"; import { callPortalId, callSiteId, + classId, ENTRANCE_ID, flowNodeId, functionId, + hubModuleId, isEntranceHub, isFunctionId, isRelativeSpecifier, jumpPortalId, + lobbyId, markerPortalId, moduleId, modulePortalId, normalisePath, parseFlowNodeId, + parseHubId, parsePortalId, resolveSpecifier, returnPortalId, @@ -52,6 +56,49 @@ describe("isEntranceHub", () => { }); }); +describe("hub ids", () => { + it("parses module hubs, lobbies and their chains", () => { + const svc = classId("src/demo.ts", "Svc"); + expect(svc).toBe("src/demo.ts//class:Svc"); + expect(lobbyId(svc, 0)).toBe(svc); + expect(lobbyId(svc, 1)).toBe("src/demo.ts//class:Svc#2"); + expect(parseHubId("src/demo.ts")).toEqual({ + module: "src/demo.ts", + classId: null, + index: 0, + }); + expect(parseHubId("src/demo.ts#3")).toEqual({ + module: "src/demo.ts", + classId: null, + index: 2, + }); + expect(parseHubId(lobbyId(svc, 0))).toEqual({ + module: "src/demo.ts", + classId: svc, + index: 0, + }); + expect(parseHubId(lobbyId(classId("a.ts", "Svc~2"), 1))).toEqual({ + module: "a.ts", + classId: "a.ts//class:Svc~2", + index: 1, + }); + expect(parseHubId(`${ENTRANCE_ID}#2`)).toEqual({ + module: ENTRANCE_ID, + classId: null, + index: 1, + }); + }); + + it("keeps a path holding # or class: whole", () => { + expect(hubModuleId("a#b/class:c.ts")).toBe("a#b/class:c.ts"); + expect(hubModuleId(lobbyId(classId("a#b/class:c.ts", "C"), 1))).toBe( + "a#b/class:c.ts" + ); + // No normalised path holds the separator, so none parses as a lobby. + expect(parseHubId(moduleId("a//class:B")).classId).toBeNull(); + }); +}); + describe("resolveSpecifier", () => { const ids = new Set([ "src/a.ts", diff --git a/packages/code-graph/src/ids.ts b/packages/code-graph/src/ids.ts index 3fa791c..77897eb 100644 --- a/packages/code-graph/src/ids.ts +++ b/packages/code-graph/src/ids.ts @@ -263,8 +263,49 @@ const parsePortalId = (id: string): PortalRef | null => { return null; }; +const CLASS_SEPARATOR = "//class:"; + +/** + * A class: `demo.ts//class:Svc`, `demo.ts//class:Svc~2` for a second + * declaration of that name. `normalisePath` never yields `//`, so no module + * id, and no hub, function or flow id built on one, can contain the + * separator, whatever the files are called. The id doubles as its lobby's. + */ +const classId = (module: string, uniqueName: string): string => + `${module}${CLASS_SEPARATOR}${uniqueName}`; + +/** A class's lobby, more chained on as `demo.ts//class:Svc#2`. */ +const lobbyId = (classNodeId: string, index: number): string => + index === 0 ? classNodeId : `${classNodeId}#${index + 1}`; + +type HubRef = { + /** The module (or the entrance) the hub belongs to. */ + readonly module: string; + /** The class whose lobby it is; null for a module hub. */ + readonly classId: string | null; + /** Its place in its chain, from 0. */ + readonly index: number; +}; + +const CHAIN_TAIL = /#(\d+)$/; + +/** + * Where a hub id stands: `demo.ts#2` is the module's second hub, + * `demo.ts//class:Svc` the first lobby of class `Svc`. Class names never + * contain `#`, so only a trailing `#` counts as a chain index. + */ +const parseHubId = (roomId: string): HubRef => { + const match = CHAIN_TAIL.exec(roomId); + const head = match === null ? roomId : roomId.slice(0, match.index); + const index = match === null ? 0 : Number(match[1]) - 1; + const separator = head.indexOf(CLASS_SEPARATOR); + return separator === -1 + ? { module: head, classId: null, index } + : { module: head.slice(0, separator), classId: head, index }; +}; + /** The module a hub id belongs to: `demo.ts#2` → `demo.ts`. */ -const hubModuleId = (roomId: string): string => roomId.replace(/#\d+$/, ""); +const hubModuleId = (roomId: string): string => parseHubId(roomId).module; /** * The repository entrance: its area and its first hub (more chain on as @@ -293,6 +334,7 @@ const uniqueNames = (): ((qualifiedName: string) => string) => { export { callPortalId, callSiteId, + classId, ENTRANCE_ID, flowNodeId, functionId, @@ -301,11 +343,13 @@ export { isFunctionId, isRelativeSpecifier, jumpPortalId, + lobbyId, markerPortalId, moduleId, modulePortalId, normalisePath, parseFlowNodeId, + parseHubId, parsePortalId, resolveSpecifier, returnPortalId, diff --git a/packages/code-graph/src/module-areas.test.ts b/packages/code-graph/src/module-areas.test.ts index 7105965..bbad1d8 100644 --- a/packages/code-graph/src/module-areas.test.ts +++ b/packages/code-graph/src/module-areas.test.ts @@ -186,7 +186,7 @@ describe("generateCodeWorld", () => { it("throws for a graph without modules", () => { expect(() => generateCodeWorld( - { modules: [], functions: [], callSites: [], edges: [] }, + { modules: [], functions: [], classes: [], callSites: [], edges: [] }, 1 ) ).toThrow(/at least one module/); diff --git a/packages/code-graph/src/subjects.test.ts b/packages/code-graph/src/subjects.test.ts index 91fc64a..a44780d 100644 --- a/packages/code-graph/src/subjects.test.ts +++ b/packages/code-graph/src/subjects.test.ts @@ -10,7 +10,7 @@ import type { import { demoGraph, fixtureGraph, importOf } from "./fixture"; import { branch, fnWith, step } from "./flow-fixture"; import { layoutFlow, planFlow } from "./flow-layout"; -import { ENTRANCE_ID, modulePortalId, parseFlowNodeId } from "./ids"; +import { classId, ENTRANCE_ID, modulePortalId, parseFlowNodeId } from "./ids"; import { packageOf, portalSubject, roomSubject } from "./subjects"; import { toWorldGraph } from "./world-graph"; @@ -75,6 +75,27 @@ describe("roomSubject", () => { expect(roomSubject(repo, ENTRANCE_ID)).toMatchObject(expected); expect(roomSubject(repo, `${ENTRANCE_ID}#2`)).toMatchObject(expected); }); + + it("names a lobby by its class and members", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: [ + { name: "a", className: "Svc" }, + { name: "main" }, + { name: "b", className: "Svc" }, + ], + }); + const expected = { + kind: "class", + module: { path: "m.ts" }, + node: { id: "m.ts//class:Svc", name: "Svc" }, + methods: [{ qualifiedName: "Svc.a" }, { qualifiedName: "Svc.b" }], + }; + expect(roomSubject(graph, "m.ts//class:Svc")).toMatchObject(expected); + expect(roomSubject(graph, "m.ts//class:Svc#2")).toMatchObject(expected); + expect(roomSubject(graph, classId("m.ts", "Other"))).toBeNull(); + expect(roomSubject(graph, classId("x.ts", "Svc"))).toBeNull(); + }); }); describe("portalSubject", () => { @@ -181,6 +202,7 @@ const graphWith = (fn: FunctionNode): CodeGraph => ({ }, ], functions: [fn], + classes: [], callSites: [], edges: [], }); diff --git a/packages/code-graph/src/subjects.ts b/packages/code-graph/src/subjects.ts index 16b55a5..8f473e8 100644 --- a/packages/code-graph/src/subjects.ts +++ b/packages/code-graph/src/subjects.ts @@ -2,6 +2,7 @@ import type { CallSite, + ClassNode, CodeGraph, FlowNode, FunctionNode, @@ -14,6 +15,7 @@ import { findFlowNode } from "./flow"; import { planFlow } from "./flow-layout"; import type { FlowPlan } from "./flow-layout"; import { flowNodeText, indexSites } from "./flow-text"; +import { classOf } from "./hub-plan"; import { flowNodeId, hubModuleId, @@ -21,6 +23,7 @@ import { isFunctionId, isRelativeSpecifier, parseFlowNodeId, + parseHubId, parsePortalId, } from "./ids"; import type { FlowNodeRef } from "./ids"; @@ -39,6 +42,14 @@ type RoomSubject = readonly modules: readonly ModuleNode[]; } | { readonly kind: "module"; readonly module: ModuleNode } + | { + /** A class's lobby. */ + readonly kind: "class"; + readonly module: ModuleNode; + readonly node: ClassNode; + /** Its members, in source order. */ + readonly methods: readonly FunctionNode[]; + } | ({ readonly kind: "function" } & FunctionSubject) | ({ readonly kind: "flow"; @@ -179,9 +190,22 @@ const flowSubject = (graph: CodeGraph, roomId: string): RoomSubject | null => { }; }; +/** A class's members, in source order (see `classOf`). */ +const membersOf = ( + graph: CodeGraph, + node: ClassNode +): readonly FunctionNode[] => { + const classes = graph.classes.filter( + (candidate) => candidate.moduleId === node.moduleId + ); + return graph.functions.filter( + (fn) => fn.moduleId === node.moduleId && classOf(fn, classes) === node + ); +}; + /** * What a room stands for: a flow room of a function, a function, a module - * hub or the repository entrance; null for corridors and ids not from this + * hub, a class's lobby or the repository entrance; null for corridors and ids not from this * graph. Flow ids also contain `::`, so they are tried first. */ const roomSubject = (graph: CodeGraph, roomId: string): RoomSubject | null => { @@ -199,9 +223,18 @@ const roomSubject = (graph: CodeGraph, roomId: string): RoomSubject | null => { modules: entryModules(graph), }; } - const moduleId = hubModuleId(roomId); - const module = graph.modules.find((candidate) => candidate.id === moduleId); - return module === undefined ? null : { kind: "module", module }; + const hub = parseHubId(roomId); + const module = graph.modules.find((candidate) => candidate.id === hub.module); + if (module === undefined) { + return null; + } + if (hub.classId === null) { + return { kind: "module", module }; + } + const node = graph.classes.find((candidate) => candidate.id === hub.classId); + return node === undefined + ? null + : { kind: "class", module, node, methods: membersOf(graph, node) }; }; type PortalSubject = diff --git a/packages/code-graph/src/world-graph-lobbies.test.ts b/packages/code-graph/src/world-graph-lobbies.test.ts new file mode 100644 index 0000000..5dbfcb5 --- /dev/null +++ b/packages/code-graph/src/world-graph-lobbies.test.ts @@ -0,0 +1,198 @@ +import type { WorldGraph } from "@repo/types"; +import { generateWorld } from "@repo/world-generator"; +import { validateGraph } from "@repo/world-generator/graph"; +import { checkLayout } from "@repo/world-generator/layout-checks"; +import { describe, expect, it } from "vitest"; + +import type { CodeGraph } from "./code-graph"; +import { fixtureGraph } from "./fixture"; +import { classId } from "./ids"; +import { toWorldGraph } from "./world-graph"; + +/** Lays the graph out for several seeds; nothing may be left unrealised. */ +const laysOutCleanly = (graph: CodeGraph) => { + const world = toWorldGraph(graph); + expect(() => validateGraph(world)).not.toThrow(); + for (let seed = 1; seed <= 8; seed += 1) { + const generated = generateWorld({ seed, graph: world }); + expect(generated.layout.unresolved, `seed ${seed}`).toEqual([]); + expect(generated.layout.unplacedPortals, `seed ${seed}`).toEqual([]); + expect(checkLayout(world, generated.layout), `seed ${seed}`).toEqual([]); + } +}; + +/** Plain connections (no call doors) as `from -> to`. */ +const plain = (world: WorldGraph) => + world.connections + .filter((connection) => connection.kind === undefined) + .map(({ from, to }) => `${from} -> ${to}`); + +const lobbiesOf = (world: WorldGraph) => + world.rooms.filter((room) => room.id.includes("//class:")); + +const methods = (className: string, names: readonly string[]) => + names.map((name) => ({ name, className, lines: 1 })); + +describe("class lobbies", () => { + it("hangs a class's root methods off one lobby behind one door of the hub", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: [{ name: "main" }, ...methods("Svc", ["a", "b", "c"])], + }); + const world = toWorldGraph(graph); + expect(lobbiesOf(world)).toEqual([ + expect.objectContaining({ + id: "m.ts//class:Svc", + label: "class Svc", + hub: true, + }), + ]); + expect(plain(world)).toEqual([ + "m.ts -> m.ts::main", + "m.ts -> m.ts//class:Svc", + "m.ts//class:Svc -> m.ts::Svc.a", + "m.ts//class:Svc -> m.ts::Svc.b", + "m.ts//class:Svc -> m.ts::Svc.c", + ]); + // Returns still land at the module hub, not the lobby. + expect( + (world.portals ?? []) + .filter((portal) => portal.kind === "return") + .map((portal) => portal.to) + ).toEqual(["m.ts", "m.ts", "m.ts", "m.ts"]); + laysOutCleanly(graph); + }); + + it("chains further lobbies when a class has many root methods", () => { + const names = ["a", "b", "c", "d", "e", "f", "g"]; + const graph = fixtureGraph({ + path: "m.ts", + functions: methods("Svc", names), + }); + const world = toWorldGraph(graph); + expect(lobbiesOf(world).map((room) => room.id)).toEqual([ + "m.ts//class:Svc", + "m.ts//class:Svc#2", + ]); + expect(plain(world)).toEqual([ + "m.ts -> m.ts//class:Svc", + "m.ts//class:Svc -> m.ts//class:Svc#2", + ...["a", "b", "c"].map((name) => `m.ts//class:Svc -> m.ts::Svc.${name}`), + ...["d", "e", "f", "g"].map( + (name) => `m.ts//class:Svc#2 -> m.ts::Svc.${name}` + ), + ]); + for (const lobby of lobbiesOf(world)) { + const doors = world.connections.filter( + ({ from, to }) => from === lobby.id || to === lobby.id + ); + expect(doors.length).toBeLessThanOrEqual(5); + } + laysOutCleanly(graph); + }); + + it("keeps the call door of a method another function calls", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: [ + { name: "main", calls: ["Svc.a"] }, + ...methods("Svc", ["a", "b"]), + ], + }); + const world = toWorldGraph(graph); + expect(world.connections).toContainEqual({ + from: "m.ts::main", + to: "m.ts::Svc.a", + kind: "call", + }); + expect(plain(world)).toEqual([ + "m.ts -> m.ts::main", + "m.ts -> m.ts//class:Svc", + "m.ts//class:Svc -> m.ts::Svc.b", + ]); + laysOutCleanly(graph); + }); + + it("gives no lobby to a class whose methods are all reached by doors", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: [ + { name: "main", calls: ["Svc.a", "Svc.b"] }, + ...methods("Svc", ["a", "b"]), + ], + }); + const world = toWorldGraph(graph); + expect(lobbiesOf(world)).toEqual([]); + expect(plain(world)).toEqual(["m.ts -> m.ts::main"]); + laysOutCleanly(graph); + }); + + it("puts each lobby at its first method's place among the hub's doors", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: [ + { name: "f", lines: 1 }, + { name: "x", className: "A", lines: 1 }, + { name: "g", lines: 1 }, + { name: "y", className: "B", lines: 1 }, + { name: "z", className: "A", lines: 1 }, + ], + }); + const world = toWorldGraph(graph); + expect(plain(world)).toEqual([ + "m.ts -> m.ts::f", + "m.ts -> m.ts//class:A", + "m.ts -> m.ts::g", + "m.ts -> m.ts//class:B", + "m.ts//class:A -> m.ts::A.x", + "m.ts//class:A -> m.ts::A.z", + "m.ts//class:B -> m.ts::B.y", + ]); + laysOutCleanly(graph); + }); + + it("leaves the methods of a class that is not top-level on the hub", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: methods("Svc", ["a", "b"]), + }); + const nested = { + ...graph, + classes: graph.classes.map((cls) => ({ ...cls, topLevel: false })), + }; + const world = toWorldGraph(nested); + expect(lobbiesOf(world)).toEqual([]); + expect(plain(world)).toEqual([ + "m.ts -> m.ts::Svc.a", + "m.ts -> m.ts::Svc.b", + ]); + }); + + it("tells two declarations of one class name apart by their spans", () => { + const graph = fixtureGraph({ + path: "m.ts", + functions: methods("Svc", ["a", "b"]), + }); + const [a, b] = graph.functions; + const [svc] = graph.classes; + if (a === undefined || b === undefined || svc === undefined) { + throw new Error("fixture lost its functions"); + } + // `if (x) { class Svc { a() {} } } else { class Svc { b() {} } }` + const split = { + ...graph, + classes: [ + { ...svc, span: a.span }, + { ...svc, id: classId("m.ts", "Svc~2"), span: b.span }, + ], + }; + const world = toWorldGraph(split); + expect(plain(world)).toEqual([ + "m.ts -> m.ts//class:Svc", + "m.ts -> m.ts//class:Svc~2", + "m.ts//class:Svc -> m.ts::Svc.a", + "m.ts//class:Svc~2 -> m.ts::Svc.b", + ]); + laysOutCleanly(split); + }); +}); diff --git a/packages/code-graph/src/world-graph.ts b/packages/code-graph/src/world-graph.ts index 6baa206..298c4ed 100644 --- a/packages/code-graph/src/world-graph.ts +++ b/packages/code-graph/src/world-graph.ts @@ -17,10 +17,10 @@ import type { ModuleNode, } from "./code-graph"; import { layoutFlow, planFlow } from "./flow-layout"; +import { MODULE_PORTALS_PER_HUB, planHubs } from "./hub-plan"; import { modulePortalId } from "./ids"; import { moduleLinks } from "./module-links"; -import { hubDimensions } from "./room-size"; -import { columnJitter, hubRatio } from "./variation"; +import { columnJitter } from "./variation"; /** Functions nobody else calls, from the directed edges, in source order. */ const rootsOf = ( @@ -110,50 +110,6 @@ const planModule = ( }; }; -/** - * Doors per hub, links to other hubs included: what the layout places - * reliably (the random generator's MAX_DEGREE). A module with more roots - * chains further hubs (`demo.ts`, `demo.ts#2`, ...) instead of asking one - * for walls it cannot have. - */ -const MAX_HUB_DOORS = 5; - -/** - * Module portals per hub: a hub stops growing at 20 x 20 m, about eight - * openings of wall, so a module importing more files spreads its module - * portals along the hub chain, chaining further hubs when it runs out. - */ -const MODULE_PORTALS_PER_HUB = 8; - -/** How many of `links` module portals stand on the hub at `index`. */ -const linksOn = (index: number, links: number): number => - Math.max( - 0, - Math.min(MODULE_PORTALS_PER_HUB, links - index * MODULE_PORTALS_PER_HUB) - ); - -/** - * Splits the roots over hubs so that no hub exceeds MAX_HUB_DOORS once its - * links to the previous and next hub count. Module portals are not doors, - * so they do not count. Always at least one group, possibly empty. - */ -const hubGroups = ( - attached: readonly FunctionNode[] -): readonly (readonly FunctionNode[])[] => { - const groups: (readonly FunctionNode[])[] = []; - let remaining = attached; - do { - const links = groups.length > 0 ? 1 : 0; - let take = Math.max(1, MAX_HUB_DOORS - links); - if (remaining.length > take) { - take = Math.max(1, take - 1); - } - groups.push(remaining.slice(0, take)); - remaining = remaining.slice(take); - } while (remaining.length > 0); - return groups; -}; - type Realised = { readonly cluster: RoomCluster; readonly calls: readonly Portal[]; @@ -238,6 +194,8 @@ const realise = ( type ModuleWorld = { /** The module's hubs, chained in order; the first is its entrance. */ readonly hubs: readonly GraphRoom[]; + /** Its classes' lobbies. */ + readonly lobbies: readonly GraphRoom[]; readonly rooms: readonly GraphRoom[]; readonly connections: readonly Connection[]; readonly portals: readonly Portal[]; @@ -341,46 +299,18 @@ const moduleWorld = ( jumps.push(...realised.jumps); markers.push(...realised.markers); } - const rootGroups = hubGroups(attached); - const groups = [ - ...rootGroups, - ...Array.from( - { - length: Math.max( - 0, - Math.ceil(links / MODULE_PORTALS_PER_HUB) - rootGroups.length - ), - }, - (): readonly FunctionNode[] => [] - ), - ]; - const hubs = groups.map((group, index): GraphRoom => { - const chain = (index > 0 ? 1 : 0) + (index < groups.length - 1 ? 1 : 0); - const id = index === 0 ? module.id : `${module.id}#${index + 1}`; - return { - id, - label: module.path, - hub: true, - ...hubDimensions( - group.length + chain + linksOn(index, links), - hubRatio(seed, id) - ), - }; - }); - const connections: Connection[] = []; - groups.forEach((group, index) => { - const hub = hubs[index]; - const next = hubs[index + 1]; - if (hub === undefined) { - return; - } - if (next !== undefined) { - connections.push({ from: hub.id, to: next.id }); - } - for (const fn of group) { - connections.push({ from: hub.id, to: fn.id }); - } + const { + hubs, + lobbies, + connections: hubConnections, + } = planHubs({ + module, + attached, + classes: graph.classes.filter((cls) => cls.moduleId === module.id), + links, + seed, }); + const connections = [...hubConnections]; connections.push( ...tree.map((edge): Connection => ({ from: edge.source, @@ -390,6 +320,7 @@ const moduleWorld = ( ); return { hubs, + lobbies, rooms, connections, portals: [...calls, ...returns, ...jumps, ...markers], @@ -400,7 +331,8 @@ const moduleWorld = ( * The spatial grammar for one module (the first by default): one cluster * per function (a column of flow rooms with a return portal at its end), * a hub that opens onto the module's roots (more hubs chained when there - * are many), a door for each call on the breadth-first tree from those + * are many; a top-level class's root methods open off a lobby of their + * own, which takes one door of the hub), a door for each call on the breadth-first tree from those * roots through the rooms' ports, a call portal for every other resolved * call, and a closed marker in every room holding calls the world cannot * follow (ambiguous, dynamic, external or unresolved; never a portal to a @@ -423,7 +355,7 @@ const toWorldGraph = ( const world = moduleWorld(graph, module, links.length, portalOnly, seed); const pathOf = (id: string): string => graph.modules.find((candidate) => candidate.id === id)?.path ?? id; - const rooms = [...world.hubs, ...world.rooms]; + const rooms = [...world.hubs, ...world.lobbies, ...world.rooms]; const portals = [ ...world.portals, ...links.map((to, index): Portal => { @@ -457,4 +389,4 @@ const toWorldGraph = ( }; }; -export { edgeKey, linksOn, MODULE_PORTALS_PER_HUB, toWorldGraph }; +export { edgeKey, toWorldGraph }; diff --git a/packages/parser/README.md b/packages/parser/README.md index 8a9ca4c..1d68b98 100644 --- a/packages/parser/README.md +++ b/packages/parser/README.md @@ -78,6 +78,13 @@ and `export =` are not recorded. `FunctionNode.exported` is true for a function declared with `export`, or for a top-level one whose name a local export record names. +Each class declaration becomes a `ClassNode` in `CodeGraph.classes`: id +`file.ts//class:Name` (suffixed `~2`, `~3` when one module declares the name +again, say in two branches), its span, whether it is exported and whether it +is `topLevel` (declared in module scope, blocks included, not inside any +function or callback). Class expressions are not recorded. A member is a +function inside its span whose `className` is its name. + Each function also carries its body as control flow (`FunctionNode.flow`, a `SequenceNode`), built after the calls are resolved so every statement can name the call sites inside it. Plain statements fold into one `step`; a diff --git a/packages/parser/src/functions.ts b/packages/parser/src/functions.ts index 8736dda..4677a39 100644 --- a/packages/parser/src/functions.ts +++ b/packages/parser/src/functions.ts @@ -17,13 +17,14 @@ import { isImportDeclaration, isVariableDeclaration, } from "@babel/types"; -import type { ClassBody, Node, Program } from "@babel/types"; +import type { ClassDeclaration, Node, Program } from "@babel/types"; import type { + ClassNode, ContainmentEdge, FunctionKind, ImportRecord, } from "@repo/code-graph"; -import { functionId, uniqueNames } from "@repo/code-graph/ids"; +import { classId, functionId, uniqueNames } from "@repo/code-graph/ids"; import { bindingNames, @@ -56,11 +57,15 @@ type Discovered = { readonly moduleScope: Scope; /** Every class declared in the module, in source order, whatever its scope. */ readonly classes: ClassTable[]; + /** The same classes as graph nodes. */ + readonly classNodes: ClassNode[]; }; type Context = { readonly moduleId: string; readonly nextName: (qualifiedName: string) => string; + /** The same for class names, kept apart so function ids never move. */ + readonly nextClassName: (name: string) => string; /** Local names exported by any form (see `exportedLocalNames`). */ readonly exportedNames: ReadonlySet; readonly out: Discovered; @@ -125,7 +130,7 @@ const visitClassBody = ( context: Context, scope: Scope, name: string, - body: ClassBody, + node: ClassDeclaration, exported: boolean, depth: number ): void => { @@ -137,7 +142,15 @@ const visitClassBody = ( }; scope.classes.set(name, table); context.out.classes.push(table); - for (const member of body.body) { + context.out.classNodes.push({ + id: classId(context.moduleId, context.nextClassName(name)), + moduleId: context.moduleId, + name, + span: spanOf(node), + exported, + topLevel: scope.parent === null, + }); + for (const member of node.body.body) { const field = fieldFunction(member); if (isClassMethod(member) || isClassPrivateMethod(member)) { const method = memberName(member); @@ -276,7 +289,7 @@ const visit = ( context, scope, name, - node.body, + node, isExported(context, scope, name, exported), depth + 1 ); @@ -328,10 +341,12 @@ const discover = ( scopeAt: new Map(), moduleScope, classes: [], + classNodes: [], }; const context: Context = { moduleId, nextName: uniqueNames(), + nextClassName: uniqueNames(), exportedNames, out, }; diff --git a/packages/parser/src/parser.test.ts b/packages/parser/src/parser.test.ts index 55cf013..b31bb8c 100644 --- a/packages/parser/src/parser.test.ts +++ b/packages/parser/src/parser.test.ts @@ -126,6 +126,50 @@ describe("function discovery", () => { ]); }); + it("records class declarations with spans, export flags and scope", () => { + const { classes } = parse( + [ + "export class Svc {", + " load() {}", + "}", + "if (flag) {", + " class Local {}", + "} else {", + " class Local {}", + "}", + "function make() {", + " class Inner {}", + "}", + "run(() => {", + " class InCallback {}", + "});", + "const Expr = class {};", + "export default class {}", + ].join("\n") + ); + expect( + classes.map(({ id, name, exported, topLevel }) => [ + id, + name, + exported, + topLevel, + ]) + ).toEqual([ + ["t.ts//class:Svc", "Svc", true, true], + ["t.ts//class:Local", "Local", false, true], + ["t.ts//class:Local~2", "Local", false, true], + ["t.ts//class:Inner", "Inner", false, false], + ["t.ts//class:InCallback", "InCallback", false, false], + ["t.ts//class:default", "default", true, true], + ]); + // The declaration itself, after `export `. + expect(classes[0]?.span).toMatchObject({ + start: 7, + startLine: 1, + endLine: 3, + }); + }); + it("qualifies nested named functions and links them to their parent", () => { const { functions, edges } = parse( "function outer() {\n function inner() { deep(); }\n const deep = () => {};\n inner();\n}" @@ -254,11 +298,15 @@ describe("parseModule", () => { describe("buildCodeGraph", () => { it("sorts modules by path and rejects duplicate paths", () => { const graph = buildCodeGraph([ - { path: "b.ts", source: "function b() {}" }, - { path: "./a.ts", source: "function a() {}" }, + { path: "b.ts", source: "function b() {}\nclass B {}" }, + { path: "./a.ts", source: "function a() {}\nclass A {}" }, ]); expect(graph.modules.map((module) => module.id)).toEqual(["a.ts", "b.ts"]); expect(graph.functions.map((fn) => fn.id)).toEqual(["a.ts::a", "b.ts::b"]); + expect(graph.classes.map((cls) => cls.id)).toEqual([ + "a.ts//class:A", + "b.ts//class:B", + ]); expect(() => buildCodeGraph([ { path: "a.ts", source: "" }, diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index d301962..204c0b8 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -1,5 +1,6 @@ import type { CallSite, + ClassNode, CodeGraph, FunctionNode, GraphEdge, @@ -28,6 +29,8 @@ type SourceFile = { type ParsedModule = { readonly module: ModuleNode; readonly functions: readonly FunctionNode[]; + /** Class declarations in source order. */ + readonly classes: readonly ClassNode[]; readonly callSites: readonly CallSite[]; readonly edges: readonly GraphEdge[]; }; @@ -135,6 +138,7 @@ const finishModule = (analysed: Analysed): ParsedModule => { return { module, functions: functions.sort((a, b) => a.span.start - b.span.start), + classes: discovered.classNodes, callSites: analysed.callSites, edges: analysed.edges, }; @@ -182,6 +186,7 @@ const buildCodeGraph = (files: readonly SourceFile[]): CodeGraph => { return { modules: parsed.map((item) => item.module), functions: parsed.flatMap((item) => item.functions), + classes: parsed.flatMap((item) => item.classes), callSites: parsed.flatMap((item) => item.callSites), edges: parsed.flatMap((item) => item.edges), }; From 4b9ad9a169d80814fb8b48bcf941fc94c100e0cb Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:26:43 +0300 Subject: [PATCH 2/3] fix: address review findings Co-Authored-By: Claude Opus 5.5 --- packages/code-graph/src/subjects.ts | 5 +++-- packages/code-graph/src/world-graph.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/code-graph/src/subjects.ts b/packages/code-graph/src/subjects.ts index 8f473e8..ea6078e 100644 --- a/packages/code-graph/src/subjects.ts +++ b/packages/code-graph/src/subjects.ts @@ -205,8 +205,9 @@ const membersOf = ( /** * What a room stands for: a flow room of a function, a function, a module - * hub, a class's lobby or the repository entrance; null for corridors and ids not from this - * graph. Flow ids also contain `::`, so they are tried first. + * hub, a class's lobby or the repository entrance; null for corridors and + * ids not from this graph. Flow ids also contain `::`, so they are tried + * first. */ const roomSubject = (graph: CodeGraph, roomId: string): RoomSubject | null => { if (parseFlowNodeId(roomId) !== null) { diff --git a/packages/code-graph/src/world-graph.ts b/packages/code-graph/src/world-graph.ts index 298c4ed..b43a646 100644 --- a/packages/code-graph/src/world-graph.ts +++ b/packages/code-graph/src/world-graph.ts @@ -310,14 +310,14 @@ const moduleWorld = ( links, seed, }); - const connections = [...hubConnections]; - connections.push( + const connections = [ + ...hubConnections, ...tree.map((edge): Connection => ({ from: edge.source, to: edge.target, kind: "call", - })) - ); + })), + ]; return { hubs, lobbies, @@ -332,8 +332,8 @@ const moduleWorld = ( * per function (a column of flow rooms with a return portal at its end), * a hub that opens onto the module's roots (more hubs chained when there * are many; a top-level class's root methods open off a lobby of their - * own, which takes one door of the hub), a door for each call on the breadth-first tree from those - * roots through the rooms' ports, a call portal for every other resolved + * own, which takes one door of the hub), a door for each call on the + * breadth-first tree from those roots through the rooms' ports, a call portal for every other resolved * call, and a closed marker in every room holding calls the world cannot * follow (ambiguous, dynamic, external or unresolved; never a portal to a * guess). The hub holds a module portal to every module it imports or From 393edec9ba80124f8485c051ef99719af7266544 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:32:08 +0300 Subject: [PATCH 3/3] fix: address PR review feedback Co-Authored-By: Claude Opus 5.5 --- packages/parser/src/functions.ts | 17 +++++++++++++++-- packages/parser/src/parser.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/parser/src/functions.ts b/packages/parser/src/functions.ts index 4677a39..8fa3c05 100644 --- a/packages/parser/src/functions.ts +++ b/packages/parser/src/functions.ts @@ -15,6 +15,7 @@ import { isFunctionExpression, isIdentifier, isImportDeclaration, + isTSModuleDeclaration, isVariableDeclaration, } from "@babel/types"; import type { ClassDeclaration, Node, Program } from "@babel/types"; @@ -68,6 +69,11 @@ type Context = { readonly nextClassName: (name: string) => string; /** Local names exported by any form (see `exportedLocalNames`). */ readonly exportedNames: ReadonlySet; + /** + * Inside a class body (a static block) or a namespace, which keep the + * enclosing scope: a class declared there is not top-level. + */ + readonly enclosed: boolean; readonly out: Discovered; }; @@ -148,7 +154,7 @@ const visitClassBody = ( name, span: spanOf(node), exported, - topLevel: scope.parent === null, + topLevel: scope.parent === null && !context.enclosed, }); for (const member of node.body.body) { const field = fieldFunction(member); @@ -192,7 +198,7 @@ const visitClassBody = ( (field.isStatic ? table.static : table.instance).set(field.name, fn); } else { for (const child of childrenOf(member)) { - visit(context, scope, child, false, depth + 1); + visit({ ...context, enclosed: true }, scope, child, false, depth + 1); } } } @@ -301,6 +307,12 @@ const visit = ( } return; } + if (isTSModuleDeclaration(node)) { + for (const child of childrenOf(node)) { + visit({ ...context, enclosed: true }, scope, child, false, depth + 1); + } + return; + } if (isCatchClause(node) && node.param !== null && node.param !== undefined) { bindingNames(node.param, scope.bindings); } @@ -347,6 +359,7 @@ const discover = ( moduleId, nextName: uniqueNames(), nextClassName: uniqueNames(), + enclosed: false, exportedNames, out, }; diff --git a/packages/parser/src/parser.test.ts b/packages/parser/src/parser.test.ts index b31bb8c..4a96ad7 100644 --- a/packages/parser/src/parser.test.ts +++ b/packages/parser/src/parser.test.ts @@ -144,6 +144,14 @@ describe("function discovery", () => { " class InCallback {}", "});", "const Expr = class {};", + "class Host {", + " static {", + " class InStatic { run() {} }", + " }", + "}", + "namespace Space {", + " class InSpace {}", + "}", "export default class {}", ].join("\n") ); @@ -160,6 +168,9 @@ describe("function discovery", () => { ["t.ts//class:Local~2", "Local", false, true], ["t.ts//class:Inner", "Inner", false, false], ["t.ts//class:InCallback", "InCallback", false, false], + ["t.ts//class:Host", "Host", false, true], + ["t.ts//class:InStatic", "InStatic", false, false], + ["t.ts//class:InSpace", "InSpace", false, false], ["t.ts//class:default", "default", true, true], ]); // The declaration itself, after `export `.