Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,19 @@ 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
and depth, plus one way into any import cycle nothing else reaches, so
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.

---

Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/source-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions apps/web/src/world-from-code-lobbies.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
11 changes: 9 additions & 2 deletions apps/web/src/world-from-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -81,14 +82,17 @@ 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}()`;
};

/**
* 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 => {
Expand All @@ -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;
};
Expand Down
14 changes: 12 additions & 2 deletions packages/code-graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions packages/code-graph/src/code-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[];
};
Expand All @@ -304,6 +324,7 @@ export type {
CallNode,
CallResolution,
CallSite,
ClassNode,
CodeGraph,
ContainmentEdge,
ContinueNode,
Expand Down
2 changes: 1 addition & 1 deletion packages/code-graph/src/entrance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 49 additions & 8 deletions packages/code-graph/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type {
CallEdge,
CallSite,
ClassNode,
CodeGraph,
ExportRecord,
FlowStep,
Expand All @@ -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 = {
Expand Down Expand Up @@ -87,27 +94,58 @@ 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;
}
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<string, ClassNode>();
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,
Expand All @@ -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}"`);
}
Expand Down Expand Up @@ -163,6 +202,7 @@ const fixtureModule = (
return {
modules: [node],
functions,
classes: classesOf(module.path, functions),
callSites,
edges: [
...functions.map((fn) => ({
Expand All @@ -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),
};
Expand Down
Loading
Loading