@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +2 -2
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +54 -0
  9. package/applicationBuildReporter.ts +12 -0
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +77 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +30 -2
  22. package/codeAgent/skills/akan-service/SKILL.md +77 -1
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/declarationReader.ts +211 -0
  26. package/executors.test.ts +65 -8
  27. package/executors.ts +5 -3
  28. package/frameworkGraph.test.ts +121 -0
  29. package/frameworkGraph.ts +165 -0
  30. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  31. package/frontendBuild/allRoutesBuilder.ts +32 -12
  32. package/frontendBuild/buildRouteClient.test.ts +192 -2
  33. package/frontendBuild/bundleDefine.ts +4 -0
  34. package/frontendBuild/clientBuildTypes.ts +7 -1
  35. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  36. package/frontendBuild/clientEntriesBundler.ts +55 -4
  37. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  38. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  39. package/frontendBuild/fontOptimizer.test.ts +54 -0
  40. package/frontendBuild/fontOptimizer.ts +5 -4
  41. package/frontendBuild/index.ts +3 -0
  42. package/frontendBuild/routeClientBuilder.ts +6 -2
  43. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  44. package/frontendBuild/routeModulePreloads.ts +52 -0
  45. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  46. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  47. package/frontendBuild/ssrDevBundler.ts +2 -1
  48. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  49. package/frontendBuild/vendorImportScanner.ts +80 -0
  50. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  51. package/frontendBuild/vendorNarrowing.ts +64 -0
  52. package/frontendBuild/vendorSpecifiers.ts +13 -0
  53. package/gitRevision.test.ts +82 -0
  54. package/gitRevision.ts +128 -0
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
  61. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
  62. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
  63. package/lint/no-daisyui-legacy-class.grit +9 -7
  64. package/lint/no-deep-internal-import.grit +1 -1
  65. package/lint/no-js-private-class-method.grit +9 -28
  66. package/maintainabilityBaseline.ts +7 -2
  67. package/maintainabilityContract.test.ts +183 -0
  68. package/maintainabilityContract.ts +145 -30
  69. package/maintainabilityExceptionScanner.test.ts +13 -2
  70. package/maintainabilityExceptionScanner.ts +16 -6
  71. package/maintainabilityLimits.ts +12 -0
  72. package/maintainabilityReport.test.ts +35 -0
  73. package/maintainabilityReport.ts +29 -8
  74. package/mobile/nativePublicEnv.test.ts +5 -1
  75. package/package.json +3 -3
  76. package/routeJsBudget.test.ts +177 -0
  77. package/routeJsBudget.ts +222 -0
  78. package/structureScanner.test.ts +167 -0
  79. package/structureScanner.ts +164 -0
  80. package/transforms/barrelAnalyzer.ts +10 -3
  81. package/transforms/barrelImportsPlugin.ts +13 -7
  82. package/transforms/serverEnvPlugin.test.ts +1 -1
  83. package/transforms/serverEnvPlugin.ts +1 -1
  84. package/transforms/transforms.test.ts +64 -12
  85. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  86. package/typeChecker.test.ts +100 -0
  87. package/typeChecker.ts +14 -2
  88. package/workflow/plan.ts +79 -17
  89. package/workflow/types.ts +1 -0
  90. package/workspaceGraph.test.ts +121 -0
  91. package/workspaceGraph.ts +149 -16
  92. package/workspaceLayout.ts +1 -0
  93. /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
- await until(proc, "build-graph-res", () => messages.some((message) => message.type === "build-graph-res"));
202
+ proc.send({ type: "build-graph", id: 6, changes: { since: null } } satisfies BuilderMessage);
203
+ proc.send({ type: "build-graph", id: 7, changes: { since: "main" } } satisfies BuilderMessage);
204
+ await until(
205
+ proc,
206
+ "build-graph-res",
207
+ () => messages.filter((message) => message.type === "build-graph-res").length === 3,
208
+ );
182
209
  } finally {
183
210
  proc.kill();
184
211
  await proc.exited;
185
212
  }
