@akanjs/devkit 3.0.0-alpha.7 → 3.0.0-alpha.71

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 (57) 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/akanConfig/akanConfig.test.ts +182 -14
  8. package/akanConfig/akanConfig.ts +132 -47
  9. package/akanConfig/types.ts +8 -0
  10. package/akanContext.ts +53 -11
  11. package/applicationBuildRunner.test.ts +1 -1
  12. package/applicationBuildRunner.ts +45 -21
  13. package/artifact/implicitRootLayout.ts +2 -2
  14. package/biome.base.json +340 -0
  15. package/biomeBase.ts +9 -0
  16. package/executors.test.ts +87 -5
  17. package/executors.ts +40 -9
  18. package/formSetterScanner.test.ts +80 -0
  19. package/formSetterScanner.ts +92 -0
  20. package/frontendBuild/buildRouteClient.test.ts +28 -2
  21. package/frontendBuild/clientBuildTypes.ts +4 -0
  22. package/frontendBuild/clientEntriesBundler.ts +4 -1
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/fontPruner.test.ts +220 -0
  26. package/frontendBuild/fontPruner.ts +206 -0
  27. package/frontendBuild/frontendBuild.test.ts +88 -1
  28. package/frontendBuild/hmrWatcher.ts +1 -1
  29. package/frontendBuild/index.ts +1 -0
  30. package/frontendBuild/routeClientBuilder.ts +12 -5
  31. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  32. package/frontendBuild/styleGuard.test.ts +15 -0
  33. package/frontendBuild/styleGuard.ts +17 -0
  34. package/frontendBuild/vendorSpecifiers.ts +1 -0
  35. package/getCredentials.ts +1 -3
  36. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  37. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  38. package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
  39. package/integration/devStabilityHarness.ts +2 -10
  40. package/lint/no-async-component-in-ui.grit +35 -0
  41. package/lint/no-daisyui-legacy-class.grit +26 -9
  42. package/lint/no-deprecated-log-level.grit +17 -0
  43. package/lint/no-import-client-in-server.grit +48 -0
  44. package/lint/no-import-server-in-client.grit +45 -0
  45. package/lint/no-init-fetch-in-client.grit +47 -0
  46. package/lint/no-model-type-in-util-zone.grit +58 -0
  47. package/lint/no-unpublished-form-setter.grit +41 -0
  48. package/linter.ts +17 -12
  49. package/package.json +5 -5
  50. package/qualityScanner.test.ts +52 -0
  51. package/qualityScanner.ts +46 -18
  52. package/repoIdentity.ts +42 -0
  53. package/scanInfo.ts +29 -23
  54. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  55. package/tsconfig.json +1 -1
  56. package/workspaceLayout.test.ts +56 -4
  57. package/workspaceLayout.ts +49 -4
