@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
package/executors.test.ts CHANGED
@@ -31,7 +31,6 @@ const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
31
31
  react: "19.0.0",
32
32
  "react-dom": "19.0.0",
33
33
  "react-server-dom-webpack": "19.0.0",
34
- sharp: "1.0.0",
35
34
  lodash: "4.0.0",
36
35
  },
37
36
  devDependencies: {
@@ -128,6 +127,49 @@ describe("Executor filesystem helpers", () => {
128
127
  expect(await readFile(path.join(root, "local/docker-compose.yaml"), "utf8")).toBe("custom");
129
128
  });
130
129
 
130
+ test("hands every scaffolded TypeScript file to the formatter, and nothing else", async () => {
131
+ // A template emits identifiers it cannot sort — `import { fetch, Task, usePage }` is right for a model
132
+ // named Task and wrong for Zoo — and unsorted imports fail `biome check`. So the scaffold is formatted
133
+ // on the way out, or it is red for most model names whatever the template says.
134
+ const root = await makeTempRoot();
135
+ const exec = new Executor("fixture", root);
136
+ const formatted: string[] = [];
137
+ exec.getLinter = () =>
138
+ ({
139
+ fixFiles: async (filePaths: string[]) => {
140
+ formatted.push(...filePaths);
141
+ return { fixed: [] };
142
+ },
143
+ }) as unknown as ReturnType<Executor["getLinter"]>;
144
+
145
+ await exec.applyTemplate({
146
+ basePath: "apps/demo/page/task",
147
+ template: "crudSinglePage",
148
+ dict: { model: "task", appName: "demo" },
149
+ });
150
+
151
+ expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
152
+ });
153
+
154
+ test("a formatter that cannot run does not fail the scaffold", async () => {
155
+ // `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary and often
156
+ // no config above the target. An unformatted file is a lint fix; a failed scaffold is not.
157
+ const root = await makeTempRoot();
158
+ const exec = new Executor("fixture", root);
159
+ exec.getLinter = () => {
160
+ throw new Error("biome.json not found");
161
+ };
162
+
163
+ const created = await exec.applyTemplate({
164
+ basePath: "apps/demo/page/task",
165
+ template: "crudSinglePage",
166
+ dict: { model: "task", appName: "demo" },
167
+ });
168
+
169
+ expect(created).toHaveLength(1);
170
+ expect(await readFile(path.join(root, "apps/demo/page/task/_index.tsx"), "utf8")).toContain("Task.Zone.Card");
171
+ });
172
+
131
173
  test("applies hidden files and directories from CLI templates", async () => {
132
174
  const root = await makeTempRoot();
133
175
  const exec = new Executor("fixture", root);
@@ -149,8 +191,10 @@ describe("Executor filesystem helpers", () => {
149
191
  "AI Development Guide",
150
192
  );
151
193
  expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
194
+ // Rules and their plugin registrations live in the package's base config, so a framework release reaches an
195
+ // existing workspace on `bun update` — the workspace file only extends it and scopes its own files.
152
196
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
153
- "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
197
+ '"extends": ["@akanjs/devkit/biome.base.json"]',
154
198
  );
155
199
  });
156
200
 
@@ -202,6 +246,7 @@ describe("Workspace and app executor environment contracts", () => {
202
246
  serveDomain: "example.com",
203
247
  env: "local",
204
248
  portOffset: 10,
249
+ workspaceId: undefined,
205
250
  });
206
251
 
207
252
  delete process.env.AKAN_PUBLIC_REPO_NAME;
@@ -232,6 +277,7 @@ describe("Workspace and app executor environment contracts", () => {
232
277
  serveDomain: "file.example.com",
233
278
  env: "develop",
234
279
  portOffset: 7,
280
+ workspaceId: undefined,
235
281
  });
236
282
  });
237
283
 
@@ -620,6 +666,64 @@ describe("Workspace and app executor environment contracts", () => {
620
666
  });
