@akanjs/devkit 3.1.1-rc.9 → 3.1.2-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 (188) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -0,0 +1,73 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // A client list request takes at most MAX_PAGE_SIZE (500) rows: SignalResolver.#clientPageLimit
5
+ // (pkgs/akanjs/server/resolver/signal.resolver.ts) clamps a larger limit and answers with 500 rows and no
6
+ // error, so a screen that asked for 10000 silently stops at row 500. A store list reads hasMoreOf<Model><Suffix>
7
+ // as true after a full 500, so only a screen that calls loadMoreOf<Model><Suffix> ever sees the rest. The server
8
+ // logs one warning per list; this rule moves it to the line that asked.
9
+ //
10
+ // Matched: a literal limit above 500 in the generated list fetch, `fetch.<model>List<Suffix>(...sliceArgs,
11
+ // skip, limit, sort?, fetchPolicy?)`; as `limit:` in the option object of `fetch.init<Model><Suffix>`,
12
+ // `fetch.get<Model>Init<Suffix>`, `st.do.init<Model><Suffix>` and `st.do.refresh<Model><Suffix>`; and as the
13
+ // argument of `st.do.setLimitOf<Model><Suffix>`.
14
+ //
15
+ // A rule cannot know a slice's arity, so the positional limit is found by shape: an argument that reads as a
16
+ // skip (`0`, `null`, `undefined`, a name with skip/offset, a page multiplied out) directly before the literal,
17
+ // and after it nothing but a non-numeric sort and a fetch policy. That keeps a numeric slice argument out
18
+ // (`fetch.statListInYear(2026)`, `fetch.statListInMonth(11, 2026)`), as it does a variable limit, `limit: 0`
19
+ // (the store's one-page mode, which the server warns about itself) and a `limit:` key anywhere else (rate
20
+ // limits). Left over: a slice whose own last two arguments read as `(0, 1000)`, such as a price range; suppress
21
+ // that one with `// biome-ignore lint/plugin: <reason>`.
22
+ pattern over_max_page_size() {
23
+ JsNumberLiteralExpression() as $size where {
24
+ $size <: r"(?:50[1-9]|5[1-9][0-9]|[6-9][0-9]{2}|[1-9][0-9]{0,2}(?:_?[0-9]{3})+|[1-9]e\+?(?:[3-9]|[1-9][0-9]+))"
25
+ }
26
+ }
27
+
28
+ pattern page_skip() {
29
+ or { r"(?:0|null|undefined)", r"(?i)[\s\S]*(?:skip|offset)[\s\S]*", r"(?i)[\s\S]*page[\s\S]*\*[\s\S]*" }
30
+ }
31
+
32
+ pattern page_sort() {
33
+ not JsNumberLiteralExpression()
34
+ }
35
+
36
+ JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) where {
37
+ or {
38
+ and {
39
+ $callee <: `fetch.$endpoint`,
40
+ $endpoint <: r"[a-z][A-Za-z0-9_$]*List(?:[A-Z][A-Za-z0-9_$]*)?",
41
+ $args <: or {
42
+ [..., page_skip(), over_max_page_size() as $limit],
43
+ [..., page_skip(), over_max_page_size() as $limit, page_sort()],
44
+ [..., page_skip(), over_max_page_size() as $limit, page_sort(), $_]
45
+ }
46
+ },
47
+ and {
48
+ or {
49
+ and {
50
+ $callee <: `fetch.$endpoint`,
51
+ $endpoint <: or { r"init[A-Z][A-Za-z0-9_$]*", r"get[A-Z][A-Za-z0-9_$]*Init(?:[A-Z][A-Za-z0-9_$]*)?" }
52
+ },
53
+ and { $callee <: `st.do.$action`, $action <: r"(?:init|refresh)[A-Z][A-Za-z0-9_$]*" }
54
+ },
55
+ $args <: some JsObjectExpression(
56
+ members = some JsPropertyObjectMember(
57
+ name = r"(?:limit|\x22limit\x22|'limit')",
58
+ value = over_max_page_size() as $limit
59
+ )
60
+ )
61
+ },
62
+ and {
63
+ $callee <: `st.do.$action`,
64
+ $action <: r"setLimitOf[A-Z][A-Za-z0-9_$]*",
65
+ $args <: [over_max_page_size() as $limit, ...]
66
+ }
67
+ },
68
+ register_diagnostic(
69
+ span = $limit,
70
+ message = "The server serves at most 500 rows per list request and drops the rest without an error, so this list silently stops at row 500. Page through it at 500 or fewer per request (skip/limit, or st.do.loadMoreOf<Model><Suffix>() while hasMoreOf<Model><Suffix>), or compute what the screen needs on the server in an insight or a dedicated endpoint.",
71
+ severity = "error"
72
+ )
73
+ }
@@ -1,8 +1,8 @@
1
1
  engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
