@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
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { chmod, mkdir } from "node:fs/promises";
2
2
  import dayjs from "dayjs";
3
3
  import { FileSys } from "../fileSys";
4
4
  import {
@@ -29,9 +29,16 @@ export class GlobalConfig {
29
29
  remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
30
30
  };
31
31
  }
32
+ /**
33
+ * This file holds the cloud jwt, a refresh token that does not expire, and the LLM api key, so it is
34
+ * written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
35
+ * and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
36
+ * existing world-readable file is tightened by the next write rather than left as it was found.
37
+ */
32
38
  static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
33
- await mkdir(basePath, { recursive: true });
39
+ await mkdir(basePath, { recursive: true, mode: 0o700 });
34
40
  await Bun.write(configPath, JSON.stringify(akanConfig, null, 2));
41
+ await chmod(configPath, 0o600);
35
42
  }
36
43
  static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
37
44
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
@@ -104,6 +104,26 @@ const select = ((config, context) => prompts().then((m) => m.select(config, cont
104
104
  const confirm = ((config, context) => prompts().then((m) => m.confirm(config, context))) as typeof inquirerConfirm;
105
105
  const input = ((config, context) => prompts().then((m) => m.input(config, context))) as typeof inquirerInput;
106
106
 
107
+ /**
108
+ * Rejects a value that is not one of the declared choices, in commander's own wording.
109
+ *
110
+ * Only a static choice list can be checked: a `DynamicEnum` resolves against the command context, which
111
+ * is not populated until the internal args are resolved, and it is the interactive `select` that consumes
112
+ * it. Comparison is stringly on purpose — the value still carries commander's raw string here, while a
113
+ * numeric choice list holds numbers.
114
+ */
115
+ const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
116
+ const enumChoices = argMeta.argsOption.enum;
117
+ if (!enumChoices || typeof enumChoices === "function") return;
118
+ const choices = normalizeEnumChoices(enumChoices);
119
+ if (choices.some((choice) => String(choice.value) === String(value))) return;
120
+ const label =
121
+ argMeta.type === "Option" ? `option '--${camelToKebabCase(argMeta.name)}'` : `argument '${argMeta.name}'`;
122
+ throw new Error(
123
+ `${label} argument '${String(value)}' is invalid. Allowed choices are ${choices.map((choice) => choice.name).join(", ")}.`,
124
+ );
125
+ };
126
+
107
127
  const resolveEnumChoices = async (argMeta: ArgMeta, context: CommandContext) => {
108
128
  const enumChoices = argMeta.argsOption.enum;
109
129
  if (!enumChoices) return null;
@@ -111,13 +131,15 @@ const resolveEnumChoices = async (argMeta: ArgMeta, context: CommandContext) =>
111
131
  return enumChoices;
112
132
  };
113
133
 
114
- const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, context: CommandContext) => {
134
+ export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, context: CommandContext) => {
115
135
  const {
116
136
  name,
117
137
  argsOption: { enum: enumChoices, default: defaultValue, type, desc, nullable, example, ask },
118
138
  } = argMeta;
119
- if (opt[argMeta.name] !== undefined) return convertArgValue(opt[argMeta.name] as string, type ?? "string");
120
- else if (defaultValue !== undefined) return defaultValue;
139
+ if (opt[argMeta.name] !== undefined) {
140
+ assertEnumChoice(argMeta, opt[argMeta.name]);
141
+ return convertArgValue(opt[argMeta.name] as string, type ?? "string");
142
+ } else if (defaultValue !== undefined) return defaultValue;
121
143
 
122
144
  if (enumChoices) {
123
145
  const choices = normalizeEnumChoices((await resolveEnumChoices(argMeta, context)) ?? []);
@@ -139,13 +161,15 @@ const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, co
139
161
  }
140
162
  };
141
163
 
142
- const getArgumentValue = async (argMeta: ArgMeta, value: string | undefined) => {
164
+ export const getArgumentValue = async (argMeta: ArgMeta, value: string | undefined) => {
143
165
  const {
144
166
  name,
145
167
  argsOption: { default: defaultValue, type, desc, nullable, example, ask },
146
168
  } = argMeta;
147
- if (value !== undefined) return convertArgValue(value, type ?? "string");
148
- else if (defaultValue !== undefined) return defaultValue;
169
+ if (value !== undefined) {
170
+ assertEnumChoice(argMeta, value);
171
+ return convertArgValue(value, type ?? "string");
172
+ } else if (defaultValue !== undefined) return defaultValue;
149
173
  else if (nullable) return null;
150
174
 
151
175
  const message = ask
@@ -23,9 +23,24 @@ type PrimitiveValue<T extends PrimitiveArgType> = T extends StringConstructor
23
23
  ? number
24
24
  : boolean;
25
25
  type MaybeNullable<Value, Option> = Option extends { nullable: true } ? Value | null : Value;
26
+ /**
27
+ * The literal union a static `enum` declares, or `never` for a `DynamicEnum` — a function does not extend
28
+ * a readonly array, so a runtime-resolved choice list falls back to the primitive type. `{ label, value }`
29
+ * choices contribute their `value`.
30
+ */
31
+ type EnumValue<Option> = Option extends { enum: infer Choices }
32
+ ? Choices extends readonly (infer Choice)[]
33
+ ? Choice extends { value: infer Value }
34
+ ? Value
35
+ : Choice
36
+ : never
37
+ : never;
38
+ type ArgValue<Type extends PrimitiveArgType, Option> = [EnumValue<Option>] extends [never]
39
+ ? PrimitiveValue<Type>
40
+ : EnumValue<Option>;
26
41
  type AddArg<Params extends unknown[], Type extends PrimitiveArgType, Option> = [
27
42
  ...Params,
28
- MaybeNullable<PrimitiveValue<Type>, Option>,
43
+ MaybeNullable<ArgValue<Type, Option>, Option>,
29
44
  ];
30
45
  type AddInternalArg<Params extends unknown[], Token extends InternalArgToken> = [...Params, Token["_value"]];
31
46
  type AddInternalArgs<Params extends unknown[], Tokens extends readonly InternalArgToken[]> = Tokens extends readonly [
@@ -68,7 +83,7 @@ class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknow
68
83
  this.#args = args;
69
84
  }
70
85
 
71
- arg<Type extends PrimitiveArgType, Option extends ArgsOption<Context> = ArgsOption<Context>>(
86
+ arg<Type extends PrimitiveArgType, const Option extends ArgsOption<Context> = ArgsOption<Context>>(
72
87
  name: string,
73
88
  type: Type,
74
89
  argsOption: Option = {} as Option,
@@ -85,7 +100,7 @@ class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknow
85
100
  ]);
86
101
  }
87
102
 
88
- option<Type extends PrimitiveArgType, Option extends ArgsOption<Context> = ArgsOption<Context>>(
103
+ option<Type extends PrimitiveArgType, const Option extends ArgsOption<Context> = ArgsOption<Context>>(
89
104
  name: string,
90
105
  type: Type,
91
106
  argsOption: Option = {} as Option,
@@ -135,12 +150,12 @@ type CommandBuilderContext<Deps extends readonly DependencyCls[]> = {
135
150
  public: (targetOption?: Omit<TargetOption, "type">) => TargetBuilder<Deps>;
136
151
  cloud: (targetOption?: Omit<TargetOption, "type">) => TargetBuilder<Deps>;
137
152
  dev: (targetOption?: Omit<TargetOption, "type">) => TargetBuilder<Deps>;
138
- arg: <Type extends PrimitiveArgType, Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
153
+ arg: <Type extends PrimitiveArgType, const Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
139
154
  name: string,
140
155
  type: Type,
141
156
  argsOption?: Option,
142
157
  ) => ArgMeta;
143
- option: <Type extends PrimitiveArgType, Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
158
+ option: <Type extends PrimitiveArgType, const Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
144
159
  name: string,
145
160
  type: Type,
146
161
  argsOption?: Option,
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { AppExecutor, WorkspaceExecutor } from "../executors";
3
- import { App, getArgMetas, Lib, Module, Pkg, Sys, Workspace } from "./argMeta";
4
- import { getInternalArgumentValue } from "./command";
3
+ import { App, type ArgMeta, type ArgsOption, getArgMetas, Lib, Module, Pkg, Sys, Workspace } from "./argMeta";
4
+ import { getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
5
5
  import { command } from "./commandBuilder";
6
6
  import {
7
7
  assertUniqueDependencies,
@@ -136,7 +136,7 @@ describe("command helper metadata", () => {
136
136
 
137
137
  const app = await getInternalArgumentValue({ key: "", idx: 0, type: "App" }, undefined, workspace);
138
138
 
139
- expect(app).toBeInstanceOf(AppExecutor);
139
+ if (!(app instanceof AppExecutor)) throw new Error(`expected an AppExecutor, got ${app.constructor.name}`);
140
140
  expect(app.name).toBe("single-command-test-app");
141
141
  expect(app.workspace).toBe(workspace);
142
142
  });
@@ -223,3 +223,115 @@ describe("command helper dependency injection", () => {
223
223
  expect(descriptor?.writable).toBe(false);
224
224
  });
225
225
  });
226
+
227
+ describe("enum arg choices", () => {
228
+ const optionMeta = (enumChoices: ArgsOption["enum"]): ArgMeta => ({
229
+ name: "format",
230
+ argsOption: { type: "string", enum: enumChoices },
231
+ key: "run",
232
+ idx: 0,
233
+ type: "Option",
234
+ });
235
+ const argumentMeta = (enumChoices: ArgsOption["enum"]): ArgMeta => ({
236
+ ...optionMeta(enumChoices),
237
+ name: "action",
238
+ type: "Argument",
239
+ });
240
+ const context = { values: {} };
241
+
242
+ test("passes a declared choice through", async () => {
243
+ expect(await getOptionValue(optionMeta(["text", "json"]), { format: "json" }, context)).toBe("json");
244
+ });
245
+
246
+ // Before this check `akan quality --format yaml` reached the script as "yaml" while the declaration
247
+ // claimed the parameter was "text" | "json".
248
+ test("rejects an undeclared value in commander's wording", async () => {
249
+ await expect(getOptionValue(optionMeta(["text", "json"]), { format: "yaml" }, context)).rejects.toThrow(
250
+ "option '--format' argument 'yaml' is invalid. Allowed choices are text, json.",
251
+ );
252
+ });
253
+
254
+ test("names a positional argument as an argument", async () => {
255
+ await expect(getArgumentValue(argumentMeta(["scan", "ssr"]), "sssr")).rejects.toThrow(
256
+ "argument 'action' argument 'sssr' is invalid. Allowed choices are scan, ssr.",
257
+ );
258
+ });
259
+
260
+ test("kebab-cases a camelCase option name the way the flag is spelled", async () => {
261
+ await expect(
262
+ getOptionValue({ ...optionMeta(["apk", "aab"]), name: "assembleType" }, { assembleType: "ipa" }, context),
263
+ ).rejects.toThrow("option '--assemble-type'");
264
+ });
265
+
266
+ test("compares a labelled choice by its value, not its label", async () => {
267
+ const labelled = optionMeta([{ label: "JSON output", value: "json" }]);
268
+ expect(await getOptionValue(labelled, { format: "json" }, context)).toBe("json");
269
+ await expect(getOptionValue(labelled, { format: "JSON output" }, context)).rejects.toThrow("is invalid");
270
+ });
271
+
272
+ // A DynamicEnum resolves against a context that is not populated yet, and the interactive select is its
273
+ // only consumer, so an explicit value passes through unchecked rather than being rejected wrongly.
274
+ test("leaves a dynamic choice list unchecked", async () => {
275
+ expect(
276
+ await getOptionValue(
277
+ optionMeta(() => ["text"]),
278
+ { format: "anything" },
279
+ context,
280
+ ),
281
+ ).toBe("anything");
282
+ });
283
+
284
+ test("an option with no enum is unaffected", async () => {
285
+ expect(await getOptionValue(optionMeta(undefined), { format: "whatever" }, context)).toBe("whatever");
286
+ });
287
+ });
288
+
289
+ describe("enum arg type inference", () => {
290
+ afterEach(() => {
291
+ CommandContainer.clear();
292
+ });
293
+
294
+ /**
295
+ * Compile-time only. Nothing here can fail at runtime: if `enum` stops narrowing, the parameter widens
296
+ * to the primitive and these calls stop typechecking, so the regression surfaces in `akan typecheck`
297
+ * rather than in fifteen `as "a" | "b"` casts creeping back into the command files.
298
+ */
299
+ const expectLiteral = <Expected>(_value: Expected) => undefined;
300
+
301
+ test("a static enum narrows the exec parameter to its declared choices", () => {
302
+ class InferenceRunner extends runner("inference") {}
303
+ class InferenceScript extends script("inference", [InferenceRunner]) {}
304
+
305
+ class InferenceCommand extends command("inference", [InferenceScript], ({ public: target }) => ({
306
+ run: target({ desc: "run" })
307
+ .arg("action", String, { enum: ["scan", "ssr"] })
308
+ .option("format", String, { enum: ["text", "json"], default: "text" })
309
+ .option("count", Number, { enum: [1, 2] })
310
+ .option("scope", String, { enum: ["all", "one"], nullable: true })
311
+ .option("label", String, { enum: [{ label: "JSON output", value: "json" }] })
312
+ .option("plain", String, { desc: "no choices declared" })
313
+ .option("dynamic", String, { enum: () => ["a", "b"] })
314
+ .exec(async (action, format, count, scope, label, plain, dynamic) => {
315
+ expectLiteral<"scan" | "ssr">(action);
316
+ expectLiteral<"text" | "json">(format);
317
+ expectLiteral<1 | 2>(count);
318
+ expectLiteral<"all" | "one" | null>(scope);
319
+ expectLiteral<"json">(label);
320
+ expectLiteral<string>(plain);
321
+ expectLiteral<string>(dynamic);
322
+ }),
323
+ })) {}
324
+
325
+ const [targetMeta] = getTargetMetas(InferenceCommand);
326
+ expect(targetMeta?.key).toBe("run");
327
+ expect(targetMeta?.args.map((arg) => ("name" in arg ? arg.name : arg.type))).toEqual([
328
+ "action",
329
+ "format",
330
+ "count",
331
+ "scope",
332
+ "label",
333
+ "plain",
334
+ "dynamic",
335
+ ]);
336
+ });
337
+ });
@@ -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");