@@ -0,0 +1,340 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": false
7
+ },
8
+ "formatter": {
9
+ "enabled": true,
10
+ "indentStyle": "space",
11
+ "indentWidth": 2,
12
+ "lineWidth": 120
13
+ },
14
+ "linter": {
15
+ "enabled": true,
16
+ "rules": {
17
+ "preset": "recommended",
18
+ "suspicious": {
19
+ "noConsole": {
20
+ "level": "error",
21
+ "options": {
22
+ "allow": ["assert", "error", "info", "warn"]
23
+ }
24
+ },
25
+ "noArrayIndexKey": "off",
26
+ "noShadowRestrictedNames": "off",
27
+ "noUnnecessaryConditions": {
28
+ "level": "warn"
29
+ }
30
+ },
31
+ "correctness": {
32
+ "noUnusedFunctionParameters": "off",
33
+ "noUnusedImports": {
34
+ "level": "error",
35
+ "fix": "safe"
36
+ },
37
+ "useParseIntRadix": "off",
38
+ "useExhaustiveDependencies": "off",
39
+ "useHookAtTopLevel": "off"
40
+ },
41
+ "nursery": {
42
+ "useSortedClasses": {
43
+ "level": "error",
44
+ "fix": "safe",
45
+ "options": {
46
+ "attributes": ["classList"],
47
+ "functions": ["cn"]
48
+ }
49
+ }
50
+ },
51
+ "a11y": "off",
52
+ "complexity": {
53
+ "noStaticOnlyClass": "off"
54
+ },
55
+ "style": {
56
+ "useTemplate": {
57
+ "level": "warn",
58
+ "fix": "safe"
59
+ }
60
+ }
61
+ },
62
+ "domains": {
63
+ "project": "recommended",
64
+ "react": "recommended",
65
+ "test": "recommended",
66
+ "types": "all"
67
+ }
68
+ },
69
+ "javascript": {
70
+ "parser": {
71
+ "unsafeParameterDecoratorsEnabled": true
72
+ },
73
+ "formatter": {
74
+ "quoteStyle": "double"
75
+ }
76
+ },
77
+ "assist": {
78
+ "enabled": true,
79
+ "actions": {
80
+ "source": {
81
+ "organizeImports": "on"
82
+ }
83
+ }
84
+ },
85
+ "overrides": [
86
+ {
87
+ "includes": [
88
+ "**/env.client.local.ts",
89
+ "**/env.client.debug.ts",
90
+ "**/env.client.develop.ts",
91
+ "**/env.client.main.ts",
92
+ "**/env.client.testing.ts",
93
+ "**/env.server.local.ts",
94
+ "**/env.server.develop.ts",
95
+ "**/env.server.main.ts",
96
+ "**/env.server.testing.ts",
97
+ "**/env.server.type.ts",
98
+ "apps/*/lib/cnst.ts",
99
+ "apps/*/lib/dict.ts",
100
+ "apps/*/lib/db.ts",
101
+ "apps/*/lib/srv.ts",
102
+ "apps/*/lib/st.ts",
103
+ "apps/*/lib/sig.ts",
104
+ "apps/*/lib/useClient.ts",
105
+ "apps/*/lib/useServer.ts",
106
+ "apps/*/client.ts",
107
+ "apps/*/server.ts",
108
+ "apps/*/lib/*/index.tsx",
109
+ "libs/*/lib/cnst.ts",
110
+ "libs/*/lib/dict.ts",
111
+ "libs/*/lib/db.ts",
112
+ "libs/*/lib/srv.ts",
113
+ "libs/*/lib/st.ts",
114
+ "libs/*/lib/sig.ts",
115
+ "libs/*/lib/useClient.ts",
116
+ "libs/*/lib/useServer.ts",
117
+ "libs/*/client.ts",
118
+ "libs/*/server.ts",
119
+ "libs/*/index.ts",
120
+ "libs/*/lib/*/index.tsx",
121
+ "apps/*/lib/__lib/**",
122
+ "libs/*/lib/__lib/**"
123
+ ],
124
+ "linter": { "enabled": false },
125
+ "formatter": { "enabled": false },
126
+ "assist": { "enabled": false }
127
+ },
128
+ {
129
+ "includes": [
130
+ "apps/**/*.ts",
131
+ "apps/**/*.tsx",
132
+ "libs/**/*.ts",
133
+ "libs/**/*.tsx",
134
+ "!**/*.test.ts",
135
+ "!**/*.test.tsx",
136
+ "!**/*.spec.ts",
137
+ "!**/*.spec.tsx",
138
+ "!**/*.constant.ts",
139
+ "!**/common/**",
140
+ "!**/env/**"
141
+ ],
142
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
143
+ },
144
+ {
145
+ "includes": [
146
+ "{apps,libs}/**/page/**/*.ts",
147
+ "{apps,libs}/**/page/**/*.tsx",
148
+ "{apps,libs}/**/*.Unit.tsx",
149
+ "{apps,libs}/**/*.View.tsx"
150
+ ],
151
+ "plugins": [
152
+ "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
153
+ "./node_modules/@akanjs/devkit/lint/no-use-client-in-server.grit"
154
+ ]
155
+ },
156
+ {
157
+ "includes": ["{apps,libs}/**/page/**/*.ts", "{apps,libs}/**/page/**/*.tsx"],
158
+ "plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
159
+ },
160
+ {
161
+ "includes": [
162
+ "{apps,libs}/**/*.constant.ts",
163
+ "{apps,libs}/**/*.document.ts",
164
+ "{apps,libs}/**/*.service.ts",
165
+ "{apps,libs}/**/*.store.ts"
166
+ ],
167
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-js-private-class-method.grit"]
168
+ },
169
+ {
170
+ "includes": ["{apps,libs}/**/*.store.ts"],
171
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
172
+ },
173
+ {
174
+ "includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
175
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
176
+ },
177
+ {
178
+ "includes": ["{apps,libs}/**/*.signal.ts"],
179
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit"]
180
+ },
181
+ {
182
+ "includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
183
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
184
+ },
185
+ {
186
+ "includes": ["{apps,libs}/**/ui/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
187
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-async-component-in-ui.grit"]
188
+ },
189
+ {
190
+ "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
191
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
192
+ },
193
+ {
194
+ "includes": [
195
+ "{apps,libs}/**/*.ts",
196
+ "{apps,libs}/**/*.tsx",
197
+ "!**/*.test.ts",
198
+ "!**/*.test.tsx",
199
+ "!**/*.spec.ts",
200
+ "!**/*.spec.tsx"
201
+ ],
202
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit"]
203
+ },
204
+ {
205
+ "includes": [
206
+ "**/ui/**/*.ts",
207
+ "**/ui/**/*.tsx",
208
+ "**/webkit/**/*.ts",
209
+ "**/webkit/**/*.tsx",
210
+ "**/common/**/*.ts",
211
+ "**/page/**/*.tsx",
212
+ "**/*.constant.ts",
213
+ "**/*.store.ts",
214
+ "**/*.Template.tsx",
215
+ "**/*.Unit.tsx",
216
+ "**/*.Util.tsx",
217
+ "**/*.View.tsx",
218
+ "**/*.Zone.tsx",
219
+ "!**/*.test.ts",
220
+ "!**/*.test.tsx",
221
+ "!**/*.spec.ts",
222
+ "!**/*.spec.tsx"
223
+ ],
224
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-bang-comment-in-client.grit"]
225
+ },
226
+ {
227
+ "includes": [
228
+ "{apps,libs}/**/*.constant.ts",
229
+ "{apps,libs}/**/*.dictionary.ts",
230
+ "{apps,libs}/**/*.document.ts",
231
+ "{apps,libs}/**/*.service.ts",
232
+ "{apps,libs}/**/*.signal.ts",
233
+ "{apps,libs}/**/*.store.ts",
234
+ "{apps,libs}/**/*.Template.tsx",
235
+ "{apps,libs}/**/*.Unit.tsx",
236
+ "{apps,libs}/**/*.Util.tsx",
237
+ "{apps,libs}/**/*.View.tsx",
238
+ "{apps,libs}/**/*.Zone.tsx"
239
+ ],
240
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"]
241
+ },
242
+ {
243
+ "includes": [
244
+ "{apps,libs}/**/page/**/*.ts",
245
+ "{apps,libs}/**/page/**/*.tsx",
246
+ "{apps,libs}/**/index.ts",
247
+ "{apps,libs}/**/index.tsx",
248
+ "{apps,libs}/**/cnst.ts",
249
+ "{apps,libs}/**/db.ts",
250
+ "{apps,libs}/**/dict.ts",
251
+ "{apps,libs}/**/option.ts",
252
+ "{apps,libs}/**/sig.ts",
253
+ "{apps,libs}/**/srv.ts",
254
+ "{apps,libs}/**/st.ts",
255
+ "{apps,libs}/**/*.constant.ts",
256
+ "{apps,libs}/**/*.dictionary.ts",
257
+ "{apps,libs}/**/*.document.ts",
258
+ "{apps,libs}/**/*.service.ts",
259
+ "{apps,libs}/**/*.signal.ts",
260
+ "{apps,libs}/**/*.signal.test.ts",
261
+ "{apps,libs}/**/*.service.test.ts",
262
+ "{apps,libs}/**/*.store.ts",
263
+ "{apps,libs}/**/*.Template.tsx",
264
+ "{apps,libs}/**/*.Unit.tsx",
265
+ "{apps,libs}/**/*.Util.tsx",
266
+ "{apps,libs}/**/*.View.tsx",
267
+ "{apps,libs}/**/*.Zone.tsx"
268
+ ],
269
+ "plugins": [
270
+ "./node_modules/@akanjs/devkit/lint/no-import-external-library.grit",
271
+ "./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"
272
+ ]
273
+ },
274
+ {
275
+ "includes": [
276
+ "apps/**/*.tsx",
277
+ "libs/**/*.tsx",
278
+ "apps/**/ui/**/*.ts",
279
+ "libs/**/ui/**/*.ts",
280
+ "apps/**/webkit/**/*.ts",
281
+ "libs/**/webkit/**/*.ts",
282
+ "apps/**/page/**/*.ts",
283
+ "libs/**/page/**/*.ts",
284
+ "apps/**/common/**/*.ts",
285
+ "libs/**/common/**/*.ts",
286
+ "apps/**/*.store.ts",
287
+ "libs/**/*.store.ts",
288
+ "apps/**/*.constant.ts",
289
+ "libs/**/*.constant.ts",
290
+ "!**/*.test.ts",
291
+ "!**/*.test.tsx",
292
+ "!**/*.spec.ts",
293
+ "!**/*.spec.tsx"
294
+ ],
295
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-import-server-in-client.grit"]
296
+ },
297
+ {
298
+ "includes": [
299
+ "apps/**/*.document.ts",
300
+ "libs/**/*.document.ts",
301
+ "apps/**/*.dictionary.ts",
302
+ "libs/**/*.dictionary.ts",
303
+ "apps/**/*.service.ts",
304
+ "libs/**/*.service.ts",
305
+ "apps/**/*.signal.ts",
306
+ "libs/**/*.signal.ts",
307
+ "apps/**/srvkit/**/*.ts",
308
+ "libs/**/srvkit/**/*.ts",
309
+ "apps/**/common/**/*.ts",
310
+ "libs/**/common/**/*.ts",
311
+ "apps/**/*.constant.ts",
312
+ "libs/**/*.constant.ts",
313
+ "!**/*.test.ts",
314
+ "!**/*.test.tsx",
315
+ "!**/*.spec.ts",
316
+ "!**/*.spec.tsx"
317
+ ],
318
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-import-client-in-server.grit"]
319
+ },
320
+ {
321
+ "includes": [
322
+ "apps/**/*.ts",
323
+ "apps/**/*.tsx",
324
+ "libs/**/*.ts",
325
+ "libs/**/*.tsx",
326
+ "!**/*.test.ts",
327
+ "!**/*.test.tsx",
328
+ "!**/*.spec.ts",
329
+ "!**/*.spec.tsx"
330
+ ],
331
+ "plugins": [
332
+ "./node_modules/@akanjs/devkit/lint/no-raw-palette-class.grit",
333
+ "./node_modules/@akanjs/devkit/lint/no-arbitrary-color.grit",
334
+ "./node_modules/@akanjs/devkit/lint/no-daisyui-legacy-class.grit",
335
+ "./node_modules/@akanjs/devkit/lint/no-inline-color.grit",
336
+ "./node_modules/@akanjs/devkit/lint/no-interpolated-arbitrary-class.grit"
337
+ ]
338
+ }
339
+ ]
340
+ }
package/biomeBase.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** `extends` target for a workspace `biome.json`; Biome resolves it through node_modules. */
2
+ export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
3
+
4
+ // Biome moves rules between groups across minors — `noUnnecessaryConditions` sat in `nursery` at 2.4 and moved to
5
+ // `suspicious` at 2.5 — and the stale position is a hard "unknown key" error, not a warning. A workspace whose
6
+ // Biome disagrees with the shipped base config therefore fails to load it at all, which is why the version is
7
+ // pinned here instead of resolved to latest at create time. Bump this and `biome.base.json` in one commit, and run
8
+ // `biome migrate --write` in the workspace root and in `pkgs/@akanjs/devkit` so both configs move together.
9
+ export const biomeVersion = "2.5.8";
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: {
@@ -149,8 +148,10 @@ describe("Executor filesystem helpers", () => {
149
148
  "AI Development Guide",
150
149
  );
151
150
  expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
151
+ // Rules and their plugin registrations live in the package's base config, so a framework release reaches an
152
+ // existing workspace on `bun update` — the workspace file only extends it and scopes its own files.
152
153
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
153
- "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
154
+ '"extends": ["@akanjs/devkit/biome.base.json"]',
154
155
  );
155
156
  });
