@intentset/architecture 0.1.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.
- package/dist/baseline.d.ts +39 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +104 -0
- package/dist/baseline.js.map +1 -0
- package/dist/boundaries.d.ts +63 -0
- package/dist/boundaries.d.ts.map +1 -0
- package/dist/boundaries.js +436 -0
- package/dist/boundaries.js.map +1 -0
- package/dist/check.d.ts +73 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +130 -0
- package/dist/check.js.map +1 -0
- package/dist/claims.d.ts +24 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +201 -0
- package/dist/claims.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +159 -0
- package/dist/config.js.map +1 -0
- package/dist/dependencies.d.ts +30 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/dependencies.js +179 -0
- package/dist/dependencies.js.map +1 -0
- package/dist/exceptions.d.ts +43 -0
- package/dist/exceptions.d.ts.map +1 -0
- package/dist/exceptions.js +233 -0
- package/dist/exceptions.js.map +1 -0
- package/dist/extract.d.ts +24 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +316 -0
- package/dist/extract.js.map +1 -0
- package/dist/finding.d.ts +39 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +29 -0
- package/dist/finding.js.map +1 -0
- package/dist/imports.d.ts +30 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +48 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/layers.d.ts +38 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +41 -0
- package/dist/layers.js.map +1 -0
- package/dist/ownership.d.ts +25 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +97 -0
- package/dist/ownership.js.map +1 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +54 -0
- package/dist/paths.js.map +1 -0
- package/dist/patterns.d.ts +25 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +101 -0
- package/dist/patterns.js.map +1 -0
- package/dist/regions.d.ts +85 -0
- package/dist/regions.d.ts.map +1 -0
- package/dist/regions.js +144 -0
- package/dist/regions.js.map +1 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +268 -0
- package/dist/resolve.js.map +1 -0
- package/dist/tsconfig.d.ts +42 -0
- package/dist/tsconfig.d.ts.map +1 -0
- package/dist/tsconfig.js +186 -0
- package/dist/tsconfig.js.map +1 -0
- package/package.json +36 -0
- package/src/baseline.ts +126 -0
- package/src/boundaries.ts +499 -0
- package/src/check.ts +197 -0
- package/src/claims.ts +229 -0
- package/src/config.ts +212 -0
- package/src/dependencies.ts +211 -0
- package/src/exceptions.ts +269 -0
- package/src/extract.ts +344 -0
- package/src/finding.ts +64 -0
- package/src/imports.ts +77 -0
- package/src/index.ts +58 -0
- package/src/layers.ts +67 -0
- package/src/ownership.ts +135 -0
- package/src/paths.ts +53 -0
- package/src/patterns.ts +95 -0
- package/src/regions.ts +243 -0
- package/src/resolve.ts +283 -0
- package/src/tsconfig.ts +198 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module resolution over an in-memory tree (profile TS002, TS004–TS006).
|
|
3
|
+
*
|
|
4
|
+
* A specifier resolves, in order, as: a relative path against the importing
|
|
5
|
+
* file; an alias from the nearest tsconfig's `paths`; a path under its
|
|
6
|
+
* `baseUrl`; a workspace package whose package.json is in the tree; or an
|
|
7
|
+
* external package, which is outside the repository and ignored. A specifier
|
|
8
|
+
* that is none of these is a problem the caller reports, never a silent miss.
|
|
9
|
+
*
|
|
10
|
+
* File probes follow TypeScript: `./x.js` tries `./x.ts` and `./x.tsx` first,
|
|
11
|
+
* then the exact file, then `x` plus each code extension, then `x/index.*`.
|
|
12
|
+
* A workspace package's entry is its `exports` (the configured conditions,
|
|
13
|
+
* then types, import, default), then `types`, then `main`; nothing is
|
|
14
|
+
* guessed, so a package that exports only built output that is not in the
|
|
15
|
+
* tree is a problem until a source condition is configured.
|
|
16
|
+
*/
|
|
17
|
+
import type { ArchitectureConfig } from "./config.ts";
|
|
18
|
+
import { basename, dirname, joinPath } from "./paths.ts";
|
|
19
|
+
import { compareStrings } from "./patterns.ts";
|
|
20
|
+
import { matchAlias, TsConfigs } from "./tsconfig.ts";
|
|
21
|
+
|
|
22
|
+
export type Via = "relative" | "alias" | "baseUrl" | "workspace" | "package";
|
|
23
|
+
|
|
24
|
+
export interface Resolution {
|
|
25
|
+
/** Repository path of the target, or null for an external package or a problem. */
|
|
26
|
+
to: string | null;
|
|
27
|
+
via: Via;
|
|
28
|
+
/** True when a wildcard opened the path: a `*` alias, an exports pattern, or a package without exports. */
|
|
29
|
+
wildcard: boolean;
|
|
30
|
+
/** Why the specifier could not be resolved; absent when it resolved or is an external package. */
|
|
31
|
+
problem?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface WorkspacePackage {
|
|
35
|
+
name: string;
|
|
36
|
+
dir: string;
|
|
37
|
+
manifest: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const JS_REWRITES: [string, string[]][] = [
|
|
41
|
+
[".js", [".ts", ".tsx", ".d.ts"]],
|
|
42
|
+
[".jsx", [".tsx"]],
|
|
43
|
+
[".mjs", [".mts", ".d.mts"]],
|
|
44
|
+
[".cjs", [".cts", ".d.cts"]],
|
|
45
|
+
];
|
|
46
|
+
const APPENDED = [".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
|
|
47
|
+
const INDEXES = ["index.ts", "index.tsx", "index.d.ts", "index.js", "index.jsx"];
|
|
48
|
+
|
|
49
|
+
/** A bare specifier that npm or node could serve: `name`, `name/sub`, `@scope/name/sub`, `node:fs`. */
|
|
50
|
+
const PACKAGE_NAME = /^(?:node:)?(?:@[a-z0-9][\w.~-]*\/)?[a-z0-9][\w.~-]*(?:\/.*)?$/i;
|
|
51
|
+
|
|
52
|
+
export class Resolver {
|
|
53
|
+
readonly tsconfigs: TsConfigs;
|
|
54
|
+
readonly notes: { path: string; message: string }[] = [];
|
|
55
|
+
private readonly files: ReadonlyMap<string, string>;
|
|
56
|
+
private readonly conditions: string[];
|
|
57
|
+
private readonly packages: WorkspacePackage[] = [];
|
|
58
|
+
|
|
59
|
+
constructor(files: ReadonlyMap<string, string>, config: Pick<ArchitectureConfig, "conditions">) {
|
|
60
|
+
this.files = files;
|
|
61
|
+
this.tsconfigs = new TsConfigs(files);
|
|
62
|
+
this.conditions = [...config.conditions, "types", "import", "default", "require", "node"];
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
for (const path of [...files.keys()].sort(compareStrings)) {
|
|
65
|
+
if (basename(path) !== "package.json") continue;
|
|
66
|
+
let manifest: unknown;
|
|
67
|
+
try {
|
|
68
|
+
manifest = JSON.parse(files.get(path) as string);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.notes.push({
|
|
71
|
+
path,
|
|
72
|
+
message: `does not parse (${(error as Error).message}), so its package cannot be resolved`,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) continue;
|
|
77
|
+
const name = (manifest as Record<string, unknown>).name;
|
|
78
|
+
if (typeof name !== "string" || name === "") continue;
|
|
79
|
+
if (seen.has(name)) {
|
|
80
|
+
this.notes.push({ path, message: `declares package ${name} a second time; the first, by path, is used` });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
seen.add(name);
|
|
84
|
+
this.packages.push({ name, dir: dirname(path), manifest: manifest as Record<string, unknown> });
|
|
85
|
+
}
|
|
86
|
+
this.packages.sort((a, b) => b.name.length - a.name.length || compareStrings(a.name, b.name));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The first existing file a module path names. */
|
|
90
|
+
probe(base: string): string | null {
|
|
91
|
+
const has = (path: string) => this.files.has(path);
|
|
92
|
+
for (const [extension, alternatives] of JS_REWRITES) {
|
|
93
|
+
if (!base.endsWith(extension)) continue;
|
|
94
|
+
const stem = base.slice(0, -extension.length);
|
|
95
|
+
for (const alternative of alternatives) if (has(stem + alternative)) return stem + alternative;
|
|
96
|
+
}
|
|
97
|
+
if (has(base)) return base;
|
|
98
|
+
for (const extension of APPENDED) if (has(base + extension)) return base + extension;
|
|
99
|
+
for (const index of INDEXES) {
|
|
100
|
+
const candidate = base === "" ? index : `${base}/${index}`;
|
|
101
|
+
if (has(candidate)) return candidate;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
resolve(from: string, specifier: string): Resolution {
|
|
107
|
+
if (specifier.startsWith("/")) {
|
|
108
|
+
return {
|
|
109
|
+
to: null,
|
|
110
|
+
via: "relative",
|
|
111
|
+
wildcard: false,
|
|
112
|
+
problem: "is an absolute path, which no repository-relative tree can resolve",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
116
|
+
const joined = joinPath(dirname(from), specifier);
|
|
117
|
+
if (joined === null)
|
|
118
|
+
return { to: null, via: "relative", wildcard: false, problem: "climbs above the repository root" };
|
|
119
|
+
const to = this.probe(joined);
|
|
120
|
+
if (to === null) return { to: null, via: "relative", wildcard: false, problem: "names no file in the tree" };
|
|
121
|
+
return { to, via: "relative", wildcard: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const config = this.tsconfigs.forFile(from);
|
|
125
|
+
let aliasMiss: string | null = null;
|
|
126
|
+
if (config !== null) {
|
|
127
|
+
const match = matchAlias(config.aliases, specifier);
|
|
128
|
+
if (match !== null) {
|
|
129
|
+
for (const candidate of match.candidates) {
|
|
130
|
+
const to = this.probe(candidate);
|
|
131
|
+
if (to !== null) return { to, via: "alias", wildcard: match.wildcard };
|
|
132
|
+
}
|
|
133
|
+
// A catch-all `*` key falls back to packages, as TypeScript does; a named alias that misses is a broken import.
|
|
134
|
+
if (!match.key.startsWith("*")) {
|
|
135
|
+
aliasMiss = `matches the alias ${match.key} in ${config.path}, but none of its targets is a file in the tree`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (config.baseUrl !== null) {
|
|
139
|
+
const joined = joinPath(config.baseUrl, specifier);
|
|
140
|
+
const to = joined === null ? null : this.probe(joined);
|
|
141
|
+
if (to !== null) return { to, via: "baseUrl", wildcard: false };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const workspace = this.resolveWorkspace(specifier);
|
|
146
|
+
if (workspace !== null) return workspace;
|
|
147
|
+
|
|
148
|
+
if (aliasMiss !== null) return { to: null, via: "alias", wildcard: false, problem: aliasMiss };
|
|
149
|
+
if (PACKAGE_NAME.test(specifier)) return { to: null, via: "package", wildcard: false };
|
|
150
|
+
return {
|
|
151
|
+
to: null,
|
|
152
|
+
via: "package",
|
|
153
|
+
wildcard: false,
|
|
154
|
+
problem: "is not relative, matches no tsconfig alias or workspace package, and is not a package name",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private resolveWorkspace(specifier: string): Resolution | null {
|
|
159
|
+
const pkg = this.packages.find((p) => specifier === p.name || specifier.startsWith(`${p.name}/`));
|
|
160
|
+
if (pkg === undefined) return null;
|
|
161
|
+
const subpath = specifier === pkg.name ? "." : `.${specifier.slice(pkg.name.length)}`;
|
|
162
|
+
const exportsField = pkg.manifest.exports;
|
|
163
|
+
const candidates: string[] = [];
|
|
164
|
+
let wildcard = false;
|
|
165
|
+
if (exportsField !== undefined && exportsField !== null) {
|
|
166
|
+
const found = this.fromExports(exportsField, subpath);
|
|
167
|
+
if (found === null) {
|
|
168
|
+
return {
|
|
169
|
+
to: null,
|
|
170
|
+
via: "workspace",
|
|
171
|
+
wildcard: false,
|
|
172
|
+
problem: `names ${subpath} of workspace package ${pkg.name}, which its exports do not include`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
candidates.push(...found.targets);
|
|
176
|
+
wildcard = found.wildcard;
|
|
177
|
+
} else if (subpath !== ".") {
|
|
178
|
+
candidates.push(subpath);
|
|
179
|
+
wildcard = true;
|
|
180
|
+
}
|
|
181
|
+
if (subpath === ".") {
|
|
182
|
+
for (const field of ["types", "typings", "main", "module"]) {
|
|
183
|
+
const value = pkg.manifest[field];
|
|
184
|
+
if (typeof value === "string") candidates.push(value);
|
|
185
|
+
}
|
|
186
|
+
if (exportsField === undefined || exportsField === null) candidates.push("./index");
|
|
187
|
+
}
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
const joined = joinPath(pkg.dir, candidate);
|
|
190
|
+
const to = joined === null ? null : this.probe(joined);
|
|
191
|
+
if (to !== null) return { to, via: "workspace", wildcard };
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
to: null,
|
|
195
|
+
via: "workspace",
|
|
196
|
+
wildcard,
|
|
197
|
+
problem: `names workspace package ${pkg.name}, whose entry is no file in the tree (name an export condition that points at source in \`conditions\`)`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private fromExports(field: unknown, subpath: string): { targets: string[]; wildcard: boolean } | null {
|
|
202
|
+
if (typeof field === "string" || Array.isArray(field)) {
|
|
203
|
+
return subpath === "." ? { targets: this.conditionTargets(field), wildcard: false } : null;
|
|
204
|
+
}
|
|
205
|
+
if (typeof field !== "object" || field === null) return null;
|
|
206
|
+
const map = field as Record<string, unknown>;
|
|
207
|
+
const keys = Object.keys(map);
|
|
208
|
+
if (keys.every((key) => !key.startsWith("."))) {
|
|
209
|
+
return subpath === "." ? { targets: this.conditionTargets(map), wildcard: false } : null;
|
|
210
|
+
}
|
|
211
|
+
if (map[subpath] !== undefined) return { targets: this.conditionTargets(map[subpath]), wildcard: false };
|
|
212
|
+
let best: { key: string; capture: string } | null = null;
|
|
213
|
+
for (const key of keys) {
|
|
214
|
+
const star = key.indexOf("*");
|
|
215
|
+
if (star === -1) continue;
|
|
216
|
+
const prefix = key.slice(0, star);
|
|
217
|
+
const suffix = key.slice(star + 1);
|
|
218
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length)
|
|
219
|
+
continue;
|
|
220
|
+
if (best === null || prefix.length > best.key.indexOf("*")) {
|
|
221
|
+
best = { key, capture: subpath.slice(prefix.length, subpath.length - suffix.length) };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (best === null) return null;
|
|
225
|
+
const capture = best.capture;
|
|
226
|
+
return {
|
|
227
|
+
targets: this.conditionTargets(map[best.key]).map((target) => target.replaceAll("*", capture)),
|
|
228
|
+
wildcard: true,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Targets of an exports value in the order tried: configured conditions, then types, import, default. */
|
|
233
|
+
private conditionTargets(value: unknown): string[] {
|
|
234
|
+
if (typeof value === "string") return [value];
|
|
235
|
+
if (Array.isArray(value)) return value.flatMap((item) => this.conditionTargets(item));
|
|
236
|
+
if (typeof value !== "object" || value === null) return [];
|
|
237
|
+
const map = value as Record<string, unknown>;
|
|
238
|
+
return this.conditions
|
|
239
|
+
.filter((condition) => map[condition] !== undefined)
|
|
240
|
+
.flatMap((condition) => this.conditionTargets(map[condition]));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A configured specifier — a tsconfig alias or a workspace package name —
|
|
245
|
+
* that resolves from `from` to `target`, shortest first; null when none
|
|
246
|
+
* does. TS002 asks for this before calling a relative path a bypass.
|
|
247
|
+
*/
|
|
248
|
+
aliasFor(from: string, target: string): string | null {
|
|
249
|
+
const found: string[] = [];
|
|
250
|
+
const config = this.tsconfigs.forFile(from);
|
|
251
|
+
const variants = [target.replace(/\.(?:d\.)?[cm]?[jt]sx?$/, ""), target];
|
|
252
|
+
if (/^index\.(?:d\.)?[cm]?[jt]sx?$/.test(basename(target))) variants.unshift(dirname(target));
|
|
253
|
+
for (const alias of config?.aliases ?? []) {
|
|
254
|
+
const star = alias.key.indexOf("*");
|
|
255
|
+
if (star === -1) {
|
|
256
|
+
if (this.resolve(from, alias.key).to === target) found.push(alias.key);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
for (const pattern of alias.targets) {
|
|
260
|
+
const at = pattern.indexOf("*");
|
|
261
|
+
if (at === -1) continue;
|
|
262
|
+
const prefix = pattern.slice(0, at);
|
|
263
|
+
const suffix = pattern.slice(at + 1);
|
|
264
|
+
for (const variant of variants) {
|
|
265
|
+
if (
|
|
266
|
+
variant.length < prefix.length + suffix.length ||
|
|
267
|
+
!variant.startsWith(prefix) ||
|
|
268
|
+
!variant.endsWith(suffix)
|
|
269
|
+
)
|
|
270
|
+
continue;
|
|
271
|
+
const capture = variant.slice(prefix.length, variant.length - suffix.length);
|
|
272
|
+
const specifier = alias.key.slice(0, star) + capture + alias.key.slice(star + 1);
|
|
273
|
+
if (this.resolve(from, specifier).to === target) found.push(specifier);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const pkg of this.packages) {
|
|
278
|
+
if (this.resolve(from, pkg.name).to === target) found.push(pkg.name);
|
|
279
|
+
}
|
|
280
|
+
found.sort((a, b) => a.length - b.length || compareStrings(a, b));
|
|
281
|
+
return found[0] ?? null;
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/tsconfig.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paths` and `baseUrl` from the tsconfig.json nearest to an importing file,
|
|
3
|
+
* walking up from it, with `extends` followed while the extended file is in
|
|
4
|
+
* the tree (profile TS002, TS004, TS006). A root tsconfig is not assumed: a
|
|
5
|
+
* monorepo's apps/web/tsconfig.json governs apps/web alone.
|
|
6
|
+
*
|
|
7
|
+
* TypeScript 7 offers no in-process config parser, so tsconfig's JSON with
|
|
8
|
+
* comments and trailing commas is read here. Resolution follows TypeScript:
|
|
9
|
+
* `paths` targets are relative to `baseUrl` when one is in effect, otherwise
|
|
10
|
+
* to the directory of the config that declares `paths`; a child's `paths` or
|
|
11
|
+
* `baseUrl` replaces its parent's whole.
|
|
12
|
+
*/
|
|
13
|
+
import { ancestors, dirname, joinPath } from "./paths.ts";
|
|
14
|
+
|
|
15
|
+
export interface PathAlias {
|
|
16
|
+
/** The key as written: `@assessment/*` or `@app/router`. */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Repository-relative targets, each with at most one `*`. */
|
|
19
|
+
targets: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TsConfig {
|
|
23
|
+
/** Repository path of the config file this was read from. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Effective baseUrl as a repository directory, or null. */
|
|
26
|
+
baseUrl: string | null;
|
|
27
|
+
/** Effective aliases, targets already joined to their base. */
|
|
28
|
+
aliases: PathAlias[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TsConfigNote {
|
|
32
|
+
path: string;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strip comments and trailing commas from JSON with comments, leaving strings alone. */
|
|
37
|
+
export function stripJsonComments(text: string): string {
|
|
38
|
+
let out = "";
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < text.length) {
|
|
41
|
+
const c = text[i];
|
|
42
|
+
if (c === '"') {
|
|
43
|
+
let j = i + 1;
|
|
44
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
45
|
+
out += text.slice(i, j + 1);
|
|
46
|
+
i = j + 1;
|
|
47
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
48
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
49
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
50
|
+
const end = text.indexOf("*/", i + 2);
|
|
51
|
+
i = end === -1 ? text.length : end + 2;
|
|
52
|
+
} else {
|
|
53
|
+
out += c;
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RawConfig {
|
|
61
|
+
extends?: unknown;
|
|
62
|
+
compilerOptions?: { baseUrl?: unknown; paths?: unknown };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class TsConfigs {
|
|
66
|
+
private readonly files: ReadonlyMap<string, string>;
|
|
67
|
+
private readonly loaded = new Map<string, TsConfig | null>();
|
|
68
|
+
private readonly nearest = new Map<string, TsConfig | null>();
|
|
69
|
+
readonly notes: TsConfigNote[] = [];
|
|
70
|
+
|
|
71
|
+
constructor(files: ReadonlyMap<string, string>) {
|
|
72
|
+
this.files = files;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The effective config for a file: the nearest tsconfig.json at or above its directory, or null. */
|
|
76
|
+
forFile(path: string): TsConfig | null {
|
|
77
|
+
const directory = dirname(path);
|
|
78
|
+
const cached = this.nearest.get(directory);
|
|
79
|
+
if (cached !== undefined) return cached;
|
|
80
|
+
let found: TsConfig | null = null;
|
|
81
|
+
for (const dir of ancestors(directory)) {
|
|
82
|
+
const candidate = dir === "" ? "tsconfig.json" : `${dir}/tsconfig.json`;
|
|
83
|
+
if (this.files.has(candidate)) {
|
|
84
|
+
found = this.load(candidate, []);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
this.nearest.set(directory, found);
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private load(path: string, chain: string[]): TsConfig | null {
|
|
93
|
+
const cached = this.loaded.get(path);
|
|
94
|
+
if (cached !== undefined) return cached;
|
|
95
|
+
if (chain.includes(path)) {
|
|
96
|
+
this.notes.push({
|
|
97
|
+
path,
|
|
98
|
+
message: `extends itself through ${[...chain, path].join(" -> ")}; the cycle is cut here`,
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const text = this.files.get(path);
|
|
103
|
+
if (text === undefined) return null;
|
|
104
|
+
let raw: RawConfig;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(stripJsonComments(text)) as RawConfig;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.notes.push({ path, message: `does not parse (${(error as Error).message}), so its paths are not applied` });
|
|
109
|
+
this.loaded.set(path, null);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const directory = dirname(path);
|
|
113
|
+
let baseUrl: string | null = null;
|
|
114
|
+
let aliases: PathAlias[] = [];
|
|
115
|
+
const parents = typeof raw.extends === "string" ? [raw.extends] : Array.isArray(raw.extends) ? raw.extends : [];
|
|
116
|
+
for (const parent of parents) {
|
|
117
|
+
if (typeof parent !== "string") continue;
|
|
118
|
+
const target = this.extendsTarget(directory, parent);
|
|
119
|
+
if (target === null) {
|
|
120
|
+
this.notes.push({
|
|
121
|
+
path,
|
|
122
|
+
message: `extends ${parent}, which is not in the tree; any paths it declares are not applied`,
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const inherited = this.load(target, [...chain, path]);
|
|
127
|
+
if (inherited === null) continue;
|
|
128
|
+
if (inherited.baseUrl !== null) baseUrl = inherited.baseUrl;
|
|
129
|
+
if (inherited.aliases.length > 0) aliases = inherited.aliases;
|
|
130
|
+
}
|
|
131
|
+
const options = raw.compilerOptions ?? {};
|
|
132
|
+
if (typeof options.baseUrl === "string") baseUrl = joinPath(directory, options.baseUrl);
|
|
133
|
+
if (options.paths !== undefined && typeof options.paths === "object" && options.paths !== null) {
|
|
134
|
+
const base = baseUrl ?? directory;
|
|
135
|
+
aliases = [];
|
|
136
|
+
for (const [key, targets] of Object.entries(options.paths as Record<string, unknown>)) {
|
|
137
|
+
if (!Array.isArray(targets)) continue;
|
|
138
|
+
const joined = targets
|
|
139
|
+
.filter((target): target is string => typeof target === "string")
|
|
140
|
+
.map((target) => joinPath(base, target))
|
|
141
|
+
.filter((target): target is string => target !== null);
|
|
142
|
+
aliases.push({ key, targets: joined });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Inherited targets were joined to the base of the config that declared them, as TypeScript resolves them.
|
|
146
|
+
const config: TsConfig = { path, baseUrl, aliases };
|
|
147
|
+
this.loaded.set(path, config);
|
|
148
|
+
return config;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private extendsTarget(directory: string, specifier: string): string | null {
|
|
152
|
+
if (!specifier.startsWith(".")) {
|
|
153
|
+
const candidate = `node_modules/${specifier}`;
|
|
154
|
+
return this.files.has(candidate) ? candidate : null;
|
|
155
|
+
}
|
|
156
|
+
const joined = joinPath(directory, specifier);
|
|
157
|
+
if (joined === null) return null;
|
|
158
|
+
if (this.files.has(joined)) return joined;
|
|
159
|
+
if (this.files.has(`${joined}.json`)) return `${joined}.json`;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* TypeScript's `paths` matching: an exact key wins; otherwise the wildcard
|
|
166
|
+
* key with the longest prefix. Returns the candidate target paths in order,
|
|
167
|
+
* and whether the key that matched had a wildcard.
|
|
168
|
+
*/
|
|
169
|
+
export function matchAlias(
|
|
170
|
+
aliases: readonly PathAlias[],
|
|
171
|
+
specifier: string,
|
|
172
|
+
): { key: string; candidates: string[]; wildcard: boolean } | null {
|
|
173
|
+
const exact = aliases.find((alias) => !alias.key.includes("*") && alias.key === specifier);
|
|
174
|
+
if (exact !== undefined) return { key: exact.key, candidates: [...exact.targets], wildcard: false };
|
|
175
|
+
let best: { alias: PathAlias; capture: string; prefix: number } | null = null;
|
|
176
|
+
for (const alias of aliases) {
|
|
177
|
+
const star = alias.key.indexOf("*");
|
|
178
|
+
if (star === -1) continue;
|
|
179
|
+
const prefix = alias.key.slice(0, star);
|
|
180
|
+
const suffix = alias.key.slice(star + 1);
|
|
181
|
+
if (specifier.length < prefix.length + suffix.length) continue;
|
|
182
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
|
|
183
|
+
if (best === null || prefix.length > best.prefix) {
|
|
184
|
+
best = {
|
|
185
|
+
alias,
|
|
186
|
+
capture: specifier.slice(prefix.length, specifier.length - suffix.length),
|
|
187
|
+
prefix: prefix.length,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (best === null) return null;
|
|
192
|
+
const capture = best.capture;
|
|
193
|
+
return {
|
|
194
|
+
key: best.alias.key,
|
|
195
|
+
candidates: best.alias.targets.map((target) => target.replace("*", capture)),
|
|
196
|
+
wildcard: true,
|
|
197
|
+
};
|
|
198
|
+
}
|