@akanjs/devkit 2.3.13 → 2.4.0-rc.1

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.
@@ -6,6 +6,8 @@ import {
6
6
  createBackendBuildStatus,
7
7
  isLegacyBackendFallbackFile,
8
8
  mergeBackendRestartReasons,
9
+ normalizeBackendReportedGeneration,
10
+ shouldAbandonBackendRecovery,
9
11
  shouldMarkBuildPhaseRecovered,
10
12
  shouldQueueBuildStatusReplay,
11
13
  shouldReplaceLastGoodMessage,
@@ -209,3 +211,28 @@ describe("legacy backend graph fallback", () => {
209
211
  expect(isLegacyBackendFallbackFile(`${root}/apps/akan/page/_index.tsx`, root)).toBe(false);
210
212
  });
211
213
  });
214
+
215
+ describe("backend recovery abandonment", () => {
216
+ test("keeps retrying below the attempt ceiling and abandons at it", () => {
217
+ expect(shouldAbandonBackendRecovery(0)).toBe(false);
218
+ expect(shouldAbandonBackendRecovery(4)).toBe(false);
219
+ expect(shouldAbandonBackendRecovery(5)).toBe(true);
220
+ expect(shouldAbandonBackendRecovery(9)).toBe(true);
221
+ });
222
+
223
+ test("honors a custom attempt ceiling", () => {
224
+ expect(shouldAbandonBackendRecovery(2, 3)).toBe(false);
225
+ expect(shouldAbandonBackendRecovery(3, 3)).toBe(true);
226
+ });
227
+ });
228
+
229
+ describe("normalizeBackendReportedGeneration", () => {
230
+ test("drops the gateway's unknown-generation sentinel", () => {
231
+ expect(normalizeBackendReportedGeneration(-1)).toBeUndefined();
232
+ });
233
+
234
+ test("keeps real generations including zero", () => {
235
+ expect(normalizeBackendReportedGeneration(0)).toBe(0);
236
+ expect(normalizeBackendReportedGeneration(7)).toBe(7);
237
+ });
238
+ });
@@ -8,9 +8,12 @@ import { IncrementalBuilderHost } from "../incrementalBuilder";
8
8
 
9
9
  const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route"]);
10
10
  const BACKEND_RESTART_DEBOUNCE_MS = 120;
11
- const BACKEND_GRACEFUL_TIMEOUT_MS = 3000;
11
+ // Must exceed the gateway's child-wait budget (AkanApp child shutdown, ~5s in dev) so the gateway
12
+ // is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
13
+ const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
12
14
  const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
13
15
  const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
16
+ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
14
17
  const BACKEND_STDERR_TAIL_LIMIT = 40;
15
18
  const BUILDER_READY_TIMEOUT_MS = 150000;
16
19
  const BUILDER_START_MAX_ATTEMPTS = 3;
@@ -39,6 +42,17 @@ export const shouldRestartBackendByDevPlan = (
39
42
  export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
40
43
  message.devPlan?.actions.includes("restart-builder") ?? false;
41
44
 
45
+ /**
46
+ * A backend that keeps dying isn't going to heal by retrying the same code; after this many
47
+ * consecutive attempts the host idles and the next server-side edit triggers a fresh restart.
48
+ */
49
+ export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
50
+ attempts >= maxAttempts;
51
+
52
+ /** The gateway reports backend failures with `generation: -1`; the host assigns its own counter then. */
53
+ export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
54
+ generation >= 0 ? generation : undefined;
55
+
42
56
  export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
43
57
  message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
44
58
 
@@ -339,6 +353,18 @@ export class AkanAppHost {
339
353
  this.#replayBuilderState();
340
354
  return;
341
355
  }
356
+ if (msg.type === "build-status") {
357
+ // The gateway reports replica boot failures (crash loops, port conflicts) this way so
358
+ // they reach the build-status log and the HMR overlay like any other build failure.
359
+ const status = this.#recordBackendBuildStatus({
360
+ generation: normalizeBackendReportedGeneration(msg.data.generation),
361
+ ok: msg.data.ok,
362
+ files: msg.data.files,
363
+ message: msg.data.message,
364
+ });
365
+ this.#sendOrQueueBuildStatus(status);
366
+ return;
367
+ }
342
368
  if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
343
369
  },
344
370
  serialization: "advanced",
@@ -504,6 +530,17 @@ export class AkanAppHost {
504
530
  }
