@akanjs/devkit 2.3.13 → 2.4.0-rc.0

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.
@@ -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
+ });
@@ -0,0 +1,610 @@
1
+ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+
5
+ //* Auto-import daemon: a source-editing sibling of DevGeneratedIndexSync. When a domain file changes,
6
+ //* framework symbols that are used but not imported (e.g. `Int` in a *.constant.ts, `fetch` in a *.store.ts)
7
+ //* are inserted automatically. Detection is a closed-registry scan: we only look for a fixed, framework-owned
8
+ //* set of identifiers per file role, so no type information is required. Writes only happen on a real diff,
9
+ //* which keeps the watcher from looping on its own edits.
10
+
11
+ export interface AutoImportSyncResult {
12
+ changedFiles: string[];
13
+ errors: string[];
14
+ }
15
+
16
+ export interface AutoImportSyncOptions {
17
+ workspaceRoot: string;
18
+ }
19
+
20
+ type FileRole =
21
+ | "constant"
22
+ | "document"
23
+ | "service"
24
+ | "signal"
25
+ | "dictionary"
26
+ | "srvkit"
27
+ | "common"
28
+ | "store"
29
+ | "client";
30
+ type ImportKind = "named" | "namespace";
31
+
32
+ interface FileContext {
33
+ role: FileRole;
34
+ scope: "apps" | "libs";
35
+ project: string;
36
+ }
37
+
38
+ interface ImportTarget {
39
+ specifier: string;
40
+ kind: ImportKind;
41
+ //* When true, emitted as a type-only import (`import type { X }`, or inline `type X` when mixed with
42
+ //* value names from the same specifier). Only meaningful for `kind: "named"`.
43
+ typeOnly?: boolean;
44
+ }
45
+
46
+ //* A registry rule: the identifiers `names` should be imported from `specifier` with the given `kind`.
47
+ //* `specifier` may depend on the file's package (e.g. the client entrypoint).
48
+ interface ImportRule {
49
+ names: string[];
50
+ specifier: string | ((ctx: FileContext) => string);
51
+ kind: ImportKind;
52
+ typeOnly?: boolean;
53
+ }
54
+
55
+ const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx)$/;
56
+
57
+ //* Shared rules reused across roles. Domain (`lib/<model>/`) files sit at a fixed depth, so the
58
+ //* relative barrels `../cnst`, `../db`, `../srv`, `../dict` are always correct for them.
59
+ const AKAN_BASE: ImportRule = {
60
+ names: ["Int", "Float", "ID", "Any", "Upload", "enumOf", "dayjs"],
61
+ specifier: "akanjs/base",
62
+ kind: "named",
63
+ };
64
+ //* Type-only exports of akanjs/base — emitted as `type` imports (e.g. `import { type Dayjs, dayjs }`).
65
+ const AKAN_BASE_TYPES: ImportRule = { names: ["Dayjs"], specifier: "akanjs/base", kind: "named", typeOnly: true };
66
+ const CNST_NS: ImportRule = { names: ["cnst"], specifier: "../cnst", kind: "namespace" };
67
+ const DB_NS: ImportRule = { names: ["db"], specifier: "../db", kind: "namespace" };
68
+ const SRV_NS: ImportRule = { names: ["srv"], specifier: "../srv", kind: "namespace" };
69
+ const ERR_DICT: ImportRule = { names: ["Err"], specifier: "../dict", kind: "named" };
70
+
71
+ //* The closed registry, keyed by file role. Derived from an import-frequency scan across apps/libs;
72
+ //* only high-confidence, framework-owned identifiers are listed (domain model/scalar refs are resolved
73
+ //* separately). Order matters only when the same name could appear twice — keep names unique per role.
74
+ const RULES: Record<FileRole, ImportRule[]> = {
75
+ constant: [AKAN_BASE, AKAN_BASE_TYPES, { names: ["via"], specifier: "akanjs/constant", kind: "named" }],
76
+ document: [
77
+ AKAN_BASE,
78
+ AKAN_BASE_TYPES,
79
+ CNST_NS,
80
+ DB_NS,
81
+ ERR_DICT,
82
+ {
83
+ names: ["by", "from", "into", "SchemaOf", "DataInputOf", "DocumentUpdateHelper", "documentQueryHelper", "Mdl"],
84
+ specifier: "akanjs/document",
85
+ kind: "named",
86
+ },
87
+ ],
88
+ service: [
89
+ AKAN_BASE,
90
+ AKAN_BASE_TYPES,
91
+ DB_NS,
92
+ SRV_NS,
93
+ CNST_NS,
94
+ ERR_DICT,
95
+ { names: ["serve"], specifier: "akanjs/service", kind: "named" },
96
+ {
97
+ names: ["DataInputOf", "ListQueryOption", "DatabaseRegistry", "getFilterInfoByKey"],
98
+ specifier: "akanjs/document",
99
+ kind: "named",
100
+ },
101
+ ],
102
+ signal: [
103
+ AKAN_BASE,
104
+ AKAN_BASE_TYPES,
105
+ SRV_NS,
106
+ CNST_NS,
107
+ ERR_DICT,
108
+ {
109
+ names: ["endpoint", "internal", "slice", "Public", "Req", "Ws", "None", "Res"],
110
+ specifier: "akanjs/signal",
111
+ kind: "named",
112
+ },
113
+ ],
114
+ dictionary: [
115
+ {
116
+ names: ["modelDictionary", "scalarDictionary", "serviceDictionary"],
117
+ specifier: "akanjs/dictionary",
118
+ kind: "named",
119
+ },
120
+ ],
121
+ srvkit: [
122
+ AKAN_BASE,
123
+ AKAN_BASE_TYPES,
124
+ { names: ["Logger", "HttpClient", "sleep"], specifier: "akanjs/common", kind: "named" },
125
+ { names: ["adapt"], specifier: "akanjs/service", kind: "named" },
126
+ { names: ["SignalContext", "Guard", "InternalArg", "Middleware"], specifier: "akanjs/signal", kind: "named" },
127
+ ],
128
+ common: [AKAN_BASE, AKAN_BASE_TYPES],
129
+ store: [
130
+ CNST_NS,
131
+ { names: ["store"], specifier: "akanjs/store", kind: "named" },
132
+ { names: ["fetch", "usePage", "sig"], specifier: "../useClient", kind: "named" },
133
+ { names: ["st"], specifier: "../st", kind: "named" },
134
+ { names: ["RootStore"], specifier: "../st", kind: "named", typeOnly: true },
135
+ ],
136
+ client: [
137
+ {
138
+ names: ["cnst", "fetch", "st", "usePage"],
139
+ specifier: (ctx) => `@${ctx.scope}/${ctx.project}/client`,
140
+ kind: "named",
141
+ },
142
+ ],
143
+ };
144
+
145
+ //* Domain imports resolve open-ended model/scalar class refs against a per-package index, rather than
146
+ //* a fixed registry. Only these roles opt in, and each may pull from the listed sibling file kinds.
147
+ type DomainKind = "constant" | "document" | "signal";
148
+ type DomainIndex = Map<string, { file: string; kind: DomainKind }[]>;
149
+ const PASCAL_CASE_RE = /^[A-Z][A-Za-z0-9]*$/;
150
+ const DOMAIN_ROLE_KINDS: Partial<Record<FileRole, DomainKind[]>> = {
151
+ constant: ["constant"],
152
+ dictionary: ["constant", "document", "signal"],
153
+ common: ["constant"],
154
+ };
155
+ //* lib barrel imports (srvkit/common): identifier -> generated `lib/<barrel>.ts` file + import shape.
156
+ //* Their specifier depth is per-file (see `#libBarrelResolver`), so they can't live in the RULES table.
157
+ const LIB_BARRELS: Record<string, { barrel: string; kind: ImportKind }> = {
158
+ Err: { barrel: "dict", kind: "named" },
159
+ db: { barrel: "db", kind: "namespace" },
160
+ cnst: { barrel: "cnst", kind: "namespace" },
161
+ srv: { barrel: "srv", kind: "namespace" },
162
+ };
163
+ //* ECMAScript/TS structural globals never resolved as domain symbols, so that a package model named
164
+ //* e.g. `Map` cannot shadow the JS `Map` used in `new Map()`. Domain-y globals like `File` are allowed
165
+ //* on purpose (they are real models here).
166
+ const DOMAIN_DENYLIST = new Set([
167
+ "Map",
168
+ "Set",
169
+ "WeakMap",
170
+ "WeakSet",
171
+ "Date",
172
+ "Promise",
173
+ "Array",
174
+ "Object",
175
+ "Error",
176
+ "TypeError",
177
+ "RangeError",
178
+ "RegExp",
179
+ "Symbol",
180
+ "Proxy",
181
+ "Reflect",
182
+ "JSON",
183
+ "Math",
184
+ "Number",
185
+ "BigInt",
186
+ "Function",
187
+ "Boolean",
188
+ "String",
189
+ "Record",
190
+ "Partial",
191
+ "Required",
192
+ "Readonly",
193
+ "Pick",
194
+ "Omit",
195
+ "Exclude",
196
+ "Extract",
197
+ "NonNullable",
198
+ "ReturnType",
199
+ "Parameters",
200
+ "Awaited",
201
+ "InstanceType",
202
+ ]);
203
+
204
+ export class AutoImportSync {
205
+ readonly #workspaceRoot: string;
206
+ //* Per-package domain index (symbol -> source file), invalidated when a package's model file changes.
207
+ readonly #domainCache = new Map<string, DomainIndex>();
208
+
209
+ constructor({ workspaceRoot }: AutoImportSyncOptions) {
210
+ this.#workspaceRoot = path.resolve(workspaceRoot);
211
+ }
212
+
213
+ async syncForBatch(files: string[]): Promise<AutoImportSyncResult> {
214
+ const changedFiles: string[] = [];
215
+ const errors: string[] = [];
216
+ const seen = new Set<string>();
217
+
218
+ // Drop cached domain indexes for any package whose model files changed in this batch, so a newly
219
+ // added/renamed model is visible to references resolved later in the same batch.
220
+ for (const file of files) {
221
+ const pkgRoot = this.#domainPkgRootOf(file);
222
+ if (pkgRoot) this.#domainCache.delete(pkgRoot);
223
+ }
224
+
225
+ for (const file of files) {
226
+ const abs = path.resolve(file);
227
+ if (seen.has(abs)) continue;
228
+ seen.add(abs);
229
+ const ctx = this.#contextFor(abs);
230
+ if (!ctx) continue;
231
+ try {
232
+ const changed = await this.#syncFile(abs, ctx);
233
+ if (changed) changedFiles.push(abs);
234
+ } catch (err) {
235
+ errors.push(`[auto-import] sync failed for ${file}: ${formatError(err)}`);
236
+ }
237
+ }
238
+
239
+ return { changedFiles, errors };
240
+ }
241
+
242
+ //* Resolve the role + package location for a file, or null when the file is out of scope
243
+ //* (outside apps/libs, a generated/declaration/test file, or a facet+extension we do not handle).
244
+ #contextFor(abs: string): FileContext | null {
245
+ const rel = path.relative(this.#workspaceRoot, abs);
246
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
247
+ const base = path.basename(abs);
248
+ if (TEST_FILE_RE.test(base) || base === "index.ts" || base === "index.tsx" || base.endsWith(".d.ts")) return null;
249
+
250
+ const parts = rel.split(path.sep).filter(Boolean);
251
+ const [scope, project, facet] = parts;
252
+ if ((scope !== "apps" && scope !== "libs") || !project || !facet) return null;
253
+ const role = roleFor(facet, base);
254
+ if (!role) return null;
255
+
256
+ return { role, scope, project };
257
+ }
258
+
259
+ async #syncFile(abs: string, ctx: FileContext): Promise<boolean> {
260
+ const stats = await stat(abs).catch(() => null);
261
+ if (!stats?.isFile()) return false;
262
+ const source = await readFile(abs, "utf8");
263
+ const resolveExtra = await this.#extraResolverFor(abs, ctx);
264
+ const next = transformSource(source, abs, ctx, resolveExtra);
265
+ if (next === null || next === source) return false;
266
+ await writeFile(abs, next);
267
+ return true;
268
+ }
269
+
270
+ //* Build the dynamic resolver for identifiers the static registry does not cover, or undefined when
271
+ //* the role has none. `srvkit`/`common` resolve the package's generated lib barrels (variable depth);
272
+ //* the domain roles resolve open-ended model/scalar refs against the per-package index. `common`
273
+ //* uses both — barrels first, then domain (the two symbol sets are disjoint).
274
+ async #extraResolverFor(abs: string, ctx: FileContext) {
275
+ const resolvers: ((symbol: string) => ImportTarget | null)[] = [];
276
+ if (ctx.role === "srvkit" || ctx.role === "common") resolvers.push(await this.#libBarrelResolver(abs, ctx));
277
+ const kinds = DOMAIN_ROLE_KINDS[ctx.role];
278
+ if (kinds) resolvers.push(await this.#domainResolver(abs, ctx, kinds));
279
+ if (resolvers.length === 0) return undefined;
280
+ return (symbol: string): ImportTarget | null => {
281
+ for (const resolve of resolvers) {
282
+ const target = resolve(symbol);
283
+ if (target) return target;
284
+ }
285
+ return null;
286
+ };
287
+ }
288
+
289
+ //* Resolve an unbound PascalCase identifier to a relative import of the package model/scalar that
290
+ //* exports it (only when exactly one package file of an allowed kind exports that name).
291
+ async #domainResolver(abs: string, ctx: FileContext, kinds: DomainKind[]) {
292
+ const index = await this.#domainIndex(path.join(this.#workspaceRoot, ctx.scope, ctx.project));
293
+ const fileDir = path.dirname(abs);
294
+ return (symbol: string): ImportTarget | null => {
295
+ if (!PASCAL_CASE_RE.test(symbol) || DOMAIN_DENYLIST.has(symbol)) return null;
296
+ const entries = (index.get(symbol) ?? []).filter((entry) => kinds.includes(entry.kind));
297
+ if (entries.length !== 1) return null; // unknown or ambiguous → leave it alone
298
+ return { specifier: relativeSpecifier(fileDir, entries[0].file), kind: "named" };
299
+ };
300
+ }
301
+
302
+ //* srvkit/common files import the package's generated lib barrels from a variable depth
303
+ //* (`../lib/dict`, `../../lib/dict`, …). Resolve each barrel name to a path relative to this file,
304
+ //* but only for barrels that actually exist (so an absent `lib/srv.ts` never yields a broken import).
305
+ async #libBarrelResolver(abs: string, ctx: FileContext) {
306
+ const fileDir = path.dirname(abs);
307
+ const libDir = path.join(this.#workspaceRoot, ctx.scope, ctx.project, "lib");
308
+ const available = new Map<string, ImportTarget>();
309
+ for (const [symbol, { barrel, kind }] of Object.entries(LIB_BARRELS)) {
310
+ if (await fileExists(path.join(libDir, `${barrel}.ts`)))
311
+ available.set(symbol, { specifier: relativeSpecifier(fileDir, path.join(libDir, barrel)), kind });
312
+ }
313
+ return (symbol: string): ImportTarget | null => available.get(symbol) ?? null;
314
+ }
315
+
316
+ async #domainIndex(pkgRoot: string): Promise<DomainIndex> {
317
+ const cached = this.#domainCache.get(pkgRoot);
318
+ if (cached) return cached;
319
+ const index = await buildDomainIndex(path.join(pkgRoot, "lib"));
320
+ this.#domainCache.set(pkgRoot, index);
321
+ return index;
322
+ }
323
+
324
+ //* The apps/libs package root for a model file (`*.constant/document/signal.ts` under `lib/`), else null.
325
+ #domainPkgRootOf(file: string): string | null {
326
+ const rel = path.relative(this.#workspaceRoot, path.resolve(file));
327
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
328
+ const [scope, project, facet] = rel.split(path.sep).filter(Boolean);
329
+ if ((scope !== "apps" && scope !== "libs") || !project || facet !== "lib") return null;
330
+ if (!domainKindOf(path.basename(file))) return null;
331
+ return path.join(this.#workspaceRoot, scope, project);
332
+ }
333
+ }
334
+
335
+ //* Which facet+extension combinations get auto-imports, and the role that drives their registry.
336
+ //* `client` covers every file that pulls domain symbols from the package client entrypoint:
337
+ //* lib UI (.tsx), the `ui`/`page` facets (.tsx), and the `webkit` facet (.ts/.tsx).
338
+ const roleFor = (facet: string, base: string): FileRole | null => {
339
+ if (facet === "lib") {
340
+ if (base.endsWith(".constant.ts")) return "constant";
341
+ if (base.endsWith(".document.ts")) return "document";
342
+ if (base.endsWith(".service.ts")) return "service";
343
+ if (base.endsWith(".signal.ts")) return "signal";
344
+ if (base.endsWith(".dictionary.ts")) return "dictionary";
345
+ if (base.endsWith(".store.ts")) return "store";
346
+ if (base.endsWith(".tsx")) return "client";
347
+ return null;
348
+ }
349
+ if (facet === "ui" || facet === "page") return base.endsWith(".tsx") ? "client" : null;
350
+ if (facet === "webkit") return base.endsWith(".ts") || base.endsWith(".tsx") ? "client" : null;
351
+ if (facet === "srvkit") return base.endsWith(".ts") ? "srvkit" : null;
352
+ if (facet === "common") return base.endsWith(".ts") || base.endsWith(".tsx") ? "common" : null;
353
+ return null;
354
+ };
355
+
356
+ const targetFor = (symbol: string, ctx: FileContext): ImportTarget | null => {
357
+ for (const rule of RULES[ctx.role]) {
358
+ if (!rule.names.includes(symbol)) continue;
359
+ const specifier = typeof rule.specifier === "function" ? rule.specifier(ctx) : rule.specifier;
360
+ return { specifier, kind: rule.kind, typeOnly: rule.typeOnly };
361
+ }
362
+ return null;
363
+ };
364
+
365
+ //* Returns the rewritten source, or null when nothing needs to change. `resolveExtra` (when supplied)
366
+ //* handles identifiers not matched by the framework registry — domain model refs and srvkit barrels.
367
+ export const transformSource = (
368
+ source: string,
369
+ fileName: string,
370
+ ctx: FileContext,
371
+ resolveExtra?: (symbol: string) => ImportTarget | null,
372
+ ): string | null => {
373
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindFor(fileName));
374
+ const bound = collectBoundNames(sf);
375
+ const used = collectUsedReferences(sf);
376
+
377
+ // Group the needed symbols by their import target: framework registry first, then dynamic resolver.
378
+ // Named groups map each name to whether it is type-only, so we can emit `import type`/inline `type`.
379
+ const namedBySpecifier = new Map<string, Map<string, boolean>>();
380
+ const namespaceImports: { name: string; specifier: string }[] = [];
381
+ for (const name of used) {
382
+ if (bound.has(name)) continue;
383
+ const target = targetFor(name, ctx) ?? resolveExtra?.(name) ?? null;
384
+ if (!target) continue;
385
+ if (target.kind === "namespace") namespaceImports.push({ name, specifier: target.specifier });
386
+ else {
387
+ const names = namedBySpecifier.get(target.specifier) ?? new Map<string, boolean>();
388
+ names.set(name, target.typeOnly ?? false);
389
+ namedBySpecifier.set(target.specifier, names);
390
+ }
391
+ }
392
+ if (namedBySpecifier.size === 0 && namespaceImports.length === 0) return null;
393
+
394
+ const importDecls = sf.statements.filter(ts.isImportDeclaration);
395
+ const anchor = importDecls.at(-1) ?? null;
396
+ const namedImportsBySpecifier = collectExistingNamedImports(importDecls);
397
+
398
+ const edits: { start: number; end: number; text: string }[] = [];
399
+ const newStatements: string[] = [];
400
+
401
+ for (const [specifier, names] of namedBySpecifier) {
402
+ const existing = namedImportsBySpecifier.get(specifier);
403
+ if (existing) {
404
+ const merged = new Map(existing.names);
405
+ for (const [name, isType] of names) if (!merged.has(name)) merged.set(name, isType);
406
+ edits.push({
407
+ start: existing.decl.getStart(sf),
408
+ end: existing.decl.getEnd(),
409
+ text: formatNamedImport(specifier, merged),
410
+ });
411
+ } else newStatements.push(formatNamedImport(specifier, names));
412
+ }
413
+ for (const ns of namespaceImports) newStatements.push(`import * as ${ns.name} from "${ns.specifier}";`);
414
+
415
+ if (newStatements.length > 0) {
416
+ // If the anchor import is itself being merged, fold the new lines into that edit to avoid a
417
+ // zero-width insertion sharing a boundary with the merge replacement.
418
+ const anchorEdit = anchor ? edits.find((edit) => edit.start === anchor.getStart(sf)) : undefined;
419
+ if (anchorEdit) anchorEdit.text = `${anchorEdit.text}\n${newStatements.join("\n")}`;
420
+ else if (anchor)
421
+ edits.push({ start: anchor.getEnd(), end: anchor.getEnd(), text: `\n${newStatements.join("\n")}` });
422
+ else {
423
+ const prologueEnd = directivePrologueEnd(sf);
424
+ if (prologueEnd >= 0) edits.push({ start: prologueEnd, end: prologueEnd, text: `\n${newStatements.join("\n")}` });
425
+ else edits.push({ start: 0, end: 0, text: `${newStatements.join("\n")}\n\n` });
426
+ }
427
+ }
428
+ if (edits.length === 0) return null;
429
+
430
+ edits.sort((a, b) => b.start - a.start);
431
+ let out = source;
432
+ for (const edit of edits) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
433
+ return out === source ? null : out;
434
+ };
435
+
436
+ //* Every name introduced into scope: import bindings plus local declarations. Over-collecting only
437
+ //* suppresses an insertion (safe); under-collecting would risk a duplicate import (unsafe).
438
+ const collectBoundNames = (sf: ts.SourceFile): Set<string> => {
439
+ const names = new Set<string>();
440
+ const visit = (node: ts.Node) => {
441
+ if (ts.isImportClause(node)) {
442
+ if (node.name) names.add(node.name.text);
443
+ const bindings = node.namedBindings;
444
+ if (bindings && ts.isNamespaceImport(bindings)) names.add(bindings.name.text);
445
+ if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) names.add(el.name.text);
446
+ }
447
+ if (
448
+ (ts.isVariableDeclaration(node) ||
449
+ ts.isFunctionDeclaration(node) ||
450
+ ts.isClassDeclaration(node) ||
451
+ ts.isParameter(node) ||
452
+ ts.isBindingElement(node) ||
453
+ ts.isEnumDeclaration(node) ||
454
+ ts.isTypeAliasDeclaration(node) ||
455
+ ts.isInterfaceDeclaration(node)) &&
456
+ node.name &&
457
+ ts.isIdentifier(node.name)
458
+ )
459
+ names.add(node.name.text);
460
+ ts.forEachChild(node, visit);
461
+ };
462
+ ts.forEachChild(sf, visit);
463
+ return names;
464
+ };
465
+
466
+ const collectUsedReferences = (sf: ts.SourceFile): Set<string> => {
467
+ const used = new Set<string>();
468
+ const visit = (node: ts.Node) => {
469
+ if (ts.isIdentifier(node) && isReferencePosition(node)) used.add(node.text);
470
+ ts.forEachChild(node, visit);
471
+ };
472
+ ts.forEachChild(sf, visit);
473
+ return used;
474
+ };
475
+
476
+ //* True when the identifier reads a binding, as opposed to being a member name, property key, or
477
+ //* declaration name. e.g. in `cnst.Coordinate`, `cnst` is a reference but `Coordinate` is not.
478
+ const isReferencePosition = (node: ts.Identifier): boolean => {
479
+ const parent = node.parent;
480
+ if (!parent) return true;
481
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false;
482
+ if (ts.isQualifiedName(parent) && parent.right === node) return false;
483
+ if (ts.isPropertyAssignment(parent) && parent.name === node) return false;
484
+ if (ts.isPropertySignature(parent) && parent.name === node) return false;
485
+ if (ts.isBindingElement(parent) && parent.propertyName === node) return false;
486
+ if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) return false;
487
+ return true;
488
+ };
489
+
490
+ interface ExistingNamedImport {
491
+ decl: ts.ImportDeclaration;
492
+ names: Map<string, boolean>; // name -> type-only (whole-clause `import type` or inline `type`)
493
+ }
494
+
495
+ const collectExistingNamedImports = (importDecls: ts.ImportDeclaration[]): Map<string, ExistingNamedImport> => {
496
+ const map = new Map<string, ExistingNamedImport>();
497
+ for (const decl of importDecls) {
498
+ const clause = decl.importClause;
499
+ const bindings = clause?.namedBindings;
500
+ if (!clause || !bindings || !ts.isNamedImports(bindings)) continue;
501
+ if (!ts.isStringLiteral(decl.moduleSpecifier)) continue;
502
+ const specifier = decl.moduleSpecifier.text;
503
+ if (map.has(specifier)) continue; // first same-specifier named import wins as the merge site
504
+ const names = new Map<string, boolean>();
505
+ for (const el of bindings.elements) names.set(el.name.text, clause.isTypeOnly || el.isTypeOnly);
506
+ map.set(specifier, { decl, names });
507
+ }
508
+ return map;
509
+ };
510
+
511
+ //* Render a named import, hoisting to `import type { … }` when every name is type-only and otherwise
512
+ //* prefixing each type-only name with inline `type`. Names sorted case-insensitively (Biome order).
513
+ const formatNamedImport = (specifier: string, names: Map<string, boolean>): string => {
514
+ const entries = [...names.entries()].sort((a, b) => compareNames(a[0], b[0]));
515
+ if (entries.every(([, isType]) => isType))
516
+ return `import type { ${entries.map(([name]) => name).join(", ")} } from "${specifier}";`;
517
+ const parts = entries.map(([name, isType]) => (isType ? `type ${name}` : name));
518
+ return `import { ${parts.join(", ")} } from "${specifier}";`;
519
+ };
520
+
521
+ const directivePrologueEnd = (sf: ts.SourceFile): number => {
522
+ let end = -1;
523
+ for (const stmt of sf.statements) {
524
+ if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) end = stmt.getEnd();
525
+ else break;
526
+ }
527
+ return end;
528
+ };
529
+
530
+ const scriptKindFor = (fileName: string) => (fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
531
+
532
+ //* Case-insensitive primary order with a case-sensitive tie-break (uppercase first), matching Biome's
533
+ //* import specifier sort — so e.g. `Dayjs` precedes `dayjs`.
534
+ const compareNames = (a: string, b: string): number => {
535
+ const [la, lb] = [a.toLowerCase(), b.toLowerCase()];
536
+ if (la !== lb) return la < lb ? -1 : 1;
537
+ return a < b ? -1 : a > b ? 1 : 0;
538
+ };
539
+
540
+ const formatError = (err: unknown) => (err instanceof Error ? err.message : String(err));
541
+
542
+ const fileExists = async (file: string) =>
543
+ stat(file)
544
+ .then((s) => s.isFile())
545
+ .catch(() => false);
546
+
547
+ // ---- Domain index ---------------------------------------------------------------------------------
548
+
549
+ const domainKindOf = (base: string): DomainKind | null => {
550
+ if (base.endsWith(".constant.ts")) return "constant";
551
+ if (base.endsWith(".document.ts")) return "document";
552
+ if (base.endsWith(".signal.ts")) return "signal";
553
+ return null;
554
+ };
555
+
556
+ //* Scan a package `lib/` for model files and index their exported PascalCase declarations.
557
+ const buildDomainIndex = async (libDir: string): Promise<DomainIndex> => {
558
+ const index: DomainIndex = new Map();
559
+ for (const file of await collectDomainFiles(libDir)) {
560
+ const kind = domainKindOf(path.basename(file));
561
+ if (!kind) continue;
562
+ const src = await readFile(file, "utf8").catch(() => null);
563
+ if (src === null) continue;
564
+ for (const name of exportedPascalNames(src, file)) {
565
+ const entries = index.get(name) ?? [];
566
+ entries.push({ file, kind });
567
+ index.set(name, entries);
568
+ }
569
+ }
570
+ return index;
571
+ };
572
+
573
+ const collectDomainFiles = async (dir: string): Promise<string[]> => {
574
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
575
+ const files: string[] = [];
576
+ for (const entry of entries) {
577
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
578
+ const full = path.join(dir, entry.name);
579
+ if (entry.isDirectory()) files.push(...(await collectDomainFiles(full)));
580
+ else if (domainKindOf(entry.name) && !TEST_FILE_RE.test(entry.name)) files.push(full);
581
+ }
582
+ return files;
583
+ };
584
+
585
+ const exportedPascalNames = (source: string, fileName: string): string[] => {
586
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
587
+ const isExported = (node: ts.Node) =>
588
+ ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
589
+ const names: string[] = [];
590
+ for (const stmt of sf.statements) {
591
+ if ((ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt) || ts.isEnumDeclaration(stmt)) && stmt.name) {
592
+ if (isExported(stmt)) names.push(stmt.name.text);
593
+ } else if (ts.isVariableStatement(stmt) && isExported(stmt)) {
594
+ for (const decl of stmt.declarationList.declarations) if (ts.isIdentifier(decl.name)) names.push(decl.name.text);
595
+ } else if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
596
+ for (const el of stmt.exportClause.elements) names.push(el.name.text);
597
+ }
598
+ }
599
+ return names.filter((name) => PASCAL_CASE_RE.test(name));
600
+ };
601
+
602
+ //* Relative module specifier from a file's directory to a target file, extension stripped, `./`-anchored.
603
+ const relativeSpecifier = (fromDir: string, toFile: string): string => {
604
+ const rel = path
605
+ .relative(fromDir, toFile)
606
+ .replace(/\.tsx?$/, "")
607
+ .split(path.sep)
608
+ .join("/");
609
+ return rel.startsWith(".") ? rel : `./${rel}`;
610
+ };
@@ -1,4 +1,5 @@
1
1
  export * from "./allRoutesBuilder";
