@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.
Files changed (145) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-plan.d.ts +228 -0
  6. package/dist/build-plan.d.ts.map +1 -0
  7. package/dist/build-plan.js +514 -0
  8. package/dist/build-plan.js.map +1 -0
  9. package/dist/build-vite.d.ts +2 -1
  10. package/dist/build-vite.d.ts.map +1 -1
  11. package/dist/build-vite.js +5 -3
  12. package/dist/build-vite.js.map +1 -1
  13. package/dist/build.d.ts +2 -197
  14. package/dist/build.d.ts.map +1 -1
  15. package/dist/build.js +92 -515
  16. package/dist/build.js.map +1 -1
  17. package/dist/chunk-names.d.ts.map +1 -1
  18. package/dist/chunk-names.js +3 -2
  19. package/dist/chunk-names.js.map +1 -1
  20. package/dist/dev-ssr-graph.d.ts.map +1 -1
  21. package/dist/dev-ssr-graph.js +63 -28
  22. package/dist/dev-ssr-graph.js.map +1 -1
  23. package/dist/diagnostic.d.ts.map +1 -1
  24. package/dist/diagnostic.js +9 -2
  25. package/dist/diagnostic.js.map +1 -1
  26. package/dist/fonts-google.d.ts.map +1 -1
  27. package/dist/fonts-google.js +3 -1
  28. package/dist/fonts-google.js.map +1 -1
  29. package/dist/fs.d.ts.map +1 -1
  30. package/dist/fs.js +2 -1
  31. package/dist/fs.js.map +1 -1
  32. package/dist/index.d.ts +16 -589
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +23 -1824
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/codegen.d.ts +68 -0
  37. package/dist/internal/codegen.d.ts.map +1 -0
  38. package/dist/internal/codegen.js +317 -0
  39. package/dist/internal/codegen.js.map +1 -0
  40. package/dist/internal/fetcher-runtime.d.ts +19 -0
  41. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  42. package/dist/internal/fetcher-runtime.js +26 -0
  43. package/dist/internal/fetcher-runtime.js.map +1 -0
  44. package/dist/internal/head-merge.d.ts +2 -0
  45. package/dist/internal/head-merge.d.ts.map +1 -1
  46. package/dist/internal/head-merge.js +17 -1
  47. package/dist/internal/head-merge.js.map +1 -1
  48. package/dist/internal/identity-policy.d.ts +51 -0
  49. package/dist/internal/identity-policy.d.ts.map +1 -0
  50. package/dist/internal/identity-policy.js +67 -0
  51. package/dist/internal/identity-policy.js.map +1 -0
  52. package/dist/internal/parity.d.ts +104 -8
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +472 -56
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/query-runtime.d.ts +8 -0
  57. package/dist/internal/query-runtime.d.ts.map +1 -0
  58. package/dist/internal/query-runtime.js +29 -0
  59. package/dist/internal/query-runtime.js.map +1 -0
  60. package/dist/internal/render-document.d.ts +372 -0
  61. package/dist/internal/render-document.d.ts.map +1 -0
  62. package/dist/internal/render-document.js +744 -0
  63. package/dist/internal/render-document.js.map +1 -0
  64. package/dist/internal/runtime-contract.d.ts +48 -0
  65. package/dist/internal/runtime-contract.d.ts.map +1 -0
  66. package/dist/internal/runtime-contract.js +27 -0
  67. package/dist/internal/runtime-contract.js.map +1 -0
  68. package/dist/internal/web-app.d.ts +171 -0
  69. package/dist/internal/web-app.d.ts.map +1 -0
  70. package/dist/internal/web-app.js +844 -0
  71. package/dist/internal/web-app.js.map +1 -0
  72. package/dist/islands.d.ts +50 -2
  73. package/dist/islands.d.ts.map +1 -1
  74. package/dist/islands.js +96 -36
  75. package/dist/islands.js.map +1 -1
  76. package/dist/manifest.d.ts +41 -6
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js.map +1 -1
  79. package/dist/module-graph.d.ts.map +1 -1
  80. package/dist/module-graph.js +22 -9
  81. package/dist/module-graph.js.map +1 -1
  82. package/dist/nano.d.ts +125 -0
  83. package/dist/nano.d.ts.map +1 -0
  84. package/dist/nano.js +190 -0
  85. package/dist/nano.js.map +1 -0
  86. package/dist/plugins/css-modules.js +2 -2
  87. package/dist/plugins/css-modules.js.map +1 -1
  88. package/dist/plugins/kit.d.ts +12 -0
  89. package/dist/plugins/kit.d.ts.map +1 -1
  90. package/dist/plugins/kit.js +62 -12
  91. package/dist/plugins/kit.js.map +1 -1
  92. package/dist/plugins/postcss.js +2 -2
  93. package/dist/plugins/postcss.js.map +1 -1
  94. package/dist/plugins/scss.js +2 -2
  95. package/dist/plugins/scss.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts +70 -0
  97. package/dist/plugins/stylex.d.ts.map +1 -0
  98. package/dist/plugins/stylex.js +166 -0
  99. package/dist/plugins/stylex.js.map +1 -0
  100. package/dist/plugins/svg.js +2 -2
  101. package/dist/plugins/svg.js.map +1 -1
  102. package/dist/render-seam.d.ts +5 -0
  103. package/dist/render-seam.d.ts.map +1 -1
  104. package/dist/render-seam.js +10 -0
  105. package/dist/render-seam.js.map +1 -1
  106. package/dist/router.d.ts +11 -1
  107. package/dist/router.d.ts.map +1 -1
  108. package/dist/router.js +254 -47
  109. package/dist/router.js.map +1 -1
  110. package/dist/vite.d.ts.map +1 -1
  111. package/dist/vite.js +34 -16
  112. package/dist/vite.js.map +1 -1
  113. package/package.json +49 -2
  114. package/src/boundary.ts +407 -0
  115. package/src/build-plan.ts +741 -0
  116. package/src/build-vite.ts +6 -7
  117. package/src/build.ts +120 -730
  118. package/src/chunk-names.ts +3 -2
  119. package/src/dev-ssr-graph.ts +63 -28
  120. package/src/diagnostic.ts +14 -2
  121. package/src/fonts-google.ts +3 -1
  122. package/src/fs.ts +2 -1
  123. package/src/index.ts +107 -2553
  124. package/src/internal/codegen.ts +400 -0
  125. package/src/internal/fetcher-runtime.ts +36 -0
  126. package/src/internal/head-merge.ts +19 -1
  127. package/src/internal/identity-policy.ts +94 -0
  128. package/src/internal/parity.ts +603 -71
  129. package/src/internal/query-runtime.ts +31 -0
  130. package/src/internal/render-document.ts +1116 -0
  131. package/src/internal/runtime-contract.ts +54 -0
  132. package/src/internal/web-app.ts +1114 -0
  133. package/src/islands.ts +106 -40
  134. package/src/manifest.ts +50 -3
  135. package/src/module-graph.ts +23 -9
  136. package/src/nano.ts +246 -0
  137. package/src/plugins/css-modules.ts +2 -2
  138. package/src/plugins/kit.ts +65 -12
  139. package/src/plugins/postcss.ts +2 -2
  140. package/src/plugins/scss.ts +2 -2
  141. package/src/plugins/stylex.ts +298 -0
  142. package/src/plugins/svg.ts +2 -2
  143. package/src/render-seam.ts +6 -0
  144. package/src/router.ts +293 -51
  145. package/src/vite.ts +41 -15