621
667
  });
622
668
 
669
+ describe("root layout source validation during page key discovery", () => {
670
+ const makeRouteValidationApp = async (appName: string, config: string, files: Record<string, string>) => {
671
+ const root = await makeTempRoot();
672
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
673
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
674
+ process.env.AKAN_PUBLIC_ENV = "local";
675
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
676
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
677
+ await writeFile(path.join(root, `apps/${appName}/akan.config.ts`), config);
678
+ for (const [file, source] of Object.entries(files)) {
679
+ const filePath = path.join(root, `apps/${appName}/page`, file);
680
+ await mkdir(path.dirname(filePath), { recursive: true });
681
+ await writeFile(filePath, source);
682
+ }
683
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
684
+ return AppExecutor.from(workspace, appName);
685
+ };
686
+ const wsConnectLayout = [
687
+ "export const wsConnect = false;",
688
+ "export default function Layout({ children }) { return children; }",
689
+ "",
690
+ ].join("\n");
691
+
692
+ test("accepts wsConnect on a configured base-path root layout", async () => {
693
+ process.env.AKAN_PUBLIC_BASE_PATHS = "web,admin";
694
+ const app = await makeRouteValidationApp(
695
+ "base-path-root-ws",
696
+ [
697
+ "export default {",
698
+ ' routes: [{ basePath: "web", domains: {} }, { basePath: "admin", domains: {} }],',
699
+ "};",
700
+ "",
701
+ ].join("\n"),
702
+ { "admin/_layout.tsx": wsConnectLayout },
703
+ );
704
+
705
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./admin/_layout.tsx"]);
706
+ });
707
+
708
+ test("rejects wsConnect on a nested layout", async () => {
709
+ const app = await makeRouteValidationApp(
710
+ "nested-layout-ws",
711
+ 'export default { routes: [{ basePath: "admin", domains: {} }] };\n',
712
+ { "admin/users/_layout.tsx": wsConnectLayout },
713
+ );
714
+
715
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(/unsupported export "wsConnect"/);
716
+ });
717
+
718
+ test("accepts wsConnect on a grouped root layout", async () => {
719
+ const app = await makeRouteValidationApp("grouped-root-ws", "export default {};\n", {
720
+ "(docs)/_layout.tsx": wsConnectLayout,
721
+ });
722
+
723
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./(docs)/_layout.tsx"]);
724
+ });
725
+ });
726
+
623
727
  test("accepts metadata route exports during page key discovery", async () => {
624
728
  const root = await makeTempRoot();
625
729
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
@@ -758,6 +862,29 @@ describe("Workspace and app executor environment contracts", () => {
758
862
  expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
759
863
  expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
760
864
  });
865
+
866
+ test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
867
+ const root = await makeTempRoot();
868
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
869
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
870
+ process.env.AKAN_PUBLIC_ENV = "local";
871
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
872
+ await mkdir(path.join(root, "apps/ambient"), { recursive: true });
873
+ await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
874
+
875
+ const originalNodeEnv = process.env.NODE_ENV;
876
+ process.env.NODE_ENV = "production";
877
+ try {
878
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
879
+ const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
880
+ expect(started.env.NODE_ENV).toBe("development");
881
+ // The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
882
+ expect(process.env.NODE_ENV).toBe("development");
883
+ } finally {
884
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
885
+ else process.env.NODE_ENV = originalNodeEnv;
886
+ }
887
+ });
761
888
  });
762
889
 
