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

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. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
328
+ inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty, since: stringProperty } },
329
+ },
324
330
  {
325
331
  name: "get_guideline",
326
332
  description: "Return an Akan agent guideline resource by name.",
@@ -0,0 +1,32 @@
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { Logger } from "akanjs/common";
3
+ import { ApplicationBuildReporter, type ApplicationBuildResult } from "./applicationBuildReporter";
4
+
5
+ const resultFor = (environment: string): ApplicationBuildResult => ({
6
+ phases: [],
7
+ durationMs: 1_200,
8
+ outputDir: "dist/apps/shop",
9
+ artifactDir: "dist/apps/shop/.akan/artifact",
10
+ environment,
11
+ });
12
+
13
+ describe("ApplicationBuildReporter.printSummary", () => {
14
+ const rawLog = spyOn(Logger, "rawLog").mockImplementation(() => undefined);
15
+ afterEach(() => rawLog.mockClear());
16
+
17
+ test("names every variable a deployed build needs when the build is for local", () => {
18
+ ApplicationBuildReporter.printSummary(resultFor("local"));
19
+
20
+ const notice = rawLog.mock.calls.map(([line]) => line).at(-1);
21
+ expect(notice).toBe(ApplicationBuildReporter.localBuildNotice);
22
+ for (const key of ["AKAN_PUBLIC_ENV=main", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"])
23
+ expect(notice).toContain(key);
24
+ });
25
+
26
+ test("adds nothing to a build for a deployed environment", () => {
27
+ ApplicationBuildReporter.printSummary(resultFor("main"));
28
+
29
+ expect(rawLog.mock.calls.map(([line]) => line)).not.toContain(ApplicationBuildReporter.localBuildNotice);
30
+ expect(rawLog.mock.calls.at(-1)?.[0]).toBe("Done in 1.2s");
31
+ });
32
+ });
@@ -15,6 +15,7 @@ export interface ApplicationBuildResult {
15
15
  durationMs: number;
16
16
  outputDir: string;
17
17
  artifactDir: string;
18
+ environment: string;
18
19
  }
19
20
 
20
21
  export interface ApplicationBuildProgressReporter {
@@ -35,8 +36,12 @@ export class ApplicationBuildReporter {
35
36
  Logger.rawLog(`Route artifacts: ${result.artifactDir}`);
36
37
  Logger.rawLog(`Server output: ${result.outputDir}`);
37
38
  Logger.rawLog(`Done in ${ApplicationBuildReporter.formatDuration(result.durationMs)}`);
39
+ if (result.environment === "local") Logger.rawLog(ApplicationBuildReporter.localBuildNotice);
38
40
  }
39
41
 
42
+ static readonly localBuildNotice =
43
+ "Built for local, which serves the dev routes to every caller. For a host others can reach, build with AKAN_PUBLIC_ENV=main, AKAN_PUBLIC_REPO_NAME and AKAN_PUBLIC_SERVE_DOMAIN set.";
44
+
40
45
  static formatError(error: unknown, workspaceRoot = process.cwd()): string {
41
46
  if (error instanceof AggregateError) {
42
47
  const nestedMessages = error.errors
@@ -182,6 +182,7 @@ export class ApplicationBuildRunner {
182
182
  durationMs: Date.now() - this.#startedAt,
183
183
  outputDir: this.#app.dist.cwdPath,
184
184
  artifactDir: path.join(this.#app.dist.cwdPath, ".akan/artifact"),
185
+ environment,
185
186
  };
186
187
  }
187
188
 
@@ -34,6 +34,25 @@ export default page()
34
34
  6. Re-skin an `akanjs/ui` component for a subtree with `_overrides.tsx`:
35
35
  `export default override({ Slot: AppComponent })`, no logic.
36
36
 
37
+ ## API at a glance
38
+
39
+ `page`, `layout` and `router` come from `akanjs/client`, `Link` from `akanjs/ui`, `usePageLocation` from
40
+ `akanjs/webkit`. A stage receives the declared params and search keys plus `lang`.
41
+
42
+ - Data-driven title: `.head(async ({ name }) => <title>{(await fetch.pokemon(name))?.label}</title>)`.
43
+ - Async page: `.render(async ({ name }) => { const pokemon = await fetch.pokemon(name); if (!pokemon) router.notFound(); … })`;
44
+ the same query in `.head()` and `.render()` is one request.
45
+ - `router.notFound()` from `.guard()`, `.head()` or `.render()` answers 404 with the nearest layout's `.notFound()`
46
+ above it (an unmatched URL: the deepest layout covering it), else the built-in 404.
47
+ - A layout's own 404 and error views: `.notFound(({ pathname }) => …)` and `.error(({ error }) => …)` on `layout()`
48
+ or `rootLayout()`.
49
+ - `<Link href="/pokemon/deoxys">`: write the path without the locale; Link adds it, or leaves it off for the default
50
+ locale under `i18n.localePrefix: "as-needed"`. `activeClassName` marks the current one.
51
+ - From a handler: `router.push(href)` / `router.replace(href)`; in a server stage, `router.redirect(href)`.
52
+ - A client filter kept in the URL without reloading the page: ``router.replace(`/pokemon?q=${q}`, { shallow: true,
53
+ scrollToTop: false })`` in a `"use client"` component, read back with `usePageLocation().searchParams`. Shallow
54
+ holds only while the path stays the same; a server `.search()` sees the value at the next full navigation.
55
+
37
56
  ## Never
38
57
 
39
58
  - A `fetch.<mutation>()` while rendering — it throws `base.error.mutationInRender`, because opening a link would
@@ -31,4 +31,51 @@ registered under `<refName>`, and the `Service` suffix is required.
31
31
  An external API or piece of infrastructure is an `adapt()` class in `srvkit/`, injected with `plug`. It
32
32
  self-registers, so do **not** add it to `lib/option.ts`.
33
33
 
34
+ ## Recipe: pages backed by an external HTTP API
35
+
36
+ `akan create-service pokeapi <app>` scaffolds `lib/_pokeapi/`; the adapter beside it is yours.
37
+
38
+ ```ts
39
+ // srvkit/pokeApi.ts — read at boot, so the built server takes API_BASE_URL from the host that runs it
40
+ export class PokeApi extends adapt("pokeApi" as const, ({ env }) => ({
41
+ baseUrl: env(() => process.env.API_BASE_URL ?? "http://localhost:9000"),
42
+ })) {
43
+ async get<T>(path: string): Promise<T | null> {
44
+ try {
45
+ const res = await fetch(`${this.baseUrl}/${path}`, { signal: AbortSignal.timeout(20_000) });
46
+ if (res.ok) return (await res.json()) as T;
47
+ if (res.status !== 404) this.logger.warn(`${path} answered ${res.status}`);
48
+ } catch (error) {
49
+ this.logger.warn(`${path} failed: ${String(error)}`);
50
+ }
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // lib/_pokeapi/pokeapi.service.ts — shape the upstream JSON here, not in the page
56
+ export class PokeapiService extends serve("pokeapi" as const, ({ plug }) => ({ pokeApi: plug(PokeApi) })) {
57
+ async getPokemon(name: string) {
58
+ const pokemon = await this.pokeApi.get<ApiPokemon>(`pokemon/${name}`);
59
+ return pokemon && { id: pokemon.id, name: pokemon.name };
60
+ }
61
+ }
62
+
63
+ // lib/_pokeapi/pokeapi.signal.ts — Any answers plain JSON; nullable lets it answer null for "not found"
64
+ export class PokeapiEndpoint extends endpoint(srv.pokeapi, ({ query }) => ({
65
+ pokemon: query(Any, { guards: [Public], nullable: true })
66
+ .param("name", String)
67
+ .exec(async function (name) {
68
+ return await this.pokeapiService.getPokemon(name);
69
+ }),
70
+ })) {}
71
+
72
+ // lib/_pokeapi/pokeapi.dictionary.ts — typecheck asks a label for every endpoint
73
+ .endpoint<PokeapiEndpoint>((fn) => ({ pokemon: fn(["Pokémon", "포켓몬"]) }))
74
+ ```
75
+
76
+ The page calls it as `await fetch.pokemon(name)` (`fetch` from `@apps/<app>/client`) and answers
77
+ `router.notFound()` on `null`. An `Any` return reaches the page as `unknown`: type it with an interface in
78
+ `common/`, or return a scalar (`akan create-scalar`) for a typed wire shape. A query that answers `null` without
79
+ `nullable: true` fails the render, naming the endpoint.
80
+
34
81
  Full detail: `get_guideline` with `modelService`.
@@ -0,0 +1,82 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { GitRevision } from "./gitRevision";
6
+
7
+ const roots: string[] = [];
8
+ const byName = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
9
+ afterEach(async () => {
10
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
11
+ });
12
+
13
+ const repoOf = async () => {
14
+ const root = await mkdtemp(path.join(tmpdir(), "akan-git-"));
15
+ roots.push(root);
16
+ const git = (...args: string[]) => {
17
+ const result = Bun.spawnSync(
18
+ ["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
19
+ { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
20
+ );
21
+ expect(result.exitCode).toBe(0);
22
+ return result.stdout.toString().trim();
23
+ };
24
+ const write = async (file: string, text: string) => {
25
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
26
+ await writeFile(path.join(root, file), text);
27
+ };
28
+ git("init", "-q", "-b", "main");
29
+ return { root, git, write };
30
+ };
31
+
32
+ describe("GitRevision", () => {
33
+ it("lists each changed file with its change, a rename as a removal and an addition", async () => {
34
+ const { root, git, write } = await repoOf();
35
+ await write(".gitignore", "apps/*/scripts\n");
36
+ await write("apps/demo/lib/memo/memo.constant.ts", "a\n");
37
+ await write("apps/demo/lib/memo/memo.service.ts", "b\n");
38
+ await write("apps/demo/lib/memo/memo.store.ts", "c\n");
39
+ git("add", "-A");
40
+ git("commit", "-q", "--no-verify", "-m", "base");
41
+ const base = git("rev-parse", "HEAD");
42
+ await write("apps/demo/lib/memo/memo.constant.ts", "a2\n");
43
+ await unlink(path.join(root, "apps/demo/lib/memo/memo.service.ts"));
44
+ git("mv", "apps/demo/lib/memo/memo.store.ts", "apps/demo/lib/memo/memo2.store.ts");
45
+ await write("apps/demo/lib/memo/memo.signal.ts", "d\n");
46
+ await write("apps/demo/scripts/seed.ts", "e\n");
47
+ const git2 = new GitRevision(root);
48
+ expect(await git2.changedFilesSince(base)).toEqual([
49
+ { file: "apps/demo/lib/memo/memo.constant.ts", change: "changed" },
50
+ { file: "apps/demo/lib/memo/memo.service.ts", change: "removed" },
51
+ { file: "apps/demo/lib/memo/memo.signal.ts", change: "added" },
52
+ { file: "apps/demo/lib/memo/memo.store.ts", change: "removed" },
53
+ { file: "apps/demo/lib/memo/memo2.store.ts", change: "added" },
54
+ ]);
55
+ expect((await git2.workingFiles()).sort(byName)).toEqual([
56
+ "apps/demo/lib/memo/memo.constant.ts",
57
+ "apps/demo/lib/memo/memo.service.ts",
58
+ "apps/demo/lib/memo/memo.signal.ts",
59
+ "apps/demo/lib/memo/memo2.store.ts",
60
+ ]);
61
+ expect([...(await git2.filesAt(base, () => true)).keys()].sort(byName)).toEqual([
62
+ "apps/demo/lib/memo/memo.constant.ts",
63
+ "apps/demo/lib/memo/memo.service.ts",
64
+ "apps/demo/lib/memo/memo.store.ts",
65
+ ]);
66
+ });
67
+
68
+ it("says so when HEAD shares no commit with the revision, instead of comparing with its tip", async () => {
69
+ const { git, write, root } = await repoOf();
70
+ await write("apps/demo/a.ts", "a\n");
71
+ git("add", "-A");
72
+ git("commit", "-q", "--no-verify", "-m", "main");
73
+ git("checkout", "-q", "--orphan", "other");
74
+ await write("apps/demo/b.ts", "b\n");
75
+ git("add", "-A");
76
+ git("commit", "-q", "--no-verify", "-m", "other");
77
+ const revision = new GitRevision(root);
78
+ expect((await revision.forkPointOf("main")).error).toContain("shares no commit with main");
79
+ expect((await revision.forkPointOf(null)).error).toContain("shares no commit with main");
80
+ expect((await revision.forkPointOf("-x")).error).toBe("-x is not a commit in this repository.");
81
+ });
82
+ });
package/gitRevision.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { AkanChangedFile } from "@akanjs/language";
2
+
3
+ export type ForkPoint = { sha: string; error?: undefined } | { sha?: undefined; error: string };
4
+
5
+ const systemPaths = ["apps", "libs"];
6
+ const changeOfStatus: { [status: string]: AkanChangedFile["change"] | undefined } = { A: "added", D: "removed" };
7
+
8
+ //? Commands run in the workspace root, which may sit below the repository root; `./<path>` and `--relative` keep every
9
+ //? path git reads or prints relative to the workspace.
10
+ export class GitRevision {
11
+ static readonly #defaultBranches = ["upstream/HEAD", "upstream/main", "origin/HEAD", "origin/main", "main", "master"];
12
+ //? Studio and MCP read git beside the user's own commands; without this, `git diff` may take index.lock to refresh it.
13
+ static readonly #env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
14
+ readonly #root: string;
15
+
16
+ constructor(root: string) {
17
+ this.#root = root;
18
+ }
19
+
20
+ async forkPointOf(since: string | null): Promise<ForkPoint> {
21
+ if ((await this.#git(["rev-parse", "--is-inside-work-tree"]))?.trim() !== "true")
22
+ return { error: "The Change Map compares with git history, and this workspace is not in a git repository." };
23
+ if (since !== null) {
24
+ //? A leading `-` would reach git as an option.
25
+ const sha = /^[^-\s][^\s]*$/.test(since)
26
+ ? (await this.#git(["rev-parse", "--verify", "--quiet", `${since}^{commit}`]))?.trim()
27
+ : undefined;
28
+ if (!sha) return { error: `${since} is not a commit in this repository.` };
29
+ return await this.#mergeBase(since, sha);
30
+ }
31
+ for (const branch of GitRevision.#defaultBranches)
32
+ if ((await this.#git(["rev-parse", "--verify", "--quiet", `${branch}^{commit}`])) !== null)
33
+ return await this.#mergeBase(branch, branch);
34
+ const head = (await this.#git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]))?.trim();
35
+ return head ? { sha: head } : { error: "The repository has no commit to compare with yet." };
36
+ }
37
+
38
+ async #mergeBase(name: string, revision: string): Promise<ForkPoint> {
39
+ const sha = (await this.#git(["merge-base", "HEAD", revision]))?.trim();
40
+ if (sha) return { sha };
41
+ return {
42
+ error: `HEAD shares no commit with ${name} here. In a shallow clone, \`git fetch --unshallow\` brings the history in.`,
43
+ };
44
+ }
45
+
46
+ async filesAt(sha: string, isWanted: (file: string) => boolean): Promise<Map<string, string>> {
47
+ const listed = await this.#git(["ls-tree", "-r", "-z", "--name-only", sha, "--", ...systemPaths]);
48
+ if (listed === null) throw new Error(`git ls-tree failed for ${sha}`);
49
+ const files = listed.split("\0").filter((file) => file && isWanted(file));
50
+ const texts = new Map<string, string>();
51
+ if (!files.length) return texts;
52
+ const proc = Bun.spawn(["git", "cat-file", "--batch"], {
53
+ cwd: this.#root,
54
+ env: GitRevision.#env,
55
+ stdin: "pipe",
56
+ stdout: "pipe",
57
+ stderr: "ignore",
58
+ });
59
+ const output = new Response(proc.stdout).arrayBuffer();
60
+ proc.stdin.write(files.map((file) => `${sha}:./${file}\n`).join(""));
61
+ await proc.stdin.end();
62
+ const bytes = new Uint8Array(await output);
63
+ if ((await proc.exited) !== 0) throw new Error(`git cat-file failed for ${sha}`);
64
+ const decoder = new TextDecoder();
65
+ let offset = 0;
66
+ for (const file of files) {
67
+ const lineEnd = bytes.indexOf(10, offset);
68
+ if (lineEnd < 0) throw new Error(`git cat-file ended before ${file} at ${sha}`);
69
+ const header = decoder.decode(bytes.subarray(offset, lineEnd));
70
+ offset = lineEnd + 1;
71
+ if (header.endsWith(" missing")) continue;
72
+ const [, type, size] = header.split(" ");
73
+ const length = Number(size);
74
+ if (type === "blob") texts.set(file, decoder.decode(bytes.subarray(offset, offset + length)));
75
+ offset += length + 1;
76
+ }
77
+ return texts;
78
+ }
79
+
80
+ async changedFilesSince(sha: string): Promise<AkanChangedFile[]> {
81
+ const [tracked, untracked] = await Promise.all([
82
+ this.#git(["diff", "--name-status", "--no-renames", "--relative", "-z", sha, "--", ...systemPaths]),
83
+ this.#git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...systemPaths]),
84
+ ]);
85
+ if (tracked === null || untracked === null) throw new Error(`git could not list the changes since ${sha}`);
86
+ const changes = new Map<string, AkanChangedFile["change"]>();
87
+ const fields = tracked.split("\0");
88
+ for (let idx = 0; idx + 1 < fields.length; idx += 2)
89
+ changes.set(fields[idx + 1], changeOfStatus[fields[idx]] ?? "changed");
90
+ for (const file of untracked.split("\0")) if (file) changes.set(file, "added");
91
+ return [...changes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([file, change]) => ({ file, change }));
92
+ }
93
+
94
+ //? Tracked and untracked files, `.gitignore` applied, so the working side of a Change Map sees what the base side
95
+ //? can: a glob would read ignored files the base never has and mark them added.
96
+ async workingFiles(): Promise<string[]> {
97
+ const listed = await this.#git([
98
+ "ls-files",
99
+ "-z",
100
+ "--cached",
101
+ "--others",
102
+ "--exclude-standard",
103
+ "--",
104
+ ...systemPaths,
105
+ ]);
106
+ if (listed === null) throw new Error("git ls-files failed");
107
+ return [...new Set(listed.split("\0").filter(Boolean))];
108
+ }
109
+
110
+ async #git(args: string[]) {
111
+ try {
112
+ const proc = Bun.spawn(["git", ...args], {
113
+ cwd: this.#root,
114
+ env: GitRevision.#env,
115
+ stdout: "pipe",
116
+ stderr: "ignore",
117
+ });
118
+ const [text, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
119
+ return exitCode === 0 ? text : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ }
@@ -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,104 @@ 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 and Change Map 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 git = (...args: string[]) => {
161
+ const result = Bun.spawnSync(
162
+ ["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
163
+ { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
164
+ );
165
+ expect(result.exitCode).toBe(0);
166
+ };
167
+ git("init", "-q", "-b", "main");
168
+ git("add", "-A");
169
+ git("commit", "-q", "--no-verify", "-m", "base");
170
+ await Promise.all(
171
+ [
172
+ ["apps/demo/lib/memo/memo.constant.ts", "Memo"],
173
+ ["apps/other/lib/note/note.constant.ts", "Note"],
174
+ ].map(([file, model]) =>
175
+ writeText(
176
+ path.join(root, file),
177
+ `export class ${model} extends via((field) => ({ name: field(String), pinned: field(Boolean) })) {}\n`,
178
+ ),
179
+ ),
180
+ );
181
+ const messages: BuilderMessage[] = [];
182
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
183
+ cwd: appDir,
184
+ env: {
185
+ ...process.env,
186
+ AKAN_WORKSPACE_ROOT: root,
187
+ AKAN_PUBLIC_APP_NAME: "demo",
188
+ AKAN_PUBLIC_REPO_NAME: "repo",
189
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
190
+ AKAN_PUBLIC_ENV: "local",
191
+ AKAN_WATCH: "0",
192
+ },
193
+ stdio: ["ignore", "ignore", "pipe"],
194
+ serialization: "advanced",
195
+ ipc: (message: BuilderMessage) => {
196
+ messages.push(message);
197
+ },
198
+ });
199
+ try {
200
+ await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
201
+ proc.send({ type: "build-graph", id: 5 } satisfies BuilderMessage);
202
+ proc.send({ type: "build-graph", id: 6, changes: { since: null } } satisfies BuilderMessage);
203
+ proc.send({ type: "build-graph", id: 7, changes: { since: "main" } } satisfies BuilderMessage);
204
+ await until(
205
+ proc,
206
+ "build-graph-res",
207
+ () => messages.filter((message) => message.type === "build-graph-res").length === 3,
208
+ );
209
+ } finally {
210
+ proc.kill();
211
+ await proc.exited;
212
+ }
213
+ const answerTo = (id: number) => {
214
+ const answer = messages.find((message) => message.type === "build-graph-res" && message.id === id);
215
+ if (answer?.type !== "build-graph-res" || !answer.ok) throw new Error(`no graph: ${JSON.stringify(answer)}`);
216
+ return answer.data;
217
+ };
218
+ const graph = answerTo(5);
219
+ if (!("graph" in graph)) throw new Error("build-graph answered with changes");
220
+ expect(graph.workspaceRoot).toBe(root);
221
+ expect(graph.graph.nodes.map((node) => node.id).sort()).toEqual(["model:apps/demo:memo", "model:libs/shared:file"]);
222
+ const changes = answerTo(6);
223
+ if (!("changes" in changes) || "error" in changes.changes)
224
+ throw new Error(`no changes: ${JSON.stringify(changes)}`);
225
+ expect(changes.changes.nodes.map(({ id, change }) => [id, change])).toEqual([["model:apps/demo:memo", "changed"]]);
226
+ expect(changes.changes.summary).toEqual({ added: 0, removed: 0, changed: 1 });
227
+ expect(answerTo(7)).toEqual(changes);
228
+ }, 30_000);
229
+
132
230
  test("answers a second shell request in the same generation without building again", async () => {
133
231
  const { root } = track(await createTempApp("demo"));
134
232
  const appDir = path.join(root, "apps/demo");
@@ -18,13 +18,18 @@ 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 { GitRevision } from "@akanjs/devkit/gitRevision";
22
+ import { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
21
23
  import { Logger } from "akanjs/common";
22
24
  import type {
23
25
  BaseBuildArtifact,
26
+ BuildChangesPayload,
24
27
  BuilderCsrReq,
28
+ BuilderGraphReq,
25
29
  BuilderMessage,
26
30
  BuilderReq,
27
31
  BuilderRes,
32
+ BuildGraphPayload,
28
33
  BuildPhase,
29
34
  BuildRouteResultPayload,
30
35
  ChangeBatch,
@@ -64,6 +69,8 @@ class IncrementalBuilder {
64
69
  #watcher: HmrWatcher | null = null;
65
70
  #generation = 0;
66
71
  #shellKey: string | null = null;
72
+ #graph: { generation: number; payload: BuildGraphPayload } | null = null;
73
+ #changes: { key: string; payload: Promise<BuildChangesPayload> } | null = null;
67
74
  #discoveredThrough = 0;
68
75
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
69
76
  #csrBundler: CsrDevBundler;
@@ -873,6 +880,46 @@ class IncrementalBuilder {
873
880
  this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
874
881
  }
875
882
 
883
+ async handleBuildGraph(msg: BuilderGraphReq): Promise<void> {
884
+ try {
885
+ const data = msg.changes ? await this.#changesPayload(msg.changes.since) : await this.#graphPayload();
886
+ await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: true, data });
887
+ } catch (err) {
888
+ const error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
889
+ this.#logger.error(`build-graph failed: ${error}`);
890
+ await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: false, error });
891
+ }
892
+ }
893
+
894
+ async #graphPayload(): Promise<BuildGraphPayload> {
895
+ const generation = this.#generation;
896
+ if (this.#graph?.generation !== generation) {
897
+ const { workspace } = this.#app;
898
+ const graph = WorkspaceGraph.visibleTo(await WorkspaceGraph.build(workspace), this.#app.name);
899
+ this.#graph = { generation, payload: { workspaceRoot: workspace.workspaceRoot, graph } };
900
+ }
901
+ return this.#graph.payload;
902
+ }
903
+
904
+ async #changesPayload(since: string | null): Promise<BuildChangesPayload> {
905
+ const { workspace, name } = this.#app;
906
+ const { workspaceRoot } = workspace;
907
+ const forkPoint = await new GitRevision(workspaceRoot).forkPointOf(since);
908
+ if (forkPoint.error !== undefined) return { workspaceRoot, changes: { error: forkPoint.error } };
909
+ const key = `${this.#generation}:${forkPoint.sha}`;
910
+ if (this.#changes?.key !== key) {
911
+ const payload = WorkspaceGraph.changesAt(workspace, forkPoint.sha).then((changes) => ({
912
+ workspaceRoot,
913
+ changes: WorkspaceGraph.changesVisibleTo(changes, name),
914
+ }));
915
+ payload.catch(() => {
916
+ if (this.#changes?.payload === payload) this.#changes = null;
917
+ });
918
+ this.#changes = { key, payload };
919
+ }
920
+ return await this.#changes.payload;
921
+ }
922
+
876
923
  // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
877
924
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
878
925
  if (msg.shell) {
@@ -1080,13 +1127,18 @@ class IncrementalBuilder {
1080
1127
  void builder.shutdown(msg.reason);
1081
1128
  return;
1082
1129
  }
1083
- if (msg.type !== "build-route" && msg.type !== "build-csr") return;
1130
+ if (msg.type !== "build-route" && msg.type !== "build-csr" && msg.type !== "build-graph") return;
1084
1131
  if (!builder || builder.shuttingDown) {
1085
1132
  const error = builder?.shuttingDown ? recyclingError : bootingError;
1086
1133
  BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
1087
1134
  return;
1088
1135
  }
1089
- const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
1136
+ const handled =
1137
+ msg.type === "build-route"
1138
+ ? builder.handleBuildRoute(msg)
1139
+ : msg.type === "build-csr"
1140
+ ? builder.handleBuildCsr(msg)
1141
+ : builder.handleBuildGraph(msg);
1090
1142
  // Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
1091
1143
  void handled.catch((err: unknown) =>
1092
1144
  logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
@@ -1,5 +1,7 @@
1
- export const CompoundButton = () => <button className="btn-primary" />; // @flag
2
- export const CompoundBadge = () => <span className="badge-success" />; // @flag
1
+ export const CompoundButton = () => <button className="btn-primary" />; // @warn
2
+ export const CompoundBadge = () => <span className="badge-success" />; // @warn
3
+ export const CompoundStatValue = () => <div className="md:stat-value" />; // @warn
4
+ export const BothHalvesReportTheColourSlot = () => <div className="btn-primary bg-base-100" />; // @flag
3
5
  export const DroppedBaseSurface = () => <div className="bg-base-100" />; // @flag
4
6
  export const DroppedBaseContentWithOpacity = () => <div className="text-base-content/70" />; // @flag
5
7
  export const DroppedBaseOnABorderSide = () => <div className="border-t-base-300" />; // @flag
@@ -1,3 +1,5 @@
1
1
  export class SyncPrivate { #compute() { return 1; } } // @flag
2
2
  export class AsyncPrivate { async #load() { return 1; } } // @flag
3
+ export class StaticPrivate { static #make() { return 1; } } // @flag
3
4
  export class CallsPrivate { run() { this.#compute(); } } // @flag
5
+ export class CallsPrivateOptionally { run() { this.#compute?.(); } } // @flag
@@ -1,3 +1,4 @@
1
- export class TypescriptPrivate { private _compute() { return 1; } } // @ok
2
- export class CallsUnderscore { run() { this._compute(); } } // @ok
1
+ export class TypescriptPrivate { private compute() { return 1; } } // @ok
2
+ export class CallsTypescriptPrivate { run() { this.compute(); } } // @ok
3
3
  export class PrivateField { #cache = new Map(); } // @ok
4
+ export class CallsOnAPrivateField { #cache = new Map<string, number>(); run() { return this.#cache.get("a"); } } // @ok
@@ -9,8 +9,10 @@ language js(typescript, jsx)
9
9
  // Tailwind or app classes (`card`, `input`, `badge`, `btn`, `divider`) are deliberately not
10
10
  // flagged, and colour slots that survived into the semantic vocabulary (`primary`, `info`,
11
11
  // `success`, `warning`, `neutral`) are not either — only the slots the vocabulary dropped.
12
- // Biome keeps only the first diagnostic a single plugin registers on a node, so a string
13
- // carrying both halves reports the component one and surfaces the colour one on the next run.
12
+ // A component name is a warning: an app's own CSS may define `stat-value`, which this rule cannot read, and
13
+ // `akan verify` drops it when the app's or a lib's stylesheet does (cli verifyStyleClasses.ts).
14
+ // Biome keeps only the first diagnostic a single plugin registers on a node, so the colour slot, an error, is
15
+ // tried first: a string carrying both halves reports it, and the component warning surfaces on the next run.
14
16
  or {
15
17
  JsxString() as $s,
16
18
  JsStringLiteralExpression() as $s,
@@ -18,19 +20,19 @@ or {
18
20
  } where {
19
21
  any {
20
22
  and {
21
- $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
23
+ $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
22
24
  register_diagnostic(
23
25
  span = $s,
24
- message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
26
+ message = "daisyUI colour slot - the closed colour vocabulary has no CSS for it, so this renders unstyled. base-100/200/300 -> background/muted/border, base-content -> foreground, <colour>-content -> <colour>-foreground, error -> destructive.",
25
27
  severity = "error"
26
28
  )
27
29
  },
28
30
  and {
29
- $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
31
+ $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
30
32
  register_diagnostic(
31
33
  span = $s,
32
- message = "daisyUI colour slot - the closed colour vocabulary has no CSS for it, so this renders unstyled. base-100/200/300 -> background/muted/border, base-content -> foreground, <colour>-content -> <colour>-foreground, error -> destructive.",
33
- severity = "error"
34
+ message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled unless your own CSS defines it. Otherwise use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
35
+ severity = "warn"
34
36
  )
35
37
  }
36
38
  }
@@ -1,42 +1,23 @@
1
1
  engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
+ // No rewrite: one node's rewrite cannot rename every reference, skip a taken name, or leave a `#field` alone, so
5
+ // `akan verify` renames a class's `#method`s and their calls in one pass instead (cli verifyPrivateMethods.ts).
6
+ // A call is matched by its callee, so `this.#cache.get()` on a `#private` field is not a private method call.
4
7
  or {
5
8
  JsMethodClassMember() as $method where {
6
- $method <: contains JsPrivateClassMemberName() as $name,
7
- $method <: r"async\s+#",
8
- $method <: contains `async` as $asyncToken,
9
- $name <: r"#(.*)"($bareName),
10
- $privateName = join(list = ["_", $bareName], separator = ""),
9
+ $method <: contains JsPrivateClassMemberName(),
11
10
  register_diagnostic(
12
11
  span = $method,
13
- message = "JavaScript private class methods (#) should not be used in mixin-enabled modules. Use a TypeScript private method with an underscore prefix instead.",
12
+ message = "A `#private` method breaks once this class is mixed into another. Use a TypeScript `private` method, `private name()` called as `this.name()`; `akan verify` renames it unless the class already has that name.",
14
13
  severity = "error"
15
- ),
16
- $asyncToken => `private async`,
17
- $name => $privateName
14
+ )
18
15
  },
19
- JsMethodClassMember() as $method where {
20
- $method <: contains JsPrivateClassMemberName() as $name,
21
- not $method <: r"async\s+#",
22
- $name <: r"#(.*)"($bareName),
23
- $privateName = join(list = ["private _", $bareName], separator = ""),
24
- register_diagnostic(
25
- span = $method,
26
- message = "JavaScript private class methods (#) should not be used in mixin-enabled modules. Use a TypeScript private method with an underscore prefix instead.",
27
- severity = "error"
28
- ),
29
- $name => $privateName
30
- },
31
- JsCallExpression() as $call where {
32
- $call <: contains JsPrivateName() as $name,
33
- $name <: r"#(.*)"($bareName),
34
- $privateName = join(list = ["_", $bareName], separator = ""),
16
+ JsCallExpression(callee = JsStaticMemberExpression(member = JsPrivateName())) as $call where {
35
17
  register_diagnostic(
36
18
  span = $call,
37
- message = "Calls to JavaScript private class methods (#) should use the underscore-prefixed TypeScript private method.",
19
+ message = "A call to a `#private` method: call the TypeScript `private` method that replaces it, `this.name()`.",
38
20
  severity = "error"
39
- ),
40
- $name => $privateName
21
+ )
41
22
  }
42
23
  }
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.4",
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.4",
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.4",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import * as ts from "typescript";
4
+ import { tempDirs, writeJson, writeText } from "./testHelpers";
5
+ import { TypeChecker } from "./typeChecker";
6
+
7
+ const makeTempRoot = tempDirs("akan-devkit-type-checker-");
8
+
9
+ //? `lib: []` adds global diagnostics (TS2318), and `emitDecoratorMetadata` alone an options one (TS5052).
10
+ const project = async (declaration: boolean) => {
11
+ const root = await makeTempRoot();
12
+ const configPath = path.join(root, "tsconfig.json");
13
+ await writeJson(configPath, {
14
+ compilerOptions: {
15
+ strict: true,
16
+ lib: [],
17
+ types: [],
18
+ target: "esnext",
19
+ module: "esnext",
20
+ moduleResolution: "bundler",
21
+ noEmit: true,
22
+ incremental: true,
23
+ tsBuildInfoFile: "./.cache/tsconfig.tsbuildinfo",
24
+ declaration,
25
+ emitDecoratorMetadata: true,
26
+ },
27
+ include: ["src/**/*.ts"],
28
+ });
29
+ await writeText(path.join(root, "src/port.ts"), "export const port: number = 8282;\n");
30
+ await writeText(
31
+ path.join(root, "src/next.ts"),
32
+ 'import { port } from "./port";\nexport const next: number = port + 1;\n',
33
+ );
34
+ await writeText(path.join(root, "src/semantic.ts"), 'export const name: number = "akan";\n');
35
+ await writeText(path.join(root, "src/syntax.ts"), "export const broken = (;\n");
36
+ await writeText(path.join(root, "src/declaration.ts"), "export const make = () => class { private secret = 1; };\n");
37
+ return { root, configPath };
38
+ };
39
+
40
+ const describeAll = (root: string, diagnostics: readonly ts.Diagnostic[]) =>
41
+ diagnostics.map((diagnostic) => ({
42
+ file: diagnostic.file ? path.relative(root, diagnostic.file.fileName) : null,
43
+ start: diagnostic.start,
44
+ length: diagnostic.length,
45
+ code: diagnostic.code,
46
+ category: diagnostic.category,
47
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
48
+ }));
49
+
50
+ const preEmitDiagnostics = (root: string, configPath: string) => {
51
+ const { fileNames, options } = TypeChecker.parseConfig(configPath);
52
+ return describeAll(root, ts.getPreEmitDiagnostics(ts.createProgram({ rootNames: fileNames, options })));
53
+ };
54
+
55
+ const nextFileToCheck = (configPath: string) => {
56
+ const { fileNames, options } = TypeChecker.parseConfig(configPath);
57
+ const next = ts
58
+ .createIncrementalProgram({ rootNames: fileNames, options })
59
+ .getSemanticDiagnosticsOfNextAffectedFile();
60
+ return next && ("fileName" in next.affected ? next.affected.fileName : "the whole program");
61
+ };
62
+
63
+ describe("TypeChecker.checkProject", () => {
64
+ test.each([true, false])(
65
+ "reports what getPreEmitDiagnostics does, cold and warm (declaration: %p)",
66
+ async (declaration) => {
67
+ const { root, configPath } = await project(declaration);
68
+ const expected = preEmitDiagnostics(root, configPath);
69
+ expect(new Set(expected.map((diagnostic) => diagnostic.code))).toEqual(
70
+ new Set([5052, 1109, 2318, 2322, ...(declaration ? [4094] : [])]),
71
+ );
72
+
73
+ expect(describeAll(root, TypeChecker.checkProject(configPath).diagnostics)).toEqual(expected);
74
+ expect(describeAll(root, TypeChecker.checkProject(configPath).diagnostics)).toEqual(expected);
75
+ },
76
+ );
77
+
78
+ test("leaves a cache from which a resumed build has no file left to check", async () => {
79
+ const { configPath } = await project(true);
80
+ TypeChecker.checkProject(configPath);
81
+ expect(nextFileToCheck(configPath)).toBeUndefined();
82
+ TypeChecker.checkProject(configPath);
83
+ expect(nextFileToCheck(configPath)).toBeUndefined();
84
+ });
85
+
86
+ test("reports an edit's new errors, and its importer's, and drops a fixed one on a warm run", async () => {
87
+ const { root, configPath } = await project(true);
88
+ TypeChecker.checkProject(configPath);
89
+ await writeText(path.join(root, "src/port.ts"), "export const port: string = 8282;\n");
90
+ await writeText(path.join(root, "src/semantic.ts"), 'export const name: string = "akan";\n');
91
+
92
+ const warm = describeAll(root, TypeChecker.checkProject(configPath).diagnostics);
93
+ expect(warm).toEqual(preEmitDiagnostics(root, configPath));
94
+ expect(warm.filter((diagnostic) => diagnostic.code === 2322).map((diagnostic) => diagnostic.file)).toEqual([
95
+ path.join("src", "next.ts"),
96
+ path.join("src", "port.ts"),
97
+ ]);
98
+ expect(nextFileToCheck(configPath)).toBeUndefined();
99
+ });
100
+ });
package/typeChecker.ts CHANGED
@@ -177,8 +177,7 @@ export class TypeChecker {
177
177
  configFileParsingDiagnostics: parsedConfig.errors,
178
178
  host,
179
179
  });
180
- const program = builderProgram.getProgram();
181
- const diagnostics = [...ts.getPreEmitDiagnostics(program), ...builderProgram.emit().diagnostics];
180
+ const diagnostics = [...TypeChecker.#preEmitDiagnostics(builderProgram), ...builderProgram.emit().diagnostics];
182
181
  const errors = diagnostics.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
183
182
  const warnings = diagnostics.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Warning);
184
183
  return {
@@ -190,6 +189,19 @@ export class TypeChecker {
190
189
  };
191
190
  }
192
191
 
192
+ //* getPreEmitDiagnostics on the Program re-checks every file; the builder answers unchanged ones from .tsbuildinfo.
193
+ static #preEmitDiagnostics(builderProgram: ts.BuilderProgram) {
194
+ const { declaration, composite } = builderProgram.getCompilerOptions();
195
+ return ts.sortAndDeduplicateDiagnostics([
196
+ ...builderProgram.getConfigFileParsingDiagnostics(),
197
+ ...builderProgram.getOptionsDiagnostics(),
198
+ ...builderProgram.getSyntacticDiagnostics(),
199
+ ...builderProgram.getGlobalDiagnostics(),
200
+ ...builderProgram.getSemanticDiagnostics(),
201
+ ...(declaration || composite ? builderProgram.getDeclarationDiagnostics() : []),
202
+ ]);
203
+ }
204
+
193
205
  static parseConfig(configPath: string): ts.ParsedCommandLine {
194
206
  const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName));
195
207
  const configDiagnostics = configFile.error ? [configFile.error] : [];
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { AkanChangeMap, AkanGraphEdge, AkanGraphNode } from "@akanjs/language";
3
+ import { WorkspaceGraph } from "./workspaceGraph";
4
+
5
+ const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]): AkanGraphNode => ({
6
+ id,
7
+ kind: id.split(":")[0] as AkanGraphNode["kind"],
8
+ name: id,
9
+ label: id,
10
+ owner,
11
+ module: null,
12
+ detail: [],
13
+ at: null,
14
+ ...(change ? { change } : {}),
15
+ });
16
+ const edge = (from: string, to: string, change?: AkanGraphEdge["change"]): AkanGraphEdge => ({
17
+ from,
18
+ to,
19
+ kind: "calls",
20
+ label: null,
21
+ count: 1,
22
+ at: null,
23
+ ...(change ? { change } : {}),
24
+ });
25
+
26
+ describe("WorkspaceGraph.changesVisibleTo", () => {
27
+ it("keeps the app's and the libs' changes, and an unchanged neighbour only while an edge still reaches it", () => {
28
+ const changes: AkanChangeMap = {
29
+ version: 1,
30
+ nodes: [
31
+ node("endpoint:apps/demo:memo.pin", "apps/demo", "added"),
32
+ node("method:libs/shared:file.touch", "libs/shared"),
33
+ node("method:libs/shared:file.open", "libs/shared"),
34
+ node("component:apps/admin/page/a.tsx#default", "apps/admin", "changed"),
35
+ node("guard:Owner", null, "added"),
36
+ ],
37
+ edges: [
38
+ edge("endpoint:apps/demo:memo.pin", "method:libs/shared:file.touch", "added"),
39
+ edge("component:apps/admin/page/a.tsx#default", "method:libs/shared:file.open"),
40
+ ],
41
+ since: "0123456789ab",
42
+ summary: { added: 2, removed: 0, changed: 1 },
43
+ unmapped: [
44
+ { file: "apps/admin/page/b.tsx", change: "added" },
45
+ { file: "apps/demo/lib/memo/memo.dictionary.ts", change: "changed" },
46
+ { file: "libs/shared/lib/file/file.md", change: "removed" },
47
+ ],
48
+ };
49
+ const visible = WorkspaceGraph.changesVisibleTo(changes, "demo");
50
+ expect(visible.nodes.map(({ id }) => id)).toEqual([
51
+ "endpoint:apps/demo:memo.pin",
52
+ "method:libs/shared:file.touch",
53
+ "guard:Owner",
54
+ ]);
55
+ expect(visible.edges).toHaveLength(1);
56
+ expect(visible.summary).toEqual({ added: 2, removed: 0, changed: 0 });
57
+ expect(visible.unmapped.map(({ file }) => file)).toEqual([
58
+ "apps/demo/lib/memo/memo.dictionary.ts",
59
+ "libs/shared/lib/file/file.md",
60
+ ]);
61
+ expect(visible.since).toBe("0123456789ab");
62
+ });
63
+ });
@@ -0,0 +1,177 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { type AkanChangeMap, type AkanGraph, AkanGraphDiff, type AkanGraphNode, AkanIndex } from "@akanjs/language";
4
+ import ts from "typescript";
5
+ import type { Workspace } from "./commandDecorators";
6
+ import { GitRevision } from "./gitRevision";
7
+
8
+ export interface GraphScope {
9
+ app?: string | null;
10
+ module?: string | null;
11
+ }
12
+
13
+ //? `env/` holds each environment's secrets in plain TypeScript; the graph never needs to read it.
14
+ const indexedPath = /^(apps|libs)\/(?!(?:.*\/)?(?:node_modules|dist|\.akan|env)\/).*\.tsx?$/;
15
+ const hiddenPath = /(?:^|\/)(?:node_modules|dist|\.akan|env)\//;
16
+ const unscopedNodeLimit = 600;
17
+ const cachedBaseLimit = 4;
18
+ const slashed = (fileName: string) => fileName.replace(/\\/g, "/");
19
+
20
+ export class WorkspaceGraph {
21
+ static readonly #bases = new Map<string, Promise<AkanGraph>>();
22
+
23
+ static async build(workspace: Workspace, { digests = false } = {}): Promise<AkanGraph> {
24
+ const root = workspace.workspaceRoot;
25
+ const files = (await workspace.getAllFiles("{apps,libs}/**/*.{ts,tsx}"))
26
+ .map(slashed)
27
+ .filter((file) => indexedPath.test(file));
28
+ return WorkspaceGraph.#graphOf(root, await WorkspaceGraph.#textsOf(root, files), digests);
29
+ }
30
+
31
+ static async #textsOf(root: string, files: string[]) {
32
+ const read = await Promise.all(
33
+ files.map(async (file) => [file, await readFile(path.join(root, file), "utf8").catch(() => null)] as const),
34
+ );
35
+ return new Map(read.filter((entry): entry is readonly [string, string] => entry[1] !== null));
36
+ }
37
+
38
+ static async changes(workspace: Workspace, since: string | null): Promise<AkanChangeMap | { error: string }> {
39
+ const forkPoint = await new GitRevision(workspace.workspaceRoot).forkPointOf(since);
40
+ if (forkPoint.error !== undefined) return { error: forkPoint.error };
41
+ return await WorkspaceGraph.changesAt(workspace, forkPoint.sha);
42
+ }
43
+
44
+ static async changesAt(workspace: Workspace, sha: string): Promise<AkanChangeMap> {
45
+ const root = workspace.workspaceRoot;
46
+ const git = new GitRevision(root);
47
+ const [before, after, files] = await Promise.all([
48
+ WorkspaceGraph.#baseAt(root, sha),
49
+ git
50
+ .workingFiles()
51
+ .then((working) =>
52
+ WorkspaceGraph.#textsOf(
53
+ root,
54
+ working.filter((file) => indexedPath.test(file)),
55
+ ),
56
+ )
57
+ .then((texts) => WorkspaceGraph.#graphOf(root, texts, true)),
58
+ git.changedFilesSince(sha),
59
+ ]);
60
+ const changes = AkanGraphDiff.between(before, after);
61
+ const mapped = new Set([
62
+ ...changes.nodes
63
+ .filter((node) => node.change)
64
+ .flatMap((node) => [
65
+ node.at?.file,
66
+ ...(node.fields ?? []).filter((field) => field.change).map(({ at }) => at.file),
67
+ ]),
68
+ ...changes.edges.filter((edge) => edge.change).map((edge) => edge.at?.file),
69
+ ]);
70
+ const unmapped = files.filter(({ file }) => !mapped.has(file) && !hiddenPath.test(file));
71
+ return { ...changes, since: sha.slice(0, 12), unmapped };
72
+ }
73
+
74
+ static #baseAt(root: string, sha: string) {
75
+ const key = `${root}\n${sha}`;
76
+ const cached = WorkspaceGraph.#bases.get(key);
77
+ if (cached) return cached;
78
+ const graph = new GitRevision(root)
79
+ .filesAt(sha, (file) => indexedPath.test(file))
80
+ .then((texts) => WorkspaceGraph.#graphOf(root, texts, true));
81
+ graph.catch(() => WorkspaceGraph.#bases.delete(key));
82
+ WorkspaceGraph.#bases.set(key, graph);
83
+ for (const old of [...WorkspaceGraph.#bases.keys()].slice(0, -cachedBaseLimit)) WorkspaceGraph.#bases.delete(old);
84
+ return graph;
85
+ }
86
+
87
+ static #graphOf(root: string, texts: Map<string, string>, digests: boolean) {
88
+ const index = new AkanIndex({ ts, root });
89
+ const byFileName = new Map<string, string>();
90
+ for (const [file, text] of texts) {
91
+ const fileName = slashed(path.join(root, file));
92
+ byFileName.set(fileName, text);
93
+ index.setFile(fileName, text);
94
+ }
95
+ return index.graph((fileName) => byFileName.get(fileName), { digests });
96
+ }
97
+
98
+ static scope(graph: AkanGraph, { app, module }: GraphScope): AkanGraph {
99
+ if (!app && !module) return graph;
100
+ const owners = app ? new Set([`apps/${app}`, `libs/${app}`]) : null;
101
+ const seeds = new Set(
102
+ graph.nodes
103
+ .filter((node) => (!owners || owners.has(node.owner ?? "")) && (!module || node.module === module))
104
+ .map((node) => node.id),
105
+ );
106
+ const edges = graph.edges.filter((edge) => seeds.has(edge.from) || seeds.has(edge.to));
107
+ const ids = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
108
+ return { version: 1, nodes: graph.nodes.filter((node) => ids.has(node.id)), edges };
109
+ }
110
+
111
+ static visibleTo(graph: AkanGraph, app: string): AkanGraph {
112
+ const appOwner = `apps/${app}`;
113
+ const isVisible = ({ owner }: AkanGraphNode) => !owner || owner === appOwner || owner.startsWith("libs/");
114
+ const nodes = graph.nodes.filter(isVisible);
115
+ const ids = new Set(nodes.map((node) => node.id));
116
+ return { version: 1, nodes, edges: graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)) };
117
+ }
118
+
119
+ static changesVisibleTo(changes: AkanChangeMap, app: string): AkanChangeMap {
120
+ const visible = WorkspaceGraph.visibleTo(changes, app);
121
+ const linked = new Set(visible.edges.flatMap((edge) => [edge.from, edge.to]));
122
+ const nodes = visible.nodes.filter((node) => node.change || linked.has(node.id));
123
+ const appPath = `apps/${app}/`;
124
+ return {
125
+ ...changes,
126
+ nodes,
127
+ edges: visible.edges,
128
+ summary: WorkspaceGraph.#summaryOf(nodes),
129
+ unmapped: changes.unmapped.filter(({ file }) => file.startsWith(appPath) || file.startsWith("libs/")),
130
+ };
131
+ }
132
+
133
+ static #summaryOf(nodes: AkanGraphNode[]) {
134
+ const summary = { added: 0, removed: 0, changed: 0 };
135
+ for (const { change } of nodes) if (change) summary[change] += 1;
136
+ return summary;
137
+ }
138
+
139
+ static answerChanges(changes: AkanChangeMap | { error: string }, scope: GraphScope) {
140
+ if ("error" in changes)
141
+ return {
142
+ diagnostics: [{ severity: "error", code: "graph-since-unknown", message: changes.error, input: "since" }],
143
+ };
144
+ const answer = WorkspaceGraph.answer(
145
+ changes,
146
+ scope,
147
+ changes.nodes.filter((node) => node.change),
148
+ );
149
+ if ("diagnostics" in answer) return { since: changes.since, summary: changes.summary, ...answer };
150
+ const summary = WorkspaceGraph.#summaryOf(answer.nodes);
151
+ const owners = scope.app ? [`apps/${scope.app}/`, `libs/${scope.app}/`] : null;
152
+ const modules = scope.module ? [`/lib/${scope.module}/`, `/lib/_${scope.module}/`] : null;
153
+ const unmapped = changes.unmapped.filter(
154
+ ({ file }) =>
155
+ (!owners || owners.some((owner) => file.startsWith(owner))) &&
156
+ (!modules || modules.some((segment) => file.includes(segment))),
157
+ );
158
+ return { since: changes.since, summary, ...answer, unmapped };
159
+ }
160
+
161
+ static answer(graph: AkanGraph, scope: GraphScope, counted = graph.nodes) {
162
+ if (scope.app || scope.module || graph.nodes.length <= unscopedNodeLimit) return WorkspaceGraph.scope(graph, scope);
163
+ const owners = new Map<string, number>();
164
+ for (const node of counted) if (node.owner) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
165
+ return {
166
+ diagnostics: [
167
+ {
168
+ severity: "error",
169
+ code: "graph-scope-required",
170
+ message: `The graph has ${graph.nodes.length} nodes. Pass app, module or both to scope it.`,
171
+ input: "app",
172
+ },
173
+ ],
174
+ owners: Object.fromEntries([...owners].sort(([a], [b]) => (a < b ? -1 : 1))),
175
+ };
176
+ }
177
+ }