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

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 (174) 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/fleetConfig.ts +73 -0
  38. package/fleetGuard.test.ts +121 -0
  39. package/fleetGuard.ts +157 -0
  40. package/fleetSpoke.test.ts +390 -0
  41. package/fleetSpoke.ts +719 -0
  42. package/formSetterScanner.test.ts +80 -0
  43. package/formSetterScanner.ts +92 -0
  44. package/frontendBuild/autoImportSync.test.ts +58 -0
  45. package/frontendBuild/autoImportSync.ts +4 -0
  46. package/frontendBuild/buildRouteClient.test.ts +47 -24
  47. package/frontendBuild/clientBuildTypes.ts +4 -0
  48. package/frontendBuild/clientEntriesBundler.ts +4 -1
  49. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  50. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  51. package/frontendBuild/cssCompiler.ts +123 -12
  52. package/frontendBuild/cssImportResolver.ts +8 -7
  53. package/frontendBuild/fontOptimizer.ts +37 -18
  54. package/frontendBuild/fontPruner.test.ts +220 -0
  55. package/frontendBuild/fontPruner.ts +206 -0
  56. package/frontendBuild/frontendBuild.test.ts +178 -10
  57. package/frontendBuild/hmrWatcher.ts +1 -1
  58. package/frontendBuild/index.ts +1 -1
  59. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  60. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  61. package/frontendBuild/routeClientBuilder.ts +12 -5
  62. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  63. package/frontendBuild/styleContract.ts +14 -20
  64. package/frontendBuild/themeValidator.ts +22 -17
  65. package/frontendBuild/vendorSpecifiers.ts +1 -0
  66. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  67. package/incrementalBuilder/devWatchBatch.ts +6 -2
  68. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  69. package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
  70. package/index.ts +0 -5
  71. package/integration/devStabilityHarness.ts +2 -10
  72. package/libSource.test.ts +109 -0
  73. package/libSource.ts +126 -0
  74. package/lint/__fixtures__/README.md +40 -0
  75. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  76. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  77. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  78. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  79. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  80. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  81. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  82. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  83. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  84. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  85. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  86. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  87. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  88. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  89. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  90. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  91. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  92. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  93. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  95. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  96. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  97. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  98. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  99. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  100. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  101. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  102. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  103. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  104. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  105. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  106. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  107. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  108. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  110. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  111. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  112. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  113. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  114. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  115. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  116. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  117. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  118. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  119. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  120. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  121. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  122. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  123. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  124. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  125. package/lint/gritRules.test.ts +178 -0
  126. package/lint/no-arbitrary-color.grit +1 -1
  127. package/lint/no-async-component-in-ui.grit +35 -0
  128. package/lint/no-bang-comment-in-client.grit +23 -10
  129. package/lint/no-daisyui-legacy-class.grit +26 -9
  130. package/lint/no-deprecated-log-level.grit +17 -0
  131. package/lint/no-import-client-in-server.grit +48 -0
  132. package/lint/no-import-server-in-client.grit +45 -0
  133. package/lint/no-init-fetch-in-client.grit +47 -0
  134. package/lint/no-inline-color.grit +10 -8
  135. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  136. package/lint/no-model-type-in-util-zone.grit +58 -0
  137. package/lint/no-raw-palette-class.grit +3 -3
  138. package/lint/no-unpublished-form-setter.grit +41 -0
  139. package/lint/non-scalar-props-restricted.grit +16 -7
  140. package/linter.test.ts +80 -0
  141. package/linter.ts +99 -25
  142. package/package.json +6 -6
  143. package/prompter.ts +9 -4
  144. package/qualityScanner.test.ts +116 -0
  145. package/qualityScanner.ts +89 -21
  146. package/recipeScanner.ts +4 -1
  147. package/repoIdentity.ts +42 -0
  148. package/scanInfo.ts +32 -26
  149. package/semver.test.ts +26 -0
  150. package/semver.ts +31 -0
  151. package/slicePlanner.test.ts +151 -0
  152. package/slicePlanner.ts +157 -0
  153. package/ssrScanner.test.ts +301 -0
  154. package/transforms/asyncDefaultExportDetector.ts +103 -0
  155. package/transforms/barrelImportsPlugin.ts +1 -1
  156. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  157. package/transforms/transforms.test.ts +5 -5
  158. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  159. package/tsconfig.json +6 -2
  160. package/typeChecker.ts +1 -1
  161. package/types.ts +1 -0
  162. package/ui/ScrollList.tsx +6 -8
  163. package/uploadRelease.ts +2 -2
  164. package/workflow/executor.test.ts +146 -0
  165. package/workspaceLayout.test.ts +56 -4
  166. package/workspaceLayout.ts +49 -4
  167. package/builder.ts +0 -164
  168. package/extractDeps.ts +0 -86
  169. package/frontendBuild/styleGuard.test.ts +0 -165
  170. package/frontendBuild/styleGuard.ts +0 -322
  171. package/getCredentials.ts +0 -19
  172. package/getModelFileData.ts +0 -62
  173. package/src/capacitorApp.ts +0 -282
  174. package/streamAi.ts +0 -45