186
- const answer = messages.find((message) => message.type === "build-graph-res");
187
- if (answer?.type !== "build-graph-res" || !answer.ok) throw new Error(`no graph: ${JSON.stringify(answer)}`);
188
- expect(answer.data.workspaceRoot).toBe(root);
189
- expect(answer.data.graph.nodes.map((node) => node.id).sort()).toEqual([
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
- #graph: { generation: number; payload: BuildGraphPayload } | null = null;
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
- if (this.#graph?.generation !== generation) {
884
- const { workspace } = this.#app;
885
- const graph = WorkspaceGraph.visibleTo(await WorkspaceGraph.build(workspace), this.#app.name);
886
- this.#graph = { generation, payload: { workspaceRoot: workspace.workspaceRoot, graph } };
887
- }
888
- await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: true, data: this.#graph.payload });
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" />; // @flag
2
- export const CompoundBadge = () => <span className="badge-success" />; // @flag
1
+ export const CompoundButton = () => <button className="btn-primary" />; // @warn
2
+ export const CompoundBadge = () => <span className="badge-success" />; // @warn
3
+ export const CompoundStatValue = () => <div className="md:stat-value" />; // @warn
4
+ export const BothHalvesReportTheColourSlot = () => <div className="btn-primary bg-base-100" />; // @flag
3
5
  export const DroppedBaseSurface = () => <div className="bg-base-100" />; // @flag
4
6
  export const DroppedBaseContentWithOpacity = () => <div className="text-base-content/70" />; // @flag
5
7
  export const DroppedBaseOnABorderSide = () => <div className="border-t-base-300" />; // @flag
@@ -1,3 +1,5 @@
1
1
  export class SyncPrivate { #compute() { return 1; } } // @flag
2
2
  export class AsyncPrivate { async #load() { return 1; } } // @flag
3
+ export class StaticPrivate { static #make() { return 1; } } // @flag
3
4
  export class CallsPrivate { run() { this.#compute(); } } // @flag
5
+ export class CallsPrivateOptionally { run() { this.#compute?.(); } } // @flag
@@ -1,3 +1,4 @@
1
- export class TypescriptPrivate { private _compute() { return 1; } } // @ok
2
- export class CallsUnderscore { run() { this._compute(); } } // @ok
1
+ export class TypescriptPrivate { private compute() { return 1; } } // @ok
2
+ export class CallsTypescriptPrivate { run() { this.compute(); } } // @ok
3
3
  export class PrivateField { #cache = new Map(); } // @ok
4
+ export class CallsOnAPrivateField { #cache = new Map<string, number>(); run() { return this.#cache.get("a"); } } // @ok
@@ -9,8 +9,10 @@ language js(typescript, jsx)
9
9
  // Tailwind or app classes (`card`, `input`, `badge`, `btn`, `divider`) are deliberately not
10
10
  // flagged, and colour slots that survived into the semantic vocabulary (`primary`, `info`,
11
11
  // `success`, `warning`, `neutral`) are not either — only the slots the vocabulary dropped.
12
- // Biome keeps only the first diagnostic a single plugin registers on a node, so a string
13
- // carrying both halves reports the component one and surfaces the colour one on the next run.
12
+ // A component name is a warning: an app's own CSS may define `stat-value`, which this rule cannot read, and
13
+ // `akan verify` drops it when the app's or a lib's stylesheet does (cli verifyStyleClasses.ts).
14
+ // Biome keeps only the first diagnostic a single plugin registers on a node, so the colour slot, an error, is
15
+ // tried first: a string carrying both halves reports it, and the component warning surfaces on the next run.
14
16
  or {
15
17
  JsxString() as $s,
16
18
  JsStringLiteralExpression() as $s,
@@ -18,19 +20,19 @@ or {
18
20
  } where {
19
21
  any {
20
22
  and {
21
- $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
23
+ $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
22
24
  register_diagnostic(
23
25
  span = $s,
24
- message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
26
+ message = "daisyUI colour slot - the closed colour vocabulary has no CSS for it, so this renders unstyled. base-100/200/300 -> background/muted/border, base-content -> foreground, <colour>-content -> <colour>-foreground, error -> destructive.",
25
27
  severity = "error"
26
28
  )
27
29
  },
28
30
  and {
29
- $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
31
+ $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
30
32
  register_diagnostic(
31
33
  span = $s,
32
- message = "daisyUI colour slot - the closed colour vocabulary has no CSS for it, so this renders unstyled. base-100/200/300 -> background/muted/border, base-content -> foreground, <colour>-content -> <colour>-foreground, error -> destructive.",
33
- severity = "error"
34
+ message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled unless your own CSS defines it. Otherwise use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
35
+ severity = "warn"
34
36
  )
35
37
  }
36
38
  }
@@ -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() as $name,
7
- $method <: r"async\s+#",
8
- $method <: contains `async` as $asyncToken,
9
- $name <: r"#(.*)"($bareName),
10
- $privateName = join(list = ["_", $bareName], separator = ""),
9
+ $method <: contains JsPrivateClassMemberName(),
11
10
  register_diagnostic(
12
11
  span = $method,
13
- message = "JavaScript private class methods (#) should not be used in mixin-enabled modules. Use a TypeScript private method with an underscore prefix instead.",
12
+ message = "A `#private` method breaks once this class is mixed into another. Use a TypeScript `private` method, `private name()` called as `this.name()`; `akan verify` renames it unless the class already has that name.",
14
13
  severity = "error"
15
- ),
16
- $asyncToken => `private async`,
17
- $name => $privateName
14
+ )
18
15
  },
19
- JsMethodClassMember() as $method where {
20
- $method <: contains JsPrivateClassMemberName() as $name,
21
- not $method <: r"async\s+#",
22
- $name <: r"#(.*)"($bareName),
23
- $privateName = join(list = ["private _", $bareName], separator = ""),
24
- register_diagnostic(
25
- span = $method,
26
- message = "JavaScript private class methods (#) should not be used in mixin-enabled modules. Use a TypeScript private method with an underscore prefix instead.",
27
- severity = "error"
28
- ),
29
- $name => $privateName
30
- },
31
- JsCallExpression() as $call where {
32
- $call <: contains JsPrivateName() as $name,
33
- $name <: r"#(.*)"($bareName),
34
- $privateName = join(list = ["_", $bareName], separator = ""),
16
+ JsCallExpression(callee = JsStaticMemberExpression(member = JsPrivateName())) as $call where {
35
17
  register_diagnostic(
36
18
  span = $call,
37
- message = "Calls to JavaScript private class methods (#) should use the underscore-prefixed TypeScript private method.",
19
+ message = "A call to a `#private` method: call the TypeScript `private` method that replaces it, `this.name()`.",
38
20
  severity = "error"
39
- ),
40
- $name => $privateName
21
+ )
41
22
  }
42
23
  }
@@ -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. A count
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
+ });