505
531
  #scheduleBackendRecovery(reason: string) {
506
532
  if (this.#backendRecoveryTimer || this.#backend) return;
533
+ if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
534
+ const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for a server-side edit to retry.`;
535
+ this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
536
+ this.logger.error(`[backend-recovery] ${message}`);
537
+ if (this.#backendStderrTail.length > 0) {
538
+ this.logger.error(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
539
+ }
540
+ const abandonedStatus = this.#recordBackendBuildStatus({ ok: false, files: [], message });
541
+ this.#sendOrQueueBuildStatus(abandonedStatus);
542
+ return;
543
+ }
507
544
  this.#setBackendLifecycleState("recovering", reason);
508
545
  const attempt = this.#backendRecoveryAttempts;
509
546
  const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
@@ -550,6 +587,7 @@ export class AkanAppHost {
550
587
  this.#sendToBackend(message);
551
588
  }
552
589
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
590
+ this.#logDevPlan(message);
553
591
  if (shouldRestartBuilderByDevPlan(message)) {
554
592
  try {
555
593
  await this.#restartDevChildren(message);
@@ -666,13 +704,18 @@ export class AkanAppHost {
666
704
  this.#sendToBackend({ type: "build-status", data: status });
667
705
  }
668
706
  }
707
+ /** One log line per planned generation, regardless of which action branch handles it. */
708
+ #logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
709
+ if (!message.devPlan) return;
710
+ const { generation, roles, actions, reasonByFile } = message.devPlan;
711
+ this.logger.verbose(
712
+ `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
713
+ );
714
+ }
715
+
669
716
  async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
670
717
  if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