2
+ export * from "./autoImportSync";
2
3
  export * from "./clientBuildTypes";
3
4
  export * from "./clientEntriesBundler";
4
5
  export * from "./clientEntryDiscovery";
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import {
3
3
  type App,
4
4
  AppExecutor,
5
+ AutoImportSync,
5
6
  type ChangeBatch,
6
7
  type ClientEntryDiscovery,
7
8
  CsrArtifactBuilder,
@@ -47,6 +48,7 @@ class IncrementalBuilder {
47
48
  #discovery: ClientEntryDiscovery;
48
49
  #changePlanner: DevChangePlanner;
49
50
  #generatedIndexSync: DevGeneratedIndexSync;
51
+ #autoImportSync: AutoImportSync;
50
52
  #generation = 0;
51
53
  #workQueue: Promise<void> = Promise.resolve();
52
54
  #cssRebuildQueue: Promise<void> = Promise.resolve();
@@ -62,6 +64,7 @@ class IncrementalBuilder {
62
64
  this.#discovery = options.discovery;
63
65
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
64
66
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
67
+ this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
65
68
  }
66
69
 
67
70
  async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
@@ -269,6 +272,13 @@ class IncrementalBuilder {
269
272
  const rawKinds = new Set(batch.kinds);
270
273
  if (rawKinds.size === 0) return;
271
274
  const generation = ++this.#generation;
275
+ //* Insert framework imports that are used but omitted (e.g. `Int` in *.constant.ts, `fetch` in
276
+ //* *.store.ts) before regenerating barrels. Edits land on files already in this batch, so they
277
+ //* rebuild in this same generation; the write is idempotent so it does not re-trigger the watcher.
278
+ const autoImport = await this.#autoImportSync.syncForBatch(batch.files);
279
+ for (const error of autoImport.errors) this.#logger.error(error);
280
+ if (autoImport.changedFiles.length > 0)
281
+ this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
272
282
  const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
273
283
  const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
274
284
  generation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.13",
3
+ "version": "2.4.0-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.13",
35
+ "akanjs": "2.4.0-rc.0",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",