@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.
- package/akanApp/akanApp.host.test.ts +27 -0
- package/akanApp/akanApp.host.ts +48 -5
- package/akanConfig/akanConfig.test.ts +1 -0
- package/capacitorApp.ts +28 -7
- package/frontendBuild/autoImportSync.test.ts +357 -0
- package/frontendBuild/autoImportSync.ts +610 -0
- package/frontendBuild/index.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +28 -5
- package/integration/devStability.integration.test.ts +143 -11
- package/integration/devStabilityHarness.ts +18 -3
- package/package.json +2 -2
|
@@ -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
|
+
};
|