@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,130 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SizeViolation } from "./maintainabilityContract";
3
+ import { MaintainabilityReport } from "./maintainabilityReport";
4
+
5
+ const fileOf = (file: string, lineCount = 450): SizeViolation => ({
6
+ kind: "size",
7
+ rule: "akan.size.file",
8
+ key: file,
9
+ file,
10
+ line: 1,
11
+ lineCount,
12
+ maxLines: 400,
13
+ });
14
+
15
+ const fixLinesOf = (message: string) => message.split("How to fix:\n")[1]?.split("\n") ?? [];
16
+
17
+ describe("MaintainabilityReport", () => {
18
+ test("gives a client file, a ui/ or webkit/ file, a long component and a busy one the same ordered paths", () => {
19
+ const message = MaintainabilityReport.describe("apps/demo", [
20
+ fileOf("lib/court/Court.Zone.tsx"),
21
+ fileOf("ui/Board.tsx"),
22
+ fileOf("webkit/useBoard.tsx"),
23
+ {
24
+ ...fileOf("lib/court/Court.Zone.tsx", 300),
25
+ rule: "akan.size.component",
26
+ key: "lib/court/Court.Zone.tsx#Canvas",
27
+ line: 4,
28
+ maxLines: 250,
29
+ component: "Canvas",
30
+ },
31
+ {
32
+ kind: "storeAccess",
33
+ rule: "akan.component.store-access",
34
+ key: "lib/court/Court.Zone.tsx#Canvas",
35
+ file: "lib/court/Court.Zone.tsx",
36
+ line: 4,
37
+ component: "Canvas",
38
+ accessCount: 20,
39
+ maxAccesses: 16,
40
+ },
41
+ ]);
42
+ const [fix = "", ...rest] = fixLinesOf(message);
43
+ const order = ["mode/kind table", "webkit/use<X>.tsx", "`common/`", "add-mutation", "`lib/<sub>`"].map((path) =>
44
+ fix.indexOf(path),
45
+ );
46
+
47
+ expect(rest).toEqual([]);
48
+ expect(fix).toStartWith(
49
+ " *.Zone.tsx, ui/**, webkit/**, component or hook, component or hook reaching the store often: ",
50
+ );
51
+ expect(order.every((idx) => idx > 0)).toBe(true);
52
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
53
+ expect(fix).toContain('`{ [kind in cnst.X["value"]]: … }`');
54
+ expect(fix).toContain("akan workflow plan create-interaction");
55
+ expect(fix).toContain("never model-bound code moved into `ui/`");
56
+ });
57
+
58
+ test("names a write sent from the wrong place, a call per item and a raised entry with their own fixes", () => {
59
+ const message = MaintainabilityReport.describe("apps/demo", [
60
+ {
61
+ kind: "baselineGrowth",
62
+ rule: "akan.baseline.growth",
63
+ key: "lib/court/court.store.ts#assign",
64
+ file: "akan.baseline.json",
65
+ line: 5,
66
+ debtRule: "akan.store.multi-write",
67
+ count: 3,
68
+ committed: 2,
69
+ reference: "3f2a9c1d0b7e",
70
+ },
71
+ {
72
+ kind: "writePlacement",
73
+ rule: "akan.write.placement",
74
+ key: "webkit/useBoard.tsx",
75
+ file: "webkit/useBoard.tsx",
76
+ line: 7,
77
+ writes: ["st.do.removeBoard", "fetch.placeBoard"],
78
+ },
79
+ {
80
+ kind: "loopCall",
81
+ rule: "akan.service.call-in-loop",
82
+ key: "lib/court/court.service.ts#syncAll",
83
+ file: "lib/court/court.service.ts",
84
+ line: 12,
85
+ owner: "syncAll",
86
+ calls: ["this.pcService.getPc"],
87
+ recorded: 0,
88
+ },
89
+ {
90
+ kind: "multiWrite",
91
+ rule: "akan.component.write-chain",
92
+ key: "webkit/useBoard.tsx#useBoard",
93
+ file: "webkit/useBoard.tsx",
94
+ line: 9,
95
+ owner: "useBoard",
96
+ writes: ["st.do.removeBoard (per item)"],
97
+ writeCount: 2,
98
+ },
99
+ ]);
100
+
101
+ expect(message).toContain(
102
+ 'akan.baseline.growth entry "lib/court/court.store.ts#assign" under akan.store.multi-write allows 3 writes, above the 2 at 3f2a9c1d0b7e',
103
+ );
104
+ expect(message).toContain(
105
+ "akan.write.placement sends 2 server write(s) from outside a store action or a module component: st.do.removeBoard, fetch.placeBoard",
106
+ );
107
+ expect(message).toContain(
108
+ "akan.service.call-in-loop service method syncAll calls once per item: this.pcService.getPc, more than the 0 recorded",
109
+ );
110
+ expect(message).toContain("hook useBoard sends a write per item from one handler: st.do.removeBoard (per item)");
111
+ expect(fixLinesOf(message).map((line) => line.split(":")[0])).toEqual([
112
+ " baseline entry",
113
+ " write sent outside a store action or module component",
114
+ " service call per item",
115
+ " handler with several writes",
116
+ ]);
117
+ });
118
+
119
+ test("keeps the server files' own fixes", () => {
120
+ const message = MaintainabilityReport.describe("apps/demo", [
121
+ fileOf("lib/court/Court.View.tsx"),
122
+ fileOf("lib/court/court.service.ts", 520),
123
+ ]);
124
+
125
+ expect(fixLinesOf(message)).toEqual([
126
+ " *.View.tsx: Move model-independent pieces into ui/ components and display logic onto Light<Model>.",
127
+ " *.service.ts: Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
128
+ ]);
129
+ });
130
+ });
@@ -0,0 +1,153 @@
1
+ import { AbstractDoc } from "./abstractDoc";
2
+ import type {
3
+ DebtRuleId,
4
+ MaintainabilityCommands,
5
+ MaintainabilityFailure,
6
+ MaintainabilityRuleId,
7
+ } from "./maintainabilityContract";
8
+ import { type FileKind, MaintainabilityLimits } from "./maintainabilityLimits";
9
+
10
+ type FixKey = FileKind | Exclude<MaintainabilityRuleId, "akan.size.file">;
11
+
12
+ export class MaintainabilityReport {
13
+ static readonly #clientSplitFix =
14
+ 'Split it, in this order: a mode/kind table `{ [kind in cnst.X["value"]]: … }` in place of mode flags, each entry a component of the module owning the data of that kind; an interaction hook in `webkit/use<X>.tsx` with its pure logic in `common/` (`akan workflow plan create-interaction`); the writes behind one endpoint (`akan workflow plan add-mutation`); last, a sub-concept in its own module (`lib/<sub>` or `lib/_<feature>`), never model-bound code moved into `ui/`.';
15
+ static readonly #serverMarkupFix =
16
+ "Move model-independent pieces into ui/ components and display logic onto Light<Model>.";
17
+ static readonly #oneEndpointFix =
18
+ "Make it one service method that runs the writes inside `this.transaction(fn)`, with a side effect such as a push command in `this.afterCommit(fn)`, behind one endpoint the action calls once; a write per item becomes one endpoint taking the list. An MCP agent or another screen calling one endpoint alone skips the rest.";
19
+ static readonly #fixes: { [key in FixKey]: string } = {
20
+ ".Template.tsx": MaintainabilityReport.#clientSplitFix,
21
+ ".Unit.tsx": MaintainabilityReport.#serverMarkupFix,
22
+ ".Util.tsx": MaintainabilityReport.#clientSplitFix,
23
+ ".View.tsx": MaintainabilityReport.#serverMarkupFix,
24
+ ".Zone.tsx": MaintainabilityReport.#clientSplitFix,
25
+ "ui/": MaintainabilityReport.#clientSplitFix,
26
+ "webkit/": MaintainabilityReport.#clientSplitFix,
27
+ ".store.ts":
28
+ "Move business logic into the service behind an endpoint and derived values onto Light<Model>; an action is fetch, set, toast.",
29
+ ".document.ts":
30
+ "Move display and predicate logic onto Light<Model>, and split an unrelated concept into its own module.",
31
+ ".service.ts":
32
+ "Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
33
+ [AbstractDoc.suffix]:
34
+ "Trim the abstract to a title, one sentence naming what the module owns, and a `## Rules` list of the invariants the code cannot show.",
35
+ "akan.size.component": MaintainabilityReport.#clientSplitFix,
36
+ "akan.store.multi-write": MaintainabilityReport.#oneEndpointFix,
37
+ "akan.store.root-write-chain": `A store may call another through \`RootStore\`, but that does not make the writes one. ${MaintainabilityReport.#oneEndpointFix}`,
38
+ "akan.component.write-chain":
39
+ "Move the writes into one service method behind one endpoint, called by one store action the handler calls once; a component chaining `st.do` writes, or writing once per item, leaves an agent or another screen half-done.",
40
+ "akan.component.store-access": MaintainabilityReport.#clientSplitFix,
41
+ "akan.write.placement":
42
+ "A server write leaves the browser through one store action, started by one module component. Move a `fetch.<mutation>` into a store action, and hand `ui/` and `webkit/` code the write as an `onX` prop or hook argument from the module's `Zone` or `Util`: they stay free of any one model, and an agent or another screen reaches the write through the store.",
43
+ "akan.service.call-in-loop":
44
+ "Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (`create<Model>Many(rows)`, one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
45
+ "akan.baseline.growth":
46
+ "Restore the entry from the default branch, then split the file or move the writes instead: `akan lint` lowers an entry as the code shrinks and never raises one.",
47
+ };
48
+
49
+ static readonly #labels: { [key in FixKey]?: string } = {
50
+ "ui/": "ui/**",
51
+ "webkit/": "webkit/**",
52
+ "akan.size.component": "component or hook",
53
+ "akan.store.multi-write": "store action with several writes",
54
+ "akan.store.root-write-chain": "store method chaining other stores' writes",
55
+ "akan.component.write-chain": "handler with several writes",
56
+ "akan.component.store-access": "component or hook reaching the store often",
57
+ "akan.write.placement": "write sent outside a store action or module component",
58
+ "akan.service.call-in-loop": "service call per item",
59
+ "akan.baseline.growth": "baseline entry",
60
+ };
61
+
62
+ static readonly #units: { [rule in DebtRuleId]: string } = {
63
+ "akan.size.file": "lines",
64
+ "akan.size.component": "lines",
65
+ "akan.component.store-access": "store accesses",
66
+ "akan.store.multi-write": "writes",
67
+ "akan.store.root-write-chain": "writes",
68
+ "akan.component.write-chain": "writes",
69
+ "akan.write.placement": "writes",
70
+ "akan.service.call-in-loop": "calls per item",
71
+ };
72
+
73
+ static describe(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
74
+ const groups = new Map<string, { labels: string[]; rules: Set<MaintainabilityRuleId> }>();
75
+ for (const failure of failures) {
76
+ const fixKey = MaintainabilityReport.#fixKeyOf(failure);
77
+ const fix = MaintainabilityReport.#fixes[fixKey];
78
+ const group = groups.get(fix) ?? { labels: [], rules: new Set() };
79
+ const label = MaintainabilityReport.#labelOf(fixKey);
80
+ if (!group.labels.includes(label)) group.labels.push(label);
81
+ group.rules.add(failure.rule);
82
+ groups.set(fix, group);
83
+ }
84
+ return [
85
+ `[maintainability] ${failures.length} violation(s) in ${sysPath}:`,
86
+ ...failures.map(
87
+ (failure) =>
88
+ ` ${sysPath}/${failure.file}:${failure.line} ${failure.rule} ${MaintainabilityReport.#problemOf(failure)}`,
89
+ ),
90
+ "How to fix:",
91
+ ...[...groups].flatMap(([fix, { labels, rules }]) => [
92
+ ` ${labels.join(", ")}: ${fix}`,
93
+ ...[...new Set([...rules].flatMap((rule) => commands[rule] ?? []))].map((command) => ` Path: ${command}`),
94
+ ]),
95
+ ].join("\n");
96
+ }
97
+
98
+ static entriesOf(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
99
+ return failures.map((failure) => ({
100
+ file: `${sysPath}/${failure.file}`,
101
+ line: failure.line,
102
+ rule: failure.rule,
103
+ problem: MaintainabilityReport.#problemOf(failure),
104
+ fix: MaintainabilityReport.#fixes[MaintainabilityReport.#fixKeyOf(failure)],
105
+ path: commands[failure.rule]?.[0] ?? null,
106
+ }));
107
+ }
108
+
109
+ static #problemOf(failure: MaintainabilityFailure) {
110
+ if (failure.kind === "baselineGrowth") {
111
+ const entry = `entry "${failure.key}" under ${failure.debtRule}`;
112
+ return failure.committed === undefined
113
+ ? `${entry} is not in the baseline at ${failure.reference}, which may only shrink`
114
+ : `${entry} allows ${failure.count} ${MaintainabilityReport.#units[failure.debtRule]}, above the ${failure.committed} at ${failure.reference}; the baseline may only shrink`;
115
+ }
116
+ const grown = failure.recorded === undefined ? "" : `, more than the ${failure.recorded} recorded`;
117
+ if (failure.kind === "multiWrite") {
118
+ const writes = failure.writes.some((write) => write.endsWith("(per item)"))
119
+ ? "a write per item"
120
+ : `${failure.writes.length} writes`;
121
+ const listed = `${failure.writes.join(", ")}${grown}`;
122
+ if (failure.rule === "akan.component.write-chain")
123
+ return `${MaintainabilityReport.#unitOf(failure.owner)} ${failure.owner} sends ${writes} from one handler: ${listed}`;
124
+ if (failure.rule === "akan.store.root-write-chain")
125
+ return `store method ${failure.owner} chains ${writes} through other stores from the browser: ${listed}`;
126
+ return `store action ${failure.owner} sends ${writes} from the browser: ${listed}`;
127
+ }
128
+ if (failure.kind === "writePlacement")
129
+ return `sends ${failure.writes.length} server write(s) from outside a store action or a module component: ${failure.writes.join(", ")}${grown}`;
130
+ if (failure.kind === "loopCall")
131
+ return `service method ${failure.owner} calls once per item: ${failure.calls.join(", ")}${grown}`;
132
+ if (failure.kind === "storeAccess")
133
+ return `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} reaches the store ${failure.accessCount} times, over the ${failure.maxAccesses}-access limit${failure.recorded === undefined ? "" : `, up from ${failure.recorded}`}`;
134
+ const subject = failure.component
135
+ ? `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} has`
136
+ : "file has";
137
+ const up = failure.recorded === undefined ? "" : `, up from ${failure.recorded}`;
138
+ return `${subject} ${failure.lineCount} lines, over the ${failure.maxLines}-line limit${up}`;
139
+ }
140
+
141
+ static #unitOf(name: string) {
142
+ return /^use[A-Z]/.test(name) ? "hook" : "component";
143
+ }
144
+
145
+ static #fixKeyOf(failure: MaintainabilityFailure): FixKey {
146
+ if (failure.rule !== "akan.size.file") return failure.rule;
147
+ return MaintainabilityLimits.fileKindOf(failure.file) ?? "akan.size.component";
148
+ }
149
+
150
+ static #labelOf(fixKey: FixKey) {
151
+ return MaintainabilityReport.#labels[fixKey] ?? `*${fixKey}`;
152
+ }
153
+ }
@@ -381,7 +381,7 @@ describe("DesktopRelease signer", () => {
381
381
 
382
382
  const windowsTool = (powershell: (out: string) => Promise<number>) =>
383
383
  recordingTool(async (cmd, env) => {
384
- if (cmd[0] === "tar.exe") {
384
+ if (cmd[0] === path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe")) {
385
385
  await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
386
386
  return { code: 0, output: "" };
387
387
  }
@@ -245,7 +245,9 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
245
245
  }
246
246
 
247
247
  static async #subjectOf(run: SignatureTool, archive: string, tmp: string, exe: string) {
248
- await run(["tar.exe", "-xzf", archive, "-C", tmp, exe]);
248
+ //? Windows' own bsdtar: a GNU tar ahead of it on PATH (Git's) takes `C:` for a remote host.
249
+ const tar = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
250
+ await run([tar, "-xzf", archive, "-C", tmp, exe]);
249
251
  const extracted = path.join(tmp, exe);
250
252
  if (!(await Bun.file(extracted).exists())) return { signer: null, unread: `the archive holds no ${exe}` };
251
253
  const out = path.join(tmp, "signer.txt");
@@ -145,6 +145,7 @@ describe("DesktopServerStage", () => {
145
145
  ];
146
146
  const imageOnly = [
147
147
  "Dockerfile",
148
+ "akan.env.json",
148
149
  "package.json",
149
150
  "rscWorker.js",
150
151
  "console.js",
@@ -2,6 +2,7 @@ import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { toError } from "akanjs/common";
4
4
  import type { ServerPolicy } from "akanjs/native/api";
5
+ import { BUILD_ENV_FILE } from "akanjs/server/lifecycle/buildEnv";
5
6
  import type { AkanAppConfig, NativeEnv } from "../akanConfig";
6
7
  import type { App } from "../commandDecorators";
7
8
  import type { PackageJson } from "../types";
@@ -26,9 +27,10 @@ export interface DesktopServerRun extends ServerPolicy {
26
27
  export class DesktopServerStage {
27
28
  //? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR
28
29
  //? artifact, and `install` wrote its own package.json and bun.lock. The rest of dist is the server's: its chunks,
29
- //? a `.node`, a `.wasm`.
30
+ //? a `.node`, a `.wasm`. The build's env file would hand it the PORT and AKAN_LOG_TO_FILE its launcher leaves out.
30
31
  static readonly uncarried = new Set([
31
32
  "Dockerfile",
33
+ BUILD_ENV_FILE,
32
34
  "package.json",
33
35
  "bun.lock",
34
36
  "rscWorker.js",
@@ -313,18 +313,33 @@ describe("NativeApp", () => {
313
313
  expect(await injected()).toContain('"indexPath":"/mobile"');
314
314
  });
315
315
 
316
- test("builds its config from the app's locales and the plugins' native contributions", async () => {
316
+ test("builds its config from the app's locales, the plugins' native contributions and the inlined public env", async () => {
317
317
  const push: AkanPlugin = { name: "push-notification", native: { permission: "push", plugins: ["push"] } };
318
- const app = new NativeApp(
319
- fakeApp([push, { name: "other" }]),
320
- target({ basePath: undefined, permissions: ["push"] }),
321
- );
318
+ const root = await makeTempRoot();
319
+ const portal = { ...fakeApp([push, { name: "other" }]), dist: { cwdPath: path.join(root, "dist/apps/portal") } };
320
+ await CsrArtifactBuilder.writePublicEnv(portal as unknown as App, {
321
+ "process.env.AKAN_PUBLIC_APP_NAME": '"portal"',
322
+ "process.env.AKAN_PUBLIC_ENV": '"main"',
323
+ "process.env.AKAN_PUBLIC_SERVE_DOMAIN": '"akamir.com"',
324
+ "process.env.AKAN_PUBLIC_SERVER_URL": '"http://192.168.0.5:8080/api"',
325
+ });
326
+ const app = new NativeApp(portal as unknown as App, target({ basePath: undefined, permissions: ["push"] }));
322
327
 
323
328
  const { config, warnings } = await app.config({ platform: "android" });
324
329
 
325
330
  expect(warnings).toEqual([]);
326
331
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
327
332
  expect(config.web.dir).toBe(path.join(nativeDir, "default/web"));
333
+ const csp = config.security?.csp as Record<string, string[]> | undefined;
334
+ expect(csp?.["connect-src"]).toEqual([
335
+ "'self'",
336
+ "blob:",
337
+ "data:",
338
+ "https:",
339
+ "wss:",
340
+ "http://192.168.0.5:8080",
341
+ "ws://192.168.0.5:8080",
342
+ ]);
328
343
  });
329
344
 
330
345
  test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
@@ -124,10 +124,11 @@ export class NativeApp {
124
124
  stageBin?: boolean;
125
125
  arch?: DesktopArch;
126
126
  }) {
127
- const [appConfig, plugins, nativePlugins] = await Promise.all([
127
+ const [appConfig, plugins, nativePlugins, publicEnv] = await Promise.all([
128
128
  this.app.getConfig(),
129
129
  this.app.collectPlugins(),
130
130
  NativePluginFolders.of(this.app),
131
+ NativePublicEnv.inlined(this.app),
131
132
  ]);
132
133
  const desktop = platform === "macos" || platform === "windows" || platform === "linux";
133
134
  const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir, arch) : [];
@@ -137,9 +138,11 @@ export class NativeApp {
137
138
  webDir: this.web.dir,
138
139
  contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
139
140
  locales: appConfig.i18n.locales,
141
+ i18n: appConfig.i18n,
140
142
  env: this.env,
141
143
  platform,
142
144
  nativePlugins,
145
+ publicEnv,
143
146
  ...(server ? { desktopServer: server } : {}),
144
147
  ...(carried.length ? { desktopBin: this.binDir } : {}),
145
148
  });