@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.
- package/akanApp/akanApp.host.ts +5 -3
- package/akanMcpContract.ts +6 -0
- package/applicationBuildReporter.test.ts +32 -0
- package/applicationBuildReporter.ts +5 -0
- package/applicationBuildRunner.ts +1 -0
- package/codeAgent/skills/akan-page/SKILL.md +19 -0
- package/codeAgent/skills/akan-service/SKILL.md +47 -0
- package/gitRevision.test.ts +82 -0
- package/gitRevision.ts +124 -0
- package/incrementalBuilder/builderRequestRouter.ts +1 -4
- package/incrementalBuilder/incrementalBuilder.host.test.ts +7 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +7 -4
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +98 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +54 -2
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
- package/lint/no-daisyui-legacy-class.grit +9 -7
- package/lint/no-js-private-class-method.grit +9 -28
- package/package.json +3 -3
- package/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workspaceGraph.test.ts +63 -0
- package/workspaceGraph.ts +177 -0
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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);
|
package/akanMcpContract.ts
CHANGED
|
@@ -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
|
|
@@ -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 {
|
|
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, "
|
|
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"
|
|
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"
|
|
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 =
|
|
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" />; // @
|
|
2
|
-
export const CompoundBadge = () => <span className="badge-success" />; // @
|
|
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
|
|
2
|
-
export class
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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\"'`{(\[:!])(?:
|
|
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
|
|
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\"'`{(\[:!])(?:
|
|
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
|
|
33
|
-
severity = "
|
|
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()
|
|
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 = "
|
|
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
|
-
|
|
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 = "
|
|
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
|
+
"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.
|
|
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.
|
|
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
|
|
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
|
+
}
|