@akanjs/devkit 3.1.2-rc.2 → 3.1.2-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,7 +50,7 @@ import {
50
50
  UNAVAILABLE_REDIS_HOST,
51
51
  } from "./devHostPolicy";
52
52
 
53
- const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
53
+ const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr", "build-graph"]);
54
54
 
55
55
  const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
56
56
 
@@ -504,7 +504,7 @@ export class AkanAppHost {
504
504
  await this.#handleInvalidate(message);
505
505
  return;
506
506
  }
507
- if (message.type === "build-route-res" || message.type === "build-csr-res") {
507
+ if (message.type === "build-route-res" || message.type === "build-csr-res" || message.type === "build-graph-res") {
508
508
  const answer = this.#builderRequests.settle(message);
509
509
  if (!answer) {
510
510
  this.logger.verbose(`[builder] dropped a ${message.type} no live backend is waiting for (id=${message.id})`);
@@ -925,6 +925,8 @@ export class AkanAppHost {
925
925
  this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
926
926
  else if (message.type === "build-csr")
927
927
  this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
928
+ else if (message.type === "build-graph")
929
+ this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error });
928
930
  else return false;
929
931
  return true;
930
932
  }
@@ -1308,7 +1310,7 @@ export class AkanAppHost {
1308
1310
  this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
1309
1311
  }
1310
1312
  // Renumbered per backend generation; the failure replies below use that backend's own `message.id`.
1311
- if (message.type === "build-route" || message.type === "build-csr") {
1313
+ if (message.type === "build-route" || message.type === "build-csr" || message.type === "build-graph") {
1312
1314
  const outgoing = this.#builderRequests.issue(message);
1313
1315
  if (this.#builder?.send(outgoing)) return;
1314
1316
  this.#builderRequests.withdraw(outgoing.id);
@@ -321,6 +321,12 @@ export const readonlyMcpTools: McpToolDefinition[] = [
321
321
  "Legacy broad module context. Pass app in monorepos; prefer inspect_akan_context.moduleContext or fieldInsertionContext for typed add-field evidence.",
322
322
  inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty }, required: ["module"] },
323
323
  },
324
+ {
325
+ name: "get_graph",
326
+ description:
327
+ "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with guards, the service methods and queries each calls, the store actions that fetch them, and the components and pages that use them. Every node and edge carries its source location. Pass app and/or module to scope a large workspace.",
328
+ inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty } },
329
+ },
324
330
  {
325
331
  name: "get_guideline",
326
332
  description: "Return an Akan agent guideline resource by name.",
@@ -1,7 +1,4 @@
1
- import type { BuilderCsrReq, BuilderCsrRes, BuilderReq, BuilderRes } from "akanjs/server";
2
-
3
- type BuilderRequest = BuilderReq | BuilderCsrReq;
4
- type BuilderResponse = BuilderRes | BuilderCsrRes;
1
+ import type { BuilderRequest, BuilderResponse } from "akanjs/server";
5
2
 
6
3
  // `BuilderRpc` numbers from 1 per backend process while the builder outlives backends, so ids collide across a
7
4
  // restart; host-owned ids keep a dead generation's answer from settling the new backend's request.
@@ -108,6 +108,7 @@ describe("IncrementalBuilderHost", () => {
108
108
  const { spawns, messages, host } = startRecordingHost();
109
109
  expect(host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] })).toBe(true);
110
110
  expect(host.send({ type: "build-csr", id: 2, reason: "device webview" })).toBe(true);
111
+ expect(host.send({ type: "build-graph", id: 4 })).toBe(true);
111
112
  // Answered before the exit, so this one must not be failed again afterwards.
112
113
  expect(host.send({ type: "build-route", id: 3, routeId: "c", seeds: [], knownEntries: [] })).toBe(true);
113
114
  spawns[0]?.options.ipc?.({ type: "build-route-res", id: 3, ok: true, data: { routeId: "c" } });
@@ -129,6 +130,12 @@ describe("IncrementalBuilderHost", () => {
129
130
  ok: false,
130
131
  error: "builder exited to release bundler memory before answering; reload to retry",
131
132
  },
133
+ {
134
+ type: "build-graph-res",
135
+ id: 4,
136
+ ok: false,
137
+ error: "builder exited to release bundler memory before answering; reload to retry",
138
+ },
132
139
  ]);
133
140
 
