@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.100

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 (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +34 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +47 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +70 -42
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +37 -2
  22. package/applicationBuildRunner.ts +53 -24
  23. package/artifact/implicitRootLayout.test.ts +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +340 -0
  27. package/biomeBase.ts +9 -0
  28. package/capacitorApp.test.ts +8 -0
  29. package/capacitorApp.ts +122 -18
  30. package/cloud/globalConfig.ts +9 -2
  31. package/codegenLock.test.ts +137 -0
  32. package/codegenLock.ts +136 -0
  33. package/commandDecorators/argMeta.ts +9 -1
  34. package/commandDecorators/command.ts +94 -7
  35. package/commandDecorators/commandBuilder.ts +20 -5
  36. package/commandDecorators/commandDecorators.test.ts +115 -3
  37. package/commandDecorators/helpFormatter.ts +4 -0
  38. package/dependencyScanner.test.ts +99 -0
  39. package/dependencyScanner.ts +27 -24
  40. package/devkitUtils.test.ts +0 -104
  41. package/executors.test.ts +137 -5
  42. package/executors.ts +116 -26
  43. package/fileEditor.ts +19 -19
  44. package/fileSys.ts +19 -1
  45. package/formSetterScanner.test.ts +80 -0
  46. package/formSetterScanner.ts +92 -0
  47. package/frontendBuild/allRoutesBuilder.ts +27 -8
  48. package/frontendBuild/autoImportSync.test.ts +58 -0
  49. package/frontendBuild/autoImportSync.ts +7 -2
  50. package/frontendBuild/buildRouteClient.test.ts +47 -24
  51. package/frontendBuild/clientBuildTypes.ts +4 -0
  52. package/frontendBuild/clientEntriesBundler.ts +4 -1
  53. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  54. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  55. package/frontendBuild/cssCandidateCache.test.ts +38 -0
  56. package/frontendBuild/cssCandidateCache.ts +15 -3
  57. package/frontendBuild/cssCompiler.ts +123 -12
  58. package/frontendBuild/cssImportResolver.ts +8 -7
  59. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  60. package/frontendBuild/fontOptimizer.ts +37 -18
  61. package/frontendBuild/fontPruner.test.ts +220 -0
  62. package/frontendBuild/fontPruner.ts +206 -0
  63. package/frontendBuild/frontendBuild.test.ts +178 -10
  64. package/frontendBuild/hmrWatcher.ts +1 -1
  65. package/frontendBuild/index.ts +1 -1
  66. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  67. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  68. package/frontendBuild/precompressArtifacts.ts +35 -7
  69. package/frontendBuild/routeClientBuilder.ts +26 -7
  70. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  71. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  72. package/frontendBuild/styleContract.ts +14 -20
  73. package/frontendBuild/themeValidator.ts +22 -17
  74. package/frontendBuild/vendorSpecifiers.ts +1 -0
  75. package/frontendBuild/watchRootResolver.test.ts +129 -0
  76. package/frontendBuild/watchRootResolver.ts +66 -1
  77. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  78. package/incrementalBuilder/devWatchBatch.ts +6 -2
  79. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  80. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  81. package/index.ts +0 -7
  82. package/integration/devStabilityHarness.ts +2 -10
  83. package/libSource.test.ts +112 -0
  84. package/libSource.ts +126 -0
  85. package/lint/__fixtures__/README.md +40 -0
  86. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  87. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  88. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  89. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  90. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  91. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  92. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  93. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  94. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  95. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  96. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  97. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  98. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  99. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  100. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  101. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  102. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  103. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  104. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  105. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  106. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  107. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  108. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  109. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  110. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  111. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  112. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  113. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  114. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  115. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  116. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  117. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  118. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  119. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  120. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  121. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  122. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  123. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  124. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  125. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  126. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  127. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  128. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  129. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  130. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  131. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  132. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  133. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  134. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  135. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  136. package/lint/gritRules.test.ts +178 -0
  137. package/lint/no-arbitrary-color.grit +1 -1
  138. package/lint/no-async-component-in-ui.grit +35 -0
  139. package/lint/no-bang-comment-in-client.grit +33 -0
  140. package/lint/no-daisyui-legacy-class.grit +26 -9
  141. package/lint/no-deprecated-log-level.grit +17 -0
  142. package/lint/no-import-client-in-server.grit +48 -0
  143. package/lint/no-import-server-in-client.grit +45 -0
  144. package/lint/no-init-fetch-in-client.grit +47 -0
  145. package/lint/no-inline-color.grit +10 -8
  146. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  147. package/lint/no-model-type-in-util-zone.grit +58 -0
  148. package/lint/no-raw-palette-class.grit +3 -3
  149. package/lint/no-return-in-store-action.grit +35 -0
  150. package/lint/no-unpublished-form-setter.grit +41 -0
  151. package/lint/non-scalar-props-restricted.grit +16 -7
  152. package/linter.test.ts +80 -0
  153. package/linter.ts +99 -25
  154. package/package.json +6 -8
  155. package/prompter.ts +9 -4
  156. package/qualityScanner.test.ts +130 -0
  157. package/qualityScanner.ts +91 -62
  158. package/recipeScanner.ts +4 -1
  159. package/repoIdentity.ts +42 -0
  160. package/routeSourceValidator.test.ts +41 -0
  161. package/routeSourceValidator.ts +2 -44
  162. package/scanInfo.ts +32 -67
  163. package/semver.test.ts +26 -0
  164. package/semver.ts +31 -0
  165. package/slicePlanner.test.ts +182 -0
  166. package/slicePlanner.ts +235 -0
  167. package/ssrScanner.test.ts +301 -0
  168. package/subspace.test.ts +418 -0
  169. package/subspace.ts +723 -0
  170. package/subspaceConfig.ts +76 -0
  171. package/transforms/asyncDefaultExportDetector.ts +103 -0
  172. package/transforms/barrelImportsPlugin.ts +1 -1
  173. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  174. package/transforms/transforms.test.ts +5 -5
  175. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  176. package/tsconfig.json +6 -2
  177. package/typeChecker.ts +1 -1
  178. package/types.ts +1 -0
  179. package/uploadRelease.ts +2 -2
  180. package/workflow/executor.test.ts +146 -0
  181. package/workspaceLayout.test.ts +78 -0
  182. package/workspaceLayout.ts +106 -0
  183. package/builder.ts +0 -164
  184. package/extractDeps.ts +0 -86
  185. package/frontendBuild/styleGuard.test.ts +0 -165
  186. package/frontendBuild/styleGuard.ts +0 -322
  187. package/getCredentials.ts +0 -19
  188. package/getModelFileData.ts +0 -62
  189. package/src/capacitorApp.ts +0 -282
  190. package/streamAi.ts +0 -45
  191. package/ui/MultiScrollList.tsx +0 -242
  192. package/ui/ScrollList.tsx +0 -107
  193. package/ui/index.ts +0 -2
  194. package/useStdoutDimensions.ts +0 -20
@@ -0,0 +1,99 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { TypeScriptDependencyScanner } from "./dependencyScanner";
6
+ import type { PackageJson } from "./types";
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ const makeTempRoot = async () => {
11
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-depscan-"));
12
+ tempRoots.push(root);
13
+ return root;
14
+ };
15
+
16
+ const write = async (root: string, relPath: string, content: string) => {
17
+ const filePath = path.join(root, relPath);
18
+ await mkdir(path.dirname(filePath), { recursive: true });
19
+ await writeFile(filePath, content);
20
+ return filePath;
21
+ };
22
+
23
+ const scannerFor = async (root: string, dependencies: Record<string, string> = {}) =>
24
+ new TypeScriptDependencyScanner(path.join(root, "pkgs/@akanjs/devkit"), {
25
+ workspaceRoot: root,
26
+ tsconfig: { compilerOptions: { target: "esnext" } },
27
+ rootPackageJson: { dependencies } as unknown as PackageJson,
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
32
+ });
33
+
34
+ describe("TypeScriptDependencyScanner — getPackageBuildDependencies", () => {
35
+ test("collects imports from ordinary source files", async () => {
36
+ const root = await makeTempRoot();
37
+ await write(root, "pkgs/@akanjs/devkit/linter.ts", `import chalk from "chalk";\nexport const x = chalk;\n`);
38
+ const scanner = await scannerFor(root, { chalk: "^5.0.0" });
39
+
40
+ const { npmDeps, missingDeps } = await scanner.getPackageBuildDependencies("@akanjs/devkit");
41
+
42
+ expect(npmDeps).toEqual(["chalk"]);
43
+ expect(missingDeps).toEqual([]);
44
+ });
45
+
46
+ test("reports an import that root package.json does not version", async () => {
47
+ const root = await makeTempRoot();
48
+ await write(root, "pkgs/@akanjs/devkit/linter.ts", `import clsx from "clsx";\nexport const x = clsx;\n`);
49
+ const scanner = await scannerFor(root);
50
+
51
+ expect((await scanner.getPackageBuildDependencies("@akanjs/devkit")).missingDeps).toEqual(["clsx"]);
52
+ });
53
+
54
+ test("skips __fixtures__, whose imports are lint violations rather than dependencies", async () => {
55
+ const root = await makeTempRoot();
56
+ await write(
57
+ root,
58
+ "pkgs/@akanjs/devkit/lint/__fixtures__/no-import-external-library/bad.tsx",
59
+ `import clsx from "clsx";\nimport { cnst } from "@libs/shared/client";\nexport const x = [clsx, cnst];\n`,
60
+ );
61
+ const scanner = await scannerFor(root);
62
+
63
+ const { npmDeps, npmDevDeps, missingDeps } = await scanner.getPackageBuildDependencies("@akanjs/devkit");
64
+
65
+ expect(missingDeps).toEqual([]);
66
+ expect(npmDeps).toEqual([]);
67
+ expect(npmDevDeps).toEqual([]);
68
+ });
69
+
70
+ test("skips a nested __fixtures__ directory at any depth", async () => {
71
+ const root = await makeTempRoot();
72
+ await write(root, "pkgs/@akanjs/devkit/__fixtures__/bad.ts", `import a from "pkg-a";\nexport const x = a;\n`);
73
+ await write(root, "pkgs/@akanjs/devkit/a/b/__fixtures__/c/bad.ts", `import b from "pkg-b";\nexport const x = b;\n`);
74
+ const scanner = await scannerFor(root);
75
+
76
+ expect((await scanner.getPackageBuildDependencies("@akanjs/devkit")).missingDeps).toEqual([]);
77
+ });
78
+
79
+ test("keeps a directory whose name merely contains the fixtures marker", async () => {
80
+ const root = await makeTempRoot();
81
+ await write(
82
+ root,
83
+ "pkgs/@akanjs/devkit/__fixtures__helper/keep.ts",
84
+ `import a from "pkg-a";\nexport const x = a;\n`,
85
+ );
86
+ const scanner = await scannerFor(root);
87
+
88
+ expect((await scanner.getPackageBuildDependencies("@akanjs/devkit")).missingDeps).toEqual(["pkg-a"]);
89
+ });
90
+
91
+ test("skips test files but still reports their non-test siblings", async () => {
92
+ const root = await makeTempRoot();
93
+ await write(root, "pkgs/@akanjs/devkit/linter.test.ts", `import a from "pkg-a";\nexport const x = a;\n`);
94
+ await write(root, "pkgs/@akanjs/devkit/linter.ts", `import b from "pkg-b";\nexport const x = b;\n`);
95
+ const scanner = await scannerFor(root);
96
+
97
+ expect((await scanner.getPackageBuildDependencies("@akanjs/devkit")).missingDeps).toEqual(["pkg-b"]);
98
+ });
99
+ });
@@ -7,6 +7,11 @@ import { FileSys } from "./fileSys";
7
7
  import type { PackageJson, TsConfigJson } from "./types";
8
8
 
9
9
  const testFileRegex = /\.(?:test|spec)\.[cm]?[tj]sx?$/;
10
+ // `__fixtures__` holds deliberately invalid sample sources for the lint-rule suites; tsconfig `exclude` and
11
+ // biome's `!` override already skip them, and their imports are violations rather than real dependencies.
12
+ const skipDirs = ["node_modules", "dist", "build", ".git", ".next", "public", "ios", "android", "__fixtures__"];
13
+ const isSkippedPath = (filePath: string) =>
14
+ skipDirs.some((dir) => filePath.includes(`/${dir}/`) || filePath.startsWith(`${dir}/`));
10
15
  const builtinModuleSet = new Set([...builtinModules, ...builtinModules.map((mod) => `node:${mod}`)]);
11
16
  const stripShebang = (source: string) => source.replace(/^#!.*(?:\r?\n|$)/, "");
12
17
 
@@ -17,10 +22,10 @@ export class TypeScriptDependencyScanner {
17
22
  #visitedFiles = new Set<string>();
18
23
  readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
19
24
  readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
20
- private readonly directory: string;
21
- private readonly rootPackageJson: PackageJson;
22
- private readonly ig: ReturnType<typeof ignore>;
23
- private readonly workspaceRoot: string;
25
+ readonly #directory: string;
26
+ readonly #rootPackageJson: PackageJson;
27
+ readonly #ig: ReturnType<typeof ignore>;
28
+ readonly #workspaceRoot: string;
24
29
 
25
30
  constructor(
26
31
  directory: string,
@@ -30,10 +35,10 @@ export class TypeScriptDependencyScanner {
30
35
  gitignorePatterns = [],
31
36
  }: { workspaceRoot: string; tsconfig: TsConfigJson; rootPackageJson: PackageJson; gitignorePatterns?: string[] },
32
37
  ) {
33
- this.directory = directory;
34
- this.rootPackageJson = rootPackageJson;
35
- this.ig = ignore().add(gitignorePatterns);
36
- this.workspaceRoot = workspaceRoot;
38
+ this.#directory = directory;
39
+ this.#rootPackageJson = rootPackageJson;
40
+ this.#ig = ignore().add(gitignorePatterns);
41
+ this.#workspaceRoot = workspaceRoot;
37
42
  }
38
43
 
39
44
  async getMonorepoDependencies(
@@ -41,7 +46,7 @@ export class TypeScriptDependencyScanner {
41
46
  { pkgs = [], libs = [] }: { pkgs?: string[]; libs?: string[] } = {},
42
47
  ): Promise<{ pkgDeps: string[]; libDeps: string[]; npmDeps: string[]; npmDevDeps: string[] }> {
43
48
  const npmSet = new Set(
44
- Object.keys({ ...this.rootPackageJson.dependencies, ...this.rootPackageJson.devDependencies }),
49
+ Object.keys({ ...this.#rootPackageJson.dependencies, ...this.#rootPackageJson.devDependencies }),
45
50
  );
46
51
  const pkgPathSet = new Set(pkgs);
47
52
  const libPathSet = new Set(libs.map((lib) => `@libs/${lib}`));
@@ -68,11 +73,11 @@ export class TypeScriptDependencyScanner {
68
73
  ): Promise<{ npmDeps: string[]; npmDevDeps: string[]; missingDeps: string[] }> {
69
74
  const runtimeDeps = new Set<string>();
70
75
  const devDeps = new Set<string>();
71
- const sourceFiles = await this.#findTypeScriptFiles(this.directory, {
76
+ const sourceFiles = await this.#findTypeScriptFiles(this.#directory, {
72
77
  excludeBuildFiles: true,
73
78
  excludeTestFiles: true,
74
79
  });
75
- const cssFiles = await this.#findCssFiles(this.directory);
80
+ const cssFiles = await this.#findCssFiles(this.#directory);
76
81
 
77
82
  for (const filePath of sourceFiles) {
78
83
  const fileContent = await FileSys.readText(filePath);
@@ -86,7 +91,7 @@ export class TypeScriptDependencyScanner {
86
91
  this.#addNormalizedImports(runtimeDeps, this.#extractCssPluginImports(fileContent), projectName);
87
92
  }
88
93
 
89
- const buildFilePath = path.join(this.directory, "build.ts");
94
+ const buildFilePath = path.join(this.#directory, "build.ts");
90
95
  if (await FileSys.fileExists(buildFilePath)) {
91
96
  const fileContent = await FileSys.readText(buildFilePath);
92
97
  const { imports, typeImports } = this.#extractImports(fileContent, buildFilePath);
@@ -95,7 +100,7 @@ export class TypeScriptDependencyScanner {
95
100
 
96
101
  for (const dep of runtimeDeps) devDeps.delete(dep);
97
102
 
98
- const rootDeps = { ...this.rootPackageJson.dependencies, ...this.rootPackageJson.devDependencies };
103
+ const rootDeps = { ...this.#rootPackageJson.dependencies, ...this.#rootPackageJson.devDependencies };
99
104
  const missingDeps: string[] = [];
100
105
  for (const dep of [...runtimeDeps, ...devDeps]) {
101
106
  if (rootDeps[dep] || (await this.#hasWorkspacePackage(dep))) continue;
@@ -109,7 +114,7 @@ export class TypeScriptDependencyScanner {
109
114
  }
110
115
 
111
116
  async #hasWorkspacePackage(dep: string) {
112
- const packageJsonPath = path.join(this.workspaceRoot, "pkgs", dep, "package.json");
117
+ const packageJsonPath = path.join(this.#workspaceRoot, "pkgs", dep, "package.json");
113
118
  if (!(await Bun.file(packageJsonPath).exists())) return false;
114
119
  try {
115
120
  const packageJson = await FileSys.readJson<PackageJson>(packageJsonPath);
@@ -156,9 +161,9 @@ export class TypeScriptDependencyScanner {
156
161
  this.#fileTypeDependencies.clear();
157
162
  this.#visitedFiles.clear();
158
163
 
159
- const files = await this.#findTypeScriptFiles(this.directory);
164
+ const files = await this.#findTypeScriptFiles(this.#directory);
160
165
 
161
- for (const file of files) await this.#analyzeFile(file, this.directory);
166
+ for (const file of files) await this.#analyzeFile(file, this.#directory);
162
167
 
163
168
  return this.#fileDependencies;
164
169
  }
@@ -171,17 +176,16 @@ export class TypeScriptDependencyScanner {
171
176
  }: { excludeBuildFiles?: boolean; excludeTestFiles?: boolean } = {},
172
177
  ): Promise<string[]> {
173
178
  const files: string[] = [];
174
- const skipDirs = ["node_modules", "dist", "build", ".git", ".next", "public", "ios", "android"];
175
179
 
176
180
  const glob = new Bun.Glob("**/*.{ts,tsx}");
177
181
  for await (const filePath of glob.scan({ cwd: directory, onlyFiles: true })) {
178
- if (skipDirs.some((dir) => filePath.includes(`/${dir}/`) || filePath.startsWith(`${dir}/`))) continue;
182
+ if (isSkippedPath(filePath)) continue;
179
183
  if (excludeBuildFiles && filePath === "build.ts") continue;
180
184
  if (excludeTestFiles && testFileRegex.test(filePath)) continue;
181
185
 
182
186
  const fullPath = path.join(directory, filePath);
183
- const relativePath = path.relative(this.workspaceRoot, fullPath);
184
- if (this.ig.ignores(relativePath)) continue;
187
+ const relativePath = path.relative(this.#workspaceRoot, fullPath);
188
+ if (this.#ig.ignores(relativePath)) continue;
185
189
 
186
190
  files.push(fullPath);
187
191
  }
@@ -190,14 +194,13 @@ export class TypeScriptDependencyScanner {
190
194
 
191
195
  async #findCssFiles(directory: string): Promise<string[]> {
192
196
  const files: string[] = [];
193
- const skipDirs = ["node_modules", "dist", "build", ".git", ".next", "public", "ios", "android"];
194
197
  const glob = new Bun.Glob("**/*.css");
195
198
  for await (const filePath of glob.scan({ cwd: directory, onlyFiles: true })) {
196
- if (skipDirs.some((dir) => filePath.includes(`/${dir}/`) || filePath.startsWith(`${dir}/`))) continue;
199
+ if (isSkippedPath(filePath)) continue;
197
200
 
198
201
  const fullPath = path.join(directory, filePath);
199
- const relativePath = path.relative(this.workspaceRoot, fullPath);
200
- if (this.ig.ignores(relativePath)) continue;
202
+ const relativePath = path.relative(this.#workspaceRoot, fullPath);
203
+ if (this.#ig.ignores(relativePath)) continue;
201
204
 
202
205
  files.push(fullPath);
203
206
  }
@@ -6,8 +6,6 @@ import { ApplicationBuildReporter } from "./applicationBuildReporter";
6
6
  import { resolveSignalTestPreloadPath } from "./applicationTestPreload";
7
7
  import { TypeScriptDependencyScanner } from "./dependencyScanner";
8
8
  import { AppExecutor, WorkspaceExecutor } from "./executors";
9
- import { extractDependencies } from "./extractDeps";
10
- import { getModelFileData } from "./getModelFileData";
11
9
  import type { PackageJson, TsConfigJson } from "./types";
12
10
 
13
11
  const tempRoots: string[] = [];
@@ -27,65 +25,6 @@ afterEach(async () => {
27
25
  await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
28
26
  });
29
27
 
30
- describe("extractDependencies", () => {
31
- const packageJson: PackageJson = {
32
- name: "fixture",
33
- version: "1.0.0",
34
- description: "fixture",
35
- dependencies: {
36
- react: "19.0.0",
37
- "@scope/pkg": "1.0.0",
38
- lodash: "4.0.0",
39
- },
40
- devDependencies: {
41
- typescript: "6.0.0",
42
- vite: "5.0.0",
43
- },
44
- };
45
-
46
- test("extracts runtime package versions from imports and requires", () => {
47
- const deps = extractDependencies(
48
- [
49
- {
50
- path: "index.ts",
51
- text: [
52
- 'import React from "react";',
53
- 'import { value } from "@scope/pkg/subpath";',
54
- 'import type { Type } from "typescript";',
55
- 'const lodash = require("lodash/fp");',
56
- 'const fs = require("node:fs");',
57
- 'const path = require("path");',
58
- "",
59
- ].join("\n"),
60
- },
61
- { path: "style.css", text: '@import "vite";' },
62
- ],
63
- packageJson,
64
- ["vite"],
65
- );
66
-
67
- expect(deps).toEqual({
68
- "@scope/pkg": "1.0.0",
69
- lodash: "4.0.0",
70
- react: "19.0.0",
71
- typescript: "6.0.0",
72
- vite: "5.0.0",
73
- });
74
- });
75
-
76
- test("reports missing dependency sections and missing versions", () => {
77
- expect(() =>
78
- extractDependencies([{ path: "index.ts", text: 'import React from "react";' }], {
79
- name: "broken",
80
- version: "1.0.0",
81
- description: "broken",
82
- }),
83
- ).toThrow("No dependencies found");
84
-
85
- expect(() => extractDependencies([], packageJson, ["missing"])).toThrow("No version found for missing");
86
- });
87
- });
88
-
89
28
  describe("resolveSignalTestPreloadPath", () => {
90
29
  test("resolves the preload file from an installed akanjs package", async () => {
91
30
  const root = await makeTempRoot();
@@ -252,49 +191,6 @@ describe("scan convention", () => {
252
191
  });
253
192
  });
254
193
 
255
- describe("getModelFileData", () => {
256
- test("reads model files and derives imported local, scalar, and lib models", async () => {
257
- const root = await makeTempRoot();
258
- const cwd = process.cwd();
259
- process.chdir(root);
260
- try {
261
- await write(
262
- path.join(root, "apps/demo/lib/post/post.constant.ts"),
263
- [
264
- 'import { cnst as shared } from "@libs/shared";',
265
- 'import { User } from "../user/user.constant";',
266
- 'import { Money } from "../_money/money.constant";',
267
- "export const Post = {};",
268
- "",
269
- ].join("\n"),
270
- );
271
- await write(
272
- path.join(root, "apps/demo/lib/post/Post.Unit.tsx"),
273
- "export default function Unit() { return null; }\n",
274
- );
275
- await write(
276
- path.join(root, "apps/demo/lib/post/Post.View.tsx"),
277
- "export default function View() { return null; }\n",
278
- );
279
-
280
- const data = await getModelFileData("apps/demo", "post");
281
- expect(data).toMatchObject({
282
- moduleType: "app",
283
- moduleName: "demo",
284
- modelName: "post",
285
- importModelNames: ["user"],
286
- hasImportScalar: true,
287
- importLibNames: ["shared"],
288
- });
289
- expect(data.constantFileStr).toContain("export const Post");
290
- expect(data.unitFileStr).toContain("Unit");
291
- expect(data.viewFileStr).toContain("View");
292
- } finally {
293
- process.chdir(cwd);
294
- }
295
- });
296
- });
297
-
298
194
  describe("ApplicationBuildReporter", () => {
299
195
  test("formats duration, phase lines, and nested errors", () => {
300
196
  expect(ApplicationBuildReporter.formatDuration(999)).toBe("999ms");
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
 
@@ -269,6 +315,11 @@ describe("Workspace and app executor environment contracts", () => {
269
315
  const prepared = await app.prepareCommand("build");
270
316
  expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
271
317
  expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
318
+ // Bundling reads `process.env` through getPublicEnv and `define`s every AKAN_PUBLIC_* into a literal, so a
319
+ // dev port published here would be baked into the artifact and outrank the PORT the container is run with.
320
+ expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("demo");
321
+ expect(process.env.AKAN_PUBLIC_CLIENT_PORT).toBeUndefined();
322
+ expect(process.env.AKAN_PUBLIC_SERVER_PORT).toBeUndefined();
272
323
  expect((await stat(path.join(root, "dist/apps/demo/private"))).isDirectory()).toBe(true);
273
324
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
274
325
  });
@@ -620,6 +671,64 @@ describe("Workspace and app executor environment contracts", () => {
620
671
  });
621
672
  });
622
673
 
674
+ describe("root layout source validation during page key discovery", () => {
675
+ const makeRouteValidationApp = async (appName: string, config: string, files: Record<string, string>) => {
676
+ const root = await makeTempRoot();
677
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
678
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
679
+ process.env.AKAN_PUBLIC_ENV = "local";
680
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
681
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
682
+ await writeFile(path.join(root, `apps/${appName}/akan.config.ts`), config);
683
+ for (const [file, source] of Object.entries(files)) {
684
+ const filePath = path.join(root, `apps/${appName}/page`, file);
685
+ await mkdir(path.dirname(filePath), { recursive: true });
686
+ await writeFile(filePath, source);
687
+ }
688
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
689
+ return AppExecutor.from(workspace, appName);
690
+ };
691
+ const wsConnectLayout = [
692
+ "export const wsConnect = false;",
693
+ "export default function Layout({ children }) { return children; }",
694
+ "",
695
+ ].join("\n");
696
+
697
+ test("accepts wsConnect on a configured base-path root layout", async () => {
698
+ process.env.AKAN_PUBLIC_BASE_PATHS = "web,admin";
699
+ const app = await makeRouteValidationApp(
700
+ "base-path-root-ws",
701
+ [
702
+ "export default {",
703
+ ' routes: [{ basePath: "web", domains: {} }, { basePath: "admin", domains: {} }],',
704
+ "};",
705
+ "",
706
+ ].join("\n"),
707
+ { "admin/_layout.tsx": wsConnectLayout },
708
+ );
709
+
710
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./admin/_layout.tsx"]);
711
+ });
712
+
713
+ test("rejects wsConnect on a nested layout", async () => {
714
+ const app = await makeRouteValidationApp(
715
+ "nested-layout-ws",
716
+ 'export default { routes: [{ basePath: "admin", domains: {} }] };\n',
717
+ { "admin/users/_layout.tsx": wsConnectLayout },
718
+ );
719
+
720
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(/unsupported export "wsConnect"/);
721
+ });
722
+
723
+ test("accepts wsConnect on a grouped root layout", async () => {
724
+ const app = await makeRouteValidationApp("grouped-root-ws", "export default {};\n", {
725
+ "(docs)/_layout.tsx": wsConnectLayout,
726
+ });
727
+
728
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./(docs)/_layout.tsx"]);
729
+ });
730
+ });
731
+
623
732
  test("accepts metadata route exports during page key discovery", async () => {
624
733
  const root = await makeTempRoot();
625
734
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
@@ -758,6 +867,29 @@ describe("Workspace and app executor environment contracts", () => {
758
867
  expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
759
868
  expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
760
869
  });
870
+
871
+ test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
872
+ const root = await makeTempRoot();
873
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
874
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
875
+ process.env.AKAN_PUBLIC_ENV = "local";
876
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
877
+ await mkdir(path.join(root, "apps/ambient"), { recursive: true });
878
+ await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
879
+
880
+ const originalNodeEnv = process.env.NODE_ENV;
881
+ process.env.NODE_ENV = "production";
882
+ try {
883
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
884
+ const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
885
+ expect(started.env.NODE_ENV).toBe("development");
886
+ // The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
887
+ expect(process.env.NODE_ENV).toBe("development");
888
+ } finally {
889
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
890
+ else process.env.NODE_ENV = originalNodeEnv;
891
+ }
892
+ });
761
893
  });
762
894
 
763
895
  describe("PkgExecutor package generation", () => {
@@ -771,7 +903,7 @@ describe("PkgExecutor package generation", () => {
771
903
  exports: { "./extra": { import: "./extra.ts" } },
772
904
  peerDependencies: { react: "19.0.0" },
773
905
  peerDependenciesMeta: { react: { optional: true } },
774
- optionalDependencies: { sharp: "1.0.0" },
906
+ optionalDependencies: { "@sample/native": "1.0.0" },
775
907
  });
776
908
 
777
909
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -781,12 +913,12 @@ describe("PkgExecutor package generation", () => {
781
913
  expect(distPackageJson).toMatchObject({
782
914
  name: "@sample/tool",
783
915
  type: "module",
784
- engines: { bun: ">=1.3.13" },
916
+ engines: { bun: ">=1.4.0" },
785
917
  dependencies: { lodash: "4.0.0" },
786
918
  devDependencies: { typescript: "6.0.0" },
787
919
  peerDependencies: { react: "19.0.0" },
788
920
  peerDependenciesMeta: { react: { optional: true } },
789
- optionalDependencies: { sharp: "1.0.0" },
921
+ optionalDependencies: { "@sample/native": "1.0.0" },
790
922
  });
791
923
  expect(distPackageJson.exports?.["."]).toEqual({
792
924
  import: "./index.ts",