@esmx/core 3.0.0-rc.117 → 3.0.0-rc.118
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/README.md +17 -3
- package/README.zh-CN.md +20 -6
- package/dist/app.mjs +4 -2
- package/dist/cli/cli.d.ts +1 -0
- package/dist/cli/cli.mjs +28 -1
- package/dist/cli/validate.d.ts +15 -0
- package/dist/cli/validate.mjs +139 -0
- package/dist/cli/validate.test.d.ts +1 -0
- package/dist/cli/validate.test.mjs +216 -0
- package/dist/core.d.ts +31 -0
- package/dist/core.mjs +71 -5
- package/dist/declaration/index.d.ts +47 -0
- package/dist/declaration/index.mjs +63 -0
- package/dist/declaration/index.test.d.ts +1 -0
- package/dist/declaration/index.test.mjs +202 -0
- package/dist/declaration/lower.d.ts +11 -0
- package/dist/declaration/lower.mjs +78 -0
- package/dist/declaration/lower.test.d.ts +1 -0
- package/dist/declaration/lower.test.mjs +333 -0
- package/dist/declaration/reader.d.ts +28 -0
- package/dist/declaration/reader.mjs +55 -0
- package/dist/declaration/reinit.e2e.test.d.ts +1 -0
- package/dist/declaration/reinit.e2e.test.mjs +117 -0
- package/dist/declaration/resolver.d.ts +59 -0
- package/dist/declaration/resolver.mjs +430 -0
- package/dist/declaration/resolver.test.d.ts +1 -0
- package/dist/declaration/resolver.test.mjs +1005 -0
- package/dist/declaration/schema.d.ts +89 -0
- package/dist/declaration/schema.mjs +282 -0
- package/dist/declaration/schema.test.d.ts +1 -0
- package/dist/declaration/schema.test.mjs +101 -0
- package/dist/declaration/semver.d.ts +22 -0
- package/dist/declaration/semver.mjs +229 -0
- package/dist/declaration/semver.test.d.ts +1 -0
- package/dist/declaration/semver.test.mjs +87 -0
- package/dist/declaration/test-fixtures.d.ts +18 -0
- package/dist/declaration/test-fixtures.mjs +69 -0
- package/dist/declaration/types.d.ts +58 -0
- package/dist/declaration/types.mjs +21 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.mjs +10 -0
- package/dist/manifest-json.d.ts +61 -0
- package/dist/manifest-json.mjs +68 -5
- package/dist/manifest-json.test.d.ts +1 -0
- package/dist/manifest-json.test.mjs +196 -0
- package/dist/module-config.d.ts +45 -5
- package/dist/module-config.mjs +60 -49
- package/dist/module-config.test.mjs +227 -91
- package/dist/pack-config.d.ts +2 -9
- package/dist/render-context.mjs +22 -5
- package/dist/utils/import-map.d.ts +23 -3
- package/dist/utils/import-map.mjs +38 -1
- package/dist/utils/import-map.test.mjs +228 -0
- package/package.json +9 -6
- package/src/app.ts +5 -2
- package/src/cli/cli.ts +44 -1
- package/src/cli/validate.test.ts +251 -0
- package/src/cli/validate.ts +196 -0
- package/src/core.ts +84 -5
- package/src/declaration/index.test.ts +223 -0
- package/src/declaration/index.ts +135 -0
- package/src/declaration/lower.test.ts +372 -0
- package/src/declaration/lower.ts +135 -0
- package/src/declaration/reader.ts +93 -0
- package/src/declaration/reinit.e2e.test.ts +148 -0
- package/src/declaration/resolver.test.ts +1111 -0
- package/src/declaration/resolver.ts +638 -0
- package/src/declaration/schema.test.ts +118 -0
- package/src/declaration/schema.ts +339 -0
- package/src/declaration/semver.test.ts +101 -0
- package/src/declaration/semver.ts +278 -0
- package/src/declaration/test-fixtures.ts +96 -0
- package/src/declaration/types.ts +71 -0
- package/src/index.ts +28 -1
- package/src/manifest-json.test.ts +236 -0
- package/src/manifest-json.ts +166 -5
- package/src/module-config.test.ts +266 -105
- package/src/module-config.ts +130 -58
- package/src/pack-config.ts +2 -9
- package/src/render-context.ts +34 -6
- package/src/utils/import-map.test.ts +261 -0
- package/src/utils/import-map.ts +92 -6
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MANIFEST_PROTOCOL_VERSION } from '../manifest-json';
|
|
4
|
+
import type { PackageRecord, ReadDeclarationResult } from './reader';
|
|
5
|
+
import { readPackageRecord } from './reader';
|
|
6
|
+
import { parseSemver, satisfiesRange } from './semver';
|
|
7
|
+
import { type Diagnostic, DiagnosticCode } from './types';
|
|
8
|
+
|
|
9
|
+
/** A resolved mount-table entry (RFC §6). */
|
|
10
|
+
export interface ResolvedMount {
|
|
11
|
+
name: string;
|
|
12
|
+
/** Package root directory (realpath'd for auto-mounts). */
|
|
13
|
+
root: string;
|
|
14
|
+
/** Artifact directory, `<root>/dist` for auto-mounts. */
|
|
15
|
+
artifactDir: string;
|
|
16
|
+
/** Whether `dist/client/manifest.json` exists. */
|
|
17
|
+
built: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Winner of the recursive supply merge for one (package, major) group
|
|
22
|
+
* (RFC §7, per-major amendment): elections are keyed by the MAJOR of the
|
|
23
|
+
* provider's resolved version, so coexisting majors are isolated islands,
|
|
24
|
+
* each with its own winner.
|
|
25
|
+
*/
|
|
26
|
+
export interface SupplyGroup {
|
|
27
|
+
/** Major of the group's resolved version; 'unknown' when unresolvable. */
|
|
28
|
+
major: number | 'unknown';
|
|
29
|
+
provider: string;
|
|
30
|
+
/** Provider's resolved installed version, null when unresolvable. */
|
|
31
|
+
version: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-package election result: one winner per major group. */
|
|
35
|
+
export interface SupplyEntry {
|
|
36
|
+
/** Group winners, highest major first ('unknown' last). */
|
|
37
|
+
groups: SupplyGroup[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolveMountsResult {
|
|
41
|
+
supply: Record<string, SupplyEntry>;
|
|
42
|
+
mounts: Record<string, ResolvedMount>;
|
|
43
|
+
diagnostics: Diagnostic[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ResolverEnvLinks {
|
|
47
|
+
[moduleName: string]: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Walks up from `fromDir` through node_modules, Node-resolution style. */
|
|
51
|
+
function resolvePackageDir(fromDir: string, name: string): string | null {
|
|
52
|
+
let dir = path.resolve(fromDir);
|
|
53
|
+
for (;;) {
|
|
54
|
+
const candidate = path.join(dir, 'node_modules', name);
|
|
55
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
const parent = path.dirname(dir);
|
|
59
|
+
if (parent === dir) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveInstalledVersion(
|
|
67
|
+
fromDir: string,
|
|
68
|
+
packageName: string
|
|
69
|
+
): string | null {
|
|
70
|
+
const packageDir = resolvePackageDir(fromDir, packageName);
|
|
71
|
+
if (!packageDir) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const record = readPackageRecord(packageDir);
|
|
75
|
+
return record && record.version !== '' ? record.version : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ManifestInfo {
|
|
79
|
+
/** Manifest protocol version; absent in pre-v2 manifests → 1. */
|
|
80
|
+
protocol: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readManifestInfo(artifactDir: string): ManifestInfo | null {
|
|
84
|
+
// Boundary adapter: read only the manifest protocol version (the linker
|
|
85
|
+
// rejects manifests newer than itself, §5). Absence or malformed JSON
|
|
86
|
+
// means "no protocol info"; the caller skips the check.
|
|
87
|
+
let json: unknown;
|
|
88
|
+
try {
|
|
89
|
+
json = JSON.parse(
|
|
90
|
+
fs.readFileSync(
|
|
91
|
+
path.join(artifactDir, 'client/manifest.json'),
|
|
92
|
+
'utf-8'
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof json !== 'object' || json === null) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const manifest = json as Record<string, unknown>;
|
|
102
|
+
const protocol =
|
|
103
|
+
typeof manifest.protocol === 'number' ? manifest.protocol : 1;
|
|
104
|
+
return { protocol };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isWorkspacePlaceholderRange(range: string): boolean {
|
|
108
|
+
return /^(workspace|file|link|portal):/.test(range);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Group winner before final shaping into SupplyGroup. */
|
|
112
|
+
interface GroupWinner {
|
|
113
|
+
provider: string;
|
|
114
|
+
version: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** pkg → majorKey ('2', '3', 'unknown') → elected winner of that group. */
|
|
118
|
+
type GroupedSupply = Record<string, Record<string, GroupWinner>>;
|
|
119
|
+
|
|
120
|
+
function majorKeyOf(version: string | null): string {
|
|
121
|
+
const parsed = version ? parseSemver(version) : null;
|
|
122
|
+
return parsed ? String(parsed.major) : 'unknown';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Numeric majors descending, 'unknown' last. */
|
|
126
|
+
function sortedMajorKeys(groups: Record<string, GroupWinner>): string[] {
|
|
127
|
+
return Object.keys(groups).sort((a, b) => {
|
|
128
|
+
if (a === 'unknown') {
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
if (b === 'unknown') {
|
|
132
|
+
return -1;
|
|
133
|
+
}
|
|
134
|
+
return Number(b) - Number(a);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Single-owner merge (RFC §7): a given (package, major) may have exactly ONE
|
|
140
|
+
* provider in the closure. Merging a second, DISTINCT provider for the same
|
|
141
|
+
* (package, major) is a conflict reported via `onConflict` (the caller emits
|
|
142
|
+
* E_DUP_PROVIDER); the existing owner is kept so resolution stays
|
|
143
|
+
* deterministic while the error gates the build. The SAME provider reaching
|
|
144
|
+
* via multiple paths (diamonds) is not a conflict — identity is compared.
|
|
145
|
+
*/
|
|
146
|
+
function mergeGrouped(
|
|
147
|
+
into: GroupedSupply,
|
|
148
|
+
from: GroupedSupply,
|
|
149
|
+
onConflict: (
|
|
150
|
+
packageName: string,
|
|
151
|
+
majorKey: string,
|
|
152
|
+
existing: string,
|
|
153
|
+
incoming: string
|
|
154
|
+
) => void
|
|
155
|
+
): void {
|
|
156
|
+
for (const [packageName, groups] of Object.entries(from)) {
|
|
157
|
+
const target = into[packageName] ?? (into[packageName] = {});
|
|
158
|
+
for (const [majorKey, winner] of Object.entries(groups)) {
|
|
159
|
+
const existing = target[majorKey];
|
|
160
|
+
if (existing) {
|
|
161
|
+
if (existing.provider !== winner.provider) {
|
|
162
|
+
onConflict(
|
|
163
|
+
packageName,
|
|
164
|
+
majorKey,
|
|
165
|
+
existing.provider,
|
|
166
|
+
winner.provider
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
target[majorKey] = winner;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Consumer-side group selection (RFC §7, per-major amendment): the group
|
|
178
|
+
* whose winner satisfies `range`; multiple satisfying groups → highest
|
|
179
|
+
* major; no range (or a workspace placeholder) → highest major group;
|
|
180
|
+
* range satisfied by no group → highest major group (the caller diagnoses
|
|
181
|
+
* the violation as E_VERSION). Groups whose version cannot be checked
|
|
182
|
+
* against the range (unparsable either side) rank after satisfying groups
|
|
183
|
+
* but before violating ones, per the RFC §11 skip-the-gate rule.
|
|
184
|
+
*/
|
|
185
|
+
export function selectSupplyGroup(
|
|
186
|
+
entry: SupplyEntry,
|
|
187
|
+
range?: string
|
|
188
|
+
): SupplyGroup | null {
|
|
189
|
+
if (entry.groups.length === 0) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (range === undefined || isWorkspacePlaceholderRange(range)) {
|
|
193
|
+
return entry.groups[0];
|
|
194
|
+
}
|
|
195
|
+
let unknownFallback: SupplyGroup | null = null;
|
|
196
|
+
for (const group of entry.groups) {
|
|
197
|
+
const satisfied = group.version
|
|
198
|
+
? satisfiesRange(group.version, range)
|
|
199
|
+
: null;
|
|
200
|
+
if (satisfied === true) {
|
|
201
|
+
return group;
|
|
202
|
+
}
|
|
203
|
+
if (satisfied === null && unknownFallback === null) {
|
|
204
|
+
unknownFallback = group;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return unknownFallback ?? entry.groups[0];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolves the mount table and the recursive supply merge for a root
|
|
212
|
+
* module's declaration (RFC §6 + §7 phases 1–2).
|
|
213
|
+
*
|
|
214
|
+
* - `envLinks` entries override auto-mounting; their values follow today's
|
|
215
|
+
* `ModuleConfig.links` semantics (artifact directory, resolved relative
|
|
216
|
+
* to `rootDir`).
|
|
217
|
+
* - Auto-mounts resolve via Node resolution from the DECLARING module's
|
|
218
|
+
* own location and are realpath'd once.
|
|
219
|
+
*/
|
|
220
|
+
export function resolveMounts(
|
|
221
|
+
rootDir: string,
|
|
222
|
+
rootPackage: ReadDeclarationResult,
|
|
223
|
+
envLinks?: ResolverEnvLinks
|
|
224
|
+
): ResolveMountsResult {
|
|
225
|
+
const diagnostics: Diagnostic[] = [...rootPackage.diagnostics];
|
|
226
|
+
const mounts: Record<string, ResolvedMount> = {};
|
|
227
|
+
const records = new Map<string, PackageRecord>([
|
|
228
|
+
[rootPackage.name, rootPackage]
|
|
229
|
+
]);
|
|
230
|
+
const supplyMemo = new Map<string, GroupedSupply>();
|
|
231
|
+
const usedSupplyMemo = new Map<string, GroupedSupply>();
|
|
232
|
+
const visiting = new Set<string>();
|
|
233
|
+
// A uses cycle makes the merge result order-dependent (which member the
|
|
234
|
+
// traversal enters first decides the winner). RFC P3 forbids emitting an
|
|
235
|
+
// arbitrary-but-usable artifact alongside an error, so a cycle hard-stops
|
|
236
|
+
// resolution: supply is withheld and only the E_CYCLE error remains,
|
|
237
|
+
// which fails the build instead of wiring a coin-flip.
|
|
238
|
+
let cyclic = false;
|
|
239
|
+
/** De-dupes E_DUP_PROVIDER so one conflict is reported once. */
|
|
240
|
+
const reportedDupes = new Set<string>();
|
|
241
|
+
|
|
242
|
+
function reportDupProvider(
|
|
243
|
+
consumer: string,
|
|
244
|
+
packageName: string,
|
|
245
|
+
majorKey: string,
|
|
246
|
+
existing: string,
|
|
247
|
+
incoming: string
|
|
248
|
+
): void {
|
|
249
|
+
const key = `${packageName}@${majorKey}`;
|
|
250
|
+
if (reportedDupes.has(key)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
reportedDupes.add(key);
|
|
254
|
+
diagnostics.push({
|
|
255
|
+
code: DiagnosticCode.E_DUP_PROVIDER,
|
|
256
|
+
severity: 'error',
|
|
257
|
+
module: consumer,
|
|
258
|
+
package: packageName,
|
|
259
|
+
found: `${existing}, ${incoming}`,
|
|
260
|
+
message: `Package "${packageName}" (major ${majorKey}) is provided by both "${existing}" and "${incoming}" in the closure of "${consumer}" — a shared dependency must have a single owner.`,
|
|
261
|
+
fix: `Consolidate "${packageName}" into one shared module that both consume via "uses", or give one copy a distinct package identity (an npm alias provided under its own name) if same-major coexistence is intended.`
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function mountUsed(
|
|
266
|
+
name: string,
|
|
267
|
+
consumer: PackageRecord
|
|
268
|
+
): PackageRecord | null {
|
|
269
|
+
const known = records.get(name);
|
|
270
|
+
if (known) {
|
|
271
|
+
return known;
|
|
272
|
+
}
|
|
273
|
+
let packageRoot: string | null = null;
|
|
274
|
+
let artifactDir: string | null = null;
|
|
275
|
+
const envLink = envLinks?.[name];
|
|
276
|
+
if (envLink !== undefined) {
|
|
277
|
+
artifactDir = path.isAbsolute(envLink)
|
|
278
|
+
? envLink
|
|
279
|
+
: path.resolve(rootDir, envLink);
|
|
280
|
+
packageRoot = path.dirname(artifactDir);
|
|
281
|
+
} else {
|
|
282
|
+
const resolved = resolvePackageDir(consumer.root, name);
|
|
283
|
+
if (resolved) {
|
|
284
|
+
packageRoot = fs.realpathSync(resolved);
|
|
285
|
+
artifactDir = path.join(packageRoot, 'dist');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const record = packageRoot ? readPackageRecord(packageRoot) : null;
|
|
289
|
+
if (!record || !artifactDir || !packageRoot) {
|
|
290
|
+
diagnostics.push({
|
|
291
|
+
code: DiagnosticCode.E_NOT_LINKED,
|
|
292
|
+
severity: 'error',
|
|
293
|
+
module: consumer.name,
|
|
294
|
+
package: name,
|
|
295
|
+
message: `Module "${consumer.name}" uses "${name}" but it is absent from the mount table (not installed and no explicit link).`,
|
|
296
|
+
fix: `Install "${name}" into node_modules, or add an explicit links entry pointing at its artifact directory.`
|
|
297
|
+
});
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
diagnostics.push(...record.diagnostics);
|
|
301
|
+
const built = fs.existsSync(
|
|
302
|
+
path.join(artifactDir, 'client/manifest.json')
|
|
303
|
+
);
|
|
304
|
+
if (!built) {
|
|
305
|
+
diagnostics.push({
|
|
306
|
+
code: DiagnosticCode.E_NOT_BUILT,
|
|
307
|
+
severity: 'error',
|
|
308
|
+
module: consumer.name,
|
|
309
|
+
package: name,
|
|
310
|
+
message: `Module "${name}" is mounted at ${artifactDir} but has no built artifact (dist/client/manifest.json missing). Manifest-dependent checks are skipped.`,
|
|
311
|
+
fix: `Build "${name}" first, then rebuild "${consumer.name}".`
|
|
312
|
+
});
|
|
313
|
+
} else {
|
|
314
|
+
// RFC §5: the linker rejects manifests whose protocol is HIGHER
|
|
315
|
+
// than its own — a newer toolchain produced facts this resolver
|
|
316
|
+
// cannot interpret.
|
|
317
|
+
const info = readManifestInfo(artifactDir);
|
|
318
|
+
if (info && info.protocol > MANIFEST_PROTOCOL_VERSION) {
|
|
319
|
+
diagnostics.push({
|
|
320
|
+
code: DiagnosticCode.E_PROTOCOL,
|
|
321
|
+
severity: 'error',
|
|
322
|
+
module: consumer.name,
|
|
323
|
+
package: name,
|
|
324
|
+
found: String(info.protocol),
|
|
325
|
+
required: `<= ${MANIFEST_PROTOCOL_VERSION}`,
|
|
326
|
+
message: `Module "${name}" was built with manifest protocol ${info.protocol}, but this linker supports up to ${MANIFEST_PROTOCOL_VERSION}.`,
|
|
327
|
+
fix: `Upgrade esmx in "${consumer.name}", or rebuild "${name}" with a toolchain emitting protocol <= ${MANIFEST_PROTOCOL_VERSION}.`
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
mounts[name] = { name, root: packageRoot, artifactDir, built };
|
|
332
|
+
records.set(name, record);
|
|
333
|
+
return record;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function checkUsedVersion(
|
|
337
|
+
consumer: PackageRecord,
|
|
338
|
+
used: PackageRecord
|
|
339
|
+
): void {
|
|
340
|
+
// A used module is a build/compose-time dependency, so its range may
|
|
341
|
+
// live in devDependencies (the hygiene-correct bucket) as well as
|
|
342
|
+
// dependencies/peerDependencies — all three are checked at link time.
|
|
343
|
+
const range =
|
|
344
|
+
consumer.dependencies[used.name] ??
|
|
345
|
+
consumer.peerDependencies[used.name] ??
|
|
346
|
+
consumer.devDependencies[used.name];
|
|
347
|
+
if (range === undefined || isWorkspacePlaceholderRange(range)) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Private/workspace placeholder versions skip the gate (RFC §11).
|
|
351
|
+
if (used.private) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const satisfied = satisfiesRange(used.version, range);
|
|
355
|
+
if (satisfied === false) {
|
|
356
|
+
diagnostics.push({
|
|
357
|
+
code: DiagnosticCode.E_VERSION,
|
|
358
|
+
severity: 'error',
|
|
359
|
+
module: consumer.name,
|
|
360
|
+
package: used.name,
|
|
361
|
+
check: 'intent',
|
|
362
|
+
found: used.version,
|
|
363
|
+
required: range,
|
|
364
|
+
message: `Module "${consumer.name}" requires "${used.name}@${range}" but the mounted module is version ${used.version}.`,
|
|
365
|
+
fix: `Update the "${used.name}" range in "${consumer.name}" dependencies, or mount a matching version.`
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function supplyOf(record: PackageRecord): GroupedSupply {
|
|
371
|
+
const memoized = supplyMemo.get(record.name);
|
|
372
|
+
if (memoized) {
|
|
373
|
+
return memoized;
|
|
374
|
+
}
|
|
375
|
+
if (visiting.has(record.name)) {
|
|
376
|
+
cyclic = true;
|
|
377
|
+
diagnostics.push({
|
|
378
|
+
code: DiagnosticCode.E_CYCLE,
|
|
379
|
+
severity: 'error',
|
|
380
|
+
module: record.name,
|
|
381
|
+
message: `The uses chain revisits module "${record.name}" — a uses cycle is an architecture error.`,
|
|
382
|
+
fix: `Break the cycle by removing one of the "uses" edges that closes it.`
|
|
383
|
+
});
|
|
384
|
+
return {};
|
|
385
|
+
}
|
|
386
|
+
visiting.add(record.name);
|
|
387
|
+
const onConflict = (
|
|
388
|
+
packageName: string,
|
|
389
|
+
majorKey: string,
|
|
390
|
+
existing: string,
|
|
391
|
+
incoming: string
|
|
392
|
+
) =>
|
|
393
|
+
reportDupProvider(
|
|
394
|
+
record.name,
|
|
395
|
+
packageName,
|
|
396
|
+
majorKey,
|
|
397
|
+
existing,
|
|
398
|
+
incoming
|
|
399
|
+
);
|
|
400
|
+
const fromUses: GroupedSupply = {};
|
|
401
|
+
for (const usedName of record.declaration?.uses ?? []) {
|
|
402
|
+
const used = mountUsed(usedName, record);
|
|
403
|
+
if (!used) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
checkUsedVersion(record, used);
|
|
407
|
+
mergeGrouped(fromUses, supplyOf(used), onConflict);
|
|
408
|
+
}
|
|
409
|
+
usedSupplyMemo.set(record.name, fromUses);
|
|
410
|
+
const merged: GroupedSupply = {};
|
|
411
|
+
mergeGrouped(merged, fromUses, onConflict);
|
|
412
|
+
// Self-provides layer on top — but a sibling already owning this
|
|
413
|
+
// (package, major) is a single-owner conflict, not a self-override.
|
|
414
|
+
for (const provided of record.declaration?.provides ?? []) {
|
|
415
|
+
const version = resolveInstalledVersion(record.root, provided);
|
|
416
|
+
const majorKey = majorKeyOf(version);
|
|
417
|
+
const existing = merged[provided]?.[majorKey];
|
|
418
|
+
if (existing && existing.provider !== record.name) {
|
|
419
|
+
reportDupProvider(
|
|
420
|
+
record.name,
|
|
421
|
+
provided,
|
|
422
|
+
majorKey,
|
|
423
|
+
existing.provider,
|
|
424
|
+
record.name
|
|
425
|
+
);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
merged[provided] = {
|
|
429
|
+
...merged[provided],
|
|
430
|
+
[majorKey]: { provider: record.name, version }
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
visiting.delete(record.name);
|
|
434
|
+
supplyMemo.set(record.name, merged);
|
|
435
|
+
return merged;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// A1 (root-only): the module's own declared entry/exports target files
|
|
439
|
+
// must exist on disk — a build-free authoring check that catches typo'd
|
|
440
|
+
// or renamed paths before the build does. Root-only because mounted deps
|
|
441
|
+
// ship dist (not src), so their `./src/*` targets are legitimately absent.
|
|
442
|
+
// Fork sides set to `false` are skipped.
|
|
443
|
+
const targetChecks: Array<[string, string]> = [];
|
|
444
|
+
const rootEntry = rootPackage.declaration.entry;
|
|
445
|
+
if (rootEntry?.client) {
|
|
446
|
+
targetChecks.push(['entry.client', rootEntry.client]);
|
|
447
|
+
}
|
|
448
|
+
if (rootEntry?.server) {
|
|
449
|
+
targetChecks.push(['entry.server', rootEntry.server]);
|
|
450
|
+
}
|
|
451
|
+
for (const [name, value] of Object.entries(
|
|
452
|
+
rootPackage.declaration.exports ?? {}
|
|
453
|
+
)) {
|
|
454
|
+
if (typeof value === 'string') {
|
|
455
|
+
targetChecks.push([`exports['${name}']`, value]);
|
|
456
|
+
} else {
|
|
457
|
+
if (typeof value.client === 'string') {
|
|
458
|
+
targetChecks.push([`exports['${name}'].client`, value.client]);
|
|
459
|
+
}
|
|
460
|
+
if (typeof value.server === 'string') {
|
|
461
|
+
targetChecks.push([`exports['${name}'].server`, value.server]);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const [label, relativePath] of targetChecks) {
|
|
466
|
+
if (!fs.existsSync(path.resolve(rootPackage.root, relativePath))) {
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
code: DiagnosticCode.E_TARGET_MISSING,
|
|
469
|
+
severity: 'error',
|
|
470
|
+
module: rootPackage.name,
|
|
471
|
+
found: relativePath,
|
|
472
|
+
message: `Declared ${label} target "${relativePath}" does not exist in module "${rootPackage.name}".`,
|
|
473
|
+
fix: `Create the file, or fix the path in the "esmx" declaration.`
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const groupedSupply = supplyOf(rootPackage);
|
|
479
|
+
// Cycle hard-stop (RFC P3): the supply table built during a cyclic walk
|
|
480
|
+
// is a function of traversal order, not of declarations. Withhold it so no
|
|
481
|
+
// caller can wire on an arbitrary result; the E_CYCLE error in
|
|
482
|
+
// `diagnostics` fails the build.
|
|
483
|
+
if (cyclic) {
|
|
484
|
+
return { supply: {}, mounts, diagnostics };
|
|
485
|
+
}
|
|
486
|
+
const supply: Record<string, SupplyEntry> = {};
|
|
487
|
+
for (const [packageName, groups] of Object.entries(groupedSupply)) {
|
|
488
|
+
supply[packageName] = {
|
|
489
|
+
groups: sortedMajorKeys(groups).map((majorKey) => ({
|
|
490
|
+
major: majorKey === 'unknown' ? 'unknown' : Number(majorKey),
|
|
491
|
+
provider: groups[majorKey].provider,
|
|
492
|
+
version: groups[majorKey].version
|
|
493
|
+
}))
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// W_MULTI_MAJOR: informational visibility for same-name multi-major
|
|
498
|
+
// coexistence — never an error, cross-major rewiring cannot happen.
|
|
499
|
+
for (const [packageName, entry] of Object.entries(supply)) {
|
|
500
|
+
if (entry.groups.length < 2) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const summary = entry.groups
|
|
504
|
+
.map(
|
|
505
|
+
(group) =>
|
|
506
|
+
`${group.major} → "${group.provider}"@${group.version ?? 'unresolved'}`
|
|
507
|
+
)
|
|
508
|
+
.join(', ');
|
|
509
|
+
diagnostics.push({
|
|
510
|
+
code: DiagnosticCode.W_MULTI_MAJOR,
|
|
511
|
+
severity: 'warning',
|
|
512
|
+
module: rootPackage.name,
|
|
513
|
+
package: packageName,
|
|
514
|
+
found: summary,
|
|
515
|
+
message: `Package "${packageName}" has coexisting major versions: ${summary}. Each major is an isolated island with its own winner; every consumer wires to the group satisfying its own range.`,
|
|
516
|
+
fix: `No action needed if coexistence is intended. Otherwise align the providers' installed "${packageName}" majors.`
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The group a layer wires to: a module providing the package itself
|
|
522
|
+
* runs on (and wires to) its own major group — instance consistency,
|
|
523
|
+
* RFC §4.1 — otherwise its dependencies ∪ peerDependencies range
|
|
524
|
+
* selects the satisfying group.
|
|
525
|
+
*/
|
|
526
|
+
function wiredGroupFor(
|
|
527
|
+
record: PackageRecord,
|
|
528
|
+
packageName: string,
|
|
529
|
+
entry: SupplyEntry
|
|
530
|
+
): SupplyGroup | null {
|
|
531
|
+
if (record.declaration?.provides?.includes(packageName)) {
|
|
532
|
+
const selfKey = majorKeyOf(
|
|
533
|
+
resolveInstalledVersion(record.root, packageName)
|
|
534
|
+
);
|
|
535
|
+
const own = entry.groups.find(
|
|
536
|
+
(group) => String(group.major) === selfKey
|
|
537
|
+
);
|
|
538
|
+
if (own) {
|
|
539
|
+
return own;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const range =
|
|
543
|
+
record.dependencies[packageName] ??
|
|
544
|
+
record.peerDependencies[packageName];
|
|
545
|
+
return selectSupplyGroup(entry, range);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Intent check: the wired group winner's resolved version must satisfy
|
|
549
|
+
// each layer's dependencies ∪ peerDependencies range for the package —
|
|
550
|
+
// validated against the group the layer wires to, never an unrelated
|
|
551
|
+
// major.
|
|
552
|
+
for (const record of records.values()) {
|
|
553
|
+
for (const [packageName, entry] of Object.entries(supply)) {
|
|
554
|
+
const range =
|
|
555
|
+
record.dependencies[packageName] ??
|
|
556
|
+
record.peerDependencies[packageName];
|
|
557
|
+
if (range === undefined || isWorkspacePlaceholderRange(range)) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const group = wiredGroupFor(record, packageName, entry);
|
|
561
|
+
if (!group?.version) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const satisfied = satisfiesRange(group.version, range);
|
|
565
|
+
if (satisfied === false) {
|
|
566
|
+
diagnostics.push({
|
|
567
|
+
code: DiagnosticCode.E_VERSION,
|
|
568
|
+
severity: 'error',
|
|
569
|
+
module: record.name,
|
|
570
|
+
package: packageName,
|
|
571
|
+
check: 'intent',
|
|
572
|
+
found: group.version,
|
|
573
|
+
required: range,
|
|
574
|
+
message: `Layer "${record.name}" declares "${packageName}@${range}" but its wired group winner "${group.provider}" resolves ${group.version}.`,
|
|
575
|
+
fix: `Update the "${packageName}" range in "${record.name}", or change the winning provider's installed version.`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// W_NO_RANGE: a layer whose uses subtree supplies a package it declares
|
|
582
|
+
// no range for, in dependencies ∪ peerDependencies ∪ devDependencies.
|
|
583
|
+
for (const record of records.values()) {
|
|
584
|
+
const usedSupply = usedSupplyMemo.get(record.name);
|
|
585
|
+
if (!usedSupply) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
for (const packageName of Object.keys(usedSupply)) {
|
|
589
|
+
const hasRange =
|
|
590
|
+
packageName in record.dependencies ||
|
|
591
|
+
packageName in record.peerDependencies ||
|
|
592
|
+
packageName in record.devDependencies;
|
|
593
|
+
if (hasRange) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
diagnostics.push({
|
|
597
|
+
code: DiagnosticCode.W_NO_RANGE,
|
|
598
|
+
severity: 'warning',
|
|
599
|
+
module: record.name,
|
|
600
|
+
package: packageName,
|
|
601
|
+
message: `Layer "${record.name}" receives "${packageName}" from its uses chain but declares no version range for it.`,
|
|
602
|
+
fix: `Add "${packageName}" to "${record.name}" dependencies or peerDependencies with the intended range.`
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// W_TYPE_DRIFT: a layer's local devDependencies copy (its types source)
|
|
608
|
+
// diverges from the elected winner's resolved version.
|
|
609
|
+
for (const record of records.values()) {
|
|
610
|
+
for (const [packageName, entry] of Object.entries(supply)) {
|
|
611
|
+
if (!(packageName in record.devDependencies)) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const group = wiredGroupFor(record, packageName, entry);
|
|
615
|
+
if (!group?.version) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const localVersion = resolveInstalledVersion(
|
|
619
|
+
record.root,
|
|
620
|
+
packageName
|
|
621
|
+
);
|
|
622
|
+
if (localVersion && localVersion !== group.version) {
|
|
623
|
+
diagnostics.push({
|
|
624
|
+
code: DiagnosticCode.W_TYPE_DRIFT,
|
|
625
|
+
severity: 'warning',
|
|
626
|
+
module: record.name,
|
|
627
|
+
package: packageName,
|
|
628
|
+
found: localVersion,
|
|
629
|
+
required: group.version,
|
|
630
|
+
message: `Layer "${record.name}" types against its local "${packageName}@${localVersion}" but the code runs on the wired group winner's ${group.version}.`,
|
|
631
|
+
fix: `Align the "${packageName}" devDependencies copy in "${record.name}" with the winner's version ${group.version}.`
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { supply, mounts, diagnostics };
|
|
638
|
+
}
|