156
157
 
@@ -620,6 +621,64 @@ describe("Workspace and app executor environment contracts", () => {
620
621
  });
621
622
  });
622
623
 
624
+ describe("root layout source validation during page key discovery", () => {
625
+ const makeRouteValidationApp = async (appName: string, config: string, files: Record<string, string>) => {
626
+ const root = await makeTempRoot();
627
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
628
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
629
+ process.env.AKAN_PUBLIC_ENV = "local";
630
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
631
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
632
+ await writeFile(path.join(root, `apps/${appName}/akan.config.ts`), config);
633
+ for (const [file, source] of Object.entries(files)) {
634
+ const filePath = path.join(root, `apps/${appName}/page`, file);
635
+ await mkdir(path.dirname(filePath), { recursive: true });
636
+ await writeFile(filePath, source);
637
+ }
638
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
639
+ return AppExecutor.from(workspace, appName);
640
+ };
641
+ const wsConnectLayout = [
642
+ "export const wsConnect = false;",
643
+ "export default function Layout({ children }) { return children; }",
644
+ "",
645
+ ].join("\n");
646
+
647
+ test("accepts wsConnect on a configured base-path root layout", async () => {
648
+ process.env.AKAN_PUBLIC_BASE_PATHS = "web,admin";
649
+ const app = await makeRouteValidationApp(
650
+ "base-path-root-ws",
651
+ [
652
+ "export default {",
653
+ ' routes: [{ basePath: "web", domains: {} }, { basePath: "admin", domains: {} }],',
654
+ "};",
655
+ "",
656
+ ].join("\n"),
657
+ { "admin/_layout.tsx": wsConnectLayout },
658
+ );
659
+
660
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./admin/_layout.tsx"]);
661
+ });
662
+
663
+ test("rejects wsConnect on a nested layout", async () => {
664
+ const app = await makeRouteValidationApp(
665
+ "nested-layout-ws",
666
+ 'export default { routes: [{ basePath: "admin", domains: {} }] };\n',
667
+ { "admin/users/_layout.tsx": wsConnectLayout },
668
+ );
669
+
670
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(/unsupported export "wsConnect"/);
671
+ });
672
+
673
+ test("accepts wsConnect on a grouped root layout", async () => {
674
+ const app = await makeRouteValidationApp("grouped-root-ws", "export default {};\n", {
675
+ "(docs)/_layout.tsx": wsConnectLayout,
676
+ });
677
+
678
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./(docs)/_layout.tsx"]);
679
+ });
680
+ });
681
+
623
682
  test("accepts metadata route exports during page key discovery", async () => {
624
683
  const root = await makeTempRoot();
625
684
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
@@ -758,6 +817,29 @@ describe("Workspace and app executor environment contracts", () => {
758
817
  expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
759
818
  expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
760
819
  });
820
+
821
+ test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
822
+ const root = await makeTempRoot();
823
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
824
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
825
+ process.env.AKAN_PUBLIC_ENV = "local";
826
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
827
+ await mkdir(path.join(root, "apps/ambient"), { recursive: true });
828
+ await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
829
+
830
+ const originalNodeEnv = process.env.NODE_ENV;
831
+ process.env.NODE_ENV = "production";
832
+ try {
833
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
834
+ const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
835
+ expect(started.env.NODE_ENV).toBe("development");
836
+ // The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
837
+ expect(process.env.NODE_ENV).toBe("development");
838
+ } finally {
839
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
840
+ else process.env.NODE_ENV = originalNodeEnv;
841
+ }
842
+ });
761
843
  });