@@ -6,10 +6,18 @@
6
6
  * manifest normalization so those callers cannot quietly grow separate rules.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
9
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
10
10
  import { lstat, realpath, stat } from "node:fs/promises"
11
- import { dirname, join, relative, resolve, sep } from "node:path"
11
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
12
+ import {
13
+ matchesSingleCopyDeclaration,
14
+ readSingleCopyDeclaration,
15
+ readSingleCopyRegistration,
16
+ SINGLE_COPY_REGISTER_SPECIFIER,
17
+ type SingleCopyRegistration,
18
+ } from "@nifrajs/core/single-copy"
12
19
  import { discoverRoutes } from "../fs.ts"
20
+ import { isIdentitySensitivePackage } from "./identity-policy.ts"
13
21
 
14
22
  const DEPENDENCY_FIELDS = [
15
23
  "dependencies",
@@ -17,15 +25,6 @@ const DEPENDENCY_FIELDS = [
17
25
  "peerDependencies",
18
26
  "optionalDependencies",
19
27
  ] as const
20
- const IDENTITY_SENSITIVE_PACKAGES = new Set([
21
- "@nifrajs/core",
22
- "react",
23
- "react-dom",
24
- "preact",
25
- "svelte",
26
- "solid-js",
27
- "vue",
28
- ])
29
28
  const MAX_WORKSPACE_IMPORTERS = 2_048
30
29
  const MAX_LINKED_PACKAGES = 64
31
30
  const MAX_LINK_PROBES = 4_096
@@ -33,6 +32,12 @@ const MAX_LINK_PROBES = 4_096
33
32
  export interface IdentityParityCopy {
34
33
  readonly version: string
35
34
  readonly path: string
35
+ /**
36
+ * The resolved realpath of the copy. `path` is display-relative to the invoked directory, which
37
+ * reads well in a report but cannot be pasted into a resolver or an editor from anywhere else.
38
+ * Absent on a finding built by hand (a test fixture, an older cached result).
39
+ */
40
+ readonly absolutePath?: string
36
41
  readonly importers: readonly string[]
37
42
  }
38
43
 
@@ -47,16 +52,86 @@ export interface IdentityParityFinding {
47
52
  readonly cause: IdentityParityCause
48
53
  readonly explanation: string
49
54
  readonly remediation: string
55
+ /**
56
+ * Why the copies exist, in install-topology terms: how many physical paths sit under how many
57
+ * install roots, and whether any of those roots is outside the scanned project.
58
+ *
59
+ * A path list alone leaves the reader to reverse-engineer the shape. The two shapes need opposite
60
+ * fixes: a NESTED install under the scanned root shadows the hoisted copy and one reinstall
61
+ * collapses it, while a SIBLING install root (a linked checkout, a standalone app beside this one)
62
+ * owns its own `node_modules` and no reinstall here can touch it.
63
+ *
64
+ * Absent on a finding built by hand (a test fixture, an older cached result).
65
+ */
66
+ readonly topology?: string
67
+ /**
68
+ * Why a copy the invoked directory does not import is still fatal here.
69
+ *
70
+ * The gate is workspace-wide on purpose. Scoping it to the invoked app would be more precise and
71
+ * would also reintroduce the blindness this guard was built for: a copy that arrives through a
72
+ * workspace-linked dependency is not visible from the app directory, and that case shipped a
73
+ * broken dev server while the check reported "none". A workspace-wide answer over-reports in
74
+ * exchange for never under-reporting - and over-reporting is the failure a developer can see and
75
+ * act on, where under-reporting is the one nobody knows happened.
76
+ *
77
+ * Present only when the answer would otherwise look wrong: a subdirectory was scanned as its
78
+ * workspace, and at least one copy sits outside that subdirectory.
79
+ */
80
+ readonly scope?: string
81
+ /**
82
+ * The copies exist but the app declared this package single-copy, so the resolver collapses them
83
+ * before anything loads. Reported, never fatal - see `SingleCopyCoverage`.
84
+ */
85
+ readonly deduplicated: boolean
86
+ }
87
+
88
+ /**
89
+ * What the app declared about deduplication, and how much of it is actually armed.
90
+ *
91
+ * A duplicate physical path is only a defect if something still LOADS both copies. An app consuming a
92
+ * linked sibling repository cannot collapse the paths without giving up the property it chose `link:`
93
+ * for - each repository owning its own `node_modules` - so nifra lets it declare the packages instead
94
+ * (`"nifra": { "singleCopy": [...] }` in package.json) and verifies the declaration here rather than
95
+ * failing on the raw path count.
96
+ *
97
+ * Bundled phases need nothing further: `buildClient`/`buildServer`/dev inject the resolver themselves.
98
+ * Unbundled phases do, because Bun's runtime never offers a bare specifier to a resolver hook, so the
99
+ * plugin has to be preloaded to intercept the load. `registration` is that proof, read statically out
100
+ * of `bunfig.toml`.
101
+ */
102
+ export interface SingleCopyCoverage {
103
+ /** Declared package names and patterns, exactly as written. Empty when nothing was declared. */
104
+ readonly declared: readonly string[]
105
+ readonly registration: SingleCopyRegistration
50
106
  }
51
107
 
52
108
  export interface IdentityParityResult {
109
+ /**
110
+ * The governing workspace root: where importer enumeration starts, and how far a copy lookup may
111
+ * walk up from an importer.
112
+ *
113
+ * EVERY caller resolves this the same way. The doctor and the build/dev preflight used to differ
114
+ * here - one anchored at the workspace, the other at the app directory - so the same project could
115
+ * be told it had duplicates by one command and a clean bill by the other. Two answers from one
116
+ * toolchain is worse than either answer, so the basis is now fixed and reported rather than chosen.
117
+ */
53
118
  readonly workspaceRoot: string
119
+ /** The directory the caller asked about. Differs from `workspaceRoot` when a package subdirectory
120
+ * is governed by a workspace above it; carried so a report can state the basis it scanned on. */
121
+ readonly requestedRoot: string
122
+ /**
123
+ * Enumeration stopped at `MAX_WORKSPACE_IMPORTERS`, so this scan is PARTIAL.
124
+ *
125
+ * An empty `findings` then means "nothing found in the part that was scanned", never "clean" - a
126
+ * caller that prints a clean bill on a truncated scan is the exact silence this flag exists to
127
+ * prevent.
128
+ */
129
+ readonly truncated: boolean
130
+ /** Findings that no declaration covers - the ones a build must refuse to start on. */
54
131
  readonly findings: readonly IdentityParityFinding[]
55
- }
56
-
57
- export interface IdentityParityOptions {
58
- /** Scan the governing workspace when the caller is a package subdirectory. */
59
- readonly useWorkspaceRoot?: boolean
132
+ /** Duplicates the declaration covers. Worth printing, never worth failing. */
133
+ readonly deduplicated: readonly IdentityParityFinding[]
134
+ readonly singleCopy: SingleCopyCoverage
60
135
  }
61
136
 
62
137
  export interface BuildManifestLike {
@@ -81,11 +156,127 @@ export interface DevelopmentParityInput {
81
156
  readonly routes: Readonly<Record<string, number>>
82
157
  readonly publicFiles: readonly string[]
83
158
  readonly css: readonly string[]
159
+ /** The scanned first-party source root, carried only so a css parity failure can name where the
160
+ * scanner looked. Optional: callers that hand-build an input for a unit test may omit it. */
161
+ readonly sourceRoot?: string
84
162
  }
85
163
 
86
164
  const SOURCE_EXTENSIONS = /\.(?:c|m)?(?:j|t)sx?$|\.(?:mdx|svelte|vue)$/
87
- const CSS_IMPORT =
88
- /(?:import\s+(?:[^"']+\s+from\s+)?|from\s+)["'][^"']+\.(?:css|scss|sass|less|styl)(?:\?[^"']*)?["']/i
165
+ /** A stylesheet path that the source scanner can prove is a real explicit import. */
166
+ const CSS_SPECIFIER = /\.(?:css|scss|sass|less|styl)(?:\?[^"'`]*)?$/i
167
+
168
+ type CssToken =
169
+ | { readonly kind: "identifier"; readonly value: string; readonly lineStart: boolean }
170
+ | { readonly kind: "string"; readonly value: string }
171
+ | { readonly kind: "punctuator"; readonly value: string }
172
+
173
+ /**
174
+ * Tokenize only the small part of TypeScript/JS needed for the CSS contract.
175
+ *
176
+ * A regex over source text is not sound here: documentation routes contain code examples such as
177
+ * `import "./app.css"` in template literals, and comments can contain the same text. Those are not
178
+ * imports and must not make a style-free production build fail. This intentionally conservative lexer
179
+ * drops comments and complete template literals (including `${...}` expressions); missing a stylesheet
180
+ * is the passing direction of this parity check, while claiming one that is not imported is not.
181
+ */
182
+ const cssTokens = (source: string): readonly CssToken[] => {
183
+ const tokens: CssToken[] = []
184
+ let lineStart = true
185
+ for (let index = 0; index < source.length; ) {
186
+ const char = source[index]
187
+ if (char === undefined) break
188
+ if (/\s/.test(char)) {
189
+ if (char === "\n" || char === "\r") lineStart = true
190
+ index++
191
+ continue
192
+ }
193
+ if (char === "/" && source[index + 1] === "/") {
194
+ index += 2
195
+ while (index < source.length && source[index] !== "\n") index++
196
+ continue
197
+ }
198
+ if (char === "/" && source[index + 1] === "*") {
199
+ const commentStart = index
200
+ index += 2
201
+ while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) index++
202
+ index = Math.min(source.length, index + 2)
203
+ if (/[\r\n]/.test(source.slice(commentStart, index))) lineStart = true
204
+ continue
205
+ }
206
+ if (char === "`") {
207
+ const templateStart = index
208
+ index++
209
+ while (index < source.length) {
210
+ const templateChar = source[index]
211
+ if (templateChar === "\\") {
212
+ index += 2
213
+ continue
214
+ }
215
+ index++
216
+ if (templateChar === "`") break
217
+ }
218
+ lineStart = /[\r\n]/.test(source.slice(templateStart, index))
219
+ continue
220
+ }
221
+ if (char === '"' || char === "'") {
222
+ const quote = char
223
+ const start = ++index
224
+ while (index < source.length) {
225
+ const stringChar = source[index]
226
+ if (stringChar === "\\") {
227
+ index += 2
228
+ continue
229
+ }
230
+ index++
231
+ if (stringChar === quote) break
232
+ }
233
+ tokens.push({ kind: "string", value: source.slice(start, Math.max(start, index - 1)) })
234
+ lineStart = false
235
+ continue
236
+ }
237
+ if (/[A-Za-z_$]/.test(char)) {
238
+ const start = index++
239
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? "")) index++
240
+ tokens.push({ kind: "identifier", value: source.slice(start, index), lineStart })
241
+ lineStart = false
242
+ continue
243
+ }
244
+ tokens.push({ kind: "punctuator", value: char })
245
+ lineStart = false
246
+ index++
247
+ }
248
+ return tokens
249
+ }
250
+
251
+ const isStylesheetToken = (token: CssToken | undefined): boolean =>
252
+ token?.kind === "string" && CSS_SPECIFIER.test(token.value)
253
+
254
+ /** Detect static imports, re-exports, literal dynamic imports, and literal require calls. */
255
+ const hasStylesheetImport = (source: string): boolean => {
256
+ const tokens = cssTokens(source)
257
+ for (let index = 0; index < tokens.length; index++) {
258
+ const token = tokens[index]
259
+ if (token?.kind !== "identifier") continue
260
+ const next = tokens[index + 1]
261
+ if (token.value === "import") {
262
+ if (token.lineStart && isStylesheetToken(next)) return true
263
+ if (next?.value === "(" && isStylesheetToken(tokens[index + 2])) return true
264
+ }
265
+ if (token.value === "require" && next?.value === "(" && isStylesheetToken(tokens[index + 2]))
266
+ return true
267
+ if (token.value === "from" && isStylesheetToken(next)) {
268
+ for (let previous = index - 1; previous >= 0; previous--) {
269
+ const candidate = tokens[previous]
270
+ if (candidate?.kind === "identifier" && candidate.lineStart) {
271
+ if (candidate.value === "import" || candidate.value === "export") return true
272
+ break
273
+ }
274
+ if (candidate?.value === ";") break
275
+ }
276
+ }
277
+ }
278
+ return false
279
+ }
89
280
  /** A single-file-component `<style>` block (Svelte/Vue). The bundler extracts these into the app
90
281
  * stylesheet even though no `import "...css"` statement exists, so the dev contract must count them
91
282
  * or a scoped-style component would look style-free next to a production manifest that carries css. */
@@ -141,8 +332,85 @@ const pathExists = (path: string): Promise<boolean> =>
141
332
  () => false,
142
333
  )
143
334
 
144
- const pathInside = (root: string, path: string): boolean =>
145
- path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
335
+ /** The real path, or the input when it cannot be resolved. Roots are compared against each other and
336
+ * printed side by side, so they have to be normalized the same way (`/var` vs `/private/var`). */
337
+ const realpathOrSelf = (path: string): string => {
338
+ try {
339
+ // The native Windows resolver is the one that knows how to expand 8.3 aliases such as
340
+ // `RUNNER~1`. The portable resolver can preserve the spelling it was given, which makes two
341
+ // handles to the same checkout look like different install roots.
342
+ return process.platform === "win32" ? realpathSync.native(path) : realpathSync(path)
343
+ } catch {
344
+ return path
345
+ }
346
+ }
347
+
348
+ /** Canonical comparison form for containment. Windows paths are case-insensitive and CI can expose
349
+ * the same directory once through a short name (`RUNNER~1`) and once through its long name. */
350
+ const comparisonPath = (path: string): string => {
351
+ const resolved = realpathOrSelf(resolve(path))
352
+ const native = resolved.replaceAll("/", sep)
353
+ return process.platform === "win32" ? native.toLowerCase() : native
354
+ }
355
+
356
+ /**
357
+ * Windows may expose the same directory through a long path and an 8.3 short path even after
358
+ * `realpathSync` has normalized both inputs. When the lexical comparison above says "outside", walk
359
+ * the target's existing ancestors and compare the filesystem identity of each one. `statSync` with
360
+ * bigint fields avoids truncating Windows file IDs, and a missing identity fails closed.
361
+ */
362
+ const pathInsideByFilesystemIdentity = (root: string, path: string): boolean => {
363
+ if (process.platform !== "win32") return false
364
+ const rootIdentity = (() => {
365
+ try {
366
+ const info = statSync(realpathOrSelf(resolve(root)), { bigint: true })
367
+ if (info.ino === 0n) return undefined
368
+ return { dev: info.dev, ino: info.ino }
369
+ } catch {
370
+ try {
371
+ const info = statSync(realpathOrSelf(resolve(root)))
372
+ if (info.ino === 0) return undefined
373
+ return { dev: BigInt(info.dev), ino: BigInt(info.ino) }
374
+ } catch {
375
+ return undefined
376
+ }
377
+ }
378
+ })()
379
+ if (rootIdentity === undefined) return false
380
+
381
+ let current = realpathOrSelf(resolve(path))
382
+ for (;;) {
383
+ try {
384
+ const info = statSync(current, { bigint: true })
385
+ if (info.ino !== 0n && info.dev === rootIdentity.dev && info.ino === rootIdentity.ino)
386
+ return true
387
+ } catch {
388
+ try {
389
+ const info = statSync(current)
390
+ if (
391
+ info.ino !== 0 &&
392
+ BigInt(info.dev) === rootIdentity.dev &&
393
+ BigInt(info.ino) === rootIdentity.ino
394
+ )
395
+ return true
396
+ } catch {
397
+ // A non-existent leaf can still have an existing parent. Keep walking; if no ancestor can
398
+ // prove containment, the answer remains false rather than trusting a spelling alone.
399
+ }
400
+ }
401
+ const parent = dirname(current)
402
+ if (parent === current) return false
403
+ current = parent
404
+ }
405
+ }
406
+
407
+ export const pathInside = (root: string, path: string): boolean => {
408
+ const distance = relative(comparisonPath(root), comparisonPath(path))
409
+ const lexicallyInside =
410
+ distance === "" ||
411
+ (distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance))
412
+ return lexicallyInside || pathInsideByFilesystemIdentity(root, path)
413
+ }
146
414
 
147
415
  const workspaceContains = async (
148
416
  root: string,
@@ -155,12 +423,7 @@ const workspaceContains = async (
155
423
  for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
156
424
  if (match.split(/[\\/]/).includes("node_modules")) continue
157
425
  const packageRoot = resolve(root, dirname(match))
158
- const targetRelative = relative(packageRoot, target)
159
- if (
160
- targetRelative === "" ||
161
- (!targetRelative.startsWith(`..${sep}`) && targetRelative !== "..")
162
- )
163
- return true
426
+ if (pathInside(packageRoot, target)) return true
164
427
  }
165
428
  }
166
429
  return false
@@ -184,17 +447,33 @@ export async function resolveParityWorkspaceRoot(
184
447
  }
185
448
  }
186
449
 
450
+ /**
451
+ * Every workspace manifest that can install its own copy of a dependency.
452
+ *
453
+ * A pathological workspace must not make the scan unbounded, but it must not make it LIE either: the
454
+ * cap used to discard everything collected and return an empty list, which reads downstream as "no
455
+ * duplicates" - a clean bill from a scan that never ran. The bounded prefix is kept and `truncated`
456
+ * says the rest was not looked at.
457
+ */
187
458
  const workspaceImporters = async (
188
459
  root: string,
189
460
  rootPackage: Record<string, unknown>,
190
- ): Promise<readonly { root: string; package: Record<string, unknown> }[]> => {
461
+ ): Promise<{
462
+ readonly importers: readonly { root: string; package: Record<string, unknown> }[]
463
+ readonly truncated: boolean
464
+ }> => {
191
465
  const manifests = new Set<string>([join(root, "package.json")])
466
+ let truncated = false
192
467
  for (const pattern of workspacePatterns(rootPackage)) {
468
+ if (truncated) break
193
469
  const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
194
470
  for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
195
471
  if (match.split(/[\\/]/).includes("node_modules")) continue
472
+ if (manifests.size >= MAX_WORKSPACE_IMPORTERS) {
473
+ truncated = true
474
+ break
475
+ }
196
476
  manifests.add(join(root, match))
197
- if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
198
477
  }
199
478
  }
200
479
  const importers: { root: string; package: Record<string, unknown> }[] = []
@@ -202,7 +481,7 @@ const workspaceImporters = async (
202
481
  const pkg = await readJson(manifest)
203
482
  if (pkg !== undefined) importers.push({ root: dirname(manifest), package: pkg })
204
483
  }
205
- return importers
484
+ return { importers, truncated }
206
485
  }
207
486
 
208
487
  export const resolvedInstalledCopy = async (
@@ -231,7 +510,23 @@ export const resolvedInstalledCopy = async (
231
510
  }
232
511
  }
233
512
 
513
+ /**
514
+ * How far above a linked package its dependencies may legitimately live.
515
+ *
516
+ * A linked SOURCE CHECKOUT (`link:../../nifra/packages/core`) really does resolve its imports from its
517
+ * own repo's `node_modules`, so the walk goes up to that repo's `.git` - a duplicate found there is one
518
+ * this project genuinely dual-loads.
519
+ *
520
+ * A path that lands INSIDE a `node_modules` directory is a package-manager store copy, not a checkout:
521
+ * a symlink into another project's store (bun's `.bun/<pkg>@<version>`, an `npm link` target, a shared
522
+ * global store) resolves there. Everything above such a copy belongs to whoever owns that store, and
523
+ * walking up would sweep an unrelated project's entire dependency tree into this project's duplicate
524
+ * report - permanent findings in a repo the developer is not even working in, that no change here can
525
+ * clear. Clamp to the copy itself: only its own nested `node_modules` counts, which is exactly the set
526
+ * of modules it can actually load.
527
+ */
234
528
  const linkedRepoBoundary = async (packageRoot: string): Promise<string> => {
529
+ if (packageRoot.split(sep).includes("node_modules")) return packageRoot
235
530
  let dir = packageRoot
236
531
  for (let depth = 0; depth < 8; depth++) {
237
532
  if (await pathExists(join(dir, ".git"))) return dir
@@ -246,7 +541,7 @@ const linkedPackageRoots = async (
246
541
  importerRoots: readonly string[],
247
542
  manifests: readonly Record<string, unknown>[],
248
543
  scanRoot: string,
249
- ): Promise<readonly string[]> => {
544
+ ): Promise<{ readonly roots: readonly string[]; readonly truncated: boolean }> => {
250
545
  const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
251
546
  const found = new Map<string, true>()
252
547
  let probes = 0
@@ -254,7 +549,11 @@ const linkedPackageRoots = async (
254
549
  const pkg = manifests[index]
255
550
  if (pkg === undefined) continue
256
551
  for (const name of dependencyNames(pkg)) {
257
- if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) return [...found.keys()]
552
+ // Same rule as the importer cap: a bounded scan reports that it stopped. Returning the prefix
553
+ // silently would let a duplicate sitting past the cap read as a clean bill.
554
+ if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) {
555
+ return { roots: [...found.keys()], truncated: true }
556
+ }
258
557
  const candidate = join(importerRoot, "node_modules", ...name.split("/"))
259
558
  const link = await lstat(candidate).catch(() => undefined)
260
559
  if (link === undefined || !link.isSymbolicLink()) continue
@@ -263,37 +562,139 @@ const linkedPackageRoots = async (
263
562
  found.set(resolved, true)
264
563
  }
265
564
  }
266
- return [...found.keys()]
565
+ return { roots: [...found.keys()], truncated: false }
267
566
  }
268
567
 
269
568
  export const displayPath = (cwd: string, path: string): string => {
270
569
  const rel = relative(cwd, path)
271
- return rel === "" ? "." : rel
570
+ return rel === "" ? "." : rel.replaceAll("\\", "/")
571
+ }
572
+
573
+ /**
574
+ * The install root a copy belongs to: the directory ABOVE its last `node_modules` segment.
575
+ *
576
+ * That directory is what a package manager operates on, so it is the unit a fix is expressed in. A
577
+ * copy that sits in no `node_modules` at all is a source checkout, which is its own install root.
578
+ */
579
+ const installRootOf = (path: string): string => {
580
+ const parts = path.split(sep)
581
+ const last = parts.lastIndexOf("node_modules")
582
+ if (last <= 0) return path
583
+ return parts.slice(0, last).join(sep)
584
+ }
585
+
586
+ /**
587
+ * State the SHAPE of a duplicate, not just its paths.
588
+ *
589
+ * The failure a developer has to act on is a topology, and the two topologies take opposite fixes.
590
+ * Copies under one install root (a nested `node_modules` shadowing the hoisted one) collapse with a
591
+ * single reinstall from that root. Copies under separate roots - a linked checkout, a standalone app
592
+ * beside this one with its own `node_modules` - do not: that second root belongs to another install,
593
+ * and nothing this project runs can merge it. Naming which case this is turns a path list into a
594
+ * decision.
595
+ */
596
+ const describeTopology = (
597
+ base: string,
598
+ scanRoot: string,
599
+ paths: readonly string[],
600
+ ): string | undefined => {
601
+ if (paths.length === 0) return undefined
602
+ const counts = new Map<string, number>()
603
+ for (const path of paths) {
604
+ const root = installRootOf(path)
605
+ counts.set(root, (counts.get(root) ?? 0) + 1)
606
+ }
607
+ const roots = [...counts.entries()].sort(([a], [b]) => a.localeCompare(b))
608
+ const breakdown = roots
609
+ .map(([root, count]) => `${count} under ${displayPath(base, root)}`)
610
+ .join(", ")
611
+ const outside = roots.filter(([root]) => !pathInside(scanRoot, root))
612
+ const shape =
613
+ outside.length > 0
614
+ ? `${outside.length} install root${outside.length === 1 ? " is" : "s are"} outside the scanned root (${outside
615
+ .map(([root]) => displayPath(base, root))
616
+ .join(
617
+ ", ",
618
+ )}), so that copy is installed by another project and reinstalling here cannot remove it`
619
+ : roots.length > 1
620
+ ? "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"
621
+ : "one install root holds every copy, so the split is inside a single install (a nested or stored copy), not across projects"
622
+ return `${paths.length} paths under ${roots.length} install root${roots.length === 1 ? "" : "s"}: ${breakdown}; ${shape}`
623
+ }
624
+
625
+ /**
626
+ * State that the gate is workspace-wide, when that is what makes a finding look wrong.
627
+ *
628
+ * Running `nifra build` inside `apps/web` can fail on a copy held by `apps/admin` - a package this
629
+ * app never imports. That is deliberate, not a miss: the scan is anchored on the workspace because
630
+ * copies reached through a workspace-linked dependency are invisible from the app directory, and
631
+ * anchoring on the app is exactly how this guard once reported "none" while dev was already broken.
632
+ * The trade is over-reporting instead of under-reporting, and the note says so rather than leaving a
633
+ * developer to conclude the tool is confused about which project it is checking.
634
+ */
635
+ const describeScope = (
636
+ requestedRoot: string,
637
+ scanRoot: string,
638
+ paths: readonly string[],
639
+ ): string | undefined => {
640
+ if (requestedRoot === scanRoot) return undefined
641
+ const outside = paths.filter((path) => !pathInside(requestedRoot, path))
642
+ if (outside.length === 0) return undefined
643
+ const here = displayPath(scanRoot, requestedRoot) || "."
644
+ 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.`
272
645
  }
273
646
 
274
647
  const identityTargets = (pkg: Record<string, unknown>): readonly string[] =>
275
- dependencyNames(pkg)
276
- .filter((name) => name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name))
277
- .sort()
648
+ dependencyNames(pkg).filter(isIdentitySensitivePackage).sort()
278
649
 
279
- const IDENTITY_REMEDIATION =
650
+ /** version-skew is a range problem: one reinstall from the root collapses it. */
651
+ const VERSION_SKEW_REMEDIATION =
280
652
  "Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy; nifra does not rewrite lockfiles for identity conflicts."
653
+ /** duplicate-path is a topology problem. When the copies come from a linked sibling repo a reinstall
654
+ * does not collapse them - each tree keeps its own copy, which is the whole reason `link:` was chosen.
655
+ * So the fix is either one physical path, or a declaration that nifra can verify and enforce. */
656
+ const duplicatePathRemediation = (name: string): string =>
657
+ `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.`
658
+ const identityRemediation = (cause: IdentityParityCause, name: string): string =>
659
+ cause === "version-skew" ? VERSION_SKEW_REMEDIATION : duplicatePathRemediation(name)
660
+
661
+ /** What is left to do about a duplicate the declaration already covers. */
662
+ const deduplicatedRemediation = (registration: SingleCopyRegistration): string =>
663
+ registration.run || registration.test
664
+ ? "Declared single-copy and enforced: every duplicate is rewritten to this app's copy, so one module instance loads."
665
+ : `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.`
666
+
667
+ /**
668
+ * Read the declaration and its runtime proof, preferring the directory the caller is in.
669
+ *
670
+ * A monorepo declares this per app, not once at the root: the app is what owns the `node_modules` the
671
+ * duplicates must collapse into, and two apps in one workspace can legitimately differ. The workspace
672
+ * root is the fallback so a single-package project needs no second file.
673
+ */
674
+ const singleCopyCoverage = (cwd: string, scanRoot: string): SingleCopyCoverage => {
675
+ const declared = readSingleCopyDeclaration(cwd) ?? readSingleCopyDeclaration(scanRoot) ?? []
676
+ const local = readSingleCopyRegistration(cwd)
677
+ const registration = local.run || local.test ? local : readSingleCopyRegistration(scanRoot)
678
+ return { declared, registration }
679
+ }
281
680
 
282
681
  /** Find duplicate identity-sensitive package realpaths without reading application payloads. */
283
682
  export async function collectIdentityParity(
284
683
  cwd: string,
285
684
  rootPackage?: Record<string, unknown>,
286
- options: IdentityParityOptions = {},
287
685
  ): Promise<IdentityParityResult> {
288
- const requestedRoot = resolve(cwd)
686
+ const requestedRoot = realpathOrSelf(resolve(cwd))
289
687
  const localPackage = await readJson(join(requestedRoot, "package.json"))
688
+ // One basis for every caller. A package subdirectory is scanned as the workspace that governs it,
689
+ // because that workspace is what installed the copies it loads - and because a doctor and a build
690
+ // that anchor differently answer the same question differently, which is how a guard loses trust.
290
691
  const workspace =
291
- options.useWorkspaceRoot && localPackage !== undefined
292
- ? await resolveParityWorkspaceRoot(requestedRoot)
293
- : { root: requestedRoot, package: localPackage ?? rootPackage ?? {} }
692
+ localPackage === undefined
693
+ ? { root: requestedRoot, package: rootPackage ?? {} }
694
+ : await resolveParityWorkspaceRoot(requestedRoot)
294
695
  const scanRoot = workspace.root
295
696
  const scanPackage = workspace.package
296
- const importers = await workspaceImporters(scanRoot, scanPackage)
697
+ const { importers, truncated } = await workspaceImporters(scanRoot, scanPackage)
297
698
  const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
298
699
  const record = (
299
700
  name: string,
@@ -313,20 +714,20 @@ export async function collectIdentityParity(
313
714
  if (importer.package.name === name) continue
314
715
  targets.add(name)
315
716
  const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
316
- if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
717
+ if (copy !== undefined) record(name, copy, displayPath(requestedRoot, importer.root))
317
718
  }
318
719
  }
319
720
  for (const name of targets) {
320
721
  const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
321
- if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
722
+ if (copy !== undefined) record(name, copy, displayPath(requestedRoot, scanRoot))
322
723
  }
323
724
 
324
- const linkedRoots = await linkedPackageRoots(
725
+ const linked = await linkedPackageRoots(
325
726
  [...importers.map((importer) => importer.root), scanRoot],
326
727
  [...importers.map((importer) => importer.package), scanPackage],
327
728
  scanRoot,
328
729
  )
329
- for (const linkedRoot of linkedRoots) {
730
+ for (const linkedRoot of linked.roots) {
330
731
  const linkedPackage = await readJson(join(linkedRoot, "package.json"))
331
732
  const boundary = await linkedRepoBoundary(linkedRoot)
332
733
  const linkedTargets = new Set([
@@ -336,23 +737,34 @@ export async function collectIdentityParity(
336
737
  for (const name of linkedTargets) {
337
738
  if (linkedPackage?.name === name) continue
338
739
  const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
339
- if (copy !== undefined) record(name, copy, displayPath(cwd, linkedRoot))
740
+ if (copy !== undefined) record(name, copy, displayPath(requestedRoot, linkedRoot))
340
741
  }
341
742
  }
342
743
 
744
+ const singleCopy = singleCopyCoverage(requestedRoot, scanRoot)
343
745
  const findings: IdentityParityFinding[] = []
746
+ const deduplicated: IdentityParityFinding[] = []
344
747
  for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
345
748
  if (copies.size < 2) continue
749
+ const absolutePaths = [...copies.keys()].sort()
346
750
  const resolvedCopies = [...copies.entries()]
347
751
  .sort(([a], [b]) => a.localeCompare(b))
348
752
  .map(([path, copy]) => ({
349
753
  version: copy.version,
350
- path: displayPath(cwd, path),
754
+ path: displayPath(requestedRoot, path),
755
+ absolutePath: path,
351
756
  importers: [...copy.importers].sort(),
352
757
  }))
758
+ const topology = describeTopology(requestedRoot, scanRoot, absolutePaths)
759
+ const scope = describeScope(requestedRoot, scanRoot, absolutePaths)
353
760
  const versions = [...new Set(resolvedCopies.map((copy) => copy.version))].sort()
354
761
  const cause: IdentityParityCause = versions.length > 1 ? "version-skew" : "duplicate-path"
355
- findings.push({
762
+ // A declaration never covers a version skew. Redirecting across versions would hand a package a
763
+ // version it did not ask for, which converts a loud install problem into a quiet behavioural one -
764
+ // so the skew stays fatal and the ranges have to be fixed.
765
+ const covered =
766
+ cause === "duplicate-path" && matchesSingleCopyDeclaration(singleCopy.declared, name)
767
+ const finding: IdentityParityFinding = {
356
768
  package: name,
357
769
  copies: resolvedCopies,
358
770
  versions,
@@ -360,14 +772,29 @@ export async function collectIdentityParity(
360
772
  explanation:
361
773
  cause === "version-skew"
362
774
  ? `${name} resolves to multiple versions and physical paths, so module state and symbols are not shared`
363
- : `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
364
- remediation: IDENTITY_REMEDIATION,
365
- })
775
+ : covered
776
+ ? `${name} is installed at more than one physical path and is declared single-copy, so every duplicate is rewritten to this app's copy`
777
+ : `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
778
+ remediation: covered
779
+ ? deduplicatedRemediation(singleCopy.registration)
780
+ : identityRemediation(cause, name),
781
+ ...(topology === undefined ? {} : { topology }),
782
+ ...(scope === undefined ? {} : { scope }),
783
+ deduplicated: covered,
784
+ }
785
+ ;(covered ? deduplicated : findings).push(finding)
786
+ }
787
+ return {
788
+ workspaceRoot: scanRoot,
789
+ requestedRoot,
790
+ truncated: truncated || linked.truncated,
791
+ findings,
792
+ deduplicated,
793
+ singleCopy,
366
794
  }
367
- return { workspaceRoot: scanRoot, findings }
368
795
  }
369
796
 
370
- /** One `- pkg [cause]: ...` line per finding, shared by the hard failure and the dev warning. */
797
+ /** One `- pkg [cause]: ...` block per finding, shared by the hard failure and the dev warning. */
371
798
  export function formatIdentityParityFindings(findings: readonly IdentityParityFinding[]): string {
372
799
  return findings
373
800
  .map(
@@ -375,25 +802,62 @@ export function formatIdentityParityFindings(findings: readonly IdentityParityFi
375
802
  `- ${finding.package} [${finding.cause}]: ${finding.explanation}. ${finding.remediation}\n` +
376
803
  ` versions: ${finding.versions.join(", ")}; paths: ${finding.copies
377
804
  .map((copy) => copy.path)
378
- .join(", ")}`,
805
+ .join(", ")}` +
806
+ (finding.topology === undefined ? "" : `\n topology: ${finding.topology}`) +
807
+ (finding.scope === undefined ? "" : `\n scope: ${finding.scope}`),
379
808
  )
