@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.90

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 (183) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +27 -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 +21 -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 +30 -14
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +1 -1
  22. package/applicationBuildRunner.ts +45 -21
  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 +86 -47
  27. package/biomeBase.ts +6 -5
  28. package/capacitorApp.ts +10 -0
  29. package/cloud/globalConfig.ts +9 -2
  30. package/codegenLock.test.ts +137 -0
  31. package/codegenLock.ts +136 -0
  32. package/commandDecorators/argMeta.ts +9 -1
  33. package/commandDecorators/command.ts +94 -7
  34. package/commandDecorators/commandBuilder.ts +20 -5
  35. package/commandDecorators/commandDecorators.test.ts +115 -3
  36. package/commandDecorators/helpFormatter.ts +4 -0
  37. package/dependencyScanner.test.ts +99 -0
  38. package/dependencyScanner.ts +27 -24
  39. package/devkitUtils.test.ts +0 -104
  40. package/executors.test.ts +129 -4
  41. package/executors.ts +105 -24
  42. package/fileEditor.ts +19 -19
  43. package/fileSys.ts +19 -1
  44. package/formSetterScanner.test.ts +80 -0
  45. package/formSetterScanner.ts +92 -0
  46. package/frontendBuild/autoImportSync.test.ts +58 -0
  47. package/frontendBuild/autoImportSync.ts +7 -2
  48. package/frontendBuild/buildRouteClient.test.ts +47 -24
  49. package/frontendBuild/clientBuildTypes.ts +4 -0
  50. package/frontendBuild/clientEntriesBundler.ts +4 -1
  51. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  52. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  53. package/frontendBuild/cssCompiler.ts +123 -12
  54. package/frontendBuild/cssImportResolver.ts +8 -7
  55. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  56. package/frontendBuild/fontOptimizer.ts +37 -18
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +178 -10
  60. package/frontendBuild/hmrWatcher.ts +1 -1
  61. package/frontendBuild/index.ts +1 -1
  62. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  63. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  64. package/frontendBuild/routeClientBuilder.ts +12 -5
  65. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  66. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  67. package/frontendBuild/styleContract.ts +14 -20
  68. package/frontendBuild/themeValidator.ts +22 -17
  69. package/frontendBuild/vendorSpecifiers.ts +1 -0
  70. package/frontendBuild/watchRootResolver.test.ts +67 -0
  71. package/frontendBuild/watchRootResolver.ts +13 -1
  72. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  73. package/incrementalBuilder/devWatchBatch.ts +6 -2
  74. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  75. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  76. package/index.ts +0 -7
  77. package/integration/devStabilityHarness.ts +2 -10
  78. package/libSource.test.ts +112 -0
  79. package/libSource.ts +126 -0
  80. package/lint/__fixtures__/README.md +40 -0
  81. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  83. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  84. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  85. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  86. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  87. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  88. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  89. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  90. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  91. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  92. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  93. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  95. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  96. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  97. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  98. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  99. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  100. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  101. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  102. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  103. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  104. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  105. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  106. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  107. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  108. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  110. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  111. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  112. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  113. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  114. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  116. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  117. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  118. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  119. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  120. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  121. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  122. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  123. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  124. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  125. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  126. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  127. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  128. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  129. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  130. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  131. package/lint/gritRules.test.ts +178 -0
  132. package/lint/no-arbitrary-color.grit +1 -1
  133. package/lint/no-async-component-in-ui.grit +35 -0
  134. package/lint/no-bang-comment-in-client.grit +23 -10
  135. package/lint/no-daisyui-legacy-class.grit +26 -9
  136. package/lint/no-deprecated-log-level.grit +17 -0
  137. package/lint/no-init-fetch-in-client.grit +47 -0
  138. package/lint/no-inline-color.grit +10 -8
  139. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  140. package/lint/no-model-type-in-util-zone.grit +58 -0
  141. package/lint/no-raw-palette-class.grit +3 -3
  142. package/lint/no-unpublished-form-setter.grit +41 -0
  143. package/lint/non-scalar-props-restricted.grit +16 -7
  144. package/linter.test.ts +80 -0
  145. package/linter.ts +99 -25
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +116 -0
  149. package/qualityScanner.ts +89 -21
  150. package/recipeScanner.ts +4 -1
  151. package/scanInfo.ts +32 -26
  152. package/semver.test.ts +26 -0
  153. package/semver.ts +31 -0
  154. package/slicePlanner.test.ts +182 -0
  155. package/slicePlanner.ts +235 -0
  156. package/ssrScanner.test.ts +301 -0
  157. package/subspace.test.ts +418 -0
  158. package/subspace.ts +723 -0
  159. package/subspaceConfig.ts +76 -0
  160. package/transforms/asyncDefaultExportDetector.ts +103 -0
  161. package/transforms/barrelImportsPlugin.ts +1 -1
  162. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  163. package/transforms/transforms.test.ts +5 -5
  164. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  165. package/tsconfig.json +6 -2
  166. package/typeChecker.ts +1 -1
  167. package/types.ts +1 -0
  168. package/uploadRelease.ts +2 -2
  169. package/workflow/executor.test.ts +146 -0
  170. package/workspaceLayout.test.ts +56 -4
  171. package/workspaceLayout.ts +48 -2
  172. package/builder.ts +0 -164
  173. package/extractDeps.ts +0 -86
  174. package/frontendBuild/styleGuard.test.ts +0 -165
  175. package/frontendBuild/styleGuard.ts +0 -322
  176. package/getCredentials.ts +0 -19
  177. package/getModelFileData.ts +0 -62
  178. package/src/capacitorApp.ts +0 -282
  179. package/streamAi.ts +0 -45
  180. package/ui/MultiScrollList.tsx +0 -242
  181. package/ui/ScrollList.tsx +0 -107
  182. package/ui/index.ts +0 -2
  183. package/useStdoutDimensions.ts +0 -20
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+ import type { SubspaceConfigInput, SubspaceDeclaration } from "akanjs";
3
+ import type { WorkspaceExecutor } from "./executors";
4
+ import { FileSys } from "./fileSys";
5
+
6
+ export type { SubspaceConfigInput, SubspaceDeclaration };
7
+
8
+ /**
9
+ * `akan.subspace.ts` at the workspace root: which customer repo each app is mirrored to.
10
+ *
11
+ * There is no branch field. The branch is whichever one the workspace is on, so the same declaration
12
+ * serves `develop` and `main`, and every subspace on one branch holds the same akanjs version and the
13
+ * same library source.
14
+ */
15
+ export class SubspaceConfig {
16
+ static readonly fileName = "akan.subspace.ts";
17
+ static readonly defaultPushableBranches = ["main", "develop", "debug"];
18
+
19
+ static async from(workspace: WorkspaceExecutor): Promise<SubspaceConfig | null> {
20
+ const configPath = path.join(workspace.workspaceRoot, SubspaceConfig.fileName);
21
+ if (!(await FileSys.fileExists(configPath))) return null;
22
+ const input = await import(configPath).then((mod: { default: SubspaceConfigInput }) => mod.default);
23
+ return new SubspaceConfig(input);
24
+ }
25
+
26
+ readonly pushableBranches: string[];
27
+ readonly exclude: string[];
28
+ readonly subspaces: SubspaceDeclaration[];
29
+
30
+ constructor(input: SubspaceConfigInput) {
31
+ this.pushableBranches = input.pushableBranches ?? SubspaceConfig.defaultPushableBranches;
32
+ this.exclude = input.exclude ?? [];
33
+ this.subspaces = input.subspaces ?? [];
34
+ this.#assertValid();
35
+ }
36
+
37
+ #assertValid() {
38
+ const seenNames = new Set<string>();
39
+ const appOwners = new Map<string, string>();
40
+ for (const subspace of this.subspaces) {
41
+ if (!subspace.name || !subspace.repo)
42
+ throw new Error(`${SubspaceConfig.fileName}: every subspace needs a name and a repo`);
43
+ if (seenNames.has(subspace.name))
44
+ throw new Error(`${SubspaceConfig.fileName}: duplicate subspace "${subspace.name}"`);
45
+ seenNames.add(subspace.name);
46
+ if (!subspace.apps?.length)
47
+ throw new Error(`${SubspaceConfig.fileName}: subspace "${subspace.name}" declares no apps`);
48
+ for (const app of subspace.apps) {
49
+ const owner = appOwners.get(app);
50
+ //* One app never goes to two subspaces: the two would need per-subspace domains and ids, which
51
+ //* this declaration cannot express. Share code through a lib instead.
52
+ if (owner)
53
+ throw new Error(
54
+ `${SubspaceConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
55
+ );
56
+ appOwners.set(app, subspace.name);
57
+ }
58
+ }
59
+ }
60
+
61
+ select(names: string[]) {
62
+ if (!names.length) return this.subspaces;
63
+ return names.map((name) => {
64
+ const subspace = this.subspaces.find((candidate) => candidate.name === name);
65
+ if (!subspace) throw new Error(`${SubspaceConfig.fileName}: unknown subspace "${name}"`);
66
+ return subspace;
67
+ });
68
+ }
69
+
70
+ assertPushable(branch: string) {
71
+ if (this.pushableBranches.includes(branch)) return;
72
+ throw new Error(
73
+ `Branch "${branch}" is not pushable. ${SubspaceConfig.fileName} allows: ${this.pushableBranches.join(", ")}`,
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type ts from "typescript";
4
+
5
+ type TypeScript = typeof ts;
6
+
7
+ export class AsyncDefaultExportDetector {
8
+ static #typescriptLoad: Promise<TypeScript> | undefined;
9
+
10
+ // `typescript` costs ~70 MB resident and this detector is reached from the cli entry through the root
11
+ // layout generator, so the compiler loads on first use rather than at import (`entryModuleGraph.test.ts`).
12
+ static #loadTypescript(): Promise<TypeScript> {
13
+ AsyncDefaultExportDetector.#typescriptLoad ??= import("typescript").then(
14
+ (mod) => (mod.default ?? mod) as TypeScript,
15
+ );
16
+ return AsyncDefaultExportDetector.#typescriptLoad;
17
+ }
18
+
19
+ static async detect(moduleAbsPath: string): Promise<boolean> {
20
+ try {
21
+ const typescript = await AsyncDefaultExportDetector.#loadTypescript();
22
+ const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
23
+ const sourceFile = typescript.createSourceFile(
24
+ moduleAbsPath,
25
+ source,
26
+ typescript.ScriptTarget.Latest,
27
+ true,
28
+ AsyncDefaultExportDetector.#scriptKind(typescript, moduleAbsPath),
29
+ );
30
+ return new AsyncDefaultExportDetector(typescript).detectInSourceFile(sourceFile);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ #ts: TypeScript;
37
+
38
+ constructor(typescript: TypeScript) {
39
+ this.#ts = typescript;
40
+ }
41
+
42
+ detectInSourceFile(sourceFile: ts.SourceFile): boolean {
43
+ const ts = this.#ts;
44
+ const asyncBindings = new Map<string, boolean>();
45
+ let defaultIdentifier: string | null = null;
46
+
47
+ for (const statement of sourceFile.statements) {
48
+ if (ts.isFunctionDeclaration(statement)) {
49
+ if (this.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
50
+ return this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
51
+ }
52
+ if (statement.name) {
53
+ asyncBindings.set(statement.name.text, this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword));
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (ts.isVariableStatement(statement)) {
59
+ for (const declaration of statement.declarationList.declarations) {
60
+ if (!ts.isIdentifier(declaration.name)) continue;
61
+ asyncBindings.set(declaration.name.text, this.#isAsyncFunctionExpression(declaration.initializer));
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (ts.isExportAssignment(statement)) {
67
+ if (this.#isAsyncFunctionExpression(statement.expression)) return true;
68
+ if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
69
+ continue;
70
+ }
71
+
72
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
73
+ const exportClause = statement.exportClause;
74
+ for (const specifier of exportClause.elements) {
75
+ if (specifier.name.text !== "default") continue;
76
+ defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
77
+ }
78
+ }
79
+ }
80
+
81
+ return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
82
+ }
83
+
84
+ #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
85
+ const ts = this.#ts;
86
+ return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
87
+ }
88
+
89
+ #isAsyncFunctionExpression(node?: ts.Expression): boolean {
90
+ const ts = this.#ts;
91
+ return Boolean(
92
+ node &&
93
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
94
+ this.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
95
+ );
96
+ }
97
+
98
+ static #scriptKind(typescript: TypeScript, moduleAbsPath: string): ts.ScriptKind {
99
+ return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx")
100
+ ? typescript.ScriptKind.TSX
101
+ : typescript.ScriptKind.TS;
102
+ }
103
+ }
@@ -223,7 +223,7 @@ const resolveNodePackageExport = async (workspaceRoot: string, specifier: string
223
223
  const subpath = specifier === packageName ? "." : `.${specifier.slice(packageName.length)}`;
224
224
  const exported = resolvePackageExport(pkgJson.exports, subpath);
225
225
  const rel = exported ?? (subpath === "." ? (pkgJson.module ?? pkgJson.main ?? "index.js") : null);
226
- if (!rel || !rel.startsWith(".")) return null;
226
+ if (!rel?.startsWith(".")) return null;
227
227
  const entryFile = await resolveFileCandidate(path.resolve(pkgDir, rel));
228
228
  if (!entryFile) return null;
229
229
  const pkgEntryName = specifier;
@@ -65,7 +65,6 @@ const DEFAULT_EXCLUDE_PREFIX = ["@akanjs/cli/", "@akanjs/devkit/"];
65
65
  const OPTIONAL_BACKEND_EXTERNAL_EXACT = new Set<string>([
66
66
  "@libsql/client",
67
67
  "bullmq",
68
- "croner",
69
68
  "ioredis",
70
69
  "postgres",
71
70
  "protobufjs",
@@ -104,7 +104,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
104
104
  ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
105
105
  ["Bee", { subpath: "@scope/pkg/leaf", originalName: "B" }],
106
106
  ]),
107
- } as BarrelAnalyzer;
107
+ } as unknown as BarrelAnalyzer;
108
108
 
109
109
  const rewritten = await rewriteBarrelImports(
110
110
  'import DefaultExport, { A, Bee as LocalBee, type Shape, Missing } from "@scope/pkg";\nconsole.log(A);',
@@ -128,7 +128,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
128
128
  test("preserves generated client barrel side effects when flattening app client imports", async () => {
129
129
  const analyzer = {
130
130
  analyze: async () => new Map([["st", { subpath: "@apps/demo/lib/st", originalName: "st" }]]),
131
- } as BarrelAnalyzer;
131
+ } as unknown as BarrelAnalyzer;
132
132
 
133
133
  const rewritten = await rewriteBarrelImports(
134
134
  'import { st } from "@apps/demo/client";\nvoid st;\n',
@@ -144,7 +144,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
144
144
  test("does not rewrite import-looking code inside template literals", async () => {
145
145
  const analyzer = {
146
146
  analyze: async () => new Map([["AkanApp", { subpath: "akanjs/server/akanApp", originalName: "AkanApp" }]]),
147
- } as BarrelAnalyzer;
147
+ } as unknown as BarrelAnalyzer;
148
148
 
149
149
  const source = [
150
150
  'import { Code } from "@apps/docs/ui";',
@@ -170,7 +170,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
170
170
  ["AkanOption", { subpath: "akanjs/server/akanOption", originalName: "AkanOption" }],
171
171
  ["Try", { subpath: "akanjs/server/decorators", originalName: "Try" }],
172
172
  ]),
173
- } as BarrelAnalyzer;
173
+ } as unknown as BarrelAnalyzer;
174
174
 
175
175
  const rewritten = await rewriteBarrelImports(
176
176
  'import { AkanOption, Try } from "akanjs/server";\nexport const option = new AkanOption();\n',
@@ -191,7 +191,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
191
191
  const analyzer = {
192
192
  analyze: async () =>
193
193
  new Map([["BottomInset", { subpath: "akanjs/ui/Layout/BottomInset", originalName: "BottomInset" }]]),
194
- } as BarrelAnalyzer;
194
+ } as unknown as BarrelAnalyzer;
195
195
 
196
196
  const rewritten = await rewriteBarrelImports('import { BottomInset } from "akanjs/ui";\n', ["akanjs/ui"], analyzer);
197
197
 
@@ -0,0 +1,230 @@
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 { createTsconfigPackageResolver } from "./barrelImportsPlugin";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const makeTempRoot = async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-resolver-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ const write = async (root: string, relPath: string, content = "export const value = 1;\n") => {
16
+ const filePath = path.join(root, relPath);
17
+ await mkdir(path.dirname(filePath), { recursive: true });
18
+ await writeFile(filePath, content);
19
+ return filePath;
20
+ };
21
+
22
+ const resolverFor = async (root: string, paths: Record<string, string[]>) =>
23
+ await createTsconfigPackageResolver({
24
+ workspace: { workspaceRoot: root },
25
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
26
+ } as never);
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
30
+ });
31
+
32
+ describe("createTsconfigPackageResolver — exact tsconfig mapping", () => {
33
+ test("resolves a package barrel and keeps the specifier", async () => {
34
+ const root = await makeTempRoot();
35
+ const entryFile = await write(root, "libs/util/index.ts");
36
+ const resolve = await resolverFor(root, { "@libs/util": ["./libs/util/index.ts"] });
37
+
38
+ expect(await resolve("@libs/util")).toEqual({
39
+ pkgName: "@libs/util",
40
+ entryFile,
41
+ pkgDir: path.join(root, "libs/util"),
42
+ });
43
+ });
44
+
45
+ test("collapses a facet specifier to its parent package", async () => {
46
+ // `@libs/util/server` maps to the sibling file `libs/util/server.ts`, not to a package directory, so a
47
+ // leaf rewritten under the raw specifier would become `@libs/util/server/lib/sig` — a path that does
48
+ // not exist. The parent specifier is what the `@libs/*` wildcard can actually resolve.
49
+ const root = await makeTempRoot();
50
+ const entryFile = await write(root, "libs/util/server.ts");
51
+ const resolve = await resolverFor(root, { "@libs/util/server": ["./libs/util/server.ts"] });
52
+
53
+ expect(await resolve("@libs/util/server")).toEqual({
54
+ pkgName: "@libs/util",
55
+ entryFile,
56
+ pkgDir: path.join(root, "libs/util"),
57
+ });
58
+ });
59
+
60
+ test("keeps the specifier when the entry file name does not match the facet", async () => {
61
+ const root = await makeTempRoot();
62
+ const entryFile = await write(root, "libs/util/clientEntry.ts");
63
+ const resolve = await resolverFor(root, { "@libs/util/client": ["./libs/util/clientEntry.ts"] });
64
+
65
+ expect(await resolve("@libs/util/client")).toEqual({
66
+ pkgName: "@libs/util/client",
67
+ entryFile,
68
+ pkgDir: path.join(root, "libs/util"),
69
+ });
70
+ });
71
+
72
+ test("returns null when the mapped file is missing", async () => {
73
+ const root = await makeTempRoot();
74
+ const resolve = await resolverFor(root, { "@libs/util": ["./libs/util/index.ts"] });
75
+ expect(await resolve("@libs/util")).toBeNull();
76
+ });
77
+
78
+ test("returns null for an empty mapping", async () => {
79
+ const root = await makeTempRoot();
80
+ await write(root, "libs/util/index.ts");
81
+ const resolve = await resolverFor(root, { "@libs/util": [""] });
82
+ expect(await resolve("@libs/util")).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe("createTsconfigPackageResolver — wildcard tsconfig mapping", () => {
87
+ test("prefers the longer prefix", async () => {
88
+ const root = await makeTempRoot();
89
+ const entryFile = await write(root, "custom/ui/index.ts");
90
+ await write(root, "libs/util/ui/index.ts");
91
+ const resolve = await resolverFor(root, {
92
+ "@libs/*": ["./libs/*"],
93
+ "@libs/util/*": ["./custom/*"],
94
+ });
95
+
96
+ expect(await resolve("@libs/util/ui")).toEqual({
97
+ pkgName: "@libs/util/ui",
98
+ entryFile,
99
+ pkgDir: path.join(root, "custom/ui"),
100
+ });
101
+ });
102
+
103
+ test("collapses a sibling facet file to its parent package", async () => {
104
+ const root = await makeTempRoot();
105
+ const entryFile = await write(root, "apps/minimal/client.ts");
106
+ const resolve = await resolverFor(root, { "@apps/*": ["./apps/*"] });
107
+
108
+ expect(await resolve("@apps/minimal/client")).toEqual({
109
+ pkgName: "@apps/minimal",
110
+ entryFile,
111
+ pkgDir: path.join(root, "apps/minimal"),
112
+ });
113
+ });
114
+
115
+ test("resolves a directory barrel and keeps the specifier", async () => {
116
+ const root = await makeTempRoot();
117
+ const entryFile = await write(root, "apps/minimal/index.tsx");
118
+ const resolve = await resolverFor(root, { "@apps/*": ["./apps/*"] });
119
+
120
+ expect(await resolve("@apps/minimal")).toEqual({
121
+ pkgName: "@apps/minimal",
122
+ entryFile,
123
+ pkgDir: path.join(root, "apps/minimal"),
124
+ });
125
+ });
126
+
127
+ test("prefers a sibling facet file over a same-named directory barrel", async () => {
128
+ const root = await makeTempRoot();
129
+ const entryFile = await write(root, "apps/minimal/client.ts");
130
+ await write(root, "apps/minimal/client/index.ts");
131
+ const resolve = await resolverFor(root, { "@apps/*": ["./apps/*"] });
132
+
133
+ expect((await resolve("@apps/minimal/client"))?.entryFile).toBe(entryFile);
134
+ });
135
+
136
+ test("stops at the matched prefix instead of falling through to a shorter one", async () => {
137
+ // Falling through would resolve `@libs/util/ui` against `./libs/*`, silently loading an unrelated
138
+ // directory that happens to sit at the same subpath under a different root.
139
+ const root = await makeTempRoot();
140
+ await write(root, "libs/util/ui/index.ts");
141
+ const resolve = await resolverFor(root, {
142
+ "@libs/*": ["./libs/*"],
143
+ "@libs/util/*": ["./custom/*"],
144
+ });
145
+
146
+ expect(await resolve("@libs/util/ui")).toBeNull();
147
+ });
148
+
149
+ test("tries every replacement of a matched prefix", async () => {
150
+ const root = await makeTempRoot();
151
+ const entryFile = await write(root, "second/util/index.ts");
152
+ const resolve = await resolverFor(root, { "@libs/*": ["./first/*", "./second/*"] });
153
+
154
+ expect((await resolve("@libs/util"))?.entryFile).toBe(entryFile);
155
+ });
156
+ });
157
+
158
+ describe("createTsconfigPackageResolver — node_modules fallback", () => {
159
+ const writePackageJson = async (root: string, pkgName: string, pkgJson: unknown) =>
160
+ await write(root, `node_modules/${pkgName}/package.json`, JSON.stringify(pkgJson));
161
+
162
+ test("resolves an exports subpath and preserves the file path", async () => {
163
+ const root = await makeTempRoot();
164
+ await writePackageJson(root, "akanjs", { exports: { "./ui": { source: "./ui/index.ts" } } });
165
+ const entryFile = await write(root, "node_modules/akanjs/ui/index.ts");
166
+ const resolve = await resolverFor(root, {});
167
+
168
+ expect(await resolve("akanjs/ui")).toEqual({
169
+ pkgName: "akanjs/ui",
170
+ entryFile,
171
+ pkgDir: path.join(root, "node_modules/akanjs/ui"),
172
+ preserveFilePath: true,
173
+ });
174
+ });
175
+
176
+ test("resolves an exports wildcard subpath", async () => {
177
+ const root = await makeTempRoot();
178
+ await writePackageJson(root, "akanjs", { exports: { "./*": "./dist/*.js" } });
179
+ const entryFile = await write(root, "node_modules/akanjs/dist/signal.js");
180
+ const resolve = await resolverFor(root, {});
181
+
182
+ expect((await resolve("akanjs/signal"))?.entryFile).toBe(entryFile);
183
+ });
184
+
185
+ test("resolves a scoped package subpath", async () => {
186
+ // A scoped name is two path segments, so the package boundary cannot be found by splitting on the
187
+ // first slash. Kept vendor-neutral on purpose: `packageExports.test.ts` reads every
188
+ // `"@akanjs/devkit/<x>"` literal under this package as a subpath the monorepo imports.
189
+ const root = await makeTempRoot();
190
+ await writePackageJson(root, "@vendor/kit", { exports: { "./lint": "./lint/index.ts" } });
191
+ const entryFile = await write(root, "node_modules/@vendor/kit/lint/index.ts");
192
+ const resolve = await resolverFor(root, {});
193
+
194
+ expect((await resolve("@vendor/kit/lint"))?.entryFile).toBe(entryFile);
195
+ });
196
+
197
+ test("falls back to module/main when exports resolution declines the specifier", async () => {
198
+ // `main: "index.js"` has no leading `./`, which the exports resolver rejects — the last fallback
199
+ // resolves it against the package directory, and it is the one path that does not preserve the file.
200
+ const root = await makeTempRoot();
201
+ await writePackageJson(root, "plainpkg", { main: "index.js" });
202
+ const entryFile = await write(root, "node_modules/plainpkg/index.js");
203
+ const resolve = await resolverFor(root, {});
204
+
205
+ expect(await resolve("plainpkg")).toEqual({
206
+ pkgName: "plainpkg",
207
+ entryFile,
208
+ pkgDir: path.join(root, "node_modules/plainpkg"),
209
+ });
210
+ });
211
+
212
+ test("returns null when the package is not installed", async () => {
213
+ const root = await makeTempRoot();
214
+ expect(await resolverFor(root, {}).then((resolve) => resolve("missingpkg"))).toBeNull();
215
+ });
216
+
217
+ test("returns null when the package.json is unparseable", async () => {
218
+ const root = await makeTempRoot();
219
+ await write(root, "node_modules/brokenpkg/package.json", "{ not json");
220
+ const resolve = await resolverFor(root, {});
221
+ expect(await resolve("brokenpkg")).toBeNull();
222
+ });
223
+
224
+ test("returns null when the resolved entry file is missing", async () => {
225
+ const root = await makeTempRoot();
226
+ await writePackageJson(root, "ghostpkg", { module: "./dist/index.js" });
227
+ const resolve = await resolverFor(root, {});
228
+ expect(await resolve("ghostpkg")).toBeNull();
229
+ });
230
+ });
package/tsconfig.json CHANGED
@@ -32,6 +32,10 @@
32
32
  "files": [],
33
33
  "include": [
34
34
  "**/*.ts",
35
- "**/*.tsx"
35
+ "**/*.tsx",
36
+ "../../akanjs/server/types/*.d.ts"
37
+ ],
38
+ "exclude": [
39
+ "lint/__fixtures__"
36
40
  ]
37
- }
41
+ }
package/typeChecker.ts CHANGED
@@ -208,7 +208,7 @@ export class TypeChecker {
208
208
  try {
209
209
  const { diagnostics } = this.check(filePath);
210
210
  return diagnostics.length === 0;
211
- } catch (error) {
211
+ } catch (_error) {
212
212
  return false;
213
213
  }
214
214
  }
package/types.ts CHANGED
@@ -15,6 +15,7 @@ export interface PackageJson {
15
15
  bun?: {
16
16
  platform?: "node" | "browser" | "bun";
17
17
  };
18
+ publishConfig?: { access?: "public" | "restricted"; registry?: string; tag?: string };
18
19
  [key: string]: unknown;
19
20
  }
20
21
 
package/uploadRelease.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HttpClient, Logger } from "akanjs/common";
1
+ import { Logger, RestClient } from "akanjs/common";
2
2
 
