@nifrajs/web 2.14.1 → 3.2.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/boundary.d.ts +158 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +237 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-plan.d.ts +228 -0
- package/dist/build-plan.d.ts.map +1 -0
- package/dist/build-plan.js +514 -0
- package/dist/build-plan.js.map +1 -0
- package/dist/build-vite.d.ts +2 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +5 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +2 -197
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +92 -515
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts.map +1 -1
- package/dist/chunk-names.js +3 -2
- package/dist/chunk-names.js.map +1 -1
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +63 -28
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +9 -2
- package/dist/diagnostic.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +3 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +2 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +16 -589
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1824
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +68 -0
- package/dist/internal/codegen.d.ts.map +1 -0
- package/dist/internal/codegen.js +317 -0
- package/dist/internal/codegen.js.map +1 -0
- package/dist/internal/fetcher-runtime.d.ts +19 -0
- package/dist/internal/fetcher-runtime.d.ts.map +1 -0
- package/dist/internal/fetcher-runtime.js +26 -0
- package/dist/internal/fetcher-runtime.js.map +1 -0
- package/dist/internal/head-merge.d.ts +2 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +17 -1
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/identity-policy.d.ts +51 -0
- package/dist/internal/identity-policy.d.ts.map +1 -0
- package/dist/internal/identity-policy.js +67 -0
- package/dist/internal/identity-policy.js.map +1 -0
- package/dist/internal/parity.d.ts +104 -8
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +472 -56
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/query-runtime.d.ts +8 -0
- package/dist/internal/query-runtime.d.ts.map +1 -0
- package/dist/internal/query-runtime.js +29 -0
- package/dist/internal/query-runtime.js.map +1 -0
- package/dist/internal/render-document.d.ts +372 -0
- package/dist/internal/render-document.d.ts.map +1 -0
- package/dist/internal/render-document.js +744 -0
- package/dist/internal/render-document.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +48 -0
- package/dist/internal/runtime-contract.d.ts.map +1 -0
- package/dist/internal/runtime-contract.js +27 -0
- package/dist/internal/runtime-contract.js.map +1 -0
- package/dist/internal/web-app.d.ts +171 -0
- package/dist/internal/web-app.d.ts.map +1 -0
- package/dist/internal/web-app.js +844 -0
- package/dist/internal/web-app.js.map +1 -0
- package/dist/islands.d.ts +50 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +96 -36
- package/dist/islands.js.map +1 -1
- package/dist/manifest.d.ts +41 -6
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +22 -9
- package/dist/module-graph.js.map +1 -1
- package/dist/nano.d.ts +125 -0
- package/dist/nano.d.ts.map +1 -0
- package/dist/nano.js +190 -0
- package/dist/nano.js.map +1 -0
- package/dist/plugins/css-modules.js +2 -2
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +12 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +62 -12
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/postcss.js +2 -2
- package/dist/plugins/postcss.js.map +1 -1
- package/dist/plugins/scss.js +2 -2
- package/dist/plugins/scss.js.map +1 -1
- package/dist/plugins/stylex.d.ts +70 -0
- package/dist/plugins/stylex.d.ts.map +1 -0
- package/dist/plugins/stylex.js +166 -0
- package/dist/plugins/stylex.js.map +1 -0
- package/dist/plugins/svg.js +2 -2
- package/dist/plugins/svg.js.map +1 -1
- package/dist/render-seam.d.ts +5 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +10 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +11 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +254 -47
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +34 -16
- package/dist/vite.js.map +1 -1
- package/package.json +49 -2
- package/src/boundary.ts +407 -0
- package/src/build-plan.ts +741 -0
- package/src/build-vite.ts +6 -7
- package/src/build.ts +120 -730
- package/src/chunk-names.ts +3 -2
- package/src/dev-ssr-graph.ts +63 -28
- package/src/diagnostic.ts +14 -2
- package/src/fonts-google.ts +3 -1
- package/src/fs.ts +2 -1
- package/src/index.ts +107 -2553
- package/src/internal/codegen.ts +400 -0
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/head-merge.ts +19 -1
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +603 -71
- package/src/internal/query-runtime.ts +31 -0
- package/src/internal/render-document.ts +1116 -0
- package/src/internal/runtime-contract.ts +54 -0
- package/src/internal/web-app.ts +1114 -0
- package/src/islands.ts +106 -40
- package/src/manifest.ts +50 -3
- package/src/module-graph.ts +23 -9
- package/src/nano.ts +246 -0
- package/src/plugins/css-modules.ts +2 -2
- package/src/plugins/kit.ts +65 -12
- package/src/plugins/postcss.ts +2 -2
- package/src/plugins/scss.ts +2 -2
- package/src/plugins/stylex.ts +298 -0
- package/src/plugins/svg.ts +2 -2
- package/src/render-seam.ts +6 -0
- package/src/router.ts +293 -51
- package/src/vite.ts +41 -15
package/dist/internal/parity.js
CHANGED
|
@@ -5,30 +5,141 @@
|
|
|
5
5
|
* modules and emitted app surfaces resolve to one coherent graph? This internal seam owns path and
|
|
6
6
|
* manifest normalization so those callers cannot quietly grow separate rules.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
9
9
|
import { lstat, realpath, stat } from "node:fs/promises";
|
|
10
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { matchesSingleCopyDeclaration, readSingleCopyDeclaration, readSingleCopyRegistration, SINGLE_COPY_REGISTER_SPECIFIER, } from "@nifrajs/core/single-copy";
|
|
11
12
|
import { discoverRoutes } from "../fs.js";
|
|
13
|
+
import { isIdentitySensitivePackage } from "./identity-policy.js";
|
|
12
14
|
const DEPENDENCY_FIELDS = [
|
|
13
15
|
"dependencies",
|
|
14
16
|
"devDependencies",
|
|
15
17
|
"peerDependencies",
|
|
16
18
|
"optionalDependencies",
|
|
17
19
|
];
|
|
18
|
-
const IDENTITY_SENSITIVE_PACKAGES = new Set([
|
|
19
|
-
"@nifrajs/core",
|
|
20
|
-
"react",
|
|
21
|
-
"react-dom",
|
|
22
|
-
"preact",
|
|
23
|
-
"svelte",
|
|
24
|
-
"solid-js",
|
|
25
|
-
"vue",
|
|
26
|
-
]);
|
|
27
20
|
const MAX_WORKSPACE_IMPORTERS = 2_048;
|
|
28
21
|
const MAX_LINKED_PACKAGES = 64;
|
|
29
22
|
const MAX_LINK_PROBES = 4_096;
|
|
30
23
|
const SOURCE_EXTENSIONS = /\.(?:c|m)?(?:j|t)sx?$|\.(?:mdx|svelte|vue)$/;
|
|
31
|
-
|
|
24
|
+
/** A stylesheet path that the source scanner can prove is a real explicit import. */
|
|
25
|
+
const CSS_SPECIFIER = /\.(?:css|scss|sass|less|styl)(?:\?[^"'`]*)?$/i;
|
|
26
|
+
/**
|
|
27
|
+
* Tokenize only the small part of TypeScript/JS needed for the CSS contract.
|
|
28
|
+
*
|
|
29
|
+
* A regex over source text is not sound here: documentation routes contain code examples such as
|
|
30
|
+
* `import "./app.css"` in template literals, and comments can contain the same text. Those are not
|
|
31
|
+
* imports and must not make a style-free production build fail. This intentionally conservative lexer
|
|
32
|
+
* drops comments and complete template literals (including `${...}` expressions); missing a stylesheet
|
|
33
|
+
* is the passing direction of this parity check, while claiming one that is not imported is not.
|
|
34
|
+
*/
|
|
35
|
+
const cssTokens = (source) => {
|
|
36
|
+
const tokens = [];
|
|
37
|
+
let lineStart = true;
|
|
38
|
+
for (let index = 0; index < source.length;) {
|
|
39
|
+
const char = source[index];
|
|
40
|
+
if (char === undefined)
|
|
41
|
+
break;
|
|
42
|
+
if (/\s/.test(char)) {
|
|
43
|
+
if (char === "\n" || char === "\r")
|
|
44
|
+
lineStart = true;
|
|
45
|
+
index++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === "/" && source[index + 1] === "/") {
|
|
49
|
+
index += 2;
|
|
50
|
+
while (index < source.length && source[index] !== "\n")
|
|
51
|
+
index++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (char === "/" && source[index + 1] === "*") {
|
|
55
|
+
const commentStart = index;
|
|
56
|
+
index += 2;
|
|
57
|
+
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/"))
|
|
58
|
+
index++;
|
|
59
|
+
index = Math.min(source.length, index + 2);
|
|
60
|
+
if (/[\r\n]/.test(source.slice(commentStart, index)))
|
|
61
|
+
lineStart = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "`") {
|
|
65
|
+
const templateStart = index;
|
|
66
|
+
index++;
|
|
67
|
+
while (index < source.length) {
|
|
68
|
+
const templateChar = source[index];
|
|
69
|
+
if (templateChar === "\\") {
|
|
70
|
+
index += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
index++;
|
|
74
|
+
if (templateChar === "`")
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
lineStart = /[\r\n]/.test(source.slice(templateStart, index));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (char === '"' || char === "'") {
|
|
81
|
+
const quote = char;
|
|
82
|
+
const start = ++index;
|
|
83
|
+
while (index < source.length) {
|
|
84
|
+
const stringChar = source[index];
|
|
85
|
+
if (stringChar === "\\") {
|
|
86
|
+
index += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
index++;
|
|
90
|
+
if (stringChar === quote)
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
tokens.push({ kind: "string", value: source.slice(start, Math.max(start, index - 1)) });
|
|
94
|
+
lineStart = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (/[A-Za-z_$]/.test(char)) {
|
|
98
|
+
const start = index++;
|
|
99
|
+
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? ""))
|
|
100
|
+
index++;
|
|
101
|
+
tokens.push({ kind: "identifier", value: source.slice(start, index), lineStart });
|
|
102
|
+
lineStart = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
tokens.push({ kind: "punctuator", value: char });
|
|
106
|
+
lineStart = false;
|
|
107
|
+
index++;
|
|
108
|
+
}
|
|
109
|
+
return tokens;
|
|
110
|
+
};
|
|
111
|
+
const isStylesheetToken = (token) => token?.kind === "string" && CSS_SPECIFIER.test(token.value);
|
|
112
|
+
/** Detect static imports, re-exports, literal dynamic imports, and literal require calls. */
|
|
113
|
+
const hasStylesheetImport = (source) => {
|
|
114
|
+
const tokens = cssTokens(source);
|
|
115
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
116
|
+
const token = tokens[index];
|
|
117
|
+
if (token?.kind !== "identifier")
|
|
118
|
+
continue;
|
|
119
|
+
const next = tokens[index + 1];
|
|
120
|
+
if (token.value === "import") {
|
|
121
|
+
if (token.lineStart && isStylesheetToken(next))
|
|
122
|
+
return true;
|
|
123
|
+
if (next?.value === "(" && isStylesheetToken(tokens[index + 2]))
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (token.value === "require" && next?.value === "(" && isStylesheetToken(tokens[index + 2]))
|
|
127
|
+
return true;
|
|
128
|
+
if (token.value === "from" && isStylesheetToken(next)) {
|
|
129
|
+
for (let previous = index - 1; previous >= 0; previous--) {
|
|
130
|
+
const candidate = tokens[previous];
|
|
131
|
+
if (candidate?.kind === "identifier" && candidate.lineStart) {
|
|
132
|
+
if (candidate.value === "import" || candidate.value === "export")
|
|
133
|
+
return true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (candidate?.value === ";")
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
32
143
|
/** A single-file-component `<style>` block (Svelte/Vue). The bundler extracts these into the app
|
|
33
144
|
* stylesheet even though no `import "...css"` statement exists, so the dev contract must count them
|
|
34
145
|
* or a scoped-style component would look style-free next to a production manifest that carries css. */
|
|
@@ -67,7 +178,88 @@ const workspacePatterns = (pkg) => {
|
|
|
67
178
|
.sort();
|
|
68
179
|
};
|
|
69
180
|
const pathExists = (path) => stat(path).then(() => true, () => false);
|
|
70
|
-
|
|
181
|
+
/** The real path, or the input when it cannot be resolved. Roots are compared against each other and
|
|
182
|
+
* printed side by side, so they have to be normalized the same way (`/var` vs `/private/var`). */
|
|
183
|
+
const realpathOrSelf = (path) => {
|
|
184
|
+
try {
|
|
185
|
+
// The native Windows resolver is the one that knows how to expand 8.3 aliases such as
|
|
186
|
+
// `RUNNER~1`. The portable resolver can preserve the spelling it was given, which makes two
|
|
187
|
+
// handles to the same checkout look like different install roots.
|
|
188
|
+
return process.platform === "win32" ? realpathSync.native(path) : realpathSync(path);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return path;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
/** Canonical comparison form for containment. Windows paths are case-insensitive and CI can expose
|
|
195
|
+
* the same directory once through a short name (`RUNNER~1`) and once through its long name. */
|
|
196
|
+
const comparisonPath = (path) => {
|
|
197
|
+
const resolved = realpathOrSelf(resolve(path));
|
|
198
|
+
const native = resolved.replaceAll("/", sep);
|
|
199
|
+
return process.platform === "win32" ? native.toLowerCase() : native;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Windows may expose the same directory through a long path and an 8.3 short path even after
|
|
203
|
+
* `realpathSync` has normalized both inputs. When the lexical comparison above says "outside", walk
|
|
204
|
+
* the target's existing ancestors and compare the filesystem identity of each one. `statSync` with
|
|
205
|
+
* bigint fields avoids truncating Windows file IDs, and a missing identity fails closed.
|
|
206
|
+
*/
|
|
207
|
+
const pathInsideByFilesystemIdentity = (root, path) => {
|
|
208
|
+
if (process.platform !== "win32")
|
|
209
|
+
return false;
|
|
210
|
+
const rootIdentity = (() => {
|
|
211
|
+
try {
|
|
212
|
+
const info = statSync(realpathOrSelf(resolve(root)), { bigint: true });
|
|
213
|
+
if (info.ino === 0n)
|
|
214
|
+
return undefined;
|
|
215
|
+
return { dev: info.dev, ino: info.ino };
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
try {
|
|
219
|
+
const info = statSync(realpathOrSelf(resolve(root)));
|
|
220
|
+
if (info.ino === 0)
|
|
221
|
+
return undefined;
|
|
222
|
+
return { dev: BigInt(info.dev), ino: BigInt(info.ino) };
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
if (rootIdentity === undefined)
|
|
230
|
+
return false;
|
|
231
|
+
let current = realpathOrSelf(resolve(path));
|
|
232
|
+
for (;;) {
|
|
233
|
+
try {
|
|
234
|
+
const info = statSync(current, { bigint: true });
|
|
235
|
+
if (info.ino !== 0n && info.dev === rootIdentity.dev && info.ino === rootIdentity.ino)
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
try {
|
|
240
|
+
const info = statSync(current);
|
|
241
|
+
if (info.ino !== 0 &&
|
|
242
|
+
BigInt(info.dev) === rootIdentity.dev &&
|
|
243
|
+
BigInt(info.ino) === rootIdentity.ino)
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// A non-existent leaf can still have an existing parent. Keep walking; if no ancestor can
|
|
248
|
+
// prove containment, the answer remains false rather than trusting a spelling alone.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const parent = dirname(current);
|
|
252
|
+
if (parent === current)
|
|
253
|
+
return false;
|
|
254
|
+
current = parent;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
export const pathInside = (root, path) => {
|
|
258
|
+
const distance = relative(comparisonPath(root), comparisonPath(path));
|
|
259
|
+
const lexicallyInside = distance === "" ||
|
|
260
|
+
(distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance));
|
|
261
|
+
return lexicallyInside || pathInsideByFilesystemIdentity(root, path);
|
|
262
|
+
};
|
|
71
263
|
const workspaceContains = async (root, patterns, target) => {
|
|
72
264
|
if (root === target)
|
|
73
265
|
return patterns.length > 0;
|
|
@@ -77,9 +269,7 @@ const workspaceContains = async (root, patterns, target) => {
|
|
|
77
269
|
if (match.split(/[\\/]/).includes("node_modules"))
|
|
78
270
|
continue;
|
|
79
271
|
const packageRoot = resolve(root, dirname(match));
|
|
80
|
-
|
|
81
|
-
if (targetRelative === "" ||
|
|
82
|
-
(!targetRelative.startsWith(`..${sep}`) && targetRelative !== ".."))
|
|
272
|
+
if (pathInside(packageRoot, target))
|
|
83
273
|
return true;
|
|
84
274
|
}
|
|
85
275
|
}
|
|
@@ -102,16 +292,29 @@ export async function resolveParityWorkspaceRoot(start) {
|
|
|
102
292
|
current = parent;
|
|
103
293
|
}
|
|
104
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Every workspace manifest that can install its own copy of a dependency.
|
|
297
|
+
*
|
|
298
|
+
* A pathological workspace must not make the scan unbounded, but it must not make it LIE either: the
|
|
299
|
+
* cap used to discard everything collected and return an empty list, which reads downstream as "no
|
|
300
|
+
* duplicates" - a clean bill from a scan that never ran. The bounded prefix is kept and `truncated`
|
|
301
|
+
* says the rest was not looked at.
|
|
302
|
+
*/
|
|
105
303
|
const workspaceImporters = async (root, rootPackage) => {
|
|
106
304
|
const manifests = new Set([join(root, "package.json")]);
|
|
305
|
+
let truncated = false;
|
|
107
306
|
for (const pattern of workspacePatterns(rootPackage)) {
|
|
307
|
+
if (truncated)
|
|
308
|
+
break;
|
|
108
309
|
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`;
|
|
109
310
|
for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
110
311
|
if (match.split(/[\\/]/).includes("node_modules"))
|
|
111
312
|
continue;
|
|
313
|
+
if (manifests.size >= MAX_WORKSPACE_IMPORTERS) {
|
|
314
|
+
truncated = true;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
112
317
|
manifests.add(join(root, match));
|
|
113
|
-
if (manifests.size > MAX_WORKSPACE_IMPORTERS)
|
|
114
|
-
return [];
|
|
115
318
|
}
|
|
116
319
|
}
|
|
117
320
|
const importers = [];
|
|
@@ -120,7 +323,7 @@ const workspaceImporters = async (root, rootPackage) => {
|
|
|
120
323
|
if (pkg !== undefined)
|
|
121
324
|
importers.push({ root: dirname(manifest), package: pkg });
|
|
122
325
|
}
|
|
123
|
-
return importers;
|
|
326
|
+
return { importers, truncated };
|
|
124
327
|
};
|
|
125
328
|
export const resolvedInstalledCopy = async (importer, boundary, name) => {
|
|
126
329
|
const parts = name.split("/");
|
|
@@ -145,7 +348,24 @@ export const resolvedInstalledCopy = async (importer, boundary, name) => {
|
|
|
145
348
|
return undefined;
|
|
146
349
|
}
|
|
147
350
|
};
|
|
351
|
+
/**
|
|
352
|
+
* How far above a linked package its dependencies may legitimately live.
|
|
353
|
+
*
|
|
354
|
+
* A linked SOURCE CHECKOUT (`link:../../nifra/packages/core`) really does resolve its imports from its
|
|
355
|
+
* own repo's `node_modules`, so the walk goes up to that repo's `.git` - a duplicate found there is one
|
|
356
|
+
* this project genuinely dual-loads.
|
|
357
|
+
*
|
|
358
|
+
* A path that lands INSIDE a `node_modules` directory is a package-manager store copy, not a checkout:
|
|
359
|
+
* a symlink into another project's store (bun's `.bun/<pkg>@<version>`, an `npm link` target, a shared
|
|
360
|
+
* global store) resolves there. Everything above such a copy belongs to whoever owns that store, and
|
|
361
|
+
* walking up would sweep an unrelated project's entire dependency tree into this project's duplicate
|
|
362
|
+
* report - permanent findings in a repo the developer is not even working in, that no change here can
|
|
363
|
+
* clear. Clamp to the copy itself: only its own nested `node_modules` counts, which is exactly the set
|
|
364
|
+
* of modules it can actually load.
|
|
365
|
+
*/
|
|
148
366
|
const linkedRepoBoundary = async (packageRoot) => {
|
|
367
|
+
if (packageRoot.split(sep).includes("node_modules"))
|
|
368
|
+
return packageRoot;
|
|
149
369
|
let dir = packageRoot;
|
|
150
370
|
for (let depth = 0; depth < 8; depth++) {
|
|
151
371
|
if (await pathExists(join(dir, ".git")))
|
|
@@ -166,8 +386,11 @@ const linkedPackageRoots = async (importerRoots, manifests, scanRoot) => {
|
|
|
166
386
|
if (pkg === undefined)
|
|
167
387
|
continue;
|
|
168
388
|
for (const name of dependencyNames(pkg)) {
|
|
169
|
-
|
|
170
|
-
|
|
389
|
+
// Same rule as the importer cap: a bounded scan reports that it stopped. Returning the prefix
|
|
390
|
+
// silently would let a duplicate sitting past the cap read as a clean bill.
|
|
391
|
+
if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) {
|
|
392
|
+
return { roots: [...found.keys()], truncated: true };
|
|
393
|
+
}
|
|
171
394
|
const candidate = join(importerRoot, "node_modules", ...name.split("/"));
|
|
172
395
|
const link = await lstat(candidate).catch(() => undefined);
|
|
173
396
|
if (link === undefined || !link.isSymbolicLink())
|
|
@@ -178,26 +401,114 @@ const linkedPackageRoots = async (importerRoots, manifests, scanRoot) => {
|
|
|
178
401
|
found.set(resolved, true);
|
|
179
402
|
}
|
|
180
403
|
}
|
|
181
|
-
return [...found.keys()];
|
|
404
|
+
return { roots: [...found.keys()], truncated: false };
|
|
182
405
|
};
|
|
183
406
|
export const displayPath = (cwd, path) => {
|
|
184
407
|
const rel = relative(cwd, path);
|
|
185
|
-
return rel === "" ? "." : rel;
|
|
408
|
+
return rel === "" ? "." : rel.replaceAll("\\", "/");
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* The install root a copy belongs to: the directory ABOVE its last `node_modules` segment.
|
|
412
|
+
*
|
|
413
|
+
* That directory is what a package manager operates on, so it is the unit a fix is expressed in. A
|
|
414
|
+
* copy that sits in no `node_modules` at all is a source checkout, which is its own install root.
|
|
415
|
+
*/
|
|
416
|
+
const installRootOf = (path) => {
|
|
417
|
+
const parts = path.split(sep);
|
|
418
|
+
const last = parts.lastIndexOf("node_modules");
|
|
419
|
+
if (last <= 0)
|
|
420
|
+
return path;
|
|
421
|
+
return parts.slice(0, last).join(sep);
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* State the SHAPE of a duplicate, not just its paths.
|
|
425
|
+
*
|
|
426
|
+
* The failure a developer has to act on is a topology, and the two topologies take opposite fixes.
|
|
427
|
+
* Copies under one install root (a nested `node_modules` shadowing the hoisted one) collapse with a
|
|
428
|
+
* single reinstall from that root. Copies under separate roots - a linked checkout, a standalone app
|
|
429
|
+
* beside this one with its own `node_modules` - do not: that second root belongs to another install,
|
|
430
|
+
* and nothing this project runs can merge it. Naming which case this is turns a path list into a
|
|
431
|
+
* decision.
|
|
432
|
+
*/
|
|
433
|
+
const describeTopology = (base, scanRoot, paths) => {
|
|
434
|
+
if (paths.length === 0)
|
|
435
|
+
return undefined;
|
|
436
|
+
const counts = new Map();
|
|
437
|
+
for (const path of paths) {
|
|
438
|
+
const root = installRootOf(path);
|
|
439
|
+
counts.set(root, (counts.get(root) ?? 0) + 1);
|
|
440
|
+
}
|
|
441
|
+
const roots = [...counts.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
442
|
+
const breakdown = roots
|
|
443
|
+
.map(([root, count]) => `${count} under ${displayPath(base, root)}`)
|
|
444
|
+
.join(", ");
|
|
445
|
+
const outside = roots.filter(([root]) => !pathInside(scanRoot, root));
|
|
446
|
+
const shape = outside.length > 0
|
|
447
|
+
? `${outside.length} install root${outside.length === 1 ? " is" : "s are"} outside the scanned root (${outside
|
|
448
|
+
.map(([root]) => displayPath(base, root))
|
|
449
|
+
.join(", ")}), so that copy is installed by another project and reinstalling here cannot remove it`
|
|
450
|
+
: roots.length > 1
|
|
451
|
+
? "all install roots are inside the scanned root, so a nested install is shadowing the hoisted copy and one reinstall from the workspace root collapses it"
|
|
452
|
+
: "one install root holds every copy, so the split is inside a single install (a nested or stored copy), not across projects";
|
|
453
|
+
return `${paths.length} paths under ${roots.length} install root${roots.length === 1 ? "" : "s"}: ${breakdown}; ${shape}`;
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* State that the gate is workspace-wide, when that is what makes a finding look wrong.
|
|
457
|
+
*
|
|
458
|
+
* Running `nifra build` inside `apps/web` can fail on a copy held by `apps/admin` - a package this
|
|
459
|
+
* app never imports. That is deliberate, not a miss: the scan is anchored on the workspace because
|
|
460
|
+
* copies reached through a workspace-linked dependency are invisible from the app directory, and
|
|
461
|
+
* anchoring on the app is exactly how this guard once reported "none" while dev was already broken.
|
|
462
|
+
* The trade is over-reporting instead of under-reporting, and the note says so rather than leaving a
|
|
463
|
+
* developer to conclude the tool is confused about which project it is checking.
|
|
464
|
+
*/
|
|
465
|
+
const describeScope = (requestedRoot, scanRoot, paths) => {
|
|
466
|
+
if (requestedRoot === scanRoot)
|
|
467
|
+
return undefined;
|
|
468
|
+
const outside = paths.filter((path) => !pathInside(requestedRoot, path));
|
|
469
|
+
if (outside.length === 0)
|
|
470
|
+
return undefined;
|
|
471
|
+
const here = displayPath(scanRoot, requestedRoot) || ".";
|
|
472
|
+
return `${outside.length} of these copies ${outside.length === 1 ? "is" : "are"} outside ${here}, so this fails every build in the workspace, including apps that never import the package. The scan is anchored on the workspace on purpose: a copy reached through a workspace-linked dependency cannot be seen from ${here} alone, and scoping to it would miss the case this check exists for. Fix the copies where they live, or declare the package single-copy.`;
|
|
473
|
+
};
|
|
474
|
+
const identityTargets = (pkg) => dependencyNames(pkg).filter(isIdentitySensitivePackage).sort();
|
|
475
|
+
/** version-skew is a range problem: one reinstall from the root collapses it. */
|
|
476
|
+
const VERSION_SKEW_REMEDIATION = "Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy; nifra does not rewrite lockfiles for identity conflicts.";
|
|
477
|
+
/** duplicate-path is a topology problem. When the copies come from a linked sibling repo a reinstall
|
|
478
|
+
* does not collapse them - each tree keeps its own copy, which is the whole reason `link:` was chosen.
|
|
479
|
+
* So the fix is either one physical path, or a declaration that nifra can verify and enforce. */
|
|
480
|
+
const duplicatePathRemediation = (name) => `Deduplicate so ${name} resolves to a single physical path, or declare it single-copy: add "nifra": { "singleCopy": ["${name}"] } to package.json and preload "${SINGLE_COPY_REGISTER_SPECIFIER}" from bunfig.toml. nifra then rewrites every duplicate to this app's copy - at one version only, so align ranges first if the copies ever differ.`;
|
|
481
|
+
const identityRemediation = (cause, name) => cause === "version-skew" ? VERSION_SKEW_REMEDIATION : duplicatePathRemediation(name);
|
|
482
|
+
/** What is left to do about a duplicate the declaration already covers. */
|
|
483
|
+
const deduplicatedRemediation = (registration) => registration.run || registration.test
|
|
484
|
+
? "Declared single-copy and enforced: every duplicate is rewritten to this app's copy, so one module instance loads."
|
|
485
|
+
: `Declared single-copy, so bundled output is deduplicated by the build. Unbundled runs are NOT: Bun's runtime never offers a bare specifier to a resolver, so add preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"] to bunfig.toml (and under [test]) or \`bun test\` still loads both copies.`;
|
|
486
|
+
/**
|
|
487
|
+
* Read the declaration and its runtime proof, preferring the directory the caller is in.
|
|
488
|
+
*
|
|
489
|
+
* A monorepo declares this per app, not once at the root: the app is what owns the `node_modules` the
|
|
490
|
+
* duplicates must collapse into, and two apps in one workspace can legitimately differ. The workspace
|
|
491
|
+
* root is the fallback so a single-package project needs no second file.
|
|
492
|
+
*/
|
|
493
|
+
const singleCopyCoverage = (cwd, scanRoot) => {
|
|
494
|
+
const declared = readSingleCopyDeclaration(cwd) ?? readSingleCopyDeclaration(scanRoot) ?? [];
|
|
495
|
+
const local = readSingleCopyRegistration(cwd);
|
|
496
|
+
const registration = local.run || local.test ? local : readSingleCopyRegistration(scanRoot);
|
|
497
|
+
return { declared, registration };
|
|
186
498
|
};
|
|
187
|
-
const identityTargets = (pkg) => dependencyNames(pkg)
|
|
188
|
-
.filter((name) => name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name))
|
|
189
|
-
.sort();
|
|
190
|
-
const IDENTITY_REMEDIATION = "Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy; nifra does not rewrite lockfiles for identity conflicts.";
|
|
191
499
|
/** Find duplicate identity-sensitive package realpaths without reading application payloads. */
|
|
192
|
-
export async function collectIdentityParity(cwd, rootPackage
|
|
193
|
-
const requestedRoot = resolve(cwd);
|
|
500
|
+
export async function collectIdentityParity(cwd, rootPackage) {
|
|
501
|
+
const requestedRoot = realpathOrSelf(resolve(cwd));
|
|
194
502
|
const localPackage = await readJson(join(requestedRoot, "package.json"));
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
503
|
+
// One basis for every caller. A package subdirectory is scanned as the workspace that governs it,
|
|
504
|
+
// because that workspace is what installed the copies it loads - and because a doctor and a build
|
|
505
|
+
// that anchor differently answer the same question differently, which is how a guard loses trust.
|
|
506
|
+
const workspace = localPackage === undefined
|
|
507
|
+
? { root: requestedRoot, package: rootPackage ?? {} }
|
|
508
|
+
: await resolveParityWorkspaceRoot(requestedRoot);
|
|
198
509
|
const scanRoot = workspace.root;
|
|
199
510
|
const scanPackage = workspace.package;
|
|
200
|
-
const importers = await workspaceImporters(scanRoot, scanPackage);
|
|
511
|
+
const { importers, truncated } = await workspaceImporters(scanRoot, scanPackage);
|
|
201
512
|
const byPackage = new Map();
|
|
202
513
|
const record = (name, copy, importer) => {
|
|
203
514
|
const copies = byPackage.get(name) ?? new Map();
|
|
@@ -214,16 +525,16 @@ export async function collectIdentityParity(cwd, rootPackage, options = {}) {
|
|
|
214
525
|
targets.add(name);
|
|
215
526
|
const copy = await resolvedInstalledCopy(importer.root, scanRoot, name);
|
|
216
527
|
if (copy !== undefined)
|
|
217
|
-
record(name, copy, displayPath(
|
|
528
|
+
record(name, copy, displayPath(requestedRoot, importer.root));
|
|
218
529
|
}
|
|
219
530
|
}
|
|
220
531
|
for (const name of targets) {
|
|
221
532
|
const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name);
|
|
222
533
|
if (copy !== undefined)
|
|
223
|
-
record(name, copy, displayPath(
|
|
534
|
+
record(name, copy, displayPath(requestedRoot, scanRoot));
|
|
224
535
|
}
|
|
225
|
-
const
|
|
226
|
-
for (const linkedRoot of
|
|
536
|
+
const linked = await linkedPackageRoots([...importers.map((importer) => importer.root), scanRoot], [...importers.map((importer) => importer.package), scanPackage], scanRoot);
|
|
537
|
+
for (const linkedRoot of linked.roots) {
|
|
227
538
|
const linkedPackage = await readJson(join(linkedRoot, "package.json"));
|
|
228
539
|
const boundary = await linkedRepoBoundary(linkedRoot);
|
|
229
540
|
const linkedTargets = new Set([
|
|
@@ -235,52 +546,110 @@ export async function collectIdentityParity(cwd, rootPackage, options = {}) {
|
|
|
235
546
|
continue;
|
|
236
547
|
const copy = await resolvedInstalledCopy(linkedRoot, boundary, name);
|
|
237
548
|
if (copy !== undefined)
|
|
238
|
-
record(name, copy, displayPath(
|
|
549
|
+
record(name, copy, displayPath(requestedRoot, linkedRoot));
|
|
239
550
|
}
|
|
240
551
|
}
|
|
552
|
+
const singleCopy = singleCopyCoverage(requestedRoot, scanRoot);
|
|
241
553
|
const findings = [];
|
|
554
|
+
const deduplicated = [];
|
|
242
555
|
for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
243
556
|
if (copies.size < 2)
|
|
244
557
|
continue;
|
|
558
|
+
const absolutePaths = [...copies.keys()].sort();
|
|
245
559
|
const resolvedCopies = [...copies.entries()]
|
|
246
560
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
247
561
|
.map(([path, copy]) => ({
|
|
248
562
|
version: copy.version,
|
|
249
|
-
path: displayPath(
|
|
563
|
+
path: displayPath(requestedRoot, path),
|
|
564
|
+
absolutePath: path,
|
|
250
565
|
importers: [...copy.importers].sort(),
|
|
251
566
|
}));
|
|
567
|
+
const topology = describeTopology(requestedRoot, scanRoot, absolutePaths);
|
|
568
|
+
const scope = describeScope(requestedRoot, scanRoot, absolutePaths);
|
|
252
569
|
const versions = [...new Set(resolvedCopies.map((copy) => copy.version))].sort();
|
|
253
570
|
const cause = versions.length > 1 ? "version-skew" : "duplicate-path";
|
|
254
|
-
|
|
571
|
+
// A declaration never covers a version skew. Redirecting across versions would hand a package a
|
|
572
|
+
// version it did not ask for, which converts a loud install problem into a quiet behavioural one -
|
|
573
|
+
// so the skew stays fatal and the ranges have to be fixed.
|
|
574
|
+
const covered = cause === "duplicate-path" && matchesSingleCopyDeclaration(singleCopy.declared, name);
|
|
575
|
+
const finding = {
|
|
255
576
|
package: name,
|
|
256
577
|
copies: resolvedCopies,
|
|
257
578
|
versions,
|
|
258
579
|
cause,
|
|
259
580
|
explanation: cause === "version-skew"
|
|
260
581
|
? `${name} resolves to multiple versions and physical paths, so module state and symbols are not shared`
|
|
261
|
-
:
|
|
262
|
-
|
|
263
|
-
|
|
582
|
+
: covered
|
|
583
|
+
? `${name} is installed at more than one physical path and is declared single-copy, so every duplicate is rewritten to this app's copy`
|
|
584
|
+
: `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
|
|
585
|
+
remediation: covered
|
|
586
|
+
? deduplicatedRemediation(singleCopy.registration)
|
|
587
|
+
: identityRemediation(cause, name),
|
|
588
|
+
...(topology === undefined ? {} : { topology }),
|
|
589
|
+
...(scope === undefined ? {} : { scope }),
|
|
590
|
+
deduplicated: covered,
|
|
591
|
+
};
|
|
592
|
+
(covered ? deduplicated : findings).push(finding);
|
|
264
593
|
}
|
|
265
|
-
return {
|
|
594
|
+
return {
|
|
595
|
+
workspaceRoot: scanRoot,
|
|
596
|
+
requestedRoot,
|
|
597
|
+
truncated: truncated || linked.truncated,
|
|
598
|
+
findings,
|
|
599
|
+
deduplicated,
|
|
600
|
+
singleCopy,
|
|
601
|
+
};
|
|
266
602
|
}
|
|
267
|
-
/** One `- pkg [cause]: ...`
|
|
603
|
+
/** One `- pkg [cause]: ...` block per finding, shared by the hard failure and the dev warning. */
|
|
268
604
|
export function formatIdentityParityFindings(findings) {
|
|
269
605
|
return findings
|
|
270
606
|
.map((finding) => `- ${finding.package} [${finding.cause}]: ${finding.explanation}. ${finding.remediation}\n` +
|
|
271
607
|
` versions: ${finding.versions.join(", ")}; paths: ${finding.copies
|
|
272
608
|
.map((copy) => copy.path)
|
|
273
|
-
.join(", ")}`
|
|
609
|
+
.join(", ")}` +
|
|
610
|
+
(finding.topology === undefined ? "" : `\n topology: ${finding.topology}`) +
|
|
611
|
+
(finding.scope === undefined ? "" : `\n scope: ${finding.scope}`))
|
|
274
612
|
.join("\n");
|
|
275
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* The basis a scan ran on, for any surface that reports its result.
|
|
616
|
+
*
|
|
617
|
+
* A verdict about installs is only as good as where it looked, and the reader cannot see that from a
|
|
618
|
+
* list of relative paths. Stating it also makes a truncated scan impossible to read as a clean one.
|
|
619
|
+
*/
|
|
620
|
+
export function identityParityBasis(result) {
|
|
621
|
+
const scope = result.workspaceRoot === result.requestedRoot
|
|
622
|
+
? `scanned ${result.workspaceRoot}`
|
|
623
|
+
: `scanned ${result.workspaceRoot} (the workspace governing ${result.requestedRoot})`;
|
|
624
|
+
return result.truncated
|
|
625
|
+
? `${scope}; PARTIAL - enumeration stopped at a scan limit (${MAX_WORKSPACE_IMPORTERS} workspace packages, ${MAX_LINKED_PACKAGES} linked packages, or ${MAX_LINK_PROBES} link probes), so installs beyond it were not examined`
|
|
626
|
+
: scope;
|
|
627
|
+
}
|
|
276
628
|
/** `2 primary package findings` / `1 primary package finding`. */
|
|
277
629
|
export const identityParityHeadline = (count) => `${count} primary package finding${count === 1 ? "" : "s"}`;
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Fail before dev/build when the same identity-sensitive package has multiple realpaths AND nothing
|
|
632
|
+
* is collapsing them.
|
|
633
|
+
*
|
|
634
|
+
* A declared package is not a suppression: the build injects the single-copy resolver, so the duplicate
|
|
635
|
+
* genuinely cannot reach the output. Failing on it anyway would leave the only supported answer being
|
|
636
|
+
* to change the install topology, which is the thing an app using linked sibling repositories cannot
|
|
637
|
+
* do. `result.deduplicated` still carries every covered duplicate for the caller to print.
|
|
638
|
+
*/
|
|
639
|
+
export async function assertIdentityParity(cwd, rootPackage) {
|
|
640
|
+
const result = await collectIdentityParity(cwd, rootPackage);
|
|
641
|
+
if (result.findings.length === 0) {
|
|
642
|
+
// A bounded scan that found nothing has not shown there is nothing: the duplicate can be sitting
|
|
643
|
+
// in the part it never reached. `truncated` is exactly that state, so the hard gate refuses to
|
|
644
|
+
// read it as a pass - a guard that answers "clean" from an incomplete scan is how a guard stops
|
|
645
|
+
// being worth having. Reporting surfaces (`nifra doctor`, the dev warning) still print the
|
|
646
|
+
// partial result through `identityParityBasis`; only the gate that must not be wrong stops here.
|
|
647
|
+
if (result.truncated) {
|
|
648
|
+
throw new Error(`[nifra] identity parity inconclusive - scan limit reached (${identityParityBasis(result)}). Narrow the scan (run from the app or workspace that governs this install) or reduce the workspace/linked-package topology so the scan can complete.`);
|
|
649
|
+
}
|
|
282
650
|
return result;
|
|
283
|
-
|
|
651
|
+
}
|
|
652
|
+
throw new Error(`[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}; ${identityParityBasis(result)}):\n${formatIdentityParityFindings(result.findings)}`);
|
|
284
653
|
}
|
|
285
654
|
const assetName = (url) => url.replace(/^.*\//, "");
|
|
286
655
|
const assetExtension = (url) => {
|
|
@@ -333,7 +702,13 @@ export function normalizeBuildManifest(manifest) {
|
|
|
333
702
|
routeChunks: Object.fromEntries(Object.entries(manifest.routes)
|
|
334
703
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
335
704
|
.map(([route, chunks]) => [route, chunks.length])),
|
|
336
|
-
|
|
705
|
+
// Allowlist, not denylist: the dev/prod module-graph contract is the JavaScript module graph
|
|
706
|
+
// only. `js:entry` and `js:route:<id>:<n>` are the roles dev can reconstruct from source. Which
|
|
707
|
+
// *non-JS* files a bundler emits (svg, woff2, an extracted stylesheet) is an output detail, not
|
|
708
|
+
// a claim dev can make - dev serves those from source. The stylesheet claim that matters keeps
|
|
709
|
+
// its own `css` section; `public/` keeps `publicFiles`. A denylist admitted every future
|
|
710
|
+
// `asset:*` prefix by default, which is how an emitted `asset:svg` hard-failed every build.
|
|
711
|
+
emittedAssets: logicalStaticAssets(manifest).filter((asset) => asset.startsWith("js:")),
|
|
337
712
|
},
|
|
338
713
|
publicFiles: [...(manifest.publicFiles ?? [])].sort(),
|
|
339
714
|
css: (manifest.css ?? []).length > 0 ? ["css:present"] : [],
|
|
@@ -359,7 +734,7 @@ const sourceFilesUnder = (root) => {
|
|
|
359
734
|
const files = [];
|
|
360
735
|
const walk = (current) => {
|
|
361
736
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
362
|
-
if (["node_modules", "dist", "build", ".git", ".nifra", "coverage"].includes(entry.name))
|
|
737
|
+
if (["node_modules", "dist", "dist-node", "build", ".git", ".nifra", "coverage"].includes(entry.name))
|
|
363
738
|
continue;
|
|
364
739
|
const path = join(current, entry.name);
|
|
365
740
|
if (entry.isDirectory())
|
|
@@ -398,7 +773,7 @@ export function collectDevelopmentParityInput(routesDir, publicDir) {
|
|
|
398
773
|
const sourceRoot = dirname(resolve(routesDir));
|
|
399
774
|
const css = sourceFilesUnder(sourceRoot).some((file) => {
|
|
400
775
|
const content = readFileSync(file, "utf8");
|
|
401
|
-
return
|
|
776
|
+
return hasStylesheetImport(content) || (isSingleFileComponent(file) && SFC_STYLE.test(content));
|
|
402
777
|
})
|
|
403
778
|
? ["css:present"]
|
|
404
779
|
: [];
|
|
@@ -406,6 +781,7 @@ export function collectDevelopmentParityInput(routesDir, publicDir) {
|
|
|
406
781
|
routes,
|
|
407
782
|
publicFiles: publicFilesUnder(publicDir),
|
|
408
783
|
css,
|
|
784
|
+
sourceRoot,
|
|
409
785
|
};
|
|
410
786
|
}
|
|
411
787
|
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -423,16 +799,56 @@ export function compareManifestParity(development, production) {
|
|
|
423
799
|
development: development.publicFiles,
|
|
424
800
|
production: production.publicFiles,
|
|
425
801
|
});
|
|
426
|
-
|
|
802
|
+
// css is the one section with an inference-based side. The dev scanner (CSS_IMPORT) is
|
|
803
|
+
// sound-but-incomplete by construction, so `dev empty, prod css` is a scanner miss, not an app
|
|
804
|
+
// defect - it passes. Fail only the load-bearing direction: dev found styles the build does not
|
|
805
|
+
// ship, so the production page would render unstyled. module-graph and public-files stay on equality
|
|
806
|
+
// because both sides are ground truth there.
|
|
807
|
+
if (development.css.length > 0 && production.css.length === 0)
|
|
427
808
|
differences.push({ section: "css", development: development.css, production: production.css });
|
|
428
809
|
return differences;
|
|
429
810
|
}
|
|
811
|
+
/** The forms the source scanner cannot prove, quoted in a css parity failure so the reader knows where to look. */
|
|
812
|
+
const CSS_SCANNER_BLIND_SPOTS = "a bare package subpath (exports), an @import inside a stylesheet, template interpolation, or plugin-injected css";
|
|
813
|
+
const symmetricDifference = (a, b) => {
|
|
814
|
+
const left = new Set(a);
|
|
815
|
+
const right = new Set(b);
|
|
816
|
+
return {
|
|
817
|
+
onlyDevelopment: a.filter((value) => !right.has(value)),
|
|
818
|
+
onlyProduction: b.filter((value) => !left.has(value)),
|
|
819
|
+
};
|
|
820
|
+
};
|
|
821
|
+
/** Turn one parity difference into a message that names the offending files, not two opaque blobs. */
|
|
822
|
+
function explainParityDifference(difference, development, production) {
|
|
823
|
+
if (difference.section === "css") {
|
|
824
|
+
const shipped = (production.css ?? []).join(", ");
|
|
825
|
+
const where = development.sourceRoot === undefined ? "" : ` under ${development.sourceRoot}`;
|
|
826
|
+
return (`css: production ships [${shipped || "(none)"}] but the development scanner found a static ` +
|
|
827
|
+
`stylesheet import${where} - it is blind to ${CSS_SCANNER_BLIND_SPOTS}`);
|
|
828
|
+
}
|
|
829
|
+
if (difference.section === "module-graph") {
|
|
830
|
+
const dev = difference.development;
|
|
831
|
+
const prod = difference.production;
|
|
832
|
+
const routes = symmetricDifference(dev.routes, prod.routes);
|
|
833
|
+
const assets = symmetricDifference(dev.emittedAssets, prod.emittedAssets);
|
|
834
|
+
const parts = [];
|
|
835
|
+
if (routes.onlyDevelopment.length > 0 || routes.onlyProduction.length > 0)
|
|
836
|
+
parts.push(`routes only in development=${JSON.stringify(routes.onlyDevelopment)} only in production=${JSON.stringify(routes.onlyProduction)}`);
|
|
837
|
+
if (assets.onlyDevelopment.length > 0 || assets.onlyProduction.length > 0)
|
|
838
|
+
parts.push(`chunks only in development=${JSON.stringify(assets.onlyDevelopment)} only in production=${JSON.stringify(assets.onlyProduction)}`);
|
|
839
|
+
if (parts.length === 0)
|
|
840
|
+
parts.push(`route chunk counts differ: development=${JSON.stringify(dev.routeChunks)} production=${JSON.stringify(prod.routeChunks)}`);
|
|
841
|
+
return `module-graph: ${parts.join("; ")}`;
|
|
842
|
+
}
|
|
843
|
+
const files = symmetricDifference(difference.development, difference.production);
|
|
844
|
+
return `${difference.section}: only in development=${JSON.stringify(files.onlyDevelopment)} only in production=${JSON.stringify(files.onlyProduction)}`;
|
|
845
|
+
}
|
|
430
846
|
/** Validate a production manifest against the source manifest a development server should serve. */
|
|
431
847
|
export function assertDevelopmentProductionParity(development, production) {
|
|
432
848
|
const differences = compareManifestParity(createDevelopmentParityManifest(development), normalizeBuildManifest(production));
|
|
433
849
|
if (differences.length > 0) {
|
|
434
850
|
const detail = differences
|
|
435
|
-
.map((difference) =>
|
|
851
|
+
.map((difference) => explainParityDifference(difference, development, production))
|
|
436
852
|
.join("; ");
|
|
437
853
|
throw new Error(`[nifra] development and production manifest parity failed: ${detail}`);
|
|
438
854
|
}
|