380
809
  .join("\n")
381
810
  }
382
811
 
812
+ /**
813
+ * The basis a scan ran on, for any surface that reports its result.
814
+ *
815
+ * A verdict about installs is only as good as where it looked, and the reader cannot see that from a
816
+ * list of relative paths. Stating it also makes a truncated scan impossible to read as a clean one.
817
+ */
818
+ export function identityParityBasis(result: IdentityParityResult): string {
819
+ const scope =
820
+ result.workspaceRoot === result.requestedRoot
821
+ ? `scanned ${result.workspaceRoot}`
822
+ : `scanned ${result.workspaceRoot} (the workspace governing ${result.requestedRoot})`
823
+ return result.truncated
824
+ ? `${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`
825
+ : scope
826
+ }
827
+
383
828
  /** `2 primary package findings` / `1 primary package finding`. */
384
829
  export const identityParityHeadline = (count: number): string =>
385
830
  `${count} primary package finding${count === 1 ? "" : "s"}`
386
831
 
387
- /** Fail before dev/build when the same identity-sensitive package has multiple realpaths. */
832
+ /**
833
+ * Fail before dev/build when the same identity-sensitive package has multiple realpaths AND nothing
834
+ * is collapsing them.
835
+ *
836
+ * A declared package is not a suppression: the build injects the single-copy resolver, so the duplicate
837
+ * genuinely cannot reach the output. Failing on it anyway would leave the only supported answer being
838
+ * to change the install topology, which is the thing an app using linked sibling repositories cannot
839
+ * do. `result.deduplicated` still carries every covered duplicate for the caller to print.
840
+ */
388
841
  export async function assertIdentityParity(
389
842
  cwd: string,
390
843
  rootPackage?: Record<string, unknown>,
391
- options: IdentityParityOptions = {},
392
844
  ): Promise<IdentityParityResult> {
393
- const result = await collectIdentityParity(cwd, rootPackage, options)
394
- if (result.findings.length === 0) return result
845
+ const result = await collectIdentityParity(cwd, rootPackage)
846
+ if (result.findings.length === 0) {
847
+ // A bounded scan that found nothing has not shown there is nothing: the duplicate can be sitting
848
+ // in the part it never reached. `truncated` is exactly that state, so the hard gate refuses to
849
+ // read it as a pass - a guard that answers "clean" from an incomplete scan is how a guard stops
850
+ // being worth having. Reporting surfaces (`nifra doctor`, the dev warning) still print the
851
+ // partial result through `identityParityBasis`; only the gate that must not be wrong stops here.
852
+ if (result.truncated) {
853
+ throw new Error(
854
+ `[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.`,
855
+ )
856
+ }
857
+ return result
858
+ }
395
859
  throw new Error(
396
- `[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}):\n${formatIdentityParityFindings(result.findings)}`,
860
+ `[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}; ${identityParityBasis(result)}):\n${formatIdentityParityFindings(result.findings)}`,
397
861
  )
398
862
  }