763
890
  describe("PkgExecutor package generation", () => {
@@ -771,7 +898,7 @@ describe("PkgExecutor package generation", () => {
771
898
  exports: { "./extra": { import: "./extra.ts" } },
772
899
  peerDependencies: { react: "19.0.0" },
773
900
  peerDependenciesMeta: { react: { optional: true } },
774
- optionalDependencies: { sharp: "1.0.0" },
901
+ optionalDependencies: { "@sample/native": "1.0.0" },
775
902
  });
776
903
 
777
904
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -781,12 +908,12 @@ describe("PkgExecutor package generation", () => {
781
908
  expect(distPackageJson).toMatchObject({
782
909
  name: "@sample/tool",
783
910
  type: "module",
784
- engines: { bun: ">=1.3.13" },
911
+ engines: { bun: ">=1.4.0" },
785
912
  dependencies: { lodash: "4.0.0" },
786
913
  devDependencies: { typescript: "6.0.0" },
787
914
  peerDependencies: { react: "19.0.0" },
788
915
  peerDependenciesMeta: { react: { optional: true } },
789
- optionalDependencies: { sharp: "1.0.0" },
916
+ optionalDependencies: { "@sample/native": "1.0.0" },
790
917
  });
791
918
  expect(distPackageJson.exports?.["."]).toEqual({
792
919
  import: "./index.ts",
package/executors.ts CHANGED
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
23
23
  import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
24
24
  import {
25
25
  capitalize,
26
+ getPageSourceFileViolation,
26
27
  isRouteSourceFile,
27
28
  Logger,
28
29
  parseRouteModuleKey,
@@ -39,9 +40,11 @@ import {
39
40
  upsertAgentBlock,
40
41
  } from "./agentsIndex";
41
42
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
43
+ import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
42
44
  import { FileSys } from "./fileSys";
43
45
  import { getDirname } from "./getDirname";
44
46
  import { Linter } from "./linter";
47
+ import { resolveRepoName } from "./repoIdentity";
45
48
  import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
46
49
  import { Spinner } from "./spinner";
47
50
  // Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
@@ -232,7 +235,7 @@ export class Executor {
232
235
  );
233
236
  });
234
237
  proc.on("exit", (code, signal) => {
235
- if (!!code || signal)
238
+ if (code || signal)
236
239
  reject(
237
240
  new CommandExecutionError({
238
241
  command,
@@ -341,7 +344,7 @@ export class Executor {
341
344
  );
342
345
  });
343
346
  proc.on("exit", (code, signal) => {
344
- if (!!code || signal)
347
+ if (code || signal)
345
348
  reject(
346
349
  new CommandExecutionError({
347
350
  command: modulePath,
@@ -429,7 +432,9 @@ export class Executor {
429
432
  const writePath = this.getPath(filePath);
430
433
  const dir = path.dirname(writePath);
431
434
  if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
432
- let contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
435
+ //? Biome formats every tracked .json and always ends a file with a newline, so a JSON write without one
436
+ //? loses a byte to `akan lint` and takes it back on the next `akan sync` — a permanent one-line git diff.
437
+ let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
433
438
 
434
439
  if (await FileSys.fileExists(writePath)) {
435
440
  const currentContent = await FileSys.readText(writePath);
@@ -685,7 +690,30 @@ export class Executor {
685
690
  Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
686
691
  ),
687
692
  };
688
- return this._applyTemplate({ ...options, dict });
693
+ const fileContents = await this._applyTemplate({ ...options, dict });
694
+ await this.#formatAppliedTemplate(fileContents);
695
+ return fileContents;
696
+ }
697
+
698
+ /**
699
+ * A template emits identifiers it cannot sort. `import { fetch, Task, usePage }` is correctly ordered for
700
+ * a model named Task and wrong for one named Zoo, and `organizeImports` fails `biome check` — so a
701
+ * scaffold that is not formatted on the way out is red for most model names, whatever the template says.
702
+ *
703
+ * Best-effort: `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary
704
+ * and often no config above the target yet. An unformatted file is a lint fix; a failed scaffold is not.
705
+ */
706
+ async #formatAppliedTemplate(fileContents: FileContent[]) {
707
+ const filePaths = fileContents
708
+ .map((fileContent) => fileContent.filePath)
709
+ .filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
710
+ if (filePaths.length === 0) return;
711
+ try {
712
+ const { fixed } = await this.getLinter().fixFiles(filePaths);
713
+ if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
714
+ } catch (err) {
715
+ this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
716
+ }
689
717
  }
690
718
  // Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
691
719
  // not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
@@ -788,7 +816,7 @@ export class WorkspaceExecutor extends Executor {
788
816
  static #execs = new Map<string, WorkspaceExecutor>();
789
817
  static fromRoot({
790
818
  workspaceRoot = process.cwd(),
791
- repoName = path.basename(process.cwd()),
819
+ repoName = resolveRepoName(workspaceRoot),
792
820
  }: {
793
821
  workspaceRoot?: string;
794
822
  repoName?: string;
@@ -908,9 +936,30 @@ export class WorkspaceExecutor extends Executor {
908
936
  return await getDirs(basePath);
909
937
  }
910
938
  async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
911
- if (init) await this.exec(`git init --quiet`);
912
- if (add) await this.exec(`git add .`);
913
- await this.exec(`git commit --quiet -m "${message}"`);
939
+ if (init) await this.spawn("git", ["init", "--quiet"]);
940
+ if (add) await this.spawn("git", ["add", "."]);
941
+ // Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
942
+ await this.spawn("git", ["commit", "--quiet", "-m", message]);
943
+ }
944
+ /** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
945
+ async hasChanges() {
946
+ return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
947
+ }
948
+ /**
949
+ * Workspace-relative paths git knows about, sorted. `untracked` adds files that exist but are not
950
+ * committed yet, still honoring `.gitignore` — which is what a freshly copied library looks like.
951
+ *
952
+ * Reading the file set from git is what keeps generated barrels, the `page/**` and `public/libs`
953
+ * symlinks, env values and the lockfile out of it without any caller restating that list.
954
+ */
955
+ async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
956
+ if (!paths.length) return [];
957
+ const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
958
+ const stdout = await this.spawn("git", ["ls-files", "-z", ...mode, "--", ...paths]);
959
+ return stdout
960
+ .split("\0")
961
+ .filter((file) => !!file)
962
+ .sort();
914
963
  }
915
964
  async #getDirHasFile(basePath: string, targetFilename: string) {
916
965
  const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
@@ -1005,6 +1054,16 @@ export class SysExecutor extends Executor {
1005
1054
  const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
1006
1055
  return await this.workspace.getDirInModule(path, this.name);
1007
1056
  }
1057
+ async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
1058
+ if (!(await this.exists("page"))) return [];
1059
+ const violations: { relativePath: string; reason: string }[] = [];
1060
+ for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
1061
+ const relativePath = entry.split(path.sep).join("/");
1062
+ const reason = getPageSourceFileViolation(relativePath);
1063
+ if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
1064
+ }
1065
+ return violations;
1066
+ }
1008
1067
 
1009
1068
  #scanInfo: AppInfo | LibInfo | null = null;
1010
1069
  hasScanInfo() {
@@ -1354,15 +1413,30 @@ export class AppExecutor extends SysExecutor {
1354
1413
  //* generated against and typechecked. Drop the cache so the build phases re-read it without them.
1355
1414
  this.#excludeDevOnlyPages = true;
1356
1415
  this.#pageKeys = null;
1357
- if (await this.exists(this.dist.cwdPath)) await this.dist.exec(`rm -rf ${this.dist.cwdPath}`);
1416
+ await this.dist.removeDir(this.dist.cwdPath);
1358
1417
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1359
1418
  //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1360
1419
  //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1420
+ //* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
1361
1421
  await Promise.all([
1362
1422
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1363
- this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1423
+ ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1364
1424
  ]);
1365
- } else await this.removeDir(".akan");
1425
+ } else {
1426
+ await this.removeDir(".akan");
1427
+ //? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
1428
+ //? builder is also the file watcher, so switching it off would take server-code HMR with it.
1429
+ if (!akanConfig.web.ssr || !akanConfig.web.csr)
1430
+ this.logger.verbose(
1431
+ `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1432
+ );
1433
+ }
1434
+ //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1435
+ //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1436
+ //? and a dev server that believes it is production serves from the production route cache, whose every
1437
+ //? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
1438
+ //? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
1439
+ if (type === "start") process.env.NODE_ENV = "development";
1366
1440
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1367
1441
  const env = this.getCommandEnv({
1368
1442
  AKAN_COMMAND_TYPE: type,
@@ -1451,7 +1525,8 @@ export class AppExecutor extends SysExecutor {
1451
1525
  );
1452
1526
  }
1453
1527
  if (!owner) owners.set(routeId, { absPath, fromLib });
1454
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1528
+ const layoutSegments = getRootBoundarySegments(key);
1529
+ const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
1455
1530
  const routeSource = await Bun.file(absPath).text();
1456
1531
  const validator = await AppExecutor.#getRouteSourceValidator();
1457
1532
  if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
@@ -1700,7 +1775,7 @@ export class LibExecutor extends SysExecutor {
1700
1775
  #akanConfig: AkanLibConfig | null = null;
1701
1776
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1702
1777
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1703
- this.#akanConfig = await AkanLibConfig.from(this);
1778
+ this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
1704
1779
  return this.#akanConfig;
1705
1780
  }
1706
1781
  }
@@ -1806,7 +1881,7 @@ export class PkgExecutor extends Executor {
1806
1881
  default: "./index.ts",
1807
1882
  },
1808
1883
  },
1809
- engines: { bun: ">=1.3.13" },
1884
+ engines: { bun: ">=1.4.0" },
1810
1885
  ...dependencyMaps,
1811
1886
  };
1812
1887
  await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
package/fileEditor.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  export class FileEditor {
2
- private filePath: string;
3
- private content: string;
2
+ #filePath: string;
3
+ #content: string;
4
4
 
5
5
  private constructor(filePath: string, content: string) {
6
- this.filePath = filePath;
7
- this.content = content;
6
+ this.#filePath = filePath;
7
+ this.#content = content;
8
8
  }
9
9
 
10
10
  static async create(filePath: string): Promise<FileEditor> {
11
11
  try {
12
12
  const content = await Bun.file(filePath).text();
13
13
  return new FileEditor(filePath, content);
14
- } catch (error) {
14
+ } catch (_error) {
15
15
  throw new Error(`Failed to read file: ${filePath}`);
16
16
  }
17
17
  }
18
18
 
19
19
  find(pattern: string | RegExp): number {
20
- const lines = this.content.split("\n");
20
+ const lines = this.#content.split("\n");
21
21
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
22
22
 
23
23
  for (let i = 0; i < lines.length; i++) {
@@ -30,7 +30,7 @@ export class FileEditor {
30
30
  }
31
31
 
32
32
  findAll(pattern: string | RegExp): number[] {
33
- const lines = this.content.split("\n");
33
+ const lines = this.#content.split("\n");
34
34
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
35
35
  const matches: number[] = [];
36
36
 
@@ -50,9 +50,9 @@ export class FileEditor {
50
50
  throw new Error(`Pattern not found: ${pattern}`);
51
51
  }
52
52
 
53
- const lines = this.content.split("\n");
53
+ const lines = this.#content.split("\n");
54
54
  lines.splice(lineIndex + 1, 0, data);
55
- this.content = lines.join("\n");
55
+ this.#content = lines.join("\n");
56
56
 
57
57
  return this;
58
58
  }
@@ -64,43 +64,43 @@ export class FileEditor {
64
64
  throw new Error(`Pattern not found: ${pattern}`);
65
65
  }
66
66
 
67
- const lines = this.content.split("\n");
67
+ const lines = this.#content.split("\n");
68
68
  lines.splice(lineIndex, 0, data);
69
- this.content = lines.join("\n");
69
+ this.#content = lines.join("\n");
70
70
 
71
71
  return this;
72
72
  }
73
73
 
74
74
  replace(pattern: string | RegExp, replacement: string): this {
75
75
  const regex = typeof pattern === "string" ? new RegExp(pattern, "g") : pattern;
76
- this.content = this.content.replace(regex, replacement);
76
+ this.#content = this.#content.replace(regex, replacement);
77
77
  return this;
78
78
  }
79
79
 
80
80
  append(data: string): this {
81
- this.content += `\n${data}`;
81
+ this.#content += `\n${data}`;
82
82
  return this;
83
83
  }
84
84
 
85
85
  prepend(data: string): this {
86
- this.content = `${data}\n${this.content}`;
86
+ this.#content = `${data}\n${this.#content}`;
87
87
  return this;
88
88
  }
89
89
 
90
90
  async save(): Promise<void> {
91
91
  try {
92
- await Bun.write(this.filePath, this.content);
93
- } catch (error) {
94
- throw new Error(`Failed to save file: ${this.filePath}`);
92
+ await Bun.write(this.#filePath, this.#content);
93
+ } catch (_error) {
94
+ throw new Error(`Failed to save file: ${this.#filePath}`);
95
95
  }
96
96
  }
97
97
 
98
98
  getContent(): string {
99
- return this.content;
99
+ return this.#content;
100
100
  }
101
101
 
102
102
  setContent(content: string): this {
103
- this.content = content;
103
+ this.#content = content;
104
104
  return this;
105
105
  }
106
106
  }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { FormSetterScanner } from "./formSetterScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+
6
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
7
+ file,
8
+ absolutePath: `/tmp/${file}`,
9
+ content,
10
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
11
+ });
12
+
13
+ const scan = (content: string) =>
14
+ new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
15
+
16
+ describe("FormSetterScanner", () => {
17
+ test("a setter passed by reference publishes, so nothing is reported", () => {
18
+ expect(
19
+ scan(`export const General = () => (
20
+ <Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
21
+ );
22
+ `),
23
+ ).toEqual([]);
24
+ });
25
+
26
+ test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
27
+ const warnings = scan(`export const General = () => (
28
+ <>
29
+ <Field.Text onChange={st.do.setTitleOnTask} />
30
+ <Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
31
+ <Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
32
+ <Field.Parent
33
+ onChange={(project) => {
34
+ st.do.setProjectOnTask(project);
35
+ if (project) st.do.addMembersOnTask(project.members ?? []);
36
+ }}
37
+ />
38
+ </>
39
+ );
40
+ `);
41
+
42
+ expect(warnings).toHaveLength(1);
43
+ expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
44
+ expect(warnings[0]?.scope).toBe("agent");
45
+ expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
46
+ expect(warnings[0]?.locations).toHaveLength(3);
47
+ });
48
+
49
+ test("a nested path write is unannotatable by design and is not counted", () => {
50
+ expect(
51
+ scan(`export const Rows = ({ idx }: { idx: number }) => (
52
+ <Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
53
+ );
54
+ `),
55
+ ).toEqual([]);
56
+ });
57
+
58
+ test("a zero-parameter handler is a button setting a constant, not a form control", () => {
59
+ expect(
60
+ scan(`export const Actions = () => (
61
+ <Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
62
+ );
63
+ `),
64
+ ).toEqual([]);
65
+ });
66
+
67
+ test("a non-setter action behind a wrapper is somebody else's rule", () => {
68
+ expect(
69
+ scan(`export const Filters = () => (
70
+ <Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
71
+ );
72
+ `),
73
+ ).toEqual([]);
74
+ });
75
+
76
+ test("only .tsx is scanned", () => {
77
+ const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
78
+ expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
79
+ });
80
+ });