134
141
  // The replacement owes nothing, so its own exit stays quiet.
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Logger } from "akanjs/common";
4
- import type { BuilderMessage } from "akanjs/server";
4
+ import type { BuilderMessage, BuilderRequest } from "akanjs/server";
5
5
  import { CSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
6
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
7
7
  import type { App } from "../commandDecorators";
@@ -9,6 +9,7 @@ import type { App } from "../commandDecorators";
9
9
  const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
10
10
  "build-route-res",
11
11
  "build-csr-res",
12
+ "build-graph-res",
12
13
  "builder-ready",
13
14
  "invalidate",
14
15
  "css-updated",
@@ -73,7 +74,7 @@ export class IncrementalBuilderHost {
73
74
  #manualStop = false;
74
75
  // Nothing else answers a request whose builder exits holding it: a crash or kill sends nothing, and a drain races
75
76
  // its own exit, so an unanswered page request would spin forever.
76
- readonly #inFlight = new Map<number, "build-route" | "build-csr">();
77
+ readonly #inFlight = new Map<number, BuilderRequest["type"]>();
77
78
  #startOptions: IncrementalBuilderStartOptions = {};
78
79
  constructor({
79
80
  app,
@@ -129,7 +130,8 @@ export class IncrementalBuilderHost {
129
130
  ipc: (msg: BuilderMessage) => {
130
131
  if (this.#proc !== proc) return;
131
132
  if (!msg || typeof msg !== "object") return;
132
- if (msg.type === "build-route-res" || msg.type === "build-csr-res") this.#inFlight.delete(msg.id);
133
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res" || msg.type === "build-graph-res")
134
+ this.#inFlight.delete(msg.id);
133
135
  if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
134
136
  if (msg.type === "builder-ready" && !this.ready) {
135
137
  this.ready = true;
@@ -269,7 +271,8 @@ export class IncrementalBuilderHost {
269
271
  }
270
272
  try {
271
273
  this.#proc.send(message);
272
- if (message.type === "build-route" || message.type === "build-csr") this.#inFlight.set(message.id, message.type);
274
+ if (message.type === "build-route" || message.type === "build-csr" || message.type === "build-graph")
275
+ this.#inFlight.set(message.id, message.type);
273
276
  return true;
274
277
  } catch (error) {
275
278
  this.logger.warn(
@@ -129,6 +129,69 @@ process.send({ type: "build-batch-result", data: { generation, errors, artifact:
129
129
  expect(messages.some((message) => message.type === "build-status")).toBe(false);
130
130
  }, 30_000);
131
131
 
132
+ test("draws the app's graph on request, with the libs it reaches and no other app", async () => {
133
+ const { root } = track(await createTempApp("demo"));
134
+ const appDir = path.join(root, "apps/demo");
135
+ await writeText(
136
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
137
+ `const { generation } = JSON.parse(process.argv[2]);
138
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
139
+ `,
140
+ );
141
+ await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
142
+ const moduleFiles = (owner: string, name: string) => {
143
+ const model = `${name[0].toUpperCase()}${name.slice(1)}`;
144
+ return [
145
+ writeText(
146
+ path.join(root, owner, `lib/${name}/${name}.constant.ts`),
147
+ `export class ${model} extends via((field) => ({ name: field(String) })) {}\n`,
148
+ ),
149
+ writeText(
150
+ path.join(root, owner, `lib/${name}/${name}.service.ts`),
151
+ `export class ${model}Service extends serve(db.${name}, () => ({})) {}\n`,
152
+ ),
153
+ ];
154
+ };
155
+ await Promise.all([
156
+ ...moduleFiles("apps/demo", "memo"),
157
+ ...moduleFiles("apps/other", "note"),
158
+ ...moduleFiles("libs/shared", "file"),
159
+ ]);
160
+ const messages: BuilderMessage[] = [];
161
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
162
+ cwd: appDir,
163
+ env: {
164
+ ...process.env,
165
+ AKAN_WORKSPACE_ROOT: root,
166
+ AKAN_PUBLIC_APP_NAME: "demo",
167
+ AKAN_PUBLIC_REPO_NAME: "repo",
168
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
169
+ AKAN_PUBLIC_ENV: "local",
170
+ AKAN_WATCH: "0",
171
+ },
172
+ stdio: ["ignore", "ignore", "pipe"],
173
+ serialization: "advanced",
174
+ ipc: (message: BuilderMessage) => {
175
+ messages.push(message);
176
+ },
177
+ });
178
+ try {
179
+ await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
180
+ proc.send({ type: "build-graph", id: 5 } satisfies BuilderMessage);
181
+ await until(proc, "build-graph-res", () => messages.some((message) => message.type === "build-graph-res"));
182
+ } finally {
183
+ proc.kill();
184
+ await proc.exited;
185
+ }
186
+ const answer = messages.find((message) => message.type === "build-graph-res");
187
+ if (answer?.type !== "build-graph-res" || !answer.ok) throw new Error(`no graph: ${JSON.stringify(answer)}`);
188
+ expect(answer.data.workspaceRoot).toBe(root);
189
+ expect(answer.data.graph.nodes.map((node) => node.id).sort()).toEqual([
190
+ "model:apps/demo:memo",
191
+ "model:libs/shared:file",
192
+ ]);
193
+ }, 30_000);
194
+
132
195
  test("answers a second shell request in the same generation without building again", async () => {
133
196
  const { root } = track(await createTempApp("demo"));
134
197
  const appDir = path.join(root, "apps/demo");
@@ -18,13 +18,16 @@ import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuil
18
18
  import { ServerGraphFile } from "@akanjs/devkit/frontendBuild/serverGraphFile";
19
19
  import { SsrDevBundler } from "@akanjs/devkit/frontendBuild/ssrDevBundler";
20
20
  import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
21
+ import { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
21
22
  import { Logger } from "akanjs/common";
22
23
  import type {
23
24
  BaseBuildArtifact,
24
25
  BuilderCsrReq,
26
+ BuilderGraphReq,
25
27
  BuilderMessage,
26
28
  BuilderReq,
27
29
  BuilderRes,
30
+ BuildGraphPayload,
28
31
  BuildPhase,
29
32
  BuildRouteResultPayload,
30
33
  ChangeBatch,
@@ -64,6 +67,7 @@ class IncrementalBuilder {
64
67
  #watcher: HmrWatcher | null = null;
65
68
  #generation = 0;
66
69
  #shellKey: string | null = null;
70
+ #graph: { generation: number; payload: BuildGraphPayload } | null = null;
67
71
  #discoveredThrough = 0;
68
72
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
69
73
  #csrBundler: CsrDevBundler;
@@ -873,6 +877,22 @@ class IncrementalBuilder {
873
877
  this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
874
878
  }
875
879
 
880
+ async handleBuildGraph(msg: BuilderGraphReq): Promise<void> {
881
+ const generation = this.#generation;
882
+ try {
883
+ if (this.#graph?.generation !== generation) {
884
+ const { workspace } = this.#app;
885
+ const graph = WorkspaceGraph.visibleTo(await WorkspaceGraph.build(workspace), this.#app.name);
886
+ this.#graph = { generation, payload: { workspaceRoot: workspace.workspaceRoot, graph } };
887
+ }
888
+ await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: true, data: this.#graph.payload });
889
+ } catch (err) {
890
+ const error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
891
+ this.#logger.error(`build-graph failed: ${error}`);
892
+ await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: false, error });
893
+ }
894
+ }
895
+
876
896
  // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
877
897
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
878
898
  if (msg.shell) {
@@ -1080,13 +1100,18 @@ class IncrementalBuilder {
1080
1100
  void builder.shutdown(msg.reason);
1081
1101
  return;
1082
1102
  }
1083
- if (msg.type !== "build-route" && msg.type !== "build-csr") return;
1103
+ if (msg.type !== "build-route" && msg.type !== "build-csr" && msg.type !== "build-graph") return;
1084
1104
  if (!builder || builder.shuttingDown) {
1085
1105
  const error = builder?.shuttingDown ? recyclingError : bootingError;
1086
1106
  BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
1087
1107
  return;
1088
1108
  }
1089
- const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
1109
+ const handled =
1110
+ msg.type === "build-route"
1111
+ ? builder.handleBuildRoute(msg)
1112
+ : msg.type === "build-csr"
1113
+ ? builder.handleBuildCsr(msg)
1114
+ : builder.handleBuildGraph(msg);
1090
1115
  // Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
1091
1116
  void handled.catch((err: unknown) =>
1092
1117
  logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.2-rc.2",
3
+ "version": "3.1.2-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -41,7 +41,7 @@
41
41
  "./*": "./*.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@akanjs/language": "3.1.2-rc.2",
44
+ "@akanjs/language": "3.1.2-rc.3",
45
45
  "@earendil-works/pi-agent-core": "1.3.0",
46
46
  "@earendil-works/pi-ai": "1.3.0",
47
47
  "@earendil-works/pi-codemode": "1.3.0",
@@ -50,7 +50,7 @@
50
50
  "@earendil-works/pi-tui": "1.3.0",
51
51
  "@inquirer/prompts": "^8.7.3",
52
52
  "@tailwindcss/node": "^4.3.3",
53
- "akanjs": "3.1.2-rc.2",
53
+ "akanjs": "3.1.2-rc.3",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -0,0 +1,73 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { type AkanGraph, type AkanGraphNode, AkanIndex } from "@akanjs/language";
4
+ import ts from "typescript";
5
+ import type { Workspace } from "./commandDecorators";
6
+
7
+ export interface GraphScope {
8
+ app?: string | null;
9
+ module?: string | null;
10
+ }
11
+
12
+ //? `env/` holds each environment's secrets in plain TypeScript; the graph never needs to read it.
13
+ const indexedPath = /^(apps|libs)\/(?!(?:.*\/)?(?:node_modules|dist|\.akan|env)\/).*\.tsx?$/;
14
+ const unscopedNodeLimit = 600;
15
+ const slashed = (fileName: string) => fileName.replace(/\\/g, "/");
16
+
17
+ export class WorkspaceGraph {
18
+ static async build(workspace: Workspace): Promise<AkanGraph> {
19
+ const root = workspace.workspaceRoot;
20
+ const index = new AkanIndex({ ts, root });
21
+ const texts = new Map<string, string>();
22
+ const files = (await workspace.getAllFiles("{apps,libs}/**/*.{ts,tsx}"))
23
+ .map(slashed)
24
+ .filter((file) => indexedPath.test(file));
25
+ await Promise.all(
26
+ files.map(async (file) => {
27
+ const fileName = slashed(path.join(root, file));
28
+ const text = await readFile(fileName, "utf8");
29
+ texts.set(fileName, text);
30
+ index.setFile(fileName, text);
31
+ }),
32
+ );
33
+ return index.graph((fileName) => texts.get(fileName));
34
+ }
35
+
36
+ static scope(graph: AkanGraph, { app, module }: GraphScope): AkanGraph {
37
+ if (!app && !module) return graph;
38
+ const owners = app ? new Set([`apps/${app}`, `libs/${app}`]) : null;
39
+ const seeds = new Set(
40
+ graph.nodes
41
+ .filter((node) => (!owners || owners.has(node.owner ?? "")) && (!module || node.module === module))
42
+ .map((node) => node.id),
43
+ );
44
+ const edges = graph.edges.filter((edge) => seeds.has(edge.from) || seeds.has(edge.to));
45
+ const ids = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
46
+ return { version: 1, nodes: graph.nodes.filter((node) => ids.has(node.id)), edges };
47
+ }
48
+
49
+ static visibleTo(graph: AkanGraph, app: string): AkanGraph {
50
+ const appOwner = `apps/${app}`;
51
+ const isVisible = ({ owner }: AkanGraphNode) => !owner || owner === appOwner || owner.startsWith("libs/");
52
+ const nodes = graph.nodes.filter(isVisible);
53
+ const ids = new Set(nodes.map((node) => node.id));
54
+ return { version: 1, nodes, edges: graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)) };
55
+ }
56
+
57
+ static answer(graph: AkanGraph, scope: GraphScope) {
58
+ if (scope.app || scope.module || graph.nodes.length <= unscopedNodeLimit) return WorkspaceGraph.scope(graph, scope);
59
+ const owners = new Map<string, number>();
60
+ for (const node of graph.nodes) if (node.owner) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
61
+ return {
62
+ diagnostics: [
63
+ {
64
+ severity: "error",
65
+ code: "graph-scope-required",
66
+ message: `The graph has ${graph.nodes.length} nodes. Pass app, module or both to scope it.`,
67
+ input: "app",
68
+ },
69
+ ],
70
+ owners: Object.fromEntries([...owners].sort(([a], [b]) => (a < b ? -1 : 1))),
71
+ };
72
+ }
73
+ }