399
863
 
@@ -449,9 +913,13 @@ export function normalizeBuildManifest(manifest: BuildManifestLike): ParityManif
449
913
  .sort(([a], [b]) => a.localeCompare(b))
450
914
  .map(([route, chunks]) => [route, chunks.length]),
451
915
  ),
452
- emittedAssets: logicalStaticAssets(manifest).filter(
453
- (asset) => !asset.startsWith("css:") && !asset.startsWith("public:"),
454
- ),
916
+ // Allowlist, not denylist: the dev/prod module-graph contract is the JavaScript module graph
917
+ // only. `js:entry` and `js:route:<id>:<n>` are the roles dev can reconstruct from source. Which
918
+ // *non-JS* files a bundler emits (svg, woff2, an extracted stylesheet) is an output detail, not
919
+ // a claim dev can make - dev serves those from source. The stylesheet claim that matters keeps
920
+ // its own `css` section; `public/` keeps `publicFiles`. A denylist admitted every future
921
+ // `asset:*` prefix by default, which is how an emitted `asset:svg` hard-failed every build.
922
+ emittedAssets: logicalStaticAssets(manifest).filter((asset) => asset.startsWith("js:")),
455
923
  },
456
924
  publicFiles: [...(manifest.publicFiles ?? [])].sort(),
