@akanjs/devkit 3.1.2-rc.3 → 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.
@@ -324,8 +324,8 @@ export const readonlyMcpTools: McpToolDefinition[] = [
324
324
  {
325
325
  name: "get_graph",
326
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 } },
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
329
  },
330
330
  {
331
331
  name: "get_guideline",
@@ -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
+ }
@@ -129,7 +129,7 @@ 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 () => {
132
+ test("draws the app's graph and Change Map on request, with the libs it reaches and no other app", async () => {
133
133
  const { root } = track(await createTempApp("demo"));
134
134
  const appDir = path.join(root, "apps/demo");
135
135
  await writeText(
@@ -157,6 +157,27 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
157
157
  ...moduleFiles("apps/other", "note"),
158
158
  ...moduleFiles("libs/shared", "file"),
159
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
+ );
160
181
  const messages: BuilderMessage[] = [];
161
182
  const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
162
183
  cwd: appDir,
@@ -178,18 +199,32 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
178
199
  try {
179
200
  await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
180
201
  proc.send({ type: "build-graph", id: 5 } satisfies BuilderMessage);
181
- await until(proc, "build-graph-res", () => messages.some((message) => message.type === "build-graph-res"));
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
+ );
182
209
  } finally {
183
210
  proc.kill();
184
211
  await proc.exited;
185
212
  }
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
- ]);
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);
193
228
  }, 30_000);
194
229
 
195
230
  test("answers a second shell request in the same generation without building again", async () => {
@@ -18,10 +18,12 @@ 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";
21
22
  import { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
22
23
  import { Logger } from "akanjs/common";
23
24
  import type {
24
25
  BaseBuildArtifact,
26
+ BuildChangesPayload,
25
27
  BuilderCsrReq,
26
28
  BuilderGraphReq,
27
29
  BuilderMessage,
@@ -68,6 +70,7 @@ class IncrementalBuilder {
68
70
  #generation = 0;
69
71
  #shellKey: string | null = null;
70
72
  #graph: { generation: number; payload: BuildGraphPayload } | null = null;
73
+ #changes: { key: string; payload: Promise<BuildChangesPayload> } | null = null;
71
74
  #discoveredThrough = 0;
72
75
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
73
76
  #csrBundler: CsrDevBundler;
@@ -878,14 +881,9 @@ class IncrementalBuilder {
878
881
  }
879
882
 
880
883
  async handleBuildGraph(msg: BuilderGraphReq): Promise<void> {
881
- const generation = this.#generation;
882
884
  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 });
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 });
889
887
  } catch (err) {
890
888
  const error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
891
889
  this.#logger.error(`build-graph failed: ${error}`);
@@ -893,6 +891,35 @@ class IncrementalBuilder {
893
891
  }
894
892
  }
895
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
+
896
923
  // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
897
924
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
898
925
  if (msg.shell) {
@@ -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.3",
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.3",
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.3",
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
+ });
package/workspaceGraph.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { type AkanGraph, type AkanGraphNode, AkanIndex } from "@akanjs/language";
3
+ import { type AkanChangeMap, type AkanGraph, AkanGraphDiff, type AkanGraphNode, AkanIndex } from "@akanjs/language";
4
4
  import ts from "typescript";
5
5
  import type { Workspace } from "./commandDecorators";
6
+ import { GitRevision } from "./gitRevision";
6
7
 
7
8
  export interface GraphScope {
8
9
  app?: string | null;
@@ -11,26 +12,87 @@ export interface GraphScope {
11
12
 
12
13
  //? `env/` holds each environment's secrets in plain TypeScript; the graph never needs to read it.
13
14
  const indexedPath = /^(apps|libs)\/(?!(?:.*\/)?(?:node_modules|dist|\.akan|env)\/).*\.tsx?$/;
15
+ const hiddenPath = /(?:^|\/)(?:node_modules|dist|\.akan|env)\//;
14
16
  const unscopedNodeLimit = 600;
17
+ const cachedBaseLimit = 4;
15
18
  const slashed = (fileName: string) => fileName.replace(/\\/g, "/");
16
19
 
17
20
  export class WorkspaceGraph {
18
- static async build(workspace: Workspace): Promise<AkanGraph> {
21
+ static readonly #bases = new Map<string, Promise<AkanGraph>>();
22
+
23
+ static async build(workspace: Workspace, { digests = false } = {}): Promise<AkanGraph> {
19
24
  const root = workspace.workspaceRoot;
20
- const index = new AkanIndex({ ts, root });
21
- const texts = new Map<string, string>();
22
25
  const files = (await workspace.getAllFiles("{apps,libs}/**/*.{ts,tsx}"))
23
26
  .map(slashed)
24
27
  .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
- }),
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),
32
34
  );
33
- return index.graph((fileName) => texts.get(fileName));
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 });
34
96
  }
35
97
 
36
98
  static scope(graph: AkanGraph, { app, module }: GraphScope): AkanGraph {
@@ -54,10 +116,52 @@ export class WorkspaceGraph {
54
116
  return { version: 1, nodes, edges: graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)) };
55
117
  }
56
118
 
57
- static answer(graph: AkanGraph, scope: GraphScope) {
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) {
58
162
  if (scope.app || scope.module || graph.nodes.length <= unscopedNodeLimit) return WorkspaceGraph.scope(graph, scope);
59
163
  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);
164
+ for (const node of counted) if (node.owner) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
61
165
  return {
62
166
  diagnostics: [
63
167
  {