@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.
- package/akanMcpContract.ts +2 -2
- 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/incrementalBuilder.proc.test.ts +44 -9
- package/incrementalBuilder/incrementalBuilder.proc.ts +34 -7
- 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 +118 -14
package/akanMcpContract.ts
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
884
|
-
|
|
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" />; // @
|
|
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
|
+
});
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
{
|