671
718
  if (message.devPlan) {
672
- const { generation, roles, actions, reasonByFile } = message.devPlan;
673
- this.logger.verbose(
674
- `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
675
- );
676
719
  const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
677
720
  if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
678
721
  return shouldRestart;
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { PackageJson } from "../types";
6
6
  import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
7
+ import type { DeepPartial, LibConfigResult } from "./types";
7
8
 
8
9
  const akanPackageJson = JSON.parse(
9
10
  fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
package/capacitorApp.ts CHANGED
@@ -1004,15 +1004,14 @@ export class CapacitorApp {
1004
1004
  }
1005
1005
  for (const permission of this.target.permissions ?? []) {
1006
1006
  const claimants = nativePlugins.get(permission);
1007
- if (!claimants?.length) {
1008
- this.app.logger.warn(
1009
- `Mobile target '${this.target.name}' declares permission '${permission}' but no plugin configures it. ` +
1010
- `Depend on a lib (e.g. @libs/util) whose akan.config declares a plugin with capacitor.permission '${permission}'.`,
1011
- );
1007
+ if (claimants?.length) {
1008
+ const ctx = this.#makeNativeContext({ operation, env });
1009
+ for (const plugin of claimants) await plugin.capacitor?.configureNative?.(ctx);
1012
1010
  continue;
1013
1011
  }
1014
- const ctx = this.#makeNativeContext({ operation, env });
1015
- for (const plugin of claimants) await plugin.capacitor?.configureNative?.(ctx);
1012
+ if (permission === "camera") await this.addCamera();
1013
+ else if (permission === "contacts") await this.addContact();
1014
+ else if (permission === "location") await this.addLocation();
1016
1015
  }
1017
1016
  }
1018
1017
  #makeNativeContext({ operation, env }: Pick<RunConfig, "operation" | "env">): AkanNativeContext {
@@ -1143,6 +1142,28 @@ export class CapacitorApp {
1143
1142
  await this.#clearRootCapacitorConfigs();
1144
1143
  }
1145
1144
  }
1145
+ async addCamera() {
1146
+ await this.#setPermissionInIos({
1147
+ cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
1148
+ photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
1149
+ photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
1150
+ });
1151
+ this.#setPermissionsInAndroid(["READ_MEDIA_IMAGES", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"]);
1152
+ }
1153
+ async addContact() {
1154
+ await this.#setPermissionInIos({
1155
+ contactsUsageDescription: "$(PRODUCT_NAME) requires access to the contacts to add new contacts.",
1156
+ });
1157
+ this.#setPermissionsInAndroid(["READ_CONTACTS", "WRITE_CONTACTS"]);
1158
+ }
1159
+ async addLocation() {
1160
+ await this.#setPermissionInIos({
1161
+ locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
1162
+ locationWhenInUseUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
1163
+ });
1164
+ this.#setPermissionsInAndroid(["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]);
1165
+ this.#setFeaturesInAndroid(["android.hardware.location.gps"]);
1166
+ }
1146
1167
  #addIosEntitlements(entitlements: Record<string, string | string[]>) {
1147
1168
  Object.assign(this.#iosEntitlements, entitlements);
1148
1169
  }
@@ -0,0 +1,357 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { AutoImportSync, transformSource } from "./autoImportSync";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const makeTempRoot = async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-auto-import-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
17
+ });
18
+
19
+ const constantCtx = { role: "constant", scope: "libs", project: "shared" } as const;
20
+ const documentCtx = { role: "document", scope: "libs", project: "shared" } as const;
21
+ const serviceCtx = { role: "service", scope: "libs", project: "shared" } as const;
22
+ const signalCtx = { role: "signal", scope: "libs", project: "shared" } as const;
23
+ const dictionaryCtx = { role: "dictionary", scope: "libs", project: "shared" } as const;
24
+ const srvkitCtx = { role: "srvkit", scope: "libs", project: "shared" } as const;
25
+ const storeCtx = { role: "store", scope: "libs", project: "shared" } as const;
26
+ const clientLibCtx = { role: "client", scope: "libs", project: "shared" } as const;
27
+ const clientAppCtx = { role: "client", scope: "apps", project: "akasys" } as const;
28
+
29
+ describe("transformSource — base scalars (akanjs/base)", () => {
30
+ test("adds a missing scalar as a new import after the last import", () => {
31
+ const source = `import { via } from "akanjs/constant";\n\nexport class A extends via {\n count = field(Int);\n}\n`;
32
+ const out = transformSource(source, "a.constant.ts", constantCtx);
33
+ expect(out).toBe(
34
+ `import { via } from "akanjs/constant";\nimport { Int } from "akanjs/base";\n\nexport class A extends via {\n count = field(Int);\n}\n`,
35
+ );
36
+ });
37
+
38
+ test("merges into an existing akanjs/base import, sorted case-insensitively", () => {
39
+ const source = `import { dayjs } from "akanjs/base";\n\nconst x = field(Int);\nconst y = field(Any);\n`;
40
+ const out = transformSource(source, "a.document.ts", documentCtx);
41
+ expect(out).toBe(
42
+ `import { Any, dayjs, Int } from "akanjs/base";\n\nconst x = field(Int);\nconst y = field(Any);\n`,
43
+ );
44
+ });
45
+
46
+ test("no change when everything used is already imported", () => {
47
+ const source = `import { Int } from "akanjs/base";\n\nconst x = field(Int);\n`;
48
+ expect(transformSource(source, "a.signal.ts", signalCtx)).toBeNull();
49
+ });
50
+
51
+ test("ignores JS globals and unknown identifiers (String, Date, Number)", () => {
52
+ const source = `const a = field(String);\nconst b = field(Date);\nconst c = field(Number);\n`;
53
+ expect(transformSource(source, "a.constant.ts", constantCtx)).toBeNull();
54
+ });
55
+
56
+ test("does not treat member access as a bare scalar reference", () => {
57
+ const source = `const a = obj.Int;\nconst b = obj.Any;\n`;
58
+ expect(transformSource(source, "a.constant.ts", constantCtx)).toBeNull();
59
+ });
60
+
61
+ test("skips a scalar that is locally declared", () => {
62
+ const source = `const Int = 1;\nconst a = Int + 2;\n`;
63
+ expect(transformSource(source, "a.constant.ts", constantCtx)).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe("transformSource — server roles (framework symbols + relative barrels)", () => {
68
+ test("document: merges akanjs/document helpers and adds db/cnst namespaces", () => {
69
+ const source = `import { SchemaOf } from "akanjs/document";\n\nexport const h = () => by("id") && from(db.user) && cnst.User;\n`;
70
+ const out = transformSource(source, "x.document.ts", documentCtx);
71
+ expect(out).toBe(
72
+ `import { by, from, SchemaOf } from "akanjs/document";\nimport * as db from "../db";\nimport * as cnst from "../cnst";\n\nexport const h = () => by("id") && from(db.user) && cnst.User;\n`,
73
+ );
74
+ });
75
+
76
+ test("service: resolves serve, db/srv namespaces, DataInputOf and Err", () => {
77
+ const source = `export class S extends serve(srv.user) {\n async m(x: DataInputOf<"user">) {\n db.user.get();\n Err.notFound();\n }\n}\n`;
78
+ const out = transformSource(source, "x.service.ts", serviceCtx);
79
+ expect(out).not.toBeNull();
80
+ expect(out).toContain(`import { serve } from "akanjs/service";`);
81
+ expect(out).toContain(`import { DataInputOf } from "akanjs/document";`);
82
+ expect(out).toContain(`import { Err } from "../dict";`);
83
+ expect(out).toContain(`import * as db from "../db";`);
84
+ expect(out).toContain(`import * as srv from "../srv";`);
85
+ });
86
+
87
+ test("signal: resolves endpoint/internal/Public plus srv/cnst namespaces", () => {
88
+ const source = `export class Sig {\n a = endpoint(Public).query(cnst.User);\n b = internal(srv.user);\n}\n`;
89
+ const out = transformSource(source, "x.signal.ts", signalCtx);
90
+ expect(out).toContain(`import { endpoint, internal, Public } from "akanjs/signal";`);
91
+ expect(out).toContain(`import * as srv from "../srv";`);
92
+ expect(out).toContain(`import * as cnst from "../cnst";`);
93
+ });
94
+
95
+ test("dictionary: resolves modelDictionary from akanjs/dictionary", () => {
96
+ const source = `export const d = modelDictionary({});\n`;
97
+ expect(transformSource(source, "x.dictionary.ts", dictionaryCtx)).toBe(
98
+ `import { modelDictionary } from "akanjs/dictionary";\n\nexport const d = modelDictionary({});\n`,
99
+ );
100
+ });
101
+
102
+ test("srvkit: resolves Logger, adapt and base scalars", () => {
103
+ const source = `export const log = new Logger("x");\nexport const a = adapt(() => dayjs());\n`;
104
+ const out = transformSource(source, "aes.ts", srvkitCtx);
105
+ expect(out).toContain(`import { Logger } from "akanjs/common";`);
106
+ expect(out).toContain(`import { adapt } from "akanjs/service";`);
107
+ expect(out).toContain(`import { dayjs } from "akanjs/base";`);
108
+ });
109
+ });
110
+
111
+ describe("transformSource — type-only imports", () => {
112
+ test("hoists to `import type` when a standalone type symbol is added", () => {
113
+ const source = `export const useRoot = (): RootStore => ({}) as RootStore;\n`;
114
+ const out = transformSource(source, "x.store.ts", storeCtx);
115
+ expect(out).toBe(
116
+ `import type { RootStore } from "../st";\n\nexport const useRoot = (): RootStore => ({}) as RootStore;\n`,
117
+ );
118
+ });
119
+
120
+ test("uses inline `type` when a type symbol joins value imports from the same specifier", () => {
121
+ const source = `import { dayjs } from "akanjs/base";\n\nexport const now = (): Dayjs => dayjs();\n`;
122
+ const out = transformSource(source, "x.document.ts", documentCtx);
123
+ expect(out).toBe(`import { type Dayjs, dayjs } from "akanjs/base";\n\nexport const now = (): Dayjs => dayjs();\n`);
124
+ });
125
+
126
+ test("no change when a type symbol is already imported", () => {
127
+ const source = `import type { RootStore } from "../st";\n\nexport const x = (): RootStore => ({}) as RootStore;\n`;
128
+ expect(transformSource(source, "x.store.ts", storeCtx)).toBeNull();
129
+ });
130
+ });
131
+
132
+ describe("transformSource — store role (relative barrels)", () => {
133
+ test("adds namespace cnst and named fetch from their respective barrels", () => {
134
+ const source = `import { store } from "akanjs/store";\n\nexport class S extends store {\n async m() {\n const a = new cnst.Admin();\n await fetch.me();\n }\n}\n`;
135
+ const out = transformSource(source, "s.store.ts", storeCtx);
136
+ expect(out).toBe(
137
+ `import { store } from "akanjs/store";\nimport { fetch } from "../useClient";\nimport * as cnst from "../cnst";\n\nexport class S extends store {\n async m() {\n const a = new cnst.Admin();\n await fetch.me();\n }\n}\n`,
138
+ );
139
+ });
140
+
141
+ test("does not re-add an already-present namespace cnst", () => {
142
+ const source = `import * as cnst from "../cnst";\n\nconst a = new cnst.Admin();\n`;
143
+ expect(transformSource(source, "s.store.ts", storeCtx)).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe("transformSource — client role (package client entry)", () => {
148
+ test("adds client imports below the use client directive when no imports exist", () => {
149
+ const source = `"use client";\n\nexport const C = () => fetch.me() && cnst.Foo;\n`;
150
+ const out = transformSource(source, "C.Unit.tsx", clientLibCtx);
151
+ expect(out).toBe(
152
+ `"use client";\nimport { cnst, fetch } from "@libs/shared/client";\n\nexport const C = () => fetch.me() && cnst.Foo;\n`,
153
+ );
154
+ });
155
+
156
+ test("resolves the app client entry for apps files", () => {
157
+ const source = `import { Load } from "akanjs/ui";\n\nexport const C = () => st.foo;\n`;
158
+ const out = transformSource(source, "C.Zone.tsx", clientAppCtx);
159
+ expect(out).toBe(
160
+ `import { Load } from "akanjs/ui";\nimport { st } from "@apps/akasys/client";\n\nexport const C = () => st.foo;\n`,
161
+ );
162
+ });
163
+
164
+ test("handles webkit .ts files (no jsx) the same as tsx", () => {
165
+ const source = `import { getEnv } from "akanjs/base";\n\nexport const load = () => fetch.me();\n`;
166
+ const out = transformSource(source, "cookie.ts", clientLibCtx);
167
+ expect(out).toBe(
168
+ `import { getEnv } from "akanjs/base";\nimport { fetch } from "@libs/shared/client";\n\nexport const load = () => fetch.me();\n`,
169
+ );
170
+ });
171
+ });
172
+
173
+ describe("AutoImportSync.syncForBatch", () => {
174
+ const seed = async (root: string, rel: string, content: string) => {
175
+ const abs = path.join(root, rel);
176
+ await mkdir(path.dirname(abs), { recursive: true });
177
+ await writeFile(abs, content);
178
+ return abs;
179
+ };
180
+
181
+ test("writes the fix once, then is idempotent", async () => {
182
+ const root = await makeTempRoot();
183
+ const abs = await seed(
184
+ root,
185
+ "libs/shared/lib/foo/foo.constant.ts",
186
+ `import { via } from "akanjs/constant";\n\nconst x = field(Int);\n`,
187
+ );
188
+ const sync = new AutoImportSync({ workspaceRoot: root });
189
+
190
+ const first = await sync.syncForBatch([abs]);
191
+ expect(first.errors).toEqual([]);
192
+ expect(first.changedFiles).toEqual([abs]);
193
+ expect(await readFile(abs, "utf8")).toContain(`import { Int } from "akanjs/base";`);
194
+
195
+ const second = await sync.syncForBatch([abs]);
196
+ expect(second.changedFiles).toEqual([]);
197
+ });
198
+
199
+ test("skips files outside a known facet and test files", async () => {
200
+ const root = await makeTempRoot();
201
+ const outside = await seed(root, "libs/shared/foo.constant.ts", `const x = field(Int);\n`);
202
+ const testFile = await seed(root, "libs/shared/lib/foo/foo.constant.test.ts", `const x = field(Int);\n`);
203
+ const scriptFile = await seed(root, "libs/shared/script/foo.ts", `const x = dayjs();\n`);
204
+ const sync = new AutoImportSync({ workspaceRoot: root });
205
+
206
+ const result = await sync.syncForBatch([outside, testFile, scriptFile]);
207
+ expect(result.changedFiles).toEqual([]);
208
+ expect(await readFile(outside, "utf8")).not.toContain("akanjs/base");
209
+ expect(await readFile(testFile, "utf8")).not.toContain("akanjs/base");
210
+ expect(await readFile(scriptFile, "utf8")).not.toContain("akanjs/base");
211
+ });
212
+
213
+ test("common facet resolves base scalars, lib barrels and domain refs", async () => {
214
+ const root = await makeTempRoot();
215
+ await seed(root, "libs/x/lib/cnst.ts", `export const x = 1;\n`);
216
+ await seed(root, "libs/x/lib/character/character.constant.ts", `export class Character extends via {}\n`);
217
+ const common = await seed(
218
+ root,
219
+ "libs/x/common/helper.ts",
220
+ `export const f = (c: Character) => dayjs() && cnst.Foo && c;\n`,
221
+ );
222
+ const sync = new AutoImportSync({ workspaceRoot: root });
223
+
224
+ const result = await sync.syncForBatch([common]);
225
+ expect(result.errors).toEqual([]);
226
+ const out = await readFile(common, "utf8");
227
+ expect(out).toContain(`import { dayjs } from "akanjs/base";`);
228
+ expect(out).toContain(`import * as cnst from "../lib/cnst";`);
229
+ expect(out).toContain(`import { Character } from "../lib/character/character.constant";`);
230
+ });
231
+
232
+ test("routes the srvkit facet and skips its generated index.ts", async () => {
233
+ const root = await makeTempRoot();
234
+ const aes = await seed(root, "libs/shared/srvkit/aes.ts", `export const log = new Logger("x");\n`);
235
+ const index = await seed(root, "libs/shared/srvkit/index.ts", `export * from "./aes";\nconst x = Logger;\n`);
236
+ const sync = new AutoImportSync({ workspaceRoot: root });
237
+
238
+ const result = await sync.syncForBatch([aes, index]);
239
+ expect(result.changedFiles).toEqual([aes]);
240
+ expect(await readFile(aes, "utf8")).toContain(`import { Logger } from "akanjs/common";`);
241
+ expect(await readFile(index, "utf8")).not.toContain("akanjs/common");
242
+ });
243
+
244
+ test("covers ui/page tsx and webkit ts, and resolves the app client entry", async () => {
245
+ const root = await makeTempRoot();
246
+ const uiFile = await seed(root, "apps/akan/ui/Card.tsx", `"use client";\n\nexport const C = () => st.foo;\n`);
247
+ const pageFile = await seed(root, "apps/akan/page/(user)/home/_index.tsx", `export default () => cnst.Foo;\n`);
248
+ const webkitFile = await seed(root, "libs/shared/webkit/cookie.ts", `export const load = () => fetch.me();\n`);
249
+ const sync = new AutoImportSync({ workspaceRoot: root });
250
+
251
+ const result = await sync.syncForBatch([uiFile, pageFile, webkitFile]);
252
+ expect(result.errors).toEqual([]);
253
+ expect(result.changedFiles.sort()).toEqual([pageFile, webkitFile, uiFile].sort());
254
+ expect(await readFile(uiFile, "utf8")).toContain(`import { st } from "@apps/akan/client";`);
255
+ expect(await readFile(pageFile, "utf8")).toContain(`import { cnst } from "@apps/akan/client";`);
256
+ expect(await readFile(webkitFile, "utf8")).toContain(`import { fetch } from "@libs/shared/client";`);
257
+ });
258
+
259
+ test("resolves domain model/scalar refs from sibling files in a constant", async () => {
260
+ const root = await makeTempRoot();
261
+ await seed(root, "libs/x/lib/file/file.constant.ts", `export class File extends via {}\n`);
262
+ await seed(root, "libs/x/lib/__scalar/encourageInfo/encourageInfo.constant.ts", `export class EncourageInfo {}\n`);
263
+ const user = await seed(
264
+ root,
265
+ "libs/x/lib/user/user.constant.ts",
266
+ `import { via } from "akanjs/constant";\n\nexport class User extends via {\n file = field(File);\n info = field(EncourageInfo);\n}\n`,
267
+ );
268
+ const sync = new AutoImportSync({ workspaceRoot: root });
269
+
270
+ const result = await sync.syncForBatch([user]);
271
+ expect(result.errors).toEqual([]);
272
+ const out = await readFile(user, "utf8");
273
+ expect(out).toContain(`import { File } from "../file/file.constant";`);
274
+ expect(out).toContain(`import { EncourageInfo } from "../__scalar/encourageInfo/encourageInfo.constant";`);
275
+ });
276
+
277
+ test("dictionary resolves same-folder .constant/.document/.signal refs", async () => {
278
+ const root = await makeTempRoot();
279
+ await seed(root, "libs/x/lib/user/user.constant.ts", `export class User extends via {}\n`);
280
+ await seed(root, "libs/x/lib/user/user.document.ts", `export class UserFilter {}\n`);
281
+ await seed(root, "libs/x/lib/user/user.signal.ts", `export class UserEndpoint {}\n`);
282
+ const dict = await seed(
283
+ root,
284
+ "libs/x/lib/user/user.dictionary.ts",
285
+ `export const d = modelDictionary(User, UserFilter, UserEndpoint);\n`,
286
+ );
287
+ const sync = new AutoImportSync({ workspaceRoot: root });
288
+
289
+ await sync.syncForBatch([dict]);
290
+ const out = await readFile(dict, "utf8");
291
+ expect(out).toContain(`import { modelDictionary } from "akanjs/dictionary";`);
292
+ expect(out).toContain(`import { User } from "./user.constant";`);
293
+ expect(out).toContain(`import { UserFilter } from "./user.document";`);
294
+ expect(out).toContain(`import { UserEndpoint } from "./user.signal";`);
295
+ });
296
+
297
+ test("leaves JS globals and ambiguous domain names alone", async () => {
298
+ const root = await makeTempRoot();
299
+ // `Map` is a real model here but must not shadow the JS global.
300
+ await seed(root, "libs/x/lib/map/map.constant.ts", `export class Map extends via {}\n`);
301
+ // `Dup` is exported by two files → ambiguous → skipped.
302
+ await seed(root, "libs/x/lib/a/a.constant.ts", `export class Dup {}\n`);
303
+ await seed(root, "libs/x/lib/b/b.constant.ts", `export class Dup {}\n`);
304
+ const consumer = await seed(
305
+ root,
306
+ "libs/x/lib/user/user.constant.ts",
307
+ `import { via } from "akanjs/constant";\n\nexport class User extends via {\n m() {\n const x = new Map();\n return Dup;\n }\n}\n`,
308
+ );
309
+ const sync = new AutoImportSync({ workspaceRoot: root });
310
+
311
+ const result = await sync.syncForBatch([consumer]);
312
+ expect(result.changedFiles).toEqual([]);
313
+ const out = await readFile(consumer, "utf8");
314
+ expect(out).not.toContain("map.constant");
315
+ expect(out).not.toContain("a.constant");
316
+ expect(out).not.toContain("b.constant");
317
+ });
318
+
319
+ test("srvkit: resolves lib barrels relative to file depth", async () => {
320
+ const root = await makeTempRoot();
321
+ for (const barrel of ["dict", "db", "cnst"]) await seed(root, `libs/x/lib/${barrel}.ts`, `export const x = 1;\n`);
322
+ const shallow = await seed(root, "libs/x/srvkit/api.ts", `export const f = () => Err.of(cnst.User, db.user);\n`);
323
+ const deep = await seed(root, "libs/x/srvkit/a/b/api.ts", `export const g = () => Err.of(cnst.User);\n`);
324
+ const sync = new AutoImportSync({ workspaceRoot: root });
325
+
326
+ await sync.syncForBatch([shallow, deep]);
327
+ const shallowOut = await readFile(shallow, "utf8");
328
+ expect(shallowOut).toContain(`import { Err } from "../lib/dict";`);
329
+ expect(shallowOut).toContain(`import * as cnst from "../lib/cnst";`);
330
+ expect(shallowOut).toContain(`import * as db from "../lib/db";`);
331
+ const deepOut = await readFile(deep, "utf8");
332
+ expect(deepOut).toContain(`import { Err } from "../../../lib/dict";`);
333
+ expect(deepOut).toContain(`import * as cnst from "../../../lib/cnst";`);
334
+ });
335
+
336
+ test("srvkit: does not import a barrel whose file is absent", async () => {
337
+ const root = await makeTempRoot();
338
+ await seed(root, "libs/x/lib/dict.ts", `export const x = 1;\n`); // no lib/srv.ts
339
+ const file = await seed(root, "libs/x/srvkit/api.ts", `export const f = () => Err.of(srv.user);\n`);
340
+ const sync = new AutoImportSync({ workspaceRoot: root });
341
+
342
+ await sync.syncForBatch([file]);
343
+ const out = await readFile(file, "utf8");
344
+ expect(out).toContain(`import { Err } from "../lib/dict";`);
345
+ expect(out).not.toContain("lib/srv");
346
+ });
347
+
348
+ test("skips generated webkit index.ts", async () => {
349
+ const root = await makeTempRoot();
350
+ const indexFile = await seed(root, "libs/shared/webkit/index.ts", `export * from "./cookie";\nconst x = fetch;\n`);
351
+ const sync = new AutoImportSync({ workspaceRoot: root });
352
+
353
+ const result = await sync.syncForBatch([indexFile]);
354
+ expect(result.changedFiles).toEqual([]);
355
+ expect(await readFile(indexFile, "utf8")).not.toContain("@libs/shared/client");
356
+ });
357
+ });