@akanjs/devkit 3.1.2-rc.3 → 3.2.0-rc.0
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 +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +2 -2
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +54 -0
- package/applicationBuildReporter.ts +12 -0
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +77 -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 +30 -2
- package/codeAgent/skills/akan-service/SKILL.md +77 -1
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -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.test.ts +82 -0
- package/gitRevision.ts +128 -0
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- 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-deep-internal-import.grit +1 -1
- package/lint/no-js-private-class-method.grit +9 -28
- 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/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/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +121 -0
- package/workspaceGraph.ts +149 -16
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
|
@@ -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,20 +199,98 @@ 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
|
-
|
|
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([
|
|
190
222
|
"model:apps/demo:memo",
|
|
191
223
|
"model:libs/shared:file",
|
|
224
|
+
"route:apps/demo:/",
|
|
192
225
|
]);
|
|
226
|
+
const changes = answerTo(6);
|
|
227
|
+
if (!("changes" in changes) || "error" in changes.changes)
|
|
228
|
+
throw new Error(`no changes: ${JSON.stringify(changes)}`);
|
|
229
|
+
expect(changes.changes.nodes.map(({ id, change }) => [id, change])).toEqual([["model:apps/demo:memo", "changed"]]);
|
|
230
|
+
expect(changes.changes.summary).toEqual({ added: 0, removed: 0, changed: 1 });
|
|
231
|
+
expect(answerTo(7)).toEqual(changes);
|
|
193
232
|
}, 30_000);
|
|
194
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
|
+
|
|
195
294
|
test("answers a second shell request in the same generation without building again", async () => {
|
|
196
295
|
const { root } = track(await createTempApp("demo"));
|
|
197
296
|
const appDir = path.join(root, "apps/demo");
|
|
@@ -18,7 +18,6 @@ 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 { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
|
|
22
21
|
import { Logger } from "akanjs/common";
|
|
23
22
|
import type {
|
|
24
23
|
BaseBuildArtifact,
|
|
@@ -27,7 +26,6 @@ import type {
|
|
|
27
26
|
BuilderMessage,
|
|
28
27
|
BuilderReq,
|
|
29
28
|
BuilderRes,
|
|
30
|
-
BuildGraphPayload,
|
|
31
29
|
BuildPhase,
|
|
32
30
|
BuildRouteResultPayload,
|
|
33
31
|
ChangeBatch,
|
|
@@ -37,6 +35,7 @@ import { CSR_DEV_PATCHING_MARKER } from "akanjs/server/hmr/csrDevManifest";
|
|
|
37
35
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
38
36
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
39
37
|
import { BuilderChannel } from "./builderChannel";
|
|
38
|
+
import { BuilderGraphPayloads } from "./builderGraphPayloads";
|
|
40
39
|
import { type BatchJob, BuilderWorkQueue, type DiscoveryJob } from "./builderWorkQueue";
|
|
41
40
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
42
41
|
|
|
@@ -67,7 +66,7 @@ class IncrementalBuilder {
|
|
|
67
66
|
#watcher: HmrWatcher | null = null;
|
|
68
67
|
#generation = 0;
|
|
69
68
|
#shellKey: string | null = null;
|
|
70
|
-
#
|
|
69
|
+
readonly #graphPayloads: BuilderGraphPayloads;
|
|
71
70
|
#discoveredThrough = 0;
|
|
72
71
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
73
72
|
#csrBundler: CsrDevBundler;
|
|
@@ -119,6 +118,7 @@ class IncrementalBuilder {
|
|
|
119
118
|
this.#discovery = options.discovery;
|
|
120
119
|
this.#generation = options.initialGeneration ?? 0;
|
|
121
120
|
this.#discoveredThrough = this.#generation;
|
|
121
|
+
this.#graphPayloads = new BuilderGraphPayloads(options.app, () => this.#generation);
|
|
122
122
|
this.#batchRunner = new BuildBatchRunner({
|
|
123
123
|
workspaceRoot: options.app.workspace.workspaceRoot,
|
|
124
124
|
cwd: options.app.cwdPath,
|
|
@@ -367,6 +367,7 @@ class IncrementalBuilder {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
async #handleWatchBatch(batch: ChangeBatch) {
|
|
370
|
+
this.#graphPayloads.touched(batch.files);
|
|
370
371
|
const rawKinds = new Set(batch.kinds);
|
|
371
372
|
if (rawKinds.size === 0) return;
|
|
372
373
|
const generation = ++this.#generation;
|
|
@@ -878,14 +879,9 @@ class IncrementalBuilder {
|
|
|
878
879
|
}
|
|
879
880
|
|
|
880
881
|
async handleBuildGraph(msg: BuilderGraphReq): Promise<void> {
|
|
881
|
-
const generation = this.#generation;
|
|
882
882
|
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 });
|
|
883
|
+
const data = await this.#graphPayloads.of(msg);
|
|
884
|
+
await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: true, data });
|
|
889
885
|
} catch (err) {
|
|
890
886
|
const error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
891
887
|
this.#logger.error(`build-graph failed: ${error}`);
|
|
@@ -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-");
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|