3
3
  import { Spinner } from "./spinner";
4
4
 
@@ -26,7 +26,7 @@ export const uploadRelease = async (
26
26
  ) => {
27
27
  const logger = new Logger("uploadRelease");
28
28
  const basePath = local ? "http://localhost:8282/backend" : "https://cloud.akanjs.com/backend";
29
- const httpClient = new HttpClient(basePath);
29
+ const httpClient = new RestClient(basePath);
30
30
  const buildPath = `${workspaceRoot}/releases/builds/${appName}-release.tar.gz`;
31
31
  const appBuildPath = `${workspaceRoot}/releases/builds/${appName}-appBuild.zip`;
32
32
  const sourcePath = `${workspaceRoot}/releases/sources/${appName}-source.tar.gz`;
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { WorkflowExecutor, workflowStepKey } from "./executor";
3
+ import type {
4
+ PrimitiveChangedFile,
5
+ WorkflowDiagnostic,
6
+ WorkflowPlan,
7
+ WorkflowStep,
8
+ WorkflowStepRegistry,
9
+ WorkflowStepResult,
10
+ } from "./types";
11
+
12
+ /**
13
+ * `WorkflowExecutor` takes its registry by constructor injection and its plan as data, so `apply` runs with
14
+ * no filesystem and no workspace — the `workspace` argument is what gates every post-apply check. These
15
+ * cover the ordering rules that `cli/workflow/workflow.test.ts` can only reach through a real scaffold.
16
+ */
17
+ const step = (id: string, tool = `tool:${id}`): WorkflowStep => ({
18
+ id,
19
+ tool,
20
+ title: id,
21
+ description: id,
22
+ });
23
+
24
+ // Every field spelled out rather than cast through `unknown`: `apply` reads `validation` before the first
25
+ // step runs, so a partial literal fails at runtime instead of at the type level.
26
+ const planOf = (steps: WorkflowStep[], diagnostics: WorkflowDiagnostic[] = []): WorkflowPlan => ({
27
+ schemaVersion: 1,
28
+ workflow: "add-field",
29
+ mode: "plan",
30
+ inputs: {},
31
+ optionalSurfaces: {},
32
+ steps,
33
+ predictedChanges: [],
34
+ validation: [],
35
+ diagnostics,
36
+ recommendations: [],
37
+ requiresApproval: true,
38
+ });
39
+
40
+ /**
41
+ * A registry that records **which key** each call resolved through, not just that a step ran — the
42
+ * resolution order is the thing under test, and every candidate key would otherwise record the same step id.
43
+ */
44
+ const changed = (path: string): PrimitiveChangedFile => ({ path, action: "modify", reason: "test" });
45
+
46
+ const recordingRegistry = (results: Record<string, WorkflowStepResult | undefined> = {}) => {
47
+ const calls: string[] = [];
48
+ const registry: WorkflowStepRegistry = {};
49
+ const on = (key: string) => {
50
+ registry[key] = async () => {
51
+ calls.push(key);
52
+ return results[key];
53
+ };
54
+ };
55
+ return { calls, registry, on };
56
+ };
57
+
58
+ describe("WorkflowExecutor.apply", () => {
59
+ test("runs no step when the plan already carries an error diagnostic", async () => {
60
+ const { calls, registry, on } = recordingRegistry();
61
+ on(workflowStepKey("add-field", "one"));
62
+ const plan = planOf([step("one")], [{ severity: "error", code: "plan-invalid", message: "bad inputs" }]);
63
+
64
+ const report = await new WorkflowExecutor(registry).apply(plan);
65
+
66
+ expect(calls).toEqual([]);
67
+ expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toContain("plan-invalid");
68
+ });
69
+
70
+ test("a warning in the plan does not stop the steps", async () => {
71
+ const { calls, registry, on } = recordingRegistry();
72
+ on(workflowStepKey("add-field", "one"));
73
+ const plan = planOf([step("one")], [{ severity: "warning", code: "plan-note", message: "heads up" }]);
74
+
75
+ await new WorkflowExecutor(registry).apply(plan);
76
+
77
+ expect(calls).toEqual([workflowStepKey("add-field", "one")]);
78
+ });
79
+
80
+ test("resolves a runner by workflow-scoped key first, then tool, then bare step id", async () => {
81
+ const { calls, registry, on } = recordingRegistry();
82
+ // All three keys are registered for the same step, so only the winner records a call.
83
+ on(workflowStepKey("add-field", "scoped"));
84
+ on("tool:scoped");
85
+ on("scoped");
86
+ await new WorkflowExecutor(registry).apply(planOf([step("scoped")]));
87
+ expect(calls).toEqual([workflowStepKey("add-field", "scoped")]);
88
+
89
+ const byTool = recordingRegistry();
90
+ byTool.on("tool:viaTool");
91
+ byTool.on("viaTool");
92
+ await new WorkflowExecutor(byTool.registry).apply(planOf([step("viaTool")]));
93
+ expect(byTool.calls).toEqual(["tool:viaTool"]);
94
+
95
+ const byId = recordingRegistry();
96
+ byId.on("viaId");
97
+ await new WorkflowExecutor(byId.registry).apply(planOf([step("viaId")]));
98
+ expect(byId.calls).toEqual(["viaId"]);
99
+ });
100
+
101
+ test("an unsupported step is reported and abandons the rest of the plan", async () => {
102
+ const { calls, registry, on } = recordingRegistry();
103
+ on(workflowStepKey("add-field", "first"));
104
+ on(workflowStepKey("add-field", "third"));
105
+
106
+ const report = await new WorkflowExecutor(registry).apply(planOf([step("first"), step("second"), step("third")]));
107
+
108
+ expect(calls).toEqual([workflowStepKey("add-field", "first")]);
109
+ expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toContain("workflow-step-unsupported");
110
+ expect(report.nextActions.map((action) => action.command)).toContain("akan workflow explain add-field");
111
+ });
112
+
113
+ test("accumulates changed files and diagnostics across steps, and stops at the first error", async () => {
114
+ const key = (id: string) => workflowStepKey("add-field", id);
115
+ const { calls, registry, on } = recordingRegistry({
116
+ [key("a")]: { changedFiles: [changed("apps/demo/lib/task/task.constant.ts")] },
117
+ [key("b")]: {
118
+ changedFiles: [changed("apps/demo/lib/task/task.dictionary.ts")],
119
+ diagnostics: [{ severity: "error", code: "step-failed", message: "no such module" }],
120
+ },
121
+ [key("c")]: { changedFiles: [changed("never-reached.ts")] },
122
+ });
123
+ on(key("a"));
124
+ on(key("b"));
125
+ on(key("c"));
126
+
127
+ const report = await new WorkflowExecutor(registry).apply(planOf([step("a"), step("b"), step("c")]));
128
+
129
+ expect(calls).toEqual([key("a"), key("b")]);
130
+ expect(report.changedFiles.map((file) => file.path)).toEqual([
131
+ "apps/demo/lib/task/task.constant.ts",
132
+ "apps/demo/lib/task/task.dictionary.ts",
133
+ ]);
134
+ expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toContain("step-failed");
135
+ });
136
+
137
+ test("a runner that answers nothing is not an error", async () => {
138
+ const { calls, registry, on } = recordingRegistry();
139
+ on(workflowStepKey("add-field", "silent"));
140
+ const report = await new WorkflowExecutor(registry).apply(planOf([step("silent")]));
141
+
142
+ expect(calls).toEqual([workflowStepKey("add-field", "silent")]);
143
+ expect(report.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]);
144
+ expect(report.changedFiles).toEqual([]);
145
+ });
146
+ });