457
925
  css: (manifest.css ?? []).length > 0 ? ["css:present"] : [],
@@ -483,7 +951,11 @@ const sourceFilesUnder = (root: string): readonly string[] => {
483
951
  const files: string[] = []
484
952
  const walk = (current: string): void => {
485
953
  for (const entry of readdirSync(current, { withFileTypes: true })) {
486
- if (["node_modules", "dist", "build", ".git", ".nifra", "coverage"].includes(entry.name))
954
+ if (
955
+ ["node_modules", "dist", "dist-node", "build", ".git", ".nifra", "coverage"].includes(
956
+ entry.name,
957
+ )
958
+ )
487
959
  continue
488
960
  const path = join(current, entry.name)
489
961
  if (entry.isDirectory()) walk(path)
@@ -522,7 +994,7 @@ export function collectDevelopmentParityInput(
522
994
  const sourceRoot = dirname(resolve(routesDir))
523
995
  const css = sourceFilesUnder(sourceRoot).some((file) => {
524
996
  const content = readFileSync(file, "utf8")
525
- return CSS_IMPORT.test(content) || (isSingleFileComponent(file) && SFC_STYLE.test(content))
997
+ return hasStylesheetImport(content) || (isSingleFileComponent(file) && SFC_STYLE.test(content))
526
998
  })
527
999
  ? ["css:present"]
528
1000
  : []
@@ -530,6 +1002,7 @@ export function collectDevelopmentParityInput(
530
1002
  routes,
531
1003
  publicFiles: publicFilesUnder(publicDir),
532
1004
  css,
1005
+ sourceRoot,
533
1006
  }
534
1007
  }
535
1008
 
@@ -552,11 +1025,73 @@ export function compareManifestParity(
552
1025
  development: development.publicFiles,
553
1026
  production: production.publicFiles,
554
1027
  })
555
- if (!equal(development.css, production.css))
1028
+ // css is the one section with an inference-based side. The dev scanner (CSS_IMPORT) is
1029
+ // sound-but-incomplete by construction, so `dev empty, prod css` is a scanner miss, not an app
1030
+ // defect - it passes. Fail only the load-bearing direction: dev found styles the build does not
1031
+ // ship, so the production page would render unstyled. module-graph and public-files stay on equality
1032
+ // because both sides are ground truth there.
1033
+ if (development.css.length > 0 && production.css.length === 0)
556
1034
  differences.push({ section: "css", development: development.css, production: production.css })
557
1035
  return differences
558
1036
  }
559
1037
 
1038
+ /** The forms the source scanner cannot prove, quoted in a css parity failure so the reader knows where to look. */
1039
+ const CSS_SCANNER_BLIND_SPOTS =
1040
+ "a bare package subpath (exports), an @import inside a stylesheet, template interpolation, or plugin-injected css"
1041
+
1042
+ const symmetricDifference = (
1043
+ a: readonly string[],
1044
+ b: readonly string[],
1045
+ ): { readonly onlyDevelopment: readonly string[]; readonly onlyProduction: readonly string[] } => {
1046
+ const left = new Set(a)
1047
+ const right = new Set(b)
1048
+ return {
1049
+ onlyDevelopment: a.filter((value) => !right.has(value)),
1050
+ onlyProduction: b.filter((value) => !left.has(value)),
1051
+ }
1052
+ }
1053
+
1054
+ /** Turn one parity difference into a message that names the offending files, not two opaque blobs. */
1055
+ function explainParityDifference(
1056
+ difference: ManifestParityDifference,
1057
+ development: DevelopmentParityInput,
1058
+ production: BuildManifestLike,
1059
+ ): string {
1060
+ if (difference.section === "css") {
1061
+ const shipped = (production.css ?? []).join(", ")
1062
+ const where = development.sourceRoot === undefined ? "" : ` under ${development.sourceRoot}`
1063
+ return (
1064
+ `css: production ships [${shipped || "(none)"}] but the development scanner found a static ` +
1065
+ `stylesheet import${where} - it is blind to ${CSS_SCANNER_BLIND_SPOTS}`
1066
+ )
1067
+ }
1068
+ if (difference.section === "module-graph") {
1069
+ const dev = difference.development as ParityManifest["moduleGraph"]
1070
+ const prod = difference.production as ParityManifest["moduleGraph"]
1071
+ const routes = symmetricDifference(dev.routes, prod.routes)
1072
+ const assets = symmetricDifference(dev.emittedAssets, prod.emittedAssets)
1073
+ const parts: string[] = []
1074
+ if (routes.onlyDevelopment.length > 0 || routes.onlyProduction.length > 0)
1075
+ parts.push(
1076
+ `routes only in development=${JSON.stringify(routes.onlyDevelopment)} only in production=${JSON.stringify(routes.onlyProduction)}`,
1077
+ )
1078
+ if (assets.onlyDevelopment.length > 0 || assets.onlyProduction.length > 0)
1079
+ parts.push(
1080
+ `chunks only in development=${JSON.stringify(assets.onlyDevelopment)} only in production=${JSON.stringify(assets.onlyProduction)}`,
1081
+ )
1082
+ if (parts.length === 0)
1083
+ parts.push(
1084
+ `route chunk counts differ: development=${JSON.stringify(dev.routeChunks)} production=${JSON.stringify(prod.routeChunks)}`,
1085
+ )
1086
+ return `module-graph: ${parts.join("; ")}`
1087
+ }
1088
+ const files = symmetricDifference(
1089
+ difference.development as readonly string[],
1090
+ difference.production as readonly string[],
1091
+ )
1092
+ return `${difference.section}: only in development=${JSON.stringify(files.onlyDevelopment)} only in production=${JSON.stringify(files.onlyProduction)}`
1093
+ }
1094
+
560
1095
  /** Validate a production manifest against the source manifest a development server should serve. */
561
1096
  export function assertDevelopmentProductionParity(
562
1097
  development: DevelopmentParityInput,
@@ -568,10 +1103,7 @@ export function assertDevelopmentProductionParity(
568
1103
  )
569
1104
  if (differences.length > 0) {
570
1105
  const detail = differences
571
- .map(
572
- (difference) =>
573
- `${difference.section}: development=${JSON.stringify(difference.development)} production=${JSON.stringify(difference.production)}`,
574
- )
1106
+ .map((difference) => explainParityDifference(difference, development, production))
575
1107
  .join("; ")
576
1108
  throw new Error(`[nifra] development and production manifest parity failed: ${detail}`)
577
1109
  }