- // `SignalContext` checks `signalOption.guards ?? []`, so an endpoint, a named slice, or a slice's root query declaring
5
- // none is open to every HTTP and websocket caller. An option the rule cannot read, a variable or a spread, is left alone.
4
+ // `SignalContext` refuses an endpoint, a named slice, or a slice's root query declaring no guards, and outside local
5
+ // operation the server does not boot with one. An option the rule cannot read, a variable or a spread, is left alone.
6
6
  or {
7
7
  JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) as $call where {
8
8
  or {
@@ -32,7 +32,7 @@ or {
32
32
  },
33
33
  register_diagnostic(
34
34
  span = $callee,
35
- message = "No `guards`, so every HTTP and websocket caller reaches this endpoint: a missing or empty `guards` fails open, which is also why MCP refuses it. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
35
+ message = "No `guards`, so every call to this endpoint is refused with 403, and outside local operation the server does not boot. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
36
36
  severity = "warn"
37
37
  )
38
38
  },
@@ -47,7 +47,7 @@ or {
47
47
  },
48
48
  register_diagnostic(
49
49
  span = $sliceOption,
50
- message = "No `guards` on this slice, so every HTTP and websocket caller reaches its root query and generated reads and writes: a missing guard fails open. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
50
+ message = "No `guards` on this slice, so it mounts no root list or insight and none of its generated reads and writes. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
51
51
  severity = "warn"
52
52
  )
53
53
  },
@@ -64,7 +64,7 @@ or {
64
64
  },
65
65
  register_diagnostic(
66
66
  span = $guardMap,
67
- message = "No `root` guard on this slice, so its root query lists any filter to every HTTP and websocket caller: a missing or empty `root` fails open. Write `root: Admin`.",
67
+ message = "No `root` guard on this slice. Left out, it mounts no root list or insight; `root: []` refuses every caller with 403, and outside local operation the server does not boot. Write `root: Admin`, or `root: Public` for anonymous access.",
68
68
  severity = "warn"
69
69
  )
70
70
  }
package/linter.test.ts CHANGED
@@ -93,4 +93,16 @@ describe("Linter", () => {
93
93
  process.env.PATH = pathBefore;
94
94
  }
95
95
  });
96
+
97
+ test("splits path arguments into batches that stay under the command-line budget", () => {
98
+ const args = Array.from({ length: 10 }, (_, idx) => `apps/demo/lib/file${idx}.ts`);
99
+ const batches = Linter.batchArgs(args, 100);
100
+ expect(batches.flat()).toEqual(args);
101
+ expect(batches.length).toBeGreaterThan(1);
102
+ for (const batch of batches) expect(batch.reduce((sum, arg) => sum + arg.length + 3, 0)).toBeLessThanOrEqual(100);
103
+ });
104
+
105
+ test("keeps an argument longer than the budget in a batch of its own", () => {
106
+ expect(Linter.batchArgs(["a".repeat(50), "b", "c"], 10)).toEqual([["a".repeat(50)], ["b", "c"]]);
107
+ });
96
108
  });
package/linter.ts CHANGED
@@ -386,27 +386,50 @@ export class Linter {
386
386
  return { ...lintResult, fixed: stdout !== source, output: stdout };
387
387
  }
388
388
 
