@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1
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/CHANGELOG.md +353 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +1 -1
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +25 -3
- package/applicationBuildReporter.ts +8 -1
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +76 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +16 -7
- package/codeAgent/skills/akan-service/SKILL.md +48 -19
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/createTunnel.test.ts +12 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.ts +6 -2
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/no-deep-internal-import.grit +1 -1
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/preview/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +26 -8
- package/preview/previewView.test.ts +87 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +58 -0
- package/workspaceGraph.ts +32 -3
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
package/gitRevision.ts
CHANGED
|
@@ -43,8 +43,12 @@ export class GitRevision {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async filesAt(
|
|
47
|
-
|
|
46
|
+
async filesAt(
|
|
47
|
+
sha: string,
|
|
48
|
+
isWanted: (file: string) => boolean,
|
|
49
|
+
paths: string[] = systemPaths,
|
|
50
|
+
): Promise<Map<string, string>> {
|
|
51
|
+
const listed = await this.#git(["ls-tree", "-r", "-z", "--name-only", sha, "--", ...paths]);
|
|
48
52
|
if (listed === null) throw new Error(`git ls-tree failed for ${sha}`);
|
|
49
53
|
const files = listed.split("\0").filter((file) => file && isWanted(file));
|
|
50
54
|
const texts = new Map<string, string>();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
2
|
+
import { FrameworkGraph } from "@akanjs/devkit/frameworkGraph";
|
|
3
|
+
import { GitRevision } from "@akanjs/devkit/gitRevision";
|
|
4
|
+
import { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
|
|
5
|
+
import type { BuildChangesPayload, BuilderGraphReq, BuildFrameworkPayload, BuildGraphPayload } from "akanjs/server";
|
|
6
|
+
|
|
7
|
+
export class BuilderGraphPayloads {
|
|
8
|
+
readonly #app: App;
|
|
9
|
+
readonly #generation: () => number;
|
|
10
|
+
#graph: { generation: number; payload: BuildGraphPayload } | null = null;
|
|
11
|
+
#changes: { key: string; payload: Promise<BuildChangesPayload> } | null = null;
|
|
12
|
+
|
|
13
|
+
constructor(app: App, generation: () => number) {
|
|
14
|
+
this.#app = app;
|
|
15
|
+
this.#generation = generation;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
touched(files: string[]) {
|
|
19
|
+
FrameworkGraph.touched(this.#app.workspace.workspaceRoot, files);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async of(msg: BuilderGraphReq): Promise<BuildGraphPayload | BuildChangesPayload | BuildFrameworkPayload> {
|
|
23
|
+
if (msg.framework) return await this.#framework();
|
|
24
|
+
return msg.changes ? await this.#changesSince(msg.changes.since) : await this.#workspaceGraph();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async #workspaceGraph(): Promise<BuildGraphPayload> {
|
|
28
|
+
const generation = this.#generation();
|
|
29
|
+
if (this.#graph?.generation !== generation) {
|
|
30
|
+
const { workspace } = this.#app;
|
|
31
|
+
const graph = WorkspaceGraph.visibleTo(await WorkspaceGraph.build(workspace), this.#app.name);
|
|
32
|
+
this.#graph = { generation, payload: { workspaceRoot: workspace.workspaceRoot, graph } };
|
|
33
|
+
}
|
|
34
|
+
return this.#graph.payload;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async #framework(): Promise<BuildFrameworkPayload> {
|
|
38
|
+
const { workspaceRoot } = this.#app.workspace;
|
|
39
|
+
return { workspaceRoot, framework: await FrameworkGraph.cached(workspaceRoot) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async #changesSince(since: string | null): Promise<BuildChangesPayload> {
|
|
43
|
+
const { workspace, name } = this.#app;
|
|
44
|
+
const { workspaceRoot } = workspace;
|
|
45
|
+
const forkPoint = await new GitRevision(workspaceRoot).forkPointOf(since);
|
|
46
|
+
if (forkPoint.error !== undefined) return { workspaceRoot, changes: { error: forkPoint.error } };
|
|
47
|
+
const key = `${this.#generation()}:${forkPoint.sha}`;
|
|
48
|
+
if (this.#changes?.key !== key) {
|
|
49
|
+
const payload = WorkspaceGraph.changesAt(workspace, forkPoint.sha).then((changes) => ({
|
|
50
|
+
workspaceRoot,
|
|
51
|
+
changes: WorkspaceGraph.changesVisibleTo(changes, name),
|
|
52
|
+
}));
|
|
53
|
+
payload.catch(() => {
|
|
54
|
+
if (this.#changes?.payload === payload) this.#changes = null;
|
|
55
|
+
});
|
|
56
|
+
this.#changes = { key, payload };
|
|
57
|
+
}
|
|
58
|
+
return await this.#changes.payload;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -218,7 +218,11 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
|
|
|
218
218
|
const graph = answerTo(5);
|
|
219
219
|
if (!("graph" in graph)) throw new Error("build-graph answered with changes");
|
|
220
220
|
expect(graph.workspaceRoot).toBe(root);
|
|
221
|
-
expect(graph.graph.nodes.map((node) => node.id).sort()).toEqual([
|
|
221
|
+
expect(graph.graph.nodes.map((node) => node.id).sort()).toEqual([
|
|
222
|
+
"model:apps/demo:memo",
|
|
223
|
+
"model:libs/shared:file",
|
|
224
|
+
"route:apps/demo:/",
|
|
225
|
+
]);
|
|
222
226
|
const changes = answerTo(6);
|
|
223
227
|
if (!("changes" in changes) || "error" in changes.changes)
|
|
224
228
|
throw new Error(`no changes: ${JSON.stringify(changes)}`);
|
|
@@ -227,6 +231,66 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
|
|
|
227
231
|
expect(answerTo(7)).toEqual(changes);
|
|
228
232
|
}, 30_000);
|
|
229
233
|
|
|
234
|
+
test("maps the framework on request, and maps it again after a save under pkgs/akanjs", async () => {
|
|
235
|
+
const { root } = track(await createTempApp("demo"));
|
|
236
|
+
const appDir = path.join(root, "apps/demo");
|
|
237
|
+
await writeText(
|
|
238
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
239
|
+
`const { generation } = JSON.parse(process.argv[2]);
|
|
240
|
+
process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
|
|
241
|
+
`,
|
|
242
|
+
);
|
|
243
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
244
|
+
await writeText(
|
|
245
|
+
path.join(appDir, "tsconfig.json"),
|
|
246
|
+
JSON.stringify({ compilerOptions: { target: "ESNext", paths: { "akanjs/*": ["./pkgs/akanjs/*"] } } }),
|
|
247
|
+
);
|
|
248
|
+
await writeText(path.join(root, "pkgs/akanjs/base/scalar.ts"), "export class Scalar {}\n");
|
|
249
|
+
const messages: BuilderMessage[] = [];
|
|
250
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
251
|
+
cwd: appDir,
|
|
252
|
+
env: {
|
|
253
|
+
...process.env,
|
|
254
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
255
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
256
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
257
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
258
|
+
AKAN_PUBLIC_ENV: "local",
|
|
259
|
+
},
|
|
260
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
261
|
+
serialization: "advanced",
|
|
262
|
+
ipc: (message: BuilderMessage) => {
|
|
263
|
+
messages.push(message);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const classesAnsweredTo = async (id: number) => {
|
|
267
|
+
proc.send({ type: "build-graph", id, framework: true } satisfies BuilderMessage);
|
|
268
|
+
await until(proc, `build-graph-res ${id}`, () =>
|
|
269
|
+
messages.some((message) => message.type === "build-graph-res" && message.id === id),
|
|
270
|
+
);
|
|
271
|
+
const answer = messages.find((message) => message.type === "build-graph-res" && message.id === id);
|
|
272
|
+
if (answer?.type !== "build-graph-res" || !answer.ok || !("framework" in answer.data))
|
|
273
|
+
throw new Error(`no framework map: ${JSON.stringify(answer)}`);
|
|
274
|
+
expect(answer.data.workspaceRoot).toBe(root);
|
|
275
|
+
return answer.data.framework?.facets.flatMap(({ classes }) => classes.map(({ name }) => name));
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
279
|
+
expect(await classesAnsweredTo(1)).toEqual(["Scalar"]);
|
|
280
|
+
await writeText(path.join(root, "pkgs/akanjs/base/scalar.ts"), "export class Scalar {}\nexport class Unit {}\n");
|
|
281
|
+
const deadline = Date.now() + 15_000;
|
|
282
|
+
let id = 2;
|
|
283
|
+
while (Date.now() < deadline) {
|
|
284
|
+
if ((await classesAnsweredTo(id++))?.includes("Unit")) break;
|
|
285
|
+
await wait(200);
|
|
286
|
+
}
|
|
287
|
+
expect(await classesAnsweredTo(id)).toEqual(["Scalar", "Unit"]);
|
|
288
|
+
} finally {
|
|
289
|
+
proc.kill();
|
|
290
|
+
await proc.exited;
|
|
291
|
+
}
|
|
292
|
+
}, 40_000);
|
|
293
|
+
|
|
230
294
|
test("answers a second shell request in the same generation without building again", async () => {
|
|
231
295
|
const { root } = track(await createTempApp("demo"));
|
|
232
296
|
const appDir = path.join(root, "apps/demo");
|
|
@@ -18,18 +18,14 @@ 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";
|
|
23
21
|
import { Logger } from "akanjs/common";
|
|
24
22
|
import type {
|
|
25
23
|
BaseBuildArtifact,
|
|
26
|
-
BuildChangesPayload,
|
|
27
24
|
BuilderCsrReq,
|
|
28
25
|
BuilderGraphReq,
|
|
29
26
|
BuilderMessage,
|
|
30
27
|
BuilderReq,
|
|
31
28
|
BuilderRes,
|
|
32
|
-
BuildGraphPayload,
|
|
33
29
|
BuildPhase,
|
|
34
30
|
BuildRouteResultPayload,
|
|
35
31
|
ChangeBatch,
|
|
@@ -39,6 +35,7 @@ import { CSR_DEV_PATCHING_MARKER } from "akanjs/server/hmr/csrDevManifest";
|
|
|
39
35
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
40
36
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
41
37
|
import { BuilderChannel } from "./builderChannel";
|
|
38
|
+
import { BuilderGraphPayloads } from "./builderGraphPayloads";
|
|
42
39
|
import { type BatchJob, BuilderWorkQueue, type DiscoveryJob } from "./builderWorkQueue";
|
|
43
40
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
44
41
|
|
|
@@ -69,8 +66,7 @@ class IncrementalBuilder {
|
|
|
69
66
|
#watcher: HmrWatcher | null = null;
|
|
70
67
|
#generation = 0;
|
|
71
68
|
#shellKey: string | null = null;
|
|
72
|
-
#
|
|
73
|
-
#changes: { key: string; payload: Promise<BuildChangesPayload> } | null = null;
|
|
69
|
+
readonly #graphPayloads: BuilderGraphPayloads;
|
|
74
70
|
#discoveredThrough = 0;
|
|
75
71
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
76
72
|
#csrBundler: CsrDevBundler;
|
|
@@ -122,6 +118,7 @@ class IncrementalBuilder {
|
|
|
122
118
|
this.#discovery = options.discovery;
|
|
123
119
|
this.#generation = options.initialGeneration ?? 0;
|
|
124
120
|
this.#discoveredThrough = this.#generation;
|
|
121
|
+
this.#graphPayloads = new BuilderGraphPayloads(options.app, () => this.#generation);
|
|
125
122
|
this.#batchRunner = new BuildBatchRunner({
|
|
126
123
|
workspaceRoot: options.app.workspace.workspaceRoot,
|
|
127
124
|
cwd: options.app.cwdPath,
|
|
@@ -370,6 +367,7 @@ class IncrementalBuilder {
|
|
|
370
367
|
}
|
|
371
368
|
|
|
372
369
|
async #handleWatchBatch(batch: ChangeBatch) {
|
|
370
|
+
this.#graphPayloads.touched(batch.files);
|
|
373
371
|
const rawKinds = new Set(batch.kinds);
|
|
374
372
|
if (rawKinds.size === 0) return;
|
|
375
373
|
const generation = ++this.#generation;
|
|
@@ -882,7 +880,7 @@ class IncrementalBuilder {
|
|
|
882
880
|
|
|
883
881
|
async handleBuildGraph(msg: BuilderGraphReq): Promise<void> {
|
|
884
882
|
try {
|
|
885
|
-
const data =
|
|
883
|
+
const data = await this.#graphPayloads.of(msg);
|
|
886
884
|
await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: true, data });
|
|
887
885
|
} catch (err) {
|
|
888
886
|
const error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
@@ -891,35 +889,6 @@ class IncrementalBuilder {
|
|
|
891
889
|
}
|
|
892
890
|
}
|
|
893
891
|
|
|
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
|
-
|
|
923
892
|
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
924
893
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
925
894
|
if (msg.shell) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { AkanAppHost } from "../akanApp";
|
|
3
3
|
import { DevGeneratedIndexSync } from "../frontendBuild";
|
|
4
|
-
import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
|
|
4
|
+
import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness.fixture";
|
|
5
5
|
|
|
6
6
|
const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
|
|
7
7
|
// Contention pushes a cold dev-server boot from ~3s to nearly a minute; 120s ran out mid-restart.
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tempDirs } from "../testHelpers";
|
|
5
|
-
import { DevStabilityHarness } from "./devStabilityHarness";
|
|
5
|
+
import { DevStabilityHarness } from "./devStabilityHarness.fixture";
|
|
6
6
|
|
|
7
7
|
const servers: Bun.Server<undefined>[] = [];
|
|
8
8
|
const makeRoot = tempDirs("akan-harness-port-");
|
|
@@ -19,7 +19,7 @@ or {
|
|
|
19
19
|
$source <: r"\"\.\./\.\./.*\"",
|
|
20
20
|
register_diagnostic(
|
|
21
21
|
span = $source,
|
|
22
|
-
message = "Module files should not import from two or more parent directories."
|
|
22
|
+
message = "Module files should not import from two or more parent directories. Import from the layer barrel instead: @apps/<app>/<layer> or @libs/<lib>/<layer>."
|
|
23
23
|
)
|
|
24
24
|
},
|
|
25
25
|
JsModuleSource() as $source where {
|
|
@@ -16,14 +16,16 @@ export interface BaselineEntry {
|
|
|
16
16
|
|
|
17
17
|
type Counts = ReadonlyMap<string, number>;
|
|
18
18
|
|
|
19
|
-
//* One section per rule, each key holding what the gate measured: lines, store accesses, writes or calls.
|
|
20
|
-
//* only falls, so a recorded entry cannot take on more of the debt it records.
|
|
19
|
+
//* One section per rule, each key holding what the gate measured: lines, members, store accesses, writes or calls.
|
|
20
|
+
//* A count only falls, so a recorded entry cannot take on more of the debt it records.
|
|
21
21
|
export class MaintainabilityBaseline {
|
|
22
22
|
static readonly fileName = "akan.baseline.json";
|
|
23
23
|
static readonly reasonMinChars = 10;
|
|
24
24
|
static readonly debtRules = [
|
|
25
25
|
"akan.size.file",
|
|
26
26
|
"akan.size.component",
|
|
27
|
+
"akan.size.function",
|
|
28
|
+
"akan.size.class",
|
|
27
29
|
"akan.component.store-access",
|
|
28
30
|
"akan.store.multi-write",
|
|
29
31
|
"akan.store.root-write-chain",
|
|
@@ -34,12 +36,15 @@ export class MaintainabilityBaseline {
|
|
|
34
36
|
static readonly reasonedRules: ReadonlySet<DebtRuleId> = new Set([
|
|
35
37
|
"akan.size.file",
|
|
36
38
|
"akan.size.component",
|
|
39
|
+
"akan.size.function",
|
|
40
|
+
"akan.size.class",
|
|
37
41
|
"akan.component.store-access",
|
|
38
42
|
"akan.service.call-in-loop",
|
|
39
43
|
]);
|
|
40
44
|
|
|
41
45
|
static countOf(violation: MaintainabilityViolation) {
|
|
42
46
|
if (violation.kind === "size") return violation.lineCount;
|
|
47
|
+
if (violation.kind === "classSize") return violation.memberCount;
|
|
43
48
|
if (violation.kind === "storeAccess") return violation.accessCount;
|
|
44
49
|
if (violation.kind === "multiWrite") return violation.writeCount;
|
|
45
50
|
if (violation.kind === "writePlacement") return violation.writes.length;
|
|
@@ -498,6 +498,8 @@ describe("MaintainabilityContract rules", () => {
|
|
|
498
498
|
expect(MaintainabilityContract.ruleIds).toEqual([
|
|
499
499
|
"akan.size.file",
|
|
500
500
|
"akan.size.component",
|
|
501
|
+
"akan.size.function",
|
|
502
|
+
"akan.size.class",
|
|
501
503
|
"akan.store.multi-write",
|
|
502
504
|
"akan.store.root-write-chain",
|
|
503
505
|
"akan.component.write-chain",
|
|
@@ -844,3 +846,184 @@ export class CourtEndpoint extends endpoint(srv.court, ({ mutation }) => ({
|
|
|
844
846
|
expect(await contractOf(root).enforce({ fix: false })).toEqual([]);
|
|
845
847
|
});
|
|
846
848
|
});
|
|
849
|
+
|
|
850
|
+
describe("MaintainabilityContract packages", () => {
|
|
851
|
+
const packageOf = (root: string) => new MaintainabilityContract(root, { type: "pkg", name: "@demo/core" });
|
|
852
|
+
const functionOf = (name: string, lineNum: number) => `export function ${name}() {\n${linesOf(lineNum - 2)}\n}\n`;
|
|
853
|
+
const classOf = (name: string, memberNum: number) =>
|
|
854
|
+
`export class ${name} {\n${Array.from({ length: memberNum }, (_, idx) => ` field${idx} = ${idx};`).join("\n")}\n}\n`;
|
|
855
|
+
|
|
856
|
+
test("holds a package file to 800 lines, a function to 150 and a class to 60 members, tests and barrels aside", async () => {
|
|
857
|
+
const root = await makeWorkspace({
|
|
858
|
+
"pkgs/@demo/core/server/host.ts": `${classOf("Host", 61)}${functionOf("boot", 151)}${linesOf(600)}`,
|
|
859
|
+
"pkgs/@demo/core/server/atLimit.ts": `${classOf("Small", 60)}${functionOf("fits", 150)}${linesOf(800 - 212)}\n`,
|
|
860
|
+
"pkgs/@demo/core/server/host.test.ts": `${functionOf("suite", 900)}`,
|
|
861
|
+
"pkgs/@demo/core/server/host.fixture.ts": `${functionOf("fixture", 900)}`,
|
|
862
|
+
"pkgs/@demo/core/server/host.instance.ts": `${functionOf("instance", 900)}`,
|
|
863
|
+
"pkgs/@demo/core/test/helpers.ts": `${functionOf("helper", 900)}`,
|
|
864
|
+
"pkgs/@demo/core/types.d.ts": linesOf(900),
|
|
865
|
+
"pkgs/@demo/core/index.ts": Array.from({ length: 801 }, (_, idx) => `export * from "./m${idx}";`).join("\n"),
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
expect(await packageOf(root).measure()).toEqual([
|
|
869
|
+
{
|
|
870
|
+
kind: "size",
|
|
871
|
+
rule: "akan.size.file",
|
|
872
|
+
key: "server/host.ts",
|
|
873
|
+
file: "server/host.ts",
|
|
874
|
+
line: 1,
|
|
875
|
+
lineCount: 814,
|
|
876
|
+
maxLines: 800,
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
kind: "size",
|
|
880
|
+
rule: "akan.size.function",
|
|
881
|
+
key: "server/host.ts#boot",
|
|
882
|
+
file: "server/host.ts",
|
|
883
|
+
line: 64,
|
|
884
|
+
lineCount: 151,
|
|
885
|
+
maxLines: 150,
|
|
886
|
+
function: "boot",
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
kind: "classSize",
|
|
890
|
+
rule: "akan.size.class",
|
|
891
|
+
key: "server/host.ts#Host",
|
|
892
|
+
file: "server/host.ts",
|
|
893
|
+
line: 1,
|
|
894
|
+
owner: "Host",
|
|
895
|
+
memberCount: 61,
|
|
896
|
+
maxMembers: 60,
|
|
897
|
+
},
|
|
898
|
+
]);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("reads a package in a repository through git, so ignored output is never measured", async () => {
|
|
902
|
+
const root = await makeWorkspace({
|
|
903
|
+
".gitignore": "**/dist\n",
|
|
904
|
+
"pkgs/@demo/core/dist/bundle.ts": linesOf(900),
|
|
905
|
+
"pkgs/@demo/core/server/committed.ts": linesOf(801),
|
|
906
|
+
});
|
|
907
|
+
await commitAll(root);
|
|
908
|
+
await writeText(path.join(root, "pkgs/@demo/core/server/untracked.ts"), linesOf(802));
|
|
909
|
+
|
|
910
|
+
expect((await packageOf(root).measure()).map(({ key }) => key)).toEqual([
|
|
911
|
+
"server/committed.ts",
|
|
912
|
+
"server/untracked.ts",
|
|
913
|
+
]);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("measures nothing in a package git ignores whole, and leaves a package's examples out", async () => {
|
|
917
|
+
const root = await makeWorkspace({
|
|
918
|
+
".gitignore": "pkgs/gen/\n",
|
|
919
|
+
"pkgs/gen/package.json": "{}\n",
|
|
920
|
+
"pkgs/gen/big.ts": functionOf("generated", 162),
|
|
921
|
+
"pkgs/@demo/core/examples/sample/src/selftest.ts": functionOf("runSelftest", 900),
|
|
922
|
+
});
|
|
923
|
+
await commitAll(root);
|
|
924
|
+
|
|
925
|
+
expect(await new MaintainabilityContract(root, { type: "pkg", name: "gen" }).measure()).toEqual([]);
|
|
926
|
+
expect(await packageOf(root).measure()).toEqual([]);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("numbers only the breaches sharing a name, so a short function above leaves a recorded key in place", async () => {
|
|
930
|
+
const callbackOf = (receiver: string, lineNum: number) => `${receiver}.map(() => {\n${linesOf(lineNum - 2)}\n});\n`;
|
|
931
|
+
const root = await makeWorkspace({
|
|
932
|
+
"pkgs/@demo/core/run.ts": `ys.map(() => 1);\n${callbackOf("xs", 160)}${callbackOf("zs", 170)}`,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
expect((await packageOf(root).measure()).map(({ key }) => key)).toEqual(["run.ts#map()", "run.ts#map()[2]"]);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test("records a package's breaches in its own baseline, which only shrinks from the default branch", async () => {
|
|
939
|
+
const root = await makeWorkspace({ "pkgs/@demo/core/server/host.ts": functionOf("boot", 200) });
|
|
940
|
+
|
|
941
|
+
expect((await packageOf(root).seed()).result).toBe("written");
|
|
942
|
+
expect(await readFile(path.join(root, "pkgs/@demo/core/akan.baseline.json"), "utf8")).toBe(
|
|
943
|
+
'{\n "akan.size.function": {\n "server/host.ts#boot": 200\n }\n}\n',
|
|
944
|
+
);
|
|
945
|
+
await commitAll(root);
|
|
946
|
+
await writeText(path.join(root, "pkgs/@demo/core/server/host.ts"), functionOf("boot", 210));
|
|
947
|
+
|
|
948
|
+
const contract = packageOf(root);
|
|
949
|
+
const failures = await contract.enforce({ fix: false });
|
|
950
|
+
|
|
951
|
+
expect(failures).toMatchObject([{ rule: "akan.size.function", key: "server/host.ts#boot", recorded: 200 }]);
|
|
952
|
+
expect(contract.describe(failures)).toBe(
|
|
953
|
+
"[maintainability] 1 violation(s) in pkgs/@demo/core:\n" +
|
|
954
|
+
" pkgs/@demo/core/server/host.ts:1 akan.size.function function boot has 210 lines, over the 150-line limit, up from 200\n" +
|
|
955
|
+
"How to fix:\n" +
|
|
956
|
+
" function: Split it by family into named functions or a collaborator class, each under the limit, keeping its signature for callers.",
|
|
957
|
+
);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe("MaintainabilityContract reference without a baseline", () => {
|
|
962
|
+
const packageOf = (root: string) => new MaintainabilityContract(root, { type: "pkg", name: "@demo/core" });
|
|
963
|
+
const functionOf = (name: string, lineNum: number) => `export function ${name}() {\n${linesOf(lineNum - 2)}\n}\n`;
|
|
964
|
+
const host = "pkgs/@demo/core/server/host.ts";
|
|
965
|
+
const writePackageBaseline = async (root: string, json: object) =>
|
|
966
|
+
await writeText(path.join(root, "pkgs/@demo/core/akan.baseline.json"), `${JSON.stringify(json, null, 2)}\n`);
|
|
967
|
+
|
|
968
|
+
test("refuses an entry for debt the fork point's code did not hold, unless a person gave it a reason", async () => {
|
|
969
|
+
const root = await makeWorkspace({ [host]: functionOf("boot", 100) });
|
|
970
|
+
await commitAll(root);
|
|
971
|
+
await writeText(path.join(root, host), functionOf("boot", 200));
|
|
972
|
+
await writePackageBaseline(root, { "akan.size.function": { "server/host.ts#boot": 200 } });
|
|
973
|
+
|
|
974
|
+
const contract = packageOf(root);
|
|
975
|
+
const failures = await contract.enforce({ fix: false });
|
|
976
|
+
|
|
977
|
+
expect(failures).toMatchObject([
|
|
978
|
+
{ kind: "baselineGrowth", key: "server/host.ts#boot", count: 200, committed: undefined, measured: true, line: 3 },
|
|
979
|
+
]);
|
|
980
|
+
expect(contract.describe(failures)).toMatch(
|
|
981
|
+
/pkgs\/@demo\/core\/akan\.baseline\.json:3 {2}akan\.baseline\.growth {2}entry "server\/host\.ts#boot" under akan\.size\.function is debt the code at [0-9a-f]{12} does not hold/,
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
await writePackageBaseline(root, {
|
|
985
|
+
"akan.size.function": { "server/host.ts#boot": 200 },
|
|
986
|
+
reasons: { "server/host.ts#boot": "One state machine a person chose to keep whole." },
|
|
987
|
+
});
|
|
988
|
+
expect(await packageOf(root).enforce({ fix: false })).toEqual([]);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("lets a scope adopt the gate with the debt its code held at the fork point, and no more", async () => {
|
|
992
|
+
const root = await makeWorkspace({ [host]: functionOf("boot", 200) });
|
|
993
|
+
await commitAll(root);
|
|
994
|
+
|
|
995
|
+
expect((await packageOf(root).seed()).result).toBe("written");
|
|
996
|
+
expect(await packageOf(root).enforce({ fix: false })).toEqual([]);
|
|
997
|
+
|
|
998
|
+
await writeText(path.join(root, host), functionOf("boot", 210));
|
|
999
|
+
await packageOf(root).seed({ force: true });
|
|
1000
|
+
|
|
1001
|
+
expect(await packageOf(root).enforce({ fix: false })).toMatchObject([
|
|
1002
|
+
{ kind: "baselineGrowth", key: "server/host.ts#boot", count: 210, committed: 200, measured: true },
|
|
1003
|
+
]);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test("holds an app new to the branch, or one whose paid-off baseline was removed, to none", async () => {
|
|
1007
|
+
const zone = "apps/demo/lib/court/Court.Zone.tsx";
|
|
1008
|
+
const root = await makeWorkspace({ "apps/other/lib/court/Court.Zone.tsx": linesOf(10) });
|
|
1009
|
+
await commitAll(root);
|
|
1010
|
+
await writeText(path.join(root, zone), linesOf(450));
|
|
1011
|
+
await writeBaseline(root, { "akan.size.file": { "lib/court/Court.Zone.tsx": 450 } });
|
|
1012
|
+
|
|
1013
|
+
expect(await contractOf(root).enforce({ fix: false })).toMatchObject([
|
|
1014
|
+
{ kind: "baselineGrowth", key: "lib/court/Court.Zone.tsx", count: 450, committed: undefined, measured: true },
|
|
1015
|
+
]);
|
|
1016
|
+
|
|
1017
|
+
await writeText(path.join(root, zone), linesOf(400));
|
|
1018
|
+
await contractOf(root).enforce({ fix: true });
|
|
1019
|
+
expect(existsSync(path.join(root, "apps/demo/akan.baseline.json"))).toBe(false);
|
|
1020
|
+
await git(root, "add", "-A");
|
|
1021
|
+
await git(root, "commit", "-q", "-m", "paid off");
|
|
1022
|
+
await writeText(path.join(root, zone), linesOf(450));
|
|
1023
|
+
await writeBaseline(root, { "akan.size.file": { "lib/court/Court.Zone.tsx": 450 } });
|
|
1024
|
+
|
|
1025
|
+
expect(await contractOf(root).enforce({ fix: false })).toMatchObject([
|
|
1026
|
+
{ kind: "baselineGrowth", key: "lib/court/Court.Zone.tsx", committed: undefined, measured: true },
|
|
1027
|
+
]);
|
|
1028
|
+
});
|
|
1029
|
+
});
|