@@ -0,0 +1,157 @@
1
+ import type { AppExecutor } from "./executors";
2
+ import { AppInfo } from "./scanInfo";
3
+ import type { PackageJson } from "./types";
4
+
5
+ export interface SlicePlan {
6
+ app: string;
7
+ /** Transitive lib closure, in mount order. */
8
+ libs: string[];
9
+ appFiles: string[];
10
+ libFiles: Record<string, string[]>;
11
+ rootFiles: string[];
12
+ /** Root manifest for a workspace holding only this slice. */
13
+ packageJson: PackageJson;
14
+ /** Root dependencies nothing in the slice imports — prune candidates for a human, never pruned here. */
15
+ unusedDependencies: string[];
16
+ warnings: string[];
17
+ }
18
+
19
+ /**
20
+ * The exact file set a single app needs to live in a workspace of its own: the app, its transitive lib
21
+ * closure, the workspace shell around them, and a root manifest for the result.
22
+ *
23
+ * Consumed by `akan plan-slice`, and by anything that moves an app or a lib between workspaces.
24
+ */
25
+ export class SlicePlanner {
26
+ /** Root entries owned by an app, a lib or an in-tree package rather than by the workspace shell. */
27
+ static readonly memberDirs = ["apps", "libs", "pkgs"];
28
+ /** Never reported unused: the toolchain a workspace needs whether or not app code imports it. */
29
+ static readonly toolchainDependencies = [
30
+ "@akanjs/cli",
31
+ "@akanjs/devkit",
32
+ "@biomejs/biome",
33
+ "@types/bun",
34
+ "akanjs",
35
+ "typescript",
36
+ ];
37
+
38
+ #app: AppExecutor;
39
+ constructor(app: AppExecutor) {
40
+ this.#app = app;
41
+ }
42
+
43
+ async #trackedFiles(paths: string[]) {
44
+ return await this.#app.workspace.listGitFiles(paths);
45
+ }
46
+
47
+ async #untrackedFiles(paths: string[]) {
48
+ const [tracked, all] = await Promise.all([
49
+ this.#app.workspace.listGitFiles(paths),
50
+ this.#app.workspace.listGitFiles(paths, { untracked: true }),
51
+ ]);
52
+ const trackedSet = new Set(tracked);
53
+ return all.filter((file) => !trackedSet.has(file));
54
+ }
55
+
56
+ #isMemberFile(file: string) {
57
+ return SlicePlanner.memberDirs.includes(file.split("/")[0] ?? "");
58
+ }
59
+
60
+ static #requiredDependencies(appInfo: AppInfo) {
61
+ const scanResults = [
62
+ appInfo.getScanResult(),
63
+ ...[...appInfo.getLibInfos().values()].map((lib) => lib.getScanResult()),
64
+ ];
65
+ return new Set([
66
+ ...SlicePlanner.toolchainDependencies,
67
+ ...scanResults.flatMap((scanResult) => [
68
+ ...scanResult.dependencies,
69
+ ...scanResult.devDependencies,
70
+ ...scanResult.pkgDeps,
71
+ ]),
72
+ ]);
73
+ }
74
+
75
+ async #patchWarnings(packageJson: PackageJson) {
76
+ const patchedDependencies = (packageJson.patchedDependencies ?? {}) as Record<string, string>;
77
+ const missing = (
78
+ await Promise.all(
79
+ Object.entries(patchedDependencies).map(async ([spec, patchPath]) =>
80
+ (await this.#app.workspace.exists(patchPath)) ? null : `${spec} -> ${patchPath}`,
81
+ ),
82
+ )
83
+ ).filter((entry): entry is string => !!entry);
84
+ return missing.length ? [`patchedDependencies references a missing patch file: ${missing.join(", ")}`] : [];
85
+ }
86
+
87
+ async plan(): Promise<SlicePlan> {
88
+ const appInfo = await AppInfo.fromExecutor(this.#app);
89
+ const libs = appInfo.getLibs();
90
+ const slicePaths = [`apps/${this.#app.name}`, ...libs.map((lib) => `libs/${lib}`)];
91
+
92
+ const [appFiles, allTracked, untracked, rootPackageJson] = await Promise.all([
93
+ this.#trackedFiles([`apps/${this.#app.name}`]),
94
+ this.#trackedFiles(["."]),
95
+ this.#untrackedFiles(slicePaths),
96
+ this.#app.workspace.getPackageJson(),
97
+ ]);
98
+
99
+ const libFiles = Object.fromEntries(
100
+ await Promise.all(libs.map(async (lib) => [lib, await this.#trackedFiles([`libs/${lib}`])] as const)),
101
+ );
102
+ const rootFiles = allTracked.filter((file) => !this.#isMemberFile(file));
103
+
104
+ const required = SlicePlanner.#requiredDependencies(appInfo);
105
+ const rootDependencies = { ...rootPackageJson.dependencies, ...rootPackageJson.devDependencies };
106
+ const unusedDependencies = Object.keys(rootDependencies)
107
+ .filter((dep) => !required.has(dep))
108
+ .sort();
109
+
110
+ //* `workspaces` names in-tree packages, and a slice never carries `pkgs/` — it consumes akanjs from
111
+ //* the registry. Dependencies are copied whole rather than pruned: the root of a hub is a superset of
112
+ //* every slice, so carrying it always installs, while a wrong prune produces a repo that does not.
113
+ const { workspaces: _workspaces, ...slicePackageJson } = rootPackageJson;
114
+
115
+ const warnings = [
116
+ ...(untracked.length
117
+ ? [`${untracked.length} untracked file(s) under the slice paths: ${untracked.join(", ")}`]
118
+ : []),
119
+ ...(rootPackageJson.workspaces ? ["root `workspaces` dropped — a slice consumes akanjs from the registry"] : []),
120
+ ...(await this.#patchWarnings(rootPackageJson)),
121
+ ];
122
+
123
+ return {
124
+ app: this.#app.name,
125
+ libs,
126
+ appFiles,
127
+ libFiles,
128
+ rootFiles,
129
+ packageJson: slicePackageJson as PackageJson,
130
+ unusedDependencies,
131
+ warnings,
132
+ };
133
+ }
134
+ }
135
+
136
+ /** Top-level entry each path sits under, so a long tree (`infra/**`) reads as one line. */
137
+ const rootEntriesOf = (rootFiles: string[]) => [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
138
+
139
+ export function formatSlicePlan(plan: SlicePlan) {
140
+ const libCounts = Object.entries(plan.libFiles).map(([lib, files]) => ` - libs/${lib}: ${files.length} files`);
141
+ const sections = [
142
+ "Akan Slice Plan",
143
+ `app: ${plan.app} (apps/${plan.app}: ${plan.appFiles.length} files)`,
144
+ `libs: ${plan.libs.length ? plan.libs.join(", ") : "(none)"}`,
145
+ ...libCounts,
146
+ `workspace shell: ${plan.rootFiles.length} files across ${rootEntriesOf(plan.rootFiles).join(", ")}`,
147
+ "",
148
+ `Unused root dependencies (${plan.unusedDependencies.length}) — prune candidates, not pruned:`,
149
+ "",
150
+ ...(plan.unusedDependencies.length ? plan.unusedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
151
+ "",
152
+ `Warnings (${plan.warnings.length}):`,
153
+ "",
154
+ ...(plan.warnings.length ? plan.warnings.map((warning) => ` - ${warning}`) : [" (none)"]),
155
+ ];
156
+ return sections.join("\n");
157
+ }
@@ -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
+ });
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type ts from "typescript";
4
+
5
+ type TypeScript = typeof ts;
6
+
7
+ export class AsyncDefaultExportDetector {
8
+ static #typescriptLoad: Promise<TypeScript> | undefined;
9
+
10
+ // `typescript` costs ~70 MB resident and this detector is reached from the cli entry through the root
11
+ // layout generator, so the compiler loads on first use rather than at import (`entryModuleGraph.test.ts`).
12
+ static #loadTypescript(): Promise<TypeScript> {
13
+ AsyncDefaultExportDetector.#typescriptLoad ??= import("typescript").then(
14
+ (mod) => (mod.default ?? mod) as TypeScript,
15
+ );
16
+ return AsyncDefaultExportDetector.#typescriptLoad;
17
+ }
18
+
19
+ static async detect(moduleAbsPath: string): Promise<boolean> {
20
+ try {
21
+ const typescript = await AsyncDefaultExportDetector.#loadTypescript();
22
+ const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
23
+ const sourceFile = typescript.createSourceFile(
24
+ moduleAbsPath,
25
+ source,
26
+ typescript.ScriptTarget.Latest,
27
+ true,
28
+ AsyncDefaultExportDetector.#scriptKind(typescript, moduleAbsPath),
29
+ );
30
+ return new AsyncDefaultExportDetector(typescript).detectInSourceFile(sourceFile);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ #ts: TypeScript;
37
+
38
+ constructor(typescript: TypeScript) {
39
+ this.#ts = typescript;
40
+ }
41
+
42
+ detectInSourceFile(sourceFile: ts.SourceFile): boolean {
43
+ const ts = this.#ts;
44
+ const asyncBindings = new Map<string, boolean>();
45
+ let defaultIdentifier: string | null = null;
46
+
47
+ for (const statement of sourceFile.statements) {
48
+ if (ts.isFunctionDeclaration(statement)) {
49
+ if (this.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
50
+ return this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
51
+ }
52
+ if (statement.name) {
53
+ asyncBindings.set(statement.name.text, this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword));
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (ts.isVariableStatement(statement)) {
59
+ for (const declaration of statement.declarationList.declarations) {
60
+ if (!ts.isIdentifier(declaration.name)) continue;
61
+ asyncBindings.set(declaration.name.text, this.#isAsyncFunctionExpression(declaration.initializer));
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (ts.isExportAssignment(statement)) {
67
+ if (this.#isAsyncFunctionExpression(statement.expression)) return true;
68
+ if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
69
+ continue;
70
+ }
71
+
72
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
73
+ const exportClause = statement.exportClause;
74
+ for (const specifier of exportClause.elements) {
75
+ if (specifier.name.text !== "default") continue;
76
+ defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
77
+ }
78
+ }
79
+ }
80
+
81
+ return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
82
+ }
83
+
84
+ #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
85
+ const ts = this.#ts;
86
+ return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
87
+ }
88
+
89
+ #isAsyncFunctionExpression(node?: ts.Expression): boolean {
90
+ const ts = this.#ts;
91
+ return Boolean(
92
+ node &&
93
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
94
+ this.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
95
+ );
96
+ }
97
+
98
+ static #scriptKind(typescript: TypeScript, moduleAbsPath: string): ts.ScriptKind {
99
+ return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx")
100
+ ? typescript.ScriptKind.TSX
101
+ : typescript.ScriptKind.TS;
102
+ }
103
+ }
@@ -223,7 +223,7 @@ const resolveNodePackageExport = async (workspaceRoot: string, specifier: string
223
223
  const subpath = specifier === packageName ? "." : `.${specifier.slice(packageName.length)}`;
224
224
  const exported = resolvePackageExport(pkgJson.exports, subpath);
225
225
  const rel = exported ?? (subpath === "." ? (pkgJson.module ?? pkgJson.main ?? "index.js") : null);
226
- if (!rel || !rel.startsWith(".")) return null;
226
+ if (!rel?.startsWith(".")) return null;
227
227
  const entryFile = await resolveFileCandidate(path.resolve(pkgDir, rel));
228
228
  if (!entryFile) return null;
229
229
  const pkgEntryName = specifier;
@@ -65,7 +65,6 @@ const DEFAULT_EXCLUDE_PREFIX = ["@akanjs/cli/", "@akanjs/devkit/"];
65
65
  const OPTIONAL_BACKEND_EXTERNAL_EXACT = new Set<string>([
66
66
  "@libsql/client",
67
67
  "bullmq",
68
- "croner",
69
68
  "ioredis",
70
69
  "postgres",
71
70
  "protobufjs",
@@ -104,7 +104,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
104
104
  ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
105
105
  ["Bee", { subpath: "@scope/pkg/leaf", originalName: "B" }],
106
106
  ]),
107
- } as BarrelAnalyzer;
107
+ } as unknown as BarrelAnalyzer;
108
108
 
109
109
  const rewritten = await rewriteBarrelImports(
110
110
  'import DefaultExport, { A, Bee as LocalBee, type Shape, Missing } from "@scope/pkg";\nconsole.log(A);',
@@ -128,7 +128,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
128
128
  test("preserves generated client barrel side effects when flattening app client imports", async () => {
129
129
  const analyzer = {
130
130
  analyze: async () => new Map([["st", { subpath: "@apps/demo/lib/st", originalName: "st" }]]),
131
- } as BarrelAnalyzer;
131
+ } as unknown as BarrelAnalyzer;
132
132
 
133
133
  const rewritten = await rewriteBarrelImports(
134
134
  'import { st } from "@apps/demo/client";\nvoid st;\n',
@@ -144,7 +144,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
144
144
  test("does not rewrite import-looking code inside template literals", async () => {
145
145
  const analyzer = {
146
146
  analyze: async () => new Map([["AkanApp", { subpath: "akanjs/server/akanApp", originalName: "AkanApp" }]]),
147
- } as BarrelAnalyzer;
147
+ } as unknown as BarrelAnalyzer;
148
148
 
149
149
  const source = [
150
150
  'import { Code } from "@apps/docs/ui";',
@@ -170,7 +170,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
170
170
  ["AkanOption", { subpath: "akanjs/server/akanOption", originalName: "AkanOption" }],
171
171
  ["Try", { subpath: "akanjs/server/decorators", originalName: "Try" }],
172
172
  ]),
173
- } as BarrelAnalyzer;
173
+ } as unknown as BarrelAnalyzer;
174
174
 
175
175
  const rewritten = await rewriteBarrelImports(
176
176
  'import { AkanOption, Try } from "akanjs/server";\nexport const option = new AkanOption();\n',
@@ -191,7 +191,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
191
191
  const analyzer = {
192
192
  analyze: async () =>
193
193
  new Map([["BottomInset", { subpath: "akanjs/ui/Layout/BottomInset", originalName: "BottomInset" }]]),
194
- } as BarrelAnalyzer;
194
+ } as unknown as BarrelAnalyzer;
195
195
 
196
196
  const rewritten = await rewriteBarrelImports('import { BottomInset } from "akanjs/ui";\n', ["akanjs/ui"], analyzer);
197
197