389
- // One spawn for all files: Biome's startup dominates a small file. Missing paths are dropped (a template may
390
- // decline a file).
389
+ // As few spawns as the command line allows: Biome's startup dominates a small file. Missing paths are dropped (a
390
+ // template may decline a file).
391
391
  async fixFiles(filePaths: string[]): Promise<{ fixed: string[] }> {
392
392
  const resolved = filePaths.map((filePath) => this.#resolveFilePath(filePath)).filter(existsSync);
393
393
  if (resolved.length === 0) return { fixed: [] };
394
394
  const before = new Map(resolved.map((filePath) => [filePath, readFileSync(filePath, "utf8")]));
395
- await this.#runBiome([
396
- "check",
397
- "--write",
398
- "--reporter=json",
399
- "--max-diagnostics=none",
400
- "--no-errors-on-unmatched",
401
- "--config-path",
402
- this.configPath,
403
- ...resolved.map((filePath) => this.#toBiomePath(filePath)),
404
- ]);
395
+ const biomePaths = resolved.map((filePath) => this.#toBiomePath(filePath));
396
+ for (const batch of Linter.batchArgs(biomePaths, Linter.#pathArgsBudget))
397
+ await this.#runBiome([
398
+ "check",
399
+ "--write",
400
+ "--reporter=json",
401
+ "--max-diagnostics=none",
402
+ "--no-errors-on-unmatched",
403
+ "--config-path",
404
+ this.configPath,
405
+ ...batch,
406
+ ]);
405
407
  return {
406
408
  fixed: resolved.filter((filePath) => readFileSync(filePath, "utf8") !== before.get(filePath)),
407
409
  };
408
410
  }
409
411
 
412
+ //? Windows refuses a command line past 32,767 characters (ENAMETOOLONG), and a whole workspace scaffold passes it.
413
+ static readonly #pathArgsBudget = 24_000;
414
+
415
+ static batchArgs(args: string[], budget: number): string[][] {
416
+ const batches: string[][] = [];
417
+ let batch: string[] = [];
418
+ let length = 0;
419
+ for (const arg of args) {
420
+ const cost = arg.length + 3;
421
+ if (batch.length > 0 && length + cost > budget) {
422
+ batches.push(batch);
423
+ batch = [];
424
+ length = 0;
425
+ }
426
+ batch.push(arg);
427
+ length += cost;
428
+ }
429
+ if (batch.length > 0) batches.push(batch);
430
+ return batches;
431
+ }
432
+
410
433
  async getConfigForFile(filePath: string): Promise<unknown> {
411
434
  const resolvedFilePath = this.#resolveFilePath(filePath);
412
435
  if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
@@ -0,0 +1,280 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MaintainabilityBaseline } from "./maintainabilityBaseline";
3
+ import { type DebtRuleId, MaintainabilityContract, type MaintainabilityViolation } from "./maintainabilityContract";
4
+
5
+ const sizeOf = (key: string, lineCount: number): MaintainabilityViolation => ({
6
+ kind: "size",
7
+ rule: key.includes("#") ? "akan.size.component" : "akan.size.file",
8
+ key,
9
+ file: key.split("#")[0] ?? key,
10
+ line: 1,
11
+ lineCount,
12
+ maxLines: 400,
13
+ });
14
+ const writeOf = (key: string, writeCount = 2): MaintainabilityViolation => ({
15
+ kind: "multiWrite",
16
+ rule: "akan.store.multi-write",
17
+ key,
18
+ file: key.split("#")[0] ?? key,
19
+ line: 1,
20
+ owner: key.split("#")[1] ?? "",
21
+ writes: [],
22
+ writeCount,
23
+ });
24
+ const accessOf = (key: string, accessCount: number): MaintainabilityViolation => ({
25
+ kind: "storeAccess",
26
+ rule: "akan.component.store-access",
27
+ key,
28
+ file: key.split("#")[0] ?? key,
29
+ line: 1,
30
+ component: key.split("#")[1] ?? "",
31
+ accessCount,
32
+ maxAccesses: 16,
33
+ });
34
+ const placementOf = (file: string, writes: string[]): MaintainabilityViolation => ({
35
+ kind: "writePlacement",
36
+ rule: "akan.write.placement",
37
+ key: file,
38
+ file,
39
+ line: 1,
40
+ writes,
41
+ });
42
+ const baselineOf = (
43
+ sections: { [rule in DebtRuleId]?: Record<string, number> },
44
+ reasons: Record<string, string> = {},
45
+ ) =>
46
+ new MaintainabilityBaseline(
47
+ new Map(Object.entries(sections).map(([rule, counts]) => [rule as DebtRuleId, new Map(Object.entries(counts))])),
48
+ new Map(Object.entries(reasons)),
49
+ );
50
+
51
+ describe("MaintainabilityBaseline", () => {
52
+ test("keeps one section per rule the gate can fail, the growth check aside", () => {
53
+ expect(new Set(MaintainabilityBaseline.debtRules)).toEqual(
54
+ new Set(MaintainabilityContract.ruleIds.filter((rule) => rule !== "akan.baseline.growth")),
55
+ );
56
+ });
57
+
58
+ test("allows a recorded entry up to its count, writes included, and nothing it does not record", () => {
59
+ const baseline = MaintainabilityBaseline.parse(
60
+ JSON.stringify({
61
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900 },
62
+ "akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
63
+ "akan.write.placement": { "ui/Board.tsx": 1 },
64
+ }),
65
+ );
66
+ expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 900))).toBe(true);
67
+ expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 901))).toBe(false);
68
+ expect(baseline.allows(sizeOf("lib/b/B.Zone.tsx", 401))).toBe(false);
69
+ expect(baseline.allows(writeOf("lib/a/a.store.ts#run"))).toBe(true);
70
+ expect(baseline.allows(writeOf("lib/a/a.store.ts#run", 3))).toBe(false);
71
+ expect(baseline.allows(writeOf("lib/a/a.store.ts#other"))).toBe(false);
72
+ expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard"]))).toBe(true);
73
+ expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard", "fetch.placeBoard"]))).toBe(false);
74
+ });
75
+
76
+ test("records the busiest of several breaches under one key", () => {
77
+ const chainOf = (writeCount: number): MaintainabilityViolation => ({
78
+ kind: "multiWrite",
79
+ rule: "akan.component.write-chain",
80
+ key: "lib/a/A.Zone.tsx#Canvas",
81
+ file: "lib/a/A.Zone.tsx",
82
+ line: 1,
83
+ owner: "Canvas",
84
+ writes: [],
85
+ writeCount,
86
+ });
87
+ const baseline = MaintainabilityBaseline.of([chainOf(2), chainOf(4)]);
88
+ expect([...(baseline.sections.get("akan.component.write-chain") ?? [])]).toEqual([["lib/a/A.Zone.tsx#Canvas", 4]]);
89
+ });
90
+
91
+ test("tightens to what still breaks: lowers a shrunk entry, keeps a grown one, drops the rest", () => {
92
+ const baseline = baselineOf({
93
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/b/B.Zone.tsx": 700, "lib/gone/Gone.Zone.tsx": 600 },
94
+ "akan.store.multi-write": { "lib/a/a.store.ts#run": 3, "lib/a/a.store.ts#fixed": 2 },
95
+ });
96
+ const tightened = baseline.tightenedBy([
97
+ sizeOf("lib/a/A.Zone.tsx", 850),
98
+ sizeOf("lib/b/B.Zone.tsx", 720),
99
+ sizeOf("lib/c/C.Zone.tsx", 500),
100
+ writeOf("lib/a/a.store.ts#run", 2),
101
+ ]);
102
+ expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([
103
+ ["lib/a/A.Zone.tsx", 850],
104
+ ["lib/b/B.Zone.tsx", 700],
105
+ ]);
106
+ expect([...(tightened.sections.get("akan.store.multi-write") ?? [])]).toEqual([["lib/a/a.store.ts#run", 2]]);
107
+ expect(baseline.tightenedBy([]).isEmpty).toBe(true);
108
+ });
109
+
110
+ test("names every entry a working baseline added or raised over the reference one, per rule", () => {
111
+ const committed = baselineOf({
112
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900 },
113
+ "akan.store.multi-write": { "a.store.ts#run": 2 },
114
+ });
115
+ const working = baselineOf({
116
+ "akan.size.file": { "lib/a/A.Zone.tsx": 950, "lib/b/B.Zone.tsx": 500 },
117
+ "akan.store.multi-write": { "a.store.ts#run": 3, "a.store.ts#sneak": 2 },
118
+ });
119
+ expect(working.grownFrom(committed)).toEqual([
120
+ { rule: "akan.size.file", key: "lib/a/A.Zone.tsx", count: 950, committed: 900 },
121
+ { rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
122
+ { rule: "akan.store.multi-write", key: "a.store.ts#run", count: 3, committed: 2 },
123
+ { rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
124
+ ]);
125
+ expect(committed.tightenedBy([sizeOf("lib/a/A.Zone.tsx", 800)]).grownFrom(committed)).toEqual([]);
126
+ });
127
+
128
+ test("ratchets a component's store accesses like a size: allows up to its entry, lowers it, refuses a raise", () => {
129
+ const baseline = MaintainabilityBaseline.parse(
130
+ JSON.stringify({
131
+ "akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 40, "lib/gone/G.Zone.tsx#Old": 30 },
132
+ }),
133
+ );
134
+ expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 40))).toBe(true);
135
+ expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(false);
136
+ expect(baseline.allows(accessOf("lib/b/B.Zone.tsx#Board", 17))).toBe(false);
137
+ expect(baseline.recordedOf(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(40);
138
+
139
+ const tightened = baseline.tightenedBy([accessOf("lib/a/A.Zone.tsx#Canvas", 33)]);
140
+ expect([...(tightened.sections.get("akan.component.store-access") ?? [])]).toEqual([
141
+ ["lib/a/A.Zone.tsx#Canvas", 33],
142
+ ]);
143
+ expect(baseline.grownFrom(tightened)).toEqual([
144
+ { rule: "akan.component.store-access", key: "lib/a/A.Zone.tsx#Canvas", count: 40, committed: 33 },
145
+ { rule: "akan.component.store-access", key: "lib/gone/G.Zone.tsx#Old", count: 30, committed: undefined },
146
+ ]);
147
+ expect(tightened.grownFrom(baseline)).toEqual([]);
148
+ });
149
+
150
+ test("lets a shape entry grow only with a reason of ten non-space characters, a write entry never", () => {
151
+ const committed = baselineOf({
152
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900 },
153
+ "akan.store.multi-write": { "a.store.ts#run": 2 },
154
+ });
155
+ const reason = "The canvas is one pointer state machine.";
156
+ const working = baselineOf(
157
+ {
158
+ "akan.size.file": { "lib/a/A.Zone.tsx": 950, "ui/Board.tsx": 450, "lib/b/B.Zone.tsx": 500 },
159
+ "akan.size.component": { "lib/c/C.Zone.tsx#Grid": 300 },
160
+ "akan.component.store-access": { "lib/d/D.Zone.tsx#Canvas": 30 },
161
+ "akan.store.multi-write": { "a.store.ts#run": 2, "a.store.ts#sneak": 2 },
162
+ "akan.component.write-chain": { "lib/c/C.Zone.tsx#Grid": 2 },
163
+ "akan.write.placement": { "ui/Board.tsx": 1 },
164
+ "akan.service.call-in-loop": { "lib/e/e.service.ts#sync": 1 },
165
+ },
166
+ {
167
+ "lib/a/A.Zone.tsx": reason,
168
+ "ui/Board.tsx": reason,
169
+ "lib/b/B.Zone.tsx": "a b c d e f g h i",
170
+ "lib/c/C.Zone.tsx#Grid": reason,
171
+ "lib/d/D.Zone.tsx#Canvas": reason,
172
+ "a.store.ts#sneak": reason,
173
+ "lib/e/e.service.ts#sync": reason,
174
+ },
175
+ );
176
+
177
+ expect(working.grownFrom(committed)).toEqual([
178
+ { rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
179
+ { rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
180
+ { rule: "akan.component.write-chain", key: "lib/c/C.Zone.tsx#Grid", count: 2, committed: undefined },
181
+ { rule: "akan.write.placement", key: "ui/Board.tsx", count: 1, committed: undefined },
182
+ ]);
183
+ expect(working.reasonOf("lib/b/B.Zone.tsx")).toBeUndefined();
184
+ expect(working.reasonOf("a.store.ts#sneak")).toBeUndefined();
185
+ expect(working.reasonOf("lib/d/D.Zone.tsx#Canvas")).toBe(reason);
186
+ expect(working.reasonOf("lib/e/e.service.ts#sync")).toBe(reason);
187
+ });
188
+
189
+ test("keeps a reason only beside a shape entry it still describes", () => {
190
+ const reason = "The canvas is one pointer state machine.";
191
+ const baseline = baselineOf(
192
+ {
193
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/gone/Gone.Zone.tsx": 600 },
194
+ "akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 30 },
195
+ "akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
196
+ },
197
+ {
198
+ "lib/a/A.Zone.tsx": reason,
199
+ "lib/gone/Gone.Zone.tsx": reason,
200
+ "lib/a/A.Zone.tsx#Canvas": reason,
201
+ "lib/a/a.store.ts#run": reason,
202
+ "lib/never/Recorded.tsx": reason,
203
+ },
204
+ );
205
+
206
+ const tightened = baseline.tightenedBy([
207
+ sizeOf("lib/a/A.Zone.tsx", 850),
208
+ accessOf("lib/a/A.Zone.tsx#Canvas", 20),
209
+ writeOf("lib/a/a.store.ts#run"),
210
+ ]);
211
+
212
+ expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([["lib/a/A.Zone.tsx", 850]]);
213
+ expect([...tightened.reasons]).toEqual([
214
+ ["lib/a/A.Zone.tsx", reason],
215
+ ["lib/a/A.Zone.tsx#Canvas", reason],
216
+ ]);
217
+ expect(baseline.tightenedBy([]).reasons.size).toBe(0);
218
+ });
219
+
220
+ test("prints the rule sections in rule order with sorted keys, as Biome would, and reprints them unchanged", () => {
221
+ const baseline = MaintainabilityBaseline.of([
222
+ writeOf("lib/z/z.store.ts#run"),
223
+ sizeOf("lib/court/Court.Zone.tsx#Canvas", 640),
224
+ sizeOf("lib/court/Court.Zone.tsx", 947),
225
+ writeOf("lib/court/court.store.ts#assignCourtPcOnCourt", 3),
226
+ placementOf("ui/Board.tsx", ["st.do.removeBoard"]),
227
+ ]);
228
+ const text = baseline.stringify();
229
+ expect(text).toBe(`{
230
+ "akan.size.file": {
231
+ "lib/court/Court.Zone.tsx": 947
232
+ },
233
+ "akan.size.component": {
234
+ "lib/court/Court.Zone.tsx#Canvas": 640
235
+ },
236
+ "akan.store.multi-write": {
237
+ "lib/court/court.store.ts#assignCourtPcOnCourt": 3,
238
+ "lib/z/z.store.ts#run": 2
239
+ },
240
+ "akan.write.placement": {
241
+ "ui/Board.tsx": 1
242
+ }
243
+ }
244
+ `);
245
+ expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
246
+ });
247
+
248
+ test("prints the reasons last and only once there are some, reprinting them unchanged", () => {
249
+ const text = `{
250
+ "akan.size.file": {
251
+ "lib/a/A.Zone.tsx": 950,
252
+ "ui/Board.tsx": 450
253
+ },
254
+ "reasons": {
255
+ "lib/a/A.Zone.tsx": "The canvas is one pointer state machine.",
256
+ "ui/Board.tsx": "Generated SVG path table for the court layout."
257
+ }
258
+ }
259
+ `;
260
+ const reasonless = '{\n "akan.size.file": {\n "lib/a/A.Zone.tsx": 950\n }\n}\n';
261
+
262
+ expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
263
+ expect(MaintainabilityBaseline.parse(reasonless).stringify()).toBe(reasonless);
264
+ expect(MaintainabilityBaseline.parse('{ "reasons": {} }').stringify()).not.toContain("reasons");
265
+ });
266
+
267
+ test("refuses a file that is not the baseline shape, naming it", () => {
268
+ expect(() => MaintainabilityBaseline.parse("{", "apps/demo/akan.baseline.json")).toThrow(
269
+ "apps/demo/akan.baseline.json is not valid JSON.",
270
+ );
271
+ expect(() => MaintainabilityBaseline.parse("[]")).toThrow("holds an object");
272
+ expect(() => MaintainabilityBaseline.parse('{ "akan.size.file": { "a": "900" } }')).toThrow('"akan.size.file"');
273
+ expect(() => MaintainabilityBaseline.parse('{ "akan.store.multi-write": ["a#b"] }')).toThrow(
274
+ '"akan.store.multi-write"',
275
+ );
276
+ expect(() => MaintainabilityBaseline.parse('{ "maxLines": {} }')).toThrow("akan quality baseline --force");
277
+ expect(() => MaintainabilityBaseline.parse('{ "reasons": { "ui/Board.tsx": 1 } }')).toThrow('"reasons"');
278
+ expect(MaintainabilityBaseline.parse("{}").isEmpty).toBe(true);
279
+ });
280
+ });