@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.81

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 (172) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/agentsIndex.test.ts +10 -0
  5. package/agentsIndex.ts +47 -1
  6. package/aiEditor.ts +1 -1
  7. package/akanApp/BackendImportGraph.test.ts +120 -0
  8. package/akanApp/BackendImportGraph.ts +167 -0
  9. package/akanApp/akanApp.host.test.ts +16 -678
  10. package/akanApp/akanApp.host.ts +65 -562
  11. package/akanApp/devHostPolicy.test.ts +542 -0
  12. package/akanApp/devHostPolicy.ts +412 -0
  13. package/akanApp/index.ts +2 -0
  14. package/akanConfig/akanConfig.test.ts +182 -14
  15. package/akanConfig/akanConfig.ts +132 -47
  16. package/akanConfig/types.ts +8 -0
  17. package/akanContext.ts +70 -14
  18. package/akanMcpContract.ts +25 -4
  19. package/applicationBuildRunner.test.ts +1 -1
  20. package/applicationBuildRunner.ts +45 -21
  21. package/artifact/implicitRootLayout.test.ts +67 -0
  22. package/artifact/implicitRootLayout.ts +27 -7
  23. package/artifact/routeSeedIndex.test.ts +1 -0
  24. package/biome.base.json +340 -0
  25. package/biomeBase.ts +9 -0
  26. package/capacitorApp.ts +10 -0
  27. package/cloud/globalConfig.ts +9 -2
  28. package/commandDecorators/command.ts +30 -6
  29. package/commandDecorators/commandBuilder.ts +20 -5
  30. package/commandDecorators/commandDecorators.test.ts +115 -3
  31. package/dependencyScanner.test.ts +99 -0
  32. package/dependencyScanner.ts +27 -24
  33. package/devkitUtils.test.ts +0 -104
  34. package/executors.test.ts +132 -5
  35. package/executors.ts +89 -14
  36. package/fileEditor.ts +19 -19
  37. package/formSetterScanner.test.ts +80 -0
  38. package/formSetterScanner.ts +92 -0
  39. package/frontendBuild/autoImportSync.test.ts +58 -0
  40. package/frontendBuild/autoImportSync.ts +4 -0
  41. package/frontendBuild/buildRouteClient.test.ts +47 -24
  42. package/frontendBuild/clientBuildTypes.ts +4 -0
  43. package/frontendBuild/clientEntriesBundler.ts +4 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  45. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  46. package/frontendBuild/cssCompiler.ts +123 -12
  47. package/frontendBuild/cssImportResolver.ts +8 -7
  48. package/frontendBuild/fontOptimizer.ts +37 -18
  49. package/frontendBuild/fontPruner.test.ts +220 -0
  50. package/frontendBuild/fontPruner.ts +206 -0
  51. package/frontendBuild/frontendBuild.test.ts +178 -10
  52. package/frontendBuild/hmrWatcher.ts +1 -1
  53. package/frontendBuild/index.ts +1 -1
  54. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  55. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  56. package/frontendBuild/routeClientBuilder.ts +12 -5
  57. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  58. package/frontendBuild/styleContract.ts +14 -20
  59. package/frontendBuild/themeValidator.ts +22 -17
  60. package/frontendBuild/vendorSpecifiers.ts +1 -0
  61. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  62. package/incrementalBuilder/devWatchBatch.ts +6 -2
  63. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
  65. package/index.ts +0 -5
  66. package/integration/devStabilityHarness.ts +2 -10
  67. package/libSource.test.ts +109 -0
  68. package/libSource.ts +126 -0
  69. package/lint/__fixtures__/README.md +40 -0
  70. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  71. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  72. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  73. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  74. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  75. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  76. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  77. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  78. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  79. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  80. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  81. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  82. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  83. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  84. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  85. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  86. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  87. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  88. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  89. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  90. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  91. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  92. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  93. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  94. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  95. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  96. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  97. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  98. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  99. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  100. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  101. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  102. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  103. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  104. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  105. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  106. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  107. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  108. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  109. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  110. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  111. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  112. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  113. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  114. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  116. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  117. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  118. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  119. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  120. package/lint/gritRules.test.ts +178 -0
  121. package/lint/no-arbitrary-color.grit +1 -1
  122. package/lint/no-async-component-in-ui.grit +35 -0
  123. package/lint/no-bang-comment-in-client.grit +23 -10
  124. package/lint/no-daisyui-legacy-class.grit +26 -9
  125. package/lint/no-deprecated-log-level.grit +17 -0
  126. package/lint/no-import-client-in-server.grit +48 -0
  127. package/lint/no-import-server-in-client.grit +45 -0
  128. package/lint/no-init-fetch-in-client.grit +47 -0
  129. package/lint/no-inline-color.grit +10 -8
  130. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  131. package/lint/no-model-type-in-util-zone.grit +58 -0
  132. package/lint/no-raw-palette-class.grit +3 -3
  133. package/lint/no-unpublished-form-setter.grit +41 -0
  134. package/lint/non-scalar-props-restricted.grit +16 -7
  135. package/linter.test.ts +80 -0
  136. package/linter.ts +99 -25
  137. package/package.json +6 -6
  138. package/prompter.ts +9 -4
  139. package/qualityScanner.test.ts +116 -0
  140. package/qualityScanner.ts +89 -21
  141. package/recipeScanner.ts +4 -1
  142. package/repoIdentity.ts +42 -0
  143. package/scanInfo.ts +32 -26
  144. package/semver.test.ts +26 -0
  145. package/semver.ts +31 -0
  146. package/slicePlanner.test.ts +182 -0
  147. package/slicePlanner.ts +235 -0
  148. package/ssrScanner.test.ts +301 -0
  149. package/subspace.test.ts +418 -0
  150. package/subspace.ts +723 -0
  151. package/subspaceConfig.ts +76 -0
  152. package/transforms/asyncDefaultExportDetector.ts +103 -0
  153. package/transforms/barrelImportsPlugin.ts +1 -1
  154. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  155. package/transforms/transforms.test.ts +5 -5
  156. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  157. package/tsconfig.json +6 -2
  158. package/typeChecker.ts +1 -1
  159. package/types.ts +1 -0
  160. package/ui/ScrollList.tsx +6 -8
  161. package/uploadRelease.ts +2 -2
  162. package/workflow/executor.test.ts +146 -0
  163. package/workspaceLayout.test.ts +56 -4
  164. package/workspaceLayout.ts +49 -4
  165. package/builder.ts +0 -164
  166. package/extractDeps.ts +0 -86
  167. package/frontendBuild/styleGuard.test.ts +0 -165
  168. package/frontendBuild/styleGuard.ts +0 -322
  169. package/getCredentials.ts +0 -19
  170. package/getModelFileData.ts +0 -62
  171. package/src/capacitorApp.ts +0 -282
  172. package/streamAi.ts +0 -45
