@akanjs/devkit 3.0.0-alpha.10 → 3.0.0-alpha.101

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 +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 +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 +37 -2
  22. package/applicationBuildRunner.ts +52 -23
  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 +82 -43
  27. package/biomeBase.ts +1 -1
  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 +134 -4
  41. package/executors.ts +114 -25
  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 +129 -0
  71. package/frontendBuild/watchRootResolver.ts +66 -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
package/fileEditor.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  export class FileEditor {
2
- private filePath: string;
3
- private content: string;
2
+ #filePath: string;
3
+ #content: string;
4
4
 
5
5
  private constructor(filePath: string, content: string) {
6
- this.filePath = filePath;
7
- this.content = content;
6
+ this.#filePath = filePath;
7
+ this.#content = content;
8
8
  }
9
9
 
10
10
  static async create(filePath: string): Promise<FileEditor> {
11
11
  try {
12
12
  const content = await Bun.file(filePath).text();
13
13
  return new FileEditor(filePath, content);
14
- } catch (error) {
14
+ } catch (_error) {
15
15
  throw new Error(`Failed to read file: ${filePath}`);
16
16
  }
17
17
  }
18
18
 
19
19
  find(pattern: string | RegExp): number {
20
- const lines = this.content.split("\n");
20
+ const lines = this.#content.split("\n");
21
21
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
22
22
 
23
23
  for (let i = 0; i < lines.length; i++) {
@@ -30,7 +30,7 @@ export class FileEditor {
30
30
  }
31
31
 
32
32
  findAll(pattern: string | RegExp): number[] {
33
- const lines = this.content.split("\n");
33
+ const lines = this.#content.split("\n");
34
34
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
35
35
  const matches: number[] = [];
36
36
 
@@ -50,9 +50,9 @@ export class FileEditor {
50
50
  throw new Error(`Pattern not found: ${pattern}`);
51
51
  }
52
52
 
53
- const lines = this.content.split("\n");
53
+ const lines = this.#content.split("\n");
54
54
  lines.splice(lineIndex + 1, 0, data);
55
- this.content = lines.join("\n");
55
+ this.#content = lines.join("\n");
56
56
 
57
57
  return this;
58
58
  }
@@ -64,43 +64,43 @@ export class FileEditor {
64
64
  throw new Error(`Pattern not found: ${pattern}`);
65
65
  }
66
66
 
67
- const lines = this.content.split("\n");
67
+ const lines = this.#content.split("\n");
68
68
  lines.splice(lineIndex, 0, data);
69
- this.content = lines.join("\n");
69
+ this.#content = lines.join("\n");
70
70
 
71
71
  return this;
72
72
  }
73
73
 
74
74
  replace(pattern: string | RegExp, replacement: string): this {
75
75
  const regex = typeof pattern === "string" ? new RegExp(pattern, "g") : pattern;
76
- this.content = this.content.replace(regex, replacement);
76
+ this.#content = this.#content.replace(regex, replacement);
77
77
  return this;
78
78
  }
79
79
 
80
80
  append(data: string): this {
81
- this.content += `\n${data}`;
81
+ this.#content += `\n${data}`;
82
82
  return this;
83
83
  }
84
84
 
85
85
  prepend(data: string): this {
86
- this.content = `${data}\n${this.content}`;
86
+ this.#content = `${data}\n${this.#content}`;
87
87
  return this;
88
88
  }
89
89
 
90
90
  async save(): Promise<void> {
91
91
  try {
92
- await Bun.write(this.filePath, this.content);
93
- } catch (error) {
94
- throw new Error(`Failed to save file: ${this.filePath}`);
92
+ await Bun.write(this.#filePath, this.#content);
93
+ } catch (_error) {
94
+ throw new Error(`Failed to save file: ${this.#filePath}`);
95
95
  }
96
96
  }
97
97
 
98
98
  getContent(): string {
99
- return this.content;
99
+ return this.#content;
100
100
  }
101
101
 
102
102
  setContent(content: string): this {
103
- this.content = content;
103
+ this.#content = content;
104
104
  return this;
105
105
  }
106
106
  }
package/fileSys.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { lstat, stat } from "node:fs/promises";
1
+ import { lstat, rename, rm, stat } from "node:fs/promises";
2
2
  import { Logger } from "akanjs/common";
3
3
 
4
4
  export class FileSys {
@@ -39,6 +39,24 @@ export class FileSys {
39
39
  static async writeText(path: string, content: string) {
40
40
  return await Bun.file(path).write(content);
41
41
  }
42
+ /**
43
+ * Replaces a file in one `rename`, so a concurrent reader sees either the old bytes or the new ones.
44
+ * Generated barrels are written by every dev server watching the same `libs/` tree while those same
45
+ * servers' watchers are reading them, and a half-written barrel reads back as a user edit.
46
+ *
47
+ * The temp file is a sibling because `rename` is only atomic within a filesystem, and it lands in a
48
+ * watched directory — the `.tmp` suffix is what `HmrChangeClassifier` ignores it by.
49
+ */
50
+ static async writeTextAtomic(filePath: string, content: string) {
51
+ const temp = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`;
52
+ try {
53
+ await Bun.write(temp, content);
54
+ await rename(temp, filePath);
55
+ } catch (error) {
56
+ await rm(temp, { force: true }).catch(() => undefined);
57
+ throw error;
58
+ }
59
+ }
42
60
  static async writeJson(path: string, content: object) {
43
61
  return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
44
62
  }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { FormSetterScanner } from "./formSetterScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+
6
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
7
+ file,
8
+ absolutePath: `/tmp/${file}`,
9
+ content,
10
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
11
+ });
12
+
13
+ const scan = (content: string) =>
14
+ new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
15
+
16
+ describe("FormSetterScanner", () => {
17
+ test("a setter passed by reference publishes, so nothing is reported", () => {
18
+ expect(
19
+ scan(`export const General = () => (
20
+ <Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
21
+ );
22
+ `),
23
+ ).toEqual([]);
24
+ });
25
+
26
+ test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
27
+ const warnings = scan(`export const General = () => (
28
+ <>
29
+ <Field.Text onChange={st.do.setTitleOnTask} />
30
+ <Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
31
+ <Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
32
+ <Field.Parent
33
+ onChange={(project) => {
34
+ st.do.setProjectOnTask(project);
35
+ if (project) st.do.addMembersOnTask(project.members ?? []);
36
+ }}
37
+ />
38
+ </>
39
+ );
40
+ `);
41
+
42
+ expect(warnings).toHaveLength(1);
43
+ expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
44
+ expect(warnings[0]?.scope).toBe("agent");
45
+ expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
46
+ expect(warnings[0]?.locations).toHaveLength(3);
47
+ });
48
+
49
+ test("a nested path write is unannotatable by design and is not counted", () => {
50
+ expect(
51
+ scan(`export const Rows = ({ idx }: { idx: number }) => (
52
+ <Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
53
+ );
54
+ `),
55
+ ).toEqual([]);
56
+ });
57
+
58
+ test("a zero-parameter handler is a button setting a constant, not a form control", () => {
59
+ expect(
60
+ scan(`export const Actions = () => (
61
+ <Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
62
+ );
63
+ `),
64
+ ).toEqual([]);
65
+ });
66
+
67
+ test("a non-setter action behind a wrapper is somebody else's rule", () => {
68
+ expect(
69
+ scan(`export const Filters = () => (
70
+ <Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
71
+ );
72
+ `),
73
+ ).toEqual([]);
74
+ });
75
+
76
+ test("only .tsx is scanned", () => {
77
+ const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
78
+ expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
79
+ });
80
+ });
@@ -0,0 +1,92 @@
1
+ import ts from "typescript";
2
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
+
4
+ /**
5
+ * The inventory of model fields a screen writes but does not publish.
6
+ *
7
+ * A control handed `onChange={st.do.setTitleOnTask}` **by reference** names the field it writes, so it emits
8
+ * `data-akan-action` and `useFieldTool` publishes the field to the in-page agent. Any wrapper around that setter is
9
+ * an anonymous closure carrying neither, and the field goes quiet — for the agent, for an E2E selector, and for the
10
+ * accessibility tree.
11
+ *
12
+ * `no-unpublished-form-setter.grit` is the per-line enforcement, and it fires only on a pure forwarding wrapper,
13
+ * because every other shape has a legitimate reading and a lint error would be wrong. This is the other half: the
14
+ * per-file count of fields that ended up unreachable whatever the reason, which is the number worth watching. A
15
+ * warning rather than an error for the same reason — the remedy depends on why the wrapper is there.
16
+ *
17
+ * Only a handler that takes a parameter is counted. A zero-parameter handler (`onClick={() => st.do.setStatusOnUser
18
+ * ("active")}`) is a button setting a constant, not a form control, and its remedy is an `st.tool` beside it.
19
+ */
20
+ export class FormSetterScanner {
21
+ static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
22
+
23
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
24
+ return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
25
+ }
26
+
27
+ #scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
28
+ const wrapped: { setter: string; line: number }[] = [];
29
+ const visit = (node: ts.Node) => {
30
+ if (ts.isJsxAttribute(node)) {
31
+ const setter = FormSetterScanner.#wrappedSetterOf(node);
32
+ if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
33
+ }
34
+ ts.forEachChild(node, visit);
35
+ };
36
+ visit(sourceFile);
37
+ if (!wrapped.length) return [];
38
+ const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
39
+ return [
40
+ {
41
+ rule: "akan.agent.unpublished-form-setter",
42
+ scope: "agent",
43
+ severity: "warning",
44
+ message:
45
+ setters.length === 1
46
+ ? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.`
47
+ : `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
48
+ file,
49
+ line: wrapped[0]?.line,
50
+ locations: wrapped.map(({ line }) => ({ file, line })),
51
+ },
52
+ ];
53
+ }
54
+
55
+ /** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
56
+ static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
57
+ if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
58
+ const initializer = attribute.initializer;
59
+ if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
60
+ const handler = initializer.expression;
61
+ if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
62
+ if (!handler.parameters.length) return null;
63
+ return FormSetterScanner.#setterCallIn(handler.body);
64
+ }
65
+
66
+ static #setterCallIn(node: ts.Node): string | null {
67
+ let found: string | null = null;
68
+ const visit = (current: ts.Node) => {
69
+ if (found) return;
70
+ if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
71
+ const { expression, name } = current.expression;
72
+ if (
73
+ ts.isPropertyAccessExpression(expression) &&
74
+ ts.isIdentifier(expression.expression) &&
75
+ expression.expression.text === "st" &&
76
+ expression.name.text === "do" &&
77
+ FormSetterScanner.#fieldSetter.test(name.text)
78
+ ) {
79
+ found = name.text;
80
+ return;
81
+ }
82
+ }
83
+ ts.forEachChild(current, visit);
84
+ };
85
+ visit(node);
86
+ return found;
87
+ }
88
+
89
+ static #lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
90
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
91
+ }
92
+ }
@@ -355,3 +355,61 @@ describe("AutoImportSync.syncForBatch", () => {
355
355
  expect(await readFile(indexFile, "utf8")).not.toContain("@libs/shared/client");
356
356
  });
357
357
  });
358
+
359
+ describe("AutoImportSync — scope", () => {
360
+ const writeAt = async (root: string, rel: string, source: string) => {
361
+ const abs = path.join(root, rel);
362
+ await mkdir(path.dirname(abs), { recursive: true });
363
+ await writeFile(abs, source);
364
+ return abs;
365
+ };
366
+
367
+ // `lib/__lib/` is generated and gitignored: `akan sync` rewrites it, which is also what makes the
368
+ // watcher hand it to the syncer, so an edit there is churn that the next sync discards.
369
+ test("leaves the generated lib/__lib stubs alone and still syncs lib/__scalar", async () => {
370
+ const root = await makeTempRoot();
371
+ const needsImport = "export class A {\n count = field(Int);\n}\n";
372
+ const generated = await writeAt(root, "libs/shared/lib/__lib/lib.constant.ts", needsImport);
373
+ const scalar = await writeAt(root, "libs/shared/lib/__scalar/money/money.constant.ts", needsImport);
374
+
375
+ const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([generated, scalar]);
376
+
377
+ expect(result.errors).toEqual([]);
378
+ expect(result.changedFiles).toEqual([scalar]);
379
+ expect(await readFile(generated, "utf8")).toBe(needsImport);
380
+ expect(await readFile(scalar, "utf8")).toContain('import { Int } from "akanjs/base";');
381
+ });
382
+
383
+ // The transform is pure and the write is conditional on a difference, so a second pass over its own
384
+ // output writes nothing — without that the watcher would re-fire on the syncer's own edit.
385
+ test("a second pass over its own output changes nothing", async () => {
386
+ const root = await makeTempRoot();
387
+ const file = await writeAt(
388
+ root,
389
+ "libs/shared/lib/task/task.constant.ts",
390
+ "export class A {\n count = field(Int);\n}\n",
391
+ );
392
+ const sync = new AutoImportSync({ workspaceRoot: root });
393
+
394
+ expect((await sync.syncForBatch([file])).changedFiles).toEqual([file]);
395
+ const afterFirst = await readFile(file, "utf8");
396
+ expect((await sync.syncForBatch([file])).changedFiles).toEqual([]);
397
+ expect(await readFile(file, "utf8")).toBe(afterFirst);
398
+ });
399
+
400
+ // One try/catch per file: a file that cannot be read is reported and the rest of the batch still runs.
401
+ test("a failing file is reported without abandoning the batch", async () => {
402
+ const root = await makeTempRoot();
403
+ const missing = path.join(root, "libs/shared/lib/task/gone.constant.ts");
404
+ const good = await writeAt(
405
+ root,
406
+ "libs/shared/lib/task/task.constant.ts",
407
+ "export class A {\n count = field(Int);\n}\n",
408
+ );
409
+
410
+ const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([missing, good]);
411
+
412
+ expect(result.changedFiles).toEqual([good]);
413
+ expect(result.errors).toEqual([]);
414
+ });
415
+ });
@@ -1,6 +1,7 @@
1
- import { readdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import ts from "typescript";
4
+ import { FileSys } from "../fileSys";
4
5
 
5
6
  //* Auto-import daemon: a source-editing sibling of DevGeneratedIndexSync. When a domain file changes,
6
7
  //* framework symbols that are used but not imported (e.g. `Int` in a *.constant.ts, `fetch` in a *.store.ts)
@@ -250,6 +251,10 @@ export class AutoImportSync {
250
251
  const parts = rel.split(path.sep).filter(Boolean);
251
252
  const [scope, project, facet] = parts;
252
253
  if ((scope !== "apps" && scope !== "libs") || !project || !facet) return null;
254
+ //* `lib/__lib/` holds the generated per-lib re-export stubs — gitignored, and rewritten by every
255
+ //* `akan sync`, which is also what makes the watcher report them. An edit there is churn at best and
256
+ //* silently discarded at worst. `lib/__scalar/` is real source and stays in scope.
257
+ if (facet === "lib" && parts[3] === "__lib") return null;
253
258
  const role = roleFor(facet, base);
254
259
  if (!role) return null;
255
260
 
@@ -263,7 +268,7 @@ export class AutoImportSync {
263
268
  const resolveExtra = await this.#extraResolverFor(abs, ctx);
264
269
  const next = transformSource(source, abs, ctx, resolveExtra);
265
270
  if (next === null || next === source) return false;
266
- await writeFile(abs, next);
271
+ await FileSys.writeTextAtomic(abs, next);
267
272
  return true;
268
273
  }
269
274
 
@@ -82,16 +82,42 @@ describe("route client store bootstrap", () => {
82
82
  });
83
83
 
84
84
  test("bundles akan fetch into production SSR client chunks", () => {
85
- expect(RouteClientBuilder.resolveSsrClientExternalOptions("start")).toMatchObject({
85
+ expect(RouteClientBuilder.resolveSsrClientBundleOptions("start")).toMatchObject({
86
86
  external: expect.arrayContaining(["akanjs/fetch"]),
87
87
  externalSubpaths: ["akanjs/fetch"],
88
88
  });
89
89
 
90
- expect(RouteClientBuilder.resolveSsrClientExternalOptions("build")).toEqual({
90
+ expect(RouteClientBuilder.resolveSsrClientBundleOptions("build")).toEqual({
91
+ target: "bun",
91
92
  external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
92
93
  });
93
94
  });
94
95
 
96
+ test("targets the server so SSR client chunks never resolve a browser export condition", async () => {
97
+ const root = await makeTempRoot();
98
+ const pkgDir = path.join(root, "node_modules/dom-conditioned-pkg");
99
+ await write(
100
+ path.join(pkgDir, "package.json"),
101
+ JSON.stringify({
102
+ name: "dom-conditioned-pkg",
103
+ type: "module",
104
+ exports: { ".": { browser: "./index.dom.js", default: "./index.js" } },
105
+ }),
106
+ );
107
+ await write(path.join(pkgDir, "index.dom.js"), 'export const element = document.createElement("i");\n');
108
+ await write(path.join(pkgDir, "index.js"), "export const element = null;\n");
109
+ const entry = path.join(root, "entry.ts");
110
+ await write(entry, 'export { element } from "dom-conditioned-pkg";\n');
111
+
112
+ for (const command of ["start", "build"] as const) {
113
+ const { target } = RouteClientBuilder.resolveSsrClientBundleOptions(command);
114
+ const built = await Bun.build({ entrypoints: [entry], target, format: "esm" });
115
+
116
+ expect(built.success).toBe(true);
117
+ expect(await built.outputs[0].text()).not.toContain("document.createElement");
118
+ }
119
+ });
120
+
95
121
  test("rewrites SSR external imports to runtime aliases", () => {
96
122
  const source = [
97
123
  'import React, { useState } from "react";',
@@ -133,28 +159,25 @@ describe("route client store bootstrap", () => {
133
159
  await write(uiEntry, 'export { ClientPathWrapper } from "./System/Client";\n');
134
160
  await write(clientEntry, '"use client";\nexport const ClientPathWrapper = () => null;\n');
135
161
 
136
- const discovery = new GraphClientEntryDiscovery(
137
- { barrelImports: ["akanjs/ui"], externalLibs: [], optimizeImports: true },
138
- async (specifier) => {
139
- if (specifier === "akanjs/ui") {
140
- return {
141
- pkgName: "akanjs/ui",
142
- entryFile: uiEntry,
143
- pkgDir: path.dirname(uiEntry),
144
- preserveFilePath: true,
145
- };
146
- }
147
- if (specifier === "akanjs/ui/System/Client.tsx") {
148
- return {
149
- pkgName: "akanjs/ui/System/Client.tsx",
150
- entryFile: clientEntry,
151
- pkgDir: path.dirname(clientEntry),
152
- preserveFilePath: true,
153
- };
154
- }
155
- return null;
156
- },
157
- );
162
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: ["akanjs/ui"] }, async (specifier) => {
163
+ if (specifier === "akanjs/ui") {
164
+ return {
165
+ pkgName: "akanjs/ui",
166
+ entryFile: uiEntry,
167
+ pkgDir: path.dirname(uiEntry),
168
+ preserveFilePath: true,
169
+ };
170
+ }
171
+ if (specifier === "akanjs/ui/System/Client.tsx") {
172
+ return {
173
+ pkgName: "akanjs/ui/System/Client.tsx",
174
+ entryFile: clientEntry,
175
+ pkgDir: path.dirname(clientEntry),
176
+ preserveFilePath: true,
177
+ };
178
+ }
179
+ return null;
180
+ });
158
181
 
159
182
  expect(await discovery.discover([seed])).toEqual([clientEntry]);
160
183
  });
@@ -55,6 +55,8 @@ export interface BuildClientResult {
55
55
  rscClientUrl: string;
56
56
  }
57
57
 
58
+ export type ClientBundleTarget = "browser" | "bun";
59
+
58
60
  export const CLIENT_BUNDLE_NAMING = {
59
61
  entry: "[name]-[hash].[ext]",
60
62
  chunk: "chunks/[hash].[ext]",
@@ -79,6 +81,8 @@ export interface BundleClientEntriesOptions {
79
81
  }
80
82
 
81
83
  export interface BundleClientEntriesInternalOptions extends BundleClientEntriesOptions {
84
+ /** Module-resolution target. `"bun"` for the server-executed `client-ssr` bundle, `"browser"` otherwise. */
85
+ target?: ClientBundleTarget;
82
86
  external?: readonly string[];
83
87
  externalSubpaths?: readonly string[];
84
88
  externalAliases?: Partial<Record<string, string>>;
@@ -7,6 +7,7 @@ import {
7
7
  type BundleClientEntriesInternalOptions,
8
8
  type BundleClientEntriesResult,
9
9
  CLIENT_BUNDLE_NAMING,
10
+ type ClientBundleTarget,
10
11
  type ClientManifest,
11
12
  type MetafileOutput,
12
13
  type OpaqueEntryAliases,
@@ -25,6 +26,7 @@ export class ClientEntriesBundler {
25
26
  #externalSubpaths: readonly string[];
26
27
  #externalAliases: Partial<Record<string, string>>;
27
28
  #command: "build" | "start";
29
+ #target: ClientBundleTarget;
28
30
  #outputSubdir: string;
29
31
  #reactFastRefresh: boolean;
30
32
  #artifactDir: string;
@@ -47,6 +49,7 @@ export class ClientEntriesBundler {
47
49
  this.#externalSubpaths = options.externalSubpaths ?? [];
48
50
  this.#externalAliases = options.externalAliases ?? {};
49
51
  this.#command = options.command ?? "start";
52
+ this.#target = options.target ?? "browser";
50
53
  this.#outputSubdir = options.outputSubdir ?? "client";
51
54
  this.#reactFastRefresh = options.reactFastRefresh ?? false;
52
55
  this.#artifactDir = `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
@@ -61,7 +64,7 @@ export class ClientEntriesBundler {
61
64
  entrypoints: this.#opaqueEntries.entries,
62
65
  outdir: this.#outdir,
63
66
  splitting: true,
64
- target: "browser",
67
+ target: this.#target,
65
68
  format: "esm",
66
69
  naming: CLIENT_BUNDLE_NAMING,
67
70
  metafile: true,
@@ -34,7 +34,7 @@ const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath)
34
34
  * the traversal matches the module graph the bundler will actually see.
35
35
  */
36
36
  export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
37
- #akanConfig: AkanConfig;
37
+ #akanConfig: Pick<AkanConfig, "barrelImports">;
38
38
  #resolvePackage: PackageResolver;
39
39
  #analyzer: BarrelAnalyzer;
40
40
  #tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
@@ -57,7 +57,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
57
57
  #unresolvedPaths = new Set<string>();
58
58
  #unresolvedSpecifiers = new Set<string>();
59
59
 
60
- constructor(akanConfig: AkanConfig, resolvePackage: PackageResolver) {
60
+ constructor(akanConfig: Pick<AkanConfig, "barrelImports">, resolvePackage: PackageResolver) {
61
61
  this.#akanConfig = akanConfig;
62
62
  this.#resolvePackage = resolvePackage;
63
63
  this.#analyzer = new BarrelAnalyzer({ resolvePackage });