762
844
 
763
845
  describe("PkgExecutor package generation", () => {
@@ -771,7 +853,7 @@ describe("PkgExecutor package generation", () => {
771
853
  exports: { "./extra": { import: "./extra.ts" } },
772
854
  peerDependencies: { react: "19.0.0" },
773
855
  peerDependenciesMeta: { react: { optional: true } },
774
- optionalDependencies: { sharp: "1.0.0" },
856
+ optionalDependencies: { "@sample/native": "1.0.0" },
775
857
  });
776
858
 
777
859
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -781,12 +863,12 @@ describe("PkgExecutor package generation", () => {
781
863
  expect(distPackageJson).toMatchObject({
782
864
  name: "@sample/tool",
783
865
  type: "module",
784
- engines: { bun: ">=1.3.13" },
866
+ engines: { bun: ">=1.4.0" },
785
867
  dependencies: { lodash: "4.0.0" },
786
868
  devDependencies: { typescript: "6.0.0" },
787
869
  peerDependencies: { react: "19.0.0" },
788
870
  peerDependenciesMeta: { react: { optional: true } },
789
- optionalDependencies: { sharp: "1.0.0" },
871
+ optionalDependencies: { "@sample/native": "1.0.0" },
790
872
  });
791
873
  expect(distPackageJson.exports?.["."]).toEqual({
792
874
  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);
@@ -788,7 +793,7 @@ export class WorkspaceExecutor extends Executor {
788
793
  static #execs = new Map<string, WorkspaceExecutor>();
789
794
  static fromRoot({
790
795
  workspaceRoot = process.cwd(),
791
- repoName = path.basename(process.cwd()),
796
+ repoName = resolveRepoName(workspaceRoot),
792
797
  }: {
793
798
  workspaceRoot?: string;
794
799
  repoName?: string;
@@ -1005,6 +1010,16 @@ export class SysExecutor extends Executor {
1005
1010
  const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
1006
1011
  return await this.workspace.getDirInModule(path, this.name);
1007
1012
  }
1013
+ async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
1014
+ if (!(await this.exists("page"))) return [];
1015
+ const violations: { relativePath: string; reason: string }[] = [];
1016
+ for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
1017
+ const relativePath = entry.split(path.sep).join("/");
1018
+ const reason = getPageSourceFileViolation(relativePath);
1019
+ if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
1020
+ }
1021
+ return violations;
1022
+ }
1008
1023
 
1009
1024
  #scanInfo: AppInfo | LibInfo | null = null;
1010
1025
  hasScanInfo() {
@@ -1358,11 +1373,26 @@ export class AppExecutor extends SysExecutor {
1358
1373
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1359
1374
  //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1360
1375
  //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1376
+ //* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
1361
1377
  await Promise.all([
1362
1378
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1363
- this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1379
+ ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1364
1380
  ]);
1365
- } else await this.removeDir(".akan");
1381
+ } else {
1382
+ await this.removeDir(".akan");
1383
+ //? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
1384
+ //? builder is also the file watcher, so switching it off would take server-code HMR with it.
1385
+ if (!akanConfig.web.ssr || !akanConfig.web.csr)
1386
+ this.logger.verbose(
1387
+ `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1388
+ );
1389
+ }
1390
+ //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1391
+ //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1392
+ //? and a dev server that believes it is production serves from the production route cache, whose every
1393
+ //? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
1394
+ //? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
1395
+ if (type === "start") process.env.NODE_ENV = "development";
1366
1396
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1367
1397
  const env = this.getCommandEnv({
1368
1398
  AKAN_COMMAND_TYPE: type,
@@ -1451,7 +1481,8 @@ export class AppExecutor extends SysExecutor {
1451
1481
  );
1452
1482
  }
1453
1483
  if (!owner) owners.set(routeId, { absPath, fromLib });
1454
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1484
+ const layoutSegments = getRootBoundarySegments(key);
1485
+ const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
1455
1486
  const routeSource = await Bun.file(absPath).text();
1456
1487
  const validator = await AppExecutor.#getRouteSourceValidator();
1457
1488
  if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
@@ -1700,7 +1731,7 @@ export class LibExecutor extends SysExecutor {
1700
1731
  #akanConfig: AkanLibConfig | null = null;
1701
1732
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1702
1733
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1703
- this.#akanConfig = await AkanLibConfig.from(this);
1734
+ this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
1704
1735
  return this.#akanConfig;
1705
1736
  }
1706
1737
  }
@@ -1806,7 +1837,7 @@ export class PkgExecutor extends Executor {
1806
1837
  default: "./index.ts",
1807
1838
  },
1808
1839
  },
1809
- engines: { bun: ">=1.3.13" },
1840
+ engines: { bun: ">=1.4.0" },
1810
1841
  ...dependencyMaps,
1811
1842
  };
1812
1843
  await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);