@@ -0,0 +1,301 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+ import { formatSsrBalance, SSR_SERVER_SHARE_TARGET, SsrScanner } from "./ssrScanner";
5
+
6
+ /**
7
+ * `SsrScanner` is a documented gate — `akan quality ssr` prints the server share and AGENTS.md names 50% as
8
+ * the floor — so both halves matter: a rule that stops firing lets the share rot, and a rule that
9
+ * over-reports is what makes developers stop reading the output. Every rule here gets a positive and the
10
+ * negative that must stay quiet.
11
+ */
12
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
13
+ file,
14
+ absolutePath: `/repo/${file}`,
15
+ content,
16
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
17
+ });
18
+
19
+ const rulesOf = (...files: SourceFileInfo[]) => new SsrScanner().scan(files).warnings.map((warning) => warning.rule);
20
+
21
+ const scanOne = (file: string, content: string) => rulesOf(fileOf(file, content));
22
+
23
+ const UI = "apps/demo/ui/Thing.tsx";
24
+
25
+ describe("akan.ssr.unnecessary-use-client", () => {
26
+ test("flags a directive with no client-only capability behind it", () => {
27
+ expect(scanOne(UI, `"use client";\nexport const Thing = () => <div />;\n`)).toContain(
28
+ "akan.ssr.unnecessary-use-client",
29
+ );
30
+ });
31
+
32
+ test("a hook, a handler, a store read and a browser global each justify the directive", () => {
33
+ const justified = [
34
+ `export const Thing = () => { const [open] = useState(false); return <div>{open}</div>; };`,
35
+ `export const Thing = () => <button onClick={save} />;`,
36
+ `export const Thing = () => <div>{st.use.task()}</div>;`,
37
+ `export const Thing = () => <div>{window.innerWidth}</div>;`,
38
+ ];
39
+ for (const body of justified)
40
+ expect(scanOne(UI, `"use client";\n${body}\n`)).not.toContain("akan.ssr.unnecessary-use-client");
41
+ });
42
+
43
+ test("a third-party import is a reason the AST cannot rule out", () => {
44
+ expect(scanOne(UI, `"use client";\nimport Chart from "recharts";\nexport const Thing = () => <div />;\n`)).toEqual(
45
+ [],
46
+ );
47
+ });
48
+
49
+ // `usePage`/`getSelf` read request-scoped server context, so they must not read as client evidence.
50
+ test("a server-safe call is not client evidence", () => {
51
+ expect(
52
+ scanOne(
53
+ UI,
54
+ `"use client";\nexport const Thing = () => { const { l } = usePage(); return <div>{l("a")}</div>; };\n`,
55
+ ),
56
+ ).toContain("akan.ssr.unnecessary-use-client");
57
+ });
58
+
59
+ test("importing st or fetch justifies the directive on its own", () => {
60
+ expect(
61
+ scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Thing = () => <div />;\n`),
62
+ ).toEqual([]);
63
+ });
64
+
65
+ // Zone/Template/Util and the lazy() boundary carry the directive by role, not by accident.
66
+ test("every convention client file is exempt", () => {
67
+ const body = `"use client";\nexport const Card = () => <div />;\n`;
68
+ const exempt = [
69
+ "apps/demo/lib/task/Task.Zone.tsx",
70
+ "apps/demo/lib/task/Task.Template.tsx",
71
+ "apps/demo/lib/task/Task.Util.tsx",
72
+ "apps/demo/ui/Chart/index_.tsx",
73
+ ];
74
+ for (const file of exempt) expect(scanOne(file, body)).toEqual([]);
75
+ });
76
+
77
+ test("a Unit or View in the same module is not exempt", () => {
78
+ const body = `"use client";\nexport const Card = () => <div />;\n`;
79
+ expect(scanOne("apps/demo/lib/task/Task.Unit.tsx", body)).toContain("akan.ssr.unnecessary-use-client");
80
+ expect(scanOne("apps/demo/lib/task/Task.View.tsx", body)).toContain("akan.ssr.unnecessary-use-client");
81
+ });
82
+
83
+ test("a Zone outside a module folder is not exempt", () => {
84
+ expect(scanOne("apps/demo/ui/Task.Zone.tsx", `"use client";\nexport const Zone = () => <div />;\n`)).toContain(
85
+ "akan.ssr.unnecessary-use-client",
86
+ );
87
+ });
88
+
89
+ test("the directive has to be the first statement to count as one", () => {
90
+ expect(
91
+ scanOne(UI, `import { cn } from "akanjs/client";\n"use client";\nexport const Thing = () => <div />;\n`),
92
+ ).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("akan.ssr.client-static-component", () => {
97
+ const four = "<div><span /><span /><span /></div>";
98
+
99
+ test("flags a static component once its subtree is worth moving", () => {
100
+ expect(
101
+ scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Card = () => ${four};\n`),
102
+ ).toContain("akan.ssr.client-static-component");
103
+ });
104
+
105
+ test("stays quiet below the mass floor", () => {
106
+ expect(
107
+ scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Card = () => <div><span /></div>;\n`),
108
+ ).toEqual([]);
109
+ });
110
+
111
+ test("a component with any client touch is not static", () => {
112
+ expect(
113
+ scanOne(UI, `"use client";\nexport const Card = () => <div onClick={save}><span /><span /><span /></div>;\n`),
114
+ ).not.toContain("akan.ssr.client-static-component");
115
+ });
116
+
117
+ // A vendor component's own tags may be client-only, so its subtree is not server-renderable markup.
118
+ test("a subtree rendering a vendor tag is exempt", () => {
119
+ expect(
120
+ scanOne(
121
+ UI,
122
+ `"use client";\nimport Chart from "recharts";\nexport const Card = () => <div><Chart /><span /><span /></div>;\n`,
123
+ ),
124
+ ).toEqual([]);
125
+ });
126
+
127
+ test("a lowercase binding is not a component", () => {
128
+ expect(scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const card = () => ${four};\n`)).toEqual(
129
+ [],
130
+ );
131
+ });
132
+ });
133
+
134
+ describe("akan.ssr.client-static-markup", () => {
135
+ const ten = `<div><p /><p /><p /><p /><p /><p /><p /><p /><button onClick={save} /></div>`;
136
+
137
+ test("flags a large subtree wrapping one interactive touch", () => {
138
+ expect(scanOne(UI, `"use client";\nexport const Panel = () => ${ten};\n`)).toContain(
139
+ "akan.ssr.client-static-markup",
140
+ );
141
+ });
142
+
143
+ test("stays quiet when the subtree is small enough that splitting buys nothing", () => {
144
+ expect(scanOne(UI, `"use client";\nexport const Panel = () => <div><button onClick={save} /></div>;\n`)).toEqual(
145
+ [],
146
+ );
147
+ });
148
+
149
+ test("stays quiet once the component is interactive throughout", () => {
150
+ const busy = `<div><button onClick={a} /><button onClick={b} /><button onClick={c} /><p /><p /><p /><p /><p /><p /></div>`;
151
+ expect(scanOne(UI, `"use client";\nexport const Panel = () => ${busy};\n`)).not.toContain(
152
+ "akan.ssr.client-static-markup",
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("akan.ssr.client-mount-load", () => {
158
+ const mount = (body: string) =>
159
+ `"use client";\nexport const Thing = () => { useEffect(() => { ${body} }, []); return <div />; };\n`;
160
+
161
+ test("flags a fetch and a generated store load fired from a mount-only effect", () => {
162
+ expect(scanOne(UI, mount("void fetch.initTaskInOrg(orgId);"))).toContain("akan.ssr.client-mount-load");
163
+ expect(scanOne(UI, mount("void st.do.initTaskInOrg();"))).toContain("akan.ssr.client-mount-load");
164
+ });
165
+
166
+ test("useLayoutEffect is the same finding", () => {
167
+ const body = `"use client";\nexport const Thing = () => { useLayoutEffect(() => { void fetch.viewTask(id); }, []); return <div />; };\n`;
168
+ expect(scanOne(UI, body)).toContain("akan.ssr.client-mount-load");
169
+ });
170
+
171
+ // A reactive effect responds to client state and has no server-side equivalent.
172
+ test("a non-empty dependency array is not a mount load", () => {
173
+ const body = `"use client";\nexport const Thing = () => { useEffect(() => { void fetch.viewTask(id); }, [id]); return <div />; };\n`;
174
+ expect(scanOne(UI, body)).not.toContain("akan.ssr.client-mount-load");
175
+ });
176
+
177
+ test("a mount effect that loads nothing is not a finding", () => {
178
+ expect(scanOne(UI, mount("subscribe();"))).not.toContain("akan.ssr.client-mount-load");
179
+ });
180
+
181
+ test("a store action that is not a load is not a finding", () => {
182
+ expect(scanOne(UI, mount("void st.do.openModal();"))).not.toContain("akan.ssr.client-mount-load");
183
+ });
184
+ });
185
+
186
+ describe("akan.ssr.template-client-state", () => {
187
+ const template = "apps/demo/lib/task/Task.Template.tsx";
188
+
189
+ test("flags useState in a Template", () => {
190
+ const body = `"use client";\nexport const General = () => { const [draft, setDraft] = useState(""); return <input value={draft} />; };\n`;
191
+ expect(scanOne(template, body)).toContain("akan.ssr.template-client-state");
192
+ });
193
+
194
+ test("the same useState in a Zone is not a finding", () => {
195
+ const body = `"use client";\nexport const Card = () => { const [open, setOpen] = useState(false); return <div>{open}</div>; };\n`;
196
+ expect(scanOne("apps/demo/lib/task/Task.Zone.tsx", body)).not.toContain("akan.ssr.template-client-state");
197
+ });
198
+
199
+ test("a store-driven Template is clean", () => {
200
+ const body = `"use client";\nexport const General = () => <input value={st.use.taskForm().name} onChange={st.do.setNameOnTask} />;\n`;
201
+ expect(scanOne(template, body)).toEqual([]);
202
+ });
203
+ });
204
+
205
+ describe("akan.ssr.module-missing-server-view", () => {
206
+ const clientMass = `<div><p /><p /><p /><p /><p /><p /><p /><p /><p /><p /><p /></div>`;
207
+
208
+ test("flags a module that renders only from client files", () => {
209
+ const zone = fileOf(
210
+ "apps/demo/lib/task/Task.Zone.tsx",
211
+ `"use client";\nexport const Card = () => ${clientMass};\n`,
212
+ );
213
+ expect(rulesOf(zone)).toContain("akan.ssr.module-missing-server-view");
214
+ });
215
+
216
+ test("one Unit or View in the module answers it", () => {
217
+ const zone = fileOf(
218
+ "apps/demo/lib/task/Task.Zone.tsx",
219
+ `"use client";\nexport const Card = () => ${clientMass};\n`,
220
+ );
221
+ const unit = fileOf("apps/demo/lib/task/Task.Unit.tsx", `export const Card = () => <div />;\n`);
222
+ expect(rulesOf(zone, unit)).not.toContain("akan.ssr.module-missing-server-view");
223
+ });
224
+
225
+ test("a module too small to matter is not a finding", () => {
226
+ const zone = fileOf(
227
+ "apps/demo/lib/task/Task.Zone.tsx",
228
+ `"use client";\nexport const Card = () => <div><p /></div>;\n`,
229
+ );
230
+ expect(rulesOf(zone)).not.toContain("akan.ssr.module-missing-server-view");
231
+ });
232
+
233
+ // `lib/_<service>` folders are service modules, which own no model to render server-side.
234
+ test("a service module is not held to it", () => {
235
+ const zone = fileOf(
236
+ "apps/demo/lib/_upload/Upload.Zone.tsx",
237
+ `"use client";\nexport const Card = () => ${clientMass};\n`,
238
+ );
239
+ expect(rulesOf(zone)).not.toContain("akan.ssr.module-missing-server-view");
240
+ });
241
+ });
242
+
243
+ describe("scan scope", () => {
244
+ test("only apps/libs ui and lib .tsx files are measured", () => {
245
+ const outOfScope = [
246
+ "apps/demo/page/task/_index.tsx",
247
+ "apps/demo/webkit/useThing.tsx",
248
+ "apps/demo/ui/Thing.test.tsx",
249
+ "pkgs/akanjs/ui/Thing.tsx",
250
+ "apps/demo/ui/thing.ts",
251
+ ];
252
+ for (const file of outOfScope)
253
+ expect(new SsrScanner().scan([fileOf(file, `"use client";\nexport const Thing = () => <div />;\n`)])).toEqual({
254
+ warnings: [],
255
+ balance: [],
256
+ });
257
+ });
258
+ });
259
+
260
+ describe("balance", () => {
261
+ test("measures the share in JSX elements and not in files", () => {
262
+ const server = fileOf("apps/demo/ui/Server.tsx", `export const A = () => <div><p /><p /></div>;\n`);
263
+ const client = fileOf("apps/demo/ui/Client.tsx", `"use client";\nexport const B = () => <div onClick={a} />;\n`);
264
+ const [entry] = new SsrScanner().scan([server, client]).balance;
265
+ expect(entry).toEqual({ scope: "apps/demo", serverMass: 3, clientMass: 1, serverShare: 0.75 });
266
+ });
267
+
268
+ test("adds a workspace row only once a second scope exists", () => {
269
+ const demo = fileOf("apps/demo/ui/A.tsx", `export const A = () => <div />;\n`);
270
+ const shared = fileOf("libs/shared/ui/B.tsx", `"use client";\nexport const B = () => <div onClick={a} />;\n`);
271
+ expect(new SsrScanner().scan([demo]).balance.map((entry) => entry.scope)).toEqual(["apps/demo"]);
272
+ expect(new SsrScanner().scan([demo, shared]).balance.map((entry) => entry.scope)).toEqual([
273
+ "apps/demo",
274
+ "libs/shared",
275
+ "workspace",
276
+ ]);
277
+ });
278
+
279
+ // An empty scope reads as fully server-rendered rather than as a division by zero.
280
+ test("a scope with no JSX at all is 100% server", () => {
281
+ const empty = fileOf("apps/demo/ui/Empty.tsx", `export const value = 1;\n`);
282
+ expect(new SsrScanner().scan([empty]).balance[0].serverShare).toBe(1);
283
+ });
284
+ });
285
+
286
+ describe("formatSsrBalance", () => {
287
+ test("flags a scope under the target and leaves one above it unmarked", () => {
288
+ const lines = formatSsrBalance([
289
+ { scope: "apps/low", serverMass: 1, clientMass: 9, serverShare: 0.1 },
290
+ { scope: "apps/high", serverMass: 9, clientMass: 1, serverShare: 0.9 },
291
+ ]);
292
+ expect(lines[0]).toContain("10% server");
293
+ expect(lines[0]).toContain(`below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target`);
294
+ expect(lines[1]).toContain("90% server");
295
+ expect(lines[1]).not.toContain("below the");
296
+ });
297
+
298
+ test("says so rather than printing an empty table", () => {
299
+ expect(formatSsrBalance([])).toEqual(["No component files found."]);
300
+ });
301
+ });